mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-20 05:29:13 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7603728e5a | ||
|
|
6c23994b2f | ||
|
|
f12d11334a | ||
|
|
be7a7b7baf | ||
|
|
406e68fba9 | ||
|
|
ea54e56bed | ||
|
|
2f42d179f6 | ||
|
|
37fc1b56b5 | ||
|
|
acc55ef119 | ||
|
|
1a0864e5d4 | ||
|
|
732c7d1a30 | ||
|
|
d42593e7ce | ||
|
|
f0bef7784c | ||
|
|
986f74e38b | ||
|
|
e450a6dfb6 | ||
|
|
5133643600 | ||
|
|
5929190e36 | ||
|
|
79ba93f5a6 | ||
|
|
e499efb1f0 | ||
|
|
cd7e16e58e | ||
|
|
47e3021fc3 | ||
|
|
e319540c6e |
@@ -49,7 +49,3 @@ config/backups/
|
|||||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||||
/starlark-apps/
|
/starlark-apps/
|
||||||
skin_renders/
|
skin_renders/
|
||||||
|
|
||||||
# JS test deps (test/js)
|
|
||||||
node_modules/
|
|
||||||
package-lock.json
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ All endpoints return JSON responses with a standard format:
|
|||||||
|
|
||||||
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
|
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||||
> SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the
|
> SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the
|
||||||
> Flask app at `app.py:799-809`. There are 111 routes total — see
|
> Flask app at `app.py:799-809`. There are 94 routes total — see
|
||||||
> `web_interface/blueprints/api_v3.py` for the canonical list.
|
> `web_interface/blueprints/api_v3.py` for the canonical list.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -223,56 +223,6 @@ Get the current display state and preview image.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### List Display Modes
|
|
||||||
|
|
||||||
**GET** `/api/v3/display/modes`
|
|
||||||
|
|
||||||
Every display mode that can be requested on-demand, with the plugin that owns
|
|
||||||
it. This is the list the force-display dialog offers.
|
|
||||||
|
|
||||||
Send the reported `plugin_id` alongside `mode` when starting an on-demand
|
|
||||||
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
|
|
||||||
`plugin_id` is omitted, and that lookup only sees modes declared in a static
|
|
||||||
manifest — a plugin whose modes are generated (each installed Starlark app is
|
|
||||||
one) returns 404 there.
|
|
||||||
|
|
||||||
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
|
||||||
opens the dashboard still gets the full list.
|
|
||||||
|
|
||||||
**Query Parameters**:
|
|
||||||
- `include_disabled` (optional): `1` to include modes belonging to disabled
|
|
||||||
plugins. They are still valid on-demand targets — the controller enables the
|
|
||||||
plugin for the duration of the request — and are reported with
|
|
||||||
`"enabled": false`.
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"data": {
|
|
||||||
"modes": [
|
|
||||||
{
|
|
||||||
"mode": "nfl_live",
|
|
||||||
"plugin_id": "football-scoreboard",
|
|
||||||
"plugin_name": "Football Scoreboard",
|
|
||||||
"name": "nfl_live",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mode": "clock-simple",
|
|
||||||
"plugin_id": "clock-simple",
|
|
||||||
"plugin_name": "Simple Clock",
|
|
||||||
"name": "Simple Clock",
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`name` is a label for a dropdown: a single-mode plugin's own name, or the raw
|
|
||||||
mode string for a multi-mode plugin, since there is no per-mode name anywhere.
|
|
||||||
|
|
||||||
### On-Demand Display Status
|
### On-Demand Display Status
|
||||||
|
|
||||||
**GET** `/api/v3/display/on-demand/status`
|
**GET** `/api/v3/display/on-demand/status`
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
# Scroll Performance
|
|
||||||
|
|
||||||
How scrolling is paced on this hardware, what was wrong with it, and how to
|
|
||||||
configure a plugin so its marquee is smooth.
|
|
||||||
|
|
||||||
Measured on a Raspberry Pi 4 driving a 2×128×64 chain (256×64 logical) at
|
|
||||||
`limit_refresh_rate_hz: 100`. Numbers below come from that panel.
|
|
||||||
|
|
||||||
| | before | after |
|
|
||||||
|---|---|---|
|
|
||||||
| scroll frame rate | 44–46 fps | **100 fps, locked** |
|
|
||||||
| frames ≥ 45 ms | 14–17% | none observed |
|
|
||||||
| dominant frame time | 20 ms | **10 ms** |
|
|
||||||
| disk cache write (~1 MB) | 14.8 ms | **5.4 ms** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The one rule that matters
|
|
||||||
|
|
||||||
**Motion is smooth when the strip advances a whole number of pixels per panel
|
|
||||||
refresh.**
|
|
||||||
|
|
||||||
Advancing one pixel per refresh on a 100 Hz panel gives 100 px/s. Slower crisp
|
|
||||||
speeds come from holding each frame for several refreshes -- 50 px/s is one
|
|
||||||
pixel every second refresh -- which is covered under *Choosing a speed* below.
|
|
||||||
A speed that lands on no such combination has to do one of two bad things:
|
|
||||||
|
|
||||||
- **blend** two adjacent columns to render a half-step — on pixel-font text
|
|
||||||
this alternates crisp and smeared frames and reads as shimmer, or as the
|
|
||||||
text jumping a pixel ahead of itself;
|
|
||||||
- **repeat** a frame — the strip stands still, then jumps, which reads as
|
|
||||||
judder.
|
|
||||||
|
|
||||||
Neither is tunable away. Pick a speed that divides evenly.
|
|
||||||
|
|
||||||
`src.common.scroll_config` solves this for you: `configure()` snaps a requested
|
|
||||||
speed to the nearest one the panel can actually show in whole pixels, and
|
|
||||||
`scripts/scroll_speeds.py` prints the full ladder for your hardware.
|
|
||||||
|
|
||||||
## Choosing a speed
|
|
||||||
|
|
||||||
The crisp speeds are not a fixed list -- they depend on how fast *your* panel
|
|
||||||
refreshes, which depends on its size, `pwm_bits`, `gpio_slowdown` and the Pi
|
|
||||||
model. A Pi Zero driving a long chain has a completely different set of good
|
|
||||||
speeds from a Pi 4 driving a short one.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# what can this panel do? (reads your configured refresh rate)
|
|
||||||
python3 scripts/scroll_speeds.py
|
|
||||||
|
|
||||||
# what does it ACTUALLY manage, rather than what is configured?
|
|
||||||
sudo systemctl stop ledmatrix
|
|
||||||
sudo python3 scripts/scroll_speeds.py --measure
|
|
||||||
sudo systemctl start ledmatrix
|
|
||||||
|
|
||||||
# highlight the closest option to the speed you want
|
|
||||||
python3 scripts/scroll_speeds.py --want 45
|
|
||||||
|
|
||||||
# try one on the panel
|
|
||||||
sudo systemctl stop ledmatrix
|
|
||||||
sudo python3 scripts/scroll_speeds.py --demo 50
|
|
||||||
sudo systemctl start ledmatrix
|
|
||||||
```
|
|
||||||
|
|
||||||
Sample ladder for a 100 Hz panel:
|
|
||||||
|
|
||||||
```
|
|
||||||
20.0 px/s (1px every 5 refreshes = 20.0 fps, slightly stepped)
|
|
||||||
25.0 px/s (1px every 4 refreshes = 25.0 fps, slightly stepped)
|
|
||||||
33.3 px/s (1px every 3 refreshes = 33.3 fps, smooth)
|
|
||||||
50.0 px/s (1px every 2 refreshes = 50.0 fps, smooth)
|
|
||||||
66.7 px/s (2px every 3 refreshes = 33.3 fps, smooth)
|
|
||||||
100.0 px/s (1px every 1 refresh = 100.0 fps, smooth)
|
|
||||||
```
|
|
||||||
|
|
||||||
### How a slow speed stays crisp
|
|
||||||
|
|
||||||
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
|
|
||||||
refreshes. **The panel keeps refreshing at its full rate either way**, so
|
|
||||||
holding a frame costs nothing in flicker -- it only changes how often a *new*
|
|
||||||
image is presented. That is what allows 50 px/s to be one whole pixel every
|
|
||||||
second refresh, instead of half a pixel every refresh (which has no good
|
|
||||||
rendering, only a choice between blur and judder).
|
|
||||||
|
|
||||||
`scroll_config.configure()` snaps the requested speed to the nearest entry on
|
|
||||||
the ladder and reports the hold that speed needs. It does **not** apply the
|
|
||||||
hold: the hold belongs to a scroll, not to a plugin's lifetime, and plugins
|
|
||||||
share one display manager -- one set at construction is reset the moment any
|
|
||||||
other plugin finishes scrolling. Apply it yourself when the scroll starts:
|
|
||||||
|
|
||||||
```python
|
|
||||||
settings = scroll_config.configure(
|
|
||||||
self.scroll_helper,
|
|
||||||
plugin_config=self.config,
|
|
||||||
global_config=self.global_config,
|
|
||||||
display_manager=self.display_manager, # supplies the panel refresh rate
|
|
||||||
)
|
|
||||||
|
|
||||||
# ...then, each time this plugin begins scrolling:
|
|
||||||
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
|
|
||||||
```
|
|
||||||
|
|
||||||
Passing `display_manager` only lets `configure` read the true refresh rate from
|
|
||||||
`display.hardware`, which a plugin config cannot see. Skipping the
|
|
||||||
`set_scrolling_state` call is the mistake that matters: the speed still
|
|
||||||
resolves, but the panel keeps presenting a new frame every refresh, so a slow
|
|
||||||
snapped speed falls back to fractional pixels. Pass `snap_to_crisp=False` to
|
|
||||||
keep an exact requested speed and accept the artefacts.
|
|
||||||
|
|
||||||
Speeds slower than about 20 px/s are stepped no matter what, because a 1-pixel
|
|
||||||
advance at 20 fps is simply a coarse increment. That is the pixel pitch, not a
|
|
||||||
software limit; the only way to move in smaller increments is sub-pixel
|
|
||||||
blending, which this display does not tolerate (see above).
|
|
||||||
|
|
||||||
## Configuring a plugin
|
|
||||||
|
|
||||||
Use the shared resolver rather than reading config keys yourself:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from src.common import scroll_config
|
|
||||||
|
|
||||||
settings = scroll_config.configure(
|
|
||||||
self.scroll_helper,
|
|
||||||
plugin_config=self.config,
|
|
||||||
global_config=self.global_config,
|
|
||||||
refresh_hz=scroll_config.refresh_hz_from_config(self.global_config),
|
|
||||||
plugin_logger=self.logger,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
It resolves every config shape in one place, applies the speed, and returns
|
|
||||||
what it did. Precedence, highest first:
|
|
||||||
|
|
||||||
1. `display_options.scroll_speed` + `scroll_delay` — **the recommended form**
|
|
||||||
2. `display.scroll_speed` + `scroll_delay` — deprecated shape
|
|
||||||
3. `scroll_speed` + `scroll_delay` at the root — legacy flat
|
|
||||||
4. `scroll_pixels_per_second` — deprecated
|
|
||||||
5. the global `display` block
|
|
||||||
6. the built-in default (100 px/s)
|
|
||||||
|
|
||||||
`scroll_speed` is pixels per frame and `scroll_delay` is the frame period in
|
|
||||||
seconds, so the pair means `scroll_speed / scroll_delay` px/s. The recommended
|
|
||||||
config for a 100 Hz panel:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"display_options": { "scroll_speed": 1.0, "scroll_delay": 0.01 }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why the deprecated key ranks below the explicit pair
|
|
||||||
|
|
||||||
Because some plugins give `scroll_pixels_per_second` a **schema default**, and
|
|
||||||
schema defaults are merged into plugin config. Ranking it above the pair means
|
|
||||||
it is always present and always wins, so the documented settings become
|
|
||||||
unreachable. That is a real, shipped bug — see
|
|
||||||
[ledmatrix-plugins#408](https://github.com/ChuckBuilds/ledmatrix-plugins/issues/408).
|
|
||||||
|
|
||||||
If you are writing a plugin: do not give a deprecated key a schema default.
|
|
||||||
|
|
||||||
## What was actually wrong
|
|
||||||
|
|
||||||
Four independent faults, each found by measurement.
|
|
||||||
|
|
||||||
### 1. The frame loop slept on top of a wait it had already done
|
|
||||||
|
|
||||||
`display_controller.py` ran the high-FPS loop as `render → SwapOnVSync (blocks
|
|
||||||
to the panel's refresh) → time.sleep(0.008) → plugin ticks`. The sleep was
|
|
||||||
unconditional and added to a wait that had already happened. Render work
|
|
||||||
measured ~4 ms, so each iteration cost ~12 ms against a 10 ms refresh grid —
|
|
||||||
every swap missed a refresh and landed on the next one. The loop settled at
|
|
||||||
exactly 50 fps while asking for 125, with no headroom, so ~14% of frames
|
|
||||||
slipped a further refresh.
|
|
||||||
|
|
||||||
Now the loop sleeps only the remainder of the frame budget, with a 1 ms floor
|
|
||||||
so plugin threads still get the GIL.
|
|
||||||
|
|
||||||
### 2. `SwapOnVSync` held the GIL while blocking
|
|
||||||
|
|
||||||
The rgbmatrix binding declares it without `nogil` (unlike `SetPixel`, `Clear`
|
|
||||||
and `Fill` immediately above it in `cppinc.pxd`), so the render thread held the
|
|
||||||
GIL for the entire vsync wait — most of every frame. Background threads were
|
|
||||||
starved into long uninterruptible bursts; a 1.5 MB API response costs ~17 ms to
|
|
||||||
parse and ~18 ms to re-encode for the cache, and `json.raw_decode` cannot be
|
|
||||||
preempted mid-document. Those bursts are what the render loop then waited on.
|
|
||||||
|
|
||||||
Fixed by rebuilding the binding: `scripts/build_rgbmatrix_nogil.sh`.
|
|
||||||
|
|
||||||
### 3. Sub-pixel blending was wrong for this display
|
|
||||||
|
|
||||||
Enabling it made things worse, not better — see the rule at the top. It is off
|
|
||||||
by default and only Vegas mode opts in via `set_sub_pixel_scrolling(True)`.
|
|
||||||
|
|
||||||
### 4. Frame-based stepping raced the vsync clock
|
|
||||||
|
|
||||||
Frame-based mode gated motion on a wall clock at `1/scroll_delay` steps per
|
|
||||||
second. Plugins set `scroll_delay` to the frame period, which puts that
|
|
||||||
comparison exactly on its own threshold: a frame arriving a hair early moved
|
|
||||||
zero pixels and rendered an identical frame, which dirty-tracking skipped, so
|
|
||||||
it returned in ~2 ms and the beat repeated. No `scroll_delay` value tunes this
|
|
||||||
out — a shorter delay just trades stalled frames for periodic double-steps.
|
|
||||||
|
|
||||||
`ScrollHelper` now accumulates elapsed time in both modes at the same
|
|
||||||
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`.
|
|
||||||
|
|
||||||
Look at the **distribution** instead:
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
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 |
|
|
||||||
|
|
||||||
Then confirm what the plugin actually loaded — config edits do not always reach
|
|
||||||
the running code:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
journalctl -u ledmatrix --since "-5min" --no-pager | grep -iE "px/s|px/frame"
|
|
||||||
```
|
|
||||||
|
|
||||||
If a plugin logs its scroll config **twice** with different modes, the second
|
|
||||||
line is what is running.
|
|
||||||
|
|
||||||
## Rebuilding the binding
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/build_rgbmatrix_nogil.sh # build into a scratch dir
|
|
||||||
sudo bash scripts/build_rgbmatrix_nogil.sh --install
|
|
||||||
sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
|
||||||
```
|
|
||||||
|
|
||||||
The build never touches the installed module. `--install` backs up the original
|
|
||||||
to `~/rgbmatrix-core.so.ORIGINAL` first, and rolls back automatically if the
|
|
||||||
service does not come back healthy. Requires `build-essential`; Cython is
|
|
||||||
installed into a cached venv under `~/.cache/ledmatrix-cython`.
|
|
||||||
|
|
||||||
Re-run it after upgrading `rpi-rgb-led-matrix`, since a library upgrade
|
|
||||||
replaces the patched binding.
|
|
||||||
|
|
||||||
## Faster JSON
|
|
||||||
|
|
||||||
`src/cache/disk_cache.py` uses `orjson` when it is importable and falls back to
|
|
||||||
the stdlib otherwise, so it is optional:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pip3 install --break-system-packages orjson
|
|
||||||
```
|
|
||||||
|
|
||||||
Encoding is where it pays — about 7× on this hardware. Decoding gains far less
|
|
||||||
(~1.3× on large payloads) because the cost there is building Python objects,
|
|
||||||
not scanning text. That is also why moving parsing to a subprocess does not
|
|
||||||
help: `pickle.loads` of the same payload costs 8.1 ms against `json.loads` at
|
|
||||||
10.9 ms, so the work just moves rather than disappearing.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
bridge_config.json
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# Home Assistant MQTT Bridge
|
|
||||||
|
|
||||||
Control the matrix from Home Assistant: force any plugin or mode on demand,
|
|
||||||
turn the display on and off, and set brightness — as real HA entities, not
|
|
||||||
hand-written `mqtt.publish` calls.
|
|
||||||
|
|
||||||
The bridge owns no display logic. It subscribes to one command topic and
|
|
||||||
turns each message into a call against the same `api_v3` routes the web UI
|
|
||||||
uses, so behaviour lives in one place. It talks to the API over HTTP only —
|
|
||||||
no filesystem access — so it can run on the Pi or anywhere that can reach
|
|
||||||
the web interface.
|
|
||||||
|
|
||||||
## What appears in Home Assistant
|
|
||||||
|
|
||||||
On connect the bridge publishes [MQTT Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery)
|
|
||||||
config, so the matrix shows up under **Settings → Devices & Services → MQTT**
|
|
||||||
with no YAML:
|
|
||||||
|
|
||||||
| Entity | Does |
|
|
||||||
|---|---|
|
|
||||||
| `select.ledmatrix_display_mode` | Every mode across enabled plugins. Choosing one force-displays it. |
|
|
||||||
| `button.ledmatrix_stop_display` | Back to normal rotation. |
|
|
||||||
| `switch.ledmatrix_power` | Starts/stops the display service. |
|
|
||||||
| `number.ledmatrix_brightness` | 0–100. |
|
|
||||||
|
|
||||||
State is read back from the API every 30 seconds, so the entities also track
|
|
||||||
changes made from the web UI or an on-demand window expiring on its own.
|
|
||||||
All four share an availability topic that is the bridge's MQTT last will:
|
|
||||||
if the bridge dies, HA greys the controls out rather than leaving them
|
|
||||||
looking live but inert.
|
|
||||||
|
|
||||||
## Raw commands
|
|
||||||
|
|
||||||
For anything the entities do not cover, publish JSON to the command topic
|
|
||||||
(`ledmatrix/command` by default):
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// Force a mode. plugin_id is optional — the bridge fills it in from
|
|
||||||
// /api/v3/display/modes.
|
|
||||||
{"action": "display", "mode": "nfl_live"}
|
|
||||||
|
|
||||||
// duration is seconds; pinned holds this one mode instead of rotating
|
|
||||||
// through every mode the plugin owns. Pin Starlark apps, where each mode
|
|
||||||
// is an unrelated widget; leave a sports plugin unpinned so live/recent/
|
|
||||||
// upcoming still cycle.
|
|
||||||
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
|
|
||||||
"duration": 300, "pinned": true}
|
|
||||||
|
|
||||||
{"action": "stop_display"}
|
|
||||||
{"action": "power", "state": "on"}
|
|
||||||
{"action": "brightness", "value": 75}
|
|
||||||
|
|
||||||
// Re-read the mode list and re-publish discovery, after installing a plugin
|
|
||||||
{"action": "refresh"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every command publishes its outcome to `<command_topic>/status`, and current
|
|
||||||
state to `<command_topic>/state`.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- A LEDMatrix install with its web interface reachable (default `http://localhost:5000`)
|
|
||||||
- An MQTT broker that Home Assistant is also connected to
|
|
||||||
- Python 3 with `paho-mqtt` 2.x and `requests`
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./scripts/install/install_mqtt_bridge.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
That copies `bridge_config.example.json` to `bridge_config.json` on first
|
|
||||||
run, installs the dependencies, and enables `ledmatrix-mqtt-bridge.service`.
|
|
||||||
Edit the config with your broker details and re-run it.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mqtt_host": "192.168.1.10",
|
|
||||||
"mqtt_port": 8883,
|
|
||||||
"mqtt_username": "ledmatrix",
|
|
||||||
"mqtt_password": null,
|
|
||||||
"mqtt_topic": "ledmatrix/command",
|
|
||||||
"mqtt_tls": true,
|
|
||||||
"ledmatrix_api_base": "http://localhost:5000"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**TLS is on by default.** Without it the broker password and every display
|
|
||||||
command cross the network in cleartext. If your broker only listens on plain
|
|
||||||
1883 — which the Mosquitto add-on does out of the box — set `"mqtt_tls": false`
|
|
||||||
and `"mqtt_port": 1883`. The bridge logs a warning at startup when a password
|
|
||||||
is configured without TLS.
|
|
||||||
|
|
||||||
`bridge_config.json` is gitignored. Any key can also be supplied through the
|
|
||||||
environment as `LEDMATRIX_MQTT_<KEY>` (`LEDMATRIX_MQTT_MQTT_PASSWORD`, say),
|
|
||||||
which keeps a broker password out of a file on disk — put it in a systemd
|
|
||||||
drop-in with `Environment=` or `EnvironmentFile=` instead.
|
|
||||||
|
|
||||||
Set `mqtt_tls: true` for a broker with TLS. `mqtt_tls_insecure` skips
|
|
||||||
certificate verification and exists only for a self-signed broker on a
|
|
||||||
trusted LAN; it logs a warning when used.
|
|
||||||
|
|
||||||
To run it in the foreground while setting things up:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py --config integrations/mqtt_bridge/bridge_config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Only one thing can be on-demand at a time — the same constraint the web UI has.
|
|
||||||
- Forcing a mode restarts the display service, so the panel blanks for a moment.
|
|
||||||
- The mode list comes from `/api/v3/display/modes`, which triggers plugin
|
|
||||||
discovery itself. Discovery is lazy and normally happens because somebody
|
|
||||||
opened the dashboard; without that endpoint a bridge that never does would
|
|
||||||
see an empty list.
|
|
||||||
- Brightness writes `display.hardware.brightness` through `/api/v3/config/main`.
|
|
||||||
The display service picks it up on its next restart, not instantly.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"mqtt_host": "192.168.1.10",
|
|
||||||
"mqtt_port": 8883,
|
|
||||||
"mqtt_username": "ledmatrix",
|
|
||||||
"mqtt_password": null,
|
|
||||||
"mqtt_client_id": "ledmatrix-mqtt-bridge",
|
|
||||||
"mqtt_topic": "ledmatrix/command",
|
|
||||||
"mqtt_tls": true,
|
|
||||||
"ledmatrix_api_base": "http://localhost:5000",
|
|
||||||
"request_timeout": 15,
|
|
||||||
"on_demand_duration": null,
|
|
||||||
"log_level": "INFO"
|
|
||||||
}
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Control a LEDMatrix display from Home Assistant over MQTT.
|
|
||||||
|
|
||||||
The bridge owns no display logic. It subscribes to one command topic and
|
|
||||||
turns each message into a call against the same api_v3 routes the web UI
|
|
||||||
uses, so behaviour stays in one place and this stays a translation layer.
|
|
||||||
|
|
||||||
On connect it publishes Home Assistant MQTT Discovery config, so a matrix
|
|
||||||
appears in HA as real entities rather than something you drive with
|
|
||||||
`mqtt.publish` by hand:
|
|
||||||
|
|
||||||
select.ledmatrix_display_mode every mode across enabled plugins;
|
|
||||||
choosing one force-displays it
|
|
||||||
button.ledmatrix_stop_display back to normal rotation
|
|
||||||
switch.ledmatrix_power the display service, on or off
|
|
||||||
number.ledmatrix_brightness 0-100
|
|
||||||
|
|
||||||
Anything the entities do not cover is still reachable by publishing JSON
|
|
||||||
to the command topic:
|
|
||||||
|
|
||||||
{"action": "display", "mode": "nfl_live"}
|
|
||||||
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
|
|
||||||
"duration": 300, "pinned": true}
|
|
||||||
{"action": "stop_display"}
|
|
||||||
{"action": "power", "state": "on" | "off"}
|
|
||||||
{"action": "brightness", "value": 75}
|
|
||||||
{"action": "refresh"} re-publish discovery after installing a plugin
|
|
||||||
|
|
||||||
Every command publishes its result to <command_topic>/status.
|
|
||||||
|
|
||||||
Run it with `python3 ledmatrix_mqtt_bridge.py [--config PATH]`, or install
|
|
||||||
ledmatrix-mqtt-bridge.service.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
logger = logging.getLogger("ledmatrix-mqtt-bridge")
|
|
||||||
|
|
||||||
DISCOVERY_PREFIX = "homeassistant"
|
|
||||||
DEVICE_ID = "ledmatrix"
|
|
||||||
DEVICE_INFO = {
|
|
||||||
"identifiers": [DEVICE_ID],
|
|
||||||
"name": "LEDMatrix",
|
|
||||||
"manufacturer": "ChuckBuilds",
|
|
||||||
"model": "LEDMatrix Display",
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULTS = {
|
|
||||||
"mqtt_host": "localhost",
|
|
||||||
"mqtt_port": 1883,
|
|
||||||
"mqtt_username": None,
|
|
||||||
"mqtt_password": None, # nosec B105 - "no password configured", not a credential
|
|
||||||
"mqtt_client_id": "ledmatrix-mqtt-bridge",
|
|
||||||
"mqtt_topic": "ledmatrix/command",
|
|
||||||
"mqtt_tls": False,
|
|
||||||
"mqtt_tls_insecure": False,
|
|
||||||
"ledmatrix_api_base": "http://localhost:5000",
|
|
||||||
"request_timeout": 15,
|
|
||||||
"on_demand_duration": None,
|
|
||||||
"log_level": "INFO",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
|
||||||
"""The bridge cannot start with the configuration it was given."""
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: str) -> Dict[str, Any]:
|
|
||||||
"""Read bridge_config.json, overlaid on DEFAULTS.
|
|
||||||
|
|
||||||
Every value may also come from the environment as LEDMATRIX_MQTT_<KEY>,
|
|
||||||
which is how a password stays out of a file that has to be world-readable
|
|
||||||
for the service user.
|
|
||||||
"""
|
|
||||||
config = dict(DEFAULTS)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
with open(path, encoding="utf-8") as handle:
|
|
||||||
try:
|
|
||||||
loaded = json.load(handle)
|
|
||||||
except json.JSONDecodeError as err:
|
|
||||||
raise ConfigError(f"{path} is not valid JSON: {err}") from err
|
|
||||||
if not isinstance(loaded, dict):
|
|
||||||
raise ConfigError(f"{path} must contain a JSON object")
|
|
||||||
config.update(loaded)
|
|
||||||
else:
|
|
||||||
logger.warning("No config file at %s - using defaults and environment", path)
|
|
||||||
|
|
||||||
for key in DEFAULTS:
|
|
||||||
env_value = os.environ.get(f"LEDMATRIX_MQTT_{key.upper()}")
|
|
||||||
if env_value is not None:
|
|
||||||
config[key] = env_value
|
|
||||||
|
|
||||||
for key in ("mqtt_port", "request_timeout"):
|
|
||||||
try:
|
|
||||||
config[key] = int(config[key])
|
|
||||||
except (TypeError, ValueError) as err:
|
|
||||||
raise ConfigError(f"{key} must be a whole number, got {config[key]!r}") from err
|
|
||||||
for key in ("mqtt_tls", "mqtt_tls_insecure"):
|
|
||||||
config[key] = str(config[key]).lower() in ("1", "true", "yes", "on")
|
|
||||||
|
|
||||||
if config.get("mqtt_password") == "REPLACE_WITH_YOUR_ACTUAL_MQTT_PASSWORD":
|
|
||||||
raise ConfigError(
|
|
||||||
"mqtt_password is still the example placeholder - set a real password, "
|
|
||||||
"or remove the key if your broker allows anonymous connections")
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
class LEDMatrixClient:
|
|
||||||
"""The api_v3 calls the bridge needs, and nothing else.
|
|
||||||
|
|
||||||
Everything goes through the HTTP API rather than the filesystem, so the
|
|
||||||
bridge does not have to live on the Pi, does not need read access to
|
|
||||||
config.json, and cannot drift from the web UI's own behaviour.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, api_base: str, timeout: int = 15,
|
|
||||||
session: Optional[requests.Session] = None):
|
|
||||||
self.api_base = api_base.rstrip("/")
|
|
||||||
self.timeout = timeout
|
|
||||||
self.session = session or requests.Session()
|
|
||||||
|
|
||||||
def _call(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
|
||||||
url = f"{self.api_base}/api/v3{path}"
|
|
||||||
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
|
|
||||||
try:
|
|
||||||
body = response.json()
|
|
||||||
except ValueError:
|
|
||||||
body = {}
|
|
||||||
if response.status_code >= 400 or body.get("status") == "error":
|
|
||||||
message = body.get("message") or f"HTTP {response.status_code}"
|
|
||||||
raise RuntimeError(f"{method} {path} failed: {message}")
|
|
||||||
return body.get("data", body)
|
|
||||||
|
|
||||||
def list_modes(self) -> List[Dict[str, Any]]:
|
|
||||||
"""Every display mode that can be force-displayed, newest discovery.
|
|
||||||
|
|
||||||
/display/modes triggers plugin discovery itself, which matters because
|
|
||||||
discovery is lazy: a bridge that never opens the dashboard would
|
|
||||||
otherwise see nothing at all.
|
|
||||||
"""
|
|
||||||
return self._call("GET", "/display/modes").get("modes", [])
|
|
||||||
|
|
||||||
def display_status(self) -> Dict[str, Any]:
|
|
||||||
return self._call("GET", "/display/on-demand/status")
|
|
||||||
|
|
||||||
def start_on_demand(self, mode: str, plugin_id: Optional[str] = None,
|
|
||||||
duration: Optional[int] = None, pinned: bool = False) -> Dict[str, Any]:
|
|
||||||
payload: Dict[str, Any] = {"mode": mode, "pinned": pinned}
|
|
||||||
if plugin_id:
|
|
||||||
# find_plugin_for_mode only sees modes declared in a static
|
|
||||||
# manifest, so a plugin whose modes are generated -- each installed
|
|
||||||
# Starlark app is one -- 404s when plugin_id is omitted. Sending it
|
|
||||||
# skips that lookup. /display/modes reports it for every mode.
|
|
||||||
payload["plugin_id"] = plugin_id
|
|
||||||
if duration:
|
|
||||||
payload["duration"] = int(duration)
|
|
||||||
return self._call("POST", "/display/on-demand/start", json=payload)
|
|
||||||
|
|
||||||
def stop_on_demand(self) -> Dict[str, Any]:
|
|
||||||
return self._call("POST", "/display/on-demand/stop", json={})
|
|
||||||
|
|
||||||
def set_power(self, on: bool) -> Dict[str, Any]:
|
|
||||||
action = "start_display" if on else "stop_display"
|
|
||||||
return self._call("POST", "/system/action", json={"action": action})
|
|
||||||
|
|
||||||
def get_brightness(self) -> Optional[int]:
|
|
||||||
config = self._call("GET", "/config/main")
|
|
||||||
value = config.get("display", {}).get("hardware", {}).get("brightness")
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def set_brightness(self, value: int) -> Dict[str, Any]:
|
|
||||||
return self._call("POST", "/config/main", json={"brightness": int(value)})
|
|
||||||
|
|
||||||
|
|
||||||
class CommandHandler:
|
|
||||||
"""Turns one decoded MQTT payload into one API call.
|
|
||||||
|
|
||||||
Kept free of MQTT so it can be tested against a fake client: the failure
|
|
||||||
modes worth pinning are all in here (an unknown mode, an out-of-range
|
|
||||||
brightness, a mode name that needs its plugin_id attached).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, client: LEDMatrixClient, default_duration: Optional[int] = None):
|
|
||||||
self.client = client
|
|
||||||
self.default_duration = default_duration
|
|
||||||
self._modes_by_name: Dict[str, Dict[str, Any]] = {}
|
|
||||||
|
|
||||||
def refresh_modes(self) -> List[Dict[str, Any]]:
|
|
||||||
modes = self.client.list_modes()
|
|
||||||
self._modes_by_name = {m["mode"]: m for m in modes}
|
|
||||||
# Home Assistant's select shows labels, so accept them back as well --
|
|
||||||
# otherwise picking "Simple Clock" in a dashboard is not a mode name.
|
|
||||||
for entry in modes:
|
|
||||||
self._modes_by_name.setdefault(entry.get("name") or entry["mode"], entry)
|
|
||||||
return modes
|
|
||||||
|
|
||||||
@property
|
|
||||||
def known_modes(self) -> List[Dict[str, Any]]:
|
|
||||||
return list({id(v): v for v in self._modes_by_name.values()}.values())
|
|
||||||
|
|
||||||
def handle(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
action = payload.get("action")
|
|
||||||
handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
|
||||||
"display": self._display,
|
|
||||||
"stop_display": lambda _p: self._ok(self.client.stop_on_demand()),
|
|
||||||
"power": self._power,
|
|
||||||
"brightness": self._brightness,
|
|
||||||
"refresh": lambda _p: self._ok({"modes": len(self.refresh_modes())}),
|
|
||||||
}
|
|
||||||
handler = handlers.get(action)
|
|
||||||
if handler is None:
|
|
||||||
return self._error(f"Unknown action {action!r}; expected one of "
|
|
||||||
f"{', '.join(sorted(handlers))}")
|
|
||||||
try:
|
|
||||||
return handler(payload)
|
|
||||||
except (requests.RequestException, RuntimeError) as err:
|
|
||||||
logger.error("Command %s failed: %s", action, err)
|
|
||||||
return self._error(str(err))
|
|
||||||
|
|
||||||
def _display(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
mode = payload.get("mode")
|
|
||||||
plugin_id = payload.get("plugin_id")
|
|
||||||
if not mode and not plugin_id:
|
|
||||||
return self._error("display requires 'mode' or 'plugin_id'")
|
|
||||||
|
|
||||||
known = self._modes_by_name.get(mode) if mode else None
|
|
||||||
if known is None and mode and not plugin_id:
|
|
||||||
# One retry against a fresh listing: a plugin installed since the
|
|
||||||
# last refresh is the common reason a valid mode looks unknown.
|
|
||||||
self.refresh_modes()
|
|
||||||
known = self._modes_by_name.get(mode)
|
|
||||||
if known is not None:
|
|
||||||
mode = known["mode"]
|
|
||||||
plugin_id = plugin_id or known.get("plugin_id")
|
|
||||||
|
|
||||||
duration = payload.get("duration", self.default_duration)
|
|
||||||
pinned = bool(payload.get("pinned", False))
|
|
||||||
result = self.client.start_on_demand(
|
|
||||||
mode=mode, plugin_id=plugin_id, duration=duration, pinned=pinned)
|
|
||||||
return self._ok(result, mode=mode, plugin_id=plugin_id)
|
|
||||||
|
|
||||||
def _power(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
state = str(payload.get("state", "")).strip().lower()
|
|
||||||
if state not in ("on", "off"):
|
|
||||||
return self._error("power requires 'state' of 'on' or 'off'")
|
|
||||||
return self._ok(self.client.set_power(state == "on"), state=state)
|
|
||||||
|
|
||||||
def _brightness(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
raw = payload.get("value")
|
|
||||||
try:
|
|
||||||
value = int(float(raw))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return self._error(f"brightness requires a number, got {raw!r}")
|
|
||||||
if not 0 <= value <= 100:
|
|
||||||
return self._error(f"brightness must be between 0 and 100, got {value}")
|
|
||||||
return self._ok(self.client.set_brightness(value), value=value)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _ok(result: Any, **extra) -> Dict[str, Any]:
|
|
||||||
return {"status": "success", "result": result, **extra}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _error(message: str) -> Dict[str, Any]:
|
|
||||||
return {"status": "error", "message": message}
|
|
||||||
|
|
||||||
|
|
||||||
def discovery_messages(command_topic: str, state_topic: str, availability_topic: str,
|
|
||||||
mode_labels: List[str]) -> List[Dict[str, Any]]:
|
|
||||||
"""The retained MQTT Discovery configs, as {topic, payload} pairs.
|
|
||||||
|
|
||||||
Pure, so the entity shapes can be asserted without a broker. Every entity
|
|
||||||
shares one availability topic, which is also the bridge's last will -- HA
|
|
||||||
then shows the matrix as unavailable when the bridge dies, instead of
|
|
||||||
leaving stale controls that silently do nothing.
|
|
||||||
"""
|
|
||||||
common = {
|
|
||||||
"device": DEVICE_INFO,
|
|
||||||
"availability_topic": availability_topic,
|
|
||||||
"payload_available": "online",
|
|
||||||
"payload_not_available": "offline",
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"topic": f"{DISCOVERY_PREFIX}/select/{DEVICE_ID}/display_mode/config",
|
|
||||||
"payload": {
|
|
||||||
**common,
|
|
||||||
"name": "Display Mode",
|
|
||||||
"unique_id": f"{DEVICE_ID}_display_mode",
|
|
||||||
"command_topic": command_topic,
|
|
||||||
"command_template": '{"action": "display", "mode": "{{ value }}"}',
|
|
||||||
"state_topic": state_topic,
|
|
||||||
"value_template": "{{ value_json.mode }}",
|
|
||||||
"options": mode_labels,
|
|
||||||
"icon": "mdi:view-dashboard",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"topic": f"{DISCOVERY_PREFIX}/button/{DEVICE_ID}/stop_display/config",
|
|
||||||
"payload": {
|
|
||||||
**common,
|
|
||||||
"name": "Stop Display",
|
|
||||||
"unique_id": f"{DEVICE_ID}_stop_display",
|
|
||||||
"command_topic": command_topic,
|
|
||||||
"payload_press": '{"action": "stop_display"}',
|
|
||||||
"icon": "mdi:stop",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"topic": f"{DISCOVERY_PREFIX}/switch/{DEVICE_ID}/power/config",
|
|
||||||
"payload": {
|
|
||||||
**common,
|
|
||||||
"name": "Power",
|
|
||||||
"unique_id": f"{DEVICE_ID}_power",
|
|
||||||
"command_topic": command_topic,
|
|
||||||
"payload_on": '{"action": "power", "state": "on"}',
|
|
||||||
"payload_off": '{"action": "power", "state": "off"}',
|
|
||||||
"state_topic": state_topic,
|
|
||||||
"value_template": "{{ 'ON' if value_json.power else 'OFF' }}",
|
|
||||||
"state_on": "ON",
|
|
||||||
"state_off": "OFF",
|
|
||||||
"icon": "mdi:power",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"topic": f"{DISCOVERY_PREFIX}/number/{DEVICE_ID}/brightness/config",
|
|
||||||
"payload": {
|
|
||||||
**common,
|
|
||||||
"name": "Brightness",
|
|
||||||
"unique_id": f"{DEVICE_ID}_brightness",
|
|
||||||
"command_topic": command_topic,
|
|
||||||
"command_template": '{"action": "brightness", "value": {{ value }}}',
|
|
||||||
"state_topic": state_topic,
|
|
||||||
"value_template": "{{ value_json.brightness }}",
|
|
||||||
"min": 0,
|
|
||||||
"max": 100,
|
|
||||||
"step": 1,
|
|
||||||
"icon": "mdi:brightness-6",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def warn_if_cleartext(config: Dict[str, Any]) -> bool:
|
|
||||||
"""Say so, once, when a broker password is going over an unencrypted link.
|
|
||||||
|
|
||||||
The shipped example has TLS on, so reaching here means somebody turned it
|
|
||||||
off deliberately -- which is legitimate (the Mosquitto add-on is plaintext
|
|
||||||
on 1883) but should not be silent when there is a password to lose. Returns
|
|
||||||
whether it warned, so the decision is testable without a broker.
|
|
||||||
"""
|
|
||||||
if config.get("mqtt_tls") or not config.get("mqtt_password"):
|
|
||||||
return False
|
|
||||||
logger.warning(
|
|
||||||
'mqtt_tls is off and a password is set: the broker password and every '
|
|
||||||
'command are sent unencrypted. Set "mqtt_tls": true (port 8883 on most '
|
|
||||||
'brokers) unless this is a trusted, isolated network.')
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def read_state(client: LEDMatrixClient) -> Dict[str, Any]:
|
|
||||||
"""The state every entity reads, so HA opens on real values.
|
|
||||||
|
|
||||||
Each field is fetched independently: a matrix with its display service
|
|
||||||
stopped still has a brightness worth showing, and one unreachable field
|
|
||||||
should not blank the rest.
|
|
||||||
"""
|
|
||||||
state: Dict[str, Any] = {"power": False, "mode": None, "brightness": None}
|
|
||||||
try:
|
|
||||||
status = client.display_status()
|
|
||||||
state["power"] = bool(status.get("service", {}).get("active"))
|
|
||||||
on_demand = status.get("state", {})
|
|
||||||
if on_demand.get("active"):
|
|
||||||
state["mode"] = on_demand.get("mode")
|
|
||||||
except (requests.RequestException, RuntimeError) as err:
|
|
||||||
logger.debug("Could not read display status: %s", err)
|
|
||||||
try:
|
|
||||||
state["brightness"] = client.get_brightness()
|
|
||||||
except (requests.RequestException, RuntimeError) as err:
|
|
||||||
logger.debug("Could not read brightness: %s", err)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
class Bridge:
|
|
||||||
"""MQTT wiring around CommandHandler."""
|
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any]):
|
|
||||||
self.config = config
|
|
||||||
self.command_topic = config["mqtt_topic"]
|
|
||||||
self.status_topic = f"{self.command_topic}/status"
|
|
||||||
self.state_topic = f"{self.command_topic}/state"
|
|
||||||
self.availability_topic = f"{self.command_topic}/availability"
|
|
||||||
self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"])
|
|
||||||
self.handler = CommandHandler(self.client, config.get("on_demand_duration"))
|
|
||||||
self._stop = threading.Event()
|
|
||||||
self._mqtt = None
|
|
||||||
|
|
||||||
# -- MQTT callbacks (paho-mqtt 2.x VERSION2 signatures) ------------------
|
|
||||||
|
|
||||||
def _on_connect(self, client, _userdata, _flags, reason_code, _properties=None):
|
|
||||||
if getattr(reason_code, "is_failure", reason_code != 0):
|
|
||||||
logger.error("MQTT connection refused: %s", reason_code)
|
|
||||||
return
|
|
||||||
logger.info("Connected to MQTT broker; subscribing to %s", self.command_topic)
|
|
||||||
client.subscribe(self.command_topic, qos=1)
|
|
||||||
client.publish(self.availability_topic, "online", qos=1, retain=True)
|
|
||||||
# Re-publish on every reconnect, not just the first connect: a broker
|
|
||||||
# restart drops retained discovery configs, and HA would otherwise be
|
|
||||||
# left with entities it can no longer describe.
|
|
||||||
self.publish_discovery()
|
|
||||||
self.publish_state()
|
|
||||||
|
|
||||||
def _on_message(self, _client, _userdata, message):
|
|
||||||
try:
|
|
||||||
payload = json.loads(message.payload.decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as err:
|
|
||||||
logger.warning("Ignoring unparseable message on %s: %s", message.topic, err)
|
|
||||||
self._publish(self.status_topic, {"status": "error", "message": f"bad payload: {err}"})
|
|
||||||
return
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
self._publish(self.status_topic,
|
|
||||||
{"status": "error", "message": "payload must be a JSON object"})
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Command: %s", payload)
|
|
||||||
result = self.handler.handle(payload)
|
|
||||||
self._publish(self.status_topic, result)
|
|
||||||
# The API applies changes asynchronously (the controller polls its
|
|
||||||
# mailbox), so read state back rather than assuming the command took.
|
|
||||||
self.publish_state()
|
|
||||||
|
|
||||||
# -- publishing ---------------------------------------------------------
|
|
||||||
|
|
||||||
def _publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
|
||||||
if self._mqtt is None:
|
|
||||||
return
|
|
||||||
body = payload if isinstance(payload, str) else json.dumps(payload)
|
|
||||||
self._mqtt.publish(topic, body, qos=1, retain=retain)
|
|
||||||
|
|
||||||
def publish_discovery(self) -> None:
|
|
||||||
try:
|
|
||||||
modes = self.handler.refresh_modes()
|
|
||||||
except (requests.RequestException, RuntimeError) as err:
|
|
||||||
logger.error("Could not list display modes: %s", err)
|
|
||||||
modes = self.handler.known_modes
|
|
||||||
labels = sorted({m.get("name") or m["mode"] for m in modes})
|
|
||||||
for message in discovery_messages(self.command_topic, self.state_topic,
|
|
||||||
self.availability_topic, labels):
|
|
||||||
self._publish(message["topic"], message["payload"], retain=True)
|
|
||||||
logger.info("Published discovery for %d display mode(s)", len(labels))
|
|
||||||
|
|
||||||
def publish_state(self) -> None:
|
|
||||||
self._publish(self.state_topic, read_state(self.client), retain=True)
|
|
||||||
|
|
||||||
# -- lifecycle ----------------------------------------------------------
|
|
||||||
|
|
||||||
def run(self) -> int:
|
|
||||||
try:
|
|
||||||
import paho.mqtt.client as mqtt
|
|
||||||
except ImportError:
|
|
||||||
logger.error("paho-mqtt is not installed: pip install -r requirements.txt")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# VERSION2 is the current callback API. The compatibility note in
|
|
||||||
# CLAUDE.md is about code written against the v1 signatures; this file
|
|
||||||
# is written against v2 and requires paho-mqtt >= 2.0.
|
|
||||||
self._mqtt = mqtt.Client(
|
|
||||||
mqtt.CallbackAPIVersion.VERSION2,
|
|
||||||
client_id=self.config["mqtt_client_id"])
|
|
||||||
if self.config.get("mqtt_username"):
|
|
||||||
self._mqtt.username_pw_set(self.config["mqtt_username"],
|
|
||||||
self.config.get("mqtt_password"))
|
|
||||||
if self.config.get("mqtt_tls"):
|
|
||||||
self._mqtt.tls_set()
|
|
||||||
if self.config.get("mqtt_tls_insecure"):
|
|
||||||
logger.warning("TLS certificate verification is disabled (mqtt_tls_insecure)")
|
|
||||||
self._mqtt.tls_insecure_set(True)
|
|
||||||
else:
|
|
||||||
warn_if_cleartext(self.config)
|
|
||||||
|
|
||||||
self._mqtt.will_set(self.availability_topic, "offline", qos=1, retain=True)
|
|
||||||
self._mqtt.on_connect = self._on_connect
|
|
||||||
self._mqtt.on_message = self._on_message
|
|
||||||
|
|
||||||
logger.info("Connecting to %s:%s", self.config["mqtt_host"], self.config["mqtt_port"])
|
|
||||||
try:
|
|
||||||
self._mqtt.connect(self.config["mqtt_host"], self.config["mqtt_port"], keepalive=60)
|
|
||||||
except OSError as err:
|
|
||||||
logger.error("Could not reach the MQTT broker: %s", err)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
self._mqtt.loop_start()
|
|
||||||
try:
|
|
||||||
while not self._stop.wait(30):
|
|
||||||
# HA is told the truth about state that changed outside the
|
|
||||||
# bridge -- somebody using the web UI, or an on-demand window
|
|
||||||
# expiring on its own.
|
|
||||||
self.publish_state()
|
|
||||||
finally:
|
|
||||||
self._publish(self.availability_topic, "offline", retain=True)
|
|
||||||
self._mqtt.loop_stop()
|
|
||||||
self._mqtt.disconnect()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def stop(self, *_args) -> None:
|
|
||||||
logger.info("Shutting down")
|
|
||||||
self._stop.set()
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[List[str]] = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
||||||
parser.add_argument(
|
|
||||||
"--config",
|
|
||||||
default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "bridge_config.json"),
|
|
||||||
help="Path to bridge_config.json (default: alongside this script)")
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s")
|
|
||||||
try:
|
|
||||||
config = load_config(args.config)
|
|
||||||
except ConfigError as err:
|
|
||||||
logger.error("%s", err)
|
|
||||||
return 1
|
|
||||||
logging.getLogger().setLevel(str(config.get("log_level", "INFO")).upper())
|
|
||||||
|
|
||||||
bridge = Bridge(config)
|
|
||||||
signal.signal(signal.SIGTERM, bridge.stop)
|
|
||||||
signal.signal(signal.SIGINT, bridge.stop)
|
|
||||||
return bridge.run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Floors are security floors, not API floors. requests < 2.33.0 carries
|
|
||||||
# CVE-2024-35195, CVE-2024-47081 and CVE-2026-25645; matches the pin in the
|
|
||||||
# project's own requirements.txt.
|
|
||||||
paho-mqtt>=2.0.0,<3.0.0
|
|
||||||
requests>=2.33.0,<3.0.0
|
|
||||||
@@ -194,15 +194,6 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
Each installed app becomes a dynamic display mode.
|
Each installed app becomes a dynamic display mode.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
#: Starlark apps are animations: a .webp render carries per-frame delays
|
|
||||||
#: and _display_frame advances at most one frame per call. The controller
|
|
||||||
#: reads this attribute to decide whether a mode needs its high-FPS loop;
|
|
||||||
#: without it display() was called once per rotation slot, so a multi-frame
|
|
||||||
#: app showed a single frame and never moved. static-image is force-run at
|
|
||||||
#: high FPS for the same reason (GIFs), but that plugin is special-cased by
|
|
||||||
#: name in the controller and this one has to declare it.
|
|
||||||
enable_scrolling = True
|
|
||||||
|
|
||||||
def __init__(self, plugin_id: str, config: Dict[str, Any],
|
def __init__(self, plugin_id: str, config: Dict[str, Any],
|
||||||
display_manager, cache_manager, plugin_manager):
|
display_manager, cache_manager, plugin_manager):
|
||||||
"""Initialize the Starlark Apps plugin."""
|
"""Initialize the Starlark Apps plugin."""
|
||||||
@@ -690,62 +681,38 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
if app.is_enabled() and app.should_render(current_time):
|
if app.is_enabled() and app.should_render(current_time):
|
||||||
self._render_app(app, force=False)
|
self._render_app(app, force=False)
|
||||||
|
|
||||||
def display(self, display_mode: Optional[str] = None, force_clear: bool = False) -> bool:
|
def display(self, force_clear: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Display current Starlark app.
|
Display current Starlark app.
|
||||||
|
|
||||||
This method is called during the display rotation.
|
This method is called during the display rotation.
|
||||||
Displays frames from the currently active app.
|
Displays frames from the currently active app.
|
||||||
|
|
||||||
`display_mode` names the app to show when it matches an installed
|
|
||||||
app_id. The controller passes the mode it is rotating to and inspects
|
|
||||||
this signature to decide whether to, so accepting it is what lets a
|
|
||||||
specific app be addressed -- including by an on-demand request pinned
|
|
||||||
to one app. Anything else (the plugin id itself, when the plugin
|
|
||||||
exposes no per-app modes) falls through to normal rotation.
|
|
||||||
|
|
||||||
Returns False when there is no app to show -- which is the state of
|
|
||||||
every install without Pixlet, and of a fresh one before any app is
|
|
||||||
added. The display controller only skips a mode on a boolean False
|
|
||||||
(it checks isinstance(result, bool)), so returning None held a black
|
|
||||||
panel for the full display_duration instead of rotating on.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if force_clear:
|
if force_clear:
|
||||||
self.display_manager.clear()
|
self.display_manager.clear()
|
||||||
|
|
||||||
if display_mode and display_mode in self.apps:
|
# If no current app, try to select one
|
||||||
self.current_app = self.apps[display_mode]
|
if not self.current_app:
|
||||||
elif force_clear or not self.current_app:
|
|
||||||
# Advance on entry to the mode. _select_next_app only ran when
|
|
||||||
# current_app was unset, so the first enabled app was picked
|
|
||||||
# once and then shown forever -- every other installed app was
|
|
||||||
# rendered on schedule and never displayed. force_clear is the
|
|
||||||
# controller's "we just switched to you" signal (it is reset
|
|
||||||
# immediately after this call), so one app gets each turn.
|
|
||||||
self._select_next_app()
|
self._select_next_app()
|
||||||
|
|
||||||
if not self.current_app:
|
if not self.current_app:
|
||||||
# No apps available
|
# No apps available
|
||||||
self.logger.debug("No Starlark apps to display")
|
self.logger.debug("No Starlark apps to display")
|
||||||
return False
|
return
|
||||||
|
|
||||||
# Render app if needed
|
# Render app if needed
|
||||||
if not self.current_app.frames:
|
if not self.current_app.frames:
|
||||||
success = self._render_app(self.current_app, force=True)
|
success = self._render_app(self.current_app, force=True)
|
||||||
if not success:
|
if not success:
|
||||||
self.logger.error(f"Failed to render app: {self.current_app.app_id}")
|
self.logger.error(f"Failed to render app: {self.current_app.app_id}")
|
||||||
return False
|
return
|
||||||
|
|
||||||
# Display current frame. The result is propagated: a failed frame
|
# Display current frame
|
||||||
# update is not a displayed frame, and returning True regardless
|
self._display_frame()
|
||||||
# told the controller the mode had rendered, so it held the dead
|
|
||||||
# frame for the whole display_duration instead of rotating on.
|
|
||||||
return self._display_frame()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error displaying Starlark app: {e}")
|
self.logger.error(f"Error displaying Starlark app: {e}")
|
||||||
return False
|
|
||||||
|
|
||||||
def _select_next_app(self) -> None:
|
def _select_next_app(self) -> None:
|
||||||
"""Select the next enabled app for display."""
|
"""Select the next enabled app for display."""
|
||||||
@@ -868,13 +835,10 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
self.logger.error(f"Error loading frames for {app.app_id}: {e}")
|
self.logger.error(f"Error loading frames for {app.app_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _display_frame(self) -> bool:
|
def _display_frame(self) -> None:
|
||||||
"""Display the current frame of the current app.
|
"""Display the current frame of the current app."""
|
||||||
|
|
||||||
:returns: whether a frame actually reached the display manager.
|
|
||||||
"""
|
|
||||||
if not self.current_app or not self.current_app.frames:
|
if not self.current_app or not self.current_app.frames:
|
||||||
return False
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
@@ -892,11 +856,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
)
|
)
|
||||||
self.current_app.last_frame_time = current_time
|
self.current_app.last_frame_time = current_time
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error displaying frame: {e}")
|
self.logger.error(f"Error displaying frame: {e}")
|
||||||
return False
|
|
||||||
|
|
||||||
def install_app(self, app_id: str, star_file_path: str, metadata: Optional[Dict[str, Any]] = None, assets_dir: Optional[str] = None) -> bool:
|
def install_app(self, app_id: str, star_file_path: str, metadata: Optional[Dict[str, Any]] = None, assets_dir: Optional[str] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -264,18 +264,10 @@ class PixletRenderer:
|
|||||||
else:
|
else:
|
||||||
value_str = str(value)
|
value_str = str(value)
|
||||||
|
|
||||||
# Validate value doesn't contain dangerous shell metacharacters.
|
# Validate value doesn't contain dangerous shell metacharacters
|
||||||
# Kept as defence in depth only: cmd is a list and there is no
|
# Block: backticks, $(), pipes, redirects, semicolons, ampersands, null bytes
|
||||||
# shell=True below, so nothing here is ever interpreted by a
|
# Allow: most printable chars including spaces, quotes, brackets, braces
|
||||||
# shell. That made the list worth trimming rather than growing
|
if re.search(r'[`$|<>&;\x00]|\$\(', value_str):
|
||||||
# -- "|" is a normal character inside a config value, and apps
|
|
||||||
# do use it as a separator (a PennDOT sign id is
|
|
||||||
# "I-476 North|175659"). Blocking it dropped the whole key
|
|
||||||
# silently, and the app then rendered its own "not configured"
|
|
||||||
# screen with nothing to say why.
|
|
||||||
# Block: backticks, $(), redirects, semicolons, ampersands, null bytes
|
|
||||||
# Allow: most printable chars including spaces, quotes, brackets, braces, pipes
|
|
||||||
if re.search(r'[`$<>&;\x00]|\$\(', value_str):
|
|
||||||
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
|
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -307,21 +299,13 @@ class PixletRenderer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
if not os.path.isfile(output_path):
|
if os.path.isfile(output_path):
|
||||||
|
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
|
||||||
|
return True, None
|
||||||
|
else:
|
||||||
error = "Rendering succeeded but output file not found"
|
error = "Rendering succeeded but output file not found"
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
return False, error
|
return False, error
|
||||||
# Pixlet exits 0 and writes a 0-byte file when the app renders
|
|
||||||
# nothing -- an app whose config leaves it with no content to
|
|
||||||
# show does exactly that. Treating existence alone as success
|
|
||||||
# handed the caller a file with no frames in it, which reads
|
|
||||||
# downstream as a working app that draws a black panel.
|
|
||||||
if os.path.getsize(output_path) == 0:
|
|
||||||
error = "Rendering produced an empty (0-byte) file - the app rendered no content"
|
|
||||||
logger.error(error)
|
|
||||||
return False, error
|
|
||||||
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
|
|
||||||
return True, None
|
|
||||||
else:
|
else:
|
||||||
error = f"Pixlet failed (exit {result.returncode}): {result.stderr}"
|
error = f"Pixlet failed (exit {result.returncode}): {result.stderr}"
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
@@ -335,76 +319,11 @@ class PixletRenderer:
|
|||||||
logger.exception("Rendering exception")
|
logger.exception("Rendering exception")
|
||||||
return False, "Rendering failed - see logs for details"
|
return False, "Rendering failed - see logs for details"
|
||||||
|
|
||||||
#: Schema extraction runs an app's own get_schema(), which may make a
|
|
||||||
#: network call. Short enough that a hung app does not stall an upload,
|
|
||||||
#: long enough for a real API round trip on a slow connection.
|
|
||||||
SCHEMA_TIMEOUT = 20
|
|
||||||
|
|
||||||
def extract_schema_via_pixlet(self, star_file: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Ask Pixlet itself for the app's schema, or None if it cannot say.
|
|
||||||
|
|
||||||
`pixlet schema` executes get_schema() instead of reading it, which is
|
|
||||||
the only way to see options an app computes at runtime -- a dropdown
|
|
||||||
whose choices come from a live API call has no option list anywhere in
|
|
||||||
the source for the regex parser below to find, so that parser reports
|
|
||||||
an empty dropdown and the config form offers nothing to pick.
|
|
||||||
|
|
||||||
Pixlet's own field keys are remapped to the ones the rest of this
|
|
||||||
plugin and the config UI already use ("typeOf"/"desc"), so the two
|
|
||||||
extractors return the same shape and callers cannot tell them apart.
|
|
||||||
"""
|
|
||||||
if not self.pixlet_binary:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[self.pixlet_binary, "schema", star_file],
|
|
||||||
capture_output=True, text=True, timeout=self.SCHEMA_TIMEOUT,
|
|
||||||
cwd=self._get_safe_working_directory(star_file),
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.warning(
|
|
||||||
"pixlet schema timed out after %ss for %s - get_schema() may be "
|
|
||||||
"making a slow network call", self.SCHEMA_TIMEOUT, star_file)
|
|
||||||
return None
|
|
||||||
except (subprocess.SubprocessError, OSError) as e:
|
|
||||||
logger.warning(f"Could not run pixlet schema for {star_file}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
# Not an error worth failing on: older Pixlet builds have no
|
|
||||||
# `schema` subcommand at all, and the source parser still works.
|
|
||||||
logger.debug(
|
|
||||||
"pixlet schema exited %d for %s: %s",
|
|
||||||
result.returncode, star_file, (result.stderr or '').strip()[:300])
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
schema = json.loads(result.stdout)
|
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
|
||||||
logger.warning(f"pixlet schema returned unparseable output for {star_file}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not isinstance(schema, dict) or not isinstance(schema.get("schema"), list):
|
|
||||||
logger.warning(f"pixlet schema returned an unexpected shape for {star_file}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
for field in schema["schema"]:
|
|
||||||
if not isinstance(field, dict):
|
|
||||||
continue
|
|
||||||
if "type" in field and "typeOf" not in field:
|
|
||||||
field["typeOf"] = field.pop("type")
|
|
||||||
if "description" in field and "desc" not in field:
|
|
||||||
field["desc"] = field.pop("description")
|
|
||||||
return schema
|
|
||||||
|
|
||||||
def extract_schema(self, star_file: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
def extract_schema(self, star_file: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Extract configuration schema from a .star file.
|
Extract configuration schema from a .star file by parsing source code.
|
||||||
|
|
||||||
Prefers `pixlet schema`, which runs the app and therefore sees options
|
Supports:
|
||||||
it computes at runtime. Falls back to parsing the source when Pixlet is
|
|
||||||
unavailable, too old to have the subcommand, or the app fails to run --
|
|
||||||
that parser handles:
|
|
||||||
- Static field definitions (location, text, toggle, dropdown, color, datetime)
|
- Static field definitions (location, text, toggle, dropdown, color, datetime)
|
||||||
- Variable-referenced dropdown options
|
- Variable-referenced dropdown options
|
||||||
- Graceful degradation for unsupported field types
|
- Graceful degradation for unsupported field types
|
||||||
@@ -418,13 +337,6 @@ class PixletRenderer:
|
|||||||
if not os.path.isfile(star_file):
|
if not os.path.isfile(star_file):
|
||||||
return False, None, f"Star file not found: {star_file}"
|
return False, None, f"Star file not found: {star_file}"
|
||||||
|
|
||||||
schema = self.extract_schema_via_pixlet(star_file)
|
|
||||||
if schema is not None:
|
|
||||||
logger.debug(
|
|
||||||
"Extracted schema with %d field(s) from %s via pixlet schema",
|
|
||||||
len(schema.get('schema', [])), star_file)
|
|
||||||
return True, schema, None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Read .star file
|
# Read .star file
|
||||||
with open(star_file, 'r', encoding='utf-8') as f:
|
with open(star_file, 'r', encoding='utf-8') as f:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Handles interaction with the Tronbyte apps repository on GitHub.
|
|||||||
Fetches app listings, metadata, and downloads .star files.
|
Fetches app listings, metadata, and downloads .star files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
@@ -50,13 +49,6 @@ class TronbyteRepository:
|
|||||||
self.base_url = "https://api.github.com"
|
self.base_url = "https://api.github.com"
|
||||||
self.raw_url = "https://raw.githubusercontent.com"
|
self.raw_url = "https://raw.githubusercontent.com"
|
||||||
|
|
||||||
# Why the last GitHub API call failed, in words a user can act on.
|
|
||||||
# _make_request used to log the reason and return a bare None, so
|
|
||||||
# every caller up the stack knew only that "something" went wrong --
|
|
||||||
# which is how an exhausted rate limit reached the store page as an
|
|
||||||
# empty grid with no explanation.
|
|
||||||
self.last_error: Optional[str] = None
|
|
||||||
|
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
if github_token:
|
if github_token:
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
@@ -78,50 +70,29 @@ class TronbyteRepository:
|
|||||||
Returns:
|
Returns:
|
||||||
JSON response or None on error
|
JSON response or None on error
|
||||||
"""
|
"""
|
||||||
self.last_error = None
|
|
||||||
try:
|
try:
|
||||||
response = self.session.get(url, timeout=timeout)
|
response = self.session.get(url, timeout=timeout)
|
||||||
|
|
||||||
if response.status_code in (403, 429):
|
if response.status_code == 403:
|
||||||
# 403 is both "rate limited" and "forbidden"; the remaining
|
# Rate limit exceeded
|
||||||
# counter is what tells them apart, and the difference matters
|
logger.warning("[Tronbyte Repo] GitHub API rate limit exceeded")
|
||||||
# to whoever reads the message -- one is fixed by waiting or
|
|
||||||
# adding a token, the other is not.
|
|
||||||
remaining = response.headers.get('X-RateLimit-Remaining')
|
|
||||||
if remaining == '0':
|
|
||||||
self.last_error = (
|
|
||||||
"GitHub API rate limit exceeded"
|
|
||||||
f" ({response.headers.get('X-RateLimit-Limit', '?')} requests/hour"
|
|
||||||
f"{'' if self.github_token else ', unauthenticated'})."
|
|
||||||
" Add a GitHub token in settings, or wait for the limit to reset."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.last_error = f"GitHub refused the request ({response.status_code})"
|
|
||||||
logger.warning(f"[Tronbyte Repo] {self.last_error}")
|
|
||||||
return None
|
return None
|
||||||
elif response.status_code == 404:
|
elif response.status_code == 404:
|
||||||
self.last_error = "Not found on GitHub"
|
|
||||||
logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
|
logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
|
||||||
return None
|
return None
|
||||||
elif response.status_code != 200:
|
elif response.status_code != 200:
|
||||||
self.last_error = f"GitHub API error {response.status_code}"
|
|
||||||
logger.error(f"[Tronbyte Repo] GitHub API error: {response.status_code}")
|
logger.error(f"[Tronbyte Repo] GitHub API error: {response.status_code}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
self.last_error = "Timed out reaching GitHub"
|
|
||||||
logger.error(f"[Tronbyte Repo] Request timeout: {url}")
|
logger.error(f"[Tronbyte Repo] Request timeout: {url}")
|
||||||
return None
|
return None
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
self.last_error = f"Network error reaching GitHub: {e.__class__.__name__}"
|
|
||||||
logger.error(f"[Tronbyte Repo] Request error: {e}", exc_info=True)
|
logger.error(f"[Tronbyte Repo] Request error: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
# Reachable whenever something on the path answers with HTML --
|
|
||||||
# a captive portal, a proxy error page, a DNS-hijacking router.
|
|
||||||
self.last_error = "GitHub returned a response that was not JSON"
|
|
||||||
logger.error(f"[Tronbyte Repo] JSON parse error for {url}: {e}", exc_info=True)
|
logger.error(f"[Tronbyte Repo] JSON parse error for {url}: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -154,61 +125,6 @@ class TronbyteRepository:
|
|||||||
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
|
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _list_app_dirs_via_trees(self) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
"""App directories via the git trees API, or None on failure.
|
|
||||||
|
|
||||||
The contents API caps a directory listing at 1000 entries and says
|
|
||||||
nothing about having truncated it, so the store showed the first 1000
|
|
||||||
apps of a repository that has more and looked complete while doing it.
|
|
||||||
The trees API caps far higher and sets `truncated` when it does, at
|
|
||||||
the cost of one extra call to resolve the `apps` tree.
|
|
||||||
"""
|
|
||||||
repo = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}"
|
|
||||||
|
|
||||||
root = self._make_request(f"{repo}/git/trees/{self.DEFAULT_BRANCH}")
|
|
||||||
if not isinstance(root, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
apps_sha = next(
|
|
||||||
(e.get('sha') for e in root.get('tree', []) or []
|
|
||||||
if e.get('path') == self.APPS_PATH and e.get('type') == 'tree'),
|
|
||||||
None)
|
|
||||||
if not apps_sha:
|
|
||||||
self.last_error = f"No '{self.APPS_PATH}' directory in the repository"
|
|
||||||
return None
|
|
||||||
|
|
||||||
tree = self._make_request(f"{repo}/git/trees/{apps_sha}")
|
|
||||||
if not isinstance(tree, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if tree.get('truncated'):
|
|
||||||
logger.warning(
|
|
||||||
"[Tronbyte Repo] GitHub truncated the app tree; the listing is incomplete")
|
|
||||||
|
|
||||||
return [
|
|
||||||
{'id': e['path'], 'path': f"{self.APPS_PATH}/{e['path']}", 'url': None}
|
|
||||||
for e in tree.get('tree', []) or []
|
|
||||||
if e.get('type') == 'tree' and e.get('path') and not e['path'].startswith('.')
|
|
||||||
]
|
|
||||||
|
|
||||||
def _list_app_dirs_via_contents(self) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
"""App directories via the contents API. Capped at 1000 entries."""
|
|
||||||
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
|
|
||||||
|
|
||||||
data = self._make_request(url)
|
|
||||||
if data is None:
|
|
||||||
return None
|
|
||||||
if not isinstance(data, list):
|
|
||||||
self.last_error = "GitHub returned an unexpected listing format"
|
|
||||||
return None
|
|
||||||
|
|
||||||
return [
|
|
||||||
{'id': item.get('name'), 'path': item.get('path'), 'url': item.get('url')}
|
|
||||||
for item in data
|
|
||||||
if item.get('type') == 'dir' and item.get('name')
|
|
||||||
and not item['name'].startswith('.')
|
|
||||||
]
|
|
||||||
|
|
||||||
def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]:
|
def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
List all available apps in the repository.
|
List all available apps in the repository.
|
||||||
@@ -216,17 +132,26 @@ class TronbyteRepository:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, apps_list, error_message)
|
Tuple of (success, apps_list, error_message)
|
||||||
"""
|
"""
|
||||||
apps = self._list_app_dirs_via_trees()
|
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
|
||||||
if apps is None:
|
|
||||||
# Fall back rather than fail: the contents API was what shipped,
|
data = self._make_request(url)
|
||||||
# so a trees-only outage should not take the store down with it.
|
if data is None:
|
||||||
trees_error = self.last_error
|
return False, None, "Failed to fetch repository contents"
|
||||||
logger.warning(
|
|
||||||
f"[Tronbyte Repo] Trees listing failed ({trees_error}); "
|
if not isinstance(data, list):
|
||||||
"falling back to the contents API")
|
return False, None, "Invalid response format"
|
||||||
apps = self._list_app_dirs_via_contents()
|
|
||||||
if apps is None:
|
# Filter directories (apps)
|
||||||
return False, None, self.last_error or trees_error or "Failed to fetch repository contents"
|
apps = []
|
||||||
|
for item in data:
|
||||||
|
if item.get('type') == 'dir':
|
||||||
|
app_id = item.get('name')
|
||||||
|
if app_id and not app_id.startswith('.'):
|
||||||
|
apps.append({
|
||||||
|
'id': app_id,
|
||||||
|
'path': item.get('path'),
|
||||||
|
'url': item.get('url')
|
||||||
|
})
|
||||||
|
|
||||||
logger.info(f"Found {len(apps)} apps in repository")
|
logger.info(f"Found {len(apps)} apps in repository")
|
||||||
return True, apps, None
|
return True, apps, None
|
||||||
@@ -342,22 +267,14 @@ class TronbyteRepository:
|
|||||||
'categories': _apps_cache['categories'],
|
'categories': _apps_cache['categories'],
|
||||||
'authors': _apps_cache['authors'],
|
'authors': _apps_cache['authors'],
|
||||||
'count': len(_apps_cache['data']),
|
'count': len(_apps_cache['data']),
|
||||||
'cached': True,
|
'cached': True
|
||||||
'error': None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fetch directory listing (a small number of GitHub API calls)
|
# Fetch directory listing (1 GitHub API call)
|
||||||
success, app_dirs, error = self.list_apps()
|
success, app_dirs, error = self.list_apps()
|
||||||
if not success or not app_dirs:
|
if not success or not app_dirs:
|
||||||
# Returning an empty list here used to read downstream as "the
|
logger.error(f"Failed to list apps for bulk fetch: {error}")
|
||||||
# repository has no apps", and the route reported that as a
|
return {'apps': [], 'categories': [], 'authors': [], 'count': 0, 'cached': False}
|
||||||
# success -- so a rate limit, a DNS failure and an empty
|
|
||||||
# repository were all drawn as the same blank grid. Hand the
|
|
||||||
# reason back instead and let the caller surface it.
|
|
||||||
reason = error or "No apps found in the repository"
|
|
||||||
logger.error(f"Failed to list apps for bulk fetch: {reason}")
|
|
||||||
return {'apps': [], 'categories': [], 'authors': [],
|
|
||||||
'count': 0, 'cached': False, 'error': reason}
|
|
||||||
|
|
||||||
logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...")
|
logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...")
|
||||||
|
|
||||||
@@ -424,8 +341,7 @@ class TronbyteRepository:
|
|||||||
'categories': categories,
|
'categories': categories,
|
||||||
'authors': authors,
|
'authors': authors,
|
||||||
'count': len(apps_with_metadata),
|
'count': len(apps_with_metadata),
|
||||||
'cached': False,
|
'cached': False
|
||||||
'error': None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def download_star_file(self, app_id: str, output_path: Path, filename: Optional[str] = None) -> Tuple[bool, Optional[str]]:
|
def download_star_file(self, app_id: str, output_path: Path, filename: Optional[str] = None) -> Tuple[bool, Optional[str]]:
|
||||||
|
|||||||
@@ -7,8 +7,3 @@ freezegun>=1.2,<2 # deterministic time for golden-image tests
|
|||||||
psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the
|
psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the
|
||||||
# /system/status endpoint's real path is exercised
|
# /system/status endpoint's real path is exercised
|
||||||
mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml)
|
mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml)
|
||||||
PyYAML>=6.0.2,<7.0.0 # not a core dependency: test_starlark_pixlet_routes loads
|
|
||||||
# plugin-repos/starlark-apps/tronbyte_repository.py the way
|
|
||||||
# the blueprint does, and that plugin imports yaml. The
|
|
||||||
# plugin declares it in its own requirements.txt, which the
|
|
||||||
# store installs on a real rig but CI never does.
|
|
||||||
|
|||||||
@@ -55,18 +55,6 @@ packaging>=23.0,<27.0
|
|||||||
# range as a hard dependency — keep the two in sync.
|
# range as a hard dependency — keep the two in sync.
|
||||||
# pip install 'psutil>=6.0.0,<7.0.0'
|
# pip install 'psutil>=6.0.0,<7.0.0'
|
||||||
#
|
#
|
||||||
# orjson — faster JSON for the disk cache
|
|
||||||
# (src/cache/disk_cache.py). Encoding a ~1MB cache
|
|
||||||
# record drops from ~12ms to ~1.6ms on a Pi 4, which
|
|
||||||
# matters because that work holds the GIL and stalls
|
|
||||||
# the render thread mid-scroll. Falls back to the
|
|
||||||
# stdlib json when missing — see docs/SCROLL_PERFORMANCE.md.
|
|
||||||
# The 3.11.6 floor is CVE-2025-67221: orjson.dumps did not
|
|
||||||
# limit recursion on deeply nested documents, and the disk
|
|
||||||
# cache encodes payloads parsed straight from third-party
|
|
||||||
# APIs. 3.11.6 covers the Python range above.
|
|
||||||
# pip install 'orjson>=3.11.6,<4.0'
|
|
||||||
#
|
|
||||||
# Flask-Limiter — request rate limiting in web_interface/app.py
|
# Flask-Limiter — request rate limiting in web_interface/app.py
|
||||||
# (accidental-abuse protection, not security). The
|
# (accidental-abuse protection, not security). The
|
||||||
# web interface starts without rate limiting when
|
# web interface starts without rate limiting when
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Find blocking work reachable from a plugin's render path.
|
|
||||||
|
|
||||||
`display()` runs on the render thread. Anything slow reached from it stalls the
|
|
||||||
panel for its whole duration, and on a vsync-paced loop that is immediately
|
|
||||||
visible: a single 15ms call on a 100Hz panel drops a frame, and a network round
|
|
||||||
trip freezes the marquee outright.
|
|
||||||
|
|
||||||
This has bitten twice already. odds-ticker called `_has_live_games()` every
|
|
||||||
frame, whose slow path read the scoreboard cache from disk and parsed JSON per
|
|
||||||
enabled league -- one stalled frame every few minutes. soccer-scoreboard timed
|
|
||||||
out inside `update()` during a cache refresh. Both were found by staring at
|
|
||||||
frame-time histograms, which is a slow way to find a bug that is visible in the
|
|
||||||
source.
|
|
||||||
|
|
||||||
The audit walks the call graph from `display()` through same-class `self.*`
|
|
||||||
methods and reports anything that reaches a known-blocking API. It is a
|
|
||||||
heuristic, not a proof: it cannot see through indirection, and a hit is not
|
|
||||||
automatically a bug -- a call guarded by an interval check may be fine. It is a
|
|
||||||
list of places worth a human look.
|
|
||||||
|
|
||||||
python3 scripts/audit_render_path.py # all plugins
|
|
||||||
python3 scripts/audit_render_path.py --dir plugin-repos # a specific tree
|
|
||||||
python3 scripts/audit_render_path.py --plugin odds-ticker
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import ast
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
#: Calls that can block for longer than a frame. Matched on the attribute or
|
|
||||||
#: function name, so `requests.get`, `self.session.get` and a bare `get` on a
|
|
||||||
#: requests-ish object all register.
|
|
||||||
BLOCKING = {
|
|
||||||
"get": "network or cache read",
|
|
||||||
"post": "network",
|
|
||||||
"put": "network",
|
|
||||||
"request": "network",
|
|
||||||
"urlopen": "network",
|
|
||||||
"read": "I/O",
|
|
||||||
"open": "file I/O",
|
|
||||||
"load": "JSON/file parse",
|
|
||||||
"loads": "JSON parse",
|
|
||||||
"dump": "file write",
|
|
||||||
"dumps": "serialise",
|
|
||||||
"sleep": "sleep on the render thread",
|
|
||||||
"run": "subprocess",
|
|
||||||
"check_output": "subprocess",
|
|
||||||
"connect": "network",
|
|
||||||
"download_logo": "network",
|
|
||||||
"_fetch": "fetch",
|
|
||||||
}
|
|
||||||
|
|
||||||
#: Names that make a hit far more likely to matter.
|
|
||||||
HIGH_SIGNAL = ("requests", "urllib", "session", "cache_manager", "subprocess",
|
|
||||||
"socket", "http")
|
|
||||||
|
|
||||||
RENDER_ENTRY = "display"
|
|
||||||
|
|
||||||
|
|
||||||
class Analyzer:
|
|
||||||
def __init__(self, tree: ast.AST):
|
|
||||||
self.methods: dict[str, ast.FunctionDef] = {}
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
||||||
self.methods.setdefault(node.name, node)
|
|
||||||
|
|
||||||
def calls_in(self, fn: ast.AST):
|
|
||||||
"""(self-method names called, blocking hits) inside one function."""
|
|
||||||
self_calls, hits = set(), []
|
|
||||||
for node in ast.walk(fn):
|
|
||||||
if not isinstance(node, ast.Call):
|
|
||||||
continue
|
|
||||||
func = node.func
|
|
||||||
if isinstance(func, ast.Attribute):
|
|
||||||
name = func.attr
|
|
||||||
base = ast.unparse(func.value) if hasattr(ast, "unparse") else ""
|
|
||||||
if base == "self" and name in self.methods:
|
|
||||||
self_calls.add(name)
|
|
||||||
continue
|
|
||||||
if name in BLOCKING:
|
|
||||||
hits.append((name, base, BLOCKING[name], node.lineno))
|
|
||||||
elif isinstance(func, ast.Name) and func.id in BLOCKING:
|
|
||||||
hits.append((func.id, "", BLOCKING[func.id], node.lineno))
|
|
||||||
return self_calls, hits
|
|
||||||
|
|
||||||
def reachable_from(self, entry: str, max_depth: int = 3):
|
|
||||||
"""Blocking hits reachable from `entry`, with the path that reaches them."""
|
|
||||||
if entry not in self.methods:
|
|
||||||
return []
|
|
||||||
found, seen = [], set()
|
|
||||||
stack = [(entry, [entry], 0)]
|
|
||||||
while stack:
|
|
||||||
name, path, depth = stack.pop()
|
|
||||||
if name in seen or depth > max_depth:
|
|
||||||
continue
|
|
||||||
seen.add(name)
|
|
||||||
self_calls, hits = self.calls_in(self.methods[name])
|
|
||||||
for hit in hits:
|
|
||||||
found.append((path, hit))
|
|
||||||
for callee in sorted(self_calls):
|
|
||||||
stack.append((callee, path + [callee], depth + 1))
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def audit_file(path: Path):
|
|
||||||
try:
|
|
||||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, SyntaxError):
|
|
||||||
return []
|
|
||||||
analyzer = Analyzer(tree)
|
|
||||||
return analyzer.reachable_from(RENDER_ENTRY)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__,
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
||||||
root = Path(__file__).resolve().parent.parent
|
|
||||||
ap.add_argument("--dir", default=str(root / "plugin-repos"))
|
|
||||||
ap.add_argument("--plugin", help="only this plugin directory")
|
|
||||||
ap.add_argument("--all-hits", action="store_true",
|
|
||||||
help="include low-signal hits (open/read/load on locals)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
base = Path(args.dir)
|
|
||||||
if not base.is_dir():
|
|
||||||
sys.exit("not a directory: %s" % base)
|
|
||||||
|
|
||||||
plugins = [base / args.plugin] if args.plugin else sorted(
|
|
||||||
d for d in base.iterdir() if d.is_dir())
|
|
||||||
|
|
||||||
total = 0
|
|
||||||
for plugin in plugins:
|
|
||||||
rows = []
|
|
||||||
for src in sorted(plugin.glob("*.py")):
|
|
||||||
if src.name.startswith("test_"):
|
|
||||||
continue
|
|
||||||
for path, (name, s_base, why, lineno) in audit_file(src):
|
|
||||||
signal = any(h in s_base.lower() for h in HIGH_SIGNAL)
|
|
||||||
if not signal and not args.all_hits:
|
|
||||||
continue
|
|
||||||
rows.append((src.name, lineno, "->".join(path),
|
|
||||||
("%s.%s" % (s_base, name)) if s_base else name, why))
|
|
||||||
if rows:
|
|
||||||
total += len(rows)
|
|
||||||
print("\n%s" % plugin.name)
|
|
||||||
for fname, lineno, chain, call, why in sorted(rows):
|
|
||||||
print(" %s:%-5d %-34s via %s" % (fname, lineno, call + " (" + why + ")", chain))
|
|
||||||
|
|
||||||
print("\n%d blocking call(s) reachable from display() across %d plugin(s)"
|
|
||||||
% (total, len(plugins)))
|
|
||||||
print("Heuristic: a hit guarded by an interval check may be fine. Look, do "
|
|
||||||
"not assume.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Rebuild the rgbmatrix Python binding so it releases the GIL.
|
|
||||||
#
|
|
||||||
# WHY THIS EXISTS
|
|
||||||
# ---------------
|
|
||||||
# The upstream binding declares FrameCanvas::SwapOnVSync WITHOUT `nogil`
|
|
||||||
# (cppinc.pxd), unlike SetPixel/Clear/Fill on the lines just above it.
|
|
||||||
# SwapOnVSync blocks until the panel's next vertical sync -- up to a full
|
|
||||||
# refresh period on every frame -- so the render thread was holding the GIL
|
|
||||||
# for most of every frame. Background threads (API fetches, JSON parsing,
|
|
||||||
# image decode) were starved into long uninterruptible bursts, which in turn
|
|
||||||
# made the render loop miss refreshes.
|
|
||||||
#
|
|
||||||
# Measured on a Pi 4 driving a 2x128x64 chain at limit_refresh_rate_hz=100:
|
|
||||||
#
|
|
||||||
# before ~44 fps average, 14-17% of frames 41-53ms
|
|
||||||
# after 100 fps, median 10.00ms, p95 10.05ms, 0% stalls
|
|
||||||
#
|
|
||||||
# The per-pixel blit (SetPixelsPillow) can also release the GIL and walk the
|
|
||||||
# Pillow buffer row-major, but that is OFF by default and you almost certainly
|
|
||||||
# want to leave it that way. Row-major changes what a partially-written frame
|
|
||||||
# looks like: column-major tearing shows as a vertical seam, row-major tearing
|
|
||||||
# shows as a horizontal split between the panel's upper and lower halves. On a
|
|
||||||
# 1/32 scan panel that reads as a one-pixel "fold" across the middle of every
|
|
||||||
# panel -- reported on hardware, and it went away when the blit was reverted.
|
|
||||||
# Enable with RGB_PATCH_BLIT=1 only if you have measured that you need it;
|
|
||||||
# essentially all of the gain above comes from the SwapOnVSync change alone.
|
|
||||||
#
|
|
||||||
# SAFETY
|
|
||||||
# ------
|
|
||||||
# Builds into a scratch directory; touches the installed module only in the
|
|
||||||
# --install step, and backs up the original first. Roll back at any time with:
|
|
||||||
#
|
|
||||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
|
||||||
#
|
|
||||||
# USAGE
|
|
||||||
# bash scripts/build_rgbmatrix_nogil.sh # build only
|
|
||||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --install
|
|
||||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
|
||||||
#
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
# Resolve the invoking user's home, not root's. --install runs under sudo,
|
|
||||||
# where $HOME is /root, so every default path below pointed somewhere the
|
|
||||||
# build had never written and the install died with "no built module found".
|
|
||||||
if [ -n "${SUDO_USER:-}" ]; then
|
|
||||||
OWNER_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)"
|
|
||||||
fi
|
|
||||||
OWNER_HOME="${OWNER_HOME:-$HOME}"
|
|
||||||
|
|
||||||
SRC_TREE="${RGB_SRC_TREE:-$OWNER_HOME/LEDMatrix/rpi-rgb-led-matrix-master}"
|
|
||||||
BUILD_DIR="${RGB_BUILD_DIR:-$OWNER_HOME/rgbmatrix-nogil-build}"
|
|
||||||
VENV="${RGB_CYTHON_VENV:-$OWNER_HOME/.cache/ledmatrix-cython}"
|
|
||||||
BACKUP="${RGB_BACKUP:-$OWNER_HOME/rgbmatrix-core.so.ORIGINAL}"
|
|
||||||
PATCH_BLIT="${RGB_PATCH_BLIT:-0}"
|
|
||||||
|
|
||||||
die() { echo "FATAL: $*" >&2; exit 1; }
|
|
||||||
|
|
||||||
# This script runs under `set -uo pipefail` -- no -e -- so an unchecked
|
|
||||||
# systemctl failure is silently ignored. That matters most for `stop`: leaving
|
|
||||||
# the old service running means cp overwrites a module the running process has
|
|
||||||
# mapped, the following `start` succeeds as a no-op, and the health check sees
|
|
||||||
# an active unit and reports SUCCESS for a binding that was never loaded.
|
|
||||||
# A machine with no ledmatrix.service at all is a normal build host, so that
|
|
||||||
# case is skipped rather than treated as a failure.
|
|
||||||
service_present() { systemctl cat ledmatrix.service >/dev/null 2>&1; }
|
|
||||||
|
|
||||||
service_do() {
|
|
||||||
local verb="$1"
|
|
||||||
if ! service_present; then
|
|
||||||
echo " (no ledmatrix.service installed - skipping $verb)"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
systemctl "$verb" ledmatrix || die "systemctl $verb ledmatrix failed"
|
|
||||||
}
|
|
||||||
|
|
||||||
py_site() {
|
|
||||||
python3 -c 'import rgbmatrix, os; print(os.path.dirname(rgbmatrix.__file__))' 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# The extension filename the interpreter that builds -- and then loads -- this
|
|
||||||
# module actually uses, e.g. core.cpython-313-aarch64-linux-gnu.so. The build
|
|
||||||
# venv is made with --system-site-packages from python3, so the two agree;
|
|
||||||
# falling back keeps --install working when the venv has been cleaned up.
|
|
||||||
abi_name() {
|
|
||||||
local py="$VENV/bin/python"
|
|
||||||
[ -x "$py" ] || py=python3
|
|
||||||
"$py" -c \
|
|
||||||
'import sysconfig; print("core" + sysconfig.get_config_var("EXT_SUFFIX"))' \
|
|
||||||
2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# Exactly the current interpreter's artifact, never merely the first one that
|
|
||||||
# sorts. Staging copies $SRC_TREE wholesale, so a core.cpython-*.so left in the
|
|
||||||
# source tree by an earlier build comes along for the ride; build_ext --inplace
|
|
||||||
# only ever overwrites the current ABI's name, and a glob piped to `head -1`
|
|
||||||
# sorts cpython-311 ahead of cpython-313. That installed a stale, unpatched
|
|
||||||
# module as core.so while the GIL check below -- which reads the freshly
|
|
||||||
# generated core.cpp, not the .so -- still reported success.
|
|
||||||
abi_so() {
|
|
||||||
local name path
|
|
||||||
name="$(abi_name)" || return 1
|
|
||||||
[ -n "$name" ] || return 1
|
|
||||||
path="$BUILD_DIR/bindings/python/rgbmatrix/$name"
|
|
||||||
[ -f "$path" ] || return 1
|
|
||||||
printf '%s\n' "$path"
|
|
||||||
}
|
|
||||||
|
|
||||||
do_rollback() {
|
|
||||||
local dst; dst="$(py_site)"
|
|
||||||
[ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
|
||||||
[ -f "$BACKUP" ] || die "no backup at $BACKUP"
|
|
||||||
service_do stop
|
|
||||||
cp -a "$BACKUP" "$dst/core.so" || die "restore failed"
|
|
||||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
|
||||||
service_do start
|
|
||||||
echo "rolled back to the original core.so"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
do_install() {
|
|
||||||
local so dst
|
|
||||||
so="$(abi_so)"; [ -n "$so" ] || die "no built module found - run the build first"
|
|
||||||
dst="$(py_site)"; [ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
|
||||||
|
|
||||||
if [ ! -f "$BACKUP" ]; then
|
|
||||||
cp -a "$dst/core.so" "$BACKUP" || die "could not back up the original"
|
|
||||||
echo "backed up original core.so -> $BACKUP"
|
|
||||||
else
|
|
||||||
echo "backup already present at $BACKUP (keeping the true original)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
service_do stop
|
|
||||||
cp "$so" "$dst/core.so" || die "install failed"
|
|
||||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
|
||||||
service_do start
|
|
||||||
|
|
||||||
echo "waiting 25s for the display to come back..."
|
|
||||||
sleep 25
|
|
||||||
local healthy=1
|
|
||||||
systemctl is-active --quiet ledmatrix || healthy=0
|
|
||||||
if journalctl -u ledmatrix --since "40 sec ago" --no-pager \
|
|
||||||
| grep -qiE "Traceback|ImportError|Segmentation fault|undefined symbol"; then
|
|
||||||
healthy=0
|
|
||||||
fi
|
|
||||||
if [ "$healthy" = "1" ]; then
|
|
||||||
echo "SUCCESS - running on the rebuilt binding"
|
|
||||||
else
|
|
||||||
echo "UNHEALTHY - rolling back"
|
|
||||||
cp -a "$BACKUP" "$dst/core.so" \
|
|
||||||
|| echo "ROLLBACK FAILED: could not restore $BACKUP -> $dst/core.so" >&2
|
|
||||||
if service_present && ! systemctl restart ledmatrix; then
|
|
||||||
echo "ROLLBACK FAILED: ledmatrix did not restart - the display is" \
|
|
||||||
"down; restore manually with 'sudo bash $0 --rollback'" >&2
|
|
||||||
fi
|
|
||||||
journalctl -u ledmatrix --since "90 sec ago" --no-pager | tail -25
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
--rollback) do_rollback ;;
|
|
||||||
--install) do_install ;;
|
|
||||||
"" ) ;;
|
|
||||||
*) die "unknown option: $1" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- build ----
|
|
||||||
[ -d "$SRC_TREE" ] || die "matrix source tree not found at $SRC_TREE (set RGB_SRC_TREE)"
|
|
||||||
command -v g++ >/dev/null || die "g++ not installed (apt install build-essential)"
|
|
||||||
|
|
||||||
echo "==> staging a scratch copy at $BUILD_DIR"
|
|
||||||
rm -rf "$BUILD_DIR"
|
|
||||||
cp -r "$SRC_TREE" "$BUILD_DIR" || die "copy failed"
|
|
||||||
|
|
||||||
# Drop any extension artifacts that came across from the source tree. Nothing
|
|
||||||
# downstream should be able to pick one up, and build_ext --inplace can decide
|
|
||||||
# a copied .so is already up to date and skip the compile entirely.
|
|
||||||
find "$BUILD_DIR/bindings/python/rgbmatrix" -maxdepth 1 \
|
|
||||||
-name 'core*.so' -delete 2>/dev/null
|
|
||||||
|
|
||||||
echo "==> patching the bindings to release the GIL"
|
|
||||||
python3 - "$BUILD_DIR" "$PATCH_BLIT" <<'PYEOF' || die "patch failed"
|
|
||||||
import io
|
|
||||||
import sys
|
|
||||||
|
|
||||||
base = sys.argv[1] + "/bindings/python/rgbmatrix/"
|
|
||||||
patch_blit = len(sys.argv) > 2 and sys.argv[2] == "1"
|
|
||||||
|
|
||||||
# --- declare SwapOnVSync as nogil ---------------------------------------
|
|
||||||
p = base + "cppinc.pxd"
|
|
||||||
s = io.open(p, encoding="utf-8").read()
|
|
||||||
OLD_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t)\n"
|
|
||||||
NEW_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t) nogil\n"
|
|
||||||
if OLD_DECL in s:
|
|
||||||
io.open(p, "w", encoding="utf-8", newline="\n").write(s.replace(OLD_DECL, NEW_DECL, 1))
|
|
||||||
print(" cppinc.pxd: SwapOnVSync declared nogil")
|
|
||||||
elif NEW_DECL in s:
|
|
||||||
print(" cppinc.pxd: already nogil")
|
|
||||||
else:
|
|
||||||
sys.exit("could not find the SwapOnVSync declaration")
|
|
||||||
|
|
||||||
# --- release the GIL across the vsync wait ------------------------------
|
|
||||||
p = base + "core.pyx"
|
|
||||||
s = io.open(p, encoding="utf-8").read()
|
|
||||||
|
|
||||||
OLD_SWAP = (
|
|
||||||
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
|
|
||||||
" return __createFrameCanvas("
|
|
||||||
"self.__matrix.SwapOnVSync(newFrame.__canvas, framerate_fraction))\n"
|
|
||||||
)
|
|
||||||
NEW_SWAP = (
|
|
||||||
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
|
|
||||||
" # Blocks until the panel's next vertical sync. Holding the GIL\n"
|
|
||||||
" # across that wait starves every other Python thread for most of\n"
|
|
||||||
" # each frame. Pointers are hoisted into C locals so the blocking\n"
|
|
||||||
" # call itself needs no Python state.\n"
|
|
||||||
" cdef cppinc.RGBMatrix* matrix = self.__matrix\n"
|
|
||||||
" cdef cppinc.FrameCanvas* frame = newFrame.__canvas\n"
|
|
||||||
" cdef uint8_t fraction = framerate_fraction\n"
|
|
||||||
" cdef cppinc.FrameCanvas* swapped\n"
|
|
||||||
" with nogil:\n"
|
|
||||||
" swapped = matrix.SwapOnVSync(frame, fraction)\n"
|
|
||||||
" return __createFrameCanvas(swapped)\n"
|
|
||||||
)
|
|
||||||
if OLD_SWAP in s:
|
|
||||||
s = s.replace(OLD_SWAP, NEW_SWAP, 1)
|
|
||||||
print(" core.pyx: SwapOnVSync releases the GIL")
|
|
||||||
elif "swapped = matrix.SwapOnVSync(frame, fraction)" in s:
|
|
||||||
print(" core.pyx: SwapOnVSync already patched")
|
|
||||||
else:
|
|
||||||
sys.exit("could not find the SwapOnVSync body")
|
|
||||||
|
|
||||||
# --- optional: release the GIL across the blit --------------------------
|
|
||||||
OLD_BLIT = (
|
|
||||||
" buffer = get_pillow_buffer(image_capsule)\n"
|
|
||||||
"\n"
|
|
||||||
" for col in range(max(0, -xstart), min(width, frame_width - xstart)):\n"
|
|
||||||
" for row in range(max(0, -ystart), min(height, frame_height - ystart)):\n"
|
|
||||||
" pixel = buffer[row][col]\n"
|
|
||||||
" r = (pixel ) & 0xFF\n"
|
|
||||||
" g = (pixel >> 8) & 0xFF\n"
|
|
||||||
" b = (pixel >> 16) & 0xFF\n"
|
|
||||||
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
|
|
||||||
)
|
|
||||||
NEW_BLIT = (
|
|
||||||
" buffer = get_pillow_buffer(image_capsule)\n"
|
|
||||||
"\n"
|
|
||||||
" # Bounds hoisted so the blit needs no Python state and can run\n"
|
|
||||||
" # without the GIL: it touches only a C buffer and a C++ canvas.\n"
|
|
||||||
" # NOTE: row-major order makes a torn frame show as a horizontal\n"
|
|
||||||
" # split across the panel's halves. See the header before enabling.\n"
|
|
||||||
" cdef int col_start = max(0, -xstart)\n"
|
|
||||||
" cdef int col_end = min(width, frame_width - xstart)\n"
|
|
||||||
" cdef int row_start = max(0, -ystart)\n"
|
|
||||||
" cdef int row_end = min(height, frame_height - ystart)\n"
|
|
||||||
"\n"
|
|
||||||
" with nogil:\n"
|
|
||||||
" for row in range(row_start, row_end):\n"
|
|
||||||
" for col in range(col_start, col_end):\n"
|
|
||||||
" pixel = buffer[row][col]\n"
|
|
||||||
" r = (pixel ) & 0xFF\n"
|
|
||||||
" g = (pixel >> 8) & 0xFF\n"
|
|
||||||
" b = (pixel >> 16) & 0xFF\n"
|
|
||||||
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
|
|
||||||
)
|
|
||||||
if patch_blit:
|
|
||||||
if OLD_BLIT in s:
|
|
||||||
s = s.replace(OLD_BLIT, NEW_BLIT, 1)
|
|
||||||
print(" core.pyx: pixel blit releases the GIL, row-major")
|
|
||||||
elif "for row in range(row_start, row_end):" in s:
|
|
||||||
print(" core.pyx: blit already patched")
|
|
||||||
else:
|
|
||||||
sys.exit("could not find the SetPixelsPillow loop")
|
|
||||||
else:
|
|
||||||
print(" core.pyx: blit left unpatched (RGB_PATCH_BLIT=1 to enable)")
|
|
||||||
|
|
||||||
io.open(p, "w", encoding="utf-8", newline="\n").write(s)
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
echo "==> building librgbmatrix.a (this takes a few minutes)"
|
|
||||||
nice -n 10 make -C "$BUILD_DIR/lib" -j2 >/dev/null 2>&1 \
|
|
||||||
|| die "library build failed - rerun 'make -C $BUILD_DIR/lib' to see why"
|
|
||||||
[ -f "$BUILD_DIR/lib/librgbmatrix.a" ] || die "librgbmatrix.a was not produced"
|
|
||||||
|
|
||||||
echo "==> preparing Cython"
|
|
||||||
[ -d "$VENV" ] || python3 -m venv --system-site-packages "$VENV" || die "venv failed"
|
|
||||||
"$VENV/bin/pip" install --quiet cython || die "cython install failed"
|
|
||||||
|
|
||||||
cat > "$BUILD_DIR/bindings/python/setup.py" <<'EOF'
|
|
||||||
from setuptools import setup, Extension
|
|
||||||
from Cython.Build import cythonize
|
|
||||||
|
|
||||||
core = Extension(
|
|
||||||
"rgbmatrix.core",
|
|
||||||
sources=["rgbmatrix/core.pyx", "rgbmatrix/shims/pillow.c"],
|
|
||||||
include_dirs=["../../include", "rgbmatrix/shims"],
|
|
||||||
extra_objects=["../../lib/librgbmatrix.a"],
|
|
||||||
language="c++",
|
|
||||||
extra_compile_args=["-O3", "-Wall", "-fno-exceptions", "-std=c++11"],
|
|
||||||
extra_link_args=["-lrt", "-lm", "-lpthread"],
|
|
||||||
)
|
|
||||||
|
|
||||||
setup(name="rgbmatrix",
|
|
||||||
ext_modules=cythonize([core], language_level="3str",
|
|
||||||
compiler_directives={"binding": False}))
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "==> compiling the extension"
|
|
||||||
( cd "$BUILD_DIR/bindings/python" && "$VENV/bin/python" setup.py build_ext --inplace ) \
|
|
||||||
>/dev/null 2>&1 || die "extension build failed"
|
|
||||||
|
|
||||||
SO="$(abi_so)" || true
|
|
||||||
[ -n "$SO" ] || die "no .so produced - expected $(abi_name) in $BUILD_DIR/bindings/python/rgbmatrix"
|
|
||||||
|
|
||||||
# Verify the GIL really is released before anyone installs this.
|
|
||||||
EXPECTED=1; [ "$PATCH_BLIT" = "1" ] && EXPECTED=2
|
|
||||||
PAIRS=$(grep -c "PyEval_SaveThread\|Py_UNBLOCK_THREADS" \
|
|
||||||
"$BUILD_DIR/bindings/python/rgbmatrix/core.cpp")
|
|
||||||
[ "$PAIRS" -ge "$EXPECTED" ] \
|
|
||||||
|| die "generated C++ has $PAIRS GIL-release sites, expected >= $EXPECTED"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "BUILT: $SO"
|
|
||||||
echo " ($PAIRS GIL-release site(s) in the generated C++)"
|
|
||||||
echo
|
|
||||||
echo "Install with: sudo bash $0 --install"
|
|
||||||
echo "Roll back with: sudo bash $0 --rollback"
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# DNS single-request fix installation script.
|
|
||||||
#
|
|
||||||
# Optional. Install this only if plugins that call external APIs (Starlark
|
|
||||||
# apps, weather, sports, music) are timing out or feel slow to first paint
|
|
||||||
# while the network is otherwise fine. See the header of
|
|
||||||
# scripts/utils/apply_dns_single_request.sh for what it changes and why.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
|
||||||
SERVICE_NAME="ledmatrix-dns-fix"
|
|
||||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
|
||||||
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
|
|
||||||
DROPIN_DIR="/etc/systemd/system/ledmatrix.service.d"
|
|
||||||
|
|
||||||
if [ "$EUID" -eq 0 ]; then
|
|
||||||
SYSTEMCTL_CMD="systemctl"
|
|
||||||
SUDO=""
|
|
||||||
else
|
|
||||||
SYSTEMCTL_CMD="sudo systemctl"
|
|
||||||
SUDO="sudo"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Installing LED Matrix DNS fix service"
|
|
||||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
|
||||||
|
|
||||||
if [ ! -f "$UNIT_SRC" ]; then
|
|
||||||
echo "✗ Missing unit file: $UNIT_SRC"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
chmod +x "$PROJECT_ROOT_DIR/scripts/utils/apply_dns_single_request.sh"
|
|
||||||
|
|
||||||
echo "Installing $UNIT_DEST..."
|
|
||||||
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
|
||||||
| $SUDO tee "$UNIT_DEST" > /dev/null
|
|
||||||
|
|
||||||
# Order ledmatrix.service after the fix. `Before=` in the unit itself only
|
|
||||||
# orders units already in the same transaction, so a plain
|
|
||||||
# `systemctl restart ledmatrix` would not wait for it -- and since this fix is
|
|
||||||
# opt-in, ledmatrix.service cannot carry the dependency in the repo.
|
|
||||||
# Wants=, not Requires=: a DNS workaround failing should not stop the display.
|
|
||||||
echo "Installing the ledmatrix.service ordering drop-in..."
|
|
||||||
$SUDO mkdir -p "$DROPIN_DIR"
|
|
||||||
printf '[Unit]\nWants=%s.service\nAfter=%s.service\n' "$SERVICE_NAME" "$SERVICE_NAME" \
|
|
||||||
| $SUDO tee "$DROPIN_DIR/10-dns-fix.conf" > /dev/null
|
|
||||||
|
|
||||||
$SYSTEMCTL_CMD daemon-reload
|
|
||||||
$SYSTEMCTL_CMD enable "$SERVICE_NAME.service"
|
|
||||||
|
|
||||||
# Do not mask a failure here. The unit exits non-zero when it could not apply
|
|
||||||
# the option -- a systemd-resolved host, an unwritable resolv.conf, a failed
|
|
||||||
# `resolvconf -u` -- and reporting "installation complete" over that would
|
|
||||||
# leave the operator believing a workaround is active when it is not.
|
|
||||||
START_STATUS=0
|
|
||||||
$SYSTEMCTL_CMD start "$SERVICE_NAME.service" || START_STATUS=$?
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
if grep -qs "^options single-request$" /etc/resolv.conf; then
|
|
||||||
echo "✓ 'options single-request' is active in /etc/resolv.conf"
|
|
||||||
elif [ "$START_STATUS" -ne 0 ]; then
|
|
||||||
echo "✗ The DNS fix could not be applied on this host."
|
|
||||||
echo " The service reported why:"
|
|
||||||
echo " journalctl -u $SERVICE_NAME -n 20"
|
|
||||||
echo ""
|
|
||||||
echo " The unit is installed and will try again on the next boot. Nothing"
|
|
||||||
echo " else about your install has changed."
|
|
||||||
exit "$START_STATUS"
|
|
||||||
else
|
|
||||||
echo "⚠ 'options single-request' is not in /etc/resolv.conf yet."
|
|
||||||
echo " Check what the service reported:"
|
|
||||||
echo " journalctl -u $SERVICE_NAME -n 20"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "DNS fix installation complete."
|
|
||||||
echo ""
|
|
||||||
echo "Useful commands:"
|
|
||||||
echo " sudo systemctl status $SERVICE_NAME # Check status"
|
|
||||||
echo " sudo journalctl -u $SERVICE_NAME -n 50 # View logs"
|
|
||||||
echo " sudo systemctl disable --now $SERVICE_NAME # Undo the service"
|
|
||||||
echo " sudo rm $DROPIN_DIR/10-dns-fix.conf # Undo the ordering drop-in"
|
|
||||||
echo " # then remove the 'options single-request' line from /etc/resolv.conf"
|
|
||||||
echo ""
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Home Assistant MQTT bridge installation script.
|
|
||||||
#
|
|
||||||
# Optional. Installs integrations/mqtt_bridge as a service so Home Assistant
|
|
||||||
# can force display modes, toggle power and set brightness over MQTT.
|
|
||||||
# See integrations/mqtt_bridge/README.md.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
|
||||||
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
|
|
||||||
SERVICE_NAME="ledmatrix-mqtt-bridge"
|
|
||||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
|
||||||
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
|
|
||||||
|
|
||||||
if [ "$EUID" -eq 0 ]; then
|
|
||||||
SYSTEMCTL_CMD="systemctl"
|
|
||||||
SUDO=""
|
|
||||||
else
|
|
||||||
SYSTEMCTL_CMD="sudo systemctl"
|
|
||||||
SUDO="sudo"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Installing LED Matrix MQTT bridge"
|
|
||||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
|
||||||
|
|
||||||
if [ ! -f "$BRIDGE_DIR/bridge_config.json" ]; then
|
|
||||||
cp "$BRIDGE_DIR/bridge_config.example.json" "$BRIDGE_DIR/bridge_config.json"
|
|
||||||
chmod 600 "$BRIDGE_DIR/bridge_config.json"
|
|
||||||
echo ""
|
|
||||||
echo "⚠ Created $BRIDGE_DIR/bridge_config.json from the example."
|
|
||||||
echo " Edit it with your broker details, then re-run this script."
|
|
||||||
echo " The service will refuse to start until the placeholder password is replaced."
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Installing Python dependencies..."
|
|
||||||
python3 -m pip install -r "$BRIDGE_DIR/requirements.txt" 2>/dev/null \
|
|
||||||
|| python3 -m pip install --break-system-packages -r "$BRIDGE_DIR/requirements.txt"
|
|
||||||
|
|
||||||
echo "Installing $UNIT_DEST..."
|
|
||||||
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
|
||||||
| $SUDO tee "$UNIT_DEST" > /dev/null
|
|
||||||
|
|
||||||
$SYSTEMCTL_CMD daemon-reload
|
|
||||||
$SYSTEMCTL_CMD enable "$SERVICE_NAME.service"
|
|
||||||
$SYSTEMCTL_CMD restart "$SERVICE_NAME.service" || true
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
if $SYSTEMCTL_CMD is-active --quiet "$SERVICE_NAME.service" 2>/dev/null; then
|
|
||||||
echo "✓ MQTT bridge is running"
|
|
||||||
echo " The matrix should appear in Home Assistant under Settings > Devices > MQTT."
|
|
||||||
else
|
|
||||||
echo "⚠ MQTT bridge is not running. Check the logs:"
|
|
||||||
echo " sudo journalctl -u $SERVICE_NAME -n 50"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Useful commands:"
|
|
||||||
echo " sudo systemctl status $SERVICE_NAME"
|
|
||||||
echo " sudo journalctl -u $SERVICE_NAME -f"
|
|
||||||
echo " sudo systemctl disable --now $SERVICE_NAME # Undo"
|
|
||||||
echo ""
|
|
||||||
@@ -6,9 +6,6 @@ Discovers and runs tests for LEDMatrix plugins.
|
|||||||
Supports both unittest and pytest.
|
Supports both unittest and pytest.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -71,79 +68,6 @@ def _find_tests_in_dir(directory: Path) -> list:
|
|||||||
return sorted(set(test_files))
|
return sorted(set(test_files))
|
||||||
|
|
||||||
|
|
||||||
def _is_script_style(path) -> bool:
|
|
||||||
"""True when a test file is a standalone script, not a pytest module.
|
|
||||||
|
|
||||||
Most plugin tests are written as `def main()` plus an `if __name__ ==
|
|
||||||
"__main__"` guard and signal through an exit code. pytest collects zero
|
|
||||||
items from those, so handing them to pytest printed "no tests ran" and this
|
|
||||||
runner reported success over work it had not done -- 151 of 248 files on a
|
|
||||||
fully populated rig.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
src = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
has_pytest_items = re.search(r"^\s*(def test_|class Test|async def test_)", src, re.M)
|
|
||||||
has_main_guard = "__main__" in src and "__name__" in src
|
|
||||||
return bool(has_main_guard and not has_pytest_items)
|
|
||||||
|
|
||||||
|
|
||||||
def run_script_tests(test_files: list, verbose: bool = False) -> int:
|
|
||||||
"""Run standalone test scripts, honouring the 0 pass / 2 skip / 1 fail
|
|
||||||
convention that ledmatrix-plugins' own runner established.
|
|
||||||
|
|
||||||
Scripts opt into skipping by printing "SKIP: <reason>" and exiting 2 --
|
|
||||||
a script that needs a tty or an LED matrix is not a regression.
|
|
||||||
"""
|
|
||||||
env = dict(os.environ)
|
|
||||||
# Prepend rather than setdefault. An inherited PYTHONPATH -- a developer's
|
|
||||||
# shell, a tox run, another checkout -- otherwise wins outright, and the
|
|
||||||
# subprocess imports a different copy of the core than the one under test.
|
|
||||||
# That is exactly the failure ledmatrix-plugins#467 describes, and it is
|
|
||||||
# invisible: the tests pass or fail against a tree nobody meant to test.
|
|
||||||
inherited = env.get("PYTHONPATH")
|
|
||||||
env["PYTHONPATH"] = (f"{PROJECT_ROOT}{os.pathsep}{inherited}"
|
|
||||||
if inherited else str(PROJECT_ROOT))
|
|
||||||
env["LEDMATRIX_CORE"] = str(PROJECT_ROOT)
|
|
||||||
|
|
||||||
passed = skipped = failed = 0
|
|
||||||
failures = []
|
|
||||||
for path in test_files:
|
|
||||||
try:
|
|
||||||
# Fixed interpreter (sys.executable) plus a test path this script
|
|
||||||
# discovered by globbing the repo; argument list, no shell, so
|
|
||||||
# nothing is word-split or expanded. Same suppression pair the
|
|
||||||
# rest of the repo uses for this shape (see permission_utils.py).
|
|
||||||
proc = subprocess.run( # noqa: S603 # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
|
||||||
[sys.executable, str(path)], # nosemgrep
|
|
||||||
cwd=str(Path(path).parent),
|
|
||||||
capture_output=True, text=True, env=env,
|
|
||||||
stdin=subprocess.DEVNULL, timeout=300,
|
|
||||||
)
|
|
||||||
rc = proc.returncode
|
|
||||||
tail = " | ".join((proc.stdout or proc.stderr or "").strip().splitlines()[-2:])[:200]
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
rc, tail = 1, "timed out after 300s"
|
|
||||||
if rc == 0:
|
|
||||||
passed += 1
|
|
||||||
label = "pass"
|
|
||||||
elif rc == 2:
|
|
||||||
skipped += 1
|
|
||||||
label = "SKIP"
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
label = "FAIL"
|
|
||||||
failures.append(f"{Path(path).name}: exit {rc} | {tail}")
|
|
||||||
if verbose or rc != 0:
|
|
||||||
print(f" [{label}] {Path(path).name}" + (f" -- {tail}" if rc != 0 else ""))
|
|
||||||
|
|
||||||
print(f"\n{passed} passed, {skipped} skipped, {failed} failed (scripts)")
|
|
||||||
for f in failures:
|
|
||||||
print(f" - {f}", file=sys.stderr)
|
|
||||||
return 1 if failed else 0
|
|
||||||
|
|
||||||
|
|
||||||
def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
|
def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
|
||||||
"""
|
"""
|
||||||
Run tests using unittest.
|
Run tests using unittest.
|
||||||
@@ -262,12 +186,7 @@ def main():
|
|||||||
print("No test files found in plugins directory")
|
print("No test files found in plugins directory")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
scripts = [f for f in test_files if _is_script_style(f)]
|
print(f"Found {len(test_files)} test file(s)")
|
||||||
modules = [f for f in test_files if f not in scripts]
|
|
||||||
|
|
||||||
print(f"Found {len(test_files)} test file(s)"
|
|
||||||
+ (f" -- {len(modules)} collectable, {len(scripts)} standalone script(s)"
|
|
||||||
if scripts else ""))
|
|
||||||
for test_file in test_files:
|
for test_file in test_files:
|
||||||
print(f" - {test_file}")
|
print(f" - {test_file}")
|
||||||
print()
|
print()
|
||||||
@@ -281,20 +200,11 @@ def main():
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
runner = 'unittest'
|
runner = 'unittest'
|
||||||
|
|
||||||
# Standalone scripts cannot be collected by pytest or unittest -- run them
|
# Run tests
|
||||||
# as the scripts they are. Doing this rather than silently collecting zero
|
|
||||||
# items is the whole point: this runner used to report success having
|
|
||||||
# executed nothing.
|
|
||||||
rc = 0
|
|
||||||
if scripts:
|
|
||||||
rc |= run_script_tests(scripts, args.verbose)
|
|
||||||
|
|
||||||
if modules:
|
|
||||||
if runner == 'pytest':
|
if runner == 'pytest':
|
||||||
rc |= run_pytest_tests(modules, args.verbose, args.coverage)
|
return run_pytest_tests(test_files, args.verbose, args.coverage)
|
||||||
else:
|
else:
|
||||||
rc |= run_unittest_tests(modules, args.verbose)
|
return run_unittest_tests(test_files, args.verbose)
|
||||||
return rc
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -1,240 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Show and try the scroll speeds your panel can display cleanly.
|
|
||||||
|
|
||||||
Motion looks smooth when the strip advances a WHOLE number of pixels per panel
|
|
||||||
refresh. Anything else has to blend two columns (which on pixel-font text reads
|
|
||||||
as shimmer) or repeat frames unevenly (which reads as judder). So the speeds
|
|
||||||
worth using are not arbitrary -- they are
|
|
||||||
|
|
||||||
refresh_hz / frame_hold * pixels_per_frame
|
|
||||||
|
|
||||||
for whole numbers of frame_hold and pixels_per_frame, and that ladder depends
|
|
||||||
on how fast YOUR panel actually refreshes. A Pi Zero driving a big chain will
|
|
||||||
have a completely different set of good speeds from a Pi 4 driving a small one.
|
|
||||||
|
|
||||||
# what can this panel do? (no hardware needed, uses your configured rate)
|
|
||||||
python3 scripts/scroll_speeds.py
|
|
||||||
|
|
||||||
# measure what the panel ACTUALLY manages, rather than what is configured
|
|
||||||
sudo systemctl stop ledmatrix
|
|
||||||
sudo python3 scripts/scroll_speeds.py --measure
|
|
||||||
sudo systemctl start ledmatrix
|
|
||||||
|
|
||||||
# what would a 60Hz panel offer?
|
|
||||||
python3 scripts/scroll_speeds.py --hz 60
|
|
||||||
|
|
||||||
# try one on the panel
|
|
||||||
sudo systemctl stop ledmatrix
|
|
||||||
sudo python3 scripts/scroll_speeds.py --demo 50
|
|
||||||
sudo systemctl start ledmatrix
|
|
||||||
|
|
||||||
This script never starts or stops the display service itself -- that is left to
|
|
||||||
you, so a crash here can never leave the panel dark.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from src.common import scroll_config # noqa: E402
|
|
||||||
|
|
||||||
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
|
|
||||||
|
|
||||||
|
|
||||||
def load_hardware():
|
|
||||||
try:
|
|
||||||
with open(CONFIG, encoding="utf-8") as handle:
|
|
||||||
return (json.load(handle).get("display") or {}).get("hardware") or {}
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
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))
|
|
||||||
o.brightness = int(hardware.get("brightness", 80))
|
|
||||||
o.hardware_mapping = hardware.get("hardware_mapping", "regular")
|
|
||||||
o.pwm_bits = int(hardware.get("pwm_bits", 11))
|
|
||||||
o.pwm_dither_bits = int(hardware.get("pwm_dither_bits", 0))
|
|
||||||
o.pwm_lsb_nanoseconds = int(hardware.get("pwm_lsb_nanoseconds", 130))
|
|
||||||
o.led_rgb_sequence = hardware.get("led_rgb_sequence", "RGB")
|
|
||||||
o.scan_mode = int(hardware.get("scan_mode", 0))
|
|
||||||
o.row_address_type = int(hardware.get("row_address_type", 0))
|
|
||||||
o.multiplexing = int(hardware.get("multiplexing", 0))
|
|
||||||
o.gpio_slowdown = int(hardware.get("gpio_slowdown", 2))
|
|
||||||
o.limit_refresh_rate_hz = (
|
|
||||||
int(refresh_override) if refresh_override is not None
|
|
||||||
else int(hardware.get("limit_refresh_rate_hz", 0))
|
|
||||||
)
|
|
||||||
return o
|
|
||||||
|
|
||||||
|
|
||||||
def open_matrix(hardware, refresh_override=None):
|
|
||||||
"""Construct the matrix, or explain why it will not open."""
|
|
||||||
if os.geteuid() != 0:
|
|
||||||
sys.exit("this needs root for GPIO access - rerun with sudo")
|
|
||||||
try:
|
|
||||||
from rgbmatrix import RGBMatrix
|
|
||||||
except ImportError:
|
|
||||||
sys.exit("rgbmatrix is not installed on this machine")
|
|
||||||
try:
|
|
||||||
return RGBMatrix(options=build_options(hardware, refresh_override))
|
|
||||||
except Exception as exc: # pragma: no cover - hardware dependent
|
|
||||||
sys.exit(
|
|
||||||
"could not open the panel ({}).\n"
|
|
||||||
"If the display service is running it owns the GPIO - stop it first:\n"
|
|
||||||
" sudo systemctl stop ledmatrix".format(exc)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def measure_refresh(hardware, seconds=6.0):
|
|
||||||
"""Actual refresh rate, by running uncapped and timing the swaps.
|
|
||||||
|
|
||||||
SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop
|
|
||||||
runs at exactly the panel's rate. This is what an older Pi or a longer
|
|
||||||
chain will really give you, as opposed to whatever limit_refresh_rate_hz
|
|
||||||
optimistically asks for.
|
|
||||||
"""
|
|
||||||
matrix = open_matrix(hardware, refresh_override=0)
|
|
||||||
canvas = matrix.CreateFrameCanvas()
|
|
||||||
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
|
|
||||||
frames = 0
|
|
||||||
started = time.perf_counter()
|
|
||||||
while time.perf_counter() - started < seconds:
|
|
||||||
canvas = matrix.SwapOnVSync(canvas)
|
|
||||||
frames += 1
|
|
||||||
measured = frames / (time.perf_counter() - started)
|
|
||||||
matrix.Clear()
|
|
||||||
return measured
|
|
||||||
|
|
||||||
|
|
||||||
def demo(hardware, target, seconds):
|
|
||||||
"""Scroll text at the crisp speed nearest `target`."""
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
from src.common.font_layout import load_truetype
|
|
||||||
|
|
||||||
hz = float(hardware.get("limit_refresh_rate_hz") or scroll_config.DEFAULT_REFRESH_HZ)
|
|
||||||
choice = scroll_config.solve_crisp(target, hz)
|
|
||||||
print("asked for {:.0f} px/s -> {}".format(target, choice.describe()))
|
|
||||||
|
|
||||||
matrix = open_matrix(hardware)
|
|
||||||
canvas = matrix.CreateFrameCanvas()
|
|
||||||
W, H = canvas.width, canvas.height
|
|
||||||
|
|
||||||
font = None
|
|
||||||
for path, size in (
|
|
||||||
(str(Path(__file__).resolve().parent.parent / "assets/fonts/PressStart2P-Regular.ttf"), 16),
|
|
||||||
("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 26),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
font = load_truetype(path, size)
|
|
||||||
break
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
if font is None:
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
|
|
||||||
text = " {:.0f} px/s *** THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG ***".format(
|
|
||||||
choice.pixels_per_second)
|
|
||||||
box = ImageDraw.Draw(Image.new("RGB", (8, 8))).textbbox((0, 0), text, font=font)
|
|
||||||
tw, th = box[2] - box[0], box[3] - box[1]
|
|
||||||
reps = max(2, (W * 3) // max(tw, 1) + 1)
|
|
||||||
strip = Image.new("RGB", (tw * reps, H), (0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(strip)
|
|
||||||
for i in range(reps):
|
|
||||||
draw.text((i * tw, (H - th) // 2 - box[1]), text, font=font, fill=(255, 210, 60))
|
|
||||||
|
|
||||||
offset = 0
|
|
||||||
frames = 0
|
|
||||||
started = time.time()
|
|
||||||
while time.time() - started < seconds:
|
|
||||||
window = strip.crop((offset, 0, offset + W, H))
|
|
||||||
if window.width < W:
|
|
||||||
whole = Image.new("RGB", (W, H), (0, 0, 0))
|
|
||||||
head = strip.crop((offset, 0, strip.width, H))
|
|
||||||
whole.paste(head, (0, 0))
|
|
||||||
whole.paste(strip.crop((0, 0, W - head.width, H)), (head.width, 0))
|
|
||||||
window = whole
|
|
||||||
canvas.SetImage(window)
|
|
||||||
canvas = matrix.SwapOnVSync(canvas, choice.frame_hold)
|
|
||||||
offset = (offset + choice.pixels_per_frame) % strip.width
|
|
||||||
frames += 1
|
|
||||||
elapsed = time.time() - started
|
|
||||||
print(" {} frames in {:.1f}s = {:.1f} fps = {:.1f} px/s actual".format(
|
|
||||||
frames, elapsed, frames / elapsed, frames * choice.pixels_per_frame / elapsed))
|
|
||||||
matrix.Clear()
|
|
||||||
|
|
||||||
|
|
||||||
def print_ladder(hz, highlight=None):
|
|
||||||
print("")
|
|
||||||
print("Whole-pixel scroll speeds at {:.1f}Hz refresh".format(hz))
|
|
||||||
print("(the panel refreshes at {:.0f}Hz for every one of these - holding a "
|
|
||||||
"frame costs no flicker)".format(hz))
|
|
||||||
print("")
|
|
||||||
for entry in scroll_config.crisp_ladder(hz):
|
|
||||||
if entry.pixels_per_second > hz * 3:
|
|
||||||
break
|
|
||||||
mark = " <-- nearest to {:.0f}".format(highlight) if (
|
|
||||||
highlight is not None
|
|
||||||
and entry.pixels_per_second == scroll_config.solve_crisp(highlight, hz).pixels_per_second
|
|
||||||
) else ""
|
|
||||||
print(" " + entry.describe() + mark)
|
|
||||||
print("")
|
|
||||||
print("Set one in config.json as pixels per second, e.g.")
|
|
||||||
print(' "display_options": {{"scroll_pixels_per_second": {:.0f}}}'.format(
|
|
||||||
scroll_config.solve_crisp(highlight if highlight else hz / 2, hz).pixels_per_second))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__,
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
||||||
ap.add_argument("--hz", type=float,
|
|
||||||
help="refresh rate to compute the ladder for (default: your config)")
|
|
||||||
ap.add_argument("--measure", action="store_true",
|
|
||||||
help="measure the panel's real refresh rate (needs root, service stopped)")
|
|
||||||
ap.add_argument("--demo", type=float, metavar="PXPS",
|
|
||||||
help="scroll text at the crisp speed nearest this (needs root)")
|
|
||||||
ap.add_argument("--seconds", type=float, default=15.0, help="demo duration")
|
|
||||||
ap.add_argument("--want", type=float, metavar="PXPS",
|
|
||||||
help="highlight the entry nearest this speed")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
hardware = load_hardware()
|
|
||||||
configured = float(hardware.get("limit_refresh_rate_hz") or 0)
|
|
||||||
|
|
||||||
if args.demo is not None:
|
|
||||||
demo(hardware, args.demo, args.seconds)
|
|
||||||
return
|
|
||||||
|
|
||||||
if args.measure:
|
|
||||||
measured = measure_refresh(hardware)
|
|
||||||
print("measured panel refresh: {:.1f}Hz".format(measured))
|
|
||||||
if configured:
|
|
||||||
print("configured limit_refresh_rate_hz: {:.0f}".format(configured))
|
|
||||||
if measured < configured * 0.95:
|
|
||||||
print(" -> the panel cannot reach the configured rate; the ladder")
|
|
||||||
print(" below uses what it actually manages")
|
|
||||||
print_ladder(measured, args.want)
|
|
||||||
return
|
|
||||||
|
|
||||||
hz = args.hz or configured or scroll_config.DEFAULT_REFRESH_HZ
|
|
||||||
if not args.hz and not configured:
|
|
||||||
print("no limit_refresh_rate_hz in config; assuming {:.0f}Hz".format(hz))
|
|
||||||
print("run with --measure to find your panel's real rate")
|
|
||||||
print_ladder(hz, args.want)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Drive a sports scoreboard scroll on the panel and report what it did.
|
|
||||||
|
|
||||||
The eight sports scoreboards scroll through ``src/common/sports_scroll.py``,
|
|
||||||
and that path is per-league opt-in: a rig showing static game cards never
|
|
||||||
constructs a SportsScrollDisplay at all, so nothing about its pacing can be
|
|
||||||
observed from a normal run. This drives it directly, with synthetic games, so
|
|
||||||
the pacing can be measured without changing anyone's configuration.
|
|
||||||
|
|
||||||
What it checks is what the shared resolver is supposed to buy:
|
|
||||||
|
|
||||||
* the requested speed lands on a whole number of pixels per refresh
|
|
||||||
* the frame hold that makes that true is published to the display manager
|
|
||||||
* frames actually arrive at the interval the hold implies
|
|
||||||
|
|
||||||
sudo systemctl stop ledmatrix
|
|
||||||
sudo python3 scripts/sports_scroll_check.py --seconds 20
|
|
||||||
sudo systemctl start ledmatrix
|
|
||||||
|
|
||||||
Like scripts/scroll_speeds.py, this never starts or stops the display service
|
|
||||||
itself -- that is left to the caller, so a crash here cannot leave the panel
|
|
||||||
dark.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import statistics
|
|
||||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from PIL import Image # noqa: E402
|
|
||||||
|
|
||||||
from src.common.sports_scroll import SportsScrollDisplay # noqa: E402
|
|
||||||
from src.display_manager import DisplayManager # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
class _Check(SportsScrollDisplay):
|
|
||||||
"""A scoreboard whose cards are plain blocks -- pacing is what matters."""
|
|
||||||
|
|
||||||
SCROLL_LEAGUE_KEYS = ("nfl",)
|
|
||||||
|
|
||||||
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
|
|
||||||
width = self.display_height * 2
|
|
||||||
cards = []
|
|
||||||
for i, _ in enumerate(games):
|
|
||||||
card = Image.new("RGB", (width, self.display_height), (0, 0, 0))
|
|
||||||
shade = 40 + (i * 37) % 180
|
|
||||||
for x in range(2, width - 2):
|
|
||||||
for y in range(2, self.display_height - 2):
|
|
||||||
card.putpixel((x, y), (shade, 90, 220 - shade // 2))
|
|
||||||
cards.append(card)
|
|
||||||
self._current_games = list(games)
|
|
||||||
self._current_game_type = game_type
|
|
||||||
self._current_leagues = list(leagues)
|
|
||||||
self.scroll_helper.create_scrolling_image(content_items=cards, item_gap=24)
|
|
||||||
return bool(cards)
|
|
||||||
|
|
||||||
|
|
||||||
class _HoldSpy:
|
|
||||||
"""Records what the scroll publishes, without changing what it does."""
|
|
||||||
|
|
||||||
def __init__(self, display_manager):
|
|
||||||
self.dm = display_manager
|
|
||||||
self.calls = []
|
|
||||||
self._real = display_manager.set_scrolling_state
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
def spy(is_scrolling, frame_hold=1):
|
|
||||||
self.calls.append((is_scrolling, frame_hold))
|
|
||||||
return self._real(is_scrolling, frame_hold=frame_hold)
|
|
||||||
self.dm.set_scrolling_state = spy
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *exc):
|
|
||||||
self.dm.set_scrolling_state = self._real
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
MESSAGE = """ledmatrix is running and owns the panel's GPIO.
|
|
||||||
|
|
||||||
Stop it first, or this run can leave the display dark:
|
|
||||||
|
|
||||||
sudo systemctl stop ledmatrix
|
|
||||||
sudo python3 scripts/sports_scroll_check.py
|
|
||||||
sudo systemctl start ledmatrix
|
|
||||||
|
|
||||||
Use --fallback to check the pacing logic without the panel, or --force if
|
|
||||||
you really mean it."""
|
|
||||||
|
|
||||||
|
|
||||||
def _refuse_if_the_service_is_running(force):
|
|
||||||
"""Refuse to touch the panel while ledmatrix has it.
|
|
||||||
|
|
||||||
rpi-rgb-led-matrix configures GPIO directions and the hardware PWM inside
|
|
||||||
RGBMatrix(), and on the root check it calls exit() from C -- no cleanup.
|
|
||||||
Do that while the service is driving those same pins and the panel goes
|
|
||||||
dark while the service carries on rendering happily: fresh framebuffer,
|
|
||||||
every pixel lit, "RGB Matrix initialized successfully", nothing in the log.
|
|
||||||
A restart brings it back, but only once you work out that is what happened.
|
|
||||||
|
|
||||||
The module docstring says to stop the service first. This makes it true.
|
|
||||||
"""
|
|
||||||
if force:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
active = subprocess.run( # nosec B603 B607 - hardcoded systemctl args # nosemgrep
|
|
||||||
["systemctl", "is-active", "ledmatrix"],
|
|
||||||
capture_output=True, text=True).stdout.strip()
|
|
||||||
except OSError:
|
|
||||||
return # not a systemd box; nothing to protect
|
|
||||||
if active == "active":
|
|
||||||
sys.exit(MESSAGE)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(
|
|
||||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
||||||
ap.add_argument("--seconds", type=float, default=20.0)
|
|
||||||
ap.add_argument("--speed", type=float, default=None,
|
|
||||||
help="px/s to request; default is the module's own")
|
|
||||||
ap.add_argument("--games", type=int, default=6)
|
|
||||||
ap.add_argument("--force", action="store_true",
|
|
||||||
help="run even though the display service is up. It owns "
|
|
||||||
"the GPIO; expect a dark panel until you restart it.")
|
|
||||||
ap.add_argument("--fallback", action="store_true",
|
|
||||||
help="run without the panel. Driving the real matrix needs "
|
|
||||||
"root; this checks everything except the vsync pacing "
|
|
||||||
"-- what speed resolves to, that the hold is published, "
|
|
||||||
"and that it is released afterwards.")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
if not args.fallback:
|
|
||||||
_refuse_if_the_service_is_running(args.force)
|
|
||||||
|
|
||||||
root = Path(__file__).resolve().parent.parent
|
|
||||||
config = json.loads((root / "config" / "config.json").read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
display_manager = DisplayManager(config, force_fallback=args.fallback)
|
|
||||||
settings = {} if args.speed is None else {
|
|
||||||
"nfl": {"scroll_settings": {"scroll_speed": args.speed}}}
|
|
||||||
|
|
||||||
display = _Check(display_manager, settings, global_config=config)
|
|
||||||
resolved = display._scroll_settings
|
|
||||||
print("resolved: %s" % resolved.describe())
|
|
||||||
print("frame hold: %d refresh(es) per frame" % resolved.frame_hold)
|
|
||||||
if resolved.warning:
|
|
||||||
print("warning: %s" % resolved.warning)
|
|
||||||
|
|
||||||
display.prepare_scroll_content(
|
|
||||||
[{"id": "g%d" % i} for i in range(args.games)], "live", ["nfl"])
|
|
||||||
|
|
||||||
gaps, drawn = [], 0
|
|
||||||
last = None
|
|
||||||
with _HoldSpy(display_manager) as spy:
|
|
||||||
started = time.perf_counter()
|
|
||||||
while time.perf_counter() - started < args.seconds:
|
|
||||||
if not display.display_scroll_frame():
|
|
||||||
break
|
|
||||||
now = time.perf_counter()
|
|
||||||
if last is not None:
|
|
||||||
gaps.append((now - last) * 1000.0)
|
|
||||||
last = now
|
|
||||||
drawn += 1
|
|
||||||
display.clear()
|
|
||||||
|
|
||||||
if not gaps:
|
|
||||||
sys.exit("no frames were drawn -- the scroll never started")
|
|
||||||
|
|
||||||
gaps.sort()
|
|
||||||
expected = 1000.0 * resolved.frame_hold / (resolved.crisp.refresh_hz
|
|
||||||
if resolved.crisp else 100.0)
|
|
||||||
print("\n%d frames in %.1fs -> %.1f fps" % (
|
|
||||||
drawn, args.seconds, drawn / args.seconds))
|
|
||||||
print("frame gap median %.2fms p95 %.2fms max %.2fms (hold implies %.2fms)"
|
|
||||||
% (statistics.median(gaps), gaps[int(len(gaps) * 0.95)], gaps[-1], expected))
|
|
||||||
|
|
||||||
holds = {h for on, h in spy.calls if on}
|
|
||||||
print("published while scrolling: frame_hold=%s" % (sorted(holds) or "NOTHING"))
|
|
||||||
print("released on clear: %s" % any(not on for on, _ in spy.calls))
|
|
||||||
print("display manager hold now: %d (1 means released)"
|
|
||||||
% getattr(display_manager, "_frame_hold", -1))
|
|
||||||
|
|
||||||
if not holds:
|
|
||||||
sys.exit("FAIL: the scroll never told the core it was scrolling")
|
|
||||||
if holds != {resolved.frame_hold}:
|
|
||||||
sys.exit("FAIL: published %s but resolved %d" % (holds, resolved.frame_hold))
|
|
||||||
print("\nOK: the resolved hold reached the panel and was released after")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -9,8 +9,6 @@ This directory contains utility scripts for maintenance and system operations.
|
|||||||
- **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode
|
- **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode
|
||||||
- **`cleanup_venv.sh`** - Cleans up Python virtual environment files
|
- **`cleanup_venv.sh`** - Cleans up Python virtual environment files
|
||||||
- **`clear_python_cache.sh`** - Clears Python cache files (__pycache__, *.pyc, etc.)
|
- **`clear_python_cache.sh`** - Clears Python cache files (__pycache__, *.pyc, etc.)
|
||||||
- **`pixlet_config_editor.sh`** - Opens Pixlet's own config UI for one installed Starlark app
|
|
||||||
- **`apply_dns_single_request.sh`** - Adds `options single-request` to the resolver (run by `ledmatrix-dns-fix.service`)
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -27,31 +25,3 @@ This script is typically called by the systemd service (`ledmatrix-web.service`)
|
|||||||
### WiFi Monitor Daemon
|
### WiFi Monitor Daemon
|
||||||
This daemon is typically run as a systemd service (`ledmatrix-wifi-monitor.service`) and automatically manages WiFi access point mode based on network connectivity.
|
This daemon is typically run as a systemd service (`ledmatrix-wifi-monitor.service`) and automatically manages WiFi access point mode based on network connectivity.
|
||||||
|
|
||||||
|
|
||||||
### Pixlet Config Editor
|
|
||||||
Run it when you want Pixlet's own config form for a Starlark app -- live
|
|
||||||
render preview, cascading dropdowns -- rather than the LEDMatrix one.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/utils/pixlet_config_editor.sh # list installed apps
|
|
||||||
./scripts/utils/pixlet_config_editor.sh penndot_signs # edit, on localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
Deliberately not a service. It stops the display for the length of the
|
|
||||||
session and `pixlet serve` listens with no authentication, so it should only
|
|
||||||
be running while you are actually editing. It backs the config up first and
|
|
||||||
restarts the display on exit, however it exits.
|
|
||||||
|
|
||||||
It binds loopback only, with no flag to change that: anything that can reach
|
|
||||||
`pixlet serve` can rewrite the app's config, and a printed warning is not
|
|
||||||
access control. To edit from another machine, forward the port -- SSH does the
|
|
||||||
authenticating and nothing is left listening on the LAN:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -L 8080:localhost:8080 pi@ledpi.local
|
|
||||||
```
|
|
||||||
|
|
||||||
### Apply DNS Single-Request Fix
|
|
||||||
Installed and run by `ledmatrix-dns-fix.service`; see `systemd/README.md`.
|
|
||||||
Safe to run by hand (`sudo ./scripts/utils/apply_dns_single_request.sh`) and
|
|
||||||
idempotent.
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Add `options single-request` to the system resolver configuration.
|
|
||||||
#
|
|
||||||
# glibc's getaddrinfo() sends the A and AAAA queries for a name in
|
|
||||||
# parallel on one socket. Some routers answer the A query and drop the
|
|
||||||
# AAAA one, so the resolver waits out its full timeout -- about five
|
|
||||||
# seconds -- before returning an address that was already available.
|
|
||||||
# Disabling IPv6 in the kernel does not help: the resolver still asks.
|
|
||||||
#
|
|
||||||
# `single-request` makes it send the two queries one after the other,
|
|
||||||
# which those routers answer correctly. Anything on the matrix that
|
|
||||||
# calls an external API pays that five seconds per lookup otherwise, and
|
|
||||||
# a Starlark app with a render timeout will simply fail instead.
|
|
||||||
#
|
|
||||||
# Idempotent, and safe to run on a machine that does not need it. Run by
|
|
||||||
# ledmatrix-dns-fix.service on every boot, because whatever manages
|
|
||||||
# resolv.conf regenerates it and drops the option again.
|
|
||||||
#
|
|
||||||
# Usage: sudo ./scripts/utils/apply_dns_single_request.sh
|
|
||||||
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
OPTION="options single-request"
|
|
||||||
RESOLVCONF_TAIL="/etc/resolvconf/resolv.conf.d/tail"
|
|
||||||
RESOLV_CONF="/etc/resolv.conf"
|
|
||||||
|
|
||||||
log() { echo "[dns-single-request] $*"; }
|
|
||||||
|
|
||||||
already_applied() {
|
|
||||||
grep -qs "^${OPTION}\$" "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# resolvconf regenerates /etc/resolv.conf from these fragments, so the
|
|
||||||
# tail file is the only place an addition survives. Prefer it when the
|
|
||||||
# directory exists, whether or not resolvconf has run yet.
|
|
||||||
if [ -d "$(dirname "$RESOLVCONF_TAIL")" ]; then
|
|
||||||
if already_applied "$RESOLVCONF_TAIL"; then
|
|
||||||
log "already present in $RESOLVCONF_TAIL"
|
|
||||||
else
|
|
||||||
echo "$OPTION" >> "$RESOLVCONF_TAIL"
|
|
||||||
log "added to $RESOLVCONF_TAIL"
|
|
||||||
fi
|
|
||||||
# Only a missing resolvconf is ignorable. If it is present and the
|
|
||||||
# regeneration fails, /etc/resolv.conf still lacks the option, and
|
|
||||||
# reporting success would be a lie.
|
|
||||||
if command -v resolvconf >/dev/null 2>&1; then
|
|
||||||
if ! resolvconf -u; then
|
|
||||||
log "resolvconf -u failed; $RESOLV_CONF was not regenerated"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# systemd-resolved owns its stub file and rewrites anything appended to it,
|
|
||||||
# and `single-request` is a glibc resolv.conf option with no resolved.conf
|
|
||||||
# equivalent -- so there is nothing this script can do here. Exit non-zero:
|
|
||||||
# the unit would otherwise record success while the workaround is inactive,
|
|
||||||
# which is the failure mode this whole script exists to avoid.
|
|
||||||
if [ -L "$RESOLV_CONF" ] && readlink -f "$RESOLV_CONF" | grep -q "systemd"; then
|
|
||||||
log "$RESOLV_CONF is managed by systemd-resolved."
|
|
||||||
log "'options single-request' is a glibc resolv.conf option and has no"
|
|
||||||
log "resolved.conf equivalent, so it cannot be applied on this host."
|
|
||||||
log "If external API calls are slow, the workaround is to stop using the"
|
|
||||||
log "systemd-resolved stub (see 'man systemd-resolved', NSS/resolv.conf modes)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if already_applied "$RESOLV_CONF"; then
|
|
||||||
log "already present in $RESOLV_CONF"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -w "$RESOLV_CONF" ] && [ -e "$RESOLV_CONF" ]; then
|
|
||||||
log "cannot write $RESOLV_CONF (run with sudo?)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A NetworkManager-generated resolv.conf is regenerated on every connection
|
|
||||||
# change, not only at boot -- and this unit is oneshot with RemainAfterExit,
|
|
||||||
# so it will not re-run within the same boot to put the option back. Say so
|
|
||||||
# rather than implying the fix is permanent. Nothing is silently swallowed:
|
|
||||||
# the append below still happens and still works until the next renewal.
|
|
||||||
if grep -qs "Generated by NetworkManager" "$RESOLV_CONF" \
|
|
||||||
&& [ ! -d "$(dirname "$RESOLVCONF_TAIL")" ]; then
|
|
||||||
log "NOTE: $RESOLV_CONF is generated by NetworkManager and has no"
|
|
||||||
log "resolvconf tail directory to write to. The option is being added, but"
|
|
||||||
log "NetworkManager will drop it on the next connection renewal, and this"
|
|
||||||
log "unit does not run again until the next boot. If lookups go slow again"
|
|
||||||
log "before a reboot, re-run this script."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$OPTION" >> "$RESOLV_CONF"
|
|
||||||
log "added to $RESOLV_CONF"
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Edit an installed Starlark app's config in Pixlet's own config UI.
|
|
||||||
#
|
|
||||||
# `pixlet serve` runs the app for real, so its form has working cascading
|
|
||||||
# dropdowns and option lists fetched live -- useful for an app whose choices
|
|
||||||
# only exist at runtime, or when you want to see the render change as you
|
|
||||||
# type. The LEDMatrix config form now reads the same runtime schema (see
|
|
||||||
# PixletRenderer.extract_schema_via_pixlet), so reach for this when you want
|
|
||||||
# Pixlet's live preview, not because the normal form is missing options.
|
|
||||||
#
|
|
||||||
# Deliberately a script you run and then Ctrl+C, not a service: it stops the
|
|
||||||
# display for the length of the session, and `pixlet serve` listens on a port
|
|
||||||
# with no authentication. Nothing here should be listening when you are not
|
|
||||||
# actually editing.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
|
|
||||||
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
|
|
||||||
#
|
|
||||||
# Binds the LAN by default, matching the web interface, which already serves
|
|
||||||
# 0.0.0.0:5000 with no authentication -- anything that can reach this can
|
|
||||||
# already reconfigure the display there. `pixlet serve` has no authentication
|
|
||||||
# either, so treat both the same way: fine on a home network, not on an open
|
|
||||||
# one. Override the bind and the session length with:
|
|
||||||
#
|
|
||||||
# PIXLET_EDITOR_HOST=127.0.0.1 ./scripts/utils/pixlet_config_editor.sh <app>
|
|
||||||
# PIXLET_EDITOR_TIMEOUT=600 ./scripts/utils/pixlet_config_editor.sh <app>
|
|
||||||
#
|
|
||||||
# For loopback-only editing from another machine, forward the port instead:
|
|
||||||
#
|
|
||||||
# ssh -L 8080:localhost:8080 pi@ledpi.local
|
|
||||||
#
|
|
||||||
# The session always ends by itself after PIXLET_EDITOR_TIMEOUT seconds
|
|
||||||
# (default 30 minutes). The display is stopped while editing, so a session
|
|
||||||
# left open would otherwise leave the panel dark indefinitely -- the timeout
|
|
||||||
# is what makes it safe to start one from the web interface.
|
|
||||||
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
|
||||||
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
|
|
||||||
PORT="${PIXLET_EDITOR_PORT:-8080}"
|
|
||||||
# LAN by default; see the header for why, and how to force loopback.
|
|
||||||
BIND_HOST="${PIXLET_EDITOR_HOST:-0.0.0.0}"
|
|
||||||
# Hard stop, so the display cannot be left off by a forgotten session.
|
|
||||||
EDITOR_TIMEOUT="${PIXLET_EDITOR_TIMEOUT:-1800}"
|
|
||||||
|
|
||||||
APP_ID="${1:-}"
|
|
||||||
|
|
||||||
list_apps() {
|
|
||||||
if [ -d "$APPS_DIR" ]; then
|
|
||||||
find "$APPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf ' %f\n' 2>/dev/null | sort
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ -z "$APP_ID" ]; then
|
|
||||||
echo "Usage: $0 <app_id>"
|
|
||||||
echo ""
|
|
||||||
echo "Installed apps:"
|
|
||||||
list_apps || true
|
|
||||||
[ -n "$(list_apps)" ] || echo " (none found in $APPS_DIR)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
APP_DIR="$APPS_DIR/$APP_ID"
|
|
||||||
if [ ! -d "$APP_DIR" ]; then
|
|
||||||
echo "No such app: $APP_ID"
|
|
||||||
echo ""
|
|
||||||
echo "Installed apps:"
|
|
||||||
list_apps
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
STAR_FILE=$(find "$APP_DIR" -maxdepth 1 -iname "*.star" | head -1)
|
|
||||||
if [ -z "$STAR_FILE" ]; then
|
|
||||||
echo "No .star file found in $APP_DIR"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Same search order the plugin itself uses: the bundled binary for this
|
|
||||||
# architecture first, then PATH -- so this works on an install that never put
|
|
||||||
# pixlet on PATH.
|
|
||||||
find_pixlet() {
|
|
||||||
local arch bundled
|
|
||||||
case "$(uname -s)-$(uname -m)" in
|
|
||||||
Linux-aarch64|Linux-arm64) arch="pixlet-linux-arm64" ;;
|
|
||||||
Linux-x86_64|Linux-amd64) arch="pixlet-linux-amd64" ;;
|
|
||||||
Darwin-arm64) arch="pixlet-darwin-arm64" ;;
|
|
||||||
Darwin-x86_64) arch="pixlet-darwin-amd64" ;;
|
|
||||||
*) arch="" ;;
|
|
||||||
esac
|
|
||||||
bundled="$PROJECT_ROOT_DIR/bin/pixlet/$arch"
|
|
||||||
if [ -n "$arch" ] && [ -x "$bundled" ]; then
|
|
||||||
echo "$bundled"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
command -v pixlet 2>/dev/null || return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
PIXLET_BIN=$(find_pixlet) || {
|
|
||||||
echo "Pixlet not found. Install it with:"
|
|
||||||
echo " ./scripts/download_pixlet.sh"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
CONFIG_FILE="$APP_DIR/config.json"
|
|
||||||
if [ -f "$CONFIG_FILE" ]; then
|
|
||||||
cp "$CONFIG_FILE" "$CONFIG_FILE.backup"
|
|
||||||
echo "Backed up existing config to $CONFIG_FILE.backup"
|
|
||||||
else
|
|
||||||
echo "{}" > "$CONFIG_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
DISPLAY_WAS_RUNNING=false
|
|
||||||
if systemctl is-active --quiet ledmatrix 2>/dev/null; then
|
|
||||||
DISPLAY_WAS_RUNNING=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Restart the display however this exits -- Ctrl+C, an error, or pixlet
|
|
||||||
# dying on its own. Leaving the panel dark because the editor crashed is the
|
|
||||||
# failure worth guarding against.
|
|
||||||
cleanup() {
|
|
||||||
echo ""
|
|
||||||
# Kill the serve child explicitly. `timeout` is started with --foreground so
|
|
||||||
# it shares this script's process group (without that it makes its own, and
|
|
||||||
# a group signal aimed at this script would orphan pixlet with the port
|
|
||||||
# still bound). Belt and braces: signal the recorded pid too, because a
|
|
||||||
# group signal only reaches it while the group is shared.
|
|
||||||
if [ -n "${SERVE_PID:-}" ] && kill -0 "$SERVE_PID" 2>/dev/null; then
|
|
||||||
kill -TERM "$SERVE_PID" 2>/dev/null || true
|
|
||||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
|
||||||
kill -0 "$SERVE_PID" 2>/dev/null || break
|
|
||||||
sleep 0.3
|
|
||||||
done
|
|
||||||
kill -KILL "$SERVE_PID" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
|
||||||
echo "Restarting the display service..."
|
|
||||||
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
|
|
||||||
fi
|
|
||||||
echo "Your config as it was before this session: $CONFIG_FILE.backup"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
|
||||||
echo "Stopping the display service so it does not read config.json mid-write..."
|
|
||||||
sudo systemctl stop ledmatrix
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$BIND_HOST" = "0.0.0.0" ]; then
|
|
||||||
REACH_HOST="$(hostname).local"
|
|
||||||
else
|
|
||||||
REACH_HOST="localhost"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Editing: $APP_ID"
|
|
||||||
echo "App file: $STAR_FILE"
|
|
||||||
echo "URL: http://$REACH_HOST:$PORT/"
|
|
||||||
echo ""
|
|
||||||
if [ "$BIND_HOST" = "0.0.0.0" ]; then
|
|
||||||
echo "Reachable on the LAN, and pixlet serve has no authentication -- the"
|
|
||||||
echo "same footing as the web interface on port 5000. Set"
|
|
||||||
echo "PIXLET_EDITOR_HOST=127.0.0.1 to keep it to this machine."
|
|
||||||
else
|
|
||||||
echo "Listening on $BIND_HOST only. From another machine, forward the port:"
|
|
||||||
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo "Changes save straight to the real config as you make them."
|
|
||||||
echo "Press Ctrl+C when finished - the display restarts automatically."
|
|
||||||
echo "This session stops on its own after ${EDITOR_TIMEOUT}s regardless."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
cd "$APP_DIR"
|
|
||||||
# `timeout` owns the hard stop rather than the caller: the trap above restarts
|
|
||||||
# the display however this exits, so a session that outlives the person who
|
|
||||||
# started it still gives the panel back. Exit 124 is timeout's own code for
|
|
||||||
# "expired", which is a normal end here, not a failure.
|
|
||||||
# --foreground: stay in this script's process group so one signal reaches the
|
|
||||||
# whole session. Backgrounded + `wait` so the EXIT trap can run while the child
|
|
||||||
# is still alive; a foreground child would leave bash waiting on it instead.
|
|
||||||
timeout --foreground "$EDITOR_TIMEOUT" "$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
|
|
||||||
--host "$BIND_HOST" \
|
|
||||||
--port "$PORT" \
|
|
||||||
--no-browser \
|
|
||||||
--saveconfig "$CONFIG_FILE" &
|
|
||||||
SERVE_PID=$!
|
|
||||||
|
|
||||||
status=0
|
|
||||||
wait "$SERVE_PID" || status=$?
|
|
||||||
if [ "$status" -eq 124 ]; then
|
|
||||||
echo "Session reached its ${EDITOR_TIMEOUT}s limit."
|
|
||||||
status=0
|
|
||||||
fi
|
|
||||||
exit "$status"
|
|
||||||
@@ -27,8 +27,6 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
||||||
|
|
||||||
from src.common.font_layout import load_truetype # noqa: E402
|
|
||||||
|
|
||||||
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
||||||
MODES = ("live", "recent", "upcoming")
|
MODES = ("live", "recent", "upcoming")
|
||||||
SPORTS = ("baseball", "basketball", "football", "hockey")
|
SPORTS = ("baseball", "basketball", "football", "hockey")
|
||||||
@@ -54,12 +52,12 @@ class FixtureHost:
|
|||||||
try:
|
try:
|
||||||
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
|
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
|
||||||
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
|
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
|
||||||
fonts['score'] = load_truetype(press, 10)
|
fonts['score'] = ImageFont.truetype(press, 10)
|
||||||
fonts['time'] = load_truetype(press, 8)
|
fonts['time'] = ImageFont.truetype(press, 8)
|
||||||
fonts['team'] = load_truetype(press, 8)
|
fonts['team'] = ImageFont.truetype(press, 8)
|
||||||
fonts['status'] = load_truetype(small, 6)
|
fonts['status'] = ImageFont.truetype(small, 6)
|
||||||
fonts['detail'] = load_truetype(small, 6)
|
fonts['detail'] = ImageFont.truetype(small, 6)
|
||||||
fonts['rank'] = load_truetype(press, 10)
|
fonts['rank'] = ImageFont.truetype(press, 10)
|
||||||
except IOError:
|
except IOError:
|
||||||
default = ImageFont.load_default()
|
default = ImageFont.load_default()
|
||||||
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
|
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from src.common.font_layout import load_truetype
|
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
@@ -450,12 +449,12 @@ class SportsCore(ABC):
|
|||||||
press_start = self._resolve_font_path("PressStart2P-Regular.ttf")
|
press_start = self._resolve_font_path("PressStart2P-Regular.ttf")
|
||||||
four_by_six = self._resolve_font_path("4x6-font.ttf")
|
four_by_six = self._resolve_font_path("4x6-font.ttf")
|
||||||
try:
|
try:
|
||||||
fonts['score'] = load_truetype(press_start, 10)
|
fonts['score'] = ImageFont.truetype(press_start, 10)
|
||||||
fonts['time'] = load_truetype(press_start, 8)
|
fonts['time'] = ImageFont.truetype(press_start, 8)
|
||||||
fonts['team'] = load_truetype(press_start, 8)
|
fonts['team'] = ImageFont.truetype(press_start, 8)
|
||||||
fonts['status'] = load_truetype(four_by_six, 6) # Using 4x6 for status
|
fonts['status'] = ImageFont.truetype(four_by_six, 6) # Using 4x6 for status
|
||||||
fonts['detail'] = load_truetype(four_by_six, 6) # Added detail font
|
fonts['detail'] = ImageFont.truetype(four_by_six, 6) # Added detail font
|
||||||
fonts['rank'] = load_truetype(press_start, 10)
|
fonts['rank'] = ImageFont.truetype(press_start, 10)
|
||||||
self.logger.info("Successfully loaded fonts")
|
self.logger.info("Successfully loaded fonts")
|
||||||
except OSError:
|
except OSError:
|
||||||
# Name the directory we searched: the usual cause is an install
|
# Name the directory we searched: the usual cause is an install
|
||||||
@@ -1032,7 +1031,7 @@ class SportsCore(ABC):
|
|||||||
if os.path.exists(font_path):
|
if os.path.exists(font_path):
|
||||||
# Try loading as TTF first (works for both TTF and some BDF files with PIL)
|
# Try loading as TTF first (works for both TTF and some BDF files with PIL)
|
||||||
if font_path.lower().endswith('.ttf'):
|
if font_path.lower().endswith('.ttf'):
|
||||||
font = load_truetype(font_path, font_size)
|
font = ImageFont.truetype(font_path, font_size)
|
||||||
self.logger.debug(f"Loaded font: {font_name} at size {font_size}")
|
self.logger.debug(f"Loaded font: {font_name} at size {font_size}")
|
||||||
self._font_cache[cache_key] = font
|
self._font_cache[cache_key] = font
|
||||||
return font
|
return font
|
||||||
@@ -1050,7 +1049,7 @@ class SportsCore(ABC):
|
|||||||
# correct one: the newer copies call truetype() on a BDF at
|
# correct one: the newer copies call truetype() on a BDF at
|
||||||
# any size (which simply fails) or refuse BDF outright.
|
# any size (which simply fails) or refuse BDF outright.
|
||||||
try:
|
try:
|
||||||
font = load_truetype(font_path, font_size)
|
font = ImageFont.truetype(font_path, font_size)
|
||||||
self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}")
|
self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}")
|
||||||
self._font_cache[cache_key] = font
|
self._font_cache[cache_key] = font
|
||||||
return font
|
return font
|
||||||
@@ -1062,7 +1061,7 @@ class SportsCore(ABC):
|
|||||||
self._bdf_native_size_cache[font_path] = native_size
|
self._bdf_native_size_cache[font_path] = native_size
|
||||||
if native_size and native_size != font_size:
|
if native_size and native_size != font_size:
|
||||||
try:
|
try:
|
||||||
font = load_truetype(font_path, native_size)
|
font = ImageFont.truetype(font_path, native_size)
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
f"Loaded BDF font: {font_name} at its native size {native_size} "
|
f"Loaded BDF font: {font_name} at its native size {native_size} "
|
||||||
f"(requested {font_size} isn't a valid strike for this file)"
|
f"(requested {font_size} isn't a valid strike for this file)"
|
||||||
@@ -1090,7 +1089,7 @@ class SportsCore(ABC):
|
|||||||
_resolve_font_family_alias(base_default))
|
_resolve_font_family_alias(base_default))
|
||||||
try:
|
try:
|
||||||
if os.path.exists(default_font_path):
|
if os.path.exists(default_font_path):
|
||||||
font = load_truetype(default_font_path, font_size)
|
font = ImageFont.truetype(default_font_path, font_size)
|
||||||
else:
|
else:
|
||||||
self.logger.warning("Default font not found, using PIL default")
|
self.logger.warning("Default font not found, using PIL default")
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
|
|||||||
Vendored
+8
-98
@@ -5,7 +5,6 @@ Handles persistent disk-based caching with atomic writes and error recovery.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -15,11 +14,6 @@ import zlib
|
|||||||
from typing import Dict, Any, Optional, Protocol
|
from typing import Dict, Any, Optional, Protocol
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
try: # optional: large speedup on the cache write path, see _dumps below
|
|
||||||
import orjson
|
|
||||||
except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
|
||||||
orjson = None
|
|
||||||
|
|
||||||
# How old an abandoned write's temp file must be before the sweep removes it.
|
# How old an abandoned write's temp file must be before the sweep removes it.
|
||||||
# A real write holds its temp file for milliseconds, so an hour is far beyond
|
# A real write holds its temp file for milliseconds, so an hour is far beyond
|
||||||
# any in-flight write while still clearing the same day's debris. Deliberately
|
# any in-flight write while still clearing the same day's debris. Deliberately
|
||||||
@@ -46,97 +40,13 @@ class CacheStrategyProtocol(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class DateTimeEncoder(json.JSONEncoder):
|
class DateTimeEncoder(json.JSONEncoder):
|
||||||
"""JSON encoder that handles datetime objects.
|
"""JSON encoder that handles datetime objects."""
|
||||||
|
|
||||||
Retained for the stdlib fallback path and for any caller importing it.
|
|
||||||
"""
|
|
||||||
def default(self, obj: Any) -> Any:
|
def default(self, obj: Any) -> Any:
|
||||||
if isinstance(obj, datetime):
|
if isinstance(obj, datetime):
|
||||||
return obj.isoformat()
|
return obj.isoformat()
|
||||||
return super().default(obj)
|
return super().default(obj)
|
||||||
|
|
||||||
|
|
||||||
def _datetime_default(obj: Any) -> Any:
|
|
||||||
"""Serialise datetimes exactly as DateTimeEncoder did."""
|
|
||||||
if isinstance(obj, datetime):
|
|
||||||
return obj.isoformat()
|
|
||||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_nonfinite(obj: Any) -> Any:
|
|
||||||
"""Non-finite floats -> None, matching what ``orjson.dumps`` writes.
|
|
||||||
|
|
||||||
Only reached once a strict pass has proved there is something to replace,
|
|
||||||
so the ordinary write path never pays for this walk.
|
|
||||||
"""
|
|
||||||
if isinstance(obj, float):
|
|
||||||
return obj if math.isfinite(obj) else None
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return {k: _replace_nonfinite(v) for k, v in obj.items()}
|
|
||||||
if isinstance(obj, (list, tuple)):
|
|
||||||
return [_replace_nonfinite(v) for v in obj]
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
# NON-FINITE FLOATS
|
|
||||||
# -----------------
|
|
||||||
# JSON has no NaN or Infinity. The stdlib emits them anyway as an extension;
|
|
||||||
# orjson refuses to and writes null. That divergence is not acceptable in a
|
|
||||||
# cache whose files outlive the decision of which encoder is installed, so the
|
|
||||||
# policy here is one behaviour on both paths:
|
|
||||||
#
|
|
||||||
# writing non-finite floats become null, whichever encoder is in use
|
|
||||||
# reading files already on disk that carry the stdlib's NaN/Infinity
|
|
||||||
# tokens stay readable, whichever encoder is in use
|
|
||||||
#
|
|
||||||
# Without the write half, installing orjson silently changed cached values.
|
|
||||||
# Without the read half, installing orjson turned every legacy record holding a
|
|
||||||
# NaN into a "corrupted cache file" that DiskCache.get logged as an error and
|
|
||||||
# deleted. Both halves are covered by test/test_cache_nonfinite_floats.py.
|
|
||||||
|
|
||||||
|
|
||||||
if orjson is not None:
|
|
||||||
# Encoding the cache record dominated the background fetch worker: on a
|
|
||||||
# Pi 4, stdlib json.dumps runs ~12ms per MB and holds the GIL for all of
|
|
||||||
# it, which stalls the render thread mid-scroll. orjson measures ~7x
|
|
||||||
# faster on the same payloads (11.9ms -> 1.6ms for 985KB). Decoding gains
|
|
||||||
# far less (~1.3x on large payloads) because the cost there is building
|
|
||||||
# the Python objects, not scanning the text, but it is still free to take.
|
|
||||||
#
|
|
||||||
# OPT_NON_STR_KEYS: stdlib json coerces int/float dict keys to strings;
|
|
||||||
# orjson raises without this, and cache records do carry numeric keys.
|
|
||||||
# OPT_PASSTHROUGH_DATETIME: orjson would otherwise emit its own RFC 3339
|
|
||||||
# form for datetimes instead of calling default(). Routing them through
|
|
||||||
# _datetime_default keeps byte-for-byte parity with the records already
|
|
||||||
# on disk.
|
|
||||||
_DUMPS_OPTS = orjson.OPT_NON_STR_KEYS | orjson.OPT_PASSTHROUGH_DATETIME
|
|
||||||
|
|
||||||
def _dumps(data: Any) -> bytes:
|
|
||||||
return orjson.dumps(data, default=_datetime_default, option=_DUMPS_OPTS)
|
|
||||||
|
|
||||||
def _loads(raw: bytes) -> Any:
|
|
||||||
try:
|
|
||||||
return orjson.loads(raw)
|
|
||||||
except orjson.JSONDecodeError:
|
|
||||||
# Legacy record written by the stdlib path, carrying NaN or
|
|
||||||
# Infinity. Genuinely malformed files raise again from here, as
|
|
||||||
# json.JSONDecodeError, which is what DiskCache.get expects.
|
|
||||||
return json.loads(raw)
|
|
||||||
else:
|
|
||||||
def _dumps(data: Any) -> bytes:
|
|
||||||
try:
|
|
||||||
return json.dumps(data, cls=DateTimeEncoder,
|
|
||||||
allow_nan=False).encode("utf-8")
|
|
||||||
except ValueError:
|
|
||||||
# allow_nan=False is what detects the non-finite values; the walk
|
|
||||||
# runs only now that we know there is one to replace.
|
|
||||||
return json.dumps(_replace_nonfinite(data), cls=DateTimeEncoder,
|
|
||||||
allow_nan=False).encode("utf-8")
|
|
||||||
|
|
||||||
def _loads(raw: bytes) -> Any:
|
|
||||||
return json.loads(raw)
|
|
||||||
|
|
||||||
|
|
||||||
class DiskCache:
|
class DiskCache:
|
||||||
"""Manages persistent disk-based cache."""
|
"""Manages persistent disk-based cache."""
|
||||||
|
|
||||||
@@ -189,8 +99,8 @@ class DiskCache:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
with open(cache_path, 'rb') as f:
|
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||||
record = _loads(f.read())
|
record = json.load(f)
|
||||||
|
|
||||||
# Determine record timestamp (prefer embedded, else file mtime)
|
# Determine record timestamp (prefer embedded, else file mtime)
|
||||||
record_ts = None
|
record_ts = None
|
||||||
@@ -279,12 +189,12 @@ class DiskCache:
|
|||||||
# write path below, and cache files are machine-read only — indenting
|
# write path below, and cache files are machine-read only — indenting
|
||||||
# them just multiplied the bytes written to the SD card.
|
# them just multiplied the bytes written to the SD card.
|
||||||
try:
|
try:
|
||||||
payload = _dumps(data)
|
payload = json.dumps(data, cls=DateTimeEncoder)
|
||||||
except (TypeError, ValueError) as e:
|
except (TypeError, ValueError) as e:
|
||||||
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
digest = zlib.adler32(payload)
|
digest = zlib.adler32(payload.encode('utf-8'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Atomic write to avoid partial/corrupt files
|
# Atomic write to avoid partial/corrupt files
|
||||||
@@ -332,7 +242,7 @@ class DiskCache:
|
|||||||
# wear source (dozens of fsyncs/min on API-heavy
|
# wear source (dozens of fsyncs/min on API-heavy
|
||||||
# installs) for data that can be re-downloaded.
|
# installs) for data that can be re-downloaded.
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, 'wb') as tmp_file:
|
with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
|
||||||
tmp_file.write(payload)
|
tmp_file.write(payload)
|
||||||
os.replace(tmp_path, cache_path)
|
os.replace(tmp_path, cache_path)
|
||||||
self._write_digests[key] = digest
|
self._write_digests[key] = digest
|
||||||
@@ -350,7 +260,7 @@ class DiskCache:
|
|||||||
else:
|
else:
|
||||||
# Fallback: direct write (not atomic, but better than failing)
|
# Fallback: direct write (not atomic, but better than failing)
|
||||||
try:
|
try:
|
||||||
with open(cache_path, 'wb') as cache_file:
|
with open(cache_path, 'w', encoding='utf-8') as cache_file:
|
||||||
cache_file.write(payload)
|
cache_file.write(payload)
|
||||||
self._write_digests[key] = digest
|
self._write_digests[key] = digest
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
@@ -380,7 +290,7 @@ class DiskCache:
|
|||||||
# is a different path, so future sets must keep
|
# is a different path, so future sets must keep
|
||||||
# retrying the primary location.
|
# retrying the primary location.
|
||||||
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
||||||
with open(fallback_path, 'wb') as tmp_file:
|
with open(fallback_path, 'w', encoding='utf-8') as tmp_file:
|
||||||
tmp_file.write(payload)
|
tmp_file.write(payload)
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -23,13 +23,6 @@ from src.common.error_handler import (
|
|||||||
)
|
)
|
||||||
from src.common.api_helper import APIHelper
|
from src.common.api_helper import APIHelper
|
||||||
from src.common.scroll_helper import ScrollHelper
|
from src.common.scroll_helper import ScrollHelper
|
||||||
from src.common import scroll_config
|
|
||||||
from src.common.scroll_config import (
|
|
||||||
ScrollSettings,
|
|
||||||
configure as configure_scroll,
|
|
||||||
resolve as resolve_scroll_settings,
|
|
||||||
refresh_hz_from_config,
|
|
||||||
)
|
|
||||||
from src.common.logo_helper import LogoHelper
|
from src.common.logo_helper import LogoHelper
|
||||||
from src.common.text_helper import TextHelper
|
from src.common.text_helper import TextHelper
|
||||||
|
|
||||||
@@ -67,11 +60,6 @@ __all__ = [
|
|||||||
'log_and_raise',
|
'log_and_raise',
|
||||||
'APIHelper',
|
'APIHelper',
|
||||||
'ScrollHelper',
|
'ScrollHelper',
|
||||||
'scroll_config',
|
|
||||||
'ScrollSettings',
|
|
||||||
'configure_scroll',
|
|
||||||
'resolve_scroll_settings',
|
|
||||||
'refresh_hz_from_config',
|
|
||||||
'LogoHelper',
|
'LogoHelper',
|
||||||
'TextHelper',
|
'TextHelper',
|
||||||
# adaptive layout & images
|
# adaptive layout & images
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
"""One text layout engine, everywhere.
|
|
||||||
|
|
||||||
``PIL.ImageFont.truetype`` picks its layout engine at load time: Raqm when the
|
|
||||||
host Pillow was built with libraqm, Basic otherwise. The two disagree about
|
|
||||||
fractional glyph advances, so the *same* Pillow version renders the *same*
|
|
||||||
string differently depending on a build option of the host.
|
|
||||||
|
|
||||||
That is invisible for ``PressStart2P-Regular.ttf`` at 8px, whose advances are
|
|
||||||
whole pixels either way — which is why most of the fleet's golden images
|
|
||||||
matched on every machine. It is not invisible for ``4x6-font.ttf`` at 6px,
|
|
||||||
where the advances are fractional: glyph positions drift cumulatively along a
|
|
||||||
run, and the committed goldens for geochron, of-the-day, christmas-countdown
|
|
||||||
and ledmatrix-weather's almanac passed on the machine that generated them and
|
|
||||||
failed everywhere else (ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391).
|
|
||||||
|
|
||||||
Pinning the Basic engine makes a render depend on the font file and the size,
|
|
||||||
and nothing else. Basic gives up complex-script shaping (Arabic, Indic) and
|
|
||||||
kerning pairs; neither applies to the bitmap-grid faces this project draws
|
|
||||||
with on an LED panel.
|
|
||||||
|
|
||||||
Use :func:`load_truetype` in place of ``ImageFont.truetype`` anywhere the
|
|
||||||
result is drawn to a panel or compared against a golden image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Union
|
|
||||||
|
|
||||||
from PIL import ImageFont
|
|
||||||
|
|
||||||
#: The engine every core font load pins. Named once so the reason above has a
|
|
||||||
#: single referent, and so a future change is one line.
|
|
||||||
LAYOUT_ENGINE = ImageFont.Layout.BASIC
|
|
||||||
|
|
||||||
|
|
||||||
def load_truetype(font: Union[str, Any], size: int, **kwargs: Any) -> ImageFont.FreeTypeFont:
|
|
||||||
"""``ImageFont.truetype`` with the layout engine pinned.
|
|
||||||
|
|
||||||
Same signature and same exceptions as the PIL call it replaces, so it is a
|
|
||||||
drop-in at every call site.
|
|
||||||
"""
|
|
||||||
kwargs.setdefault("layout_engine", LAYOUT_ENGINE)
|
|
||||||
return ImageFont.truetype(font, size, **kwargs)
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
"""One place that turns plugin config into a configured ScrollHelper.
|
|
||||||
|
|
||||||
Five ticker plugins each hand-rolled this resolution (odds-ticker, news and
|
|
||||||
ledmatrix-leaderboard reference the deprecated ``scroll_pixels_per_second``
|
|
||||||
key 16-18 times apiece), and they disagreed in ways that were invisible until
|
|
||||||
someone watched the panel:
|
|
||||||
|
|
||||||
* odds-ticker read ``scroll_pixels_per_second`` on the *recommended* config
|
|
||||||
path and let it override ``scroll_speed``/``scroll_delay``. Because that key
|
|
||||||
carries a schema default, the documented settings were dead for every user
|
|
||||||
-- see ChuckBuilds/ledmatrix-plugins#408.
|
|
||||||
* ledmatrix-leaderboard read the same key only as a fallback, so identical
|
|
||||||
config produced different speeds in the two plugins.
|
|
||||||
* stock-news derived px/frame from it via its own arithmetic.
|
|
||||||
|
|
||||||
What matters on the hardware
|
|
||||||
----------------------------
|
|
||||||
Motion is smooth when the strip advances a **whole number of pixels per panel
|
|
||||||
refresh**. On a 100Hz panel that means 100 px/s, 200 px/s, and so on. Anything
|
|
||||||
else has to either blend adjacent columns (which on pixel-font text reads as
|
|
||||||
shimmer) or repeat frames (which reads as judder). :func:`resolve` warns when
|
|
||||||
the requested speed will not divide evenly, because that is a real display
|
|
||||||
artefact and not a rounding detail.
|
|
||||||
|
|
||||||
Speed is always expressed to the helper as pixels per second and applied in
|
|
||||||
time-based mode. Frame-based stepping gates motion on a wall clock at
|
|
||||||
``1/scroll_delay`` steps per second; plugins set ``scroll_delay`` to the frame
|
|
||||||
period, which puts that comparison exactly on its own threshold and makes the
|
|
||||||
step count flip on sub-millisecond jitter. Accumulating elapsed time keeps
|
|
||||||
position proportional to real time instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass, replace
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
#: Speed used when a plugin supplies nothing usable. One pixel per refresh on a
|
|
||||||
#: 100Hz panel, which is the slowest crisp scroll that hardware can show.
|
|
||||||
DEFAULT_PIXELS_PER_SECOND = 100.0
|
|
||||||
|
|
||||||
#: Bounds accepted from config. Below the floor a marquee appears frozen;
|
|
||||||
#: above the ceiling it outruns any panel's refresh and tears.
|
|
||||||
MIN_PIXELS_PER_SECOND = 1.0
|
|
||||||
MAX_PIXELS_PER_SECOND = 500.0
|
|
||||||
|
|
||||||
#: Assumed refresh when the caller does not say. Matches the usual
|
|
||||||
#: ``display.hardware.limit_refresh_rate_hz``.
|
|
||||||
DEFAULT_REFRESH_HZ = 100.0
|
|
||||||
|
|
||||||
#: How far px/s may sit from a whole number of pixels per refresh before it is
|
|
||||||
#: worth warning about. 0.05px per frame is invisible; a third of a pixel is not.
|
|
||||||
_WHOLE_PIXEL_TOLERANCE = 0.05
|
|
||||||
|
|
||||||
|
|
||||||
#: Longest a frame may be held before motion reads as a slideshow rather than
|
|
||||||
#: a scroll. 6 refreshes at 100Hz is ~17px/s, already visibly stepped.
|
|
||||||
MAX_FRAME_HOLD = 8
|
|
||||||
|
|
||||||
#: Largest whole-pixel jump per presented frame before motion looks like it is
|
|
||||||
#: teleporting rather than sliding.
|
|
||||||
MAX_PIXELS_PER_FRAME = 6
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CrispSpeed:
|
|
||||||
"""A speed the panel can show with whole-pixel motion.
|
|
||||||
|
|
||||||
``pixels_per_second`` is always ``refresh_hz / frame_hold * pixels_per_frame``
|
|
||||||
exactly -- no rounding, no fractional pixel positions, so nothing has to be
|
|
||||||
blended or repeated unevenly.
|
|
||||||
|
|
||||||
:param frame_hold: refreshes each frame is held for. This is rgbmatrix's
|
|
||||||
``SwapOnVSync(canvas, framerate_fraction)``. The panel keeps refreshing
|
|
||||||
at full rate either way, so holding a frame costs nothing in flicker.
|
|
||||||
:param pixels_per_frame: whole pixels advanced per presented frame.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pixels_per_second: float
|
|
||||||
frame_hold: int
|
|
||||||
pixels_per_frame: int
|
|
||||||
refresh_hz: float
|
|
||||||
|
|
||||||
@property
|
|
||||||
def frames_per_second(self) -> float:
|
|
||||||
"""Distinct frames per second: the refresh divided by the hold."""
|
|
||||||
return self.refresh_hz / self.frame_hold
|
|
||||||
|
|
||||||
@property
|
|
||||||
def steppiness(self) -> str:
|
|
||||||
"""Rough readability hint for this combination."""
|
|
||||||
if self.pixels_per_frame > 2:
|
|
||||||
return "jumpy"
|
|
||||||
if self.frames_per_second < 20:
|
|
||||||
return "stepped"
|
|
||||||
if self.frames_per_second < 30:
|
|
||||||
return "slightly stepped"
|
|
||||||
return "smooth"
|
|
||||||
|
|
||||||
def describe(self) -> str:
|
|
||||||
"""This speed as a line for the speed ladder, aligned for a column."""
|
|
||||||
return (
|
|
||||||
f"{self.pixels_per_second:6.1f} px/s "
|
|
||||||
f"({self.pixels_per_frame}px every {self.frame_hold} refresh"
|
|
||||||
f"{'es' if self.frame_hold != 1 else ' '} = "
|
|
||||||
f"{self.frames_per_second:5.1f} fps, {self.steppiness})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def crisp_ladder(
|
|
||||||
refresh_hz: float = DEFAULT_REFRESH_HZ,
|
|
||||||
max_frame_hold: int = MAX_FRAME_HOLD,
|
|
||||||
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
|
|
||||||
):
|
|
||||||
"""Every whole-pixel speed this panel can show, slowest first.
|
|
||||||
|
|
||||||
Duplicates are collapsed keeping the gentlest option: 100 px/s is reachable
|
|
||||||
as 1px every refresh or 2px every 2nd refresh, and the former moves in
|
|
||||||
smaller increments, so that is the one worth offering.
|
|
||||||
"""
|
|
||||||
best = {}
|
|
||||||
for hold in range(1, max_frame_hold + 1):
|
|
||||||
for ppf in range(1, max_pixels_per_frame + 1):
|
|
||||||
pps = refresh_hz / hold * ppf
|
|
||||||
key = round(pps, 3)
|
|
||||||
candidate = CrispSpeed(pps, hold, ppf, refresh_hz)
|
|
||||||
incumbent = best.get(key)
|
|
||||||
if incumbent is None or ppf < incumbent.pixels_per_frame:
|
|
||||||
best[key] = candidate
|
|
||||||
return [best[k] for k in sorted(best)]
|
|
||||||
|
|
||||||
|
|
||||||
#: How much a bigger pixel step costs, as a fraction of the target speed.
|
|
||||||
#: Tuned so 66.7px/s (2px at 33fps) beats 50px/s (1px at 50fps) when 60 was
|
|
||||||
#: asked for, but 33.3px/s (1px, smooth) still beats 28.6px/s (2px at 14fps)
|
|
||||||
#: when 30 was asked for -- being 11% slow is worth far less than looking bad.
|
|
||||||
_STEP_PENALTY = 0.05
|
|
||||||
_SLOW_FPS_PENALTY = 0.25 # below 20fps
|
|
||||||
_LOWISH_FPS_PENALTY = 0.10 # below 25fps
|
|
||||||
|
|
||||||
|
|
||||||
def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
|
|
||||||
"""Lower is better. Numeric closeness alone picks bad-looking speeds.
|
|
||||||
|
|
||||||
Nearest-by-value would answer "30 px/s" with 28.6 px/s -- which is 2px
|
|
||||||
jumps at 14fps -- over 33.3 px/s, which is single-pixel motion at 33fps and
|
|
||||||
obviously better on the panel. Proximity has to be traded against how the
|
|
||||||
motion actually reads.
|
|
||||||
"""
|
|
||||||
error = abs(candidate.pixels_per_second - target) / max(target, 1e-6)
|
|
||||||
cost = error + _STEP_PENALTY * (candidate.pixels_per_frame - 1)
|
|
||||||
fps = candidate.frames_per_second
|
|
||||||
if fps < 20:
|
|
||||||
cost += _SLOW_FPS_PENALTY
|
|
||||||
elif fps < 25:
|
|
||||||
cost += _LOWISH_FPS_PENALTY
|
|
||||||
return cost
|
|
||||||
|
|
||||||
|
|
||||||
def solve_crisp(
|
|
||||||
target_pixels_per_second: float,
|
|
||||||
refresh_hz: float = DEFAULT_REFRESH_HZ,
|
|
||||||
max_frame_hold: int = MAX_FRAME_HOLD,
|
|
||||||
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
|
|
||||||
) -> CrispSpeed:
|
|
||||||
"""The whole-pixel speed that will look best for what was asked for.
|
|
||||||
|
|
||||||
Not simply the nearest -- see :func:`_quality_cost`. Ties break toward the
|
|
||||||
smaller pixel step and the shorter hold.
|
|
||||||
"""
|
|
||||||
ladder = crisp_ladder(refresh_hz, max_frame_hold, max_pixels_per_frame)
|
|
||||||
# Clamp into the ladder's range first. Relative error saturates near 1.0
|
|
||||||
# for a target far outside it, so the quality penalty would dominate and
|
|
||||||
# answer "10000 px/s" with the *slowest* entry -- smooth, and useless.
|
|
||||||
target = min(max(target_pixels_per_second, ladder[0].pixels_per_second),
|
|
||||||
ladder[-1].pixels_per_second)
|
|
||||||
return min(
|
|
||||||
ladder,
|
|
||||||
key=lambda c: (round(_quality_cost(c, target), 6),
|
|
||||||
c.pixels_per_frame, c.frame_hold),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ScrollSettings:
|
|
||||||
"""The resolved outcome, and which config key produced it."""
|
|
||||||
|
|
||||||
pixels_per_second: float
|
|
||||||
source: str
|
|
||||||
target_fps: Optional[float] = None
|
|
||||||
pixels_per_frame: Optional[float] = None
|
|
||||||
warning: Optional[str] = None
|
|
||||||
#: The whole-pixel speed actually applied, when snapping was enabled.
|
|
||||||
crisp: Optional[CrispSpeed] = None
|
|
||||||
#: What the config asked for, before snapping.
|
|
||||||
requested_pixels_per_second: Optional[float] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def frame_hold(self) -> int:
|
|
||||||
"""Refreshes to hold each frame for; pass to set_scrolling_state()."""
|
|
||||||
return self.crisp.frame_hold if self.crisp else 1
|
|
||||||
|
|
||||||
def describe(self) -> str:
|
|
||||||
"""One log line: the speed applied, and which config key produced it."""
|
|
||||||
text = f"{self.pixels_per_second:.1f} px/s (from {self.source})"
|
|
||||||
if self.pixels_per_frame is not None:
|
|
||||||
text += f" = {self.pixels_per_frame:.2f} px/frame"
|
|
||||||
if self.target_fps:
|
|
||||||
text += f" at {self.target_fps:.0f} fps"
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce(value: Any) -> Optional[float]:
|
|
||||||
"""A positive float, or None. Config reaches us with nulls and strings."""
|
|
||||||
if value is None or isinstance(value, bool):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
number = float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
return number if number > 0 else None
|
|
||||||
|
|
||||||
|
|
||||||
def _from_speed_and_delay(block: Any) -> Optional[float]:
|
|
||||||
"""px/s from a ``scroll_speed`` (px/frame) + ``scroll_delay`` (s) pair."""
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
return None
|
|
||||||
speed = _coerce(block.get("scroll_speed"))
|
|
||||||
delay = _coerce(block.get("scroll_delay"))
|
|
||||||
if speed is None or delay is None:
|
|
||||||
return None
|
|
||||||
return speed / delay
|
|
||||||
|
|
||||||
|
|
||||||
def resolve(
|
|
||||||
plugin_config: Optional[Dict[str, Any]] = None,
|
|
||||||
global_config: Optional[Dict[str, Any]] = None,
|
|
||||||
default_pixels_per_second: float = DEFAULT_PIXELS_PER_SECOND,
|
|
||||||
refresh_hz: Optional[float] = None,
|
|
||||||
) -> ScrollSettings:
|
|
||||||
"""Resolve one scroll speed from the several shapes plugins accept.
|
|
||||||
|
|
||||||
Precedence, highest first. The deprecated flat key sits *below* the
|
|
||||||
explicit pairs deliberately: it carries schema defaults in some plugins, so
|
|
||||||
ranking it above them silently disables the documented settings.
|
|
||||||
|
|
||||||
1. ``display_options.scroll_speed`` + ``scroll_delay`` (current)
|
|
||||||
2. ``display.scroll_speed`` + ``scroll_delay`` (deprecated shape)
|
|
||||||
3. ``scroll_speed`` + ``scroll_delay`` at the root (legacy flat)
|
|
||||||
4. ``scroll_pixels_per_second``, nested or flat (deprecated)
|
|
||||||
5. the global ``display`` block
|
|
||||||
6. ``default_pixels_per_second``
|
|
||||||
|
|
||||||
:param refresh_hz: panel refresh, used only to check whether the resolved
|
|
||||||
speed lands on whole pixels per frame and to fill in ``target_fps``.
|
|
||||||
"""
|
|
||||||
plugin_config = plugin_config or {}
|
|
||||||
global_config = global_config or {}
|
|
||||||
refresh = _coerce(refresh_hz) or DEFAULT_REFRESH_HZ
|
|
||||||
|
|
||||||
display_options = plugin_config.get("display_options")
|
|
||||||
display_block = plugin_config.get("display")
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
(_from_speed_and_delay(display_options), "display_options.scroll_speed/delay"),
|
|
||||||
(_from_speed_and_delay(display_block), "display.scroll_speed/delay"),
|
|
||||||
(_from_speed_and_delay(plugin_config), "scroll_speed/delay (root)"),
|
|
||||||
]
|
|
||||||
for block, label in (
|
|
||||||
(display_options, "display_options.scroll_pixels_per_second"),
|
|
||||||
(display_block, "display.scroll_pixels_per_second"),
|
|
||||||
(plugin_config, "scroll_pixels_per_second"),
|
|
||||||
):
|
|
||||||
if isinstance(block, dict):
|
|
||||||
candidates.append((_coerce(block.get("scroll_pixels_per_second")), label))
|
|
||||||
|
|
||||||
global_display = global_config.get("display")
|
|
||||||
candidates.append((_from_speed_and_delay(global_display), "global display.scroll_speed/delay"))
|
|
||||||
|
|
||||||
pixels_per_second = None
|
|
||||||
source = "default"
|
|
||||||
for value, label in candidates:
|
|
||||||
if value is not None:
|
|
||||||
pixels_per_second, source = value, label
|
|
||||||
break
|
|
||||||
if pixels_per_second is None:
|
|
||||||
pixels_per_second = default_pixels_per_second
|
|
||||||
|
|
||||||
clamped = max(MIN_PIXELS_PER_SECOND, min(MAX_PIXELS_PER_SECOND, pixels_per_second))
|
|
||||||
warning = None
|
|
||||||
if clamped != pixels_per_second:
|
|
||||||
warning = (
|
|
||||||
f"scroll speed {pixels_per_second:.1f} px/s out of range, "
|
|
||||||
f"clamped to {clamped:.1f}"
|
|
||||||
)
|
|
||||||
pixels_per_second = clamped
|
|
||||||
|
|
||||||
pixels_per_frame = pixels_per_second / refresh if refresh > 0 else None
|
|
||||||
if warning is None and pixels_per_frame is not None:
|
|
||||||
offset = abs(pixels_per_frame - round(pixels_per_frame))
|
|
||||||
if pixels_per_frame < 1.0 - _WHOLE_PIXEL_TOLERANCE or offset > _WHOLE_PIXEL_TOLERANCE:
|
|
||||||
suggestion = max(1.0, round(pixels_per_frame)) * refresh
|
|
||||||
warning = (
|
|
||||||
f"{pixels_per_second:.1f} px/s is {pixels_per_frame:.2f} px per "
|
|
||||||
f"refresh at {refresh:.0f}Hz, so some frames repeat and the "
|
|
||||||
f"scroll will judder; {suggestion:.0f} px/s divides evenly"
|
|
||||||
)
|
|
||||||
|
|
||||||
return ScrollSettings(
|
|
||||||
pixels_per_second=pixels_per_second,
|
|
||||||
source=source,
|
|
||||||
target_fps=refresh,
|
|
||||||
pixels_per_frame=pixels_per_frame,
|
|
||||||
warning=warning,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def configure(
|
|
||||||
scroll_helper: Any,
|
|
||||||
plugin_config: Optional[Dict[str, Any]] = None,
|
|
||||||
global_config: Optional[Dict[str, Any]] = None,
|
|
||||||
default_pixels_per_second: float = DEFAULT_PIXELS_PER_SECOND,
|
|
||||||
refresh_hz: Optional[float] = None,
|
|
||||||
plugin_logger: Optional[logging.Logger] = None,
|
|
||||||
display_manager: Any = None,
|
|
||||||
snap_to_crisp: bool = True,
|
|
||||||
) -> ScrollSettings:
|
|
||||||
"""Resolve the config and apply it to ``scroll_helper``.
|
|
||||||
|
|
||||||
Applied in time-based mode: see the module docstring for why frame-based
|
|
||||||
stepping is not used. ``hasattr`` guards keep this usable against older
|
|
||||||
ScrollHelper builds that a plugin may be running on.
|
|
||||||
|
|
||||||
:param display_manager: consulted for the panel's refresh rate only (it can
|
|
||||||
see display.hardware; a plugin cannot). The frame hold is NOT applied
|
|
||||||
here -- see the note in the body. The caller must pass
|
|
||||||
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True,
|
|
||||||
...)`` when it starts scrolling, or a sub-refresh speed still presents
|
|
||||||
a new frame every refresh and the motion falls back to fractional
|
|
||||||
pixels.
|
|
||||||
:param snap_to_crisp: move the requested speed to the nearest speed the
|
|
||||||
panel can show in whole pixels. On by default because a speed that does
|
|
||||||
not divide evenly has no good rendering, only a choice of artefacts.
|
|
||||||
|
|
||||||
:returns: the settings applied, so the caller can log or assert on them.
|
|
||||||
"""
|
|
||||||
log = plugin_logger or logger
|
|
||||||
|
|
||||||
# Refresh rate, most authoritative first: what the caller passed, then the
|
|
||||||
# display manager (which can see display.hardware; a plugin cannot), then
|
|
||||||
# the global config, then the default.
|
|
||||||
#
|
|
||||||
# This has to be settled BEFORE resolve(), not after. resolve() uses the
|
|
||||||
# refresh to fill in target_fps, pixels_per_frame and the judder warning,
|
|
||||||
# so deriving it afterwards described a 100Hz panel to everyone running at
|
|
||||||
# 60 -- and with snap_to_crisp=False nothing downstream corrected it, so
|
|
||||||
# set_target_fps() paced the helper to 100 FPS on a 60Hz panel.
|
|
||||||
hz = _coerce(refresh_hz)
|
|
||||||
if hz is None and display_manager is not None:
|
|
||||||
hz = _coerce(getattr(display_manager, "refresh_hz", None))
|
|
||||||
if hz is None:
|
|
||||||
hz = refresh_hz_from_config(global_config)
|
|
||||||
|
|
||||||
settings = resolve(
|
|
||||||
plugin_config,
|
|
||||||
global_config,
|
|
||||||
default_pixels_per_second=default_pixels_per_second,
|
|
||||||
refresh_hz=hz,
|
|
||||||
)
|
|
||||||
applied = settings.pixels_per_second
|
|
||||||
choice = None
|
|
||||||
|
|
||||||
if snap_to_crisp:
|
|
||||||
choice = solve_crisp(settings.pixels_per_second, hz)
|
|
||||||
applied = choice.pixels_per_second
|
|
||||||
settings = replace(
|
|
||||||
settings,
|
|
||||||
pixels_per_second=applied,
|
|
||||||
requested_pixels_per_second=settings.pixels_per_second,
|
|
||||||
crisp=choice,
|
|
||||||
pixels_per_frame=float(choice.pixels_per_frame),
|
|
||||||
# Snapping resolves the whole-pixel problem the warning describes.
|
|
||||||
warning=None if settings.warning and "judder" in settings.warning
|
|
||||||
else settings.warning,
|
|
||||||
)
|
|
||||||
|
|
||||||
if hasattr(scroll_helper, "set_frame_based_scrolling"):
|
|
||||||
scroll_helper.set_frame_based_scrolling(False)
|
|
||||||
scroll_helper.set_scroll_speed(applied)
|
|
||||||
|
|
||||||
# A crisp speed is a whole number of pixels per presented frame, so step by
|
|
||||||
# that number rather than by speed * elapsed time. Snapping alone only
|
|
||||||
# fixes the average: the wall clock puts the accumulator back on an integer
|
|
||||||
# boundary every frame, where jitter of a fraction of a millisecond decides
|
|
||||||
# whether the pixel moves. That is what the ladder was bought to prevent.
|
|
||||||
if hasattr(scroll_helper, "set_pixels_per_frame"):
|
|
||||||
scroll_helper.set_pixels_per_frame(
|
|
||||||
choice.pixels_per_frame if choice else None)
|
|
||||||
if choice and hasattr(scroll_helper, "set_target_fps"):
|
|
||||||
scroll_helper.set_target_fps(choice.frames_per_second)
|
|
||||||
elif settings.target_fps and hasattr(scroll_helper, "set_target_fps"):
|
|
||||||
scroll_helper.set_target_fps(settings.target_fps)
|
|
||||||
|
|
||||||
# Deliberately NOT applied here. The hold belongs to a scroll, not to a
|
|
||||||
# plugin's lifetime: plugins share one display manager, and one left set at
|
|
||||||
# construction is reset the moment any other plugin finishes scrolling.
|
|
||||||
# Callers pass settings.frame_hold to set_scrolling_state(True, ...) when
|
|
||||||
# they start scrolling. configure() only reports what is needed.
|
|
||||||
|
|
||||||
if choice:
|
|
||||||
requested = settings.requested_pixels_per_second
|
|
||||||
if abs(requested - applied) > 0.05:
|
|
||||||
log.info(
|
|
||||||
"Scroll configured: %s (asked for %.1f px/s from %s; "
|
|
||||||
"nearest whole-pixel speed on a %.0fHz panel)",
|
|
||||||
choice.describe(), requested, settings.source, hz,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.info("Scroll configured: %s (from %s)",
|
|
||||||
choice.describe(), settings.source)
|
|
||||||
if choice.frame_hold > 1:
|
|
||||||
log.debug(
|
|
||||||
"Scroll needs a frame hold of %d - pass settings.frame_hold to "
|
|
||||||
"display_manager.set_scrolling_state(True, ...) each scroll",
|
|
||||||
choice.frame_hold,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.info("Scroll configured: %s", settings.describe())
|
|
||||||
|
|
||||||
if settings.warning:
|
|
||||||
log.warning("Scroll speed: %s", settings.warning)
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_hz_from_config(global_config: Optional[Dict[str, Any]]) -> float:
|
|
||||||
"""The panel's refresh cap from the global config, or the default."""
|
|
||||||
if not isinstance(global_config, dict):
|
|
||||||
return DEFAULT_REFRESH_HZ
|
|
||||||
# Each level is checked for being a mapping rather than merely truthy: a
|
|
||||||
# malformed config where display or display.hardware is a string or a list
|
|
||||||
# raised AttributeError out of what is meant to be a total function with a
|
|
||||||
# default, taking down every caller that asked for the refresh rate.
|
|
||||||
display = global_config.get("display")
|
|
||||||
if not isinstance(display, dict):
|
|
||||||
return DEFAULT_REFRESH_HZ
|
|
||||||
hardware = display.get("hardware")
|
|
||||||
if not isinstance(hardware, dict):
|
|
||||||
return DEFAULT_REFRESH_HZ
|
|
||||||
return _coerce(hardware.get("limit_refresh_rate_hz")) or DEFAULT_REFRESH_HZ
|
|
||||||
+23
-145
@@ -16,7 +16,6 @@ Features:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import time
|
import time
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -30,49 +29,6 @@ except ImportError:
|
|||||||
HAS_SCIPY = False
|
HAS_SCIPY = False
|
||||||
|
|
||||||
|
|
||||||
def frame_stats(frame_times: list) -> Dict[str, Any]:
|
|
||||||
"""Summary statistics over one window of frame durations (seconds).
|
|
||||||
|
|
||||||
Split out of log_frame_rate() so the arithmetic can be tested without a
|
|
||||||
clock. Median and p95 are the real ones: the median takes both middle
|
|
||||||
samples on an even window, and p95 is nearest-rank, so a 100-frame window
|
|
||||||
reports the 95th sorted sample rather than the 96th. That matters twice
|
|
||||||
over, because the median is also the threshold the stall and skip counts
|
|
||||||
are measured against.
|
|
||||||
"""
|
|
||||||
window = sorted(frame_times)
|
|
||||||
n = len(window)
|
|
||||||
median = (window[n // 2] if n % 2
|
|
||||||
else (window[n // 2 - 1] + window[n // 2]) / 2.0)
|
|
||||||
mean = sum(window) / n
|
|
||||||
# Anything past 1.5x the median missed a panel refresh; anything under
|
|
||||||
# half of it never reached the panel at all (dirty tracking skipped the
|
|
||||||
# swap, so the frame did not wait for vsync).
|
|
||||||
return {
|
|
||||||
"frames": n,
|
|
||||||
"fps": (1.0 / mean) if mean > 0 else 0.0,
|
|
||||||
"median": median,
|
|
||||||
"p95": window[max(0, math.ceil(0.95 * n) - 1)],
|
|
||||||
"max": window[-1],
|
|
||||||
"min": window[0],
|
|
||||||
"stalls": sum(1 for f in window if f > median * 1.5),
|
|
||||||
"skips": sum(1 for f in window if f < median * 0.5),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def format_frame_stats(frame_times: list) -> str:
|
|
||||||
"""The one-line rendering of frame_stats(), in milliseconds."""
|
|
||||||
s = frame_stats(frame_times)
|
|
||||||
n = s["frames"]
|
|
||||||
return (
|
|
||||||
f"{s['fps']:.1f} fps over {n} frames | "
|
|
||||||
f"median {s['median'] * 1000:.2f}ms p95 {s['p95'] * 1000:.2f}ms "
|
|
||||||
f"max {s['max'] * 1000:.2f}ms min {s['min'] * 1000:.2f}ms | "
|
|
||||||
f"stalls {s['stalls']} ({100.0 * s['stalls'] / n:.1f}%) "
|
|
||||||
f"skips {s['skips']} ({100.0 * s['skips'] / n:.1f}%)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ScrollHelper:
|
class ScrollHelper:
|
||||||
"""
|
"""
|
||||||
Helper class for scrolling text and image content on LED displays.
|
Helper class for scrolling text and image content on LED displays.
|
||||||
@@ -119,26 +75,12 @@ class ScrollHelper:
|
|||||||
# Pre-allocated buffer for output frame (reused to avoid allocations)
|
# Pre-allocated buffer for output frame (reused to avoid allocations)
|
||||||
self._frame_buffer: Optional[np.ndarray] = None
|
self._frame_buffer: Optional[np.ndarray] = None
|
||||||
|
|
||||||
# Sub-pixel scrolling: OFF by default, and that is deliberate.
|
# Sub-pixel scrolling settings (disabled - using high FPS integer scrolling instead)
|
||||||
# Blending renders a half-step by mixing two adjacent columns 50/50.
|
self.sub_pixel_scrolling = False # Disabled - use high frame rate for smoothness
|
||||||
# On a high-resolution screen that reads as smooth motion; on a coarse
|
|
||||||
# LED matrix showing pixel-font text it does not. A one-pixel stroke
|
|
||||||
# becomes two half-brightness pixels, so frames alternate between crisp
|
|
||||||
# and smeared and the text appears to shimmer and jump a pixel ahead --
|
|
||||||
# tested on a 2x128x64 panel and clearly worse than integer stepping.
|
|
||||||
#
|
|
||||||
# The rule this display obeys: motion is smooth when it advances a
|
|
||||||
# whole number of pixels per refresh. Anything slower must either
|
|
||||||
# blend (blur) or repeat frames (judder); blending is the worse of the
|
|
||||||
# two here. Vegas mode still opts in via set_sub_pixel_scrolling().
|
|
||||||
self.sub_pixel_scrolling = False
|
|
||||||
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
|
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
|
||||||
|
|
||||||
# Frame-based scrolling settings
|
# Frame-based scrolling settings
|
||||||
self.frame_based_scrolling = False
|
self.frame_based_scrolling = False # If True, use scroll_delay to throttle and move scroll_speed pixels
|
||||||
#: Whole pixels to advance per presented frame, or None to pace
|
|
||||||
#: off elapsed time. See set_pixels_per_frame.
|
|
||||||
self.fixed_pixels_per_frame = None # If True, use scroll_delay to throttle and move scroll_speed pixels
|
|
||||||
self.last_step_time = 0.0 # Track last step time for frame-based throttling
|
self.last_step_time = 0.0 # Track last step time for frame-based throttling
|
||||||
|
|
||||||
# Time tracking for scroll updates
|
# Time tracking for scroll updates
|
||||||
@@ -163,9 +105,6 @@ class ScrollHelper:
|
|||||||
self.last_frame_time = time.time()
|
self.last_frame_time = time.time()
|
||||||
self.last_fps_log_time = time.time()
|
self.last_fps_log_time = time.time()
|
||||||
self.frame_times = []
|
self.frame_times = []
|
||||||
# Every frame time since the last stats line, so the 5s summary can
|
|
||||||
# report the tail rather than one arbitrary sample. Cleared on log.
|
|
||||||
self._window: list = []
|
|
||||||
|
|
||||||
# Scrolling state management
|
# Scrolling state management
|
||||||
self.is_scrolling = False
|
self.is_scrolling = False
|
||||||
@@ -298,44 +237,26 @@ class ScrollHelper:
|
|||||||
self.last_progress_log_time = current_time
|
self.last_progress_log_time = current_time
|
||||||
|
|
||||||
# Update scroll position
|
# Update scroll position
|
||||||
if self.fixed_pixels_per_frame:
|
if self.frame_based_scrolling:
|
||||||
# One presented frame, one fixed whole-pixel step. No clock is
|
|
||||||
# consulted, so no jitter reaches the position and every frame
|
|
||||||
# moves the eye by the same amount. See set_pixels_per_frame.
|
|
||||||
pixels_to_move = self.fixed_pixels_per_frame
|
|
||||||
self.last_step_time = current_time
|
|
||||||
elif self.frame_based_scrolling:
|
|
||||||
# Frame-based: move fixed amount when scroll_delay has passed
|
# Frame-based: move fixed amount when scroll_delay has passed
|
||||||
# This matches stock ticker behavior: move pixels, then wait scroll_delay
|
# This matches stock ticker behavior: move pixels, then wait scroll_delay
|
||||||
# Initialize last_step_time on first call to prevent huge initial jump
|
# Initialize last_step_time on first call to prevent huge initial jump
|
||||||
if self.last_step_time == 0.0:
|
if self.last_step_time == 0.0:
|
||||||
self.last_step_time = current_time
|
self.last_step_time = current_time
|
||||||
|
|
||||||
# Frame-based mode advances by elapsed time, exactly like the
|
# Check if scroll_delay has passed
|
||||||
# time-based branch below, at the same configured speed
|
time_since_last_step = current_time - self.last_step_time
|
||||||
# (scroll_speed px per scroll_delay seconds).
|
if time_since_last_step >= self.scroll_delay:
|
||||||
#
|
# Move pixels (can move multiple steps if lag occurred, but cap to prevent huge jumps)
|
||||||
# It used to step discretely: 0, 1 or 2 whole pixels depending on
|
steps = int(time_since_last_step / self.scroll_delay)
|
||||||
# whether a wall clock had passed scroll_delay. Plugins set
|
# Cap at reasonable number to prevent huge jumps from lag
|
||||||
# scroll_delay to the target frame period, so that comparison sits
|
max_steps = max(1, int(0.04 / self.scroll_delay)) # Limit to 0.04s (2 steps at 50 FPS) for smoother scrolling
|
||||||
# exactly on its own threshold and the decision flips on sub-
|
steps = min(steps, max_steps)
|
||||||
# millisecond jitter -- a frame a hair early moved nothing and
|
pixels_to_move = self.scroll_speed * steps
|
||||||
# rendered an identical frame, a frame a hair late moved two
|
# Update last_step_time, preserving fractional delay for smooth timing
|
||||||
# pixels. Rounding the step count fixed the stalls but still
|
self.last_step_time = current_time - (time_since_last_step % self.scroll_delay)
|
||||||
# discarded the remainder, so the error never corrected.
|
|
||||||
#
|
|
||||||
# Accumulating elapsed time keeps position exactly proportional to
|
|
||||||
# real time: jitter shifts a pixel boundary by a fraction of a
|
|
||||||
# frame instead of flipping a whole step, and nothing is lost or
|
|
||||||
# gained. This is what the one visibly smooth scroller on the
|
|
||||||
# hardware (the stock ticker) was already doing by virtue of never
|
|
||||||
# enabling frame-based mode.
|
|
||||||
if self.scroll_delay > 0:
|
|
||||||
pixels_per_second = self.scroll_speed / self.scroll_delay
|
|
||||||
else:
|
else:
|
||||||
pixels_per_second = self.scroll_speed * 100.0
|
pixels_to_move = 0.0
|
||||||
pixels_to_move = pixels_per_second * delta_time
|
|
||||||
self.last_step_time = current_time
|
|
||||||
else:
|
else:
|
||||||
# Time-based: move based on time delta (correct speed over time)
|
# Time-based: move based on time delta (correct speed over time)
|
||||||
# scroll_speed is pixels per second
|
# scroll_speed is pixels per second
|
||||||
@@ -988,13 +909,6 @@ class ScrollHelper:
|
|||||||
Args:
|
Args:
|
||||||
speed: Scroll speed (interpretation depends on frame_based_scrolling mode)
|
speed: Scroll speed (interpretation depends on frame_based_scrolling mode)
|
||||||
"""
|
"""
|
||||||
# A speed set directly is a request to pace off that speed, so drop any
|
|
||||||
# fixed per-frame step left by an earlier configure(). scroll_config
|
|
||||||
# calls this first and set_pixels_per_frame second, so the crisp path
|
|
||||||
# is unaffected; what this protects is a legacy caller changing speed
|
|
||||||
# on a helper that scroll_config had already put in fixed-step mode,
|
|
||||||
# where the new speed would otherwise be silently ignored.
|
|
||||||
self.fixed_pixels_per_frame = None
|
|
||||||
if self.frame_based_scrolling:
|
if self.frame_based_scrolling:
|
||||||
# In frame-based mode, clamp to reasonable pixels per frame (0.1-5)
|
# In frame-based mode, clamp to reasonable pixels per frame (0.1-5)
|
||||||
# Higher values cause visible jumps - 1-2 pixels/frame is ideal for smoothness
|
# Higher values cause visible jumps - 1-2 pixels/frame is ideal for smoothness
|
||||||
@@ -1015,35 +929,6 @@ class ScrollHelper:
|
|||||||
self.scroll_delay = max(0.001, min(1.0, delay))
|
self.scroll_delay = max(0.001, min(1.0, delay))
|
||||||
self.logger.debug(f"Scroll delay set to: {self.scroll_delay}")
|
self.logger.debug(f"Scroll delay set to: {self.scroll_delay}")
|
||||||
|
|
||||||
def set_pixels_per_frame(self, pixels) -> None:
|
|
||||||
"""Advance exactly `pixels` per presented frame, ignoring the clock.
|
|
||||||
|
|
||||||
Pass None to go back to pacing off elapsed time.
|
|
||||||
|
|
||||||
Smooth motion is not a frame-rate property. The strip has to advance
|
|
||||||
the same number of WHOLE pixels every frame, and deriving that from a
|
|
||||||
wall clock cannot deliver it: the position accumulates
|
|
||||||
``speed * delta_time`` and is then truncated to a pixel, so any jitter
|
|
||||||
in delta_time lands either side of an integer boundary. Measured on
|
|
||||||
hardware at a rock-steady 100.0 fps, individual frames still ranged
|
|
||||||
5.6ms to 15.2ms -- 0.57px to 1.44px of movement -- and 53% of frames
|
|
||||||
advanced by something other than one pixel: about half moved nothing
|
|
||||||
at all and then jumped two. That is the micro-stutter, and it survived
|
|
||||||
every frame-timing fix because frame timing was never the problem.
|
|
||||||
|
|
||||||
It is worst precisely at a crisp speed. At 100 px/s on a 100Hz panel
|
|
||||||
the accumulator sits exactly on integer boundaries, so sub-millisecond
|
|
||||||
jitter flips it either way and the motion beats at around 50Hz.
|
|
||||||
|
|
||||||
Stepping per frame is only correct because SwapOnVSync blocks until
|
|
||||||
the panel has taken the frame, which makes the frame count a truer
|
|
||||||
clock than time.time(). Before the swap was locked to vsync this would
|
|
||||||
have run at whatever speed the loop happened to spin at.
|
|
||||||
"""
|
|
||||||
self.fixed_pixels_per_frame = int(pixels) if pixels else None
|
|
||||||
self.logger.debug("Fixed step set to: %s px/frame",
|
|
||||||
self.fixed_pixels_per_frame)
|
|
||||||
|
|
||||||
def set_target_fps(self, fps: float) -> None:
|
def set_target_fps(self, fps: float) -> None:
|
||||||
"""
|
"""
|
||||||
Set the target frames per second for scrolling.
|
Set the target frames per second for scrolling.
|
||||||
@@ -1133,24 +1018,17 @@ class ScrollHelper:
|
|||||||
if len(self.frame_times) > 100:
|
if len(self.frame_times) > 100:
|
||||||
self.frame_times.pop(0)
|
self.frame_times.pop(0)
|
||||||
|
|
||||||
# Every frame since the last log, not just the last 100 and not just
|
|
||||||
# the one that happens to land on the 5s boundary. The old line
|
|
||||||
# reported a single instantaneous sample -- roughly 1 frame in 500 --
|
|
||||||
# which cannot see a stall that hits 1% of frames, and reported it
|
|
||||||
# next to an average that hides the same stall by construction (a 2ms
|
|
||||||
# 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
|
# Log FPS every 5 seconds to avoid spam
|
||||||
if current_time - self.last_fps_log_time >= 5.0:
|
if current_time - self.last_fps_log_time >= 5.0:
|
||||||
self.logger.info(
|
avg_frame_time = sum(self.frame_times) / len(self.frame_times)
|
||||||
"Scroll frame stats - %s",
|
avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0
|
||||||
format_frame_stats(self._window or [frame_time]),
|
instant_fps = 1.0 / frame_time if frame_time > 0 else 0
|
||||||
)
|
|
||||||
|
self.logger.info(f"Scroll frame stats - Avg FPS: {avg_fps:.1f}, "
|
||||||
|
f"Current FPS: {instant_fps:.1f}, "
|
||||||
|
f"Frame time: {frame_time*1000:.2f}ms")
|
||||||
self.last_fps_log_time = current_time
|
self.last_fps_log_time = current_time
|
||||||
self.frame_count = 0
|
self.frame_count = 0
|
||||||
self._window = []
|
|
||||||
|
|
||||||
self.last_frame_time = current_time
|
self.last_frame_time = current_time
|
||||||
self.frame_count += 1
|
self.frame_count += 1
|
||||||
|
|||||||
+39
-83
@@ -43,7 +43,6 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from src.common import scroll_config
|
|
||||||
from src.common.scroll_helper import ScrollHelper
|
from src.common.scroll_helper import ScrollHelper
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -59,9 +58,13 @@ DEFAULT_SCROLL_SETTINGS: Dict[str, Any] = {
|
|||||||
"dynamic_duration": True,
|
"dynamic_duration": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#: Bounds on the px/second -> px/frame conversion, applied before the helper
|
||||||
|
#: sees the value. FPS is *not* clamped here — ScrollHelper.set_target_fps
|
||||||
|
#: already does that, and a second copy of the range would drift from it.
|
||||||
|
MIN_PIXELS_PER_FRAME = 0.1
|
||||||
|
MAX_PIXELS_PER_FRAME = 5.0
|
||||||
|
|
||||||
#: Pacing to assume when scroll_delay is 0, i.e. the plugin has not set one.
|
#: Pacing to assume when scroll_delay is 0, i.e. the plugin has not set one.
|
||||||
#: Only used to interpret this module's own px/frame config shape; the speed
|
|
||||||
#: bounds and the px/s -> px/frame conversion belong to scroll_config now.
|
|
||||||
ASSUMED_FPS_WHEN_UNPACED = 100.0
|
ASSUMED_FPS_WHEN_UNPACED = 100.0
|
||||||
|
|
||||||
|
|
||||||
@@ -233,75 +236,51 @@ class SportsScrollDisplay:
|
|||||||
"""Apply config to the scroll helper. Safe to call again after a change."""
|
"""Apply config to the scroll helper. Safe to call again after a change."""
|
||||||
settings = self._get_scroll_settings()
|
settings = self._get_scroll_settings()
|
||||||
|
|
||||||
|
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
|
||||||
|
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
|
||||||
dynamic_duration = bool(settings.get("dynamic_duration", True))
|
dynamic_duration = bool(settings.get("dynamic_duration", True))
|
||||||
|
|
||||||
|
self.scroll_helper.set_scroll_delay(scroll_delay)
|
||||||
self.scroll_helper.set_dynamic_duration_settings(
|
self.scroll_helper.set_dynamic_duration_settings(
|
||||||
enabled=dynamic_duration,
|
enabled=dynamic_duration,
|
||||||
min_duration=settings.get("min_duration", 30),
|
min_duration=settings.get("min_duration", 30),
|
||||||
max_duration=settings.get("max_duration", 600),
|
max_duration=settings.get("max_duration", 600),
|
||||||
buffer=0.2, # ensure the strip clears the panel completely
|
buffer=0.2, # ensure the strip clears the panel completely
|
||||||
)
|
)
|
||||||
|
# Frame-based scrolling: motion advances per rendered frame rather than
|
||||||
|
# per wall-clock second, which is what makes the pacing stable.
|
||||||
|
self.scroll_helper.set_frame_based_scrolling(True)
|
||||||
|
|
||||||
# Speed goes through scroll_config, which every other scrolling plugin
|
# Config states speed in px/second; frame-based mode wants px/frame.
|
||||||
# already uses. What must NOT happen is handing it this module's
|
if scroll_delay > 0:
|
||||||
# settings dict: the two read the same key names with different
|
pixels_per_frame = scroll_speed * scroll_delay
|
||||||
# meanings, and the collision is a factor of 1/scroll_delay.
|
else:
|
||||||
#
|
pixels_per_frame = scroll_speed / ASSUMED_FPS_WHEN_UNPACED
|
||||||
# sports_scroll: scroll_speed is px/SECOND; scroll_delay is only the
|
pixels_per_frame = max(
|
||||||
# frame period used to reach px/frame.
|
MIN_PIXELS_PER_FRAME, min(MAX_PIXELS_PER_FRAME, pixels_per_frame)
|
||||||
# scroll_config: scroll_speed is px per STEP, so px/s = speed/delay.
|
)
|
||||||
#
|
self.scroll_helper.set_scroll_speed(pixels_per_frame)
|
||||||
# Passing {"scroll_speed": 50.0, "scroll_delay": 0.01} straight through
|
|
||||||
# resolves to 5000 px/s (clamped to 500) instead of 50. So this module
|
|
||||||
# keeps ownership of reading its own config -- _get_scroll_settings
|
|
||||||
# merges the league overrides -- and hands the resolver a plain px/s.
|
|
||||||
pixels_per_second = self._resolve_pixels_per_second(settings)
|
|
||||||
|
|
||||||
resolved = scroll_config.configure(
|
effective_pps = (
|
||||||
self.scroll_helper,
|
pixels_per_frame / scroll_delay
|
||||||
plugin_config=None,
|
if scroll_delay > 0
|
||||||
global_config=self.global_config,
|
else pixels_per_frame * ASSUMED_FPS_WHEN_UNPACED
|
||||||
default_pixels_per_second=pixels_per_second,
|
|
||||||
display_manager=self.display_manager,
|
|
||||||
plugin_logger=self.logger,
|
|
||||||
refresh_hz=self._resolve_refresh_hz(),
|
|
||||||
)
|
)
|
||||||
self._scroll_settings = resolved
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"ScrollHelper configured: %s (requested %.1f px/s), "
|
f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, "
|
||||||
"dynamic_duration=%s",
|
f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s from "
|
||||||
resolved.describe(),
|
f"{scroll_speed} px/s config), dynamic_duration={dynamic_duration}"
|
||||||
pixels_per_second, dynamic_duration,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_pixels_per_second(self, settings: Dict[str, Any]) -> float:
|
# The reason this module exists upstream: the bundled copies hardcode
|
||||||
"""This module's config shape, expressed as plain pixels per second.
|
# ~100 FPS via scroll_delay and never consult the global target.
|
||||||
|
# No hasattr guard here, unlike the plugin copies: they probe because
|
||||||
``scroll_speed`` is already px/s here. ``scroll_delay`` only matters
|
# they may run against an older core, whereas this module ships in the
|
||||||
when a caller supplied px/frame instead, which the 0 case covers.
|
# same release as the ScrollHelper it calls. The helper clamps.
|
||||||
"""
|
target_fps = self._resolve_target_fps()
|
||||||
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
|
if target_fps:
|
||||||
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
|
self.scroll_helper.set_target_fps(target_fps)
|
||||||
if scroll_delay <= 0:
|
self.logger.info(f"Target FPS set to {target_fps}")
|
||||||
return scroll_speed * ASSUMED_FPS_WHEN_UNPACED
|
|
||||||
return scroll_speed
|
|
||||||
|
|
||||||
def _resolve_refresh_hz(self) -> Optional[float]:
|
|
||||||
"""The panel refresh the crisp ladder should be computed against.
|
|
||||||
|
|
||||||
Prefers the configured hardware refresh. Falls back to the global
|
|
||||||
``target_fps``/``scroll_target_fps`` this module has always honoured:
|
|
||||||
under the old model that key *was* the rate frames were presented at,
|
|
||||||
so it is the faithful translation for anyone who set it. Returning
|
|
||||||
None lets scroll_config apply its own default.
|
|
||||||
"""
|
|
||||||
hardware = scroll_config.refresh_hz_from_config(self.global_config)
|
|
||||||
if hardware and hardware != scroll_config.DEFAULT_REFRESH_HZ:
|
|
||||||
return hardware
|
|
||||||
return self._resolve_target_fps() or hardware or None
|
|
||||||
|
|
||||||
def _scroll_frame_hold(self) -> int:
|
|
||||||
"""Refreshes to hold each frame for, from the resolved settings."""
|
|
||||||
return getattr(getattr(self, "_scroll_settings", None), "frame_hold", 1)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Frame pumping
|
# Frame pumping
|
||||||
@@ -326,16 +305,6 @@ class SportsScrollDisplay:
|
|||||||
if not visible:
|
if not visible:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Tell the core the panel is scrolling, and for how many
|
|
||||||
# refreshes to hold each frame. Without this the frame hold is
|
|
||||||
# never applied -- so a speed the ladder made crisp still presents
|
|
||||||
# a new frame every refresh and judders -- and, because deferred
|
|
||||||
# updates only run while nothing is scrolling, core would run
|
|
||||||
# blocking work in the middle of this scroll.
|
|
||||||
if hasattr(self.display_manager, "set_scrolling_state"):
|
|
||||||
self.display_manager.set_scrolling_state(
|
|
||||||
True, frame_hold=self._scroll_frame_hold())
|
|
||||||
|
|
||||||
self.display_manager.image = visible
|
self.display_manager.image = visible
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
self._frame_count += 1
|
self._frame_count += 1
|
||||||
@@ -362,19 +331,7 @@ class SportsScrollDisplay:
|
|||||||
|
|
||||||
def is_scroll_complete(self) -> bool:
|
def is_scroll_complete(self) -> bool:
|
||||||
"""True when the strip has scrolled fully past the panel."""
|
"""True when the strip has scrolled fully past the panel."""
|
||||||
complete = self.scroll_helper.is_scroll_complete()
|
return self.scroll_helper.is_scroll_complete()
|
||||||
if complete:
|
|
||||||
self._release_scrolling_state()
|
|
||||||
return complete
|
|
||||||
|
|
||||||
def _release_scrolling_state(self) -> None:
|
|
||||||
"""Tell the core this display is no longer scrolling.
|
|
||||||
|
|
||||||
The scrolling flag and the frame hold are global to the display
|
|
||||||
manager, so leaving them set holds every other plugin's frames too.
|
|
||||||
"""
|
|
||||||
if hasattr(self.display_manager, "set_scrolling_state"):
|
|
||||||
self.display_manager.set_scrolling_state(False)
|
|
||||||
|
|
||||||
def reset_scroll(self) -> None:
|
def reset_scroll(self) -> None:
|
||||||
"""Return the strip to its starting position, keeping the content."""
|
"""Return the strip to its starting position, keeping the content."""
|
||||||
@@ -392,7 +349,6 @@ class SportsScrollDisplay:
|
|||||||
self._vegas_content_items = []
|
self._vegas_content_items = []
|
||||||
self._is_scrolling = False
|
self._is_scrolling = False
|
||||||
self._scroll_start_time = None
|
self._scroll_start_time = None
|
||||||
self._release_scrolling_state()
|
|
||||||
self.logger.debug("Scroll display cleared")
|
self.logger.debug("Scroll display cleared")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
|||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from src.common.font_layout import load_truetype
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -713,11 +712,11 @@ class SportsCoreSharedMixin:
|
|||||||
while size > grid:
|
while size > grid:
|
||||||
if probe.textlength(
|
if probe.textlength(
|
||||||
self._SCORE_PROBE_TEXT,
|
self._SCORE_PROBE_TEXT,
|
||||||
font=load_truetype(path, size)) <= budget:
|
font=ImageFont.truetype(path, size)) <= budget:
|
||||||
break
|
break
|
||||||
size -= grid
|
size -= grid
|
||||||
if size != getattr(fonts['score'], 'size', size):
|
if size != getattr(fonts['score'], 'size', size):
|
||||||
fonts['score'] = load_truetype(path, size)
|
fonts['score'] = ImageFont.truetype(path, size)
|
||||||
self._score_grew = True
|
self._score_grew = True
|
||||||
|
|
||||||
if not self._score_grew and not self._user_chose_size('score_text') \
|
if not self._score_grew and not self._user_chose_size('score_text') \
|
||||||
@@ -737,7 +736,7 @@ class SportsCoreSharedMixin:
|
|||||||
if _size <= current:
|
if _size <= current:
|
||||||
continue
|
continue
|
||||||
_path = _resolve_font_path(f"assets/fonts/{_name}")
|
_path = _resolve_font_path(f"assets/fonts/{_name}")
|
||||||
_candidate = load_truetype(_path, _size)
|
_candidate = ImageFont.truetype(_path, _size)
|
||||||
if probe.textlength(self._SCORE_PROBE_TEXT,
|
if probe.textlength(self._SCORE_PROBE_TEXT,
|
||||||
font=_candidate) <= budget:
|
font=_candidate) <= budget:
|
||||||
fonts['score'] = _candidate
|
fonts['score'] = _candidate
|
||||||
@@ -752,7 +751,7 @@ class SportsCoreSharedMixin:
|
|||||||
if ceiling and size >= ceiling:
|
if ceiling and size >= ceiling:
|
||||||
size = max(grid, ceiling - grid)
|
size = max(grid, ceiling - grid)
|
||||||
if size != getattr(fonts['time'], 'size', size):
|
if size != getattr(fonts['time'], 'size', size):
|
||||||
fonts['time'] = load_truetype(path, size)
|
fonts['time'] = ImageFont.truetype(path, size)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.debug("Headline font scaling skipped", exc_info=True)
|
self.logger.debug("Headline font scaling skipped", exc_info=True)
|
||||||
return fonts
|
return fonts
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from pathlib import Path
|
|||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from src.common.font_layout import load_truetype
|
|
||||||
|
|
||||||
# Shared throwaway draw surface for measuring text without a target canvas.
|
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||||
@@ -61,7 +60,7 @@ class TextHelper:
|
|||||||
size = config['size']
|
size = config['size']
|
||||||
|
|
||||||
if font_path.exists():
|
if font_path.exists():
|
||||||
font = load_truetype(str(font_path), size)
|
font = ImageFont.truetype(str(font_path), size)
|
||||||
fonts[font_name] = font
|
fonts[font_name] = font
|
||||||
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
||||||
else:
|
else:
|
||||||
|
|||||||
+85
-189
@@ -198,10 +198,6 @@ class DisplayController:
|
|||||||
# the main run loop reconciles (loads/unloads) on its own thread so
|
# the main run loop reconciles (loads/unloads) on its own thread so
|
||||||
# mutating available_modes never races with rendering.
|
# mutating available_modes never races with rendering.
|
||||||
self._pending_plugin_reconcile = False
|
self._pending_plugin_reconcile = False
|
||||||
# Monotonic stamp of the last mailbox disk read; see
|
|
||||||
# _poll_on_demand_requests. None means "never polled", so the first
|
|
||||||
# call always goes through.
|
|
||||||
self._last_on_demand_poll: Optional[float] = None
|
|
||||||
self.on_demand_active = False
|
self.on_demand_active = False
|
||||||
self.on_demand_mode: Optional[str] = None
|
self.on_demand_mode: Optional[str] = None
|
||||||
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
|
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
|
||||||
@@ -307,7 +303,39 @@ class DisplayController:
|
|||||||
|
|
||||||
# Check for on-demand plugin filter from cache
|
# Check for on-demand plugin filter from cache
|
||||||
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
|
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
|
||||||
enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config)
|
on_demand_plugin_id = on_demand_config.get('plugin_id') if on_demand_config else None
|
||||||
|
|
||||||
|
if on_demand_plugin_id:
|
||||||
|
logger.info("On-demand mode detected during initialization: filtering to plugin '%s' only", on_demand_plugin_id)
|
||||||
|
# Only load the on-demand plugin, but ensure it's enabled
|
||||||
|
if on_demand_plugin_id not in discovered_plugins:
|
||||||
|
error_msg = f"On-demand plugin '{on_demand_plugin_id}' not found in discovered plugins"
|
||||||
|
logger.error(error_msg)
|
||||||
|
logger.warning("Falling back to normal mode (all enabled plugins)")
|
||||||
|
on_demand_plugin_id = None
|
||||||
|
enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)]
|
||||||
|
else:
|
||||||
|
plugin_config = self.config.get(on_demand_plugin_id, {})
|
||||||
|
was_disabled = not plugin_config.get('enabled', False)
|
||||||
|
if was_disabled:
|
||||||
|
logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id)
|
||||||
|
if on_demand_plugin_id not in self.config:
|
||||||
|
self.config[on_demand_plugin_id] = {}
|
||||||
|
self.config[on_demand_plugin_id]['enabled'] = True
|
||||||
|
enabled_plugins = [on_demand_plugin_id]
|
||||||
|
# Set on-demand state from cached config
|
||||||
|
self.on_demand_active = True
|
||||||
|
self.on_demand_plugin_id = on_demand_plugin_id
|
||||||
|
self.on_demand_mode = on_demand_config.get('mode')
|
||||||
|
self.on_demand_duration = on_demand_config.get('duration')
|
||||||
|
self.on_demand_pinned = on_demand_config.get('pinned', False)
|
||||||
|
self.on_demand_requested_at = on_demand_config.get('requested_at')
|
||||||
|
self.on_demand_expires_at = on_demand_config.get('expires_at')
|
||||||
|
self.on_demand_status = 'active'
|
||||||
|
self.on_demand_schedule_override = True
|
||||||
|
logger.info("On-demand mode: loading only plugin '%s'", on_demand_plugin_id)
|
||||||
|
else:
|
||||||
|
enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)]
|
||||||
|
|
||||||
# Count enabled plugins for progress tracking
|
# Count enabled plugins for progress tracking
|
||||||
enabled_count = len(enabled_plugins)
|
enabled_count = len(enabled_plugins)
|
||||||
@@ -1239,119 +1267,12 @@ class DisplayController:
|
|||||||
self.on_demand_schedule_override = False
|
self.on_demand_schedule_override = False
|
||||||
self._publish_on_demand_state()
|
self._publish_on_demand_state()
|
||||||
|
|
||||||
#: Shortest gap between mailbox disk reads. This is called after every
|
|
||||||
#: frame -- about 125 times a second on a scrolling mode -- and the read
|
|
||||||
#: below is deliberately uncached, so without a floor it was 125 disk reads
|
|
||||||
#: per second to find nothing. An on-demand request comes from a person
|
|
||||||
#: clicking in the web UI, so a quarter second of latency is not
|
|
||||||
#: perceptible, and it cuts the read rate by 30x.
|
|
||||||
ON_DEMAND_POLL_INTERVAL = 0.25
|
|
||||||
|
|
||||||
def _select_startup_plugins(self, discovered_plugins: List[str],
|
|
||||||
on_demand_config: Optional[Dict[str, Any]]) -> List[str]:
|
|
||||||
"""Which plugins to load at startup, restoring on-demand state if any.
|
|
||||||
|
|
||||||
Every normally-enabled plugin loads, on-demand or not. Loading only the
|
|
||||||
on-demand plugin left every other plugin unavailable for the rest of
|
|
||||||
the process's life whenever the service was restarted while on-demand
|
|
||||||
was still active -- and a restart during an on-demand session is
|
|
||||||
routine, since that is how updates and config changes are applied. The
|
|
||||||
panel came back cycling that one plugin's modes and nothing else, with
|
|
||||||
no way out but clearing the on-demand cache by hand.
|
|
||||||
|
|
||||||
On-demand still resumes on its saved mode; this only widens what gets
|
|
||||||
loaded, so normal rotation has somewhere to return to when it ends.
|
|
||||||
A plugin that is disabled in config but named by the on-demand request
|
|
||||||
is still enabled and added, since otherwise the mode being resumed
|
|
||||||
would have nothing behind it.
|
|
||||||
"""
|
|
||||||
enabled_plugins = [p for p in discovered_plugins
|
|
||||||
if self.config.get(p, {}).get('enabled', False)]
|
|
||||||
|
|
||||||
on_demand_plugin_id = on_demand_config.get('plugin_id') if on_demand_config else None
|
|
||||||
if not on_demand_plugin_id:
|
|
||||||
return enabled_plugins
|
|
||||||
|
|
||||||
if on_demand_plugin_id not in discovered_plugins:
|
|
||||||
logger.error("On-demand plugin '%s' not found in discovered plugins",
|
|
||||||
on_demand_plugin_id)
|
|
||||||
logger.warning("Falling back to normal mode (all enabled plugins)")
|
|
||||||
return enabled_plugins
|
|
||||||
|
|
||||||
if not self.config.get(on_demand_plugin_id, {}).get('enabled', False):
|
|
||||||
logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id)
|
|
||||||
self.config.setdefault(on_demand_plugin_id, {})['enabled'] = True
|
|
||||||
if on_demand_plugin_id not in enabled_plugins:
|
|
||||||
enabled_plugins.append(on_demand_plugin_id)
|
|
||||||
|
|
||||||
# Restore on-demand state from the cached request so it resumes.
|
|
||||||
self.on_demand_active = True
|
|
||||||
self.on_demand_plugin_id = on_demand_plugin_id
|
|
||||||
self.on_demand_mode = on_demand_config.get('mode')
|
|
||||||
self.on_demand_duration = on_demand_config.get('duration')
|
|
||||||
self.on_demand_pinned = on_demand_config.get('pinned', False)
|
|
||||||
self.on_demand_requested_at = on_demand_config.get('requested_at')
|
|
||||||
self.on_demand_expires_at = on_demand_config.get('expires_at')
|
|
||||||
self.on_demand_status = 'active'
|
|
||||||
self.on_demand_schedule_override = True
|
|
||||||
logger.info("On-demand mode detected during initialization: resuming on plugin '%s'; "
|
|
||||||
"all %d enabled plugin(s) still load normally",
|
|
||||||
on_demand_plugin_id, len(enabled_plugins))
|
|
||||||
return enabled_plugins
|
|
||||||
|
|
||||||
def _consume_on_demand_request(self, request_id: str) -> None:
|
|
||||||
"""Remove the request we just handled from the mailbox.
|
|
||||||
|
|
||||||
Leaving it on disk meant a restart replayed the previous request: the
|
|
||||||
fresh controller read it, activated it and cached it, so the request
|
|
||||||
the caller had just made was ignored and the panel silently showed the
|
|
||||||
earlier plugin.
|
|
||||||
|
|
||||||
Compare before deleting. The web process can post a newer request
|
|
||||||
between the read and this delete; an unconditional delete threw that
|
|
||||||
one away and it was never processed -- the user's second click did
|
|
||||||
nothing. Re-reading uncached and only deleting our own request_id
|
|
||||||
leaves a newer request in the mailbox for the next poll instead.
|
|
||||||
|
|
||||||
This narrows the window rather than closing it: a request landing
|
|
||||||
between the re-read and the delete is still lost. Closing it properly
|
|
||||||
needs an atomic claim (a rename, or a compare-and-delete primitive)
|
|
||||||
that the cache layer does not currently offer, so the honest fix is a
|
|
||||||
smaller window plus this note, not a bigger lock. For start requests
|
|
||||||
processed_id still guards against reprocessing if the delete fails.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
current = self.cache_manager.get('display_on_demand_request',
|
|
||||||
max_age=3600, memory_ttl=0)
|
|
||||||
if not current or current.get('request_id') == request_id:
|
|
||||||
self.cache_manager.delete('display_on_demand_request')
|
|
||||||
else:
|
|
||||||
logger.debug("Newer on-demand request %s arrived while processing "
|
|
||||||
"%s; leaving it in the mailbox",
|
|
||||||
current.get('request_id'), request_id)
|
|
||||||
except (OSError, AttributeError, KeyError) as err:
|
|
||||||
logger.debug("Could not clear the on-demand request mailbox: %s", err)
|
|
||||||
|
|
||||||
def _poll_on_demand_requests(self) -> None:
|
def _poll_on_demand_requests(self) -> None:
|
||||||
"""Poll cache for new on-demand requests from external controllers."""
|
"""Poll cache for new on-demand requests from external controllers."""
|
||||||
now = time.monotonic()
|
|
||||||
if (self._last_on_demand_poll is not None
|
|
||||||
and now - self._last_on_demand_poll < self.ON_DEMAND_POLL_INTERVAL):
|
|
||||||
return
|
|
||||||
self._last_on_demand_poll = now
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use a long max_age (1 hour) to ensure requests aren't expired before processing
|
# Use a long max_age (1 hour) to ensure requests aren't expired before processing
|
||||||
# The request_id check prevents duplicate processing.
|
# The request_id check prevents duplicate processing
|
||||||
#
|
request = self.cache_manager.get('display_on_demand_request', max_age=3600)
|
||||||
# memory_ttl=0 is required, not optional: this key is a mailbox the
|
|
||||||
# web process writes and this process reads. get() defaults the
|
|
||||||
# in-memory TTL to max_age, so without it the first request read was
|
|
||||||
# pinned in memory for the full hour and every later poll returned
|
|
||||||
# that stale copy -- meaning no second on-demand request was honoured
|
|
||||||
# for an hour, while the API still reported success.
|
|
||||||
request = self.cache_manager.get('display_on_demand_request',
|
|
||||||
max_age=3600, memory_ttl=0)
|
|
||||||
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
||||||
logger.error("Failed to read on-demand request: %s", err, exc_info=True)
|
logger.error("Failed to read on-demand request: %s", err, exc_info=True)
|
||||||
return
|
return
|
||||||
@@ -1378,13 +1299,6 @@ class DisplayController:
|
|||||||
logger.debug("Stop request %s received but on-demand is not active", request_id)
|
logger.debug("Stop request %s received but on-demand is not active", request_id)
|
||||||
# Still update request_id to acknowledge the request
|
# Still update request_id to acknowledge the request
|
||||||
self.on_demand_request_id = request_id
|
self.on_demand_request_id = request_id
|
||||||
# Stop requests are deliberately exempt from the request_id/
|
|
||||||
# processed_id guards above, so that a second click stops a mode
|
|
||||||
# that a race left running. Consuming the mailbox is therefore the
|
|
||||||
# only thing that ends the request: without it the same stop was
|
|
||||||
# re-read and re-processed on every poll, forever, logging at
|
|
||||||
# ON_DEMAND_POLL_INTERVAL for the life of the process.
|
|
||||||
self._consume_on_demand_request(request_id)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# For start requests, check if already processed
|
# For start requests, check if already processed
|
||||||
@@ -1404,7 +1318,6 @@ class DisplayController:
|
|||||||
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
||||||
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
||||||
self.on_demand_request_id = request_id
|
self.on_demand_request_id = request_id
|
||||||
self._consume_on_demand_request(request_id)
|
|
||||||
|
|
||||||
if action == 'start':
|
if action == 'start':
|
||||||
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
||||||
@@ -1447,14 +1360,17 @@ class DisplayController:
|
|||||||
return modes[0]
|
return modes[0]
|
||||||
return plugin_id
|
return plugin_id
|
||||||
|
|
||||||
def _on_demand_modes_for_plugin(self, plugin_id: str) -> List[str]:
|
def _populate_on_demand_modes_from_plugin(self) -> None:
|
||||||
"""Every loaded display mode belonging to `plugin_id`, in rotation order.
|
|
||||||
|
|
||||||
Live modes that actually have content lead, then the rest, then live
|
|
||||||
modes with nothing to show -- so an on-demand request for a sports
|
|
||||||
plugin opens on a game in progress rather than an empty live screen.
|
|
||||||
Returns an empty list when the plugin has no loaded modes.
|
|
||||||
"""
|
"""
|
||||||
|
Populate on_demand_modes from the on-demand plugin's display modes.
|
||||||
|
Called after plugin loading completes when on-demand state is restored from cache.
|
||||||
|
"""
|
||||||
|
if not self.on_demand_active or not self.on_demand_plugin_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
plugin_id = self.on_demand_plugin_id
|
||||||
|
|
||||||
|
# Get all modes for this plugin
|
||||||
plugin_modes = self.plugin_display_modes.get(plugin_id, [])
|
plugin_modes = self.plugin_display_modes.get(plugin_id, [])
|
||||||
if not plugin_modes:
|
if not plugin_modes:
|
||||||
# Fallback: find all modes that belong to this plugin
|
# Fallback: find all modes that belong to this plugin
|
||||||
@@ -1462,8 +1378,11 @@ class DisplayController:
|
|||||||
|
|
||||||
# Filter to only include modes that exist in plugin_modes
|
# Filter to only include modes that exist in plugin_modes
|
||||||
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
|
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
|
||||||
|
|
||||||
if not available_plugin_modes:
|
if not available_plugin_modes:
|
||||||
return []
|
logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id)
|
||||||
|
self.on_demand_modes = []
|
||||||
|
return
|
||||||
|
|
||||||
# Prioritize live modes if they exist and have content
|
# Prioritize live modes if they exist and have content
|
||||||
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
|
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
|
||||||
@@ -1491,45 +1410,6 @@ class DisplayController:
|
|||||||
# Only live modes available but no content - use them anyway
|
# Only live modes available but no content - use them anyway
|
||||||
ordered_modes = live_modes
|
ordered_modes = live_modes
|
||||||
|
|
||||||
return ordered_modes
|
|
||||||
|
|
||||||
def _apply_on_demand_pin(self, ordered_modes: List[str], resolved_mode: Optional[str],
|
|
||||||
pinned: bool) -> List[str]:
|
|
||||||
"""Narrow an on-demand rotation to the single requested mode when pinned.
|
|
||||||
|
|
||||||
`pinned` reaches the controller from the API and was stored and
|
|
||||||
republished but never acted on, so a pinned request still rotated
|
|
||||||
through every mode the resolved plugin owns. That is the right default
|
|
||||||
for a sports plugin, whose modes are views of one subject
|
|
||||||
(nfl_live/nfl_recent/nfl_upcoming), and the wrong one for a plugin
|
|
||||||
whose modes are unrelated -- each Starlark app is its own widget, so
|
|
||||||
asking for one and getting all of them is not what was requested.
|
|
||||||
"""
|
|
||||||
if not pinned or not resolved_mode or resolved_mode not in ordered_modes:
|
|
||||||
return ordered_modes
|
|
||||||
return [resolved_mode]
|
|
||||||
|
|
||||||
def _populate_on_demand_modes_from_plugin(self) -> None:
|
|
||||||
"""
|
|
||||||
Populate on_demand_modes from the on-demand plugin's display modes.
|
|
||||||
Called after plugin loading completes when on-demand state is restored from cache.
|
|
||||||
"""
|
|
||||||
if not self.on_demand_active or not self.on_demand_plugin_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
plugin_id = self.on_demand_plugin_id
|
|
||||||
|
|
||||||
ordered_modes = self._on_demand_modes_for_plugin(plugin_id)
|
|
||||||
if not ordered_modes:
|
|
||||||
logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id)
|
|
||||||
self.on_demand_modes = []
|
|
||||||
return
|
|
||||||
|
|
||||||
# A restart must not silently un-pin: the pin is part of the request
|
|
||||||
# being resumed, and it is restored from the same cached config above.
|
|
||||||
ordered_modes = self._apply_on_demand_pin(
|
|
||||||
ordered_modes, self.on_demand_mode, self.on_demand_pinned)
|
|
||||||
|
|
||||||
self.on_demand_modes = ordered_modes
|
self.on_demand_modes = ordered_modes
|
||||||
# Set index to match the restored mode if available, otherwise start at 0
|
# Set index to match the restored mode if available, otherwise start at 0
|
||||||
if self.on_demand_mode and self.on_demand_mode in ordered_modes:
|
if self.on_demand_mode and self.on_demand_mode in ordered_modes:
|
||||||
@@ -1584,13 +1464,45 @@ class DisplayController:
|
|||||||
if resolved_mode in self.available_modes:
|
if resolved_mode in self.available_modes:
|
||||||
self.current_mode_index = self.available_modes.index(resolved_mode)
|
self.current_mode_index = self.available_modes.index(resolved_mode)
|
||||||
|
|
||||||
ordered_modes = self._on_demand_modes_for_plugin(resolved_plugin_id)
|
# Get all modes for this plugin
|
||||||
if not ordered_modes:
|
plugin_modes = self.plugin_display_modes.get(resolved_plugin_id, [])
|
||||||
|
if not plugin_modes:
|
||||||
|
# Fallback: find all modes that belong to this plugin
|
||||||
|
plugin_modes = [mode for mode, pid in self.mode_to_plugin_id.items() if pid == resolved_plugin_id]
|
||||||
|
|
||||||
|
# Filter to only include modes that exist in plugin_modes
|
||||||
|
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
|
||||||
|
|
||||||
|
if not available_plugin_modes:
|
||||||
logger.error("No valid display modes found for plugin '%s'", resolved_plugin_id)
|
logger.error("No valid display modes found for plugin '%s'", resolved_plugin_id)
|
||||||
self._set_on_demand_error("no-modes")
|
self._set_on_demand_error("no-modes")
|
||||||
return
|
return
|
||||||
|
|
||||||
ordered_modes = self._apply_on_demand_pin(ordered_modes, resolved_mode, pinned)
|
# Prioritize live modes if they exist and have content
|
||||||
|
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
|
||||||
|
other_modes = [m for m in available_plugin_modes if not m.endswith('_live')]
|
||||||
|
|
||||||
|
# Check if live modes have content
|
||||||
|
live_with_content = []
|
||||||
|
for live_mode in live_modes:
|
||||||
|
plugin_instance = self.plugin_modes.get(live_mode)
|
||||||
|
if plugin_instance and hasattr(plugin_instance, 'has_live_content'):
|
||||||
|
try:
|
||||||
|
if plugin_instance.has_live_content():
|
||||||
|
live_with_content.append(live_mode)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build mode list: live modes with content first, then other modes, then live modes without content
|
||||||
|
if live_with_content:
|
||||||
|
ordered_modes = live_with_content + other_modes + [m for m in live_modes if m not in live_with_content]
|
||||||
|
else:
|
||||||
|
# No live content, skip live modes
|
||||||
|
ordered_modes = other_modes
|
||||||
|
|
||||||
|
if not ordered_modes:
|
||||||
|
# Only live modes available but no content - use them anyway
|
||||||
|
ordered_modes = live_modes
|
||||||
|
|
||||||
self.on_demand_active = True
|
self.on_demand_active = True
|
||||||
self.on_demand_mode = resolved_mode # Keep for backward compatibility
|
self.on_demand_mode = resolved_mode # Keep for backward compatibility
|
||||||
@@ -2493,7 +2405,6 @@ class DisplayController:
|
|||||||
)
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
_frame_start = time.perf_counter()
|
|
||||||
try:
|
try:
|
||||||
with self._display_lock_or_skip(plugin_id) as can_display:
|
with self._display_lock_or_skip(plugin_id) as can_display:
|
||||||
if can_display:
|
if can_display:
|
||||||
@@ -2514,26 +2425,11 @@ class DisplayController:
|
|||||||
# Multi-display sync: send follower frame after each render
|
# Multi-display sync: send follower frame after each render
|
||||||
self._send_follower_frame(manager_to_display)
|
self._send_follower_frame(manager_to_display)
|
||||||
|
|
||||||
|
time.sleep(display_interval)
|
||||||
self._tick_plugin_updates()
|
self._tick_plugin_updates()
|
||||||
self._poll_on_demand_requests()
|
self._poll_on_demand_requests()
|
||||||
self._check_on_demand_expiration()
|
self._check_on_demand_expiration()
|
||||||
|
|
||||||
# Pace to the frame deadline rather than sleeping a flat
|
|
||||||
# interval on top of the work. display() has already
|
|
||||||
# blocked on the panel's vsync by this point, so an
|
|
||||||
# unconditional sleep is added to a wait that already
|
|
||||||
# happened. Measured on a 2x128x64 chain at
|
|
||||||
# limit_refresh_rate_hz=100: ~4ms of render plus a flat
|
|
||||||
# 8ms put each iteration at ~12ms against a 10ms refresh
|
|
||||||
# grid, so every swap missed a refresh and the loop
|
|
||||||
# settled at 50fps where display_interval asks for 125 --
|
|
||||||
# and with zero headroom, ~14% of frames slipped a
|
|
||||||
# further refresh, which is what reads as scroll stutter.
|
|
||||||
_remaining = display_interval - (time.perf_counter() - _frame_start)
|
|
||||||
# Yield even when the frame overran its budget, so plugin
|
|
||||||
# update threads and the web UI are not starved of the GIL.
|
|
||||||
time.sleep(_remaining if _remaining > 0 else 0.001)
|
|
||||||
|
|
||||||
if self.current_display_mode != active_mode:
|
if self.current_display_mode != active_mode:
|
||||||
logger.debug("Mode changed during high-FPS loop, breaking early")
|
logger.debug("Mode changed during high-FPS loop, breaking early")
|
||||||
break
|
break
|
||||||
|
|||||||
+23
-196
@@ -34,7 +34,6 @@ else:
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from src.common.font_layout import load_truetype
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
@@ -56,31 +55,6 @@ from src.common.permission_utils import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.INFO) # Set to INFO level
|
logger.setLevel(logging.INFO) # Set to INFO level
|
||||||
|
|
||||||
#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed
|
|
||||||
#: size regardless, but a Face needs an active size before its metrics --
|
|
||||||
#: and therefore get_font_height() -- report anything but 0.
|
|
||||||
_CALENDAR_FONT_PX = 7
|
|
||||||
|
|
||||||
|
|
||||||
def _bdf_native_size(face) -> int:
|
|
||||||
"""The pixel height a BDF Face declares, or 0 if it does not say.
|
|
||||||
|
|
||||||
Used only to rescue a Face that was built without ``set_char_size``, so a
|
|
||||||
zero line height never reaches layout code.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
sizes = getattr(face, "available_sizes", None) or []
|
|
||||||
if sizes:
|
|
||||||
return int(getattr(sizes[0], "height", 0) or 0)
|
|
||||||
except (AttributeError, IndexError, TypeError, ValueError) as exc:
|
|
||||||
# This runs on the measurement path for a face the caller already
|
|
||||||
# holds, so a malformed strike table must degrade to "unknown" rather
|
|
||||||
# than take the display down. Say which face, so a font that is
|
|
||||||
# actually broken is diagnosable rather than silently 8px.
|
|
||||||
logger.debug("Could not read BDF strike size from %r: %s", face, exc)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class _LogicalMatrix:
|
class _LogicalMatrix:
|
||||||
"""Proxy that reports a logical (per-screen) size for a physical matrix.
|
"""Proxy that reports a logical (per-screen) size for a physical matrix.
|
||||||
@@ -266,14 +240,6 @@ class DisplayManager:
|
|||||||
self._update_lock = threading.RLock()
|
self._update_lock = threading.RLock()
|
||||||
|
|
||||||
# Scrolling state tracking for graceful updates
|
# Scrolling state tracking for graceful updates
|
||||||
# How many panel refreshes each pushed frame is held for. 1 means a new
|
|
||||||
# frame every refresh. Higher values are how a scroll runs slower than
|
|
||||||
# one pixel per refresh WITHOUT fractional pixel positions: the panel
|
|
||||||
# keeps refreshing at full rate (so flicker is unchanged) but motion
|
|
||||||
# advances a whole pixel every Nth refresh instead of every one.
|
|
||||||
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
|
|
||||||
self._frame_hold = 1
|
|
||||||
|
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
'is_scrolling': False,
|
'is_scrolling': False,
|
||||||
'last_scroll_activity': 0,
|
'last_scroll_activity': 0,
|
||||||
@@ -403,7 +369,7 @@ class DisplayManager:
|
|||||||
|
|
||||||
# Initialize font with Press Start 2P
|
# Initialize font with Press Start 2P
|
||||||
try:
|
try:
|
||||||
self.font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||||
logger.info("Initial Press Start 2P font loaded successfully")
|
logger.info("Initial Press Start 2P font loaded successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load initial font: {e}")
|
logger.error(f"Failed to load initial font: {e}")
|
||||||
@@ -583,33 +549,19 @@ class DisplayManager:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _fitting_font(self, lines, width):
|
def _fitting_font(self, lines, width):
|
||||||
"""The largest font from the usual ladder that fits every line.
|
"""The largest font from the usual ladder that fits every line."""
|
||||||
|
|
||||||
The ladder ends at 4x6 at 5px because a full dotted-quad address --
|
|
||||||
"255.255.255.255", the widest this screen ever shows -- is 66px at
|
|
||||||
6px and a 64px panel has 62 to give it. That used to squeak in only
|
|
||||||
because the measurement depended on which text layout engine the host
|
|
||||||
Pillow had; with the engine pinned it does not, so the rung the
|
|
||||||
worst case actually needs is here rather than implied.
|
|
||||||
"""
|
|
||||||
candidates = [self.font,
|
candidates = [self.font,
|
||||||
("assets/fonts/4x6-font.ttf", 6),
|
("assets/fonts/4x6-font.ttf", 6)]
|
||||||
("assets/fonts/4x6-font.ttf", 5)]
|
|
||||||
narrowest = None
|
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
try:
|
try:
|
||||||
font = candidate
|
font = candidate
|
||||||
if isinstance(candidate, tuple):
|
if isinstance(candidate, tuple):
|
||||||
font = load_truetype(candidate[0], candidate[1])
|
font = ImageFont.truetype(candidate[0], candidate[1])
|
||||||
narrowest = font
|
|
||||||
if all(self.draw.textlength(t, font=font) <= width for t in lines):
|
if all(self.draw.textlength(t, font=font) <= width for t in lines):
|
||||||
return font
|
return font
|
||||||
except (OSError, ValueError, AttributeError):
|
except (OSError, ValueError, AttributeError):
|
||||||
continue
|
continue
|
||||||
# Nothing fit. Return the smallest face that loaded, not self.font --
|
return self.font
|
||||||
# falling back to the widest option is how "Initializing" ran off the
|
|
||||||
# side of a 64px panel in the first place.
|
|
||||||
return narrowest or self.font
|
|
||||||
|
|
||||||
def _draw_startup_banner(self, lines, width: int, height: int) -> None:
|
def _draw_startup_banner(self, lines, width: int, height: int) -> None:
|
||||||
"""Centre `lines` over whatever the test pattern already drew.
|
"""Centre `lines` over whatever the test pattern already drew.
|
||||||
@@ -819,33 +771,16 @@ class DisplayManager:
|
|||||||
return # Skip hardware write — content is being captured off-screen
|
return # Skip hardware write — content is being captured off-screen
|
||||||
|
|
||||||
digest = None
|
digest = None
|
||||||
frame_checksum = None
|
|
||||||
if self._dirty_tracking_enabled:
|
if self._dirty_tracking_enabled:
|
||||||
try:
|
try:
|
||||||
brightness = getattr(self.matrix, 'brightness', None)
|
brightness = getattr(self.matrix, 'brightness', None)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
brightness = None
|
brightness = None
|
||||||
frame_checksum = zlib.adler32(self.image.tobytes())
|
digest = (zlib.adler32(self.image.tobytes()), brightness)
|
||||||
digest = (frame_checksum, brightness)
|
if digest == self._last_pushed_digest:
|
||||||
if digest == self._last_pushed_digest and not self.is_currently_scrolling():
|
|
||||||
# Nothing changed since the last push — the panel is
|
# Nothing changed since the last push — the panel is
|
||||||
# already showing exactly this frame.
|
# already showing exactly this frame.
|
||||||
#
|
self._write_snapshot_if_due()
|
||||||
# Never taken mid-scroll, and that exception is the
|
|
||||||
# point. SwapOnVSync is what paces the render loop, so
|
|
||||||
# skipping it also skips the wait: a duplicate frame
|
|
||||||
# returns in ~8ms instead of ~10ms on a 100Hz panel,
|
|
||||||
# advances only 0.8px instead of 1.0px, and so makes
|
|
||||||
# the *next* frame more likely to repeat as well. That
|
|
||||||
# is self-sustaining -- measured at ~20% duplicate
|
|
||||||
# frames mid-scroll on the odds ticker, against
|
|
||||||
# essentially zero on a lighter plugin with identical
|
|
||||||
# scroll settings. Swapping an identical frame costs
|
|
||||||
# one canvas copy and keeps the loop locked to the
|
|
||||||
# panel; falling out of that lock costs smooth motion.
|
|
||||||
# Static content is unaffected: is_currently_scrolling()
|
|
||||||
# expires on its own inactivity threshold.
|
|
||||||
self._write_snapshot_if_due(frame_checksum)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Copy the current image to the offscreen canvas. In double-sided
|
# Copy the current image to the offscreen canvas. In double-sided
|
||||||
@@ -855,10 +790,8 @@ class DisplayManager:
|
|||||||
else:
|
else:
|
||||||
self.offscreen_canvas.SetImage(self.image)
|
self.offscreen_canvas.SetImage(self.image)
|
||||||
|
|
||||||
# Swap buffers immediately. framerate_fraction holds the frame
|
# Swap buffers immediately
|
||||||
# for N refreshes; SwapOnVSync blocks for all of them, which is
|
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
||||||
# what paces the render loop to the chosen frame rate.
|
|
||||||
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
|
|
||||||
|
|
||||||
# Swap our canvas references
|
# Swap our canvas references
|
||||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||||
@@ -866,7 +799,7 @@ class DisplayManager:
|
|||||||
self._last_pushed_digest = digest
|
self._last_pushed_digest = digest
|
||||||
|
|
||||||
# Write a snapshot for the web preview (throttled)
|
# Write a snapshot for the web preview (throttled)
|
||||||
self._write_snapshot_if_due(frame_checksum)
|
self._write_snapshot_if_due()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating display: {e}")
|
logger.error(f"Error updating display: {e}")
|
||||||
|
|
||||||
@@ -973,11 +906,11 @@ class DisplayManager:
|
|||||||
self._text_width_cache.clear()
|
self._text_width_cache.clear()
|
||||||
try:
|
try:
|
||||||
# Load Press Start 2P font
|
# Load Press Start 2P font
|
||||||
self.regular_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||||
logger.info("Press Start 2P font loaded successfully")
|
logger.info("Press Start 2P font loaded successfully")
|
||||||
|
|
||||||
# Use the same font for small text (currently same size; adjust size here if needed)
|
# Use the same font for small text (currently same size; adjust size here if needed)
|
||||||
self.small_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
self.small_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||||
logger.info("Press Start 2P small font loaded successfully")
|
logger.info("Press Start 2P small font loaded successfully")
|
||||||
|
|
||||||
# Load 5x7 BDF font for calendar events
|
# Load 5x7 BDF font for calendar events
|
||||||
@@ -990,17 +923,6 @@ class DisplayManager:
|
|||||||
|
|
||||||
# Load with freetype for proper BDF handling
|
# Load with freetype for proper BDF handling
|
||||||
face = freetype.Face(self.calendar_font_path)
|
face = freetype.Face(self.calendar_font_path)
|
||||||
# A freshly constructed Face has no active size, so
|
|
||||||
# face.size.height is 0 until set_char_size is called -- and
|
|
||||||
# get_font_height() reads exactly that. Without this, every
|
|
||||||
# caller measuring the 5x7 face got 0 and stacked rows on top
|
|
||||||
# of one another; the "Calendar font size: 0 pixels" line
|
|
||||||
# below has been printing the symptom on every start-up.
|
|
||||||
# font_manager._load_bdf_font already does this; the two paths
|
|
||||||
# disagreed about whether a Face was usable for measurement.
|
|
||||||
# 5x7.bdf is a fixed strike, so FreeType renders 7px whatever
|
|
||||||
# is asked for -- this sets the metrics, not the raster.
|
|
||||||
face.set_char_size(_CALENDAR_FONT_PX * 64, _CALENDAR_FONT_PX * 64, 72, 72)
|
|
||||||
logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}")
|
logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}")
|
||||||
logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
|
logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
|
||||||
|
|
||||||
@@ -1021,7 +943,7 @@ class DisplayManager:
|
|||||||
try:
|
try:
|
||||||
font_path = "assets/fonts/4x6-font.ttf"
|
font_path = "assets/fonts/4x6-font.ttf"
|
||||||
logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size 6")
|
logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size 6")
|
||||||
self.extra_small_font = load_truetype(font_path, 6)
|
self.extra_small_font = ImageFont.truetype(font_path, 6)
|
||||||
logger.info(f"4x6 TTF extra small font loaded successfully from {font_path}")
|
logger.info(f"4x6 TTF extra small font loaded successfully from {font_path}")
|
||||||
except Exception as font_err:
|
except Exception as font_err:
|
||||||
logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.")
|
logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.")
|
||||||
@@ -1079,13 +1001,7 @@ class DisplayManager:
|
|||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
# For FreeType faces (BDF), the 'height' metric gives the recommended line spacing.
|
# For FreeType faces (BDF), the 'height' metric gives the recommended line spacing.
|
||||||
height = font.size.height >> 6
|
return font.size.height >> 6
|
||||||
if height:
|
|
||||||
return height
|
|
||||||
# A Face constructed without set_char_size reports 0, and a
|
|
||||||
# zero line height collapses every stacked row onto one line.
|
|
||||||
# Fall back to the strike the file declares.
|
|
||||||
return _bdf_native_size(font) or 8
|
|
||||||
else:
|
else:
|
||||||
# For PIL TTF fonts, getmetrics() provides ascent and descent.
|
# For PIL TTF fonts, getmetrics() provides ascent and descent.
|
||||||
# The line height is the sum of ascent and descent.
|
# The line height is the sum of ascent and descent.
|
||||||
@@ -1364,65 +1280,12 @@ class DisplayManager:
|
|||||||
|
|
||||||
return dt.strftime(f"%b %-d{suffix}")
|
return dt.strftime(f"%b %-d{suffix}")
|
||||||
|
|
||||||
@property
|
def set_scrolling_state(self, is_scrolling: bool):
|
||||||
def refresh_hz(self) -> float:
|
"""Set the current scrolling state. Call this when a display starts/stops scrolling."""
|
||||||
"""The panel's refresh rate in Hz, from the hardware config.
|
|
||||||
|
|
||||||
The authoritative place to ask, because a plugin only receives its own
|
|
||||||
config section and cannot see display.hardware. Scroll pacing needs
|
|
||||||
this: the speeds a panel can show in whole pixels are refresh_hz
|
|
||||||
divided by the frame hold, so getting it wrong silently produces
|
|
||||||
fractional-pixel motion. See src/common/scroll_config.py.
|
|
||||||
|
|
||||||
Note this is the configured *cap*, not necessarily what the panel
|
|
||||||
achieves -- scripts/scroll_speeds.py --measure reports the real rate.
|
|
||||||
"""
|
|
||||||
hardware = (self.config.get('display') or {}).get('hardware') or {}
|
|
||||||
try:
|
|
||||||
value = float(hardware.get('limit_refresh_rate_hz') or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
value = 0.0
|
|
||||||
return value if value > 0 else 100.0
|
|
||||||
|
|
||||||
def set_frame_hold(self, refreshes: int) -> None:
|
|
||||||
"""Hold each pushed frame for this many panel refreshes (>=1).
|
|
||||||
|
|
||||||
Set by the scroll configuration so a plugin can run at, say, 50px/s on
|
|
||||||
a 100Hz panel as one whole pixel every second refresh, rather than half
|
|
||||||
a pixel every refresh (which has to be blended or repeated unevenly).
|
|
||||||
|
|
||||||
Reset to 1 whenever scrolling stops, so one plugin's pacing cannot
|
|
||||||
leak into the next thing on screen.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
value = int(refreshes)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
logger.warning("Ignoring unusable frame hold: %r", refreshes)
|
|
||||||
return
|
|
||||||
self._frame_hold = max(1, min(255, value))
|
|
||||||
|
|
||||||
def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1):
|
|
||||||
"""Set the current scrolling state, and this scroll's frame pacing.
|
|
||||||
|
|
||||||
Call this when a display starts or stops scrolling. ``frame_hold`` is
|
|
||||||
how many panel refreshes each frame is held for -- 2 gives one whole
|
|
||||||
pixel every second refresh, which is how a scroll runs at half the
|
|
||||||
refresh rate without fractional pixel positions.
|
|
||||||
|
|
||||||
The hold is set here rather than once at plugin construction because
|
|
||||||
it must not outlive the scroll that asked for it: plugins share one
|
|
||||||
display manager, so a hold left set by whoever scrolled last would
|
|
||||||
silently re-pace the next plugin. Passing it alongside the state makes
|
|
||||||
the lifetime exactly the scroll, and the default of 1 means any caller
|
|
||||||
that does not care gets a new frame every refresh.
|
|
||||||
"""
|
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
self._scrolling_state['is_scrolling'] = is_scrolling
|
self._scrolling_state['is_scrolling'] = is_scrolling
|
||||||
if is_scrolling:
|
if is_scrolling:
|
||||||
self._scrolling_state['last_scroll_activity'] = current_time
|
self._scrolling_state['last_scroll_activity'] = current_time
|
||||||
self.set_frame_hold(frame_hold)
|
|
||||||
else:
|
|
||||||
self._frame_hold = 1
|
|
||||||
logger.debug(f"Scrolling state set to: {is_scrolling}")
|
logger.debug(f"Scrolling state set to: {is_scrolling}")
|
||||||
|
|
||||||
def is_currently_scrolling(self) -> bool:
|
def is_currently_scrolling(self) -> bool:
|
||||||
@@ -1436,14 +1299,6 @@ class DisplayManager:
|
|||||||
# If we've been inactive for the threshold period, consider it not scrolling
|
# If we've been inactive for the threshold period, consider it not scrolling
|
||||||
if current_time - self._scrolling_state['last_scroll_activity'] > self._scrolling_state['scroll_inactivity_threshold']:
|
if current_time - self._scrolling_state['last_scroll_activity'] > self._scrolling_state['scroll_inactivity_threshold']:
|
||||||
self._scrolling_state['is_scrolling'] = False
|
self._scrolling_state['is_scrolling'] = False
|
||||||
# Drop the hold with the state, exactly as set_scrolling_state(False)
|
|
||||||
# does. This path is the one a scroll takes when it ends without
|
|
||||||
# saying so -- the rotation moves on mid-scroll, or the plugin is
|
|
||||||
# torn down -- and leaving the hold set there means every later
|
|
||||||
# plugin, scrolling or static, is presented at refresh/N until
|
|
||||||
# somebody calls set_scrolling_state(False). The hold must not
|
|
||||||
# outlive the scroll that asked for it, however that scroll ends.
|
|
||||||
self._frame_hold = 1
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -1561,20 +1416,11 @@ class DisplayManager:
|
|||||||
self._viewer_fresh = False
|
self._viewer_fresh = False
|
||||||
return self._viewer_fresh
|
return self._viewer_fresh
|
||||||
|
|
||||||
def _write_snapshot_if_due(self, frame_checksum: Optional[int] = None) -> None:
|
def _write_snapshot_if_due(self) -> None:
|
||||||
"""Mirror the current frame to the preview snapshot when the policy
|
"""Mirror the current frame to the preview snapshot when the policy
|
||||||
says it's worth it — see src/common/snapshot_policy.py. Unchanged
|
says it's worth it — see src/common/snapshot_policy.py. Unchanged
|
||||||
frames are never re-encoded; without viewers the cadence drops to
|
frames are never re-encoded; without viewers the cadence drops to
|
||||||
the idle keepalive.
|
the idle keepalive."""
|
||||||
|
|
||||||
Args:
|
|
||||||
frame_checksum: adler32 of the current frame, when the caller has
|
|
||||||
already computed one. Dirty tracking checksums every frame a
|
|
||||||
few lines above the call site, and re-deriving it here meant a
|
|
||||||
second tobytes() plus a second pass over the whole framebuffer
|
|
||||||
on every single frame — ~0.17ms per frame of the two combined
|
|
||||||
at 256x64, paid 100 times a second to reach the same number.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
viewer_fresh = self._viewer_is_fresh(now)
|
viewer_fresh = self._viewer_is_fresh(now)
|
||||||
@@ -1584,8 +1430,7 @@ class DisplayManager:
|
|||||||
self._last_snapshot_ts = 0.0
|
self._last_snapshot_ts = 0.0
|
||||||
self._viewer_was_fresh = viewer_fresh
|
self._viewer_was_fresh = viewer_fresh
|
||||||
|
|
||||||
digest = (frame_checksum if frame_checksum is not None
|
digest = zlib.adler32(self.image.tobytes())
|
||||||
else zlib.adler32(self.image.tobytes()))
|
|
||||||
action = snapshot_policy.decide(
|
action = snapshot_policy.decide(
|
||||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||||
viewer_fresh, digest != self._last_snapshot_digest)
|
viewer_fresh, digest != self._last_snapshot_digest)
|
||||||
@@ -1608,30 +1453,12 @@ class DisplayManager:
|
|||||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||||
self._snapshot_dir_prepared = True
|
self._snapshot_dir_prepared = True
|
||||||
# Write atomically: temp then replace. The temp name must be
|
# Write atomically: temp then replace
|
||||||
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky,
|
tmp_path = f"{self._snapshot_path}.tmp"
|
||||||
# and this file is written by whichever user the display service
|
self.image.save(tmp_path, format='PNG')
|
||||||
# runs as while tests and tooling run as someone else. A leftover
|
|
||||||
# fixed-name temp owned by another user is then unopenable even by
|
|
||||||
# root (fs.protected_regular refuses O_CREAT on a foreign file in a
|
|
||||||
# sticky dir), which froze the preview and the health check's
|
|
||||||
# liveness proxy until somebody deleted it by hand. Same pattern as
|
|
||||||
# the hardware-status write above.
|
|
||||||
_fd, tmp_path = tempfile.mkstemp(
|
|
||||||
dir=str(snapshot_path_obj.parent),
|
|
||||||
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
|
||||||
try:
|
try:
|
||||||
with os.fdopen(_fd, "wb") as _f:
|
|
||||||
self.image.save(_f, format='PNG')
|
|
||||||
os.chmod(tmp_path, 0o644)
|
|
||||||
os.replace(tmp_path, self._snapshot_path)
|
os.replace(tmp_path, self._snapshot_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Never leave the temp behind -- that is what made the failure
|
|
||||||
# permanent rather than transient.
|
|
||||||
try:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
# Fallback to direct save if replace not supported
|
# Fallback to direct save if replace not supported
|
||||||
self.image.save(self._snapshot_path, format='PNG')
|
self.image.save(self._snapshot_path, format='PNG')
|
||||||
# Set proper file permissions after saving
|
# Set proper file permissions after saving
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, Dict, Optional, Tuple, Union
|
from typing import Any, Dict, Optional, Tuple, Union
|
||||||
|
|
||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
from src.common.font_layout import load_truetype
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import freetype
|
import freetype
|
||||||
@@ -156,7 +155,7 @@ def load_font(font_name: str, size: int) -> Any:
|
|||||||
face.set_char_size(size * 64, size * 64, 72, 72)
|
face.set_char_size(size * 64, size * 64, 72, 72)
|
||||||
font: Any = face
|
font: Any = face
|
||||||
else:
|
else:
|
||||||
font = load_truetype(path, size)
|
font = ImageFont.truetype(path, size)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error loading font %s at %spx: %s, using fallback",
|
logger.warning("Error loading font %s at %spx: %s, using fallback",
|
||||||
path, size, e)
|
path, size, e)
|
||||||
@@ -175,7 +174,7 @@ def _load_fallback_font(size: int) -> Any:
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
try:
|
try:
|
||||||
font = load_truetype(path, size)
|
font = ImageFont.truetype(path, size)
|
||||||
_font_cache[cache_key] = font
|
_font_cache[cache_key] = font
|
||||||
return font
|
return font
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+3
-5
@@ -38,7 +38,6 @@ import time
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
from src.common.font_layout import load_truetype
|
|
||||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -97,8 +96,7 @@ class FontManager:
|
|||||||
self.common_fonts = {
|
self.common_fonts = {
|
||||||
"press_start": "assets/fonts/PressStart2P-Regular.ttf",
|
"press_start": "assets/fonts/PressStart2P-Regular.ttf",
|
||||||
"four_by_six": "assets/fonts/4x6-font.ttf",
|
"four_by_six": "assets/fonts/4x6-font.ttf",
|
||||||
"five_by_seven": "assets/fonts/5x7.bdf",
|
"five_by_seven": "assets/fonts/5x7.bdf"
|
||||||
"tom_thumb": "assets/fonts/tom-thumb.bdf"
|
|
||||||
# Note: cozette_bdf removed - font file not available
|
# Note: cozette_bdf removed - font file not available
|
||||||
# To re-enable: download cozette.bdf from https://github.com/the-moonwitch/Cozette
|
# To re-enable: download cozette.bdf from https://github.com/the-moonwitch/Cozette
|
||||||
# and add: "cozette_bdf": "assets/fonts/cozette.bdf"
|
# and add: "cozette_bdf": "assets/fonts/cozette.bdf"
|
||||||
@@ -480,7 +478,7 @@ class FontManager:
|
|||||||
if font_path.endswith('.bdf'):
|
if font_path.endswith('.bdf'):
|
||||||
font = self._load_bdf_font(font_path, size_px)
|
font = self._load_bdf_font(font_path, size_px)
|
||||||
else:
|
else:
|
||||||
font = load_truetype(font_path, size_px)
|
font = ImageFont.truetype(font_path, size_px)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading font {font_path}: {e}")
|
logger.error(f"Error loading font {font_path}: {e}")
|
||||||
self.performance_stats["failed_loads"] += 1
|
self.performance_stats["failed_loads"] += 1
|
||||||
@@ -860,7 +858,7 @@ class FontManager:
|
|||||||
return {"valid": True, "type": "bdf", "family": "unknown"}
|
return {"valid": True, "type": "bdf", "family": "unknown"}
|
||||||
elif font_path.endswith('.ttf'):
|
elif font_path.endswith('.ttf'):
|
||||||
# Try to load TTF font
|
# Try to load TTF font
|
||||||
load_truetype(font_path, 12)
|
ImageFont.truetype(font_path, 12)
|
||||||
return {"valid": True, "type": "ttf", "family": "unknown"}
|
return {"valid": True, "type": "ttf", "family": "unknown"}
|
||||||
else:
|
else:
|
||||||
return {"valid": False, "error": "Unsupported font format"}
|
return {"valid": False, "error": "Unsupported font format"}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import json
|
|||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from src.common.font_layout import load_truetype
|
|
||||||
from PIL.PngImagePlugin import PngInfo
|
from PIL.PngImagePlugin import PngInfo
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
@@ -748,7 +747,7 @@ class LogoDownloader:
|
|||||||
|
|
||||||
# Try to load a font, fallback to default
|
# Try to load a font, fallback to default
|
||||||
try:
|
try:
|
||||||
font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
|
font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
|
||||||
except (OSError, IOError):
|
except (OSError, IOError):
|
||||||
try:
|
try:
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
|
|||||||
@@ -1589,7 +1589,6 @@ class PluginStoreManager:
|
|||||||
with open(manifest_path, 'r') as f:
|
with open(manifest_path, 'r') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
|
|
||||||
requested_id = plugin_id
|
|
||||||
plugin_id = plugin_id or manifest.get('id')
|
plugin_id = plugin_id or manifest.get('id')
|
||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
return {
|
return {
|
||||||
@@ -1665,15 +1664,6 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
branch_info = f" (branch: {branch_used})" if branch_used else ""
|
branch_info = f" (branch: {branch_used})" if branch_used else ""
|
||||||
self.logger.info(f"Successfully installed plugin from URL: {plugin_id}{branch_info}")
|
self.logger.info(f"Successfully installed plugin from URL: {plugin_id}{branch_info}")
|
||||||
# User deliberately (re)installed this plugin -- clear any persistent
|
|
||||||
# uninstall record, exactly as install_plugin() does. Without this the
|
|
||||||
# id stays in config/uninstalled_plugins.json and
|
|
||||||
# purge_uninstalled_plugins(), which runs at every web-app startup,
|
|
||||||
# deletes the directory again: the plugin works for the rest of the
|
|
||||||
# session and is gone after the next reboot.
|
|
||||||
self.forget_uninstalled_plugin(
|
|
||||||
*(pid for pid in (requested_id, plugin_id, manifest.get('id')) if pid)
|
|
||||||
)
|
|
||||||
result = {
|
result = {
|
||||||
'success': True,
|
'success': True,
|
||||||
'plugin_id': plugin_id,
|
'plugin_id': plugin_id,
|
||||||
@@ -2434,65 +2424,6 @@ class PluginStoreManager:
|
|||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Last resort: the directory name may differ from the id being looked
|
|
||||||
# up. install_plugin() deliberately renames a plugin's directory to the
|
|
||||||
# MANIFEST id when it differs from the REGISTRY id (see the rename near
|
|
||||||
# "doesn't match registry ID" above), so `stocks` in the registry lands
|
|
||||||
# in `ledmatrix-stocks/`. Every lookup above is by directory name, so
|
|
||||||
# update_plugin("stocks") found nothing and reported the plugin as not
|
|
||||||
# installed -- silently, and for good: the user sees no error and stays
|
|
||||||
# on a stale version. Four installed plugins hit this in practice
|
|
||||||
# (leaderboard, music, stocks, weather).
|
|
||||||
#
|
|
||||||
# Deliberately last so the two lookups above keep their exact meaning;
|
|
||||||
# this only runs when a direct hit already failed. See
|
|
||||||
# test_discovery_path_contract.py, which pins that ordering.
|
|
||||||
for search_dir in self._candidate_plugin_dirs():
|
|
||||||
match = self._find_by_manifest_id(search_dir, plugin_id)
|
|
||||||
if match is not None:
|
|
||||||
self.logger.debug(
|
|
||||||
"Resolved plugin '%s' to %s via its manifest id "
|
|
||||||
"(directory name differs from the id)", plugin_id, match)
|
|
||||||
return match
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _candidate_plugin_dirs(self) -> List[Path]:
|
|
||||||
"""Directories that may hold installed plugins, configured one first."""
|
|
||||||
dirs = [self.plugins_dir]
|
|
||||||
try:
|
|
||||||
base = self.plugins_dir if self.plugins_dir.is_absolute() else self.plugins_dir.resolve()
|
|
||||||
sibling = base.parent / 'plugins'
|
|
||||||
if sibling != self.plugins_dir:
|
|
||||||
dirs.append(sibling)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
pass
|
|
||||||
return [d for d in dirs if d.exists()]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _find_by_manifest_id(search_dir: Path, plugin_id: str) -> Optional[Path]:
|
|
||||||
"""A subdirectory of `search_dir` whose manifest declares `plugin_id`.
|
|
||||||
|
|
||||||
Skips half-finished installs: store_manager renames a directory aside
|
|
||||||
with '.standalone-backup-' during install and rollback, and treating
|
|
||||||
one as installed would resurrect a ghost plugin.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
entries = sorted(search_dir.iterdir())
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
for entry in entries:
|
|
||||||
if not entry.is_dir() or '.standalone-backup-' in entry.name:
|
|
||||||
continue
|
|
||||||
manifest = entry / 'manifest.json'
|
|
||||||
if not manifest.is_file():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
with open(manifest, 'r', encoding='utf-8') as handle:
|
|
||||||
if json.load(handle).get('id') == plugin_id:
|
|
||||||
return entry
|
|
||||||
except (OSError, ValueError):
|
|
||||||
continue
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
|
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ via BoundsCheckingDisplayManager, and golden-image comparison.
|
|||||||
import contextlib
|
import contextlib
|
||||||
import http.client
|
import http.client
|
||||||
import inspect
|
import inspect
|
||||||
import time
|
|
||||||
from datetime import timedelta
|
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -27,7 +25,7 @@ from PIL import Image, ImageChops
|
|||||||
|
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from .bounds_display_manager import BoundsCheckingDisplayManager
|
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||||
from .loading import load_config_defaults, load_manifest, merge_config
|
from .loading import load_config_defaults, load_manifest
|
||||||
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
|
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
|
||||||
|
|
||||||
logger = get_logger("[Plugin Harness]")
|
logger = get_logger("[Plugin Harness]")
|
||||||
@@ -118,20 +116,11 @@ def list_modes(plugin_instance: Any, manifest: Dict[str, Any], plugin_id: str) -
|
|||||||
|
|
||||||
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||||
config: Dict[str, Any], mock_data: Dict[str, Any],
|
config: Dict[str, Any], mock_data: Dict[str, Any],
|
||||||
display_manager: Any, cache_manager: Any = None) -> Any:
|
display_manager: Any) -> Any:
|
||||||
"""Load and construct a plugin instance with mocked managers.
|
"""Load and construct a plugin instance with mocked managers."""
|
||||||
|
|
||||||
Pass ``cache_manager`` to share one cache across the renders of a plugin.
|
|
||||||
Building a fresh one per (size, mode) made every render a cold start, so a
|
|
||||||
plugin that fetches per game or per player re-fetched everything N times --
|
|
||||||
baseball-scoreboard took 840s for nine renders where ~72s was the arithmetic
|
|
||||||
-- and the cache-hit path, which is what a running rig executes almost
|
|
||||||
always, was never exercised.
|
|
||||||
"""
|
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
from src.plugin_system.testing import MockCacheManager, MockPluginManager
|
from src.plugin_system.testing import MockCacheManager, MockPluginManager
|
||||||
|
|
||||||
if cache_manager is None:
|
|
||||||
cache_manager = MockCacheManager()
|
cache_manager = MockCacheManager()
|
||||||
for key, value in (mock_data or {}).items():
|
for key, value in (mock_data or {}).items():
|
||||||
cache_manager.set(key, value)
|
cache_manager.set(key, value)
|
||||||
@@ -171,107 +160,6 @@ def _render_mode(plugin_instance: Any, mode: str) -> Any:
|
|||||||
return plugin_instance.display(force_clear=False)
|
return plugin_instance.display(force_clear=False)
|
||||||
|
|
||||||
|
|
||||||
# How many extra frames to drive before believing a mode really draws nothing.
|
|
||||||
# A scroll starts with its content off-panel, so frame 1 is legitimately blank;
|
|
||||||
# measured across the fleet, content appears by frame 2-4 (f1-scoreboard),
|
|
||||||
# frame 4 (ledmatrix-elections) and frame 38 at 64px (ledmatrix-leaderboard).
|
|
||||||
EMPTY_RECHECK_FRAMES = 48
|
|
||||||
# Seconds to advance the clock between those frames. Scroll position is usually
|
|
||||||
# driven by elapsed time, which a frozen clock never provides.
|
|
||||||
EMPTY_RECHECK_STEP = 0.05
|
|
||||||
|
|
||||||
|
|
||||||
def _has_content(image) -> bool:
|
|
||||||
"""True when any pixel is lit above the threshold."""
|
|
||||||
if image is None:
|
|
||||||
return False
|
|
||||||
return image.convert("L").point(
|
|
||||||
lambda p: 255 if p > _LIT_THRESHOLD else 0).getbbox() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _render_mode_again(plugin_instance: Any, mode: str) -> Any:
|
|
||||||
"""Draw one more frame WITHOUT force_clear.
|
|
||||||
|
|
||||||
_render_mode passes force_clear=True, which for a scrolling plugin means
|
|
||||||
"reset the scroll to the start" -- so repeating it would redraw frame 1 for
|
|
||||||
ever. The re-check needs the plugin to advance.
|
|
||||||
"""
|
|
||||||
sig = inspect.signature(plugin_instance.display)
|
|
||||||
if "display_mode" in sig.parameters:
|
|
||||||
return plugin_instance.display(force_clear=False, display_mode=mode)
|
|
||||||
return plugin_instance.display(force_clear=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _settle_empty_frame(inst, mode, dm, result, freezer) -> None:
|
|
||||||
"""Give an apparently-empty mode a few frames to draw before believing it.
|
|
||||||
|
|
||||||
One frame is not evidence: a scroll's first frame is its blank scroll-in
|
|
||||||
buffer. Without this, every scrolling plugin was warned about -- 60 of 76
|
|
||||||
warnings on a 44-plugin rig were false, which is the rate at which people
|
|
||||||
stop reading a warning.
|
|
||||||
"""
|
|
||||||
if result.error is not None or result.display_returned is False:
|
|
||||||
return
|
|
||||||
if _has_content(result.image):
|
|
||||||
return
|
|
||||||
# The frozen clock is shared by every render in the matrix, so any time this
|
|
||||||
# probe borrows has to be given back -- otherwise a mode that scrolls in
|
|
||||||
# leaves the clock advanced and every later mode renders at the wrong
|
|
||||||
# instant, drifting its golden. Seen as 5 spurious f1_upcoming drifts.
|
|
||||||
resume_at = None
|
|
||||||
if freezer is not None:
|
|
||||||
try:
|
|
||||||
resume_at = freezer()
|
|
||||||
except (AttributeError, TypeError, ValueError):
|
|
||||||
# Not a freezegun factory, or a version whose factory is not
|
|
||||||
# callable. Only used to restore the clock, never load-bearing.
|
|
||||||
resume_at = None
|
|
||||||
try:
|
|
||||||
_settle_loop(inst, mode, dm, result, freezer)
|
|
||||||
finally:
|
|
||||||
if resume_at is not None:
|
|
||||||
try:
|
|
||||||
freezer.move_to(resume_at)
|
|
||||||
except (AttributeError, TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _settle_loop(inst, mode, dm, result, freezer) -> None:
|
|
||||||
tick = getattr(freezer, "tick", None) if freezer is not None else None
|
|
||||||
for _ in range(EMPTY_RECHECK_FRAMES):
|
|
||||||
if tick is not None:
|
|
||||||
# timedelta rather than a bare float: freezegun has accepted a
|
|
||||||
# number only since 1.x, and a stale pin would raise here.
|
|
||||||
try:
|
|
||||||
tick(timedelta(seconds=EMPTY_RECHECK_STEP))
|
|
||||||
except (AttributeError, TypeError, ValueError):
|
|
||||||
# Pacing is best-effort; a freezegun that will not take a
|
|
||||||
# timedelta just means this probe runs without advancing time.
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# No frozen clock, so the real one has to do the advancing. Without
|
|
||||||
# this the 48 frames run in microseconds, elapsed time stays ~0, and
|
|
||||||
# a scroll driven by elapsed time never moves -- which is exactly
|
|
||||||
# the plugin this check is trying not to slander.
|
|
||||||
time.sleep(EMPTY_RECHECK_STEP)
|
|
||||||
try:
|
|
||||||
result.display_returned = _render_mode_again(inst, mode)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
# Deliberately broad: this calls a plugin's display(), which can
|
|
||||||
# raise anything. Recorded rather than swallowed -- a mode that
|
|
||||||
# renders one good frame and then crashes on the next is broken,
|
|
||||||
# and returning silently here reported it as passing. The frame
|
|
||||||
# already captured stays on the result so the failure is still
|
|
||||||
# inspectable.
|
|
||||||
result.error = repr(e)
|
|
||||||
return
|
|
||||||
image = dm.get_image()
|
|
||||||
if _has_content(image):
|
|
||||||
result.image = image
|
|
||||||
result.overflow = dm.check_overflow()
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def _freeze(freeze_time: Optional[str]):
|
def _freeze(freeze_time: Optional[str]):
|
||||||
"""Context manager that freezes wall-clock time when freeze_time is given,
|
"""Context manager that freezes wall-clock time when freeze_time is given,
|
||||||
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
|
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
|
||||||
@@ -305,9 +193,7 @@ def render_plugin_matrix(
|
|||||||
manifest = load_manifest(plugin_dir)
|
manifest = load_manifest(plugin_dir)
|
||||||
# Start from config_schema.json defaults so the plugin behaves like a real
|
# Start from config_schema.json defaults so the plugin behaves like a real
|
||||||
# install; explicit caller config still wins over a schema default.
|
# install; explicit caller config still wins over a schema default.
|
||||||
config = merge_config(
|
config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
|
||||||
merge_config({"enabled": True}, load_config_defaults(plugin_dir)),
|
|
||||||
config or {})
|
|
||||||
sizes = sizes or DEFAULT_TEST_SIZES
|
sizes = sizes or DEFAULT_TEST_SIZES
|
||||||
results: List[RenderResult] = []
|
results: List[RenderResult] = []
|
||||||
|
|
||||||
@@ -316,35 +202,25 @@ def render_plugin_matrix(
|
|||||||
# rendering a smaller one, instead of being clipped into a false pass.
|
# rendering a smaller one, instead of being clipped into a false pass.
|
||||||
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
|
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
|
||||||
|
|
||||||
# One cache for the whole matrix: see _instantiate. The display manager
|
with _freeze(freeze_time):
|
||||||
# stays per-render (the bounds checking depends on that); only fetched data
|
|
||||||
# is shared.
|
|
||||||
from src.plugin_system.testing import MockCacheManager
|
|
||||||
cache_manager = MockCacheManager()
|
|
||||||
for key, value in (mock_data or {}).items():
|
|
||||||
cache_manager.set(key, value)
|
|
||||||
|
|
||||||
with _freeze(freeze_time) as freezer:
|
|
||||||
for width, height in sizes:
|
for width, height in sizes:
|
||||||
results.extend(_render_size(
|
results.extend(_render_size(
|
||||||
plugin_id, manifest, plugin_dir, config, mock_data or {},
|
plugin_id, manifest, plugin_dir, config, mock_data or {},
|
||||||
width, height, run_update, extent, cache_manager, freezer,
|
width, height, run_update, extent,
|
||||||
))
|
))
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||||
width, height, run_update, extent,
|
width, height, run_update, extent) -> List[RenderResult]:
|
||||||
cache_manager=None, freezer=None) -> List[RenderResult]:
|
|
||||||
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
|
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
|
||||||
results: List[RenderResult] = []
|
results: List[RenderResult] = []
|
||||||
|
|
||||||
# Discover modes once per size (instance build can depend on config).
|
# Discover modes once per size (instance build can depend on config).
|
||||||
try:
|
try:
|
||||||
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||||
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm,
|
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm)
|
||||||
cache_manager)
|
|
||||||
modes = list_modes(probe, manifest, plugin_id)
|
modes = list_modes(probe, manifest, plugin_id)
|
||||||
except Exception as e: # noqa: BLE001 — surface any load failure as a result
|
except Exception as e: # noqa: BLE001 — surface any load failure as a result
|
||||||
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
|
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
|
||||||
@@ -353,8 +229,7 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
|||||||
result = RenderResult(plugin_id, width, height, mode)
|
result = RenderResult(plugin_id, width, height, mode)
|
||||||
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||||
try:
|
try:
|
||||||
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm,
|
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm)
|
||||||
cache_manager)
|
|
||||||
if run_update:
|
if run_update:
|
||||||
try:
|
try:
|
||||||
inst.update()
|
inst.update()
|
||||||
@@ -373,9 +248,6 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
|||||||
result.display_returned = _render_mode(inst, mode)
|
result.display_returned = _render_mode(inst, mode)
|
||||||
result.image = dm.get_image()
|
result.image = dm.get_image()
|
||||||
result.overflow = dm.check_overflow()
|
result.overflow = dm.check_overflow()
|
||||||
# A blank first frame is not proof of a blank mode; see
|
|
||||||
# _settle_empty_frame.
|
|
||||||
_settle_empty_frame(inst, mode, dm, result, freezer)
|
|
||||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||||
result.error = repr(e)
|
result.error = repr(e)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|||||||
@@ -33,45 +33,6 @@ def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
|||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
def _defaults_from_properties(properties: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Defaults for one `properties` block, recursing into nested objects.
|
|
||||||
|
|
||||||
An object property carries its defaults on its children, not on itself, so
|
|
||||||
reading only the top level dropped everything nested. That is most of the
|
|
||||||
fleet: config organised by league, or under customization/display_options,
|
|
||||||
lost 2,386 defaults across 37 of 44 plugins -- soccer-scoreboard alone lost
|
|
||||||
539 of 565 -- and the harness rendered them with a config no install would
|
|
||||||
ever have.
|
|
||||||
"""
|
|
||||||
defaults: Dict[str, Any] = {}
|
|
||||||
for key, prop in (properties or {}).items():
|
|
||||||
if not isinstance(prop, dict):
|
|
||||||
continue
|
|
||||||
if prop.get('type') == 'object' and isinstance(prop.get('properties'), dict):
|
|
||||||
nested = _defaults_from_properties(prop['properties'])
|
|
||||||
if nested:
|
|
||||||
defaults[key] = nested
|
|
||||||
elif 'default' in prop:
|
|
||||||
defaults[key] = prop['default']
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
|
|
||||||
def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Deep-merge override onto base, without dropping sibling defaults.
|
|
||||||
|
|
||||||
A shallow merge would let `-c '{"nhl": {"enabled": true}}'` replace the whole
|
|
||||||
nhl subtree and silently discard every other nhl default -- the same class of
|
|
||||||
bug this function exists to fix.
|
|
||||||
"""
|
|
||||||
merged = dict(base)
|
|
||||||
for key, value in (override or {}).items():
|
|
||||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
||||||
merged[key] = merge_config(merged[key], value)
|
|
||||||
else:
|
|
||||||
merged[key] = value
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||||
"""Extract default values from a plugin's config_schema.json (empty if none)."""
|
"""Extract default values from a plugin's config_schema.json (empty if none)."""
|
||||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||||
@@ -79,7 +40,11 @@ def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
|||||||
return {}
|
return {}
|
||||||
with open(schema_path, 'r') as f:
|
with open(schema_path, 'r') as f:
|
||||||
schema = json.load(f)
|
schema = json.load(f)
|
||||||
return _defaults_from_properties(schema.get('properties', {}))
|
defaults: Dict[str, Any] = {}
|
||||||
|
for key, prop in schema.get('properties', {}).items():
|
||||||
|
if isinstance(prop, dict) and 'default' in prop:
|
||||||
|
defaults[key] = prop['default']
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from pathlib import Path
|
|||||||
from typing import Any, List, Optional, Tuple
|
from typing import Any, List, Optional, Tuple
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from src.common.font_layout import load_truetype
|
|
||||||
|
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
@@ -142,8 +141,8 @@ class VisualTestDisplayManager:
|
|||||||
|
|
||||||
# Press Start 2P — regular and small (both 8px)
|
# Press Start 2P — regular and small (both 8px)
|
||||||
ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf')
|
ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf')
|
||||||
self.regular_font = load_truetype(ttf_path, 8)
|
self.regular_font = ImageFont.truetype(ttf_path, 8)
|
||||||
self.small_font = load_truetype(ttf_path, 8)
|
self.small_font = ImageFont.truetype(ttf_path, 8)
|
||||||
self.font = self.regular_font # alias used by some code paths
|
self.font = self.regular_font # alias used by some code paths
|
||||||
|
|
||||||
# 5x7 BDF font via freetype
|
# 5x7 BDF font via freetype
|
||||||
@@ -163,7 +162,7 @@ class VisualTestDisplayManager:
|
|||||||
# 4x6 extra small TTF
|
# 4x6 extra small TTF
|
||||||
try:
|
try:
|
||||||
xs_path = str(fonts_dir / '4x6-font.ttf')
|
xs_path = str(fonts_dir / '4x6-font.ttf')
|
||||||
self.extra_small_font = load_truetype(xs_path, 6)
|
self.extra_small_font = ImageFont.truetype(xs_path, 6)
|
||||||
except (FileNotFoundError, OSError) as e:
|
except (FileNotFoundError, OSError) as e:
|
||||||
logger.debug("Extra small font not available, using fallback: %s", e)
|
logger.debug("Extra small font not available, using fallback: %s", e)
|
||||||
self.extra_small_font = self.small_font
|
self.extra_small_font = self.small_font
|
||||||
@@ -507,18 +506,9 @@ class VisualTestDisplayManager:
|
|||||||
# Scrolling state (no-op interface compat)
|
# Scrolling state (no-op interface compat)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1):
|
def set_scrolling_state(self, is_scrolling: bool):
|
||||||
"""Set the current scrolling state (no-op for testing).
|
"""Set the current scrolling state (no-op for testing)."""
|
||||||
|
|
||||||
``frame_hold`` mirrors the DisplayManager signature this change adds.
|
|
||||||
The two are kept in step deliberately: a double that accepts arguments
|
|
||||||
production does not lets a call pass every harness run and then raise
|
|
||||||
TypeError on the panel, and a double that lacks one production has
|
|
||||||
fails every render of a plugin that legitimately paces its scroll.
|
|
||||||
Plugins begin passing it in ledmatrix-plugins#462.
|
|
||||||
"""
|
|
||||||
self._scrolling_state['is_scrolling'] = is_scrolling
|
self._scrolling_state['is_scrolling'] = is_scrolling
|
||||||
self._scrolling_state['frame_hold'] = frame_hold
|
|
||||||
if is_scrolling:
|
if is_scrolling:
|
||||||
self._scrolling_state['last_scroll_activity'] = time.time()
|
self._scrolling_state['last_scroll_activity'] = time.time()
|
||||||
|
|
||||||
|
|||||||
@@ -19,35 +19,12 @@ This directory contains systemd service unit files for LEDMatrix services.
|
|||||||
- Automatically enables/disables access point mode
|
- Automatically enables/disables access point mode
|
||||||
- Uses `scripts/utils/wifi_monitor_daemon.py`
|
- Uses `scripts/utils/wifi_monitor_daemon.py`
|
||||||
|
|
||||||
- **`ledmatrix-dns-fix.service`** - DNS single-request fix (optional)
|
|
||||||
- Re-applies `options single-request` to the resolver on every boot,
|
|
||||||
because whatever manages `resolv.conf` regenerates it and drops the
|
|
||||||
option again
|
|
||||||
- Works around glibc's parallel A/AAAA lookup stalling ~5s per name on
|
|
||||||
routers that answer only the A query, which makes any plugin calling an
|
|
||||||
external API slow or (for Starlark apps, which have a render timeout)
|
|
||||||
fail outright
|
|
||||||
- Uses `scripts/utils/apply_dns_single_request.sh`
|
|
||||||
- Install only if external API calls are timing out; it is not part of a
|
|
||||||
normal install
|
|
||||||
|
|
||||||
- **`ledmatrix-mqtt-bridge.service`** - Home Assistant MQTT bridge (optional)
|
|
||||||
- Exposes the display to Home Assistant over MQTT Discovery: force a mode,
|
|
||||||
stop on-demand, toggle power, set brightness
|
|
||||||
- Uses `integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py`, which drives the
|
|
||||||
web API rather than the display directly
|
|
||||||
- Needs `integrations/mqtt_bridge/bridge_config.json`; see that directory's
|
|
||||||
README
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
These service files are installed by the installation scripts in `scripts/install/`:
|
These service files are installed by the installation scripts in `scripts/install/`:
|
||||||
- `install_service.sh` installs `ledmatrix.service`
|
- `install_service.sh` installs `ledmatrix.service`
|
||||||
- `install_web_service.sh` installs `ledmatrix-web.service`
|
- `install_web_service.sh` installs `ledmatrix-web.service`
|
||||||
- `install_wifi_monitor.sh` installs `ledmatrix-wifi-monitor.service`
|
- `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)
|
|
||||||
- `install_mqtt_bridge.sh` installs `ledmatrix-mqtt-bridge.service` (opt-in)
|
|
||||||
|
|
||||||
## Manual Installation
|
## Manual Installation
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=LED Matrix DNS single-request fix (works around slow A/AAAA lookups)
|
|
||||||
After=network-online.target NetworkManager.service
|
|
||||||
Wants=network-online.target
|
|
||||||
Before=ledmatrix.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=__PROJECT_ROOT_DIR__/scripts/utils/apply_dns_single_request.sh
|
|
||||||
RemainAfterExit=yes
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=ledmatrix-dns-fix
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=LED Matrix Home Assistant MQTT Bridge
|
|
||||||
After=network-online.target ledmatrix-web.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
|
||||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=ledmatrix-mqtt-bridge
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
Vendored
-998
@@ -1,998 +0,0 @@
|
|||||||
[
|
|
||||||
[
|
|
||||||
"/api/v3/backup/<path:filename>",
|
|
||||||
"api_v3.backup_delete",
|
|
||||||
[
|
|
||||||
"DELETE",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/backup/download/<path:filename>",
|
|
||||||
"api_v3.backup_download",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/backup/export",
|
|
||||||
"api_v3.backup_export",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/backup/list",
|
|
||||||
"api_v3.backup_list",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/backup/preview",
|
|
||||||
"api_v3.backup_preview",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/backup/restore",
|
|
||||||
"api_v3.backup_restore",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/backup/validate",
|
|
||||||
"api_v3.backup_validate",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/cache/delete",
|
|
||||||
"api_v3.delete_cache_file",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/cache/list",
|
|
||||||
"api_v3.list_cache_files",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/config/dim-schedule",
|
|
||||||
"api_v3.get_dim_schedule_config",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/config/dim-schedule",
|
|
||||||
"api_v3.save_dim_schedule_config",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/config/main",
|
|
||||||
"api_v3.get_main_config",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/config/main",
|
|
||||||
"api_v3.save_main_config",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/config/raw/main",
|
|
||||||
"api_v3.save_raw_main_config",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/config/raw/secrets",
|
|
||||||
"api_v3.save_raw_secrets_config",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/config/schedule",
|
|
||||||
"api_v3.get_schedule_config",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/config/schedule",
|
|
||||||
"api_v3.save_schedule_config",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/config/secrets",
|
|
||||||
"api_v3.get_secrets_config",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/display/current",
|
|
||||||
"api_v3.get_display_current",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/display/current-status",
|
|
||||||
"api_v3.get_current_display_status",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/display/modes",
|
|
||||||
"api_v3.get_display_modes",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/display/on-demand/start",
|
|
||||||
"api_v3.start_on_demand_display",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/display/on-demand/status",
|
|
||||||
"api_v3.get_on_demand_status",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/display/on-demand/stop",
|
|
||||||
"api_v3.stop_on_demand_display",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/errors/clear",
|
|
||||||
"api_v3.clear_old_errors",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/errors/plugin/<plugin_id>",
|
|
||||||
"api_v3.get_plugin_errors",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/errors/summary",
|
|
||||||
"api_v3.get_error_summary",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/fonts/<font_family>",
|
|
||||||
"api_v3.delete_font",
|
|
||||||
[
|
|
||||||
"DELETE",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/fonts/catalog",
|
|
||||||
"api_v3.get_fonts_catalog",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/fonts/overrides",
|
|
||||||
"api_v3.get_fonts_overrides",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/fonts/overrides",
|
|
||||||
"api_v3.save_fonts_overrides",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/fonts/overrides/<element_key>",
|
|
||||||
"api_v3.delete_font_override",
|
|
||||||
[
|
|
||||||
"DELETE",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/fonts/preview",
|
|
||||||
"api_v3.get_font_preview",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/fonts/tokens",
|
|
||||||
"api_v3.get_font_tokens",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/fonts/upload",
|
|
||||||
"api_v3.upload_font",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/hardware/status",
|
|
||||||
"api_v3.get_hardware_status",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/health",
|
|
||||||
"api_v3.get_health",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/integrations/mqtt-bridge",
|
|
||||||
"api_v3.get_mqtt_bridge",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/integrations/mqtt-bridge/config",
|
|
||||||
"api_v3.update_mqtt_bridge_config",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"PUT"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/logs",
|
|
||||||
"api_v3.get_logs",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/<plugin_id>/static/<path:file_path>",
|
|
||||||
"api_v3.serve_plugin_static",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/action",
|
|
||||||
"api_v3.execute_plugin_action",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/assets/delete",
|
|
||||||
"api_v3.delete_plugin_asset",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/assets/list",
|
|
||||||
"api_v3.list_plugin_assets",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/assets/upload",
|
|
||||||
"api_v3.upload_plugin_asset",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/authenticate/spotify",
|
|
||||||
"api_v3.authenticate_spotify",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/authenticate/ytm",
|
|
||||||
"api_v3.authenticate_ytm",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/calendar/authenticate",
|
|
||||||
"api_v3.authenticate_calendar",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/calendar/list-calendars",
|
|
||||||
"api_v3.list_calendar_calendars",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/calendar/upload-credentials",
|
|
||||||
"api_v3.upload_calendar_credentials",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/config",
|
|
||||||
"api_v3.get_plugin_config",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/config",
|
|
||||||
"api_v3.save_plugin_config",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/config/reset",
|
|
||||||
"api_v3.reset_plugin_config",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/health",
|
|
||||||
"api_v3.get_plugin_health",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/health/<plugin_id>",
|
|
||||||
"api_v3.get_plugin_health_single",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/health/<plugin_id>/reset",
|
|
||||||
"api_v3.reset_plugin_health",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/install",
|
|
||||||
"api_v3.install_plugin",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/install-from-url",
|
|
||||||
"api_v3.install_plugin_from_url",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/installed",
|
|
||||||
"api_v3.get_installed_plugins",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/limits/<plugin_id>",
|
|
||||||
"api_v3.manage_plugin_limits",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/metrics",
|
|
||||||
"api_v3.get_plugin_metrics",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/metrics/<plugin_id>",
|
|
||||||
"api_v3.get_plugin_metrics_single",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/metrics/<plugin_id>/reset",
|
|
||||||
"api_v3.reset_plugin_metrics",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/of-the-day/json/delete",
|
|
||||||
"api_v3.delete_of_the_day_json",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/of-the-day/json/upload",
|
|
||||||
"api_v3.upload_of_the_day_json",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/operation/<operation_id>",
|
|
||||||
"api_v3.get_operation_status",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/operation/history",
|
|
||||||
"api_v3.clear_operation_history",
|
|
||||||
[
|
|
||||||
"DELETE",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/operation/history",
|
|
||||||
"api_v3.get_operation_history",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/reconciliation-status",
|
|
||||||
"api_v3.get_reconciliation_status",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/registry-from-url",
|
|
||||||
"api_v3.get_registry_from_url",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/saved-repositories",
|
|
||||||
"api_v3.add_saved_repository",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/saved-repositories",
|
|
||||||
"api_v3.get_saved_repositories",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/saved-repositories",
|
|
||||||
"api_v3.remove_saved_repository",
|
|
||||||
[
|
|
||||||
"DELETE",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/schema",
|
|
||||||
"api_v3.get_plugin_schema",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/state",
|
|
||||||
"api_v3.get_plugin_state",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/state/reconcile",
|
|
||||||
"api_v3.reconcile_plugin_state",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/store/github-status",
|
|
||||||
"api_v3.get_github_auth_status",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/store/list",
|
|
||||||
"api_v3.list_plugin_store",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/store/refresh",
|
|
||||||
"api_v3.refresh_plugin_store",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/toggle",
|
|
||||||
"api_v3.toggle_plugin",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/uninstall",
|
|
||||||
"api_v3.uninstall_plugin",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/plugins/update",
|
|
||||||
"api_v3.update_plugin",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/skins",
|
|
||||||
"api_v3.list_skins",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/apps",
|
|
||||||
"api_v3.get_starlark_apps",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/apps/<app_id>",
|
|
||||||
"api_v3.get_starlark_app",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/apps/<app_id>",
|
|
||||||
"api_v3.uninstall_starlark_app",
|
|
||||||
[
|
|
||||||
"DELETE",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/apps/<app_id>/config",
|
|
||||||
"api_v3.get_starlark_app_config",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/apps/<app_id>/config",
|
|
||||||
"api_v3.update_starlark_app_config",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"PUT"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/apps/<app_id>/render",
|
|
||||||
"api_v3.render_starlark_app",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/apps/<app_id>/toggle",
|
|
||||||
"api_v3.toggle_starlark_app",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/editor/apps",
|
|
||||||
"api_v3.list_pixlet_editor_apps",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/editor/start",
|
|
||||||
"api_v3.start_pixlet_editor",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/editor/status",
|
|
||||||
"api_v3.get_pixlet_editor_status",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/editor/stop",
|
|
||||||
"api_v3.stop_pixlet_editor",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/install-pixlet",
|
|
||||||
"api_v3.install_pixlet",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/repository/browse",
|
|
||||||
"api_v3.browse_tronbyte_repository",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/repository/categories",
|
|
||||||
"api_v3.get_tronbyte_categories",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/repository/install",
|
|
||||||
"api_v3.install_from_tronbyte_repository",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/status",
|
|
||||||
"api_v3.get_starlark_status",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/starlark/upload",
|
|
||||||
"api_v3.upload_starlark_app",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/sync/status",
|
|
||||||
"api_v3.get_sync_status",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/system/action",
|
|
||||||
"api_v3.execute_system_action",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/system/check-update",
|
|
||||||
"api_v3.check_for_update",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/system/git-branches",
|
|
||||||
"api_v3.get_git_branches",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/system/git-info",
|
|
||||||
"api_v3.get_git_info",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/system/status",
|
|
||||||
"api_v3.get_system_status",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/system/version",
|
|
||||||
"api_v3.get_system_version",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/wifi/ap/auto-enable",
|
|
||||||
"api_v3.get_auto_enable_ap_mode",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/wifi/ap/auto-enable",
|
|
||||||
"api_v3.set_auto_enable_ap_mode",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/wifi/ap/disable",
|
|
||||||
"api_v3.disable_ap_mode",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/wifi/ap/enable",
|
|
||||||
"api_v3.enable_ap_mode",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/wifi/connect",
|
|
||||||
"api_v3.connect_wifi",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/wifi/disconnect",
|
|
||||||
"api_v3.disconnect_wifi",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/wifi/radio",
|
|
||||||
"api_v3.get_wifi_radio",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/wifi/radio",
|
|
||||||
"api_v3.set_wifi_radio",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/wifi/scan",
|
|
||||||
"api_v3.scan_wifi_networks",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/wifi/status",
|
|
||||||
"api_v3.get_wifi_status",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Web-interface JS tests
|
|
||||||
|
|
||||||
Covers `web_interface/static/v3/js/plugins/list_filter.js` (the shared
|
|
||||||
search/filter/sort controller) and the plugin-manager grids that use it:
|
|
||||||
Installed Plugins, the Plugin Store, and Starlark Apps.
|
|
||||||
|
|
||||||
There is no JS toolchain in this repo, so these are plain node scripts with no
|
|
||||||
test framework. Each prints `ok`/`FAIL` lines and exits non-zero on failure.
|
|
||||||
|
|
||||||
## Running
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd test/js
|
|
||||||
npm install # jsdom, for the DOM suites only
|
|
||||||
node run_all.js
|
|
||||||
```
|
|
||||||
|
|
||||||
The unit suites need nothing but node. The DOM suites additionally need a
|
|
||||||
running web interface, because they test against the **real** server-rendered
|
|
||||||
HTML and the **real** API rather than fixtures:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# in another shell, from the repo root
|
|
||||||
EMULATOR=true python3 web_interface/app.py # http://localhost:5000
|
|
||||||
|
|
||||||
# or point the suites at a device
|
|
||||||
BASE=http://10.0.10.169:5000 node run_all.js
|
|
||||||
```
|
|
||||||
|
|
||||||
`run_all.js` skips the DOM suites (rather than failing) when jsdom is missing or
|
|
||||||
nothing is listening, so it stays useful in a bare checkout.
|
|
||||||
|
|
||||||
## The suites
|
|
||||||
|
|
||||||
| Suite | Needs a server | Covers |
|
|
||||||
|---|---|---|
|
|
||||||
| `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 |
|
|
||||||
| `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` |
|
|
||||||
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
|
|
||||||
|
|
||||||
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
|
|
||||||
with two plugins installed will pass while exercising very little.
|
|
||||||
|
|
||||||
## Notes for whoever changes this next
|
|
||||||
|
|
||||||
- The suites read the shipped files off disk and, for the DOM ones, the partial
|
|
||||||
from the running server. They do not keep their own copy of the markup, so
|
|
||||||
renaming an element id will fail them loudly rather than silently pass.
|
|
||||||
- `unit/test_list_filter.js` `eval`s a slice of `plugins_manager.js` located by
|
|
||||||
the text `function installedSortName(plugin)`. If that function is renamed,
|
|
||||||
fix the slice markers rather than pasting a copy of the config into the test.
|
|
||||||
- A few assertions exist specifically to stop earlier bugs coming back:
|
|
||||||
trailing spaces surviving the search debounce; a multi-word query that spans
|
|
||||||
two adjacent search fields (field order in the haystack is load-bearing);
|
|
||||||
`window.installedPlugins` staying at full length while the grid is filtered.
|
|
||||||
- Watch for assertions that can pass vacuously. Several here deliberately guard
|
|
||||||
against it — e.g. counting only non-skeleton cards, and asserting a search
|
|
||||||
phrase matches something before comparing two results.
|
|
||||||
|
|
||||||
The old-vs-new differential suites used to verify that the store and Starlark
|
|
||||||
migrations were behaviour-preserving are not included: they compared against the
|
|
||||||
pre-refactor implementation, which now only exists in git history. See PR #540
|
|
||||||
if that comparison ever needs redoing.
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
// Integration test in a REAL DOM (jsdom):
|
|
||||||
// - HTML comes from the running server's /partials/plugins (real template output)
|
|
||||||
// - list_filter.js is loaded as a real script
|
|
||||||
// - plugin data comes from the running server's real API
|
|
||||||
// - interactions are real dispatched DOM events on the real pill/select nodes
|
|
||||||
// This exercises HTML parsing, attribute reflection, event bubbling and
|
|
||||||
// delegation for real — none of which the hand-rolled shim could vouch for.
|
|
||||||
const fs = require('fs');
|
|
||||||
const http = require('http');
|
|
||||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
|
||||||
|
|
||||||
function get(path) {
|
|
||||||
return new Promise((res, rej) => {
|
|
||||||
http.get(BASE + path, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); })
|
|
||||||
.on('error', rej);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const partial = await get('/partials/plugins');
|
|
||||||
const installed = JSON.parse(await get('/api/v3/plugins/installed')).data.plugins;
|
|
||||||
|
|
||||||
// Collected so an uncaught error inside a handler fails the run instead of
|
|
||||||
// silently vanishing.
|
|
||||||
const jsErrors = [];
|
|
||||||
const vc = new VirtualConsole();
|
|
||||||
vc.on('jsdomError', e => jsErrors.push(String(e.message || e)));
|
|
||||||
vc.on('error', (...a) => jsErrors.push('console.error: ' + a.join(' ')));
|
|
||||||
|
|
||||||
const dom = new JSDOM(
|
|
||||||
`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
|
|
||||||
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/' });
|
|
||||||
|
|
||||||
const { window } = dom;
|
|
||||||
const { document } = window;
|
|
||||||
|
|
||||||
// Minimal ambient globals the extracted block expects from plugins_manager.js.
|
|
||||||
window.pluginLog = () => {};
|
|
||||||
window.debugLog = () => {};
|
|
||||||
window.PLUGIN_DEBUG = false;
|
|
||||||
window.installedPlugins = installed;
|
|
||||||
window.escapeHtml = function (text) {
|
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load the helper as a real <script>.
|
|
||||||
const s = document.createElement('script');
|
|
||||||
s.textContent = fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8');
|
|
||||||
document.body.appendChild(s);
|
|
||||||
|
|
||||||
if (!window.ListFilter) { console.log('FAIL: list_filter.js did not expose window.ListFilter'); process.exit(1); }
|
|
||||||
|
|
||||||
// Pull the installed-plugins wiring out of plugins_manager.js verbatim and run
|
|
||||||
// it as a real script in this document.
|
|
||||||
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
|
|
||||||
const a = src.indexOf('function installedSortName(plugin)');
|
|
||||||
const b = src.indexOf('// Set up event delegation for plugin action buttons');
|
|
||||||
const c = src.indexOf('function handlePluginAction(event)');
|
|
||||||
const d = src.indexOf('function findInstalledPlugin(pluginId)');
|
|
||||||
if ([a, b, c, d].some(i => i < 0)) { console.log('FAIL: could not slice plugins_manager.js'); process.exit(1); }
|
|
||||||
|
|
||||||
const s2 = document.createElement('script');
|
|
||||||
s2.textContent = `
|
|
||||||
var installedPlugins = window.installedPlugins;
|
|
||||||
var escapeHtml = window.escapeHtml, pluginLog = window.pluginLog, debugLog = window.debugLog;
|
|
||||||
var PLUGIN_DEBUG = false;
|
|
||||||
${src.slice(a, b)}
|
|
||||||
${src.slice(b, c)}
|
|
||||||
${src.slice(c, d)}
|
|
||||||
window.__t = { getInstalledFilter, renderInstalledPlugins, setupInstalledFilterListeners,
|
|
||||||
applyInstalledFiltersAndRender };
|
|
||||||
`;
|
|
||||||
document.body.appendChild(s2);
|
|
||||||
if (jsErrors.length) { console.log('FAIL: errors while loading:\n ' + jsErrors.join('\n ')); process.exit(1); }
|
|
||||||
|
|
||||||
const T = window.__t;
|
|
||||||
const $ = id => document.getElementById(id);
|
|
||||||
const grid = () => $('installed-plugins-grid');
|
|
||||||
const cards = () => grid().querySelectorAll('.plugin-card:not(.installed-skeleton)').length;
|
|
||||||
const pill = v => document.querySelector(`#installed-filter-pills [data-installed-filter="${v}"]`);
|
|
||||||
const litPills = () => [...document.querySelectorAll('#installed-filter-pills [data-installed-filter]')]
|
|
||||||
.filter(b => b.getAttribute('data-active') === 'true')
|
|
||||||
.map(b => b.getAttribute('data-installed-filter'));
|
|
||||||
const countText = () => $('installed-count').textContent.trim();
|
|
||||||
|
|
||||||
const click = el => el.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
||||||
const type = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('input', { bubbles: true })); };
|
|
||||||
const change = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('change', { bubbles: true })); };
|
|
||||||
const tick = ms => new Promise(r => setTimeout(r, ms));
|
|
||||||
|
|
||||||
let pass = 0, fail = 0;
|
|
||||||
const ok = (l, cond, extra) => cond ? (pass++, console.log(' ok ' + l))
|
|
||||||
: (fail++, console.log(' FAIL ' + l + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')));
|
|
||||||
|
|
||||||
console.log('\n── real-DOM integration (jsdom) ──');
|
|
||||||
console.log(` server gave ${installed.length} installed plugins: ` +
|
|
||||||
installed.map(p => `${p.id}(en=${p.enabled},upd=${!!p.update_available})`).join(' '));
|
|
||||||
|
|
||||||
// ── the markup the server actually rendered ────────────────────────────
|
|
||||||
ok('toolbar present in server HTML', !!$('installed-filter-bar'));
|
|
||||||
ok('4 pills parsed from server HTML',
|
|
||||||
document.querySelectorAll('#installed-filter-pills [data-installed-filter]').length === 4);
|
|
||||||
ok('sort dropdown has the 5 agreed options',
|
|
||||||
[...$('installed-sort').options].map(o => o.value).join(',') === 'a-z,z-a,status,recent,category',
|
|
||||||
[...$('installed-sort').options].map(o => o.value));
|
|
||||||
ok('skeletons present before first render', grid().querySelectorAll('.installed-skeleton').length === 3);
|
|
||||||
|
|
||||||
// ── first render ───────────────────────────────────────────────────────
|
|
||||||
T.setupInstalledFilterListeners();
|
|
||||||
T.renderInstalledPlugins(installed);
|
|
||||||
ok('skeletons removed after render', grid().querySelectorAll('.installed-skeleton').length === 0);
|
|
||||||
ok('one card per plugin', cards() === installed.length, cards());
|
|
||||||
ok('count text unfiltered', countText() === `${installed.length} installed`, countText());
|
|
||||||
ok('only the All pill is lit', litPills().join(',') === 'all', litPills());
|
|
||||||
ok('aria-pressed mirrors data-active', pill('all').getAttribute('aria-pressed') === 'true');
|
|
||||||
|
|
||||||
const enabledCount = installed.filter(p => p.enabled).length;
|
|
||||||
const disabledCount = installed.length - enabledCount;
|
|
||||||
const updCount = installed.filter(p => p.update_available).length;
|
|
||||||
|
|
||||||
// ── the CSS hook this feature depends on ───────────────────────────────
|
|
||||||
// .filter-pill[data-active="true"] had never been used by any code before
|
|
||||||
// this change, so confirm the attribute really flips on the real nodes.
|
|
||||||
click(pill('enabled'));
|
|
||||||
ok('Enabled pill lights up', litPills().join(',') === 'enabled', litPills());
|
|
||||||
ok('Enabled filters the grid', cards() === enabledCount, { cards: cards(), expect: enabledCount });
|
|
||||||
ok('count switches to "N of M shown"', countText() === `${enabledCount} of ${installed.length} shown`, countText());
|
|
||||||
ok('Clear button revealed', !$('installed-clear-filters').classList.contains('hidden'));
|
|
||||||
|
|
||||||
click(pill('disabled'));
|
|
||||||
ok('Disabled pill lights, Enabled unlights', litPills().join(',') === 'disabled', litPills());
|
|
||||||
ok('Disabled filters the grid', cards() === disabledCount, { cards: cards(), expect: disabledCount });
|
|
||||||
|
|
||||||
click(pill('updates'));
|
|
||||||
ok('Updates filters the grid', cards() === updCount, { cards: cards(), expect: updCount });
|
|
||||||
ok('updates badge count matches', $('installed-updates-count').textContent === String(updCount),
|
|
||||||
$('installed-updates-count').textContent);
|
|
||||||
ok('badge visible when >0', updCount === 0 || !$('installed-updates-count').classList.contains('hidden'));
|
|
||||||
|
|
||||||
click(pill('all'));
|
|
||||||
ok('back to All shows everything', cards() === installed.length);
|
|
||||||
ok('Clear button hidden again', $('installed-clear-filters').classList.contains('hidden'));
|
|
||||||
|
|
||||||
// ── debounced search, through the real event path ──────────────────────
|
|
||||||
const first = installed[0];
|
|
||||||
type($('installed-search'), first.name.slice(0, 4));
|
|
||||||
await tick(350);
|
|
||||||
ok('search narrows to a match', cards() >= 1 && cards() <= installed.length, cards());
|
|
||||||
ok('search clear ✕ revealed', !$('installed-search-clear').classList.contains('hidden'));
|
|
||||||
|
|
||||||
type($('installed-search'), 'zzz-no-such-plugin');
|
|
||||||
await tick(350);
|
|
||||||
ok('no matches → zero cards', cards() === 0);
|
|
||||||
ok('shows the filter empty state', /No plugins match your filters/.test(grid().textContent));
|
|
||||||
ok('does NOT say "No plugins installed"', !/No plugins installed/.test(grid().textContent));
|
|
||||||
|
|
||||||
// Clear button inside the empty state goes through the delegated handler.
|
|
||||||
const emptyClear = grid().querySelector('[data-action="clear-installed-filters"]');
|
|
||||||
ok('empty state offers a Clear button', !!emptyClear);
|
|
||||||
if (emptyClear) {
|
|
||||||
click(emptyClear);
|
|
||||||
ok('empty-state Clear restores the grid', cards() === installed.length, cards());
|
|
||||||
ok('empty-state Clear empties the search box', $('installed-search').value === '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── regression: typing a space must survive the debounce ───────────────
|
|
||||||
// setSearch() trims for matching; if the trimmed value were written back into
|
|
||||||
// the input, pausing mid-phrase would delete the space you just typed and
|
|
||||||
// multi-word terms would be untypable.
|
|
||||||
const si = $('installed-search');
|
|
||||||
type(si, 'web ');
|
|
||||||
await tick(350);
|
|
||||||
ok('trailing space survives the debounce', si.value === 'web ', JSON.stringify(si.value));
|
|
||||||
type(si, 'web u');
|
|
||||||
await tick(350);
|
|
||||||
ok('can keep typing past the space', si.value === 'web u', JSON.stringify(si.value));
|
|
||||||
type(si, ' ');
|
|
||||||
await tick(350);
|
|
||||||
ok('whitespace-only is not treated as an active filter', cards() === installed.length, cards());
|
|
||||||
ok('whitespace-only text is still left in the box', si.value === ' ', JSON.stringify(si.value));
|
|
||||||
type(si, '');
|
|
||||||
await tick(350);
|
|
||||||
|
|
||||||
// ── sorts, via the real select ─────────────────────────────────────────
|
|
||||||
const names = () => [...grid().querySelectorAll('.plugin-card h4')].map(h => h.textContent.trim());
|
|
||||||
change($('installed-sort'), 'a-z');
|
|
||||||
const az = names();
|
|
||||||
change($('installed-sort'), 'z-a');
|
|
||||||
ok('z-a is the reverse of a-z', names().join('|') === [...az].reverse().join('|'),
|
|
||||||
{ az, za: names() });
|
|
||||||
for (const s of ['status', 'recent', 'category']) {
|
|
||||||
change($('installed-sort'), s);
|
|
||||||
ok(`sort "${s}" keeps every card`, cards() === installed.length, cards());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Clear Filters resets all three axes at once ────────────────────────
|
|
||||||
type($('installed-search'), first.name.slice(0, 3));
|
|
||||||
await tick(350);
|
|
||||||
click(pill('enabled'));
|
|
||||||
change($('installed-sort'), 'z-a');
|
|
||||||
click($('installed-clear-filters'));
|
|
||||||
ok('Clear resets search box', $('installed-search').value === '');
|
|
||||||
ok('Clear resets sort select', $('installed-sort').value === 'a-z');
|
|
||||||
ok('Clear relights All', litPills().join(',') === 'all', litPills());
|
|
||||||
ok('Clear restores all cards', cards() === installed.length, cards());
|
|
||||||
ok('Clear hides itself', $('installed-clear-filters').classList.contains('hidden'));
|
|
||||||
|
|
||||||
// ── the state-leak guard, on a real DOM ────────────────────────────────
|
|
||||||
click(pill('updates'));
|
|
||||||
ok('grid is filtered', cards() === updCount);
|
|
||||||
ok('window.installedPlugins still full length', window.installedPlugins.length === installed.length,
|
|
||||||
window.installedPlugins.length);
|
|
||||||
click(pill('all'));
|
|
||||||
|
|
||||||
// ── HTMX partial re-swap: the path no unit test could reach ────────────
|
|
||||||
// Replace the section's markup wholesale (what hx-swap does when you leave
|
|
||||||
// and re-enter the tab), then re-run init exactly as initPluginsPage would.
|
|
||||||
click(pill('enabled'));
|
|
||||||
type($('installed-search'), first.name.slice(0, 3));
|
|
||||||
await tick(350);
|
|
||||||
type($('installed-search'), $('installed-search').value + ' '); // trailing space, on purpose
|
|
||||||
await tick(350);
|
|
||||||
const beforeSwap = { search: $('installed-search').value, pills: litPills().join(','), cards: cards() };
|
|
||||||
ok('pre-swap value retains its trailing space', /\s$/.test(beforeSwap.search), JSON.stringify(beforeSwap.search));
|
|
||||||
|
|
||||||
document.getElementById('app').innerHTML = partial; // fresh toolbar at defaults
|
|
||||||
ok('post-swap DOM starts at defaults',
|
|
||||||
$('installed-search').value === '' && litPills().join(',') === 'all');
|
|
||||||
|
|
||||||
T.setupInstalledFilterListeners(); // what initPluginsPage() calls
|
|
||||||
T.renderInstalledPlugins(window.installedPlugins);
|
|
||||||
ok('post-swap: search text restored', $('installed-search').value === beforeSwap.search,
|
|
||||||
{ got: $('installed-search').value, want: beforeSwap.search });
|
|
||||||
ok('post-swap: pill state restored', litPills().join(',') === beforeSwap.pills, litPills());
|
|
||||||
// Search AND pill are both still active, so the restored view must match the
|
|
||||||
// pre-swap view exactly — comparing to it avoids restating the filter logic.
|
|
||||||
ok('post-swap: same view restored', cards() === beforeSwap.cards,
|
|
||||||
{ got: cards(), want: beforeSwap.cards });
|
|
||||||
|
|
||||||
// and the freshly swapped controls must still be live
|
|
||||||
type($('installed-search'), '');
|
|
||||||
await tick(350);
|
|
||||||
ok('post-swap: clearing search re-widens to the pill alone', cards() === enabledCount, cards());
|
|
||||||
click(pill('all'));
|
|
||||||
ok('post-swap: pills still clickable', cards() === installed.length, cards());
|
|
||||||
click(pill('disabled'));
|
|
||||||
ok('post-swap: pill filters again', cards() === disabledCount, cards());
|
|
||||||
click(pill('all'));
|
|
||||||
type($('installed-search'), 'zzz-none');
|
|
||||||
await tick(350);
|
|
||||||
ok('post-swap: search still live', cards() === 0);
|
|
||||||
click(grid().querySelector('[data-action="clear-installed-filters"]'));
|
|
||||||
ok('post-swap: delegated Clear still wired', cards() === installed.length);
|
|
||||||
|
|
||||||
// ── does the stylesheet actually hook the markup? ──────────────────────
|
|
||||||
const css = fs.readFileSync(V3 + '/app.css', 'utf8');
|
|
||||||
const st = document.createElement('style');
|
|
||||||
st.textContent = css;
|
|
||||||
document.head.appendChild(st);
|
|
||||||
click(pill('enabled'));
|
|
||||||
const activeWeight = window.getComputedStyle(pill('enabled')).fontWeight;
|
|
||||||
const idleWeight = window.getComputedStyle(pill('disabled')).fontWeight;
|
|
||||||
ok('.filter-pill[data-active="true"] matches our markup (font-weight 600)',
|
|
||||||
activeWeight === '600', { active: activeWeight, idle: idleWeight });
|
|
||||||
ok('inactive pill does not pick up the active rule', idleWeight !== '600', idleWeight);
|
|
||||||
click(pill('all'));
|
|
||||||
|
|
||||||
ok('no uncaught JS errors anywhere', jsErrors.length === 0, jsErrors.slice(0, 4));
|
|
||||||
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
|
||||||
process.exit(fail ? 1 : 0);
|
|
||||||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
// Proves CodeRabbit's second finding is fixed: typing in the store search or
|
|
||||||
// changing the category must filter the CACHED list, not refetch
|
|
||||||
// /api/v3/plugins/store/list.
|
|
||||||
//
|
|
||||||
// This loads the WHOLE of plugins_manager.js into jsdom (not a slice), so the
|
|
||||||
// legacy initializePlugins() wiring is present if it still exists, and counts
|
|
||||||
// every fetch the page makes.
|
|
||||||
const fs = require('fs');
|
|
||||||
const http = require('http');
|
|
||||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
|
||||||
const get = p => new Promise((res, rej) =>
|
|
||||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const partial = await get('/partials/plugins');
|
|
||||||
const store = JSON.parse(await get('/api/v3/plugins/store/list'));
|
|
||||||
const installed = JSON.parse(await get('/api/v3/plugins/installed'));
|
|
||||||
|
|
||||||
const loadErrors = [];
|
|
||||||
const vc = new VirtualConsole(); // quiet, but keep real errors
|
|
||||||
vc.on('jsdomError', e => loadErrors.push(String(e.message || e).split('\n')[0]));
|
|
||||||
const dom = new JSDOM(`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
|
|
||||||
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/', pretendToBeVisual: true });
|
|
||||||
const { window } = dom;
|
|
||||||
|
|
||||||
// Record every request the page attempts; serve from the payloads above.
|
|
||||||
const calls = [];
|
|
||||||
window.fetch = (url, opts) => {
|
|
||||||
const u = String(url);
|
|
||||||
calls.push(u);
|
|
||||||
let body = { status: 'success', data: {} };
|
|
||||||
if (u.includes('/plugins/store/list')) body = store;
|
|
||||||
else if (u.includes('/plugins/installed')) body = installed;
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true, status: 200,
|
|
||||||
json: () => Promise.resolve(body),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(body)),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
window.EventSource = function () { return { addEventListener() {}, close() {} }; };
|
|
||||||
window.confirm = () => false;
|
|
||||||
window.alert = () => {};
|
|
||||||
window.scrollTo = () => {};
|
|
||||||
|
|
||||||
// Globals plugins_manager.js expects from app.js (it declares /* global debugLog */).
|
|
||||||
const s0 = window.document.createElement('script');
|
|
||||||
s0.textContent = `
|
|
||||||
window.debugLog = function () {};
|
|
||||||
window.showNotification = function () {};
|
|
||||||
window.showError = function () {};
|
|
||||||
window.updateSystemStatus = function () {};
|
|
||||||
window.Alpine = undefined;
|
|
||||||
window.htmx = { process: function () {}, ajax: function () {} };
|
|
||||||
`;
|
|
||||||
window.document.body.appendChild(s0);
|
|
||||||
|
|
||||||
const s = window.document.createElement('script');
|
|
||||||
s.textContent = fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8');
|
|
||||||
window.document.body.appendChild(s);
|
|
||||||
|
|
||||||
const s2 = window.document.createElement('script');
|
|
||||||
s2.textContent = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
|
|
||||||
window.document.body.appendChild(s2);
|
|
||||||
|
|
||||||
let pass = 0, fail = 0;
|
|
||||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
|
||||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 300) : '')));
|
|
||||||
const tick = ms => new Promise(r => setTimeout(r, ms));
|
|
||||||
|
|
||||||
console.log('\n── store search must not refetch (CodeRabbit #2) ──');
|
|
||||||
if (loadErrors.length) console.log(' load errors: ' + loadErrors.slice(0, 3).join(' | '));
|
|
||||||
ok('whole plugins_manager.js evaluated', typeof window.initPluginsPage === 'function');
|
|
||||||
|
|
||||||
if (typeof window.initPluginsPage === 'function') window.initPluginsPage();
|
|
||||||
await tick(600);
|
|
||||||
|
|
||||||
const storeListCalls = () => calls.filter(u => u.includes('/plugins/store/list')).length;
|
|
||||||
const afterInit = storeListCalls();
|
|
||||||
console.log(` init issued ${afterInit} store/list request(s) — that part is expected`);
|
|
||||||
ok('init loaded the store at least once', afterInit >= 1, calls);
|
|
||||||
|
|
||||||
const search = window.document.getElementById('plugin-search');
|
|
||||||
const category = window.document.getElementById('plugin-category');
|
|
||||||
ok('store search input present', !!search);
|
|
||||||
|
|
||||||
// Type a realistic burst, then wait past both debounce windows (300 ms each).
|
|
||||||
const before = storeListCalls();
|
|
||||||
for (const v of ['w', 'we', 'wea', 'weat', 'weath', 'weathe', 'weather']) {
|
|
||||||
search.value = v;
|
|
||||||
search.dispatchEvent(new window.Event('input', { bubbles: true }));
|
|
||||||
await tick(40);
|
|
||||||
}
|
|
||||||
await tick(700);
|
|
||||||
ok('typing 7 characters issued ZERO new store/list fetches',
|
|
||||||
storeListCalls() === before, { before, after: storeListCalls(), calls: calls.slice(-4) });
|
|
||||||
|
|
||||||
const beforeCat = storeListCalls();
|
|
||||||
if (category) {
|
|
||||||
category.value = 'sports';
|
|
||||||
category.dispatchEvent(new window.Event('change', { bubbles: true }));
|
|
||||||
await tick(700);
|
|
||||||
ok('changing category issued ZERO new store/list fetches',
|
|
||||||
storeListCalls() === beforeCat, { before: beforeCat, after: storeListCalls() });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...and the filtering still actually happened.
|
|
||||||
// Clear both axes before counting: 'weather' AND category=sports legitimately
|
|
||||||
// matches nothing, so counting here would measure the filter, not the render.
|
|
||||||
const beforeClear = storeListCalls();
|
|
||||||
search.value = '';
|
|
||||||
search.dispatchEvent(new window.Event('input', { bubbles: true }));
|
|
||||||
if (category) { category.value = ''; category.dispatchEvent(new window.Event('change', { bubbles: true })); }
|
|
||||||
await tick(700);
|
|
||||||
ok('clearing the filters also issued ZERO fetches', storeListCalls() === beforeClear,
|
|
||||||
{ before: beforeClear, after: storeListCalls() });
|
|
||||||
|
|
||||||
// Count only real cards: the partial ships loading skeletons that also carry
|
|
||||||
// .plugin-card, which would let this pass on a dead page.
|
|
||||||
const grid = window.document.getElementById('plugin-store-grid');
|
|
||||||
const real = grid.querySelectorAll('.plugin-card:not(.animate-pulse)').length;
|
|
||||||
ok('the grid rendered real cards from the cache', real > 0, { real, html: grid.innerHTML.slice(0, 160) });
|
|
||||||
|
|
||||||
ok('no leftover _listenerSetup flag on the search input',
|
|
||||||
search && search._listenerSetup === undefined, search && search._listenerSetup);
|
|
||||||
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
|
||||||
process.exit(fail ? 1 : 0);
|
|
||||||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 6).join('\n')); process.exit(1); });
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
// Real-DOM (jsdom) test of the migrated Plugin Store toolbar, against the
|
|
||||||
// server's actual 48-plugin registry — enough data for 4 pages, so pagination,
|
|
||||||
// the ellipsis strip and per-page changes are exercised for real.
|
|
||||||
// The card renderer itself is stubbed: Step 2 did not touch it, and stubbing
|
|
||||||
// keeps the assertions on the parts that did change.
|
|
||||||
const fs = require('fs');
|
|
||||||
const http = require('http');
|
|
||||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
|
||||||
const get = path => new Promise((res, rej) =>
|
|
||||||
http.get(BASE + path, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
|
||||||
|
|
||||||
function slice(src, a, b) {
|
|
||||||
const i = src.indexOf(a), j = src.indexOf(b, i + 1);
|
|
||||||
if (i < 0 || j < 0) throw new Error('cannot slice: ' + a);
|
|
||||||
return src.slice(i, j);
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const partial = await get('/partials/plugins');
|
|
||||||
const storeJson = JSON.parse(await get('/api/v3/plugins/store/list'));
|
|
||||||
const storePlugins = (storeJson.data && storeJson.data.plugins) || storeJson.plugins || [];
|
|
||||||
const installed = JSON.parse(await get('/api/v3/plugins/installed')).data.plugins;
|
|
||||||
|
|
||||||
const jsErrors = [];
|
|
||||||
const vc = new VirtualConsole();
|
|
||||||
vc.on('jsdomError', e => jsErrors.push(String(e.message || e)));
|
|
||||||
|
|
||||||
const dom = new JSDOM(`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
|
|
||||||
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/' });
|
|
||||||
const { window } = dom;
|
|
||||||
const { document } = window;
|
|
||||||
|
|
||||||
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
|
|
||||||
|
|
||||||
function boot() {
|
|
||||||
const s = document.createElement('script');
|
|
||||||
// IIFE-wrapped so a second boot gets a fresh closure instead of clashing on
|
|
||||||
// the top-level `const ListFilter`; it still publishes via window.ListFilter.
|
|
||||||
s.textContent = '(function(){\n' + fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8') + '\n})();';
|
|
||||||
document.body.appendChild(s);
|
|
||||||
|
|
||||||
const s2 = document.createElement('script');
|
|
||||||
s2.textContent = `(function(){
|
|
||||||
${slice(src, 'const safeLocalStorage = {', '\n// ')}
|
|
||||||
var installedPlugins = ${JSON.stringify(installed)};
|
|
||||||
window.installedPlugins = installedPlugins;
|
|
||||||
var pluginStoreCache = ${JSON.stringify(storePlugins)};
|
|
||||||
window.__renderCalls = 0;
|
|
||||||
// Stand-in for renderPluginStore: real DOM nodes, minimal markup.
|
|
||||||
function renderPluginStore(plugins) {
|
|
||||||
window.__renderCalls++;
|
|
||||||
const grid = document.getElementById('plugin-store-grid');
|
|
||||||
grid.innerHTML = (plugins || []).map(p =>
|
|
||||||
'<div class="plugin-card" data-id="' + p.id + '"></div>').join('');
|
|
||||||
}
|
|
||||||
${slice(src, 'function isStorePluginInstalled(', '// Expose searchPluginStore')}
|
|
||||||
window.__t = { applyStoreFiltersAndSort, setupStoreFilterListeners, getStoreFilter };
|
|
||||||
})();`;
|
|
||||||
document.body.appendChild(s2);
|
|
||||||
}
|
|
||||||
boot();
|
|
||||||
if (jsErrors.length) { console.log('FAIL loading:\n ' + jsErrors.join('\n ')); process.exit(1); }
|
|
||||||
|
|
||||||
const T = window.__t;
|
|
||||||
const $ = id => document.getElementById(id);
|
|
||||||
const cards = () => $('plugin-store-grid').querySelectorAll('.plugin-card').length;
|
|
||||||
const ids = () => [...$('plugin-store-grid').querySelectorAll('.plugin-card')].map(c => c.dataset.id);
|
|
||||||
const info = () => $('store-results-info').textContent.trim();
|
|
||||||
const infoBot = () => $('store-results-info-bottom') ? $('store-results-info-bottom').textContent.trim() : null;
|
|
||||||
const pagTop = () => $('store-pagination-top').innerHTML;
|
|
||||||
const pageBtns = () => [...$('store-pagination-top').querySelectorAll('[data-list-page]')];
|
|
||||||
const clickPage = label => {
|
|
||||||
const b = pageBtns().find(x => x.textContent.trim() === String(label));
|
|
||||||
if (!b) throw new Error('no page button labelled ' + label);
|
|
||||||
b.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
||||||
};
|
|
||||||
const click = el => el.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
||||||
const change = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('change', { bubbles: true })); };
|
|
||||||
const type = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('input', { bubbles: true })); };
|
|
||||||
const tick = ms => new Promise(r => setTimeout(r, ms));
|
|
||||||
|
|
||||||
let pass = 0, fail = 0;
|
|
||||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
|
||||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 240) : '')));
|
|
||||||
|
|
||||||
console.log(`\n── store, real DOM, ${storePlugins.length} plugins from the live registry ──`);
|
|
||||||
|
|
||||||
T.setupStoreFilterListeners();
|
|
||||||
T.applyStoreFiltersAndSort();
|
|
||||||
|
|
||||||
const N = storePlugins.length;
|
|
||||||
ok('page 1 holds 12 cards', cards() === 12, cards());
|
|
||||||
ok('results info counts all ' + N, info() === `Showing 1–12 of ${N} plugins`, info());
|
|
||||||
ok('bottom info matches top', infoBot() === info(), { top: info(), bottom: infoBot() });
|
|
||||||
ok('pagination rendered', pageBtns().length > 0);
|
|
||||||
ok('per-page control shows 12', $('store-per-page').value === '12');
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(N / 12);
|
|
||||||
ok(`last page button is ${totalPages}`,
|
|
||||||
pageBtns().some(b => b.textContent.trim() === String(totalPages)), pagTop().slice(0, 200));
|
|
||||||
ok('prev is disabled on page 1', pageBtns()[0].hasAttribute('disabled'));
|
|
||||||
|
|
||||||
const p1 = ids();
|
|
||||||
clickPage(2);
|
|
||||||
ok('page 2 renders 12 different cards', cards() === 12 && ids().join() !== p1.join());
|
|
||||||
ok('page 2 info', info() === `Showing 13–24 of ${N} plugins`, info());
|
|
||||||
clickPage(totalPages);
|
|
||||||
ok('last page info ends at ' + N, new RegExp(`–${N} of ${N} plugins$`).test(info()), info());
|
|
||||||
ok('next is disabled on the last page',
|
|
||||||
pageBtns()[pageBtns().length - 1].hasAttribute('disabled'));
|
|
||||||
ok('ellipsis appears for a far page', /…|…/.test(pagTop()), pagTop().slice(0, 120));
|
|
||||||
clickPage(1);
|
|
||||||
ok('back on page 1 shows the original slice', ids().join() === p1.join());
|
|
||||||
|
|
||||||
// ── per-page ───────────────────────────────────────────────────────────
|
|
||||||
change($('store-per-page'), '48');
|
|
||||||
ok('per-page 48 shows all in one page', cards() === Math.min(48, N), cards());
|
|
||||||
ok('pagination hidden when one page', pagTop().trim() === '' || pageBtns().length === 0, pagTop().slice(0, 80));
|
|
||||||
ok('per-page persisted', window.localStorage.getItem('storePerPage') === '48');
|
|
||||||
change($('store-per-page'), '12');
|
|
||||||
ok('back to 12 restores pagination', cards() === 12 && pageBtns().length > 0);
|
|
||||||
|
|
||||||
// ── sorts ──────────────────────────────────────────────────────────────
|
|
||||||
const firstOf = () => ids()[0];
|
|
||||||
change($('store-sort'), 'a-z');
|
|
||||||
const azFirst = firstOf();
|
|
||||||
change($('store-sort'), 'z-a');
|
|
||||||
ok('z-a changes the leading card', firstOf() !== azFirst, { az: azFirst, za: firstOf() });
|
|
||||||
ok('sort persisted to localStorage', window.localStorage.getItem('storeSort') === 'z-a');
|
|
||||||
for (const s of ['category', 'author', 'newest']) {
|
|
||||||
change($('store-sort'), s);
|
|
||||||
ok(`sort "${s}" still fills a page`, cards() === 12, cards());
|
|
||||||
}
|
|
||||||
change($('store-sort'), 'a-z');
|
|
||||||
|
|
||||||
// ── category ───────────────────────────────────────────────────────────
|
|
||||||
const cat = [...$('plugin-category').options].map(o => o.value).find(v => v && storePlugins.some(p => (p.category || '').toLowerCase() === v.toLowerCase()));
|
|
||||||
if (cat) {
|
|
||||||
change($('plugin-category'), cat);
|
|
||||||
const expect = storePlugins.filter(p => (p.category || '').toLowerCase() === cat.toLowerCase()).length;
|
|
||||||
ok(`category "${cat}" filters to ${expect}`, cards() === Math.min(expect, 12), { cards: cards(), expect });
|
|
||||||
ok('badge reports active filters', /filter/.test($('store-active-filters').textContent),
|
|
||||||
$('store-active-filters').textContent);
|
|
||||||
change($('plugin-category'), '');
|
|
||||||
ok('category cleared restores', cards() === 12);
|
|
||||||
} else { console.log(' -- no usable category option in the template, skipped'); }
|
|
||||||
|
|
||||||
// ── tri-state installed button ─────────────────────────────────────────
|
|
||||||
const instBtn = $('store-filter-installed');
|
|
||||||
ok('starts as All', /All/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-white'), instBtn.innerHTML);
|
|
||||||
click(instBtn);
|
|
||||||
ok('→ Installed, green', /Installed/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-green-50')
|
|
||||||
&& !instBtn.classList.contains('bg-white'), instBtn.innerHTML);
|
|
||||||
const instCount = storePlugins.filter(p => installed.some(i => i.id === p.id ||
|
|
||||||
(p.plugin_path && i.id === p.plugin_path.split('/').pop()))).length;
|
|
||||||
ok('Installed count matches the installed set', cards() === Math.min(instCount, 12), { cards: cards(), expect: instCount });
|
|
||||||
click(instBtn);
|
|
||||||
ok('→ Not Installed, red', /Not Installed/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-red-50'), instBtn.innerHTML);
|
|
||||||
ok('Not Installed count is the complement', cards() === Math.min(N - instCount, 12), { cards: cards(), expect: N - instCount });
|
|
||||||
click(instBtn);
|
|
||||||
ok('→ back to All', /All/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-white'));
|
|
||||||
|
|
||||||
// ── search ─────────────────────────────────────────────────────────────
|
|
||||||
const term = (storePlugins[0].name || storePlugins[0].id).slice(0, 4);
|
|
||||||
type($('plugin-search'), term);
|
|
||||||
await tick(400);
|
|
||||||
ok(`search "${term}" narrows the set`, cards() > 0 && cards() <= 12, cards());
|
|
||||||
type($('plugin-search'), 'zzz-definitely-nothing');
|
|
||||||
await tick(400);
|
|
||||||
ok('no matches → 0 cards', cards() === 0);
|
|
||||||
ok('no-match info text', info() === 'No plugins match your filters', info());
|
|
||||||
type($('plugin-search'), '');
|
|
||||||
await tick(400);
|
|
||||||
ok('search cleared restores', cards() === 12);
|
|
||||||
|
|
||||||
// ── clear filters ──────────────────────────────────────────────────────
|
|
||||||
change($('store-per-page'), '24');
|
|
||||||
change($('store-sort'), 'z-a');
|
|
||||||
if (cat) change($('plugin-category'), cat);
|
|
||||||
click(instBtn);
|
|
||||||
type($('plugin-search'), term);
|
|
||||||
await tick(400);
|
|
||||||
ok('clear button visible with filters active', !$('store-clear-filters').classList.contains('hidden'));
|
|
||||||
click($('store-clear-filters'));
|
|
||||||
ok('clear resets search', $('plugin-search').value === '');
|
|
||||||
ok('clear resets sort to a-z', $('store-sort').value === 'a-z');
|
|
||||||
ok('clear resets category', $('plugin-category').value === '');
|
|
||||||
ok('clear resets installed button to All', /All/.test(instBtn.innerHTML));
|
|
||||||
ok('clear PRESERVES per-page 24', $('store-per-page').value === '24', $('store-per-page').value);
|
|
||||||
ok('clear shows a full 24-card page', cards() === 24, cards());
|
|
||||||
ok('clear hides itself', $('store-clear-filters').classList.contains('hidden'));
|
|
||||||
|
|
||||||
// ── persistence across a reload ────────────────────────────────────────
|
|
||||||
change($('store-sort'), 'author');
|
|
||||||
change($('store-per-page'), '48');
|
|
||||||
const saved = { sort: window.localStorage.getItem('storeSort'), per: window.localStorage.getItem('storePerPage') };
|
|
||||||
ok('prefs written under the original keys', saved.sort === 'author' && saved.per === '48', saved);
|
|
||||||
|
|
||||||
// Re-swap the partial (fresh controls at defaults) and re-boot the module, as
|
|
||||||
// a page reload would.
|
|
||||||
document.getElementById('app').innerHTML = partial;
|
|
||||||
ok('fresh markup starts at defaults',
|
|
||||||
$('store-sort').value === 'a-z' && $('store-per-page').value === '12');
|
|
||||||
const controllerBefore = window.__t;
|
|
||||||
boot(); // fresh module instances, fresh localStorage read
|
|
||||||
ok('re-boot produced a new module instance', window.__t !== controllerBefore);
|
|
||||||
window.__t.setupStoreFilterListeners();
|
|
||||||
window.__t.applyStoreFiltersAndSort();
|
|
||||||
ok('reload restores sort=author into the control', $('store-sort').value === 'author', $('store-sort').value);
|
|
||||||
ok('reload restores per-page=48 into the control', $('store-per-page').value === '48', $('store-per-page').value);
|
|
||||||
ok('reload renders with the restored page size', cards() === Math.min(48, N), cards());
|
|
||||||
|
|
||||||
ok('no uncaught JS errors', jsErrors.length === 0, jsErrors.slice(0, 3));
|
|
||||||
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
|
||||||
process.exit(fail ? 1 : 0);
|
|
||||||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
// Real-DOM (jsdom) test of the two new Tools sections. The HTML is the actual
|
|
||||||
// server-rendered /partials/tools, and the payloads are the real API's, so a
|
|
||||||
// renamed field or a changed shape fails this rather than passing quietly.
|
|
||||||
const http = require('http');
|
|
||||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
|
||||||
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
|
||||||
const get = p => new Promise((res, rej) =>
|
|
||||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const partial = await get('/partials/tools');
|
|
||||||
const bridge = JSON.parse(await get('/api/v3/integrations/mqtt-bridge'));
|
|
||||||
const apps = JSON.parse(await get('/api/v3/starlark/editor/apps'));
|
|
||||||
|
|
||||||
const errs = [];
|
|
||||||
const vc = new VirtualConsole();
|
|
||||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
|
||||||
|
|
||||||
// Controllable fetch: serve the real payloads, and let tests swap in others.
|
|
||||||
let editorStatus = { status: 'success', data: { running: false } };
|
|
||||||
let bridgePayload = bridge;
|
|
||||||
let onPut = null;
|
|
||||||
const stubFetch = (url, opts) => {
|
|
||||||
const u = String(url);
|
|
||||||
if (onPut && opts && opts.method === 'PUT') onPut(JSON.parse(opts.body));
|
|
||||||
let body = { status: 'success', data: {} };
|
|
||||||
if (u.includes('/integrations/mqtt-bridge')) body = bridgePayload;
|
|
||||||
else if (u.includes('/starlark/editor/status')) body = editorStatus;
|
|
||||||
else if (u.includes('/starlark/editor/apps')) body = apps;
|
|
||||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
|
||||||
};
|
|
||||||
|
|
||||||
// runScripts:'dangerously' so the partial's own <script> executes the way a
|
|
||||||
// browser runs it -- function declarations land on window. Evaluating the
|
|
||||||
// source by hand instead leaves helpers like escHtml off the global object
|
|
||||||
// and the page fails in ways it never would in a browser.
|
|
||||||
const dom = new JSDOM(`<!doctype html><html><body>${partial}</body></html>`,
|
|
||||||
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/',
|
|
||||||
beforeParse(w) { w.fetch = stubFetch; w.confirm = () => true; } });
|
|
||||||
const { window } = dom;
|
|
||||||
|
|
||||||
const tick = ms => new Promise(r => setTimeout(r, ms));
|
|
||||||
await tick(300);
|
|
||||||
|
|
||||||
const $ = id => window.document.getElementById(id);
|
|
||||||
let pass = 0, fail = 0;
|
|
||||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
|
||||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 200) : '')));
|
|
||||||
|
|
||||||
console.log('\n── Tools: MQTT bridge + Pixlet editor (real DOM) ──');
|
|
||||||
|
|
||||||
// ── MQTT bridge ────────────────────────────────────────────────────────
|
|
||||||
ok('bridge form rendered', !!$('mqtt-host'), $('mqtt-bridge-body').textContent.slice(0, 80));
|
|
||||||
ok('host prefilled from the API', $('mqtt-host').value === bridge.data.config.mqtt_host,
|
|
||||||
{ got: $('mqtt-host') && $('mqtt-host').value, want: bridge.data.config.mqtt_host });
|
|
||||||
ok('port prefilled', $('mqtt-port').value === String(bridge.data.config.mqtt_port));
|
|
||||||
ok('log level selected', $('mqtt-log-level').value === bridge.data.config.log_level);
|
|
||||||
ok('TLS checkbox matches', $('mqtt-tls').checked === !!bridge.data.config.mqtt_tls);
|
|
||||||
ok('password field is EMPTY', $('mqtt-password').value === '');
|
|
||||||
ok('password field is type=password', $('mqtt-password').type === 'password');
|
|
||||||
ok('no password value anywhere in the DOM',
|
|
||||||
!/s3cret|mqtt_password"\s*:\s*"/.test(window.document.body.innerHTML));
|
|
||||||
ok('state badge rendered', ($('mqtt-bridge-state').textContent || '').trim().length > 0,
|
|
||||||
$('mqtt-bridge-state').textContent);
|
|
||||||
ok('not-installed shows an Install button', !!$('btn-mqtt-install'));
|
|
||||||
ok('config path shown', $('mqtt-bridge-body').textContent.includes('bridge_config.json'));
|
|
||||||
ok('env override hint shown', $('mqtt-bridge-body').textContent.includes('LEDMATRIX_MQTT_'));
|
|
||||||
|
|
||||||
// The save body must omit the password when the field is blank.
|
|
||||||
let sent = null;
|
|
||||||
onPut = body => { sent = body; };
|
|
||||||
window.saveMqttBridge();
|
|
||||||
await tick(150);
|
|
||||||
ok('save omits password when left blank', sent && !('mqtt_password' in sent), sent && Object.keys(sent));
|
|
||||||
ok('save sends the edited fields', sent && sent.mqtt_host === bridge.data.config.mqtt_host, sent);
|
|
||||||
|
|
||||||
$('mqtt-password').value = 'typed-secret';
|
|
||||||
window.saveMqttBridge();
|
|
||||||
await tick(150);
|
|
||||||
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
|
|
||||||
onPut = null;
|
|
||||||
|
|
||||||
// ── Pixlet editor, idle ────────────────────────────────────────────────
|
|
||||||
const appIds = (apps.data.apps || []).map(a => a.id);
|
|
||||||
ok('editor lists the apps on disk',
|
|
||||||
appIds.every(id => $('pixlet-editor-body').textContent.includes(id)), appIds);
|
|
||||||
ok('no session banner while idle', !$('pixlet-countdown'));
|
|
||||||
ok('warns that the display stops',
|
|
||||||
window.document.body.textContent.includes('display stops while a session is open'));
|
|
||||||
|
|
||||||
// ── Pixlet editor, running ─────────────────────────────────────────────
|
|
||||||
editorStatus = { status: 'success', data: {
|
|
||||||
running: true, app_id: 'test-editor-app', port: 8099, seconds_remaining: 1634,
|
|
||||||
timeout: 1800, host_bound: '0.0.0.0' } };
|
|
||||||
window.loadPixletEditor();
|
|
||||||
await tick(200);
|
|
||||||
|
|
||||||
ok('running session shows the banner', !!$('pixlet-countdown'));
|
|
||||||
ok('countdown formatted mm:ss', $('pixlet-countdown').textContent === '27:14',
|
|
||||||
$('pixlet-countdown') && $('pixlet-countdown').textContent);
|
|
||||||
ok('Stop button offered', !!$('btn-pixlet-stop'));
|
|
||||||
const link = [...window.document.querySelectorAll('#pixlet-editor-body a')].find(a => /Open the editor/.test(a.textContent));
|
|
||||||
ok('editor link present', !!link);
|
|
||||||
ok('link uses this host, not localhost — no tunnel needed',
|
|
||||||
!!link && link.href.includes(window.location.hostname) && link.href.includes(':8099'),
|
|
||||||
link && link.href);
|
|
||||||
ok('Edit buttons disabled while a session runs',
|
|
||||||
[...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')].every(b => b.disabled));
|
|
||||||
ok('banner names the app being edited',
|
|
||||||
$('pixlet-editor-body').textContent.includes('test-editor-app'));
|
|
||||||
|
|
||||||
ok('no uncaught JS errors', errs.length === 0, errs.slice(0, 3));
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
|
||||||
process.exit(fail ? 1 : 0);
|
|
||||||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 5).join('\n')); process.exit(1); });
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ledmatrix-webui-js-tests",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "JS tests for the v3 web interface (ListFilter and the plugin-manager grids)",
|
|
||||||
"scripts": {
|
|
||||||
"test": "node run_all.js"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"jsdom": "^24.1.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Runs the web-interface JS suites.
|
|
||||||
//
|
|
||||||
// node run_all.js unit suites, plus DOM suites if a
|
|
||||||
// web interface is reachable
|
|
||||||
// BASE=http://10.0.10.169:5000 node run_all.js point the DOM suites at a rig
|
|
||||||
//
|
|
||||||
// Unit suites need nothing but node. The DOM suites need `npm install` (jsdom)
|
|
||||||
// and a running web interface, because they deliberately test against the real
|
|
||||||
// server-rendered HTML and the real API rather than fixtures.
|
|
||||||
const { spawnSync } = require('child_process');
|
|
||||||
const http = require('http');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
|
||||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
|
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
|
||||||
'dom/test_tools_sections.js'];
|
|
||||||
|
|
||||||
function reachable(url) {
|
|
||||||
return new Promise(res => {
|
|
||||||
const req = http.get(url, r => { r.resume(); res(r.statusCode < 500); });
|
|
||||||
req.on('error', () => res(false));
|
|
||||||
req.setTimeout(4000, () => { req.destroy(); res(false); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(file) {
|
|
||||||
const r = spawnSync(process.execPath, [path.join(__dirname, file)],
|
|
||||||
{ stdio: 'inherit', cwd: __dirname, env: process.env });
|
|
||||||
return r.status === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const results = [];
|
|
||||||
for (const f of UNIT) results.push([f, run(f)]);
|
|
||||||
|
|
||||||
const haveJsdom = fs.existsSync(path.join(__dirname, 'node_modules', 'jsdom'));
|
|
||||||
const up = await reachable(BASE + '/');
|
|
||||||
|
|
||||||
if (!haveJsdom) {
|
|
||||||
console.log(`\nSKIPPING DOM suites: jsdom not installed (run: npm install)\n`);
|
|
||||||
} else if (!up) {
|
|
||||||
console.log(`\nSKIPPING DOM suites: no web interface reachable at ${BASE}`);
|
|
||||||
console.log(` start one with: EMULATOR=true python3 web_interface/app.py`);
|
|
||||||
console.log(` or point at a rig: BASE=http://<host>:5000 node run_all.js\n`);
|
|
||||||
} else {
|
|
||||||
console.log(`\nDOM suites against ${BASE}\n`);
|
|
||||||
for (const f of DOM) results.push([f, run(f)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const failed = results.filter(([, ok]) => !ok);
|
|
||||||
console.log('\n─── summary ───');
|
|
||||||
results.forEach(([f, ok]) => console.log(` ${ok ? 'PASS' : 'FAIL'} ${f}`));
|
|
||||||
if (failed.length) { console.log(`\n${failed.length} suite(s) failed\n`); process.exit(1); }
|
|
||||||
console.log('\nall suites passed\n');
|
|
||||||
})();
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
// Functional test for ListFilter + the installed-plugins config extracted
|
|
||||||
// verbatim from plugins_manager.js. Runs under node with a minimal DOM shim.
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
|
||||||
|
|
||||||
// ── minimal DOM shim ───────────────────────────────────────────────────────
|
|
||||||
class El {
|
|
||||||
constructor(id, attrs = {}) {
|
|
||||||
this.id = id; this._attrs = { ...attrs }; this.value = '';
|
|
||||||
this.textContent = ''; this.innerHTML = ''; this.title = '';
|
|
||||||
this._classes = new Set(); this.children = []; this._listeners = {};
|
|
||||||
this.parentEl = null;
|
|
||||||
const self = this;
|
|
||||||
this.classList = {
|
|
||||||
add: (...c) => c.forEach(x => self._classes.add(x)),
|
|
||||||
remove: (...c) => c.forEach(x => self._classes.delete(x)),
|
|
||||||
contains: c => self._classes.has(c),
|
|
||||||
toggle: (c, force) => {
|
|
||||||
const on = force === undefined ? !self._classes.has(c) : !!force;
|
|
||||||
if (on) self._classes.add(c); else self._classes.delete(c);
|
|
||||||
return on;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
setAttribute(k, v) { this._attrs[k] = String(v); }
|
|
||||||
getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; }
|
|
||||||
addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); }
|
|
||||||
fire(type, target) {
|
|
||||||
// Real DOM listeners get `this` === the element the listener is bound to;
|
|
||||||
// the sort/select handlers rely on that, so mirror it.
|
|
||||||
(this._listeners[type] || []).forEach(fn =>
|
|
||||||
fn.call(this, { target: target || this, currentTarget: this, key: undefined,
|
|
||||||
preventDefault() {}, stopPropagation() {} }));
|
|
||||||
}
|
|
||||||
append(child) { child.parentEl = this; this.children.push(child); return child; }
|
|
||||||
querySelectorAll(sel) {
|
|
||||||
const m = /^\[([^\]=]+)\]$/.exec(sel);
|
|
||||||
if (m) return this.children.filter(c => c.getAttribute(m[1]) !== null);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
contains(node) { return node === this || this.children.includes(node); }
|
|
||||||
closest(sel) {
|
|
||||||
const m = /^\[([^\]=]+)\]$/.exec(sel);
|
|
||||||
let n = this;
|
|
||||||
while (n) { if (m && n.getAttribute(m[1]) !== null) return n; n = n.parentEl; }
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const registry = new Map();
|
|
||||||
function mk(id, attrs) { const e = new El(id, attrs); if (id) registry.set(id, e); return e; }
|
|
||||||
|
|
||||||
global.document = {
|
|
||||||
getElementById: id => registry.get(id) || null,
|
|
||||||
querySelector: sel => {
|
|
||||||
let m = /^#([\w-]+)$/.exec(sel);
|
|
||||||
if (m) return registry.get(m[1]) || null;
|
|
||||||
m = /^#([\w-]+)\s+\[([\w-]+)="([^"]+)"\]$/.exec(sel);
|
|
||||||
if (m) {
|
|
||||||
const parent = registry.get(m[1]);
|
|
||||||
if (!parent) return null;
|
|
||||||
return parent.children.find(c => c.getAttribute(m[2]) === m[3]) || null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
dispatchEvent() {}, addEventListener() {},
|
|
||||||
};
|
|
||||||
global.CustomEvent = class { constructor(t, o) { this.type = t; Object.assign(this, o); } };
|
|
||||||
global.window = global;
|
|
||||||
|
|
||||||
// ── build the toolbar exactly as plugins.html declares it ─────────────────
|
|
||||||
mk('installed-search');
|
|
||||||
mk('installed-search-clear');
|
|
||||||
mk('installed-sort');
|
|
||||||
mk('installed-clear-filters');
|
|
||||||
mk('installed-count');
|
|
||||||
mk('installed-updates-count');
|
|
||||||
mk('installed-plugins-grid');
|
|
||||||
const pills = mk('installed-filter-pills');
|
|
||||||
['all', 'enabled', 'disabled', 'updates'].forEach(v => {
|
|
||||||
const b = new El(null, { 'data-installed-filter': v });
|
|
||||||
pills.append(b);
|
|
||||||
});
|
|
||||||
document.getElementById('installed-sort').value = 'a-z';
|
|
||||||
|
|
||||||
// ── load the helper ───────────────────────────────────────────────────────
|
|
||||||
const ListFilter = require(path.join(V3, 'js/plugins/list_filter.js'));
|
|
||||||
global.ListFilter = ListFilter;
|
|
||||||
|
|
||||||
// ── extract the installed-plugins config verbatim from plugins_manager.js ──
|
|
||||||
const src = fs.readFileSync(path.join(V3, 'plugins_manager.js'), 'utf8');
|
|
||||||
const start = src.indexOf('function installedSortName(plugin)');
|
|
||||||
const endMark = 'function setupInstalledFilterListeners()';
|
|
||||||
const end = src.indexOf(endMark);
|
|
||||||
if (start < 0 || end < 0) { console.error('FAIL: could not locate installed filter block'); process.exit(1); }
|
|
||||||
const block = src.slice(start, end);
|
|
||||||
|
|
||||||
// deps the block expects from its enclosing IIFE
|
|
||||||
let rendered = null;
|
|
||||||
global.installedPlugins = [];
|
|
||||||
global.pluginLog = () => {};
|
|
||||||
function renderInstalledCards(list, total) { rendered = { list, total }; }
|
|
||||||
|
|
||||||
eval(block); // defines installedSortName/comparators/getInstalledFilter/etc.
|
|
||||||
|
|
||||||
// ── fixture ───────────────────────────────────────────────────────────────
|
|
||||||
const P = (id, o) => Object.assign(
|
|
||||||
{ id, name: id, enabled: true, update_available: false, category: 'general',
|
|
||||||
description: '', author: 'someone', tags: [], version: '1.0.0', last_updated: null }, o);
|
|
||||||
|
|
||||||
const FIXTURE = [
|
|
||||||
P('zulu-clock', { name: 'Zulu Clock', enabled: true, category: 'time', last_updated: '2026-01-05' }),
|
|
||||||
P('alpha-weather', { name: 'Alpha Weather', enabled: false, category: 'weather', last_updated: '2026-03-11', update_available: true, latest_version: '2.0.0' }),
|
|
||||||
P('mid-stocks', { name: 'Mid Stocks', enabled: true, category: 'financial', last_updated: null, update_available: true, latest_version: '3.1.0' }),
|
|
||||||
P('no-date', { name: 'No Date Here', enabled: false, category: 'general', last_updated: null }),
|
|
||||||
P('taggy', { name: 'Taggy', enabled: true, category: 'media', last_updated: '2026-06-30', tags: ['hockey', 'nhl'] }),
|
|
||||||
P('bad-date', { name: 'Bad Date', enabled: true, category: 'general', last_updated: 'not-a-date' }),
|
|
||||||
];
|
|
||||||
global.installedPlugins = FIXTURE;
|
|
||||||
window.installedPlugins = FIXTURE;
|
|
||||||
|
|
||||||
const ctl = getInstalledFilter();
|
|
||||||
if (!ctl) { console.error('FAIL: controller not created'); process.exit(1); }
|
|
||||||
ctl.bind();
|
|
||||||
|
|
||||||
// ── assertions ────────────────────────────────────────────────────────────
|
|
||||||
let pass = 0, fail = 0;
|
|
||||||
function ok(label, cond, extra) {
|
|
||||||
if (cond) { pass++; console.log(' ok ' + label); }
|
|
||||||
else { fail++; console.log(' FAIL ' + label + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')); }
|
|
||||||
}
|
|
||||||
const ids = () => rendered.list.map(p => p.id);
|
|
||||||
const countText = () => document.getElementById('installed-count').textContent;
|
|
||||||
const badge = () => document.getElementById('installed-updates-count');
|
|
||||||
const pillOf = v => pills.children.find(c => c.getAttribute('data-installed-filter') === v);
|
|
||||||
const setPill = v => pills.fire('click', pillOf(v));
|
|
||||||
|
|
||||||
console.log('\n1. default state (no filters)');
|
|
||||||
ctl.apply();
|
|
||||||
ok('renders all 6', rendered.list.length === 6, ids());
|
|
||||||
ok('total is 6', rendered.total === 6);
|
|
||||||
ok('sorted A→Z', ids().join(',') === 'alpha-weather,bad-date,mid-stocks,no-date,taggy,zulu-clock', ids());
|
|
||||||
ok('count reads "6 installed"', countText() === '6 installed', countText());
|
|
||||||
ok('updates badge shows 2', badge().textContent === '2' && !badge().classList.contains('hidden'), badge().textContent);
|
|
||||||
ok('clear button hidden', document.getElementById('installed-clear-filters').classList.contains('hidden'));
|
|
||||||
|
|
||||||
console.log('\n2. pill: enabled / disabled partition');
|
|
||||||
setPill('enabled');
|
|
||||||
const enabledIds = ids();
|
|
||||||
ok('enabled → 4', enabledIds.length === 4, enabledIds);
|
|
||||||
ok('count reads "4 of 6 shown"', countText() === '4 of 6 shown', countText());
|
|
||||||
ok('clear button visible', !document.getElementById('installed-clear-filters').classList.contains('hidden'));
|
|
||||||
ok('enabled pill lit', pillOf('enabled').getAttribute('data-active') === 'true');
|
|
||||||
ok('all pill unlit', pillOf('all').getAttribute('data-active') === 'false');
|
|
||||||
ok('aria-pressed set', pillOf('enabled').getAttribute('aria-pressed') === 'true');
|
|
||||||
setPill('disabled');
|
|
||||||
const disabledIds = ids();
|
|
||||||
ok('disabled → 2', disabledIds.length === 2, disabledIds);
|
|
||||||
ok('partition is exact, no overlap/loss',
|
|
||||||
enabledIds.length + disabledIds.length === 6 &&
|
|
||||||
!enabledIds.some(i => disabledIds.includes(i)));
|
|
||||||
|
|
||||||
console.log('\n3. pill: updates');
|
|
||||||
setPill('updates');
|
|
||||||
ok('updates → only update_available', ids().join(',') === 'alpha-weather,mid-stocks', ids());
|
|
||||||
ok('badge count unaffected by filtering', badge().textContent === '2');
|
|
||||||
|
|
||||||
console.log('\n4. search');
|
|
||||||
setPill('all');
|
|
||||||
const searchEl = document.getElementById('installed-search');
|
|
||||||
function search(text) { searchEl.value = text; ctl.setSearch(text); }
|
|
||||||
search('weather');
|
|
||||||
ok('name match', ids().join(',') === 'alpha-weather', ids());
|
|
||||||
search('WEATHER');
|
|
||||||
ok('case-insensitive', ids().join(',') === 'alpha-weather', ids());
|
|
||||||
search('nhl');
|
|
||||||
ok('matches tags[]', ids().join(',') === 'taggy', ids());
|
|
||||||
search('financial');
|
|
||||||
ok('matches category', ids().join(',') === 'mid-stocks', ids());
|
|
||||||
search('someone');
|
|
||||||
ok('matches author → all 6', ids().length === 6);
|
|
||||||
search(' zulu ');
|
|
||||||
ok('trims whitespace', ids().join(',') === 'zulu-clock', ids());
|
|
||||||
search('zzzznope');
|
|
||||||
ok('no match → empty list, total preserved', rendered.list.length === 0 && rendered.total === 6);
|
|
||||||
ok('count reads "0 of 6 shown"', countText() === '0 of 6 shown', countText());
|
|
||||||
// The haystack joins fields in the CONFIGURED order (name, id, description,
|
|
||||||
// author, category, tags), so a query may straddle a field boundary. The
|
|
||||||
// fixtures deliberately insert `id` before `name`, so reading the object's own
|
|
||||||
// entry order instead would break this.
|
|
||||||
search('zulu clock zulu-clock');
|
|
||||||
ok('phrase spanning name→id matches (field order preserved)',
|
|
||||||
ids().join(',') === 'zulu-clock', ids());
|
|
||||||
search('zulu-clock zulu clock');
|
|
||||||
ok('the reverse (object key order) does NOT match', rendered.list.length === 0, ids());
|
|
||||||
search('taggy media');
|
|
||||||
ok('phrase spanning category→tags respects field order', ids().length === 0, ids());
|
|
||||||
search('');
|
|
||||||
ok('cleared → all 6 back', ids().length === 6);
|
|
||||||
ok('count back to "6 installed"', countText() === '6 installed', countText());
|
|
||||||
|
|
||||||
console.log('\n5. search + pill combine (AND)');
|
|
||||||
setPill('enabled');
|
|
||||||
search('a');
|
|
||||||
const both = rendered.list;
|
|
||||||
ok('all results enabled', both.every(p => p.enabled), ids());
|
|
||||||
ok('all results match "a"', both.every(p => (p.name + p.id + p.category + p.author).toLowerCase().includes('a')));
|
|
||||||
search('');
|
|
||||||
setPill('all');
|
|
||||||
|
|
||||||
console.log('\n6. sorts');
|
|
||||||
const sortEl = document.getElementById('installed-sort');
|
|
||||||
function sort(v) { sortEl.value = v; sortEl.fire('change'); }
|
|
||||||
sort('z-a');
|
|
||||||
ok('z-a reverses', ids().join(',') === 'zulu-clock,taggy,no-date,mid-stocks,bad-date,alpha-weather', ids());
|
|
||||||
sort('status');
|
|
||||||
const st = ids();
|
|
||||||
ok('status: updates first', st.slice(0, 2).sort().join(',') === 'alpha-weather,mid-stocks', st);
|
|
||||||
ok('status: disabled last', st[st.length - 1] === 'no-date', st);
|
|
||||||
sort('recent');
|
|
||||||
const rec = ids();
|
|
||||||
ok('recent: newest first', rec[0] === 'taggy' && rec[1] === 'alpha-weather' && rec[2] === 'zulu-clock', rec);
|
|
||||||
ok('recent: missing/unparseable dates last',
|
|
||||||
['bad-date', 'mid-stocks', 'no-date'].every(i => rec.indexOf(i) >= 3), rec);
|
|
||||||
ok('recent: nothing dropped', rec.length === 6);
|
|
||||||
sort('category');
|
|
||||||
const cat = ids();
|
|
||||||
ok('category groups', cat.join(',') === 'mid-stocks,bad-date,no-date,taggy,zulu-clock,alpha-weather', cat);
|
|
||||||
sort('a-z');
|
|
||||||
|
|
||||||
console.log('\n7. clear filters');
|
|
||||||
search('taggy'); setPill('updates'); sort('z-a');
|
|
||||||
ok('3 axes active', ctl.activeCount() === 3, ctl.activeCount());
|
|
||||||
document.getElementById('installed-clear-filters').fire('click');
|
|
||||||
ok('reset → all 6', ids().length === 6, ids());
|
|
||||||
ok('reset clears search input', searchEl.value === '');
|
|
||||||
ok('reset restores sort to a-z', ctl.state.sort === 'a-z' && sortEl.value === 'a-z');
|
|
||||||
ok('reset relights All pill', pillOf('all').getAttribute('data-active') === 'true');
|
|
||||||
ok('activeCount back to 0', ctl.activeCount() === 0);
|
|
||||||
|
|
||||||
console.log('\n8. sticky: the just-toggled card must not vanish');
|
|
||||||
setPill('enabled');
|
|
||||||
ok('zulu-clock visible while enabled', ids().includes('zulu-clock'));
|
|
||||||
ctl.sticky.add('zulu-clock');
|
|
||||||
FIXTURE[0].enabled = false; // simulate the toggle landing
|
|
||||||
ctl.apply();
|
|
||||||
ok('still visible after being disabled', ids().includes('zulu-clock'), ids());
|
|
||||||
ok('but total unchanged', rendered.total === 6);
|
|
||||||
setPill('enabled'); // user touches toolbar → sticky clears
|
|
||||||
ok('sticky cleared on toolbar use', !ids().includes('zulu-clock'), ids());
|
|
||||||
FIXTURE[0].enabled = true;
|
|
||||||
setPill('all');
|
|
||||||
|
|
||||||
console.log('\n9. state-leak guard: window.installedPlugins must stay whole');
|
|
||||||
search('taggy');
|
|
||||||
ok('grid shows 1', rendered.list.length === 1);
|
|
||||||
ok('window.installedPlugins still 6', window.installedPlugins.length === 6, window.installedPlugins.length);
|
|
||||||
search('');
|
|
||||||
|
|
||||||
console.log('\n10. updates badge when nothing needs updating');
|
|
||||||
const noUpd = FIXTURE.map(p => ({ ...p, update_available: false }));
|
|
||||||
window.installedPlugins = noUpd; global.installedPlugins = noUpd;
|
|
||||||
ctl.apply();
|
|
||||||
ok('badge hidden at 0', badge().classList.contains('hidden'));
|
|
||||||
ok('updates pill dimmed', pillOf('updates').classList.contains('opacity-50'));
|
|
||||||
ok('pill title explains', /No plugins have updates/.test(pillOf('updates').title), pillOf('updates').title);
|
|
||||||
window.installedPlugins = FIXTURE; global.installedPlugins = FIXTURE;
|
|
||||||
ctl.apply();
|
|
||||||
ok('badge back at 2', badge().textContent === '2' && !badge().classList.contains('hidden'));
|
|
||||||
ok('pill undimmed', !pillOf('updates').classList.contains('opacity-50'));
|
|
||||||
|
|
||||||
console.log('\n11. empty installed list');
|
|
||||||
window.installedPlugins = []; global.installedPlugins = [];
|
|
||||||
ctl.apply();
|
|
||||||
ok('renders nothing, total 0', rendered.list.length === 0 && rendered.total === 0);
|
|
||||||
ok('count reads "0 installed"', countText() === '0 installed', countText());
|
|
||||||
|
|
||||||
console.log('\n12. defensive: missing fields');
|
|
||||||
const ragged = [{ id: 'bare' }, { id: 'nulls', name: null, tags: null, category: null, last_updated: null }];
|
|
||||||
window.installedPlugins = ragged; global.installedPlugins = ragged;
|
|
||||||
sort('recent'); ctl.apply();
|
|
||||||
ok('no throw on ragged records, both rendered', rendered.list.length === 2, ids());
|
|
||||||
search('bare');
|
|
||||||
ok('search works on ragged records', ids().join(',') === 'bare', ids());
|
|
||||||
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
|
||||||
process.exit(fail ? 1 : 0);
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// Verifies renderInstalledCards (extracted verbatim from plugins_manager.js):
|
|
||||||
// the two empty states, the card markup, and that it never publishes state.
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
|
||||||
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
|
|
||||||
|
|
||||||
function slice(fromMark, toMark) {
|
|
||||||
const a = src.indexOf(fromMark);
|
|
||||||
const b = src.indexOf(toMark, a + 1);
|
|
||||||
if (a < 0 || b < 0) { console.error('FAIL: cannot locate ' + fromMark); process.exit(1); }
|
|
||||||
return src.slice(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = {
|
|
||||||
innerHTML: '',
|
|
||||||
querySelectorAll: () => [], // no skeletons in this harness
|
|
||||||
};
|
|
||||||
// escapeHtml() escapes via a detached element, so mirror what a browser does
|
|
||||||
// when you read innerHTML back off textContent: & < > are escaped, quotes are not.
|
|
||||||
class FakeEl {
|
|
||||||
set textContent(v) { this._t = String(v == null ? '' : v); }
|
|
||||||
get textContent() { return this._t || ''; }
|
|
||||||
get innerHTML() {
|
|
||||||
return (this._t || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
global.document = {
|
|
||||||
getElementById: id => (id === 'installed-plugins-grid' ? container : null),
|
|
||||||
createElement: () => new FakeEl(),
|
|
||||||
};
|
|
||||||
global.window = global;
|
|
||||||
global.pluginLog = () => {};
|
|
||||||
global.PLUGIN_DEBUG = false;
|
|
||||||
global.debugLog = () => {};
|
|
||||||
function setupInstalledEventDelegation() {} // stubbed; tested separately
|
|
||||||
|
|
||||||
eval(slice('function escapeHtml(text)', '\nfunction ', ));
|
|
||||||
eval(slice('function renderInstalledCards(plugins, total)',
|
|
||||||
'// Set up event delegation for plugin action buttons'));
|
|
||||||
|
|
||||||
let pass = 0, fail = 0;
|
|
||||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
|
||||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 300) : '')));
|
|
||||||
|
|
||||||
const SENTINEL = ['do', 'not', 'touch'];
|
|
||||||
window.installedPlugins = SENTINEL;
|
|
||||||
|
|
||||||
console.log('\n1. no plugins installed at all');
|
|
||||||
renderInstalledCards([], 0);
|
|
||||||
ok('shows "No plugins installed"', /No plugins installed/.test(container.innerHTML));
|
|
||||||
ok('does NOT show the filter empty state', !/No plugins match/.test(container.innerHTML));
|
|
||||||
ok('uses the plug icon', /fa-plug/.test(container.innerHTML));
|
|
||||||
|
|
||||||
console.log('\n2. plugins installed but none match the filters');
|
|
||||||
renderInstalledCards([], 12);
|
|
||||||
ok('shows "No plugins match your filters"', /No plugins match your filters/.test(container.innerHTML));
|
|
||||||
ok('does NOT claim nothing is installed', !/No plugins installed/.test(container.innerHTML));
|
|
||||||
ok('reports the installed total', /12 plugins installed/.test(container.innerHTML), container.innerHTML.match(/\d+ plugins? installed/));
|
|
||||||
ok('offers a Clear filters button', /data-action="clear-installed-filters"/.test(container.innerHTML));
|
|
||||||
ok('uses the filter icon', /fa-filter/.test(container.innerHTML));
|
|
||||||
|
|
||||||
console.log('\n3. singular/plural of the installed total');
|
|
||||||
renderInstalledCards([], 1);
|
|
||||||
ok('"1 plugin installed" (singular)', /1 plugin installed/.test(container.innerHTML) && !/1 plugins/.test(container.innerHTML));
|
|
||||||
|
|
||||||
console.log('\n4. real cards');
|
|
||||||
const plugins = [
|
|
||||||
{ id: 'alpha', name: 'Alpha', author: 'Ann', version: '1.0.0', category: 'weather',
|
|
||||||
description: 'Shows weather', enabled: true, tags: ['a', 'b'], verified: true },
|
|
||||||
{ id: 'beta', name: 'Beta', author: 'Bob', version: '1.0.0', latest_version: '2.0.0',
|
|
||||||
update_available: true, category: 'time', description: 'Clock', enabled: false },
|
|
||||||
];
|
|
||||||
renderInstalledCards(plugins, 5);
|
|
||||||
const html = container.innerHTML;
|
|
||||||
ok('renders 2 cards', (html.match(/class="plugin-card"/g) || []).length === 2);
|
|
||||||
ok('enabled card shows Enabled', /<span>Enabled<\/span>/.test(html));
|
|
||||||
ok('disabled card shows Disabled', /<span>Disabled<\/span>/.test(html));
|
|
||||||
ok('update badge shows target version', /v2\.0\.0 available/.test(html));
|
|
||||||
ok('update button labelled "Update to v2.0.0"', /Update to v2\.0\.0/.test(html));
|
|
||||||
ok('pulsing ring class on the outdated one', /plugin-update-available/.test(html));
|
|
||||||
ok('verified badge present', /Verified/.test(html));
|
|
||||||
ok('tags rendered', /badge-info">a</.test(html) && /badge-info">b</.test(html));
|
|
||||||
ok('per-plugin action hooks present',
|
|
||||||
/data-action="configure"/.test(html) && /data-action="update"/.test(html) &&
|
|
||||||
/data-action="uninstall"/.test(html) && /data-action="toggle"/.test(html));
|
|
||||||
ok('no empty state alongside cards', !/empty-state/.test(html));
|
|
||||||
|
|
||||||
console.log('\n5. it must not publish state');
|
|
||||||
ok('window.installedPlugins untouched', window.installedPlugins === SENTINEL, window.installedPlugins);
|
|
||||||
|
|
||||||
console.log('\n6. total defaults to list length when omitted');
|
|
||||||
renderInstalledCards([], undefined);
|
|
||||||
ok('omitted total + empty list → "no plugins installed"', /No plugins installed/.test(container.innerHTML));
|
|
||||||
|
|
||||||
console.log('\n7. XSS: hostile plugin metadata is escaped');
|
|
||||||
renderInstalledCards([{
|
|
||||||
id: 'evil', name: '<img src=x onerror=alert(1)>', author: '"><script>bad()</script>',
|
|
||||||
version: '1.0.0', category: 'x', description: '<b>nope</b>', enabled: true, tags: ['<i>t</i>'],
|
|
||||||
}], 1);
|
|
||||||
const evil = container.innerHTML;
|
|
||||||
ok('no raw <img', !/<img src=x/.test(evil));
|
|
||||||
ok('no raw <script', !/<script>bad/.test(evil));
|
|
||||||
ok('no raw <b> from description', !/<b>nope<\/b>/.test(evil));
|
|
||||||
ok('no raw <i> from tags', !/<i>t<\/i>/.test(evil));
|
|
||||||
ok('escaped entities present instead', /</.test(evil));
|
|
||||||
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
|
||||||
process.exit(fail ? 1 : 0);
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
"""GET /api/v3/display/modes -- the list of modes an on-demand request can name.
|
|
||||||
|
|
||||||
Anything driving the display from outside the web UI needs two things that no
|
|
||||||
existing endpoint gave it: the set of display modes, and which plugin owns each
|
|
||||||
one. /plugins/installed carries neither, so callers read every plugin's
|
|
||||||
manifest.json off disk and reimplemented PluginManager's own fallbacks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
|
|
||||||
api_v3_client, api_v3_module,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
MANIFESTS = {
|
|
||||||
'clock-simple': {'name': 'Simple Clock', 'display_modes': ['clock-simple']},
|
|
||||||
'football-scoreboard': {
|
|
||||||
'name': 'Football Scoreboard',
|
|
||||||
'display_modes': ['nfl_live', 'nfl_recent', 'nfl_upcoming'],
|
|
||||||
},
|
|
||||||
'ledmatrix-weather': {'name': 'Weather', 'display_modes': ['weather']},
|
|
||||||
}
|
|
||||||
|
|
||||||
CONFIG = {
|
|
||||||
'clock-simple': {'enabled': True},
|
|
||||||
'football-scoreboard': {'enabled': True},
|
|
||||||
'ledmatrix-weather': {'enabled': False},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(api_v3_module, api_v3_client):
|
|
||||||
pm = api_v3_module.api_v3.plugin_manager
|
|
||||||
pm.plugin_manifests = MANIFESTS
|
|
||||||
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
|
||||||
pm.get_plugin_display_modes = MagicMock(
|
|
||||||
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
|
|
||||||
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value=CONFIG)
|
|
||||||
return api_v3_client
|
|
||||||
|
|
||||||
|
|
||||||
def _modes(response):
|
|
||||||
return {m['mode']: m for m in response.get_json()['data']['modes']}
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheModeListing:
|
|
||||||
def test_enabled_plugins_contribute_every_mode(self, client):
|
|
||||||
modes = _modes(client.get('/api/v3/display/modes'))
|
|
||||||
assert set(modes) == {'clock-simple', 'nfl_live', 'nfl_recent', 'nfl_upcoming'}
|
|
||||||
|
|
||||||
def test_each_mode_names_its_plugin(self, client):
|
|
||||||
"""on-demand/start's find_plugin_for_mode fallback cannot see generated
|
|
||||||
modes, so the caller has to send plugin_id -- it must come from here."""
|
|
||||||
modes = _modes(client.get('/api/v3/display/modes'))
|
|
||||||
assert modes['nfl_live']['plugin_id'] == 'football-scoreboard'
|
|
||||||
|
|
||||||
def test_disabled_plugins_are_left_out_by_default(self, client):
|
|
||||||
modes = _modes(client.get('/api/v3/display/modes'))
|
|
||||||
assert 'weather' not in modes
|
|
||||||
|
|
||||||
def test_disabled_plugins_can_be_asked_for(self, client):
|
|
||||||
"""They are still valid on-demand targets: the controller enables them
|
|
||||||
for the duration of the request."""
|
|
||||||
modes = _modes(client.get('/api/v3/display/modes?include_disabled=1'))
|
|
||||||
assert modes['weather']['enabled'] is False
|
|
||||||
|
|
||||||
def test_a_single_mode_plugin_is_labelled_with_its_own_name(self, client):
|
|
||||||
modes = _modes(client.get('/api/v3/display/modes'))
|
|
||||||
assert modes['clock-simple']['name'] == 'Simple Clock'
|
|
||||||
|
|
||||||
def test_a_multi_mode_plugin_labels_each_mode_distinctly(self, client):
|
|
||||||
"""There is no per-mode name anywhere, and three modes all called
|
|
||||||
"Football Scoreboard" are not a usable dropdown."""
|
|
||||||
modes = _modes(client.get('/api/v3/display/modes'))
|
|
||||||
assert modes['nfl_live']['name'] == 'nfl_live'
|
|
||||||
assert modes['nfl_live']['plugin_name'] == 'Football Scoreboard'
|
|
||||||
|
|
||||||
|
|
||||||
class TestItWorksForACallerThatNeverOpensTheDashboard:
|
|
||||||
def test_discovery_is_triggered(self, client, api_v3_module):
|
|
||||||
"""Discovery is lazy and normally runs because a person loaded the
|
|
||||||
dashboard; a bridge or script would otherwise get an empty list."""
|
|
||||||
client.get('/api/v3/display/modes')
|
|
||||||
api_v3_module.api_v3.plugin_manager.discover_plugins.assert_called_once()
|
|
||||||
|
|
||||||
def test_no_plugin_manager_is_a_clean_error(self, api_v3_module, api_v3_client):
|
|
||||||
api_v3_module.api_v3.plugin_manager = None
|
|
||||||
response = api_v3_client.get('/api/v3/display/modes')
|
|
||||||
assert response.status_code == 500
|
|
||||||
assert response.get_json()['status'] == 'error'
|
|
||||||
|
|
||||||
def test_a_plugin_with_no_declared_modes_still_appears(self, client, api_v3_module):
|
|
||||||
"""Its mode is its own id -- the same fallback the controller uses."""
|
|
||||||
pm = api_v3_module.api_v3.plugin_manager
|
|
||||||
pm.plugin_manifests = {'starlark-apps': {'name': 'Starlark Apps', 'display_modes': []}}
|
|
||||||
pm.get_plugin_display_modes = MagicMock(return_value=[])
|
|
||||||
api_v3_module.api_v3.config_manager.load_config = MagicMock(
|
|
||||||
return_value={'starlark-apps': {'enabled': True}})
|
|
||||||
|
|
||||||
modes = _modes(client.get('/api/v3/display/modes'))
|
|
||||||
assert modes['starlark-apps']['plugin_id'] == 'starlark-apps'
|
|
||||||
|
|
||||||
|
|
||||||
class TestOneBadConfigSectionDoesNotBlankTheList:
|
|
||||||
"""config.json can hold a non-dict under a plugin id.
|
|
||||||
|
|
||||||
DisplayController guards the same shape, so it happens in practice. Here it
|
|
||||||
used to raise AttributeError mid-loop and answer 500 with no modes at all --
|
|
||||||
and the MQTT bridge builds every one of its entities from this list, so one
|
|
||||||
hand-edited section would empty the Home Assistant dropdown.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client_with_bad_section(self, api_v3_module, api_v3_client):
|
|
||||||
pm = api_v3_module.api_v3.plugin_manager
|
|
||||||
pm.plugin_manifests = MANIFESTS
|
|
||||||
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
|
||||||
pm.get_plugin_display_modes = MagicMock(
|
|
||||||
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
|
|
||||||
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value={
|
|
||||||
'clock-simple': {'enabled': True},
|
|
||||||
'football-scoreboard': "true", # a string, not an object
|
|
||||||
'ledmatrix-weather': {'enabled': True},
|
|
||||||
})
|
|
||||||
return api_v3_client
|
|
||||||
|
|
||||||
def test_the_endpoint_still_answers(self, client_with_bad_section):
|
|
||||||
assert client_with_bad_section.get('/api/v3/display/modes').status_code == 200
|
|
||||||
|
|
||||||
def test_the_healthy_plugins_are_still_listed(self, client_with_bad_section):
|
|
||||||
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
|
|
||||||
assert 'clock-simple' in modes and 'weather' in modes
|
|
||||||
|
|
||||||
def test_the_bad_section_is_treated_as_disabled(self, client_with_bad_section):
|
|
||||||
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
|
|
||||||
assert 'nfl_live' not in modes
|
|
||||||
|
|
||||||
def test_a_failure_is_reported_the_way_every_other_handler_reports_one(
|
|
||||||
self, api_v3_module, api_v3_client):
|
|
||||||
"""describe_exception, per test_web_error_detail's contract -- an
|
|
||||||
opaque "see logs for details" is what that test exists to prevent."""
|
|
||||||
api_v3_module.api_v3.plugin_manager.discover_plugins = MagicMock(
|
|
||||||
side_effect=RuntimeError("disk is gone"))
|
|
||||||
resp = api_v3_client.get('/api/v3/display/modes')
|
|
||||||
assert resp.status_code == 500
|
|
||||||
assert 'disk is gone' in resp.get_json()['details']
|
|
||||||
|
|
||||||
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
|
|
||||||
"""describe_exception is what makes returning detail safe."""
|
|
||||||
api_v3_module.api_v3.plugin_manager.discover_plugins = MagicMock(
|
|
||||||
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
|
|
||||||
body = api_v3_client.get('/api/v3/display/modes').get_json()
|
|
||||||
assert 'SEC123' not in json.dumps(body)
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"""Regression test: POST /fonts/upload must enforce its own stated size limit.
|
|
||||||
|
|
||||||
validate_file_upload(filename, max_size_mb=10, allowed_extensions=[...]) reads
|
|
||||||
like it checks the upload's size, but it only ever validated the filename
|
|
||||||
(traversal characters, extension) -- max_size_mb was accepted and silently
|
|
||||||
ignored. Nothing else in the handler checked the actual upload size either,
|
|
||||||
so it saved whatever was posted to assets/fonts/<family><ext> regardless of
|
|
||||||
size, unlike the sibling .star and plugin-asset upload routes, which check
|
|
||||||
`file.tell()` against a stated limit before saving.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import io
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
|
||||||
|
|
||||||
URL = "/api/v3/fonts/upload"
|
|
||||||
TEN_MB = 10 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fonts_root(tmp_path):
|
|
||||||
# PROJECT_ROOT is bound by value in fonts.py, so it is patched there.
|
|
||||||
with patch("web_interface.blueprints.api_v3.fonts.PROJECT_ROOT", tmp_path):
|
|
||||||
yield tmp_path
|
|
||||||
|
|
||||||
|
|
||||||
def upload(client, content, filename="myfont.ttf", family="myfont"):
|
|
||||||
data = {
|
|
||||||
"font_file": (io.BytesIO(content), filename),
|
|
||||||
"font_family": family,
|
|
||||||
}
|
|
||||||
return client.post(URL, data=data, content_type="multipart/form-data")
|
|
||||||
|
|
||||||
|
|
||||||
class TestFontUploadSizeLimit:
|
|
||||||
def test_oversized_font_is_rejected(self, api_v3_client, fonts_root):
|
|
||||||
response = upload(api_v3_client, b"x" * (TEN_MB + 1))
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "too large" in response.get_json()["message"].lower()
|
|
||||||
assert not (fonts_root / "assets" / "fonts" / "myfont.ttf").exists(), (
|
|
||||||
"an oversized font was saved to disk before being rejected")
|
|
||||||
|
|
||||||
def test_a_font_right_at_the_limit_is_accepted(self, api_v3_client, fonts_root):
|
|
||||||
response = upload(api_v3_client, b"x" * TEN_MB,
|
|
||||||
filename="atlimit.ttf", family="atlimit")
|
|
||||||
assert response.status_code == 200, response.get_json()
|
|
||||||
assert (fonts_root / "assets" / "fonts" / "atlimit.ttf").exists()
|
|
||||||
|
|
||||||
def test_an_ordinary_small_font_is_still_accepted(self, api_v3_client, fonts_root):
|
|
||||||
response = upload(api_v3_client, b"fake font bytes",
|
|
||||||
filename="small.ttf", family="small")
|
|
||||||
assert response.status_code == 200, response.get_json()
|
|
||||||
assert (fonts_root / "assets" / "fonts" / "small.ttf").read_bytes() == b"fake font bytes"
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"""Regression test: POST /display/on-demand/start restarting a running
|
|
||||||
service must not import a name that does not exist.
|
|
||||||
|
|
||||||
display.py has `import web_interface.blueprints.api_v3 as _pkg` and reads
|
|
||||||
mutable, test-patched attributes back through it (`_pkg.time.time()`,
|
|
||||||
`_pkg._get_starlark_plugin()`, ...) rather than binding them by value, per
|
|
||||||
the package's own docstring. One spot went further and wrote a genuine
|
|
||||||
`import` *statement* against that alias --
|
|
||||||
|
|
||||||
import _pkg.time as time_module
|
|
||||||
|
|
||||||
-- but `_pkg` is a local name bound by `import ... as _pkg` in this module,
|
|
||||||
not a real top-level package, so `import _pkg.time` is not something Python
|
|
||||||
can resolve; it raises ModuleNotFoundError. That line only runs when the
|
|
||||||
display service is already running and the caller also asked to (re)start
|
|
||||||
it, so this endpoint failed on exactly the restart path -- the one where a
|
|
||||||
cache write recording the new on-demand request had already happened.
|
|
||||||
|
|
||||||
The route wraps its body in `except Exception`, so the failure reached the
|
|
||||||
caller as a handled 500 with a generic message, not an unhandled crash --
|
|
||||||
but a 500 all the same on a request that should have restarted the service
|
|
||||||
and reported success.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
|
||||||
|
|
||||||
URL = "/api/v3/display/on-demand/start"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def restart_path(api_v3_module):
|
|
||||||
"""Force the `service_was_running and start_service` branch.
|
|
||||||
|
|
||||||
plugin_manager and config_manager are set to None so the route takes
|
|
||||||
the simplest path to that branch rather than tripping over unrelated
|
|
||||||
MagicMock plumbing; _ensure_cache_manager, _get_display_service_status,
|
|
||||||
_stop_display_service and _ensure_display_service_running are bound by
|
|
||||||
value in display.py (see its own docstring), so they are patched on
|
|
||||||
that submodule rather than on the package.
|
|
||||||
"""
|
|
||||||
api_v3_module.api_v3.plugin_manager = None
|
|
||||||
api_v3_module.api_v3.config_manager = None
|
|
||||||
|
|
||||||
with patch("web_interface.blueprints.api_v3.display._ensure_cache_manager") as ensure_cache, \
|
|
||||||
patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \
|
|
||||||
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop_service, \
|
|
||||||
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running") as ensure_running:
|
|
||||||
ensure_cache.return_value = MagicMock()
|
|
||||||
# Active before the request: service_was_running becomes True.
|
|
||||||
get_status.return_value = {"active": True}
|
|
||||||
ensure_running.return_value = {"active": True}
|
|
||||||
yield {
|
|
||||||
"ensure_cache": ensure_cache,
|
|
||||||
"get_status": get_status,
|
|
||||||
"stop_service": stop_service,
|
|
||||||
"ensure_running": ensure_running,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestRestartingARunningService:
|
|
||||||
def test_it_does_not_500(self, api_v3_client, restart_path):
|
|
||||||
response = api_v3_client.post(
|
|
||||||
URL, json={"plugin_id": "weather", "start_service": True})
|
|
||||||
body = response.get_json()
|
|
||||||
assert response.status_code == 200, body
|
|
||||||
assert body["status"] == "success", body
|
|
||||||
|
|
||||||
def test_the_service_is_actually_stopped_and_restarted(
|
|
||||||
self, api_v3_client, restart_path):
|
|
||||||
api_v3_client.post(
|
|
||||||
URL, json={"plugin_id": "weather", "start_service": True})
|
|
||||||
restart_path["stop_service"].assert_called_once()
|
|
||||||
restart_path["ensure_running"].assert_called_once()
|
|
||||||
|
|
||||||
def test_a_service_that_was_not_running_is_not_stopped_first(
|
|
||||||
self, api_v3_client, restart_path):
|
|
||||||
# The buggy import sits inside `if service_was_running and
|
|
||||||
# start_service`, so it only ever fired on the restart path --
|
|
||||||
# this is the other side of that branch, unaffected either way,
|
|
||||||
# kept here so the branch condition itself stays covered.
|
|
||||||
restart_path["get_status"].return_value = {"active": False}
|
|
||||||
response = api_v3_client.post(
|
|
||||||
URL, json={"plugin_id": "weather", "start_service": True})
|
|
||||||
assert response.status_code == 200, response.get_json()
|
|
||||||
restart_path["stop_service"].assert_not_called()
|
|
||||||
@@ -106,12 +106,7 @@ class TestMissingBodyGivesTheDeclaredError:
|
|||||||
|
|
||||||
|
|
||||||
class TestNoBodyReadContradictsItsOwnGuard:
|
class TestNoBodyReadContradictsItsOwnGuard:
|
||||||
PKG = Path(__file__).parent.parent / "web_interface/blueprints/api_v3"
|
SOURCE = Path(__file__).parent.parent / "web_interface/blueprints/api_v3.py"
|
||||||
|
|
||||||
@property
|
|
||||||
def _source(self) -> str:
|
|
||||||
"""api_v3 is a package; read every module of it."""
|
|
||||||
return "\n".join(p.read_text() for p in sorted(self.PKG.glob("*.py")))
|
|
||||||
|
|
||||||
def test_no_or_default_read_is_unguarded(self):
|
def test_no_or_default_read_is_unguarded(self):
|
||||||
"""`get_json() or <default>` is a contradiction without silent=True.
|
"""`get_json() or <default>` is a contradiction without silent=True.
|
||||||
@@ -120,7 +115,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
|
|||||||
means the call raises before the default can apply.
|
means the call raises before the default can apply.
|
||||||
"""
|
"""
|
||||||
offenders = [
|
offenders = [
|
||||||
line.strip() for line in self._source.splitlines()
|
line.strip() for line in self.SOURCE.read_text().splitlines()
|
||||||
if "request.get_json()" in line and " or " in line
|
if "request.get_json()" in line and " or " in line
|
||||||
]
|
]
|
||||||
assert offenders == [], (
|
assert offenders == [], (
|
||||||
@@ -129,7 +124,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
|
|||||||
|
|
||||||
def test_no_not_data_guard_is_unreachable(self):
|
def test_no_not_data_guard_is_unreachable(self):
|
||||||
"""A `if not data:` guard needs a read that can actually return None."""
|
"""A `if not data:` guard needs a read that can actually return None."""
|
||||||
lines = self._source.splitlines()
|
lines = self.SOURCE.read_text().splitlines()
|
||||||
offenders = []
|
offenders = []
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
|
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
"""Regression test: per-day schedule validation errors must say "time", not
|
|
||||||
"_pkg.time".
|
|
||||||
|
|
||||||
The split into a package rewrote every bare `time` reference that needed to
|
|
||||||
read through the shared module as `_pkg.time` (see the package's own
|
|
||||||
docstring on why -- tests patch it, so it has to be read back live rather
|
|
||||||
than bound by value). That rewrite was mechanical and matched the substring
|
|
||||||
"time" inside string literals and comments too, so the user-facing message
|
|
||||||
|
|
||||||
"Invalid start time for {day}: ..."
|
|
||||||
|
|
||||||
came out as
|
|
||||||
|
|
||||||
"Invalid start _pkg.time for {day}: ..."
|
|
||||||
|
|
||||||
in both POST /config/schedule and POST /config/dim-schedule, for both the
|
|
||||||
start and end time of a per-day entry.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("url", [
|
|
||||||
"/api/v3/config/schedule",
|
|
||||||
"/api/v3/config/dim-schedule",
|
|
||||||
])
|
|
||||||
class TestPerDayTimeErrorsAreNotCorrupted:
|
|
||||||
def test_invalid_start_time_message(self, api_v3_client, api_v3_module, url):
|
|
||||||
response = api_v3_client.post(url, json={
|
|
||||||
"mode": "per_day",
|
|
||||||
"monday_start": "not-a-time",
|
|
||||||
})
|
|
||||||
assert response.status_code == 400
|
|
||||||
message = response.get_json()["message"]
|
|
||||||
assert "_pkg" not in message, message
|
|
||||||
assert message.startswith("Invalid start time for monday:"), message
|
|
||||||
|
|
||||||
def test_invalid_end_time_message(self, api_v3_client, api_v3_module, url):
|
|
||||||
response = api_v3_client.post(url, json={
|
|
||||||
"mode": "per_day",
|
|
||||||
"monday_start": "07:00",
|
|
||||||
"monday_end": "not-a-time",
|
|
||||||
})
|
|
||||||
assert response.status_code == 400
|
|
||||||
message = response.get_json()["message"]
|
|
||||||
assert "_pkg" not in message, message
|
|
||||||
assert message.startswith("Invalid end time for monday:"), message
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"""The /api/v3 URL map is a contract, and the split must not have moved it.
|
|
||||||
|
|
||||||
api_v3.py was one 10,469-line module and is now a package. Every route module
|
|
||||||
in it decorates the *same* Blueprint object, so this refactor was supposed to
|
|
||||||
be invisible from outside: same URLs, same endpoint names, same methods.
|
|
||||||
|
|
||||||
"Supposed to be" is the problem. A route function silently dropped during a
|
|
||||||
move -- a module that never gets imported, a decorator left behind -- costs
|
|
||||||
nothing at import time and fails only when someone hits the URL. So the map is
|
|
||||||
pinned here.
|
|
||||||
|
|
||||||
The snapshot is intentionally the *whole* map rather than a count. A count
|
|
||||||
passes when one route is deleted and another added, which is exactly the shape
|
|
||||||
a careless move produces.
|
|
||||||
|
|
||||||
If you are adding a route, this test is meant to fail: add the entry to
|
|
||||||
EXPECTED. If you are moving one between modules, it is meant to pass unchanged
|
|
||||||
-- endpoint names are `api_v3.<function>` regardless of which module the
|
|
||||||
function lives in, and that is the property that makes the package safe.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from flask import Flask
|
|
||||||
|
|
||||||
from web_interface.blueprints.api_v3 import api_v3
|
|
||||||
|
|
||||||
SNAPSHOT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
||||||
"fixtures", "api_v3_url_map.json")
|
|
||||||
|
|
||||||
|
|
||||||
def _current_map():
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
|
||||||
return sorted(
|
|
||||||
[r.rule, r.endpoint, sorted(r.methods)]
|
|
||||||
for r in app.url_map.iter_rules()
|
|
||||||
if r.endpoint != "static"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_url_map_matches_the_snapshot():
|
|
||||||
current = _current_map()
|
|
||||||
with open(SNAPSHOT, encoding="utf-8") as fh:
|
|
||||||
expected = json.load(fh)
|
|
||||||
|
|
||||||
cur = {(r, e) for r, e, _ in current}
|
|
||||||
exp = {(r, e) for r, e, _ in expected}
|
|
||||||
|
|
||||||
lost = sorted(exp - cur)
|
|
||||||
added = sorted(cur - exp)
|
|
||||||
assert not lost, (
|
|
||||||
f"{len(lost)} route(s) disappeared from /api/v3: {lost[:5]}. "
|
|
||||||
"A route lost in a module move costs nothing at import time and fails "
|
|
||||||
"only when someone hits the URL.")
|
|
||||||
assert not added, (
|
|
||||||
f"{len(added)} new route(s): {added[:5]}. If that is intended, "
|
|
||||||
f"regenerate {os.path.relpath(SNAPSHOT)}.")
|
|
||||||
|
|
||||||
# Methods too: a route that quietly loses POST is still a broken route.
|
|
||||||
cur_methods = {(r, e): m for r, e, m in current}
|
|
||||||
for rule, endpoint, methods in expected:
|
|
||||||
assert cur_methods[(rule, endpoint)] == methods, (
|
|
||||||
f"{rule} ({endpoint}) methods changed: "
|
|
||||||
f"{methods} -> {cur_methods[(rule, endpoint)]}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_endpoint_is_on_the_one_blueprint():
|
|
||||||
"""The package must not fragment into several blueprints.
|
|
||||||
|
|
||||||
Splitting into per-domain *blueprints* would rename every endpoint from
|
|
||||||
`api_v3.foo` to `api_v3_plugins.foo` and break any url_for() that names
|
|
||||||
one. Keeping a single Blueprint object across the modules is what makes
|
|
||||||
the split a pure code move, so assert it rather than trusting it.
|
|
||||||
"""
|
|
||||||
for rule, endpoint, _ in _current_map():
|
|
||||||
assert endpoint.startswith("api_v3."), (
|
|
||||||
f"{rule} is registered as {endpoint}, not on the api_v3 blueprint")
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_snapshot_is_not_empty():
|
|
||||||
"""Guards the failure mode this file exists to prevent.
|
|
||||||
|
|
||||||
An empty or truncated snapshot would make every assertion above pass
|
|
||||||
vacuously -- the same trap as a route map that imports no modules.
|
|
||||||
"""
|
|
||||||
with open(SNAPSHOT, encoding="utf-8") as fh:
|
|
||||||
expected = json.load(fh)
|
|
||||||
assert len(expected) > 100, (
|
|
||||||
f"snapshot has only {len(expected)} routes; it should have the whole "
|
|
||||||
"/api/v3 surface")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("module", [
|
|
||||||
"backup", "config", "display", "fonts", "misc",
|
|
||||||
"plugins", "starlark", "system", "wifi",
|
|
||||||
])
|
|
||||||
def test_every_route_module_contributes(module):
|
|
||||||
"""Each module must actually register something.
|
|
||||||
|
|
||||||
A module that fails to import, or that is left out of __init__, takes its
|
|
||||||
routes with it silently -- the package still imports and the app still
|
|
||||||
starts.
|
|
||||||
"""
|
|
||||||
import importlib
|
|
||||||
mod = importlib.import_module(f"web_interface.blueprints.api_v3.{module}")
|
|
||||||
routes = [n for n in dir(mod)
|
|
||||||
if callable(getattr(mod, n, None))
|
|
||||||
and getattr(getattr(mod, n), "__module__", "") == mod.__name__]
|
|
||||||
assert routes, f"{module}.py defines no view functions"
|
|
||||||
|
|
||||||
|
|
||||||
def test_project_root_points_at_the_project():
|
|
||||||
"""PROJECT_ROOT is derived from __file__, so moving the file breaks it.
|
|
||||||
|
|
||||||
The split moved this code from web_interface/blueprints/api_v3.py to
|
|
||||||
web_interface/blueprints/api_v3/_common.py -- one directory deeper -- and
|
|
||||||
`Path(__file__).parent.parent.parent` quietly began resolving to
|
|
||||||
web_interface/ instead of the project root. Nothing failed at import. It
|
|
||||||
surfaced as routes returning 404 and "installation script not found",
|
|
||||||
because every path built from it pointed one level too shallow.
|
|
||||||
|
|
||||||
A URL-map check cannot catch that: the routes were all registered, they
|
|
||||||
just could not find anything.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from web_interface.blueprints.api_v3 import PROJECT_ROOT
|
|
||||||
|
|
||||||
# The project root is the directory holding run.py and web_interface/.
|
|
||||||
assert (PROJECT_ROOT / "run.py").is_file(), (
|
|
||||||
f"PROJECT_ROOT is {PROJECT_ROOT}, which has no run.py; it is not the "
|
|
||||||
"project root")
|
|
||||||
assert (PROJECT_ROOT / "web_interface").is_dir()
|
|
||||||
assert PROJECT_ROOT == Path(__file__).resolve().parents[1]
|
|
||||||
@@ -229,61 +229,6 @@ class TestRadio:
|
|||||||
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
|
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
|
||||||
|
|
||||||
|
|
||||||
class TestAutoEnableApMode:
|
|
||||||
URL = "/api/v3/wifi/ap/auto-enable"
|
|
||||||
|
|
||||||
def test_requires_the_field(self, api_v3_client, wifi_manager):
|
|
||||||
response = api_v3_client.post(self.URL, json={})
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("raw,expected", [
|
|
||||||
(True, True), (False, False),
|
|
||||||
("true", True), ("True", True), ("1", True), ("yes", True),
|
|
||||||
("false", False), ("False", False), ("0", False), ("no", False),
|
|
||||||
(1, True), (0, False),
|
|
||||||
])
|
|
||||||
def test_value_is_coerced_not_just_truthy(
|
|
||||||
self, api_v3_client, wifi_manager, raw, expected):
|
|
||||||
# Regression: bool(data['auto_enable_ap_mode']) meant a caller who
|
|
||||||
# sent the JSON string "false" got it stored as True — bool("false")
|
|
||||||
# is True, since any non-empty string is truthy.
|
|
||||||
wifi_manager.config = {}
|
|
||||||
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": raw})
|
|
||||||
assert response.status_code == 200, response.get_json()
|
|
||||||
assert response.get_json()["data"]["auto_enable_ap_mode"] is expected
|
|
||||||
assert wifi_manager.config["auto_enable_ap_mode"] is expected
|
|
||||||
|
|
||||||
def test_a_string_false_does_not_enable_it(self, api_v3_client, wifi_manager):
|
|
||||||
# The exact shape of the bug.
|
|
||||||
wifi_manager.config = {}
|
|
||||||
api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "false"})
|
|
||||||
assert wifi_manager.config["auto_enable_ap_mode"] is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestRadioEnabledAndForceAcceptIntegers:
|
|
||||||
"""`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old
|
|
||||||
coercion was `raw is True or (isinstance(raw, str) and ...)`, and
|
|
||||||
`1 is True` is False in Python -- an int is never the `True` singleton
|
|
||||||
even though it equals it -- so a plain integer fell through to False
|
|
||||||
regardless of its value.
|
|
||||||
"""
|
|
||||||
URL = "/api/v3/wifi/radio"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
|
|
||||||
def test_enabled_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
|
|
||||||
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
|
||||||
wifi_manager.get_wifi_radio_state.return_value = {}
|
|
||||||
api_v3_client.post(self.URL, json={"enabled": raw})
|
|
||||||
wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
|
|
||||||
def test_force_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
|
|
||||||
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
|
||||||
wifi_manager.get_wifi_radio_state.return_value = {}
|
|
||||||
api_v3_client.post(self.URL, json={"enabled": True, "force": raw})
|
|
||||||
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=expected)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNoRealNetworking:
|
class TestNoRealNetworking:
|
||||||
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
|
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
|
||||||
# Guard against a future refactor moving the import to module level,
|
# Guard against a future refactor moving the import to module level,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""web_interface/app.py resolves plugins_dir from config at import time.
|
||||||
|
|
||||||
|
`project_root` used to be assigned only inside the `else` branch (the
|
||||||
|
relative-path case). SchemaManager and composer_bp are wired up with
|
||||||
|
`project_root` further down the same module, so a device configured with an
|
||||||
|
*absolute* `plugin_system.plugins_directory` (a supported value -- see
|
||||||
|
CLAUDE.md) hit `UnboundLocalError` importing web_interface.app at all, taking
|
||||||
|
the whole web UI down.
|
||||||
|
|
||||||
|
app.py isn't imported directly by the test suite -- it has real side effects
|
||||||
|
(managers, background state) at import time -- so this extracts the exact
|
||||||
|
resolution block by its stable comment markers and executes it in isolation,
|
||||||
|
the same way the rest of the suite validates generated/templated code rather
|
||||||
|
than re-implementing it.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP_PY = Path(__file__).resolve().parent.parent / "web_interface/app.py"
|
||||||
|
|
||||||
|
START_MARKER = "# Resolve plugin directory - handle both absolute and relative paths\n"
|
||||||
|
END_MARKER = "\nplugin_manager = PluginManager("
|
||||||
|
|
||||||
|
|
||||||
|
def _resolution_block() -> str:
|
||||||
|
src = APP_PY.read_text()
|
||||||
|
start = src.index(START_MARKER)
|
||||||
|
end = src.index(END_MARKER, start)
|
||||||
|
return src[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolution_block_still_matches_expected_markers():
|
||||||
|
"""If app.py is restructured enough that these markers move, the exec
|
||||||
|
below would silently test nothing -- fail loudly instead."""
|
||||||
|
block = _resolution_block()
|
||||||
|
assert "plugins_dir" in block and "os.path.isabs" in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_absolute_plugins_directory_does_not_raise_unboundlocalerror():
|
||||||
|
import os
|
||||||
|
ns = {"os": os, "Path": Path, "__file__": str(APP_PY),
|
||||||
|
"plugins_dir_name": "/opt/ledmatrix-plugins"}
|
||||||
|
exec(compile(_resolution_block(), str(APP_PY), "exec"), ns) # noqa: S102
|
||||||
|
assert ns["project_root"] == APP_PY.parent.parent
|
||||||
|
assert ns["plugins_dir"] == Path("/opt/ledmatrix-plugins")
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_plugins_directory_still_resolves_under_project_root():
|
||||||
|
import os
|
||||||
|
ns = {"os": os, "Path": Path, "__file__": str(APP_PY),
|
||||||
|
"plugins_dir_name": "plugin-repos"}
|
||||||
|
exec(compile(_resolution_block(), str(APP_PY), "exec"), ns) # noqa: S102
|
||||||
|
assert ns["plugins_dir"] == ns["project_root"] / "plugin-repos"
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"""Non-finite floats round-trip identically with and without orjson.
|
|
||||||
|
|
||||||
JSON has no NaN or Infinity. The stdlib emits them anyway as an extension;
|
|
||||||
orjson refuses to and writes null. Cache files outlive the decision of which
|
|
||||||
encoder is installed, so both halves of that gap are pinned here:
|
|
||||||
|
|
||||||
* writing -- installing orjson must not silently change what gets cached,
|
|
||||||
so the stdlib path writes null too;
|
|
||||||
* reading -- records already on disk carrying NaN or Infinity must stay
|
|
||||||
readable, or installing orjson turns each of them into a "corrupted cache
|
|
||||||
file" that DiskCache.get logs as an error and deletes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from src.cache import disk_cache as disk_cache_module # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def stdlib_cache():
|
|
||||||
"""The module as it loads on a host with no orjson wheel."""
|
|
||||||
# A None entry in sys.modules makes `import orjson` raise ImportError,
|
|
||||||
# which is the branch we want, whether or not orjson is really installed.
|
|
||||||
with mock.patch.dict(sys.modules, {"orjson": None}):
|
|
||||||
module = importlib.reload(disk_cache_module)
|
|
||||||
assert module.orjson is None
|
|
||||||
yield module
|
|
||||||
importlib.reload(disk_cache_module)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def orjson_cache():
|
|
||||||
"""The module as it loads with orjson available."""
|
|
||||||
module = importlib.reload(disk_cache_module)
|
|
||||||
if module.orjson is None:
|
|
||||||
pytest.skip("orjson is not installed on this host")
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
NON_FINITE = {"nan": float("nan"), "inf": float("inf"), "ninf": float("-inf")}
|
|
||||||
|
|
||||||
|
|
||||||
def _reject_constant(name):
|
|
||||||
"""Make json.loads as strict as orjson about NaN/Infinity tokens."""
|
|
||||||
raise AssertionError(f"non-spec JSON constant in output: {name}")
|
|
||||||
|
|
||||||
|
|
||||||
class TestWritePolicy:
|
|
||||||
"""Non-finite floats become null on whichever encoder is in use."""
|
|
||||||
|
|
||||||
def _assert_nulled(self, module):
|
|
||||||
record = module._loads(module._dumps(dict(NON_FINITE, ok=1.5)))
|
|
||||||
assert record["nan"] is None
|
|
||||||
assert record["inf"] is None
|
|
||||||
assert record["ninf"] is None
|
|
||||||
# Finite values are untouched.
|
|
||||||
assert record["ok"] == 1.5
|
|
||||||
|
|
||||||
def test_stdlib_writes_null(self, stdlib_cache):
|
|
||||||
self._assert_nulled(stdlib_cache)
|
|
||||||
|
|
||||||
def test_orjson_writes_null(self, orjson_cache):
|
|
||||||
self._assert_nulled(orjson_cache)
|
|
||||||
|
|
||||||
def test_stdlib_emits_spec_compliant_json(self, stdlib_cache):
|
|
||||||
# The point of the write half: bytes written without orjson must still
|
|
||||||
# parse once orjson is installed later. json.loads accepts the
|
|
||||||
# extension tokens, so it cannot show this -- assert on the bytes, and
|
|
||||||
# on a strict reader when there is one.
|
|
||||||
raw = stdlib_cache._dumps(dict(NON_FINITE))
|
|
||||||
assert b"NaN" not in raw
|
|
||||||
assert b"Infinity" not in raw
|
|
||||||
assert json.loads(raw, parse_constant=_reject_constant) == {
|
|
||||||
"nan": None, "inf": None, "ninf": None}
|
|
||||||
|
|
||||||
def test_nested_non_finite_are_replaced(self, stdlib_cache):
|
|
||||||
data = {"a": [1.0, float("nan"), {"b": float("inf")}], "c": (float("-inf"),)}
|
|
||||||
record = stdlib_cache._loads(stdlib_cache._dumps(data))
|
|
||||||
assert record["a"] == [1.0, None, {"b": None}]
|
|
||||||
assert record["c"] == [None]
|
|
||||||
|
|
||||||
def test_finite_payloads_are_byte_identical_to_the_old_encoder(self, stdlib_cache):
|
|
||||||
# allow_nan=False must not change ordinary output.
|
|
||||||
data = {"x": 1, "y": [1.5, "s", True, None], "z": {"k": 2.25}}
|
|
||||||
assert stdlib_cache._dumps(data) == json.dumps(
|
|
||||||
data, cls=stdlib_cache.DateTimeEncoder).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
class TestReplaceNonFinite:
|
|
||||||
def test_leaves_ordinary_values_alone(self):
|
|
||||||
for value in (1, 1.5, "s", True, None, [], {}):
|
|
||||||
assert disk_cache_module._replace_nonfinite(value) == value
|
|
||||||
|
|
||||||
def test_replaces_every_non_finite_float(self):
|
|
||||||
for value in NON_FINITE.values():
|
|
||||||
assert disk_cache_module._replace_nonfinite(value) is None
|
|
||||||
|
|
||||||
def test_walks_nested_containers(self):
|
|
||||||
assert disk_cache_module._replace_nonfinite(
|
|
||||||
{"a": [{"b": float("nan")}]}) == {"a": [{"b": None}]}
|
|
||||||
|
|
||||||
|
|
||||||
class TestLegacyRecordsStayReadable:
|
|
||||||
"""Files written before orjson arrived still load."""
|
|
||||||
|
|
||||||
LEGACY = b'{"timestamp": 1000.0, "value": NaN, "other": Infinity}'
|
|
||||||
|
|
||||||
def test_stdlib_reads_legacy_tokens(self, stdlib_cache):
|
|
||||||
record = stdlib_cache._loads(self.LEGACY)
|
|
||||||
assert math.isnan(record["value"])
|
|
||||||
assert math.isinf(record["other"])
|
|
||||||
|
|
||||||
def test_orjson_falls_back_for_legacy_tokens(self, orjson_cache):
|
|
||||||
record = orjson_cache._loads(self.LEGACY)
|
|
||||||
assert math.isnan(record["value"])
|
|
||||||
assert math.isinf(record["other"])
|
|
||||||
|
|
||||||
def test_genuinely_malformed_files_still_raise(self, stdlib_cache):
|
|
||||||
with pytest.raises(json.JSONDecodeError):
|
|
||||||
stdlib_cache._loads(b'{"a": ')
|
|
||||||
|
|
||||||
def test_orjson_still_raises_for_malformed_files(self, orjson_cache):
|
|
||||||
with pytest.raises(json.JSONDecodeError):
|
|
||||||
orjson_cache._loads(b'{"a": ')
|
|
||||||
|
|
||||||
|
|
||||||
class TestDiskCacheEndToEnd:
|
|
||||||
def _cache(self, module, tmp_path):
|
|
||||||
return module.DiskCache(str(tmp_path))
|
|
||||||
|
|
||||||
def test_legacy_file_is_not_deleted_as_corrupt(self, orjson_cache, tmp_path):
|
|
||||||
cache = self._cache(orjson_cache, tmp_path)
|
|
||||||
path = cache.get_cache_path("legacy")
|
|
||||||
Path(path).write_bytes(
|
|
||||||
b'{"timestamp": %d, "value": NaN}' % int(__import__("time").time()))
|
|
||||||
|
|
||||||
record = cache.get("legacy", max_age=None)
|
|
||||||
|
|
||||||
assert record is not None, "legacy NaN record was treated as corrupt"
|
|
||||||
assert math.isnan(record["value"])
|
|
||||||
assert Path(path).exists(), "legacy NaN record was deleted"
|
|
||||||
|
|
||||||
def test_round_trip_through_set_and_get(self, stdlib_cache, tmp_path):
|
|
||||||
cache = self._cache(stdlib_cache, tmp_path)
|
|
||||||
cache.set("k", {"timestamp": __import__("time").time(),
|
|
||||||
"value": float("nan")})
|
|
||||||
record = cache.get("k", max_age=None)
|
|
||||||
assert record is not None
|
|
||||||
assert record["value"] is None
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""composer_bp must actually be registered on the Flask app.
|
||||||
|
|
||||||
|
web_interface/blueprints/composer.py defines a full blueprint -- the
|
||||||
|
/composer/ page, /composer/api/generate, /composer/api/install, etc. -- but
|
||||||
|
nothing in web_interface/app.py imported or registered it. pages_v3 and
|
||||||
|
api_v3 both go through the same "import, wire managers, register_blueprint"
|
||||||
|
sequence; composer_bp had none of the three. The routes existed and worked
|
||||||
|
in isolation (every other composer test builds its own minimal Flask app
|
||||||
|
and registers composer_bp directly), but in the real running app every
|
||||||
|
composer URL -- including the page itself -- was a 404, and the frontend's
|
||||||
|
hardcoded '/composer/api/...' fetches (composer-app.js) had nothing to
|
||||||
|
reach.
|
||||||
|
|
||||||
|
app.py has import-time side effects (it loads config, builds managers, and
|
||||||
|
in some configurations starts background threads), so this scans the source
|
||||||
|
text rather than importing the module -- the same approach already used for
|
||||||
|
test_composer_js_contracts.py's structural checks.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP_PY = Path(__file__).resolve().parent.parent / "web_interface" / "app.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _source() -> str:
|
||||||
|
return APP_PY.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_composer_blueprint_is_imported():
|
||||||
|
src = _source()
|
||||||
|
assert re.search(
|
||||||
|
r"from web_interface\.blueprints\.composer import composer_bp", src
|
||||||
|
), "app.py never imports composer_bp"
|
||||||
|
|
||||||
|
|
||||||
|
def test_composer_blueprint_is_registered_under_composer_prefix():
|
||||||
|
src = _source()
|
||||||
|
match = re.search(
|
||||||
|
r"app\.register_blueprint\(\s*composer_bp\s*,\s*url_prefix\s*=\s*"
|
||||||
|
r"['\"](?P<prefix>[^'\"]*)['\"]",
|
||||||
|
src,
|
||||||
|
)
|
||||||
|
assert match, "app.py never calls app.register_blueprint(composer_bp, ...)"
|
||||||
|
# composer.html and composer-app.js hardcode '/composer/api/...' and
|
||||||
|
# '/composer/api/fonts/...' -- any other prefix serves a page whose own
|
||||||
|
# fetches and @font-face all 404.
|
||||||
|
assert match.group("prefix") == "/composer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_composer_blueprint_managers_are_wired_before_registration():
|
||||||
|
"""Registered with no managers set means every route 503s or crashes.
|
||||||
|
|
||||||
|
composer.py initialises config_manager/plugin_manager/plugins_dir/
|
||||||
|
project_root to None at import time specifically so app.py can set them
|
||||||
|
-- the same pattern api_v3 and pages_v3 use.
|
||||||
|
"""
|
||||||
|
src = _source()
|
||||||
|
register_match = re.search(r"app\.register_blueprint\(\s*composer_bp", src)
|
||||||
|
assert register_match, "app.py never registers composer_bp"
|
||||||
|
before_registration = src[: register_match.start()]
|
||||||
|
for attr in ("config_manager", "plugin_manager", "plugins_dir", "project_root"):
|
||||||
|
assert re.search(rf"composer_bp\.{attr}\s*=", before_registration), (
|
||||||
|
f"composer_bp.{attr} is never set before registration"
|
||||||
|
)
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""The composer generates Python that the plugin loader imports and executes.
|
||||||
|
|
||||||
|
/api/install writes the generated manager.py into plugins_dir and the loader
|
||||||
|
imports it, so anything the payload can splice into that source runs on the
|
||||||
|
device. The ast.parse check in _generate_plugin_files rejects only *invalid*
|
||||||
|
syntax -- an injected `import os` is perfectly valid and passed it.
|
||||||
|
|
||||||
|
Two ways in, both confirmed against the code before it was fixed:
|
||||||
|
|
||||||
|
metadata.name = a name containing a triple-quote, a newline, then
|
||||||
|
`import os; PWNED = os.getuid()`, then another triple-quote
|
||||||
|
-> closes the module docstring; the rest became module-level statements
|
||||||
|
(spelled out rather than shown literally -- writing the payload into
|
||||||
|
this docstring closes *this* file's docstring, which is the bug)
|
||||||
|
|
||||||
|
element x = '0 or __import__("os").system("id")'
|
||||||
|
-> f-string interpolated it verbatim: x=0 or __import__("os").system("id")
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from web_interface.blueprints import composer as C # noqa: E402
|
||||||
|
|
||||||
|
BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a",
|
||||||
|
"version": "1.0.0", "description": "d"}
|
||||||
|
|
||||||
|
#: Values that terminate a Python expression and start a new statement.
|
||||||
|
EXPR_PAYLOADS = [
|
||||||
|
'0 or __import__("os").system("id")',
|
||||||
|
'0);import os;os.system("id");(',
|
||||||
|
'__import__("subprocess").run(["id"])',
|
||||||
|
"0 if False else exec('x=1')",
|
||||||
|
"1e999", "nan", "0x41", "0__0",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Values that close a string literal in the generated source.
|
||||||
|
LITERAL_PAYLOADS = [
|
||||||
|
'Clock"""\nimport os; PWNED = os.getuid()\n"""',
|
||||||
|
"Clock'''\nimport os\n'''",
|
||||||
|
'Clock" + __import__("os").system("id") + "',
|
||||||
|
"Clock\\", "Clock\nimport os",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(**over):
|
||||||
|
# dataModel.configVars is the key _generate_plugin_files reads; "config_vars"
|
||||||
|
# was never looked at, so anything passed through it tested nothing.
|
||||||
|
p = {"metadata": dict(BASE_META), "elements": [],
|
||||||
|
"dataModel": {"configVars": over.pop("config_vars", [])}}
|
||||||
|
p["metadata"].update(over.pop("metadata", {}))
|
||||||
|
p.update(over)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _generated(payload):
|
||||||
|
return C._generate_plugin_files(payload)["manager.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def _module_level_code(src):
|
||||||
|
"""Statements at module level that are not the docstring/imports/classes."""
|
||||||
|
tree = ast.parse(src)
|
||||||
|
out = []
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.ImportFrom, ast.Import)):
|
||||||
|
continue
|
||||||
|
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
|
||||||
|
continue # the docstring
|
||||||
|
out.append(ast.unparse(node))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", LITERAL_PAYLOADS)
|
||||||
|
def test_a_name_that_breaks_out_of_a_literal_is_refused(payload):
|
||||||
|
with pytest.raises(C.ComposerInputError):
|
||||||
|
_generated(_payload(metadata={"name": payload}))
|
||||||
|
|
||||||
|
|
||||||
|
#: Types with a drawing branch in manager.py.j2. An injection test using any
|
||||||
|
#: other type proves nothing: _preprocess_elements drops it, so its values
|
||||||
|
#: never reach the generated source and every assertion passes trivially.
|
||||||
|
#: This test previously used "line", which has never had a branch.
|
||||||
|
RENDERED_GEOMETRY_CASES = [
|
||||||
|
("rectangle", {"x": 0, "y": 0, "width": 10, "height": 8}),
|
||||||
|
("arc", {"x": 0, "y": 0, "width": 24, "height": 24}),
|
||||||
|
("ellipse", {"x": 0, "y": 0, "width": 24, "height": 12}),
|
||||||
|
("rounded_rectangle", {"x": 0, "y": 0, "width": 24, "height": 10}),
|
||||||
|
("gauge", {"x": 0, "y": 0, "width": 32, "height": 32}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("etype,base", RENDERED_GEOMETRY_CASES)
|
||||||
|
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
||||||
|
@pytest.mark.parametrize("field", ["x", "y", "width", "height"])
|
||||||
|
def test_a_non_numeric_geometry_value_cannot_reach_the_source(etype, base, evil, field):
|
||||||
|
"""width/height were interpolated raw into the generated source.
|
||||||
|
|
||||||
|
p['x2_expr'] = f"({x_expr}) + {w}" with w straight off the payload, so a
|
||||||
|
rectangle with width='0 or __import__("os").system("id")' produced
|
||||||
|
|
||||||
|
[0, 0, (0) + 0 or __import__("os").system("id"), (0) + 8],
|
||||||
|
|
||||||
|
in a manager.py that /api/install writes to disk and the loader imports.
|
||||||
|
"""
|
||||||
|
el = {"type": etype, "id": "e1", **base}
|
||||||
|
el[field] = evil
|
||||||
|
src = _generated(_payload(elements=[el]))
|
||||||
|
assert "__import__" not in src, f"{etype}.{field}={evil!r} reached the generated source"
|
||||||
|
assert "os.system" not in src
|
||||||
|
assert not _module_level_code(src), \
|
||||||
|
f"{etype}.{field}={evil!r} produced module-level statements: {_module_level_code(src)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_injection_case_uses_a_type_that_actually_renders():
|
||||||
|
"""Guards against the whole suite quietly going vacuous again.
|
||||||
|
|
||||||
|
An element type with no template branch is dropped before generation, so
|
||||||
|
an injection test written against one asserts nothing and still passes.
|
||||||
|
"""
|
||||||
|
used = {etype for etype, _ in RENDERED_GEOMETRY_CASES}
|
||||||
|
missing = used - set(C._RENDERABLE_ELEMENT_TYPES)
|
||||||
|
assert not missing, f"injection tests use non-rendering types: {sorted(missing)}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
||||||
|
@pytest.mark.parametrize("channel", ["r", "g", "b"])
|
||||||
|
def test_a_non_numeric_colour_channel_cannot_reach_the_source(evil, channel):
|
||||||
|
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
|
||||||
|
"font": "press_start", "r": 255, "g": 255, "b": 255}
|
||||||
|
el[channel] = evil
|
||||||
|
src = _generated(_payload(elements=[el]))
|
||||||
|
assert "__import__" not in src and "os.system" not in src
|
||||||
|
assert not _module_level_code(src)
|
||||||
|
|
||||||
|
|
||||||
|
def test_colour_channels_are_clamped_to_a_byte():
|
||||||
|
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
|
||||||
|
"font": "press_start", "r": 99999, "g": -5, "b": 128}
|
||||||
|
src = _generated(_payload(elements=[el]))
|
||||||
|
assert "(255, 0, 128)" in src, "channels were not clamped to 0-255"
|
||||||
|
|
||||||
|
|
||||||
|
#: fillR/fillG/fillB (and outR/G/B, bgR/G/B, trackR/G/B) go through
|
||||||
|
#: _as_fill_filter -> _as_rgb_filter, a separate path from _rgb_expr above.
|
||||||
|
#: It used raw int() until it was found to raise ValueError on a non-numeric
|
||||||
|
#: channel instead of clamping like every other coerced value in this module.
|
||||||
|
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
||||||
|
@pytest.mark.parametrize("channel", ["fillR", "fillG", "fillB"])
|
||||||
|
def test_a_non_numeric_fill_channel_cannot_reach_the_source(evil, channel):
|
||||||
|
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8,
|
||||||
|
"fillR": 0, "fillG": 0, "fillB": 128}
|
||||||
|
el[channel] = evil
|
||||||
|
src = _generated(_payload(elements=[el]))
|
||||||
|
assert "__import__" not in src and "os.system" not in src
|
||||||
|
assert not _module_level_code(src)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_channels_are_clamped_to_a_byte():
|
||||||
|
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8,
|
||||||
|
"fillR": 99999, "fillG": -5, "fillB": 128}
|
||||||
|
src = _generated(_payload(elements=[el]))
|
||||||
|
assert "(255, 0, 128)" in src, "fill channels were not clamped to 0-255"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_generated_module_still_has_no_top_level_statements():
|
||||||
|
"""The clean case: a normal payload produces only imports and a class."""
|
||||||
|
el = {"type": "text", "id": "t1", "x": 4, "y": 4, "text": "hi",
|
||||||
|
"font": "press_start", "r": 1, "g": 2, "b": 3}
|
||||||
|
src = _generated(_payload(elements=[el]))
|
||||||
|
assert not _module_level_code(src)
|
||||||
|
assert "(1, 2, 3)" in src
|
||||||
|
|
||||||
|
|
||||||
|
# --- config variable keys ---------------------------------------------------
|
||||||
|
|
||||||
|
def _with_key(key):
|
||||||
|
return {"metadata": dict(BASE_META), "elements": [],
|
||||||
|
"dataModel": {"configVars": [{"key": key, "type": "string",
|
||||||
|
"default": "x", "label": "L"}]}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["class", "def", "import", "None", "True",
|
||||||
|
"lambda", "pass", "match", "case"])
|
||||||
|
def test_a_keyword_config_key_is_named_in_the_error(key):
|
||||||
|
"""ast.parse already rejected these, but as an unhelpful line number.
|
||||||
|
|
||||||
|
"Generated code has a syntax error: invalid syntax (line 17)" tells the
|
||||||
|
user nothing about which field to fix.
|
||||||
|
"""
|
||||||
|
with pytest.raises(C.ComposerInputError) as exc:
|
||||||
|
_generated(_with_key(key))
|
||||||
|
assert key in str(exc.value) and "keyword" in str(exc.value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["config", "logger", "display_manager",
|
||||||
|
"cache_manager", "plugin_id", "enabled",
|
||||||
|
"self", "update", "display"])
|
||||||
|
def test_a_reserved_attribute_config_key_is_refused(key):
|
||||||
|
"""These generate *valid* Python that silently clobbers plugin state.
|
||||||
|
|
||||||
|
The worst is `config`: the assignment lands right after super().__init__(),
|
||||||
|
so `self.config = config.get("config", "x")` replaces the plugin's config
|
||||||
|
dict with a string and every later self.config.get(...) fails at runtime.
|
||||||
|
"""
|
||||||
|
with pytest.raises(C.ComposerInputError) as exc:
|
||||||
|
_generated(_with_key(key))
|
||||||
|
assert key in str(exc.value) and "reserved" in str(exc.value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["brightness", "my_var", "_private", "x1",
|
||||||
|
"update_interval_seconds"])
|
||||||
|
def test_ordinary_config_keys_are_still_accepted(key):
|
||||||
|
src = _generated(_with_key(key))
|
||||||
|
assert f"self.{key} = config.get(" in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_generated_config_assignment_does_not_precede_super_init():
|
||||||
|
"""Guards the reasoning behind the reserved list, not just the list."""
|
||||||
|
src = _generated(_with_key("brightness"))
|
||||||
|
body = src.splitlines()
|
||||||
|
super_at = next(i for i, line in enumerate(body) if "super().__init__(" in line)
|
||||||
|
assign_at = next(i for i, line in enumerate(body)
|
||||||
|
if "self.brightness = config.get(" in line)
|
||||||
|
assert assign_at > super_at, (
|
||||||
|
"config vars are assigned before super().__init__(); the reserved-name "
|
||||||
|
"list assumes they land after it")
|
||||||
|
|
||||||
|
|
||||||
|
# --- optional keys ----------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("el_type,missing", [
|
||||||
|
("text", "text"), ("text", "text2"), ("clock", "format"),
|
||||||
|
])
|
||||||
|
def test_an_element_missing_an_optional_key_does_not_500(el_type, missing):
|
||||||
|
"""`p` is a copy of the raw element, so an absent key stays absent.
|
||||||
|
|
||||||
|
The defaults were applied to locals only, so manager.py.j2 rendered
|
||||||
|
`{{ el.text | tojson }}` over a jinja2.Undefined and tojson raised
|
||||||
|
TypeError -- which no handler catches, making a missing key a 500 rather
|
||||||
|
than a validation error or a sensible default.
|
||||||
|
"""
|
||||||
|
el = {"type": el_type, "id": "e1", "x": 0, "y": 0, "font": "press_start"}
|
||||||
|
src = _generated(_payload(elements=[el]))
|
||||||
|
ast.parse(src) # must still be valid Python
|
||||||
|
assert "Undefined" not in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_clock_without_a_format_uses_the_documented_default():
|
||||||
|
el = {"type": "clock", "id": "c1", "x": 0, "y": 0, "font": "press_start"}
|
||||||
|
src = _generated(_payload(elements=[el]))
|
||||||
|
assert '"%H:%M"' in src, "the %H:%M default did not reach the generated source"
|
||||||
|
|
||||||
|
|
||||||
|
#: (element type, channel key, base element) for colour channels that were
|
||||||
|
#: interpolated raw rather than through _rgb_expr/_safe_int. Prefixed channels
|
||||||
|
#: (emptyR/G/B, labelR/G/B) were the ones the original r/g/b test never reached.
|
||||||
|
RAW_COLOUR_CASES = [
|
||||||
|
("progress_bar", "r", {"x": 0, "y": 0}),
|
||||||
|
("progress_bar", "g", {"x": 0, "y": 0}),
|
||||||
|
("pips", "b", {"x": 0, "y": 0}),
|
||||||
|
("pips", "emptyR", {"x": 0, "y": 0}),
|
||||||
|
("pips", "emptyG", {"x": 0, "y": 0}),
|
||||||
|
("sparkline", "r", {"x": 0, "y": 0}),
|
||||||
|
("gauge", "labelR", {"x": 0, "y": 0, "width": 32, "height": 32}),
|
||||||
|
("gauge", "labelB", {"x": 0, "y": 0, "width": 32, "height": 32}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("etype,channel,base", RAW_COLOUR_CASES)
|
||||||
|
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
||||||
|
def test_a_prefixed_colour_channel_cannot_reach_the_source(etype, channel, base, evil):
|
||||||
|
"""Five tuples were built with f"({el.get('r', 100)}, ...)" -- no coercion.
|
||||||
|
|
||||||
|
The pre-existing colour test only covered r/g/b on a text element, so the
|
||||||
|
prefixed channels and the four other types were never exercised.
|
||||||
|
"""
|
||||||
|
el = {"type": etype, "id": "e1", **base}
|
||||||
|
el[channel] = evil
|
||||||
|
src = _generated(_payload(elements=[el]))
|
||||||
|
assert "__import__" not in src, f"{etype}.{channel}={evil!r} reached the source"
|
||||||
|
assert "os.system" not in src
|
||||||
|
assert not _module_level_code(src)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan")])
|
||||||
|
@pytest.mark.parametrize("field", ["x", "y", "width", "height"])
|
||||||
|
def test_a_non_finite_dimension_does_not_escape_as_an_unhandled_error(field, value):
|
||||||
|
"""json.loads accepts Infinity/NaN and Flask passes them through, so a
|
||||||
|
payload can hand _safe_int a non-finite float. int(inf) raises
|
||||||
|
OverflowError -- neither ValueError nor ComposerInputError -- so it escaped
|
||||||
|
both handlers and surfaced as a 500 with a traceback instead of a 422."""
|
||||||
|
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8}
|
||||||
|
el[field] = value
|
||||||
|
src = _generated(_payload(elements=[el])) # must not raise
|
||||||
|
# A non-finite value must be replaced by the default, not spelled into the
|
||||||
|
# source. Word-boundary match: "info" in self.logger.info contains "inf".
|
||||||
|
assert not re.search(r"\b(inf|nan|Infinity|NaN)\b", src), \
|
||||||
|
f"{field}={value!r} leaked a non-finite literal into the source"
|
||||||
|
assert not _module_level_code(src)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_id", [
|
||||||
|
'x = __import__("os").system("id") #',
|
||||||
|
"x\nimport os\n_y",
|
||||||
|
"x[0]",
|
||||||
|
"",
|
||||||
|
"a" * 200,
|
||||||
|
])
|
||||||
|
def test_a_marquee_id_cannot_become_code(bad_id):
|
||||||
|
"""data_key is spliced UNQUOTED into variable names
|
||||||
|
(_{{ data_key }}_text = ...), so a non-identifier id landed in the source
|
||||||
|
as code. ast.parse caught it, but the caller then got an opaque
|
||||||
|
"Generated code has a syntax error" rather than being told the id is bad."""
|
||||||
|
el = {"type": "marquee", "id": bad_id, "x": 0, "y": 0, "text": "hi"}
|
||||||
|
src = _generated(_payload(elements=[el])) # must not raise
|
||||||
|
assert "__import__(" not in src
|
||||||
|
assert "os.system(" not in src
|
||||||
|
assert not _module_level_code(src)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""An element the template cannot draw must not produce an empty `if` block.
|
||||||
|
|
||||||
|
manager.py.j2 wraps each element in `if width >= N:` (breakpoint) and/or
|
||||||
|
`if int(time.time() * 2) % 2:` (blink), and the body comes from the per-type
|
||||||
|
branches. A type with no branch contributed nothing, so the wrapper opened a
|
||||||
|
block with no statements in it. ast.parse in _generate_plugin_files then
|
||||||
|
failed and the caller was told only:
|
||||||
|
|
||||||
|
Generated code has a syntax error: expected an indented block after
|
||||||
|
'if' statement on line 49
|
||||||
|
|
||||||
|
which names a line of generated source the user never sees. Confirmed against
|
||||||
|
the code before the fix with a `group` element carrying minWidth.
|
||||||
|
|
||||||
|
Two defences, both covered here: _preprocess_elements drops types the template
|
||||||
|
has no branch for, and the template emits a `pass` fallback so a type added to
|
||||||
|
the canvas before its branch exists degrades to a no-op instead of a broken
|
||||||
|
plugin.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from web_interface.blueprints import composer as C # noqa: E402
|
||||||
|
|
||||||
|
TEMPLATE = (Path(__file__).resolve().parent.parent
|
||||||
|
/ "web_interface/templates/v3/composer/manager.py.j2")
|
||||||
|
|
||||||
|
BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a",
|
||||||
|
"version": "1.0.0", "description": "d"}
|
||||||
|
|
||||||
|
|
||||||
|
def generate(element):
|
||||||
|
return C._generate_plugin_files({
|
||||||
|
"metadata": BASE_META,
|
||||||
|
"elements": [element],
|
||||||
|
"dataModel": {"configVars": []},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("wrapper", [
|
||||||
|
{"minWidth": 64}, # breakpoint block
|
||||||
|
{"blink": True}, # blink block
|
||||||
|
{"minWidth": 64, "blink": True}, # both, nested
|
||||||
|
])
|
||||||
|
@pytest.mark.parametrize("etype", ["group", "widget_9000", "section"])
|
||||||
|
def test_undrawable_element_does_not_break_generation(etype, wrapper):
|
||||||
|
element = {"type": etype, "x": 0, "y": 0, "color": "#ffffff", **wrapper}
|
||||||
|
files = generate(element) # must not raise ComposerInputError
|
||||||
|
assert "manager.py" in files
|
||||||
|
|
||||||
|
|
||||||
|
def test_drawable_element_still_renders_inside_a_breakpoint():
|
||||||
|
files = generate({"type": "text", "text": "hi", "x": 0, "y": 0,
|
||||||
|
"minWidth": 64, "color": "#ffffff"})
|
||||||
|
src = files["manager.py"]
|
||||||
|
assert "if width >= 64:" in src
|
||||||
|
assert "draw_text" in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_renderable_types_match_the_template_branches():
|
||||||
|
"""The constant and the template must agree.
|
||||||
|
|
||||||
|
A type listed in the constant with no branch emits an empty block (the bug
|
||||||
|
above); a type with a branch but missing from the constant is silently
|
||||||
|
dropped from every generated plugin. Neither is visible without this check.
|
||||||
|
"""
|
||||||
|
branches = set(re.findall(r"el\.type == '([a-z_]+)'", TEMPLATE.read_text()))
|
||||||
|
assert branches == set(C._RENDERABLE_ELEMENT_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_closes_the_branch_chain_with_a_fallback():
|
||||||
|
"""Belt and braces: even if the constant drifts, no empty block escapes."""
|
||||||
|
text = TEMPLATE.read_text()
|
||||||
|
assert "{% else %}" in text
|
||||||
|
assert "pass # element type" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("wrapper", [
|
||||||
|
{"minWidth": 64},
|
||||||
|
{"blink": True},
|
||||||
|
{"minWidth": 64, "blink": True},
|
||||||
|
])
|
||||||
|
def test_dynamic_text_with_non_config_binding_does_not_break_generation(wrapper):
|
||||||
|
"""dynamic_text only renders a body for binding.source == 'config'.
|
||||||
|
|
||||||
|
_preprocess_elements accepts any string as binding.source (it just
|
||||||
|
defaults a missing one to 'config'), so a 'live' or 'sensor' source --
|
||||||
|
anything a client sends that isn't literally 'config' -- hit the same
|
||||||
|
empty-if-block bug as an undrawable element type, just one level deeper:
|
||||||
|
the branch is taken, but its own inner `if` produced nothing.
|
||||||
|
"""
|
||||||
|
element = {"type": "dynamic_text", "x": 0, "y": 0, "color": "#ffffff",
|
||||||
|
"binding": {"source": "live", "key": "temperature"}, **wrapper}
|
||||||
|
files = generate(element) # must not raise ComposerInputError
|
||||||
|
assert "manager.py" in files
|
||||||
|
assert "non-config dynamic_text binding draws nothing" in files["manager.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_binding_source_cannot_break_out_of_the_comment_it_lands_in():
|
||||||
|
"""binding.source used to be interpolated straight into a Python comment
|
||||||
|
(`pass # dynamic_text binding_source "{{ el.binding_source }}" draws
|
||||||
|
nothing`) with no escaping. A source string carrying a newline closed the
|
||||||
|
comment, and text on the following line(s), indented to match, became a
|
||||||
|
real statement in the generated plugin -- CWE-94, and live once
|
||||||
|
composer_bp was registered (confirmed: this exact payload produces a
|
||||||
|
manager.py containing a clean, ast.parse-valid `import os` against the
|
||||||
|
pre-fix template). The comment is now a fixed literal that never
|
||||||
|
interpolates the value at all.
|
||||||
|
"""
|
||||||
|
payload = "foo\n import os\n os.system('id') # "
|
||||||
|
element = {"type": "dynamic_text", "x": 0, "y": 0, "color": "#ffffff",
|
||||||
|
"binding": {"source": payload, "key": "temperature"}}
|
||||||
|
files = generate(element)
|
||||||
|
src = files["manager.py"]
|
||||||
|
assert "import os" not in src
|
||||||
|
assert "os.system" not in src
|
||||||
|
assert "non-config dynamic_text binding draws nothing" in src
|
||||||
|
ast.parse(src) # belt and braces: generate() already enforces this
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Structural checks on the composer's JavaScript.
|
||||||
|
|
||||||
|
There is no JS test runner in this repo, but three defects here are structural
|
||||||
|
enough to assert on the parse tree, and each was a real bug:
|
||||||
|
|
||||||
|
- Stroke widths inside _drawElement were left in canvas pixels while the
|
||||||
|
geometry around them scaled by SCALE, so at SCALE>1 every outline rendered
|
||||||
|
thinner than one LED pixel and the preview stopped matching the panel.
|
||||||
|
- The `line` branch drew raw el.x0/el.y0/el.x1/el.y1, ignoring the anchor that
|
||||||
|
every other element type honours, so setting xAnchor moved everything except
|
||||||
|
lines -- and getBoundingBox had the same omission, leaving the hit box behind.
|
||||||
|
- Four methods mutated exactly what _snapshot() serialises (metadata,
|
||||||
|
currentPreset) without calling it. _snapshot is the only caller of
|
||||||
|
_debouncedAutosave, so those changes were lost on reload and could not be
|
||||||
|
undone.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
tree_sitter = pytest.importorskip("tree_sitter")
|
||||||
|
tree_sitter_javascript = pytest.importorskip("tree_sitter_javascript")
|
||||||
|
|
||||||
|
JS_DIR = Path(__file__).resolve().parent.parent / "web_interface/static/v3/js/composer"
|
||||||
|
CANVAS = JS_DIR / "composer-canvas.js"
|
||||||
|
APP = JS_DIR / "composer-app.js"
|
||||||
|
|
||||||
|
|
||||||
|
def _function_source(path: Path, name: str) -> str:
|
||||||
|
"""Return the source of a top-level function declaration by name."""
|
||||||
|
src = path.read_bytes()
|
||||||
|
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
||||||
|
tree = tree_sitter.Parser(lang).parse(src)
|
||||||
|
found = []
|
||||||
|
|
||||||
|
def walk(node):
|
||||||
|
if node.type == "function_declaration":
|
||||||
|
ident = node.child_by_field_name("name")
|
||||||
|
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
|
||||||
|
found.append(src[node.start_byte:node.end_byte].decode())
|
||||||
|
for c in node.children:
|
||||||
|
walk(c)
|
||||||
|
|
||||||
|
walk(tree.root_node)
|
||||||
|
assert found, f"{name} not found in {path.name}"
|
||||||
|
return found[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _method_source(path: Path, name: str) -> str:
|
||||||
|
"""Return the source of a top-level object method by name."""
|
||||||
|
src = path.read_bytes()
|
||||||
|
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
||||||
|
tree = tree_sitter.Parser(lang).parse(src)
|
||||||
|
found = []
|
||||||
|
|
||||||
|
def walk(node):
|
||||||
|
if node.type == "method_definition":
|
||||||
|
ident = node.child_by_field_name("name")
|
||||||
|
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
|
||||||
|
found.append(src[node.start_byte:node.end_byte].decode())
|
||||||
|
for c in node.children:
|
||||||
|
walk(c)
|
||||||
|
|
||||||
|
walk(tree.root_node)
|
||||||
|
assert found, f"{name} not found in {path.name}"
|
||||||
|
return found[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_files_parse():
|
||||||
|
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
||||||
|
parser = tree_sitter.Parser(lang)
|
||||||
|
for path in (CANVAS, APP):
|
||||||
|
tree = parser.parse(path.read_bytes())
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def walk(node):
|
||||||
|
if node.type == "ERROR" or node.is_missing:
|
||||||
|
errors.append(node.start_point[0] + 1)
|
||||||
|
for c in node.children:
|
||||||
|
walk(c)
|
||||||
|
|
||||||
|
walk(tree.root_node)
|
||||||
|
assert not errors, f"{path.name} has parse errors at lines {errors}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_element_strokes_scale_with_scale():
|
||||||
|
"""No bare `ctx.lineWidth = 1` inside _drawElement.
|
||||||
|
|
||||||
|
Selection handles and the grid are drawn in canvas pixels deliberately and
|
||||||
|
live in other functions, so this is scoped to the element drawing routine.
|
||||||
|
"""
|
||||||
|
body = _function_source(CANVAS, "_drawElement")
|
||||||
|
offenders = re.findall(r"ctx\.lineWidth\s*=\s*1\s*;", body)
|
||||||
|
assert not offenders, f"{len(offenders)} unscaled stroke width(s) in _drawElement"
|
||||||
|
|
||||||
|
|
||||||
|
def test_line_branch_applies_the_anchor_offset():
|
||||||
|
"""Scoped to _drawElement.
|
||||||
|
|
||||||
|
getBoundingBox has its own `case 'line': {` and appears first in the file,
|
||||||
|
so searching the whole text found *that* branch -- this assertion passed
|
||||||
|
with the draw branch's anchor offset removed. Verified: stripping it and
|
||||||
|
re-running gave 11/11 green.
|
||||||
|
"""
|
||||||
|
body = _function_source(CANVAS, "_drawElement")
|
||||||
|
line_branch = body[body.index("case 'line': {"):]
|
||||||
|
line_branch = line_branch[:line_branch.index("case 'divider'")]
|
||||||
|
assert "ax - el.x0" in line_branch and "ay - el.y0" in line_branch, \
|
||||||
|
"line drawing ignores xAnchor/yAnchor"
|
||||||
|
assert "moveTo(el.x0 * s" not in line_branch, \
|
||||||
|
"line still drawn from unanchored endpoints"
|
||||||
|
|
||||||
|
|
||||||
|
def test_line_bounding_box_applies_the_anchor_offset():
|
||||||
|
"""The companion to the above: scoped to getBoundingBox specifically, so
|
||||||
|
the two tests cannot both be satisfied by the same branch."""
|
||||||
|
body = _function_source(CANVAS, "getBoundingBox")
|
||||||
|
box = body[body.index("case 'line'"):]
|
||||||
|
box = box[:box.index("case 'divider'")]
|
||||||
|
assert "ax - el.x0" in box, "line bounding box ignores the anchor"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", [
|
||||||
|
"onBgColorChange", # mutates metadata.bgColor
|
||||||
|
"setCustomSize", # mutates currentPreset / MATRIX_W / MATRIX_H
|
||||||
|
"changePreset", # mutates currentPreset / MATRIX_W / MATRIX_H
|
||||||
|
"applyPresetLabel", # same, for sizes not in DISPLAY_PRESETS
|
||||||
|
"onColorChange", # the one that was already fixed — keeps it fixed
|
||||||
|
])
|
||||||
|
def test_state_mutations_take_a_snapshot(method):
|
||||||
|
body = _method_source(APP, method)
|
||||||
|
assert "_snapshot()" in body, \
|
||||||
|
f"{method} changes snapshotted state without calling _snapshot()"
|
||||||
|
assert "isDirty = true" in body, f"{method} does not mark the design dirty"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", ["changePreset", "applyPresetLabel"])
|
||||||
|
def test_restore_path_stays_snapshot_free(method):
|
||||||
|
"""_applyState and loadTemplate call these with {silent: true} while
|
||||||
|
restoring; snapshotting there would push restore steps onto the undo stack
|
||||||
|
and re-autosave the state just loaded."""
|
||||||
|
body = _method_source(APP, method)
|
||||||
|
assert "opts.silent" in body, f"{method} lost its silent guard"
|
||||||
|
snap = body.index("_snapshot()")
|
||||||
|
guard = body.index("!opts.silent")
|
||||||
|
assert guard < snap, f"{method} snapshots outside the !opts.silent guard"
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATE_HTML = (Path(__file__).resolve().parent.parent
|
||||||
|
/ "web_interface/templates/v3/composer.html")
|
||||||
|
|
||||||
|
#: The six toolbar buttons and the wrapper each must call.
|
||||||
|
ALIGN_BUTTONS = ["alignLeft", "alignHCenter", "alignRight",
|
||||||
|
"alignTop", "alignVCenter", "alignBottom"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_alignment_buttons_use_the_anchor_clearing_path():
|
||||||
|
"""Two alignment implementations existed and the toolbar used the wrong one.
|
||||||
|
|
||||||
|
The legacy alignElement(dir) set el.x/el.y but left xAnchor/yAnchor in
|
||||||
|
place. resolveAnchor turns anchor='right' into `dim - val`, so "align left"
|
||||||
|
(el.x = 0) resolved to x = MATRIX_W -- the element jumped to the far right
|
||||||
|
edge instead. _alignElement clears the anchor first, so the stored value is
|
||||||
|
absolute, and it also updates el.x0/el.y0 so lines actually move.
|
||||||
|
"""
|
||||||
|
html = TEMPLATE_HTML.read_text()
|
||||||
|
for wrapper in ALIGN_BUTTONS:
|
||||||
|
assert f"{wrapper}()" in html, f"toolbar does not call {wrapper}()"
|
||||||
|
assert not re.search(r"[^_]alignElement\(", html), \
|
||||||
|
"toolbar still calls the legacy alignElement()"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_legacy_alignelement_is_gone():
|
||||||
|
"""Leaving it in place invites the toolbar drifting back to it."""
|
||||||
|
src = APP.read_text()
|
||||||
|
assert not re.search(r"^\s{4}alignElement\(dir\)", src, re.M), \
|
||||||
|
"legacy alignElement(dir) still defined"
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_clears_the_anchor_and_moves_line_endpoints():
|
||||||
|
body = _method_source(APP, "_alignElement")
|
||||||
|
assert "xAnchor = null" in body and "yAnchor = null" in body, \
|
||||||
|
"_alignElement no longer clears the anchor, so aligning an anchored " \
|
||||||
|
"element resolves to the wrong edge"
|
||||||
|
assert "el.x0" in body and "el.y0" in body, \
|
||||||
|
"_alignElement no longer moves line endpoints"
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_translates_both_line_endpoints_not_just_the_start():
|
||||||
|
"""Setting only x0 (or y0) left x1/y1 behind, so aligning a line changed
|
||||||
|
its shape instead of moving it -- e.g. a line from x0=20 to x1=50 aligned
|
||||||
|
right became x0=98, x1=50, stretching rather than translating it. Both
|
||||||
|
endpoints must move by the same delta."""
|
||||||
|
body = _method_source(APP, "_alignElement")
|
||||||
|
x_branch = body[body.index("if (axis === 'x')"):body.index("} else {")]
|
||||||
|
y_branch = body[body.index("} else {"):]
|
||||||
|
assert "el.x1" in x_branch, \
|
||||||
|
"_alignElement moves x0 but not x1 -- a line's shape changes, not its position"
|
||||||
|
assert "el.y1" in y_branch, \
|
||||||
|
"_alignElement moves y0 but not y1 -- a line's shape changes, not its position"
|
||||||
|
|
||||||
|
|
||||||
|
def test_divider_stroke_is_centered_in_led_pixels():
|
||||||
|
"""A 0.5 canvas-pixel offset (correct only at SCALE=1) was applied after
|
||||||
|
scaling instead of before it, so at SCALE>1 the stroke bled into the
|
||||||
|
preceding LED row/column instead of straddling its own."""
|
||||||
|
body = _function_source(CANVAS, "_drawElement")
|
||||||
|
divider_branch = body[body.index("case 'divider'"):]
|
||||||
|
divider_branch = divider_branch[:divider_branch.index("case 'pips'")]
|
||||||
|
assert "ay * s + 0.5" not in divider_branch, \
|
||||||
|
"divider still offsets by 0.5 canvas pixels after scaling"
|
||||||
|
assert "ax * s + 0.5" not in divider_branch, \
|
||||||
|
"divider still offsets by 0.5 canvas pixels after scaling"
|
||||||
|
assert "(ay + 0.5) * s" in divider_branch and "(ax + 0.5) * s" in divider_branch, \
|
||||||
|
"divider stroke is not centered within its LED pixel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gauge_radii_are_clamped_to_zero():
|
||||||
|
"""An imported design can carry a small gauge with a wide lineWidth --
|
||||||
|
width=1, height=1, lineWidth=3 sends a negative radius into
|
||||||
|
ctx.ellipse(), which throws IndexSizeError and aborts render() for every
|
||||||
|
element still to be drawn, not just the gauge."""
|
||||||
|
body = _function_source(CANVAS, "_drawElement")
|
||||||
|
gauge_branch = body[body.index("case 'gauge'"):]
|
||||||
|
assert re.search(r"Math\.max\(0,\s*rx\s*-\s*lwPx\s*/\s*2\)", gauge_branch), \
|
||||||
|
"gauge x-radius is not clamped to zero"
|
||||||
|
assert re.search(r"Math\.max\(0,\s*ry\s*-\s*lwPx\s*/\s*2\)", gauge_branch), \
|
||||||
|
"gauge y-radius is not clamped to zero"
|
||||||
|
assert "rx - lwPx / 2" not in re.sub(r"Math\.max\(0,\s*rx\s*-\s*lwPx\s*/\s*2\)", "", gauge_branch), \
|
||||||
|
"an unclamped gauge radius is still passed to ctx.ellipse()"
|
||||||
|
|
||||||
|
|
||||||
|
#: ELEMENT_DEFAULTS in composer-canvas.js gives exactly these types a
|
||||||
|
#: `binding` object; verified against that file rather than assumed.
|
||||||
|
BOUND_TYPES = ["dynamic_text", "progress_bar", "countdown", "pips", "sparkline", "gauge"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_types_constant_lists_every_bound_element_type():
|
||||||
|
"""ELEMENT_DEFAULTS in composer-canvas.js gives dynamic_text, progress_bar,
|
||||||
|
countdown, pips, sparkline and gauge a `binding` object. If a type is added
|
||||||
|
to (or removed from) that list without updating APP's BOUND_TYPES, the
|
||||||
|
checks below drift out of sync silently -- this pins the two together."""
|
||||||
|
src = APP.read_text()
|
||||||
|
match = re.search(r"const BOUND_TYPES = \[([^\]]*)\];", src)
|
||||||
|
assert match, "BOUND_TYPES constant not found in composer-app.js"
|
||||||
|
declared = {t.strip().strip("'\"") for t in match.group(1).split(",") if t.strip()}
|
||||||
|
assert declared == set(BOUND_TYPES), \
|
||||||
|
f"BOUND_TYPES {declared} does not match the bound element types {set(BOUND_TYPES)}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", ["_isBound", "removeConfigVar", "_validateBeforeExport"])
|
||||||
|
def test_binding_checks_cover_every_bound_element_type(method):
|
||||||
|
"""Only dynamic_text and progress_bar were checked, so a countdown, pips,
|
||||||
|
sparkline or gauge element with an empty binding passed export validation
|
||||||
|
silently, and deleting a config var still used by one of them gave no
|
||||||
|
warning -- the plugin would read a now-missing key at runtime with no
|
||||||
|
indication why."""
|
||||||
|
body = _method_source(APP, method)
|
||||||
|
assert "BOUND_TYPES.includes(e.type)" in body, \
|
||||||
|
f"{method} does not check every bound element type via BOUND_TYPES"
|
||||||
|
assert "e.type === 'dynamic_text'" not in body, \
|
||||||
|
f"{method} still hardcodes only dynamic_text instead of BOUND_TYPES"
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""A composer plugin id must never resolve outside the plugins directory.
|
||||||
|
|
||||||
|
CodeQL reported sixteen high-severity py/path-injection alerts against
|
||||||
|
web_interface/blueprints/composer.py: a request-supplied plugin_id reaching
|
||||||
|
Path(plugins_dir) / plugin_id, which is then created, written to, deleted
|
||||||
|
(shutil.rmtree) and read back.
|
||||||
|
|
||||||
|
The id was already validated by an anchored regex, so every traversal payload
|
||||||
|
was in fact rejected. What was missing was the guarantee living *with* the path
|
||||||
|
building rather than in a regex several hundred lines away -- loosen that regex
|
||||||
|
later and the traversal opens silently, with nothing at the filesystem boundary
|
||||||
|
to catch it. _plugin_dir() closes that, and is the form static analysis can see.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from web_interface.blueprints import composer as C # noqa: E402
|
||||||
|
|
||||||
|
#: Anything that has ever been used to climb out of a directory.
|
||||||
|
TRAVERSAL = [
|
||||||
|
"../../etc/passwd", "..", ".", "a/../../etc", "good/../../..",
|
||||||
|
"/etc/passwd", "//etc/passwd", "a\\..\\..", "a%2f..%2f..",
|
||||||
|
"....//....//etc", "a/./../../etc", "~", "~root",
|
||||||
|
"plugin/../../../../../../etc/shadow",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Rejected for shape, not traversal -- but rejected all the same.
|
||||||
|
MALFORMED = ["", "A-upper", "1-leading-digit", "-leading-dash", "has_underscore",
|
||||||
|
"has space", "has.dot", "a" * 64, "plugin\n", "plugin\n../../etc",
|
||||||
|
"\n", "plug\x00in"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plugins_dir(tmp_path, monkeypatch):
|
||||||
|
base = tmp_path / "plugin-repos"
|
||||||
|
base.mkdir()
|
||||||
|
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", TRAVERSAL)
|
||||||
|
def test_traversal_payloads_are_refused(plugins_dir, payload):
|
||||||
|
assert C._plugin_dir(payload) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", MALFORMED)
|
||||||
|
def test_malformed_ids_are_refused(plugins_dir, payload):
|
||||||
|
assert C._plugin_dir(payload) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", ["a", "my-plugin", "x9", "a" * 63])
|
||||||
|
def test_valid_ids_resolve_inside_the_base(plugins_dir, payload):
|
||||||
|
resolved = C._plugin_dir(payload)
|
||||||
|
assert resolved is not None, f"{payload!r} was rejected but is valid"
|
||||||
|
assert resolved.parent == plugins_dir.resolve(), (
|
||||||
|
f"{payload!r} resolved to {resolved}, outside {plugins_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_payload_can_escape_even_if_the_regex_is_loosened(plugins_dir, monkeypatch):
|
||||||
|
"""The containment check must stand on its own.
|
||||||
|
|
||||||
|
This is the whole point of resolving at the filesystem boundary: if the id
|
||||||
|
pattern is ever relaxed, traversal must still be impossible. Replace the
|
||||||
|
regex with one that permits slashes and dots, then re-run the payloads.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
||||||
|
import os
|
||||||
|
escaped = []
|
||||||
|
base = os.path.realpath(str(plugins_dir))
|
||||||
|
for payload in TRAVERSAL:
|
||||||
|
resolved = C._plugin_dir(payload)
|
||||||
|
if resolved is None:
|
||||||
|
continue
|
||||||
|
real = os.path.realpath(str(resolved))
|
||||||
|
if real != base and os.path.commonpath([base, real]) != base:
|
||||||
|
escaped.append((payload, real))
|
||||||
|
assert not escaped, f"these escaped the base with a loosened regex: {escaped}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_sibling_directory_with_a_shared_prefix_is_not_inside(tmp_path, monkeypatch):
|
||||||
|
"""commonpath, not startswith.
|
||||||
|
|
||||||
|
"/x/plugins-evil" starts with "/x/plugins" but is a different directory, so
|
||||||
|
a prefix test would accept it.
|
||||||
|
"""
|
||||||
|
base = tmp_path / "plugins"
|
||||||
|
base.mkdir()
|
||||||
|
(tmp_path / "plugins-evil").mkdir()
|
||||||
|
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
|
||||||
|
import re
|
||||||
|
# Neutralise the two layers in front so this exercises the containment
|
||||||
|
# check itself; otherwise secure_filename rejects the payload first and a
|
||||||
|
# startswith regression would go unnoticed here.
|
||||||
|
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
||||||
|
monkeypatch.setattr(C, "secure_filename", lambda v: v)
|
||||||
|
assert C._plugin_dir("../plugins-evil") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_containment_still_holds_if_the_sanitiser_is_defeated(plugins_dir, monkeypatch):
|
||||||
|
"""Each layer is tested on its own, not just the stack.
|
||||||
|
|
||||||
|
secure_filename's equality guard rejects every traversal payload before the
|
||||||
|
containment check sees it, so removing containment does not fail the other
|
||||||
|
tests -- which would make it look load-bearing when it is not. Neutralise
|
||||||
|
the regex *and* the sanitiser, and the realpath/commonpath check must still
|
||||||
|
refuse everything on its own.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
||||||
|
monkeypatch.setattr(C, "secure_filename", lambda v: v)
|
||||||
|
import os
|
||||||
|
base = os.path.realpath(str(plugins_dir))
|
||||||
|
escaped = []
|
||||||
|
for payload in TRAVERSAL:
|
||||||
|
resolved = C._plugin_dir(payload)
|
||||||
|
if resolved is None:
|
||||||
|
continue
|
||||||
|
real = os.path.realpath(str(resolved))
|
||||||
|
# Inside the base is fine -- "...." and "~" are ordinary directory
|
||||||
|
# names on Linux, so they are not escapes. What must never happen is
|
||||||
|
# landing outside the base, or on the base itself: install() rmtrees
|
||||||
|
# its target, so the plugins root resolving to a "plugin" would wipe
|
||||||
|
# every installed plugin.
|
||||||
|
if real == base or os.path.commonpath([base, real]) != base:
|
||||||
|
escaped.append((payload, real))
|
||||||
|
assert not escaped, f"containment alone let these through: {escaped}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_secure_filename_never_rewrites_an_accepted_id(plugins_dir):
|
||||||
|
"""The sanitiser must be a no-op on everything the regex accepts.
|
||||||
|
|
||||||
|
If secure_filename ever altered an accepted id, _plugin_dir would resolve
|
||||||
|
to a *different* plugin's directory than the caller asked for -- a silent
|
||||||
|
redirect, which is worse than a refusal. The guard turns that into a
|
||||||
|
refusal; this proves the guard never has to fire in practice.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
random.seed(1)
|
||||||
|
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-"
|
||||||
|
altered = []
|
||||||
|
for _ in range(2000):
|
||||||
|
n = random.randint(1, 63)
|
||||||
|
cand = random.choice("abcdefghijklmnopqrstuvwxyz") + "".join(
|
||||||
|
random.choice(alphabet) for _ in range(n - 1))
|
||||||
|
if C._PLUGIN_ID_RE.match(cand) and secure_filename(cand) != cand:
|
||||||
|
altered.append((cand, secure_filename(cand)))
|
||||||
|
assert not altered, f"secure_filename rewrote accepted ids: {altered[:5]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_trailing_newline_is_not_a_valid_id():
|
||||||
|
r"""Python's `$` also matches before a trailing newline, so the original
|
||||||
|
`^...$` accepted "myplugin\n" and would have created a directory whose
|
||||||
|
name ends in one. \Z does not."""
|
||||||
|
assert C._PLUGIN_ID_RE.match("myplugin") is not None
|
||||||
|
assert C._PLUGIN_ID_RE.match("myplugin\n") is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- font serving -----------------------------------------------------------
|
||||||
|
|
||||||
|
FONT_TRAVERSAL = [
|
||||||
|
"../../../etc/passwd", "../config/config.json", "..%2f..%2fetc%2fpasswd",
|
||||||
|
"PressStart2P-Regular.ttf/../../../etc/passwd", "/etc/passwd", "",
|
||||||
|
"PressStart2P-Regular.TTF", # case differs -> not the allowlisted name
|
||||||
|
"PressStart2P-Regular.ttf ", # trailing space
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_font_refuses_a_file_that_exists_but_is_not_allowlisted(monkeypatch, tmp_path):
|
||||||
|
"""The allowlist must be what refuses it, not a missing file.
|
||||||
|
|
||||||
|
Asserting 404 on traversal payloads proves nothing here: Flask's router
|
||||||
|
will not match a path segment containing '/', and everything else 404s
|
||||||
|
simply because no such file exists. Put a real, readable file next to the
|
||||||
|
fonts and confirm it is still refused -- that is the allowlist working.
|
||||||
|
"""
|
||||||
|
fonts = tmp_path / "assets" / "fonts"
|
||||||
|
fonts.mkdir(parents=True)
|
||||||
|
(fonts / "id_rsa.ttf").write_bytes(b"PRIVATE KEY")
|
||||||
|
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
|
||||||
|
app = __import__("flask").Flask(__name__)
|
||||||
|
app.register_blueprint(C.composer_bp)
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.get("/api/fonts/id_rsa.ttf")
|
||||||
|
assert resp.status_code == 404, (
|
||||||
|
"a readable non-allowlisted file was served; the allowlist is not gating")
|
||||||
|
assert b"PRIVATE KEY" not in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", FONT_TRAVERSAL)
|
||||||
|
def test_serve_font_refuses_anything_not_allowlisted(payload, monkeypatch, tmp_path):
|
||||||
|
"""The name reaching the filesystem must come from the allowlist constant.
|
||||||
|
|
||||||
|
_ALLOWED_FONTS gates this endpoint, so nothing here was ever exploitable.
|
||||||
|
Building the path from the matched constant rather than the request value
|
||||||
|
is what makes that provable -- and it is why CodeQL reported two
|
||||||
|
high-severity py/path-injection alerts on an endpoint that was already
|
||||||
|
safe.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
|
||||||
|
app = C.composer_bp.name and __import__("flask").Flask(__name__)
|
||||||
|
app.register_blueprint(C.composer_bp)
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.get(f"/api/fonts/{payload}")
|
||||||
|
assert resp.status_code in (404, 405, 308), (
|
||||||
|
f"{payload!r} was not refused (status {resp.status_code})")
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_font_still_serves_each_allowlisted_font(monkeypatch, tmp_path):
|
||||||
|
fonts = tmp_path / "assets" / "fonts"
|
||||||
|
fonts.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
|
||||||
|
app = __import__("flask").Flask(__name__)
|
||||||
|
app.register_blueprint(C.composer_bp)
|
||||||
|
for name in C._ALLOWED_FONTS:
|
||||||
|
(fonts / name).write_bytes(b"\x00\x01ttf")
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.get(f"/api/fonts/{name}")
|
||||||
|
assert resp.status_code == 200, f"{name} should be served, got {resp.status_code}"
|
||||||
@@ -103,43 +103,6 @@ def test_non_dict_input_passes_through():
|
|||||||
assert _redact_credentials(None) is None
|
assert _redact_credentials(None) is None
|
||||||
|
|
||||||
|
|
||||||
def test_a_list_of_bare_credentials_is_redacted():
|
|
||||||
"""A credential-named key whose value is a list of scalars, not a list
|
|
||||||
of named-field dicts, used to pass through untouched: the dict branch
|
|
||||||
recursed into `v` on its own value only, so a bare string list item had
|
|
||||||
no field name to test and fell through the base case unredacted.
|
|
||||||
"""
|
|
||||||
config = {"tokens": ["abc123", "def456"], "timezone": "America/New_York"}
|
|
||||||
out = _redact_credentials(config)
|
|
||||||
assert out["tokens"] == ["", ""]
|
|
||||||
assert out["timezone"] == "America/New_York"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_list_nested_inside_a_credential_named_container_is_also_blanked():
|
|
||||||
"""The list fix applies at any depth under a credential-named key, not
|
|
||||||
only when the list is the key's direct value.
|
|
||||||
"""
|
|
||||||
config = {"auth": {"backup_tokens": ["a", "b"], "note": "keep"}}
|
|
||||||
out = _redact_credentials(config)
|
|
||||||
assert out["auth"]["backup_tokens"] == ["", ""]
|
|
||||||
assert out["auth"]["note"] == "keep"
|
|
||||||
|
|
||||||
|
|
||||||
def test_credential_shaped_container_with_named_fields_still_only_blanks_those():
|
|
||||||
"""Unchanged behaviour for the case the container test above already
|
|
||||||
covers: a dict whose sub-keys are semantically named fields is walked
|
|
||||||
normally, not blanket-blanked, so an ordinary field next to a
|
|
||||||
credential-named one survives.
|
|
||||||
"""
|
|
||||||
config = {"secrets": {"api_key": "k", "note": "keep", "list": ["a", "b"]}}
|
|
||||||
out = _redact_credentials(config)
|
|
||||||
assert out["secrets"]["api_key"] == ""
|
|
||||||
assert out["secrets"]["note"] == "keep"
|
|
||||||
# "list" isn't itself credential-named, so it is walked, not blanked --
|
|
||||||
# but it holds no credential-named field either, so it survives whole.
|
|
||||||
assert out["secrets"]["list"] == ["a", "b"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_endpoint_itself_redacts():
|
def test_the_endpoint_itself_redacts():
|
||||||
"""Through the view function, not the helper.
|
"""Through the view function, not the helper.
|
||||||
|
|
||||||
@@ -167,8 +130,7 @@ def test_the_endpoint_itself_redacts():
|
|||||||
app = flask.Flask(__name__)
|
app = flask.Flask(__name__)
|
||||||
try:
|
try:
|
||||||
with app.test_request_context("/config/main"):
|
with app.test_request_context("/config/main"):
|
||||||
# get_main_config is a route; it lives in the config module now.
|
response = mod.get_main_config()
|
||||||
response = mod.config.get_main_config()
|
|
||||||
payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data)
|
payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data)
|
||||||
finally:
|
finally:
|
||||||
mod.api_v3.config_manager = previous
|
mod.api_v3.config_manager = previous
|
||||||
|
|||||||
@@ -116,77 +116,6 @@ class TestFallbackDivergence:
|
|||||||
plugin_dir / "config_schema.json"
|
plugin_dir / "config_schema.json"
|
||||||
|
|
||||||
|
|
||||||
class TestRegistryIdVersusDirectoryName:
|
|
||||||
"""install_plugin() renames a plugin's directory to the MANIFEST id when it
|
|
||||||
differs from the REGISTRY id, so `stocks` in the registry lands in
|
|
||||||
`ledmatrix-stocks/`. Every path lookup is by directory name, so
|
|
||||||
update_plugin("stocks") used to find nothing and report the plugin as not
|
|
||||||
installed -- silently. Four installed plugins hit this in practice
|
|
||||||
(leaderboard, music, stocks, weather): the user clicks update, nothing
|
|
||||||
happens, no error, and they stay on a stale version indefinitely.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_resolves_a_plugin_whose_directory_name_differs_from_its_id(self, tmp_path):
|
|
||||||
configured = tmp_path / "plugin-repos"
|
|
||||||
configured.mkdir()
|
|
||||||
plugin_dir = _write_plugin(configured, "stocks", dir_name="ledmatrix-stocks")
|
|
||||||
|
|
||||||
store = PluginStoreManager(plugins_dir=str(configured))
|
|
||||||
assert store._find_plugin_path("stocks") == plugin_dir
|
|
||||||
|
|
||||||
def test_direct_directory_hit_still_wins(self, tmp_path):
|
|
||||||
"""The manifest scan is a last resort. A directory named for the id
|
|
||||||
must still be preferred, so the two documented lookups above keep
|
|
||||||
their exact meaning."""
|
|
||||||
configured = tmp_path / "plugin-repos"
|
|
||||||
configured.mkdir()
|
|
||||||
direct = _write_plugin(configured, "demo", dir_name="demo")
|
|
||||||
# A second directory whose manifest claims the same id.
|
|
||||||
_write_plugin(configured, "demo", dir_name="zz-other-demo")
|
|
||||||
|
|
||||||
store = PluginStoreManager(plugins_dir=str(configured))
|
|
||||||
assert store._find_plugin_path("demo") == direct
|
|
||||||
|
|
||||||
def test_unknown_id_is_still_not_found(self, tmp_path):
|
|
||||||
configured = tmp_path / "plugin-repos"
|
|
||||||
configured.mkdir()
|
|
||||||
_write_plugin(configured, "stocks", dir_name="ledmatrix-stocks")
|
|
||||||
|
|
||||||
store = PluginStoreManager(plugins_dir=str(configured))
|
|
||||||
assert store._find_plugin_path("no-such-plugin") is None
|
|
||||||
|
|
||||||
def test_half_finished_installs_are_not_resurrected(self, tmp_path):
|
|
||||||
"""A directory renamed aside during install/rollback carries a valid
|
|
||||||
manifest. Matching one would report a ghost plugin as installed."""
|
|
||||||
configured = tmp_path / "plugin-repos"
|
|
||||||
configured.mkdir()
|
|
||||||
_write_plugin(configured, "ghost",
|
|
||||||
dir_name="ghost.standalone-backup-1234")
|
|
||||||
|
|
||||||
store = PluginStoreManager(plugins_dir=str(configured))
|
|
||||||
assert store._find_plugin_path("ghost") is None
|
|
||||||
|
|
||||||
def test_unreadable_manifest_does_not_break_the_scan(self, tmp_path):
|
|
||||||
configured = tmp_path / "plugin-repos"
|
|
||||||
configured.mkdir()
|
|
||||||
broken = configured / "broken-plugin"
|
|
||||||
broken.mkdir()
|
|
||||||
(broken / "manifest.json").write_text("{ not json")
|
|
||||||
wanted = _write_plugin(configured, "stocks", dir_name="ledmatrix-stocks")
|
|
||||||
|
|
||||||
store = PluginStoreManager(plugins_dir=str(configured))
|
|
||||||
assert store._find_plugin_path("stocks") == wanted
|
|
||||||
|
|
||||||
def test_sibling_plugins_dir_is_searched_too(self, tmp_path):
|
|
||||||
configured = tmp_path / "plugin-repos"
|
|
||||||
configured.mkdir()
|
|
||||||
legacy = _write_plugin(tmp_path / "plugins", "weather",
|
|
||||||
dir_name="ledmatrix-weather")
|
|
||||||
|
|
||||||
store = PluginStoreManager(plugins_dir=str(configured))
|
|
||||||
assert store._find_plugin_path("weather") == legacy
|
|
||||||
|
|
||||||
|
|
||||||
class TestStandaloneBackupContract:
|
class TestStandaloneBackupContract:
|
||||||
def test_discovery_skips_backup_dirs(self, tmp_path):
|
def test_discovery_skips_backup_dirs(self, tmp_path):
|
||||||
plugins_dir = tmp_path / "plugins"
|
plugins_dir = tmp_path / "plugins"
|
||||||
|
|||||||
@@ -47,17 +47,12 @@ class _SwapSpy:
|
|||||||
def __init__(self, matrix):
|
def __init__(self, matrix):
|
||||||
self.matrix = matrix
|
self.matrix = matrix
|
||||||
self.count = 0
|
self.count = 0
|
||||||
self.last_frame_hold = None
|
|
||||||
self._orig = matrix.SwapOnVSync
|
self._orig = matrix.SwapOnVSync
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
def counting(canvas, *args):
|
def counting(canvas):
|
||||||
# *args carries framerate_fraction, which display_manager passes so
|
|
||||||
# a frame can be held for several refreshes. Signature must match
|
|
||||||
# the real binding or the spy hides a TypeError as a failed push.
|
|
||||||
self.count += 1
|
self.count += 1
|
||||||
self.last_frame_hold = args[0] if args else 1
|
return self._orig(canvas)
|
||||||
return self._orig(canvas, *args)
|
|
||||||
self.matrix.SwapOnVSync = counting
|
self.matrix.SwapOnVSync = counting
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -114,11 +109,6 @@ class TestDirtyTracking:
|
|||||||
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
|
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
|
||||||
dm.update_display() # push + snapshot write (first frame)
|
dm.update_display() # push + snapshot write (first frame)
|
||||||
assert os.path.exists(dm._snapshot_path)
|
assert os.path.exists(dm._snapshot_path)
|
||||||
# Backdate the file so the "was it bumped?" check below cannot be
|
|
||||||
# defeated by filesystem mtime granularity -- on Windows two writes in
|
|
||||||
# the same tick get identical timestamps, which made this test fail
|
|
||||||
# roughly two runs in three regardless of the code under test.
|
|
||||||
os.utime(dm._snapshot_path, (time.time() - 60, time.time() - 60))
|
|
||||||
first_mtime = os.path.getmtime(dm._snapshot_path)
|
first_mtime = os.path.getmtime(dm._snapshot_path)
|
||||||
|
|
||||||
# Age the write/touch bookkeeping past TOUCH_INTERVAL so the next
|
# Age the write/touch bookkeeping past TOUCH_INTERVAL so the next
|
||||||
@@ -135,62 +125,6 @@ class TestDirtyTracking:
|
|||||||
assert os.path.getmtime(dm._snapshot_path) > first_mtime
|
assert os.path.getmtime(dm._snapshot_path) > first_mtime
|
||||||
|
|
||||||
|
|
||||||
class TestScrollLock:
|
|
||||||
"""Dirty tracking must not skip the panel push while a scroll is running.
|
|
||||||
|
|
||||||
SwapOnVSync is what paces the render loop, so skipping it also skips the
|
|
||||||
wait for the panel. A duplicate frame therefore returns early -- ~8ms
|
|
||||||
instead of ~10ms on a 100Hz panel -- which advances the strip only 0.8px
|
|
||||||
instead of 1.0px, which makes the NEXT frame more likely to be a duplicate
|
|
||||||
too. That is self-sustaining: measured at ~20% duplicate frames mid-scroll
|
|
||||||
on the odds ticker against essentially zero on a lighter plugin with
|
|
||||||
identical scroll settings. Pushing an identical frame costs one canvas
|
|
||||||
copy; falling out of vsync lock costs smooth motion.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_identical_frames_still_push_while_scrolling(self, dm):
|
|
||||||
dm.draw.rectangle([0, 0, 12, 12], fill=(0, 0, 255))
|
|
||||||
dm.update_display()
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
try:
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.count == 3, "scrolling must stay locked to the panel"
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
def test_identical_frames_are_skipped_when_not_scrolling(self, dm):
|
|
||||||
"""The optimisation still applies to static content."""
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
dm.draw.rectangle([0, 0, 14, 14], fill=(255, 0, 255))
|
|
||||||
dm.update_display()
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.count == 0
|
|
||||||
|
|
||||||
def test_stale_scrolling_state_stops_forcing_pushes(self, dm):
|
|
||||||
"""A plugin that stops scrolling without saying so must not pin the
|
|
||||||
panel into always-push forever. is_currently_scrolling() expires on
|
|
||||||
its own inactivity threshold, and the skip has to come back with it."""
|
|
||||||
dm.draw.rectangle([0, 0, 16, 16], fill=(0, 255, 255))
|
|
||||||
dm.update_display()
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
try:
|
|
||||||
# Backdate the activity marker past the inactivity threshold.
|
|
||||||
dm._scrolling_state['last_scroll_activity'] = (
|
|
||||||
time.time() - dm._scrolling_state['scroll_inactivity_threshold'] - 1.0)
|
|
||||||
assert dm.is_currently_scrolling() is False
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.count == 0
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
|
|
||||||
class TestKillSwitch:
|
class TestKillSwitch:
|
||||||
def test_dirty_tracking_can_be_disabled(self, dm):
|
def test_dirty_tracking_can_be_disabled(self, dm):
|
||||||
dm._dirty_tracking_enabled = False
|
dm._dirty_tracking_enabled = False
|
||||||
@@ -226,112 +160,3 @@ class TestKillSwitch:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(pytest.main([__file__, "-v"]))
|
sys.exit(pytest.main([__file__, "-v"]))
|
||||||
|
|
||||||
|
|
||||||
class TestFrameHold:
|
|
||||||
"""Holding a frame for N refreshes is how a scroll runs slower than one
|
|
||||||
pixel per refresh without fractional pixel positions."""
|
|
||||||
|
|
||||||
def test_hold_reaches_swap_on_vsync(self, dm):
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
dm.set_frame_hold(3)
|
|
||||||
try:
|
|
||||||
dm.draw.rectangle([0, 0, 9, 9], fill=(120, 0, 200))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.count == 1
|
|
||||||
assert spy.last_frame_hold == 3
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
def test_default_is_every_refresh(self, dm):
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
try:
|
|
||||||
dm.draw.rectangle([0, 0, 11, 11], fill=(0, 120, 200))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.last_frame_hold == 1
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
def test_hold_resets_when_scrolling_stops(self, dm):
|
|
||||||
"""One plugin's pacing must not leak into whatever is on screen next."""
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
dm.set_frame_hold(5)
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
try:
|
|
||||||
dm.draw.rectangle([0, 0, 13, 13], fill=(200, 120, 0))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.last_frame_hold == 1
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad,expected", [(0, 1), (-4, 1), (None, 1), ("x", 1)])
|
|
||||||
def test_unusable_holds_are_ignored_or_floored(self, dm, bad, expected):
|
|
||||||
dm.set_frame_hold(bad)
|
|
||||||
assert dm._frame_hold == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestFrameHoldLifetime:
|
|
||||||
"""The hold must last exactly as long as the scroll that asked for it.
|
|
||||||
|
|
||||||
Plugins share one display manager. A hold applied at plugin construction is
|
|
||||||
wiped the moment any *other* plugin finishes scrolling, so by the time the
|
|
||||||
first plugin renders it is back to one pixel per refresh -- the speed reads
|
|
||||||
correct in the log and is wrong on the panel.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_scrolling_state_carries_the_hold(self, dm):
|
|
||||||
dm.set_scrolling_state(True, frame_hold=4)
|
|
||||||
try:
|
|
||||||
dm.draw.rectangle([0, 0, 7, 7], fill=(10, 200, 10))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.last_frame_hold == 4
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
def test_a_scroll_that_times_out_does_not_strand_a_hold(self, dm):
|
|
||||||
"""The hold must go when the state does, however the scroll ended.
|
|
||||||
|
|
||||||
set_scrolling_state(False) is the polite exit. The other one is
|
|
||||||
is_currently_scrolling() deciding, after scroll_inactivity_threshold
|
|
||||||
of silence, that the scroll is over -- which is what happens when the
|
|
||||||
rotation moves on mid-scroll or a plugin is torn down. That path used
|
|
||||||
to clear the flag and keep the hold, so every later plugin, scrolling
|
|
||||||
or static, was presented at refresh/N by whoever scrolled last.
|
|
||||||
"""
|
|
||||||
dm.set_scrolling_state(True, frame_hold=5)
|
|
||||||
# Age the scroll past the inactivity threshold rather than sleeping.
|
|
||||||
dm._scrolling_state['last_scroll_activity'] -= (
|
|
||||||
dm._scrolling_state['scroll_inactivity_threshold'] + 1.0)
|
|
||||||
|
|
||||||
assert dm.is_currently_scrolling() is False
|
|
||||||
dm.draw.rectangle([0, 0, 5, 5], fill=(10, 10, 200))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.last_frame_hold == 1, (
|
|
||||||
"a timed-out scroll left its frame hold behind; the next plugin "
|
|
||||||
"is being presented at a fraction of the refresh rate")
|
|
||||||
|
|
||||||
def test_another_plugin_stopping_does_not_strand_a_hold(self, dm):
|
|
||||||
dm.set_scrolling_state(True, frame_hold=3)
|
|
||||||
dm.set_scrolling_state(False) # some other plugin finishes
|
|
||||||
dm.set_scrolling_state(True) # a plugin that wants no hold
|
|
||||||
try:
|
|
||||||
dm.draw.rectangle([0, 0, 6, 6], fill=(200, 10, 10))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.last_frame_hold == 1
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
def test_default_keeps_previous_behaviour(self, dm):
|
|
||||||
"""Callers that never heard of frame holds get one frame per refresh."""
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
try:
|
|
||||||
assert dm._frame_hold == 1
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
"""The visual-test double must not drift from the real DisplayManager.
|
|
||||||
|
|
||||||
Both directions of drift are silent and both are damaging:
|
|
||||||
|
|
||||||
* the double accepts an argument production does not -- the call passes
|
|
||||||
every harness run and raises TypeError on the panel, which is precisely
|
|
||||||
the failure a safety harness exists to prevent;
|
|
||||||
* the double lacks an argument production has -- every plugin that
|
|
||||||
legitimately uses it fails every render, and the harness blames the
|
|
||||||
plugin.
|
|
||||||
|
|
||||||
`set_scrolling_state` has been each of those in turn across two branches, so
|
|
||||||
the parity is asserted rather than remembered.
|
|
||||||
|
|
||||||
Read with ast rather than imported: src/display_manager.py imports rgbmatrix
|
|
||||||
at module scope, which is absent anywhere without the panel library, and this
|
|
||||||
check should hold on a laptop and in CI as well as on a Pi.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
REAL = ROOT / "src" / "display_manager.py"
|
|
||||||
DOUBLE = ROOT / "src" / "plugin_system" / "testing" / "visual_display_manager.py"
|
|
||||||
|
|
||||||
#: Methods a plugin calls on whichever manager it is handed.
|
|
||||||
SHARED_METHODS = ["set_scrolling_state", "is_currently_scrolling"]
|
|
||||||
|
|
||||||
|
|
||||||
def _signature(path: Path, class_hint: str, method: str):
|
|
||||||
"""(name, default-repr) pairs for `method`, or None if it is absent."""
|
|
||||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if not isinstance(node, ast.ClassDef) or class_hint not in node.name:
|
|
||||||
continue
|
|
||||||
for item in node.body:
|
|
||||||
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == method:
|
|
||||||
args = [a.arg for a in item.args.args if a.arg != "self"]
|
|
||||||
pad = [None] * (len(args) - len(item.args.defaults))
|
|
||||||
defaults = pad + [ast.unparse(d) for d in item.args.defaults]
|
|
||||||
return list(zip(args, defaults))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("method", SHARED_METHODS)
|
|
||||||
def test_the_double_matches_production(method):
|
|
||||||
real = _signature(REAL, "DisplayManager", method)
|
|
||||||
double = _signature(DOUBLE, "DisplayManager", method)
|
|
||||||
|
|
||||||
assert real is not None, f"DisplayManager lost {method}"
|
|
||||||
assert double is not None, \
|
|
||||||
f"the test double is missing {method}, so every plugin using it fails to render"
|
|
||||||
assert double == real, (
|
|
||||||
f"{method} has drifted: production takes {real}, the double takes {double}. "
|
|
||||||
"A double that is more permissive hides a production TypeError; one that "
|
|
||||||
"is less permissive fails plugins that are actually correct."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_frame_hold_is_accepted_by_both():
|
|
||||||
"""The specific argument that has drifted twice."""
|
|
||||||
for path, label in ((REAL, "DisplayManager"), (DOUBLE, "VisualTestDisplayManager")):
|
|
||||||
params = dict(_signature(path, "DisplayManager", "set_scrolling_state") or [])
|
|
||||||
assert "frame_hold" in params, f"{label} does not accept frame_hold"
|
|
||||||
assert params["frame_hold"] == "1", \
|
|
||||||
f"{label} must default frame_hold to 1 so existing callers are unaffected"
|
|
||||||
@@ -101,61 +101,3 @@ class TestSkippedResults:
|
|||||||
r = _result(None, returned=None)
|
r = _result(None, returned=None)
|
||||||
check_empty_claimed([r], strict=True)
|
check_empty_claimed([r], strict=True)
|
||||||
assert r.empty_claimed is None
|
assert r.empty_claimed is None
|
||||||
|
|
||||||
|
|
||||||
class TestSettleRecordsLaterFailures:
|
|
||||||
"""A mode that renders one good frame and then crashes is broken.
|
|
||||||
|
|
||||||
_settle_loop re-renders a mode that came back blank, to give a scroll or an
|
|
||||||
animation time to put something on the panel. Swallowing an exception from
|
|
||||||
those later frames meant the harness reported a passing result for a mode
|
|
||||||
that crashes as soon as it is asked for a second frame -- exactly the kind
|
|
||||||
of defect the harness exists to catch.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Boom:
|
|
||||||
"""Renders once, then raises."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.calls = 0
|
|
||||||
|
|
||||||
def display(self, force_clear=False):
|
|
||||||
self.calls += 1
|
|
||||||
raise RuntimeError("second frame exploded")
|
|
||||||
|
|
||||||
def _settle(self, inst, dm, result):
|
|
||||||
from src.plugin_system.testing import harness
|
|
||||||
harness._settle_loop(inst, "mode", dm, result, None)
|
|
||||||
|
|
||||||
def test_the_exception_is_recorded_on_the_result(self, monkeypatch):
|
|
||||||
from src.plugin_system.testing import harness
|
|
||||||
# Keep the probe short; this test is about the error, not the pacing.
|
|
||||||
monkeypatch.setattr(harness, "EMPTY_RECHECK_FRAMES", 1)
|
|
||||||
monkeypatch.setattr(harness, "EMPTY_RECHECK_STEP", 0)
|
|
||||||
|
|
||||||
result = _result(_blank())
|
|
||||||
assert result.error is None
|
|
||||||
self._settle(self.Boom(), _FakeDM(), result)
|
|
||||||
|
|
||||||
assert result.error is not None, "a crash on a later frame was swallowed"
|
|
||||||
assert "second frame exploded" in result.error
|
|
||||||
|
|
||||||
def test_the_already_captured_frame_is_kept(self, monkeypatch):
|
|
||||||
from src.plugin_system.testing import harness
|
|
||||||
monkeypatch.setattr(harness, "EMPTY_RECHECK_FRAMES", 1)
|
|
||||||
monkeypatch.setattr(harness, "EMPTY_RECHECK_STEP", 0)
|
|
||||||
|
|
||||||
image = _blank()
|
|
||||||
result = _result(image)
|
|
||||||
self._settle(self.Boom(), _FakeDM(), result)
|
|
||||||
assert result.image is image, "the good frame was discarded along with the error"
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeDM:
|
|
||||||
"""Minimal display-manager double for _settle_loop."""
|
|
||||||
|
|
||||||
def get_image(self):
|
|
||||||
return _blank()
|
|
||||||
|
|
||||||
def check_overflow(self):
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
"""The Home Assistant MQTT bridge, without a broker or a matrix.
|
|
||||||
|
|
||||||
The bridge is a translation layer: one MQTT payload in, one api_v3 call out.
|
|
||||||
Everything worth pinning is on that path -- attaching the plugin_id a mode
|
|
||||||
needs, rejecting values HA can produce but the API cannot take, and the
|
|
||||||
discovery configs HA reads once and caches -- so it is all reachable with a
|
|
||||||
fake API client and a pure function.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
BRIDGE_PATH = (Path(__file__).resolve().parent.parent
|
|
||||||
/ "integrations" / "mqtt_bridge" / "ledmatrix_mqtt_bridge.py")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def bridge_module():
|
|
||||||
if not BRIDGE_PATH.exists():
|
|
||||||
pytest.skip("mqtt bridge is not present")
|
|
||||||
try:
|
|
||||||
spec = importlib.util.spec_from_file_location("ledmatrix_mqtt_bridge", BRIDGE_PATH)
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[spec.name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
except ImportError as e:
|
|
||||||
pytest.skip(f"mqtt bridge dependencies are not installed here: {e}")
|
|
||||||
finally:
|
|
||||||
sys.modules.pop("ledmatrix_mqtt_bridge", None)
|
|
||||||
|
|
||||||
|
|
||||||
MODES = [
|
|
||||||
{"mode": "clock-simple", "plugin_id": "clock-simple",
|
|
||||||
"plugin_name": "Simple Clock", "name": "Simple Clock", "enabled": True},
|
|
||||||
{"mode": "nfl_live", "plugin_id": "football-scoreboard",
|
|
||||||
"plugin_name": "Football Scoreboard", "name": "nfl_live", "enabled": True},
|
|
||||||
{"mode": "aquarium", "plugin_id": "starlark-apps",
|
|
||||||
"plugin_name": "Starlark Apps", "name": "aquarium", "enabled": True},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
"""A stand-in for LEDMatrixClient that records what it was asked to do."""
|
|
||||||
fake = MagicMock()
|
|
||||||
fake.list_modes.return_value = list(MODES)
|
|
||||||
fake.start_on_demand.return_value = {"request_id": "r1"}
|
|
||||||
fake.stop_on_demand.return_value = {}
|
|
||||||
fake.set_power.return_value = {}
|
|
||||||
fake.set_brightness.return_value = {}
|
|
||||||
fake.get_brightness.return_value = 90
|
|
||||||
fake.display_status.return_value = {
|
|
||||||
"service": {"active": True},
|
|
||||||
"state": {"active": True, "mode": "nfl_live"},
|
|
||||||
}
|
|
||||||
return fake
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def handler(bridge_module, client):
|
|
||||||
h = bridge_module.CommandHandler(client)
|
|
||||||
h.refresh_modes()
|
|
||||||
return h
|
|
||||||
|
|
||||||
|
|
||||||
class TestDisplayCommands:
|
|
||||||
def test_a_mode_is_forced_on_demand(self, handler, client):
|
|
||||||
result = handler.handle({"action": "display", "mode": "nfl_live"})
|
|
||||||
assert result["status"] == "success"
|
|
||||||
assert client.start_on_demand.call_args.kwargs["mode"] == "nfl_live"
|
|
||||||
|
|
||||||
def test_the_owning_plugin_is_sent_with_the_mode(self, handler, client):
|
|
||||||
"""on-demand/start's find_plugin_for_mode fallback cannot resolve a
|
|
||||||
generated mode, so an omitted plugin_id 404s for every Starlark app."""
|
|
||||||
handler.handle({"action": "display", "mode": "aquarium"})
|
|
||||||
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "starlark-apps"
|
|
||||||
|
|
||||||
def test_a_home_assistant_label_resolves_to_its_mode(self, handler, client):
|
|
||||||
"""The select entity shows labels, so that is what comes back."""
|
|
||||||
handler.handle({"action": "display", "mode": "Simple Clock"})
|
|
||||||
assert client.start_on_demand.call_args.kwargs["mode"] == "clock-simple"
|
|
||||||
|
|
||||||
def test_an_unknown_mode_refreshes_before_giving_up(self, handler, client):
|
|
||||||
"""A plugin installed since the last refresh is the usual reason."""
|
|
||||||
client.list_modes.return_value = MODES + [
|
|
||||||
{"mode": "new_app", "plugin_id": "starlark-apps", "name": "new_app"}]
|
|
||||||
handler.handle({"action": "display", "mode": "new_app"})
|
|
||||||
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "starlark-apps"
|
|
||||||
|
|
||||||
def test_an_explicit_plugin_id_is_respected(self, handler, client):
|
|
||||||
handler.handle({"action": "display", "mode": "x", "plugin_id": "custom"})
|
|
||||||
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "custom"
|
|
||||||
|
|
||||||
def test_duration_and_pinned_are_passed_through(self, handler, client):
|
|
||||||
handler.handle({"action": "display", "mode": "aquarium",
|
|
||||||
"duration": 300, "pinned": True})
|
|
||||||
kwargs = client.start_on_demand.call_args.kwargs
|
|
||||||
assert kwargs["duration"] == 300 and kwargs["pinned"] is True
|
|
||||||
|
|
||||||
def test_display_with_nothing_to_show_is_rejected(self, handler, client):
|
|
||||||
result = handler.handle({"action": "display"})
|
|
||||||
assert result["status"] == "error"
|
|
||||||
client.start_on_demand.assert_not_called()
|
|
||||||
|
|
||||||
def test_stop_returns_to_normal_rotation(self, handler, client):
|
|
||||||
assert handler.handle({"action": "stop_display"})["status"] == "success"
|
|
||||||
client.stop_on_demand.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestPowerAndBrightness:
|
|
||||||
@pytest.mark.parametrize("state,expected", [("on", True), ("OFF", False), (" On ", True)])
|
|
||||||
def test_power_states_are_accepted(self, handler, client, state, expected):
|
|
||||||
assert handler.handle({"action": "power", "state": state})["status"] == "success"
|
|
||||||
client.set_power.assert_called_with(expected)
|
|
||||||
|
|
||||||
def test_an_unknown_power_state_is_rejected(self, handler, client):
|
|
||||||
assert handler.handle({"action": "power", "state": "maybe"})["status"] == "error"
|
|
||||||
client.set_power.assert_not_called()
|
|
||||||
|
|
||||||
def test_brightness_is_applied(self, handler, client):
|
|
||||||
assert handler.handle({"action": "brightness", "value": 75})["status"] == "success"
|
|
||||||
client.set_brightness.assert_called_with(75)
|
|
||||||
|
|
||||||
def test_a_float_from_home_assistant_is_accepted(self, handler, client):
|
|
||||||
"""The number entity publishes "75.0"; int() would raise on that."""
|
|
||||||
handler.handle({"action": "brightness", "value": "75.0"})
|
|
||||||
client.set_brightness.assert_called_with(75)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", [-1, 101, "bright", None])
|
|
||||||
def test_out_of_range_brightness_never_reaches_the_api(self, handler, client, value):
|
|
||||||
assert handler.handle({"action": "brightness", "value": value})["status"] == "error"
|
|
||||||
client.set_brightness.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestFailuresAreReportedNotRaised:
|
|
||||||
"""A failed command must publish an error, not kill the MQTT loop."""
|
|
||||||
|
|
||||||
def test_an_api_error_becomes_an_error_result(self, handler, client):
|
|
||||||
client.start_on_demand.side_effect = RuntimeError("Display service is not running")
|
|
||||||
result = handler.handle({"action": "display", "mode": "nfl_live"})
|
|
||||||
assert result["status"] == "error"
|
|
||||||
assert "not running" in result["message"]
|
|
||||||
|
|
||||||
def test_an_unknown_action_lists_the_known_ones(self, handler):
|
|
||||||
result = handler.handle({"action": "explode"})
|
|
||||||
assert result["status"] == "error"
|
|
||||||
assert "brightness" in result["message"]
|
|
||||||
|
|
||||||
def test_a_missing_action_is_an_error(self, handler):
|
|
||||||
assert handler.handle({})["status"] == "error"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDiscoveryPayloads:
|
|
||||||
"""HA reads these once and caches them; a wrong shape is a dead entity."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def messages(self, bridge_module):
|
|
||||||
return bridge_module.discovery_messages(
|
|
||||||
"ledmatrix/command", "ledmatrix/command/state",
|
|
||||||
"ledmatrix/command/availability", ["Simple Clock", "nfl_live"])
|
|
||||||
|
|
||||||
def test_every_entity_is_published(self, messages):
|
|
||||||
components = {m["topic"].split("/")[1] for m in messages}
|
|
||||||
assert components == {"select", "button", "switch", "number"}
|
|
||||||
|
|
||||||
def test_each_entity_has_a_stable_unique_id(self, messages):
|
|
||||||
"""Without one HA cannot let a user rename or reassign the entity."""
|
|
||||||
ids = [m["payload"]["unique_id"] for m in messages]
|
|
||||||
assert len(ids) == len(set(ids)) and all(ids)
|
|
||||||
|
|
||||||
def test_the_select_offers_the_modes_it_was_given(self, messages):
|
|
||||||
select = next(m for m in messages if "/select/" in m["topic"])
|
|
||||||
assert select["payload"]["options"] == ["Simple Clock", "nfl_live"]
|
|
||||||
|
|
||||||
def test_every_entity_shares_the_availability_topic(self, messages):
|
|
||||||
"""It is also the bridge's last will, so HA greys the controls out
|
|
||||||
when the bridge dies rather than leaving them silently inert."""
|
|
||||||
assert all(m["payload"]["availability_topic"] == "ledmatrix/command/availability"
|
|
||||||
for m in messages)
|
|
||||||
|
|
||||||
def test_every_entity_belongs_to_one_device(self, messages):
|
|
||||||
assert all(m["payload"]["device"]["identifiers"] == ["ledmatrix"] for m in messages)
|
|
||||||
|
|
||||||
def test_the_command_templates_are_valid_json_the_handler_accepts(self, messages, handler):
|
|
||||||
"""A template that renders malformed JSON fails only at runtime, in HA."""
|
|
||||||
select = next(m for m in messages if "/select/" in m["topic"])
|
|
||||||
rendered = select["payload"]["command_template"].replace("{{ value }}", "nfl_live")
|
|
||||||
assert handler.handle(json.loads(rendered))["status"] == "success"
|
|
||||||
|
|
||||||
number = next(m for m in messages if "/number/" in m["topic"])
|
|
||||||
rendered = number["payload"]["command_template"].replace("{{ value }}", "40")
|
|
||||||
assert handler.handle(json.loads(rendered))["status"] == "success"
|
|
||||||
|
|
||||||
def test_the_switch_payloads_are_valid_json_the_handler_accepts(self, messages, handler):
|
|
||||||
switch = next(m for m in messages if "/switch/" in m["topic"])
|
|
||||||
for key in ("payload_on", "payload_off"):
|
|
||||||
assert handler.handle(json.loads(switch["payload"][key]))["status"] == "success"
|
|
||||||
|
|
||||||
def test_the_button_payload_is_valid_json_the_handler_accepts(self, messages, handler):
|
|
||||||
button = next(m for m in messages if "/button/" in m["topic"])
|
|
||||||
assert handler.handle(json.loads(button["payload"]["payload_press"]))["status"] == "success"
|
|
||||||
|
|
||||||
|
|
||||||
class TestStateReporting:
|
|
||||||
def test_state_reflects_the_running_display(self, bridge_module, client):
|
|
||||||
state = bridge_module.read_state(client)
|
|
||||||
assert state == {"power": True, "mode": "nfl_live", "brightness": 90}
|
|
||||||
|
|
||||||
def test_normal_rotation_reports_no_forced_mode(self, bridge_module, client):
|
|
||||||
client.display_status.return_value = {
|
|
||||||
"service": {"active": True}, "state": {"active": False}}
|
|
||||||
assert bridge_module.read_state(client)["mode"] is None
|
|
||||||
|
|
||||||
def test_an_unreachable_field_does_not_blank_the_others(self, bridge_module, client):
|
|
||||||
"""A stopped display service still has a brightness worth showing."""
|
|
||||||
client.display_status.side_effect = RuntimeError("connection refused")
|
|
||||||
state = bridge_module.read_state(client)
|
|
||||||
assert state["brightness"] == 90 and state["power"] is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigLoading:
|
|
||||||
def test_defaults_apply_when_no_file_exists(self, bridge_module, tmp_path):
|
|
||||||
config = bridge_module.load_config(str(tmp_path / "absent.json"))
|
|
||||||
assert config["mqtt_topic"] == "ledmatrix/command"
|
|
||||||
assert config["mqtt_port"] == 1883
|
|
||||||
|
|
||||||
def test_the_file_overrides_defaults(self, bridge_module, tmp_path):
|
|
||||||
path = tmp_path / "bridge_config.json"
|
|
||||||
path.write_text(json.dumps({"mqtt_host": "broker.local", "mqtt_port": "8883"}))
|
|
||||||
config = bridge_module.load_config(str(path))
|
|
||||||
assert config["mqtt_host"] == "broker.local"
|
|
||||||
assert config["mqtt_port"] == 8883, "a port read from JSON must still be an int"
|
|
||||||
|
|
||||||
def test_the_environment_overrides_the_file(self, bridge_module, tmp_path, monkeypatch):
|
|
||||||
"""So a password need not sit in a file the service user can read."""
|
|
||||||
path = tmp_path / "bridge_config.json"
|
|
||||||
path.write_text(json.dumps({"mqtt_password": "from-file"}))
|
|
||||||
monkeypatch.setenv("LEDMATRIX_MQTT_MQTT_PASSWORD", "from-env")
|
|
||||||
assert bridge_module.load_config(str(path))["mqtt_password"] == "from-env"
|
|
||||||
|
|
||||||
def test_the_example_placeholder_password_is_refused(self, bridge_module, tmp_path):
|
|
||||||
"""Copying the example unedited must fail loudly, not silently fail to
|
|
||||||
authenticate against the broker."""
|
|
||||||
path = tmp_path / "bridge_config.json"
|
|
||||||
path.write_text(json.dumps(
|
|
||||||
{"mqtt_password": "REPLACE_WITH_YOUR_ACTUAL_MQTT_PASSWORD"}))
|
|
||||||
with pytest.raises(bridge_module.ConfigError):
|
|
||||||
bridge_module.load_config(str(path))
|
|
||||||
|
|
||||||
def test_malformed_json_is_a_clear_error(self, bridge_module, tmp_path):
|
|
||||||
path = tmp_path / "bridge_config.json"
|
|
||||||
path.write_text("{not json")
|
|
||||||
with pytest.raises(bridge_module.ConfigError):
|
|
||||||
bridge_module.load_config(str(path))
|
|
||||||
|
|
||||||
def test_a_non_numeric_port_is_a_clear_error(self, bridge_module, tmp_path):
|
|
||||||
path = tmp_path / "bridge_config.json"
|
|
||||||
path.write_text(json.dumps({"mqtt_port": "not-a-port"}))
|
|
||||||
with pytest.raises(bridge_module.ConfigError):
|
|
||||||
bridge_module.load_config(str(path))
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheExampleConfigIsSecureByDefault:
|
|
||||||
"""The installer copies bridge_config.example.json verbatim on first run.
|
|
||||||
|
|
||||||
Without TLS the broker password and every display command cross the network
|
|
||||||
in cleartext, so the shipped default has to be the safe one -- a plaintext
|
|
||||||
broker is a deliberate edit, not something you get by not reading.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def example(self):
|
|
||||||
path = (Path(__file__).resolve().parent.parent
|
|
||||||
/ "integrations" / "mqtt_bridge" / "bridge_config.example.json")
|
|
||||||
if not path.exists():
|
|
||||||
pytest.skip("mqtt bridge example config is not present")
|
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
def test_tls_is_on(self, example):
|
|
||||||
assert example["mqtt_tls"] is True
|
|
||||||
|
|
||||||
def test_the_port_is_the_tls_one(self, example):
|
|
||||||
"""1883 with mqtt_tls on would just fail to connect."""
|
|
||||||
assert example["mqtt_port"] == 8883
|
|
||||||
|
|
||||||
def test_certificate_verification_is_not_disabled(self, example):
|
|
||||||
assert example.get("mqtt_tls_insecure", False) is False
|
|
||||||
|
|
||||||
def test_no_password_ships_in_the_example(self, example):
|
|
||||||
assert example["mqtt_password"] is None
|
|
||||||
|
|
||||||
def test_the_example_loads(self, bridge_module, tmp_path):
|
|
||||||
"""It is copied verbatim, so it must survive load_config."""
|
|
||||||
path = (Path(__file__).resolve().parent.parent
|
|
||||||
/ "integrations" / "mqtt_bridge" / "bridge_config.example.json")
|
|
||||||
config = bridge_module.load_config(str(path))
|
|
||||||
assert config["mqtt_tls"] is True and config["mqtt_port"] == 8883
|
|
||||||
|
|
||||||
|
|
||||||
class TestCleartextIsCalledOut:
|
|
||||||
"""Turning TLS off is allowed -- the Mosquitto add-on is plaintext on 1883 --
|
|
||||||
but it should not be silent when a password is going over it."""
|
|
||||||
|
|
||||||
def test_a_password_without_tls_warns(self, bridge_module, caplog):
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
warned = bridge_module.warn_if_cleartext(
|
|
||||||
{"mqtt_tls": False, "mqtt_password": "hunter2"})
|
|
||||||
assert warned is True
|
|
||||||
assert "unencrypted" in caplog.text
|
|
||||||
|
|
||||||
def test_the_warning_does_not_repeat_the_password(self, bridge_module, caplog):
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
bridge_module.warn_if_cleartext({"mqtt_tls": False, "mqtt_password": "hunter2"})
|
|
||||||
assert "hunter2" not in caplog.text
|
|
||||||
|
|
||||||
def test_no_password_means_nothing_to_lose(self, bridge_module):
|
|
||||||
assert bridge_module.warn_if_cleartext(
|
|
||||||
{"mqtt_tls": False, "mqtt_password": None}) is False
|
|
||||||
|
|
||||||
def test_tls_on_does_not_warn(self, bridge_module):
|
|
||||||
assert bridge_module.warn_if_cleartext(
|
|
||||||
{"mqtt_tls": True, "mqtt_password": "hunter2"}) is False
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""The on-demand request mailbox: how often it is read, and how it is consumed.
|
|
||||||
|
|
||||||
The mailbox is a cache key the web process writes and the display process
|
|
||||||
reads. Two properties matter and neither is obvious from the call site:
|
|
||||||
|
|
||||||
* it is polled after every rendered frame, so an uncached read here is a
|
|
||||||
disk read at frame rate;
|
|
||||||
* consuming it must not throw away a request that arrived while the previous
|
|
||||||
one was being processed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
class TestPollingIsBounded:
|
|
||||||
"""_poll_on_demand_requests runs ~125x/second on a scrolling mode.
|
|
||||||
|
|
||||||
The read is deliberately uncached (memory_ttl=0) because a cached one
|
|
||||||
pinned the first request for an hour. That makes the call a real disk read,
|
|
||||||
so it needs a floor -- without one it was ~125 reads per second to find
|
|
||||||
nothing at all.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_first_call_always_reads(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
c.cache_manager.get = MagicMock(return_value=None)
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
assert c.cache_manager.get.call_count == 1
|
|
||||||
|
|
||||||
def test_immediate_second_call_does_not_read(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
c.cache_manager.get = MagicMock(return_value=None)
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
for _ in range(50):
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
assert c.cache_manager.get.call_count == 1, "polling was not bounded"
|
|
||||||
|
|
||||||
def test_reads_again_once_the_interval_has_passed(self, test_display_controller, monkeypatch):
|
|
||||||
c = test_display_controller
|
|
||||||
c.cache_manager.get = MagicMock(return_value=None)
|
|
||||||
clock = {"t": 1000.0}
|
|
||||||
monkeypatch.setattr("src.display_controller.time.monotonic", lambda: clock["t"])
|
|
||||||
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
clock["t"] += c.ON_DEMAND_POLL_INTERVAL / 2
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
assert c.cache_manager.get.call_count == 1, "read before the interval elapsed"
|
|
||||||
|
|
||||||
clock["t"] += c.ON_DEMAND_POLL_INTERVAL
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
assert c.cache_manager.get.call_count == 2
|
|
||||||
|
|
||||||
def test_the_interval_is_short_enough_to_feel_instant(self, test_display_controller):
|
|
||||||
# A person clicking in the web UI must not notice the floor.
|
|
||||||
assert test_display_controller.ON_DEMAND_POLL_INTERVAL <= 0.5
|
|
||||||
|
|
||||||
|
|
||||||
class TestMailboxIsConsumedByIdentity:
|
|
||||||
"""Deleting whatever is in the mailbox loses a request that raced in."""
|
|
||||||
|
|
||||||
def _arrange(self, controller, first, later):
|
|
||||||
"""Mailbox returns `first`, then `later` on the pre-delete re-read."""
|
|
||||||
controller.on_demand_active = False
|
|
||||||
controller.on_demand_request_id = None
|
|
||||||
controller._last_on_demand_poll = None
|
|
||||||
reads = iter([first, later])
|
|
||||||
|
|
||||||
def fake_get(key, *a, **kw):
|
|
||||||
if key == 'display_on_demand_request':
|
|
||||||
return next(reads, later)
|
|
||||||
return None # processed-id lookup
|
|
||||||
|
|
||||||
controller.cache_manager.get = MagicMock(side_effect=fake_get)
|
|
||||||
controller.cache_manager.set = MagicMock()
|
|
||||||
controller.cache_manager.delete = MagicMock()
|
|
||||||
controller._activate_on_demand = MagicMock()
|
|
||||||
|
|
||||||
REQ_A = {'request_id': 'A', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
|
||||||
REQ_B = {'request_id': 'B', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
|
||||||
|
|
||||||
def test_own_request_is_deleted(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
self._arrange(c, self.REQ_A, self.REQ_A)
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
|
||||||
|
|
||||||
def test_a_newer_request_is_left_for_the_next_poll(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
self._arrange(c, self.REQ_A, self.REQ_B)
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
assert c.cache_manager.delete.call_count == 0, \
|
|
||||||
"request B was deleted without ever being processed"
|
|
||||||
|
|
||||||
def test_an_already_empty_mailbox_is_still_cleared(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
self._arrange(c, self.REQ_A, None)
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
|
||||||
|
|
||||||
def test_the_request_is_still_processed(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
self._arrange(c, self.REQ_A, self.REQ_B)
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
c._activate_on_demand.assert_called_once()
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
"""On-demand behaviour that a person can ask for but the controller ignored.
|
|
||||||
|
|
||||||
Three separate gaps, all reachable from the web UI's force-display dialog:
|
|
||||||
|
|
||||||
* `pinned` was accepted by the API, stored on the controller and published
|
|
||||||
back in the status payload, but never narrowed the rotation -- a pinned
|
|
||||||
request still cycled every mode its plugin owns;
|
|
||||||
* restarting while on-demand was active loaded *only* the on-demand plugin,
|
|
||||||
so normal rotation had nothing to return to for the life of the process;
|
|
||||||
* a stop request was exempt from the duplicate guards on purpose and was
|
|
||||||
never removed from the mailbox, so it was re-processed on every poll
|
|
||||||
forever.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def _plugin_with_modes(controller, plugin_id, modes):
|
|
||||||
"""Register `modes` as loaded modes belonging to `plugin_id`."""
|
|
||||||
controller.plugin_display_modes[plugin_id] = list(modes)
|
|
||||||
for mode in modes:
|
|
||||||
controller.plugin_modes[mode] = MagicMock(spec=[])
|
|
||||||
controller.mode_to_plugin_id[mode] = plugin_id
|
|
||||||
|
|
||||||
|
|
||||||
class TestPinnedNarrowsTheRotation:
|
|
||||||
"""A pinned request shows the one mode that was asked for.
|
|
||||||
|
|
||||||
Unpinned stays the default: a sports plugin's modes are views of one
|
|
||||||
subject, so rotating them is right. A Starlark plugin's modes are
|
|
||||||
unrelated widgets, so it is not.
|
|
||||||
"""
|
|
||||||
|
|
||||||
MODES = ['app_a', 'app_b', 'app_c']
|
|
||||||
|
|
||||||
def _activate(self, controller, pinned):
|
|
||||||
_plugin_with_modes(controller, 'starlark-apps', self.MODES)
|
|
||||||
controller.available_modes = list(self.MODES)
|
|
||||||
controller._activate_on_demand({
|
|
||||||
'request_id': 'r1',
|
|
||||||
'action': 'start',
|
|
||||||
'plugin_id': 'starlark-apps',
|
|
||||||
'mode': 'app_b',
|
|
||||||
'pinned': pinned,
|
|
||||||
})
|
|
||||||
|
|
||||||
def test_pinned_rotation_holds_the_requested_mode(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
self._activate(c, pinned=True)
|
|
||||||
assert c.on_demand_modes == ['app_b']
|
|
||||||
|
|
||||||
def test_unpinned_still_rotates_the_whole_plugin(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
self._activate(c, pinned=False)
|
|
||||||
assert set(c.on_demand_modes) == set(self.MODES)
|
|
||||||
|
|
||||||
def test_pinned_still_starts_on_the_requested_mode(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
self._activate(c, pinned=True)
|
|
||||||
assert c.current_display_mode == 'app_b'
|
|
||||||
|
|
||||||
def test_the_pin_is_recorded_for_the_status_payload(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
self._activate(c, pinned=True)
|
|
||||||
assert c.on_demand_pinned is True
|
|
||||||
|
|
||||||
def test_an_unresolvable_pin_does_not_empty_the_rotation(self, test_display_controller):
|
|
||||||
"""A pin naming a mode outside the plugin must not leave nothing to show."""
|
|
||||||
c = test_display_controller
|
|
||||||
_plugin_with_modes(c, 'starlark-apps', self.MODES)
|
|
||||||
assert c._apply_on_demand_pin(list(self.MODES), 'not_a_mode', True) == self.MODES
|
|
||||||
|
|
||||||
|
|
||||||
class TestPinSurvivesARestart:
|
|
||||||
"""The pin is part of the request being resumed, not a per-session flag."""
|
|
||||||
|
|
||||||
def test_restored_pinned_state_narrows_the_rotation(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
_plugin_with_modes(c, 'starlark-apps', ['app_a', 'app_b', 'app_c'])
|
|
||||||
c.on_demand_active = True
|
|
||||||
c.on_demand_plugin_id = 'starlark-apps'
|
|
||||||
c.on_demand_mode = 'app_c'
|
|
||||||
c.on_demand_pinned = True
|
|
||||||
|
|
||||||
c._populate_on_demand_modes_from_plugin()
|
|
||||||
assert c.on_demand_modes == ['app_c']
|
|
||||||
|
|
||||||
def test_restored_unpinned_state_keeps_every_mode(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
_plugin_with_modes(c, 'starlark-apps', ['app_a', 'app_b', 'app_c'])
|
|
||||||
c.on_demand_active = True
|
|
||||||
c.on_demand_plugin_id = 'starlark-apps'
|
|
||||||
c.on_demand_mode = 'app_c'
|
|
||||||
c.on_demand_pinned = False
|
|
||||||
|
|
||||||
c._populate_on_demand_modes_from_plugin()
|
|
||||||
assert set(c.on_demand_modes) == {'app_a', 'app_b', 'app_c'}
|
|
||||||
|
|
||||||
|
|
||||||
class TestRestartDoesNotStarveTheOtherPlugins:
|
|
||||||
"""Restarting mid-on-demand used to load only the on-demand plugin.
|
|
||||||
|
|
||||||
Restarts during an on-demand session are routine -- it is how an update or
|
|
||||||
a config change is applied -- and the panel came back cycling one plugin's
|
|
||||||
modes and nothing else until the on-demand cache was cleared by hand.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DISCOVERED = ['clock', 'weather', 'starlark-apps', 'disabled-one']
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def controller(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
c.config.update({
|
|
||||||
'clock': {'enabled': True},
|
|
||||||
'weather': {'enabled': True},
|
|
||||||
'starlark-apps': {'enabled': True},
|
|
||||||
'disabled-one': {'enabled': False},
|
|
||||||
})
|
|
||||||
return c
|
|
||||||
|
|
||||||
def test_every_enabled_plugin_still_loads(self, controller):
|
|
||||||
selected = controller._select_startup_plugins(
|
|
||||||
self.DISCOVERED, {'plugin_id': 'starlark-apps', 'mode': 'app_a'})
|
|
||||||
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
|
|
||||||
|
|
||||||
def test_disabled_plugins_are_still_left_out(self, controller):
|
|
||||||
selected = controller._select_startup_plugins(
|
|
||||||
self.DISCOVERED, {'plugin_id': 'starlark-apps', 'mode': 'app_a'})
|
|
||||||
assert 'disabled-one' not in selected
|
|
||||||
|
|
||||||
def test_the_on_demand_state_is_still_restored(self, controller):
|
|
||||||
controller._select_startup_plugins(
|
|
||||||
self.DISCOVERED,
|
|
||||||
{'plugin_id': 'starlark-apps', 'mode': 'app_a', 'pinned': True})
|
|
||||||
assert controller.on_demand_active is True
|
|
||||||
assert controller.on_demand_plugin_id == 'starlark-apps'
|
|
||||||
assert controller.on_demand_mode == 'app_a'
|
|
||||||
assert controller.on_demand_pinned is True
|
|
||||||
|
|
||||||
def test_a_disabled_on_demand_plugin_is_enabled_and_loaded(self, controller):
|
|
||||||
"""Otherwise the mode being resumed has nothing behind it."""
|
|
||||||
selected = controller._select_startup_plugins(
|
|
||||||
self.DISCOVERED, {'plugin_id': 'disabled-one', 'mode': 'x'})
|
|
||||||
assert 'disabled-one' in selected
|
|
||||||
assert controller.config['disabled-one']['enabled'] is True
|
|
||||||
|
|
||||||
def test_an_unknown_on_demand_plugin_falls_back_to_normal(self, controller):
|
|
||||||
selected = controller._select_startup_plugins(
|
|
||||||
self.DISCOVERED, {'plugin_id': 'uninstalled', 'mode': 'x'})
|
|
||||||
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
|
|
||||||
assert controller.on_demand_active is False
|
|
||||||
|
|
||||||
def test_no_on_demand_config_is_a_normal_startup(self, controller):
|
|
||||||
selected = controller._select_startup_plugins(self.DISCOVERED, None)
|
|
||||||
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
|
|
||||||
assert controller.on_demand_active is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestStopRequestsAreConsumed:
|
|
||||||
"""A stop request is exempt from the duplicate guards, so the mailbox
|
|
||||||
delete is the only thing that ends it."""
|
|
||||||
|
|
||||||
STOP = {'request_id': 'S1', 'action': 'stop'}
|
|
||||||
|
|
||||||
def _arrange(self, controller, active):
|
|
||||||
controller.on_demand_active = active
|
|
||||||
controller.on_demand_status = 'active' if active else 'idle'
|
|
||||||
controller._last_on_demand_poll = None
|
|
||||||
controller.cache_manager.get = MagicMock(
|
|
||||||
side_effect=lambda key, *a, **kw:
|
|
||||||
self.STOP if key == 'display_on_demand_request' else None)
|
|
||||||
controller.cache_manager.set = MagicMock()
|
|
||||||
controller.cache_manager.delete = MagicMock()
|
|
||||||
controller._clear_on_demand = MagicMock()
|
|
||||||
|
|
||||||
def test_a_handled_stop_is_removed_from_the_mailbox(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
self._arrange(c, active=True)
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
|
||||||
|
|
||||||
def test_a_stop_arriving_while_idle_is_also_removed(self, test_display_controller):
|
|
||||||
"""Otherwise a stop sent to an idle display re-fires forever."""
|
|
||||||
c = test_display_controller
|
|
||||||
self._arrange(c, active=False)
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
|
||||||
|
|
||||||
def test_the_stop_is_still_acted_on(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
self._arrange(c, active=True)
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
c._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
|
||||||
|
|
||||||
def test_a_start_racing_in_behind_a_stop_is_not_discarded(self, test_display_controller):
|
|
||||||
"""The compare-before-delete applies to stops too."""
|
|
||||||
c = test_display_controller
|
|
||||||
self._arrange(c, active=True)
|
|
||||||
newer = {'request_id': 'S2', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
|
||||||
reads = iter([self.STOP, newer])
|
|
||||||
c.cache_manager.get = MagicMock(
|
|
||||||
side_effect=lambda key, *a, **kw:
|
|
||||||
next(reads, newer) if key == 'display_on_demand_request' else None)
|
|
||||||
|
|
||||||
c._poll_on_demand_requests()
|
|
||||||
assert c.cache_manager.delete.call_count == 0
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
"""starlark-apps PixletRenderer: what reaches Pixlet, and what counts as a render.
|
|
||||||
|
|
||||||
Three things a Starlark app can do that the renderer got wrong:
|
|
||||||
|
|
||||||
* put a "|" in a config value -- a shell metacharacter filter dropped the
|
|
||||||
whole key, though the command is a list and no shell is involved;
|
|
||||||
* render nothing -- Pixlet exits 0 and writes a 0-byte file, which was
|
|
||||||
reported as a successful render;
|
|
||||||
* compute its schema at runtime -- the source parser can only read option
|
|
||||||
lists that are written out literally, so a dropdown fed by a live API call
|
|
||||||
came back empty.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin-repos" / "starlark-apps"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def renderer_module():
|
|
||||||
if not PLUGIN_DIR.exists():
|
|
||||||
pytest.skip("starlark-apps plugin is not checked out")
|
|
||||||
sys.path.insert(0, str(PLUGIN_DIR))
|
|
||||||
try:
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
"pixlet_renderer_under_test", PLUGIN_DIR / "pixlet_renderer.py")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
except Exception as e: # noqa: BLE001 - optional deps may be absent
|
|
||||||
pytest.skip(f"pixlet_renderer is not importable here: {e}")
|
|
||||||
finally:
|
|
||||||
sys.path.remove(str(PLUGIN_DIR))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def renderer(renderer_module):
|
|
||||||
"""A renderer with a known binary and __init__'s binary search bypassed."""
|
|
||||||
r = renderer_module.PixletRenderer.__new__(renderer_module.PixletRenderer)
|
|
||||||
r.timeout = 30
|
|
||||||
r.pixlet_binary = "/usr/local/bin/pixlet"
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def _completed(returncode=0, stdout="", stderr=""):
|
|
||||||
return subprocess.CompletedProcess(args=[], returncode=returncode,
|
|
||||||
stdout=stdout, stderr=stderr)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigValuesReachPixlet:
|
|
||||||
"""cmd is a list and there is no shell=True, so nothing here is ever
|
|
||||||
interpreted by a shell -- the filter is defence in depth, not a boundary."""
|
|
||||||
|
|
||||||
def _args_for(self, renderer, tmp_path, config):
|
|
||||||
star = tmp_path / "app.star"
|
|
||||||
star.write_text("# app", encoding="utf-8")
|
|
||||||
out = tmp_path / "out.webp"
|
|
||||||
|
|
||||||
def fake_run(cmd, **kw):
|
|
||||||
out.write_bytes(b"webp-bytes")
|
|
||||||
fake_run.cmd = cmd
|
|
||||||
return _completed()
|
|
||||||
|
|
||||||
with patch.object(subprocess, "run", side_effect=fake_run):
|
|
||||||
renderer.render(str(tmp_path / "app.star"), str(out), config=config)
|
|
||||||
return fake_run.cmd
|
|
||||||
|
|
||||||
def test_a_pipe_in_a_value_is_passed_through(self, renderer, tmp_path):
|
|
||||||
"""Real apps use "|" as a separator inside one config value."""
|
|
||||||
args = self._args_for(renderer, tmp_path, {"sign_id": "I-476 North|175659"})
|
|
||||||
assert "sign_id=I-476 North|175659" in args
|
|
||||||
|
|
||||||
def test_a_dropped_value_does_not_take_the_key_with_it(self, renderer, tmp_path):
|
|
||||||
args = self._args_for(renderer, tmp_path, {"sign_id": "I-476 North|175659"})
|
|
||||||
assert any(a.startswith("sign_id=") for a in args)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", [
|
|
||||||
"$(rm -rf /)",
|
|
||||||
"`whoami`",
|
|
||||||
"a;b",
|
|
||||||
"a&b",
|
|
||||||
"a>b",
|
|
||||||
"a<b",
|
|
||||||
])
|
|
||||||
def test_actual_shell_metacharacters_are_still_dropped(self, renderer, tmp_path, value):
|
|
||||||
args = self._args_for(renderer, tmp_path, {"k": value})
|
|
||||||
assert not any(a.startswith("k=") for a in args)
|
|
||||||
|
|
||||||
def test_an_invalid_key_is_still_dropped(self, renderer, tmp_path):
|
|
||||||
args = self._args_for(renderer, tmp_path, {"bad key!": "x"})
|
|
||||||
assert not any("bad key!" in a for a in args)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnEmptyRenderIsNotASuccess:
|
|
||||||
"""Pixlet exits 0 and writes 0 bytes when an app renders no content."""
|
|
||||||
|
|
||||||
def _render(self, renderer, tmp_path, write_bytes):
|
|
||||||
star = tmp_path / "app.star"
|
|
||||||
star.write_text("# app", encoding="utf-8")
|
|
||||||
out = tmp_path / "out.webp"
|
|
||||||
|
|
||||||
def fake_run(cmd, **kw):
|
|
||||||
out.write_bytes(write_bytes)
|
|
||||||
return _completed()
|
|
||||||
|
|
||||||
with patch.object(subprocess, "run", side_effect=fake_run):
|
|
||||||
return renderer.render(str(tmp_path / "app.star"), str(out))
|
|
||||||
|
|
||||||
def test_a_zero_byte_output_is_reported_as_a_failure(self, renderer, tmp_path):
|
|
||||||
ok, error = self._render(renderer, tmp_path, b"")
|
|
||||||
assert ok is False
|
|
||||||
assert "empty" in (error or "").lower()
|
|
||||||
|
|
||||||
def test_a_real_output_still_succeeds(self, renderer, tmp_path):
|
|
||||||
ok, error = self._render(renderer, tmp_path, b"RIFF....WEBP")
|
|
||||||
assert (ok, error) == (True, None)
|
|
||||||
|
|
||||||
def test_a_missing_output_still_fails(self, renderer, tmp_path):
|
|
||||||
star = tmp_path / "app.star"
|
|
||||||
star.write_text("# app", encoding="utf-8")
|
|
||||||
out = tmp_path / "out.webp"
|
|
||||||
with patch.object(subprocess, "run", return_value=_completed()):
|
|
||||||
ok, error = renderer.render(str(tmp_path / "app.star"), str(out))
|
|
||||||
assert ok is False
|
|
||||||
assert "not found" in (error or "").lower()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchemaComesFromPixletWhenItCan:
|
|
||||||
"""`pixlet schema` runs get_schema(); the source parser only reads it.
|
|
||||||
|
|
||||||
An app whose dropdown options come from a live API call has no option list
|
|
||||||
anywhere in its source, so the parser reports an empty dropdown and the
|
|
||||||
config form offers nothing to choose.
|
|
||||||
"""
|
|
||||||
|
|
||||||
PIXLET_OUTPUT = json.dumps({
|
|
||||||
"version": "1",
|
|
||||||
"schema": [
|
|
||||||
{"id": "roadway", "name": "Roadway", "type": "dropdown",
|
|
||||||
"description": "Pick a road", "options": [{"display": "I-476", "value": "476"}]},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
def test_the_pixlet_output_is_used(self, renderer, tmp_path):
|
|
||||||
star = tmp_path / "app.star"
|
|
||||||
star.write_text("# no schema literal anywhere\n", encoding="utf-8")
|
|
||||||
with patch.object(subprocess, "run",
|
|
||||||
return_value=_completed(stdout=self.PIXLET_OUTPUT)):
|
|
||||||
ok, schema, error = renderer.extract_schema(str(star))
|
|
||||||
assert ok is True and error is None
|
|
||||||
assert schema["schema"][0]["options"][0]["value"] == "476"
|
|
||||||
|
|
||||||
def test_pixlet_field_keys_are_remapped_for_the_config_form(self, renderer, tmp_path):
|
|
||||||
"""The UI reads typeOf/desc; Pixlet emits type/description."""
|
|
||||||
star = tmp_path / "app.star"
|
|
||||||
star.write_text("#\n", encoding="utf-8")
|
|
||||||
with patch.object(subprocess, "run",
|
|
||||||
return_value=_completed(stdout=self.PIXLET_OUTPUT)):
|
|
||||||
_, schema, _ = renderer.extract_schema(str(star))
|
|
||||||
field = schema["schema"][0]
|
|
||||||
assert field["typeOf"] == "dropdown"
|
|
||||||
assert field["desc"] == "Pick a road"
|
|
||||||
assert "type" not in field and "description" not in field
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("outcome", [
|
|
||||||
{"return_value": _completed(returncode=1, stderr="unknown command \"schema\"")},
|
|
||||||
{"return_value": _completed(stdout="not json at all")},
|
|
||||||
{"side_effect": subprocess.TimeoutExpired(cmd="pixlet", timeout=20)},
|
|
||||||
{"side_effect": OSError("no such binary")},
|
|
||||||
])
|
|
||||||
def test_it_falls_back_to_the_source_parser(self, renderer, tmp_path, outcome):
|
|
||||||
"""Older Pixlet has no `schema` subcommand, and an app can fail to run.
|
|
||||||
Neither should cost the schema an app does state literally."""
|
|
||||||
star = tmp_path / "app.star"
|
|
||||||
star.write_text(
|
|
||||||
'def get_schema():\n'
|
|
||||||
' return schema.Schema(version = "1", fields = [\n'
|
|
||||||
' schema.Text(id = "city", name = "City", desc = "Your city",'
|
|
||||||
' icon = "gear"),\n'
|
|
||||||
' ])\n',
|
|
||||||
encoding="utf-8")
|
|
||||||
with patch.object(subprocess, "run", **outcome):
|
|
||||||
ok, parsed, error = renderer.extract_schema(str(star))
|
|
||||||
assert ok is True and error is None
|
|
||||||
assert [f["id"] for f in parsed["schema"]] == ["city"]
|
|
||||||
|
|
||||||
def test_no_pixlet_binary_skips_straight_to_the_parser(self, renderer, tmp_path):
|
|
||||||
renderer.pixlet_binary = None
|
|
||||||
star = tmp_path / "app.star"
|
|
||||||
star.write_text("#\n", encoding="utf-8")
|
|
||||||
assert renderer.extract_schema_via_pixlet(str(star)) is None
|
|
||||||
|
|
||||||
def test_a_missing_star_file_is_still_an_error(self, renderer, tmp_path):
|
|
||||||
ok, schema, error = renderer.extract_schema(str(tmp_path / "gone.star"))
|
|
||||||
assert ok is False and schema is None and "not found" in error
|
|
||||||
|
|
||||||
def test_an_unexpected_pixlet_shape_is_not_trusted(self, renderer, tmp_path):
|
|
||||||
"""A bare list, or a dict with no schema array, must not reach the UI."""
|
|
||||||
star = tmp_path / "app.star"
|
|
||||||
star.write_text("#\n", encoding="utf-8")
|
|
||||||
with patch.object(subprocess, "run",
|
|
||||||
return_value=_completed(stdout='["not", "a", "schema"]')):
|
|
||||||
assert renderer.extract_schema_via_pixlet(str(star)) is None
|
|
||||||
@@ -1,467 +0,0 @@
|
|||||||
"""Tests for the shared scroll configuration resolver."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from src.common.scroll_config import ( # noqa: E402
|
|
||||||
DEFAULT_PIXELS_PER_SECOND,
|
|
||||||
MAX_PIXELS_PER_FRAME,
|
|
||||||
crisp_ladder,
|
|
||||||
solve_crisp,
|
|
||||||
MAX_PIXELS_PER_SECOND,
|
|
||||||
MIN_PIXELS_PER_SECOND,
|
|
||||||
ScrollSettings,
|
|
||||||
configure,
|
|
||||||
refresh_hz_from_config,
|
|
||||||
resolve,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeHelper:
|
|
||||||
"""Records what configure() applied."""
|
|
||||||
|
|
||||||
def __init__(self, with_optional=True):
|
|
||||||
self.speed = None
|
|
||||||
self.frame_based = None
|
|
||||||
self.target_fps = None
|
|
||||||
if not with_optional:
|
|
||||||
del FakeHelper.set_frame_based_scrolling
|
|
||||||
del FakeHelper.set_target_fps
|
|
||||||
|
|
||||||
def set_scroll_speed(self, speed):
|
|
||||||
self.speed = speed
|
|
||||||
|
|
||||||
def set_frame_based_scrolling(self, enabled):
|
|
||||||
self.frame_based = enabled
|
|
||||||
|
|
||||||
def set_target_fps(self, fps):
|
|
||||||
self.target_fps = fps
|
|
||||||
|
|
||||||
|
|
||||||
class FakeDisplayManager:
|
|
||||||
"""Records the frame hold configure() applies."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.hold = None
|
|
||||||
|
|
||||||
def set_frame_hold(self, refreshes):
|
|
||||||
self.hold = refreshes
|
|
||||||
|
|
||||||
|
|
||||||
class MinimalHelper:
|
|
||||||
"""An older helper exposing only set_scroll_speed."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.speed = None
|
|
||||||
|
|
||||||
def set_scroll_speed(self, speed):
|
|
||||||
self.speed = speed
|
|
||||||
|
|
||||||
|
|
||||||
class TestPrecedence:
|
|
||||||
def test_display_options_pair_wins(self):
|
|
||||||
s = resolve({"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01}})
|
|
||||||
assert s.pixels_per_second == 100.0
|
|
||||||
assert s.source == "display_options.scroll_speed/delay"
|
|
||||||
|
|
||||||
def test_display_block_used_when_options_absent(self):
|
|
||||||
s = resolve({"display": {"scroll_speed": 2.0, "scroll_delay": 0.01}})
|
|
||||||
assert s.pixels_per_second == 200.0
|
|
||||||
assert s.source == "display.scroll_speed/delay"
|
|
||||||
|
|
||||||
def test_root_pair_used_when_both_blocks_absent(self):
|
|
||||||
s = resolve({"scroll_speed": 1.0, "scroll_delay": 0.02})
|
|
||||||
assert s.pixels_per_second == 50.0
|
|
||||||
|
|
||||||
def test_pixels_per_second_used_when_no_pair_given(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 120.0}})
|
|
||||||
assert s.pixels_per_second == 120.0
|
|
||||||
assert s.source == "display_options.scroll_pixels_per_second"
|
|
||||||
|
|
||||||
def test_global_display_is_the_last_resort_before_default(self):
|
|
||||||
s = resolve({}, {"display": {"scroll_speed": 1.0, "scroll_delay": 0.005}})
|
|
||||||
assert s.pixels_per_second == 200.0
|
|
||||||
|
|
||||||
def test_default_when_nothing_configured(self):
|
|
||||||
s = resolve({}, {})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
assert s.source == "default"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeprecatedKeyCannotOverrideExplicitPair:
|
|
||||||
"""Regression for ChuckBuilds/ledmatrix-plugins#408.
|
|
||||||
|
|
||||||
odds-ticker ranked scroll_pixels_per_second above the documented
|
|
||||||
scroll_speed/scroll_delay pair. Because that key carries a schema default,
|
|
||||||
the documented settings became unreachable for every user and the ticker
|
|
||||||
silently ran at the default speed. The pair must win.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_pair_beats_pixels_per_second_in_the_same_block(self):
|
|
||||||
s = resolve(
|
|
||||||
{
|
|
||||||
"display_options": {
|
|
||||||
"scroll_speed": 1.0,
|
|
||||||
"scroll_delay": 0.01,
|
|
||||||
"scroll_pixels_per_second": 50.0, # schema default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert s.pixels_per_second == 100.0
|
|
||||||
assert "scroll_speed/delay" in s.source
|
|
||||||
|
|
||||||
def test_pair_beats_pixels_per_second_in_an_outer_block(self):
|
|
||||||
s = resolve(
|
|
||||||
{
|
|
||||||
"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01},
|
|
||||||
"scroll_pixels_per_second": 50.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert s.pixels_per_second == 100.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestMalformedValues:
|
|
||||||
@pytest.mark.parametrize("bad", [None, "fast", "", {}, [], float("nan")])
|
|
||||||
def test_unusable_speed_falls_through(self, bad):
|
|
||||||
s = resolve({"display_options": {"scroll_speed": bad, "scroll_delay": 0.01}})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad", [0, -5, 0.0])
|
|
||||||
def test_non_positive_values_fall_through(self, bad):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": bad}})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
def test_booleans_are_not_treated_as_numbers(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": True}})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
def test_zero_delay_does_not_divide_by_zero(self):
|
|
||||||
s = resolve({"display_options": {"scroll_speed": 1.0, "scroll_delay": 0}})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
def test_non_dict_blocks_are_ignored(self):
|
|
||||||
s = resolve({"display_options": "nonsense", "display": 5})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
|
|
||||||
class TestClamping:
|
|
||||||
def test_absurdly_fast_is_clamped(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 100000.0}})
|
|
||||||
assert s.pixels_per_second == MAX_PIXELS_PER_SECOND
|
|
||||||
assert s.warning and "clamped" in s.warning
|
|
||||||
|
|
||||||
def test_absurdly_slow_is_clamped(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 0.01}})
|
|
||||||
assert s.pixels_per_second == MIN_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
|
|
||||||
class TestWholePixelWarning:
|
|
||||||
"""The display rule: whole pixels per refresh, or it judders."""
|
|
||||||
|
|
||||||
def test_no_warning_when_speed_divides_evenly(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 100.0}}, refresh_hz=100)
|
|
||||||
assert s.warning is None
|
|
||||||
assert s.pixels_per_frame == pytest.approx(1.0)
|
|
||||||
|
|
||||||
def test_no_warning_at_an_integer_multiple(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 200.0}}, refresh_hz=100)
|
|
||||||
assert s.warning is None
|
|
||||||
|
|
||||||
def test_warns_on_a_half_pixel_per_frame(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 50.0}}, refresh_hz=100)
|
|
||||||
assert s.warning is not None
|
|
||||||
assert "judder" in s.warning
|
|
||||||
assert "100 px/s" in s.warning
|
|
||||||
|
|
||||||
def test_warns_on_a_fractional_multiple(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 130.0}}, refresh_hz=100)
|
|
||||||
assert s.warning is not None
|
|
||||||
|
|
||||||
def test_respects_a_non_default_refresh_rate(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 150.0}}, refresh_hz=150)
|
|
||||||
assert s.warning is None
|
|
||||||
assert s.pixels_per_frame == pytest.approx(1.0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigure:
|
|
||||||
def test_applies_time_based_mode_and_speed(self):
|
|
||||||
helper = FakeHelper()
|
|
||||||
s = configure(helper, {"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01}})
|
|
||||||
assert helper.speed == 100.0
|
|
||||||
assert helper.frame_based is False, "must not use the wall-clock step gate"
|
|
||||||
assert helper.target_fps == 100.0
|
|
||||||
assert s.pixels_per_second == 100.0
|
|
||||||
|
|
||||||
def test_works_against_a_helper_without_optional_methods(self):
|
|
||||||
helper = MinimalHelper()
|
|
||||||
configure(helper, {"display_options": {"scroll_pixels_per_second": 100.0}})
|
|
||||||
assert helper.speed == 100.0
|
|
||||||
|
|
||||||
def test_snapping_removes_the_judder_warning(self, caplog):
|
|
||||||
"""50px/s cannot be shown in whole pixels at 100Hz *per refresh*, but it
|
|
||||||
can as 1px every 2nd refresh -- so once snapped there is nothing to warn
|
|
||||||
about. resolve() alone still warns; configure() resolves it."""
|
|
||||||
assert "judder" in resolve(
|
|
||||||
{"display_options": {"scroll_pixels_per_second": 50.0}},
|
|
||||||
refresh_hz=100).warning
|
|
||||||
helper, dm = FakeHelper(), FakeDisplayManager()
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
settings = configure(
|
|
||||||
helper, {"display_options": {"scroll_pixels_per_second": 50.0}},
|
|
||||||
display_manager=dm)
|
|
||||||
assert settings.warning is None
|
|
||||||
assert not [r for r in caplog.records if "judder" in r.getMessage()]
|
|
||||||
|
|
||||||
def test_reports_the_hold_without_applying_it(self):
|
|
||||||
"""The hold belongs to a scroll, not a plugin's lifetime -- one left set
|
|
||||||
at construction is wiped as soon as any other plugin stops scrolling.
|
|
||||||
configure() reports it; the caller passes it to set_scrolling_state."""
|
|
||||||
dm = FakeDisplayManager()
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 25.0}},
|
|
||||||
display_manager=dm)
|
|
||||||
assert s.frame_hold == 4, "25px/s at 100Hz is 1px every 4th refresh"
|
|
||||||
assert dm.hold is None, "must not apply the hold behind the caller's back"
|
|
||||||
|
|
||||||
def test_full_speed_needs_no_hold(self):
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 100.0}},
|
|
||||||
display_manager=FakeDisplayManager())
|
|
||||||
assert s.frame_hold == 1
|
|
||||||
|
|
||||||
def test_frame_hold_is_one_when_snapping_is_off(self):
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 50.0}},
|
|
||||||
snap_to_crisp=False)
|
|
||||||
assert s.frame_hold == 1
|
|
||||||
|
|
||||||
def test_snaps_to_the_nearest_crisp_speed_and_reports_both(self):
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 45.0}},
|
|
||||||
display_manager=FakeDisplayManager())
|
|
||||||
assert s.requested_pixels_per_second == 45.0
|
|
||||||
assert s.pixels_per_second == 50.0
|
|
||||||
assert s.crisp is not None and s.crisp.pixels_per_frame == 1
|
|
||||||
|
|
||||||
def test_snapping_can_be_turned_off(self):
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 45.0}},
|
|
||||||
snap_to_crisp=False)
|
|
||||||
assert s.pixels_per_second == 45.0
|
|
||||||
assert s.crisp is None
|
|
||||||
|
|
||||||
def test_helper_target_fps_matches_the_presentation_rate(self):
|
|
||||||
"""At a hold of 4 the panel still refreshes at 100Hz, but frames are
|
|
||||||
presented at 25/s -- that is the rate the helper should pace to."""
|
|
||||||
helper = FakeHelper()
|
|
||||||
configure(helper, {"display_options": {"scroll_pixels_per_second": 25.0}},
|
|
||||||
display_manager=FakeDisplayManager())
|
|
||||||
assert helper.target_fps == pytest.approx(25.0)
|
|
||||||
|
|
||||||
def test_returns_settings_describing_the_source(self):
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_speed": 2.0, "scroll_delay": 0.01}})
|
|
||||||
assert isinstance(s, ScrollSettings)
|
|
||||||
assert "200.0 px/s" in s.describe()
|
|
||||||
|
|
||||||
|
|
||||||
class TestRefreshFromConfig:
|
|
||||||
def test_reads_the_hardware_limit(self):
|
|
||||||
assert refresh_hz_from_config(
|
|
||||||
{"display": {"hardware": {"limit_refresh_rate_hz": 150}}}
|
|
||||||
) == 150.0
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("cfg", [None, {}, {"display": {}}, {"display": {"hardware": {}}},
|
|
||||||
"nonsense", {"display": {"hardware": {"limit_refresh_rate_hz": None}}}])
|
|
||||||
def test_falls_back_to_the_default(self, cfg):
|
|
||||||
assert refresh_hz_from_config(cfg) == 100.0
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("cfg", [
|
|
||||||
{"display": "nonsense"},
|
|
||||||
{"display": ["nonsense"]},
|
|
||||||
{"display": 60},
|
|
||||||
{"display": {"hardware": "nonsense"}},
|
|
||||||
{"display": {"hardware": ["nonsense"]}},
|
|
||||||
{"display": {"hardware": 60}},
|
|
||||||
])
|
|
||||||
def test_malformed_nesting_falls_back_rather_than_raising(self, cfg):
|
|
||||||
# Truthy-but-not-a-mapping at either level used to reach .get() on a
|
|
||||||
# str/list and raise AttributeError out of a function whose whole
|
|
||||||
# contract is "a refresh rate, or the default".
|
|
||||||
assert refresh_hz_from_config(cfg) == 100.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestCrispLadder:
|
|
||||||
"""Whole-pixel speeds available on a given panel."""
|
|
||||||
|
|
||||||
def test_every_entry_is_exactly_reachable(self):
|
|
||||||
for c in crisp_ladder(100):
|
|
||||||
assert c.pixels_per_second == pytest.approx(
|
|
||||||
c.refresh_hz / c.frame_hold * c.pixels_per_frame)
|
|
||||||
|
|
||||||
def test_sorted_slowest_first(self):
|
|
||||||
speeds = [c.pixels_per_second for c in crisp_ladder(100)]
|
|
||||||
assert speeds == sorted(speeds)
|
|
||||||
|
|
||||||
def test_no_duplicate_speeds(self):
|
|
||||||
speeds = [round(c.pixels_per_second, 3) for c in crisp_ladder(100)]
|
|
||||||
assert len(speeds) == len(set(speeds))
|
|
||||||
|
|
||||||
def test_duplicates_resolve_to_the_smaller_step(self):
|
|
||||||
"""100px/s is 1px every refresh or 2px every 2nd; prefer the former."""
|
|
||||||
entry = next(c for c in crisp_ladder(100)
|
|
||||||
if c.pixels_per_second == pytest.approx(100.0))
|
|
||||||
assert entry.pixels_per_frame == 1
|
|
||||||
assert entry.frame_hold == 1
|
|
||||||
|
|
||||||
def test_ladder_scales_with_the_panel(self):
|
|
||||||
assert any(c.pixels_per_second == pytest.approx(60.0)
|
|
||||||
for c in crisp_ladder(60))
|
|
||||||
assert any(c.pixels_per_second == pytest.approx(30.0)
|
|
||||||
for c in crisp_ladder(60))
|
|
||||||
|
|
||||||
def test_full_refresh_speed_is_present(self):
|
|
||||||
for hz in (60, 75, 100, 120):
|
|
||||||
assert any(c.pixels_per_second == pytest.approx(float(hz))
|
|
||||||
for c in crisp_ladder(hz))
|
|
||||||
|
|
||||||
|
|
||||||
class TestSolveCrisp:
|
|
||||||
def test_exact_targets_are_matched_exactly(self):
|
|
||||||
for target in (100, 50, 25, 20):
|
|
||||||
assert solve_crisp(target, 100).pixels_per_second == pytest.approx(target)
|
|
||||||
|
|
||||||
def test_prefers_smooth_motion_over_raw_proximity(self):
|
|
||||||
"""30 -> 33.3 (1px, 33fps), not 28.6 (2px at 14fps) which is nearer."""
|
|
||||||
got = solve_crisp(30, 100)
|
|
||||||
assert got.pixels_per_second == pytest.approx(33.333, abs=0.01)
|
|
||||||
assert got.pixels_per_frame == 1
|
|
||||||
|
|
||||||
def test_does_not_chase_a_jumpy_exact_match(self):
|
|
||||||
"""45 -> 50 (1px, smooth) beats 42.9 (3px at 14fps)."""
|
|
||||||
assert solve_crisp(45, 100).pixels_per_frame == 1
|
|
||||||
|
|
||||||
def test_allows_a_two_pixel_step_when_it_is_clearly_closer(self):
|
|
||||||
"""60 -> 66.7 (2px at 33fps) rather than 50 (1px) which is 17% slow."""
|
|
||||||
got = solve_crisp(60, 100)
|
|
||||||
assert got.pixels_per_second == pytest.approx(66.667, abs=0.01)
|
|
||||||
assert got.pixels_per_frame == 2
|
|
||||||
|
|
||||||
def test_result_is_always_on_the_ladder(self):
|
|
||||||
ladder = {round(c.pixels_per_second, 3) for c in crisp_ladder(100)}
|
|
||||||
for target in range(5, 205, 5):
|
|
||||||
assert round(solve_crisp(target, 100).pixels_per_second, 3) in ladder
|
|
||||||
|
|
||||||
def test_adapts_to_a_slower_panel(self):
|
|
||||||
got = solve_crisp(30, 60)
|
|
||||||
assert got.pixels_per_second == pytest.approx(30.0)
|
|
||||||
assert got.pixels_per_frame == 1
|
|
||||||
assert got.frame_hold == 2
|
|
||||||
|
|
||||||
def test_speeds_beyond_the_panel_clamp_to_the_fastest_available(self):
|
|
||||||
got = solve_crisp(10_000, 100)
|
|
||||||
assert got.frame_hold == 1
|
|
||||||
assert got.pixels_per_frame == MAX_PIXELS_PER_FRAME
|
|
||||||
|
|
||||||
def test_steppiness_labels_are_sane(self):
|
|
||||||
assert solve_crisp(100, 100).steppiness == "smooth"
|
|
||||||
assert crisp_ladder(100)[0].steppiness == "stepped"
|
|
||||||
|
|
||||||
|
|
||||||
class TestRefreshFromDisplayManager:
|
|
||||||
"""A plugin sees only its own config section, so the display manager is the
|
|
||||||
authoritative source for the panel's refresh rate."""
|
|
||||||
|
|
||||||
class DM:
|
|
||||||
def __init__(self, hz):
|
|
||||||
self.refresh_hz = hz
|
|
||||||
self.hold = None
|
|
||||||
|
|
||||||
def set_frame_hold(self, refreshes):
|
|
||||||
self.hold = refreshes
|
|
||||||
|
|
||||||
def test_uses_the_display_manager_rate(self):
|
|
||||||
dm = self.DM(60.0)
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 30.0}},
|
|
||||||
display_manager=dm)
|
|
||||||
# 30px/s on a 60Hz panel is 1px every 2nd refresh, exactly.
|
|
||||||
assert s.pixels_per_second == pytest.approx(30.0)
|
|
||||||
assert s.frame_hold == 2
|
|
||||||
|
|
||||||
def test_explicit_refresh_hz_wins_over_the_display_manager(self):
|
|
||||||
dm = self.DM(60.0)
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 25.0}},
|
|
||||||
display_manager=dm, refresh_hz=100)
|
|
||||||
assert s.frame_hold == 4, "25px/s at 100Hz is 1px every 4th refresh"
|
|
||||||
|
|
||||||
def test_missing_attribute_falls_back_to_the_default(self):
|
|
||||||
class Bare:
|
|
||||||
def set_frame_hold(self, refreshes):
|
|
||||||
self.hold = refreshes
|
|
||||||
|
|
||||||
bare = Bare()
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 50.0}},
|
|
||||||
display_manager=bare)
|
|
||||||
assert s.frame_hold == 2, "assumed 100Hz"
|
|
||||||
|
|
||||||
def test_reported_settings_describe_the_display_manager_rate(self):
|
|
||||||
"""Without snapping, nothing downstream corrects a wrong refresh rate.
|
|
||||||
|
|
||||||
The rate used to be read *after* resolve() had already filled in
|
|
||||||
target_fps, pixels_per_frame and the judder warning from the 100Hz
|
|
||||||
default -- so on a 60Hz panel every one of those described 100Hz, and
|
|
||||||
set_target_fps() paced the helper to 100 FPS.
|
|
||||||
"""
|
|
||||||
dm = self.DM(60.0)
|
|
||||||
helper = FakeHelper()
|
|
||||||
s = configure(helper, {"display_options": {"scroll_pixels_per_second": 30.0}},
|
|
||||||
display_manager=dm, snap_to_crisp=False)
|
|
||||||
|
|
||||||
assert s.target_fps == pytest.approx(60.0)
|
|
||||||
assert s.pixels_per_frame == pytest.approx(0.5), "30px/s over 60 frames"
|
|
||||||
assert helper.target_fps == pytest.approx(60.0)
|
|
||||||
|
|
||||||
def test_judder_warning_is_computed_at_the_real_refresh_rate(self):
|
|
||||||
dm = self.DM(60.0)
|
|
||||||
# 60px/s is exactly 1px per refresh at 60Hz -- crisp, no warning. At
|
|
||||||
# the 100Hz default it is 0.6px per refresh and would be flagged.
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 60.0}},
|
|
||||||
display_manager=dm, snap_to_crisp=False)
|
|
||||||
assert s.warning is None, s.warning
|
|
||||||
|
|
||||||
def test_global_config_supplies_the_rate_without_a_display_manager(self):
|
|
||||||
s = configure(
|
|
||||||
FakeHelper(),
|
|
||||||
{"display_options": {"scroll_pixels_per_second": 30.0}},
|
|
||||||
{"display": {"hardware": {"limit_refresh_rate_hz": 60}}},
|
|
||||||
snap_to_crisp=False,
|
|
||||||
)
|
|
||||||
assert s.target_fps == pytest.approx(60.0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFrameHoldIsReportedNotApplied:
|
|
||||||
"""configure() reports the hold; the caller applies it when it scrolls.
|
|
||||||
|
|
||||||
The hold belongs to a scroll, not to a plugin's lifetime -- plugins share
|
|
||||||
one display manager, so one set at construction is reset the moment any
|
|
||||||
other plugin stops scrolling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class RecordingDM:
|
|
||||||
refresh_hz = 100.0
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.calls = []
|
|
||||||
|
|
||||||
def set_scrolling_state(self, is_scrolling, frame_hold=1):
|
|
||||||
self.calls.append((is_scrolling, frame_hold))
|
|
||||||
|
|
||||||
def set_frame_hold(self, refreshes):
|
|
||||||
self.calls.append(("set_frame_hold", refreshes))
|
|
||||||
|
|
||||||
def test_configure_does_not_touch_the_display_manager(self):
|
|
||||||
dm = self.RecordingDM()
|
|
||||||
settings = configure(
|
|
||||||
FakeHelper(), {"display_options": {"scroll_pixels_per_second": 25.0}},
|
|
||||||
display_manager=dm)
|
|
||||||
assert dm.calls == [], "configure() must not apply the hold itself"
|
|
||||||
assert settings.frame_hold == 4, "but it must report what to apply"
|
|
||||||
@@ -11,11 +11,7 @@ import time
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from src.common.scroll_helper import (
|
from src.common.scroll_helper import ScrollHelper
|
||||||
ScrollHelper,
|
|
||||||
format_frame_stats,
|
|
||||||
frame_stats,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
DISPLAY_W = 64
|
DISPLAY_W = 64
|
||||||
@@ -319,77 +315,3 @@ class TestGetScrollInfo:
|
|||||||
helper.scroll_position = 42.0
|
helper.scroll_position = 42.0
|
||||||
info = helper.get_scroll_info()
|
info = helper.get_scroll_info()
|
||||||
assert info["scroll_position"] == 42.0
|
assert info["scroll_position"] == 42.0
|
||||||
|
|
||||||
|
|
||||||
class TestFrameStatsPercentiles:
|
|
||||||
"""The stats line is the instrument this whole scroll change is measured
|
|
||||||
with, so its median and p95 have to be the real ones.
|
|
||||||
|
|
||||||
Both are also thresholds: stalls are counted at 1.5x the median and skips
|
|
||||||
at 0.5x, so an off-by-one in the median biases the counts as well as the
|
|
||||||
printed numbers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# 100 samples of 1..100ms. True median 50.5ms (the mean of the two middle
|
|
||||||
# samples, not the upper one at 51ms); nearest-rank p95 is the 95th
|
|
||||||
# sample at 95ms, not the 96th at 96ms.
|
|
||||||
HUNDRED = [i / 1000.0 for i in range(1, 101)]
|
|
||||||
|
|
||||||
def test_even_window_median_averages_both_middle_samples(self):
|
|
||||||
assert frame_stats(self.HUNDRED)["median"] == pytest.approx(0.0505)
|
|
||||||
|
|
||||||
def test_p95_uses_nearest_rank(self):
|
|
||||||
assert frame_stats(self.HUNDRED)["p95"] == pytest.approx(0.095)
|
|
||||||
|
|
||||||
def test_odd_window_median_is_the_middle_sample(self):
|
|
||||||
times = [i / 1000.0 for i in range(1, 102)] # 101 samples
|
|
||||||
assert frame_stats(times)["median"] == pytest.approx(0.051)
|
|
||||||
|
|
||||||
def test_single_sample_window_does_not_index_out_of_range(self):
|
|
||||||
stats = frame_stats([0.010])
|
|
||||||
assert stats["median"] == pytest.approx(0.010)
|
|
||||||
assert stats["p95"] == pytest.approx(0.010)
|
|
||||||
assert stats["min"] == stats["max"] == pytest.approx(0.010)
|
|
||||||
|
|
||||||
def test_two_sample_window(self):
|
|
||||||
stats = frame_stats([0.010, 0.020])
|
|
||||||
assert stats["median"] == pytest.approx(0.015)
|
|
||||||
assert stats["p95"] == pytest.approx(0.020)
|
|
||||||
|
|
||||||
def test_input_order_does_not_matter(self):
|
|
||||||
assert frame_stats(list(reversed(self.HUNDRED))) == frame_stats(self.HUNDRED)
|
|
||||||
|
|
||||||
def test_caller_window_is_not_mutated(self):
|
|
||||||
times = [0.030, 0.010, 0.020]
|
|
||||||
frame_stats(times)
|
|
||||||
assert times == [0.030, 0.010, 0.020]
|
|
||||||
|
|
||||||
def test_stall_threshold_follows_the_median(self):
|
|
||||||
# Ten 10ms frames and two 30ms stalls: median 10ms, so >15ms is a
|
|
||||||
# stall -- exactly the two.
|
|
||||||
stats = frame_stats([0.010] * 10 + [0.030] * 2)
|
|
||||||
assert stats["stalls"] == 2
|
|
||||||
assert stats["skips"] == 0
|
|
||||||
|
|
||||||
def test_skips_are_frames_that_never_reached_the_panel(self):
|
|
||||||
stats = frame_stats([0.010] * 10 + [0.002] * 3)
|
|
||||||
assert stats["skips"] == 3
|
|
||||||
assert stats["stalls"] == 0
|
|
||||||
|
|
||||||
def test_fps_is_the_reciprocal_of_the_mean(self):
|
|
||||||
assert frame_stats([0.010] * 50)["fps"] == pytest.approx(100.0)
|
|
||||||
|
|
||||||
def test_formatted_line_reports_the_corrected_values(self):
|
|
||||||
line = format_frame_stats(self.HUNDRED)
|
|
||||||
assert "median 50.50ms" in line, line
|
|
||||||
assert "p95 95.00ms" in line, line
|
|
||||||
assert "over 100 frames" in line, line
|
|
||||||
|
|
||||||
def test_log_frame_rate_emits_the_line_and_clears_the_window(self, helper):
|
|
||||||
helper._window = [0.010] * 20
|
|
||||||
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 == []
|
|
||||||
|
|||||||
+48
-121
@@ -21,6 +21,8 @@ sys.modules.setdefault("rgbmatrix", MagicMock())
|
|||||||
|
|
||||||
from src.common.sports_scroll import ( # noqa: E402
|
from src.common.sports_scroll import ( # noqa: E402
|
||||||
DEFAULT_SCROLL_SETTINGS,
|
DEFAULT_SCROLL_SETTINGS,
|
||||||
|
MAX_PIXELS_PER_FRAME,
|
||||||
|
MIN_PIXELS_PER_FRAME,
|
||||||
SportsScrollDisplay,
|
SportsScrollDisplay,
|
||||||
SportsScrollDisplayManager,
|
SportsScrollDisplayManager,
|
||||||
)
|
)
|
||||||
@@ -185,48 +187,29 @@ class TestScrollSettings:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestConfigureScrollHelper:
|
class TestConfigureScrollHelper:
|
||||||
"""Pacing now comes from scroll_config, the same resolver every other
|
def test_speed_is_converted_to_pixels_per_frame(self, build):
|
||||||
scrolling plugin uses. What this class guards is the translation into it,
|
|
||||||
because the two modules read identically-named keys differently."""
|
|
||||||
|
|
||||||
def test_config_speed_is_pixels_per_second_not_pixels_per_step(self, build):
|
|
||||||
"""The collision that makes a naive hand-off wrong by 1/scroll_delay.
|
|
||||||
|
|
||||||
sports config states scroll_speed in px/SECOND and uses scroll_delay
|
|
||||||
only to reach px/frame. scroll_config states it in px per STEP, so it
|
|
||||||
computes px/s as speed/delay. Handing this module's settings dict
|
|
||||||
straight to the resolver turns 50 px/s into 5000 px/s, which its own
|
|
||||||
bounds then clamp to 500 -- a tenfold speed-up on every scoreboard.
|
|
||||||
"""
|
|
||||||
display = build({"nhl": {"scroll_settings": {
|
display = build({"nhl": {"scroll_settings": {
|
||||||
"scroll_speed": 50.0, "scroll_delay": 0.01}}})
|
"scroll_speed": 100.0, "scroll_delay": 0.02}}})
|
||||||
applied = display.scroll_helper.set_scroll_speed.call_args[0][0]
|
# 100 px/s * 0.02 s/frame = 2 px/frame
|
||||||
assert applied == pytest.approx(50.0, abs=1.0), (
|
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
|
||||||
f"applied {applied} px/s; 500 means the settings dict was passed "
|
|
||||||
"through to the resolver instead of a plain px/s")
|
|
||||||
|
|
||||||
def test_speed_is_snapped_to_whole_pixel_motion(self, build):
|
def test_conversion_is_clamped_low(self, build):
|
||||||
"""50 px/s on a 100Hz panel is half a pixel per refresh, which cannot
|
display = build({"nhl": {"scroll_settings": {
|
||||||
render as motion -- it alternates 0 and 1 px steps and judders. The
|
"scroll_speed": 0.001, "scroll_delay": 0.001}}})
|
||||||
ladder keeps the speed and holds each frame for two refreshes."""
|
display.scroll_helper.set_scroll_speed.assert_called_with(MIN_PIXELS_PER_FRAME)
|
||||||
display = build()
|
|
||||||
assert display._scroll_settings.frame_hold == 2
|
|
||||||
assert display._scroll_settings.pixels_per_second == pytest.approx(50.0)
|
|
||||||
|
|
||||||
def test_the_resolved_hold_is_what_gets_published(self, build):
|
def test_conversion_is_clamped_high(self, build):
|
||||||
display = build()
|
display = build({"nhl": {"scroll_settings": {
|
||||||
assert display._scroll_frame_hold() == 2
|
"scroll_speed": 5000.0, "scroll_delay": 0.5}}})
|
||||||
|
display.scroll_helper.set_scroll_speed.assert_called_with(MAX_PIXELS_PER_FRAME)
|
||||||
|
|
||||||
def test_hold_defaults_to_one_before_anything_is_resolved(self, display_manager):
|
def test_zero_delay_assumes_a_pacing_instead_of_dividing_by_zero(self, build):
|
||||||
bare = SportsScrollDisplay.__new__(SportsScrollDisplay)
|
display = build({"nhl": {"scroll_settings": {
|
||||||
assert SportsScrollDisplay._scroll_frame_hold(bare) == 1
|
"scroll_speed": 100.0, "scroll_delay": 0}}})
|
||||||
|
display.scroll_helper.set_scroll_speed.assert_called_with(1.0)
|
||||||
|
|
||||||
def test_stepping_is_time_based(self, build):
|
def test_frame_based_scrolling_is_enabled(self, build):
|
||||||
"""Frame-based mode gated motion on a wall clock at 1/scroll_delay
|
build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(True)
|
||||||
steps, with scroll_delay set to the frame period -- putting the
|
|
||||||
comparison exactly on its own threshold, so it flipped on sub-
|
|
||||||
millisecond jitter."""
|
|
||||||
build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(False)
|
|
||||||
|
|
||||||
def test_dynamic_duration_settings_are_applied(self, build):
|
def test_dynamic_duration_settings_are_applied(self, build):
|
||||||
display = build({"nhl": {"scroll_settings": {
|
display = build({"nhl": {"scroll_settings": {
|
||||||
@@ -236,38 +219,32 @@ class TestConfigureScrollHelper:
|
|||||||
assert kwargs["min_duration"] == 5
|
assert kwargs["min_duration"] == 5
|
||||||
assert kwargs["max_duration"] == 50
|
assert kwargs["max_duration"] == 50
|
||||||
|
|
||||||
def test_zero_delay_still_means_pixels_per_frame(self, build):
|
def test_target_fps_reaches_the_helper(self, build):
|
||||||
"""The one case where scroll_speed is not already px/s."""
|
"""The whole point of upstreaming: the bundled copies hardcode ~100 FPS
|
||||||
display = build({"nhl": {"scroll_settings": {
|
via scroll_delay and never consult the global target."""
|
||||||
"scroll_speed": 1.0, "scroll_delay": 0}}})
|
display = build(global_config={"target_fps": 120})
|
||||||
applied = display.scroll_helper.set_scroll_speed.call_args[0][0]
|
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||||
assert applied == pytest.approx(100.0, abs=1.0)
|
|
||||||
|
|
||||||
def test_global_target_fps_sets_the_refresh_the_ladder_uses(self, build):
|
|
||||||
"""Under the old model this key was the rate frames were presented at,
|
|
||||||
so it is the faithful translation of it into a panel refresh."""
|
|
||||||
display = build(global_config={"target_fps": 60})
|
|
||||||
assert display._resolve_refresh_hz() == 60.0
|
|
||||||
|
|
||||||
def test_legacy_key_is_honored(self, build):
|
def test_legacy_key_is_honored(self, build):
|
||||||
display = build(global_config={"scroll_target_fps": 90})
|
display = build(global_config={"scroll_target_fps": 90})
|
||||||
assert display._resolve_refresh_hz() == 90.0
|
display.scroll_helper.set_target_fps.assert_called_once_with(90.0)
|
||||||
|
|
||||||
def test_modern_key_wins_over_legacy(self, build):
|
def test_modern_key_wins_over_legacy(self, build):
|
||||||
display = build(global_config={"target_fps": 120, "scroll_target_fps": 90})
|
display = build(global_config={"target_fps": 120, "scroll_target_fps": 90})
|
||||||
assert display._resolve_refresh_hz() == 120.0
|
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||||
|
|
||||||
def test_configured_hardware_refresh_wins_over_the_fps_target(self, build):
|
def test_absent_target_fps_leaves_config_pacing_alone(self, build):
|
||||||
display = build(global_config={
|
build().scroll_helper.set_target_fps.assert_not_called()
|
||||||
"target_fps": 60,
|
|
||||||
"display": {"hardware": {"limit_refresh_rate_hz": 120}}})
|
|
||||||
assert display._resolve_refresh_hz() == 120.0
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad", ["fast", None, {}, [], "", 0])
|
@pytest.mark.parametrize("bad", ["fast", None, {}, [], "", 0])
|
||||||
def test_unusable_target_fps_degrades_instead_of_raising(self, build, bad):
|
def test_unusable_target_fps_degrades_instead_of_raising(self, build, bad):
|
||||||
"""A malformed global config must cost the FPS target, not the display."""
|
"""A malformed global config must cost the FPS target, not the display."""
|
||||||
display = build(global_config={"target_fps": bad})
|
display = build(global_config={"target_fps": bad})
|
||||||
assert display._scroll_settings.pixels_per_second > 0
|
display.scroll_helper.set_target_fps.assert_not_called()
|
||||||
|
|
||||||
|
def test_string_digits_are_accepted(self, build):
|
||||||
|
display = build(global_config={"target_fps": "120"})
|
||||||
|
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad", [None, "fast", {}, []])
|
@pytest.mark.parametrize("bad", [None, "fast", {}, []])
|
||||||
def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad):
|
def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad):
|
||||||
@@ -275,63 +252,24 @@ class TestConfigureScrollHelper:
|
|||||||
present with null reaches the arithmetic and raises inside __init__,
|
present with null reaches the arithmetic and raises inside __init__,
|
||||||
taking the whole display down before it renders anything."""
|
taking the whole display down before it renders anything."""
|
||||||
display = build({"nhl": {"scroll_settings": {"scroll_speed": bad}}})
|
display = build({"nhl": {"scroll_settings": {"scroll_speed": bad}}})
|
||||||
applied = display.scroll_helper.set_scroll_speed.call_args[0][0]
|
# 50.0 px/s * 0.01 s/frame == 0.5 px/frame, i.e. the default speed.
|
||||||
assert applied == pytest.approx(50.0, abs=1.0)
|
display.scroll_helper.set_scroll_speed.assert_called_with(0.5)
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad", [None, "slow", {}])
|
@pytest.mark.parametrize("bad", [None, "slow", {}])
|
||||||
def test_unusable_scroll_delay_degrades_instead_of_crashing(self, build, bad):
|
def test_unusable_scroll_delay_degrades_instead_of_crashing(self, build, bad):
|
||||||
display = build({"nhl": {"scroll_settings": {"scroll_delay": bad}}})
|
display = build({"nhl": {"scroll_settings": {"scroll_delay": bad}}})
|
||||||
applied = display.scroll_helper.set_scroll_speed.call_args[0][0]
|
display.scroll_helper.set_scroll_delay.assert_called_with(0.01)
|
||||||
assert applied == pytest.approx(50.0, abs=1.0)
|
|
||||||
|
|
||||||
def test_numeric_strings_are_accepted(self, build):
|
def test_numeric_strings_are_accepted(self, build):
|
||||||
display = build({"nhl": {"scroll_settings": {
|
display = build({"nhl": {"scroll_settings": {
|
||||||
"scroll_speed": "100", "scroll_delay": "0.02"}}})
|
"scroll_speed": "100", "scroll_delay": "0.02"}}})
|
||||||
applied = display.scroll_helper.set_scroll_speed.call_args[0][0]
|
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
|
||||||
assert applied == pytest.approx(100.0, abs=1.0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestScrollingStateIsPublished:
|
|
||||||
"""The core has to be told, or the frame hold is never applied and
|
|
||||||
deferred work runs in the middle of the scroll."""
|
|
||||||
|
|
||||||
def _drawable(self, display):
|
|
||||||
display.scroll_helper.cached_image = Image.new("RGB", (400, 32))
|
|
||||||
display.scroll_helper.get_visible_portion.return_value = Image.new(
|
|
||||||
"RGB", (128, 32))
|
|
||||||
|
|
||||||
def test_a_drawn_frame_declares_the_hold(self, build, display_manager):
|
|
||||||
display = build()
|
|
||||||
self._drawable(display)
|
|
||||||
assert display.display_scroll_frame() is True
|
|
||||||
display_manager.set_scrolling_state.assert_called_with(
|
|
||||||
True, frame_hold=display._scroll_frame_hold())
|
|
||||||
|
|
||||||
def test_completion_releases_the_state(self, build, display_manager):
|
|
||||||
display = build()
|
|
||||||
display.scroll_helper.is_scroll_complete.return_value = True
|
|
||||||
assert display.is_scroll_complete() is True
|
|
||||||
display_manager.set_scrolling_state.assert_called_with(False)
|
|
||||||
|
|
||||||
def test_an_incomplete_scroll_does_not_release(self, build, display_manager):
|
|
||||||
display = build()
|
|
||||||
display.scroll_helper.is_scroll_complete.return_value = False
|
|
||||||
display.is_scroll_complete()
|
|
||||||
assert (False,) not in [c.args for c in
|
|
||||||
display_manager.set_scrolling_state.call_args_list]
|
|
||||||
|
|
||||||
def test_clearing_releases_the_state(self, build, display_manager):
|
|
||||||
display = build()
|
|
||||||
display.clear()
|
|
||||||
display_manager.set_scrolling_state.assert_called_with(False)
|
|
||||||
|
|
||||||
def test_an_older_core_without_the_call_is_tolerated(self, build):
|
|
||||||
"""Plugins ship independently of the core they run against."""
|
|
||||||
display = build()
|
|
||||||
del display.display_manager.set_scrolling_state
|
|
||||||
self._drawable(display)
|
|
||||||
assert display.display_scroll_frame() is True
|
|
||||||
|
|
||||||
|
def test_fps_clamping_is_left_to_the_helper(self, build):
|
||||||
|
"""Deliberately not clamped here — a second copy of the range would
|
||||||
|
drift from ScrollHelper.set_target_fps."""
|
||||||
|
display = build(global_config={"target_fps": 5000})
|
||||||
|
display.scroll_helper.set_target_fps.assert_called_once_with(5000.0)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -491,11 +429,7 @@ class TestManager:
|
|||||||
never honoring target_fps."""
|
never honoring target_fps."""
|
||||||
child = manager.get_scroll_display("live")
|
child = manager.get_scroll_display("live")
|
||||||
assert child.global_config == {"target_fps": 120}
|
assert child.global_config == {"target_fps": 120}
|
||||||
# The target is now the refresh the crisp ladder is computed against,
|
child.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||||
# so what proves the hand-off is that the child reasons about 120Hz --
|
|
||||||
# not the presentation rate, which the chosen hold divides down.
|
|
||||||
assert child._resolve_refresh_hz() == 120.0
|
|
||||||
child.scroll_helper.set_target_fps.assert_called_once()
|
|
||||||
|
|
||||||
def test_prepare_sets_the_active_type(self, manager):
|
def test_prepare_sets_the_active_type(self, manager):
|
||||||
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is True
|
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is True
|
||||||
@@ -617,17 +551,10 @@ class TestAgainstTheRealScrollHelper:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_configuration_lands_on_the_real_helper(self, real):
|
def test_configuration_lands_on_the_real_helper(self, real):
|
||||||
display = real.get_scroll_display("live")
|
helper = real.get_scroll_display("live").scroll_helper
|
||||||
helper = display.scroll_helper
|
assert helper.target_fps == 120.0
|
||||||
# 500 px/s on a 120Hz panel snaps to a whole number of pixels per
|
assert helper.frame_based_scrolling is True
|
||||||
# refresh, and the helper is driven in px/s in time-based mode.
|
assert helper.scroll_speed == pytest.approx(0.5) # 500 px/s * 0.001 s
|
||||||
assert helper.frame_based_scrolling is False
|
|
||||||
assert helper.scroll_speed == pytest.approx(
|
|
||||||
display._scroll_settings.pixels_per_second)
|
|
||||||
assert helper.scroll_speed == pytest.approx(500.0, abs=25.0)
|
|
||||||
# target_fps is the presentation rate the chosen hold produces.
|
|
||||||
assert helper.target_fps == pytest.approx(
|
|
||||||
120.0 / display._scroll_settings.frame_hold, abs=1.0)
|
|
||||||
|
|
||||||
def test_a_strip_is_built_and_scrolls_to_completion(self, real):
|
def test_a_strip_is_built_and_scrolls_to_completion(self, real):
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
"""starlark-apps: display() must report what actually reached the panel.
|
|
||||||
|
|
||||||
The display controller skips a mode only on a boolean False. Returning True
|
|
||||||
after the frame update failed told it the mode had rendered, so it held a dead
|
|
||||||
frame for the whole display_duration instead of rotating on -- the same class
|
|
||||||
of defect as a display() that returns None.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin-repos" / "starlark-apps"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def manager_module():
|
|
||||||
if not PLUGIN_DIR.exists():
|
|
||||||
pytest.skip("starlark-apps plugin is not checked out")
|
|
||||||
sys.path.insert(0, str(PLUGIN_DIR))
|
|
||||||
# fcntl is POSIX-only and the manager imports it at module scope for
|
|
||||||
# manifest locking. None of the display paths under test touch it, so a
|
|
||||||
# stub is what keeps these assertions running on a developer's machine
|
|
||||||
# rather than skipping everywhere but the Pi and CI.
|
|
||||||
injected_fcntl = "fcntl" not in sys.modules
|
|
||||||
if injected_fcntl:
|
|
||||||
stub = types.ModuleType("fcntl")
|
|
||||||
stub.LOCK_EX = 2
|
|
||||||
stub.LOCK_UN = 8
|
|
||||||
stub.flock = lambda *a, **kw: None
|
|
||||||
sys.modules["fcntl"] = stub
|
|
||||||
try:
|
|
||||||
import importlib
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
"starlark_manager_under_test", PLUGIN_DIR / "manager.py")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
except Exception as e: # noqa: BLE001 - optional deps (Pillow, pixlet) may be absent
|
|
||||||
pytest.skip(f"starlark-apps manager is not importable here: {e}")
|
|
||||||
finally:
|
|
||||||
sys.path.remove(str(PLUGIN_DIR))
|
|
||||||
if injected_fcntl:
|
|
||||||
sys.modules.pop("fcntl", None)
|
|
||||||
|
|
||||||
|
|
||||||
def _plugin(manager_module):
|
|
||||||
"""A manager with __init__ bypassed -- only display paths are under test."""
|
|
||||||
cls = manager_module.StarlarkAppsPlugin
|
|
||||||
inst = cls.__new__(cls)
|
|
||||||
inst.logger = MagicMock()
|
|
||||||
inst.display_manager = MagicMock()
|
|
||||||
inst.current_app = None
|
|
||||||
return inst
|
|
||||||
|
|
||||||
|
|
||||||
class _App:
|
|
||||||
def __init__(self, frames):
|
|
||||||
self.frames = frames
|
|
||||||
self.current_frame_index = 0
|
|
||||||
self.last_frame_time = 0.0
|
|
||||||
self.app_id = "app"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDisplayFramePropagates:
|
|
||||||
def test_a_failed_update_returns_false(self, manager_module):
|
|
||||||
p = _plugin(manager_module)
|
|
||||||
p.current_app = _App([("frame", 100)])
|
|
||||||
p.display_manager.update_display.side_effect = RuntimeError("panel gone")
|
|
||||||
|
|
||||||
assert p._display_frame() is False
|
|
||||||
|
|
||||||
def test_a_good_update_returns_true(self, manager_module):
|
|
||||||
p = _plugin(manager_module)
|
|
||||||
p.current_app = _App([("frame", 100)])
|
|
||||||
|
|
||||||
assert p._display_frame() is True
|
|
||||||
|
|
||||||
def test_no_frames_returns_false(self, manager_module):
|
|
||||||
p = _plugin(manager_module)
|
|
||||||
p.current_app = _App([])
|
|
||||||
|
|
||||||
assert p._display_frame() is False
|
|
||||||
|
|
||||||
def test_display_reports_the_frame_failure(self, manager_module):
|
|
||||||
p = _plugin(manager_module)
|
|
||||||
p.current_app = _App([("frame", 100)])
|
|
||||||
p.display_manager.update_display.side_effect = RuntimeError("panel gone")
|
|
||||||
|
|
||||||
result = p.display()
|
|
||||||
|
|
||||||
assert result is False, "display() claimed success over a failed frame update"
|
|
||||||
assert isinstance(result, bool), "the controller only skips on a real bool"
|
|
||||||
|
|
||||||
|
|
||||||
class _SelectableApp(_App):
|
|
||||||
"""An app that display() can pick, render and show."""
|
|
||||||
|
|
||||||
def __init__(self, app_id, enabled=True):
|
|
||||||
super().__init__(frames=[("frame", 100)])
|
|
||||||
self.app_id = app_id
|
|
||||||
self._enabled = enabled
|
|
||||||
|
|
||||||
def is_enabled(self):
|
|
||||||
return self._enabled
|
|
||||||
|
|
||||||
|
|
||||||
def _plugin_with_apps(manager_module, *app_ids):
|
|
||||||
p = _plugin(manager_module)
|
|
||||||
p.apps = {app_id: _SelectableApp(app_id) for app_id in app_ids}
|
|
||||||
p._display_frame = lambda: True
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
class TestDisplayModeSelectsTheApp:
|
|
||||||
"""The controller passes the mode it is rotating to; the plugin must use it.
|
|
||||||
|
|
||||||
It inspects display()'s signature to decide whether to pass display_mode at
|
|
||||||
all, so the parameter is the whole mechanism -- without it there is no way
|
|
||||||
to address one specific app, including from an on-demand request pinned to
|
|
||||||
one.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_display_accepts_display_mode(self, manager_module):
|
|
||||||
import inspect
|
|
||||||
params = inspect.signature(manager_module.StarlarkAppsPlugin.display).parameters
|
|
||||||
assert "display_mode" in params, \
|
|
||||||
"the controller only passes display_mode to plugins whose signature accepts it"
|
|
||||||
|
|
||||||
def test_a_named_app_is_the_one_shown(self, manager_module):
|
|
||||||
p = _plugin_with_apps(manager_module, "aquarium", "printer", "nowplaying")
|
|
||||||
assert p.display(display_mode="printer") is True
|
|
||||||
assert p.current_app.app_id == "printer"
|
|
||||||
|
|
||||||
def test_a_named_app_wins_over_the_one_already_showing(self, manager_module):
|
|
||||||
p = _plugin_with_apps(manager_module, "aquarium", "printer")
|
|
||||||
p.display(display_mode="aquarium")
|
|
||||||
p.display(display_mode="printer")
|
|
||||||
assert p.current_app.app_id == "printer"
|
|
||||||
|
|
||||||
def test_an_unknown_mode_falls_back_to_rotation(self, manager_module):
|
|
||||||
"""The plugin id arrives here when no per-app modes are exposed."""
|
|
||||||
p = _plugin_with_apps(manager_module, "aquarium")
|
|
||||||
assert p.display(display_mode="starlark-apps") is True
|
|
||||||
assert p.current_app.app_id == "aquarium"
|
|
||||||
|
|
||||||
def test_force_clear_does_not_override_a_named_app(self, manager_module):
|
|
||||||
p = _plugin_with_apps(manager_module, "aquarium", "printer")
|
|
||||||
p.display(display_mode="printer", force_clear=True)
|
|
||||||
assert p.current_app.app_id == "printer"
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstalledAppsTakeTurns:
|
|
||||||
"""Every installed app gets shown, not just whichever was picked first.
|
|
||||||
|
|
||||||
_select_next_app ran only while current_app was unset, so the first enabled
|
|
||||||
app was chosen once and displayed forever; the rest were rendered on
|
|
||||||
schedule and never reached the panel.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_each_entry_to_the_mode_advances(self, manager_module):
|
|
||||||
p = _plugin_with_apps(manager_module, "a", "b", "c")
|
|
||||||
seen = []
|
|
||||||
for _ in range(3):
|
|
||||||
p.display(force_clear=True) # force_clear = "you are up"
|
|
||||||
seen.append(p.current_app.app_id)
|
|
||||||
assert seen == ["a", "b", "c"]
|
|
||||||
|
|
||||||
def test_the_rotation_wraps(self, manager_module):
|
|
||||||
p = _plugin_with_apps(manager_module, "a", "b")
|
|
||||||
seen = []
|
|
||||||
for _ in range(4):
|
|
||||||
p.display(force_clear=True)
|
|
||||||
seen.append(p.current_app.app_id)
|
|
||||||
assert seen == ["a", "b", "a", "b"]
|
|
||||||
|
|
||||||
def test_frames_within_one_slot_do_not_advance_the_app(self, manager_module):
|
|
||||||
"""Only entry to the mode rotates -- the high-FPS loop must not."""
|
|
||||||
p = _plugin_with_apps(manager_module, "a", "b", "c")
|
|
||||||
p.display(force_clear=True)
|
|
||||||
for _ in range(20):
|
|
||||||
p.display(force_clear=False)
|
|
||||||
assert p.current_app.app_id == "a"
|
|
||||||
|
|
||||||
def test_a_disabled_app_is_skipped(self, manager_module):
|
|
||||||
p = _plugin_with_apps(manager_module, "a", "b")
|
|
||||||
p.apps["a"]._enabled = False
|
|
||||||
p.display(force_clear=True)
|
|
||||||
assert p.current_app.app_id == "b"
|
|
||||||
|
|
||||||
def test_no_enabled_apps_reports_nothing_displayed(self, manager_module):
|
|
||||||
p = _plugin_with_apps(manager_module, "a")
|
|
||||||
p.apps["a"]._enabled = False
|
|
||||||
assert p.display(force_clear=True) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnimationsRunAtFrameRate:
|
|
||||||
def test_the_plugin_asks_for_the_high_fps_loop(self, manager_module):
|
|
||||||
"""The controller reads this attribute; a multi-frame app is otherwise
|
|
||||||
called once per rotation slot and never advances past frame one."""
|
|
||||||
plugin = manager_module.StarlarkAppsPlugin
|
|
||||||
assert getattr(plugin, "enable_scrolling", False) is True
|
|
||||||
@@ -470,10 +470,7 @@ class TestApiBoundsMatchValidate:
|
|||||||
"""Extract the numeric_fields map from api_v3 without importing Flask."""
|
"""Extract the numeric_fields map from api_v3 without importing Flask."""
|
||||||
import ast
|
import ast
|
||||||
import pathlib
|
import pathlib
|
||||||
# api_v3 is a package; the routes are spread across its modules.
|
src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text()
|
||||||
src = '\n'.join(
|
|
||||||
p.read_text() for p in
|
|
||||||
sorted(pathlib.Path('web_interface/blueprints/api_v3').glob('*.py')))
|
|
||||||
tree = ast.parse(src)
|
tree = ast.parse(src)
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
if not isinstance(node, ast.Assign):
|
if not isinstance(node, ast.Assign):
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ class TestConfigAPI:
|
|||||||
class TestSystemAPI:
|
class TestSystemAPI:
|
||||||
"""Test system API endpoints."""
|
"""Test system API endpoints."""
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
@patch('web_interface.blueprints.api_v3.subprocess')
|
||||||
def test_get_system_status(self, mock_subprocess, client):
|
def test_get_system_status(self, mock_subprocess, client):
|
||||||
"""Test getting system status."""
|
"""Test getting system status."""
|
||||||
# The endpoint returns 503 without psutil, which is an optional
|
# The endpoint returns 503 without psutil, which is an optional
|
||||||
@@ -407,7 +407,7 @@ class TestSystemAPI:
|
|||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'service' in data or 'status' in data or 'active' in data
|
assert 'service' in data or 'status' in data or 'active' in data
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
@patch('web_interface.blueprints.api_v3.subprocess')
|
||||||
def test_get_system_version(self, mock_subprocess, client):
|
def test_get_system_version(self, mock_subprocess, client):
|
||||||
"""Test getting system version."""
|
"""Test getting system version."""
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
@@ -421,7 +421,7 @@ class TestSystemAPI:
|
|||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'version' in data.get('data', {}) or 'version' in data
|
assert 'version' in data.get('data', {}) or 'version' in data
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
@patch('web_interface.blueprints.api_v3.subprocess')
|
||||||
def test_execute_system_action(self, mock_subprocess, client):
|
def test_execute_system_action(self, mock_subprocess, client):
|
||||||
"""Test executing system action."""
|
"""Test executing system action."""
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
@@ -502,7 +502,7 @@ class TestDisplayAPI:
|
|||||||
if response.status_code in [200, 201]:
|
if response.status_code in [200, 201]:
|
||||||
assert api_v3.cache_manager.set.called
|
assert api_v3.cache_manager.set.called
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3.display._ensure_cache_manager')
|
@patch('web_interface.blueprints.api_v3._ensure_cache_manager')
|
||||||
def test_stop_on_demand_display(self, mock_ensure_cache, client):
|
def test_stop_on_demand_display(self, mock_ensure_cache, client):
|
||||||
"""Test stopping on-demand display."""
|
"""Test stopping on-demand display."""
|
||||||
|
|
||||||
|
|||||||
@@ -114,62 +114,9 @@ class TestHandlersCarryDetail:
|
|||||||
"""
|
"""
|
||||||
import ast
|
import ast
|
||||||
|
|
||||||
# api_v3 is a package; the routes are spread across its modules.
|
src = open("web_interface/blueprints/api_v3.py").read()
|
||||||
import pathlib
|
|
||||||
src = "\n".join(
|
|
||||||
p.read_text() for p in
|
|
||||||
sorted(pathlib.Path("web_interface/blueprints/api_v3").glob("*.py")))
|
|
||||||
tree = ast.parse(src)
|
tree = ast.parse(src)
|
||||||
|
generic = "An error occurred; see logs for details"
|
||||||
# This used to match one exact message string, so a handler that wrote
|
|
||||||
# its own wording was never checked. All thirteen Starlark routes did
|
|
||||||
# -- "Failed to browse repository" and friends -- and every one of them
|
|
||||||
# answered a 500 with no detail at all, which is how the app store
|
|
||||||
# spent three releases failing for reasons nobody could read. The rule
|
|
||||||
# is now the shape that matters: if it returns 5xx, it says why.
|
|
||||||
PRE_EXISTING = {
|
|
||||||
# Not part of this change. This set may shrink, never grow.
|
|
||||||
'backup_delete', 'backup_export', 'backup_list', 'backup_preview',
|
|
||||||
'backup_restore', 'backup_validate', 'checkout_branch',
|
|
||||||
'execute_system_action', 'get_git_branches', 'get_git_info',
|
|
||||||
'get_hardware_status', 'get_logs', 'get_system_status',
|
|
||||||
'get_system_version', 'scan_wifi_networks',
|
|
||||||
}
|
|
||||||
|
|
||||||
def enclosing_function(handler):
|
|
||||||
"""Innermost function containing `handler`."""
|
|
||||||
best = None
|
|
||||||
for fn in [n for n in ast.walk(tree)
|
|
||||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]:
|
|
||||||
if any(h is handler for h in ast.walk(fn)):
|
|
||||||
if best is None or fn.lineno > best.lineno:
|
|
||||||
best = fn
|
|
||||||
return best.name if best else '<module>'
|
|
||||||
|
|
||||||
def only_catches_importerror(handler):
|
|
||||||
"""An `except ImportError` arm and nothing else.
|
|
||||||
|
|
||||||
A missing optional dependency is a configuration fact, not a
|
|
||||||
crash: the module name is the whole diagnosis and it is already
|
|
||||||
in the response, so a stack trace would be noise. Detail is still
|
|
||||||
required -- only the traceback log is excused.
|
|
||||||
"""
|
|
||||||
t = handler.type
|
|
||||||
names = ([t] if isinstance(t, ast.Name)
|
|
||||||
else list(t.elts) if isinstance(t, ast.Tuple) else [])
|
|
||||||
return bool(names) and all(
|
|
||||||
isinstance(n, ast.Name) and n.id == 'ImportError' for n in names)
|
|
||||||
|
|
||||||
def returns_5xx(handler):
|
|
||||||
for r in [n for n in ast.walk(handler) if isinstance(n, ast.Return)]:
|
|
||||||
v = r.value
|
|
||||||
if isinstance(v, ast.Tuple) and len(v.elts) == 2:
|
|
||||||
code = v.elts[1]
|
|
||||||
if (isinstance(code, ast.Constant)
|
|
||||||
and isinstance(code.value, int)
|
|
||||||
and 500 <= code.value < 600):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def logs_a_traceback(handler):
|
def logs_a_traceback(handler):
|
||||||
"""An error/exception-level log call carrying exc_info."""
|
"""An error/exception-level log call carrying exc_info."""
|
||||||
@@ -214,12 +161,11 @@ class TestHandlersCarryDetail:
|
|||||||
|
|
||||||
offenders = []
|
offenders = []
|
||||||
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
|
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
|
||||||
if not returns_5xx(h):
|
seg = ast.get_source_segment(src, h) or ""
|
||||||
continue
|
if generic not in seg:
|
||||||
if enclosing_function(h) in PRE_EXISTING:
|
|
||||||
continue
|
continue
|
||||||
missing = []
|
missing = []
|
||||||
if not logs_a_traceback(h) and not only_catches_importerror(h):
|
if not logs_a_traceback(h):
|
||||||
missing.append("error-level log with exc_info")
|
missing.append("error-level log with exc_info")
|
||||||
if not returns_the_detail(h):
|
if not returns_the_detail(h):
|
||||||
missing.append("describe_exception(e) in the response")
|
missing.append("describe_exception(e) in the response")
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
"""Smooth motion is a per-frame property, not a frame-rate one.
|
|
||||||
|
|
||||||
Frame-time statistics on this project have twice looked perfect while a
|
|
||||||
marquee still visibly stuttered. They measure the wrong thing. What the eye
|
|
||||||
judges is whether the strip advances the SAME number of whole pixels on every
|
|
||||||
presented frame, and that is what these tests measure.
|
|
||||||
|
|
||||||
The old stepping derived position from ``scroll_speed * delta_time`` and then
|
|
||||||
truncated it to a pixel, so any jitter in delta_time landed either side of an
|
|
||||||
integer boundary. Against the frame times measured on hardware -- a rock-steady
|
|
||||||
100.0 fps whose individual frames still ranged 5.6ms to 15.2ms -- about 5.8% of
|
|
||||||
frames advanced 0 or 2 pixels instead of 1. Roughly six hitches a second.
|
|
||||||
"""
|
|
||||||
import random
|
|
||||||
from collections import Counter
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from src.common import scroll_config
|
|
||||||
from src.common.scroll_helper import ScrollHelper
|
|
||||||
|
|
||||||
#: Wide enough that no test consumes the strip. A scroll that completes freezes
|
|
||||||
#: at the end, and counting those frozen frames as "did not advance" is how the
|
|
||||||
#: first version of this measurement talked itself into a wrong answer.
|
|
||||||
STRIP_WIDTH = 60000
|
|
||||||
|
|
||||||
|
|
||||||
class _Panel:
|
|
||||||
"""A display manager that only has to answer the refresh question."""
|
|
||||||
|
|
||||||
refresh_hz = 100.0
|
|
||||||
|
|
||||||
def set_frame_hold(self, refreshes):
|
|
||||||
"""Recorded so a test can assert on it; the panel is not involved."""
|
|
||||||
self.hold = refreshes
|
|
||||||
|
|
||||||
|
|
||||||
def _helper():
|
|
||||||
"""A helper with a strip long enough that no sample can consume it."""
|
|
||||||
helper = ScrollHelper(128, 64)
|
|
||||||
helper.cached_image = Image.new("RGB", (STRIP_WIDTH, 64))
|
|
||||||
helper.cached_array = np.zeros((64, STRIP_WIDTH, 3), dtype=np.uint8)
|
|
||||||
helper.total_scroll_width = STRIP_WIDTH
|
|
||||||
return helper
|
|
||||||
|
|
||||||
|
|
||||||
def _measured_frame_times(count, seed=7):
|
|
||||||
"""Frame gaps shaped like the ones logged on hardware.
|
|
||||||
|
|
||||||
100.0 fps overall and a 10.00ms median, with the short and long tails that
|
|
||||||
a real vsync-paced loop actually produces.
|
|
||||||
"""
|
|
||||||
rng = random.Random(seed)
|
|
||||||
out = []
|
|
||||||
for _ in range(count):
|
|
||||||
roll = rng.random()
|
|
||||||
if roll < 0.04:
|
|
||||||
out.append(rng.uniform(5.6, 7.5))
|
|
||||||
elif roll < 0.08:
|
|
||||||
out.append(rng.uniform(13.0, 15.2))
|
|
||||||
else:
|
|
||||||
out.append(rng.gauss(10.0, 0.35))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _advances(helper, frame_times):
|
|
||||||
"""Histogram of whole-pixel movement per presented frame.
|
|
||||||
|
|
||||||
The first call is primed and discarded. update_scroll_position sets
|
|
||||||
last_update_time on its way through, so the very first call sees a
|
|
||||||
delta_time of zero and moves nothing in time-based mode. Counting that
|
|
||||||
synthetic frame put a guaranteed zero in every histogram, which was enough
|
|
||||||
on its own to satisfy "time-based stepping produces uneven motion" -- the
|
|
||||||
pin below would have passed against perfectly uniform motion.
|
|
||||||
"""
|
|
||||||
counts, now = Counter(), 1000.0
|
|
||||||
with patch("src.common.scroll_helper.time.time") as clock:
|
|
||||||
clock.return_value = now
|
|
||||||
helper.update_scroll_position()
|
|
||||||
last = int(helper.scroll_position)
|
|
||||||
for gap_ms in frame_times:
|
|
||||||
now += gap_ms / 1000.0
|
|
||||||
clock.return_value = now
|
|
||||||
helper.update_scroll_position()
|
|
||||||
position = int(helper.scroll_position)
|
|
||||||
counts[position - last] += 1
|
|
||||||
last = position
|
|
||||||
assert not helper.is_scroll_complete(), (
|
|
||||||
"the strip ran out mid-measurement; a finished scroll freezes and its "
|
|
||||||
"frozen frames read as zero-advance")
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
class TestWholePixelStepping:
|
|
||||||
def test_a_crisp_speed_moves_the_same_pixels_every_frame(self):
|
|
||||||
"""The whole point: identical movement on every frame, jitter or not."""
|
|
||||||
helper = _helper()
|
|
||||||
scroll_config.configure(helper, plugin_config=None,
|
|
||||||
default_pixels_per_second=100.0,
|
|
||||||
display_manager=_Panel(), refresh_hz=100.0)
|
|
||||||
counts = _advances(helper, _measured_frame_times(3000))
|
|
||||||
assert set(counts) == {1}, (
|
|
||||||
"frames advanced %s px; at a crisp speed every frame must move the "
|
|
||||||
"same whole number, or the motion beats against the frame rate"
|
|
||||||
% sorted(counts))
|
|
||||||
|
|
||||||
def test_the_wall_clock_is_not_consulted_at_all(self):
|
|
||||||
"""Jitter must not reach the position, however violent."""
|
|
||||||
helper = _helper()
|
|
||||||
scroll_config.configure(helper, plugin_config=None,
|
|
||||||
default_pixels_per_second=100.0,
|
|
||||||
display_manager=_Panel(), refresh_hz=100.0)
|
|
||||||
savage = [1.0, 40.0, 2.0, 25.0, 0.5, 60.0] * 200
|
|
||||||
assert set(_advances(helper, savage)) == {1}
|
|
||||||
|
|
||||||
def test_two_pixels_per_frame_is_also_uniform(self):
|
|
||||||
"""Uniformity is not special to one pixel; 200 px/s steps by two."""
|
|
||||||
helper = _helper()
|
|
||||||
scroll_config.configure(helper, plugin_config=None,
|
|
||||||
default_pixels_per_second=200.0,
|
|
||||||
display_manager=_Panel(), refresh_hz=100.0)
|
|
||||||
assert helper.fixed_pixels_per_frame == 2
|
|
||||||
assert set(_advances(helper, _measured_frame_times(2000))) == {2}
|
|
||||||
|
|
||||||
def test_time_based_stepping_is_what_it_replaces(self):
|
|
||||||
"""Pin the defect, so a revert cannot pass this file quietly."""
|
|
||||||
helper = _helper()
|
|
||||||
helper.set_scroll_speed(100.0) # the old path: px/s off the clock
|
|
||||||
assert helper.fixed_pixels_per_frame is None
|
|
||||||
counts = _advances(helper, _measured_frame_times(3000))
|
|
||||||
total = sum(counts.values())
|
|
||||||
uneven = sum(n for px, n in counts.items() if px != 1)
|
|
||||||
|
|
||||||
# A proportion, not "more than zero". Against these frame times the
|
|
||||||
# old path misses roughly one frame in twenty; 1% is comfortably below
|
|
||||||
# that and still far above anything a stray frame could produce, so
|
|
||||||
# this fails if the defect is genuinely gone rather than merely rare.
|
|
||||||
assert uneven > total * 0.01, (
|
|
||||||
"time-based stepping produced only %d uneven frames in %d against "
|
|
||||||
"real measured frame times. If that is genuinely fixed elsewhere, "
|
|
||||||
"this test and the fixed-step mode both deserve re-examining"
|
|
||||||
% (uneven, total))
|
|
||||||
|
|
||||||
|
|
||||||
class TestItStaysOptIn:
|
|
||||||
def test_a_speed_that_is_not_crisp_keeps_pacing_off_time(self):
|
|
||||||
"""Without a whole-pixel step to take, elapsed time is still the best
|
|
||||||
available answer -- fixed stepping is opt-in, not a global switch."""
|
|
||||||
helper = _helper()
|
|
||||||
scroll_config.configure(helper, plugin_config=None,
|
|
||||||
default_pixels_per_second=100.0,
|
|
||||||
display_manager=_Panel(), refresh_hz=100.0,
|
|
||||||
snap_to_crisp=False)
|
|
||||||
assert helper.fixed_pixels_per_frame is None
|
|
||||||
|
|
||||||
def test_setting_a_speed_directly_drops_the_fixed_step(self):
|
|
||||||
"""Otherwise the new speed is silently ignored."""
|
|
||||||
helper = _helper()
|
|
||||||
scroll_config.configure(helper, plugin_config=None,
|
|
||||||
default_pixels_per_second=100.0,
|
|
||||||
display_manager=_Panel(), refresh_hz=100.0)
|
|
||||||
assert helper.fixed_pixels_per_frame == 1
|
|
||||||
helper.set_scroll_speed(37.0)
|
|
||||||
assert helper.fixed_pixels_per_frame is None
|
|
||||||
assert helper.scroll_speed == 37.0
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", [None, 0])
|
|
||||||
def test_clearing_the_step_restores_time_based_motion(self, value):
|
|
||||||
"""Zero and None both mean "no fixed step", not "advance zero pixels"."""
|
|
||||||
helper = _helper()
|
|
||||||
helper.set_pixels_per_frame(3)
|
|
||||||
helper.set_pixels_per_frame(value)
|
|
||||||
assert helper.fixed_pixels_per_frame is None
|
|
||||||
|
|
||||||
def test_an_older_core_without_the_setter_still_configures(self):
|
|
||||||
"""Plugins ship independently of the core they run against."""
|
|
||||||
class Old:
|
|
||||||
"""A helper from before set_pixels_per_frame existed."""
|
|
||||||
|
|
||||||
scroll_speed = None
|
|
||||||
|
|
||||||
def set_scroll_speed(self, v):
|
|
||||||
"""The one call this old helper does understand."""
|
|
||||||
self.scroll_speed = v
|
|
||||||
|
|
||||||
def set_frame_based_scrolling(self, v):
|
|
||||||
"""Accepted and ignored; the mode is not what is under test."""
|
|
||||||
|
|
||||||
def set_target_fps(self, v):
|
|
||||||
"""Accepted and ignored; the rate is not what is under test."""
|
|
||||||
|
|
||||||
old = Old()
|
|
||||||
scroll_config.configure(old, plugin_config=None,
|
|
||||||
default_pixels_per_second=100.0,
|
|
||||||
display_manager=_Panel(), refresh_hz=100.0)
|
|
||||||
assert old.scroll_speed == 100.0
|
|
||||||
@@ -140,41 +140,6 @@ class TestRequestValidation:
|
|||||||
assert restore.call_args[0][2].restore_config is True
|
assert restore.call_args[0][2].restore_config is True
|
||||||
|
|
||||||
|
|
||||||
class TestOptionsAreBooleanAware:
|
|
||||||
"""Regression: bool("false") is True in Python.
|
|
||||||
|
|
||||||
Every restore flag used bare bool() coercion, so a caller that sends its
|
|
||||||
options as JSON strings rather than real booleans -- a form field, a
|
|
||||||
hand-built request -- had `{"restore_secrets": "false"}` restore secrets
|
|
||||||
anyway, the opposite of what was asked. Fixed with the same
|
|
||||||
string-aware `_coerce_to_bool` already used for checkbox-style config
|
|
||||||
fields elsewhere in this package (config.py, plugins.py).
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("raw,expected", [
|
|
||||||
("false", False), ("False", False), ("FALSE", False),
|
|
||||||
("0", False),
|
|
||||||
("true", True), ("True", True), ("1", True),
|
|
||||||
])
|
|
||||||
def test_string_valued_flags_are_parsed_not_just_truthy(
|
|
||||||
self, client, restore, raw, expected):
|
|
||||||
post(client, options=json.dumps({"restore_secrets": raw}))
|
|
||||||
assert restore.call_args[0][2].restore_secrets is expected
|
|
||||||
|
|
||||||
def test_a_string_false_does_not_restore_secrets(self, client, restore):
|
|
||||||
# The exact shape of the bug: a truthy non-empty string coerced by
|
|
||||||
# bare bool() to True regardless of its contents.
|
|
||||||
post(client, options=json.dumps({"restore_secrets": "false"}))
|
|
||||||
assert restore.call_args[0][2].restore_secrets is False
|
|
||||||
|
|
||||||
def test_real_json_booleans_still_work(self, client, restore):
|
|
||||||
post(client, options=json.dumps({"restore_secrets": False,
|
|
||||||
"restore_config": True}))
|
|
||||||
options = restore.call_args[0][2]
|
|
||||||
assert options.restore_secrets is False
|
|
||||||
assert options.restore_config is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestSuccess:
|
class TestSuccess:
|
||||||
def test_success_returns_the_result(self, client, restore):
|
def test_success_returns_the_result(self, client, restore):
|
||||||
restore.return_value = FakeResult(success=True, restored=["config", "secrets"])
|
restore.return_value = FakeResult(success=True, restored=["config", "secrets"])
|
||||||
@@ -265,7 +230,7 @@ class TestPluginReinstall:
|
|||||||
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
|
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
|
||||||
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
||||||
api_v3.plugin_store_manager = None
|
api_v3.plugin_store_manager = None
|
||||||
with patch("web_interface.blueprints.api_v3.backup.plugin_store_manager", None):
|
with patch("web_interface.blueprints.api_v3.plugin_store_manager", None):
|
||||||
body = post(client).get_json()
|
body = post(client).get_json()
|
||||||
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
|
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
|
||||||
|
|
||||||
|
|||||||
@@ -77,10 +77,7 @@ class TestTheRoutesExistAtAll:
|
|||||||
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
|
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||||
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
|
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
|
||||||
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
|
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
|
||||||
# api_v3 is a package now, so the route strings are spread across its
|
source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
|
||||||
# modules; read the whole directory rather than one file.
|
|
||||||
pkg = Path(project_root) / 'web_interface/blueprints/api_v3'
|
|
||||||
source = "\n".join(f.read_text(encoding='utf-8') for f in sorted(pkg.glob('*.py')))
|
|
||||||
assert "'/plugins/calendar/list-calendars'" in source
|
assert "'/plugins/calendar/list-calendars'" in source
|
||||||
assert "'/plugins/calendar/authenticate'" in source
|
assert "'/plugins/calendar/authenticate'" in source
|
||||||
|
|
||||||
@@ -363,22 +360,6 @@ class TestDiagnosticsAreRedacted:
|
|||||||
assert 'hunter2' not in error, error
|
assert 'hunter2' not in error, error
|
||||||
assert '<redacted>' in error, error
|
assert '<redacted>' in error, error
|
||||||
|
|
||||||
def test_script_stderr_is_redacted_in_the_log_too(self, tmp_path, caplog):
|
|
||||||
# Regression: the return value went through redact_text (asserted
|
|
||||||
# above), but the logger.error call right next to it logged `raw`
|
|
||||||
# verbatim -- a script that handles OAuth client secrets and can
|
|
||||||
# quote them in its stderr, landing unredacted in the log (CWE-532).
|
|
||||||
script = tmp_path / 'calendar_registration.py'
|
|
||||||
script.write_text(
|
|
||||||
'import sys\n'
|
|
||||||
'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
|
|
||||||
encoding='utf-8')
|
|
||||||
with caplog.at_level('ERROR', logger=mod.logger.name):
|
|
||||||
mod._run_calendar_registration(tmp_path, '')
|
|
||||||
logged = '\n'.join(r.getMessage() for r in caplog.records)
|
|
||||||
assert 'hunter2' not in logged, logged
|
|
||||||
assert '<redacted>' in logged, logged
|
|
||||||
|
|
||||||
def test_a_failing_script_payload_is_redacted(self, client):
|
def test_a_failing_script_payload_is_redacted(self, client):
|
||||||
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
|
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
|
||||||
(client.plugin_dir / 'calendar_registration.py').write_text(
|
(client.plugin_dir / 'calendar_registration.py').write_text(
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
API_V3_PKG = (Path(__file__).resolve().parents[2]
|
SOURCE = (Path(__file__).resolve().parents[2]
|
||||||
/ "web_interface" / "blueprints" / "api_v3")
|
/ "web_interface" / "blueprints" / "api_v3.py")
|
||||||
|
|
||||||
#: Objects that still hold submitted secret values at the point these log
|
#: Objects that still hold submitted secret values at the point these log
|
||||||
#: calls run. Interpolating one whole into a log message leaks credentials.
|
#: calls run. Interpolating one whole into a log message leaks credentials.
|
||||||
@@ -19,7 +19,7 @@ UNREDACTED = ("plugin_config", "secrets_config", "current_secrets")
|
|||||||
|
|
||||||
|
|
||||||
def _logging_lines():
|
def _logging_lines():
|
||||||
for number, line in enumerate("\n".join(p.read_text(encoding="utf-8") for p in sorted(API_V3_PKG.glob("*.py"))).splitlines(), 1):
|
for number, line in enumerate(SOURCE.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped.startswith("#"):
|
if stripped.startswith("#"):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -15,18 +15,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
||||||
|
|
||||||
API_V3_PKG = (Path(__file__).resolve().parents[2]
|
API_V3_PATH = (Path(__file__).resolve().parents[2]
|
||||||
/ "web_interface" / "blueprints" / "api_v3")
|
/ "web_interface" / "blueprints" / "api_v3.py")
|
||||||
|
|
||||||
|
|
||||||
def _api_v3_source() -> str:
|
|
||||||
"""Every module of the api_v3 package as one string.
|
|
||||||
|
|
||||||
It used to be a single file; the inline copies this guards against could
|
|
||||||
now reappear in any module of the package.
|
|
||||||
"""
|
|
||||||
return "\n".join(p.read_text(encoding="utf-8")
|
|
||||||
for p in sorted(API_V3_PKG.glob("*.py")))
|
|
||||||
|
|
||||||
# The migration is complete: any inline reimplementation is a regression.
|
# The migration is complete: any inline reimplementation is a regression.
|
||||||
EXPECTED_INLINE_COPIES = 0
|
EXPECTED_INLINE_COPIES = 0
|
||||||
@@ -34,7 +24,7 @@ EXPECTED_INLINE_COPIES = 0
|
|||||||
|
|
||||||
class TestNoInlineCopies:
|
class TestNoInlineCopies:
|
||||||
def _count(self, name: str) -> int:
|
def _count(self, name: str) -> int:
|
||||||
source = _api_v3_source()
|
source = API_V3_PATH.read_text(encoding="utf-8")
|
||||||
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
|
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
|
||||||
|
|
||||||
def test_no_inline_find_secret_fields(self):
|
def test_no_inline_find_secret_fields(self):
|
||||||
@@ -56,7 +46,7 @@ class TestNoInlineCopies:
|
|||||||
def test_canonical_import_present(self):
|
def test_canonical_import_present(self):
|
||||||
# Tripwire: the endpoints still need the helpers, so removing the
|
# Tripwire: the endpoints still need the helpers, so removing the
|
||||||
# import means either dead secret handling or a new local copy.
|
# import means either dead secret handling or a new local copy.
|
||||||
source = _api_v3_source()
|
source = API_V3_PATH.read_text(encoding="utf-8")
|
||||||
assert re.search(
|
assert re.search(
|
||||||
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
|
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -1,876 +0,0 @@
|
|||||||
"""The Starlark routes the frontend calls must exist.
|
|
||||||
|
|
||||||
`plugins_manager.js` posts to /api/v3/starlark/install-pixlet and then reloads
|
|
||||||
/api/v3/starlark/status. Neither route existed: #330 rewrote api_v3.py and
|
|
||||||
dropped all thirteen Starlark routes that #253 had added, so both calls fell
|
|
||||||
through to Flask's 404 handler, which answers
|
|
||||||
|
|
||||||
{"status": "error", "message": "Resource not found"}
|
|
||||||
|
|
||||||
and the button reported "Pixlet install failed: Resource not found" -- a
|
|
||||||
message that names neither the resource nor the cause.
|
|
||||||
|
|
||||||
These assert the routes are registered and answer in the shape the frontend
|
|
||||||
reads, so a future rewrite of this file cannot silently drop them again.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
from web_interface.app import app
|
|
||||||
app.config['TESTING'] = True
|
|
||||||
with app.test_client() as c:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
|
|
||||||
class TestRoutesAreRegistered:
|
|
||||||
"""The failure was a missing route, so check the URL map directly.
|
|
||||||
|
|
||||||
All thirteen, not just the two the Pixlet button needs: #330 dropped the
|
|
||||||
lot, and the app store page is built on repository/browse,
|
|
||||||
repository/categories and repository/install, which 404 the same way.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("rule,method", [
|
|
||||||
("/api/v3/starlark/install-pixlet", "POST"),
|
|
||||||
("/api/v3/starlark/status", "GET"),
|
|
||||||
("/api/v3/starlark/apps", "GET"),
|
|
||||||
("/api/v3/starlark/upload", "POST"),
|
|
||||||
("/api/v3/starlark/repository/browse", "GET"),
|
|
||||||
("/api/v3/starlark/repository/categories", "GET"),
|
|
||||||
("/api/v3/starlark/repository/install", "POST"),
|
|
||||||
("/api/v3/starlark/apps/<app_id>", "GET"),
|
|
||||||
("/api/v3/starlark/apps/<app_id>", "DELETE"),
|
|
||||||
("/api/v3/starlark/apps/<app_id>/config", "GET"),
|
|
||||||
("/api/v3/starlark/apps/<app_id>/config", "PUT"),
|
|
||||||
("/api/v3/starlark/apps/<app_id>/toggle", "POST"),
|
|
||||||
("/api/v3/starlark/apps/<app_id>/render", "POST"),
|
|
||||||
])
|
|
||||||
def test_route_exists(self, rule, method):
|
|
||||||
from web_interface.app import app
|
|
||||||
matches = [r for r in app.url_map.iter_rules()
|
|
||||||
if r.rule == rule and method in r.methods]
|
|
||||||
assert matches, (
|
|
||||||
f"{method} {rule} is not registered; the frontend calls it and "
|
|
||||||
"would get Flask's generic 'Resource not found'")
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallPixlet:
|
|
||||||
def test_it_does_not_404(self, client):
|
|
||||||
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
|
||||||
run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
|
||||||
assert resp.status_code != 404, "the route is still missing"
|
|
||||||
assert resp.get_json().get('message') != 'Resource not found'
|
|
||||||
|
|
||||||
def test_success_is_reported_in_the_shape_the_button_reads(self, client):
|
|
||||||
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
|
||||||
run.return_value = MagicMock(returncode=0, stdout="done", stderr="")
|
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
|
||||||
body = resp.get_json()
|
|
||||||
assert body['status'] == 'success', body
|
|
||||||
assert 'message' in body, "the JS shows data.message on success"
|
|
||||||
|
|
||||||
def test_a_failed_download_says_why(self, client):
|
|
||||||
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
|
||||||
run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release")
|
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
|
||||||
body = resp.get_json()
|
|
||||||
assert body['status'] == 'error'
|
|
||||||
assert 'no such release' in body['message'], \
|
|
||||||
"the installer's own stderr is what tells the user what went wrong"
|
|
||||||
|
|
||||||
def test_a_timeout_is_reported_rather_than_hanging(self, client):
|
|
||||||
import subprocess as sp
|
|
||||||
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run',
|
|
||||||
side_effect=sp.TimeoutExpired(cmd='x', timeout=300)):
|
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
|
||||||
assert resp.get_json()['status'] == 'error'
|
|
||||||
assert 'timed out' in resp.get_json()['message'].lower()
|
|
||||||
|
|
||||||
|
|
||||||
class TestStarlarkStatus:
|
|
||||||
def test_it_does_not_404(self, client):
|
|
||||||
resp = client.get('/api/v3/starlark/status')
|
|
||||||
assert resp.status_code != 404, "the route is still missing"
|
|
||||||
assert resp.get_json().get('message') != 'Resource not found'
|
|
||||||
|
|
||||||
def test_it_reports_pixlet_availability_without_the_plugin_loaded(self, client):
|
|
||||||
# The status call runs before install too -- it must answer even when
|
|
||||||
# starlark-apps is not loaded, which is the state a user is in when
|
|
||||||
# they press the install button for the first time.
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None):
|
|
||||||
resp = client.get('/api/v3/starlark/status')
|
|
||||||
body = resp.get_json()
|
|
||||||
assert body['status'] == 'success', body
|
|
||||||
assert 'pixlet_available' in body
|
|
||||||
assert body['plugin_loaded'] is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheInstallerScriptIsActuallyThere:
|
|
||||||
def test_download_pixlet_script_exists_and_is_executable(self):
|
|
||||||
# install_pixlet chmods and runs this; a missing file is the one error
|
|
||||||
# it reports as a 404 of its own, which would look identical to the
|
|
||||||
# bug being fixed here.
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from web_interface.blueprints.api_v3 import PROJECT_ROOT
|
|
||||||
script = Path(PROJECT_ROOT) / 'scripts' / 'download_pixlet.sh'
|
|
||||||
assert script.is_file(), f"{script} is missing; install_pixlet would 404"
|
|
||||||
assert os.access(script, os.R_OK)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheAppStoreFlow:
|
|
||||||
"""Browsing and installing from the Tronbyte repository.
|
|
||||||
|
|
||||||
These are the calls the app store page makes. Each returned the generic
|
|
||||||
"Resource not found" before this change, which is indistinguishable from
|
|
||||||
an empty store.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def offline_repo(self):
|
|
||||||
"""No live GitHub calls from the test suite.
|
|
||||||
|
|
||||||
browse and categories reach _get_tronbyte_repository_class() and then
|
|
||||||
list_all_apps_cached(); with a cold server-side cache that is a real
|
|
||||||
network request, which makes the run slow, rate-limitable, and able to
|
|
||||||
pass on a 500 because these assertions only check for a 404.
|
|
||||||
"""
|
|
||||||
repo = MagicMock()
|
|
||||||
# Matches what the real list_all_apps_cached returns; the handler
|
|
||||||
# indexes every one of these keys.
|
|
||||||
repo.return_value.list_all_apps_cached.return_value = {
|
|
||||||
'apps': [{'id': 'quoteoftheday', 'name': 'A Quote A Day',
|
|
||||||
'category': 'text'}],
|
|
||||||
'categories': ['text'],
|
|
||||||
'authors': ['someone'],
|
|
||||||
'count': 1,
|
|
||||||
'cached': True,
|
|
||||||
}
|
|
||||||
repo.return_value.get_rate_limit_info.return_value = {'remaining': 5000}
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
|
||||||
return_value=repo):
|
|
||||||
yield repo
|
|
||||||
|
|
||||||
def test_browse_does_not_404(self, client, offline_repo):
|
|
||||||
resp = client.get('/api/v3/starlark/repository/browse')
|
|
||||||
assert resp.status_code != 404, "the store cannot list anything"
|
|
||||||
assert resp.get_json().get('message') != 'Resource not found'
|
|
||||||
|
|
||||||
def test_browse_returns_the_apps_the_store_lists(self, client, offline_repo):
|
|
||||||
resp = client.get('/api/v3/starlark/repository/browse')
|
|
||||||
body = resp.get_json()
|
|
||||||
assert body['status'] == 'success', body
|
|
||||||
assert any(a.get('id') == 'quoteoftheday' for a in body.get('apps', [])), body
|
|
||||||
|
|
||||||
def test_categories_does_not_404(self, client, offline_repo):
|
|
||||||
resp = client.get('/api/v3/starlark/repository/categories')
|
|
||||||
assert resp.status_code != 404
|
|
||||||
assert resp.get_json().get('message') != 'Resource not found'
|
|
||||||
|
|
||||||
def test_no_live_network_call_is_made(self, client, offline_repo):
|
|
||||||
client.get('/api/v3/starlark/repository/browse')
|
|
||||||
assert offline_repo.called, \
|
|
||||||
"the route did not go through the patched repository class"
|
|
||||||
|
|
||||||
def test_installed_apps_list_does_not_404(self, client):
|
|
||||||
resp = client.get('/api/v3/starlark/apps')
|
|
||||||
assert resp.status_code != 404
|
|
||||||
assert resp.get_json().get('message') != 'Resource not found'
|
|
||||||
|
|
||||||
def test_repository_install_rejects_a_missing_body_rather_than_404ing(self, client):
|
|
||||||
# A 400/422 here is the route working: it received the call and said
|
|
||||||
# what was wrong. A 404 means it was never reached at all.
|
|
||||||
resp = client.post('/api/v3/starlark/repository/install',
|
|
||||||
json={}, content_type='application/json')
|
|
||||||
assert resp.status_code != 404, "the install route is still missing"
|
|
||||||
assert resp.get_json().get('message') != 'Resource not found'
|
|
||||||
|
|
||||||
def test_upload_rejects_an_empty_post_rather_than_404ing(self, client):
|
|
||||||
resp = client.post('/api/v3/starlark/upload')
|
|
||||||
assert resp.status_code != 404
|
|
||||||
assert resp.get_json().get('message') != 'Resource not found'
|
|
||||||
|
|
||||||
|
|
||||||
class TestNoStarlarkRouteIsMissing:
|
|
||||||
"""A single check that the whole set is present.
|
|
||||||
|
|
||||||
#330 removed all thirteen at once by rewriting this file. One assertion
|
|
||||||
over the frontend's own list is what would have caught that.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_every_endpoint_the_frontend_calls_is_registered(self):
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from werkzeug.exceptions import MethodNotAllowed, NotFound
|
|
||||||
from web_interface.app import app
|
|
||||||
|
|
||||||
root = Path(__file__).resolve().parent.parent.parent
|
|
||||||
js = (root / 'web_interface' / 'static' / 'v3' / 'plugins_manager.js').read_text()
|
|
||||||
|
|
||||||
# The frontend builds some of these with template literals, e.g.
|
|
||||||
# `/api/v3/starlark/apps/${appId}/toggle`. Substitute a placeholder so
|
|
||||||
# the URL is concrete, then let Werkzeug match it the way a request
|
|
||||||
# would -- string comparison cannot see <app_id> rules.
|
|
||||||
raw = set(re.findall(r"[\'\"`](/api/v3/starlark/[^\'\"`\s]*)", js))
|
|
||||||
urls = set()
|
|
||||||
for u in raw:
|
|
||||||
u = re.sub(r"\$\{[^}]*\}", "probe", u)
|
|
||||||
urls.add(u.rstrip('/') or u)
|
|
||||||
assert urls, "found no starlark calls in the frontend -- did the file move?"
|
|
||||||
|
|
||||||
adapter = app.url_map.bind('localhost')
|
|
||||||
missing = []
|
|
||||||
for u in sorted(urls):
|
|
||||||
try:
|
|
||||||
adapter.match(u, method='GET')
|
|
||||||
except MethodNotAllowed:
|
|
||||||
pass # route exists, just not for GET -- fine
|
|
||||||
except NotFound:
|
|
||||||
missing.append(u)
|
|
||||||
assert not missing, f"the frontend calls these and they are not registered: {missing}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstalledAppsAppearWithTheOtherPlugins:
|
|
||||||
"""An installed .star app must be manageable like any other plugin.
|
|
||||||
|
|
||||||
#253 surfaced installed apps in /plugins/installed as `starlark:<app_id>`
|
|
||||||
entries, so they could be seen and enabled/disabled from the same list as
|
|
||||||
everything else, and routed `starlark:` toggles to the Starlark manifest.
|
|
||||||
#330 removed both. The result was an app that installs successfully, then
|
|
||||||
appears nowhere and cannot be turned on or off.
|
|
||||||
"""
|
|
||||||
|
|
||||||
APPS = {'apps': {'quoteoftheday': {'name': 'A Quote A Day', 'enabled': True}}}
|
|
||||||
|
|
||||||
def test_an_installed_app_is_listed(self, client):
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
||||||
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS):
|
|
||||||
resp = client.get('/api/v3/plugins/installed')
|
|
||||||
ids = [p['id'] for p in resp.get_json()['data']['plugins']]
|
|
||||||
assert 'starlark:quoteoftheday' in ids, \
|
|
||||||
"an installed Starlark app does not appear among the plugins"
|
|
||||||
|
|
||||||
def test_the_entry_carries_what_the_ui_needs(self, client):
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
||||||
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS):
|
|
||||||
resp = client.get('/api/v3/plugins/installed')
|
|
||||||
entry = next(p for p in resp.get_json()['data']['plugins']
|
|
||||||
if p['id'] == 'starlark:quoteoftheday')
|
|
||||||
assert entry['name'] == 'A Quote A Day'
|
|
||||||
assert entry['enabled'] is True
|
|
||||||
assert entry['is_starlark_app'] is True, "the UI keys its Starlark handling off this"
|
|
||||||
assert entry['category'] == 'Starlark App'
|
|
||||||
|
|
||||||
def test_a_starlark_failure_does_not_empty_the_plugin_list(self, client):
|
|
||||||
# The virtual entries are appended to the real ones; a broken manifest
|
|
||||||
# must cost the Starlark rows, not everybody else's.
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
|
|
||||||
side_effect=RuntimeError('boom')):
|
|
||||||
resp = client.get('/api/v3/plugins/installed')
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.get_json()['status'] == 'success'
|
|
||||||
|
|
||||||
def test_toggling_an_app_does_not_report_plugin_not_found(self, client):
|
|
||||||
written = {}
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
||||||
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
|
||||||
return_value={'apps': {'quoteoftheday': {'enabled': True}}}), \
|
|
||||||
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
|
|
||||||
side_effect=lambda m: written.update(m) or True):
|
|
||||||
resp = client.post('/api/v3/plugins/toggle',
|
|
||||||
json={'plugin_id': 'starlark:quoteoftheday', 'enabled': False})
|
|
||||||
body = resp.get_json()
|
|
||||||
assert body['status'] == 'success', body
|
|
||||||
assert body['enabled'] is False
|
|
||||||
assert written['apps']['quoteoftheday']['enabled'] is False, \
|
|
||||||
"the manifest was not actually updated"
|
|
||||||
|
|
||||||
def test_toggling_an_unknown_app_says_so(self, client):
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
||||||
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
|
||||||
return_value={'apps': {}}):
|
|
||||||
resp = client.post('/api/v3/plugins/toggle',
|
|
||||||
json={'plugin_id': 'starlark:nope', 'enabled': True})
|
|
||||||
assert resp.status_code == 404
|
|
||||||
assert 'nope' in resp.get_json()['message']
|
|
||||||
|
|
||||||
def test_a_traversal_app_id_is_rejected_before_touching_the_manifest(self, client):
|
|
||||||
resp = client.post('/api/v3/plugins/toggle',
|
|
||||||
json={'plugin_id': 'starlark:../../etc/passwd', 'enabled': True})
|
|
||||||
assert resp.status_code == 400
|
|
||||||
assert 'traversal' in resp.get_json()['message']
|
|
||||||
|
|
||||||
def test_an_app_id_the_listing_published_can_be_toggled(self, client):
|
|
||||||
"""The id here is exactly what _starlark_virtual_plugins publishes.
|
|
||||||
|
|
||||||
It used to be re-slugified on the way back in -- lowercased, with every
|
|
||||||
character outside [a-z0-9_] replaced -- so an app stored as 'My-App'
|
|
||||||
was listed as 'starlark:My-App' and looked up as 'my_app'. Toggling an
|
|
||||||
app the page had just drawn answered 404.
|
|
||||||
"""
|
|
||||||
manifest = {'apps': {'My-App': {'name': 'My App', 'enabled': False}}}
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
||||||
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
|
||||||
return_value=manifest), \
|
|
||||||
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
|
|
||||||
return_value=True) as write:
|
|
||||||
resp = client.post('/api/v3/plugins/toggle',
|
|
||||||
json={'plugin_id': 'starlark:My-App', 'enabled': True})
|
|
||||||
assert resp.status_code == 200, resp.get_json()
|
|
||||||
assert write.called, "the toggle never reached the manifest"
|
|
||||||
assert manifest['apps']['My-App']['enabled'] is True
|
|
||||||
|
|
||||||
def test_a_loaded_app_missing_from_the_manifest_does_not_500(self, client):
|
|
||||||
"""_update_manifest_safe does not catch KeyError, so indexing an entry
|
|
||||||
that is not on disk yet escaped as a 500 instead of writing it."""
|
|
||||||
app = MagicMock()
|
|
||||||
app.manifest = {'enabled': False}
|
|
||||||
plugin = MagicMock()
|
|
||||||
plugin.apps = {'demo': app}
|
|
||||||
written = {}
|
|
||||||
|
|
||||||
def run_updater(fn):
|
|
||||||
fn(written)
|
|
||||||
return True
|
|
||||||
|
|
||||||
plugin._update_manifest_safe.side_effect = run_updater
|
|
||||||
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
|
||||||
resp = client.post('/api/v3/plugins/toggle',
|
|
||||||
json={'plugin_id': 'starlark:demo', 'enabled': True})
|
|
||||||
assert resp.status_code == 200, resp.get_json()
|
|
||||||
assert written['apps']['demo']['enabled'] is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheManifestStaysRelocatable:
|
|
||||||
"""`star_file` is joined to the app's own directory by its readers.
|
|
||||||
|
|
||||||
_standalone_render_starlark_app does `app_dir / app_data.get('star_file',
|
|
||||||
f'{app_id}.star')`, so the key's default is a bare filename. Storing an
|
|
||||||
absolute path gave it a second meaning, and Path.__truediv__ discards the
|
|
||||||
left side when the right is absolute -- which pinned the manifest to the
|
|
||||||
PROJECT_ROOT that installed it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def starlark_dir(self, tmp_path, monkeypatch):
|
|
||||||
from web_interface.blueprints import api_v3 as module
|
|
||||||
apps_dir = tmp_path / "starlark-apps"
|
|
||||||
apps_dir.mkdir()
|
|
||||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
|
||||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
|
||||||
return apps_dir
|
|
||||||
|
|
||||||
def _install(self, tmp_path):
|
|
||||||
from web_interface.blueprints import api_v3 as module
|
|
||||||
source = tmp_path / "source.star"
|
|
||||||
source.write_text("# app")
|
|
||||||
with patch.object(module, '_get_pixlet_renderer_class',
|
|
||||||
side_effect=ImportError("no pixlet here")):
|
|
||||||
assert module._install_star_file('demo', str(source), {'name': 'Demo'})
|
|
||||||
return json.loads((module._STARLARK_MANIFEST_FILE).read_text())['apps']['demo']
|
|
||||||
|
|
||||||
def test_the_star_file_is_recorded_by_name(self, starlark_dir, tmp_path):
|
|
||||||
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
|
||||||
|
|
||||||
def test_the_recorded_path_is_not_absolute(self, starlark_dir, tmp_path):
|
|
||||||
"""An absolute value survives a move only by accident."""
|
|
||||||
assert not os.path.isabs(self._install(tmp_path)['star_file'])
|
|
||||||
|
|
||||||
def test_the_value_resolves_against_the_app_directory(self, starlark_dir, tmp_path):
|
|
||||||
"""Which is the one thing every reader of this key does with it."""
|
|
||||||
entry = self._install(tmp_path)
|
|
||||||
assert (starlark_dir / 'demo' / entry['star_file']).is_file()
|
|
||||||
|
|
||||||
def test_it_matches_the_default_a_reader_falls_back_to(self, starlark_dir, tmp_path):
|
|
||||||
"""Stored and defaulted values must mean the same thing."""
|
|
||||||
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
|
||||||
|
|
||||||
|
|
||||||
class TestManifestLockPreventsLostUpdates:
|
|
||||||
"""The standalone manifest fallback (no plugin instance loaded) reads,
|
|
||||||
mutates and writes manifest.json with no coordination across requests.
|
|
||||||
Each write is atomic on its own (temp file + rename), but two concurrent
|
|
||||||
read-modify-write cycles can still race: both read the same starting
|
|
||||||
manifest, and the second write silently discards whatever the first one
|
|
||||||
added. _starlark_manifest_lock closes that window -- mirrors
|
|
||||||
StarlarkAppsPlugin._update_manifest_safe, which already does this when
|
|
||||||
the plugin instance is loaded.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def starlark_dir(self, tmp_path, monkeypatch):
|
|
||||||
from web_interface.blueprints import api_v3 as module
|
|
||||||
apps_dir = tmp_path / "starlark-apps"
|
|
||||||
apps_dir.mkdir()
|
|
||||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
|
||||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
|
||||||
module._write_starlark_manifest({'apps': {}})
|
|
||||||
return apps_dir
|
|
||||||
|
|
||||||
def test_two_concurrent_updates_are_both_kept(self, starlark_dir):
|
|
||||||
import threading
|
|
||||||
import time as _time
|
|
||||||
|
|
||||||
from web_interface.blueprints import api_v3 as module
|
|
||||||
|
|
||||||
def add_app(app_id):
|
|
||||||
with module._starlark_manifest_lock():
|
|
||||||
manifest = module._read_starlark_manifest()
|
|
||||||
# Widen the window between read and write. Without the lock
|
|
||||||
# both threads read here before either writes, and whichever
|
|
||||||
# writes second overwrites the other's addition; with the
|
|
||||||
# lock, the second thread cannot even start its read until
|
|
||||||
# the first has written and released.
|
|
||||||
_time.sleep(0.05)
|
|
||||||
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
|
||||||
module._write_starlark_manifest(manifest)
|
|
||||||
|
|
||||||
threads = [threading.Thread(target=add_app, args=(app_id,))
|
|
||||||
for app_id in ('a', 'b')]
|
|
||||||
for t in threads:
|
|
||||||
t.start()
|
|
||||||
for t in threads:
|
|
||||||
t.join(timeout=5)
|
|
||||||
|
|
||||||
manifest = module._read_starlark_manifest()
|
|
||||||
assert set(manifest['apps']) == {'a', 'b'}, (
|
|
||||||
"a concurrent update was lost: %r" % (manifest,))
|
|
||||||
|
|
||||||
def test_the_lock_is_reentrant_safe_across_sequential_calls(self, starlark_dir):
|
|
||||||
"""Not reentrant within one thread -- just that using it twice in a
|
|
||||||
row (the ordinary case: one request, then the next) works cleanly
|
|
||||||
and does not leak the lock file descriptor or leave it locked."""
|
|
||||||
from web_interface.blueprints import api_v3 as module
|
|
||||||
|
|
||||||
for app_id in ('first', 'second'):
|
|
||||||
with module._starlark_manifest_lock():
|
|
||||||
manifest = module._read_starlark_manifest()
|
|
||||||
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
|
||||||
module._write_starlark_manifest(manifest)
|
|
||||||
|
|
||||||
manifest = module._read_starlark_manifest()
|
|
||||||
assert set(manifest['apps']) == {'first', 'second'}
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigAndManifestStayInSync:
|
|
||||||
"""Standalone-mode PUT /starlark/apps/<id>/config (no plugin instance
|
|
||||||
loaded) writes config.json and then the manifest. If the manifest write
|
|
||||||
fails after config.json was already written, the two disagree about
|
|
||||||
what was saved unless config.json is rolled back.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def app_dir(self, tmp_path, monkeypatch):
|
|
||||||
from web_interface.blueprints import api_v3 as module
|
|
||||||
apps_dir = tmp_path / "starlark-apps"
|
|
||||||
apps_dir.mkdir()
|
|
||||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
|
||||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
|
||||||
one_app_dir = apps_dir / 'demo'
|
|
||||||
one_app_dir.mkdir()
|
|
||||||
module._write_starlark_manifest({'apps': {'demo': {'name': 'Demo', 'enabled': True}}})
|
|
||||||
return one_app_dir
|
|
||||||
|
|
||||||
def test_manifest_write_failure_rolls_back_an_existing_config_json(self, client, app_dir):
|
|
||||||
config_file = app_dir / 'config.json'
|
|
||||||
config_file.write_text(json.dumps({'existing': 'value'}))
|
|
||||||
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
||||||
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
|
|
||||||
resp = client.put('/api/v3/starlark/apps/demo/config',
|
|
||||||
json={'new_field': 'x'})
|
|
||||||
|
|
||||||
assert resp.status_code == 500
|
|
||||||
assert json.loads(config_file.read_text()) == {'existing': 'value'}, (
|
|
||||||
"config.json kept the new value even though the manifest write "
|
|
||||||
"that was supposed to follow it failed")
|
|
||||||
|
|
||||||
def test_manifest_write_failure_removes_a_freshly_created_config_json(self, client, app_dir):
|
|
||||||
config_file = app_dir / 'config.json'
|
|
||||||
assert not config_file.exists()
|
|
||||||
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
||||||
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
|
|
||||||
resp = client.put('/api/v3/starlark/apps/demo/config',
|
|
||||||
json={'new_field': 'x'})
|
|
||||||
|
|
||||||
assert resp.status_code == 500
|
|
||||||
assert not config_file.exists(), (
|
|
||||||
"config.json was left behind even though the manifest write "
|
|
||||||
"that was supposed to follow it failed")
|
|
||||||
|
|
||||||
def test_success_updates_both_config_and_manifest(self, client, app_dir):
|
|
||||||
from web_interface.blueprints import api_v3 as module
|
|
||||||
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None):
|
|
||||||
resp = client.put('/api/v3/starlark/apps/demo/config',
|
|
||||||
json={'new_field': 'x'})
|
|
||||||
|
|
||||||
assert resp.status_code == 200, resp.get_json()
|
|
||||||
assert json.loads((app_dir / 'config.json').read_text())['new_field'] == 'x'
|
|
||||||
manifest = json.loads(module._STARLARK_MANIFEST_FILE.read_text())
|
|
||||||
assert manifest['apps']['demo']['config']['new_field'] == 'x'
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The store loaded, then stopped loading, and nothing anywhere said why.
|
|
||||||
#
|
|
||||||
# #535 restored the routes, so the 404 was gone -- but two failure modes
|
|
||||||
# underneath it produce the same blank grid, and neither could be read from
|
|
||||||
# outside. On the device this was diagnosed on, /repository/browse answered
|
|
||||||
# 200 with 1000 apps in 27s while GitHub reported 18 of 60 unauthenticated
|
|
||||||
# requests remaining, with 48 installed plugins checking for updates against
|
|
||||||
# the same budget. When that budget runs out the store goes blank and says
|
|
||||||
# nothing at all.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _repository_module():
|
|
||||||
"""Load tronbyte_repository.py the way the blueprint does."""
|
|
||||||
import importlib.util
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
path = (Path(__file__).resolve().parents[2]
|
|
||||||
/ 'plugin-repos' / 'starlark-apps' / 'tronbyte_repository.py')
|
|
||||||
spec = importlib.util.spec_from_file_location('_tronbyte_repo_under_test', path)
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules['_tronbyte_repo_under_test'] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
class _Resp:
|
|
||||||
"""Enough of requests.Response for the paths under test."""
|
|
||||||
|
|
||||||
def __init__(self, status_code=200, payload=None, headers=None, raises=None):
|
|
||||||
self.status_code = status_code
|
|
||||||
self._payload = payload
|
|
||||||
self.headers = headers or {}
|
|
||||||
self._raises = raises
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
if self._raises is not None:
|
|
||||||
raise self._raises
|
|
||||||
return self._payload
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheJsonGuardIsNotItselfACrash:
|
|
||||||
"""`except (json.JSONDecodeError, ValueError)` with no `import json`.
|
|
||||||
|
|
||||||
Evaluating that tuple raises NameError, so the guard written for exactly
|
|
||||||
this case never ran: a non-JSON body -- a captive portal, a proxy error
|
|
||||||
page, a DNS-hijacking router answering for api.github.com -- came out as
|
|
||||||
a 500 instead of the None the caller was written to handle.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_a_non_json_body_returns_none(self):
|
|
||||||
repo = _repository_module().TronbyteRepository()
|
|
||||||
repo.session.get = lambda *a, **k: _Resp(
|
|
||||||
raises=ValueError("Expecting value: line 1 column 1 (char 0)"))
|
|
||||||
|
|
||||||
assert repo._make_request("https://api.github.com/anything") is None
|
|
||||||
|
|
||||||
def test_it_says_the_response_was_not_json(self):
|
|
||||||
repo = _repository_module().TronbyteRepository()
|
|
||||||
repo.session.get = lambda *a, **k: _Resp(raises=ValueError("nope"))
|
|
||||||
|
|
||||||
repo._make_request("https://api.github.com/anything")
|
|
||||||
assert 'JSON' in (repo.last_error or ''), repo.last_error
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnExhaustedRateLimitSaysSo:
|
|
||||||
"""60 requests/hour unauthenticated, shared with every update check."""
|
|
||||||
|
|
||||||
def test_the_message_names_the_rate_limit(self):
|
|
||||||
repo = _repository_module().TronbyteRepository()
|
|
||||||
repo.session.get = lambda *a, **k: _Resp(
|
|
||||||
status_code=403,
|
|
||||||
headers={'X-RateLimit-Remaining': '0', 'X-RateLimit-Limit': '60'})
|
|
||||||
|
|
||||||
assert repo._make_request("https://api.github.com/anything") is None
|
|
||||||
assert 'rate limit' in (repo.last_error or '').lower(), repo.last_error
|
|
||||||
|
|
||||||
def test_it_mentions_being_unauthenticated_when_there_is_no_token(self):
|
|
||||||
repo = _repository_module().TronbyteRepository()
|
|
||||||
repo.session.get = lambda *a, **k: _Resp(
|
|
||||||
status_code=403,
|
|
||||||
headers={'X-RateLimit-Remaining': '0', 'X-RateLimit-Limit': '60'})
|
|
||||||
|
|
||||||
repo._make_request("https://api.github.com/anything")
|
|
||||||
assert 'unauthenticated' in (repo.last_error or ''), repo.last_error
|
|
||||||
|
|
||||||
def test_a_plain_403_is_not_reported_as_a_rate_limit(self):
|
|
||||||
repo = _repository_module().TronbyteRepository()
|
|
||||||
repo.session.get = lambda *a, **k: _Resp(
|
|
||||||
status_code=403, headers={'X-RateLimit-Remaining': '57'})
|
|
||||||
|
|
||||||
repo._make_request("https://api.github.com/anything")
|
|
||||||
assert 'rate limit' not in (repo.last_error or '').lower(), repo.last_error
|
|
||||||
|
|
||||||
|
|
||||||
class TestAFailedFetchIsNotAnEmptyRepository:
|
|
||||||
"""list_all_apps_cached turned every failure into an empty app list.
|
|
||||||
|
|
||||||
The route then reported that as a success, so a rate limit, a DNS failure
|
|
||||||
and a genuinely empty repository were all drawn as the same blank grid.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_the_reason_comes_back_with_the_empty_list(self):
|
|
||||||
module = _repository_module()
|
|
||||||
repo = module.TronbyteRepository()
|
|
||||||
repo.list_apps = lambda: (False, None, "GitHub API rate limit exceeded")
|
|
||||||
|
|
||||||
result = repo.list_all_apps_cached()
|
|
||||||
assert result['count'] == 0
|
|
||||||
assert 'rate limit' in result['error'].lower(), result
|
|
||||||
|
|
||||||
def test_a_failure_is_not_cached_as_an_empty_repository(self):
|
|
||||||
module = _repository_module()
|
|
||||||
repo = module.TronbyteRepository()
|
|
||||||
repo.list_apps = lambda: (False, None, "boom")
|
|
||||||
repo.list_all_apps_cached()
|
|
||||||
|
|
||||||
assert module._apps_cache['data'] is None, \
|
|
||||||
"a failed fetch was cached, so the store stays empty for 2 hours"
|
|
||||||
|
|
||||||
def test_a_successful_fetch_reports_no_error(self):
|
|
||||||
module = _repository_module()
|
|
||||||
repo = module.TronbyteRepository()
|
|
||||||
repo.list_apps = lambda: (True, [{'id': 'a', 'path': 'apps/a'}], None)
|
|
||||||
repo._fetch_raw_file = lambda *a, **k: "name: A\nsummary: s\n"
|
|
||||||
|
|
||||||
assert repo.list_all_apps_cached().get('error') is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheStoreReportsWhyItIsEmpty:
|
|
||||||
"""The route's half of the same failure."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def failing_repo(self):
|
|
||||||
repo = MagicMock()
|
|
||||||
repo.return_value.list_all_apps_cached.return_value = {
|
|
||||||
'apps': [], 'categories': [], 'authors': [], 'count': 0,
|
|
||||||
'cached': False,
|
|
||||||
'error': 'GitHub API rate limit exceeded (60 requests/hour, '
|
|
||||||
'unauthenticated).',
|
|
||||||
}
|
|
||||||
repo.return_value.get_rate_limit_info.return_value = {'remaining': 0}
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
|
||||||
return_value=repo):
|
|
||||||
yield repo
|
|
||||||
|
|
||||||
def test_browse_does_not_call_a_failure_a_success(self, client, failing_repo):
|
|
||||||
body = client.get('/api/v3/starlark/repository/browse').get_json()
|
|
||||||
assert body['status'] == 'error', body
|
|
||||||
|
|
||||||
def test_browse_answers_502_not_200(self, client, failing_repo):
|
|
||||||
resp = client.get('/api/v3/starlark/repository/browse')
|
|
||||||
assert resp.status_code == 502, resp.get_json()
|
|
||||||
|
|
||||||
def test_the_reason_reaches_the_page(self, client, failing_repo):
|
|
||||||
body = client.get('/api/v3/starlark/repository/browse').get_json()
|
|
||||||
assert 'rate limit' in body['message'].lower(), body
|
|
||||||
|
|
||||||
def test_categories_reports_it_too(self, client, failing_repo):
|
|
||||||
resp = client.get('/api/v3/starlark/repository/categories')
|
|
||||||
assert resp.status_code == 502
|
|
||||||
assert 'rate limit' in resp.get_json()['message'].lower()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def working_repo(self):
|
|
||||||
repo = MagicMock()
|
|
||||||
repo.return_value.list_all_apps_cached.return_value = {
|
|
||||||
'apps': [{'id': 'quoteoftheday'}], 'categories': [], 'authors': [],
|
|
||||||
'count': 1, 'cached': False, 'error': None,
|
|
||||||
}
|
|
||||||
repo.return_value.get_rate_limit_info.return_value = {'remaining': 57}
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
|
||||||
return_value=repo):
|
|
||||||
yield repo
|
|
||||||
|
|
||||||
def test_a_working_fetch_is_still_a_success(self, client, working_repo):
|
|
||||||
resp = client.get('/api/v3/starlark/repository/browse')
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.get_json()['status'] == 'success'
|
|
||||||
|
|
||||||
|
|
||||||
class TestACrashCarriesItsDetail:
|
|
||||||
"""Seventeen Starlark handlers answered 5xx with no detail at all."""
|
|
||||||
|
|
||||||
def test_browse_returns_the_exception_detail(self, client):
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
|
||||||
side_effect=ImportError("No module named 'yaml'")):
|
|
||||||
body = client.get('/api/v3/starlark/repository/browse').get_json()
|
|
||||||
|
|
||||||
assert 'yaml' in body.get('details', ''), body
|
|
||||||
|
|
||||||
def test_status_returns_the_exception_detail(self, client):
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
|
|
||||||
side_effect=RuntimeError("plugin manager is not attached")):
|
|
||||||
body = client.get('/api/v3/starlark/status').get_json()
|
|
||||||
|
|
||||||
assert 'plugin manager is not attached' in body.get('details', ''), body
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheListingIsNotCappedAtOneThousand:
|
|
||||||
"""The contents API caps a directory at 1000 entries and does not say so.
|
|
||||||
|
|
||||||
tronbyt/apps returns exactly 1000 through that endpoint, which is the cap
|
|
||||||
rather than the app count -- the store looked complete while showing a
|
|
||||||
truncated repository.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _repo_with_tree(self, module, count):
|
|
||||||
repo = module.TronbyteRepository()
|
|
||||||
entries = [{'path': 'app%04d' % i, 'type': 'tree'} for i in range(count)]
|
|
||||||
|
|
||||||
def fake_request(url, timeout=10):
|
|
||||||
if url.endswith('/git/trees/main'):
|
|
||||||
return {'tree': [{'path': 'apps', 'type': 'tree', 'sha': 'deadbeef'}]}
|
|
||||||
if url.endswith('/git/trees/deadbeef'):
|
|
||||||
return {'tree': entries, 'truncated': False}
|
|
||||||
raise AssertionError("unexpected request: %s" % url)
|
|
||||||
|
|
||||||
repo._make_request = fake_request
|
|
||||||
return repo
|
|
||||||
|
|
||||||
def test_more_than_a_thousand_apps_are_listed(self):
|
|
||||||
module = _repository_module()
|
|
||||||
repo = self._repo_with_tree(module, 1400)
|
|
||||||
|
|
||||||
ok, apps, err = repo.list_apps()
|
|
||||||
assert ok, err
|
|
||||||
assert len(apps) == 1400
|
|
||||||
|
|
||||||
def test_the_path_is_still_the_one_manifest_fetches_use(self):
|
|
||||||
module = _repository_module()
|
|
||||||
repo = self._repo_with_tree(module, 3)
|
|
||||||
|
|
||||||
_, apps, _ = repo.list_apps()
|
|
||||||
assert apps[0]['path'] == 'apps/app0000', apps[0]
|
|
||||||
|
|
||||||
def test_dotfiles_and_files_are_skipped(self):
|
|
||||||
module = _repository_module()
|
|
||||||
repo = module.TronbyteRepository()
|
|
||||||
|
|
||||||
def fake_request(url, timeout=10):
|
|
||||||
if url.endswith('/git/trees/main'):
|
|
||||||
return {'tree': [{'path': 'apps', 'type': 'tree', 'sha': 'x'}]}
|
|
||||||
return {'tree': [{'path': '.github', 'type': 'tree'},
|
|
||||||
{'path': 'realapp', 'type': 'tree'},
|
|
||||||
{'path': 'README.md', 'type': 'blob'}]}
|
|
||||||
|
|
||||||
repo._make_request = fake_request
|
|
||||||
_, apps, _ = repo.list_apps()
|
|
||||||
assert [a['id'] for a in apps] == ['realapp']
|
|
||||||
|
|
||||||
def test_it_falls_back_to_the_contents_api(self):
|
|
||||||
"""A trees outage must not take the store down with it."""
|
|
||||||
module = _repository_module()
|
|
||||||
repo = module.TronbyteRepository()
|
|
||||||
|
|
||||||
def fake_request(url, timeout=10):
|
|
||||||
if '/git/trees/' in url:
|
|
||||||
repo.last_error = "GitHub API error 500"
|
|
||||||
return None
|
|
||||||
return [{'name': 'fallbackapp', 'path': 'apps/fallbackapp', 'type': 'dir'}]
|
|
||||||
|
|
||||||
repo._make_request = fake_request
|
|
||||||
ok, apps, err = repo.list_apps()
|
|
||||||
assert ok, err
|
|
||||||
assert [a['id'] for a in apps] == ['fallbackapp']
|
|
||||||
|
|
||||||
def test_both_paths_failing_reports_the_reason(self):
|
|
||||||
module = _repository_module()
|
|
||||||
repo = module.TronbyteRepository()
|
|
||||||
|
|
||||||
def fake_request(url, timeout=10):
|
|
||||||
repo.last_error = "Timed out reaching GitHub"
|
|
||||||
return None
|
|
||||||
|
|
||||||
repo._make_request = fake_request
|
|
||||||
ok, apps, err = repo.list_apps()
|
|
||||||
assert not ok
|
|
||||||
assert 'Timed out' in err, err
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheStoreUsesTheTokenTheUserConfigured:
|
|
||||||
"""The store authenticated with a key nothing ever writes.
|
|
||||||
|
|
||||||
The three repository routes read `github_token` off config.json. Nothing
|
|
||||||
writes that key: config.template.json has no such field, no setting
|
|
||||||
offers it, and the token the user actually configures goes to
|
|
||||||
config_secrets.json as `github.api_token`, which PluginStoreManager loads
|
|
||||||
and every other GitHub caller uses.
|
|
||||||
|
|
||||||
So the store ran unauthenticated at 60 requests/hour on the same per-IP
|
|
||||||
budget as 48 plugins' update checks, while the configured token sat
|
|
||||||
unused raising that same budget to 5000. On the device this was found on,
|
|
||||||
/plugins/store/github-status reported `authenticated: true` with a
|
|
||||||
rate_limit of 5000 while /starlark/repository/browse reported a limit of
|
|
||||||
60 -- the store going blank was that 60 running out.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_the_store_managers_token_is_used(self):
|
|
||||||
from web_interface.blueprints import api_v3 as mod
|
|
||||||
|
|
||||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
|
||||||
MagicMock(github_token='ghp_configured')):
|
|
||||||
assert mod._starlark_github_token() == 'ghp_configured'
|
|
||||||
|
|
||||||
def test_a_hand_edited_config_key_still_works(self):
|
|
||||||
from web_interface.blueprints import api_v3 as mod
|
|
||||||
|
|
||||||
cfg = MagicMock()
|
|
||||||
cfg.load_config.return_value = {'github_token': 'ghp_by_hand'}
|
|
||||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
|
||||||
MagicMock(github_token=None)), \
|
|
||||||
patch.object(mod.api_v3, 'config_manager', cfg):
|
|
||||||
assert mod._starlark_github_token() == 'ghp_by_hand'
|
|
||||||
|
|
||||||
def test_no_token_anywhere_is_not_an_error(self):
|
|
||||||
from web_interface.blueprints import api_v3 as mod
|
|
||||||
|
|
||||||
cfg = MagicMock()
|
|
||||||
cfg.load_config.return_value = {}
|
|
||||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
|
||||||
MagicMock(github_token=None)), \
|
|
||||||
patch.object(mod.api_v3, 'config_manager', cfg):
|
|
||||||
assert mod._starlark_github_token() is None
|
|
||||||
|
|
||||||
def test_an_unreadable_config_does_not_take_the_store_down(self):
|
|
||||||
from web_interface.blueprints import api_v3 as mod
|
|
||||||
|
|
||||||
cfg = MagicMock()
|
|
||||||
cfg.load_config.side_effect = OSError("config.json is unreadable")
|
|
||||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
|
||||||
MagicMock(github_token=None)), \
|
|
||||||
patch.object(mod.api_v3, 'config_manager', cfg):
|
|
||||||
assert mod._starlark_github_token() is None
|
|
||||||
|
|
||||||
def test_browse_hands_the_token_to_the_repository(self, client):
|
|
||||||
from web_interface.blueprints import api_v3 as mod
|
|
||||||
|
|
||||||
repo = MagicMock()
|
|
||||||
repo.return_value.list_all_apps_cached.return_value = {
|
|
||||||
'apps': [], 'categories': [], 'authors': [], 'count': 0,
|
|
||||||
'cached': False, 'error': None,
|
|
||||||
}
|
|
||||||
repo.return_value.get_rate_limit_info.return_value = {'remaining': 4999}
|
|
||||||
|
|
||||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
|
||||||
MagicMock(github_token='ghp_configured')), \
|
|
||||||
patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
|
||||||
return_value=repo):
|
|
||||||
client.get('/api/v3/starlark/repository/browse')
|
|
||||||
|
|
||||||
repo.assert_called_once_with(github_token='ghp_configured')
|
|
||||||
@@ -19,12 +19,7 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
# api_v3 is a package; a sudo systemctl call can live in any of its modules.
|
API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
|
||||||
API_V3_PKG = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3"
|
|
||||||
|
|
||||||
|
|
||||||
def _api_v3_source() -> str:
|
|
||||||
return "\n".join(p.read_text() for p in sorted(API_V3_PKG.glob("*.py")))
|
|
||||||
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
||||||
|
|
||||||
|
|
||||||
@@ -56,7 +51,7 @@ def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def test_every_sudo_systemctl_call_is_granted() -> None:
|
def test_every_sudo_systemctl_call_is_granted() -> None:
|
||||||
calls = _sudo_systemctl_calls(_api_v3_source())
|
calls = _sudo_systemctl_calls(API_V3.read_text())
|
||||||
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
|
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
|
||||||
|
|
||||||
assert calls, "expected to find sudo systemctl calls in api_v3.py"
|
assert calls, "expected to find sudo systemctl calls in api_v3.py"
|
||||||
@@ -73,7 +68,7 @@ def test_every_sudo_systemctl_call_is_granted() -> None:
|
|||||||
def test_units_are_fully_qualified() -> None:
|
def test_units_are_fully_qualified() -> None:
|
||||||
"""Privileged systemctl calls must name the unit as <name>.service so they
|
"""Privileged systemctl calls must name the unit as <name>.service so they
|
||||||
match the sudoers grants, which use the fully-qualified unit name."""
|
match the sudoers grants, which use the fully-qualified unit name."""
|
||||||
calls = _sudo_systemctl_calls(_api_v3_source())
|
calls = _sudo_systemctl_calls(API_V3.read_text())
|
||||||
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
|
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
|
||||||
assert not unqualified, (
|
assert not unqualified, (
|
||||||
"sudo systemctl calls must use fully-qualified .service unit names: "
|
"sudo systemctl calls must use fully-qualified .service unit names: "
|
||||||
|
|||||||
@@ -83,11 +83,11 @@ plugin_system_config = config.get('plugin_system', {})
|
|||||||
plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos')
|
plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos')
|
||||||
|
|
||||||
# Resolve plugin directory - handle both absolute and relative paths
|
# Resolve plugin directory - handle both absolute and relative paths
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
if os.path.isabs(plugins_dir_name):
|
if os.path.isabs(plugins_dir_name):
|
||||||
plugins_dir = Path(plugins_dir_name)
|
plugins_dir = Path(plugins_dir_name)
|
||||||
else:
|
else:
|
||||||
# If relative, resolve relative to the project root (LEDMatrix directory)
|
# If relative, resolve relative to the project root (LEDMatrix directory)
|
||||||
project_root = Path(__file__).parent.parent
|
|
||||||
plugins_dir = project_root / plugins_dir_name
|
plugins_dir = project_root / plugins_dir_name
|
||||||
|
|
||||||
plugin_manager = PluginManager(
|
plugin_manager = PluginManager(
|
||||||
@@ -157,6 +157,7 @@ _health_monitor_initialized = False
|
|||||||
# Register blueprints
|
# Register blueprints
|
||||||
from web_interface.blueprints.pages_v3 import pages_v3
|
from web_interface.blueprints.pages_v3 import pages_v3
|
||||||
from web_interface.blueprints.api_v3 import api_v3
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
from web_interface.blueprints.composer import composer_bp
|
||||||
|
|
||||||
# Initialize managers in blueprints
|
# Initialize managers in blueprints
|
||||||
pages_v3.config_manager = config_manager
|
pages_v3.config_manager = config_manager
|
||||||
@@ -173,6 +174,11 @@ api_v3.operation_queue = operation_queue
|
|||||||
api_v3.plugin_state_manager = plugin_state_manager
|
api_v3.plugin_state_manager = plugin_state_manager
|
||||||
api_v3.operation_history = operation_history
|
api_v3.operation_history = operation_history
|
||||||
api_v3.health_monitor = health_monitor
|
api_v3.health_monitor = health_monitor
|
||||||
|
|
||||||
|
composer_bp.config_manager = config_manager
|
||||||
|
composer_bp.plugin_manager = plugin_manager
|
||||||
|
composer_bp.plugins_dir = plugins_dir
|
||||||
|
composer_bp.project_root = project_root
|
||||||
# Initialize cache manager for API endpoints
|
# Initialize cache manager for API endpoints
|
||||||
from src.cache_manager import CacheManager
|
from src.cache_manager import CacheManager
|
||||||
api_v3.cache_manager = CacheManager()
|
api_v3.cache_manager = CacheManager()
|
||||||
@@ -200,6 +206,7 @@ except Exception as _hm_err: # pragma: no cover - defensive startup guard
|
|||||||
app.register_blueprint(pages_v3, url_prefix='')
|
app.register_blueprint(pages_v3, url_prefix='')
|
||||||
app.register_blueprint(pages_v3, url_prefix='/v3', name='pages_v3_legacy')
|
app.register_blueprint(pages_v3, url_prefix='/v3', name='pages_v3_legacy')
|
||||||
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||||
|
app.register_blueprint(composer_bp, url_prefix='/composer')
|
||||||
|
|
||||||
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
|
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
|
||||||
@app.route('/assets/plugins/<plugin_id>/uploads/<path:filename>', methods=['GET'])
|
@app.route('/assets/plugins/<plugin_id>/uploads/<path:filename>', methods=['GET'])
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,216 +0,0 @@
|
|||||||
"""Backup creation, listing and restore.
|
|
||||||
|
|
||||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
|
||||||
endpoint names are unchanged by living here.
|
|
||||||
"""
|
|
||||||
from web_interface.blueprints.api_v3 import (
|
|
||||||
PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3,
|
|
||||||
datetime, json, jsonify, logger, os, plugin_store_manager, request,
|
|
||||||
tempfile,
|
|
||||||
)
|
|
||||||
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.
|
|
||||||
# Several are also called from helpers that live in __init__, so the
|
|
||||||
# package is the only patch point that covers every caller.
|
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/backup/preview', methods=['GET'])
|
|
||||||
def backup_preview():
|
|
||||||
"""Return a summary of what a new backup would include."""
|
|
||||||
try:
|
|
||||||
from src.backup_manager import preview_backup_contents
|
|
||||||
data = preview_backup_contents(PROJECT_ROOT)
|
|
||||||
return jsonify({'status': 'success', 'data': data})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("backup_preview failed: %s", e, exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
|
||||||
@api_v3.route('/backup/list', methods=['GET'])
|
|
||||||
def backup_list():
|
|
||||||
"""List backup ZIPs stored in the export directory."""
|
|
||||||
try:
|
|
||||||
_pkg._BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
entries = []
|
|
||||||
for p in sorted(_pkg._BACKUP_EXPORT_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
|
||||||
if not p.is_file() or p.suffix != '.zip':
|
|
||||||
continue
|
|
||||||
st = p.stat()
|
|
||||||
entries.append({
|
|
||||||
'filename': p.name,
|
|
||||||
'size': st.st_size,
|
|
||||||
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
|
||||||
})
|
|
||||||
return jsonify({'status': 'success', 'data': entries})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("backup_list failed: %s", e, exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
|
||||||
@api_v3.route('/backup/export', methods=['POST'])
|
|
||||||
def backup_export():
|
|
||||||
"""Create a new backup ZIP and return its filename."""
|
|
||||||
try:
|
|
||||||
from src.backup_manager import create_backup
|
|
||||||
zip_path = create_backup(PROJECT_ROOT, output_dir=_pkg._BACKUP_EXPORT_DIR)
|
|
||||||
return jsonify({'status': 'success', 'filename': zip_path.name})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("backup_export failed: %s", e, exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
|
||||||
@api_v3.route('/backup/validate', methods=['POST'])
|
|
||||||
def backup_validate():
|
|
||||||
"""Validate an uploaded backup ZIP and return its manifest."""
|
|
||||||
try:
|
|
||||||
from src.backup_manager import validate_backup
|
|
||||||
if 'backup_file' not in request.files:
|
|
||||||
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
|
||||||
f = request.files['backup_file']
|
|
||||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
|
||||||
tmp_path = tmp.name
|
|
||||||
f.save(tmp_path)
|
|
||||||
try:
|
|
||||||
ok, err_msg, manifest = validate_backup(Path(tmp_path))
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if not ok:
|
|
||||||
logger.warning("Backup validation failed: %s", err_msg)
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
|
|
||||||
safe_manifest = {
|
|
||||||
'schema_version': manifest.get('schema_version'),
|
|
||||||
'created_at': manifest.get('created_at'),
|
|
||||||
'ledmatrix_version': manifest.get('ledmatrix_version'),
|
|
||||||
'hostname': manifest.get('hostname'),
|
|
||||||
'contents': manifest.get('contents', []),
|
|
||||||
'detected_contents': manifest.get('detected_contents', []),
|
|
||||||
'plugins': manifest.get('plugins', []),
|
|
||||||
'total_uncompressed': manifest.get('total_uncompressed'),
|
|
||||||
'file_count': manifest.get('file_count'),
|
|
||||||
}
|
|
||||||
return jsonify({'status': 'success', 'data': safe_manifest})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("backup_validate failed: %s", e, exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
|
||||||
@api_v3.route('/backup/restore', methods=['POST'])
|
|
||||||
def backup_restore():
|
|
||||||
"""Restore a backup ZIP with optional RestoreOptions."""
|
|
||||||
try:
|
|
||||||
from src.backup_manager import restore_backup, RestoreOptions
|
|
||||||
if 'backup_file' not in request.files:
|
|
||||||
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
|
||||||
f = request.files['backup_file']
|
|
||||||
options_raw = request.form.get('options', '{}')
|
|
||||||
try:
|
|
||||||
opts_dict = json.loads(options_raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
opts_dict = None
|
|
||||||
if not isinstance(opts_dict, dict):
|
|
||||||
# Every option defaults to True, so falling back to {} on a
|
|
||||||
# parse failure would silently perform a FULL restore —
|
|
||||||
# secrets and all — for a caller who asked for a narrow one
|
|
||||||
# and mis-serialized it. Refuse instead of guessing.
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Invalid options: expected a JSON object',
|
|
||||||
}), 400
|
|
||||||
# _coerce_to_bool (not bare bool()) because a request can send these
|
|
||||||
# as JSON strings: bool("false") is True in Python, so a caller who
|
|
||||||
# explicitly asked to skip secrets would have had them restored
|
|
||||||
# anyway.
|
|
||||||
options = RestoreOptions(
|
|
||||||
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
|
|
||||||
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
|
|
||||||
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
|
|
||||||
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
|
|
||||||
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
|
|
||||||
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
|
|
||||||
)
|
|
||||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
|
||||||
tmp_path = tmp.name
|
|
||||||
f.save(tmp_path)
|
|
||||||
try:
|
|
||||||
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Reinstall plugins if requested and store manager available
|
|
||||||
if options.reinstall_plugins and result.plugins_to_install:
|
|
||||||
psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager
|
|
||||||
for plug in result.plugins_to_install:
|
|
||||||
pid = plug.get('plugin_id')
|
|
||||||
if not pid:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if psm and hasattr(psm, 'install_plugin'):
|
|
||||||
ok = psm.install_plugin(pid)
|
|
||||||
if ok:
|
|
||||||
result.plugins_installed.append(pid)
|
|
||||||
else:
|
|
||||||
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
|
|
||||||
else:
|
|
||||||
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
|
|
||||||
except Exception as pe:
|
|
||||||
logger.error(
|
|
||||||
"[Backup] Failed to reinstall plugin %r: %s", pid, pe, exc_info=True
|
|
||||||
)
|
|
||||||
result.plugins_failed.append({'plugin_id': pid, 'error': 'Installation failed; see server logs'})
|
|
||||||
|
|
||||||
# A restore that dropped files can still report success if the only
|
|
||||||
# failures were plugin reinstalls, since those don't touch result.errors.
|
|
||||||
if result.plugins_failed:
|
|
||||||
result.success = False
|
|
||||||
|
|
||||||
data = result.to_dict()
|
|
||||||
if not result.success:
|
|
||||||
# Name what failed, and what nonetheless landed. A restore is
|
|
||||||
# partial far more often than it is total -- a fresh install can
|
|
||||||
# leave config_secrets.json unwritable by the web service, so
|
|
||||||
# config restores and secrets do not. "Restore had errors" alone
|
|
||||||
# left the user unable to tell a wholly failed restore from one
|
|
||||||
# that quietly dropped their API keys.
|
|
||||||
failed_plugins = [
|
|
||||||
str(p.get('plugin_id')) for p in (result.plugins_failed or []) if p.get('plugin_id')
|
|
||||||
]
|
|
||||||
parts = []
|
|
||||||
if result.restored:
|
|
||||||
parts.append(f"restored: {', '.join(result.restored)}")
|
|
||||||
if result.errors:
|
|
||||||
parts.append(f"failed: {'; '.join(result.errors)}")
|
|
||||||
if failed_plugins:
|
|
||||||
parts.append(f"plugins not reinstalled: {', '.join(failed_plugins)}")
|
|
||||||
message = 'Restore incomplete — ' + ('. '.join(parts) if parts else 'see logs')
|
|
||||||
return jsonify({'status': 'error', 'message': message, 'data': data}), 500
|
|
||||||
return jsonify({'status': 'success', 'data': data})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("backup_restore failed: %s", e, exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
|
||||||
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
|
|
||||||
def backup_download(filename):
|
|
||||||
"""Stream a backup ZIP to the browser."""
|
|
||||||
from flask import send_from_directory
|
|
||||||
if _safe_backup_path(filename) is None:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
|
||||||
try:
|
|
||||||
# send_from_directory uses werkzeug safe_join internally — CodeQL-recognized sanitizer.
|
|
||||||
return send_from_directory(_pkg._BACKUP_EXPORT_DIR, filename, as_attachment=True)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
|
||||||
@api_v3.route('/backup/<path:filename>', methods=['DELETE'])
|
|
||||||
def backup_delete(filename):
|
|
||||||
"""Delete a stored backup ZIP."""
|
|
||||||
safe = _safe_backup_path(filename)
|
|
||||||
if safe is None:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
|
||||||
# Enumerate the export directory and match by name so the unlink target is
|
|
||||||
# a filesystem-derived path rather than one constructed from user input.
|
|
||||||
try:
|
|
||||||
for entry in _pkg._BACKUP_EXPORT_DIR.iterdir():
|
|
||||||
if entry.is_file() and entry.name == safe.name:
|
|
||||||
entry.unlink()
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
except OSError as e:
|
|
||||||
logger.error("backup_delete failed: %s", e, exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
|
||||||
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,326 +0,0 @@
|
|||||||
"""Display control, on-demand playback and preview.
|
|
||||||
|
|
||||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
|
||||||
endpoint names are unchanged by living here.
|
|
||||||
"""
|
|
||||||
from web_interface.blueprints.api_v3 import (
|
|
||||||
_ensure_cache_manager, _ensure_display_service_running,
|
|
||||||
_get_display_service_status, _stop_display_service, api_v3,
|
|
||||||
describe_exception, jsonify, logger, os, request, uuid,
|
|
||||||
)
|
|
||||||
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.
|
|
||||||
# Several are also called from helpers that live in __init__, so the
|
|
||||||
# package is the only patch point that covers every caller.
|
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/display/current', methods=['GET'])
|
|
||||||
def get_display_current():
|
|
||||||
"""Get current display state"""
|
|
||||||
try:
|
|
||||||
import base64
|
|
||||||
from PIL import Image
|
|
||||||
import io
|
|
||||||
|
|
||||||
snapshot_path = "/tmp/led_matrix_preview.png"
|
|
||||||
|
|
||||||
# Get display dimensions from config
|
|
||||||
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
|
|
||||||
except Exception:
|
|
||||||
width = 128
|
|
||||||
height = 64
|
|
||||||
|
|
||||||
# Try to read snapshot file
|
|
||||||
image_data = None
|
|
||||||
if os.path.exists(snapshot_path):
|
|
||||||
try:
|
|
||||||
with Image.open(snapshot_path) as img:
|
|
||||||
# Convert to PNG and encode as base64
|
|
||||||
buffer = io.BytesIO()
|
|
||||||
img.save(buffer, format='PNG')
|
|
||||||
image_data = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
||||||
except Exception as img_err:
|
|
||||||
# File might be being written or corrupted, return None
|
|
||||||
pass
|
|
||||||
|
|
||||||
display_data = {
|
|
||||||
'timestamp': _pkg.time.time(),
|
|
||||||
'width': width,
|
|
||||||
'height': height,
|
|
||||||
'image': image_data # Base64 encoded image data or None if unavailable
|
|
||||||
}
|
|
||||||
return jsonify({'status': 'success', 'data': display_data})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/display/modes', methods=['GET'])
|
|
||||||
def get_display_modes():
|
|
||||||
"""Every display mode that can be requested on-demand, with its plugin.
|
|
||||||
|
|
||||||
/plugins/installed carries no mode information, so anything driving the
|
|
||||||
display from outside the web UI (the Home Assistant MQTT bridge, a script)
|
|
||||||
had to read each plugin's manifest.json off disk and reimplement the
|
|
||||||
fallbacks in PluginManager.get_plugin_display_modes to do it. This is the
|
|
||||||
same list the force-display dialog offers, from the source that owns it.
|
|
||||||
|
|
||||||
Knowing each mode's plugin_id also matters because /display/on-demand/start
|
|
||||||
falls back to find_plugin_for_mode when plugin_id is omitted, and that
|
|
||||||
lookup only sees modes declared in a static manifest -- a plugin whose
|
|
||||||
modes are generated (each installed Starlark app is one) 404s there.
|
|
||||||
Sending the plugin_id from this list skips the lookup entirely.
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
include_disabled: '1' to list modes of disabled plugins too. They can
|
|
||||||
still be requested on-demand -- the controller enables the plugin
|
|
||||||
for the duration -- so they are reported with enabled: false
|
|
||||||
rather than omitted.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not api_v3.plugin_manager:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
||||||
|
|
||||||
# Discovery is lazy and normally triggered by whichever endpoint runs
|
|
||||||
# first, which is a person opening the dashboard. A caller that never
|
|
||||||
# visits it would otherwise see an empty list.
|
|
||||||
api_v3.plugin_manager.discover_plugins()
|
|
||||||
|
|
||||||
include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True')
|
|
||||||
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
|
||||||
|
|
||||||
modes = []
|
|
||||||
for plugin_id, manifest in sorted(api_v3.plugin_manager.plugin_manifests.items()):
|
|
||||||
# A hand-edited or migrated config.json can hold a non-dict under a
|
|
||||||
# plugin id; DisplayController._reconcile guards the same shape, so
|
|
||||||
# it happens in practice. Without this, .get() raises AttributeError,
|
|
||||||
# the loop aborts and the endpoint answers 500 with no modes at all
|
|
||||||
# -- one bad section would blank every entity the MQTT bridge builds
|
|
||||||
# from this list.
|
|
||||||
plugin_config = full_config.get(plugin_id)
|
|
||||||
if not isinstance(plugin_config, dict):
|
|
||||||
if plugin_config is not None:
|
|
||||||
logger.warning(
|
|
||||||
"Config for plugin %r is %s, not an object; treating it as disabled",
|
|
||||||
plugin_id, type(plugin_config).__name__)
|
|
||||||
plugin_config = {}
|
|
||||||
enabled = bool(plugin_config.get('enabled', False))
|
|
||||||
if not enabled and not include_disabled:
|
|
||||||
continue
|
|
||||||
plugin_name = (manifest or {}).get('name') or plugin_id
|
|
||||||
plugin_modes = api_v3.plugin_manager.get_plugin_display_modes(plugin_id) or [plugin_id]
|
|
||||||
for mode in plugin_modes:
|
|
||||||
# A single-mode plugin's mode is the plugin, so its own name is
|
|
||||||
# the readable label. Multi-mode plugins have no per-mode name
|
|
||||||
# anywhere, so the raw mode string is the only thing to show.
|
|
||||||
modes.append({
|
|
||||||
'mode': mode,
|
|
||||||
'plugin_id': plugin_id,
|
|
||||||
'plugin_name': plugin_name,
|
|
||||||
'name': plugin_name if len(plugin_modes) == 1 else mode,
|
|
||||||
'enabled': enabled,
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'data': {'modes': modes}})
|
|
||||||
except Exception as exc:
|
|
||||||
# describe_exception, not a bare message: test_web_error_detail.py
|
|
||||||
# enforces that every handler here returns it, because a device whose
|
|
||||||
# storage is failing otherwise answers "see logs for details" from the
|
|
||||||
# log viewer too. It redacts credentials out of the exception text.
|
|
||||||
# CodeQL flags this as stack-trace exposure across all ~75 handlers;
|
|
||||||
# it is the project's deliberate, reviewed trade-off.
|
|
||||||
logger.error('Error in get_display_modes', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
|
||||||
@api_v3.route('/display/on-demand/status', methods=['GET'])
|
|
||||||
def get_on_demand_status():
|
|
||||||
"""Return the current on-demand display state."""
|
|
||||||
try:
|
|
||||||
cache = _ensure_cache_manager()
|
|
||||||
state = cache.get('display_on_demand_state', max_age=120)
|
|
||||||
if state is None:
|
|
||||||
state = {
|
|
||||||
'active': False,
|
|
||||||
'status': 'idle',
|
|
||||||
'last_updated': None
|
|
||||||
}
|
|
||||||
service_status = _get_display_service_status()
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'data': {
|
|
||||||
'state': state,
|
|
||||||
'service': service_status
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error('Error in get_on_demand_status', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
|
||||||
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
|
||||||
def start_on_demand_display():
|
|
||||||
"""Request the display controller to run a specific plugin on-demand."""
|
|
||||||
try:
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
plugin_id = data.get('plugin_id')
|
|
||||||
mode = data.get('mode')
|
|
||||||
duration = data.get('duration')
|
|
||||||
pinned = bool(data.get('pinned', False))
|
|
||||||
start_service = data.get('start_service', True)
|
|
||||||
|
|
||||||
if not plugin_id and not mode:
|
|
||||||
return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400
|
|
||||||
|
|
||||||
resolved_plugin = plugin_id
|
|
||||||
resolved_mode = mode
|
|
||||||
|
|
||||||
if api_v3.plugin_manager:
|
|
||||||
if resolved_plugin and resolved_plugin not in api_v3.plugin_manager.plugin_manifests:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404
|
|
||||||
|
|
||||||
if resolved_plugin and not resolved_mode:
|
|
||||||
modes = api_v3.plugin_manager.get_plugin_display_modes(resolved_plugin)
|
|
||||||
resolved_mode = modes[0] if modes else resolved_plugin
|
|
||||||
elif resolved_mode and not resolved_plugin:
|
|
||||||
resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode)
|
|
||||||
if not resolved_plugin:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
|
|
||||||
|
|
||||||
# Note: On-demand can work with disabled plugins - the display controller
|
|
||||||
# will temporarily enable them during initialization if needed
|
|
||||||
# We don't block the request here, but log it for debugging
|
|
||||||
if api_v3.config_manager and resolved_plugin:
|
|
||||||
config = api_v3.config_manager.load_config()
|
|
||||||
plugin_config = config.get(resolved_plugin, {})
|
|
||||||
if 'enabled' in plugin_config and not plugin_config.get('enabled', False):
|
|
||||||
logger.info(
|
|
||||||
"On-demand request for disabled plugin '%s' - will be temporarily enabled",
|
|
||||||
resolved_plugin,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set the on-demand request in cache FIRST (before starting service)
|
|
||||||
# This ensures the request is available when the service starts/restarts
|
|
||||||
cache = _ensure_cache_manager()
|
|
||||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
|
||||||
request_payload = {
|
|
||||||
'request_id': request_id,
|
|
||||||
'action': 'start',
|
|
||||||
'plugin_id': resolved_plugin,
|
|
||||||
'mode': resolved_mode,
|
|
||||||
'duration': duration,
|
|
||||||
'pinned': pinned,
|
|
||||||
'timestamp': _pkg.time.time()
|
|
||||||
}
|
|
||||||
cache.set('display_on_demand_request', request_payload)
|
|
||||||
|
|
||||||
# Check if display service is running (or will be started)
|
|
||||||
service_status = _get_display_service_status()
|
|
||||||
service_was_running = service_status.get('active', False)
|
|
||||||
|
|
||||||
# Stop the display service first to ensure clean state when we will restart it
|
|
||||||
if service_was_running and start_service:
|
|
||||||
import time as time_module
|
|
||||||
logger.debug("Stopping display service before starting on-demand mode")
|
|
||||||
_stop_display_service()
|
|
||||||
# Wait a brief moment for the service to fully stop
|
|
||||||
time_module.sleep(1.5)
|
|
||||||
logger.debug("Display service stopped, now starting with on-demand request")
|
|
||||||
|
|
||||||
if not service_status.get('active') and not start_service:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
|
||||||
'service_status': service_status
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
service_result = None
|
|
||||||
if start_service:
|
|
||||||
service_result = _ensure_display_service_running()
|
|
||||||
# Check if service actually started
|
|
||||||
if service_result and not service_result.get('active'):
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
|
||||||
'service_result': service_result
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
# Service was restarted (or started fresh) with on-demand request in cache
|
|
||||||
# The display controller will read the request during initialization or when it polls
|
|
||||||
|
|
||||||
response_data = {
|
|
||||||
'request_id': request_id,
|
|
||||||
'plugin_id': resolved_plugin,
|
|
||||||
'mode': resolved_mode,
|
|
||||||
'duration': duration,
|
|
||||||
'pinned': pinned,
|
|
||||||
'service': service_result
|
|
||||||
}
|
|
||||||
return jsonify({'status': 'success', 'data': response_data})
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error('Error in start_on_demand_display', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
|
||||||
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
|
||||||
def stop_on_demand_display():
|
|
||||||
"""Request the display controller to stop on-demand mode."""
|
|
||||||
try:
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
stop_service = data.get('stop_service', False)
|
|
||||||
|
|
||||||
# Set the stop request in cache FIRST
|
|
||||||
# The display controller will poll this and restart without the on-demand filter
|
|
||||||
cache = _ensure_cache_manager()
|
|
||||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
|
||||||
request_payload = {
|
|
||||||
'request_id': request_id,
|
|
||||||
'action': 'stop',
|
|
||||||
'timestamp': _pkg.time.time()
|
|
||||||
}
|
|
||||||
cache.set('display_on_demand_request', request_payload)
|
|
||||||
|
|
||||||
# Note: The display controller's _clear_on_demand() will handle the restart
|
|
||||||
# to restore normal operation with all plugins
|
|
||||||
|
|
||||||
service_result = None
|
|
||||||
if stop_service:
|
|
||||||
service_result = _stop_display_service()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'data': {
|
|
||||||
'request_id': request_id,
|
|
||||||
'service': service_result
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error('Error in stop_on_demand_display', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
|
||||||
@api_v3.route('/display/current-status', methods=['GET'])
|
|
||||||
def get_current_display_status():
|
|
||||||
"""Return the display mode/plugin currently intended to be shown.
|
|
||||||
|
|
||||||
Published by the display process (display_controller._publish_current_mode_state)
|
|
||||||
to the shared cache whenever the active mode changes, so the web UI (e.g. the
|
|
||||||
System Logs page) can show what's on screen without querying the display
|
|
||||||
process directly.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
cache = _ensure_cache_manager()
|
|
||||||
state = cache.get('display_current_state', max_age=120)
|
|
||||||
if state is None:
|
|
||||||
state = {
|
|
||||||
'mode': None,
|
|
||||||
'plugin_id': None,
|
|
||||||
'last_updated': None,
|
|
||||||
}
|
|
||||||
return jsonify({'status': 'success', 'data': state})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error in get_current_display_status', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@@ -1,467 +0,0 @@
|
|||||||
"""Font catalogue, upload, preview and deletion.
|
|
||||||
|
|
||||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
|
||||||
endpoint names are unchanged by living here.
|
|
||||||
"""
|
|
||||||
from web_interface.blueprints.api_v3 import (
|
|
||||||
PROJECT_ROOT, Path, Response, SYSTEM_FONTS, api_v3, describe_exception,
|
|
||||||
jsonify, logger, os, re, request, validate_file_upload,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/fonts/catalog', methods=['GET'])
|
|
||||||
def get_fonts_catalog():
|
|
||||||
"""Get fonts catalog"""
|
|
||||||
try:
|
|
||||||
# Check cache first (5 minute TTL)
|
|
||||||
try:
|
|
||||||
from web_interface.cache import get_cached, set_cached
|
|
||||||
cached_result = get_cached('fonts_catalog', ttl_seconds=300)
|
|
||||||
if cached_result is not None:
|
|
||||||
return jsonify({'status': 'success', 'data': {'catalog': cached_result}})
|
|
||||||
except ImportError:
|
|
||||||
# Cache not available, continue without caching
|
|
||||||
get_cached = None
|
|
||||||
set_cached = None
|
|
||||||
|
|
||||||
# Try to import freetype, but continue without it if unavailable
|
|
||||||
try:
|
|
||||||
import freetype
|
|
||||||
freetype_available = True
|
|
||||||
except ImportError:
|
|
||||||
freetype_available = False
|
|
||||||
|
|
||||||
# Scan assets/fonts directory for actual font files
|
|
||||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
|
||||||
catalog = {}
|
|
||||||
|
|
||||||
if fonts_dir.exists() and fonts_dir.is_dir():
|
|
||||||
for filename in os.listdir(fonts_dir):
|
|
||||||
if filename.endswith(('.ttf', '.otf', '.bdf')):
|
|
||||||
filepath = fonts_dir / filename
|
|
||||||
# Generate family name from filename (without extension)
|
|
||||||
family_name = os.path.splitext(filename)[0]
|
|
||||||
|
|
||||||
# Try to get font metadata using freetype (for TTF/OTF)
|
|
||||||
metadata = {}
|
|
||||||
if filename.endswith(('.ttf', '.otf')) and freetype_available:
|
|
||||||
try:
|
|
||||||
face = freetype.Face(str(filepath))
|
|
||||||
if face.valid:
|
|
||||||
# Get font family name from font file
|
|
||||||
family_name_from_font = face.family_name.decode('utf-8') if face.family_name else family_name
|
|
||||||
metadata = {
|
|
||||||
'family': family_name_from_font,
|
|
||||||
'style': face.style_name.decode('utf-8') if face.style_name else 'Regular',
|
|
||||||
'num_glyphs': face.num_glyphs,
|
|
||||||
'units_per_em': face.units_per_EM
|
|
||||||
}
|
|
||||||
# Use font's family name if available
|
|
||||||
if family_name_from_font:
|
|
||||||
family_name = family_name_from_font
|
|
||||||
except Exception:
|
|
||||||
# If freetype fails, use filename-based name
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Store relative path from project root
|
|
||||||
relative_path = str(filepath.relative_to(PROJECT_ROOT))
|
|
||||||
font_type = 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf'
|
|
||||||
|
|
||||||
# Generate human-readable display name from family_name
|
|
||||||
display_name = family_name.replace('-', ' ').replace('_', ' ')
|
|
||||||
# Add space before capital letters for camelCase names
|
|
||||||
display_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', display_name)
|
|
||||||
# Add space before numbers that follow letters
|
|
||||||
display_name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', display_name)
|
|
||||||
# Clean up multiple spaces
|
|
||||||
display_name = ' '.join(display_name.split())
|
|
||||||
|
|
||||||
# Use filename (without extension) as unique key to avoid collisions
|
|
||||||
# when multiple files share the same family_name from font metadata
|
|
||||||
catalog_key = os.path.splitext(filename)[0]
|
|
||||||
|
|
||||||
# Check if this is a system font (cannot be deleted)
|
|
||||||
is_system = catalog_key.lower() in SYSTEM_FONTS
|
|
||||||
|
|
||||||
catalog[catalog_key] = {
|
|
||||||
'filename': filename,
|
|
||||||
'family_name': family_name,
|
|
||||||
'display_name': display_name,
|
|
||||||
'path': relative_path,
|
|
||||||
'type': font_type,
|
|
||||||
'is_system': is_system,
|
|
||||||
'metadata': metadata if metadata else None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache the result (5 minute TTL) if available
|
|
||||||
if set_cached:
|
|
||||||
try:
|
|
||||||
set_cached('fonts_catalog', catalog, ttl_seconds=300)
|
|
||||||
except Exception:
|
|
||||||
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'data': {'catalog': catalog}})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("%s failed", request.path, exc_info=True)
|
|
||||||
return jsonify({'status': 'error',
|
|
||||||
'message': 'An error occurred; see logs for details',
|
|
||||||
'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/fonts/tokens', methods=['GET'])
|
|
||||||
def get_font_tokens():
|
|
||||||
"""Get font size tokens"""
|
|
||||||
try:
|
|
||||||
# This would integrate with the actual font system
|
|
||||||
# For now, return sample tokens
|
|
||||||
tokens = {
|
|
||||||
'xs': 6,
|
|
||||||
'sm': 8,
|
|
||||||
'md': 10,
|
|
||||||
'lg': 12,
|
|
||||||
'xl': 14,
|
|
||||||
'xxl': 16
|
|
||||||
}
|
|
||||||
return jsonify({'status': 'success', 'data': {'tokens': tokens}})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/fonts/overrides', methods=['GET'])
|
|
||||||
def get_fonts_overrides():
|
|
||||||
"""Get font overrides"""
|
|
||||||
try:
|
|
||||||
# This would integrate with the actual font system
|
|
||||||
# For now, return empty overrides
|
|
||||||
overrides = {}
|
|
||||||
return jsonify({'status': 'success', 'data': {'overrides': overrides}})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/fonts/overrides', methods=['POST'])
|
|
||||||
def save_fonts_overrides():
|
|
||||||
"""Save font overrides"""
|
|
||||||
try:
|
|
||||||
data = request.get_json(silent=True)
|
|
||||||
if not data:
|
|
||||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
|
||||||
|
|
||||||
# This would integrate with the actual font system
|
|
||||||
return jsonify({'status': 'success', 'message': 'Font overrides saved'})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/fonts/overrides/<element_key>', methods=['DELETE'])
|
|
||||||
def delete_font_override(element_key):
|
|
||||||
"""Delete font override"""
|
|
||||||
try:
|
|
||||||
# This would integrate with the actual font system
|
|
||||||
return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/fonts/upload', methods=['POST'])
|
|
||||||
def upload_font():
|
|
||||||
"""Upload font file"""
|
|
||||||
try:
|
|
||||||
if 'font_file' not in request.files:
|
|
||||||
return jsonify({'status': 'error', 'message': 'No font file provided'}), 400
|
|
||||||
|
|
||||||
font_file = request.files['font_file']
|
|
||||||
if font_file.filename == '':
|
|
||||||
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
|
|
||||||
|
|
||||||
# Validate filename. validate_file_upload takes max_size_mb but only
|
|
||||||
# checks the filename/extension with it -- it never looks at the
|
|
||||||
# actual upload size, so the size limit below is enforced separately
|
|
||||||
# before the file is saved (same pattern as the .star upload above).
|
|
||||||
MAX_FONT_SIZE_MB = 10
|
|
||||||
is_valid, error_msg = validate_file_upload(
|
|
||||||
font_file.filename,
|
|
||||||
max_size_mb=MAX_FONT_SIZE_MB,
|
|
||||||
allowed_extensions=['.ttf', '.otf', '.bdf']
|
|
||||||
)
|
|
||||||
if not is_valid:
|
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
|
||||||
|
|
||||||
# Check file size (stated limit is MAX_FONT_SIZE_MB)
|
|
||||||
font_file.seek(0, 2) # Seek to end
|
|
||||||
file_size = font_file.tell()
|
|
||||||
font_file.seek(0) # Reset to beginning
|
|
||||||
max_font_size_bytes = MAX_FONT_SIZE_MB * 1024 * 1024
|
|
||||||
if file_size > max_font_size_bytes:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'File too large (max {MAX_FONT_SIZE_MB}MB, got {file_size / 1024 / 1024:.1f}MB)'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
font_family = request.form.get('font_family', '')
|
|
||||||
|
|
||||||
if not font_family:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400
|
|
||||||
|
|
||||||
# Validate font family name
|
|
||||||
if not font_family.replace('_', '').replace('-', '').isalnum():
|
|
||||||
return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400
|
|
||||||
|
|
||||||
# Save the font file to assets/fonts directory
|
|
||||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
|
||||||
fonts_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create filename from family name
|
|
||||||
original_ext = os.path.splitext(font_file.filename)[1].lower()
|
|
||||||
safe_filename = f"{font_family}{original_ext}"
|
|
||||||
filepath = fonts_dir / safe_filename
|
|
||||||
|
|
||||||
# Check if file already exists
|
|
||||||
if filepath.exists():
|
|
||||||
return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400
|
|
||||||
|
|
||||||
# Save the file
|
|
||||||
font_file.save(str(filepath))
|
|
||||||
|
|
||||||
# Clear font catalog cache
|
|
||||||
try:
|
|
||||||
from web_interface.cache import delete_cached
|
|
||||||
delete_cached('fonts_catalog')
|
|
||||||
except ImportError as e:
|
|
||||||
logger.warning("[FontUpload] Cache module not available: %s", e)
|
|
||||||
except Exception:
|
|
||||||
logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': f'Font {font_family} uploaded successfully',
|
|
||||||
'font_family': font_family,
|
|
||||||
'filename': safe_filename,
|
|
||||||
'path': f'assets/fonts/{safe_filename}'
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/fonts/preview', methods=['GET'])
|
|
||||||
def get_font_preview() -> tuple[Response, int] | Response:
|
|
||||||
"""Generate a preview image of text rendered with a specific font"""
|
|
||||||
try:
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
import io
|
|
||||||
import base64
|
|
||||||
|
|
||||||
# Limits to prevent DoS via large image generation on constrained devices
|
|
||||||
MAX_TEXT_CHARS = 100
|
|
||||||
MAX_TEXT_LINES = 3
|
|
||||||
MAX_DIM = 1024 # Max width or height in pixels
|
|
||||||
MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700)
|
|
||||||
|
|
||||||
font_filename = request.args.get('font', '')
|
|
||||||
text = request.args.get('text', 'Sample Text 123')
|
|
||||||
bg_color = request.args.get('bg', '000000')
|
|
||||||
fg_color = request.args.get('fg', 'ffffff')
|
|
||||||
|
|
||||||
# Validate text length and line count early
|
|
||||||
if len(text) > MAX_TEXT_CHARS:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400
|
|
||||||
if text.count('\n') >= MAX_TEXT_LINES:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400
|
|
||||||
|
|
||||||
# Safe integer parsing for size
|
|
||||||
try:
|
|
||||||
size = int(request.args.get('size', 12))
|
|
||||||
except (ValueError, TypeError, OverflowError):
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
|
|
||||||
|
|
||||||
if not font_filename:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Font filename required'}), 400
|
|
||||||
|
|
||||||
# Validate size
|
|
||||||
if size < 4 or size > 72:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400
|
|
||||||
|
|
||||||
# Security: Validate font_filename to prevent path traversal
|
|
||||||
# Only allow alphanumeric, hyphen, underscore, and dot (for extension)
|
|
||||||
safe_name = Path(font_filename).name # Strip any directory components
|
|
||||||
if safe_name != font_filename or '..' in font_filename:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400
|
|
||||||
|
|
||||||
# Validate extension
|
|
||||||
allowed_extensions = ['.ttf', '.otf', '.bdf']
|
|
||||||
has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions)
|
|
||||||
name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name
|
|
||||||
|
|
||||||
# Find the font file
|
|
||||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
|
||||||
if not fonts_dir.exists():
|
|
||||||
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
|
||||||
|
|
||||||
font_path = fonts_dir / safe_name
|
|
||||||
|
|
||||||
if not font_path.exists() and not has_valid_ext:
|
|
||||||
# Try finding by family name (without extension)
|
|
||||||
for ext in allowed_extensions:
|
|
||||||
potential_path = fonts_dir / f"{name_without_ext}{ext}"
|
|
||||||
if potential_path.exists():
|
|
||||||
font_path = potential_path
|
|
||||||
break
|
|
||||||
|
|
||||||
# Final security check: ensure path is within fonts_dir
|
|
||||||
try:
|
|
||||||
font_path.resolve().relative_to(fonts_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400
|
|
||||||
|
|
||||||
if not font_path.exists():
|
|
||||||
return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404
|
|
||||||
|
|
||||||
# Parse colors
|
|
||||||
try:
|
|
||||||
bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4))
|
|
||||||
fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4))
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
bg_rgb = (0, 0, 0)
|
|
||||||
fg_rgb = (255, 255, 255)
|
|
||||||
|
|
||||||
# Load font
|
|
||||||
font = None
|
|
||||||
if str(font_path).endswith('.bdf'):
|
|
||||||
# BDF fonts require complex per-glyph rendering via freetype
|
|
||||||
# Return explicit error rather than showing misleading preview with default font
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
|
|
||||||
}), 400
|
|
||||||
else:
|
|
||||||
# TTF/OTF fonts
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype(str(font_path), size)
|
|
||||||
except (IOError, OSError) as e:
|
|
||||||
# IOError/OSError raised for invalid/corrupt font files
|
|
||||||
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
|
|
||||||
# Calculate text size
|
|
||||||
temp_img = Image.new('RGB', (1, 1))
|
|
||||||
temp_draw = ImageDraw.Draw(temp_img)
|
|
||||||
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
|
||||||
text_width = bbox[2] - bbox[0]
|
|
||||||
text_height = bbox[3] - bbox[1]
|
|
||||||
|
|
||||||
# Create image with padding
|
|
||||||
padding = 10
|
|
||||||
img_width = max(text_width + padding * 2, 100)
|
|
||||||
img_height = max(text_height + padding * 2, 30)
|
|
||||||
|
|
||||||
# Validate resulting image size to prevent memory/CPU spikes
|
|
||||||
if img_width > MAX_DIM or img_height > MAX_DIM:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
|
||||||
if img_width * img_height > MAX_PIXELS:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
|
||||||
|
|
||||||
img = Image.new('RGB', (img_width, img_height), bg_rgb)
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
# Center text
|
|
||||||
x = (img_width - text_width) // 2
|
|
||||||
y = (img_height - text_height) // 2
|
|
||||||
|
|
||||||
draw.text((x, y), text, font=font, fill=fg_rgb)
|
|
||||||
|
|
||||||
# Convert to base64
|
|
||||||
buffer = io.BytesIO()
|
|
||||||
img.save(buffer, format='PNG')
|
|
||||||
buffer.seek(0)
|
|
||||||
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'data': {
|
|
||||||
'image': f'data:image/png;base64,{img_base64}',
|
|
||||||
'width': img_width,
|
|
||||||
'height': img_height
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
|
|
||||||
def delete_font(font_family: str) -> tuple[Response, int] | Response:
|
|
||||||
"""Delete a user-uploaded font file"""
|
|
||||||
try:
|
|
||||||
# Security: Validate font_family to prevent path traversal
|
|
||||||
# Reject if it contains path separators or ..
|
|
||||||
if '..' in font_family or '/' in font_family or '\\' in font_family:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
|
||||||
|
|
||||||
# Only allow safe characters: alphanumeric, hyphen, underscore, dot
|
|
||||||
if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family):
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
|
||||||
|
|
||||||
# Check if this is a system font (uses module-level SYSTEM_FONTS frozenset)
|
|
||||||
if font_family.lower() in SYSTEM_FONTS:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403
|
|
||||||
|
|
||||||
# Find and delete the font file
|
|
||||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
|
||||||
|
|
||||||
# Ensure fonts directory exists
|
|
||||||
if not fonts_dir.exists() or not fonts_dir.is_dir():
|
|
||||||
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
|
||||||
|
|
||||||
deleted = False
|
|
||||||
deleted_filename = None
|
|
||||||
|
|
||||||
# Only try valid font extensions (no empty string to avoid matching directories)
|
|
||||||
for ext in ['.ttf', '.otf', '.bdf']:
|
|
||||||
potential_path = fonts_dir / f"{font_family}{ext}"
|
|
||||||
|
|
||||||
# Security: Verify path is within fonts_dir
|
|
||||||
try:
|
|
||||||
potential_path.resolve().relative_to(fonts_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
continue # Path escapes fonts_dir, skip
|
|
||||||
|
|
||||||
if potential_path.exists() and potential_path.is_file():
|
|
||||||
potential_path.unlink()
|
|
||||||
deleted = True
|
|
||||||
deleted_filename = f"{font_family}{ext}"
|
|
||||||
break
|
|
||||||
|
|
||||||
if not deleted:
|
|
||||||
# Try case-insensitive match within fonts directory
|
|
||||||
font_family_lower = font_family.lower()
|
|
||||||
for filename in os.listdir(fonts_dir):
|
|
||||||
# Only consider files with valid font extensions
|
|
||||||
if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']):
|
|
||||||
continue
|
|
||||||
|
|
||||||
name_without_ext = os.path.splitext(filename)[0]
|
|
||||||
if name_without_ext.lower() == font_family_lower:
|
|
||||||
filepath = fonts_dir / filename
|
|
||||||
|
|
||||||
# Security: Verify path is within fonts_dir
|
|
||||||
try:
|
|
||||||
filepath.resolve().relative_to(fonts_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
continue # Path escapes fonts_dir, skip
|
|
||||||
|
|
||||||
if filepath.is_file():
|
|
||||||
filepath.unlink()
|
|
||||||
deleted = True
|
|
||||||
deleted_filename = filename
|
|
||||||
break
|
|
||||||
|
|
||||||
if not deleted:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404
|
|
||||||
|
|
||||||
# Clear font catalog cache
|
|
||||||
try:
|
|
||||||
from web_interface.cache import delete_cached
|
|
||||||
delete_cached('fonts_catalog')
|
|
||||||
except ImportError as e:
|
|
||||||
logger.warning("[FontDelete] Cache module not available: %s", e)
|
|
||||||
except Exception:
|
|
||||||
logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': f'Font {deleted_filename} deleted successfully'
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@@ -1,496 +0,0 @@
|
|||||||
"""Routes with no larger group of their own: errors, integrations,
|
|
||||||
cache, sync, skins, logs, health and hardware.
|
|
||||||
|
|
||||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
|
||||||
endpoint names are unchanged by living here.
|
|
||||||
"""
|
|
||||||
from web_interface.blueprints.api_v3 import (
|
|
||||||
ErrorCode, Path, _JOURNALCTL, _MQTT_BRIDGE_CONFIG, _MQTT_BRIDGE_DEFAULTS,
|
|
||||||
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
|
|
||||||
_get_display_service_status, _mqtt_bridge_service_state,
|
|
||||||
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
|
|
||||||
error_response, get_error_aggregator, json, jsonify, logger, os, request,
|
|
||||||
subprocess, success_response, tempfile,
|
|
||||||
)
|
|
||||||
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.
|
|
||||||
# Several are also called from helpers that live in __init__, so the
|
|
||||||
# package is the only patch point that covers every caller.
|
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/health', methods=['GET'])
|
|
||||||
def get_health():
|
|
||||||
"""Get system health status"""
|
|
||||||
try:
|
|
||||||
health_status = {
|
|
||||||
'status': 'healthy',
|
|
||||||
'timestamp': _pkg.time.time(),
|
|
||||||
'services': {},
|
|
||||||
'checks': {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check web interface service
|
|
||||||
# Stamp the start _pkg.time before measuring against it -- reading it with a
|
|
||||||
# fallback of _pkg.time.time() and only assigning afterwards made the very
|
|
||||||
# first call subtract two separate clock reads, reporting a small
|
|
||||||
# negative uptime.
|
|
||||||
if not hasattr(get_health, '_start_time'):
|
|
||||||
get_health._start_time = _pkg.time.time()
|
|
||||||
health_status['services']['web_interface'] = {
|
|
||||||
'status': 'running',
|
|
||||||
'uptime_seconds': _pkg.time.time() - get_health._start_time
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check display service
|
|
||||||
display_service_status = _get_display_service_status()
|
|
||||||
health_status['services']['display_service'] = {
|
|
||||||
'status': 'active' if display_service_status.get('active') else 'inactive',
|
|
||||||
'details': display_service_status
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check config file accessibility
|
|
||||||
try:
|
|
||||||
if api_v3.config_manager:
|
|
||||||
test_config = api_v3.config_manager.load_config()
|
|
||||||
health_status['checks']['config_file'] = {
|
|
||||||
'status': 'accessible',
|
|
||||||
'readable': True
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
health_status['checks']['config_file'] = {
|
|
||||||
'status': 'unknown',
|
|
||||||
'readable': False
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
health_status['checks']['config_file'] = {
|
|
||||||
'status': 'error',
|
|
||||||
'readable': False,
|
|
||||||
'error': 'see logs for details'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check plugin system
|
|
||||||
try:
|
|
||||||
if api_v3.plugin_manager:
|
|
||||||
# Try to discover plugins (lightweight check)
|
|
||||||
plugin_count = len(api_v3.plugin_manager.get_available_plugins()) if hasattr(api_v3.plugin_manager, 'get_available_plugins') else 0
|
|
||||||
health_status['checks']['plugin_system'] = {
|
|
||||||
'status': 'operational',
|
|
||||||
'plugin_count': plugin_count
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
health_status['checks']['plugin_system'] = {
|
|
||||||
'status': 'not_initialized'
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
health_status['checks']['plugin_system'] = {
|
|
||||||
'status': 'error',
|
|
||||||
'error': 'see logs for details'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check hardware connectivity (if display manager available)
|
|
||||||
try:
|
|
||||||
snapshot_path = "/tmp/led_matrix_preview.png"
|
|
||||||
if os.path.exists(snapshot_path):
|
|
||||||
# Check if snapshot is recent (updated in last 60 seconds)
|
|
||||||
mtime = os.path.getmtime(snapshot_path)
|
|
||||||
age_seconds = _pkg.time.time() - mtime
|
|
||||||
health_status['checks']['hardware'] = {
|
|
||||||
'status': 'connected' if age_seconds < 60 else 'stale',
|
|
||||||
'snapshot_age_seconds': round(age_seconds, 1)
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
health_status['checks']['hardware'] = {
|
|
||||||
'status': 'no_snapshot',
|
|
||||||
'note': 'Display service may not be running'
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
health_status['checks']['hardware'] = {
|
|
||||||
'status': 'unknown',
|
|
||||||
'error': 'see logs for details'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Determine overall health
|
|
||||||
all_healthy = all(
|
|
||||||
check.get('status') in ['accessible', 'operational', 'connected', 'running', 'active']
|
|
||||||
for check in health_status['checks'].values()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not all_healthy:
|
|
||||||
health_status['status'] = 'degraded'
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'data': health_status})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("%s failed", request.path, exc_info=True)
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'An error occurred; see logs for details',
|
|
||||||
'details': describe_exception(e),
|
|
||||||
'data': {'status': 'unhealthy'}
|
|
||||||
}), 500
|
|
||||||
@api_v3.route('/hardware/status', methods=['GET'])
|
|
||||||
def get_hardware_status():
|
|
||||||
"""Return LED matrix hardware initialization status written by display_manager at startup."""
|
|
||||||
status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
|
|
||||||
try:
|
|
||||||
with open(status_path) as f:
|
|
||||||
hw_data = json.load(f)
|
|
||||||
return jsonify({"status": "success", "data": hw_data})
|
|
||||||
except FileNotFoundError:
|
|
||||||
return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}})
|
|
||||||
except PermissionError:
|
|
||||||
logger.warning("Permission denied reading hardware status file; display service may be running as a different user")
|
|
||||||
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status temporarily unavailable"}})
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.error("Failed to parse hardware status file", exc_info=True)
|
|
||||||
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status file corrupted"}})
|
|
||||||
except Exception:
|
|
||||||
logger.error("Unexpected error reading hardware status", exc_info=True)
|
|
||||||
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
|
|
||||||
@api_v3.route('/skins', methods=['GET'])
|
|
||||||
def list_skins():
|
|
||||||
"""List installed visual skins (docs/SKIN_SYSTEM.md).
|
|
||||||
|
|
||||||
Optional ?plugin_id=... filters to skins matching that plugin.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
|
|
||||||
plugin_id = request.args.get('plugin_id')
|
|
||||||
if plugin_id:
|
|
||||||
skins = skin_runtime.skins_for_plugin(plugin_id)
|
|
||||||
else:
|
|
||||||
# The discovery cache self-invalidates on directory/manifest
|
|
||||||
# mtime changes, so no force_refresh — keeps Pi disk I/O down.
|
|
||||||
skins = skin_runtime.discover_skins()
|
|
||||||
|
|
||||||
payload = []
|
|
||||||
for skin_id, manifest in sorted(skins.items()):
|
|
||||||
skin_dir = Path(manifest['_skin_dir'])
|
|
||||||
preview = manifest.get('preview')
|
|
||||||
payload.append({
|
|
||||||
'id': skin_id,
|
|
||||||
'name': manifest.get('name', skin_id),
|
|
||||||
'version': manifest.get('version'),
|
|
||||||
'author': manifest.get('author'),
|
|
||||||
'description': manifest.get('description', ''),
|
|
||||||
'skin_api_version': manifest.get('skin_api_version'),
|
|
||||||
'targets': manifest.get('targets', {}),
|
|
||||||
'modes': manifest.get('modes', []),
|
|
||||||
'has_preview': bool(preview and (skin_dir / preview).is_file()),
|
|
||||||
})
|
|
||||||
return jsonify({'status': 'success', 'data': {'skins': payload}})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error in list_skins', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/logs', methods=['GET'])
|
|
||||||
def get_logs():
|
|
||||||
"""Get system logs from journalctl"""
|
|
||||||
try:
|
|
||||||
if not _JOURNALCTL:
|
|
||||||
return jsonify({'status': 'error', 'message': 'journalctl not found on this system'}), 503
|
|
||||||
# Get recent logs from journalctl
|
|
||||||
_cmd = ([_SUDO, _JOURNALCTL] if _SUDO else [_JOURNALCTL]) + [
|
|
||||||
'-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
|
|
||||||
'-n', '100', '--no-pager', '--output=short-iso']
|
|
||||||
result = subprocess.run(
|
|
||||||
_cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
logs_text = result.stdout.strip()
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'data': {
|
|
||||||
'logs': logs_text if logs_text else 'No logs available from ledmatrix or ledmatrix-web service'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Failed to get logs: {result.stderr}'
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Timeout while fetching logs'
|
|
||||||
}), 500
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("%s failed", request.path, exc_info=True)
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'An error occurred; see logs for details',
|
|
||||||
'details': describe_exception(e)
|
|
||||||
}), 500
|
|
||||||
# Multi-Display Sync Endpoints
|
|
||||||
@api_v3.route('/sync/status', methods=['GET'])
|
|
||||||
def get_sync_status():
|
|
||||||
"""Return live multi-display sync status written by the display process."""
|
|
||||||
import os as _os
|
|
||||||
status_file = "/tmp/led_matrix_sync_status.json"
|
|
||||||
# Also surface config so the UI can show the configured role even before
|
|
||||||
# the display process has written a status file.
|
|
||||||
cfg_role = "standalone"
|
|
||||||
cfg_port = 5765
|
|
||||||
if api_v3.config_manager:
|
|
||||||
try:
|
|
||||||
cfg = api_v3.config_manager.load_config().get("sync", {})
|
|
||||||
cfg_role = cfg.get("role", "standalone")
|
|
||||||
cfg_port = int(cfg.get("port", 5765))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if _os.path.exists(status_file):
|
|
||||||
try:
|
|
||||||
with open(status_file) as f:
|
|
||||||
live = json.load(f)
|
|
||||||
return jsonify({"status": "success", "data": live})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Status file not yet written — return config-only placeholder
|
|
||||||
return jsonify({
|
|
||||||
"status": "success",
|
|
||||||
"data": {
|
|
||||||
"role": cfg_role,
|
|
||||||
"port": cfg_port,
|
|
||||||
"state": "starting",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@api_v3.route('/cache/list', methods=['GET'])
|
|
||||||
def list_cache_files():
|
|
||||||
"""List all cache files with metadata"""
|
|
||||||
try:
|
|
||||||
if not api_v3.cache_manager:
|
|
||||||
# Initialize cache manager if not already initialized
|
|
||||||
from src.cache_manager import CacheManager
|
|
||||||
api_v3.cache_manager = CacheManager()
|
|
||||||
|
|
||||||
cache_files = api_v3.cache_manager.list_cache_files()
|
|
||||||
cache_dir = api_v3.cache_manager.get_cache_dir()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'data': {
|
|
||||||
'cache_files': cache_files,
|
|
||||||
'cache_dir': cache_dir,
|
|
||||||
'total_files': len(cache_files)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error in list_cache_files', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/cache/delete', methods=['POST'])
|
|
||||||
def delete_cache_file():
|
|
||||||
"""Delete a specific cache file by key"""
|
|
||||||
try:
|
|
||||||
if not api_v3.cache_manager:
|
|
||||||
# Initialize cache manager if not already initialized
|
|
||||||
from src.cache_manager import CacheManager
|
|
||||||
api_v3.cache_manager = CacheManager()
|
|
||||||
|
|
||||||
data = request.get_json(silent=True)
|
|
||||||
if not data or 'key' not in data:
|
|
||||||
return jsonify({'status': 'error', 'message': 'cache key is required'}), 400
|
|
||||||
|
|
||||||
cache_key = data['key']
|
|
||||||
|
|
||||||
# Delete the cache file
|
|
||||||
api_v3.cache_manager.clear_cache(cache_key)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': f'Cache file for key "{cache_key}" deleted successfully'
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error in delete_cache_file', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/errors/summary', methods=['GET'])
|
|
||||||
def get_error_summary():
|
|
||||||
"""
|
|
||||||
Get summary of all errors for monitoring and debugging.
|
|
||||||
|
|
||||||
Returns error counts, detected patterns, and recent errors.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
aggregator = get_error_aggregator()
|
|
||||||
summary = aggregator.get_error_summary()
|
|
||||||
return success_response(data=summary, message="Error summary retrieved")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting error summary: {e}", exc_info=True)
|
|
||||||
return error_response(
|
|
||||||
error_code=ErrorCode.SYSTEM_ERROR,
|
|
||||||
message="Failed to retrieve error summary",
|
|
||||||
status_code=500
|
|
||||||
)
|
|
||||||
@api_v3.route('/errors/plugin/<plugin_id>', methods=['GET'])
|
|
||||||
def get_plugin_errors(plugin_id):
|
|
||||||
"""
|
|
||||||
Get error health status for a specific plugin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_id: Plugin identifier
|
|
||||||
|
|
||||||
Returns health status and error statistics for the plugin.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
aggregator = get_error_aggregator()
|
|
||||||
health = aggregator.get_plugin_health(plugin_id)
|
|
||||||
return success_response(data=health, message="Plugin health retrieved")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
|
|
||||||
return error_response(
|
|
||||||
error_code=ErrorCode.SYSTEM_ERROR,
|
|
||||||
message=f"Failed to retrieve health for plugin {plugin_id}",
|
|
||||||
status_code=500
|
|
||||||
)
|
|
||||||
@api_v3.route('/errors/clear', methods=['POST'])
|
|
||||||
def clear_old_errors():
|
|
||||||
"""
|
|
||||||
Clear error records older than specified age.
|
|
||||||
|
|
||||||
Request body (optional):
|
|
||||||
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
raw_max_age = data.get('max_age_hours', 24)
|
|
||||||
|
|
||||||
# Validate and coerce max_age_hours
|
|
||||||
try:
|
|
||||||
max_age_hours = int(raw_max_age)
|
|
||||||
if max_age_hours < 1:
|
|
||||||
return error_response(
|
|
||||||
error_code=ErrorCode.INVALID_INPUT,
|
|
||||||
message="max_age_hours must be at least 1",
|
|
||||||
context={'provided_value': raw_max_age},
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
if max_age_hours > 8760: # 1 year max
|
|
||||||
return error_response(
|
|
||||||
error_code=ErrorCode.INVALID_INPUT,
|
|
||||||
message="max_age_hours cannot exceed 8760 (1 year)",
|
|
||||||
context={'provided_value': raw_max_age},
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError, OverflowError):
|
|
||||||
return error_response(
|
|
||||||
error_code=ErrorCode.INVALID_INPUT,
|
|
||||||
message="max_age_hours must be a valid integer",
|
|
||||||
context={'provided_value': str(raw_max_age)},
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
|
|
||||||
aggregator = get_error_aggregator()
|
|
||||||
cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours)
|
|
||||||
|
|
||||||
return success_response(
|
|
||||||
data={'cleared_count': cleared_count},
|
|
||||||
message=f"Cleared {cleared_count} error records older than {max_age_hours} hours"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
|
||||||
return error_response(
|
|
||||||
error_code=ErrorCode.SYSTEM_ERROR,
|
|
||||||
message="Failed to clear old errors",
|
|
||||||
status_code=500
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/integrations/mqtt-bridge', methods=['GET'])
|
|
||||||
def get_mqtt_bridge():
|
|
||||||
"""Bridge service state and its settings, minus the password."""
|
|
||||||
try:
|
|
||||||
config = _read_mqtt_bridge_config()
|
|
||||||
password = config.get('mqtt_password')
|
|
||||||
safe = {key: config.get(key, default)
|
|
||||||
for key, default in _MQTT_BRIDGE_DEFAULTS.items()}
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'data': {
|
|
||||||
'service': _mqtt_bridge_service_state(),
|
|
||||||
'config_exists': _MQTT_BRIDGE_CONFIG.is_file(),
|
|
||||||
'config_path': str(_MQTT_BRIDGE_CONFIG),
|
|
||||||
'config': safe,
|
|
||||||
# Enough to render "a password is set" without disclosing it.
|
|
||||||
'password_set': bool(password),
|
|
||||||
'env_override_prefix': 'LEDMATRIX_MQTT_',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception('Error reading MQTT bridge settings')
|
|
||||||
return jsonify({'status': 'error', 'message': 'Could not read bridge settings',
|
|
||||||
'details': describe_exception(e)}), 500
|
|
||||||
|
|
||||||
@api_v3.route('/integrations/mqtt-bridge/config', methods=['PUT'])
|
|
||||||
def update_mqtt_bridge_config():
|
|
||||||
"""Write bridge_config.json.
|
|
||||||
|
|
||||||
The password is write-only: omit it to leave whatever is stored alone, send
|
|
||||||
a value to replace it, or send clear_password to remove it. It is never
|
|
||||||
returned by the GET above, so a form that round-tripped it would otherwise
|
|
||||||
have to blank it on every save.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return jsonify({'status': 'error', 'message': 'Body must be a JSON object'}), 400
|
|
||||||
|
|
||||||
config = _read_mqtt_bridge_config()
|
|
||||||
existing_password = config.get('mqtt_password')
|
|
||||||
|
|
||||||
updates = {}
|
|
||||||
for key in _MQTT_BRIDGE_DEFAULTS:
|
|
||||||
if key not in data:
|
|
||||||
continue
|
|
||||||
value, err = _coerce_mqtt_bridge_value(key, data[key])
|
|
||||||
if err:
|
|
||||||
return jsonify({'status': 'error', 'message': err}), 400
|
|
||||||
updates[key] = value
|
|
||||||
|
|
||||||
config.update(updates)
|
|
||||||
|
|
||||||
if data.get('clear_password'):
|
|
||||||
config['mqtt_password'] = None
|
|
||||||
elif 'mqtt_password' in data and str(data['mqtt_password']) != '':
|
|
||||||
new_password = str(data['mqtt_password'])
|
|
||||||
if len(new_password) > 300:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Password is too long'}), 400
|
|
||||||
config['mqtt_password'] = new_password
|
|
||||||
else:
|
|
||||||
config['mqtt_password'] = existing_password
|
|
||||||
|
|
||||||
if config.get('mqtt_password') and not config.get('mqtt_tls'):
|
|
||||||
logger.warning('MQTT bridge: a password is set without TLS; '
|
|
||||||
'credentials will cross the network in cleartext')
|
|
||||||
|
|
||||||
_MQTT_BRIDGE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
# Write via a temp file in the same directory so a crash mid-write
|
|
||||||
# cannot leave a half-written config the bridge would refuse to load.
|
|
||||||
fd, tmp_path = tempfile.mkstemp(dir=str(_MQTT_BRIDGE_DIR), prefix='.bridge_config.')
|
|
||||||
try:
|
|
||||||
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
|
||||||
json.dump(config, handle, indent=2, sort_keys=True)
|
|
||||||
handle.write('\n')
|
|
||||||
os.chmod(tmp_path, 0o600)
|
|
||||||
os.replace(tmp_path, _MQTT_BRIDGE_CONFIG)
|
|
||||||
except Exception:
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
raise
|
|
||||||
|
|
||||||
service = _mqtt_bridge_service_state()
|
|
||||||
message = 'Bridge settings saved.'
|
|
||||||
if service['active']:
|
|
||||||
message += ' Restart the bridge for them to take effect.'
|
|
||||||
return jsonify({'status': 'success', 'message': message,
|
|
||||||
'data': {'password_set': bool(config.get('mqtt_password')),
|
|
||||||
'restart_required': service['active']}})
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception('Error saving MQTT bridge settings')
|
|
||||||
return jsonify({'status': 'error', 'message': 'Could not save bridge settings',
|
|
||||||
'details': describe_exception(e)}), 500
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user