mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 19:16:37 +00:00
Compare commits
16
Commits
6ad3ff5aba
...
v3.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3f184d81 | ||
|
|
869e36fb2f | ||
|
|
d01da3bd9f | ||
|
|
814c21de1c | ||
|
|
914bf2002f | ||
|
|
47afaaac2b | ||
|
|
6d1cbfb70b | ||
|
|
8e6d7c280f | ||
|
|
dac71afedc | ||
|
|
a6b3384032 | ||
|
|
11bf39cd66 | ||
|
|
bc60b41445 | ||
|
|
f9b1f87e8d | ||
|
|
7e580dc005 | ||
|
|
d51f7ada14 | ||
|
|
d1e821c625 |
@@ -5,6 +5,9 @@ __pycache__/
|
||||
|
||||
# Secrets
|
||||
config/config_secrets.json
|
||||
# Atomic writes leave these behind when a save or a test is interrupted;
|
||||
# the suite drops several per run.
|
||||
config/.config_secrets.json.tmp.*
|
||||
config/config.json
|
||||
config/config.json.backup
|
||||
config/wifi_config.json
|
||||
|
||||
+162
@@ -19,6 +19,166 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 3.4.0
|
||||
|
||||
Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down:
|
||||
|
||||
- `BasePlugin.get_update_interval()` (#555) — return seconds to override the
|
||||
manifest's `update_interval` at runtime (e.g. poll fast only while a game is
|
||||
live), or `None` to keep it. Clamped to at least 5 seconds; a raising or
|
||||
non-numeric return is ignored. Called every scheduling tick, so keep it
|
||||
cheap. Older cores never call it. See `docs/PLUGIN_API_REFERENCE.md`.
|
||||
- `src.common.scroll_config` (#523) — turns a plugin's scroll config into a
|
||||
configured `ScrollHelper` in one place, replacing per-plugin resolution that
|
||||
disagreed between tickers, and warns when a speed won't advance whole pixels
|
||||
per panel refresh. Floor on 3.4.0 to import it.
|
||||
- **Skins are marked unsupported.** No current scoreboard plugin builds on
|
||||
`src.base_classes`, so the skin hook (`SportsCore._render_game`) never runs.
|
||||
The web UI no longer shows the Visual Skin dropdown, the store hides and
|
||||
refuses `"type": "skin"` entries, and `GET /api/v3/skins` reports
|
||||
`"supported": false`. Saved `skin` config values still load and save.
|
||||
`src/skin_system/` is unchanged.
|
||||
- **Web preview size** now comes from `src/display_geometry.py`, the same
|
||||
computation `DisplayManager` uses: double-sided setups preview one screen,
|
||||
and a missing `chain_length` defaults to 2 everywhere (the Starlark magnify
|
||||
default and the sync handshake used 1). The module is core-internal: plugins
|
||||
keep reading `display_manager.width`/`height`.
|
||||
- `src.common.font_layout` (#539, #565) — `load_truetype()` is
|
||||
`ImageFont.truetype` with the layout engine pinned, so text lays out the same
|
||||
whether or not the host's Pillow was built with libraqm; `crisp_size()` and
|
||||
`FONT_PIXEL_GRID` give the size a bundled face renders on whole pixels at
|
||||
(`sports_card` still re-exports them); `resolve_asset_path()` resolves
|
||||
`assets/fonts/...` against the install root, not the working directory.
|
||||
Floor on 3.4.0 to import it. Relatedly, `DisplayManager` now draws text
|
||||
1-bit (#521), so golden images recorded against 3.3.x may need regenerating.
|
||||
|
||||
### Install and updates
|
||||
|
||||
**Weekly automatic updates (#581), off by default.** Switching on
|
||||
*Automatically check for and install updates once a week* on the General tab
|
||||
(or `first_time_install.sh --enable-auto-update` / `LEDMATRIX_AUTO_UPDATE=1`)
|
||||
updates the core and then every installed plugin once a week, preferably 2–5 AM
|
||||
local time. It follows the branch the checkout tracks — `main` on a standard
|
||||
install — so a device gets whatever has merged there, not only tagged releases.
|
||||
See `docs/WEB_INTERFACE_GUIDE.md`.
|
||||
|
||||
- The core step is skipped, with the reason shown, when the checkout has local
|
||||
edits or commits, a rebase or merge is in progress, the branch has no
|
||||
upstream, less than 300 MB is free, or that commit was already rolled back.
|
||||
- After pulling, `ledmatrix-update-verify.service` restarts the services and
|
||||
requires the web interface to answer and the display to stay up. If they
|
||||
don't, or the new requirements fail to install, it resets to the previous
|
||||
commit, reinstalls its requirements and restarts again. Anything but success
|
||||
shows under the toggle and as a banner on Overview.
|
||||
- Plugins update through the Plugin Store even when the core step is skipped,
|
||||
fails or is rolled back. A plugin version whose `ledmatrix_min_version` is
|
||||
above the device's core is held back, not installed. When the core did
|
||||
update, plugins wait for its health check, and are left alone if that check
|
||||
never reports or the rollback fails.
|
||||
- No SSH is needed: switching the toggle on restarts the display service, which
|
||||
installs the health-check units (`src/auto_update_setup.py`, core-internal
|
||||
and not a plugin API).
|
||||
|
||||
Installer and service fixes:
|
||||
|
||||
- rgbmatrix builds on ARMv6 boards (Pi Zero, Pi 1); an existing checkout is
|
||||
moved forward to the new pin and no longer left root-owned (#577).
|
||||
- `first_time_install.sh` grants the web user `safe_pip_install.sh`, as
|
||||
`configure_web_sudo.sh` already did, so plugin requirements install where
|
||||
the display service can see them (#579).
|
||||
- The web interface starts when `web_display_autostart` is missing or
|
||||
`config.json` is unreadable; only an explicit `false` keeps it down (#556).
|
||||
- Installers render every systemd unit from its `systemd/` template, so the
|
||||
boot-time unit-drift warning can clear, non-root installs included (#547).
|
||||
|
||||
### Scrolling
|
||||
|
||||
- **Frame pacing (#523).** The loop waits only for the rest of each panel
|
||||
refresh instead of a flat 8 ms: 44–46 fps → 100 fps, and slow frames 14% →
|
||||
0.02%, on a 2×128×64 chain. Sub-pixel blending is off by default again (it
|
||||
shimmered on pixel fonts; Vegas mode still opts in).
|
||||
- **Whole-pixel steps (#545).** At a speed `scroll_config` can render in whole
|
||||
pixels, every frame advances by exactly the same amount, removing about six
|
||||
hitches a second. A loop that can't keep up now scrolls slightly slow rather
|
||||
than jumping.
|
||||
- The eight sports scoreboards scroll through `scroll_config` too (#542): the
|
||||
default 50 px/s holds each frame for two refreshes instead of alternating
|
||||
0 px and 1 px steps.
|
||||
- **Frame stats ignore the pause between scrolls (#582).** The `Scroll frame
|
||||
stats` log line counted the idle wait before each scroll as one frame,
|
||||
inflating `max` and the stall rate. `docs/SCROLL_PERFORMANCE.md` now
|
||||
describes the line actually logged.
|
||||
|
||||
### Plugins
|
||||
|
||||
- `FontManager` registers the bundled `tom_thumb` font, so plugins no longer
|
||||
need a private loader (#534).
|
||||
- The test harness's `set_scrolling_state()` accepts `frame_hold`, as
|
||||
`DisplayManager`'s does (#534).
|
||||
- A `display()` with nothing to draw should return `False`, the only value the
|
||||
controller skips on; starlark-apps now does, rather than holding a black
|
||||
panel (#534).
|
||||
- Starlark apps may set `render_width`/`render_height` in their `config.json`
|
||||
to render at their own canvas size instead of Pixlet's 64×32 (#552).
|
||||
- `scripts/render_plugin.py --display-mode <mode>` renders one mode of a
|
||||
multi-mode plugin; scoreboards previously rendered blank (#522).
|
||||
- Scoreboards resolve their own directory under the real plugin loader
|
||||
(declare `_PLUGIN_DIR`), so 4x6 text snaps to its 7px grid instead of
|
||||
rendering a pixel narrow, and an unreadable schema is logged (#519, #520).
|
||||
`DisplayManager` loads 4x6 on that grid too (#565).
|
||||
- The 5x7 BDF face reports a real height, so rows stacked by
|
||||
`get_font_height()` no longer overlap (#539).
|
||||
- `LogoHelper` remembers a missing logo instead of warning every rotation
|
||||
(#548), and the decoded sports logo cache is bounded (#559).
|
||||
|
||||
### Web interface
|
||||
|
||||
- Installed Plugins has search, All / Enabled / Disabled / Updates filters and
|
||||
sort (#540).
|
||||
- Hardened and polished per the September 2026 audit (#568): utility classes
|
||||
such as `.hidden` actually exist, focus rings, labels and modal focus
|
||||
trapping, dark theme throughout, no overflow at phone width, and background
|
||||
streams pause when hidden, with first-load JS/CSS down from 1358 KB to 291 KB.
|
||||
- WiFi Connect works from the LEDMatrix-Setup hotspot: the page is answered
|
||||
before the hotspot drops, and reopening it shows why an attempt failed (#571).
|
||||
- Pixlet install, the Starlark app store and app toggles work again (#535,
|
||||
#537); the store uses the configured GitHub token and reports a rate limit
|
||||
instead of drawing a blank grid (#541).
|
||||
- Plugin config: geochron and news saves no longer always fail (#575), the page
|
||||
survives stored values the schema outgrew (#578), the form uses the full page
|
||||
height (#573), and file-manager widgets show the script's error (#574).
|
||||
- The live status stream reports real disk usage and available memory (#558);
|
||||
a system action refused for want of passwordless sudo says so and names
|
||||
`configure_web_sudo.sh` (#560).
|
||||
|
||||
### Tools and security
|
||||
|
||||
- **CodeQL triage (#561):** 129 of 134 alerts fixed. Three were exploitable
|
||||
path-handling flaws in the web interface and are closed; web UI escapers now
|
||||
escape quotes, and URL fields refuse script schemes. Path checks share
|
||||
`src/common/path_safety.py` (core-internal).
|
||||
- **Home Assistant MQTT bridge** (`integrations/mqtt_bridge`, #538): mode
|
||||
select, stop, power and brightness over MQTT Discovery.
|
||||
- **Tools tab** manages the MQTT bridge and the Pixlet editor (#554); the
|
||||
editor stays on loopback when `PIXLET_EDITOR_HOST` says so.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Updating a plugin whose directory is named for its manifest id (leaderboard,
|
||||
music, stocks, weather) silently did nothing (#536).
|
||||
- Plugin reconciliation no longer reports working plugins as stale or replaces
|
||||
their config with a stub, and the Overview banner advises each case correctly
|
||||
(#557).
|
||||
- Two config saves in the same second no longer share one backup, so rollback
|
||||
restores the version asked for (#564).
|
||||
- On-demand: a second request is honoured without a restart (#534), a pinned
|
||||
request stays on its mode, and restarting mid-session loads every plugin
|
||||
again (#538).
|
||||
- `/health` and `/display/current` report real state, and the preview no longer
|
||||
freezes on a leftover snapshot temp file (#534).
|
||||
|
||||
### Per-element display customization
|
||||
|
||||
**Per-element display customization, and the last mile of it into the web UI.**
|
||||
A user can set the font, size, colour, position, visibility and alignment of
|
||||
individual display elements per plugin -- and, where a plugin has display
|
||||
@@ -109,6 +269,8 @@ Removed:
|
||||
|
||||
## 3.3.0
|
||||
|
||||
Historical note: tags `v3.3.0` and `v3.3.1` both report `__version__` "3.3.0" and both ship `src/common/sports_shared.py`, so a "3.3.0" floor always means a core with `sports_shared`.
|
||||
|
||||
**The release the sports scoreboards floor on to delete their bundled copies.**
|
||||
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
|
||||
enforceable; this ships the last three shared modules and completes the store
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
||||
- `plugin-repos/` — **Default** plugin install directory used by the
|
||||
Plugin Store, set by `plugin_system.plugins_directory` in
|
||||
`config.json` (default per `config/config.template.json:167`).
|
||||
`config.json` (default per `config/config.template.json`).
|
||||
Not gitignored.
|
||||
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
|
||||
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
|
||||
@@ -23,7 +23,7 @@
|
||||
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
|
||||
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
||||
- Config schemas use JSON Schema Draft-7
|
||||
- Display dimensions: always read dynamically from `self.display_manager.matrix.width/height`
|
||||
- Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
|
||||
- Secrets: namespaced by plugin id in `config/config_secrets.json`, declared
|
||||
via `"x-secret": true` in the plugin's config schema, and deep-merged into
|
||||
the plugin's config dict at load time — plugins read them with plain
|
||||
@@ -45,9 +45,12 @@
|
||||
- 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)
|
||||
## Skin System (visual overlays for sports scoreboards) — NOT SUPPORTED YET
|
||||
- Skins do not render with the current scoreboard plugins: the only hook is `SportsCore._render_game()` in `src/base_classes/sports/core.py`, and no current scoreboard plugin (monorepo or third-party registry) builds on `src.base_classes`
|
||||
- So core doesn't offer them: no Visual Skin dropdown (`get_plugin_schema` skips `inject_skin_selector`), the store hides/refuses `"type": "skin"` entries, `GET /api/v3/skins` reports `"supported": false`. Switch: `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py`
|
||||
- Stored `skin` / `skin_options` config values must keep loading and saving (base schema allows them; form saves deep-merge over the stored section)
|
||||
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
|
||||
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports/core.py`
|
||||
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); keep it and its tests
|
||||
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
|
||||
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
|
||||
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
|
||||
|
||||
@@ -463,13 +463,12 @@ For plugin development, check out the [Hello World Plugin](https://github.com/Ch
|
||||
|
||||
### 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).
|
||||
**Not supported yet.** Skins are meant to restyle a sports scoreboard's
|
||||
live/recent/upcoming screens without forking the plugin, but the current
|
||||
scoreboard plugins don't render them: a selected skin has no effect. The web
|
||||
UI doesn't offer skin install or selection for that reason. The skin system
|
||||
and its docs stay in place for when scoreboards adopt it; see
|
||||
[docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) for why.
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"web_display_autostart": true,
|
||||
"auto_update": {
|
||||
"enabled": false
|
||||
},
|
||||
"schedule": {
|
||||
"enabled": false,
|
||||
"mode": "per-day",
|
||||
|
||||
@@ -223,8 +223,8 @@ The harness already renders every plugin at a spread of sizes (now
|
||||
including 96x48):
|
||||
|
||||
```bash
|
||||
python scripts/check_plugin.py <plugin-dir> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||
python scripts/render_plugin.py <plugin-dir> --width 96 --height 48
|
||||
python scripts/check_plugin.py --plugin <plugin-id> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||
python scripts/render_plugin.py --plugin <plugin-id> --width 96 --height 48
|
||||
```
|
||||
|
||||
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
||||
|
||||
+17
-5
@@ -1,5 +1,14 @@
|
||||
# Creating Skins
|
||||
|
||||
> **Not supported yet: skins don't render with the current scoreboard
|
||||
> plugins.** The only render hook is `SportsCore._render_game()` in
|
||||
> `src/base_classes/sports/core.py`, and no current scoreboard (monorepo or
|
||||
> third-party) builds on `src.base_classes`, so a skin you build here passes
|
||||
> `validate_skin.py` but never appears on the matrix. The web UI and Plugin
|
||||
> Store don't offer skins for that reason. Details:
|
||||
> [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet). The guide below
|
||||
> stays accurate for the skin API itself.
|
||||
|
||||
A skin restyles a sports scoreboard (live / recent / upcoming) without
|
||||
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
|
||||
doing vegas mode; your skin only draws. Architecture background:
|
||||
@@ -19,7 +28,9 @@ 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`:
|
||||
To select it, add to your plugin's section in `config/config.json` (this is
|
||||
stored and validated, but has no visible effect until a scoreboard uses the
|
||||
skin hook — see the note at the top):
|
||||
|
||||
```json
|
||||
"baseball-scoreboard": {
|
||||
@@ -28,8 +39,8 @@ To see it on your matrix, add to your plugin's section in `config/config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
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:
|
||||
The web UI's **Visual Skin** dropdown is hidden while skins are unsupported.
|
||||
`"skin"` also accepts a per-mode mapping:
|
||||
`{"live": "my-skin", "recent": "built-in"}`.
|
||||
|
||||
## The manifest (`skin.json`)
|
||||
@@ -234,8 +245,9 @@ Tips that keep Claude (and you) out of trouble:
|
||||
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).
|
||||
`git clone <repo> skins/<id>`). Registry entries with `"type": "skin"` are
|
||||
hidden and refused by the Plugin Store while skins are unsupported (see
|
||||
[SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
||||
|
||||
**Trust note:** a skin is Python running inside the display service — the
|
||||
same trust level as a plugin. Review code before installing skins from
|
||||
|
||||
@@ -36,7 +36,11 @@ self.enabled # Boolean enabled status
|
||||
|
||||
#### `update() -> None`
|
||||
|
||||
Fetch/update data for this plugin. Called based on `update_interval` specified in the plugin's manifest.
|
||||
Fetch/update data for this plugin. Called on the plugin's update interval:
|
||||
the value `get_update_interval()` returns when it returns a number, otherwise
|
||||
the static interval: the `update_interval` in the plugin's manifest, else
|
||||
`update_interval` in the plugin's section of `config.json`, else 60 seconds
|
||||
(see [`get_update_interval()`](#get_update_interval---optionalfloat) below).
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
@@ -109,6 +113,46 @@ Called when plugin is enabled.
|
||||
|
||||
Called when plugin is disabled.
|
||||
|
||||
#### `get_update_interval() -> Optional[float]`
|
||||
|
||||
How often this plugin wants `update()` called right now, in seconds. The
|
||||
manifest's `update_interval` is one static number; override this when the
|
||||
right cadence depends on state only the plugin knows, e.g. poll every 15s
|
||||
while a game is live and fall back to the manifest value otherwise.
|
||||
|
||||
**Returns**: seconds as a number, or `None` (the default) for no opinion.
|
||||
|
||||
How `PluginManager` (`_get_plugin_update_interval` in
|
||||
`src/plugin_system/plugin_manager.py`) resolves the interval on each
|
||||
scheduling tick:
|
||||
|
||||
1. It calls `get_update_interval()`. A number wins over everything below.
|
||||
Values under `PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL` (5 seconds) are
|
||||
raised to it.
|
||||
2. If the hook returns `None`, raises, or returns something that isn't a
|
||||
finite number (a `bool`, a string, NaN, infinity), it is ignored and the
|
||||
static interval applies: the manifest's `update_interval`, else
|
||||
`update_interval` in the plugin's section of `config.json`, else 60
|
||||
seconds.
|
||||
|
||||
The static value is cached per plugin until the plugin is loaded or
|
||||
unloaded again, so editing `update_interval` in config takes effect on the
|
||||
next reload. The hook's return value is never cached: it is called on every
|
||||
tick of the display loop, so keep it to attribute reads (no config lookups,
|
||||
no I/O, no locks a fetch might hold) and don't let it raise.
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
def get_update_interval(self):
|
||||
# Fast while something is live, manifest default otherwise.
|
||||
if any(m.live_games for m in self._live_managers):
|
||||
return self.config.get("live_update_interval", 15)
|
||||
return None
|
||||
```
|
||||
|
||||
Added in core 3.4.0; older cores never call it, so a plugin that relies on
|
||||
it should floor `ledmatrix_min_version` at `3.4.0`.
|
||||
|
||||
#### `get_display_duration() -> float`
|
||||
|
||||
Get display duration for this plugin. Can be overridden for dynamic durations.
|
||||
|
||||
@@ -189,7 +189,9 @@ class BasePlugin(ABC):
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Fetch/update data for this plugin.
|
||||
Called based on update_interval in manifest.
|
||||
Called every get_update_interval() seconds when that returns a
|
||||
number, otherwise at the static interval: the manifest's
|
||||
update_interval, else the plugin config's update_interval, else 60s.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -204,6 +206,21 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_update_interval(self) -> Optional[float]:
|
||||
"""
|
||||
Seconds until update() should run again, decided at runtime.
|
||||
Return None (the default) to use the static interval.
|
||||
|
||||
PluginManager._get_plugin_update_interval calls this on every
|
||||
scheduling tick. A number overrides the manifest and is clamped up
|
||||
to PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL (5s); None, a raise,
|
||||
or a non-finite/non-numeric value falls back to the manifest's
|
||||
update_interval, then the plugin config's update_interval, then
|
||||
60s. The static value is cached until the plugin reloads; the hook
|
||||
is not cached, so it must be cheap and must not raise.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
"""
|
||||
Get the display duration for this plugin instance.
|
||||
|
||||
@@ -10,11 +10,11 @@ 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).
|
||||
> **Want a different look for an existing sports scoreboard?** Skins are
|
||||
> meant for that, but they are **not supported yet**: the current scoreboard
|
||||
> plugins don't render them (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet)).
|
||||
> For now, change the look through the plugin's own display settings or its
|
||||
> code.
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
+2
-2
@@ -56,8 +56,8 @@ Going deeper:
|
||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
||||
cache management, background services, permissions
|
||||
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
||||
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards
|
||||
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin
|
||||
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards (not supported yet: current scoreboards don't render skins)
|
||||
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin (same caveat)
|
||||
|
||||
## Reference
|
||||
|
||||
|
||||
+46
-11
@@ -203,26 +203,61 @@ configured speed, so position stays proportional to real time.
|
||||
|
||||
## Diagnosing a juddery scroller
|
||||
|
||||
**`Avg FPS` will lie to you.** It is a 100-frame moving average, and a 2 ms
|
||||
duplicate frame plus a 21 ms double-wait average to exactly 10 ms. A ticker
|
||||
that is stalling on half its frames still reports a healthy `100.0`.
|
||||
**An average will lie to you.** A 2 ms duplicate frame and a 21 ms double-wait
|
||||
mean exactly 10 ms, so a ticker stalling on half its frames still averages to a
|
||||
healthy 100 fps. The stats line reports the tail for that reason — read the
|
||||
percentiles, not the fps.
|
||||
|
||||
Look at the **distribution** instead:
|
||||
Every scroller emits one line every 5 seconds covering *every* frame in that
|
||||
window, tagged with the plugin it came from:
|
||||
|
||||
```bash
|
||||
journalctl -u ledmatrix --since "-10min" --no-pager \
|
||||
| grep -oE "Frame time: [0-9.]+ms" | awk '{print $3}' | sed 's/ms//' \
|
||||
| awk '{printf "%.0f\n", $1}' | sort -n | uniq -c
|
||||
journalctl -u ledmatrix --since "-10min" --no-pager | grep "Scroll frame stats"
|
||||
```
|
||||
|
||||
```
|
||||
[Plugin: news] Scroll frame stats - 100.0 fps over 501 frames | median 10.00ms
|
||||
p95 10.11ms max 12.03ms min 7.98ms | stalls 0 (0.0%) skips 0 (0.0%)
|
||||
```
|
||||
|
||||
Reading it, on a 100 Hz panel:
|
||||
|
||||
| you see | it means |
|
||||
|---|---|
|
||||
| everything at 10 ms | healthy |
|
||||
| a mode at ~2 ms | **duplicate frames** — the swap was skipped because the image did not change. The scroller is advancing less than one pixel per frame. |
|
||||
| a mode at 20/30/50 ms | frames missing refreshes — per-frame work is overrunning, or a background thread is holding the GIL |
|
||||
| `Avg FPS` above 100 | duplicates present, unless the scroll cycle has completed and is idling |
|
||||
| median 10 ms, p95 within ~0.5 ms of it | healthy — locked to the panel |
|
||||
| p95 or max at 20/30/50 ms | frames missing refreshes — per-frame work is overrunning, or a background thread is holding the GIL |
|
||||
| non-zero **skips**, or a median *below* 10 ms | **duplicate frames** — the swap was skipped because the image did not change, so the frame never waited on vsync. The scroller is advancing less than one pixel per frame. |
|
||||
| non-zero **stalls** | frames past 1.5× the median, which is the measure of judder that survives averaging |
|
||||
|
||||
`stalls` and `skips` are both counted against that window's own median, so they
|
||||
stay meaningful on a panel running at any refresh rate.
|
||||
|
||||
To rank every scroller at once rather than reading lines one at a time:
|
||||
|
||||
```bash
|
||||
journalctl -u ledmatrix --since "-3h" --no-pager | grep "Scroll frame stats" \
|
||||
| sed -E 's/.*- (\S+) - (\[Plugin: [^]]+\] )?Scroll.*median ([0-9.]+)ms p95 ([0-9.]+)ms.*/\1 \3 \4/' \
|
||||
| awk '$2 < 1000 {n[$1]++; m[$1]+=$2; p[$1]+=$3} END {for (k in n)
|
||||
printf "%-28s %5d windows median %6.2fms p95 %6.2fms\n", k, n[k], m[k]/n[k], p[k]/n[k]}' \
|
||||
| sort -k7 -rn
|
||||
```
|
||||
|
||||
The `$2 < 1000` guard drops windows whose median is a whole second or more.
|
||||
Those are not frames. Until the idle-gap fix in `log_frame_rate()`, the first
|
||||
frame of every scroll was timed against the end of the *previous* scroll, so
|
||||
the gap between them was recorded as one enormous sample — it landed in the
|
||||
`max` field of otherwise healthy windows and counted as one stall per scroll,
|
||||
roughly 0.2% at 500 frames to a window, which is the same order as the real
|
||||
stall rates it sat beside. Current builds emit none, but the guard costs
|
||||
nothing and keeps the command honest against older journals.
|
||||
|
||||
A scroller whose p95 sits several times its median is the one to fix, and it is
|
||||
usually the one doing the most per-frame work rather than the one configured
|
||||
worst. Measured over 20 minutes with two scrollers set identically at 100 px/s,
|
||||
the leaderboard held 10 ms flat while the odds ticker spent ~20% of its frames
|
||||
on duplicates. Same settings, different render cost: odds does more per-frame
|
||||
work, and more variably, so it is first to land a frame that advances less than
|
||||
a whole pixel. Check the render path before the config.
|
||||
|
||||
Then confirm what the plugin actually loaded — config edits do not always reach
|
||||
the running code:
|
||||
|
||||
+48
-12
@@ -1,5 +1,35 @@
|
||||
# Skin System Architecture
|
||||
|
||||
## Status: not supported yet
|
||||
|
||||
**Skins don't render with the current scoreboard plugins.** The skin system
|
||||
below works in isolation (it loads, validates and renders skins in
|
||||
`scripts/validate_skin.py` and `test/test_skin_system.py`), but nothing on a
|
||||
running display calls it:
|
||||
|
||||
- The only render hook is `SportsCore._render_game()` in
|
||||
`src/base_classes/sports/core.py`.
|
||||
- None of the current scoreboard plugins build on `src.base_classes`. The
|
||||
official scoreboards in the `ledmatrix-plugins` monorepo, and the
|
||||
third-party scoreboards in the plugin registry, carry their own sports and
|
||||
rendering code (with the shared `src/common/sports_*` helpers) and never
|
||||
reach `SportsCore._render_game()`.
|
||||
|
||||
So a skin can be dropped into `skins/` and named in a plugin's config, but the
|
||||
scoreboard keeps drawing its built-in layout. Until a scoreboard adopts the
|
||||
hook, core does not offer skins to users:
|
||||
|
||||
- The plugin config page shows no **Visual Skin** dropdown.
|
||||
- The Plugin Store hides registry entries with `"type": "skin"` and refuses
|
||||
to install one (`POST /api/v3/plugins/install` answers 400 with the reason).
|
||||
- `GET /api/v3/skins` still lists what is in `skins/`, with
|
||||
`"supported": false` and a `message`.
|
||||
- A config that already contains `"skin"` / `"skin_options"` still loads,
|
||||
validates and saves unchanged; the value is simply unused.
|
||||
|
||||
The rest of this document describes the design as built, for whoever wires a
|
||||
scoreboard to it.
|
||||
|
||||
Skins are user-installable **visual overlays** for the sports scoreboards.
|
||||
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
|
||||
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
|
||||
@@ -32,8 +62,10 @@ crashing) simply restores the built-in look.
|
||||
|
||||
## The render funnel
|
||||
|
||||
Every sports scoreboard (baseball, football, basketball, hockey — anything
|
||||
built on the `src/base_classes/sports/` package, `core.py`) renders through exactly one seam:
|
||||
A sports scoreboard built on the `src/base_classes/sports/` package
|
||||
(`core.py`) renders through exactly one seam. No current scoreboard plugin is
|
||||
built on it (see [Status](#status-not-supported-yet)), so for them this seam is
|
||||
never reached:
|
||||
`SportsCore._render_game(game, force_clear)`.
|
||||
|
||||
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
|
||||
@@ -135,22 +167,26 @@ Inside the plugin's own config section in `config/config.json`:
|
||||
`"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=`).
|
||||
`SchemaManager.inject_skin_selector` can add a **Visual Skin** enum to the
|
||||
*served* schema for plugins with matching skins installed. While skins are
|
||||
unsupported the plugin schema endpoint does not call it, so the dropdown is
|
||||
not shown. Validation never sees the enum either way: the base schema allows
|
||||
any `skin` value, so a config that references an uninstalled skin stays valid.
|
||||
`GET /api/v3/skins` lists installed skins (optionally filtered by
|
||||
`?plugin_id=`) and reports `"supported": false`.
|
||||
|
||||
## Distribution
|
||||
|
||||
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
|
||||
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
|
||||
plugins.
|
||||
- **Store:** 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
|
||||
- **Store (disabled while unsupported):** registry entries with
|
||||
`"type": "skin"` are hidden from the store list and refused on install.
|
||||
`PluginStoreManager._install_skin_from_info` is kept: once
|
||||
`SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` is true, such
|
||||
entries install through the same `plugins.json` pipeline, land in `skins/`,
|
||||
are validated against `skin.json` (including the API major version) instead
|
||||
of `manifest.json`, and never install dependencies — skins are render-only
|
||||
(stdlib + PIL + the provided context, no third-party packages in v1).
|
||||
|
||||
## Trust model
|
||||
|
||||
@@ -96,6 +96,33 @@ Configure basic system settings:
|
||||
- **Plugin System Settings** — including the `plugins_directory` (default
|
||||
`plugin-repos/`) used by the plugin loader
|
||||
- **Autostart** options for the display service
|
||||
- **Automatic updates** — once a week, update LEDMatrix and every installed
|
||||
plugin with a newer version. Off by default. Runs 2–5 AM local time when
|
||||
possible, otherwise within a day of being due. The last result and next
|
||||
check are shown under the toggle, and anything other than success raises a
|
||||
banner on **Overview**.
|
||||
- *Checks first:* the code update is skipped, with the reason shown, if
|
||||
tracked files were edited locally, the checkout has local commits, a
|
||||
rebase/merge is in progress, the branch has no upstream, less than 300 MB
|
||||
is free, or the newest version already failed once. A failed fetch is
|
||||
retried the next day.
|
||||
- *Health check and rollback:* after pulling, `ledmatrix-update-verify.service`
|
||||
restarts the services and checks that the web interface responds and the
|
||||
display (if it was running) stays up. If not — or if the new dependencies
|
||||
failed to install — it resets to the previous commit, reinstalls the
|
||||
previous dependencies and restarts again. A running display is restarted;
|
||||
a stopped one stays stopped.
|
||||
- *Plugins* update through the Plugin Store, which refuses versions that need
|
||||
a newer LEDMatrix and restores the old copy when an install fails. When the
|
||||
code changed, plugins wait until it passes its health check. Plugins are
|
||||
not health-checked after updating.
|
||||
- *Setup needs no SSH.* Turning the toggle on restarts the display service,
|
||||
which installs the health check (`ledmatrix-update-verify.path` and
|
||||
`.service`); the General tab shows when it is ready, or why setup failed.
|
||||
Until then only plugins update. New installs set it up during
|
||||
installation and can switch updates on with
|
||||
`first_time_install.sh --enable-auto-update` (or `LEDMATRIX_AUTO_UPDATE=1`,
|
||||
which `one-shot-install.sh` passes through), or at the installer's prompt.
|
||||
|
||||
Click **Save** to write changes to `config/config.json`. Most changes
|
||||
require a display service restart from **Overview**.
|
||||
|
||||
+142
-6
@@ -125,6 +125,72 @@ fi
|
||||
# Get the home directory of the actual user
|
||||
USER_HOME=$(eval echo ~$ACTUAL_USER)
|
||||
|
||||
# --- rpi-rgb-led-matrix checkout helpers -------------------------------------
|
||||
# Run git as whoever owns the project directory. Run as root against a
|
||||
# user-owned repo, git refuses it ("dubious ownership"), and anything it does
|
||||
# create — such as .git/modules/<submodule> — ends up root-owned, locking the
|
||||
# user out of their own checkout. A root-owned install keeps running as root.
|
||||
_rgb_repo_owner() {
|
||||
stat -c %U "$PROJECT_ROOT_DIR" 2>/dev/null || echo root
|
||||
}
|
||||
|
||||
_git_as_repo_owner() {
|
||||
local owner
|
||||
owner=$(_rgb_repo_owner)
|
||||
if [ "$(id -u)" = "0" ] && [ "$owner" != "root" ] && command -v sudo >/dev/null 2>&1; then
|
||||
sudo -u "$owner" -H git "$@"
|
||||
else
|
||||
git "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Earlier installer versions ran the submodule git commands as root, leaving
|
||||
# root-owned files the repo owner (and so _git_as_repo_owner) cannot write.
|
||||
_reclaim_rgb_checkout() {
|
||||
local owner path
|
||||
owner=$(_rgb_repo_owner)
|
||||
if [ "$(id -u)" != "0" ] || [ "$owner" = "root" ]; then
|
||||
return 0
|
||||
fi
|
||||
for path in "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" "$PROJECT_ROOT_DIR/.git/modules/rpi-rgb-led-matrix-master"; do
|
||||
if [ -e "$path" ]; then
|
||||
chown -R "$owner:" "$path" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# `git pull` on the main repo never moves an existing submodule checkout, so a
|
||||
# submodule bump (e.g. the ARMv6 build fix for Pi Zero/1) would never reach a
|
||||
# device installed before it. Move the checkout forward to the pinned commit —
|
||||
# but never backward or sideways: a user who ran `git submodule update --remote`
|
||||
# is newer than the pin and is left alone. Never fatal.
|
||||
_sync_rgb_submodule() {
|
||||
local sub="$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" pinned current
|
||||
if [ ! -f "$PROJECT_ROOT_DIR/.gitmodules" ] || ! grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules" \
|
||||
|| [ ! -e "$sub/.git" ]; then
|
||||
return 0
|
||||
fi
|
||||
if ! pinned=$(_git_as_repo_owner -C "$PROJECT_ROOT_DIR" rev-parse "HEAD:rpi-rgb-led-matrix-master" 2>/dev/null) \
|
||||
|| [ -z "$pinned" ]; then
|
||||
return 0
|
||||
fi
|
||||
current=$(_git_as_repo_owner -C "$sub" rev-parse HEAD 2>/dev/null) || current=""
|
||||
if [ "$current" = "$pinned" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$current" ] && _git_as_repo_owner -C "$sub" cat-file -e "${pinned}^{commit}" 2>/dev/null \
|
||||
&& ! _git_as_repo_owner -C "$sub" merge-base --is-ancestor "$current" "$pinned" 2>/dev/null; then
|
||||
echo "rpi-rgb-led-matrix-master is at ${current:0:7}, not behind the pinned ${pinned:0:7}; leaving it as is"
|
||||
return 0
|
||||
fi
|
||||
echo "Updating rpi-rgb-led-matrix-master to the pinned commit ${pinned:0:7}..."
|
||||
if ! _git_as_repo_owner -C "$PROJECT_ROOT_DIR" submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
echo "⚠ Could not update rpi-rgb-led-matrix-master to the pinned commit; building the existing checkout"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
# --- end rpi-rgb-led-matrix checkout helpers ---------------------------------
|
||||
|
||||
# Determine the Project Root Directory (where this script is located)
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
@@ -154,6 +220,8 @@ SKIP_PERF=${LEDMATRIX_SKIP_PERF:-0}
|
||||
SKIP_REBOOT_PROMPT=${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}
|
||||
SKIP_SWAP=${LEDMATRIX_SKIP_SWAP:-0}
|
||||
BUILD_JOBS_OVERRIDE=${LEDMATRIX_BUILD_JOBS:-}
|
||||
# Weekly automatic updates: 1 on, 0 off, empty = ask (interactive) or leave as is.
|
||||
AUTO_UPDATE=${LEDMATRIX_AUTO_UPDATE:-}
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
@@ -168,12 +236,15 @@ Options:
|
||||
--skip-swap Never add temporary swap for the C++ build
|
||||
--build-jobs N Compile the C++ library with N parallel jobs
|
||||
(default: scaled to available RAM)
|
||||
--enable-auto-update Turn on weekly automatic updates (with health
|
||||
check and automatic rollback)
|
||||
--no-auto-update Leave weekly automatic updates off
|
||||
-h, --help Show this help message and exit
|
||||
|
||||
Environment variables (same effect as flags):
|
||||
LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1,
|
||||
LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=1,
|
||||
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N
|
||||
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N, LEDMATRIX_AUTO_UPDATE=1|0
|
||||
|
||||
Low-memory devices:
|
||||
On a Pi with under 2GB of RAM the C++ build is limited to fewer parallel
|
||||
@@ -191,6 +262,8 @@ while [ $# -gt 0 ]; do
|
||||
--skip-perf) SKIP_PERF=1 ;;
|
||||
--no-reboot-prompt) SKIP_REBOOT_PROMPT=1 ;;
|
||||
--skip-swap) SKIP_SWAP=1 ;;
|
||||
--enable-auto-update) AUTO_UPDATE=1 ;;
|
||||
--no-auto-update) AUTO_UPDATE=0 ;;
|
||||
--build-jobs)
|
||||
shift
|
||||
if [ $# -eq 0 ]; then echo "--build-jobs requires a number"; usage; exit 1; fi
|
||||
@@ -797,6 +870,50 @@ else
|
||||
echo "✓ Main config file already exists"
|
||||
fi
|
||||
|
||||
# Weekly automatic updates (General tab -> Automatic Updates). Off unless asked
|
||||
# for: --enable-auto-update / LEDMATRIX_AUTO_UPDATE=1, or "y" at the prompt when
|
||||
# installing interactively. Only an explicit choice changes the setting, so
|
||||
# re-running the installer with -y never switches it silently.
|
||||
if [ -z "$AUTO_UPDATE" ] && [ "$ASSUME_YES" != "1" ] && [ -t 0 ]; then
|
||||
read -p "Automatically check for and install LEDMatrix updates once a week, with automatic rollback if an update breaks something? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then AUTO_UPDATE=1; else AUTO_UPDATE=0; fi
|
||||
fi
|
||||
if [ "$AUTO_UPDATE" = "1" ] || [ "$AUTO_UPDATE" = "0" ]; then
|
||||
if python3 - "$PROJECT_ROOT_DIR/config/config.json" "$AUTO_UPDATE" <<'PY'
|
||||
import json, os, sys, tempfile
|
||||
path, enabled = sys.argv[1], sys.argv[2] == "1"
|
||||
with open(path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
if not isinstance(config.get("auto_update"), dict):
|
||||
config["auto_update"] = {}
|
||||
config["auto_update"]["enabled"] = enabled
|
||||
# Written beside the original and swapped in whole: the display service's
|
||||
# config watcher may be running and must never read a half-written file.
|
||||
original = os.stat(path)
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(path)), prefix=".config.")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=4)
|
||||
f.write("\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.chmod(tmp, original.st_mode & 0o777)
|
||||
if hasattr(os, "chown"):
|
||||
os.chown(tmp, original.st_uid, original.st_gid)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
PY
|
||||
then
|
||||
if [ "$AUTO_UPDATE" = "1" ]; then echo "✓ Weekly automatic updates enabled"; else echo "✓ Weekly automatic updates off"; fi
|
||||
else
|
||||
echo "⚠ Could not set auto_update in config/config.json; turn it on from the General tab instead"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create config_secrets.json from template if missing
|
||||
if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
||||
if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.template.json" ]; then
|
||||
@@ -1038,8 +1155,9 @@ else
|
||||
# so git clone doesn't fail with "destination path already exists".
|
||||
_clone_rpi_rgb() {
|
||||
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
_git_as_repo_owner clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
}
|
||||
_reclaim_rgb_checkout
|
||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
@@ -1047,7 +1165,7 @@ else
|
||||
# Try to initialize submodule if .gitmodules exists
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
||||
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
if ! retry _git_as_repo_owner submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||
retry _clone_rpi_rgb
|
||||
fi
|
||||
@@ -1066,12 +1184,14 @@ else
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
rm -rf rpi-rgb-led-matrix-master
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
retry _git_as_repo_owner submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
else
|
||||
retry _clone_rpi_rgb
|
||||
fi
|
||||
fi
|
||||
|
||||
_sync_rgb_submodule
|
||||
|
||||
# Add temporary swap on low-memory devices so the compiler survives.
|
||||
CURRENT_STEP="Prepare the low-memory build environment"
|
||||
if [ "$LOWMEM_AVAILABLE" = "1" ] && [ "$SKIP_SWAP" != "1" ]; then
|
||||
@@ -1272,7 +1392,7 @@ if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "/etc/systemd/system/ledmatrix-web.service" ] || [ "$NEEDS_UPDATE" = true ]; then
|
||||
if [ ! -f "/etc/systemd/system/ledmatrix-web.service" ] || [ ! -f "/etc/systemd/system/ledmatrix-update-verify.path" ] || [ "$NEEDS_UPDATE" = true ]; then
|
||||
bash "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"
|
||||
# Ensure systemd sees any new/changed unit files
|
||||
systemctl daemon-reload || true
|
||||
@@ -1288,7 +1408,7 @@ echo ""
|
||||
CURRENT_STEP="Harden systemd unit file permissions"
|
||||
echo "Step 8.1: Setting systemd unit file permissions..."
|
||||
echo "-----------------------------------------------"
|
||||
for unit in "/etc/systemd/system/ledmatrix.service" "/etc/systemd/system/ledmatrix-web.service" "/etc/systemd/system/ledmatrix-wifi-monitor.service"; do
|
||||
for unit in "/etc/systemd/system/ledmatrix.service" "/etc/systemd/system/ledmatrix-web.service" "/etc/systemd/system/ledmatrix-wifi-monitor.service" "/etc/systemd/system/ledmatrix-update-verify.service" "/etc/systemd/system/ledmatrix-update-verify.path"; do
|
||||
if [ -f "$unit" ]; then
|
||||
chown root:root "$unit" || true
|
||||
chmod 644 "$unit" || true
|
||||
@@ -1416,6 +1536,9 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controll
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
|
||||
# Install a requirements.txt as root via vetted helper, so packages are visible
|
||||
# to root-run ledmatrix.service (not just the web interface's own user).
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_pip_install.sh *
|
||||
EOF
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||
@@ -1623,6 +1746,19 @@ chmod 755 "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" "$PROJECT_ROOT_
|
||||
# Re-apply special permissions for config directory (lost during normalization)
|
||||
chmod 2775 "$PROJECT_ROOT_DIR/config" || true
|
||||
|
||||
# Harden the sudo-granted helper scripts: root-owned, not writable by the web
|
||||
# user (matches scripts/install/configure_web_sudo.sh). The sudoers rules in
|
||||
# Step 10 run these as root, so a user-owned copy is a root shell for whoever
|
||||
# can edit it. This must come after Step 11's project-wide chown to
|
||||
# $ACTUAL_USER, which would otherwise hand them straight back.
|
||||
for helper in safe_plugin_rm.sh safe_pip_install.sh; do
|
||||
HELPER_PATH="$PROJECT_ROOT_DIR/scripts/fix_perms/$helper"
|
||||
if [ -f "$HELPER_PATH" ]; then
|
||||
chown root:root "$HELPER_PATH" || echo "⚠ Could not set ownership on $HELPER_PATH"
|
||||
chmod 755 "$HELPER_PATH" || echo "⚠ Could not set permissions on $HELPER_PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✓ Project file permissions normalized"
|
||||
echo ""
|
||||
|
||||
|
||||
+8
-4
@@ -43,10 +43,14 @@ packaging>=23.0,<27.0
|
||||
# full feature set, or skip them for a minimal install.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# scipy — sub-pixel interpolation in
|
||||
# src/common/scroll_helper.py for smoother
|
||||
# scrolling. Falls back to a simpler shift algorithm.
|
||||
# pip install 'scipy>=1.10.0,<2.0.0'
|
||||
# scipy — nothing, as of #570. It was listed for the sub-pixel
|
||||
# interpolation path in src/common/scroll_helper.py, but
|
||||
# get_visible_portion never consulted HAS_SCIPY, so that
|
||||
# path was dead before it was deleted. The blend that
|
||||
# replaced it is numpy-only. Do not install it expecting
|
||||
# smoother scrolling: sub-pixel blending is off by default
|
||||
# because it reads worse on a coarse panel, not because it
|
||||
# is missing a library. See docs/SCROLL_PERFORMANCE.md.
|
||||
#
|
||||
# psutil — per-plugin resource monitoring in
|
||||
# src/plugin_system/resource_monitor.py. The monitor
|
||||
|
||||
Submodule rpi-rgb-led-matrix-master updated: 8907235630...1ee4f76f2b
@@ -78,6 +78,9 @@ logger = get_logger("[Check Plugin]")
|
||||
DEFAULT_SEARCH_DIRS = [
|
||||
str(PROJECT_ROOT / 'plugins'),
|
||||
str(PROJECT_ROOT / 'plugin-repos'),
|
||||
# The scoreboards live in the sibling ledmatrix-plugins checkout, not
|
||||
# in this repo. Without this, --all silently skips every one of them.
|
||||
str(PROJECT_ROOT.parent / 'ledmatrix-plugins' / 'plugins'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -72,12 +72,8 @@ def load_main_config(path: Path) -> Dict[str, Any]:
|
||||
|
||||
def display_size_from_config(config: Dict[str, Any]) -> tuple:
|
||||
"""Derive the logical ticker size the way DisplayManager does."""
|
||||
hw = config.get('display', {}).get('hardware', {})
|
||||
cols = int(hw.get('cols', 64))
|
||||
chain = int(hw.get('chain_length', 1))
|
||||
rows = int(hw.get('rows', 32))
|
||||
parallel = int(hw.get('parallel', 1))
|
||||
return cols * chain, rows * parallel
|
||||
from src.display_geometry import logical_size
|
||||
return logical_size(config)
|
||||
|
||||
|
||||
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
|
||||
|
||||
@@ -95,6 +95,19 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Health check / rollback units for automatic updates; see install_web_service.sh.
|
||||
for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path; do
|
||||
if [ -f "$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT" ]; then
|
||||
VERIFY_UNIT_TMP=$(mktemp)
|
||||
if sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" "$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT" > "$VERIFY_UNIT_TMP"; then
|
||||
sudo cp "$VERIFY_UNIT_TMP" "/etc/systemd/system/$VERIFY_UNIT"
|
||||
else
|
||||
echo "WARNING: failed to render $VERIFY_UNIT; automatic code updates will stay paused." >&2
|
||||
fi
|
||||
rm -f "$VERIFY_UNIT_TMP"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Reloading systemd daemon for web service..."
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
@@ -102,6 +115,11 @@ if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
||||
echo "Enabling ledmatrix-web.service to start on boot..."
|
||||
sudo systemctl enable ledmatrix-web.service
|
||||
|
||||
if [ -f /etc/systemd/system/ledmatrix-update-verify.path ]; then
|
||||
echo "Enabling ledmatrix-update-verify.path (automatic update health check)..."
|
||||
sudo systemctl enable --now ledmatrix-update-verify.path || echo "WARNING: could not enable ledmatrix-update-verify.path; automatic code updates will stay paused" >&2
|
||||
fi
|
||||
|
||||
echo "Starting ledmatrix-web.service..."
|
||||
sudo systemctl start ledmatrix-web.service
|
||||
|
||||
|
||||
@@ -46,6 +46,22 @@ ESCAPED_ACTUAL_USER=$(sed_escape_replacement "$ACTUAL_USER")
|
||||
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||
"$TEMPLATE" > /etc/systemd/system/ledmatrix-web.service
|
||||
|
||||
# Health check and rollback for the web UI's automatic updates. Its own unit so
|
||||
# it survives the web service restart it performs; never enabled -- the web
|
||||
# interface starts it after an update. Without it, automatic code updates
|
||||
# stay paused rather than running with nothing to undo them.
|
||||
for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path; do
|
||||
VERIFY_TEMPLATE="$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT"
|
||||
if [ -f "$VERIFY_TEMPLATE" ]; then
|
||||
echo "Writing unit file to /etc/systemd/system/$VERIFY_UNIT"
|
||||
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||
"$VERIFY_TEMPLATE" > "/etc/systemd/system/$VERIFY_UNIT"
|
||||
chmod 644 "/etc/systemd/system/$VERIFY_UNIT"
|
||||
else
|
||||
echo "WARNING: $VERIFY_TEMPLATE not found; automatic code updates will stay paused"
|
||||
fi
|
||||
done
|
||||
|
||||
# Ensure cache directory exists with proper permissions
|
||||
# This is a fallback for older systemd versions that don't support CacheDirectory
|
||||
# Systemd 239+ will automatically create it via CacheDirectory directive
|
||||
@@ -80,6 +96,13 @@ systemctl daemon-reload
|
||||
echo "Enabling ledmatrix-web.service..."
|
||||
systemctl enable ledmatrix-web.service
|
||||
|
||||
# The path unit is what starts the health check after an automatic update.
|
||||
if [ -f /etc/systemd/system/ledmatrix-update-verify.path ]; then
|
||||
echo "Enabling ledmatrix-update-verify.path..."
|
||||
systemctl enable --now ledmatrix-update-verify.path || \
|
||||
echo "WARNING: could not enable ledmatrix-update-verify.path; automatic code updates will stay paused"
|
||||
fi
|
||||
|
||||
# Start the service
|
||||
echo "Starting ledmatrix-web.service..."
|
||||
systemctl start ledmatrix-web.service
|
||||
|
||||
@@ -408,6 +408,7 @@ main() {
|
||||
# which would silently reinstate the duplicate apt update.
|
||||
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 \
|
||||
LEDMATRIX_APT_UPDATED="${LEDMATRIX_APT_UPDATED:-0}" \
|
||||
LEDMATRIX_AUTO_UPDATE="${LEDMATRIX_AUTO_UPDATE:-}" \
|
||||
bash ./first_time_install.sh -y </dev/null
|
||||
fi
|
||||
INSTALL_EXIT_CODE=$?
|
||||
|
||||
@@ -59,10 +59,13 @@ def build_options(hardware, refresh_override=None):
|
||||
from rgbmatrix import RGBMatrixOptions
|
||||
|
||||
o = RGBMatrixOptions()
|
||||
o.rows = int(hardware.get("rows", 32))
|
||||
o.cols = int(hardware.get("cols", 64))
|
||||
o.chain_length = int(hardware.get("chain_length", 1))
|
||||
o.parallel = int(hardware.get("parallel", 1))
|
||||
from src.display_geometry import (
|
||||
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||
)
|
||||
o.rows = int(hardware.get("rows", DEFAULT_ROWS))
|
||||
o.cols = int(hardware.get("cols", DEFAULT_COLS))
|
||||
o.chain_length = int(hardware.get("chain_length", DEFAULT_CHAIN_LENGTH))
|
||||
o.parallel = int(hardware.get("parallel", DEFAULT_PARALLEL))
|
||||
o.brightness = int(hardware.get("brightness", 80))
|
||||
o.hardware_mapping = hardware.get("hardware_mapping", "regular")
|
||||
o.pwm_bits = int(hardware.get("pwm_bits", 11))
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that an automatic LEDMatrix update left the device working; roll it back if not.
|
||||
|
||||
Started by the web interface's weekly updater (web_interface/auto_update.py)
|
||||
through ledmatrix-update-verify.service, right after it pulls new code. It has
|
||||
to run outside the web service: checking the update means restarting that
|
||||
service, and a check running inside it would be killed by its own restart.
|
||||
|
||||
It must not be the code it is checking, either. The updater copies this file
|
||||
to data/auto_update_verifier.py *before* pulling and the unit runs that copy,
|
||||
so a broken update cannot break its own rollback. Standard library only for
|
||||
the same reason: the rollback cannot depend on packages the update changed.
|
||||
|
||||
The updater leaves data/auto_update_pending.json:
|
||||
|
||||
{"status": "pending", "old_head": ..., "new_head": ...,
|
||||
"display_was_active": bool, "dependency_failures": [...], "created_at": ...}
|
||||
|
||||
This moves its status to "verifying" and then to one of "success",
|
||||
"rolled_back" or "rollback_failed", with "reason" and "detail" saying why.
|
||||
The web interface reports that outcome and raises a banner for anything but
|
||||
success.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
import sys
|
||||
from collections import namedtuple
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
PENDING_NAME = 'auto_update_pending.json'
|
||||
REQUIREMENT_FILES = ('requirements.txt', 'web_interface/requirements.txt')
|
||||
WEB_HEALTH_URL = 'http://127.0.0.1:5000/api/v3/system/version'
|
||||
#: How long the services get to come up after a restart...
|
||||
HEALTH_TIMEOUT_SECONDS = 180
|
||||
#: ...and how long they must then stay up. Restart=on-failure makes a crash
|
||||
#: loop look healthy between attempts, so a single "is-active" proves nothing.
|
||||
STABLE_SECONDS = 45
|
||||
POLL_SECONDS = 5
|
||||
PIP_TIMEOUT_SECONDS = 600
|
||||
#: sudoers matches the exact command line, so bash is named by path, the same
|
||||
#: candidates src/common/permission_utils.install_requirements_file tries.
|
||||
BASH_CANDIDATES = ('/usr/bin/bash', '/bin/bash')
|
||||
|
||||
#: What a command that could not run at all reports: its callers only read
|
||||
#: these three fields, the same ones a completed subprocess has.
|
||||
_Failed = namedtuple('_Failed', 'returncode stdout stderr')
|
||||
|
||||
|
||||
def pending_path(project_root):
|
||||
return Path(project_root) / 'data' / PENDING_NAME
|
||||
|
||||
|
||||
def read_pending(path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def write_pending(path, data):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.auto_update_pending_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _web_responds(url=WEB_HEALTH_URL):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as resp: # nosec B310 - fixed loopback URL
|
||||
return resp.status == 200
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _short(sha):
|
||||
return (sha or 'unknown')[:7]
|
||||
|
||||
|
||||
class Verifier:
|
||||
def __init__(self, project_root, run=subprocess.run, sleep=time.sleep,
|
||||
clock=time.monotonic, web_responds=_web_responds, log=None):
|
||||
self.project_root = Path(project_root)
|
||||
self.pending_file = pending_path(project_root)
|
||||
self.run = run
|
||||
self.sleep = sleep
|
||||
self.clock = clock
|
||||
self.web_responds = web_responds
|
||||
self.log = log or (lambda msg: print(f'[auto-update-verify] {msg}', flush=True))
|
||||
|
||||
def _run(self, args, timeout=60):
|
||||
try:
|
||||
return self.run(args, cwd=str(self.project_root), capture_output=True,
|
||||
text=True, timeout=timeout)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
return _Failed(returncode=1, stdout='', stderr=str(e))
|
||||
|
||||
# -- services ---------------------------------------------------------
|
||||
|
||||
def service_active(self, unit):
|
||||
return self._run(['systemctl', 'is-active', unit], timeout=10).stdout.strip() == 'active'
|
||||
|
||||
def restart_count(self, unit):
|
||||
out = self._run(['systemctl', 'show', '-p', 'NRestarts', '--value', unit],
|
||||
timeout=10).stdout.strip()
|
||||
return int(out) if out.isdigit() else None
|
||||
|
||||
def restart(self, unit):
|
||||
result = self._run(['sudo', '-n', 'systemctl', 'restart', f'{unit}.service'], timeout=90)
|
||||
if result.returncode != 0:
|
||||
self.log(f'restarting {unit} failed: {(result.stderr or "").strip()}')
|
||||
return result.returncode == 0
|
||||
|
||||
def restart_services(self, display):
|
||||
"""Restart what should be running. False if any restart command failed."""
|
||||
ok = True
|
||||
# A display the user had stopped stays stopped.
|
||||
if display:
|
||||
ok = self.restart('ledmatrix') and ok
|
||||
return self.restart('ledmatrix-web') and ok
|
||||
|
||||
def wait_healthy(self, display):
|
||||
"""None once the services are up and stay up, else what went wrong."""
|
||||
deadline = self.clock() + HEALTH_TIMEOUT_SECONDS + STABLE_SECONDS
|
||||
healthy_since = baseline = None
|
||||
web = disp = False
|
||||
count_known = True
|
||||
while self.clock() < deadline:
|
||||
web = self.web_responds()
|
||||
disp = self.service_active('ledmatrix') if display else True
|
||||
restarts = self.restart_count('ledmatrix') if display else None
|
||||
# Without a restart count a crash loop looks healthy between
|
||||
# attempts, so an unreadable count never counts as stable.
|
||||
count_known = not display or restarts is not None
|
||||
if web and disp and count_known and (healthy_since is None or restarts == baseline):
|
||||
if healthy_since is None:
|
||||
healthy_since, baseline = self.clock(), restarts
|
||||
elif self.clock() - healthy_since >= STABLE_SECONDS:
|
||||
return None
|
||||
else:
|
||||
healthy_since = None
|
||||
self.sleep(POLL_SECONDS)
|
||||
problems = []
|
||||
if not web:
|
||||
problems.append('the web interface did not respond')
|
||||
if not disp:
|
||||
problems.append('the display service did not stay running')
|
||||
if web and disp and not count_known:
|
||||
problems.append("the display service's restart count could not be read")
|
||||
return '; '.join(problems) or 'the display service kept restarting'
|
||||
|
||||
# -- rollback ---------------------------------------------------------
|
||||
|
||||
def changed_requirements(self, old, new):
|
||||
result = self._run(['git', 'diff', '--name-only', old, new])
|
||||
# If the diff is unavailable, reinstall both rather than guess.
|
||||
changed = set(result.stdout.split()) if result.returncode == 0 else set(REQUIREMENT_FILES)
|
||||
return [rel for rel in REQUIREMENT_FILES if rel in changed]
|
||||
|
||||
def install_requirements(self, rel):
|
||||
wrapper = self.project_root / 'scripts' / 'fix_perms' / 'safe_pip_install.sh'
|
||||
req = self.project_root / rel
|
||||
if not req.exists():
|
||||
return True
|
||||
for bash in BASH_CANDIDATES:
|
||||
result = self._run(['sudo', '-n', bash, str(wrapper), str(req)],
|
||||
timeout=PIP_TIMEOUT_SECONDS)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
def rollback(self, pending):
|
||||
"""Reset to the previous commit and its dependencies. Returns (ok, detail)."""
|
||||
old, new = pending.get('old_head'), pending.get('new_head')
|
||||
if not old:
|
||||
return False, 'the commit to roll back to is unknown'
|
||||
requirements = self.changed_requirements(old, new) if new else list(REQUIREMENT_FILES)
|
||||
# Safe to --hard: the updater refuses to run with local edits to
|
||||
# tracked files, so the only thing this discards is the update.
|
||||
result = self._run(['git', 'reset', '--hard', old], timeout=120)
|
||||
if result.returncode != 0:
|
||||
return False, (f'"git reset --hard {old}" failed: '
|
||||
f'{(result.stderr or result.stdout or "").strip()}')
|
||||
failed = [rel for rel in requirements if not self.install_requirements(rel)]
|
||||
if failed:
|
||||
return True, ('reinstalling the previous dependencies from ' + ', '.join(failed)
|
||||
+ ' failed; run Install Base Requirements from the Tools tab')
|
||||
return True, ''
|
||||
|
||||
# -- the check itself -------------------------------------------------
|
||||
|
||||
def _finish(self, pending, status, reason=None, detail=None):
|
||||
pending.update({'status': status, 'reason': reason, 'detail': detail or None,
|
||||
'finished_at': time.time()})
|
||||
write_pending(self.pending_file, pending)
|
||||
self.log(' '.join(p for p in (status, reason or '', detail or '') if p))
|
||||
|
||||
def verify(self):
|
||||
pending = read_pending(self.pending_file)
|
||||
if not pending or pending.get('status') != 'pending':
|
||||
self.log('no update is waiting to be verified')
|
||||
return 0
|
||||
pending['status'] = 'verifying'
|
||||
write_pending(self.pending_file, pending)
|
||||
|
||||
display = bool(pending.get('display_was_active'))
|
||||
dependency_failures = pending.get('dependency_failures') or []
|
||||
if dependency_failures:
|
||||
# Never restart onto code whose packages did not install.
|
||||
reason = 'installing its dependencies failed (' + ', '.join(dependency_failures) + ')'
|
||||
elif not self.restart_services(display):
|
||||
# The old process may still be answering; checking it would pass
|
||||
# an update that never started.
|
||||
reason = 'restarting the services failed'
|
||||
else:
|
||||
reason = self.wait_healthy(display)
|
||||
if reason is None:
|
||||
self._finish(pending, 'success')
|
||||
return 0
|
||||
|
||||
self.log(f'update to {_short(pending.get("new_head"))} is unhealthy ({reason}); '
|
||||
f'rolling back to {_short(pending.get("old_head"))}')
|
||||
ok, detail = self.rollback(pending)
|
||||
if not ok:
|
||||
self._finish(pending, 'rollback_failed', reason, detail)
|
||||
return 1
|
||||
still = (self.wait_healthy(display) if self.restart_services(display)
|
||||
else 'restarting the services failed')
|
||||
if still:
|
||||
self._finish(pending, 'rollback_failed', reason,
|
||||
f'still unhealthy after rolling back: {still}'
|
||||
+ (f'; {detail}' if detail else ''))
|
||||
return 1
|
||||
self._finish(pending, 'rolled_back', reason, detail)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2:
|
||||
print('usage: auto_update_verify.py PROJECT_ROOT', file=sys.stderr)
|
||||
return 2
|
||||
verifier = Verifier(Path(argv[1]))
|
||||
try:
|
||||
return verifier.verify()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
# Whatever happened, the web interface must not be left thinking the
|
||||
# check is still running.
|
||||
try:
|
||||
pending = read_pending(verifier.pending_file) or {}
|
||||
if pending.get('status') in ('pending', 'verifying'):
|
||||
pending.update({'status': 'rollback_failed', 'reason': 'the health check crashed',
|
||||
'detail': str(e), 'finished_at': time.time()})
|
||||
write_pending(verifier.pending_file, pending)
|
||||
except OSError:
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv))
|
||||
+8
-3
@@ -1,5 +1,10 @@
|
||||
# skins/
|
||||
|
||||
> **Not supported yet.** The current scoreboard plugins don't render skins,
|
||||
> so a skin placed here and selected in config has no effect, and the web UI
|
||||
> and Plugin Store don't offer them. See
|
||||
> [docs/SKIN_SYSTEM.md](../docs/SKIN_SYSTEM.md#status-not-supported-yet).
|
||||
|
||||
User-installable **visual skins** for the sports scoreboards. Each
|
||||
subdirectory is one skin:
|
||||
|
||||
@@ -10,10 +15,10 @@ skins/<skin-id>/
|
||||
preview.png # optional
|
||||
```
|
||||
|
||||
- Install a skin: `git clone <skin repo> skins/<skin-id>` (or via the Plugin
|
||||
Store for registry entries with `"type": "skin"`).
|
||||
- Install a skin: `git clone <skin repo> skins/<skin-id>`. The Plugin Store
|
||||
refuses registry entries with `"type": "skin"` while skins don't render.
|
||||
- Select it: set `"skin": "<skin-id>"` in the plugin's section of
|
||||
`config/config.json`, or use the web UI's Visual Skin dropdown.
|
||||
`config/config.json`. The web UI no longer shows a Visual Skin dropdown.
|
||||
- Build one: start from `example-classic-baseball/` and read
|
||||
[docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with
|
||||
`python scripts/validate_skin.py --skin <skin-id>`.
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.3.0"
|
||||
__version__ = "3.4.0"
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Install the automatic-update health check, from the display service.
|
||||
|
||||
The weekly updater (web_interface/auto_update.py) will not update LEDMatrix
|
||||
code unless ledmatrix-update-verify.path and .service are installed: they
|
||||
restart the services after an update and roll it back if the device is
|
||||
unhealthy. Installing units takes root and the web interface is not root, and
|
||||
"SSH in and run an installer" means most people never get updates with a
|
||||
safety net.
|
||||
|
||||
The display service already runs this repository's code as root, so it
|
||||
installs them -- but only while the user has automatic updates turned on, only
|
||||
these two units, rendered from the repository's templates for the web
|
||||
interface's own user, and it reports what happened in
|
||||
data/auto_update_setup.json for the General tab. It grants nothing new: the
|
||||
units run as the web user, who can already change the code this process runs.
|
||||
|
||||
Called at display startup; the web interface restarts the display service
|
||||
when the toggle is switched on, so setup happens straight away. Refreshing a
|
||||
unit whose template changed happens the same way, which is why this compares
|
||||
content rather than only checking that the files exist.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
SYSTEMD_DIR = Path('/etc/systemd/system')
|
||||
SERVICE_UNIT = 'ledmatrix-update-verify.service'
|
||||
PATH_UNIT = 'ledmatrix-update-verify.path'
|
||||
UNITS = (SERVICE_UNIT, PATH_UNIT)
|
||||
WEB_UNIT = 'ledmatrix-web.service'
|
||||
RESULT_REL = Path('data') / 'auto_update_setup.json'
|
||||
_USER_RE = re.compile(r'^[a-z_][a-z0-9_-]{0,31}$')
|
||||
|
||||
|
||||
class SetupError(Exception):
|
||||
"""A reason setup cannot proceed, worded for the General tab."""
|
||||
|
||||
|
||||
def _is_root():
|
||||
return hasattr(os, 'geteuid') and os.geteuid() == 0
|
||||
|
||||
|
||||
def _lookup_ids(user):
|
||||
try:
|
||||
import pwd
|
||||
entry = pwd.getpwnam(user)
|
||||
return entry.pw_uid, entry.pw_gid
|
||||
except (ImportError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def _directive(text, key):
|
||||
match = re.search(rf'^{key}=(.*)$', text or '', re.M)
|
||||
return match.group(1).strip() if match else None
|
||||
|
||||
|
||||
def _read(path):
|
||||
try:
|
||||
return Path(path).read_text(encoding='utf-8')
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def is_enabled(config):
|
||||
return bool((config.get('auto_update') or {}).get('enabled', False))
|
||||
|
||||
|
||||
class UpdateHelperSetup:
|
||||
def __init__(self, project_root=PROJECT_ROOT, systemd_dir=SYSTEMD_DIR, run=subprocess.run,
|
||||
is_root=_is_root, lookup_ids=_lookup_ids, clock=time.time):
|
||||
self.project_root = Path(project_root)
|
||||
self.systemd_dir = Path(systemd_dir)
|
||||
self.run = run
|
||||
self.is_root = is_root
|
||||
self.lookup_ids = lookup_ids
|
||||
self.clock = clock
|
||||
self.result_file = self.project_root / RESULT_REL
|
||||
self._web_ids = None
|
||||
|
||||
def _systemctl(self, *args):
|
||||
return self.run(['systemctl', *args], capture_output=True, text=True, timeout=60)
|
||||
|
||||
def _check(self, result, what):
|
||||
if result.returncode != 0:
|
||||
raise SetupError(f'"{what}" failed: {(result.stderr or result.stdout or "").strip()}')
|
||||
|
||||
def path_active(self):
|
||||
try:
|
||||
return self._systemctl('is-active', PATH_UNIT).stdout.strip() == 'active'
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
|
||||
def ensure(self, config):
|
||||
"""Install or refresh the units while automatic updates are on.
|
||||
|
||||
Returns the result recorded for the General tab, or None when there
|
||||
was nothing to do (updates off, or not a systemd host at all).
|
||||
"""
|
||||
if not is_enabled(config) or not self.systemd_dir.is_dir():
|
||||
return None
|
||||
try:
|
||||
changed = self._install()
|
||||
except SetupError as e:
|
||||
return self._report('failed', str(e))
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
return self._report('failed', f'Could not install the update health check: {e}')
|
||||
if changed:
|
||||
return self._report('installed', 'Installed the update health check.')
|
||||
return self._report('installed', 'The update health check is installed.', quiet=True)
|
||||
|
||||
def _install(self):
|
||||
if not self.is_root():
|
||||
raise SetupError('The display service is not running as root, so it cannot install the '
|
||||
'update health check. Run "sudo ./scripts/install/install_web_service.sh" once.')
|
||||
|
||||
web_text = _read(self.systemd_dir / WEB_UNIT)
|
||||
if web_text is None:
|
||||
raise SetupError('The web interface service (ledmatrix-web.service) is not installed.')
|
||||
user = _directive(web_text, 'User') or 'root'
|
||||
ids = self.lookup_ids(user) if _USER_RE.match(user) else None
|
||||
if ids is None:
|
||||
raise SetupError(f'The web interface runs as "{user}", which is not a usable account.')
|
||||
self._web_ids = ids
|
||||
workdir = _directive(web_text, 'WorkingDirectory')
|
||||
if not workdir or Path(workdir).resolve() != self.project_root.resolve():
|
||||
raise SetupError(f'The web interface service runs from {workdir or "an unknown folder"}, '
|
||||
f'not {self.project_root}.')
|
||||
# Spaces are fine -- the templates quote every command-line path --
|
||||
# but systemd expands % specifiers, and a quote, backslash or line
|
||||
# break would be reinterpreted in a unit file. (On Windows, where the
|
||||
# tests also run, a backslash is the path separator, not a name.)
|
||||
root_text = str(self.project_root)
|
||||
unsafe = set('%"') | ({'\\'} if os.sep == '/' else set())
|
||||
if any(ch in unsafe or ord(ch) < 32 for ch in root_text):
|
||||
raise SetupError(f'LEDMatrix is installed in {root_text!r}, a folder name systemd cannot use '
|
||||
'in a unit file. Move it to a path without %, quotes, backslashes or '
|
||||
'control characters.')
|
||||
|
||||
rendered = {}
|
||||
for name in UNITS:
|
||||
template = _read(self.project_root / 'systemd' / name)
|
||||
if template is None:
|
||||
raise SetupError(f'The unit template systemd/{name} is missing.')
|
||||
rendered[name] = (template.replace('__PROJECT_ROOT_DIR__', str(self.project_root))
|
||||
.replace('__USER__', user))
|
||||
# The templates are ordinary repository files. Whatever they say,
|
||||
# this root process only installs a service that runs as the web user
|
||||
# and a path unit that starts exactly that service.
|
||||
if _directive(rendered[SERVICE_UNIT], 'User') != user:
|
||||
raise SetupError(f'systemd/{SERVICE_UNIT} does not run as the web interface user; '
|
||||
'refusing to install it.')
|
||||
if _directive(rendered[PATH_UNIT], 'Unit') != SERVICE_UNIT:
|
||||
raise SetupError(f'systemd/{PATH_UNIT} does not start {SERVICE_UNIT}; refusing to install it.')
|
||||
|
||||
changed = [name for name in UNITS if _read(self.systemd_dir / name) != rendered[name]]
|
||||
for name in changed:
|
||||
self._write_unit(self.systemd_dir / name, rendered[name])
|
||||
if changed:
|
||||
self._check(self._systemctl('daemon-reload'), 'systemctl daemon-reload')
|
||||
self._check(self._systemctl('enable', PATH_UNIT), f'systemctl enable {PATH_UNIT}')
|
||||
self._check(self._systemctl('restart', PATH_UNIT), f'systemctl restart {PATH_UNIT}')
|
||||
elif not self.path_active():
|
||||
self._check(self._systemctl('enable', '--now', PATH_UNIT), f'systemctl enable --now {PATH_UNIT}')
|
||||
changed = [PATH_UNIT]
|
||||
if not self.path_active():
|
||||
raise SetupError(f'{PATH_UNIT} did not start; see "journalctl -u {PATH_UNIT}".')
|
||||
return bool(changed)
|
||||
|
||||
def _write_unit(self, path, text):
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f'.{path.name}.')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _report(self, status, message, quiet=False):
|
||||
previous = None
|
||||
try:
|
||||
previous = json.loads(_read(self.result_file) or 'null')
|
||||
except ValueError:
|
||||
pass
|
||||
if quiet and isinstance(previous, dict) and previous.get('status') == status:
|
||||
return previous # nothing new; don't rewrite it on every boot
|
||||
result = {'status': status, 'message': message, 'at': self.clock()}
|
||||
(logger.info if status == 'installed' else logger.warning)("Automatic update setup: %s", message)
|
||||
try:
|
||||
self.result_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(self.result_file.parent), prefix='.auto_update_setup_')
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, indent=2)
|
||||
os.chmod(tmp, 0o644)
|
||||
if self._web_ids and hasattr(os, 'chown'):
|
||||
# Only root can give the file away; the result is readable
|
||||
# (0644) either way, so a failed chown must not lose it.
|
||||
try:
|
||||
os.chown(tmp, *self._web_ids)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, self.result_file)
|
||||
except OSError as e:
|
||||
logger.warning("Could not record automatic update setup result: %s", e)
|
||||
return result
|
||||
|
||||
|
||||
def ensure_update_helper(config):
|
||||
return UpdateHelperSetup().ensure(config)
|
||||
+53
-173
@@ -30,6 +30,12 @@ except ImportError:
|
||||
HAS_SCIPY = False
|
||||
|
||||
|
||||
# How often the frame-stats line is emitted, and therefore also the ceiling
|
||||
# on a believable frame time: a scroll that renders at all cannot take this
|
||||
# long over one frame, so a sample this large is an idle gap between scrolls.
|
||||
FPS_LOG_INTERVAL = 5.0
|
||||
|
||||
|
||||
def frame_stats(frame_times: list) -> Dict[str, Any]:
|
||||
"""Summary statistics over one window of frame durations (seconds).
|
||||
|
||||
@@ -158,9 +164,11 @@ class ScrollHelper:
|
||||
self.last_progress_log_time: Optional[float] = None
|
||||
self.progress_log_interval = 5.0 # seconds
|
||||
|
||||
# Frame rate tracking
|
||||
# Frame rate tracking. last_frame_time is None until the first frame
|
||||
# of a scroll is rendered -- see log_frame_rate() for why timing from
|
||||
# construction (or from the end of the previous scroll) is wrong.
|
||||
self.frame_count = 0
|
||||
self.last_frame_time = time.time()
|
||||
self.last_frame_time: Optional[float] = None
|
||||
self.last_fps_log_time = time.time()
|
||||
self.frame_times = []
|
||||
# Every frame time since the last stats line, so the 5s summary can
|
||||
@@ -534,168 +542,6 @@ class ScrollHelper:
|
||||
|
||||
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||
|
||||
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
||||
"""
|
||||
Get visible portion with sub-pixel interpolation for smooth scrolling.
|
||||
Uses bilinear interpolation to blend between pixels.
|
||||
"""
|
||||
# We need to extract a region that's 1 pixel wider to allow for interpolation
|
||||
start_x = start_x_int
|
||||
end_x = start_x_int + self.display_width + 1
|
||||
|
||||
# Check if we need wrap-around
|
||||
if end_x <= self.cached_image.width:
|
||||
# Normal case: extract region with 1 extra pixel for interpolation
|
||||
source_region = self.cached_array[:, start_x:end_x]
|
||||
|
||||
# Use bilinear interpolation for sub-pixel shifting
|
||||
if HAS_SCIPY:
|
||||
# Use scipy for high-quality sub-pixel shifting
|
||||
shifted = shift(source_region, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||||
# Extract the display_width portion
|
||||
frame_array = shifted[:, :self.display_width].astype(np.uint8)
|
||||
else:
|
||||
# Fallback: simple linear interpolation using numpy
|
||||
# Blend between current and next pixel based on fractional part
|
||||
frame_array = self._interpolate_subpixel(source_region, fractional)
|
||||
|
||||
return Image.fromarray(frame_array)
|
||||
else:
|
||||
# Wrap-around case with sub-pixel
|
||||
# Use pre-allocated buffer
|
||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||
|
||||
width1 = self.cached_image.width - start_x
|
||||
if width1 > 0:
|
||||
# First part from end of image
|
||||
# Need width1 + 1 pixels for interpolation
|
||||
source1_width = min(width1 + 1, self.cached_image.width - start_x)
|
||||
source1 = self.cached_array[:, start_x:start_x + source1_width]
|
||||
if HAS_SCIPY:
|
||||
shifted1 = shift(source1, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||||
# Ensure we get exactly width1 pixels, padding if necessary
|
||||
if shifted1.shape[1] >= width1:
|
||||
self._frame_buffer[:, :width1] = shifted1[:, :width1].astype(np.uint8)
|
||||
else:
|
||||
# Shifted array is smaller - pad with zeros or repeat last pixel
|
||||
actual_width = shifted1.shape[1]
|
||||
self._frame_buffer[:, :actual_width] = shifted1.astype(np.uint8)
|
||||
if actual_width < width1:
|
||||
# Pad with last pixel
|
||||
self._frame_buffer[:, actual_width:width1] = shifted1[:, -1:].astype(np.uint8)
|
||||
else:
|
||||
interpolated1 = self._interpolate_subpixel(source1, fractional, output_width=width1)
|
||||
# Ensure exact width match
|
||||
if interpolated1.shape[1] == width1:
|
||||
self._frame_buffer[:, :width1] = interpolated1
|
||||
else:
|
||||
# Handle size mismatch
|
||||
copy_width = min(width1, interpolated1.shape[1])
|
||||
self._frame_buffer[:, :copy_width] = interpolated1[:, :copy_width]
|
||||
if copy_width < width1:
|
||||
self._frame_buffer[:, copy_width:width1] = interpolated1[:, -1:]
|
||||
|
||||
# Second part from beginning
|
||||
remaining_width = self.display_width - width1
|
||||
if remaining_width > 0:
|
||||
source2 = self.cached_array[:, :remaining_width + 1]
|
||||
if HAS_SCIPY:
|
||||
shifted2 = shift(source2, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||||
# Ensure we get exactly remaining_width pixels
|
||||
if shifted2.shape[1] >= remaining_width:
|
||||
self._frame_buffer[:, width1:width1 + remaining_width] = shifted2[:, :remaining_width].astype(np.uint8)
|
||||
else:
|
||||
# Shifted array is smaller - pad if necessary
|
||||
actual_width = shifted2.shape[1]
|
||||
self._frame_buffer[:, width1:width1 + actual_width] = shifted2.astype(np.uint8)
|
||||
if actual_width < remaining_width:
|
||||
self._frame_buffer[:, width1 + actual_width:width1 + remaining_width] = shifted2[:, -1:].astype(np.uint8)
|
||||
else:
|
||||
interpolated2 = self._interpolate_subpixel(source2, fractional, output_width=remaining_width)
|
||||
# Ensure exact width match
|
||||
if interpolated2.shape[1] == remaining_width:
|
||||
self._frame_buffer[:, width1:] = interpolated2
|
||||
else:
|
||||
copy_width = min(remaining_width, interpolated2.shape[1])
|
||||
self._frame_buffer[:, width1:width1 + copy_width] = interpolated2[:, :copy_width]
|
||||
if copy_width < remaining_width:
|
||||
self._frame_buffer[:, width1 + copy_width:width1 + remaining_width] = interpolated2[:, -1:]
|
||||
else:
|
||||
# Edge case: wrap to beginning
|
||||
source = self.cached_array[:, :self.display_width + 1]
|
||||
if HAS_SCIPY:
|
||||
shifted = shift(source, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||||
# Ensure we get exactly display_width pixels
|
||||
if shifted.shape[1] >= self.display_width:
|
||||
self._frame_buffer = shifted[:, :self.display_width].astype(np.uint8)
|
||||
else:
|
||||
# Shifted array is smaller - pad if necessary
|
||||
actual_width = shifted.shape[1]
|
||||
self._frame_buffer[:, :actual_width] = shifted.astype(np.uint8)
|
||||
if actual_width < self.display_width:
|
||||
self._frame_buffer[:, actual_width:] = shifted[:, -1:].astype(np.uint8)
|
||||
else:
|
||||
interpolated = self._interpolate_subpixel(source, fractional, output_width=self.display_width)
|
||||
# _interpolate_subpixel now always returns exact width, so this should work
|
||||
self._frame_buffer = interpolated
|
||||
|
||||
return Image.fromarray(self._frame_buffer)
|
||||
|
||||
def _interpolate_subpixel(self, source: np.ndarray, fractional: float, output_width: Optional[int] = None) -> np.ndarray:
|
||||
"""
|
||||
Simple linear interpolation for sub-pixel positioning.
|
||||
Blends between adjacent pixels based on fractional offset.
|
||||
|
||||
Args:
|
||||
source: Source array to interpolate (width should be at least output_width + 1)
|
||||
fractional: Fractional part of scroll position (0.0-1.0)
|
||||
output_width: Desired output width (defaults to display_width)
|
||||
|
||||
Returns:
|
||||
Interpolated array of shape (height, output_width, 3) - ALWAYS exactly output_width
|
||||
"""
|
||||
if output_width is None:
|
||||
output_width = self.display_width
|
||||
|
||||
# Always return exactly output_width pixels, padding if necessary
|
||||
result = np.zeros((source.shape[0], output_width, 3), dtype=np.uint8)
|
||||
|
||||
# Ensure we have enough source pixels for interpolation
|
||||
if source.shape[1] < 2:
|
||||
# Very small source - just copy what we have and pad
|
||||
copy_width = min(source.shape[1], output_width)
|
||||
result[:, :copy_width] = source[:, :copy_width].astype(np.uint8)
|
||||
if copy_width < output_width:
|
||||
# Pad with last pixel
|
||||
result[:, copy_width:] = source[:, -1:].astype(np.uint8)
|
||||
return result
|
||||
|
||||
# Calculate how many pixels we can actually interpolate
|
||||
# Need at least 2 pixels to interpolate, so max output is source.shape[1] - 1
|
||||
max_interpolated_width = source.shape[1] - 1
|
||||
interpolated_width = min(output_width, max_interpolated_width)
|
||||
|
||||
if interpolated_width > 0:
|
||||
# Extract pixels at x and x+1 for interpolation
|
||||
pixels_x = source[:, :interpolated_width].astype(np.float32)
|
||||
pixels_x1 = source[:, 1:interpolated_width + 1].astype(np.float32)
|
||||
|
||||
# Linear interpolation
|
||||
interpolated = pixels_x * (1.0 - fractional) + pixels_x1 * fractional
|
||||
|
||||
# Clip and convert back to uint8
|
||||
interpolated = np.clip(interpolated, 0, 255).astype(np.uint8)
|
||||
|
||||
# Copy interpolated portion to result
|
||||
result[:, :interpolated_width] = interpolated
|
||||
|
||||
# If we need more pixels than we can interpolate, pad with last pixel
|
||||
if interpolated_width < output_width:
|
||||
result[:, interpolated_width:] = source[:, -1:].astype(np.uint8)
|
||||
|
||||
return result
|
||||
|
||||
def calculate_dynamic_duration(self) -> int:
|
||||
"""
|
||||
Calculate display duration based on content width and scroll settings.
|
||||
@@ -926,6 +772,10 @@ class ScrollHelper:
|
||||
# Reset last_update_time to prevent large delta_time on next update
|
||||
# This ensures smooth scrolling after reset without jumping ahead
|
||||
self.last_update_time = now
|
||||
# Same reasoning for the frame-rate clock: the first frame after a
|
||||
# reset has no predecessor in this scroll, and timing it against the
|
||||
# last frame of the previous one measures the idle gap between them.
|
||||
self.last_frame_time = None
|
||||
self.logger.debug("Scroll position reset")
|
||||
|
||||
def reset(self) -> None:
|
||||
@@ -1124,11 +974,37 @@ class ScrollHelper:
|
||||
Log frame rate statistics for performance monitoring.
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
|
||||
# The first frame of a scroll has no predecessor, so it has no frame
|
||||
# time. Measuring one anyway records the whole idle gap since the last
|
||||
# scroll as a single frame: on hardware that produced windows reading
|
||||
# "0.0 fps over 1 frames | median 136776.02ms", and -- worse, because
|
||||
# it is not obviously wrong -- put that gap in the max field of
|
||||
# otherwise healthy windows and counted it as one stall per scroll.
|
||||
# At ~500 frames to a window that is ~0.2%, which is the same order as
|
||||
# the real stall rates being measured, so the number could not be
|
||||
# trusted at all. Seed the clock and take no sample.
|
||||
if self.last_frame_time is None:
|
||||
self.last_frame_time = current_time
|
||||
# Restart the window with the scroll. Otherwise the boundary is
|
||||
# already long overdue when the second frame arrives, and the new
|
||||
# scroll opens by reporting a window of exactly one frame.
|
||||
self.last_fps_log_time = current_time
|
||||
return
|
||||
|
||||
# Calculate instantaneous frame time
|
||||
frame_time = current_time - self.last_frame_time
|
||||
|
||||
# A caller that scrolls without ever calling reset_scroll() never arms
|
||||
# the sentinel above, so catch the same gap by its size. Nothing that
|
||||
# renders a scroll produces a frame longer than the log interval; a
|
||||
# sample that large is an idle period, not a frame.
|
||||
if frame_time >= FPS_LOG_INTERVAL:
|
||||
self.last_frame_time = current_time
|
||||
return
|
||||
|
||||
self.frame_times.append(frame_time)
|
||||
|
||||
|
||||
# Keep only last 100 frames for average
|
||||
if len(self.frame_times) > 100:
|
||||
self.frame_times.pop(0)
|
||||
@@ -1141,17 +1017,21 @@ class ScrollHelper:
|
||||
# duplicate and a 21ms double-wait mean exactly 10ms). Chasing scroll
|
||||
# judder needs the tail, so keep the window and report percentiles.
|
||||
self._window.append(frame_time)
|
||||
|
||||
|
||||
# Log FPS every 5 seconds to avoid spam
|
||||
if current_time - self.last_fps_log_time >= 5.0:
|
||||
self.logger.info(
|
||||
"Scroll frame stats - %s",
|
||||
format_frame_stats(self._window or [frame_time]),
|
||||
)
|
||||
if current_time - self.last_fps_log_time >= FPS_LOG_INTERVAL:
|
||||
# An empty window means every sample in this interval was dropped
|
||||
# as an idle gap. There is nothing to report, and reporting the
|
||||
# gap itself is the bug above.
|
||||
if self._window:
|
||||
self.logger.info(
|
||||
"Scroll frame stats - %s",
|
||||
format_frame_stats(self._window),
|
||||
)
|
||||
self.last_fps_log_time = current_time
|
||||
self.frame_count = 0
|
||||
self._window = []
|
||||
|
||||
|
||||
self.last_frame_time = current_time
|
||||
self.frame_count += 1
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ from typing import Callable, Optional
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from src.display_geometry import DEFAULT_CHAIN_LENGTH
|
||||
|
||||
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
|
||||
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
|
||||
_RAW_MAGIC = b'SYNC_RAW'
|
||||
@@ -194,7 +196,7 @@ class DisplaySyncManager:
|
||||
local_cols = hw.get("cols", 64)
|
||||
peer_rows = int(msg.get("rows", 0))
|
||||
peer_cols = int(msg.get("cols", 0))
|
||||
peer_chain = int(msg.get("chain", 1))
|
||||
peer_chain = int(msg.get("chain", DEFAULT_CHAIN_LENGTH))
|
||||
|
||||
compatible = peer_rows == local_rows and peer_cols == local_cols
|
||||
|
||||
@@ -589,7 +591,7 @@ class DisplaySyncManager:
|
||||
"t": "hello",
|
||||
"rows": hw.get("rows", 32),
|
||||
"cols": hw.get("cols", 64),
|
||||
"chain": hw.get("chain_length", 1),
|
||||
"chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||
}).encode("utf-8")
|
||||
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
||||
dest = ("<broadcast>", self.port)
|
||||
@@ -660,7 +662,7 @@ class DisplaySyncManager:
|
||||
"port": self.port,
|
||||
"local_rows": hw.get("rows", 32),
|
||||
"local_cols": hw.get("cols", 64),
|
||||
"local_chain": hw.get("chain_length", 1),
|
||||
"local_chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||
}
|
||||
|
||||
if self.role == SyncRole.STANDALONE:
|
||||
|
||||
@@ -119,6 +119,15 @@ class DisplayController:
|
||||
# validator.raise_on_errors() # Uncomment to fail fast on errors
|
||||
except Exception as e:
|
||||
logger.warning(f"Startup validation could not be completed: {e}")
|
||||
|
||||
# Automatic updates need their health-check units, and this is the
|
||||
# one root process running project code, so it installs them while
|
||||
# automatic updates are on. See src/auto_update_setup.py.
|
||||
try:
|
||||
from src.auto_update_setup import ensure_update_helper
|
||||
ensure_update_helper(self.config)
|
||||
except Exception as e:
|
||||
logger.warning("Automatic update setup could not be completed: %s", e)
|
||||
|
||||
config_time = time.time()
|
||||
self.display_manager = DisplayManager(self.config)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Display size from config: the one computation every caller shares.
|
||||
|
||||
``DisplayManager`` sizes its canvas from ``display.hardware`` plus
|
||||
``display.double_sided``. The web preview, the Starlark magnify default and
|
||||
the multi-display sync handshake used to re-derive that size themselves,
|
||||
each with its own defaults (``chain_length`` fell back to 2 in one place and
|
||||
1 in three others) and none of them applying double-sided mode. They now all
|
||||
call this module.
|
||||
|
||||
Kept free of hardware imports on purpose: the web interface imports it, and
|
||||
``display_manager`` pulls in ``rgbmatrix``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Match config/config.template.json's display.hardware block.
|
||||
DEFAULT_ROWS = 32
|
||||
DEFAULT_COLS = 64
|
||||
DEFAULT_CHAIN_LENGTH = 2
|
||||
DEFAULT_PARALLEL = 1
|
||||
|
||||
|
||||
def _display(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
|
||||
# A hand-edited config.json can hold anything here; treat a non-mapping
|
||||
# like a missing block so callers get the defaults, not AttributeError.
|
||||
display = (config or {}).get('display')
|
||||
return display if isinstance(display, Mapping) else {}
|
||||
|
||||
|
||||
def _hardware(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
|
||||
hw = _display(config).get('hardware')
|
||||
return hw if isinstance(hw, Mapping) else {}
|
||||
|
||||
|
||||
def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
|
||||
"""Width and height of the whole panel chain, in pixels.
|
||||
|
||||
``cols * chain_length`` by ``rows * parallel``. Raises ``ValueError`` or
|
||||
``TypeError`` on a non-numeric value, as ``DisplayManager`` does; callers
|
||||
decide their own fallback.
|
||||
|
||||
A non-finite value (``Infinity``, which Python's JSON parser accepts in a
|
||||
hand-edited config.json) raises ``ValueError`` too, not ``OverflowError``,
|
||||
so every caller's existing fallback catches it.
|
||||
"""
|
||||
hw = _hardware(config)
|
||||
try:
|
||||
rows = int(hw.get('rows', DEFAULT_ROWS))
|
||||
cols = int(hw.get('cols', DEFAULT_COLS))
|
||||
chain_length = int(hw.get('chain_length', DEFAULT_CHAIN_LENGTH))
|
||||
parallel = int(hw.get('parallel', DEFAULT_PARALLEL))
|
||||
except OverflowError as e:
|
||||
raise ValueError(f"display.hardware size is not finite: {e}") from e
|
||||
return max(1, cols * chain_length), max(1, rows * parallel)
|
||||
|
||||
|
||||
def resolve_double_sided(physical_width: int, physical_height: int,
|
||||
ds_config: Dict[str, Any],
|
||||
quiet: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""Validate the ``display.double_sided`` config against the physical size.
|
||||
|
||||
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
|
||||
feature is enabled and the physical panel divides evenly into ``copies``
|
||||
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
|
||||
config is logged and disabled rather than raised — a misconfigured panel
|
||||
should still light up.
|
||||
|
||||
Only pixels are checked, not whole panels: ``chain_length`` and
|
||||
``parallel`` don't say which axis a panel lies on once an orientation
|
||||
``Rotate:`` or U-mapper ``pixel_mapper_config`` rearranges the chain.
|
||||
|
||||
``quiet`` suppresses the log lines, for callers that run on every web
|
||||
request and would otherwise repeat them on each poll.
|
||||
"""
|
||||
def _log(level, *args):
|
||||
if not quiet:
|
||||
logger.log(level, *args)
|
||||
|
||||
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
|
||||
return None
|
||||
|
||||
copies = ds_config.get('copies', 2)
|
||||
if not isinstance(copies, int) or copies < 2:
|
||||
_log(logging.WARNING,
|
||||
"double_sided: 'copies' must be an integer >= 2 (got %r); "
|
||||
"disabling double-sided mode", copies)
|
||||
return None
|
||||
|
||||
axis = ds_config.get('axis', 'horizontal')
|
||||
if axis not in ('horizontal', 'vertical'):
|
||||
_log(logging.WARNING,
|
||||
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
|
||||
"(got %r); defaulting to 'horizontal'", axis)
|
||||
axis = 'horizontal'
|
||||
|
||||
# Horizontal splits the chain (panels side by side); vertical splits the
|
||||
# parallel outputs (panels stacked). The split axis must divide evenly.
|
||||
if axis == 'horizontal':
|
||||
if physical_width % copies != 0:
|
||||
_log(logging.WARNING,
|
||||
"double_sided: physical width %d is not divisible by copies "
|
||||
"%d; disabling double-sided mode", physical_width, copies)
|
||||
return None
|
||||
logical_width = physical_width // copies
|
||||
logical_height = physical_height
|
||||
else:
|
||||
if physical_height % copies != 0:
|
||||
_log(logging.WARNING,
|
||||
"double_sided: physical height %d is not divisible by copies "
|
||||
"%d; disabling double-sided mode", physical_height, copies)
|
||||
return None
|
||||
logical_width = physical_width
|
||||
logical_height = physical_height // copies
|
||||
|
||||
_log(logging.INFO,
|
||||
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
|
||||
"tiled across physical %dx%d", copies, axis, logical_width,
|
||||
logical_height, physical_width, physical_height)
|
||||
return {
|
||||
'copies': copies,
|
||||
'axis': axis,
|
||||
'logical_width': logical_width,
|
||||
'logical_height': logical_height,
|
||||
}
|
||||
|
||||
|
||||
def logical_size(config: Optional[Mapping[str, Any]],
|
||||
quiet: bool = True) -> Tuple[int, int]:
|
||||
"""The size plugins draw at and the web preview shows.
|
||||
|
||||
The physical size, divided by ``double_sided.copies`` along its axis when
|
||||
double-sided mode is enabled and valid — the same answer
|
||||
``DisplayManager.width``/``height`` give.
|
||||
"""
|
||||
width, height = physical_size(config)
|
||||
ds = resolve_double_sided(width, height,
|
||||
_display(config).get('double_sided') or {},
|
||||
quiet=quiet)
|
||||
if ds is not None:
|
||||
return ds['logical_width'], ds['logical_height']
|
||||
return width, height
|
||||
+13
-67
@@ -35,6 +35,10 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
|
||||
from src.display_geometry import (
|
||||
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||
physical_size, resolve_double_sided,
|
||||
)
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
@@ -123,62 +127,10 @@ class _LogicalMatrix:
|
||||
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
||||
|
||||
|
||||
def _resolve_double_sided(physical_width: int, physical_height: int,
|
||||
ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Validate the ``display.double_sided`` config against the physical size.
|
||||
|
||||
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
|
||||
feature is enabled and the physical panel divides evenly into ``copies``
|
||||
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
|
||||
config is logged and disabled rather than raised — a misconfigured panel
|
||||
should still light up.
|
||||
"""
|
||||
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
|
||||
return None
|
||||
|
||||
copies = ds_config.get('copies', 2)
|
||||
if not isinstance(copies, int) or copies < 2:
|
||||
logger.warning(
|
||||
"double_sided: 'copies' must be an integer >= 2 (got %r); "
|
||||
"disabling double-sided mode", copies)
|
||||
return None
|
||||
|
||||
axis = ds_config.get('axis', 'horizontal')
|
||||
if axis not in ('horizontal', 'vertical'):
|
||||
logger.warning(
|
||||
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
|
||||
"(got %r); defaulting to 'horizontal'", axis)
|
||||
axis = 'horizontal'
|
||||
|
||||
# Horizontal splits the chain (panels side by side); vertical splits the
|
||||
# parallel outputs (panels stacked). The split axis must divide evenly.
|
||||
if axis == 'horizontal':
|
||||
if physical_width % copies != 0:
|
||||
logger.warning(
|
||||
"double_sided: physical width %d is not divisible by copies "
|
||||
"%d; disabling double-sided mode", physical_width, copies)
|
||||
return None
|
||||
logical_width = physical_width // copies
|
||||
logical_height = physical_height
|
||||
else:
|
||||
if physical_height % copies != 0:
|
||||
logger.warning(
|
||||
"double_sided: physical height %d is not divisible by copies "
|
||||
"%d; disabling double-sided mode", physical_height, copies)
|
||||
return None
|
||||
logical_width = physical_width
|
||||
logical_height = physical_height // copies
|
||||
|
||||
logger.info(
|
||||
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
|
||||
"tiled across physical %dx%d", copies, axis, logical_width,
|
||||
logical_height, physical_width, physical_height)
|
||||
return {
|
||||
'copies': copies,
|
||||
'axis': axis,
|
||||
'logical_width': logical_width,
|
||||
'logical_height': logical_height,
|
||||
}
|
||||
# Moved to src/display_geometry.py so the web preview, Starlark magnify and
|
||||
# sync handshake compute the display size exactly as DisplayManager does
|
||||
# without importing rgbmatrix. Aliased here for existing callers.
|
||||
_resolve_double_sided = resolve_double_sided
|
||||
|
||||
|
||||
class DisplayManager:
|
||||
@@ -327,10 +279,10 @@ class DisplayManager:
|
||||
runtime_config = self.config.get('display', {}).get('runtime', {})
|
||||
|
||||
# Basic hardware settings
|
||||
options.rows = hardware_config.get('rows', 32)
|
||||
options.cols = hardware_config.get('cols', 64)
|
||||
options.chain_length = hardware_config.get('chain_length', 2)
|
||||
options.parallel = hardware_config.get('parallel', 1)
|
||||
options.rows = hardware_config.get('rows', DEFAULT_ROWS)
|
||||
options.cols = hardware_config.get('cols', DEFAULT_COLS)
|
||||
options.chain_length = hardware_config.get('chain_length', DEFAULT_CHAIN_LENGTH)
|
||||
options.parallel = hardware_config.get('parallel', DEFAULT_PARALLEL)
|
||||
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
|
||||
|
||||
# Performance and stability settings
|
||||
@@ -421,13 +373,7 @@ class DisplayManager:
|
||||
# Create a fallback image for web preview using configured dimensions when available
|
||||
self.matrix = None
|
||||
try:
|
||||
hardware_config = self.config.get('display', {}).get('hardware', {}) if self.config else {}
|
||||
rows = int(hardware_config.get('rows', 32))
|
||||
cols = int(hardware_config.get('cols', 64))
|
||||
chain_length = int(hardware_config.get('chain_length', 2))
|
||||
parallel = int(hardware_config.get('parallel', 1))
|
||||
fallback_width = max(1, cols * chain_length)
|
||||
fallback_height = max(1, rows * parallel)
|
||||
fallback_width, fallback_height = physical_size(self.config)
|
||||
# Mirror double-sided in fallback so the preview shows one screen.
|
||||
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
|
||||
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
|
||||
|
||||
+46
-3
@@ -761,14 +761,54 @@ def _adopt_handwritten_block(schema: Dict[str, Any],
|
||||
customization.setdefault('x-widget', 'style-editor')
|
||||
props = customization['properties']
|
||||
|
||||
layout_block = props.get('layout')
|
||||
layout_fields = (layout_block.get('properties')
|
||||
if isinstance(layout_block, dict) else None)
|
||||
layout_fields = layout_fields if isinstance(layout_fields, dict) else {}
|
||||
|
||||
for key in element_keys:
|
||||
_upgrade_font_property(props[key])
|
||||
# Marked like a declared element, so consumers can tell the style
|
||||
# blocks from whatever else the plugin keeps under customization.
|
||||
# Football's block also holds favorite_result_colors, which is a
|
||||
# feature with its own fields -- without this the editor treats it
|
||||
# as an element and every row grows an "enabled"/"win color" column.
|
||||
props[key]['x-style-managed'] = True
|
||||
# Where this element's offsets live, resolved through the same alias
|
||||
# map the renderer reads them with. The two blocks were never named
|
||||
# alike -- football styles score_text but positions score -- and the
|
||||
# editor matched them by exact name, so it drew one offset in eleven
|
||||
# and the rest had no control anywhere. Recording the answer here
|
||||
# keeps the alias rules in one place instead of a JavaScript copy.
|
||||
# Only an object-shaped entry (x_offset/y_offset/...) can hold an
|
||||
# element's offsets. A leaf directly under layout -- a show_logo
|
||||
# toggle -- is its own control, so it is never claimed by a row and
|
||||
# always gets a position row of its own.
|
||||
layout_key = next((name for name in alias_keys(key)
|
||||
if isinstance(layout_fields.get(name), dict)
|
||||
and isinstance(layout_fields[name].get('properties'),
|
||||
dict)), None)
|
||||
if layout_key is not None:
|
||||
props[key]['x-layout-key'] = layout_key
|
||||
|
||||
if layout_fields:
|
||||
# Positions are listed in the order the plugin declared them. Flask's
|
||||
# JSON provider sorts keys, which would put the logos after the date.
|
||||
layout_block.setdefault('x-propertyOrder', list(layout_fields))
|
||||
|
||||
modes = customization.get('x-style-modes')
|
||||
if isinstance(modes, list) and modes:
|
||||
props.setdefault('modes',
|
||||
_modes_block_from_properties(props, element_keys,
|
||||
modes))
|
||||
mode_blocks = props['modes'].get('properties') if isinstance(
|
||||
props.get('modes'), dict) else None
|
||||
for mode_block in (mode_blocks or {}).values():
|
||||
mode_layout = ((mode_block or {}).get('properties') or {}).get('layout')
|
||||
if (isinstance(mode_layout, dict)
|
||||
and isinstance(mode_layout.get('properties'), dict)):
|
||||
mode_layout.setdefault('x-propertyOrder',
|
||||
list(mode_layout['properties']))
|
||||
|
||||
# Stated explicitly because the config form serialises the schema with
|
||||
# Flask's JSON provider, which sorts keys -- without this the elements
|
||||
@@ -904,11 +944,14 @@ def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||
return None
|
||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||
try:
|
||||
rgb = tuple(int(c) for c in value)
|
||||
# Clamped, not rejected. The readers this replaced clamped
|
||||
# (sports_card.coerce_rgb), and the eight scoreboards' own tests
|
||||
# pin it: a configured [999, -5, 20] is a typo'd bright red, and
|
||||
# answering "unusable, take the default" turned it white instead.
|
||||
rgb = tuple(max(0, min(255, int(c))) for c in value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if all(0 <= c <= 255 for c in rgb):
|
||||
return rgb # type: ignore[return-value]
|
||||
return rgb # type: ignore[return-value]
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1338,9 +1338,16 @@ 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)
|
||||
# Visual skins share the registry. _install_skin_from_info can put one
|
||||
# in skins/, but no current scoreboard plugin renders skins, so the
|
||||
# store refuses them rather than installing something that does
|
||||
# nothing (docs/SKIN_SYSTEM.md). Manual installs under skins/ and
|
||||
# uninstall_skin are unaffected.
|
||||
if (plugin_info.get('type') or 'plugin') == 'skin':
|
||||
from src.skin_system import SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE
|
||||
if not SKINS_RENDER_SUPPORTED:
|
||||
self.logger.error(f"Not installing skin {plugin_id}: {SKINS_UNSUPPORTED_MESSAGE}")
|
||||
return False
|
||||
return self._install_skin_from_info(plugin_id, plugin_info, branch)
|
||||
|
||||
repo_url = plugin_info.get('repo')
|
||||
|
||||
@@ -6,7 +6,19 @@ 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 (
|
||||
# Skins are not offered to users yet. The only render hook is
|
||||
# SportsCore._render_game in src/base_classes/sports/core.py, and none of the
|
||||
# current scoreboard plugins (monorepo or third-party) build on
|
||||
# src.base_classes, so a selected skin never draws. The web UI and store
|
||||
# read these instead of offering install/selection; stored "skin" config
|
||||
# values still load and save. See docs/SKIN_SYSTEM.md.
|
||||
SKINS_RENDER_SUPPORTED = False
|
||||
SKINS_UNSUPPORTED_MESSAGE = (
|
||||
"Skins aren't supported yet: the current scoreboard plugins don't render "
|
||||
"them. Installed skins and saved skin settings are kept but have no effect."
|
||||
)
|
||||
|
||||
from src.skin_system.skin_base import ( # noqa: E402
|
||||
SKIN_API_VERSION,
|
||||
VIEW_MODEL_VERSION,
|
||||
ScoreboardSkin,
|
||||
|
||||
@@ -81,6 +81,8 @@ class StartupValidator:
|
||||
_UNITS = (
|
||||
("systemd/ledmatrix.service", "/etc/systemd/system/ledmatrix.service"),
|
||||
("systemd/ledmatrix-web.service", "/etc/systemd/system/ledmatrix-web.service"),
|
||||
("systemd/ledmatrix-update-verify.service", "/etc/systemd/system/ledmatrix-update-verify.service"),
|
||||
("systemd/ledmatrix-update-verify.path", "/etc/systemd/system/ledmatrix-update-verify.path"),
|
||||
)
|
||||
|
||||
def _validate_systemd_units(self) -> None:
|
||||
|
||||
+39
-1
@@ -714,9 +714,24 @@ class WiFiManager:
|
||||
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
|
||||
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
|
||||
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
|
||||
# Written by the web process while connect_to_network runs. Joining a network
|
||||
# from the setup AP takes the AP down first, and the monitor daemon (a separate
|
||||
# process) would otherwise see "disconnected" on its next tick and bring the
|
||||
# AP straight back up mid-connect.
|
||||
_CONNECT_IN_PROGRESS_FLAG_PATH = Path("/tmp/ledmatrix_wifi_connect_in_progress") # nosec B108 - process-specific named file; device is single-user RPi
|
||||
# Longest a connect can legitimately take (AP teardown, nmcli's 30s timeout,
|
||||
# verification, restore). An older flag was left by a process that died.
|
||||
_CONNECT_FLAG_MAX_AGE_SECONDS = 180
|
||||
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
|
||||
_startup_cleanup_done: bool = False
|
||||
|
||||
def _connect_in_progress(self) -> bool:
|
||||
try:
|
||||
age = time.time() - self._CONNECT_IN_PROGRESS_FLAG_PATH.stat().st_mtime
|
||||
except OSError:
|
||||
return False
|
||||
return age < self._CONNECT_FLAG_MAX_AGE_SECONDS
|
||||
|
||||
def _validate_ap_config(self) -> Tuple[str, int]:
|
||||
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||
ssid = str(self.config.get("ap_ssid", DEFAULT_AP_SSID))
|
||||
@@ -1270,6 +1285,21 @@ class WiFiManager:
|
||||
logger.warning("Rejected WiFi connect request: %s", error)
|
||||
return False, error
|
||||
|
||||
try:
|
||||
self._CONNECT_IN_PROGRESS_FLAG_PATH.touch()
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not create connect-in-progress flag: {e}")
|
||||
try:
|
||||
return self._connect_validated(ssid, password)
|
||||
finally:
|
||||
try:
|
||||
self._CONNECT_IN_PROGRESS_FLAG_PATH.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
# Never mask the connect result; the age limit retires the flag.
|
||||
logger.warning(f"Could not remove connect-in-progress flag: {e}")
|
||||
|
||||
def _connect_validated(self, ssid: str, password: str) -> Tuple[bool, str]:
|
||||
"""connect_to_network after validation, with the in-progress flag held."""
|
||||
# Save current connection info for failsafe restoration
|
||||
original_connection = None
|
||||
original_ssid = None
|
||||
@@ -2690,7 +2720,15 @@ address=/detectportal.firefox.com/192.168.4.1
|
||||
if self._disconnected_checks > 0:
|
||||
logger.debug("Network connected, resetting disconnected check counter")
|
||||
self._disconnected_checks = 0
|
||||
|
||||
|
||||
if self._connect_in_progress():
|
||||
# A connect has just taken the AP down on purpose. Leave the
|
||||
# radio alone, and restart the grace period so a failed attempt
|
||||
# (which re-enables the AP itself) isn't followed by a flap.
|
||||
logger.debug("WiFi connect in progress; skipping AP management this check")
|
||||
self._disconnected_checks = 0
|
||||
return False
|
||||
|
||||
# Only enable AP if we've had enough consecutive disconnected checks
|
||||
should_have_ap = (auto_enable and
|
||||
is_disconnected and
|
||||
|
||||
+12
-1
@@ -14,6 +14,16 @@ This directory contains systemd service unit files for LEDMatrix services.
|
||||
- Starts automatically on boot if `web_display_autostart` is enabled
|
||||
- Uses `scripts/utils/start_web_conditionally.py`
|
||||
|
||||
- **`ledmatrix-update-verify.service`** / **`.path`** - Automatic update health check and rollback
|
||||
- The path unit starts the service when the web interface creates
|
||||
`data/auto_update_verify.request` after a weekly automatic update
|
||||
- Installed by the installers, or by the display service when automatic
|
||||
updates are turned on in the web UI (`src/auto_update_setup.py`)
|
||||
- Restarts the services, checks they stay up, and otherwise resets to the
|
||||
previous commit and its dependencies
|
||||
- Runs `data/auto_update_verifier.py`, a copy of
|
||||
`scripts/utils/auto_update_verify.py` taken before the update
|
||||
|
||||
- **`ledmatrix-wifi-monitor.service`** - WiFi monitor daemon service
|
||||
- Monitors WiFi/Ethernet connectivity
|
||||
- Automatically enables/disables access point mode
|
||||
@@ -43,7 +53,8 @@ This directory contains systemd service unit files for LEDMatrix services.
|
||||
|
||||
These service files are installed by the installation scripts in `scripts/install/`:
|
||||
- `install_service.sh` installs `ledmatrix.service`
|
||||
- `install_web_service.sh` installs `ledmatrix-web.service`
|
||||
- `install_web_service.sh` installs `ledmatrix-web.service` and the
|
||||
`ledmatrix-update-verify` service and path units
|
||||
- `install_wifi_monitor.sh` installs `ledmatrix-wifi-monitor.service`
|
||||
- `install_dns_fix.sh` installs `ledmatrix-dns-fix.service` (opt-in, not run
|
||||
by the normal installer)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# This is a template. Do not install it directly -- it is installed with
|
||||
# ledmatrix-update-verify.service by scripts/install/install_web_service.sh,
|
||||
# or by the display service when automatic updates are turned on
|
||||
# (src/auto_update_setup.py). Substitutes:
|
||||
# __PROJECT_ROOT_DIR__ -> the directory the project is installed in
|
||||
#
|
||||
# Starts the automatic update health check when the web interface asks for it
|
||||
# by creating data/auto_update_verify.request. A path unit rather than a
|
||||
# sudoers rule: the web interface needs no extra privilege to trigger it, and
|
||||
# the service it starts runs as the web interface's own user.
|
||||
|
||||
[Unit]
|
||||
Description=Watch for LED Matrix automatic update health check requests
|
||||
|
||||
[Path]
|
||||
PathExists=__PROJECT_ROOT_DIR__/data/auto_update_verify.request
|
||||
Unit=ledmatrix-update-verify.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,31 @@
|
||||
# This is a template. Do not install it directly -- run
|
||||
# scripts/install/install_web_service.sh (or install_service.sh); the display
|
||||
# service also installs it when automatic updates are turned on
|
||||
# (src/auto_update_setup.py). Substitutes:
|
||||
# __PROJECT_ROOT_DIR__ -> the directory the project is installed in
|
||||
# __USER__ -> the user the web interface runs as
|
||||
#
|
||||
# Health check and rollback for the web interface's weekly automatic update
|
||||
# (web_interface/auto_update.py). A separate unit because the check restarts
|
||||
# ledmatrix-web.service, which would kill it if it ran there.
|
||||
#
|
||||
# Never enabled itself: ledmatrix-update-verify.path starts it when the web
|
||||
# interface creates data/auto_update_verify.request. It runs as the web
|
||||
# interface's own user, and ExecStart points at the copy the updater makes
|
||||
# before pulling, so the update being checked cannot replace its checker.
|
||||
# Command-line paths are quoted so an install folder with spaces still works.
|
||||
|
||||
[Unit]
|
||||
Description=LED Matrix automatic update health check and rollback
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=__USER__
|
||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||
# Consume the request first, so the path unit cannot start this again.
|
||||
ExecStartPre=/bin/rm -f "__PROJECT_ROOT_DIR__/data/auto_update_verify.request"
|
||||
ExecStart=/usr/bin/python3 "__PROJECT_ROOT_DIR__/data/auto_update_verifier.py" "__PROJECT_ROOT_DIR__"
|
||||
TimeoutStartSec=30min
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=ledmatrix-update-verify
|
||||
Vendored
+17
@@ -841,6 +841,23 @@
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/auto-update",
|
||||
"api_v3.get_auto_update_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/auto-update/dismiss",
|
||||
"api_v3.dismiss_auto_update_alert",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/check-update",
|
||||
"api_v3.check_for_update",
|
||||
|
||||
@@ -36,6 +36,9 @@ nothing is listening, so it stays useful in a bare checkout.
|
||||
|---|---|---|
|
||||
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
|
||||
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
|
||||
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
||||
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
||||
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
|
||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
||||
|
||||
+3
-1
@@ -15,7 +15,9 @@ const fs = require('fs');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||
'unit/test_html_escaping.js'];
|
||||
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
||||
'unit/test_style_editor_layout_leaf_columns.js',
|
||||
'unit/test_style_editor_layout_leaf_collision.js'];
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||
'dom/test_tools_sections.js'];
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Regression test for how style-editor.js decides which rows to draw,
|
||||
// extracted verbatim from the shipped widget (own/ownObj/elementKeys/
|
||||
// styleRows/positionRows have no DOM dependency) so the test can't drift from
|
||||
// the real implementation.
|
||||
//
|
||||
// render() claims the whole `layout` child of a customization schema as the
|
||||
// widget's own -- removing it from the generic fallback renderer entirely,
|
||||
// because posting the same offset twice from two controls is worse than
|
||||
// owning slightly too much. That is only safe when every entry under `layout`
|
||||
// gets exactly one row:
|
||||
//
|
||||
// * styleRows(): one per style element, paired with the layout entry that
|
||||
// holds its offsets. The pairing is not by exact name -- a hand-written
|
||||
// schema styles 'score_text' but positions 'score' -- so core records it
|
||||
// as x-layout-key, resolved through the same alias map the renderer reads
|
||||
// offsets with.
|
||||
// * positionRows(): one per layout entry no style element claimed -- a logo,
|
||||
// a timeout indicator, a possession arrow, or a bare leaf like a
|
||||
// show_logo toggle.
|
||||
//
|
||||
// This contract replaced an earlier one in which elementKeys() itself
|
||||
// appended layout-only keys. That compared names exactly, so an aliased entry
|
||||
// ('score' for 'score_text') was listed a second time and its offsets got two
|
||||
// controls posting the same field.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
|
||||
const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8');
|
||||
const start = src.indexOf('function own(obj, key)');
|
||||
const endMark = 'function control(opts)';
|
||||
const end = src.indexOf(endMark);
|
||||
if (start === -1 || end === -1) {
|
||||
console.error('FAIL: could not locate own()..positionRows() in style-editor.js -- slice markers need updating');
|
||||
process.exit(1);
|
||||
}
|
||||
eval(src.slice(start, end)); // defines own/ownObj/elementKeys/styleRows/positionRows
|
||||
|
||||
let failures = 0;
|
||||
function ok(desc, cond) {
|
||||
if (cond) { console.log(`ok - ${desc}`); }
|
||||
else { console.log(`FAIL - ${desc}`); failures++; }
|
||||
}
|
||||
|
||||
function layoutKeysOf(rows) { return rows.map(function (r) { return r.layoutKey; }); }
|
||||
|
||||
// score_text is styled and positioned under an alias; odds_text has no
|
||||
// offsets at all; possession is positioned but never styled; show_logo is a
|
||||
// bare leaf under layout.
|
||||
const schema = {
|
||||
properties: {
|
||||
score_text: {
|
||||
type: 'object', 'x-style-managed': true, 'x-layout-key': 'score',
|
||||
properties: { font: { type: 'string' }, text_color: { type: 'array' } },
|
||||
},
|
||||
odds_text: {
|
||||
type: 'object', 'x-style-managed': true,
|
||||
properties: { font: { type: 'string' } },
|
||||
},
|
||||
layout: {
|
||||
type: 'object',
|
||||
'x-propertyOrder': ['score', 'possession', 'show_logo'],
|
||||
properties: {
|
||||
score: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
|
||||
possession: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
|
||||
show_logo: { type: 'boolean', default: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const keys = elementKeys(schema);
|
||||
ok('elementKeys lists the style elements', keys.join(',') === 'score_text,odds_text');
|
||||
ok('layout and modes containers are never treated as elements',
|
||||
keys.indexOf('layout') === -1 && keys.indexOf('modes') === -1);
|
||||
|
||||
const styled = styleRows(schema);
|
||||
ok('a style element is paired with the layout entry core resolved for it',
|
||||
styled[0].key === 'score_text' && styled[0].layoutKey === 'score');
|
||||
ok('a style element with no offsets claims nothing', styled[1].layoutKey === null);
|
||||
|
||||
const positions = positionRows(schema);
|
||||
ok('a layout-only object entry gets a position row',
|
||||
layoutKeysOf(positions).indexOf('possession') !== -1);
|
||||
ok('a layout-only leaf gets a position row too',
|
||||
layoutKeysOf(positions).indexOf('show_logo') !== -1);
|
||||
ok('an entry claimed through an alias is not listed again as a position',
|
||||
layoutKeysOf(positions).indexOf('score') === -1);
|
||||
ok('positions follow the declared order',
|
||||
layoutKeysOf(positions).join(',') === 'possession,show_logo');
|
||||
|
||||
const every = layoutKeysOf(styled).filter(Boolean).concat(layoutKeysOf(positions));
|
||||
ok('every layout entry gets exactly one row',
|
||||
every.length === new Set(every).size
|
||||
&& every.slice().sort().join(',') === ['possession', 'score', 'show_logo'].join(','));
|
||||
|
||||
// A schema with no layout block at all must behave exactly as before --
|
||||
// the overwhelmingly common case must not gain a phantom row.
|
||||
const noLayout = {
|
||||
properties: {
|
||||
score_text: {
|
||||
type: 'object', 'x-style-managed': true,
|
||||
properties: { font: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
};
|
||||
ok('a schema without a layout block has no positions',
|
||||
elementKeys(noLayout).join(',') === 'score_text' && positionRows(noLayout).length === 0);
|
||||
|
||||
// Without an annotation (the compact declaration, or a schema that never went
|
||||
// through expansion) the two blocks share one key.
|
||||
const unannotated = {
|
||||
properties: {
|
||||
home_logo: { type: 'object', 'x-style-managed': true, properties: {} },
|
||||
layout: {
|
||||
type: 'object',
|
||||
properties: { home_logo: { type: 'object', properties: { x_offset: {}, scale: {} } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
ok('an exact-name layout entry is claimed without an annotation',
|
||||
styleRows(unannotated)[0].layoutKey === 'home_logo'
|
||||
&& positionRows(unannotated).length === 0);
|
||||
|
||||
if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
|
||||
console.log('\nall checks passed');
|
||||
@@ -0,0 +1,76 @@
|
||||
// Regression test for style-editor.js's columnsFor(), extracted verbatim
|
||||
// from the shipped widget (see test_style_editor_layout_leaf_columns.js for
|
||||
// the base case this builds on).
|
||||
//
|
||||
// A layout-only leaf key (e.g. a bare "scale" toggle directly under
|
||||
// `layout`, no x/y object underneath) used to be keyed in the internal
|
||||
// `seen` map by its own bare field name. If some *other* element's style
|
||||
// block, or another element's layout axis block, happened to declare a
|
||||
// sub-field with that exact same name (e.g. "scale" is also a real
|
||||
// COLUMN_ORDER axis name most elements use), the `!seen.has(key)` guard
|
||||
// skipped creating the leaf's column -- it silently reused the existing
|
||||
// 'element'/'layout' column instead. That column's row binding in
|
||||
// elementRow() then looked the field up under the wrong schema location for
|
||||
// the leaf's own row, found nothing, and rendered a blank cell: the same
|
||||
// "silently disappears" bug the leaf-column fix was meant to close, just
|
||||
// reached through a name collision instead of a missing column altogether.
|
||||
// This pins the fix: layout-leaf columns are keyed under a namespaced id so
|
||||
// they can never be shadowed by an unrelated column sharing their name.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
|
||||
const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8');
|
||||
const start = src.indexOf('function own(obj, key)');
|
||||
const endMark = 'function control(opts)';
|
||||
const end = src.indexOf(endMark);
|
||||
if (start === -1 || end === -1) {
|
||||
console.error('FAIL: could not locate own()/columnsFor() in style-editor.js -- slice markers need updating');
|
||||
process.exit(1);
|
||||
}
|
||||
eval(src.slice(start, end)); // defines own/ownObj/elementKeys/columnsFor
|
||||
|
||||
let failures = 0;
|
||||
function ok(desc, cond) {
|
||||
if (cond) { console.log(`ok - ${desc}`); }
|
||||
else { console.log(`FAIL - ${desc}`); failures++; }
|
||||
}
|
||||
|
||||
// `possession` is a real style element whose layout axis block declares a
|
||||
// "scale" sub-field, which seeds seen.set('scale', 'layout'). `timeout_flag`
|
||||
// is layout-only and a bare leaf valued directly under layout, but its own
|
||||
// key is *also* "scale" -- an unrelated collision with that same name.
|
||||
const schema = {
|
||||
properties: {
|
||||
possession: {
|
||||
type: 'object',
|
||||
'x-style-managed': true,
|
||||
properties: { font: { type: 'string' } },
|
||||
},
|
||||
scale: {
|
||||
// The collision: a second element literally named "scale", entirely
|
||||
// unrelated to the layout-only leaf below sharing that string.
|
||||
type: 'boolean',
|
||||
},
|
||||
layout: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
possession: { type: 'object', properties: { scale: { type: 'number' } } },
|
||||
scale: { type: 'boolean', default: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const columns = columnsFor(schema);
|
||||
const leafColumns = columns.filter(function (c) { return c.where === 'layout-leaf'; });
|
||||
|
||||
ok('the colliding layout axis sub-field still gets its own column',
|
||||
columns.some(function (c) { return c.key === 'scale' && c.where === 'layout'; }));
|
||||
ok('the layout-only leaf key still gets its own column despite the name collision',
|
||||
leafColumns.length === 1 && leafColumns[0].key === 'scale');
|
||||
ok('the leaf column is distinct from the colliding element/layout column',
|
||||
columns.filter(function (c) { return c.key === 'scale'; }).length === 2);
|
||||
|
||||
if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
|
||||
console.log('\nall checks passed');
|
||||
@@ -0,0 +1,89 @@
|
||||
// Regression test for style-editor.js's columnsFor(), extracted verbatim
|
||||
// from the shipped widget (own/ownObj/elementKeys/columnsFor have no DOM
|
||||
// dependency) so the test can't drift from the real implementation.
|
||||
//
|
||||
// elementKeys() already appends a layout-only key that never got its own
|
||||
// top-level style block (see test_style_editor_element_keys.js), so table()
|
||||
// draws a row for it. But render() still claims the whole `layout` child as
|
||||
// the widget's own regardless of what that row actually shows -- and
|
||||
// columnsFor() only produced a column for a layout-only key whose own value
|
||||
// is an *object* with sub-fields (x_offset, y_offset, ...). A hand-written
|
||||
// schema can instead put a plain leaf value directly under layout -- a
|
||||
// "show_logo" toggle, a timeout indicator's on/off flag -- with no x/y
|
||||
// object underneath it. That key still got a row (data-element="show_logo"),
|
||||
// but zero columns ever matched it, so the row rendered with every cell
|
||||
// blank: no control, and no way back to the fallback since layout was
|
||||
// removed wholesale. This pins the fix: such a key gets its own
|
||||
// self-keyed 'layout-leaf' column.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
|
||||
const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8');
|
||||
const start = src.indexOf('function own(obj, key)');
|
||||
const endMark = 'function control(opts)';
|
||||
const end = src.indexOf(endMark);
|
||||
if (start === -1 || end === -1) {
|
||||
console.error('FAIL: could not locate own()/columnsFor() in style-editor.js -- slice markers need updating');
|
||||
process.exit(1);
|
||||
}
|
||||
eval(src.slice(start, end)); // defines own/ownObj/elementKeys/columnsFor
|
||||
|
||||
let failures = 0;
|
||||
function ok(desc, cond) {
|
||||
if (cond) { console.log(`ok - ${desc}`); }
|
||||
else { console.log(`FAIL - ${desc}`); failures++; }
|
||||
}
|
||||
|
||||
// score_text is a real style element; possession is layout-only but shaped
|
||||
// like an object (x/y); show_logo is layout-only and a bare leaf value.
|
||||
const schema = {
|
||||
properties: {
|
||||
score_text: {
|
||||
type: 'object',
|
||||
'x-style-managed': true,
|
||||
properties: { font: { type: 'string' }, text_color: { type: 'array' } },
|
||||
},
|
||||
layout: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
score_text: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
|
||||
possession: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
|
||||
show_logo: { type: 'boolean', default: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const columns = columnsFor(schema);
|
||||
const byKey = {};
|
||||
columns.forEach(function (c) { byKey[c.key] = c; });
|
||||
|
||||
ok('an element style field still gets its own column', byKey.font && byKey.font.where === 'element');
|
||||
ok('a layout-only object key still contributes its x/y columns',
|
||||
byKey.x_offset && byKey.x_offset.where === 'layout');
|
||||
ok('a layout-only leaf key gets a column keyed to itself',
|
||||
!!byKey.show_logo);
|
||||
ok('...marked as a layout-leaf column, not a shared sub-field',
|
||||
byKey.show_logo && byKey.show_logo.where === 'layout-leaf');
|
||||
ok('nothing is duplicated', columns.length === new Set(columns.map(function (c) { return c.key; })).size);
|
||||
|
||||
// A schema with no leaf-valued layout key must not gain a phantom column --
|
||||
// this is the common case (every existing scoreboard) and must be unaffected.
|
||||
const noLeaf = {
|
||||
properties: {
|
||||
score_text: {
|
||||
type: 'object', 'x-style-managed': true,
|
||||
properties: { font: { type: 'string' } },
|
||||
},
|
||||
layout: {
|
||||
type: 'object',
|
||||
properties: { score_text: { type: 'object', properties: { x_offset: {} } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
ok('a schema with only object-shaped layout keys gets no layout-leaf column',
|
||||
columnsFor(noLeaf).every(function (c) { return c.where !== 'layout-leaf'; }));
|
||||
|
||||
if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
|
||||
console.log('\nall checks passed');
|
||||
@@ -26,6 +26,9 @@ def wifi_manager():
|
||||
"""Patch WiFiManager where it is defined; yield the instance mock."""
|
||||
with patch("src.wifi_manager.WiFiManager") as cls:
|
||||
instance = MagicMock()
|
||||
# A bare MagicMock is truthy, which would send every connect down
|
||||
# the setup-AP background path.
|
||||
instance._is_ap_mode_active.return_value = False
|
||||
cls.return_value = instance
|
||||
yield instance
|
||||
|
||||
@@ -97,6 +100,129 @@ class TestConnect:
|
||||
assert "RuntimeError" in body["details"]
|
||||
|
||||
|
||||
class TestConnectThroughSetupAp:
|
||||
"""With the setup AP up, the phone making the request is connected through
|
||||
the very network the connect tears down. A synchronous answer can never
|
||||
arrive, so the route answers 202 first and connects in the background."""
|
||||
|
||||
URL = "/api/v3/wifi/connect"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ap_active(self, api_v3_module, wifi_manager, monkeypatch):
|
||||
from web_interface.blueprints.api_v3 import wifi as wifi_routes
|
||||
wifi_manager._is_ap_mode_active.return_value = True
|
||||
monkeypatch.setattr(wifi_routes, "_AP_HANDOFF_DELAY_SECONDS", 0)
|
||||
monkeypatch.setattr(wifi_routes, "_last_connect_attempt", None)
|
||||
self.spawned = []
|
||||
monkeypatch.setattr(wifi_routes, "_spawn", self.spawned.append)
|
||||
self.routes = wifi_routes
|
||||
|
||||
def _run_spawned(self):
|
||||
assert len(self.spawned) == 1
|
||||
self.spawned.pop()()
|
||||
|
||||
def test_answers_202_before_connecting(self, api_v3_client, wifi_manager):
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
|
||||
assert response.status_code == 202
|
||||
assert response.get_json()["status"] == "pending"
|
||||
wifi_manager.connect_to_network.assert_not_called()
|
||||
assert self.routes._last_connect_snapshot()["state"] == "pending"
|
||||
|
||||
def test_background_result_is_reported_by_status(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
|
||||
api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
|
||||
self._run_spawned()
|
||||
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "hunter22")
|
||||
|
||||
wifi_manager.config = {}
|
||||
wifi_manager.get_wifi_status.return_value = MagicMock(
|
||||
connected=True, ssid="HomeNet", ip_address="10.0.0.5", signal=70,
|
||||
ap_mode_active=False)
|
||||
attempt = api_v3_client.get("/api/v3/wifi/status").get_json()["data"]["last_connect_attempt"]
|
||||
assert attempt["ssid"] == "HomeNet"
|
||||
assert attempt["state"] == "success"
|
||||
assert "hunter22" not in str(attempt)
|
||||
|
||||
def test_wrong_password_is_flagged(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (
|
||||
False, "wrong_password: Secrets were required, but not provided")
|
||||
api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
|
||||
self._run_spawned()
|
||||
attempt = self.routes._last_connect_snapshot()
|
||||
assert attempt["state"] == "failed"
|
||||
assert attempt["error_type"] == "wrong_password"
|
||||
assert attempt["message"] == "Incorrect password for HomeNet"
|
||||
|
||||
def test_manager_exception_is_recorded_as_a_failure(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.side_effect = RuntimeError("nmcli vanished")
|
||||
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
self._run_spawned()
|
||||
attempt = self.routes._last_connect_snapshot()
|
||||
assert attempt["state"] == "failed"
|
||||
assert "RuntimeError" in attempt["message"]
|
||||
|
||||
def test_a_second_connect_while_one_is_pending_is_refused(self, api_v3_client, wifi_manager):
|
||||
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "OtherNet"})
|
||||
assert response.status_code == 409
|
||||
assert len(self.spawned) == 1
|
||||
|
||||
def test_pending_is_honoured_after_the_ap_has_gone_down(self, api_v3_client, wifi_manager):
|
||||
# The background attempt tears the AP down long before it finishes; a
|
||||
# second click must not slip through as a synchronous connect.
|
||||
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
wifi_manager._is_ap_mode_active.return_value = False
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "OtherNet"})
|
||||
assert response.status_code == 409
|
||||
wifi_manager.connect_to_network.assert_not_called()
|
||||
|
||||
def test_a_thread_that_fails_to_start_does_not_leave_pending_behind(
|
||||
self, api_v3_client, wifi_manager, monkeypatch):
|
||||
def refuse(_target):
|
||||
raise RuntimeError("can't start new thread")
|
||||
monkeypatch.setattr(self.routes, "_spawn", refuse)
|
||||
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 500
|
||||
assert self.routes._last_connect_snapshot() is None
|
||||
|
||||
monkeypatch.setattr(self.routes, "_spawn", self.spawned.append)
|
||||
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 202
|
||||
|
||||
|
||||
class TestConnectSerializedWithoutAp:
|
||||
"""Without the AP the route connects synchronously, but still one attempt
|
||||
at a time: a second connect_to_network would remove the in-progress flag
|
||||
the daemon relies on when the first finishes."""
|
||||
|
||||
URL = "/api/v3/wifi/connect"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_prior_attempt(self, api_v3_module, monkeypatch):
|
||||
from web_interface.blueprints.api_v3 import wifi as wifi_routes
|
||||
monkeypatch.setattr(wifi_routes, "_last_connect_attempt", None)
|
||||
self.routes = wifi_routes
|
||||
|
||||
def test_a_connect_during_a_synchronous_connect_is_refused(
|
||||
self, api_v3_client, wifi_manager):
|
||||
inner = []
|
||||
|
||||
def connect(ssid, password):
|
||||
if not inner:
|
||||
inner.append(api_v3_client.post(self.URL, json={"ssid": "OtherNet"}))
|
||||
return True, f"Connected to {ssid}"
|
||||
|
||||
wifi_manager.connect_to_network.side_effect = connect
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
assert response.status_code == 200
|
||||
assert inner[0].status_code == 409
|
||||
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "")
|
||||
assert self.routes._last_connect_snapshot()["state"] == "success"
|
||||
|
||||
def test_an_exception_does_not_leave_pending_behind(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.side_effect = RuntimeError("nmcli gone")
|
||||
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 500
|
||||
assert self.routes._last_connect_snapshot()["state"] == "failed"
|
||||
|
||||
|
||||
class TestDisconnect:
|
||||
URL = "/api/v3/wifi/disconnect"
|
||||
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
"""Weekly automatic updates (web_interface/auto_update.py).
|
||||
|
||||
The updater pulls code and restarts services unattended. These pin down that
|
||||
it runs only when asked and due, refuses to touch a checkout it could damage,
|
||||
never leaves new code running without a health check, and reports every
|
||||
failure instead of logging it and moving on.
|
||||
|
||||
Git runs for real against a throwaway origin/device clone pair; systemd, sudo
|
||||
and the health check service are faked.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from web_interface import auto_update as au # noqa: E402
|
||||
|
||||
# Local time is pinned to UTC below, so 03:00 is inside the quiet hours.
|
||||
NIGHT = datetime(2026, 9, 14, 3, 0, tzinfo=timezone.utc).timestamp()
|
||||
NOON = datetime(2026, 9, 14, 12, 0, tzinfo=timezone.utc).timestamp()
|
||||
ON = {'auto_update': {'enabled': True}}
|
||||
OFF = {'auto_update': {'enabled': False}}
|
||||
|
||||
needs_git = pytest.mark.skipif(shutil.which('git') is None, reason='git not installed')
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def local_is_utc(monkeypatch):
|
||||
"""Pin 'local time' so quiet-hours checks don't depend on the host zone."""
|
||||
monkeypatch.setattr(au, '_zone', lambda name: timezone.utc)
|
||||
|
||||
|
||||
def git(cwd, *args):
|
||||
result = subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@example.com', *args],
|
||||
cwd=str(cwd), capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
class Repo:
|
||||
"""An origin, a checkout that publishes to it, and the device's clone."""
|
||||
|
||||
def __init__(self, tmp):
|
||||
self.seed = tmp / 'seed'
|
||||
self.seed.mkdir()
|
||||
git(self.seed, 'init', '-q', '-b', 'main')
|
||||
(self.seed / 'app.py').write_text('v = 1\n')
|
||||
git(self.seed, 'add', '.')
|
||||
git(self.seed, 'commit', '-qm', 'one')
|
||||
self.origin = tmp / 'origin.git'
|
||||
git(tmp, 'clone', '-q', '--bare', str(self.seed), str(self.origin))
|
||||
git(self.seed, 'remote', 'add', 'origin', str(self.origin))
|
||||
self.device = tmp / 'device'
|
||||
git(tmp, 'clone', '-q', str(self.origin), str(self.device))
|
||||
|
||||
def head(self):
|
||||
return git(self.device, 'rev-parse', 'HEAD')
|
||||
|
||||
def publish(self, text='v = 2\n'):
|
||||
(self.seed / 'app.py').write_text(text)
|
||||
git(self.seed, 'commit', '-qam', 'next')
|
||||
git(self.seed, 'push', '-q', 'origin', 'main')
|
||||
return git(self.seed, 'rev-parse', 'HEAD')
|
||||
|
||||
|
||||
def real_pull(device, **extra):
|
||||
def core_update():
|
||||
before = git(device, 'rev-parse', 'HEAD')
|
||||
r = subprocess.run(['git', 'pull', '-q', '--rebase', '--autostash'],
|
||||
cwd=str(device), capture_output=True, text=True)
|
||||
after = git(device, 'rev-parse', 'HEAD')
|
||||
result = {'status': 'success' if r.returncode == 0 else 'error',
|
||||
'message': 'Code updated successfully.' if r.returncode == 0 else r.stderr,
|
||||
'restart_required': before != after, 'dependency_failures': []}
|
||||
result.update(extra)
|
||||
return result
|
||||
return core_update
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, plugins_dir, versions, bump=(), fail=()):
|
||||
self.plugins_dir = str(plugins_dir)
|
||||
self.bump, self.fail = set(bump), set(fail)
|
||||
for pid, version in versions.items():
|
||||
d = Path(plugins_dir) / pid
|
||||
d.mkdir(parents=True)
|
||||
(d / 'manifest.json').write_text(json.dumps({'id': pid, 'version': version}))
|
||||
self.updated_calls = []
|
||||
|
||||
def list_installed_plugins(self):
|
||||
return [p.name for p in Path(self.plugins_dir).iterdir()]
|
||||
|
||||
def _get_local_git_info(self, path):
|
||||
return None
|
||||
|
||||
def update_plugin(self, pid):
|
||||
self.updated_calls.append(pid)
|
||||
if pid in self.fail:
|
||||
return False
|
||||
if pid in self.bump:
|
||||
path = Path(self.plugins_dir) / pid / 'manifest.json'
|
||||
m = json.loads(path.read_text())
|
||||
m['version'] = '9.9.9'
|
||||
path.write_text(json.dumps(m))
|
||||
return True
|
||||
|
||||
|
||||
class Harness:
|
||||
"""An AutoUpdater on a real clone.
|
||||
|
||||
``helper`` is whether the health check's path unit is active; ``pickup``
|
||||
whether the health check actually starts when asked. Starting it is
|
||||
simulated the way systemd does it: the request file is consumed
|
||||
(ExecStartPre) and the verifier marks the pending update "verifying".
|
||||
"""
|
||||
|
||||
def __init__(self, tmp, repo, *, helper=True, pickup=True, core_update=None, store=None,
|
||||
disk_free=10 ** 12, display_active=True, enabled=True, clock=NIGHT):
|
||||
self.sudo, self.restarts, self.now = [], [], clock
|
||||
self.state_at_handoff = None
|
||||
|
||||
def run(args, **kwargs):
|
||||
if args[0] == 'sudo':
|
||||
self.sudo.append(list(args))
|
||||
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
|
||||
return subprocess.run(args, **kwargs)
|
||||
|
||||
def sleep(seconds):
|
||||
updater = self.updater
|
||||
if self.state_at_handoff is None:
|
||||
self.state_at_handoff = au.load_state(updater.state_file)
|
||||
if pickup and updater.request_file.exists():
|
||||
updater.request_file.unlink()
|
||||
pending = au._read_json(updater.pending_file)
|
||||
pending['status'] = 'verifying'
|
||||
au._write_json(updater.pending_file, pending)
|
||||
|
||||
self.config = {'auto_update': {'enabled': enabled}, 'timezone': 'UTC'}
|
||||
cm = MagicMock()
|
||||
cm.load_config.return_value = self.config
|
||||
self.updater = au.AutoUpdater(
|
||||
config_manager=cm, core_update=core_update or real_pull(repo.device),
|
||||
store_manager=store, project_root=repo.device, clock=lambda: self.now,
|
||||
restart=self.restarts.append, run=run,
|
||||
service_active=lambda unit: display_active, helper_ready=lambda: helper,
|
||||
disk_free=lambda path: disk_free, sleep=sleep)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return au.load_state(self.updater.state_file)
|
||||
|
||||
@property
|
||||
def pending(self):
|
||||
return au._read_json(self.updater.pending_file)
|
||||
|
||||
def write_pending(self, **fields):
|
||||
au._write_json(self.updater.pending_file, fields)
|
||||
|
||||
|
||||
def stub_updater(tmp_path, outcome='up_to_date', message='ok', store=None, enabled=True, clock=NIGHT):
|
||||
"""For scheduling and plugin tests: the core update is stubbed out."""
|
||||
cm = MagicMock()
|
||||
cm.load_config.return_value = {'auto_update': {'enabled': enabled}, 'timezone': 'UTC'}
|
||||
restarts = []
|
||||
updater = au.AutoUpdater(config_manager=cm, core_update=MagicMock(), store_manager=store,
|
||||
project_root=tmp_path, clock=lambda: clock,
|
||||
restart=restarts.append)
|
||||
updater.update_core = MagicMock(return_value={'outcome': outcome, 'message': message})
|
||||
return updater, restarts
|
||||
|
||||
|
||||
# -- scheduling ---------------------------------------------------------------
|
||||
|
||||
class TestIsDue:
|
||||
def test_not_before_next_due(self):
|
||||
assert not au.is_due(100, 200, 3)
|
||||
|
||||
def test_in_quiet_hours_once_due(self):
|
||||
assert au.is_due(200, 200, 3)
|
||||
|
||||
def test_waits_for_quiet_hours(self):
|
||||
assert not au.is_due(200, 100, 12)
|
||||
|
||||
def test_gives_up_waiting_after_grace(self):
|
||||
assert au.is_due(100 + au.QUIET_HOURS_GRACE_SECONDS, 100, 12)
|
||||
|
||||
|
||||
class TestTick:
|
||||
def test_disabled_never_runs(self, tmp_path):
|
||||
updater, _ = stub_updater(tmp_path, enabled=False)
|
||||
assert updater.tick() is False
|
||||
updater.update_core.assert_not_called()
|
||||
|
||||
def test_enabling_at_noon_waits_for_the_night(self, tmp_path):
|
||||
updater, _ = stub_updater(tmp_path, clock=NOON)
|
||||
assert updater.tick() is False
|
||||
updater.update_core.assert_not_called()
|
||||
assert au.load_state(updater.state_file)['next_due'] == NOON
|
||||
|
||||
def test_runs_when_due_and_schedules_a_week_out(self, tmp_path):
|
||||
updater, _ = stub_updater(tmp_path)
|
||||
assert updater.tick() is True
|
||||
state = au.load_state(updater.state_file)
|
||||
assert state['next_due'] == NIGHT + au.UPDATE_INTERVAL_SECONDS
|
||||
assert updater.tick() is False, "ran twice in one night"
|
||||
|
||||
def test_transient_error_retries_next_day(self, tmp_path):
|
||||
updater, _ = stub_updater(tmp_path, outcome='error', message='no network')
|
||||
updater.tick()
|
||||
state = au.load_state(updater.state_file)
|
||||
assert state['next_due'] == NIGHT + au.RETRY_AFTER_FAILURE_SECONDS
|
||||
assert state['alert']['message'] == 'no network'
|
||||
|
||||
def test_blocked_keeps_weekly_cadence_but_alerts(self, tmp_path):
|
||||
updater, _ = stub_updater(tmp_path, outcome='blocked', message='local edits')
|
||||
updater.tick()
|
||||
state = au.load_state(updater.state_file)
|
||||
assert state['next_due'] == NIGHT + au.UPDATE_INTERVAL_SECONDS
|
||||
assert 'local edits' in state['alert']['message']
|
||||
|
||||
|
||||
# -- checks before touching the checkout ---------------------------------------
|
||||
|
||||
@needs_git
|
||||
class TestPreflightRefuses:
|
||||
def test_nothing_new_is_up_to_date(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
h = Harness(tmp_path, repo)
|
||||
result = h.updater.run()
|
||||
assert result['core_outcome'] == 'up_to_date'
|
||||
assert result['status'] == 'success'
|
||||
assert h.sudo == [] and 'alert' not in h.state
|
||||
|
||||
def test_without_the_health_check_code_is_not_touched(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
old = repo.head()
|
||||
repo.publish()
|
||||
h = Harness(tmp_path, repo, helper=False)
|
||||
result = h.updater.run()
|
||||
assert result['core_outcome'] == 'blocked'
|
||||
assert repo.head() == old
|
||||
assert 'health check is set up' in result['core_message']
|
||||
assert h.state['alert']
|
||||
|
||||
def test_local_edits_are_never_stashed(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
old = repo.head()
|
||||
repo.publish()
|
||||
(repo.device / 'app.py').write_text('mine\n')
|
||||
h = Harness(tmp_path, repo)
|
||||
result = h.updater.run()
|
||||
assert result['core_outcome'] == 'blocked'
|
||||
assert 'app.py' in result['core_message']
|
||||
assert repo.head() == old
|
||||
assert (repo.device / 'app.py').read_text() == 'mine\n'
|
||||
assert git(repo.device, 'stash', 'list') == ''
|
||||
|
||||
def test_local_commits_block(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
repo.publish()
|
||||
(repo.device / 'extra.txt').write_text('x\n')
|
||||
git(repo.device, 'add', 'extra.txt')
|
||||
git(repo.device, 'commit', '-qm', 'local')
|
||||
mine = repo.head()
|
||||
h = Harness(tmp_path, repo)
|
||||
result = h.updater.run()
|
||||
assert result['core_outcome'] == 'blocked'
|
||||
assert 'local commit' in result['core_message']
|
||||
assert repo.head() == mine
|
||||
|
||||
def test_a_rebase_really_in_progress_blocks(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
repo.publish('v = 2\n')
|
||||
(repo.device / 'app.py').write_text('v = mine\n')
|
||||
git(repo.device, 'commit', '-qam', 'local')
|
||||
git(repo.device, 'fetch', '-q')
|
||||
stopped = subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@example.com',
|
||||
'rebase', 'origin/main'], cwd=str(repo.device), capture_output=True, text=True)
|
||||
assert stopped.returncode != 0, "the rebase should stop on the conflict"
|
||||
result = Harness(tmp_path, repo).updater.run()
|
||||
assert result['core_outcome'] == 'blocked'
|
||||
assert 'rebase is in progress' in result['core_message']
|
||||
assert (repo.device / '.git' / 'rebase-merge').exists(), "a live rebase must be left alone"
|
||||
|
||||
def test_an_abandoned_rebase_is_cleared_and_the_update_runs(self, tmp_path):
|
||||
"""Found on a real device: a pull stopped mid-rebase a week earlier,
|
||||
HEAD was later checked out to a branch, and the leftover rebase
|
||||
directory would have blocked every automatic update forever."""
|
||||
repo = Repo(tmp_path)
|
||||
leftover = repo.device / '.git' / 'rebase-merge'
|
||||
leftover.mkdir()
|
||||
(leftover / 'head-name').write_text('refs/heads/main\n')
|
||||
new = repo.publish()
|
||||
result = Harness(tmp_path, repo).updater.run()
|
||||
assert not leftover.exists()
|
||||
assert result['core_outcome'] == 'verifying'
|
||||
assert repo.head() == new
|
||||
|
||||
def test_a_merge_in_progress_blocks(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
repo.publish()
|
||||
(repo.device / '.git' / 'MERGE_HEAD').write_text(repo.head() + '\n')
|
||||
result = Harness(tmp_path, repo).updater.run()
|
||||
assert result['core_outcome'] == 'blocked'
|
||||
assert 'merge is in progress' in result['core_message']
|
||||
|
||||
def test_low_disk_blocks(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
repo.publish()
|
||||
result = Harness(tmp_path, repo, disk_free=10 * 1024 * 1024).updater.run()
|
||||
assert result['core_outcome'] == 'blocked'
|
||||
assert 'disk space' in result['core_message']
|
||||
|
||||
def test_unreachable_origin_retries_tomorrow(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
git(repo.device, 'remote', 'set-url', 'origin', str(tmp_path / 'nowhere'))
|
||||
h = Harness(tmp_path, repo)
|
||||
result = h.updater.run()
|
||||
assert result['core_outcome'] == 'error'
|
||||
assert h.state['next_due'] == NIGHT + au.RETRY_AFTER_FAILURE_SECONDS
|
||||
|
||||
def test_version_already_rolled_back_is_not_retried(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
old = repo.head()
|
||||
bad = repo.publish()
|
||||
h = Harness(tmp_path, repo)
|
||||
au.save_state({'rolled_back_head': bad}, h.updater.state_file)
|
||||
result = h.updater.run()
|
||||
assert result['core_outcome'] == 'up_to_date'
|
||||
assert 'rolled back before' in result['core_message']
|
||||
assert repo.head() == old and h.sudo == []
|
||||
|
||||
|
||||
# -- the update and its hand-off to the health check ---------------------------
|
||||
|
||||
@needs_git
|
||||
class TestUpdateIsVerified:
|
||||
def test_pull_hands_off_to_the_health_check(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
old = repo.head()
|
||||
new = repo.publish()
|
||||
store = FakeStore(tmp_path / 'plugins', {'weather': '1.0.0'}, bump={'weather'})
|
||||
h = Harness(tmp_path, repo, store=store)
|
||||
result = h.updater.run()
|
||||
|
||||
assert result['core_outcome'] == 'verifying' and result['status'] == 'pending'
|
||||
assert repo.head() == new
|
||||
assert h.pending == {**h.pending, 'status': 'verifying', 'old_head': old, 'new_head': new,
|
||||
'display_was_active': True, 'dependency_failures': []}
|
||||
assert not h.updater.request_file.exists(), "the request is consumed when the check starts"
|
||||
assert h.sudo == [], "triggering the health check needs no privilege"
|
||||
assert h.restarts == [], "restarts belong to the health check, not the web process"
|
||||
assert store.updated_calls == [], "plugins must wait until the new code is verified"
|
||||
assert h.state['plugins_pending'] is True
|
||||
assert h.updater.verifier_copy.read_text() == au.VERIFIER_SOURCE.read_text()
|
||||
|
||||
def test_state_is_saved_before_the_health_check_restarts_this_process(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
repo.publish()
|
||||
h = Harness(tmp_path, repo)
|
||||
h.updater.run()
|
||||
assert h.state_at_handoff['last_result']['core_outcome'] == 'verifying'
|
||||
assert h.state_at_handoff['plugins_pending'] is True
|
||||
|
||||
def test_dependency_failures_reach_the_health_check(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
repo.publish()
|
||||
h = Harness(tmp_path, repo,
|
||||
core_update=real_pull(repo.device, dependency_failures=['requirements.txt']))
|
||||
h.updater.run()
|
||||
assert h.pending['dependency_failures'] == ['requirements.txt']
|
||||
|
||||
def test_a_health_check_that_never_starts_means_the_update_is_undone(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
old = repo.head()
|
||||
repo.publish()
|
||||
h = Harness(tmp_path, repo, pickup=False)
|
||||
result = h.updater.run()
|
||||
assert repo.head() == old
|
||||
assert result['core_outcome'] == 'blocked'
|
||||
assert 'did not start' in result['core_message']
|
||||
assert h.pending is None and not h.updater.request_file.exists()
|
||||
assert h.state['alert']
|
||||
|
||||
def test_failed_pull_retries_tomorrow(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
old = repo.head()
|
||||
repo.publish()
|
||||
h = Harness(tmp_path, repo,
|
||||
core_update=lambda: {'status': 'error', 'message': 'Update failed: network'})
|
||||
result = h.updater.run()
|
||||
assert result['core_outcome'] == 'error'
|
||||
assert repo.head() == old
|
||||
assert h.state['next_due'] == NIGHT + au.RETRY_AFTER_FAILURE_SECONDS
|
||||
|
||||
def test_failed_pull_that_moved_head_is_rolled_back(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
old = repo.head()
|
||||
repo.publish()
|
||||
pull = real_pull(repo.device)
|
||||
|
||||
def half_failed():
|
||||
pull()
|
||||
return {'status': 'error', 'message': 'Update failed: interrupted'}
|
||||
result = Harness(tmp_path, repo, core_update=half_failed).updater.run()
|
||||
assert repo.head() == old
|
||||
assert 'rolled back' in result['core_message']
|
||||
|
||||
|
||||
# -- reporting the health check's outcome --------------------------------------
|
||||
|
||||
@needs_git
|
||||
class TestHealthCheckOutcome:
|
||||
def _after_update(self, tmp_path, **pending):
|
||||
repo = Repo(tmp_path)
|
||||
store = FakeStore(tmp_path / 'plugins', {'weather': '1.0.0'}, bump={'weather'})
|
||||
h = Harness(tmp_path, repo, store=store)
|
||||
au.save_state({'plugins_pending': True, 'next_due': NIGHT + au.UPDATE_INTERVAL_SECONDS,
|
||||
'last_result': {'core_outcome': 'verifying', 'status': 'pending',
|
||||
'plugins_deferred': True}}, h.updater.state_file)
|
||||
h.write_pending(old_head='a' * 40, new_head='b' * 40, created_at=NIGHT, **pending)
|
||||
return h, store
|
||||
|
||||
def test_waits_while_the_check_runs(self, tmp_path):
|
||||
h, store = self._after_update(tmp_path, status='verifying')
|
||||
h.now = NIGHT + 60
|
||||
assert h.updater.tick() is False
|
||||
assert store.updated_calls == [] and h.pending
|
||||
|
||||
def test_success_then_runs_the_deferred_plugins(self, tmp_path):
|
||||
h, store = self._after_update(tmp_path, status='success')
|
||||
assert h.updater.tick() is True
|
||||
result = h.state['last_result']
|
||||
assert result['core_outcome'] == 'updated' and result['status'] == 'success'
|
||||
assert result['plugins_updated'] == ['weather']
|
||||
assert h.restarts == ['ledmatrix']
|
||||
assert 'alert' not in h.state and h.pending is None
|
||||
|
||||
def test_rollback_alerts_and_remembers_the_bad_commit(self, tmp_path):
|
||||
h, store = self._after_update(tmp_path, status='rolled_back',
|
||||
reason='the web interface did not respond')
|
||||
h.updater.tick()
|
||||
assert 'rolled back' in h.state['alert']['message']
|
||||
assert 'the web interface did not respond' in h.state['alert']['message']
|
||||
assert h.state['rolled_back_head'] == 'b' * 40
|
||||
assert store.updated_calls == ['weather'], "the old code is healthy; plugins may update"
|
||||
|
||||
def test_failed_rollback_alerts_and_skips_plugins(self, tmp_path):
|
||||
h, store = self._after_update(tmp_path, status='rollback_failed',
|
||||
reason='display down', detail='reset failed')
|
||||
h.updater.tick()
|
||||
assert f'git reset --hard {"a" * 40}' in h.state['alert']['message']
|
||||
assert store.updated_calls == []
|
||||
assert h.state['plugins_pending'] is False
|
||||
|
||||
def test_a_check_that_never_reports_back_alerts(self, tmp_path):
|
||||
h, _ = self._after_update(tmp_path, status='verifying')
|
||||
h.now = NIGHT + au.VERIFY_LOST_SECONDS + 1
|
||||
h.updater.tick()
|
||||
assert h.state['last_result']['core_outcome'] == 'lost'
|
||||
assert 'never reported back' in h.state['alert']['message']
|
||||
|
||||
def test_dismissed_alert_stays_hidden_until_a_new_one(self, tmp_path):
|
||||
h, _ = self._after_update(tmp_path, status='rolled_back', reason='x')
|
||||
h.updater.tick()
|
||||
alert_id = h.state['alert']['id']
|
||||
|
||||
def status():
|
||||
return au.describe_status(h.config, state_file=h.updater.state_file,
|
||||
pending_file=h.updater.pending_file,
|
||||
setup_file=tmp_path / 'no-setup.json', helper=lambda: True)
|
||||
assert status()['alert_id'] == alert_id
|
||||
au.dismiss_alert(alert_id, h.updater.state_file)
|
||||
assert status()['alert'] is None
|
||||
state = h.state
|
||||
state['alert'] = {'id': 'newer', 'message': 'again'}
|
||||
au.save_state(state, h.updater.state_file)
|
||||
assert status()['alert'] == 'again'
|
||||
|
||||
|
||||
def test_describe_status_reports_setup(tmp_path):
|
||||
setup = tmp_path / 'setup.json'
|
||||
setup.write_text(json.dumps({'status': 'failed', 'message': 'not root'}))
|
||||
kwargs = dict(state={}, state_file=tmp_path / 's.json', pending_file=tmp_path / 'p.json',
|
||||
setup_file=setup)
|
||||
on = au.describe_status(ON, helper=lambda: False, **kwargs)
|
||||
assert on['verifier_installed'] is False
|
||||
assert (on['setup_status'], on['setup_message']) == ('failed', 'not root')
|
||||
|
||||
def never(): raise AssertionError("systemctl asked while updates are off")
|
||||
assert au.describe_status(OFF, helper=never, **kwargs)['verifier_installed'] is None
|
||||
|
||||
|
||||
# -- setting it up from the web UI ----------------------------------------------
|
||||
|
||||
class TestSetupFromTheWebUI:
|
||||
def _host(self, monkeypatch, ready=False, active=True, restart_ok=True):
|
||||
restarts = []
|
||||
monkeypatch.setattr(au, 'helper_ready', lambda: ready)
|
||||
monkeypatch.setattr(au, '_service_active', lambda unit: active)
|
||||
monkeypatch.setattr(au, 'restart_service', lambda unit: restarts.append(unit) or restart_ok)
|
||||
return restarts
|
||||
|
||||
def test_switching_on_restarts_the_display_to_finish_setup(self, monkeypatch):
|
||||
restarts = self._host(monkeypatch)
|
||||
note = au.start_setup_if_needed(False, ON)
|
||||
assert restarts == ['ledmatrix']
|
||||
assert 'display is restarting' in note
|
||||
|
||||
@pytest.mark.parametrize('was_enabled, config, ready', [
|
||||
(True, ON, False), # already on: an unrelated save must not restart the display
|
||||
(False, OFF, False), # still off
|
||||
(False, ON, True), # already set up
|
||||
])
|
||||
def test_nothing_to_do(self, monkeypatch, was_enabled, config, ready):
|
||||
restarts = self._host(monkeypatch, ready=ready)
|
||||
assert au.start_setup_if_needed(was_enabled, config) is None
|
||||
assert restarts == []
|
||||
|
||||
def test_a_stopped_display_is_not_started(self, monkeypatch):
|
||||
restarts = self._host(monkeypatch, active=False)
|
||||
assert 'next time the display service starts' in au.start_setup_if_needed(False, ON)
|
||||
assert restarts == []
|
||||
|
||||
def test_a_failed_restart_says_so(self, monkeypatch):
|
||||
self._host(monkeypatch, restart_ok=False)
|
||||
assert 'Could not restart' in au.start_setup_if_needed(False, ON)
|
||||
|
||||
|
||||
# -- plugins --------------------------------------------------------------------
|
||||
|
||||
class TestPlugins:
|
||||
def test_nothing_changed_restarts_nothing(self, tmp_path):
|
||||
store = FakeStore(tmp_path / 'plugins', {'clock': '1.0.0'})
|
||||
updater, restarts = stub_updater(tmp_path, store=store)
|
||||
updater.run()
|
||||
assert store.updated_calls == ['clock']
|
||||
assert restarts == []
|
||||
|
||||
def test_plugin_update_restarts_display_only(self, tmp_path):
|
||||
store = FakeStore(tmp_path / 'plugins', {'clock': '1.0.0', 'weather': '1.0.0'},
|
||||
bump={'weather'})
|
||||
updater, restarts = stub_updater(tmp_path, store=store)
|
||||
result = updater.run()
|
||||
assert result['plugins_updated'] == ['weather']
|
||||
assert restarts == ['ledmatrix']
|
||||
|
||||
def test_plugin_failure_alerts_but_keeps_weekly_cadence(self, tmp_path):
|
||||
store = FakeStore(tmp_path / 'plugins', {'zip-only': '1.0.0'}, fail={'zip-only'})
|
||||
updater, _ = stub_updater(tmp_path, store=store)
|
||||
result = updater.run()
|
||||
assert result['plugins_failed'] == ['zip-only'] and result['status'] == 'error'
|
||||
state = au.load_state(updater.state_file)
|
||||
assert 'zip-only' in state['alert']['message']
|
||||
assert state['next_due'] == NIGHT + au.UPDATE_INTERVAL_SECONDS
|
||||
|
||||
def test_local_only_plugins_are_left_alone(self, tmp_path):
|
||||
store = FakeStore(tmp_path / 'plugins', {'mine': '1.0.0'})
|
||||
path = tmp_path / 'plugins' / 'mine' / 'manifest.json'
|
||||
path.write_text(json.dumps({'id': 'mine', 'version': '1.0.0', 'local_only': True}))
|
||||
updater, _ = stub_updater(tmp_path, store=store)
|
||||
updater.run()
|
||||
assert store.updated_calls == []
|
||||
|
||||
|
||||
def test_restart_service_skips_a_stopped_unit(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def run(args, **kwargs):
|
||||
calls.append(args)
|
||||
return MagicMock(stdout='inactive\n', returncode=3, stderr='')
|
||||
monkeypatch.setattr(au.subprocess, 'run', run)
|
||||
assert au.restart_service('ledmatrix') is False
|
||||
assert calls == [['systemctl', 'is-active', 'ledmatrix']], (
|
||||
"a display the user stopped must not be started by an update")
|
||||
|
||||
|
||||
def test_core_update_names_the_requirement_files_that_failed():
|
||||
"""The health check refuses code whose dependencies did not install, so
|
||||
the failure has to arrive as data, not only as a sentence in the message."""
|
||||
from web_interface.blueprints import api_v3 as pkg
|
||||
from web_interface.blueprints.api_v3 import system
|
||||
|
||||
heads = iter(['aaa111\n', 'bbb222\n'])
|
||||
|
||||
def run(args, **kwargs):
|
||||
if args[:2] == ['git', 'rev-parse'] and args[-1] == 'HEAD':
|
||||
out = next(heads, 'bbb222\n')
|
||||
elif args[:2] == ['git', 'diff']:
|
||||
out = 'requirements.txt\n'
|
||||
elif '@{u}' in args:
|
||||
out = 'origin/main\n'
|
||||
else:
|
||||
out = ''
|
||||
return subprocess.CompletedProcess(args, 0, stdout=out, stderr='')
|
||||
|
||||
pkg.api_v3.plugin_store_manager = None
|
||||
failed_install = subprocess.CompletedProcess([], 1, stdout='', stderr='boom')
|
||||
with patch.object(pkg.subprocess, 'run', run), \
|
||||
patch.object(system, '_pip_install_requirements', return_value=failed_install):
|
||||
result = system.perform_core_update()
|
||||
assert result['status'] == 'success'
|
||||
assert result['dependency_failures'] == ['requirements.txt']
|
||||
|
||||
|
||||
# -- web routes -------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def api_client(monkeypatch):
|
||||
from flask import Flask
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||
cm = MagicMock()
|
||||
cm.load_config.return_value = {'timezone': 'UTC', 'auto_update': {'enabled': True}}
|
||||
cm.get_raw_file_content.return_value = {}
|
||||
cm.save_config_atomic.return_value = MagicMock(status=MagicMock(value='success'), message=None)
|
||||
api_v3.config_manager = cm
|
||||
api_v3.plugin_manager = MagicMock(plugins={})
|
||||
# Never restart a real display from a test run.
|
||||
setup_calls = []
|
||||
monkeypatch.setattr(au, 'start_setup_if_needed',
|
||||
lambda was_enabled, config: setup_calls.append(was_enabled) or None)
|
||||
return app.test_client(), cm, setup_calls
|
||||
|
||||
|
||||
class TestStatusRoutes:
|
||||
def test_status_carries_the_alert(self, api_client, monkeypatch):
|
||||
client, _, _ = api_client
|
||||
monkeypatch.setattr(au, 'describe_status', lambda config: {'alert': 'rolled back', 'alert_id': '7'})
|
||||
data = client.get('/api/v3/system/auto-update').get_json()['data']
|
||||
assert data == {'alert': 'rolled back', 'alert_id': '7'}
|
||||
|
||||
@pytest.mark.parametrize('body', [{}, [1], 'x', 5])
|
||||
def test_dismiss_needs_an_id_in_a_json_object(self, api_client, body):
|
||||
client, _, _ = api_client
|
||||
assert client.post('/api/v3/system/auto-update/dismiss', json=body).status_code == 400
|
||||
|
||||
def test_dismiss(self, api_client, monkeypatch):
|
||||
client, _, _ = api_client
|
||||
calls = []
|
||||
monkeypatch.setattr(au, 'dismiss_alert', calls.append)
|
||||
assert client.post('/api/v3/system/auto-update/dismiss', json={'alert_id': '7'}).status_code == 200
|
||||
assert calls == ['7']
|
||||
|
||||
|
||||
class TestSettingsSave:
|
||||
def _saved(self, cm):
|
||||
return cm.save_config_atomic.call_args[0][0]
|
||||
|
||||
def test_checked_toggle_saves_enabled(self, api_client):
|
||||
client, cm, setup_calls = api_client
|
||||
cm.load_config.return_value = {'timezone': 'UTC'}
|
||||
resp = client.post('/api/v3/config/main', json={'timezone': 'UTC', 'auto_update_enabled': 'true'})
|
||||
assert resp.status_code == 200
|
||||
saved = self._saved(cm)
|
||||
assert saved['auto_update'] == {'enabled': True}
|
||||
assert 'auto_update_enabled' not in saved, "the form field must not leak into config.json"
|
||||
assert setup_calls == [False], "setup is told the toggle was previously off"
|
||||
|
||||
def test_unchecked_toggle_on_general_save_disables(self, api_client):
|
||||
client, cm, setup_calls = api_client
|
||||
resp = client.post('/api/v3/config/main', json={'timezone': 'UTC'})
|
||||
assert resp.status_code == 200
|
||||
assert self._saved(cm)['auto_update'] == {'enabled': False}
|
||||
assert setup_calls == [True]
|
||||
|
||||
def test_setup_note_reaches_the_user(self, api_client, monkeypatch):
|
||||
client, cm, _ = api_client
|
||||
cm.load_config.return_value = {'timezone': 'UTC'}
|
||||
monkeypatch.setattr(au, 'start_setup_if_needed',
|
||||
lambda was_enabled, config: 'Finishing automatic update setup: the display is restarting.')
|
||||
body = client.post('/api/v3/config/main',
|
||||
json={'timezone': 'UTC', 'auto_update_enabled': 'true'}).get_json()
|
||||
assert 'the display is restarting' in body['message']
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Installing the automatic-update health check from the display service.
|
||||
|
||||
src/auto_update_setup.py is how a user who never opens a terminal gets updates
|
||||
with a safety net: it runs as root inside the display service and writes
|
||||
systemd units. So it has to install exactly the right thing when asked, do
|
||||
nothing when not asked, refuse templates that would run as anyone but the web
|
||||
user, and say why whenever it could not finish.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src import auto_update_setup as aus # noqa: E402
|
||||
|
||||
ON = {'auto_update': {'enabled': True}}
|
||||
|
||||
|
||||
class FakeSystemctl:
|
||||
def __init__(self, fail=(), activates=True):
|
||||
self.calls = []
|
||||
self.fail = set(fail)
|
||||
self.activates = activates
|
||||
self.active = False
|
||||
|
||||
def __call__(self, args, **kwargs):
|
||||
assert args[0] == 'systemctl', args
|
||||
self.calls.append(args[1:])
|
||||
verb = args[1]
|
||||
if verb == 'is-active':
|
||||
return subprocess.CompletedProcess(args, 0 if self.active else 3,
|
||||
stdout='active\n' if self.active else 'inactive\n', stderr='')
|
||||
if verb in self.fail:
|
||||
return subprocess.CompletedProcess(args, 1, stdout='', stderr=f'{verb} refused')
|
||||
if verb in ('restart', 'enable') and self.activates and (verb == 'restart' or '--now' in args):
|
||||
self.active = True
|
||||
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
|
||||
|
||||
|
||||
def project(tmp_path, user='hdpi', workdir=None, name='LEDMatrix'):
|
||||
root = tmp_path / name
|
||||
(root / 'systemd').mkdir(parents=True)
|
||||
for name in aus.UNITS:
|
||||
shutil.copy(ROOT / 'systemd' / name, root / 'systemd' / name)
|
||||
etc = tmp_path / 'etc'
|
||||
etc.mkdir()
|
||||
(etc / aus.WEB_UNIT).write_text(
|
||||
f'[Service]\nUser={user}\nWorkingDirectory={workdir or root}\n', encoding='utf-8')
|
||||
return root, etc
|
||||
|
||||
|
||||
def setup(root, etc, systemctl, root_user=True):
|
||||
return aus.UpdateHelperSetup(project_root=root, systemd_dir=etc, run=systemctl,
|
||||
is_root=lambda: root_user,
|
||||
lookup_ids=lambda user: None if user == 'ghost' else (1000, 1000),
|
||||
clock=lambda: 1234.0)
|
||||
|
||||
|
||||
def result(root):
|
||||
return json.loads((root / aus.RESULT_REL).read_text())
|
||||
|
||||
|
||||
def test_does_nothing_while_updates_are_off(tmp_path):
|
||||
root, etc = project(tmp_path)
|
||||
systemctl = FakeSystemctl()
|
||||
assert setup(root, etc, systemctl).ensure({'auto_update': {'enabled': False}}) is None
|
||||
assert systemctl.calls == []
|
||||
assert not (etc / aus.SERVICE_UNIT).exists()
|
||||
assert not (root / aus.RESULT_REL).exists()
|
||||
|
||||
|
||||
def test_installs_both_units_for_the_web_user(tmp_path):
|
||||
root, etc = project(tmp_path)
|
||||
systemctl = FakeSystemctl()
|
||||
out = setup(root, etc, systemctl).ensure(ON)
|
||||
|
||||
assert out['status'] == 'installed' and result(root)['status'] == 'installed'
|
||||
service = (etc / aus.SERVICE_UNIT).read_text()
|
||||
assert 'User=hdpi' in service
|
||||
assert f'ExecStart=/usr/bin/python3 "{root}/data/auto_update_verifier.py" "{root}"' in service
|
||||
assert f'PathExists={root}/data/auto_update_verify.request' in (etc / aus.PATH_UNIT).read_text()
|
||||
assert '__' not in service
|
||||
assert ['daemon-reload'] in systemctl.calls
|
||||
assert ['enable', aus.PATH_UNIT] in systemctl.calls
|
||||
assert systemctl.active
|
||||
|
||||
|
||||
def test_second_start_changes_nothing(tmp_path):
|
||||
root, etc = project(tmp_path)
|
||||
systemctl = FakeSystemctl()
|
||||
setup(root, etc, systemctl).ensure(ON)
|
||||
first = result(root)
|
||||
systemctl.calls.clear()
|
||||
setup(root, etc, systemctl).ensure(ON)
|
||||
assert systemctl.calls == [['is-active', aus.PATH_UNIT], ['is-active', aus.PATH_UNIT]]
|
||||
assert result(root) == first, "an unchanged setup must not rewrite its result every boot"
|
||||
|
||||
|
||||
def test_a_changed_template_is_reinstalled(tmp_path):
|
||||
root, etc = project(tmp_path)
|
||||
systemctl = FakeSystemctl()
|
||||
setup(root, etc, systemctl).ensure(ON)
|
||||
template = root / 'systemd' / aus.SERVICE_UNIT
|
||||
template.write_text(template.read_text() + '\n# newer\n')
|
||||
systemctl.calls.clear()
|
||||
assert setup(root, etc, systemctl).ensure(ON)['message'] == 'Installed the update health check.'
|
||||
assert (etc / aus.SERVICE_UNIT).read_text().endswith('# newer\n')
|
||||
assert ['daemon-reload'] in systemctl.calls
|
||||
|
||||
|
||||
def test_a_template_that_would_not_run_as_the_web_user_is_refused(tmp_path):
|
||||
root, etc = project(tmp_path)
|
||||
template = root / 'systemd' / aus.SERVICE_UNIT
|
||||
template.write_text(template.read_text().replace('User=__USER__', 'User=root'))
|
||||
systemctl = FakeSystemctl()
|
||||
out = setup(root, etc, systemctl).ensure(ON)
|
||||
assert out['status'] == 'failed' and 'refusing' in out['message']
|
||||
assert not (etc / aus.SERVICE_UNIT).exists()
|
||||
assert systemctl.calls == []
|
||||
|
||||
|
||||
def test_a_path_unit_that_starts_something_else_is_refused(tmp_path):
|
||||
root, etc = project(tmp_path)
|
||||
template = root / 'systemd' / aus.PATH_UNIT
|
||||
template.write_text(template.read_text().replace('Unit=ledmatrix-update-verify.service', 'Unit=other.service'))
|
||||
out = setup(root, etc, FakeSystemctl()).ensure(ON)
|
||||
assert out['status'] == 'failed' and not (etc / aus.PATH_UNIT).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('case, expected', [
|
||||
('not_root', 'not running as root'),
|
||||
('no_web_unit', 'not installed'),
|
||||
('other_folder', 'runs from'),
|
||||
('bad_user', 'not a usable account'),
|
||||
])
|
||||
def test_reports_why_it_could_not_install(tmp_path, case, expected):
|
||||
root, etc = project(tmp_path, user='ghost' if case == 'bad_user' else 'hdpi',
|
||||
workdir=tmp_path / 'elsewhere' if case == 'other_folder' else None)
|
||||
if case == 'no_web_unit':
|
||||
(etc / aus.WEB_UNIT).unlink()
|
||||
out = setup(root, etc, FakeSystemctl(), root_user=case != 'not_root').ensure(ON)
|
||||
assert out['status'] == 'failed' and expected in out['message']
|
||||
assert result(root)['message'] == out['message']
|
||||
assert not (etc / aus.SERVICE_UNIT).exists()
|
||||
|
||||
|
||||
def test_a_path_unit_that_will_not_start_is_a_failure(tmp_path):
|
||||
root, etc = project(tmp_path)
|
||||
out = setup(root, etc, FakeSystemctl(activates=False)).ensure(ON)
|
||||
assert out['status'] == 'failed' and 'did not start' in out['message']
|
||||
|
||||
|
||||
def test_systemctl_errors_are_reported(tmp_path):
|
||||
root, etc = project(tmp_path)
|
||||
out = setup(root, etc, FakeSystemctl(fail={'daemon-reload'})).ensure(ON)
|
||||
assert out['status'] == 'failed' and 'daemon-reload refused' in out['message']
|
||||
|
||||
|
||||
def test_not_a_systemd_host_is_left_alone(tmp_path):
|
||||
root, _ = project(tmp_path)
|
||||
systemctl = FakeSystemctl()
|
||||
assert setup(root, tmp_path / 'no-etc', systemctl).ensure(ON) is None
|
||||
assert systemctl.calls == []
|
||||
|
||||
|
||||
def test_a_folder_with_spaces_is_quoted_in_the_commands(tmp_path):
|
||||
root, etc = project(tmp_path, name='LED Matrix')
|
||||
assert setup(root, etc, FakeSystemctl()).ensure(ON)['status'] == 'installed'
|
||||
service = (etc / aus.SERVICE_UNIT).read_text()
|
||||
assert f'ExecStartPre=/bin/rm -f "{root}/data/auto_update_verify.request"' in service
|
||||
assert f'ExecStart=/usr/bin/python3 "{root}/data/auto_update_verifier.py" "{root}"' in service
|
||||
|
||||
|
||||
def test_a_folder_name_systemd_would_reinterpret_is_refused(tmp_path):
|
||||
root, etc = project(tmp_path, name='LED%Matrix')
|
||||
systemctl = FakeSystemctl()
|
||||
out = setup(root, etc, systemctl).ensure(ON)
|
||||
assert out['status'] == 'failed' and 'systemd cannot use' in out['message']
|
||||
assert not (etc / aus.SERVICE_UNIT).exists() and systemctl.calls == []
|
||||
|
||||
|
||||
def test_installer_sets_the_toggle_without_a_half_written_config(tmp_path):
|
||||
"""first_time_install.sh may run while the display service watches
|
||||
config.json; the file must be replaced whole, never truncated in place."""
|
||||
import re
|
||||
text = (ROOT / 'first_time_install.sh').read_text(encoding='utf-8').replace('\r\n', '\n')
|
||||
script = next(s for s in re.findall(r"<<'PY'\n(.*?)\nPY\n", text, re.S) if 'auto_update' in s)
|
||||
assert 'os.replace(' in script
|
||||
config = tmp_path / 'config.json'
|
||||
config.write_text(json.dumps({'timezone': 'UTC', 'auto_update': {'enabled': False}}))
|
||||
run = subprocess.run([sys.executable, '-', str(config), '1'], input=script, text=True,
|
||||
capture_output=True)
|
||||
assert run.returncode == 0, run.stderr
|
||||
assert json.loads(config.read_text()) == {'timezone': 'UTC', 'auto_update': {'enabled': True}}
|
||||
assert [p.name for p in tmp_path.iterdir()] == ['config.json'], "a temp file was left behind"
|
||||
|
||||
|
||||
def test_the_result_is_kept_when_it_cannot_be_given_to_the_web_user(tmp_path, monkeypatch):
|
||||
"""Caught by CI, which runs as a non-root Linux user: chown needs root, and
|
||||
a failed chown used to discard the result, so the General tab never
|
||||
learned whether setup worked."""
|
||||
def refuse(*args):
|
||||
raise PermissionError('Operation not permitted')
|
||||
monkeypatch.setattr(aus.os, 'chown', refuse, raising=False)
|
||||
root, etc = project(tmp_path)
|
||||
out = setup(root, etc, FakeSystemctl()).ensure(ON)
|
||||
assert out['status'] == 'installed'
|
||||
assert result(root) == out
|
||||
@@ -0,0 +1,248 @@
|
||||
"""The automatic update's health check and rollback (scripts/utils/auto_update_verify.py).
|
||||
|
||||
This is what stands between an unattended update and a device that no longer
|
||||
works, so each way an update can fail is exercised against a real git repo:
|
||||
the new commit is checked out, the services are "restarted", and whether they
|
||||
come up healthy depends on which commit they were restarted onto. Only
|
||||
systemd, sudo and the HTTP check are faked.
|
||||
"""
|
||||
import importlib.util
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
'auto_update_verify', ROOT / 'scripts' / 'utils' / 'auto_update_verify.py')
|
||||
av = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(av)
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which('git') is None, reason='git not installed')
|
||||
|
||||
|
||||
def git(cwd, *args):
|
||||
result = subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@example.com', *args],
|
||||
cwd=str(cwd), capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def done(args, stdout='', rc=0):
|
||||
return subprocess.CompletedProcess(args, rc, stdout=stdout, stderr='' if rc == 0 else 'failed')
|
||||
|
||||
|
||||
def updated_repo(tmp_path, new_requirements=False):
|
||||
"""A checkout on a new commit, with the commit it came from."""
|
||||
repo = tmp_path / 'LEDMatrix'
|
||||
repo.mkdir()
|
||||
git(repo, 'init', '-q', '-b', 'main')
|
||||
(repo / 'app.py').write_text('v = 1\n')
|
||||
(repo / 'requirements.txt').write_text('requests\n')
|
||||
git(repo, 'add', '.')
|
||||
git(repo, 'commit', '-qm', 'old')
|
||||
old = git(repo, 'rev-parse', 'HEAD')
|
||||
(repo / 'app.py').write_text('v = 2\n')
|
||||
if new_requirements:
|
||||
(repo / 'requirements.txt').write_text('requests\nnewthing\n')
|
||||
git(repo, 'commit', '-qam', 'new')
|
||||
return repo, old, git(repo, 'rev-parse', 'HEAD')
|
||||
|
||||
|
||||
class FakeHost:
|
||||
"""systemd, sudo and the web interface.
|
||||
|
||||
Services run whatever commit was checked out when they were last
|
||||
restarted; ``failure`` says how they misbehave on the new commit
|
||||
("display_down", "web_down", "crash_loop") or on any commit ("always").
|
||||
"""
|
||||
|
||||
def __init__(self, repo, bad_head, failure=None, pip_ok=True, restart_failures=0, count_readable=True):
|
||||
self.repo, self.bad_head, self.failure, self.pip_ok = repo, bad_head, failure, pip_ok
|
||||
self.restart_failures = restart_failures # how many restart commands fail, first to last
|
||||
self.count_readable = count_readable
|
||||
self.running_head = None # not restarted yet: still the old, healthy code
|
||||
self.restarts = [] # (unit, commit it was restarted onto)
|
||||
self.pip_installs = []
|
||||
self.now = 0.0
|
||||
self.nrestarts = 0
|
||||
|
||||
def broken(self, kind):
|
||||
if self.running_head is None:
|
||||
return False
|
||||
return self.failure == 'always' or (self.running_head == self.bad_head and self.failure == kind)
|
||||
|
||||
def run(self, args, **kwargs):
|
||||
if args[0] == 'git':
|
||||
return subprocess.run(args, **kwargs)
|
||||
if args[:2] == ['systemctl', 'is-active']:
|
||||
return done(args, 'failed\n' if self.broken('display_down') else 'active\n')
|
||||
if args[:2] == ['systemctl', 'show']:
|
||||
if not self.count_readable:
|
||||
return done(args, rc=1)
|
||||
if self.broken('crash_loop'):
|
||||
self.nrestarts += 1
|
||||
return done(args, f'{self.nrestarts}\n')
|
||||
if args[0] == 'sudo' and any(a.endswith('safe_pip_install.sh') for a in args):
|
||||
self.pip_installs.append(args[-1])
|
||||
return done(args, rc=0 if self.pip_ok else 1)
|
||||
if args[:4] == ['sudo', '-n', 'systemctl', 'restart']:
|
||||
if self.restart_failures:
|
||||
self.restart_failures -= 1
|
||||
return done(args, rc=1) # the old process keeps running
|
||||
self.running_head = git(self.repo, 'rev-parse', 'HEAD')
|
||||
self.restarts.append((args[4], self.running_head))
|
||||
return done(args)
|
||||
raise AssertionError(f'unexpected command: {args}')
|
||||
|
||||
def web_responds(self):
|
||||
return not self.broken('web_down')
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.now += seconds
|
||||
|
||||
def verifier(self):
|
||||
return av.Verifier(self.repo, run=self.run, sleep=self.sleep, clock=lambda: self.now,
|
||||
web_responds=self.web_responds, log=lambda msg: None)
|
||||
|
||||
|
||||
def check(tmp_path, failure=None, new_requirements=False, pip_ok=True, restart_failures=0,
|
||||
count_readable=True, **pending):
|
||||
repo, old, new = updated_repo(tmp_path, new_requirements)
|
||||
fields = {'status': 'pending', 'old_head': old, 'new_head': new,
|
||||
'display_was_active': True, 'dependency_failures': []}
|
||||
fields.update(pending)
|
||||
av.write_pending(av.pending_path(repo), fields)
|
||||
host = FakeHost(repo, new, failure, pip_ok, restart_failures, count_readable)
|
||||
code = host.verifier().verify()
|
||||
result = av.read_pending(av.pending_path(repo))
|
||||
return code, result, host, git(repo, 'rev-parse', 'HEAD'), old, new
|
||||
|
||||
|
||||
def test_a_healthy_update_is_kept(tmp_path):
|
||||
code, result, host, head, old, new = check(tmp_path)
|
||||
assert code == 0 and result['status'] == 'success'
|
||||
assert head == new
|
||||
assert host.restarts == [('ledmatrix.service', new), ('ledmatrix-web.service', new)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure, reason', [
|
||||
('display_down', 'the display service did not stay running'),
|
||||
('web_down', 'the web interface did not respond'),
|
||||
('crash_loop', 'the display service kept restarting'),
|
||||
])
|
||||
def test_an_unhealthy_update_is_rolled_back(tmp_path, failure, reason):
|
||||
code, result, host, head, old, new = check(tmp_path, failure)
|
||||
assert code == 0
|
||||
assert result['status'] == 'rolled_back' and result['reason'] == reason
|
||||
assert head == old
|
||||
assert host.restarts[-2:] == [('ledmatrix.service', old), ('ledmatrix-web.service', old)]
|
||||
|
||||
|
||||
def test_a_stopped_display_is_not_started(tmp_path):
|
||||
code, result, host, head, old, new = check(tmp_path, display_was_active=False)
|
||||
assert result['status'] == 'success'
|
||||
assert [unit for unit, _ in host.restarts] == ['ledmatrix-web.service']
|
||||
|
||||
|
||||
def test_failed_dependencies_roll_back_before_anything_restarts_onto_them(tmp_path):
|
||||
code, result, host, head, old, new = check(tmp_path, dependency_failures=['requirements.txt'])
|
||||
assert result['status'] == 'rolled_back'
|
||||
assert 'requirements.txt' in result['reason']
|
||||
assert head == old
|
||||
assert all(commit == old for _, commit in host.restarts), host.restarts
|
||||
|
||||
|
||||
def test_rollback_reinstalls_the_previous_dependencies(tmp_path):
|
||||
code, result, host, head, old, new = check(tmp_path, 'display_down', new_requirements=True)
|
||||
assert result['status'] == 'rolled_back' and result['detail'] is None
|
||||
assert host.pip_installs == [str(tmp_path / 'LEDMatrix' / 'requirements.txt')]
|
||||
|
||||
|
||||
def test_a_failed_dependency_reinstall_is_reported(tmp_path):
|
||||
code, result, host, head, old, new = check(tmp_path, 'display_down', new_requirements=True,
|
||||
pip_ok=False)
|
||||
assert result['status'] == 'rolled_back' and head == old
|
||||
assert 'Install Base Requirements' in result['detail']
|
||||
|
||||
|
||||
def test_still_broken_after_rolling_back_is_rollback_failed(tmp_path):
|
||||
code, result, host, head, old, new = check(tmp_path, 'always')
|
||||
assert code == 1 and result['status'] == 'rollback_failed'
|
||||
assert 'still unhealthy after rolling back' in result['detail']
|
||||
|
||||
|
||||
def test_a_failed_restart_is_not_mistaken_for_a_healthy_update(tmp_path):
|
||||
"""When the restart command itself fails the old process keeps answering,
|
||||
so checking it would pass an update that never started."""
|
||||
code, result, host, head, old, new = check(tmp_path, restart_failures=1)
|
||||
assert result['status'] == 'rolled_back'
|
||||
assert result['reason'] == 'restarting the services failed'
|
||||
assert head == old
|
||||
|
||||
|
||||
def test_restarts_that_keep_failing_after_rollback_are_rollback_failed(tmp_path):
|
||||
code, result, host, head, old, new = check(tmp_path, restart_failures=100)
|
||||
assert code == 1 and result['status'] == 'rollback_failed'
|
||||
assert 'restarting the services failed' in result['detail']
|
||||
|
||||
|
||||
def test_an_unreadable_restart_count_is_never_called_stable(tmp_path):
|
||||
"""With no restart count a crash loop looks healthy between attempts."""
|
||||
code, result, host, head, old, new = check(tmp_path, count_readable=False)
|
||||
assert result['status'] != 'success'
|
||||
assert 'restart count could not be read' in result['reason']
|
||||
|
||||
|
||||
def test_nothing_pending_does_nothing(tmp_path):
|
||||
repo, _, _ = updated_repo(tmp_path)
|
||||
host = FakeHost(repo, None)
|
||||
assert host.verifier().verify() == 0
|
||||
assert host.restarts == []
|
||||
|
||||
|
||||
def test_a_crash_is_recorded_not_left_as_running(tmp_path, monkeypatch):
|
||||
repo, old, new = updated_repo(tmp_path)
|
||||
av.write_pending(av.pending_path(repo), {'status': 'pending', 'old_head': old, 'new_head': new})
|
||||
|
||||
def boom(self):
|
||||
raise RuntimeError('boom')
|
||||
monkeypatch.setattr(av.Verifier, 'verify', boom)
|
||||
assert av.main(['auto_update_verify.py', str(repo)]) == 1
|
||||
result = av.read_pending(av.pending_path(repo))
|
||||
assert result['status'] == 'rollback_failed' and result['detail'] == 'boom'
|
||||
|
||||
|
||||
def test_units_installers_and_updater_agree():
|
||||
"""The request file, the copied verifier, the unit names and the places
|
||||
that install them must all line up, or the health check silently never
|
||||
starts on real devices -- and code updates stay paused."""
|
||||
from web_interface import auto_update as au
|
||||
from src import auto_update_setup as aus
|
||||
from src.startup_validator import StartupValidator
|
||||
|
||||
service = (ROOT / 'systemd' / aus.SERVICE_UNIT).read_text(encoding='utf-8')
|
||||
path = (ROOT / 'systemd' / aus.PATH_UNIT).read_text(encoding='utf-8')
|
||||
request = f'__PROJECT_ROOT_DIR__/{au.REQUEST_REL.as_posix()}'
|
||||
assert f'PathExists={request}' in path
|
||||
assert f'Unit={aus.SERVICE_UNIT}' in path
|
||||
assert f'ExecStartPre=/bin/rm -f "{request}"' in service, "the request must be consumed or the path unit re-fires"
|
||||
assert f'"__PROJECT_ROOT_DIR__/{au.VERIFIER_COPY_REL.as_posix()}" "__PROJECT_ROOT_DIR__"' in service
|
||||
assert au.PENDING_REL.name == av.PENDING_NAME
|
||||
assert au.PATH_UNIT == aus.PATH_UNIT and au.SETUP_RESULT_REL == aus.RESULT_REL
|
||||
|
||||
for installer in ('scripts/install/install_web_service.sh', 'scripts/install/install_service.sh'):
|
||||
text = (ROOT / installer).read_text(encoding='utf-8')
|
||||
assert 'ledmatrix-update-verify.service ledmatrix-update-verify.path' in text, installer
|
||||
assert 'enable --now ledmatrix-update-verify.path' in text, installer
|
||||
for unit in aus.UNITS:
|
||||
assert (f'systemd/{unit}', f'/etc/systemd/system/{unit}') in StartupValidator._UNITS
|
||||
|
||||
# Triggering takes no privilege any more; no sudoers rule should linger.
|
||||
for sudoers in ('scripts/install/configure_web_sudo.sh', 'first_time_install.sh'):
|
||||
assert not re.search(r'NOPASSWD:.*update-verify', (ROOT / sudoers).read_text(encoding='utf-8')), sudoers
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Regression: the native color input in the color-picker widget must have
|
||||
an accessible name independent of showHexInput.
|
||||
|
||||
CodeRabbit flagged (PR #568) that the <input type="color"> only carried a
|
||||
`title` attribute -- screen readers don't reliably announce `title`, and
|
||||
when showHexInput is false there is no other label naming the control.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
WIDGET_JS = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "web_interface" / "static" / "v3" / "js" / "widgets" / "color-picker.js"
|
||||
)
|
||||
|
||||
|
||||
def test_native_color_input_has_aria_label():
|
||||
source = WIDGET_JS.read_text(encoding="utf-8")
|
||||
start = source.index('<input type="color"')
|
||||
end = source.index(">", start)
|
||||
tag = source[start:end]
|
||||
assert 'aria-label=' in tag, (
|
||||
"native color <input> lost its accessible name; screen readers need "
|
||||
"aria-label since it has no associated <label> and showHexInput can "
|
||||
"be false"
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""The web preview must be the size DisplayManager actually renders at.
|
||||
|
||||
Before src/display_geometry.py, the preview endpoints computed
|
||||
``cols * chain_length`` by ``rows * parallel`` themselves, ignored
|
||||
``display.double_sided`` (so a double-sided panel previewed two screens side
|
||||
by side), and the ``chain_length`` fallback was 2 in DisplayManager but 1 in
|
||||
the Starlark magnify default and the sync handshake.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from src.display_geometry import (
|
||||
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||
logical_size, physical_size,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _config(hardware=None, double_sided=None):
|
||||
display = {'hardware': hardware or {}}
|
||||
if double_sided is not None:
|
||||
display['double_sided'] = double_sided
|
||||
return {'display': display}
|
||||
|
||||
|
||||
def test_defaults_match_the_config_template():
|
||||
template = json.loads(
|
||||
(REPO_ROOT / 'config' / 'config.template.json').read_text(encoding='utf-8'))
|
||||
hw = template['display']['hardware']
|
||||
assert (DEFAULT_ROWS, DEFAULT_COLS, DEFAULT_CHAIN_LENGTH, DEFAULT_PARALLEL) == (
|
||||
hw['rows'], hw['cols'], hw['chain_length'], hw['parallel'])
|
||||
|
||||
|
||||
def test_missing_hardware_uses_the_template_defaults():
|
||||
assert physical_size({}) == (128, 32)
|
||||
assert logical_size({}) == (128, 32)
|
||||
|
||||
|
||||
def test_parallel_multiplies_height():
|
||||
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 2})
|
||||
assert physical_size(cfg) == (128, 64)
|
||||
|
||||
|
||||
def test_double_sided_horizontal_previews_one_screen():
|
||||
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 4, 'parallel': 1},
|
||||
{'enabled': True, 'copies': 2, 'axis': 'horizontal'})
|
||||
assert physical_size(cfg) == (256, 32)
|
||||
assert logical_size(cfg) == (128, 32)
|
||||
|
||||
|
||||
def test_double_sided_vertical_splits_parallel():
|
||||
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 2},
|
||||
{'enabled': True, 'copies': 2, 'axis': 'vertical'})
|
||||
assert logical_size(cfg) == (128, 32)
|
||||
|
||||
|
||||
def test_double_sided_that_does_not_divide_falls_back_to_physical():
|
||||
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1},
|
||||
{'enabled': True, 'copies': 3, 'axis': 'horizontal'})
|
||||
assert logical_size(cfg) == (128, 32)
|
||||
|
||||
|
||||
def test_disabled_double_sided_is_ignored():
|
||||
cfg = _config({'chain_length': 4}, {'enabled': False, 'copies': 2})
|
||||
assert logical_size(cfg) == (256, 32)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('display', ['oops', ['a'], 1, {'hardware': 'oops'},
|
||||
{'hardware': ['a']}])
|
||||
def test_non_mapping_display_config_uses_the_defaults(display):
|
||||
assert physical_size({'display': display}) == (128, 32)
|
||||
assert logical_size({'display': display}) == (128, 32)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ['rows', 'cols', 'chain_length', 'parallel'])
|
||||
def test_infinite_hardware_value_raises_value_error(key):
|
||||
# json.loads accepts Infinity; int(inf) raises OverflowError, which the
|
||||
# Starlark magnify default and the preview stream did not catch (HTTP 500).
|
||||
cfg = _config({key: json.loads('Infinity')})
|
||||
with pytest.raises(ValueError):
|
||||
physical_size(cfg)
|
||||
with pytest.raises(ValueError):
|
||||
logical_size(cfg)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def display_client(monkeypatch):
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
config_manager = MagicMock()
|
||||
monkeypatch.setattr(api_v3, 'config_manager', config_manager, raising=False)
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||
with app.test_client() as client:
|
||||
yield client, config_manager
|
||||
|
||||
|
||||
def test_display_current_reports_the_logical_size(display_client):
|
||||
client, config_manager = display_client
|
||||
config_manager.load_config.return_value = _config(
|
||||
{'rows': 32, 'cols': 64, 'chain_length': 4, 'parallel': 2},
|
||||
{'enabled': True, 'copies': 2, 'axis': 'horizontal'})
|
||||
|
||||
data = client.get('/api/v3/display/current').get_json()['data']
|
||||
|
||||
assert (data['width'], data['height']) == (128, 64)
|
||||
|
||||
|
||||
def test_display_current_defaults_chain_length_like_display_manager(display_client):
|
||||
client, config_manager = display_client
|
||||
config_manager.load_config.return_value = _config({'rows': 32, 'cols': 64})
|
||||
|
||||
data = client.get('/api/v3/display/current').get_json()['data']
|
||||
|
||||
assert (data['width'], data['height']) == (64 * DEFAULT_CHAIN_LENGTH, 32)
|
||||
|
||||
|
||||
def test_display_current_falls_back_to_the_shared_default(display_client):
|
||||
client, config_manager = display_client
|
||||
config_manager.load_config.side_effect = ValueError('unreadable')
|
||||
|
||||
data = client.get('/api/v3/display/current').get_json()['data']
|
||||
|
||||
assert (data['width'], data['height']) == logical_size({}) == (128, 32)
|
||||
|
||||
|
||||
def test_preview_callers_do_not_rederive_the_size():
|
||||
"""Every preview/size caller goes through display_geometry, so none of
|
||||
them can drift back to a private chain_length default."""
|
||||
for rel in ('web_interface/app.py',
|
||||
'web_interface/blueprints/api_v3/display.py',
|
||||
'web_interface/blueprints/api_v3/__init__.py',
|
||||
'src/common/sync_manager.py',
|
||||
'src/display_manager.py'):
|
||||
source = (REPO_ROOT / rel).read_text(encoding='utf-8')
|
||||
assert "get('chain_length', 1)" not in source, rel
|
||||
assert 'get("chain_length", 1)' not in source, rel
|
||||
assert 'cols * chain_length' not in source, rel
|
||||
@@ -25,6 +25,7 @@ from PIL import ImageFont
|
||||
|
||||
from src.element_style import (
|
||||
alias_keys,
|
||||
element_color,
|
||||
ElementStyleResolver,
|
||||
_load_font_sized,
|
||||
native_bdf_size,
|
||||
@@ -306,6 +307,18 @@ class TestUserOverrides:
|
||||
assert not style.user_forced
|
||||
assert style.color == (255, 0, 0)
|
||||
|
||||
def test_color_override_clamps_out_of_range_components(self, style_schema_path):
|
||||
"""The resolver path normalizes colour separately from element_color
|
||||
(see src/element_style.py's _resolve) -- a rejection there reads as
|
||||
"not configured" and the element would silently get the default
|
||||
colour back instead of the clamped one the user asked for."""
|
||||
config = {"customization": {"title_text": {"text_color": [999, -5, 20]}}}
|
||||
style = _resolver(config, style_schema_path).style(
|
||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8, classic_color=(255, 255, 255))
|
||||
assert style.user_forced_color
|
||||
assert style.color == (255, 0, 20)
|
||||
|
||||
def test_offsets(self, style_schema_path):
|
||||
config = {"customization": {"layout": {
|
||||
"title_text": {"x_offset": 4, "y_offset": -2}}}}
|
||||
@@ -1270,3 +1283,49 @@ class TestAdoptedModes:
|
||||
font = self._live()["score_text"]["properties"]["font"]
|
||||
assert font["x-options"]["maxFixedSize"] == 16
|
||||
assert "null" in font["type"]
|
||||
|
||||
|
||||
class TestElementColorClamps:
|
||||
"""An out-of-range component is clamped, not thrown away.
|
||||
|
||||
#569 fixed this in _normalize_color and #572 covered the resolver path,
|
||||
whose own test notes that "the resolver path normalizes colour separately
|
||||
from element_color". This covers the other one: the stateless element_color()
|
||||
that src.common.sports_card delegates to, which is the path all nine
|
||||
scoreboard plugins take for every per-element colour they draw.
|
||||
|
||||
It regressed once already. element_color() moved here with the per-element
|
||||
customization framework, the coercion rejected out-of-range components where
|
||||
the reader it replaced clamped them, and rejection reads as "not configured"
|
||||
-- so a single component over 255 painted the element white while the user's
|
||||
chosen colour sat in their config. Every scoreboard's
|
||||
test_element_text_colors.py failed on it.
|
||||
"""
|
||||
|
||||
CFG = {"customization": {
|
||||
"rank_text": {"text_color": [999, -5, 20]},
|
||||
"score_text": {"text_color": "#FF0000"},
|
||||
"period_text": {"text_color": [0, 255, 255]},
|
||||
"team_name": {"text_color": "nonsense"},
|
||||
}}
|
||||
|
||||
def test_out_of_range_components_are_clamped(self):
|
||||
assert element_color(self.CFG, "rank_text") == (255, 0, 20)
|
||||
|
||||
def test_in_range_values_are_unchanged(self):
|
||||
assert element_color(self.CFG, "period_text") == (0, 255, 255)
|
||||
|
||||
def test_hex_still_parses(self):
|
||||
assert element_color(self.CFG, "score_text") == (255, 0, 0)
|
||||
|
||||
def test_an_unparseable_value_still_falls_back(self):
|
||||
assert element_color(self.CFG, "team_name") == (255, 255, 255)
|
||||
|
||||
def test_a_missing_element_still_falls_back(self):
|
||||
assert element_color(self.CFG, "detail_text") == (255, 255, 255)
|
||||
|
||||
def test_it_agrees_with_coerce_rgb(self):
|
||||
from src.common.sports_card import coerce_rgb
|
||||
for value in ([999, -5, 20], [300, 300, 300], [-1, -1, -1], [0, 128, 255]):
|
||||
cfg = {"customization": {"rank_text": {"text_color": value}}}
|
||||
assert element_color(cfg, "rank_text") == coerce_rgb(value, (0, 0, 0)), value
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Tests for the rpi-rgb-led-matrix checkout helpers in first_time_install.sh, and
|
||||
for the contract between scripts/install/one-shot-install.sh and that script.
|
||||
|
||||
Background: the submodule was pinned to a commit whose RP1 backend emits the
|
||||
ARMv7-only `dmb ishst` instruction, so the build failed on every ARMv6 board
|
||||
(Pi Zero / Zero W / Pi 1). Bumping the pin fixes fresh installs, but `git pull`
|
||||
never moves an existing submodule checkout, and earlier installers ran the
|
||||
submodule git commands as root, leaving .git/modules/<submodule> root-owned and
|
||||
the user locked out of their own checkout.
|
||||
|
||||
These cover everything that runs without root. The ownership-repair path
|
||||
(root, a second user, chown) mutates the system, so -- as with the swap helpers
|
||||
in test_install_lowmem.py -- it is exercised manually instead.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
INSTALLER = ROOT / "first_time_install.sh"
|
||||
ONE_SHOT = ROOT / "scripts" / "install" / "one-shot-install.sh"
|
||||
|
||||
BEGIN = "# --- rpi-rgb-led-matrix checkout helpers"
|
||||
END = "# --- end rpi-rgb-led-matrix checkout helpers"
|
||||
SUB = "rpi-rgb-led-matrix-master"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"), reason="exercises the installer's bash under Linux"
|
||||
)
|
||||
|
||||
|
||||
def helper_block() -> str:
|
||||
text = INSTALLER.read_text(encoding="utf-8")
|
||||
assert text.count(BEGIN) == 1 and text.count(END) == 1, "helper block markers missing or duplicated"
|
||||
return text[text.index(BEGIN): text.index(END)]
|
||||
|
||||
|
||||
def git(*args: str, cwd: Path, env: dict) -> str:
|
||||
result = subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True)
|
||||
assert result.returncode == 0, f"git {' '.join(args)} failed: {result.stderr}"
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_env(tmp_path):
|
||||
config = tmp_path / "gitconfig"
|
||||
config.write_text(
|
||||
"[user]\n\tname = t\n\temail = t@t\n"
|
||||
"[protocol \"file\"]\n\tallow = always\n"
|
||||
"[init]\n\tdefaultBranch = master\n"
|
||||
"[advice]\n\tdetachedHead = false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return {
|
||||
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
"HOME": str(tmp_path),
|
||||
"GIT_CONFIG_GLOBAL": str(config),
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upstream(tmp_path, git_env):
|
||||
"""A stand-in for hzeller/rpi-rgb-led-matrix with commits A -> B -> C."""
|
||||
up = tmp_path / "upstream"
|
||||
up.mkdir()
|
||||
git("init", "-q", ".", cwd=up, env=git_env)
|
||||
commits = {}
|
||||
for name, filename in (("A", "Makefile"), ("B", "b"), ("C", "c")):
|
||||
(up / filename).write_text(name, encoding="utf-8")
|
||||
git("add", ".", cwd=up, env=git_env)
|
||||
git("commit", "-qm", name, cwd=up, env=git_env)
|
||||
commits[name] = git("rev-parse", "HEAD", cwd=up, env=git_env)
|
||||
return up, commits
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_project(tmp_path, git_env, upstream):
|
||||
"""make_project(checkout) -> project pinned at B, submodule checked out at `checkout`."""
|
||||
up, commits = upstream
|
||||
|
||||
def _make(checkout: str) -> Path:
|
||||
project = tmp_path / f"project-{checkout}"
|
||||
project.mkdir()
|
||||
git("init", "-q", ".", cwd=project, env=git_env)
|
||||
git("submodule", "add", "-q", str(up), SUB, cwd=project, env=git_env)
|
||||
git("checkout", "-q", commits["B"], cwd=project / SUB, env=git_env)
|
||||
git("add", SUB, cwd=project, env=git_env)
|
||||
git("commit", "-qm", "pin", cwd=project, env=git_env)
|
||||
git("checkout", "-q", commits[checkout], cwd=project / SUB, env=git_env)
|
||||
return project
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
def run_helpers(snippet: str, project: Path, env: dict) -> subprocess.CompletedProcess:
|
||||
"""Run a snippet against the helpers under the installer's own strict mode."""
|
||||
script = (
|
||||
"set -Eeuo pipefail\n"
|
||||
"trap 'echo ERR_TRAP_FIRED >&2; exit 99' ERR\n"
|
||||
f"{helper_block()}\n"
|
||||
f'PROJECT_ROOT_DIR="{project}"\n'
|
||||
f"{snippet}\n"
|
||||
)
|
||||
return subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env)
|
||||
|
||||
|
||||
def head_of(project: Path, env: dict) -> str:
|
||||
return git("rev-parse", "HEAD", cwd=project / SUB, env=env)
|
||||
|
||||
|
||||
class TestInstallerStructure:
|
||||
def test_installer_is_syntactically_valid(self):
|
||||
result = subprocess.run(["bash", "-n", str(INSTALLER)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
def test_helper_block_defines_the_helpers(self):
|
||||
block = helper_block()
|
||||
for name in ("_rgb_repo_owner", "_git_as_repo_owner", "_reclaim_rgb_checkout", "_sync_rgb_submodule"):
|
||||
assert re.search(rf"^{name}\(\) \{{", block, re.M), f"{name} is not defined in the helper block"
|
||||
|
||||
def test_every_called_underscore_function_is_defined_before_use(self):
|
||||
# A renamed helper with a stale call site aborts the installer with
|
||||
# "command not found" under set -e -- the users this protects are the
|
||||
# ones least able to recover from that.
|
||||
lines = INSTALLER.read_text(encoding="utf-8").splitlines()
|
||||
defined_at = {}
|
||||
for number, line in enumerate(lines, 1):
|
||||
match = re.match(r"\s*(_[A-Za-z0-9_]+)\(\) \{", line)
|
||||
if match:
|
||||
defined_at.setdefault(match.group(1), number)
|
||||
# A name in command position; `_name=value` is an assignment and
|
||||
# `_name() {` a definition, so neither counts as a call.
|
||||
call = re.compile(r"(?:^\s*|\bretry\s+|\bif\s+!?\s*|&&\s*|\|\|\s*|\$\()(_[A-Za-z0-9_]+)\b(?!\(\)|=|\[)")
|
||||
for number, line in enumerate(lines, 1):
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
for name in call.findall(line):
|
||||
if name.startswith("_") and not re.match(r"_[A-Z0-9_]+$", name): # skip $_VARS
|
||||
assert name in defined_at, f"line {number} calls undefined function {name}"
|
||||
# Functions defined inside another function body run later;
|
||||
# top-level calls must come after the definition.
|
||||
if not line.startswith((" ", "\t")):
|
||||
assert defined_at[name] < number, f"line {number} calls {name} before it is defined"
|
||||
|
||||
|
||||
class TestSyncSubmodule:
|
||||
def test_checkout_behind_the_pin_is_moved_forward(self, make_project, upstream, git_env):
|
||||
project = make_project("A")
|
||||
result = run_helpers("_reclaim_rgb_checkout; _sync_rgb_submodule", project, git_env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert head_of(project, git_env) == upstream[1]["B"]
|
||||
|
||||
def test_checkout_newer_than_the_pin_is_left_alone(self, make_project, upstream, git_env):
|
||||
# What `git submodule update --remote` -- advice the installer itself
|
||||
# prints for Pi 5 -- leaves behind. Must never be rolled back.
|
||||
project = make_project("C")
|
||||
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert head_of(project, git_env) == upstream[1]["C"]
|
||||
assert "leaving it as is" in result.stdout
|
||||
|
||||
def test_checkout_at_the_pin_is_a_silent_no_op(self, make_project, upstream, git_env):
|
||||
project = make_project("B")
|
||||
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == ""
|
||||
assert head_of(project, git_env) == upstream[1]["B"]
|
||||
|
||||
def test_unreachable_pin_warns_and_keeps_the_checkout(self, make_project, upstream, git_env):
|
||||
project = make_project("A")
|
||||
ghost = "0123456789abcdef0123456789abcdef01234567"
|
||||
git("update-index", "--cacheinfo", f"160000,{ghost},{SUB}", cwd=project, env=git_env)
|
||||
git("commit", "-qm", "ghost pin", cwd=project, env=git_env)
|
||||
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "ERR_TRAP_FIRED" not in result.stderr
|
||||
assert "Could not update" in result.stdout
|
||||
assert head_of(project, git_env) == upstream[1]["A"]
|
||||
|
||||
def test_local_files_blocking_the_checkout_are_not_clobbered(self, make_project, git_env):
|
||||
project = make_project("A")
|
||||
(project / SUB / "b").write_text("mine", encoding="utf-8") # B adds a tracked `b`
|
||||
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert (project / SUB / "b").read_text(encoding="utf-8") == "mine"
|
||||
|
||||
def test_directory_without_git_metadata_is_a_no_op(self, make_project, git_env):
|
||||
project = make_project("A")
|
||||
subprocess.run(["rm", "-rf", str(project / SUB / ".git"), str(project / ".git" / "modules")], check=True)
|
||||
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == ""
|
||||
|
||||
def test_project_without_the_submodule_configured_is_a_no_op(self, tmp_path, git_env):
|
||||
project = tmp_path / "plain"
|
||||
(project / SUB).mkdir(parents=True)
|
||||
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
class TestOwnershipHelpersWithoutRoot:
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="covers the non-root path")
|
||||
def test_reclaim_is_a_no_op_and_git_runs_directly(self, make_project, git_env):
|
||||
project = make_project("B")
|
||||
result = run_helpers("_reclaim_rgb_checkout; _git_as_repo_owner --version", project, git_env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.startswith("git version")
|
||||
|
||||
|
||||
class TestOneShotContract:
|
||||
"""one-shot-install.sh clones the repo and hands off to first_time_install.sh."""
|
||||
|
||||
def test_one_shot_invokes_the_installer_at_the_repo_root(self):
|
||||
one_shot = ONE_SHOT.read_text(encoding="utf-8")
|
||||
assert "first_time_install.sh -y" in one_shot
|
||||
assert INSTALLER.is_file()
|
||||
|
||||
def test_installer_accepts_everything_the_one_shot_passes(self):
|
||||
one_shot = ONE_SHOT.read_text(encoding="utf-8")
|
||||
installer = INSTALLER.read_text(encoding="utf-8")
|
||||
assert re.search(r"^\s*-y\|--yes\)", installer, re.M), "installer no longer parses -y"
|
||||
for var in sorted(set(re.findall(r"\b(LEDMATRIX_[A-Z_]+)=", one_shot))):
|
||||
assert var in installer, f"one-shot passes {var}, which first_time_install.sh never reads"
|
||||
|
||||
def test_installer_initialises_the_submodule_itself(self):
|
||||
# The one-shot clones without --recurse-submodules, so the installer is
|
||||
# the only thing that ever fetches the matrix library.
|
||||
assert "--recurse-submodules" not in ONE_SHOT.read_text(encoding="utf-8")
|
||||
installer = INSTALLER.read_text(encoding="utf-8")
|
||||
assert re.search(rf"submodule update --init --recursive {SUB}", installer)
|
||||
@@ -387,9 +387,81 @@ class TestFrameStatsPercentiles:
|
||||
|
||||
def test_log_frame_rate_emits_the_line_and_clears_the_window(self, helper):
|
||||
helper._window = [0.010] * 20
|
||||
helper.last_frame_time = time.time() # arm the clock; see below
|
||||
helper.last_fps_log_time = 0.0 # force the 5s boundary
|
||||
with patch.object(helper.logger, "info") as info:
|
||||
helper.log_frame_rate()
|
||||
assert info.called
|
||||
assert "Scroll frame stats" in info.call_args[0][0]
|
||||
assert helper._window == []
|
||||
|
||||
|
||||
class TestIdleGapIsNotAFrame:
|
||||
"""The first frame of a scroll has no predecessor, so timing one measures
|
||||
the idle gap since the last scroll rather than a frame.
|
||||
|
||||
Left in, that gap lands in the max field of otherwise healthy windows and
|
||||
counts as one stall per scroll start -- about 0.2% at 500 frames to a
|
||||
window, which is the same order as the real stall rates it sits next to.
|
||||
The stall rate is the number used to judge whether a scroll change worked,
|
||||
so it has to be clean.
|
||||
"""
|
||||
|
||||
def test_clock_starts_unarmed(self, helper):
|
||||
assert helper.last_frame_time is None
|
||||
|
||||
def test_first_frame_seeds_the_clock_without_sampling(self, helper):
|
||||
helper.log_frame_rate()
|
||||
assert helper.last_frame_time is not None
|
||||
assert helper._window == []
|
||||
assert helper.frame_times == []
|
||||
|
||||
def test_second_frame_is_sampled(self, helper):
|
||||
helper.log_frame_rate()
|
||||
helper.log_frame_rate()
|
||||
assert len(helper._window) == 1
|
||||
|
||||
def test_reset_scroll_disarms_the_clock(self, helper):
|
||||
helper.log_frame_rate()
|
||||
assert helper.last_frame_time is not None
|
||||
helper.reset_scroll()
|
||||
assert helper.last_frame_time is None
|
||||
|
||||
def test_gap_longer_than_the_log_interval_is_dropped(self, helper):
|
||||
"""Covers callers that scroll without ever calling reset_scroll()."""
|
||||
helper.last_frame_time = time.time() - 137.0 # a real observed gap
|
||||
helper.log_frame_rate()
|
||||
assert helper._window == []
|
||||
assert helper.frame_times == []
|
||||
|
||||
def test_a_gap_does_not_reach_the_stats_line(self, helper):
|
||||
helper.last_frame_time = time.time() - 137.0
|
||||
helper.last_fps_log_time = 0.0 # the 5s boundary is also due
|
||||
with patch.object(helper.logger, "info") as info:
|
||||
helper.log_frame_rate()
|
||||
assert not info.called, "the idle gap was reported as a frame"
|
||||
|
||||
def test_window_of_only_gaps_logs_nothing(self, helper):
|
||||
"""A window whose every sample was dropped has nothing to report --
|
||||
and reporting the gap itself is the bug this guards."""
|
||||
helper.last_fps_log_time = 0.0
|
||||
with patch.object(helper.logger, "info") as info:
|
||||
helper.log_frame_rate() # seeds
|
||||
helper.last_frame_time = time.time() - 137.0
|
||||
helper.log_frame_rate() # dropped
|
||||
assert not info.called
|
||||
|
||||
def test_seeding_restarts_the_window_timer(self, helper):
|
||||
"""A new scroll should not open by reporting a one-frame window."""
|
||||
helper.last_fps_log_time = 0.0 # boundary long overdue
|
||||
helper.log_frame_rate() # seeds
|
||||
with patch.object(helper.logger, "info") as info:
|
||||
helper.log_frame_rate() # first real frame
|
||||
assert not info.called
|
||||
assert len(helper._window) == 1
|
||||
|
||||
def test_a_normal_frame_still_counts(self, helper):
|
||||
helper.last_frame_time = time.time() - 0.010
|
||||
helper.log_frame_rate()
|
||||
assert len(helper._window) == 1
|
||||
assert helper._window[0] == pytest.approx(0.010, abs=0.005)
|
||||
|
||||
@@ -53,6 +53,14 @@ class TestColour:
|
||||
cfg = {"customization": {"score_text": {"text_color": value}}}
|
||||
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
|
||||
|
||||
def test_out_of_range_components_are_clamped_not_rejected(self):
|
||||
"""The readers this replaced clamped, and the eight scoreboards' own
|
||||
tests pin it. Rejecting instead turned a typo'd [999, -5, 20] -- a
|
||||
bright red -- into the default white, which is a colour the user never
|
||||
asked for rather than the one they nearly asked for."""
|
||||
cfg = {"customization": {"score_text": {"text_color": [999, -5, 20]}}}
|
||||
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (255, 0, 20)
|
||||
|
||||
def test_coerce_rgb_clamps_rather_than_rejecting(self):
|
||||
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)
|
||||
|
||||
|
||||
@@ -164,6 +164,18 @@ def test_static_asset_served(client, asset):
|
||||
assert len(resp.data) > 0
|
||||
|
||||
|
||||
def test_widget_bundle_is_served_and_requested(client):
|
||||
"""base.html loads every widget through one versioned bundle request."""
|
||||
page = client.get("/").get_data(as_text=True)
|
||||
assert "/assets/widgets.js?v=" in page
|
||||
resp = client.get("/assets/widgets.js")
|
||||
assert resp.status_code == 200
|
||||
assert resp.mimetype == "application/javascript"
|
||||
body = resp.get_data(as_text=True)
|
||||
assert body.index("/* registry.js */") < body.index("/* notification.js */")
|
||||
assert "/* json-file-manager.js */" in body
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -37,11 +37,11 @@ def test_every_used_breakpoint_class_is_defined():
|
||||
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 attr in class_attr.findall(path.read_text(encoding="utf-8")):
|
||||
for m in bp_class.finditer(attr):
|
||||
used.add(m.group(0))
|
||||
|
||||
css = APP_CSS.read_text()
|
||||
css = APP_CSS.read_text(encoding="utf-8")
|
||||
defined = {
|
||||
m.group(0).lstrip(".").replace("\\:", ":")
|
||||
for m in re.finditer(
|
||||
@@ -56,13 +56,90 @@ def test_every_used_breakpoint_class_is_defined():
|
||||
)
|
||||
|
||||
|
||||
_VARIANTS = (
|
||||
r"(?:(?:sm|md|lg|xl|2xl|hover|focus|focus-visible|active|disabled|first|last"
|
||||
r"|group-hover|peer-checked|peer-focus|placeholder|file):)*"
|
||||
)
|
||||
_UTILITY = re.compile(
|
||||
"^" + _VARIANTS + r"-?(?:"
|
||||
r"(?:block|inline|inline-block|inline-flex|flex|grid|hidden|table|contents"
|
||||
r"|static|fixed|absolute|relative|sticky|truncate|sr-only|transform"
|
||||
r"|uppercase|lowercase|capitalize|italic|underline|line-through|border|shadow"
|
||||
r"|rounded|transition|grow|shrink|group|peer)"
|
||||
r"|(?:m|p)[trblxy]?-[\w./]+"
|
||||
r"|(?:w|h|min-w|min-h|max-w|max-h|gap|gap-x|gap-y|space-x|space-y|top|right"
|
||||
r"|bottom|left|inset|inset-x|inset-y|z|opacity|duration|scale|rotate"
|
||||
r"|translate-x|translate-y|grid-cols|col-span|leading|tracking)-[\w./\[\]]+"
|
||||
r"|(?:text|bg|border|divide|ring|placeholder|accent)-(?:[a-z]+-\d{2,3}(?:/\d+)?"
|
||||
r"|white|black|transparent|xs|sm|base|md|lg|xl|[2-5]xl|left|center|right"
|
||||
r"|opacity-\d+|[trblxy](?:-\d)?|\d|dashed|dotted|offset-\d)"
|
||||
r"|font-(?:mono|sans|serif|thin|light|normal|medium|semibold|bold|extrabold)"
|
||||
r"|select-(?:none|all|text|auto)"
|
||||
r"|(?:rounded|shadow|items|justify|self|flex|whitespace|break|object|list"
|
||||
r"|overflow|overflow-x|overflow-y|cursor|pointer-events|appearance"
|
||||
r"|ease|divide|line-clamp)-[a-z0-9]+(?:-[a-z0-9]+)?(?:\[[^\]]+\])?"
|
||||
r")$"
|
||||
)
|
||||
|
||||
|
||||
def _css_light_classes(css):
|
||||
"""Classes that get a rule outside [data-theme="dark"]."""
|
||||
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
defined = set()
|
||||
for block in re.finditer(r"([^{}]+)\{", css):
|
||||
for sel in block.group(1).split(","):
|
||||
sel = sel.strip()
|
||||
if sel.startswith("@") or sel.startswith('[data-theme="dark"]'):
|
||||
continue
|
||||
for m in re.finditer(r"\.((?:\\.|[\w-])+)", sel):
|
||||
defined.add(m.group(1).replace("\\", ""))
|
||||
return defined
|
||||
|
||||
|
||||
def test_every_used_utility_class_is_defined():
|
||||
"""app.css is the whole stylesheet (no Tailwind build), so a utility class
|
||||
it doesn't define silently does nothing. `.hidden` was missing for years,
|
||||
which broke every JS show/hide toggle. Scans templates and static JS."""
|
||||
attr = re.compile(r"""(?:class|className)\s*[=:]\s*(["'`])(.*?)\1""", re.S)
|
||||
class_list = re.compile(r"classList\.(?:add|remove|toggle)\(([^)]*)\)")
|
||||
template_expr = re.compile(r"\{\{.*?\}\}|\{%.*?%\}|\$\{[^}]*\}", re.S)
|
||||
files = list(_template_files()) + [
|
||||
p for p in (STATIC / "v3").rglob("*.js")
|
||||
if "vendor" not in p.parts and not p.name.endswith(".min.js")
|
||||
]
|
||||
used = {}
|
||||
for path in files:
|
||||
text = template_expr.sub(" ", path.read_text(encoding="utf-8"))
|
||||
chunks = [m.group(2) for m in attr.finditer(text)]
|
||||
chunks += re.findall(r"""['"]([^'"]*)['"]""", " ".join(
|
||||
m.group(1) for m in class_list.finditer(text)))
|
||||
# :class="{ 'hidden': open }" and ternaries hold class names inside
|
||||
# string literals; read those as well as the raw chunk.
|
||||
bound = re.compile(r""":class\s*=\s*"([^"]*)""")
|
||||
for m in bound.finditer(text):
|
||||
chunks.append(m.group(1))
|
||||
for chunk in chunks:
|
||||
candidates = [chunk] + re.findall(r"""['`]([^'`]*)['`]""", chunk)
|
||||
for candidate in candidates:
|
||||
for token in candidate.split():
|
||||
if _UTILITY.match(token):
|
||||
used.setdefault(token, path.relative_to(PROJECT_ROOT))
|
||||
|
||||
defined = _css_light_classes(APP_CSS.read_text(encoding="utf-8"))
|
||||
missing = sorted(f"{cls} ({used[cls]})" for cls in used if cls not in defined)
|
||||
assert not missing, (
|
||||
"Utility classes used in templates/JS but not defined in app.css "
|
||||
f"(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()):
|
||||
for filename in ref.findall(path.read_text(encoding="utf-8")):
|
||||
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}"
|
||||
@@ -73,7 +150,7 @@ def test_js_files_calling_debuglog_declare_the_global():
|
||||
for path in sorted((STATIC / "v3").rglob("*.js")):
|
||||
if "vendor" in path.parts:
|
||||
continue
|
||||
text = path.read_text()
|
||||
text = path.read_text(encoding="utf-8")
|
||||
# Calls debugLog( but neither defines it nor declares the global
|
||||
calls = re.search(r"(?<![.\w])debugLog\(", text)
|
||||
defines = "window.debugLog" in text
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""The two installers that write /etc/sudoers.d/ledmatrix_web must agree.
|
||||
|
||||
first_time_install.sh (Step 10, a heredoc) and scripts/install/configure_web_sudo.sh
|
||||
(a block of echo lines) each generate the web user's sudo allow-list. They
|
||||
drifted: configure_web_sudo.sh granted scripts/fix_perms/safe_pip_install.sh
|
||||
but first_time_install.sh did not, so on a device set up only by the first-time
|
||||
installer permission_utils.install_requirements_file could not use the root
|
||||
wrapper and fell back to a user-level install that root-run ledmatrix.service
|
||||
may not see (and the auto-update rollback reported its reinstall as failed).
|
||||
|
||||
This compares the granted command sets after normalising the spellings that
|
||||
differ between the files but expand identically at install time:
|
||||
$WEB_USER/$ACTUAL_USER, $PROJECT_ROOT/$PROJECT_ROOT_DIR, and the helper-path
|
||||
variables configure_web_sudo.sh defines ($SAFE_RM_PATH, ...).
|
||||
|
||||
It also checks that every fix_perms helper granted via sudo is hardened to
|
||||
root:root in both scripts -- and, in first_time_install.sh, after Step 11's
|
||||
project-wide chown to the user, which would otherwise undo it.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FIRST_TIME = ROOT / "first_time_install.sh"
|
||||
CONFIGURE = ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
||||
|
||||
#: Grants that intentionally exist in only one installer, as normalised
|
||||
#: commands. There are none today; add one here with a reason rather than
|
||||
#: loosening the comparison.
|
||||
ONLY_IN_FIRST_TIME = frozenset()
|
||||
ONLY_IN_CONFIGURE = frozenset()
|
||||
|
||||
|
||||
def _text(path):
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def _web_sudoers_section(path):
|
||||
"""The part of the script that writes the ledmatrix_web allow-list.
|
||||
|
||||
first_time_install.sh also writes other files later (WiFi permissions are
|
||||
delegated to a separate script, but keep this robust against future
|
||||
additions), so restrict it to Step 10.
|
||||
"""
|
||||
text = _text(path)
|
||||
if path == FIRST_TIME:
|
||||
start = text.index('CURRENT_STEP="Configure passwordless sudo access"')
|
||||
end = text.index('CURRENT_STEP="Configure WiFi management permissions"')
|
||||
return text[start:end]
|
||||
return text
|
||||
|
||||
|
||||
def _variables(text):
|
||||
"""Simple NAME="..." assignments, so $SAFE_RM_PATH can be expanded."""
|
||||
return {m.group(1): m.group(2)
|
||||
for m in re.finditer(r'^\s*([A-Z_]+)="([^"$]*\$[^"]*)"\s*$', text, re.M)}
|
||||
|
||||
|
||||
def _normalise(command, variables):
|
||||
for _ in range(3): # helper paths reference $PROJECT_ROOT
|
||||
command = re.sub(r"\$\{?([A-Z][A-Z0-9_]*)\}?",
|
||||
lambda m: variables.get(m.group(1), m.group(0)), command)
|
||||
command = command.replace("$PROJECT_ROOT_DIR", "$PROJECT_ROOT")
|
||||
return " ".join(command.split())
|
||||
|
||||
|
||||
def _grants(path):
|
||||
"""{(tags, command)} for every ledmatrix_web rule the script writes."""
|
||||
section = _web_sudoers_section(path)
|
||||
variables = _variables(_text(path))
|
||||
grants = set()
|
||||
for line in section.splitlines():
|
||||
m = re.search(r'\$(?:WEB_USER|ACTUAL_USER) ALL=\(ALL\) (NOPASSWD:(?:NOEXEC:)?)\s*(.*)$',
|
||||
line)
|
||||
if not m:
|
||||
continue
|
||||
command = m.group(2).rstrip().rstrip('"').rstrip()
|
||||
grants.add((m.group(1), _normalise(command, variables)))
|
||||
return grants
|
||||
|
||||
|
||||
def test_both_installers_generate_rules():
|
||||
# Guards against the parser silently matching nothing in either file.
|
||||
assert len(_grants(FIRST_TIME)) >= 15
|
||||
assert len(_grants(CONFIGURE)) >= 15
|
||||
|
||||
|
||||
def test_installers_grant_the_same_commands():
|
||||
first = _grants(FIRST_TIME)
|
||||
configure = _grants(CONFIGURE)
|
||||
only_first = {c for c in first - configure if c[1] not in ONLY_IN_FIRST_TIME}
|
||||
only_configure = {c for c in configure - first if c[1] not in ONLY_IN_CONFIGURE}
|
||||
assert not only_first and not only_configure, (
|
||||
"ledmatrix_web sudoers drift between installers:\n"
|
||||
f" only in first_time_install.sh: {sorted(only_first)}\n"
|
||||
f" only in configure_web_sudo.sh: {sorted(only_configure)}")
|
||||
|
||||
|
||||
def test_pip_install_helper_is_granted():
|
||||
wanted = ("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *")
|
||||
assert wanted in _grants(FIRST_TIME)
|
||||
assert wanted in _grants(CONFIGURE)
|
||||
|
||||
|
||||
def _granted_helpers():
|
||||
helpers = set()
|
||||
for _, command in _grants(FIRST_TIME) | _grants(CONFIGURE):
|
||||
m = re.search(r"scripts/fix_perms/([\w.-]+\.sh)", command)
|
||||
if m:
|
||||
helpers.add(m.group(1))
|
||||
return helpers
|
||||
|
||||
|
||||
def test_every_granted_helper_is_hardened_in_configure_web_sudo():
|
||||
text = _text(CONFIGURE)
|
||||
variables = _variables(text)
|
||||
hardened = {Path(_normalise(m.group(2), variables)).name
|
||||
for m in re.finditer(r"sudo (chown root:root|chmod 755) \"?([^\"\s]+)", text)
|
||||
if m.group(1).startswith("chown")}
|
||||
assert _granted_helpers() <= hardened, _granted_helpers() - hardened
|
||||
|
||||
|
||||
def test_every_granted_helper_is_hardened_in_first_time_install_after_chown():
|
||||
text = _text(FIRST_TIME)
|
||||
project_chown = text.index('-exec chown -h "$ACTUAL_USER:$ACTUAL_USER"')
|
||||
loop = re.search(r"for helper in ([^;]+); do\n(.*?)\ndone", text, re.S)
|
||||
assert loop, "no helper-hardening loop in first_time_install.sh"
|
||||
assert "chown root:root" in loop.group(2) and "chmod 755" in loop.group(2)
|
||||
assert loop.start() > project_chown, (
|
||||
"helper hardening runs before Step 11's project-wide chown, which undoes it")
|
||||
assert _granted_helpers() <= set(loop.group(1).split())
|
||||
+73
-40
@@ -1,66 +1,99 @@
|
||||
"""Guard: every widget JS file must be loaded by base.html or explicitly allowlisted.
|
||||
"""Guard: every widget JS file must ship in the widget bundle or be allowlisted.
|
||||
|
||||
Widget files register themselves with LEDMatrixWidgets at load time; a file
|
||||
that exists but is never <script>-included silently breaks any plugin whose
|
||||
config schema declares that widget (the field renders as an empty container
|
||||
that polls the registry forever). base.html's widget list is maintained by
|
||||
hand, so this test keeps it honest.
|
||||
that exists but is never loaded silently breaks any plugin whose config schema
|
||||
declares that widget (the field renders as an empty container that polls the
|
||||
registry forever).
|
||||
|
||||
base.html loads them as one concatenated request (web_interface/widget_bundle.py),
|
||||
so BUNDLE_ORDER is the hand-maintained list this test keeps honest. It also
|
||||
checks that base.html actually requests the bundle, and that the bundle's
|
||||
concatenation order puts the registry and base class first.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from web_interface import widget_bundle # noqa: E402
|
||||
|
||||
WIDGETS_DIR = PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js' / 'widgets'
|
||||
BASE_HTML = PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'base.html'
|
||||
|
||||
# Matches url_for('static', filename='...') inside actual <script> tags.
|
||||
SCRIPT_SRC_RE = re.compile(
|
||||
r"""<script\s[^>]*src="\{\{\s*url_for\(\s*'static'\s*,\s*filename='([^']+)'\s*\)\s*\}\}[^"]*"""
|
||||
)
|
||||
|
||||
# Files that must NOT be script-included, with the reason.
|
||||
ALLOWLIST = {
|
||||
# Documentation example (docs/widget-guide.md); registers the name
|
||||
# 'color-picker' and would shadow the real color-picker.js if loaded.
|
||||
'example-color-picker.js',
|
||||
}
|
||||
# Files that must NOT be loaded, with the reason.
|
||||
ALLOWLIST = set(widget_bundle.EXCLUDED)
|
||||
|
||||
|
||||
def _included_widget_scripts() -> Set[str]:
|
||||
"""Return widget JS basenames referenced by real <script> tags in base.html."""
|
||||
base_html = BASE_HTML.read_text(encoding='utf-8')
|
||||
return {
|
||||
Path(filename).name
|
||||
for filename in SCRIPT_SRC_RE.findall(base_html)
|
||||
if filename.startswith('v3/js/widgets/')
|
||||
}
|
||||
def _bundled_names() -> Set[str]:
|
||||
return set(widget_bundle.BUNDLE_ORDER)
|
||||
|
||||
|
||||
def test_every_widget_script_is_included_in_base_html() -> None:
|
||||
"""Every non-allowlisted widget file must be loaded by a <script> tag."""
|
||||
def test_every_widget_script_is_bundled() -> None:
|
||||
"""Every non-allowlisted widget file must be in BUNDLE_ORDER."""
|
||||
assert WIDGETS_DIR.is_dir(), f'Widget directory missing: {WIDGETS_DIR}'
|
||||
included = _included_widget_scripts()
|
||||
assert included, 'No widget <script> tags found in base.html — regex or template drift?'
|
||||
bundled = _bundled_names()
|
||||
assert bundled, 'BUNDLE_ORDER is empty — widget_bundle drift?'
|
||||
missing = [
|
||||
js_file.name
|
||||
for js_file in sorted(WIDGETS_DIR.glob('*.js'))
|
||||
if js_file.name not in ALLOWLIST and js_file.name not in included
|
||||
if js_file.name not in ALLOWLIST and js_file.name not in bundled
|
||||
]
|
||||
assert not missing, (
|
||||
'Widget files exist but are never <script>-included in base.html '
|
||||
'(plugins declaring these widgets get blank config fields): '
|
||||
+ ', '.join(missing)
|
||||
+ '. Add a script tag to base.html or add the file to ALLOWLIST '
|
||||
'with a reason.'
|
||||
'Widget files exist but are never loaded (plugins declaring these '
|
||||
'widgets get blank config fields): ' + ', '.join(missing)
|
||||
+ '. Add the file to BUNDLE_ORDER in web_interface/widget_bundle.py, '
|
||||
'or to EXCLUDED with a reason.'
|
||||
)
|
||||
|
||||
|
||||
def test_allowlisted_widgets_are_not_included() -> None:
|
||||
"""Allowlisted (must-not-load) widget files must stay out of base.html."""
|
||||
included = _included_widget_scripts()
|
||||
wrongly_included = [name for name in ALLOWLIST if name in included]
|
||||
def test_allowlisted_widgets_are_not_bundled() -> None:
|
||||
"""Allowlisted (must-not-load) widget files stay out of the bundle."""
|
||||
wrongly_included = sorted(ALLOWLIST & _bundled_names())
|
||||
assert not wrongly_included, (
|
||||
'Allowlisted (must-not-load) widget files are script-included in '
|
||||
'base.html: ' + ', '.join(wrongly_included)
|
||||
'Allowlisted (must-not-load) widget files are in the bundle: '
|
||||
+ ', '.join(wrongly_included)
|
||||
)
|
||||
|
||||
|
||||
def test_bundle_order_lists_only_existing_files() -> None:
|
||||
"""A renamed or deleted widget must not linger in BUNDLE_ORDER."""
|
||||
stale = [name for name in widget_bundle.BUNDLE_ORDER
|
||||
if not (WIDGETS_DIR / name).is_file()]
|
||||
assert not stale, f'BUNDLE_ORDER names files that do not exist: {stale}'
|
||||
|
||||
|
||||
def test_registry_and_base_widget_load_first() -> None:
|
||||
"""Widgets call into the registry and base class as they load."""
|
||||
order = widget_bundle.BUNDLE_ORDER
|
||||
assert order[0] == 'registry.js', order[:3]
|
||||
assert order[1] == 'base-widget.js', order[:3]
|
||||
|
||||
|
||||
def test_base_html_requests_the_bundle() -> None:
|
||||
"""base.html must load the bundle (and no longer tag widgets one by one)."""
|
||||
base_html = BASE_HTML.read_text(encoding='utf-8')
|
||||
assert 'widgets_bundle_url()' in base_html, (
|
||||
'base.html does not request the widget bundle'
|
||||
)
|
||||
per_file = re.findall(
|
||||
r"""<script\s[^>]*src="\{\{\s*url_for\(\s*'static'\s*,\s*"""
|
||||
r"""filename='(v3/js/widgets/[^']+)'""",
|
||||
base_html,
|
||||
)
|
||||
assert not per_file, (
|
||||
'base.html still loads widget files individually alongside the '
|
||||
f'bundle (they would run twice): {per_file}'
|
||||
)
|
||||
|
||||
|
||||
def test_bundle_concatenates_every_file() -> None:
|
||||
"""The built bundle contains each file, separated so sources can't merge."""
|
||||
body, version = widget_bundle.build_bundle()
|
||||
assert version and version == widget_bundle.bundle_version()
|
||||
for name in widget_bundle.BUNDLE_ORDER:
|
||||
assert f'/* {name} */' in body, f'{name} missing from the built bundle'
|
||||
for name in ALLOWLIST:
|
||||
assert f'/* {name} */' not in body, f'{name} must not be bundled'
|
||||
|
||||
@@ -40,6 +40,57 @@ def _wire(wm, connected, ethernet, ap_active):
|
||||
wm._save_cached_scan = MagicMock()
|
||||
wm._FORCE_AP_FLAG_PATH = MagicMock()
|
||||
wm._FORCE_AP_FLAG_PATH.exists.return_value = False
|
||||
wm._connect_in_progress = MagicMock(return_value=False)
|
||||
|
||||
|
||||
class TestConnectInProgressHoldsOffTheDaemon:
|
||||
"""Connecting from the setup AP takes the AP down before joining. The
|
||||
daemon had already counted the whole AP session as "disconnected", so its
|
||||
next tick re-enabled the AP in the middle of the connect."""
|
||||
|
||||
def test_no_ap_enable_while_a_connect_is_running(self, wm):
|
||||
_wire(wm, connected=False, ethernet=False, ap_active=False)
|
||||
wm._connect_in_progress.return_value = True
|
||||
wm._disconnected_checks = 10 # accumulated while the AP was up
|
||||
changed, *_ = wm.check_and_manage_ap_mode_with_state()
|
||||
assert changed is False
|
||||
wm.enable_ap_mode.assert_not_called()
|
||||
assert wm._disconnected_checks == 0
|
||||
|
||||
def test_grace_period_restarts_after_the_connect(self, wm):
|
||||
_wire(wm, connected=False, ethernet=False, ap_active=False)
|
||||
wm._connect_in_progress.return_value = True
|
||||
wm._disconnected_checks = 10
|
||||
wm.check_and_manage_ap_mode_with_state()
|
||||
wm._connect_in_progress.return_value = False
|
||||
wm.check_and_manage_ap_mode_with_state()
|
||||
wm.enable_ap_mode.assert_not_called()
|
||||
|
||||
def test_flag_is_held_for_the_connect_and_removed_after(self, wm, tmp_path):
|
||||
flag = tmp_path / "connecting"
|
||||
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
|
||||
seen = []
|
||||
wm._connect_validated = lambda ssid, pw: seen.append(flag.exists()) or (True, "ok")
|
||||
assert wm.connect_to_network("HomeNet", "hunter22") == (True, "ok")
|
||||
assert seen == [True]
|
||||
assert not flag.exists()
|
||||
|
||||
def test_flag_is_removed_when_the_connect_raises(self, wm, tmp_path):
|
||||
flag = tmp_path / "connecting"
|
||||
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
|
||||
wm._connect_validated = MagicMock(side_effect=RuntimeError("nmcli gone"))
|
||||
with pytest.raises(RuntimeError):
|
||||
wm.connect_to_network("HomeNet", "hunter22")
|
||||
assert not flag.exists()
|
||||
|
||||
def test_a_stale_flag_from_a_dead_process_is_ignored(self, wm, tmp_path):
|
||||
flag = tmp_path / "connecting"
|
||||
flag.touch()
|
||||
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
|
||||
assert WiFiManager._connect_in_progress(wm) is True
|
||||
old = flag.stat().st_mtime - WiFiManager._CONNECT_FLAG_MAX_AGE_SECONDS - 1
|
||||
os.utime(flag, (old, old))
|
||||
assert WiFiManager._connect_in_progress(wm) is False
|
||||
|
||||
|
||||
class TestWithState:
|
||||
|
||||
@@ -134,6 +134,7 @@ class TestConnectEndpointSurfacesTheRefusal:
|
||||
def wifi_manager(self):
|
||||
with patch("src.wifi_manager.WiFiManager") as cls:
|
||||
instance = MagicMock()
|
||||
instance._is_ap_mode_active.return_value = False
|
||||
cls.return_value = instance
|
||||
yield instance
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""An array of objects with no ``x-widget`` must survive a save of its own form.
|
||||
|
||||
Geochron's ``cities`` is a list of ``{name, lat, lon, timezone}`` objects and
|
||||
names no widget. The template sent that to the comma-separated text input,
|
||||
which renders each city as a Python dict repr joined by commas. Saving the
|
||||
untouched form posted those back as a list of strings, the schema rejected
|
||||
them, and *every* save of the plugin failed with 400 "Configuration
|
||||
validation failed" -- no setting on the page could be changed.
|
||||
|
||||
So the test renders the real form, submits what a browser would, and requires
|
||||
the save to succeed with the cities intact.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.datastructures import MultiDict
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
CITIES = [
|
||||
{"name": "New York", "lat": 40.71, "lon": -74.01, "timezone": "America/New_York"},
|
||||
{"name": "Tokyo", "lat": 35.68, "lon": 139.65, "timezone": "Asia/Tokyo"},
|
||||
]
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": False},
|
||||
"timezone": {"type": ["string", "null"], "default": None},
|
||||
"show_cities": {"type": "boolean", "default": True},
|
||||
"cities": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "maxLength": 20},
|
||||
"lat": {"type": "number", "minimum": -90, "maximum": 90},
|
||||
"lon": {"type": "number", "minimum": -180, "maximum": 180},
|
||||
"timezone": {"type": "string"},
|
||||
},
|
||||
"required": ["name", "lat", "lon"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"default": CITIES,
|
||||
},
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
STORED = {"enabled": True, "timezone": None, "show_cities": True,
|
||||
"cities": CITIES}
|
||||
|
||||
|
||||
class _FormFields(HTMLParser):
|
||||
"""Collect what a browser would submit from the rendered form."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pairs = []
|
||||
self._select = None
|
||||
self._select_first = None
|
||||
self._select_chosen = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
a = dict(attrs)
|
||||
name = a.get("name")
|
||||
if tag == "input" and name and "disabled" not in a:
|
||||
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
|
||||
return
|
||||
if a.get("type") in ("button", "submit", "file"):
|
||||
return
|
||||
self.pairs.append((name, a.get("value", "")))
|
||||
elif tag == "select" and name:
|
||||
self._select, self._select_first, self._select_chosen = name, None, None
|
||||
elif tag == "option" and self._select:
|
||||
if self._select_first is None:
|
||||
self._select_first = a.get("value", "")
|
||||
if "selected" in a:
|
||||
self._select_chosen = a.get("value", "")
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "select" and self._select:
|
||||
chosen = self._select_chosen if self._select_chosen is not None else self._select_first
|
||||
self.pairs.append((self._select, chosen or ""))
|
||||
self._select = None
|
||||
|
||||
|
||||
def _render_form():
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
|
||||
"author": "me", "version": "1.0.0"}
|
||||
return env.get_template("v3/partials/plugin_config.html").render(
|
||||
plugin=plugin, schema=SCHEMA, config=json.loads(json.dumps(STORED)))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post(tmp_path):
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
|
||||
|
||||
store = {"demo": json.loads(json.dumps(STORED))}
|
||||
cm = MagicMock()
|
||||
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
|
||||
cm.get_raw_file_content.return_value = {}
|
||||
cm.get_config_path.return_value = str(tmp_path / "config.json")
|
||||
|
||||
def _save(cfg, **_kw):
|
||||
store.clear()
|
||||
store.update(cfg)
|
||||
return type("R", (), {"status": type("S", (), {"value": "success"})(),
|
||||
"message": None})()
|
||||
|
||||
cm.save_config_atomic.side_effect = _save
|
||||
|
||||
a.api_v3.config_manager = cm
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
|
||||
client = app.test_client()
|
||||
|
||||
def _post(pairs):
|
||||
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
|
||||
data=MultiDict(pairs))
|
||||
return resp, store.get("demo", {})
|
||||
|
||||
try:
|
||||
yield _post
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(a.api_v3, k, v)
|
||||
|
||||
|
||||
def test_the_form_does_not_flatten_objects_into_text():
|
||||
html = _render_form()
|
||||
assert "{'name'" not in html and "{'name'" not in html, (
|
||||
"cities rendered as a dict repr in a comma-separated text box")
|
||||
|
||||
|
||||
def test_saving_the_untouched_form_succeeds(post):
|
||||
parser = _FormFields()
|
||||
parser.feed(_render_form())
|
||||
resp, cfg = post(parser.pairs)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert cfg["cities"] == CITIES
|
||||
|
||||
|
||||
def test_an_item_without_its_optional_object_does_not_gain_an_empty_one(post):
|
||||
"""News: a custom feed with no logo. The unchecked-checkbox pass walked
|
||||
into ``logo`` to look for booleans and left ``logo: {}`` behind, which
|
||||
fails the logo's ``required: [id, path]`` -- 400 on every save."""
|
||||
from web_interface.blueprints.api_v3 import _set_missing_booleans_to_false
|
||||
|
||||
props = {"feeds": {"type": "object", "properties": {"custom_feeds": {
|
||||
"type": "array",
|
||||
"items": {"type": "object", "properties": {
|
||||
"name": {"type": "string"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"logo": {"type": "object", "required": ["id", "path"],
|
||||
"properties": {"id": {"type": "string"},
|
||||
"path": {"type": "string"}}},
|
||||
"extra": {"type": "object",
|
||||
"properties": {"flag": {"type": "boolean"}}},
|
||||
}},
|
||||
}}}}
|
||||
cfg = {"feeds": {"custom_feeds": [{"name": "Verge", "enabled": True}]}}
|
||||
_set_missing_booleans_to_false(
|
||||
cfg, props, {"feeds.custom_feeds.0.name"}, sections={"feeds"})
|
||||
|
||||
item = cfg["feeds"]["custom_feeds"][0]
|
||||
assert "logo" not in item
|
||||
# A nested object that really does hold an unchecked box still gets it.
|
||||
assert item["extra"] == {"flag": False}
|
||||
assert item["enabled"] is False
|
||||
@@ -99,9 +99,15 @@ class TestTheRoutesExistAtAll:
|
||||
assert "'google-oauth'" in allow_list_line[0], allow_list_line[0]
|
||||
|
||||
def test_the_widget_script_is_served(self):
|
||||
# Widgets load through one bundle (web_interface/widget_bundle.py), so
|
||||
# the page must request the bundle and the bundle must carry the file.
|
||||
from web_interface import widget_bundle
|
||||
base = (Path(project_root) / 'web_interface/templates/v3/base.html'
|
||||
).read_text(encoding='utf-8')
|
||||
assert 'widgets/google-oauth.js' in base
|
||||
assert 'widgets_bundle_url()' in base
|
||||
assert 'google-oauth.js' in widget_bundle.BUNDLE_ORDER
|
||||
body, _version = widget_bundle.build_bundle()
|
||||
assert '/* google-oauth.js */' in body
|
||||
|
||||
def test_the_status_line_is_announced(self):
|
||||
# Every message the widget gives arrives after an async call, so a
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""The stdlib gzip fallback must compress the UI's text assets.
|
||||
|
||||
flask-compress is optional (app.py tolerates its absence). Without a fallback a
|
||||
Pi missing it ships ~1.2 MB of uncompressed JavaScript to a phone over WiFi on
|
||||
every first load and every update. These pin the fallback's contract: text
|
||||
assets compress and round-trip byte-for-byte, clients that don't ask for gzip
|
||||
get the original bytes, and small or non-text responses are left alone.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
STATIC = Path(__file__).resolve().parents[2] / "web_interface" / "static"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
import web_interface.app as web_app
|
||||
# Exercise the fallback even on machines that do have flask-compress.
|
||||
monkeypatch.setattr(web_app, "_HAVE_FLASK_COMPRESS", False)
|
||||
web_app.app.config["TESTING"] = True
|
||||
with web_app.app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url,path", [
|
||||
("/static/v3/plugins_manager.js", "v3/plugins_manager.js"),
|
||||
("/static/v3/app.css", "v3/app.css"),
|
||||
])
|
||||
def test_static_text_asset_is_gzipped_and_round_trips(client, url, path):
|
||||
resp = client.get(url, headers={"Accept-Encoding": "gzip, deflate"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers.get("Content-Encoding") == "gzip"
|
||||
assert "Accept-Encoding" in resp.headers.get("Vary", "")
|
||||
original = (STATIC / path).read_bytes()
|
||||
assert gzip.decompress(resp.data) == original
|
||||
assert len(resp.data) < len(original) / 2
|
||||
assert int(resp.headers["Content-Length"]) == len(resp.data)
|
||||
|
||||
|
||||
def test_repeat_request_serves_identical_compressed_bytes(client):
|
||||
headers = {"Accept-Encoding": "gzip"}
|
||||
first = client.get("/static/v3/js/app-shell.js", headers=headers).data
|
||||
second = client.get("/static/v3/js/app-shell.js", headers=headers).data
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_client_without_gzip_gets_the_original_bytes(client):
|
||||
resp = client.get("/static/v3/app.css", headers={"Accept-Encoding": "identity"})
|
||||
assert resp.status_code == 200
|
||||
assert "Content-Encoding" not in resp.headers
|
||||
assert resp.data == (STATIC / "v3/app.css").read_bytes()
|
||||
|
||||
|
||||
def test_binary_assets_are_not_recompressed(client):
|
||||
resp = client.get("/static/v3/icons/icon-192.png", headers={"Accept-Encoding": "gzip"})
|
||||
assert resp.status_code == 200
|
||||
assert "Content-Encoding" not in resp.headers
|
||||
|
||||
|
||||
def test_widget_bundle_is_gzipped_and_immutable(client):
|
||||
resp = client.get("/assets/widgets.js", headers={"Accept-Encoding": "gzip"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers.get("Content-Encoding") == "gzip"
|
||||
assert "immutable" in resp.headers.get("Cache-Control", "")
|
||||
assert b"/* registry.js */" in gzip.decompress(resp.data)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Stored values the current schema no longer describes must not break the form.
|
||||
|
||||
Two cases from the news plugin:
|
||||
|
||||
* ``global.dynamic_duration`` used to be a boolean and is now an object. An
|
||||
install that has not saved since still holds ``true``; rendering the nested
|
||||
section did ``key in true`` and the whole config page failed to load.
|
||||
* A custom feed logo stored with a ``path`` but no ``id``. The template always
|
||||
emitted an empty ``logo.id`` input, which the save route parsed to ``None``
|
||||
and the schema rejected (``Expected type string``) -- 400 on every save.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.datastructures import MultiDict
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"global": {"type": "object", "properties": {
|
||||
"dynamic_duration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"min_duration_seconds": {"type": "integer", "default": 30,
|
||||
"minimum": 10, "maximum": 300},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}, "additionalProperties": False},
|
||||
"feeds": {"type": "object", "properties": {
|
||||
"custom_feeds": {
|
||||
"type": "array",
|
||||
"x-widget": "custom-feeds",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"url": {"type": "string"},
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"logo": {"type": "object", "required": ["path"],
|
||||
"properties": {"id": {"type": "string"},
|
||||
"path": {"type": "string"}}},
|
||||
},
|
||||
"required": ["name", "url"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}, "additionalProperties": False},
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
LOGO_PATH = "assets/plugins/news/uploads/verge.png"
|
||||
STORED = {
|
||||
"enabled": True,
|
||||
"global": {"dynamic_duration": True},
|
||||
"feeds": {"custom_feeds": [{"name": "Verge",
|
||||
"url": "https://www.theverge.com/rss/index.xml",
|
||||
"enabled": True,
|
||||
"logo": {"path": LOGO_PATH}}]},
|
||||
}
|
||||
|
||||
|
||||
class _FormFields(HTMLParser):
|
||||
"""Collect what a browser would submit from the rendered form."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pairs = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
a = dict(attrs)
|
||||
if tag != "input" or not a.get("name") or "disabled" in a:
|
||||
return
|
||||
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
|
||||
return
|
||||
if a.get("type") in ("button", "submit", "file"):
|
||||
return
|
||||
self.pairs.append((a["name"], a.get("value", "")))
|
||||
|
||||
|
||||
def _render():
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
|
||||
"author": "me", "version": "1.0.0"}
|
||||
return env.get_template("v3/partials/plugin_config.html").render(
|
||||
plugin=plugin, schema=SCHEMA, config=json.loads(json.dumps(STORED)))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post(tmp_path):
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
|
||||
|
||||
store = {"demo": json.loads(json.dumps(STORED))}
|
||||
cm = MagicMock()
|
||||
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
|
||||
cm.get_raw_file_content.return_value = {}
|
||||
cm.get_config_path.return_value = str(tmp_path / "config.json")
|
||||
|
||||
def _save(cfg, **_kw):
|
||||
store.clear()
|
||||
store.update(cfg)
|
||||
return type("R", (), {"status": type("S", (), {"value": "success"})(),
|
||||
"message": None})()
|
||||
|
||||
cm.save_config_atomic.side_effect = _save
|
||||
|
||||
a.api_v3.config_manager = cm
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
|
||||
client = app.test_client()
|
||||
|
||||
def _post(pairs):
|
||||
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
|
||||
data=MultiDict(pairs))
|
||||
return resp, store.get("demo", {})
|
||||
|
||||
try:
|
||||
yield _post
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(a.api_v3, k, v)
|
||||
|
||||
|
||||
def test_a_scalar_stored_under_an_object_field_still_renders():
|
||||
html = _render()
|
||||
assert 'name="global.dynamic_duration.min_duration_seconds"' in html
|
||||
|
||||
|
||||
def test_a_logo_without_an_id_posts_no_id_field():
|
||||
parser = _FormFields()
|
||||
parser.feed(_render())
|
||||
names = [name for name, _ in parser.pairs]
|
||||
assert "feeds.custom_feeds.0.logo.path" in names
|
||||
assert "feeds.custom_feeds.0.logo.id" not in names
|
||||
|
||||
|
||||
def test_saving_that_form_succeeds_and_upgrades_the_legacy_value(post):
|
||||
parser = _FormFields()
|
||||
parser.feed(_render())
|
||||
resp, cfg = post(parser.pairs)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert isinstance(cfg["global"]["dynamic_duration"], dict)
|
||||
# The legacy `true` was the on switch; upgrading must not turn it off.
|
||||
assert cfg["global"]["dynamic_duration"]["enabled"] is True
|
||||
assert cfg["global"]["dynamic_duration"]["min_duration_seconds"] == 30
|
||||
assert cfg["feeds"]["custom_feeds"][0]["logo"] == {"path": LOGO_PATH}
|
||||
@@ -165,6 +165,27 @@ class TestPathTraversal:
|
||||
assert "hunter2" not in r.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_a_symlinked_widgets_directory_cannot_escape(self, tmp_path, make_client):
|
||||
"""widgets/ itself is contained before the script is resolved under it,
|
||||
so a symlink out of the plugin can't become the containment base."""
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "loot.js").write_text("const KEY='hunter2';", encoding="utf-8")
|
||||
d = tmp_path / "soccer-scoreboard"
|
||||
d.mkdir()
|
||||
manifest = {"id": "soccer-scoreboard", "name": "soccer-scoreboard",
|
||||
"version": "1.0.0",
|
||||
"widgets": [{"name": "loot", "script": "loot.js"}]}
|
||||
(d / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
try:
|
||||
(d / "widgets").symlink_to(outside, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks unavailable on this platform")
|
||||
r = make_client().get(URL.format("soccer-scoreboard", "loot"))
|
||||
assert r.status_code == 404
|
||||
assert "hunter2" not in r.get_data(as_text=True)
|
||||
|
||||
|
||||
class TestDegradation:
|
||||
def test_an_unknown_plugin_is_404(self, tmp_path, make_client):
|
||||
assert make_client().get(URL.format("nope", "w")).status_code == 404
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""When the style editor takes over a customization block, it takes over
|
||||
exactly its own part of it -- and it actually gets to take over at all.
|
||||
|
||||
Two defects this pins, both found by rendering the real partial in a browser
|
||||
rather than by reading the code:
|
||||
|
||||
1. The hand-off guard asked "do any fallback controls differ from their
|
||||
server-rendered defaults?" as a proxy for "is someone editing this?". The
|
||||
fallback contains this block's own font fields, and the font-selector
|
||||
widget populates them on the same 50ms timer -- so a plain page load with
|
||||
nobody touching anything raced into "dirty" (seven customization.*.font
|
||||
selects, measured ~60ms after injection) and the style editor removed
|
||||
itself, leaving the accordion form it exists to replace. The question is
|
||||
whether a *person* typed, and isTrusted answers exactly that.
|
||||
|
||||
2. Taking over used to remove the whole fallback section. A customization
|
||||
block can hold more than styling -- football keeps favorite_result_colors
|
||||
there -- so that removed the only UI those fields had.
|
||||
|
||||
These assert on the template source because the behaviour lives in an inline
|
||||
script that no Python test executes; the browser check that found both is not
|
||||
something CI runs.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
TEMPLATE = (PROJECT_ROOT / "web_interface" / "templates" / "v3" / "partials"
|
||||
/ "plugin_config.html")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def source():
|
||||
return TEMPLATE.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestTheHandOffGuard:
|
||||
def test_it_asks_whether_a_person_typed(self, source):
|
||||
assert "isTrusted" in source, (
|
||||
"a programmatic change by a sibling widget must not read as a "
|
||||
"user edit -- that is what removed the editor on a plain load")
|
||||
assert "userEdited" in source
|
||||
|
||||
def test_it_no_longer_diffs_values_against_defaults(self, source):
|
||||
assert "fallbackIsDirty" not in source, (
|
||||
"value-diffing cannot distinguish a user's edit from another "
|
||||
"widget populating this block's own fields")
|
||||
assert "defaultSelected" not in source
|
||||
|
||||
def test_the_listeners_are_attached_synchronously(self, source):
|
||||
"""The edit worth protecting can happen before initWidget runs, so
|
||||
the listeners cannot wait for it.
|
||||
|
||||
Scoped to this branch: the template defines an initWidget for every
|
||||
widget it can render, and the first one in the file belongs to a
|
||||
different branch entirely.
|
||||
"""
|
||||
start = source.index("obj_widget == 'style-editor'")
|
||||
branch = source[start:source.index("{% elif prop.properties %}", start)]
|
||||
assert "watchForRealEdits" in branch
|
||||
assert branch.index("watchForRealEdits") < branch.index("function initWidget"), (
|
||||
"the watcher must be installed above this branch's initWidget, "
|
||||
"and invoked immediately rather than from inside it")
|
||||
|
||||
def test_the_guard_still_backs_off_for_a_real_edit(self, source):
|
||||
assert re.search(r"if \(userEdited\) \{ container\.remove\(\); return; \}",
|
||||
source), "a genuine in-progress edit must still win"
|
||||
|
||||
|
||||
class TestItTakesOverOnlyItsOwnBlocks:
|
||||
def test_children_are_individually_addressable(self, source):
|
||||
assert 'data-child-key="{{ nested_key }}"' in source, (
|
||||
"without a handle per child the only options are removing the "
|
||||
"whole section or none of it")
|
||||
|
||||
def test_removal_is_driven_by_what_the_widget_reported(self, source):
|
||||
assert "ownedKeys" in source
|
||||
assert "fallback.querySelector(" in source
|
||||
|
||||
def test_an_unowned_child_survives(self, source):
|
||||
"""The empty-section case still removes the lot, so a block that is
|
||||
entirely styling looks exactly as it did before."""
|
||||
assert "!fallback.querySelector('[data-child-key]')" in source
|
||||
|
||||
def test_the_owned_key_is_escaped_before_it_becomes_a_selector(self, source):
|
||||
"""ownedKeys comes back off a dataset attribute, not a literal --
|
||||
splicing it into '[data-child-key="' + k + '"]' unescaped breaks (or
|
||||
is steerable) on a key containing a quote or backslash. The rest of
|
||||
this codebase (plugin-file-manager.js, app-shell.js) always routes a
|
||||
dynamic value through CSS.escape() before it lands in a selector."""
|
||||
assert "CSS.escape(k)" in source, (
|
||||
"an owned key must be CSS.escape()'d before being interpolated "
|
||||
"into the data-child-key attribute selector")
|
||||
|
||||
|
||||
class TestTheSchemaSaysWhichBlocksAreStyling:
|
||||
def test_adopted_blocks_are_marked_and_others_are_not(self):
|
||||
from src.element_style import expand_style_elements
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"score_text": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"font": {"type": "string",
|
||||
"default": "PressStart2P-Regular.ttf"},
|
||||
"font_size": {"type": "integer", "default": 10},
|
||||
"text_color": {"type": "array",
|
||||
"default": [255, 255, 255]},
|
||||
},
|
||||
},
|
||||
# Not styling: a feature that happens to live here.
|
||||
"favorite_result_colors": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": False},
|
||||
"win_color": {"type": "array",
|
||||
"default": [0, 255, 0]},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
props = expand_style_elements(schema)["properties"]["customization"]["properties"]
|
||||
assert props["score_text"]["x-style-managed"] is True
|
||||
assert "x-style-managed" not in props["favorite_result_colors"]
|
||||
|
||||
|
||||
# Football's real layout keys, in its schema's order. The style block and the
|
||||
# layout block were written years apart and never agreed on names.
|
||||
FOOTBALL_LAYOUT = ["home_logo", "away_logo", "score", "status_text", "date",
|
||||
"time", "down_distance", "timeouts", "possession",
|
||||
"records", "odds"]
|
||||
|
||||
|
||||
def _offsets(*axes):
|
||||
return {"type": "object",
|
||||
"properties": {a: {"type": "integer", "default": 0} for a in axes}}
|
||||
|
||||
|
||||
def _style_block():
|
||||
return {"type": "object", "properties": {
|
||||
"font": {"type": "string", "default": "PressStart2P-Regular.ttf"},
|
||||
"font_size": {"type": "integer", "default": 10},
|
||||
"text_color": {"type": "array", "default": [255, 255, 255]},
|
||||
}}
|
||||
|
||||
|
||||
def _football_shaped():
|
||||
layout = {k: _offsets("x_offset", "y_offset") for k in FOOTBALL_LAYOUT}
|
||||
layout["records"] = _offsets("away_x_offset", "home_x_offset", "y_offset")
|
||||
return {"type": "object", "properties": {"customization": {
|
||||
"type": "object",
|
||||
"x-style-modes": ["live", "recent"],
|
||||
"properties": {
|
||||
**{k: _style_block() for k in (
|
||||
"score_text", "period_text", "team_name", "status_text",
|
||||
"detail_text", "odds_text", "rank_text")},
|
||||
"layout": {"type": "object", "properties": layout},
|
||||
}}}}
|
||||
|
||||
|
||||
class TestEveryAdvertisedOffsetGetsAControl:
|
||||
"""The style editor took the layout section over but only drew offsets
|
||||
whose layout key matched a style key exactly. In football that was
|
||||
status_text alone: score, odds, both logos, timeouts, possession,
|
||||
down-and-distance, date, time and records -- options the schema
|
||||
advertises and the renderer reads -- had no control anywhere."""
|
||||
|
||||
def _customization(self):
|
||||
from src.element_style import expand_style_elements
|
||||
return expand_style_elements(_football_shaped())[
|
||||
"properties"]["customization"]
|
||||
|
||||
def test_a_style_element_is_told_where_its_offsets_live(self):
|
||||
"""Resolved in core through the same alias map the resolver reads
|
||||
offsets with, so the editor cannot drift from the renderer."""
|
||||
props = self._customization()["properties"]
|
||||
assert props["score_text"]["x-layout-key"] == "score"
|
||||
assert props["odds_text"]["x-layout-key"] == "odds"
|
||||
assert props["status_text"]["x-layout-key"] == "status_text"
|
||||
|
||||
def test_an_element_with_no_offsets_claims_nothing(self):
|
||||
props = self._customization()["properties"]
|
||||
for key in ("period_text", "detail_text", "team_name", "rank_text"):
|
||||
assert "x-layout-key" not in props[key], key
|
||||
|
||||
def test_the_mode_copies_carry_it_too(self):
|
||||
live = self._customization()["properties"]["modes"]["properties"][
|
||||
"live"]["properties"]
|
||||
assert live["score_text"]["x-layout-key"] == "score"
|
||||
|
||||
def test_positions_keep_the_order_the_plugin_declared(self):
|
||||
"""Flask sorts keys when it serialises the schema, which would
|
||||
otherwise list the logos after the date."""
|
||||
c = self._customization()
|
||||
assert c["properties"]["layout"]["x-propertyOrder"] == FOOTBALL_LAYOUT
|
||||
live_layout = c["properties"]["modes"]["properties"]["live"][
|
||||
"properties"]["layout"]
|
||||
assert live_layout["x-propertyOrder"] == FOOTBALL_LAYOUT
|
||||
|
||||
def test_every_layout_entry_is_claimed_or_listed_as_a_position(self):
|
||||
"""Mirrors the widget's split: a layout entry either belongs to a
|
||||
style row or gets a row of its own. Nothing is left over."""
|
||||
props = self._customization()["properties"]
|
||||
layout = props["layout"]["properties"]
|
||||
claimed = {props[k]["x-layout-key"] for k in props
|
||||
if isinstance(props[k], dict) and "x-layout-key" in props[k]}
|
||||
positions = [k for k in props["layout"]["x-propertyOrder"]
|
||||
if k not in claimed]
|
||||
assert claimed | set(positions) == set(layout)
|
||||
assert positions == ["home_logo", "away_logo", "date", "time",
|
||||
"down_distance", "timeouts", "possession",
|
||||
"records"]
|
||||
|
||||
def test_the_widget_reads_the_annotation_and_lists_positions(self):
|
||||
js = (PROJECT_ROOT / "web_interface" / "static" / "v3" / "js"
|
||||
/ "widgets" / "style-editor.js").read_text(encoding="utf-8")
|
||||
assert "x-layout-key" in js, (
|
||||
"the alias rules live in element_style.py; the widget must read "
|
||||
"their result rather than carry a second copy")
|
||||
assert "function positionRows" in js
|
||||
|
||||
def test_a_leaf_under_layout_is_never_claimed_as_offsets(self):
|
||||
"""A show_logo toggle straight under layout has no x/y object, so it
|
||||
cannot hold an element's offsets. Claiming it would give that row no
|
||||
layout columns and remove the leaf from the positions list -- the
|
||||
field would lose its only control."""
|
||||
from src.element_style import expand_style_elements
|
||||
|
||||
schema = _football_shaped()
|
||||
cust = schema["properties"]["customization"]
|
||||
# A style element whose alias ("show_logo") names a leaf.
|
||||
cust["properties"]["show_logo_text"] = _style_block()
|
||||
cust["properties"]["layout"]["properties"]["show_logo"] = {
|
||||
"type": "boolean", "default": True}
|
||||
props = expand_style_elements(schema)["properties"]["customization"][
|
||||
"properties"]
|
||||
assert "x-layout-key" not in props["show_logo_text"]
|
||||
assert "show_logo" in props["layout"]["x-propertyOrder"]
|
||||
+112
-12
@@ -4,6 +4,7 @@ import logging
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import gzip
|
||||
import shutil
|
||||
import sys
|
||||
import subprocess
|
||||
@@ -69,10 +70,12 @@ except ImportError:
|
||||
# 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.
|
||||
_HAVE_FLASK_COMPRESS = False
|
||||
try:
|
||||
from flask_compress import Compress
|
||||
|
||||
Compress(app)
|
||||
_HAVE_FLASK_COMPRESS = True
|
||||
except ImportError:
|
||||
logging.getLogger(__name__).warning(
|
||||
"flask-compress not installed - responses will be served uncompressed. "
|
||||
@@ -500,6 +503,80 @@ def add_static_version(endpoint, values):
|
||||
pass
|
||||
|
||||
|
||||
# Gzip fallback for when flask-compress isn't installed. Without it the UI
|
||||
# ships ~1.2 MB of uncompressed JS to a phone over WiFi. Compressed bytes are
|
||||
# cached per URL+version, so the Pi compresses each asset once, not per request.
|
||||
# URL-versioned assets: safe to cache as immutable (and to gzip once).
|
||||
# /assets/ is the widget bundle from pages_v3 (also reachable under /v3).
|
||||
_VERSIONED_ASSET_PREFIXES = ('/static/', '/assets/', '/v3/assets/')
|
||||
_GZIP_MIN_BYTES = 1024
|
||||
_GZIP_TYPES = (
|
||||
'text/html', 'text/css', 'text/plain', 'text/javascript',
|
||||
'application/javascript', 'application/json', 'image/svg+xml',
|
||||
)
|
||||
_GZIP_CACHE_MAX_BYTES = 4 * 1024 * 1024
|
||||
_gzip_cache = {}
|
||||
_gzip_cache_bytes = 0
|
||||
_gzip_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
@app.after_request
|
||||
def compress_text_responses(response):
|
||||
"""Gzip text responses when flask-compress is absent (SSE untouched)."""
|
||||
if _HAVE_FLASK_COMPRESS or response.status_code != 200:
|
||||
return response
|
||||
if 'Content-Encoding' in response.headers:
|
||||
return response
|
||||
# send_file responses report is_streamed (they wrap a file) but have a
|
||||
# known size; genuinely streamed bodies (SSE, generators) are left alone.
|
||||
# SSE is also excluded by its text/event-stream mimetype below.
|
||||
if response.is_streamed and not response.direct_passthrough:
|
||||
return response
|
||||
mimetype = (response.mimetype or '').lower()
|
||||
if mimetype not in _GZIP_TYPES:
|
||||
return response
|
||||
if 'gzip' not in (request.headers.get('Accept-Encoding') or '').lower():
|
||||
return response
|
||||
|
||||
cache_key = None
|
||||
if request.path.startswith(_VERSIONED_ASSET_PREFIXES):
|
||||
cache_key = (request.full_path, response.headers.get('Last-Modified'))
|
||||
with _gzip_cache_lock:
|
||||
cached = _gzip_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return _apply_gzip(response, cached)
|
||||
|
||||
# send_file responses stream from disk; materialize before compressing.
|
||||
if response.direct_passthrough:
|
||||
response.direct_passthrough = False
|
||||
data = response.get_data()
|
||||
if len(data) < _GZIP_MIN_BYTES:
|
||||
return response
|
||||
compressed = gzip.compress(data, compresslevel=6)
|
||||
if len(compressed) >= len(data):
|
||||
return response
|
||||
|
||||
if cache_key is not None:
|
||||
global _gzip_cache_bytes
|
||||
with _gzip_cache_lock:
|
||||
if _gzip_cache_bytes + len(compressed) <= _GZIP_CACHE_MAX_BYTES:
|
||||
_gzip_cache[cache_key] = compressed
|
||||
_gzip_cache_bytes += len(compressed)
|
||||
return _apply_gzip(response, compressed)
|
||||
|
||||
|
||||
def _apply_gzip(response, compressed):
|
||||
response.set_data(compressed)
|
||||
response.headers['Content-Encoding'] = 'gzip'
|
||||
response.headers['Content-Length'] = str(len(compressed))
|
||||
if 'accept-encoding' not in (response.headers.get('Vary') or '').lower():
|
||||
response.headers.add('Vary', 'Accept-Encoding')
|
||||
etag = response.headers.get('ETag')
|
||||
if etag and 'gzip' not in etag:
|
||||
response.headers['ETag'] = etag.rstrip('"') + '-gzip"'
|
||||
return response
|
||||
|
||||
|
||||
# Add security headers and caching to all responses
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
@@ -511,7 +588,7 @@ def add_security_headers(response):
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
|
||||
# Add caching headers for static assets
|
||||
if request.path.startswith('/static/'):
|
||||
if request.path.startswith(_VERSIONED_ASSET_PREFIXES):
|
||||
# Cache static assets for 1 year (with versioning via query params)
|
||||
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||
response.headers['Expires'] = (datetime.now() + timedelta(days=365)).strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||
@@ -671,18 +748,13 @@ def display_preview_generator():
|
||||
except OSError:
|
||||
pass # display side treats a missing marker as "no viewer"
|
||||
|
||||
# Get display dimensions from config
|
||||
# Get display dimensions from config: the logical size DisplayManager
|
||||
# renders at, so double-sided setups preview one screen
|
||||
from src.display_geometry import logical_size
|
||||
try:
|
||||
main_config = config_manager.load_config()
|
||||
cols = main_config.get('display', {}).get('hardware', {}).get('cols', 64)
|
||||
chain_length = main_config.get('display', {}).get('hardware', {}).get('chain_length', 2)
|
||||
rows = main_config.get('display', {}).get('hardware', {}).get('rows', 32)
|
||||
parallel = main_config.get('display', {}).get('hardware', {}).get('parallel', 1)
|
||||
width = cols * chain_length
|
||||
height = rows * parallel
|
||||
except (KeyError, TypeError, ValueError, ConfigError):
|
||||
width = 128
|
||||
height = 64
|
||||
width, height = logical_size(config_manager.load_config())
|
||||
except (KeyError, TypeError, ValueError, AttributeError, ConfigError):
|
||||
width, height = logical_size({})
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -975,8 +1047,36 @@ def check_health_monitor():
|
||||
_reconciliation_started = True
|
||||
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
|
||||
|
||||
_auto_updater = None
|
||||
|
||||
|
||||
def start_auto_update_scheduler():
|
||||
"""Start the weekly auto-update thread (web_interface/auto_update.py).
|
||||
|
||||
Called by the launchers, not at import: tests and dev tools import this
|
||||
module, and none of them should ever git pull. Idle unless
|
||||
auto_update.enabled is set, so it is safe to always start.
|
||||
"""
|
||||
global _auto_updater
|
||||
if _auto_updater is not None:
|
||||
return _auto_updater
|
||||
from web_interface.auto_update import AutoUpdater
|
||||
from web_interface.blueprints.api_v3.system import perform_core_update
|
||||
_auto_updater = AutoUpdater(
|
||||
config_manager=config_manager,
|
||||
core_update=perform_core_update,
|
||||
store_manager=plugin_store_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
schema_manager=schema_manager,
|
||||
operation_history=operation_history,
|
||||
)
|
||||
_auto_updater.start()
|
||||
return _auto_updater
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os as _os
|
||||
start_auto_update_scheduler()
|
||||
# threaded=True is Flask's default since 1.0 but stated explicitly so that
|
||||
# long-lived /api/v3/stream/* SSE connections don't starve other requests.
|
||||
# Debug mode is off by default; opt in with FLASK_DEBUG=1 in the environment.
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
"""Weekly automatic updates: LEDMatrix code, then installed plugins.
|
||||
|
||||
Turned on by ``auto_update.enabled`` in config.json (General tab, or the
|
||||
installer's --enable-auto-update). Off by default: an update pulls new code and
|
||||
restarts the display, which nobody should get without asking for it.
|
||||
|
||||
Nothing here is allowed to leave a device broken without saying so:
|
||||
|
||||
* **Checks first.** The code update is skipped, and the reason reported, when
|
||||
the checkout has local edits or commits, a rebase or merge is in progress,
|
||||
the branch has no upstream, disk is low, the newest commit was already
|
||||
rolled back once, or the health check is not set up.
|
||||
* **Verified, and rolled back.** The pull itself is the Overview "Update Code"
|
||||
path (``perform_core_update``). Restarting and checking the result is handed
|
||||
to ledmatrix-update-verify.service (scripts/utils/auto_update_verify.py),
|
||||
started through ledmatrix-update-verify.path by a request file, which
|
||||
survives the web service restart, confirms both services come up and stay
|
||||
up, and otherwise resets to the previous commit and its dependencies.
|
||||
* **Set up without SSH.** Those units are installed by the installer, or by the
|
||||
display service when the toggle is switched on (src/auto_update_setup.py).
|
||||
* **Plugins after the code.** Plugins use the Plugin Store's own update, which
|
||||
refuses versions this core cannot run and restores the old copy when an
|
||||
install fails. When the code changed they wait until it passed its check,
|
||||
so they are never gated against code that is about to be rolled back.
|
||||
* **Loud failures.** Anything other than success is written to the state file,
|
||||
shown under the toggle and raised as a banner on the Overview tab.
|
||||
|
||||
The schedule survives restarts through data/auto_update_state.json (gitignored).
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
STATE_REL = Path('data') / 'auto_update_state.json'
|
||||
PENDING_REL = Path('data') / 'auto_update_pending.json'
|
||||
REQUEST_REL = Path('data') / 'auto_update_verify.request'
|
||||
SETUP_RESULT_REL = Path('data') / 'auto_update_setup.json'
|
||||
VERIFIER_COPY_REL = Path('data') / 'auto_update_verifier.py'
|
||||
VERIFIER_SOURCE = PROJECT_ROOT / 'scripts' / 'utils' / 'auto_update_verify.py'
|
||||
STATE_FILE = PROJECT_ROOT / STATE_REL
|
||||
PENDING_FILE = PROJECT_ROOT / PENDING_REL
|
||||
SETUP_RESULT_FILE = PROJECT_ROOT / SETUP_RESULT_REL
|
||||
|
||||
VERIFY_UNIT = 'ledmatrix-update-verify'
|
||||
PATH_UNIT = f'{VERIFY_UNIT}.path'
|
||||
|
||||
UPDATE_INTERVAL_SECONDS = 7 * 24 * 3600
|
||||
#: A failure that is probably transient (usually no network) is retried the
|
||||
#: next day rather than a week later.
|
||||
RETRY_AFTER_FAILURE_SECONDS = 24 * 3600
|
||||
#: Local hours an update prefers, since it restarts the display...
|
||||
QUIET_HOURS = range(2, 5)
|
||||
#: ...but a device that is never powered at night must still get updates.
|
||||
QUIET_HOURS_GRACE_SECONDS = 24 * 3600
|
||||
CHECK_INTERVAL_SECONDS = 30 * 60
|
||||
#: Let the web service settle after boot (and never pull in a crash loop).
|
||||
STARTUP_DELAY_SECONDS = 10 * 60
|
||||
MIN_FREE_BYTES = 300 * 1024 * 1024
|
||||
#: How long the health check gets to pick up a request before the update is undone.
|
||||
HANDOFF_TIMEOUT_SECONDS = 90
|
||||
#: The health check takes a few minutes at most; far past that, it is lost.
|
||||
VERIFY_LOST_SECONDS = 45 * 60
|
||||
|
||||
#: Core outcomes that need the user's attention.
|
||||
ERROR_OUTCOMES = frozenset({'error', 'blocked', 'rolled_back', 'rollback_failed', 'lost'})
|
||||
|
||||
HELPER_MISSING_MESSAGE = (
|
||||
"LEDMatrix code updates are paused until the update health check is set up. "
|
||||
"The display service sets it up when it starts while automatic updates are on, "
|
||||
"so restart the display from the Overview tab. If this keeps happening, the "
|
||||
"setup result under Automatic Updates on the General tab says why. Plugin "
|
||||
"updates still run."
|
||||
)
|
||||
|
||||
_STATE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def is_enabled(config):
|
||||
return bool((config.get('auto_update') or {}).get('enabled', False))
|
||||
|
||||
|
||||
def _read_json(path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_json(path, data):
|
||||
path = Path(path)
|
||||
tmp = None
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f'.{path.stem}_')
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, path)
|
||||
tmp = None
|
||||
finally:
|
||||
if tmp and os.path.exists(tmp):
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _unlink(path):
|
||||
try:
|
||||
Path(path).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load_state(state_file=STATE_FILE):
|
||||
return _read_json(state_file) or {}
|
||||
|
||||
|
||||
def save_state(state, state_file=STATE_FILE):
|
||||
with _STATE_LOCK:
|
||||
try:
|
||||
_write_json(state_file, state)
|
||||
except OSError as e:
|
||||
logger.warning("Could not save auto-update state: %s", e)
|
||||
|
||||
|
||||
def dismiss_alert(alert_id, state_file=STATE_FILE):
|
||||
with _STATE_LOCK:
|
||||
state = load_state(state_file)
|
||||
state['alert_dismissed'] = str(alert_id)
|
||||
save_state(state, state_file)
|
||||
|
||||
|
||||
def _zone(tz_name):
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
return ZoneInfo(tz_name) if tz_name else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _local_datetime(ts, tz_name):
|
||||
return datetime.fromtimestamp(ts, _zone(tz_name))
|
||||
|
||||
|
||||
def _short(sha):
|
||||
return (sha or 'unknown')[:7]
|
||||
|
||||
|
||||
def is_due(now, next_due, local_hour):
|
||||
"""Due once ``next_due`` has passed, in quiet hours or after the grace period."""
|
||||
if next_due is None or now < next_due:
|
||||
return False
|
||||
return local_hour in QUIET_HOURS or now >= next_due + QUIET_HOURS_GRACE_SECONDS
|
||||
|
||||
|
||||
def _plugin_fingerprint(store_manager, plugin_dir):
|
||||
"""(manifest version, git sha): what changing tells us an update landed."""
|
||||
version = None
|
||||
try:
|
||||
with open(plugin_dir / 'manifest.json', 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
if manifest.get('local_only'):
|
||||
return None
|
||||
version = manifest.get('version')
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
sha = None
|
||||
try:
|
||||
git_info = store_manager._get_local_git_info(plugin_dir)
|
||||
sha = git_info.get('sha') if git_info else None
|
||||
except Exception:
|
||||
logger.debug("git info unavailable for %s", plugin_dir, exc_info=True)
|
||||
return (version, sha)
|
||||
|
||||
|
||||
def update_plugins(store_manager, operation_history=None):
|
||||
"""Update every installed plugin that has an update. Returns (updated, failed)."""
|
||||
updated, failed = [], []
|
||||
plugins_dir = Path(store_manager.plugins_dir)
|
||||
for plugin_id in sorted(store_manager.list_installed_plugins()):
|
||||
plugin_dir = plugins_dir / plugin_id
|
||||
before = _plugin_fingerprint(store_manager, plugin_dir)
|
||||
if before is None:
|
||||
continue # local_only: managed by hand, never from the registry
|
||||
try:
|
||||
ok = store_manager.update_plugin(plugin_id)
|
||||
except Exception:
|
||||
logger.exception("Automatic update of plugin %s raised", plugin_id)
|
||||
ok = False
|
||||
if not ok:
|
||||
failed.append(plugin_id)
|
||||
status = 'failed'
|
||||
elif _plugin_fingerprint(store_manager, plugin_dir) != before:
|
||||
updated.append(plugin_id)
|
||||
status = 'success'
|
||||
else:
|
||||
continue # already current; not worth a history entry every week
|
||||
if operation_history:
|
||||
try:
|
||||
operation_history.record_operation(
|
||||
'update', plugin_id=plugin_id, status=status,
|
||||
details={'automatic': True})
|
||||
except Exception:
|
||||
logger.debug("Could not record auto-update history", exc_info=True)
|
||||
return updated, failed
|
||||
|
||||
|
||||
def _service_active(unit):
|
||||
try:
|
||||
result = subprocess.run(['systemctl', 'is-active', unit],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
return result.stdout.strip() == 'active'
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def helper_ready():
|
||||
"""True when the health check can be triggered (its path unit is watching)."""
|
||||
return _service_active(PATH_UNIT)
|
||||
|
||||
|
||||
def restart_service(unit):
|
||||
"""Restart a unit only if it is running, via the sudoers-allowed command."""
|
||||
if not _service_active(unit):
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(['sudo', '-n', 'systemctl', 'restart', f'{unit}.service'],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning("Auto-update could not restart %s: %s", unit, e)
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
logger.warning("Auto-update restart of %s failed: %s", unit, result.stderr.strip())
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def start_setup_if_needed(was_enabled, config):
|
||||
"""After settings are saved: switching updates on finishes setting them up.
|
||||
|
||||
The health check units are installed by the display service at startup
|
||||
(src/auto_update_setup.py), so switching the toggle on restarts it.
|
||||
Returns a note for the save message, or None.
|
||||
"""
|
||||
if was_enabled or not is_enabled(config) or helper_ready():
|
||||
return None
|
||||
if not _service_active('ledmatrix'):
|
||||
return 'Automatic update setup finishes the next time the display service starts.'
|
||||
if restart_service('ledmatrix'):
|
||||
return 'Finishing automatic update setup: the display is restarting.'
|
||||
return 'Could not restart the display to finish automatic update setup; restart it from the Overview tab.'
|
||||
|
||||
|
||||
def _load_verifier(path):
|
||||
spec = importlib.util.spec_from_file_location('ledmatrix_auto_update_verifier', str(path))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class AutoUpdater:
|
||||
"""Decides when an automatic update is due and runs it."""
|
||||
|
||||
def __init__(self, config_manager, core_update, store_manager=None,
|
||||
plugin_manager=None, schema_manager=None, operation_history=None,
|
||||
project_root=PROJECT_ROOT, state_file=None, clock=time.time,
|
||||
restart=restart_service, run=subprocess.run,
|
||||
service_active=_service_active, helper_ready=helper_ready,
|
||||
verifier_source=VERIFIER_SOURCE, disk_free=None, sleep=time.sleep):
|
||||
self.config_manager = config_manager
|
||||
self.core_update = core_update
|
||||
self.store_manager = store_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
self.schema_manager = schema_manager
|
||||
self.operation_history = operation_history
|
||||
self.project_root = Path(project_root)
|
||||
self.state_file = Path(state_file) if state_file else self.project_root / STATE_REL
|
||||
self.pending_file = self.project_root / PENDING_REL
|
||||
self.request_file = self.project_root / REQUEST_REL
|
||||
self.verifier_copy = self.project_root / VERIFIER_COPY_REL
|
||||
self.verifier_source = Path(verifier_source)
|
||||
self.clock = clock
|
||||
self.restart = restart
|
||||
self.run_command = run
|
||||
self.service_active = service_active
|
||||
self.helper_ready = helper_ready
|
||||
self.disk_free = disk_free or (lambda path: shutil.disk_usage(path).free)
|
||||
self.sleep = sleep
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
|
||||
# -- scheduling ---------------------------------------------------------
|
||||
|
||||
def tick(self):
|
||||
"""Do whatever is due. Returns True if an update ran."""
|
||||
now = self.clock()
|
||||
state = load_state(self.state_file)
|
||||
# Before the enabled check: a verification started before the user
|
||||
# switched updates off still has to be reported.
|
||||
if self._finalize_verification(state, now) == 'waiting':
|
||||
return False
|
||||
|
||||
try:
|
||||
config = self.config_manager.load_config()
|
||||
except Exception:
|
||||
logger.debug("Auto-update could not load config", exc_info=True)
|
||||
return False
|
||||
if not is_enabled(config):
|
||||
return False
|
||||
|
||||
if state.get('plugins_pending'):
|
||||
self._run_deferred_plugins(state, now)
|
||||
return True
|
||||
|
||||
if state.get('next_due') is None:
|
||||
# Just switched on: due now, which in practice means the next
|
||||
# quiet-hours window rather than the moment Save was clicked.
|
||||
state['next_due'] = now
|
||||
save_state(state, self.state_file)
|
||||
|
||||
local_hour = _local_datetime(now, config.get('timezone')).hour
|
||||
if not is_due(now, state['next_due'], local_hour):
|
||||
return False
|
||||
self.run(state)
|
||||
return True
|
||||
|
||||
def run(self, state=None):
|
||||
state = load_state(self.state_file) if state is None else state
|
||||
logger.info("Automatic update starting")
|
||||
try:
|
||||
core = self.update_core(state)
|
||||
except Exception as e:
|
||||
logger.exception("Automatic core update raised")
|
||||
core = {'outcome': 'error', 'message': f'The LEDMatrix update failed unexpectedly: {e}'}
|
||||
|
||||
deferred = core['outcome'] == 'verifying'
|
||||
updated, failed = ([], []) if deferred else self._update_plugins()
|
||||
self._store_run(state, core, updated, failed)
|
||||
logger.info("Automatic update: core %s (%s); plugins updated=%s failed=%s%s",
|
||||
core['outcome'], core['message'], updated, failed,
|
||||
'; plugins wait for the health check' if deferred else '')
|
||||
|
||||
# Code restarts belong to the health check; this only covers plugins.
|
||||
if updated:
|
||||
self.restart('ledmatrix')
|
||||
return state['last_result']
|
||||
|
||||
def _store_run(self, state, core, updated, failed):
|
||||
now = self.clock()
|
||||
deferred = core['outcome'] == 'verifying'
|
||||
state.update({
|
||||
'last_run': now,
|
||||
'next_due': now + (RETRY_AFTER_FAILURE_SECONDS if core['outcome'] == 'error'
|
||||
else UPDATE_INTERVAL_SECONDS),
|
||||
'plugins_pending': deferred,
|
||||
'last_result': {
|
||||
'core_outcome': core['outcome'],
|
||||
'core_message': core['message'],
|
||||
'plugins_updated': updated,
|
||||
'plugins_failed': failed,
|
||||
'plugins_deferred': deferred,
|
||||
},
|
||||
})
|
||||
self._apply_result(state, now)
|
||||
save_state(state, self.state_file)
|
||||
|
||||
# -- core update --------------------------------------------------------
|
||||
|
||||
def _git(self, *args, timeout=60):
|
||||
return self.run_command(['git', *args], cwd=str(self.project_root),
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
def _count(self, rev_range):
|
||||
out = self._git('rev-list', '--count', rev_range).stdout.strip()
|
||||
return int(out) if out.isdigit() else 0
|
||||
|
||||
def preflight(self, state):
|
||||
"""Decide whether the code update may run.
|
||||
|
||||
Returns ``(outcome, message, info)`` with outcome ``ready``,
|
||||
``up_to_date``, ``blocked`` (needs the user) or ``error`` (retry soon).
|
||||
"""
|
||||
if not self.helper_ready():
|
||||
return 'blocked', HELPER_MISSING_MESSAGE, {}
|
||||
|
||||
git_dir = self._git('rev-parse', '--git-dir')
|
||||
if git_dir.returncode != 0:
|
||||
return 'blocked', 'LEDMatrix is not installed as a git checkout, so it cannot update itself.', {}
|
||||
git_dir = Path(git_dir.stdout.strip())
|
||||
if not git_dir.is_absolute():
|
||||
git_dir = self.project_root / git_dir
|
||||
rebase_dirs = [git_dir / name for name in ('rebase-merge', 'rebase-apply') if (git_dir / name).exists()]
|
||||
if rebase_dirs:
|
||||
# A rebase really in progress leaves HEAD detached. With HEAD back
|
||||
# on a branch it was abandoned -- typically a pull that stopped on
|
||||
# a conflict, then a checkout -- and it makes every later pull
|
||||
# fail. It holds no work (local edits are checked separately
|
||||
# below), so clear it rather than strand a device nobody SSHes into.
|
||||
if self._git('symbolic-ref', '-q', 'HEAD').returncode != 0:
|
||||
return 'blocked', ('A git rebase is in progress in the LEDMatrix folder. '
|
||||
'Finish or abort it; automatic updates will not touch it.'), {}
|
||||
cleared = self._git('rebase', '--quit')
|
||||
if cleared.returncode != 0 or any(path.exists() for path in rebase_dirs):
|
||||
return 'blocked', ('An abandoned git rebase in the LEDMatrix folder could not be cleared: '
|
||||
f'{(cleared.stderr or "").strip() or "unknown error"}.'), {}
|
||||
logger.warning("Cleared an abandoned git rebase from %s", ', '.join(str(p) for p in rebase_dirs))
|
||||
if (git_dir / 'MERGE_HEAD').exists():
|
||||
return 'blocked', ('A git merge is in progress in the LEDMatrix folder. '
|
||||
'Finish or abort it; automatic updates will not touch it.'), {}
|
||||
|
||||
if self._git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}').returncode != 0:
|
||||
return 'blocked', ('The current branch has no upstream to update from (or HEAD is detached). '
|
||||
'Use Update Code once, or Tools -> Switch branch.'), {}
|
||||
|
||||
# Mode-only changes are ignored: older installers chmod tracked
|
||||
# scripts, and --autostash in the pull carries those across. Plugin
|
||||
# folders are separate installs, as in perform_core_update.
|
||||
status = self._git('-c', 'core.fileMode=false', 'status', '--porcelain', '--untracked-files=no')
|
||||
changed = [line[3:] for line in status.stdout.splitlines()
|
||||
if line.strip() and 'plugins/' not in line and 'plugin-repos/' not in line]
|
||||
if status.returncode != 0 or changed:
|
||||
example = f' (for example {changed[0]})' if changed else ''
|
||||
return 'blocked', (f'{len(changed) or "Some"} tracked file(s) in the LEDMatrix folder were edited '
|
||||
f'locally{example}. Automatic updates will not stash your changes; '
|
||||
'commit or revert them, or update manually with Update Code.'), {}
|
||||
|
||||
free = self.disk_free(str(self.project_root))
|
||||
if free < MIN_FREE_BYTES:
|
||||
return 'blocked', (f'Only {free // (1024 * 1024)} MB of disk space is free; an update '
|
||||
f'needs at least {MIN_FREE_BYTES // (1024 * 1024)} MB.'), {}
|
||||
|
||||
fetch = self._git('fetch', '--quiet', timeout=120)
|
||||
if fetch.returncode != 0:
|
||||
detail = next((ln.strip() for ln in (fetch.stderr or '').splitlines() if ln.strip()), '')
|
||||
return 'error', f'Could not check for LEDMatrix updates: {detail or "git fetch failed"}.', {}
|
||||
|
||||
ahead = self._count('@{u}..HEAD')
|
||||
if ahead:
|
||||
return 'blocked', (f'This checkout has {ahead} local commit(s) that are not upstream. '
|
||||
'Automatic updates will not rebase them; update manually with Update Code.'), {}
|
||||
if not self._count('HEAD..@{u}'):
|
||||
return 'up_to_date', 'LEDMatrix is already up to date.', {}
|
||||
|
||||
upstream = self._git('rev-parse', '@{u}').stdout.strip()
|
||||
if upstream and upstream == state.get('rolled_back_head'):
|
||||
return 'up_to_date', (f'The newest LEDMatrix version ({_short(upstream)}) failed its health '
|
||||
'check and was rolled back before; waiting for a newer one.'), {}
|
||||
head = self._git('rev-parse', 'HEAD').stdout.strip()
|
||||
return 'ready', '', {'old_head': head, 'upstream_head': upstream}
|
||||
|
||||
def update_core(self, state):
|
||||
try:
|
||||
outcome, message, info = self.preflight(state)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
return {'outcome': 'error', 'message': f'Pre-update checks failed: {e}.'}
|
||||
if outcome != 'ready':
|
||||
return {'outcome': outcome, 'message': message}
|
||||
old_head = info['old_head']
|
||||
|
||||
display_was_active = self.service_active('ledmatrix')
|
||||
try:
|
||||
# Copied before pulling, so the checker is the known-good version.
|
||||
self.verifier_copy.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(self.verifier_source, self.verifier_copy)
|
||||
verifier = _load_verifier(self.verifier_copy)
|
||||
except Exception as e:
|
||||
return {'outcome': 'error', 'message': f'Could not prepare the update health check: {e}.'}
|
||||
|
||||
try:
|
||||
core = self.core_update()
|
||||
except Exception as e:
|
||||
logger.exception("perform_core_update raised")
|
||||
core = {'status': 'error', 'message': f'Update failed: {e}'}
|
||||
new_head = self._git('rev-parse', 'HEAD').stdout.strip()
|
||||
pending = {
|
||||
'status': 'pending',
|
||||
'old_head': old_head,
|
||||
'new_head': new_head,
|
||||
'display_was_active': display_was_active,
|
||||
'dependency_failures': list(core.get('dependency_failures') or []),
|
||||
'created_at': self.clock(),
|
||||
}
|
||||
|
||||
def rollback():
|
||||
return verifier.Verifier(self.project_root, run=self.run_command).rollback(pending)
|
||||
|
||||
if core.get('status') != 'success':
|
||||
message = core.get('message') or 'The LEDMatrix update failed.'
|
||||
if new_head and new_head != old_head:
|
||||
ok, detail = rollback()
|
||||
message += (' The partial update was rolled back.' if ok
|
||||
else f' Rolling back the partial update also failed: {detail}.')
|
||||
if not ok:
|
||||
return {'outcome': 'rollback_failed', 'message': message}
|
||||
return {'outcome': 'error', 'message': message}
|
||||
if new_head == old_head:
|
||||
return {'outcome': 'up_to_date', 'message': core.get('message') or 'LEDMatrix is already up to date.'}
|
||||
|
||||
handoff = {'outcome': 'verifying',
|
||||
'message': (f'Updated LEDMatrix from {_short(old_head)} to {_short(new_head)}; '
|
||||
'restarting and checking the services.')}
|
||||
# Recorded before the handoff: the health check restarts this process.
|
||||
self._store_run(state, handoff, [], [])
|
||||
try:
|
||||
_write_json(self.pending_file, pending)
|
||||
self.request_file.write_text(new_head, encoding='utf-8')
|
||||
picked_up = self._wait_for_pickup()
|
||||
except OSError as e:
|
||||
logger.error("Could not request the update health check: %s", e)
|
||||
picked_up = False
|
||||
if picked_up:
|
||||
return handoff
|
||||
|
||||
# No health check means no update: undo it while the old code is
|
||||
# still the code that is running.
|
||||
logger.error("%s did not start the health check; rolling the update back", PATH_UNIT)
|
||||
_unlink(self.request_file)
|
||||
_unlink(self.pending_file)
|
||||
ok, detail = rollback()
|
||||
if not ok:
|
||||
return {'outcome': 'rollback_failed',
|
||||
'message': (f'The update health check did not start, and rolling back to '
|
||||
f'{_short(old_head)} failed: {detail}.')}
|
||||
return {'outcome': 'blocked',
|
||||
'message': ('The update health check did not start, so the update was undone. '
|
||||
f'Check "systemctl status {PATH_UNIT}".'
|
||||
+ (f' Note: {detail}.' if detail else ''))}
|
||||
|
||||
def _wait_for_pickup(self):
|
||||
for _ in range(HANDOFF_TIMEOUT_SECONDS):
|
||||
pending = _read_json(self.pending_file)
|
||||
if pending and pending.get('status') != 'pending':
|
||||
return True
|
||||
self.sleep(1)
|
||||
pending = _read_json(self.pending_file)
|
||||
return bool(pending and pending.get('status') != 'pending')
|
||||
|
||||
def _finalize_verification(self, state, now):
|
||||
"""Fold the health check's outcome into the state. None, 'waiting' or 'done'."""
|
||||
pending = _read_json(self.pending_file)
|
||||
if pending is None:
|
||||
return None
|
||||
status = pending.get('status')
|
||||
old, new = pending.get('old_head'), pending.get('new_head')
|
||||
reason, detail = pending.get('reason'), pending.get('detail')
|
||||
|
||||
if status in ('pending', 'verifying'):
|
||||
if now - float(pending.get('created_at') or now) < VERIFY_LOST_SECONDS:
|
||||
return 'waiting'
|
||||
outcome = 'lost'
|
||||
message = (f'LEDMatrix was updated to {_short(new)}, but its health check never reported back, '
|
||||
'so it is not known whether the device is healthy. '
|
||||
f'See "journalctl -u {VERIFY_UNIT}".')
|
||||
elif status == 'success':
|
||||
outcome = 'updated'
|
||||
message = (f'Updated LEDMatrix from {_short(old)} to {_short(new)}; '
|
||||
'the services restarted and stayed healthy.')
|
||||
elif status == 'rolled_back':
|
||||
outcome = 'rolled_back'
|
||||
message = (f'The LEDMatrix update to {_short(new)} was rolled back to {_short(old)} because '
|
||||
f'{reason or "it failed its health check"}.' + (f' Note: {detail}.' if detail else ''))
|
||||
state['rolled_back_head'] = new
|
||||
else:
|
||||
outcome = 'rollback_failed'
|
||||
message = (f'The LEDMatrix update to {_short(new)} failed ({reason or "unknown reason"}) and ' # nosec B608 - user-facing message, not SQL # nosemgrep
|
||||
f'could not be rolled back: {detail or "unknown error"}. The device may need '
|
||||
f'attention: run "git reset --hard {old}" in the LEDMatrix folder, then restart '
|
||||
'the display and web services.')
|
||||
|
||||
result = state.setdefault('last_result', {})
|
||||
result.update({'core_outcome': outcome, 'core_message': message})
|
||||
if outcome not in ('updated', 'rolled_back'):
|
||||
# The device is in an unknown state; don't pile plugin changes on it.
|
||||
state['plugins_pending'] = False
|
||||
result['plugins_deferred'] = False
|
||||
self._apply_result(state, now)
|
||||
save_state(state, self.state_file)
|
||||
_unlink(self.pending_file)
|
||||
(logger.info if outcome == 'updated' else logger.error)("Automatic update: %s", message)
|
||||
return 'done'
|
||||
|
||||
# -- plugins and reporting ----------------------------------------------
|
||||
|
||||
def _update_plugins(self):
|
||||
if not self.store_manager:
|
||||
return [], []
|
||||
updated, failed = update_plugins(self.store_manager, self.operation_history)
|
||||
for plugin_id in updated:
|
||||
if self.schema_manager:
|
||||
self.schema_manager.invalidate_cache(plugin_id)
|
||||
if updated and self.plugin_manager:
|
||||
try:
|
||||
self.plugin_manager.discover_plugins()
|
||||
except Exception:
|
||||
logger.debug("discover_plugins after auto-update failed", exc_info=True)
|
||||
return updated, failed
|
||||
|
||||
def _run_deferred_plugins(self, state, now):
|
||||
state['plugins_pending'] = False
|
||||
updated, failed = self._update_plugins()
|
||||
result = state.setdefault('last_result', {})
|
||||
result.update({'plugins_updated': updated, 'plugins_failed': failed, 'plugins_deferred': False})
|
||||
self._apply_result(state, now)
|
||||
save_state(state, self.state_file)
|
||||
if updated:
|
||||
self.restart('ledmatrix')
|
||||
|
||||
@staticmethod
|
||||
def _apply_result(state, now):
|
||||
result = state.get('last_result') or {}
|
||||
outcome = result.get('core_outcome')
|
||||
failed = result.get('plugins_failed') or []
|
||||
if outcome in ERROR_OUTCOMES or failed:
|
||||
result['status'] = 'error'
|
||||
elif outcome == 'verifying':
|
||||
result['status'] = 'pending'
|
||||
else:
|
||||
result['status'] = 'success'
|
||||
|
||||
if result['status'] == 'error':
|
||||
parts = []
|
||||
if outcome in ERROR_OUTCOMES:
|
||||
parts.append(result.get('core_message') or 'The LEDMatrix update failed.')
|
||||
if failed:
|
||||
parts.append('These plugins failed to update: ' + ', '.join(failed) + '.')
|
||||
state['alert'] = {'id': str(int(now * 1000)), 'message': ' '.join(parts)}
|
||||
elif result['status'] == 'success':
|
||||
state.pop('alert', None)
|
||||
|
||||
# -- thread -------------------------------------------------------------
|
||||
|
||||
def start(self):
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(target=self._loop, name='auto-update', daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
def _loop(self):
|
||||
if self._stop.wait(STARTUP_DELAY_SECONDS):
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
self.tick()
|
||||
except Exception:
|
||||
logger.exception("Automatic update check failed")
|
||||
if self._stop.wait(CHECK_INTERVAL_SECONDS):
|
||||
return
|
||||
|
||||
|
||||
def describe_status(config, state=None, state_file=STATE_FILE, pending_file=PENDING_FILE,
|
||||
setup_file=SETUP_RESULT_FILE, helper=None):
|
||||
"""What the General tab and the Overview banner show."""
|
||||
state = load_state(state_file) if state is None else state
|
||||
tz = config.get('timezone')
|
||||
fmt = '%b %d, %Y %I:%M %p'
|
||||
pending = _read_json(pending_file) or {}
|
||||
setup = _read_json(setup_file) or {}
|
||||
enabled = is_enabled(config)
|
||||
out = {
|
||||
'last_run': None, 'summary': None, 'status': None, 'next_due': None,
|
||||
'alert': None, 'alert_id': None,
|
||||
# Only asked while enabled: it runs systemctl, on every page load.
|
||||
'verifier_installed': (helper or helper_ready)() if enabled else None,
|
||||
'setup_status': setup.get('status'),
|
||||
'setup_message': setup.get('message'),
|
||||
'verifying': pending.get('status') in ('pending', 'verifying'),
|
||||
}
|
||||
if state.get('last_run'):
|
||||
out['last_run'] = _local_datetime(state['last_run'], tz).strftime(fmt)
|
||||
result = state.get('last_result') or {}
|
||||
out['status'] = result.get('status')
|
||||
parts = [result.get('core_message') or 'No result recorded.']
|
||||
if result.get('plugins_updated'):
|
||||
parts.append('Plugins updated: ' + ', '.join(result['plugins_updated']) + '.')
|
||||
if result.get('plugins_failed'):
|
||||
parts.append('Plugins that failed to update: ' + ', '.join(result['plugins_failed']) + '.')
|
||||
if result.get('plugins_deferred') and state.get('plugins_pending'):
|
||||
parts.append('Plugin updates run once the code update passes its health check.')
|
||||
out['summary'] = ' '.join(parts)
|
||||
if enabled and state.get('next_due'):
|
||||
out['next_due'] = _local_datetime(state['next_due'], tz).strftime(fmt)
|
||||
alert = state.get('alert')
|
||||
if alert and alert.get('id') != state.get('alert_dismissed'):
|
||||
out['alert'], out['alert_id'] = alert.get('message'), alert.get('id')
|
||||
return out
|
||||
@@ -1131,14 +1131,20 @@ def _set_missing_booleans_to_false(plugin_config, schema_props, form_keys, prefi
|
||||
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||
# Recurse into nested objects
|
||||
if config_node is not None:
|
||||
# Inside an array item — ensure nested dict exists in item
|
||||
if prop_name not in node or not isinstance(node[prop_name], dict):
|
||||
node[prop_name] = {}
|
||||
# Inside an array item. Walk into the existing dict, or a scratch
|
||||
# one that is attached only if a boolean actually landed in it:
|
||||
# an optional object the item never had (a custom feed with no
|
||||
# logo) must stay absent, because an empty stub fails the
|
||||
# object's own `required` list and 400s every save.
|
||||
existing = node.get(prop_name)
|
||||
child = existing if isinstance(existing, dict) else {}
|
||||
_set_missing_booleans_to_false(
|
||||
plugin_config, prop_schema['properties'], form_keys, full_path,
|
||||
config_node=node[prop_name],
|
||||
config_node=child,
|
||||
sections=sections, submitted_parents=submitted_parents
|
||||
)
|
||||
if child is not existing and child:
|
||||
node[prop_name] = child
|
||||
else:
|
||||
_set_missing_booleans_to_false(
|
||||
plugin_config, prop_schema['properties'], form_keys, full_path,
|
||||
@@ -1687,11 +1693,14 @@ def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[st
|
||||
|
||||
magnify = plugin_config.get('magnify')
|
||||
if magnify is None:
|
||||
hw = full_config.get('display', {}).get('hardware', {})
|
||||
cols = hw.get('cols', 64)
|
||||
chain = hw.get('chain_length', 1)
|
||||
rows = hw.get('rows', 32)
|
||||
magnify = max(1, min(8, int(min((cols * chain) / 64, rows / 32))))
|
||||
# The size DisplayManager renders at (shared defaults, double-sided
|
||||
# applied), so the Pixlet render matches the screen it lands on
|
||||
from src.display_geometry import logical_size
|
||||
try:
|
||||
width, height = logical_size(full_config)
|
||||
except (TypeError, ValueError):
|
||||
width, height = 64, 32
|
||||
magnify = max(1, min(8, int(min(width / 64, height / 32))))
|
||||
else:
|
||||
try:
|
||||
magnify = max(1, min(8, int(magnify)))
|
||||
|
||||
@@ -454,16 +454,21 @@ def save_main_config():
|
||||
|
||||
# Merge with existing config (similar to original implementation)
|
||||
current_config = api_v3.config_manager.load_config()
|
||||
was_auto_update_enabled = bool((current_config.get('auto_update') or {}).get('enabled'))
|
||||
|
||||
# Handle general settings
|
||||
# Note: Checkboxes don't send data when unchecked, so we need to check if we're updating general settings
|
||||
# If any general setting is present, we're updating the general tab
|
||||
is_general_update = any(k in data for k in ['timezone', 'city', 'state', 'country', 'web_display_autostart',
|
||||
'auto_discover', 'auto_load_enabled', 'development_mode', 'plugins_directory'])
|
||||
'auto_discover', 'auto_load_enabled', 'development_mode', 'plugins_directory',
|
||||
'auto_update_enabled'])
|
||||
|
||||
if is_general_update:
|
||||
# For checkbox: if not present in data during general update, it means unchecked
|
||||
current_config['web_display_autostart'] = _coerce_to_bool(data.get('web_display_autostart'))
|
||||
if not isinstance(current_config.get('auto_update'), dict):
|
||||
current_config['auto_update'] = {}
|
||||
current_config['auto_update']['enabled'] = _coerce_to_bool(data.get('auto_update_enabled'))
|
||||
|
||||
if 'timezone' in data:
|
||||
current_config['timezone'] = data['timezone']
|
||||
@@ -1033,7 +1038,7 @@ def save_main_config():
|
||||
if key in ['timezone', 'city', 'state', 'country',
|
||||
'web_display_autostart', 'auto_discover',
|
||||
'auto_load_enabled', 'development_mode',
|
||||
'plugins_directory', 'target_fps']:
|
||||
'plugins_directory', 'target_fps', 'auto_update_enabled']:
|
||||
continue
|
||||
# Skip fields that are already handled above in their own named sections.
|
||||
# Without this, every form field name lands as a top-level config key too.
|
||||
@@ -1068,7 +1073,17 @@ def save_main_config():
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return success_response(message='Configuration saved successfully')
|
||||
message = 'Configuration saved successfully'
|
||||
# Switching automatic updates on finishes their setup, which needs
|
||||
# the display service to restart (web_interface/auto_update.py).
|
||||
try:
|
||||
from web_interface import auto_update
|
||||
note = auto_update.start_setup_if_needed(was_auto_update_enabled, current_config)
|
||||
if note:
|
||||
message = f'{message}. {note}'
|
||||
except Exception:
|
||||
logger.warning("Automatic update setup could not be started", exc_info=True)
|
||||
return success_response(message=message)
|
||||
except Exception as e:
|
||||
logger.error("Error saving config", exc_info=True)
|
||||
return error_response(
|
||||
|
||||
@@ -25,23 +25,14 @@ def get_display_current():
|
||||
|
||||
snapshot_path = "/tmp/led_matrix_preview.png"
|
||||
|
||||
# Get display dimensions from config
|
||||
# Get display dimensions from config: the logical size DisplayManager
|
||||
# renders at, so double-sided setups preview one screen
|
||||
from src.display_geometry import logical_size
|
||||
try:
|
||||
if api_v3.config_manager:
|
||||
main_config = api_v3.config_manager.load_config()
|
||||
hardware_config = main_config.get('display', {}).get('hardware', {})
|
||||
cols = hardware_config.get('cols', 64)
|
||||
chain_length = hardware_config.get('chain_length', 2)
|
||||
rows = hardware_config.get('rows', 32)
|
||||
parallel = hardware_config.get('parallel', 1)
|
||||
width = cols * chain_length
|
||||
height = rows * parallel
|
||||
else:
|
||||
width = 128
|
||||
height = 64
|
||||
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||
width, height = logical_size(config)
|
||||
except Exception:
|
||||
width = 128
|
||||
height = 64
|
||||
width, height = logical_size({})
|
||||
|
||||
# Try to read snapshot file
|
||||
image_data = None
|
||||
|
||||
@@ -154,9 +154,15 @@ def list_skins():
|
||||
"""List installed visual skins (docs/SKIN_SYSTEM.md).
|
||||
|
||||
Optional ?plugin_id=... filters to skins matching that plugin.
|
||||
|
||||
The response carries ``supported: false`` and a ``message``: the current
|
||||
scoreboard plugins don't render skins, so a client must not present
|
||||
these as selectable.
|
||||
"""
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
from src.skin_system import (
|
||||
SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE, skin_runtime,
|
||||
)
|
||||
|
||||
plugin_id = request.args.get('plugin_id')
|
||||
if plugin_id:
|
||||
@@ -181,7 +187,10 @@ def list_skins():
|
||||
'modes': manifest.get('modes', []),
|
||||
'has_preview': bool(preview and (skin_dir / preview).is_file()),
|
||||
})
|
||||
return jsonify({'status': 'success', 'data': {'skins': payload}})
|
||||
data = {'skins': payload, 'supported': SKINS_RENDER_SUPPORTED}
|
||||
if not SKINS_RENDER_SUPPORTED:
|
||||
data['message'] = SKINS_UNSUPPORTED_MESSAGE
|
||||
return jsonify({'status': 'success', 'data': data})
|
||||
except Exception as e:
|
||||
logger.error('Error in list_skins', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@@ -1256,6 +1256,16 @@ def install_plugin():
|
||||
plugin_id = data['plugin_id']
|
||||
branch = data.get('branch') # Optional branch parameter
|
||||
|
||||
# A registry skin would install but never render with the current
|
||||
# scoreboards; refuse it with the reason rather than a generic failure
|
||||
try:
|
||||
registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id)
|
||||
except Exception:
|
||||
registry_entry = None
|
||||
if isinstance(registry_entry, dict) and (registry_entry.get('type') or 'plugin') == 'skin':
|
||||
from src.skin_system import SKINS_UNSUPPORTED_MESSAGE
|
||||
return jsonify({'status': 'error', 'message': SKINS_UNSUPPORTED_MESSAGE}), 400
|
||||
|
||||
# Install the plugin
|
||||
# Log the plugins directory being used for debugging
|
||||
plugins_dir = api_v3.plugin_store_manager.plugins_dir
|
||||
@@ -1456,9 +1466,13 @@ def get_registry_from_url():
|
||||
registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url)
|
||||
|
||||
if registry:
|
||||
# Skins aren't offered: current scoreboards don't render them
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'plugins': registry.get('plugins', []),
|
||||
'plugins': [
|
||||
p for p in registry.get('plugins', [])
|
||||
if not (isinstance(p, dict) and (p.get('type') or 'plugin') == 'skin')
|
||||
],
|
||||
'registry_url': repo_url
|
||||
})
|
||||
else:
|
||||
@@ -1573,6 +1587,10 @@ def list_plugin_store():
|
||||
# Format plugins for the web interface
|
||||
formatted_plugins = []
|
||||
for plugin in plugins:
|
||||
# Registry skins install but never render with the current
|
||||
# scoreboards, so the store doesn't offer them
|
||||
if (plugin.get('type') or 'plugin') == 'skin':
|
||||
continue
|
||||
formatted_plugins.append({
|
||||
'id': plugin.get('id'),
|
||||
'name': plugin.get('name'),
|
||||
@@ -2649,18 +2667,11 @@ 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)
|
||||
# No "Visual Skin" dropdown: the current scoreboard plugins don't
|
||||
# render skins (src.skin_system.SKINS_UNSUPPORTED_MESSAGE), so
|
||||
# schema_mgr.inject_skin_selector is deliberately not called. A
|
||||
# stored "skin" value is unaffected: validation still allows it
|
||||
# and a form save deep-merges over the stored section, keeping it.
|
||||
return jsonify({'status': 'success', 'data': {'schema': schema}})
|
||||
|
||||
# Return a simple default schema if file not found
|
||||
|
||||
@@ -12,6 +12,8 @@ from web_interface.blueprints.api_v3 import (
|
||||
get_git_version, jsonify, logger, os, request, resolve_pull_command,
|
||||
shutil, subprocess,
|
||||
)
|
||||
import threading
|
||||
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# as module attributes, and a value binding would not see the patch.
|
||||
@@ -120,6 +122,32 @@ def get_system_version():
|
||||
except Exception as e:
|
||||
logger.error("get_system_version failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'Unable to retrieve version'}), 500
|
||||
@api_v3.route('/system/auto-update', methods=['GET'])
|
||||
def get_auto_update_status():
|
||||
"""Weekly automatic update status: last result, next check, and any alert.
|
||||
|
||||
No local except: a failure falls through to the app-wide handler in
|
||||
web_interface/app.py, which logs the traceback and returns the redacted
|
||||
detail -- the response every route gives, without a second copy here.
|
||||
"""
|
||||
from web_interface import auto_update
|
||||
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||
return jsonify({'status': 'success', 'data': auto_update.describe_status(config)})
|
||||
|
||||
|
||||
@api_v3.route('/system/auto-update/dismiss', methods=['POST'])
|
||||
def dismiss_auto_update_alert():
|
||||
"""Hide the current automatic-update banner until a new alert replaces it."""
|
||||
from web_interface import auto_update
|
||||
payload = request.get_json(silent=True)
|
||||
# A JSON array or scalar is a bad request, not a 500.
|
||||
alert_id = str(payload.get('alert_id') or '').strip() if isinstance(payload, dict) else ''
|
||||
if not alert_id:
|
||||
return jsonify({'status': 'error', 'message': 'alert_id required'}), 400
|
||||
auto_update.dismiss_alert(alert_id)
|
||||
return jsonify({'status': 'success'})
|
||||
|
||||
|
||||
@api_v3.route('/system/check-update', methods=['GET'])
|
||||
def check_for_update():
|
||||
"""Check whether a newer LEDMatrix commit is available on origin/main."""
|
||||
@@ -198,6 +226,219 @@ def _sudo_hint_for(text):
|
||||
return None
|
||||
|
||||
|
||||
_core_update_lock = threading.Lock()
|
||||
|
||||
|
||||
def perform_core_update():
|
||||
"""Pull the latest LEDMatrix code and sync its dependencies.
|
||||
|
||||
Shared by the Overview "Update Code" button and the weekly automatic
|
||||
updater (web_interface/auto_update.py), so both take exactly the same
|
||||
path. Returns the JSON-able payload the button has always received:
|
||||
``status``, ``message`` and ``restart_required``.
|
||||
"""
|
||||
# The button and the scheduler can fire together; two pulls racing
|
||||
# over one checkout (and one stash) is how local changes get lost.
|
||||
if not _core_update_lock.acquire(blocking=False):
|
||||
return {'status': 'error', 'restart_required': False,
|
||||
'message': 'An update is already in progress; try again shortly.'}
|
||||
try:
|
||||
return _perform_core_update_locked()
|
||||
finally:
|
||||
_core_update_lock.release()
|
||||
|
||||
|
||||
def _perform_core_update_locked():
|
||||
project_dir = str(PROJECT_ROOT)
|
||||
|
||||
# Decide how to pull BEFORE stashing. If this checkout cannot be
|
||||
# updated at all, stashing first would put the user's local changes
|
||||
# away for an update that was never going to run.
|
||||
pull_args, upstream_note, pull_error = resolve_pull_command(project_dir)
|
||||
if pull_error:
|
||||
logger.warning("git pull not attempted: %s", pull_error)
|
||||
return {'status': 'error', 'message': pull_error, 'restart_required': False}
|
||||
|
||||
# Check if there are local changes that need to be stashed
|
||||
# Exclude plugins directory - plugins are separate repos and shouldn't be stashed with base project
|
||||
# Use --untracked-files=no to skip untracked files check (much faster with symlinked plugins)
|
||||
try:
|
||||
status_result = subprocess.run(
|
||||
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=project_dir
|
||||
)
|
||||
# Filter out any changes in plugins directory - plugins are separate repositories
|
||||
# Git status format: XY filename (where X is status of index, Y is status of work tree)
|
||||
status_lines = [line for line in status_result.stdout.strip().split('\n')
|
||||
if line.strip() and 'plugins/' not in line]
|
||||
has_changes = bool('\n'.join(status_lines).strip())
|
||||
except subprocess.TimeoutExpired:
|
||||
# If status check times out, assume there might be changes and proceed
|
||||
# This is safer than failing the update
|
||||
has_changes = True
|
||||
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
||||
|
||||
stash_info = ""
|
||||
|
||||
# Stash local changes if they exist (excluding plugins)
|
||||
# Plugins are separate repositories and shouldn't be stashed with base project updates
|
||||
if has_changes:
|
||||
try:
|
||||
# Use pathspec to exclude plugins directory from stash
|
||||
stash_result = subprocess.run(
|
||||
['git', 'stash', 'push', '-m', 'LEDMatrix auto-stash before update', '--', ':!plugins'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=project_dir
|
||||
)
|
||||
if stash_result.returncode == 0:
|
||||
logger.debug("git stash: stashed local changes before pull")
|
||||
stash_info = " Local changes were stashed."
|
||||
else:
|
||||
logger.warning("git stash failed before pull (returncode=%d)", stash_result.returncode)
|
||||
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")
|
||||
|
||||
# Whether the pull actually brought new code in. "Already up to
|
||||
# date" is a success too, and prompting for a restart then would
|
||||
# train users to ignore the prompt.
|
||||
code_changed = False
|
||||
# Requirement files whose install failed. The automatic updater refuses
|
||||
# to restart onto code whose dependencies did not install.
|
||||
dependency_failures = []
|
||||
|
||||
# Perform the git pull. Branches without an upstream were given
|
||||
# an explicit "origin <branch>" above so the update still works.
|
||||
result = subprocess.run(
|
||||
pull_args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=project_dir
|
||||
)
|
||||
|
||||
# Give the branch tracking information so the next pull is a plain
|
||||
# `git pull` — otherwise every update repeats the fallback.
|
||||
if result.returncode == 0 and upstream_note:
|
||||
branch = _git_current_branch(project_dir)
|
||||
if branch:
|
||||
try:
|
||||
subprocess.run(
|
||||
['git', 'branch', f'--set-upstream-to=origin/{branch}', branch],
|
||||
capture_output=True, text=True, timeout=10, cwd=project_dir)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.debug("could not set upstream for %s: %s", branch, exc)
|
||||
|
||||
# Return custom response for git_pull
|
||||
if result.returncode == 0:
|
||||
pull_message = "Code updated successfully."
|
||||
if has_changes:
|
||||
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}"
|
||||
if upstream_note:
|
||||
pull_message = f"{pull_message} {upstream_note}"
|
||||
|
||||
# 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:
|
||||
code_changed = True
|
||||
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:
|
||||
dependency_failures.append(rel)
|
||||
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:
|
||||
dependency_failures.append(rel)
|
||||
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:
|
||||
dependency_failures.append(rel)
|
||||
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
|
||||
# resurrect them.
|
||||
if api_v3.plugin_store_manager:
|
||||
try:
|
||||
purged = api_v3.plugin_store_manager.purge_uninstalled_plugins()
|
||||
if purged:
|
||||
logger.info(
|
||||
"Re-removed %d uninstalled plugin(s) restored by update: %s",
|
||||
len(purged), ", ".join(purged),
|
||||
)
|
||||
except (OSError, RuntimeError) as purge_err:
|
||||
logger.warning("Post-update plugin purge failed: %s", purge_err)
|
||||
else:
|
||||
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
|
||||
# Show git's own first line: "check logs" leaves the user with
|
||||
# nothing to act on, and these failures are usually actionable
|
||||
# (conflicting local commits, no upstream, network).
|
||||
detail = next((ln.strip() for ln in (result.stderr or '').splitlines()
|
||||
if ln.strip()), '')
|
||||
pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details"
|
||||
|
||||
# Nothing here restarts anything: the pull replaces files on
|
||||
# disk while the display and web services keep running the code
|
||||
# they loaded at boot. Without this the user is told the update
|
||||
# succeeded and sees no change until they happen to reboot.
|
||||
return {
|
||||
'status': 'success' if result.returncode == 0 else 'error',
|
||||
'message': pull_message,
|
||||
'restart_required': bool(result.returncode == 0 and code_changed),
|
||||
'dependency_failures': dependency_failures,
|
||||
}
|
||||
|
||||
|
||||
@api_v3.route('/system/action', methods=['POST'])
|
||||
def execute_system_action():
|
||||
"""Execute system actions (start/stop/reboot/etc)"""
|
||||
@@ -265,188 +506,7 @@ def execute_system_action():
|
||||
result = subprocess.run(['sudo', 'poweroff'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'git_pull':
|
||||
# Use PROJECT_ROOT instead of hardcoded path
|
||||
project_dir = str(PROJECT_ROOT)
|
||||
|
||||
# Decide how to pull BEFORE stashing. If this checkout cannot be
|
||||
# updated at all, stashing first would put the user's local changes
|
||||
# away for an update that was never going to run.
|
||||
pull_args, upstream_note, pull_error = resolve_pull_command(project_dir)
|
||||
if pull_error:
|
||||
logger.warning("git pull not attempted: %s", pull_error)
|
||||
return jsonify({'status': 'error', 'message': pull_error})
|
||||
|
||||
# Check if there are local changes that need to be stashed
|
||||
# Exclude plugins directory - plugins are separate repos and shouldn't be stashed with base project
|
||||
# Use --untracked-files=no to skip untracked files check (much faster with symlinked plugins)
|
||||
try:
|
||||
status_result = subprocess.run(
|
||||
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=project_dir
|
||||
)
|
||||
# Filter out any changes in plugins directory - plugins are separate repositories
|
||||
# Git status format: XY filename (where X is status of index, Y is status of work tree)
|
||||
status_lines = [line for line in status_result.stdout.strip().split('\n')
|
||||
if line.strip() and 'plugins/' not in line]
|
||||
has_changes = bool('\n'.join(status_lines).strip())
|
||||
except subprocess.TimeoutExpired:
|
||||
# If status check times out, assume there might be changes and proceed
|
||||
# This is safer than failing the update
|
||||
has_changes = True
|
||||
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
||||
|
||||
stash_info = ""
|
||||
|
||||
# Stash local changes if they exist (excluding plugins)
|
||||
# Plugins are separate repositories and shouldn't be stashed with base project updates
|
||||
if has_changes:
|
||||
try:
|
||||
# Use pathspec to exclude plugins directory from stash
|
||||
stash_result = subprocess.run(
|
||||
['git', 'stash', 'push', '-m', 'LEDMatrix auto-stash before update', '--', ':!plugins'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=project_dir
|
||||
)
|
||||
if stash_result.returncode == 0:
|
||||
logger.debug("git stash: stashed local changes before pull")
|
||||
stash_info = " Local changes were stashed."
|
||||
else:
|
||||
logger.warning("git stash failed before pull (returncode=%d)", stash_result.returncode)
|
||||
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")
|
||||
|
||||
# Whether the pull actually brought new code in. "Already up to
|
||||
# date" is a success too, and prompting for a restart then would
|
||||
# train users to ignore the prompt.
|
||||
code_changed = False
|
||||
|
||||
# Perform the git pull. Branches without an upstream were given
|
||||
# an explicit "origin <branch>" above so the update still works.
|
||||
result = subprocess.run(
|
||||
pull_args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=project_dir
|
||||
)
|
||||
|
||||
# Give the branch tracking information so the next pull is a plain
|
||||
# `git pull` — otherwise every update repeats the fallback.
|
||||
if result.returncode == 0 and upstream_note:
|
||||
branch = _git_current_branch(project_dir)
|
||||
if branch:
|
||||
try:
|
||||
subprocess.run(
|
||||
['git', 'branch', f'--set-upstream-to=origin/{branch}', branch],
|
||||
capture_output=True, text=True, timeout=10, cwd=project_dir)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.debug("could not set upstream for %s: %s", branch, exc)
|
||||
|
||||
# Return custom response for git_pull
|
||||
if result.returncode == 0:
|
||||
pull_message = "Code updated successfully."
|
||||
if has_changes:
|
||||
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}"
|
||||
if upstream_note:
|
||||
pull_message = f"{pull_message} {upstream_note}"
|
||||
|
||||
# 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:
|
||||
code_changed = True
|
||||
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
|
||||
# resurrect them.
|
||||
if api_v3.plugin_store_manager:
|
||||
try:
|
||||
purged = api_v3.plugin_store_manager.purge_uninstalled_plugins()
|
||||
if purged:
|
||||
logger.info(
|
||||
"Re-removed %d uninstalled plugin(s) restored by update: %s",
|
||||
len(purged), ", ".join(purged),
|
||||
)
|
||||
except (OSError, RuntimeError) as purge_err:
|
||||
logger.warning("Post-update plugin purge failed: %s", purge_err)
|
||||
else:
|
||||
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
|
||||
# Show git's own first line: "check logs" leaves the user with
|
||||
# nothing to act on, and these failures are usually actionable
|
||||
# (conflicting local commits, no upstream, network).
|
||||
detail = next((ln.strip() for ln in (result.stderr or '').splitlines()
|
||||
if ln.strip()), '')
|
||||
pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details"
|
||||
|
||||
# Nothing here restarts anything: the pull replaces files on
|
||||
# disk while the display and web services keep running the code
|
||||
# they loaded at boot. Without this the user is told the update
|
||||
# succeeded and sees no change until they happen to reboot.
|
||||
return jsonify({
|
||||
'status': 'success' if result.returncode == 0 else 'error',
|
||||
'message': pull_message,
|
||||
'restart_required': bool(result.returncode == 0 and code_changed),
|
||||
})
|
||||
return jsonify(perform_core_update())
|
||||
elif action == 'checkout_branch':
|
||||
# Switch branches from the Tools tab. Needed because a checkout
|
||||
# that predates tracking (or a restored backup) can leave the pi
|
||||
|
||||
@@ -3,10 +3,73 @@
|
||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||
endpoint names are unchanged by living here.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
api_v3, describe_exception, jsonify, logger, request,
|
||||
)
|
||||
|
||||
# Joining a network while the setup AP is up has to take the AP down first,
|
||||
# and the caller is almost always a phone connected *through* that AP. Run
|
||||
# synchronously, the request outlives the link it arrived on: the browser
|
||||
# never gets an answer and the Connect button appears to do nothing. So in AP
|
||||
# mode the connect runs in the background and the result is kept here, where
|
||||
# /wifi/status can report it once the user is back on either network.
|
||||
_connect_lock = threading.Lock()
|
||||
_last_connect_attempt = None
|
||||
# Lets the 202 reach the phone before hostapd stops.
|
||||
_AP_HANDOFF_DELAY_SECONDS = 2.0
|
||||
|
||||
|
||||
def _last_connect_snapshot():
|
||||
with _connect_lock:
|
||||
return dict(_last_connect_attempt) if _last_connect_attempt else None
|
||||
|
||||
|
||||
def _spawn(target):
|
||||
threading.Thread(target=target, name='wifi-connect', daemon=True).start()
|
||||
|
||||
|
||||
def _connect_result_payload(ssid, success, message):
|
||||
"""Shape a connect_to_network result for the client.
|
||||
|
||||
The manager flags a rejected passphrase by prefixing the message with
|
||||
"wrong_password:"; the setup pages key off `error_type` rather than
|
||||
parsing nmcli's wording.
|
||||
"""
|
||||
if success:
|
||||
return {'status': 'success', 'message': message}
|
||||
payload = {'status': 'error', 'message': message or 'Failed to connect to network'}
|
||||
if message and message.startswith('wrong_password:'):
|
||||
payload['error_type'] = 'wrong_password'
|
||||
payload['message'] = f'Incorrect password for {ssid}'
|
||||
return payload
|
||||
|
||||
|
||||
def _record_connect_result(ssid, payload):
|
||||
global _last_connect_attempt
|
||||
with _connect_lock:
|
||||
_last_connect_attempt = {
|
||||
'ssid': ssid,
|
||||
'state': 'success' if payload['status'] == 'success' else 'failed',
|
||||
'message': payload['message'],
|
||||
'error_type': payload.get('error_type'),
|
||||
'finished_at': time.time(),
|
||||
}
|
||||
|
||||
|
||||
def _run_background_connect(ssid, password):
|
||||
try:
|
||||
time.sleep(_AP_HANDOFF_DELAY_SECONDS)
|
||||
from src.wifi_manager import WiFiManager
|
||||
success, message = WiFiManager().connect_to_network(ssid, password)
|
||||
payload = _connect_result_payload(ssid, success, message)
|
||||
except Exception as e:
|
||||
logger.error("Background WiFi connect failed", exc_info=True)
|
||||
payload = {'status': 'error', 'message': describe_exception(e)}
|
||||
_record_connect_result(ssid, payload)
|
||||
|
||||
|
||||
def _parse_bool_ish(value):
|
||||
"""Coerce a JSON value that is supposed to be a boolean.
|
||||
@@ -65,7 +128,8 @@ def get_wifi_status():
|
||||
'ip_address': status.ip_address,
|
||||
'signal': status.signal,
|
||||
'ap_mode_active': status.ap_mode_active,
|
||||
'auto_enable_ap_mode': auto_enable_ap
|
||||
'auto_enable_ap_mode': auto_enable_ap,
|
||||
'last_connect_attempt': _last_connect_snapshot(),
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -149,7 +213,12 @@ def scan_wifi_networks():
|
||||
}), 500
|
||||
@api_v3.route('/wifi/connect', methods=['POST'])
|
||||
def connect_wifi():
|
||||
"""Connect to a WiFi network"""
|
||||
"""Connect to a WiFi network.
|
||||
|
||||
With the setup AP active this answers 202 at once and connects in the
|
||||
background (see _last_connect_attempt); otherwise it waits for the result.
|
||||
"""
|
||||
global _last_connect_attempt
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
@@ -177,18 +246,50 @@ def connect_wifi():
|
||||
password = data.get('password', '') or ''
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
success, message = wifi_manager.connect_to_network(ssid, password)
|
||||
ap_mode_active = wifi_manager._is_ap_mode_active()
|
||||
|
||||
if success:
|
||||
# One attempt at a time on either path: concurrent connects fight over
|
||||
# the radio, and the first to finish would clear the in-progress flag
|
||||
# the monitor daemon still needs for the other. The check can't depend
|
||||
# on AP state either -- a background attempt takes the AP down long
|
||||
# before it finishes.
|
||||
with _connect_lock:
|
||||
if _last_connect_attempt and _last_connect_attempt['state'] == 'pending':
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f"Already connecting to {_last_connect_attempt['ssid']}"
|
||||
}), 409
|
||||
_last_connect_attempt = {
|
||||
'ssid': ssid, 'state': 'pending', 'message': None,
|
||||
'error_type': None, 'finished_at': None,
|
||||
}
|
||||
|
||||
if ap_mode_active:
|
||||
try:
|
||||
_spawn(lambda: _run_background_connect(ssid, password))
|
||||
except Exception:
|
||||
# Nothing will ever finish this attempt; don't leave every
|
||||
# later request refused.
|
||||
with _connect_lock:
|
||||
_last_connect_attempt = None
|
||||
raise
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': message
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message or 'Failed to connect to network'
|
||||
}), 400
|
||||
'status': 'pending',
|
||||
'message': (
|
||||
f'Connecting to {ssid}. The LEDMatrix-Setup network will turn off, '
|
||||
'so this page will lose its connection.'
|
||||
),
|
||||
'data': {'ssid': ssid},
|
||||
}), 202
|
||||
|
||||
try:
|
||||
success, message = wifi_manager.connect_to_network(ssid, password)
|
||||
except Exception as e:
|
||||
_record_connect_result(ssid, {'status': 'error', 'message': describe_exception(e)})
|
||||
raise
|
||||
payload = _connect_result_payload(ssid, success, message)
|
||||
_record_connect_result(ssid, payload)
|
||||
return jsonify(payload), (200 if success else 400)
|
||||
except Exception as e:
|
||||
logger.error("Error connecting to WiFi", exc_info=True)
|
||||
return jsonify({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint, render_template, flash, jsonify
|
||||
from flask import Blueprint, Response, render_template, flash, jsonify, url_for
|
||||
from jinja2 import TemplateNotFound
|
||||
from markupsafe import escape
|
||||
from html.parser import HTMLParser
|
||||
@@ -14,6 +14,7 @@ _SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
|
||||
from src.web_interface.secret_helpers import mask_secret_fields
|
||||
from src.common.path_safety import resolve_under, safe_path_component
|
||||
from web_interface import widget_bundle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,6 +27,30 @@ schema_manager = None
|
||||
pages_v3 = Blueprint('pages_v3', __name__)
|
||||
|
||||
|
||||
@pages_v3.route('/assets/widgets.js')
|
||||
def widgets_bundle():
|
||||
"""Every widget script in one request (see web_interface/widget_bundle.py).
|
||||
|
||||
The individual files stay served from /static for plugin-loader.js and
|
||||
debugging; this saves the Pi ~30 round trips on each first page load.
|
||||
Cached as immutable by app.py because the URL carries the version.
|
||||
"""
|
||||
body, version = widget_bundle.build_bundle()
|
||||
response = Response(body, mimetype='application/javascript')
|
||||
response.headers['X-Widget-Bundle-Version'] = str(version)
|
||||
return response
|
||||
|
||||
|
||||
@pages_v3.app_context_processor
|
||||
def inject_widget_bundle_url():
|
||||
"""`widgets_bundle_url()` for templates, versioned by file mtime."""
|
||||
return {
|
||||
'widgets_bundle_url': lambda: url_for(
|
||||
'pages_v3.widgets_bundle', v=widget_bundle.bundle_version()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class _SettingsIndexParser(HTMLParser):
|
||||
"""Extract searchable settings fields from a rendered partial's HTML.
|
||||
|
||||
@@ -456,7 +481,11 @@ def serve_plugin_widget(plugin_id, widget_name):
|
||||
# to ship an undeclared file is not something to confirm.
|
||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||
|
||||
widgets_dir = (plugin_dir / 'widgets').resolve()
|
||||
# Contain widgets/ itself first: a symlinked widgets directory would
|
||||
# otherwise become the base the script is checked against.
|
||||
widgets_dir = resolve_under(plugin_dir, 'widgets')
|
||||
if widgets_dir is None:
|
||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||
# The script name comes from the plugin's manifest, not the request,
|
||||
# so it gets the same containment treatment the URL parts got.
|
||||
script_path = resolve_under(widgets_dir, script)
|
||||
@@ -496,8 +525,15 @@ def _load_general_partial():
|
||||
try:
|
||||
if pages_v3.config_manager:
|
||||
main_config = pages_v3.config_manager.load_config()
|
||||
try:
|
||||
from web_interface.auto_update import describe_status
|
||||
auto_update_status = describe_status(main_config)
|
||||
except Exception:
|
||||
logger.debug("Could not read auto-update status", exc_info=True)
|
||||
auto_update_status = None
|
||||
return render_template('v3/partials/general.html',
|
||||
main_config=main_config)
|
||||
main_config=main_config,
|
||||
auto_update_status=auto_update_status)
|
||||
except Exception as e:
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
@@ -100,8 +100,9 @@ def main():
|
||||
werkzeug_logger.error = log_exception_filtered
|
||||
|
||||
# Import and run the Flask app
|
||||
from web_interface.app import app
|
||||
|
||||
from web_interface.app import app, start_auto_update_scheduler
|
||||
start_auto_update_scheduler()
|
||||
|
||||
print("Starting LED Matrix Web Interface V3...")
|
||||
print("Web server binding to: 0.0.0.0:5000")
|
||||
|
||||
|
||||
+654
-70
@@ -26,6 +26,9 @@
|
||||
--color-info-bg: #dbeafe;
|
||||
--color-purple-bg: #f3e8ff;
|
||||
--color-purple-text: #6b21a8;
|
||||
--color-field-bg: #ffffff;
|
||||
--color-field-border: #d1d5db;
|
||||
--color-focus-ring: #2563eb;
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 5%);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 10%), 0 1px 2px 0 rgb(0 0 0 / 6%);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 10%), 0 2px 4px -1px rgb(0 0 0 / 6%);
|
||||
@@ -57,6 +60,9 @@
|
||||
--color-info-bg: #1e3a5f;
|
||||
--color-purple-bg: #2e1065;
|
||||
--color-purple-text: #c4b5fd;
|
||||
--color-field-bg: #374151;
|
||||
--color-field-border: #4b5563;
|
||||
--color-focus-ring: #60a5fa;
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 30%);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 40%), 0 1px 2px 0 rgb(0 0 0 / 30%);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 40%), 0 2px 4px -1px rgb(0 0 0 / 30%);
|
||||
@@ -105,6 +111,77 @@ body {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
/* Border utilities (.border, .border-t, ...) set width and style only; this
|
||||
gives them the theme's border color unless a border-* color class says
|
||||
otherwise. */
|
||||
*, ::before, ::after {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* Button reset. Markup styles buttons with utility classes (bg-*, px-*,
|
||||
border); without it, any button the classes don't fully style falls back to
|
||||
the browser's gray outset look. Element selector, so every class wins. */
|
||||
:where(button) {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
line-height: inherit;
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:where(button:disabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Inline code (paths, commands) must not push a row wider than a phone. */
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* One keyboard focus indicator for everything. Pointer clicks don't show it
|
||||
(:focus-visible); components with their own ring keep theirs. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: rgb(37 99 235 / 25%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] ::selection {
|
||||
background-color: rgb(96 165 250 / 35%);
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-color: #94a3b8 transparent;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
scrollbar-color: #4b5563 transparent;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: -4rem;
|
||||
z-index: 100;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.bg-gray-50 { background-color: #f9fafb; }
|
||||
.bg-gray-100 { background-color: #f3f4f6; }
|
||||
@@ -285,10 +362,10 @@ h4 { font-size: 1.125rem; }
|
||||
.hover\:text-gray-700:hover { color: #374151; }
|
||||
.hover\:border-gray-300:hover { border-color: #d1d5db; }
|
||||
|
||||
.focus\:outline-none:focus { outline: 2px solid transparent; outline-offset: 2px; }
|
||||
/* Only suppress the outline for pointer focus; keyboard focus keeps it. */
|
||||
.focus\:outline-none:focus:not(:focus-visible) { outline: 2px solid transparent; outline-offset: 2px; }
|
||||
.focus\:ring-2:focus {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 calc(2px + var(--tw-ring-offset-width, 0px)) var(--tw-ring-color, rgb(59 130 246 / 50%)), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
/* Optimized: Split complex selector onto multiple lines for readability */
|
||||
.focus\:ring-blue-500:focus { --tw-ring-color: #3b82f6; }
|
||||
@@ -353,12 +430,16 @@ a, button, input, select, textarea {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Ensure logs content doesn't cause overflow issues */
|
||||
/* Ensure logs content doesn't cause overflow issues. content-visibility lets
|
||||
the Pi skip layout and paint for the rows scrolled out of view; the
|
||||
intrinsic size keeps the scrollbar stable. */
|
||||
.log-entry {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 1.5rem;
|
||||
}
|
||||
|
||||
/* Ensure proper containment of logs */
|
||||
@@ -398,17 +479,356 @@ a, button, input, select, textarea {
|
||||
/* Logs container responsive height - simplified for better scrolling */
|
||||
@media (max-width: 768px) {
|
||||
#logs-container {
|
||||
height: 400px !important;
|
||||
min-height: 300px !important;
|
||||
height: clamp(260px, 60vh, 480px) !important;
|
||||
height: clamp(260px, 60svh, 480px) !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
#logs-container {
|
||||
height: 350px !important;
|
||||
min-height: 250px !important;
|
||||
}
|
||||
}
|
||||
/* ===== Utility layer (fills the Tailwind-style gaps) =====
|
||||
Templates and JS are authored in Tailwind class names, but there is no
|
||||
Tailwind build: this file is the whole stylesheet. Every utility class the
|
||||
markup uses must be defined here or it silently does nothing
|
||||
(test/test_web_static_audit.py enforces this). Values follow Tailwind v3.
|
||||
Keep `.hidden` after the display utilities so JS show/hide wins. */
|
||||
.-translate-y-1\/2 { --tw-translate-y: -50%; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.accent-blue-600 { accent-color: #2563eb; }
|
||||
.align-bottom { vertical-align: bottom; }
|
||||
.appearance-none { appearance: none; -webkit-appearance: none; }
|
||||
.backdrop-blur-sm { backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
||||
.bg-amber-100 { --tw-bg-opacity: 1; background-color: rgb(254 243 199 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-amber-50 { --tw-bg-opacity: 1; background-color: rgb(255 251 235 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-amber-500\/10 { background-color: rgb(245 158 11 / 0.1); }
|
||||
.bg-amber-600 { --tw-bg-opacity: 1; background-color: rgb(217 119 6 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-black { --tw-bg-opacity: 1; background-color: rgb(0 0 0 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-blue-100 { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-blue-50 { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-gray-300 { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-gray-400 { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-gray-500 { --tw-bg-opacity: 1; background-color: rgb(107 114 128 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-gray-600 { --tw-bg-opacity: 1; background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-green-50 { --tw-bg-opacity: 1; background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-opacity-20 { --tw-bg-opacity: 0.2; }
|
||||
.bg-opacity-70 { --tw-bg-opacity: 0.7; }
|
||||
.bg-opacity-75 { --tw-bg-opacity: 0.75; }
|
||||
.bg-purple-100 { --tw-bg-opacity: 1; background-color: rgb(243 232 255 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-purple-50 { --tw-bg-opacity: 1; background-color: rgb(250 245 255 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-purple-700 { --tw-bg-opacity: 1; background-color: rgb(126 34 206 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-red-50 { --tw-bg-opacity: 1; background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-red-800 { --tw-bg-opacity: 1; background-color: rgb(153 27 27 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-yellow-50 { --tw-bg-opacity: 1; background-color: rgb(254 252 232 / var(--tw-bg-opacity, 1)); }
|
||||
.block { display: block; }
|
||||
.border { border-width: 1px; border-style: solid; }
|
||||
.border-2 { border-width: 2px; border-style: solid; }
|
||||
.border-amber-200 { border-color: #fde68a; }
|
||||
.border-amber-300 { border-color: #fcd34d; }
|
||||
.border-amber-500\/60 { border-color: rgb(245 158 11 / 0.6); }
|
||||
.border-blue-200 { border-color: #bfdbfe; }
|
||||
.border-blue-300 { border-color: #93c5fd; }
|
||||
.border-dashed { border-style: dashed; }
|
||||
.border-gray-100 { border-color: #f3f4f6; }
|
||||
.border-gray-400 { border-color: #9ca3af; }
|
||||
.border-gray-600 { border-color: #4b5563; }
|
||||
.border-green-200 { border-color: #bbf7d0; }
|
||||
.border-green-300 { border-color: #86efac; }
|
||||
.border-green-400 { border-color: #4ade80; }
|
||||
.border-green-500 { border-color: #22c55e; }
|
||||
.border-l-0 { border-left-width: 0px; border-left-style: solid; }
|
||||
.border-purple-200 { border-color: #e9d5ff; }
|
||||
.border-purple-300 { border-color: #d8b4fe; }
|
||||
.border-r-0 { border-right-width: 0px; border-right-style: solid; }
|
||||
.border-red-200 { border-color: #fecaca; }
|
||||
.border-red-300 { border-color: #fca5a5; }
|
||||
.border-red-400 { border-color: #f87171; }
|
||||
.border-red-500 { border-color: #ef4444; }
|
||||
.border-t { border-top-width: 1px; border-top-style: solid; }
|
||||
.border-yellow-200 { border-color: #fef08a; }
|
||||
.border-yellow-300 { border-color: #fde047; }
|
||||
.border-yellow-400 { border-color: #facc15; }
|
||||
.break-all { word-break: break-all; }
|
||||
.break-words { overflow-wrap: break-word; }
|
||||
.capitalize { text-transform: capitalize; }
|
||||
.col-span-full { grid-column: 1 / -1; }
|
||||
.cursor-move { cursor: move; }
|
||||
.cursor-not-allowed { cursor: not-allowed; }
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.cursor-wait { cursor: wait; }
|
||||
.divide-gray-100 > :not(:first-child) { border-color: #f3f4f6; }
|
||||
.divide-gray-200 > :not(:first-child) { border-color: #e5e7eb; }
|
||||
.divide-y > :not(:first-child) { border-top-width: 1px; border-top-style: solid; }
|
||||
.duration-150 { transition-duration: 150ms; }
|
||||
.duration-200 { transition-duration: 200ms; }
|
||||
.duration-300 { transition-duration: 300ms; }
|
||||
.ease-in-out { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
.ease-out { transition-timing-function: cubic-bezier(0, 0, 0.2, 1); }
|
||||
.flex-\[3\] { flex: 3 1 0%; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
|
||||
.font-normal { font-weight: 400; }
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.h-1 { height: 0.25rem; }
|
||||
.h-12 { height: 3rem; }
|
||||
.h-20 { height: 5rem; }
|
||||
.h-3 { height: 0.75rem; }
|
||||
.h-40 { height: 10rem; }
|
||||
.h-5 { height: 1.25rem; }
|
||||
.h-6 { height: 1.5rem; }
|
||||
.h-64 { height: 16rem; }
|
||||
.h-7 { height: 1.75rem; }
|
||||
.h-8 { height: 2rem; }
|
||||
.h-9 { height: 2.25rem; }
|
||||
.h-full { height: 100%; }
|
||||
.inline { display: inline; }
|
||||
.inline-block { display: inline-block; }
|
||||
.inset-0 { inset: 0px; }
|
||||
.inset-y-0 { top: 0px; bottom: 0px; }
|
||||
.italic { font-style: italic; }
|
||||
.items-end { align-items: flex-end; }
|
||||
.items-start { align-items: flex-start; }
|
||||
.justify-end { justify-content: flex-end; }
|
||||
.leading-6 { line-height: 1.5rem; }
|
||||
.leading-none { line-height: 1; }
|
||||
.left-0 { left: 0px; }
|
||||
.left-3 { left: 0.75rem; }
|
||||
.left-\[3px\] { left: 3px; }
|
||||
.line-clamp-2 { overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.line-through { text-decoration-line: line-through; }
|
||||
.list-disc { list-style-type: disc; }
|
||||
.list-inside { list-style-position: inside; }
|
||||
.max-h-32 { max-height: 8rem; }
|
||||
.max-h-48 { max-height: 12rem; }
|
||||
.max-h-64 { max-height: 16rem; }
|
||||
.max-h-96 { max-height: 24rem; }
|
||||
.max-h-screen { max-height: 100vh; }
|
||||
.max-w-\[14rem\] { max-width: 14rem; }
|
||||
.max-w-lg { max-width: 32rem; }
|
||||
.max-w-md { max-width: 28rem; }
|
||||
.max-w-xs { max-width: 20rem; }
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-5 { margin-bottom: 1.25rem; }
|
||||
.min-h-\[100px\] { min-height: 100px; }
|
||||
.min-h-screen { min-height: 100vh; }
|
||||
.min-w-\[12rem\] { min-width: 12rem; }
|
||||
.min-w-full { min-width: 100%; }
|
||||
.ml-1 { margin-left: 0.25rem; }
|
||||
.ml-1\.5 { margin-left: 0.375rem; }
|
||||
.ml-2 { margin-left: 0.5rem; }
|
||||
.ml-4 { margin-left: 1rem; }
|
||||
.ml-5 { margin-left: 1.25rem; }
|
||||
.ml-6 { margin-left: 1.5rem; }
|
||||
.ml-auto { margin-left: auto; }
|
||||
.mr-1 { margin-right: 0.25rem; }
|
||||
.mr-3 { margin-right: 0.75rem; }
|
||||
.mt-0\.5 { margin-top: 0.125rem; }
|
||||
.mt-8 { margin-top: 2rem; }
|
||||
.mt-auto { margin-top: auto; }
|
||||
.mx-2 { margin-left: 0.5rem; margin-right: 0.5rem; }
|
||||
.my-2 { margin-top: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.object-cover { object-fit: cover; }
|
||||
.opacity-50 { opacity: 0.5; }
|
||||
.opacity-60 { opacity: 0.6; }
|
||||
.opacity-70 { opacity: 0.7; }
|
||||
.overflow-auto { overflow: auto; }
|
||||
.overflow-y-auto { overflow-y: auto; }
|
||||
.p-0 { padding: 0px; }
|
||||
.p-0\.5 { padding: 0.125rem; }
|
||||
.p-1 { padding: 0.25rem; }
|
||||
.pb-20 { padding-bottom: 5rem; }
|
||||
.pb-3 { padding-bottom: 0.75rem; }
|
||||
.pl-3 { padding-left: 0.75rem; }
|
||||
.pl-5 { padding-left: 1.25rem; }
|
||||
.pl-8 { padding-left: 2rem; }
|
||||
.pl-9 { padding-left: 2.25rem; }
|
||||
.pointer-events-auto { pointer-events: auto; }
|
||||
.pointer-events-none { pointer-events: none; }
|
||||
.pr-10 { padding-right: 2.5rem; }
|
||||
.pr-2 { padding-right: 0.5rem; }
|
||||
.pr-20 { padding-right: 5rem; }
|
||||
.pr-3 { padding-right: 0.75rem; }
|
||||
.pr-4 { padding-right: 1rem; }
|
||||
.pr-8 { padding-right: 2rem; }
|
||||
.pt-2 { padding-top: 0.5rem; }
|
||||
.pt-3 { padding-top: 0.75rem; }
|
||||
.pt-4 { padding-top: 1rem; }
|
||||
.pt-5 { padding-top: 1.25rem; }
|
||||
.pt-6 { padding-top: 1.5rem; }
|
||||
.pt-8 { padding-top: 2rem; }
|
||||
.px-1 { padding-left: 0.25rem; padding-right: 0.25rem; }
|
||||
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||
.py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
|
||||
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||
.py-12 { padding-top: 3rem; padding-bottom: 3rem; }
|
||||
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
||||
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
|
||||
.right-0 { right: 0px; }
|
||||
.right-2 { right: 0.5rem; }
|
||||
.ring-0 { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 var(--tw-ring-offset-width, 0px) var(--tw-ring-color, transparent); }
|
||||
.rounded-b-lg { border-bottom-left-radius: 0.5rem; border-bottom-right-radius: 0.5rem; }
|
||||
.rounded-l-md { border-top-left-radius: 0.375rem; border-bottom-left-radius: 0.375rem; }
|
||||
.rounded-r-lg { border-top-right-radius: 0.5rem; border-bottom-right-radius: 0.5rem; }
|
||||
.rounded-r-md { border-top-right-radius: 0.375rem; border-bottom-right-radius: 0.375rem; }
|
||||
.rounded-t-lg { border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; }
|
||||
.self-center { align-self: center; }
|
||||
.shadow-inner { box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 5%); }
|
||||
.shadow-xl { box-shadow: 0 20px 25px -5px rgb(0 0 0 / 10%), 0 8px 10px -6px rgb(0 0 0 / 10%); }
|
||||
.shrink-0 { flex-shrink: 0; }
|
||||
.space-x-3 > * + * { margin-left: 0.75rem; }
|
||||
.space-y-3 > * + * { margin-top: 0.75rem; }
|
||||
.space-y-5 > * + * { margin-top: 1.25rem; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; }
|
||||
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
|
||||
.text-amber-200\/90 { color: rgb(253 230 138 / 0.9); }
|
||||
.text-amber-300 { color: #fcd34d; }
|
||||
.text-amber-500 { color: #f59e0b; }
|
||||
.text-amber-600 { color: #d97706; }
|
||||
.text-amber-700 { color: #b45309; }
|
||||
.text-amber-800 { color: #92400e; }
|
||||
.text-amber-900 { color: #78350f; }
|
||||
.text-black { color: #000000; }
|
||||
.text-blue-400 { color: #60a5fa; }
|
||||
.text-blue-500 { color: #3b82f6; }
|
||||
.text-blue-600 { color: #2563eb; }
|
||||
.text-blue-700 { color: #1d4ed8; }
|
||||
.text-blue-800 { color: #1e40af; }
|
||||
.text-blue-900 { color: #1e3a8a; }
|
||||
.text-center { text-align: center; }
|
||||
.text-gray-700 { color: #374151; }
|
||||
.text-gray-800 { color: #1f2937; }
|
||||
.text-green-500 { color: #22c55e; }
|
||||
.text-green-700 { color: #15803d; }
|
||||
.text-green-900 { color: #14532d; }
|
||||
.text-left { text-align: left; }
|
||||
.text-md { font-size: 1rem; line-height: 1.5rem; }
|
||||
.text-purple-600 { color: #9333ea; }
|
||||
.text-purple-700 { color: #7e22ce; }
|
||||
.text-purple-800 { color: #6b21a8; }
|
||||
.text-red-400 { color: #f87171; }
|
||||
.text-red-500 { color: #ef4444; }
|
||||
.text-red-700 { color: #b91c1c; }
|
||||
.text-red-900 { color: #7f1d1d; }
|
||||
.text-right { text-align: right; }
|
||||
.text-yellow-400 { color: #facc15; }
|
||||
.text-yellow-500 { color: #eab308; }
|
||||
.text-yellow-600 { color: #ca8a04; }
|
||||
.text-yellow-700 { color: #a16207; }
|
||||
.text-yellow-900 { color: #713f12; }
|
||||
.top-1\/2 { top: 50%; }
|
||||
.top-2 { top: 0.5rem; }
|
||||
.top-\[3px\] { top: 3px; }
|
||||
.tracking-wider { letter-spacing: 0.05em; }
|
||||
.transform { transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.transition-colors { transition-property: color, background-color, border-color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.transition-shadow { transition-property: box-shadow; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.transition-transform { transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.translate-x-0 { --tw-translate-x: 0px; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.translate-x-1 { --tw-translate-x: 0.25rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.translate-x-5 { --tw-translate-x: 1.25rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.translate-x-6 { --tw-translate-x: 1.5rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.translate-x-full { --tw-translate-x: 100%; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.underline { text-decoration-line: underline; text-underline-offset: 0.15em; }
|
||||
.uppercase { text-transform: uppercase; }
|
||||
.w-1\/2 { width: 50%; }
|
||||
.w-1\/3 { width: 33.333333%; }
|
||||
.w-1\/4 { width: 25%; }
|
||||
.w-10 { width: 2.5rem; }
|
||||
.w-11 { width: 2.75rem; }
|
||||
.w-12 { width: 3rem; }
|
||||
.w-14 { width: 3.5rem; }
|
||||
.w-16 { width: 4rem; }
|
||||
.w-2\/3 { width: 66.666667%; }
|
||||
.w-2\/5 { width: 40%; }
|
||||
.w-20 { width: 5rem; }
|
||||
.w-24 { width: 6rem; }
|
||||
.w-3 { width: 0.75rem; }
|
||||
.w-3\/4 { width: 75%; }
|
||||
.w-3\/5 { width: 60%; }
|
||||
.w-32 { width: 8rem; }
|
||||
.w-4\/5 { width: 80%; }
|
||||
.w-4\/6 { width: 66.666667%; }
|
||||
.w-48 { width: 12rem; }
|
||||
.w-5 { width: 1.25rem; }
|
||||
.w-5\/6 { width: 83.333333%; }
|
||||
.w-6 { width: 1.5rem; }
|
||||
.w-8 { width: 2rem; }
|
||||
.w-80 { width: 20rem; }
|
||||
.w-96 { width: 24rem; }
|
||||
.w-px { width: 1px; }
|
||||
.whitespace-nowrap { white-space: nowrap; }
|
||||
.whitespace-pre-wrap { white-space: pre-wrap; }
|
||||
.-m-2 { margin: -0.5rem; }
|
||||
.list-none { list-style-type: none; }
|
||||
.pl-0 { padding-left: 0px; }
|
||||
.w-auto { width: auto; }
|
||||
.pb-1 { padding-bottom: 0.25rem; }
|
||||
.hidden { display: none; }
|
||||
|
||||
/* State and pseudo variants */
|
||||
.disabled\:bg-gray-400:disabled { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
|
||||
.disabled\:cursor-not-allowed:disabled { cursor: not-allowed; }
|
||||
.disabled\:opacity-50:disabled { opacity: 0.5; }
|
||||
.disabled\:text-gray-400:disabled { color: #6b7280; }
|
||||
.file\:bg-blue-50::file-selector-button { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
|
||||
.file\:border-0::file-selector-button { border-width: 0px; }
|
||||
.file\:font-medium::file-selector-button { font-weight: 500; }
|
||||
.file\:mr-4::file-selector-button { margin-right: 1rem; }
|
||||
.file\:px-4::file-selector-button { padding-left: 1rem; padding-right: 1rem; }
|
||||
.file\:py-2::file-selector-button { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.file\:rounded-md::file-selector-button { border-radius: 0.375rem; }
|
||||
.file\:text-blue-700::file-selector-button { color: #1d4ed8; }
|
||||
.file\:text-sm::file-selector-button { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.first\:border-t-0:first-child { border-top-width: 0px; border-top-style: solid; }
|
||||
.focus\:border-blue-500:focus { border-color: #3b82f6; }
|
||||
.focus\:ring-indigo-500:focus { --tw-ring-color: #6366f1; }
|
||||
.focus\:ring-offset-2:focus { --tw-ring-offset-width: 2px; --tw-ring-offset-shadow: 0 0 0 2px var(--color-surface, #fff); }
|
||||
.focus\:shadow-md:focus { box-shadow: var(--shadow-md); }
|
||||
.group:hover .group-hover\:scale-105 { --tw-scale-x: 1.05; --tw-scale-y: 1.05; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.hover\:bg-amber-50:hover { --tw-bg-opacity: 1; background-color: rgb(255 251 235 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-amber-700:hover { --tw-bg-opacity: 1; background-color: rgb(180 83 9 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-blue-100:hover { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-blue-50:hover { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-blue-700:hover { --tw-bg-opacity: 1; background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-gray-100:hover { --tw-bg-opacity: 1; background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-gray-300:hover { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-gray-400:hover { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-gray-700:hover { --tw-bg-opacity: 1; background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-opacity-20:hover { --tw-bg-opacity: 0.2; }
|
||||
.hover\:bg-opacity-30:hover { --tw-bg-opacity: 0.3; }
|
||||
.hover\:bg-red-50:hover { --tw-bg-opacity: 1; background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-red-900:hover { --tw-bg-opacity: 1; background-color: rgb(127 29 29 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-white:hover { --tw-bg-opacity: 1; background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:border-blue-400:hover { border-color: #60a5fa; }
|
||||
.hover\:file\:bg-blue-100::file-selector-button:hover { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:opacity-100:hover { opacity: 1; }
|
||||
.hover\:scale-110:hover { --tw-scale-x: 1.1; --tw-scale-y: 1.1; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.hover\:shadow-md:hover { box-shadow: var(--shadow-md); }
|
||||
.hover\:text-blue-600:hover { color: #2563eb; }
|
||||
.hover\:text-blue-700:hover { color: #1d4ed8; }
|
||||
.hover\:text-blue-800:hover { color: #1e40af; }
|
||||
.hover\:text-gray-600:hover { color: #374151; }
|
||||
.hover\:text-gray-900:hover { color: #111827; }
|
||||
.hover\:text-red-300:hover { color: #fca5a5; }
|
||||
.hover\:text-red-600:hover { color: #dc2626; }
|
||||
.hover\:text-red-800:hover { color: #991b1b; }
|
||||
.hover\:text-red-900:hover { color: #7f1d1d; }
|
||||
.hover\:text-white:hover { color: #ffffff; }
|
||||
.hover\:text-yellow-700:hover { color: #a16207; }
|
||||
.hover\:text-yellow-800:hover { color: #854d0e; }
|
||||
.hover\:text-yellow-900:hover { color: #713f12; }
|
||||
.hover\:underline:hover { text-decoration-line: underline; text-underline-offset: 0.15em; }
|
||||
.last\:border-0:last-child { border-width: 0px; }
|
||||
.peer:checked ~ .peer-checked\:bg-green-500 { --tw-bg-opacity: 1; background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1)); }
|
||||
.peer:focus-visible ~ .peer-focus\:outline-none { outline: 2px solid transparent; outline-offset: 2px; }
|
||||
.peer:focus-visible ~ .peer-focus\:ring-4 { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 calc(4px + var(--tw-ring-offset-width, 0px)) var(--tw-ring-color, rgb(59 130 246 / 50%)), var(--tw-shadow, 0 0 #0000); }
|
||||
.peer:focus-visible ~ .peer-focus\:ring-blue-300 { --tw-ring-color: #93c5fd; }
|
||||
.placeholder\:text-gray-400::placeholder { color: #6b7280; }
|
||||
.placeholder\:text-gray-500::placeholder { color: #4b5563; }
|
||||
|
||||
.hover\:bg-gray-200:hover { --tw-bg-opacity: 1; background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); }
|
||||
|
||||
.hover\:text-gray-800:hover { color: #1f2937; }
|
||||
|
||||
/* Responsive breakpoints */
|
||||
@media (min-width: 640px) {
|
||||
@@ -416,6 +836,30 @@ a, button, input, select, textarea {
|
||||
.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; }
|
||||
.sm\:align-middle { vertical-align: middle; }
|
||||
.sm\:flex { display: flex; }
|
||||
.sm\:flex-row-reverse { flex-direction: row-reverse; }
|
||||
.sm\:h-10 { height: 2.5rem; }
|
||||
.sm\:hidden { display: none; }
|
||||
.sm\:inline { display: inline; }
|
||||
.sm\:items-start { align-items: flex-start; }
|
||||
.sm\:max-w-4xl { max-width: 56rem; }
|
||||
.sm\:max-w-lg { max-width: 32rem; }
|
||||
.sm\:ml-3 { margin-left: 0.75rem; }
|
||||
.sm\:ml-4 { margin-left: 1rem; }
|
||||
.sm\:mt-0 { margin-top: 0px; }
|
||||
.sm\:mx-0 { margin-left: 0px; margin-right: 0px; }
|
||||
.sm\:my-8 { margin-top: 2rem; margin-bottom: 2rem; }
|
||||
.sm\:p-0 { padding: 0px; }
|
||||
.sm\:p-6 { padding: 1.5rem; }
|
||||
.sm\:pb-4 { padding-bottom: 1rem; }
|
||||
.sm\:text-left { text-align: left; }
|
||||
.sm\:text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||
.sm\:w-10 { width: 2.5rem; }
|
||||
.sm\:w-auto { width: auto; }
|
||||
.sm\:w-full { width: 100%; }
|
||||
.sm\:flex-nowrap { flex-wrap: nowrap; }
|
||||
.sm\:gap-4 { gap: 1rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -601,8 +1045,9 @@ a, button, input, select, textarea {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Enhanced Button styles */
|
||||
.btn {
|
||||
/* Enhanced Button styles. :where() keeps the base at zero specificity so
|
||||
utility classes on the same element (px-3 py-1, rounded) take effect. */
|
||||
:where(.btn) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -637,10 +1082,9 @@ a, button, input, select, textarea {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
outline: 2px solid transparent;
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 3px rgb(37 99 235 / 30%);
|
||||
}
|
||||
|
||||
/* Global button text contrast fix: Ensure buttons with white backgrounds have dark text */
|
||||
@@ -661,14 +1105,16 @@ button.bg-white {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
/* :where() so utilities on the field (pl-8 for a search icon, w-48,
|
||||
h-4 w-4 on checkboxes) override the component defaults. */
|
||||
:where(.form-control) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--color-field-border);
|
||||
border-radius: 0.375rem;
|
||||
background-color: #ffffff;
|
||||
color: #111827; /* text-gray-900 - ensure dark text on white background */
|
||||
background-color: var(--color-field-bg);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
transition: border-color 0.15s ease-in-out;
|
||||
@@ -678,12 +1124,12 @@ button.bg-white {
|
||||
.form-control:focus {
|
||||
border-color: var(--color-primary);
|
||||
/* Using outline instead of box-shadow for focus state (better performance) */
|
||||
outline: 2px solid rgb(37 99 235 / 20%);
|
||||
outline-offset: 2px;
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.form-control:disabled {
|
||||
background-color: #f9fafb;
|
||||
background-color: var(--color-background);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -718,28 +1164,12 @@ button.bg-white {
|
||||
/* Added CSS containment for better performance isolation */
|
||||
}
|
||||
|
||||
.plugin-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.plugin-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-primary);
|
||||
/* Removed box-shadow transition for better performance */
|
||||
}
|
||||
|
||||
.plugin-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.plugin-card:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
@@ -1080,7 +1510,7 @@ button.bg-white {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: #374151; /* text-gray-700 for better readability */
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
|
||||
cursor: pointer;
|
||||
@@ -1097,30 +1527,30 @@ button.bg-white {
|
||||
|
||||
/* Inactive state - improved contrast */
|
||||
.nav-tab:not(.nav-tab-active) {
|
||||
color: #374151; /* text-gray-700 */
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.nav-tab:not(.nav-tab-active) i {
|
||||
color: #374151; /* text-gray-700 */
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Hover state - enhanced visibility */
|
||||
.nav-tab:not(.nav-tab-active):hover {
|
||||
color: #111827; /* text-gray-900 */
|
||||
background-color: #f3f4f6; /* bg-gray-100 */
|
||||
border-bottom-color: #d1d5db; /* border-gray-300 */
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-border-light);
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
.nav-tab:not(.nav-tab-active):hover i {
|
||||
color: #111827; /* text-gray-900 */
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Active state - prominent with gradient background */
|
||||
.nav-tab-active {
|
||||
color: #1d4ed8; /* text-blue-700 */
|
||||
color: var(--color-primary-hover);
|
||||
font-weight: 600;
|
||||
border-bottom-width: 3px;
|
||||
border-bottom-color: #2563eb; /* border-blue-600 */
|
||||
border-bottom-color: var(--color-primary);
|
||||
background: linear-gradient(135deg, rgb(37 99 235 / 10%) 0%, rgb(59 130 246 / 5%) 100%);
|
||||
box-shadow: 0 2px 4px rgb(37 99 235 / 10%);
|
||||
}
|
||||
@@ -1155,17 +1585,6 @@ button.bg-white {
|
||||
}
|
||||
|
||||
/* Form controls */
|
||||
[data-theme="dark"] .form-control {
|
||||
background-color: var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-control:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline-color: rgb(59 130 246 / 30%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-control:disabled {
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
@@ -1275,6 +1694,84 @@ button.bg-white {
|
||||
[data-theme="dark"] .text-green-800 { color: #6ee7b7; }
|
||||
[data-theme="dark"] .text-green-700 { color: #6ee7b7; }
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
[data-theme="dark"] .bg-amber-100 { background-color: rgb(245 158 11 / 18%); }
|
||||
[data-theme="dark"] .bg-amber-50 { background-color: rgb(245 158 11 / 12%); }
|
||||
[data-theme="dark"] .bg-blue-100 { background-color: rgb(59 130 246 / 18%); }
|
||||
[data-theme="dark"] .bg-gray-300 { background-color: #4b5563; }
|
||||
[data-theme="dark"] .bg-gray-400 { background-color: #6b7280; }
|
||||
[data-theme="dark"] .bg-purple-100 { background-color: rgb(168 85 247 / 18%); }
|
||||
[data-theme="dark"] .bg-purple-50 { background-color: rgb(168 85 247 / 12%); }
|
||||
[data-theme="dark"] .border-amber-200 { border-color: rgb(245 158 11 / 40%); }
|
||||
[data-theme="dark"] .border-amber-300 { border-color: rgb(245 158 11 / 55%); }
|
||||
[data-theme="dark"] .border-blue-300 { border-color: rgb(59 130 246 / 55%); }
|
||||
[data-theme="dark"] .border-gray-100 { border-color: var(--color-border); }
|
||||
[data-theme="dark"] .border-gray-400 { border-color: #6b7280; }
|
||||
[data-theme="dark"] .border-green-300 { border-color: rgb(34 197 94 / 55%); }
|
||||
[data-theme="dark"] .border-purple-200 { border-color: rgb(168 85 247 / 40%); }
|
||||
[data-theme="dark"] .border-purple-300 { border-color: rgb(168 85 247 / 55%); }
|
||||
[data-theme="dark"] .border-red-300 { border-color: rgb(239 68 68 / 55%); }
|
||||
[data-theme="dark"] .border-yellow-300 { border-color: rgb(234 179 8 / 55%); }
|
||||
[data-theme="dark"] .divide-gray-100 > :not(:first-child) { border-color: var(--color-border); }
|
||||
[data-theme="dark"] .divide-gray-200 > :not(:first-child) { border-color: var(--color-border); }
|
||||
[data-theme="dark"] .text-amber-500 { color: #fbbf24; }
|
||||
[data-theme="dark"] .text-amber-600 { color: #fcd34d; }
|
||||
[data-theme="dark"] .text-amber-700 { color: #fcd34d; }
|
||||
[data-theme="dark"] .text-amber-800 { color: #fcd34d; }
|
||||
[data-theme="dark"] .text-amber-900 { color: #fcd34d; }
|
||||
[data-theme="dark"] .text-black { color: var(--color-text-primary); }
|
||||
[data-theme="dark"] .text-blue-500 { color: #60a5fa; }
|
||||
[data-theme="dark"] .text-blue-600 { color: #93c5fd; }
|
||||
[data-theme="dark"] .text-blue-900 { color: #93c5fd; }
|
||||
[data-theme="dark"] .text-gray-700 { color: var(--color-text-secondary); }
|
||||
[data-theme="dark"] .text-gray-800 { color: var(--color-text-primary); }
|
||||
[data-theme="dark"] .text-green-500 { color: #4ade80; }
|
||||
[data-theme="dark"] .text-green-900 { color: #86efac; }
|
||||
[data-theme="dark"] .text-purple-600 { color: #d8b4fe; }
|
||||
[data-theme="dark"] .text-purple-700 { color: #d8b4fe; }
|
||||
[data-theme="dark"] .text-purple-800 { color: #d8b4fe; }
|
||||
[data-theme="dark"] .text-red-500 { color: #f87171; }
|
||||
[data-theme="dark"] .text-red-900 { color: #fca5a5; }
|
||||
[data-theme="dark"] .text-yellow-500 { color: #facc15; }
|
||||
[data-theme="dark"] .text-yellow-900 { color: #fde047; }
|
||||
[data-theme="dark"] .disabled\:bg-gray-400:disabled { background-color: #6b7280; }
|
||||
[data-theme="dark"] .file\:bg-blue-50::file-selector-button { background-color: rgb(59 130 246 / 12%); }
|
||||
[data-theme="dark"] .file\:text-blue-700::file-selector-button { color: #93c5fd; }
|
||||
[data-theme="dark"] .hover\:bg-amber-50:hover { background-color: rgb(245 158 11 / 12%); }
|
||||
[data-theme="dark"] .hover\:bg-blue-100:hover { background-color: rgb(59 130 246 / 18%); }
|
||||
[data-theme="dark"] .hover\:bg-blue-50:hover { background-color: rgb(59 130 246 / 12%); }
|
||||
[data-theme="dark"] .hover\:bg-gray-100:hover { background-color: var(--color-border); }
|
||||
[data-theme="dark"] .hover\:bg-gray-300:hover { background-color: #4b5563; }
|
||||
[data-theme="dark"] .hover\:bg-gray-400:hover { background-color: #6b7280; }
|
||||
[data-theme="dark"] .hover\:bg-red-50:hover { background-color: rgb(239 68 68 / 12%); }
|
||||
[data-theme="dark"] .hover\:file\:bg-blue-100::file-selector-button:hover { background-color: rgb(59 130 246 / 18%); }
|
||||
[data-theme="dark"] .hover\:text-blue-600:hover { color: #bfdbfe; }
|
||||
[data-theme="dark"] .hover\:text-blue-700:hover { color: #bfdbfe; }
|
||||
[data-theme="dark"] .hover\:text-blue-800:hover { color: #bfdbfe; }
|
||||
[data-theme="dark"] .hover\:text-gray-600:hover { color: var(--color-text-tertiary); }
|
||||
[data-theme="dark"] .hover\:text-gray-900:hover { color: var(--color-text-primary); }
|
||||
[data-theme="dark"] .hover\:text-red-600:hover { color: #fecaca; }
|
||||
[data-theme="dark"] .hover\:text-red-800:hover { color: #fecaca; }
|
||||
[data-theme="dark"] .hover\:text-red-900:hover { color: #fecaca; }
|
||||
[data-theme="dark"] .hover\:text-yellow-700:hover { color: #fef08a; }
|
||||
[data-theme="dark"] .hover\:text-yellow-800:hover { color: #fef08a; }
|
||||
[data-theme="dark"] .hover\:text-yellow-900:hover { color: #fef08a; }
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
[data-theme="dark"] .hover\:text-gray-800:hover { color: var(--color-text-primary); }
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
|
||||
|
||||
/* Theme toggle button */
|
||||
.theme-toggle-btn {
|
||||
display: inline-flex;
|
||||
@@ -1432,10 +1929,10 @@ button.bg-white {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background-color: #ffffff;
|
||||
background-color: var(--color-surface, #ffffff);
|
||||
}
|
||||
.style-editor-head > :first-child {
|
||||
background-color: #f9fafb;
|
||||
background-color: var(--color-background, #f9fafb);
|
||||
}
|
||||
.style-editor-row {
|
||||
/* Set by the widget: the column count depends on what the plugin
|
||||
@@ -1455,27 +1952,27 @@ button.bg-white {
|
||||
width: 100%;
|
||||
}
|
||||
.style-editor-row input[type="number"]:disabled {
|
||||
background-color: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
background-color: var(--color-border-light, #f3f4f6);
|
||||
color: var(--color-text-tertiary, #9ca3af);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.style-editor-colour {
|
||||
width: 2rem;
|
||||
height: 1.6rem;
|
||||
padding: 0;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--color-field-border, #d1d5db);
|
||||
border-radius: 0.25rem;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.style-editor-tab {
|
||||
border: 1px solid #d1d5db;
|
||||
background-color: #f9fafb;
|
||||
color: #374151;
|
||||
border: 1px solid var(--color-field-border, #d1d5db);
|
||||
background-color: var(--color-background, #f9fafb);
|
||||
color: var(--color-text-secondary, #374151);
|
||||
}
|
||||
.style-editor-tab.is-active {
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
background-color: var(--color-primary, #2563eb);
|
||||
border-color: var(--color-primary, #2563eb);
|
||||
color: #ffffff;
|
||||
}
|
||||
.style-editor-panel[hidden] {
|
||||
@@ -1494,3 +1991,90 @@ button.bg-white {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* A label wrapping a visually hidden file input shows the input's focus. */
|
||||
.file-trigger:focus-within {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Dark theme: native fields that no component or utility styles would
|
||||
otherwise keep the browser's white surface. */
|
||||
[data-theme="dark"] :where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea) {
|
||||
background-color: var(--color-field-bg);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-field-border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] ::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Range tracks and native controls follow the theme accent instead of the
|
||||
browser's default light chrome. */
|
||||
input[type="range"],
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
progress {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ===== Guards that must stay at the end of the file ===== */
|
||||
|
||||
/* `.hidden` has to beat component classes that set their own display. */
|
||||
.btn.hidden,
|
||||
.badge.hidden,
|
||||
.status-indicator.hidden,
|
||||
.help-tip.hidden,
|
||||
.nav-tab.hidden,
|
||||
.theme-toggle-btn.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
/* .theme-toggle-btn (display: inline-flex) is declared after md:hidden,
|
||||
which left the mobile menu button visible on desktop. */
|
||||
.theme-toggle-btn.md\:hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch screens: controls in the page content get a 44px target. Switches
|
||||
and inline help triggers keep their drawn size. */
|
||||
@media (pointer: coarse) {
|
||||
#tab-content button:not([role="switch"], .help-tip),
|
||||
#tab-content a.inline-flex,
|
||||
#tab-content select,
|
||||
#tab-content input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], .sr-only) {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion: no entrances, slides, pulses or smooth scrolling.
|
||||
Spinners keep turning, slowly, because they are the only busy signal. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html,
|
||||
#logs-container {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
.fa-spin,
|
||||
.fa-spinner,
|
||||
.animate-spin,
|
||||
.jfm-spin,
|
||||
.spinner {
|
||||
animation-duration: 1.5s !important;
|
||||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
.card:hover,
|
||||
.plugin-card:hover,
|
||||
.btn:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,28 @@
|
||||
// LED Matrix v3 JavaScript
|
||||
// Additional helpers for HTMX and Alpine.js integration
|
||||
|
||||
// Global notification system
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
// Use Alpine.js notification if available
|
||||
if (window.Alpine) {
|
||||
// This would trigger the Alpine.js notification system
|
||||
const event = new CustomEvent('show-notification', {
|
||||
// Global notification system — implemented by widgets/notification.js.
|
||||
// This fallback only exists if nothing defined showNotification earlier; it
|
||||
// hands off to the widget when registered, otherwise queues the message for
|
||||
// the widget to show once it loads.
|
||||
if (typeof window.showNotification !== 'function') {
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
const registry = window.LEDMatrixWidgets;
|
||||
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
|
||||
if (widget && typeof widget.show === 'function') {
|
||||
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
|
||||
}
|
||||
if (!Array.isArray(window.__pendingNotifications)) {
|
||||
window.__pendingNotifications = [];
|
||||
}
|
||||
window.__pendingNotifications.push([message, type]);
|
||||
document.dispatchEvent(new CustomEvent('show-notification', {
|
||||
detail: { message, type }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
} else {
|
||||
// Fallback notification — user-facing last resort, so never gated
|
||||
console.info(`${type}: ${message}`);
|
||||
}
|
||||
};
|
||||
}));
|
||||
// User-facing last resort, so never gated
|
||||
console.info(`${(type && type.type) || type}: ${message}`);
|
||||
};
|
||||
}
|
||||
|
||||
// HTMX response handlers
|
||||
document.body.addEventListener('htmx:beforeRequest', function(event) {
|
||||
@@ -180,30 +188,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
} catch { /* no-op */ }
|
||||
});
|
||||
|
||||
// SSE reconnection helper — closes and reopens both SSE streams,
|
||||
// reattaching the open/error handlers defined in base.html.
|
||||
window.reconnectSSE = function() {
|
||||
if (window.statsSource) {
|
||||
window.statsSource.close();
|
||||
window.statsSource = new EventSource('/api/v3/stream/stats');
|
||||
window.statsSource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (typeof updateSystemStats === 'function') updateSystemStats(data);
|
||||
};
|
||||
if (window._statsOpenHandler) window.statsSource.addEventListener('open', window._statsOpenHandler);
|
||||
if (window._statsErrorHandler) window.statsSource.addEventListener('error', window._statsErrorHandler);
|
||||
}
|
||||
|
||||
if (window.displaySource) {
|
||||
window.displaySource.close();
|
||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||
window.displaySource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
|
||||
};
|
||||
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
|
||||
}
|
||||
};
|
||||
// SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in
|
||||
// js/app-shell.js — do not open EventSources for stats/display here.
|
||||
|
||||
// Utility functions
|
||||
window.hexToRgb = function(hex) {
|
||||
@@ -462,6 +448,8 @@ window.updateFloatingPreviewVisibility = function(tab) {
|
||||
img.src = 'data:image/png;base64,' + window._lastPreviewFrame;
|
||||
}
|
||||
}
|
||||
// The display SSE stream is only open while a preview is on screen
|
||||
if (window.LEDStreams) window.LEDStreams.refresh();
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
/* global debugLog */
|
||||
// SSE wiring + full Alpine app() implementation and tab logic
|
||||
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
||||
// Assign to window so reconnectSSE() in app.js can reach them.
|
||||
window.statsSource = new EventSource('/api/v3/stream/stats');
|
||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||
|
||||
window.statsSource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateSystemStats(data);
|
||||
};
|
||||
|
||||
window.displaySource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateDisplayPreview(data);
|
||||
};
|
||||
|
||||
function _setConnectionStatus(connected, reconnecting) {
|
||||
function _setConnectionStatus(connected, reconnecting, paused) {
|
||||
const el = document.getElementById('connection-status');
|
||||
if (!el) return;
|
||||
if (connected) {
|
||||
if (paused) {
|
||||
// Intentionally closed while the page is hidden — not an error.
|
||||
el.innerHTML = `
|
||||
<div class="w-2 h-2 bg-gray-200 border border-gray-300 rounded-full"></div>
|
||||
<span class="text-gray-600" title="Live updates pause while this page is in the background">Paused</span>
|
||||
`;
|
||||
} else if (connected) {
|
||||
el.innerHTML = `
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span class="text-gray-600">Connected</span>
|
||||
@@ -38,8 +30,8 @@
|
||||
|
||||
var _statsErrorCount = 0;
|
||||
|
||||
// Named on window so reconnectSSE() in app.js can reattach them after
|
||||
// replacing the EventSource instances.
|
||||
// Kept on window for backward compatibility; LEDStreams attaches them
|
||||
// to every EventSource it opens.
|
||||
window._statsOpenHandler = function() {
|
||||
_statsErrorCount = 0;
|
||||
_setConnectionStatus(true, false);
|
||||
@@ -56,9 +48,173 @@
|
||||
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
|
||||
};
|
||||
|
||||
window.statsSource.addEventListener('open', window._statsOpenHandler);
|
||||
window.statsSource.addEventListener('error', window._statsErrorHandler);
|
||||
window.displaySource.addEventListener('error', window._displayErrorHandler);
|
||||
// ===== Tab/page visibility — shared by partials =====
|
||||
// LEDVisibility.onActive(tab, start, stop[, key]) runs start() when
|
||||
// `tab` is the active tab AND the page is visible, and stop() when
|
||||
// either stops being true. start() should refresh immediately. The
|
||||
// registration is keyed (default: tab name), so a partial re-injected
|
||||
// by HTMX replaces its previous registration — the old stop() runs
|
||||
// first — instead of stacking intervals. Returns an unregister fn.
|
||||
// Tab changes are also broadcast as `ledmatrix:tab-changed` on document.
|
||||
window.LEDVisibility = (function() {
|
||||
const regs = new Map();
|
||||
let lastTab = null;
|
||||
|
||||
function activeTab() {
|
||||
const el = document.querySelector('[x-data="app()"]');
|
||||
const data = el && el._x_dataStack && el._x_dataStack[0];
|
||||
return (data && data.activeTab) || lastTab;
|
||||
}
|
||||
function isActive(tab) {
|
||||
return !document.hidden && activeTab() === tab;
|
||||
}
|
||||
function evaluate(reg) {
|
||||
const want = isActive(reg.tab);
|
||||
if (want === reg.running) return;
|
||||
reg.running = want;
|
||||
try {
|
||||
(want ? reg.start : reg.stop)();
|
||||
} catch (e) {
|
||||
console.error('[LEDVisibility] ' + reg.key + (want ? ' start' : ' stop') + ' failed:', e);
|
||||
}
|
||||
}
|
||||
function onActive(tab, start, stop, key) {
|
||||
key = key || tab;
|
||||
const prev = regs.get(key);
|
||||
if (prev && prev.running) {
|
||||
prev.running = false;
|
||||
try { prev.stop(); } catch (e) { console.error('[LEDVisibility] ' + key + ' stop failed:', e); }
|
||||
}
|
||||
const reg = { tab: tab, start: start, stop: stop, key: key, running: false };
|
||||
regs.set(key, reg);
|
||||
evaluate(reg);
|
||||
return function() {
|
||||
if (regs.get(key) !== reg) return;
|
||||
regs.delete(key);
|
||||
if (reg.running) { reg.running = false; stop(); }
|
||||
};
|
||||
}
|
||||
function refresh() {
|
||||
regs.forEach(evaluate);
|
||||
if (window.LEDStreams) window.LEDStreams.refresh();
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', refresh);
|
||||
document.addEventListener('ledmatrix:tab-changed', function(e) {
|
||||
lastTab = (e.detail && e.detail.tab) || lastTab;
|
||||
refresh();
|
||||
});
|
||||
|
||||
return { activeTab: activeTab, isActive: isActive, onActive: onActive, refresh: refresh };
|
||||
})();
|
||||
|
||||
// ===== Live SSE streams — the single owner =====
|
||||
// /stream/stats feeds the header stats and connection badge on every
|
||||
// tab, so it stays open while the page is visible. /stream/display
|
||||
// pushes base64 PNG frames and is only opened while a preview is on
|
||||
// screen: the Overview live preview, or the floating preview when open.
|
||||
// Both close while the page is hidden. The server's broadcaster ends its
|
||||
// generator thread once the last subscriber leaves, so closing here
|
||||
// saves Pi CPU, not only bandwidth.
|
||||
//
|
||||
// window.statsSource / window.displaySource remain public. An
|
||||
// EventSource can't be reopened, so each reopen creates a new one;
|
||||
// listeners other code attached with addEventListener (e.g. the Tools
|
||||
// power panel) are recorded and re-attached to the replacement.
|
||||
window.LEDStreams = (function() {
|
||||
const CONFIG = {
|
||||
stats: {
|
||||
url: '/api/v3/stream/stats',
|
||||
prop: 'statsSource',
|
||||
onmessage: function(data) { updateSystemStats(data); },
|
||||
wire: function(src) {
|
||||
src.onopen = window._statsOpenHandler;
|
||||
src.onerror = window._statsErrorHandler;
|
||||
}
|
||||
},
|
||||
display: {
|
||||
url: '/api/v3/stream/display',
|
||||
prop: 'displaySource',
|
||||
onmessage: function(data) { updateDisplayPreview(data); },
|
||||
wire: function(src) { src.onerror = window._displayErrorHandler; }
|
||||
}
|
||||
};
|
||||
const open = { stats: null, display: null };
|
||||
const external = { stats: [], display: [] };
|
||||
|
||||
function openStream(name) {
|
||||
if (open[name]) return;
|
||||
const cfg = CONFIG[name];
|
||||
const src = new EventSource(cfg.url);
|
||||
const add = src.addEventListener.bind(src);
|
||||
const remove = src.removeEventListener.bind(src);
|
||||
src.addEventListener = function(type, listener, options) {
|
||||
external[name].push([type, listener, options]);
|
||||
add(type, listener, options);
|
||||
};
|
||||
src.removeEventListener = function(type, listener, options) {
|
||||
external[name] = external[name].filter(function(l) {
|
||||
return !(l[0] === type && l[1] === listener);
|
||||
});
|
||||
remove(type, listener, options);
|
||||
};
|
||||
external[name].forEach(function(l) { add(l[0], l[1], l[2]); });
|
||||
src.onmessage = function(event) {
|
||||
let data;
|
||||
try { data = JSON.parse(event.data); } catch { return; }
|
||||
cfg.onmessage(data);
|
||||
};
|
||||
cfg.wire(src);
|
||||
open[name] = src;
|
||||
window[cfg.prop] = src;
|
||||
}
|
||||
|
||||
// The closed instance stays on window so late `addEventListener`
|
||||
// calls are still recorded and replayed on the next open.
|
||||
function closeStream(name) {
|
||||
if (!open[name]) return;
|
||||
open[name].close();
|
||||
open[name] = null;
|
||||
}
|
||||
|
||||
function previewOnScreen() {
|
||||
if (window.LEDVisibility.activeTab() === 'overview') return true;
|
||||
const panel = document.getElementById('floating-preview');
|
||||
return !!(panel && panel.style.display === 'block');
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (document.hidden) {
|
||||
// Also covers a page first loaded in a background tab.
|
||||
closeStream('stats');
|
||||
closeStream('display');
|
||||
_setConnectionStatus(false, false, true);
|
||||
return;
|
||||
}
|
||||
if (!open.stats) {
|
||||
_statsErrorCount = 0;
|
||||
openStream('stats');
|
||||
}
|
||||
if (previewOnScreen()) openStream('display');
|
||||
else closeStream('display');
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
closeStream('stats');
|
||||
closeStream('display');
|
||||
refresh();
|
||||
}
|
||||
|
||||
return { refresh: refresh, reconnect: reconnect, isOpen: function(name) { return !!open[name]; } };
|
||||
})();
|
||||
|
||||
// Public helper (previously duplicated in app.js).
|
||||
window.reconnectSSE = function() { window.LEDStreams.reconnect(); };
|
||||
|
||||
// Header stats start immediately; the display stream opens once the
|
||||
// active tab is known (Alpine init dispatches ledmatrix:tab-changed).
|
||||
window.LEDStreams.refresh();
|
||||
document.addEventListener('DOMContentLoaded', function() { window.LEDStreams.refresh(); });
|
||||
|
||||
// Reset any time the currently-active warning clears, so a future
|
||||
// (new) occurrence shows the banner again even if this one was dismissed.
|
||||
@@ -323,6 +479,10 @@
|
||||
if (typeof window.updateFloatingPreviewVisibility === 'function') {
|
||||
window.updateFloatingPreviewVisibility(newTab);
|
||||
}
|
||||
// Pause/resume per-tab timers and SSE (LEDVisibility)
|
||||
document.dispatchEvent(new CustomEvent('ledmatrix:tab-changed', {
|
||||
detail: { tab: newTab, previous: oldTab }
|
||||
}));
|
||||
// Trigger content load when tab changes
|
||||
this.$nextTick(() => {
|
||||
this.loadTabContent(newTab);
|
||||
@@ -335,6 +495,9 @@
|
||||
if (typeof window.updateNavAriaCurrent === 'function') {
|
||||
window.updateNavAriaCurrent(this.activeTab);
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('ledmatrix:tab-changed', {
|
||||
detail: { tab: this.activeTab, previous: null }
|
||||
}));
|
||||
});
|
||||
|
||||
// Listen for plugin updates from pluginManager
|
||||
@@ -1878,18 +2041,21 @@
|
||||
});
|
||||
};
|
||||
|
||||
// showNotification is provided by notification.js widget
|
||||
// This fallback is only used if the widget hasn't loaded yet
|
||||
// showNotification is implemented by the notification.js widget.
|
||||
// Until it loads, this fallback queues messages; the widget shows the
|
||||
// queue as soon as it registers (and then replaces this function).
|
||||
if (typeof window.showNotification !== 'function') {
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
debugLog(`[${type.toUpperCase()}]`, message);
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
|
||||
type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'
|
||||
} text-white`;
|
||||
notification.textContent = message;
|
||||
document.body.appendChild(notification);
|
||||
setTimeout(() => { notification.style.opacity = '0'; setTimeout(() => notification.remove(), 500); }, 3000);
|
||||
const registry = window.LEDMatrixWidgets;
|
||||
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
|
||||
if (widget && typeof widget.show === 'function') {
|
||||
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
|
||||
}
|
||||
if (!Array.isArray(window.__pendingNotifications)) {
|
||||
window.__pendingNotifications = [];
|
||||
}
|
||||
window.__pendingNotifications.push([message, type]);
|
||||
debugLog(`[${String((type && type.type) || type).toUpperCase()}]`, message);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -211,28 +211,28 @@ const ConfigDiffViewer = {
|
||||
modalContainer.className = 'fixed inset-0 z-50 overflow-y-auto';
|
||||
modalContainer.innerHTML = `
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onclick="this.closest('#config-diff-modal-container').remove(); window.__configDiffResolve(false);"></div>
|
||||
|
||||
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" data-diff-backdrop aria-hidden="true"></div>
|
||||
|
||||
<div id="config-diff-modal-panel" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full" style="position:relative;">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10" aria-hidden="true">
|
||||
<i class="fas fa-code-branch text-blue-600"></i>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Review Configuration Changes</h3>
|
||||
<div class="mt-4 max-h-96 overflow-y-auto">
|
||||
<h3 id="config-diff-modal-title" class="text-lg leading-6 font-medium text-gray-900">Review Configuration Changes</h3>
|
||||
<div class="mt-4 max-h-96 overflow-y-auto" tabindex="0" role="region" aria-label="Changes to save">
|
||||
${this.formatDiff(differences)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button id="config-diff-confirm-btn"
|
||||
<button type="button" id="config-diff-confirm-btn"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
<i class="fas fa-check mr-2"></i>Save Changes
|
||||
<i class="fas fa-check mr-2" aria-hidden="true"></i>Save Changes
|
||||
</button>
|
||||
<button id="config-diff-cancel-btn"
|
||||
<button type="button" id="config-diff-cancel-btn"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
@@ -240,27 +240,41 @@ const ConfigDiffViewer = {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
document.body.appendChild(modalContainer);
|
||||
|
||||
// Store resolve function globally (hack for onclick handlers)
|
||||
window.__configDiffResolve = resolve;
|
||||
|
||||
|
||||
let release = null;
|
||||
let settled = false;
|
||||
// Single close path: remove the modal, release the focus trap
|
||||
// (returning focus to where the user was) and settle the promise.
|
||||
function finish(result) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
modalContainer.remove();
|
||||
if (window.__configDiffResolve === finish) window.__configDiffResolve = undefined;
|
||||
if (release) release();
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
// Kept for backwards compatibility with code that calls it directly.
|
||||
window.__configDiffResolve = finish;
|
||||
|
||||
// Attach event listeners
|
||||
const confirmBtn = modalContainer.querySelector('#config-diff-confirm-btn');
|
||||
const cancelBtn = modalContainer.querySelector('#config-diff-cancel-btn');
|
||||
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
modalContainer.remove();
|
||||
delete window.__configDiffResolve;
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
modalContainer.remove();
|
||||
delete window.__configDiffResolve;
|
||||
resolve(false);
|
||||
});
|
||||
const backdrop = modalContainer.querySelector('[data-diff-backdrop]');
|
||||
|
||||
confirmBtn.addEventListener('click', () => finish(true));
|
||||
cancelBtn.addEventListener('click', () => finish(false));
|
||||
if (backdrop) backdrop.addEventListener('click', () => finish(false));
|
||||
|
||||
if (window.LEDDialog) {
|
||||
release = window.LEDDialog.trap(modalContainer.querySelector('#config-diff-modal-panel'), {
|
||||
labelledBy: 'config-diff-modal-title',
|
||||
initialFocus: confirmBtn,
|
||||
onEscape: () => finish(false)
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -134,9 +134,7 @@
|
||||
|
||||
function openResults() {
|
||||
resultsBox.classList.remove('hidden');
|
||||
// .hidden has no effect without a matching CSS rule (this app's stylesheet
|
||||
// is a hand-picked utility subset, not full Tailwind) - force it directly,
|
||||
// same as the revealNode/collapseNode fallback below.
|
||||
// Clear any inline display left by closeResults(); the class does the rest.
|
||||
resultsBox.style.display = '';
|
||||
if (input) input.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Shared modal-dialog accessibility helper.
|
||||
*
|
||||
* The UI has several modal implementations (inline markup toggled with
|
||||
* style.display, markup injected and removed, the `hidden` class). This helper
|
||||
* deliberately does not show or hide anything — each caller keeps its own
|
||||
* visibility logic — it only supplies what every modal was missing:
|
||||
*
|
||||
* - role="dialog", aria-modal="true" and aria-labelledby on the panel
|
||||
* - initial focus inside the panel
|
||||
* - Tab / Shift+Tab kept inside the panel while it is open
|
||||
* - Escape routed to the caller's close function
|
||||
* - focus returned to whatever was focused before the dialog opened
|
||||
*
|
||||
* Usage:
|
||||
* const release = window.LEDDialog.trap(panelEl, {
|
||||
* labelledBy: 'my-modal-title', // id of the visible heading (optional)
|
||||
* label: 'Plugin files', // aria-label when there is no heading
|
||||
* initialFocus: inputEl, // element or selector (optional)
|
||||
* onEscape: closeMyModal // omit to ignore Escape
|
||||
* });
|
||||
* // ...when the modal closes (from any path):
|
||||
* release();
|
||||
*
|
||||
* Nested dialogs stack: only the top-most trap handles Tab and Escape.
|
||||
* release() is idempotent, and calling trap() again on the same panel
|
||||
* releases the previous trap first.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const FOCUSABLE = [
|
||||
'a[href]', 'area[href]', 'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])',
|
||||
'textarea:not([disabled])', 'iframe', '[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
].join(',');
|
||||
|
||||
const stack = [];
|
||||
|
||||
function visible(el) {
|
||||
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
||||
}
|
||||
|
||||
function focusables(panel) {
|
||||
return Array.prototype.filter.call(panel.querySelectorAll(FOCUSABLE), visible);
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
const top = stack[stack.length - 1];
|
||||
if (!top) return;
|
||||
if (e.key === 'Escape' && top.onEscape) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
top.onEscape(e);
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab') return;
|
||||
const items = focusables(top.panel);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
top.panel.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (!top.panel.contains(active)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
} else if (e.shiftKey && active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function trap(panel, opts) {
|
||||
if (!panel) return function () {};
|
||||
opts = opts || {};
|
||||
|
||||
const existing = stack.find(function (t) { return t.panel === panel; });
|
||||
if (existing) existing.release();
|
||||
|
||||
panel.setAttribute('role', opts.role || 'dialog');
|
||||
panel.setAttribute('aria-modal', 'true');
|
||||
if (opts.labelledBy) {
|
||||
panel.setAttribute('aria-labelledby', opts.labelledBy);
|
||||
} else if (opts.label) {
|
||||
panel.setAttribute('aria-label', opts.label);
|
||||
}
|
||||
if (!panel.hasAttribute('tabindex')) panel.setAttribute('tabindex', '-1');
|
||||
|
||||
const entry = {
|
||||
panel: panel,
|
||||
onEscape: opts.onEscape || null,
|
||||
returnTo: document.activeElement,
|
||||
released: false,
|
||||
release: null
|
||||
};
|
||||
|
||||
entry.release = function () {
|
||||
if (entry.released) return;
|
||||
entry.released = true;
|
||||
const wasTop = stack[stack.length - 1] === entry;
|
||||
const i = stack.indexOf(entry);
|
||||
if (i !== -1) stack.splice(i, 1);
|
||||
if (stack.length === 0) document.removeEventListener('keydown', onKeydown, true);
|
||||
// Releasing a dialog underneath another one must not pull focus
|
||||
// out of the dialog the user is actually in.
|
||||
if (!wasTop) return;
|
||||
const target = entry.returnTo;
|
||||
const active = stack[stack.length - 1];
|
||||
if (active && !(target && active.panel.contains(target))) {
|
||||
active.panel.focus();
|
||||
} else if (target && typeof target.focus === 'function' && document.contains(target)) {
|
||||
target.focus();
|
||||
}
|
||||
};
|
||||
|
||||
stack.push(entry);
|
||||
if (stack.length === 1) document.addEventListener('keydown', onKeydown, true);
|
||||
|
||||
// Focus after the caller has made the panel visible.
|
||||
requestAnimationFrame(function () {
|
||||
if (entry.released) return;
|
||||
let target = opts.initialFocus;
|
||||
if (typeof target === 'string') target = panel.querySelector(target);
|
||||
if (!target || !visible(target)) target = focusables(panel)[0] || panel;
|
||||
target.focus();
|
||||
});
|
||||
|
||||
return entry.release;
|
||||
}
|
||||
|
||||
window.LEDDialog = { trap: trap };
|
||||
})();
|
||||
@@ -7,95 +7,97 @@
|
||||
|
||||
/**
|
||||
* Comprehensive error code to user-friendly message mapping.
|
||||
* Used only when the server response carries no message of its own.
|
||||
*/
|
||||
const ERROR_MESSAGES = {
|
||||
// Configuration errors
|
||||
'CONFIG_SAVE_FAILED': 'Failed to save configuration',
|
||||
'CONFIG_LOAD_FAILED': 'Failed to load configuration',
|
||||
'CONFIG_VALIDATION_FAILED': 'Configuration validation failed',
|
||||
'CONFIG_ROLLBACK_FAILED': 'Failed to rollback configuration',
|
||||
|
||||
'CONFIG_SAVE_FAILED': "Couldn't save your settings. Check the values and try again",
|
||||
'CONFIG_LOAD_FAILED': "Couldn't load your settings. Reload the page to try again",
|
||||
'CONFIG_VALIDATION_FAILED': 'Some settings have invalid values. Fix the highlighted fields and save again',
|
||||
'CONFIG_ROLLBACK_FAILED': "Couldn't restore the previous settings. Try again, or restore from Backup & Restore",
|
||||
|
||||
// Plugin errors
|
||||
'PLUGIN_NOT_FOUND': 'Plugin not found',
|
||||
'PLUGIN_INSTALL_FAILED': 'Failed to install plugin',
|
||||
'PLUGIN_UPDATE_FAILED': 'Failed to update plugin',
|
||||
'PLUGIN_UNINSTALL_FAILED': 'Failed to uninstall plugin',
|
||||
'PLUGIN_LOAD_FAILED': 'Failed to load plugin',
|
||||
'PLUGIN_OPERATION_CONFLICT': 'Plugin operation conflict - another operation is in progress',
|
||||
|
||||
'PLUGIN_NOT_FOUND': "That plugin isn't installed. Check the Plugin Manager",
|
||||
'PLUGIN_INSTALL_FAILED': "Couldn't install the plugin. Check the Pi's internet connection and try again",
|
||||
'PLUGIN_UPDATE_FAILED': "Couldn't update the plugin. Try again in a moment",
|
||||
'PLUGIN_UNINSTALL_FAILED': "Couldn't uninstall the plugin. Try again in a moment",
|
||||
'PLUGIN_LOAD_FAILED': "The plugin couldn't start. Check the Logs tab for details",
|
||||
'PLUGIN_OPERATION_CONFLICT': 'Another plugin operation is still running. Wait for it to finish, then try again',
|
||||
|
||||
// Validation errors
|
||||
'VALIDATION_ERROR': 'Validation error',
|
||||
'SCHEMA_VALIDATION_FAILED': 'Configuration schema validation failed',
|
||||
'INVALID_INPUT': 'Invalid input provided',
|
||||
|
||||
'VALIDATION_ERROR': 'Some values are invalid. Fix them and try again',
|
||||
'SCHEMA_VALIDATION_FAILED': "Some settings don't match what the plugin expects. Fix them and save again",
|
||||
'INVALID_INPUT': "That value isn't valid. Check it and try again",
|
||||
|
||||
// Network errors
|
||||
'NETWORK_ERROR': 'Network error occurred',
|
||||
'API_ERROR': 'API request failed',
|
||||
'TIMEOUT': 'Operation timed out',
|
||||
|
||||
'NETWORK_ERROR': "Couldn't reach the LEDMatrix device. Check that it's on and connected, then try again",
|
||||
'API_ERROR': 'The request failed. Try again in a moment',
|
||||
'TIMEOUT': 'This took too long to respond. Try again in a moment',
|
||||
|
||||
// Permission errors
|
||||
'PERMISSION_DENIED': 'Permission denied',
|
||||
'FILE_PERMISSION_ERROR': 'File permission error',
|
||||
|
||||
'PERMISSION_DENIED': "LEDMatrix doesn't have permission to do that. See the troubleshooting guide",
|
||||
'FILE_PERMISSION_ERROR': "LEDMatrix can't write a file it needs. See the troubleshooting guide",
|
||||
|
||||
// System errors
|
||||
'SYSTEM_ERROR': 'System error occurred',
|
||||
'SERVICE_UNAVAILABLE': 'Service unavailable',
|
||||
|
||||
'SYSTEM_ERROR': 'Something went wrong on the device. Check the Logs tab for details',
|
||||
'SERVICE_UNAVAILABLE': 'The LEDMatrix service is not running. Restart it from the Overview tab',
|
||||
|
||||
// Unknown errors
|
||||
'UNKNOWN_ERROR': 'An unknown error occurred'
|
||||
'UNKNOWN_ERROR': 'Something went wrong. Try again, and check the Logs tab if it keeps happening'
|
||||
};
|
||||
|
||||
/**
|
||||
* Error code to troubleshooting documentation links.
|
||||
*/
|
||||
const TROUBLESHOOTING_URL = 'https://github.com/ChuckBuilds/LEDMatrix/blob/main/docs/TROUBLESHOOTING.md';
|
||||
const ERROR_DOCS = {
|
||||
'CONFIG_SAVE_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#configuration-errors',
|
||||
'CONFIG_VALIDATION_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#validation-errors',
|
||||
'PLUGIN_INSTALL_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#plugin-installation',
|
||||
'PLUGIN_OPERATION_CONFLICT': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#plugin-operations',
|
||||
'PERMISSION_DENIED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#permissions',
|
||||
'FILE_PERMISSION_ERROR': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#permissions'
|
||||
'CONFIG_SAVE_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration',
|
||||
'CONFIG_VALIDATION_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration',
|
||||
'PLUGIN_INSTALL_FAILED': TROUBLESHOOTING_URL + '#plugin-issues',
|
||||
'PLUGIN_OPERATION_CONFLICT': TROUBLESHOOTING_URL + '#plugin-issues',
|
||||
'PERMISSION_DENIED': TROUBLESHOOTING_URL + '#permission-issues',
|
||||
'FILE_PERMISSION_ERROR': TROUBLESHOOTING_URL + '#permission-issues'
|
||||
};
|
||||
|
||||
/**
|
||||
* Format error message for display to user.
|
||||
*
|
||||
*
|
||||
* @param {Object} error - Error object from API response
|
||||
* @returns {string} Formatted error message
|
||||
*/
|
||||
function formatError(error) {
|
||||
if (!error) {
|
||||
return 'An unknown error occurred';
|
||||
return ERROR_MESSAGES.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
|
||||
// If error is a string, return it
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
|
||||
// If error has a message, use it
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
|
||||
// If error has error_code, format it
|
||||
if (error.error_code) {
|
||||
const message = ERROR_MESSAGES[error.error_code] || error.message || 'An error occurred';
|
||||
|
||||
const message = ERROR_MESSAGES[error.error_code] || error.message || ERROR_MESSAGES.UNKNOWN_ERROR;
|
||||
|
||||
// Add details if available
|
||||
if (error.details) {
|
||||
return `${message}: ${error.details}`;
|
||||
}
|
||||
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
return 'An error occurred';
|
||||
|
||||
return ERROR_MESSAGES.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested fixes for an error.
|
||||
*
|
||||
*
|
||||
* @param {Object} error - Error object from API response
|
||||
* @returns {Array<string>} Array of suggested fixes
|
||||
*/
|
||||
@@ -103,13 +105,13 @@ function getSuggestedFixes(error) {
|
||||
if (!error || !error.suggested_fixes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
return error.suggested_fixes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display error with suggestions in a rich UI.
|
||||
*
|
||||
*
|
||||
* @param {Object} error - Error object from API response
|
||||
* @param {string} context - Optional context about what was being done
|
||||
* @param {Object} options - Display options
|
||||
@@ -121,19 +123,19 @@ function displayError(error, context = null, options = {}) {
|
||||
const suggestions = getSuggestedFixes(error);
|
||||
const errorCode = error?.error_code;
|
||||
const docLink = errorCode ? ERROR_DOCS[errorCode] : null;
|
||||
|
||||
|
||||
// Build full message
|
||||
let fullMessage = message;
|
||||
if (context) {
|
||||
fullMessage = `${context}: ${message}`;
|
||||
}
|
||||
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
fullMessage += '\n\nSuggested fixes:\n' + suggestions.map(s => `• ${s}`).join('\n');
|
||||
}
|
||||
|
||||
|
||||
// If showDetails is true, show a rich error modal
|
||||
if (options.showDetails !== false && (suggestions.length > 0 || docLink || error.details)) {
|
||||
if (options.showDetails !== false && (suggestions.length > 0 || docLink || error?.details)) {
|
||||
showErrorModal(error, context, message, suggestions, docLink);
|
||||
} else {
|
||||
// Simple notification
|
||||
@@ -146,9 +148,12 @@ function displayError(error, context = null, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Release function for the open error modal's focus trap (see utils/dialog.js).
|
||||
let errorModalRelease = null;
|
||||
|
||||
/**
|
||||
* Show a rich error modal with details, suggestions, and copy button.
|
||||
*
|
||||
*
|
||||
* @param {Object} error - Error object
|
||||
* @param {string} context - Context
|
||||
* @param {string} message - Formatted message
|
||||
@@ -156,6 +161,9 @@ function displayError(error, context = null, options = {}) {
|
||||
* @param {string} docLink - Documentation link
|
||||
*/
|
||||
function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
error = error || {};
|
||||
suggestions = suggestions || [];
|
||||
|
||||
// Create modal container if it doesn't exist
|
||||
let modalContainer = document.getElementById('error-modal-container');
|
||||
if (!modalContainer) {
|
||||
@@ -165,7 +173,13 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
modalContainer.style.display = 'none';
|
||||
document.body.appendChild(modalContainer);
|
||||
}
|
||||
|
||||
|
||||
// Re-opening while open: release the previous trap before replacing it.
|
||||
if (errorModalRelease) {
|
||||
errorModalRelease();
|
||||
errorModalRelease = null;
|
||||
}
|
||||
|
||||
// Build modal content
|
||||
const contextText = context ? `<div class="text-sm text-gray-600 mb-2">${escapeHtml(context)}</div>` : '';
|
||||
const suggestionsHtml = suggestions.length > 0 ? `
|
||||
@@ -176,15 +190,15 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
</ul>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
|
||||
const docLinkHtml = docLink ? `
|
||||
<div class="mt-4">
|
||||
<a href="${docLink}" target="_blank" class="text-blue-600 hover:text-blue-800 text-sm underline">
|
||||
<i class="fas fa-book mr-1"></i>View troubleshooting guide
|
||||
<a href="${escapeHtml(docLink)}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 text-sm underline">
|
||||
<i class="fas fa-book mr-1" aria-hidden="true"></i>View troubleshooting guide<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;"> (opens in a new tab)</span>
|
||||
</a>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
|
||||
const detailsHtml = error.details ? `
|
||||
<div class="mt-4">
|
||||
<details class="cursor-pointer">
|
||||
@@ -193,26 +207,26 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
</details>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
|
||||
const errorCodeHtml = error.error_code ? `
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
Error code: <code class="bg-gray-100 px-1 py-0.5 rounded">${escapeHtml(error.error_code)}</code>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
|
||||
modalContainer.innerHTML = `
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onclick="window.errorHandler.closeErrorModal()"></div>
|
||||
|
||||
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" onclick="window.errorHandler.closeErrorModal()"></div>
|
||||
|
||||
<div id="error-modal-panel" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full" style="position:relative;">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10" aria-hidden="true">
|
||||
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Error</h3>
|
||||
<div class="mt-2">
|
||||
<h3 id="error-modal-title" class="text-lg leading-6 font-medium text-gray-900">Something went wrong</h3>
|
||||
<div class="mt-2" id="error-modal-description">
|
||||
${contextText}
|
||||
<p class="text-sm text-gray-500">${escapeHtml(message)}</p>
|
||||
${errorCodeHtml}
|
||||
@@ -224,11 +238,11 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button id="error-modal-copy-btn"
|
||||
<button type="button" id="error-modal-copy-btn"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
<i class="fas fa-copy mr-2"></i>Copy Error Details
|
||||
<i class="fas fa-copy mr-2" aria-hidden="true"></i>Copy Error Details
|
||||
</button>
|
||||
<button onclick="window.errorHandler.closeErrorModal()"
|
||||
<button type="button" id="error-modal-close-btn" onclick="window.errorHandler.closeErrorModal()"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Close
|
||||
</button>
|
||||
@@ -236,7 +250,7 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
// Attach event listener to copy button
|
||||
const copyBtn = modalContainer.querySelector('#error-modal-copy-btn');
|
||||
if (copyBtn) {
|
||||
@@ -244,8 +258,20 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
copyErrorDetails(error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
modalContainer.style.display = 'block';
|
||||
|
||||
const panel = modalContainer.querySelector('#error-modal-panel');
|
||||
if (panel) {
|
||||
panel.setAttribute('aria-describedby', 'error-modal-description');
|
||||
if (window.LEDDialog) {
|
||||
errorModalRelease = window.LEDDialog.trap(panel, {
|
||||
labelledBy: 'error-modal-title',
|
||||
initialFocus: '#error-modal-close-btn',
|
||||
onEscape: closeErrorModal
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,6 +282,11 @@ function closeErrorModal() {
|
||||
if (modalContainer) {
|
||||
modalContainer.style.display = 'none';
|
||||
}
|
||||
if (errorModalRelease) {
|
||||
const release = errorModalRelease;
|
||||
errorModalRelease = null;
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,27 +308,35 @@ function escapeHtml(text) {
|
||||
|
||||
/**
|
||||
* Copy error details to clipboard.
|
||||
*
|
||||
*
|
||||
* @param {Object} error - Error object from API response
|
||||
*/
|
||||
function copyErrorDetails(error) {
|
||||
const errorText = JSON.stringify(error, null, 2);
|
||||
|
||||
function copyFailed(err) {
|
||||
console.error('Failed to copy error details:', err);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification("Couldn't copy to the clipboard. Open Technical details and copy the text by hand.", 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(errorText).then(() => {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Error details copied to clipboard', 'success');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy error details:', err);
|
||||
});
|
||||
}).catch(copyFailed);
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = errorText;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
// Keep focus inside the open dialog while copying.
|
||||
const panel = document.getElementById('error-modal-panel');
|
||||
const host = panel && panel.offsetParent !== null ? panel : document.body;
|
||||
const returnFocus = document.activeElement;
|
||||
host.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
@@ -305,9 +344,10 @@ function copyErrorDetails(error) {
|
||||
showNotification('Error details copied to clipboard', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy error details:', err);
|
||||
copyFailed(err);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
host.removeChild(textArea);
|
||||
if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,4 +376,3 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
ERROR_DOCS
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -415,7 +415,8 @@
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||
removeBtn.onclick = function() { window.removeArrayTableRow(this); };
|
||||
removeBtn.innerHTML = '<i class="fas fa-trash"></i>';
|
||||
removeBtn.setAttribute('aria-label', 'Remove row');
|
||||
removeBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
||||
actionsCell.appendChild(removeBtn);
|
||||
|
||||
if (hasAdvanced) {
|
||||
@@ -424,7 +425,8 @@
|
||||
editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1';
|
||||
editBtn.title = 'Edit advanced properties (layout, style…)';
|
||||
editBtn.onclick = function() { window.openArrayTableRowEditor(this); };
|
||||
editBtn.innerHTML = '<i class="fas fa-sliders-h"></i>';
|
||||
editBtn.setAttribute('aria-label', 'Edit advanced properties');
|
||||
editBtn.innerHTML = '<i class="fas fa-sliders-h" aria-hidden="true"></i>';
|
||||
actionsCell.appendChild(editBtn);
|
||||
}
|
||||
|
||||
@@ -446,9 +448,8 @@
|
||||
if (!advancedCell) return;
|
||||
|
||||
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
|
||||
// Close any existing modal
|
||||
const existing = document.getElementById('array-row-editor-modal');
|
||||
if (existing) existing.remove();
|
||||
// Close any existing modal (also releases its focus trap)
|
||||
window.closeArrayTableRowEditor();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'array-row-editor-modal';
|
||||
@@ -463,9 +464,10 @@
|
||||
// Header
|
||||
safeSetHTML(dialog, `
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
||||
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||
<h3 id="array-row-editor-title" class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
aria-label="Close advanced properties"
|
||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times" aria-hidden="true"></i></button>
|
||||
</div>`);
|
||||
|
||||
const body = document.createElement('div');
|
||||
@@ -558,11 +560,27 @@
|
||||
dialog.appendChild(footer);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
if (window.LEDDialog) {
|
||||
rowEditorRelease = window.LEDDialog.trap(dialog, {
|
||||
labelledBy: 'array-row-editor-title',
|
||||
initialFocus: '[data-modal-prop]',
|
||||
onEscape: window.closeArrayTableRowEditor
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Focus-trap release for the open row editor (see utils/dialog.js).
|
||||
let rowEditorRelease = null;
|
||||
|
||||
window.closeArrayTableRowEditor = function() {
|
||||
const modal = document.getElementById('array-row-editor-modal');
|
||||
if (modal) modal.remove();
|
||||
if (rowEditorRelease) {
|
||||
const release = rowEditorRelease;
|
||||
rowEditorRelease = null;
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -107,6 +107,8 @@
|
||||
html += `
|
||||
<input type="color"
|
||||
id="${fieldId}_color"
|
||||
title="Choose color"
|
||||
aria-label="Choose color"
|
||||
value="${currentValue}"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onColorChange('${fieldId}')"
|
||||
@@ -122,6 +124,7 @@
|
||||
id="${fieldId}_hex"
|
||||
value="${currentValue.substring(1)}"
|
||||
maxlength="6"
|
||||
aria-label="Hex color code"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onHexChange('${fieldId}')"
|
||||
oninput="window.LEDMatrixWidgets.getHandlers('color-picker').onHexInput('${fieldId}')"
|
||||
@@ -161,7 +164,8 @@
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('color-picker').onPresetClick('${fieldId}', this.dataset.color)"
|
||||
class="w-6 h-6 rounded border border-gray-300 hover:scale-110 transition-transform ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
|
||||
style="background-color: ${escapeHtml(normalized)};"
|
||||
title="${escapeHtml(normalized)}">
|
||||
title="${escapeHtml(normalized)}"
|
||||
aria-label="Use color ${escapeHtml(normalized)}">
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('date-picker').onChange('${fieldId}')"
|
||||
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<i class="fas fa-calendar-alt text-gray-400"></i>
|
||||
<i class="fas fa-calendar-alt text-gray-400" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -96,8 +96,8 @@
|
||||
id="${fieldId}_clear"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('date-picker').onClear('${fieldId}')"
|
||||
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
||||
title="Clear">
|
||||
<i class="fas fa-times"></i>
|
||||
title="Clear" aria-label="Clear date">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@
|
||||
</div>`;
|
||||
html += `<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
|
||||
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image">
|
||||
<i class="fas fa-times"></i>
|
||||
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image" aria-label="Remove image">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>`;
|
||||
html += '</div>';
|
||||
|
||||
|
||||
@@ -840,7 +840,7 @@
|
||||
<!-- Schedule Mode -->
|
||||
<div id="schedule_options_${sanitizedId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
|
||||
<label for="schedule_mode_${sanitizedId}" class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
|
||||
<select id="schedule_mode_${sanitizedId}"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
@@ -855,8 +855,8 @@
|
||||
<!-- Time Range Mode -->
|
||||
<div id="time_range_${sanitizedId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
|
||||
<input type="time"
|
||||
<label for="schedule_start_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
|
||||
<input type="time"
|
||||
id="schedule_start_${sanitizedId}"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
@@ -865,8 +865,8 @@
|
||||
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
|
||||
<input type="time"
|
||||
<label for="schedule_end_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
|
||||
<input type="time"
|
||||
id="schedule_end_${sanitizedId}"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
@@ -900,6 +900,7 @@
|
||||
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${sanitizedId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
|
||||
<input type="time"
|
||||
id="day_${day}_start_${sanitizedId}"
|
||||
aria-label="${day} start time"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
data-image-idx="${imageIdx}"
|
||||
@@ -909,6 +910,7 @@
|
||||
${!dayConfig.enabled ? 'disabled' : ''}>
|
||||
<input type="time"
|
||||
id="day_${day}_end_${sanitizedId}"
|
||||
aria-label="${day} end time"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
data-image-idx="${imageIdx}"
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
this._editFile = null;
|
||||
this._deleteFile = null;
|
||||
this._keyHandler = this._onKey.bind(this);
|
||||
// Focus-trap release functions for open modals (utils/dialog.js)
|
||||
this._releases = new Map();
|
||||
|
||||
this._inject();
|
||||
this._bind();
|
||||
@@ -67,9 +69,30 @@
|
||||
|
||||
_destroy() {
|
||||
document.removeEventListener('keydown', this._keyHandler);
|
||||
for (const key of Array.from(this._releases.keys())) this._release(key);
|
||||
this.el._jfmInstance = null;
|
||||
}
|
||||
|
||||
// Open a modal's focus trap. Escape is routed here, so _onKey no
|
||||
// longer handles it.
|
||||
_trap(key, modal, titleId, initialFocus, onClose) {
|
||||
if (!modal || !window.LEDDialog) return;
|
||||
const box = modal.querySelector('.jfm-modal-box');
|
||||
this._releases.set(key, window.LEDDialog.trap(box, {
|
||||
labelledBy: titleId,
|
||||
initialFocus: initialFocus || null,
|
||||
onEscape: onClose
|
||||
}));
|
||||
}
|
||||
|
||||
_release(key) {
|
||||
const release = this._releases.get(key);
|
||||
if (release) {
|
||||
this._releases.delete(key);
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── DOM Injection ────────────────────────────────────────────────────
|
||||
|
||||
_inject() {
|
||||
@@ -108,21 +131,22 @@
|
||||
</div>` : ''}
|
||||
|
||||
<!-- ── Edit modal ─────────────────────────────────────── -->
|
||||
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal" id="${u}-edit-modal" hidden>
|
||||
<div class="jfm-modal-box jfm-modal-wide">
|
||||
<div class="jfm-modal-head">
|
||||
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
|
||||
<div class="jfm-modal-tools">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">×</button>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close editor">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
|
||||
<div id="${u}-edit-err" class="jfm-err-bar" role="alert" hidden></div>
|
||||
<textarea id="${u}-editor" class="jfm-editor"
|
||||
spellcheck="false" autocomplete="off"
|
||||
autocorrect="off" autocapitalize="off"
|
||||
aria-label="JSON editor"></textarea>
|
||||
aria-label="JSON editor" aria-describedby="${u}-editor-hint"></textarea>
|
||||
<span id="${u}-editor-hint" hidden>Tab inserts two spaces. Shift+Tab moves focus back to the toolbar. Ctrl+S saves.</span>
|
||||
<div class="jfm-modal-foot">
|
||||
<span id="${u}-charcount" class="jfm-stat"></span>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
|
||||
@@ -133,10 +157,10 @@
|
||||
|
||||
<!-- ── Delete modal ───────────────────────────────────── -->
|
||||
${hasDelete ? `
|
||||
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal" id="${u}-del-modal" hidden>
|
||||
<div class="jfm-modal-box">
|
||||
<div class="jfm-modal-head">
|
||||
<span class="jfm-modal-title">Delete file</span>
|
||||
<span id="${u}-del-title" class="jfm-modal-title">Delete file</span>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="jfm-modal-body">
|
||||
@@ -144,7 +168,7 @@
|
||||
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
|
||||
</div>
|
||||
<div class="jfm-modal-foot">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del" id="${u}-del-cancel">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,10 +176,10 @@
|
||||
|
||||
<!-- ── Create modal ───────────────────────────────────── -->
|
||||
${hasCreate ? `
|
||||
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal" id="${u}-create-modal" hidden>
|
||||
<div class="jfm-modal-box">
|
||||
<div class="jfm-modal-head">
|
||||
<span class="jfm-modal-title">Create new file</span>
|
||||
<span id="${u}-create-title" class="jfm-modal-title">Create new file</span>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="jfm-modal-body">
|
||||
@@ -189,93 +213,106 @@
|
||||
}
|
||||
|
||||
_css(u) {
|
||||
// Colors come from the app theme tokens (app.css :root / [data-theme="dark"])
|
||||
// with the original light values as fallbacks for pages without app.css.
|
||||
// Shades the tokens don't cover are local variables with a dark override.
|
||||
return `<style>
|
||||
#${u}{font-family:inherit;color:#111827;}
|
||||
#${u}{--jfm-border-strong:#d1d5db;--jfm-border-hover:#9ca3af;--jfm-subtle:#f3f4f6;--jfm-accent-soft:#93c5fd;--jfm-danger-border:#fecaca;--jfm-danger-text:#991b1b;--jfm-text-muted:#6b7280;}
|
||||
[data-theme="dark"] #${u}{--jfm-border-strong:#4b5563;--jfm-border-hover:#6b7280;--jfm-subtle:#374151;--jfm-accent-soft:#1e40af;--jfm-danger-border:#991b1b;--jfm-danger-text:#fecaca;--jfm-text-muted:#9ca3af;}
|
||||
#${u}{font-family:inherit;color:var(--color-text-primary,#111827);}
|
||||
#${u} *{box-sizing:border-box;}
|
||||
|
||||
/* Header */
|
||||
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
|
||||
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
|
||||
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
|
||||
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
|
||||
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:var(--color-text-primary,#111827);}
|
||||
#${u} .jfm-dir{font-size:.75rem;color:var(--jfm-text-muted);background:var(--jfm-subtle);padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
|
||||
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
|
||||
|
||||
/* Buttons */
|
||||
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
|
||||
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
|
||||
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
|
||||
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid var(--jfm-border-strong);background:var(--color-surface,#fff);color:var(--color-text-secondary,#374151);font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
|
||||
#${u} .jfm-btn:hover:not(:disabled){background:var(--color-background,#f9fafb);border-color:var(--jfm-border-hover);}
|
||||
#${u} .jfm-btn:focus-visible,#${u} .jfm-close-btn:focus-visible{outline:2px solid var(--color-primary,#3b82f6);outline-offset:1px;}
|
||||
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
|
||||
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
|
||||
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
|
||||
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
|
||||
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
|
||||
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
|
||||
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
|
||||
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
|
||||
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
|
||||
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
|
||||
#${u} .jfm-btn-primary{background:var(--color-primary,#3b82f6);border-color:var(--color-primary,#3b82f6);color:#fff;}
|
||||
#${u} .jfm-btn-primary:hover:not(:disabled){background:var(--color-primary-hover,#2563eb);border-color:var(--color-primary-hover,#2563eb);}
|
||||
#${u} .jfm-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}
|
||||
#${u} .jfm-btn-danger:hover:not(:disabled){background:#b91c1c;border-color:#b91c1c;}
|
||||
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:var(--jfm-text-muted);}
|
||||
#${u} .jfm-btn-ghost:hover:not(:disabled){background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
|
||||
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:var(--jfm-text-muted);font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
|
||||
#${u} .jfm-close-btn:hover{background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
|
||||
|
||||
/* File list */
|
||||
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
|
||||
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
|
||||
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
|
||||
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:var(--jfm-text-muted);font-size:.875rem;}
|
||||
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:var(--jfm-text-muted);}
|
||||
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
|
||||
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
|
||||
#${u} .jfm-empty-title{font-weight:600;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
|
||||
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
|
||||
|
||||
/* File cards */
|
||||
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
|
||||
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
|
||||
#${u} .jfm-card{border:1px solid var(--color-border,#e5e7eb);border-radius:.5rem;padding:.875rem;background:var(--color-surface,#fff);display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
|
||||
#${u} .jfm-card:hover{border-color:var(--jfm-accent-soft);box-shadow:var(--shadow-md,0 2px 8px rgba(59,130,246,.1));}
|
||||
#${u} .jfm-card.jfm-off{opacity:.6;}
|
||||
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
|
||||
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
|
||||
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
|
||||
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
|
||||
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:var(--color-text-primary,#111827);flex:1;}
|
||||
#${u} .jfm-card-meta{font-size:.75rem;color:var(--jfm-text-muted);display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
|
||||
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid var(--color-border-light,#f3f4f6);margin-top:.125rem;}
|
||||
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
|
||||
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
|
||||
|
||||
/* Toggle */
|
||||
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
|
||||
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:var(--jfm-text-muted);white-space:nowrap;flex-shrink:0;}
|
||||
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
|
||||
|
||||
/* Upload zone */
|
||||
#${u} .jfm-upload-wrap{margin-top:.25rem;}
|
||||
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
|
||||
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
|
||||
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
|
||||
#${u} .jfm-dropzone{border:2px dashed var(--jfm-border-strong);border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:var(--color-background,#f9fafb);user-select:none;}
|
||||
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:var(--color-primary,#3b82f6);background:var(--color-info-bg,#eff6ff);border-style:solid;outline:none;}
|
||||
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
|
||||
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
|
||||
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
|
||||
#${u} .jfm-drop-primary{font-size:.875rem;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
|
||||
#${u} .jfm-drop-hint{font-size:.75rem;color:var(--jfm-text-muted);margin:0;}
|
||||
|
||||
/* Modals */
|
||||
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
|
||||
#${u} .jfm-modal[hidden]{display:none;}
|
||||
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
|
||||
#${u} .jfm-modal-box{background:var(--color-surface,#fff);color:var(--color-text-primary,#111827);border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
|
||||
#${u} .jfm-modal-box:focus{outline:none;}
|
||||
#${u} .jfm-modal-wide{max-width:880px;}
|
||||
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
|
||||
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid var(--color-border,#e5e7eb);flex-shrink:0;gap:.5rem;}
|
||||
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:var(--color-text-primary,#111827);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
|
||||
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
|
||||
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
|
||||
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
|
||||
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid var(--color-border,#e5e7eb);flex-shrink:0;background:var(--color-background,#f9fafb);border-radius:0 0 .5rem .5rem;}
|
||||
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:var(--jfm-text-muted);font-variant-numeric:tabular-nums;}
|
||||
|
||||
/* JSON editor */
|
||||
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
|
||||
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
|
||||
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:var(--color-text-primary,#1e293b);background:var(--color-background,#fafafa);tab-size:2;}
|
||||
#${u} .jfm-editor:focus-visible{box-shadow:inset 0 0 0 2px var(--color-primary,#3b82f6);}
|
||||
#${u} .jfm-err-bar{background:var(--color-error-bg,#fef2f2);border-bottom:1px solid var(--jfm-danger-border);color:var(--jfm-danger-text);font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
|
||||
#${u} .jfm-err-bar[hidden]{display:none;}
|
||||
|
||||
/* Create form */
|
||||
#${u} .jfm-field{margin-bottom:.875rem;}
|
||||
#${u} .jfm-field:last-child{margin-bottom:0;}
|
||||
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
|
||||
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
|
||||
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
|
||||
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
|
||||
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
|
||||
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:var(--color-text-secondary,#374151);margin-bottom:.3125rem;}
|
||||
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid var(--jfm-border-strong);border-radius:.375rem;font-size:.875rem;color:var(--color-text-primary,#111827);background:var(--color-surface,#fff);}
|
||||
#${u} .jfm-field input:focus{outline:none;border-color:var(--color-primary,#3b82f6);box-shadow:0 0 0 3px rgba(59,130,246,.25);}
|
||||
#${u} .jfm-hint{display:block;font-size:.75rem;color:var(--jfm-text-muted);margin-top:.25rem;}
|
||||
#${u} .jfm-muted{font-size:.875rem;color:var(--jfm-text-muted);margin-top:.375rem;}
|
||||
|
||||
/* Spinner */
|
||||
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
|
||||
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid var(--color-border,#e5e7eb);border-top-color:var(--color-primary,#3b82f6);border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
|
||||
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
#${u} .jfm-btn,#${u} .jfm-card,#${u} .jfm-dropzone{transition:none;}
|
||||
#${u} .jfm-spin{animation-duration:1.5s;}
|
||||
#${u} .jfm-modal{backdrop-filter:none;}
|
||||
}
|
||||
</style>`;
|
||||
}
|
||||
|
||||
@@ -319,7 +356,9 @@
|
||||
if (this._editorEl) {
|
||||
this._editorEl.addEventListener('input', () => this._updateStat());
|
||||
this._editorEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Tab') {
|
||||
// Plain Tab indents; Shift+Tab is left alone so keyboard
|
||||
// users can always move focus out of the editor.
|
||||
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
const s = this._editorEl.selectionStart;
|
||||
const end = this._editorEl.selectionEnd;
|
||||
@@ -336,15 +375,8 @@
|
||||
}
|
||||
|
||||
_onKey(e) {
|
||||
// Escape is handled by the dialog focus trap (see _trap).
|
||||
const editOpen = this._editModal && !this._editModal.hidden;
|
||||
const delOpen = this._delModal && !this._delModal.hidden;
|
||||
const createOpen = this._createModal && !this._createModal.hidden;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (editOpen) { this._closeEdit(); return; }
|
||||
if (delOpen) { this._closeDel(); return; }
|
||||
if (createOpen) { this._closeCreate(); return; }
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
|
||||
e.preventDefault();
|
||||
this._doSave();
|
||||
@@ -408,8 +440,13 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!r.ok) throw new Error('Server error ' + r.status);
|
||||
const ct = r.headers.get('content-type') || '';
|
||||
if (!r.ok) {
|
||||
// A failing action script comes back as a 400 whose JSON body
|
||||
// carries the script's own message; surface it, not the status.
|
||||
const data = ct.includes('application/json') ? await r.json().catch(() => null) : null;
|
||||
throw new Error(data?.message || 'Server error ' + r.status);
|
||||
}
|
||||
if (!ct.includes('application/json')) {
|
||||
const txt = await r.text();
|
||||
throw new Error('Unexpected response: ' + txt.slice(0, 120));
|
||||
@@ -488,6 +525,7 @@
|
||||
this._editorEl.value = 'Loading…';
|
||||
this._updateStat();
|
||||
this._editModal.hidden = false;
|
||||
this._trap('edit', this._editModal, `${this._uid}-edit-title`, this._editorEl, () => this._closeEdit());
|
||||
|
||||
try {
|
||||
const data = await this._api('get', { filename });
|
||||
@@ -498,7 +536,7 @@
|
||||
this._editorEl.setSelectionRange(0, 0);
|
||||
this._editorEl.scrollTop = 0;
|
||||
} catch (err) {
|
||||
this._showErr('Failed to load file: ' + err.message);
|
||||
this._showErr(`Couldn't load this file (${err.message}). Close the editor and try again.`);
|
||||
this._editorEl.value = '';
|
||||
}
|
||||
}
|
||||
@@ -507,6 +545,7 @@
|
||||
if (this._editModal) this._editModal.hidden = true;
|
||||
this._editFile = null;
|
||||
this._clearErr();
|
||||
this._release('edit');
|
||||
}
|
||||
|
||||
_formatJson() {
|
||||
@@ -562,12 +601,18 @@
|
||||
this._deleteFile = filename;
|
||||
const el = document.getElementById(`${this._uid}-del-name`);
|
||||
if (el) el.textContent = filename;
|
||||
if (this._delModal) this._delModal.hidden = false;
|
||||
if (this._delModal) {
|
||||
this._delModal.hidden = false;
|
||||
// Destructive dialog: start on Cancel.
|
||||
this._trap('del', this._delModal, `${this._uid}-del-title`,
|
||||
document.getElementById(`${this._uid}-del-cancel`), () => this._closeDel());
|
||||
}
|
||||
}
|
||||
|
||||
_closeDel() {
|
||||
if (this._delModal) this._delModal.hidden = true;
|
||||
this._deleteFile = null;
|
||||
this._release('del');
|
||||
}
|
||||
|
||||
async _doDelete() {
|
||||
@@ -597,11 +642,13 @@
|
||||
});
|
||||
this._createModal.hidden = false;
|
||||
const first = this.createFields[0];
|
||||
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
|
||||
const firstInput = first ? document.getElementById(`${this._uid}-cf-${first.key}`) : null;
|
||||
this._trap('create', this._createModal, `${this._uid}-create-title`, firstInput, () => this._closeCreate());
|
||||
}
|
||||
|
||||
_closeCreate() {
|
||||
if (this._createModal) this._createModal.hidden = true;
|
||||
this._release('create');
|
||||
}
|
||||
|
||||
async _doCreate() {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* LEDMatrix Notification Widget
|
||||
*
|
||||
* Global notification/toast system for displaying messages to users.
|
||||
* Consolidates all notification functionality into a single widget.
|
||||
* This is the single implementation: the early fallbacks in app-shell.js,
|
||||
* app.js and partials/fonts.html only queue messages until this widget has
|
||||
* loaded (see window.__pendingNotifications) and then delegate to it.
|
||||
*
|
||||
* Usage:
|
||||
* window.showNotification('Message here', 'success');
|
||||
@@ -11,6 +13,16 @@
|
||||
*
|
||||
* Types: success, error, warning, info (default)
|
||||
*
|
||||
* Accessibility:
|
||||
* - Toasts are announced through two persistent, visually hidden live
|
||||
* regions: errors use an assertive region (role="alert"), everything else
|
||||
* a polite one (role="status"). The visible toast is not itself a live
|
||||
* region, so each message is announced exactly once.
|
||||
* - Errors stay on screen for at least ERROR_MIN_DURATION and always have a
|
||||
* dismiss button.
|
||||
* - Auto-dismiss pauses while the pointer is over a toast or focus is in it.
|
||||
* - Slide animations are skipped under prefers-reduced-motion.
|
||||
*
|
||||
* @module NotificationWidget
|
||||
*/
|
||||
|
||||
@@ -26,35 +38,51 @@
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
containerId: 'notifications',
|
||||
politeRegionId: 'notifications-live-polite',
|
||||
assertiveRegionId: 'notifications-live-assertive',
|
||||
defaultDuration: 4000,
|
||||
warningDuration: 6000,
|
||||
errorMinDuration: 10000,
|
||||
fadeOutDuration: 300,
|
||||
maxNotifications: 5,
|
||||
position: 'top-right' // top-right, top-left, bottom-right, bottom-left
|
||||
};
|
||||
|
||||
// Type-specific styling
|
||||
// Type-specific styling. `bg` class names are kept for anyone styling
|
||||
// against them; `color` is applied inline so white text meets 4.5:1
|
||||
// contrast even where the utility classes are missing or lighter.
|
||||
const TYPE_STYLES = {
|
||||
success: {
|
||||
bg: 'bg-green-500',
|
||||
color: '#047857',
|
||||
icon: 'fa-check-circle',
|
||||
label: 'Success'
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-500',
|
||||
color: '#b91c1c',
|
||||
icon: 'fa-exclamation-circle',
|
||||
label: 'Error'
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-yellow-500',
|
||||
color: '#b45309',
|
||||
icon: 'fa-exclamation-triangle',
|
||||
label: 'Warning'
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-500',
|
||||
color: '#1d4ed8',
|
||||
icon: 'fa-info-circle',
|
||||
label: 'Info'
|
||||
}
|
||||
};
|
||||
// Lookups go through a Map so an arbitrary type string can't reach
|
||||
// Object.prototype properties.
|
||||
const STYLE_BY_TYPE = new Map(Object.entries(TYPE_STYLES));
|
||||
|
||||
const VISUALLY_HIDDEN = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;' +
|
||||
'overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
|
||||
|
||||
// Track active notifications
|
||||
let activeNotifications = [];
|
||||
@@ -62,8 +90,14 @@
|
||||
// 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();
|
||||
// Auto-dismiss timers, keyed by notification id: { timer, remaining, startedAt }
|
||||
const timers = new Map();
|
||||
let notificationCounter = 0;
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the notifications container
|
||||
* @returns {HTMLElement} Container element
|
||||
@@ -75,14 +109,55 @@
|
||||
container = document.createElement('div');
|
||||
container.id = CONFIG.containerId;
|
||||
container.className = 'fixed top-4 right-4 z-50 space-y-2 pointer-events-none';
|
||||
container.setAttribute('aria-live', 'polite');
|
||||
container.setAttribute('aria-label', 'Notifications');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
if (!container.dataset.ledNotifications) {
|
||||
container.dataset.ledNotifications = 'true';
|
||||
// Announcements go through the dedicated live regions below; a
|
||||
// live container would announce every toast a second time.
|
||||
container.removeAttribute('aria-live');
|
||||
container.setAttribute('role', 'region');
|
||||
container.setAttribute('aria-label', 'Notifications');
|
||||
// Let clicks pass through the empty parts of the stack even when
|
||||
// the utility classes are not defined.
|
||||
container.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
getLiveRegion(CONFIG.politeRegionId, 'status', 'polite');
|
||||
getLiveRegion(CONFIG.assertiveRegionId, 'alert', 'assertive');
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function getLiveRegion(id, role, politeness) {
|
||||
let region = document.getElementById(id);
|
||||
if (!region) {
|
||||
region = document.createElement('div');
|
||||
region.id = id;
|
||||
region.setAttribute('role', role);
|
||||
region.setAttribute('aria-live', politeness);
|
||||
region.setAttribute('aria-atomic', 'true');
|
||||
region.style.cssText = VISUALLY_HIDDEN;
|
||||
document.body.appendChild(region);
|
||||
}
|
||||
return region;
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce a message to screen readers once.
|
||||
*/
|
||||
function announce(text, assertive) {
|
||||
const region = getLiveRegion(
|
||||
assertive ? CONFIG.assertiveRegionId : CONFIG.politeRegionId,
|
||||
assertive ? 'alert' : 'status',
|
||||
assertive ? 'assertive' : 'polite'
|
||||
);
|
||||
// Clear first so repeating the same message is announced again.
|
||||
region.textContent = '';
|
||||
setTimeout(() => { region.textContent = text; }, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
@@ -94,16 +169,55 @@
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function clearTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (t && t.timer) clearTimeout(t.timer);
|
||||
timers.delete(notificationId);
|
||||
}
|
||||
|
||||
function startTimer(notificationId, ms) {
|
||||
const t = timers.get(notificationId) || {};
|
||||
if (t.timer) clearTimeout(t.timer);
|
||||
t.remaining = ms;
|
||||
t.startedAt = Date.now();
|
||||
t.timer = setTimeout(() => removeNotification(notificationId), ms);
|
||||
timers.set(notificationId, t);
|
||||
}
|
||||
|
||||
function pauseTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (!t || !t.timer) return;
|
||||
clearTimeout(t.timer);
|
||||
t.timer = null;
|
||||
t.remaining = Math.max(0, t.remaining - (Date.now() - t.startedAt));
|
||||
}
|
||||
|
||||
function resumeTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (!t || t.timer) return;
|
||||
// Give the reader a moment after they move away.
|
||||
startTimer(notificationId, Math.max(t.remaining, 2000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a notification by ID
|
||||
* @param {string} notificationId - Notification ID
|
||||
* @param {boolean} immediate - Skip fade animation
|
||||
*/
|
||||
function removeNotification(notificationId, immediate = false) {
|
||||
clearTimer(notificationId);
|
||||
const notification = document.getElementById(notificationId);
|
||||
|
||||
// Remove from tracking array
|
||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||
actionCallbacks.delete(notificationId);
|
||||
|
||||
if (!notification) return;
|
||||
|
||||
if (immediate) {
|
||||
// If focus is inside the toast, don't strand it on a removed node.
|
||||
const hadFocus = notification.contains(document.activeElement);
|
||||
|
||||
if (immediate || prefersReducedMotion()) {
|
||||
notification.remove();
|
||||
} else {
|
||||
notification.style.transition = `opacity ${CONFIG.fadeOutDuration}ms, transform ${CONFIG.fadeOutDuration}ms`;
|
||||
@@ -115,9 +229,16 @@
|
||||
}, CONFIG.fadeOutDuration);
|
||||
}
|
||||
|
||||
// Remove from tracking array
|
||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||
actionCallbacks.delete(notificationId);
|
||||
if (hadFocus) {
|
||||
const next = activeNotifications.length
|
||||
? document.querySelector(`#${activeNotifications[activeNotifications.length - 1]} button`)
|
||||
: null;
|
||||
if (next) {
|
||||
next.focus();
|
||||
} else if (document.activeElement && typeof document.activeElement.blur === 'function') {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,15 +252,26 @@
|
||||
if (typeof options === 'string') {
|
||||
options = { type: options };
|
||||
}
|
||||
options = options || {};
|
||||
|
||||
const type = options.type || 'info';
|
||||
const duration = options.duration !== undefined ? options.duration : CONFIG.defaultDuration;
|
||||
const type = STYLE_BY_TYPE.has(options.type) ? options.type : 'info';
|
||||
const isError = type === 'error';
|
||||
let duration = options.duration !== undefined
|
||||
? Number(options.duration)
|
||||
: (isError ? CONFIG.errorMinDuration
|
||||
: type === 'warning' ? CONFIG.warningDuration : CONFIG.defaultDuration);
|
||||
// Errors must stay long enough to read; 0 still means "until dismissed".
|
||||
if (isError && duration > 0 && duration < CONFIG.errorMinDuration) {
|
||||
duration = CONFIG.errorMinDuration;
|
||||
}
|
||||
const showIcon = options.showIcon !== false;
|
||||
const dismissible = options.dismissible !== false;
|
||||
// Errors always get a dismiss button.
|
||||
const dismissible = isError || duration <= 0 || options.dismissible !== false;
|
||||
|
||||
const style = TYPE_STYLES[type] || TYPE_STYLES.info;
|
||||
const style = STYLE_BY_TYPE.get(type);
|
||||
const container = getContainer();
|
||||
const notificationId = `notification_${++notificationCounter}`;
|
||||
const reduceMotion = prefersReducedMotion();
|
||||
|
||||
// Enforce max notifications limit
|
||||
while (activeNotifications.length >= CONFIG.maxNotifications) {
|
||||
@@ -150,18 +282,25 @@
|
||||
const notification = document.createElement('div');
|
||||
notification.id = notificationId;
|
||||
notification.className = `${style.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 pointer-events-auto transform transition-all duration-300 ease-out`;
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.setAttribute('role', 'alert');
|
||||
notification.style.backgroundColor = style.color;
|
||||
notification.style.color = '#ffffff';
|
||||
notification.style.pointerEvents = 'auto';
|
||||
notification.dataset.type = type;
|
||||
if (reduceMotion) {
|
||||
notification.style.transition = 'none';
|
||||
} else {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
}
|
||||
|
||||
// Build content
|
||||
let html = '';
|
||||
|
||||
if (showIcon) {
|
||||
html += `<i class="fas ${style.icon} flex-shrink-0"></i>`;
|
||||
html += `<i class="fas ${style.icon} flex-shrink-0" aria-hidden="true"></i>`;
|
||||
}
|
||||
|
||||
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
|
||||
html += `<span class="flex-1 text-sm"><span style="${VISUALLY_HIDDEN}">${style.label}: </span>${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
|
||||
@@ -171,7 +310,8 @@
|
||||
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">
|
||||
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"
|
||||
style="background:rgba(255,255,255,.2);color:inherit;border:0;">
|
||||
${escapeHtml(options.actionLabel)}
|
||||
</button>
|
||||
`;
|
||||
@@ -182,8 +322,9 @@
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.get('notification').dismiss('${notificationId}')"
|
||||
class="flex-shrink-0 ml-2 w-5 h-5 flex items-center justify-center rounded-full opacity-70 hover:opacity-100 hover:bg-white hover:bg-opacity-20 transition-all duration-150"
|
||||
aria-label="Dismiss notification">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
style="width:1.75rem;height:1.75rem;display:inline-flex;align-items:center;justify-content:center;background:transparent;color:inherit;border:0;border-radius:9999px;cursor:pointer;"
|
||||
aria-label="Dismiss ${style.label.toLowerCase()} notification">
|
||||
<svg class="w-3 h-3" width="12" height="12" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -194,17 +335,29 @@
|
||||
container.appendChild(notification);
|
||||
activeNotifications.push(notificationId);
|
||||
|
||||
// Trigger animation (need to wait for DOM update)
|
||||
requestAnimationFrame(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateX(0)';
|
||||
});
|
||||
announce(`${style.label}: ${String(message)}`, isError);
|
||||
|
||||
// Auto-dismiss (0 = no auto-dismiss)
|
||||
if (!reduceMotion) {
|
||||
// Trigger animation (need to wait for DOM update)
|
||||
requestAnimationFrame(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateX(0)';
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-dismiss (0 = no auto-dismiss), paused while hovered or focused
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(notificationId);
|
||||
}, duration);
|
||||
startTimer(notificationId, duration);
|
||||
notification.addEventListener('mouseenter', () => pauseTimer(notificationId));
|
||||
notification.addEventListener('mouseleave', () => {
|
||||
if (!notification.contains(document.activeElement)) resumeTimer(notificationId);
|
||||
});
|
||||
notification.addEventListener('focusin', () => pauseTimer(notificationId));
|
||||
notification.addEventListener('focusout', (e) => {
|
||||
if (!notification.contains(e.relatedTarget) && !notification.matches(':hover')) {
|
||||
resumeTimer(notificationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Log for debugging
|
||||
@@ -224,16 +377,17 @@
|
||||
// Register the widget
|
||||
window.LEDMatrixWidgets.register('notification', {
|
||||
name: 'Notification Widget',
|
||||
version: '1.0.0',
|
||||
version: '1.1.0',
|
||||
|
||||
/**
|
||||
* Show a notification
|
||||
* @param {string} message - Message to display
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.type - Notification type: success, error, warning, info
|
||||
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss)
|
||||
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss).
|
||||
* Errors are kept for at least 10s.
|
||||
* @param {boolean} options.showIcon - Show type icon (default: true)
|
||||
* @param {boolean} options.dismissible - Show dismiss button (default: true)
|
||||
* @param {boolean} options.dismissible - Show dismiss button (default: true; always true for errors)
|
||||
* @returns {string} Notification ID
|
||||
*/
|
||||
show: showNotification,
|
||||
@@ -295,15 +449,28 @@
|
||||
// Global shorthand function (backwards compatible with existing code).
|
||||
// Accepts either the legacy type string or a full options object
|
||||
// ({ type, duration, actionLabel, onAction, ... }).
|
||||
// This always replaces the early fallbacks, whatever order they loaded in.
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
|
||||
};
|
||||
window.showNotification.__ledNotificationWidget = true;
|
||||
|
||||
// Initialize container on load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', getContainer);
|
||||
} else {
|
||||
function flushPending() {
|
||||
getContainer();
|
||||
const pending = window.__pendingNotifications;
|
||||
window.__pendingNotifications = [];
|
||||
if (Array.isArray(pending)) {
|
||||
pending.forEach(args => {
|
||||
try { window.showNotification(args[0], args[1]); } catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize container (and show anything queued before we loaded)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', flushPending);
|
||||
} else {
|
||||
flushPending();
|
||||
}
|
||||
|
||||
console.log('[NotificationWidget] Notification widget registered');
|
||||
|
||||
@@ -101,8 +101,9 @@
|
||||
html += `
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onDecrement('${fieldId}')"
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-r-0 border-gray-300 hover:bg-gray-200 ${prefix ? '' : 'rounded-l-md'}">
|
||||
<i class="fas fa-minus text-xs"></i>
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-r-0 border-gray-300 hover:bg-gray-200 ${prefix ? '' : 'rounded-l-md'}"
|
||||
aria-label="Decrease" aria-controls="${fieldId}_input">
|
||||
<i class="fas fa-minus text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -128,8 +129,9 @@
|
||||
html += `
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onIncrement('${fieldId}')"
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-l-0 border-gray-300 hover:bg-gray-200 ${suffix ? '' : 'rounded-r-md'}">
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-l-0 border-gray-300 hover:bg-gray-200 ${suffix ? '' : 'rounded-r-md'}"
|
||||
aria-label="Increase" aria-controls="${fieldId}_input">
|
||||
<i class="fas fa-plus text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -142,8 +142,9 @@
|
||||
id="${fieldId}_toggle"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('password-input').onToggle('${fieldId}')"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||
title="Show/hide password">
|
||||
<i id="${fieldId}_icon" class="fas fa-eye"></i>
|
||||
title="Show/hide password"
|
||||
aria-label="Show password" aria-controls="${fieldId}_input">
|
||||
<i id="${fieldId}_icon" class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -297,16 +298,19 @@
|
||||
const safeId = sanitizeId(fieldId);
|
||||
const input = document.getElementById(`${safeId}_input`);
|
||||
const icon = document.getElementById(`${safeId}_icon`);
|
||||
const toggleBtn = document.getElementById(`${safeId}_toggle`);
|
||||
|
||||
if (input && icon) {
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.remove('fa-eye');
|
||||
icon.classList.add('fa-eye-slash');
|
||||
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Hide password');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.remove('fa-eye-slash');
|
||||
icon.classList.add('fa-eye');
|
||||
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Show password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,108 +56,141 @@
|
||||
if (!document.getElementById('pfm-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'pfm-styles';
|
||||
// Colors come from the app theme tokens (app.css :root / [data-theme="dark"]),
|
||||
// with the original light values as fallbacks for pages without app.css.
|
||||
// The few shades the tokens don't cover are widget-local variables with
|
||||
// a dark-theme override.
|
||||
style.textContent = `
|
||||
.pfm-root { font-family: inherit; }
|
||||
.pfm-root, .pfm-overlay {
|
||||
--pfm-border-strong:#d1d5db; --pfm-subtle:#f3f4f6; --pfm-subtle-hover:#e5e7eb;
|
||||
--pfm-danger-border:#fecaca; --pfm-danger-text:#991b1b; --pfm-today:#fef9c3;
|
||||
--pfm-text-muted:#6b7280;
|
||||
}
|
||||
[data-theme="dark"] .pfm-root, [data-theme="dark"] .pfm-overlay {
|
||||
--pfm-border-strong:#4b5563; --pfm-subtle:#374151; --pfm-subtle-hover:#4b5563;
|
||||
--pfm-danger-border:#991b1b; --pfm-danger-text:#fecaca; --pfm-today:#422006;
|
||||
--pfm-text-muted:#9ca3af;
|
||||
}
|
||||
.pfm-root { font-family: inherit; color:var(--color-text-primary,#111827); }
|
||||
.pfm-header { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-bottom:.75rem; }
|
||||
.pfm-title { font-size:1rem; font-weight:600; color:#111827; }
|
||||
.pfm-dir { font-size:.75rem; color:#6b7280; margin-top:.125rem; }
|
||||
.pfm-upload { border:2px dashed #d1d5db; border-radius:.5rem; padding:1.25rem;
|
||||
.pfm-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); }
|
||||
.pfm-dir { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.125rem; }
|
||||
.pfm-upload { border:2px dashed var(--pfm-border-strong); border-radius:.5rem; padding:1.25rem;
|
||||
text-align:center; cursor:pointer; transition:border-color .15s,background .15s; }
|
||||
.pfm-upload:hover,.pfm-upload.dragover { border-color:#3b82f6; background:#eff6ff; }
|
||||
.pfm-upload p { font-size:.875rem; color:#4b5563; margin:.25rem 0 0; }
|
||||
.pfm-upload small { font-size:.75rem; color:#9ca3af; }
|
||||
.pfm-upload:hover,.pfm-upload.dragover { border-color:var(--color-primary,#3b82f6); background:var(--color-info-bg,#eff6ff); }
|
||||
.pfm-upload p { font-size:.875rem; color:var(--color-text-tertiary,#4b5563); margin:.25rem 0 0; }
|
||||
.pfm-upload small { font-size:.75rem; color:var(--pfm-text-muted); }
|
||||
.pfm-upload-icon { font-size:1.5rem; color:var(--pfm-text-muted); }
|
||||
.pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
|
||||
gap:.75rem; margin-top:.75rem; }
|
||||
.pfm-card { border:1px solid #e5e7eb; border-radius:.5rem; padding:.875rem;
|
||||
background:#fff; transition:box-shadow .15s; }
|
||||
.pfm-card:hover { box-shadow:0 1px 4px rgba(0,0,0,.1); }
|
||||
.pfm-card { border:1px solid var(--color-border,#e5e7eb); border-radius:.5rem; padding:.875rem;
|
||||
background:var(--color-surface,#fff); transition:box-shadow .15s; }
|
||||
.pfm-card:hover { box-shadow:var(--shadow-md,0 1px 4px rgba(0,0,0,.1)); }
|
||||
.pfm-card.disabled { opacity:.55; }
|
||||
.pfm-card-top { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-bottom:.5rem; }
|
||||
.pfm-card-icon { width:2rem; height:2rem; background:#f3f4f6; border-radius:.375rem;
|
||||
.pfm-card-icon { width:2rem; height:2rem; background:var(--pfm-subtle); border-radius:.375rem;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
color:#6b7280; font-size:1rem; }
|
||||
.pfm-card-name { font-weight:600; color:#111827; font-size:.875rem; margin:.375rem 0 .125rem; }
|
||||
.pfm-card-meta { font-size:.75rem; color:#6b7280; line-height:1.5; }
|
||||
color:var(--pfm-text-muted); font-size:1rem; }
|
||||
.pfm-card-name { font-weight:600; color:var(--color-text-primary,#111827); font-size:.875rem; margin:.375rem 0 .125rem; }
|
||||
.pfm-card-meta { font-size:.75rem; color:var(--pfm-text-muted); line-height:1.5; }
|
||||
.pfm-card-actions { display:flex; gap:.375rem; margin-top:.625rem; }
|
||||
.pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem;
|
||||
border-radius:.375rem; font-size:.8125rem; font-weight:500;
|
||||
border:none; cursor:pointer; transition:background .15s; }
|
||||
.pfm-btn-primary { background:#2563eb; color:#fff; flex:1; justify-content:center; }
|
||||
.pfm-btn-primary:hover { background:#1d4ed8; }
|
||||
.pfm-btn:focus-visible { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; }
|
||||
.pfm-btn:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.pfm-btn-primary { background:var(--color-primary,#2563eb); color:#fff; flex:1; justify-content:center; }
|
||||
.pfm-btn-primary:hover { background:var(--color-primary-hover,#1d4ed8); }
|
||||
/* Solid fills with white text: fixed shades read well on both themes. */
|
||||
.pfm-btn-danger { background:#dc2626; color:#fff; }
|
||||
.pfm-btn-danger:hover { background:#b91c1c; }
|
||||
.pfm-btn-secondary { background:#f3f4f6; color:#374151; border:1px solid #d1d5db; }
|
||||
.pfm-btn-secondary:hover { background:#e5e7eb; }
|
||||
.pfm-btn-secondary { background:var(--pfm-subtle); color:var(--color-text-secondary,#374151); border:1px solid var(--pfm-border-strong); }
|
||||
.pfm-btn-secondary:hover { background:var(--pfm-subtle-hover); }
|
||||
.pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; }
|
||||
.pfm-btn-create { background:#059669; color:#fff; }
|
||||
.pfm-btn-create:hover { background:#047857; }
|
||||
.pfm-toggle-wrap { display:flex; align-items:center; gap:.375rem; }
|
||||
.pfm-toggle-label { font-size:.75rem; color:#6b7280; }
|
||||
.pfm-toggle-label { font-size:.75rem; color:var(--pfm-text-muted); }
|
||||
.pfm-toggle-cb { position:relative; display:inline-block; width:2rem; height:1.125rem; }
|
||||
.pfm-toggle-cb input { opacity:0; width:0; height:0; }
|
||||
.pfm-toggle-slider { position:absolute; inset:0; background:#d1d5db; border-radius:9999px;
|
||||
.pfm-toggle-slider { position:absolute; inset:0; background:var(--pfm-border-strong); border-radius:9999px;
|
||||
cursor:pointer; transition:background .2s; }
|
||||
.pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem;
|
||||
left:.1875rem; bottom:.1875rem; background:#fff;
|
||||
border-radius:50%; transition:transform .2s; }
|
||||
.pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; }
|
||||
.pfm-toggle-cb input:checked + .pfm-toggle-slider:before { transform:translateX(.875rem); }
|
||||
.pfm-empty { text-align:center; padding:2rem; color:#9ca3af; }
|
||||
.pfm-toggle-cb input:focus-visible + .pfm-toggle-slider { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; }
|
||||
.pfm-empty { text-align:center; padding:2rem; color:var(--pfm-text-muted); }
|
||||
.pfm-empty i { font-size:2rem; margin-bottom:.5rem; display:block; }
|
||||
.pfm-error-text { color:var(--color-error,#dc2626); }
|
||||
|
||||
/* Modal */
|
||||
.pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5);
|
||||
display:flex; align-items:flex-start; justify-content:center;
|
||||
z-index:9999; padding:2rem 1rem; overflow-y:auto; }
|
||||
.pfm-modal { background:#fff; border-radius:.75rem; width:100%; max-width:56rem;
|
||||
.pfm-modal { background:var(--color-surface,#fff); color:var(--color-text-primary,#111827);
|
||||
border-radius:.75rem; width:100%; max-width:56rem;
|
||||
box-shadow:0 20px 50px rgba(0,0,0,.3); margin:auto; }
|
||||
.pfm-modal:focus { outline:none; }
|
||||
.pfm-modal-header { display:flex; align-items:center; justify-content:space-between;
|
||||
padding:1rem 1.25rem; border-bottom:1px solid #e5e7eb; }
|
||||
.pfm-modal-title { font-size:1rem; font-weight:600; color:#111827; }
|
||||
padding:1rem 1.25rem; border-bottom:1px solid var(--color-border,#e5e7eb); }
|
||||
.pfm-modal-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); }
|
||||
.pfm-modal-body { padding:1.25rem; overflow-y:auto; max-height:70vh; }
|
||||
.pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem;
|
||||
padding:.875rem 1.25rem; border-top:1px solid #e5e7eb;
|
||||
background:#f9fafb; border-radius:0 0 .75rem .75rem; }
|
||||
padding:.875rem 1.25rem; border-top:1px solid var(--color-border,#e5e7eb);
|
||||
background:var(--color-background,#f9fafb); border-radius:0 0 .75rem .75rem; }
|
||||
|
||||
/* Entry table */
|
||||
.pfm-table-wrap { overflow-x:auto; }
|
||||
.pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; }
|
||||
.pfm-table th { background:#f9fafb; text-align:left; padding:.5rem .625rem;
|
||||
font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb;
|
||||
.pfm-table th { background:var(--color-background,#f9fafb); text-align:left; padding:.5rem .625rem;
|
||||
font-weight:600; color:var(--color-text-secondary,#374151); border-bottom:1px solid var(--color-border,#e5e7eb);
|
||||
white-space:nowrap; position:sticky; top:0; }
|
||||
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid #f3f4f6;
|
||||
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid var(--color-border-light,#f3f4f6);
|
||||
vertical-align:top; }
|
||||
.pfm-table tr.today-row td { background:#fef9c3; }
|
||||
.pfm-table tr.today-row td { background:var(--pfm-today); }
|
||||
.pfm-table td input, .pfm-table td textarea {
|
||||
width:100%; border:1px solid #d1d5db; border-radius:.25rem;
|
||||
width:100%; border:1px solid var(--pfm-border-strong); border-radius:.25rem;
|
||||
padding:.25rem .375rem; font-size:.8125rem; font-family:inherit;
|
||||
resize:vertical; background:#fff; }
|
||||
resize:vertical; background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-table td input:focus, .pfm-table td textarea:focus {
|
||||
outline:none; border-color:#3b82f6; }
|
||||
outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); }
|
||||
.pfm-day-col { width:3rem; text-align:center; font-weight:600;
|
||||
color:#6b7280; white-space:nowrap; }
|
||||
color:var(--pfm-text-muted); white-space:nowrap; }
|
||||
.pfm-table-info { font-size:.75rem; color:var(--pfm-text-muted); margin-bottom:.375rem; }
|
||||
.pfm-pagination { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-top:.75rem; font-size:.8125rem; color:#6b7280; }
|
||||
margin-top:.75rem; font-size:.8125rem; color:var(--pfm-text-muted); }
|
||||
.pfm-page-jump { display:flex; align-items:center; gap:.375rem; font-size:.8125rem; }
|
||||
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid #d1d5db;
|
||||
border-radius:.25rem; text-align:center; }
|
||||
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid var(--pfm-border-strong);
|
||||
border-radius:.25rem; text-align:center;
|
||||
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-json-ta { width:100%; font-family:monospace; font-size:.75rem; border:1px solid var(--pfm-border-strong);
|
||||
border-radius:.375rem; padding:.5rem;
|
||||
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-json-err { color:var(--color-error,#dc2626); font-size:.75rem; margin-top:.25rem; }
|
||||
|
||||
/* Form in create modal */
|
||||
.pfm-field { margin-bottom:.875rem; }
|
||||
.pfm-field label { display:block; font-size:.875rem; font-weight:500;
|
||||
color:#374151; margin-bottom:.25rem; }
|
||||
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid #d1d5db;
|
||||
border-radius:.375rem; font-size:.875rem; }
|
||||
.pfm-field input:focus { outline:none; border-color:#3b82f6; }
|
||||
.pfm-field-hint { font-size:.75rem; color:#9ca3af; margin-top:.2rem; }
|
||||
.pfm-field-error { font-size:.75rem; color:#dc2626; margin-top:.2rem; }
|
||||
color:var(--color-text-secondary,#374151); margin-bottom:.25rem; }
|
||||
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid var(--pfm-border-strong);
|
||||
border-radius:.375rem; font-size:.875rem;
|
||||
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-field input:focus { outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); }
|
||||
.pfm-field-hint { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.2rem; }
|
||||
.pfm-field-error { font-size:.75rem; color:var(--color-error,#dc2626); margin-top:.2rem; }
|
||||
|
||||
/* Delete danger box */
|
||||
.pfm-danger-box { background:#fef2f2; border:1px solid #fecaca;
|
||||
.pfm-danger-box { background:var(--color-error-bg,#fef2f2); border:1px solid var(--pfm-danger-border);
|
||||
border-radius:.5rem; padding:.875rem; font-size:.875rem;
|
||||
color:#991b1b; }
|
||||
color:var(--pfm-danger-text); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pfm-upload, .pfm-card, .pfm-btn, .pfm-toggle-slider, .pfm-toggle-slider:before { transition:none; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -356,7 +389,8 @@
|
||||
delBtn.dataset.pfmAction = 'delete';
|
||||
delBtn.dataset.pfmField = fieldId;
|
||||
delBtn.dataset.pfmFile = f.filename;
|
||||
delBtn.innerHTML = '<i class="fas fa-trash"></i>'; // static
|
||||
delBtn.setAttribute('aria-label', 'Delete ' + f.filename);
|
||||
delBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>'; // static
|
||||
actions.appendChild(delBtn);
|
||||
}
|
||||
card.appendChild(actions);
|
||||
@@ -375,18 +409,18 @@
|
||||
modal.className = 'pfm-modal';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="pfm-modal-title" id="${escHtml(fieldId)}_modal_title"><i class="fas fa-edit mr-2" aria-hidden="true"></i>${escHtml(filename)}</span>
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close" aria-label="Close">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
|
||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i>Loading…</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||
<i class="fas fa-save mr-1"></i>Save
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
||||
<button type="button" class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||
<i class="fas fa-save mr-1" aria-hidden="true"></i>Save
|
||||
</button>
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
@@ -394,11 +428,12 @@
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename));
|
||||
trapModal(fieldId, modal, `${fieldId}_modal_title`);
|
||||
|
||||
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
|
||||
const body = document.getElementById(`${fieldId}_edit_body`);
|
||||
if (!data || data.status !== 'success' || !body) {
|
||||
if (body) safeSetHTML(body, '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>');
|
||||
if (body) safeSetHTML(body, `<div class="pfm-empty pfm-error-text">Couldn't load ${escHtml(filename)}. Close this window and try again.</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -413,10 +448,10 @@
|
||||
// Textarea path: _editData stays null; save() reads from the <textarea>
|
||||
st._editData = null;
|
||||
safeSetHTML(body, `
|
||||
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
|
||||
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
||||
<textarea id="${escHtml(fieldId)}_json_ta" rows="20" class="pfm-json-ta"
|
||||
aria-label="File contents (JSON)" aria-describedby="${escHtml(fieldId)}_json_err"
|
||||
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
||||
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`);
|
||||
<div id="${escHtml(fieldId)}_json_err" class="pfm-json-err" role="alert"></div>`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -461,7 +496,7 @@
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
safeSetHTML(container, `
|
||||
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
|
||||
<div class="pfm-table-info">
|
||||
${total} entries total
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
|
||||
onclick="(function(){const targetPage=Math.ceil(${todayDoy}/${perPage});window._pfmTablePage('${fieldId}',targetPage);setTimeout(function(){const row=document.querySelector('tr[data-day=\\'${todayDoy}\\']');if(row)row.scrollIntoView({block:'center'});},60);})()">
|
||||
@@ -579,9 +614,9 @@
|
||||
modal.style.maxWidth = '28rem';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="pfm-modal-title" id="${escHtml(fieldId)}_del_title"><i class="fas fa-trash mr-2" aria-hidden="true"></i>Delete File</span>
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close" aria-label="Close">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
@@ -591,15 +626,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
||||
<button type="button" class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||
<i class="fas fa-trash mr-1" aria-hidden="true"></i>Delete
|
||||
</button>
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
const cancelBtn = modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
cancelBtn.addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename));
|
||||
// Destructive dialog: start on Cancel.
|
||||
trapModal(fieldId, modal, `${fieldId}_del_title`, cancelBtn);
|
||||
};
|
||||
|
||||
window._pfmConfirmDelete = async function (fieldId, filename) {
|
||||
@@ -611,7 +649,7 @@
|
||||
window._pfmCloseModal(fieldId);
|
||||
await loadFiles(fieldId);
|
||||
} else {
|
||||
notify('Delete failed: ' + (result.message || ''), 'error');
|
||||
notify("Couldn't delete the file: " + (result.message || 'unknown error') + '. Try again.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -626,13 +664,13 @@
|
||||
modal.style.maxWidth = '32rem';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="pfm-modal-title" id="${escHtml(fieldId)}_cre_title"><i class="fas fa-plus-circle mr-2" aria-hidden="true"></i>Create New File</span>
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close" aria-label="Close">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
||||
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" role="alert" style="margin-bottom:.5rem;"></div>
|
||||
${fields.map(f => `
|
||||
<div class="pfm-field">
|
||||
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
||||
@@ -643,16 +681,16 @@
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
||||
<i class="fas fa-plus mr-1"></i>Create
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
||||
<button type="button" class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
||||
<i class="fas fa-plus mr-1" aria-hidden="true"></i>Create
|
||||
</button>
|
||||
</div>
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId));
|
||||
trapModal(fieldId, modal, `${fieldId}_cre_title`, modal.querySelector('.pfm-field input'));
|
||||
};
|
||||
|
||||
window._pfmConfirmCreate = async function (fieldId) {
|
||||
@@ -691,12 +729,14 @@
|
||||
window._pfmToggle = async function (fieldId, categoryName, enabled) {
|
||||
const st = getState(fieldId);
|
||||
const result = await callAction(st.pluginId, st.actions.toggle, { category_name: categoryName, enabled })
|
||||
.catch(() => ({ status: 'error' }));
|
||||
.catch(() => ({ status: 'error', message: 'Network error' }));
|
||||
if (result.status === 'success') {
|
||||
notify(enabled ? `${categoryName} enabled` : `${categoryName} disabled`, 'success');
|
||||
await loadFiles(fieldId);
|
||||
} else {
|
||||
notify('Toggle failed', 'error');
|
||||
// The action endpoint relays the script's own message on failure;
|
||||
// without it every error reads as a bare "Toggle failed".
|
||||
notify(result.message ? `Toggle failed: ${result.message}` : 'Toggle failed', 'error');
|
||||
await loadFiles(fieldId); // revert UI
|
||||
}
|
||||
};
|
||||
@@ -739,11 +779,28 @@
|
||||
return overlay;
|
||||
}
|
||||
|
||||
// Dialog semantics + focus trap for an open modal (utils/dialog.js).
|
||||
// Every close path goes through _pfmCloseModal, which releases it.
|
||||
function trapModal(fieldId, modal, titleId, initialFocus) {
|
||||
if (!window.LEDDialog) return;
|
||||
const st = getState(fieldId);
|
||||
st._releaseDialog = window.LEDDialog.trap(modal, {
|
||||
labelledBy: titleId,
|
||||
initialFocus: initialFocus || null,
|
||||
onEscape: () => window._pfmCloseModal(fieldId)
|
||||
});
|
||||
}
|
||||
|
||||
window._pfmCloseModal = function (fieldId) {
|
||||
const st = getState(fieldId);
|
||||
if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; }
|
||||
st._editData = null;
|
||||
st._editFilename = null;
|
||||
if (st._releaseDialog) {
|
||||
const release = st._releaseDialog;
|
||||
st._releaseDialog = null;
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Widget registration ──────────────────────────────────────────────────
|
||||
@@ -793,7 +850,7 @@
|
||||
<input type="file" id="${fieldId}_file_input" accept=".json"
|
||||
style="display:none"
|
||||
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size:1.5rem;color:#9ca3af;"></i>
|
||||
<i class="fas fa-cloud-upload-alt pfm-upload-icon" aria-hidden="true"></i>
|
||||
<p>Drag and drop or click to upload</p>
|
||||
<small>${escHtml(st.uploadHint)}</small>
|
||||
</div>` : ''}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
saturday: 'Saturday',
|
||||
sunday: 'Sunday'
|
||||
};
|
||||
const DAY_LABEL_MAP = new Map(Object.entries(DAY_LABELS));
|
||||
|
||||
// Use BaseWidget utilities if available
|
||||
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
|
||||
@@ -283,17 +284,19 @@
|
||||
// Render each day row
|
||||
DAYS.forEach(day => {
|
||||
const dayConfig = schedule.days[day];
|
||||
const dayLabel = escapeHtml(DAY_LABEL_MAP.get(day) || day);
|
||||
const disabled = !dayConfig.enabled;
|
||||
const disabledClass = disabled ? 'bg-gray-100' : '';
|
||||
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50" id="${fieldId}_row_${day}">
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="text-sm font-medium text-gray-900">${escapeHtml(DAY_LABELS[day])}</span>
|
||||
<span class="text-sm font-medium text-gray-900">${dayLabel}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<input type="checkbox"
|
||||
id="${fieldId}_${day}_enabled"
|
||||
aria-label="${dayLabel} enabled"
|
||||
${dayConfig.enabled ? 'checked' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayEnabledChange('${fieldId}', '${day}', this.checked)"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
@@ -301,6 +304,7 @@
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<input type="time"
|
||||
id="${fieldId}_${day}_start"
|
||||
aria-label="${dayLabel} start time"
|
||||
value="${escapeHtml(dayConfig.start_time)}"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
|
||||
@@ -309,6 +313,7 @@
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<input type="time"
|
||||
id="${fieldId}_${day}_end"
|
||||
aria-label="${dayLabel} end time"
|
||||
value="${escapeHtml(dayConfig.end_time)}"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
|
||||
|
||||
@@ -146,10 +146,20 @@
|
||||
function elementKeys(schema) {
|
||||
var props = (schema && schema.properties) || {};
|
||||
var order = schema['x-propertyOrder'] || Object.keys(props);
|
||||
return order.filter(function (k) {
|
||||
var shaped = order.filter(function (k) {
|
||||
return k !== 'layout' && k !== 'modes'
|
||||
&& own(props, k) && ownObj(props, k).properties;
|
||||
});
|
||||
// Core marks the blocks it recognises as styling. Prefer that: a
|
||||
// customization block can also hold a feature of its own -- football
|
||||
// keeps favorite_result_colors there -- and treating one as an element
|
||||
// gives every row that feature's fields as extra columns.
|
||||
var managed = shaped.filter(function (k) {
|
||||
return ownObj(props, k)['x-style-managed'] === true;
|
||||
});
|
||||
// Nothing marked means the schema never went through expansion, so
|
||||
// fall back to the shape test rather than rendering an empty table.
|
||||
return managed.length ? managed : shaped;
|
||||
}
|
||||
|
||||
function titleOf(schema, key) {
|
||||
@@ -357,25 +367,100 @@
|
||||
visible: '3.5rem'
|
||||
};
|
||||
|
||||
/**
|
||||
* Where an element's offsets live in the layout block, or null.
|
||||
*
|
||||
* A hand-written schema does not line up: football styles 'score_text'
|
||||
* but positions it under 'score'. Core resolves that through the same
|
||||
* alias map the renderer reads offsets with and records the answer as
|
||||
* x-layout-key, so the rules exist once, in element_style.py. Without
|
||||
* the annotation both blocks share one key, as the compact declaration
|
||||
* does.
|
||||
*/
|
||||
function layoutKeyFor(schema, key) {
|
||||
var props = (schema && schema.properties) || {};
|
||||
var layoutProps = ownObj(props, 'layout').properties || {};
|
||||
var declared = ownObj(props, key)['x-layout-key'];
|
||||
// Only an object-shaped entry holds offsets. A leaf straight under
|
||||
// layout (a show_logo toggle) is a control of its own, so it is never
|
||||
// claimed here and always gets a position row.
|
||||
if (typeof declared === 'string' && ownObj(layoutProps, declared).properties) {
|
||||
return declared;
|
||||
}
|
||||
return ownObj(layoutProps, key).properties ? key : null;
|
||||
}
|
||||
|
||||
/** One row per styled element, paired with its offsets if it has any. */
|
||||
function styleRows(schema) {
|
||||
return elementKeys(schema).map(function (key) {
|
||||
return { key: key, layoutKey: layoutKeyFor(schema, key) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per positioned thing that has no style block of its own.
|
||||
*
|
||||
* Football positions both logos, timeouts, possession, down-and-distance,
|
||||
* the date, the time and the records, none of which has a font or a
|
||||
* colour. The editor takes the whole layout section over, so anything
|
||||
* not drawn here has no control at all.
|
||||
*/
|
||||
function positionRows(schema) {
|
||||
var layout = ownObj((schema && schema.properties) || {}, 'layout');
|
||||
var layoutProps = layout.properties || {};
|
||||
var claimed = styleRows(schema).map(function (r) { return r.layoutKey; });
|
||||
var order = layout['x-propertyOrder'] || Object.keys(layoutProps);
|
||||
return order.filter(function (lk) {
|
||||
var entry = own(layoutProps, lk);
|
||||
// Object-shaped entries carry offsets; a leaf is itself the
|
||||
// control. Both need a row here, because the editor removes the
|
||||
// fallback's whole layout section.
|
||||
return entry && typeof entry === 'object' && claimed.indexOf(lk) === -1;
|
||||
}).map(function (lk) {
|
||||
return { key: null, layoutKey: lk };
|
||||
});
|
||||
}
|
||||
|
||||
function layoutTitleOf(schema, layoutKey) {
|
||||
var layoutProps = ownObj((schema && schema.properties) || {}, 'layout').properties || {};
|
||||
return ownObj(layoutProps, layoutKey).title || layoutKey.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns a table needs, derived from the schema rather than fixed.
|
||||
*
|
||||
* Two sources: the sub-fields elements declare (font, font_size,
|
||||
* text_color, visible, align) and the sub-fields their layout blocks
|
||||
* declare (x_offset, y_offset, scale).
|
||||
* Three sources: the sub-fields a row's style block declares (font,
|
||||
* font_size, text_color, visible, align), the sub-fields of its layout
|
||||
* entry (x_offset, y_offset, scale), and a layout entry whose own value
|
||||
* *is* the field -- a show_logo toggle straight under layout has no x/y
|
||||
* object underneath, so it gets a column keyed to itself.
|
||||
*
|
||||
* `rows` defaults to every row the schema produces, style and position.
|
||||
*/
|
||||
function columnsFor(schema) {
|
||||
function columnsFor(schema, rows) {
|
||||
var props = schema.properties || {};
|
||||
var layoutProps = ownObj(props, 'layout').properties || {};
|
||||
rows = rows || styleRows(schema).concat(positionRows(schema));
|
||||
// A Map, not an object: the keys are field names out of a schema, so
|
||||
// a field literally named "constructor" is a column like any other
|
||||
// and never touches a prototype.
|
||||
var seen = new Map();
|
||||
elementKeys(schema).forEach(function (key) {
|
||||
Object.keys(ownObj(props, key).properties || {}).forEach(
|
||||
function (f) { seen.set(f, 'element'); });
|
||||
Object.keys(ownObj(layoutProps, key).properties || {}).forEach(
|
||||
function (f) { seen.set(f, 'layout'); });
|
||||
rows.forEach(function (row) {
|
||||
if (row.key) {
|
||||
Object.keys(ownObj(props, row.key).properties || {}).forEach(
|
||||
function (f) { seen.set(f, 'element'); });
|
||||
}
|
||||
if (!row.layoutKey) { return; }
|
||||
var entry = own(layoutProps, row.layoutKey);
|
||||
if (entry && typeof entry === 'object' && entry.properties) {
|
||||
Object.keys(entry.properties).forEach(
|
||||
function (f) { seen.set(f, 'layout'); });
|
||||
} else if (entry && typeof entry === 'object') {
|
||||
// A leaf. Namespaced so an unrelated column sharing its name
|
||||
// -- another entry's "scale" axis, say -- can never shadow it
|
||||
// and leave this row's only control blank.
|
||||
seen.set('layout-leaf:' + row.layoutKey, 'layout-leaf');
|
||||
}
|
||||
});
|
||||
var known = COLUMN_ORDER.filter(function (f) { return seen.get(f); });
|
||||
// Anything the schema declares that this file has never heard of
|
||||
@@ -384,10 +469,12 @@
|
||||
return COLUMN_ORDER.indexOf(f) === -1;
|
||||
}).sort();
|
||||
return known.concat(extra).map(function (f) {
|
||||
var isLeaf = f.indexOf('layout-leaf:') === 0;
|
||||
var fieldKey = isLeaf ? f.slice('layout-leaf:'.length) : f;
|
||||
return {
|
||||
key: f,
|
||||
key: fieldKey,
|
||||
where: seen.get(f),
|
||||
label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ')
|
||||
label: own(COLUMN_LABELS, fieldKey) || fieldKey.replace(/_/g, ' ')
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -421,21 +508,22 @@
|
||||
|
||||
function elementRow(opts) {
|
||||
var schema = opts.schema;
|
||||
var key = opts.key;
|
||||
var key = opts.row.key; // null for a position-only row
|
||||
var layoutKey = opts.row.layoutKey; // null for a style-only row
|
||||
var value = opts.value;
|
||||
var optional = opts.optional;
|
||||
|
||||
var props = ownObj(schema.properties || {}, key).properties || {};
|
||||
var props = key ? (ownObj(schema.properties || {}, key).properties || {}) : {};
|
||||
var layoutProps = ownObj(schema.properties || {}, 'layout').properties || {};
|
||||
var axes = ownObj(layoutProps, key).properties || {};
|
||||
var axes = layoutKey ? (ownObj(layoutProps, layoutKey).properties || {}) : {};
|
||||
|
||||
var row = el('div', {
|
||||
class: 'style-editor-row grid items-center gap-2 py-1',
|
||||
'data-element': key
|
||||
'data-element': key || ('layout.' + layoutKey)
|
||||
});
|
||||
row.appendChild(el('div', {
|
||||
class: 'text-sm text-gray-700 style-editor-label',
|
||||
text: titleOf(schema, key)
|
||||
text: key ? titleOf(schema, key) : layoutTitleOf(schema, layoutKey)
|
||||
}));
|
||||
|
||||
var sizeInput = null;
|
||||
@@ -461,8 +549,12 @@
|
||||
}
|
||||
|
||||
opts.columns.forEach(function (col) {
|
||||
var isLeaf = col.where === 'layout-leaf';
|
||||
var inLayout = col.where === 'layout';
|
||||
var prop = inLayout ? own(axes, col.key) : own(props, col.key);
|
||||
// A leaf column belongs to the one position row named after it;
|
||||
// every other row leaves that cell blank.
|
||||
var prop = isLeaf ? (col.key === layoutKey ? own(layoutProps, layoutKey) : null)
|
||||
: inLayout ? own(axes, col.key) : own(props, col.key);
|
||||
var cell;
|
||||
if (!prop) {
|
||||
// This element does not declare that field; keep the grid
|
||||
@@ -470,13 +562,19 @@
|
||||
row.appendChild(el('span'));
|
||||
return;
|
||||
}
|
||||
var path = inLayout ? ['layout', key, col.key] : [key, col.key];
|
||||
var base = inLayout ? opts.layoutPrefix + '.' + key
|
||||
: opts.prefix + '.' + key;
|
||||
var path = isLeaf ? ['layout', layoutKey]
|
||||
: inLayout ? ['layout', layoutKey, col.key] : [key, col.key];
|
||||
// Posted under the key the schema declares, never the style key:
|
||||
// a plugin's own offset reader looks up layout.score, so a value
|
||||
// saved as layout.score_text would be kept and never drawn. A
|
||||
// leaf is the field itself, so its name has no sub-field suffix --
|
||||
// the same name the generic renderer would have posted.
|
||||
var base = (isLeaf || inLayout) ? opts.layoutPrefix + '.' + layoutKey
|
||||
: opts.prefix + '.' + key;
|
||||
var node = control({
|
||||
key: col.key,
|
||||
prop: prop,
|
||||
name: base + '.' + col.key,
|
||||
name: isLeaf ? base : base + '.' + col.key,
|
||||
current: effective(value, path, prop, optional),
|
||||
optional: optional,
|
||||
fonts: opts.fonts,
|
||||
@@ -500,11 +598,11 @@
|
||||
return row;
|
||||
}
|
||||
|
||||
function header(columns) {
|
||||
function header(columns, firstLabel) {
|
||||
var row = el('div', {
|
||||
class: 'style-editor-row style-editor-head grid gap-2 pb-1 mb-1 border-b border-gray-300'
|
||||
});
|
||||
['Element'].concat(columns.map(function (c) { return c.label; }))
|
||||
[firstLabel || 'Element'].concat(columns.map(function (c) { return c.label; }))
|
||||
.forEach(function (label) {
|
||||
row.appendChild(el('div', {
|
||||
class: 'text-xs font-semibold text-gray-500 uppercase',
|
||||
@@ -516,7 +614,9 @@
|
||||
|
||||
function table(opts) {
|
||||
var wrap = el('div', { class: 'style-editor-table' });
|
||||
var columns = columnsFor(opts.schema);
|
||||
// Columns per table, so the positions table does not inherit a Font
|
||||
// column and the style table does not grow a "home x offset" one.
|
||||
var columns = columnsFor(opts.schema, opts.rows);
|
||||
// Sized here rather than in CSS: the column count depends on what
|
||||
// the plugin declared.
|
||||
wrap.style.gridTemplateColumns = '';
|
||||
@@ -524,11 +624,11 @@
|
||||
'minmax(7rem, 1.4fr) ' + columns.map(function (c) {
|
||||
return COLUMN_WIDTHS[c.key] || '5rem';
|
||||
}).join(' '));
|
||||
wrap.appendChild(header(columns));
|
||||
elementKeys(opts.schema).forEach(function (key) {
|
||||
wrap.appendChild(header(columns, opts.firstLabel));
|
||||
opts.rows.forEach(function (row) {
|
||||
wrap.appendChild(elementRow({
|
||||
schema: opts.schema,
|
||||
key: key,
|
||||
row: row,
|
||||
columns: columns,
|
||||
prefix: opts.prefix,
|
||||
layoutPrefix: opts.prefix + '.layout',
|
||||
@@ -540,6 +640,29 @@
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** One panel's tables: the styled elements, then anything only positioned. */
|
||||
function panelBody(opts) {
|
||||
var body = el('div');
|
||||
var shared = {
|
||||
schema: opts.schema, prefix: opts.prefix, value: opts.value,
|
||||
fonts: opts.fonts, optional: opts.optional
|
||||
};
|
||||
var styled = styleRows(opts.schema);
|
||||
if (styled.length) {
|
||||
body.appendChild(table(Object.assign({ rows: styled }, shared)));
|
||||
}
|
||||
var positioned = positionRows(opts.schema);
|
||||
if (positioned.length) {
|
||||
body.appendChild(el('div', {
|
||||
class: 'text-xs font-semibold text-gray-500 uppercase mt-4 mb-1 style-editor-subhead',
|
||||
text: 'Other positions'
|
||||
}));
|
||||
body.appendChild(table(Object.assign(
|
||||
{ rows: positioned, firstLabel: 'Item' }, shared)));
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// ---- widget ----------------------------------------------------------
|
||||
|
||||
window.LEDMatrixWidgets.register('style-editor', {
|
||||
@@ -555,6 +678,20 @@
|
||||
var root = el('div', { class: 'style-editor' });
|
||||
container.appendChild(root);
|
||||
|
||||
// Published synchronously, because the host reads it the moment
|
||||
// this returns while the panels below wait on the font catalog.
|
||||
// layout and modes count as ours: their fields appear as columns
|
||||
// in these rows, so leaving them to the generic renderer would
|
||||
// post every offset twice from two different controls.
|
||||
var owned = elementKeys(schema);
|
||||
if (ownObj(schema.properties || {}, 'layout').properties) {
|
||||
owned = owned.concat(['layout']);
|
||||
}
|
||||
if (ownObj(schema.properties || {}, 'modes').properties) {
|
||||
owned = owned.concat(['modes']);
|
||||
}
|
||||
container.dataset.ownedKeys = owned.join(',');
|
||||
|
||||
loadFonts().then(function (fonts) {
|
||||
var modeProps = ownObj(schema.properties || {}, 'modes').properties || {};
|
||||
var modes = Object.keys(modeProps);
|
||||
@@ -596,7 +733,7 @@
|
||||
tabs.appendChild(b);
|
||||
}
|
||||
|
||||
panel('__base__', table({
|
||||
panel('__base__', panelBody({
|
||||
schema: schema, prefix: base, value: current,
|
||||
fonts: fonts, optional: false
|
||||
}));
|
||||
@@ -610,7 +747,7 @@
|
||||
class: 'text-xs text-gray-500 mb-2',
|
||||
text: 'Anything left blank follows the "All modes" tab.'
|
||||
}));
|
||||
node.appendChild(table({
|
||||
node.appendChild(panelBody({
|
||||
schema: modeSchema,
|
||||
prefix: base + '.modes.' + mode,
|
||||
value: modeValue,
|
||||
|
||||
@@ -121,8 +121,8 @@
|
||||
id="${fieldId}_clear"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('text-input').onClear('${fieldId}')"
|
||||
class="inline-flex items-center px-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
||||
title="Clear">
|
||||
<i class="fas fa-times"></i>
|
||||
title="Clear" aria-label="Clear">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')"
|
||||
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<i class="fas fa-clock text-gray-400"></i>
|
||||
<i class="fas fa-clock text-gray-400" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -101,8 +101,8 @@
|
||||
id="${fieldId}_clear"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('time-picker').onClear('${fieldId}')"
|
||||
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
||||
title="Clear">
|
||||
<i class="fas fa-times"></i>
|
||||
title="Clear" aria-label="Clear time">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user