Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d64fa9761a | ||
|
|
9c0c0dc851 | ||
|
|
71739d85d1 | ||
|
|
cc258aaffd | ||
|
|
fe5a3aa99d | ||
|
|
10e75b977f | ||
|
|
cf0a551f7b | ||
|
|
9018fa23cd | ||
|
|
0c5b9c57d3 | ||
|
|
9083df9f5c | ||
|
|
0901d044d3 | ||
|
|
08265c1135 | ||
|
|
5713fd20a7 | ||
|
|
9cf30bbbef | ||
|
|
a51fb7ce11 | ||
|
|
fce1fdac57 | ||
|
|
7171e6c022 | ||
|
|
9fbdd71941 | ||
|
|
2add759f40 | ||
|
|
bb1a1671ec | ||
|
|
8159afca43 | ||
|
|
44f59ede07 | ||
|
|
ca26c1b83b | ||
|
|
f887063434 | ||
|
|
6287acd591 | ||
|
|
ee59caa577 | ||
|
|
fc25a70d75 | ||
|
|
003312f4ff | ||
|
|
31d607f6b3 | ||
|
|
d6c5f97c13 |
@@ -4,6 +4,9 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
# Manual runs against any branch — useful when a PR's automatic run
|
||||
# needs a re-run or didn't get created.
|
||||
workflow_dispatch:
|
||||
|
||||
# Both jobs only check out the repo and run pytest.
|
||||
permissions:
|
||||
@@ -13,6 +16,12 @@ jobs:
|
||||
plugin-safety:
|
||||
name: Plugin safety harness + unit tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# The bundled fixture plugin gives the harness at least one real plugin
|
||||
# to render, and REQUIRE_PLUGINS turns "discovered zero plugins" into a
|
||||
# hard failure instead of a silent all-skip green run.
|
||||
LEDMATRIX_PLUGINS_DIR: test/fixtures/plugins
|
||||
LEDMATRIX_REQUIRE_PLUGINS: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
@@ -29,12 +38,9 @@ jobs:
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install RGBMatrixEmulator
|
||||
|
||||
- name: Run harness + visual rendering tests
|
||||
- name: Run plugin safety harness
|
||||
run: |
|
||||
pytest --no-cov \
|
||||
test/plugins/test_harness.py \
|
||||
test/plugins/test_visual_rendering.py \
|
||||
test/plugins/test_plugin_matrix.py
|
||||
pytest --no-cov test/plugins/
|
||||
|
||||
unit-tests:
|
||||
name: Core unit tests
|
||||
@@ -55,37 +61,15 @@ jobs:
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install RGBMatrixEmulator
|
||||
|
||||
# Safety net for the shared sports/scroll/style infrastructure. These
|
||||
# suites existed but were not enrolled in CI, so a refactor of
|
||||
# src/base_classes or src/common could regress them silently. Enrolled
|
||||
# explicitly (not `pytest test/`) so known hardware-only suites don't
|
||||
# break CI; grow this list as more suites are made headless.
|
||||
# Run the ENTIRE test tree (except test/plugins, which the
|
||||
# plugin-safety job owns). New test files are enrolled automatically;
|
||||
# excluding anything requires a visible, commented --ignore here.
|
||||
# Coverage is measured and enforced only in this step — pytest.ini
|
||||
# deliberately carries no coverage flags so local runs stay fast.
|
||||
- name: Run core unit suites
|
||||
run: |
|
||||
pytest --no-cov \
|
||||
test/test_skin_system.py \
|
||||
test/test_font_manager.py \
|
||||
test/test_data_sources.py \
|
||||
test/test_api_extractors.py \
|
||||
test/test_scroll_helper.py \
|
||||
test/test_scroll_helper_continuous.py \
|
||||
test/test_adaptive_layout.py \
|
||||
test/test_loader_compat_warning.py \
|
||||
test/test_sports_base_characterization.py \
|
||||
test/test_element_style.py \
|
||||
test/test_sports_core_promotions.py \
|
||||
test/test_sports_modes_promotions.py \
|
||||
test/test_sports_capabilities.py \
|
||||
test/test_sports_scroll.py \
|
||||
test/test_version_consistency.py \
|
||||
test/test_plugin_compatibility_gate.py \
|
||||
test/test_install_preserves_existing.py \
|
||||
test/test_core_owned_config_keys.py \
|
||||
test/test_async_plugin_updates.py \
|
||||
test/test_plugin_update_reservation.py \
|
||||
test/test_template_targets.py \
|
||||
test/test_widget_scripts.py \
|
||||
test/test_doc_links.py \
|
||||
test/test_registry_id_resolution.py \
|
||||
test/test_backup_manager.py \
|
||||
test/web_interface/test_cache.py
|
||||
pytest -m "not hardware" test/ \
|
||||
--ignore=test/plugins \
|
||||
--cov=src --cov=web_interface \
|
||||
--cov-report=term \
|
||||
--cov-fail-under=52
|
||||
|
||||
@@ -600,6 +600,14 @@ These settings are typically only needed for non-standard panels or custom confi
|
||||
- Leave empty unless you need custom mapping
|
||||
- See rpi-rgb-led-matrix documentation for full options
|
||||
|
||||
- **`orientation`** (string, default: "normal")
|
||||
- Rotates the rendered image to match how the panel is physically mounted
|
||||
- Set to `"180"` (or use the "Upside Down" option in the web UI's Display
|
||||
settings) if the panel is mounted upside down — useful for optimizing
|
||||
where the Raspberry Pi and wiring sit relative to the mounting location
|
||||
- Applied independently of `pixel_mapper_config` (appended as a trailing
|
||||
`Rotate:180` mapper), so custom mapper configs keep working alongside it
|
||||
|
||||
- **`row_address_type`** (integer, default: 0)
|
||||
- How rows are addressed on the panel
|
||||
- Most panels use 0 (direct addressing)
|
||||
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -112,6 +112,7 @@
|
||||
"led_rgb_sequence": "RGB",
|
||||
"limit_refresh_rate_hz": 100,
|
||||
"pixel_mapper_config": "",
|
||||
"orientation": "normal",
|
||||
"row_address_type": 0,
|
||||
"multiplexing": 0,
|
||||
"panel_type": ""
|
||||
@@ -129,6 +130,9 @@
|
||||
"plugin_rotation_order": [],
|
||||
"use_short_date_format": true,
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": false,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5,
|
||||
"enabled": false,
|
||||
"scroll_speed": 50,
|
||||
"separator_width": 32,
|
||||
@@ -149,7 +153,7 @@
|
||||
"min_plugin_width": 8,
|
||||
"lead_in_width": 0,
|
||||
"plugins_per_cycle": 6,
|
||||
"max_plugin_width_ratio": 3.0,
|
||||
"max_plugin_width_ratio": 0.0,
|
||||
"overflow_mode": "rotate",
|
||||
"dynamic_duration_enabled": true,
|
||||
"min_cycle_duration": 60,
|
||||
|
||||
@@ -64,10 +64,98 @@ JSON is optional.
|
||||
| `target_fps` | `125` | Target frame rate |
|
||||
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
||||
|
||||
This table is a subset — `display.vegas_scroll` supports 26 keys in
|
||||
This table is a subset — `display.vegas_scroll` supports 30 keys in
|
||||
total. See the full list in
|
||||
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
|
||||
|
||||
### Live Content in the Ticker
|
||||
|
||||
By default, live content **preempts** Vegas mode: while any plugin reports
|
||||
live priority, the display controller refuses to run the ticker and shows
|
||||
that plugin's full-screen display instead. You get a big readable scoreboard,
|
||||
but the marquee stops entirely for the duration of the game.
|
||||
|
||||
Set `live_in_ticker` to keep the ticker running and let live content take
|
||||
**extra turns inside it** instead:
|
||||
|
||||
```json
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": true,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5
|
||||
}
|
||||
```
|
||||
|
||||
#### Why weights exist
|
||||
|
||||
The rotation is otherwise a strict round robin — every plugin appears exactly
|
||||
once per cycle. With a dozen plugins enabled, a live score comes round once a
|
||||
lap and can be minutes old by the time you see it. A weight of *N* gives a
|
||||
plugin *N* slots per cycle.
|
||||
|
||||
The slots are placed by **Smooth Weighted Round-Robin**, the same scheduler
|
||||
the sports plugins use internally to rotate their own games. The important
|
||||
property is that repeats are *spread through the cycle* rather than clumped:
|
||||
three appearances in a row followed by a long silence would be worse than not
|
||||
boosting at all.
|
||||
|
||||
Twelve plugins, with a favorite's baseball game and an ordinary live hockey
|
||||
game (`live_weight: 3`, `favorite_live_weight: 5`):
|
||||
|
||||
```
|
||||
baseball > hockey > weather > clock > baseball
|
||||
stocks > news > flights > baseball > hockey
|
||||
calendar > f1 > music > baseball > tides
|
||||
birds > hockey > baseball
|
||||
```
|
||||
|
||||
18 slots for 12 plugins. Baseball appears 5 times, hockey 3, everything else
|
||||
once, and no plugin ever appears twice in a row — **including across the seam**
|
||||
where the cycle loops back on itself. Smooth Weighted Round-Robin schedules the
|
||||
heaviest item first and usually last as well, so the strip would otherwise show
|
||||
it twice running at exactly the one join a within-cycle check cannot see. The
|
||||
trailing repeat is moved into the widest remaining gap. Where a double is
|
||||
unavoidable — a plugin holding most of the slots has to neighbour itself — the
|
||||
schedule is left as it is.
|
||||
|
||||
#### Where the weight comes from
|
||||
|
||||
For each plugin in the rotation, in order:
|
||||
|
||||
1. **The plugin's own answer.** If it implements
|
||||
`get_vegas_priority_weight()` and returns a number, that wins. This is the
|
||||
only route for favorite-team awareness — the core can see *that* a game is
|
||||
live, but not *whose*, so a scoreboard has to say so itself.
|
||||
2. **The core's default.** When the plugin returns `None` (the base-class
|
||||
default), a plugin where both `has_live_priority()` and `has_live_content()`
|
||||
are true gets `live_weight`.
|
||||
3. **Everything else** gets 1.
|
||||
|
||||
Because of step 2, **existing plugins need no changes** — any scoreboard with
|
||||
`live_priority` enabled already gets extra turns. Step 1 is opt-in, for
|
||||
plugins that want to distinguish a favorite's game from any other live game.
|
||||
|
||||
Weights are clamped to 1–10. A weight of 1 is no boost; a weight below 1 would
|
||||
drop the plugin from the rotation entirely, which is never what is meant.
|
||||
|
||||
#### Things worth knowing
|
||||
|
||||
- **Weights are per plugin, not per game.** A scoreboard showing four live
|
||||
games still occupies one slot at a time, rotating its own games within that
|
||||
slot using its own `favorite_live_boost`. This controls how often the
|
||||
*plugin* comes round.
|
||||
- **The ticker is zero-sum.** Giving baseball 5 slots does not make the cycle
|
||||
faster; it makes the cycle *longer* and everything else proportionally
|
||||
rarer. If you want live scores sooner in wall-clock terms, pair this with a
|
||||
smaller `plugins_per_cycle`.
|
||||
- **Frequency is not freshness.** Each appearance redraws from the plugin's
|
||||
current data (`refresh_updated_plugins()` drops cached content when a
|
||||
plugin's data changes), but how current that data is depends on the
|
||||
plugin's own `live_update_interval`. Showing a stale score five times a lap
|
||||
is no better than showing it once.
|
||||
- **Everything still appears.** A boost never starves another plugin out of
|
||||
the cycle; low-weight plugins keep their single slot.
|
||||
|
||||
### Per-Plugin Configuration
|
||||
|
||||
Override Vegas behavior for specific plugins:
|
||||
|
||||
@@ -66,6 +66,7 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
|
||||
| `led_rgb_sequence` | string, `"RGB"` |
|
||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
|
||||
| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value |
|
||||
| `row_address_type` | int, `0` — non-standard panel row addressing |
|
||||
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
|
||||
@@ -103,7 +104,8 @@ logical image to multiple chained physical panels.
|
||||
## `display.vegas_scroll` — continuous scroll mode
|
||||
|
||||
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details.
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details, including
|
||||
[live content in the ticker](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
@@ -127,13 +129,16 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `min_plugin_width` | int, `8` |
|
||||
| `lead_in_width` | int, `0` |
|
||||
| `plugins_per_cycle` | int, `6` |
|
||||
| `max_plugin_width_ratio` | float, `3.0` |
|
||||
| `max_plugin_width_ratio` | float, `0.0` |
|
||||
| `overflow_mode` | string, `"rotate"` |
|
||||
| `dynamic_duration_enabled` | bool, `true` |
|
||||
| `min_cycle_duration` | int, `60` |
|
||||
| `max_cycle_duration` | int, `240` |
|
||||
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
||||
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
||||
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
|
||||
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
|
||||
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
|
||||
|
||||
## `sync` — multi-display synchronization
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Running on Low-Memory Boards
|
||||
|
||||
Applies to the Pi Zero 2 W (512 MB), Pi 3 / 3B+ (1 GB), and the 1 GB Pi 4.
|
||||
If your board has 2 GB or more you can skip this document.
|
||||
|
||||
## The failure this prevents
|
||||
|
||||
The display process is the largest thing on the board. On a 1 GB Pi 3B+ with
|
||||
around 20 plugins enabled it settles near **600 MB of 905 MB usable**, leaving
|
||||
under 200 MB of headroom for everything else.
|
||||
|
||||
When that headroom runs out, the board does not crash cleanly. `fork()` starts
|
||||
failing, and because a new process is needed to do almost anything, the
|
||||
symptoms look nothing like "out of memory":
|
||||
|
||||
| What you see | Why |
|
||||
|---|---|
|
||||
| SSH accepts the connection then closes it instantly, before any banner | `sshd` forks a session per connection; the fork fails |
|
||||
| The web UI still responds quickly | Already running, serves from existing threads, forks nothing |
|
||||
| Ping is perfect, 0% loss | Handled entirely in the kernel |
|
||||
| The panel is dark | The display process was killed and cannot be respawned |
|
||||
| The clock is wrong after the next boot | `fake-hwclock`'s periodic save is a scheduled job, and it cannot fork either |
|
||||
|
||||
The board looks healthy from the outside and cannot be logged into. Only a
|
||||
power cycle clears it. If you are here because SSH stopped working, also see
|
||||
[SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md), which
|
||||
covers the more common cause (AP mode).
|
||||
|
||||
## Check your headroom
|
||||
|
||||
```bash
|
||||
free -m
|
||||
ps -eo rss,comm --sort=-rss | head -5
|
||||
```
|
||||
|
||||
If `MemAvailable` is under ~150 MB while the display is running, you are close
|
||||
to the edge. To watch it over time:
|
||||
|
||||
```bash
|
||||
watch -n 30 'free -m | head -2'
|
||||
```
|
||||
|
||||
Available memory that falls steadily rather than holding flat means you will
|
||||
reach the wall; it is a question of when.
|
||||
|
||||
## What to do
|
||||
|
||||
**1. Enable the memory cgroup controller.** Without it, the `MemoryMax=85%` in
|
||||
`systemd/ledmatrix.service` is accepted by systemd and silently ignored, so the
|
||||
service has no ceiling and a runaway takes the whole board down instead of just
|
||||
restarting. Raspberry Pi firmware disables this controller by default.
|
||||
|
||||
`first_time_install.sh` does this for you. To check it took effect:
|
||||
|
||||
```bash
|
||||
grep memory /sys/fs/cgroup/cgroup.controllers
|
||||
```
|
||||
|
||||
If that prints nothing, add `cgroup_enable=memory cgroup_memory=1` to the
|
||||
kernel command line and reboot. Edit whichever file your image uses —
|
||||
`/boot/firmware/cmdline.txt` on current Raspberry Pi OS, `/boot/cmdline.txt` on
|
||||
older layouts (the installer checks the first and falls back to the second).
|
||||
Everything must stay on a single line.
|
||||
|
||||
This changes the failure mode from "the board becomes unreachable" to "the
|
||||
display service restarts". It is a safety net, not a fix.
|
||||
|
||||
**2. Run fewer plugins.** This is the actual remedy. Every enabled plugin costs
|
||||
memory permanently — its module, its parsed config, and its cached API
|
||||
responses. On a 512 MB or 1 GB board, keep the enabled set small and prefer
|
||||
plugins that poll infrequently.
|
||||
|
||||
**3. Lower the cache ceiling.** The in-memory cache is sized from total RAM
|
||||
(150 entries at 1 GB and below, up to 1500 at 8 GB). To go lower still:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/ledmatrix.service.d/override.conf
|
||||
[Service]
|
||||
Environment=LEDMATRIX_CACHE_MAX_ENTRIES=75
|
||||
```
|
||||
|
||||
Writing the file does not change the running service. Reload systemd and
|
||||
restart it:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
Fewer entries means more API calls, so lower this only while you are actually
|
||||
short of memory.
|
||||
|
||||
**4. Consider `MemoryHigh`.** `MemoryMax` kills and restarts. `MemoryHigh`
|
||||
throttles and reclaims instead, which is gentler — but on a board where the
|
||||
process genuinely wants more than the limit, sustained reclaim can stall the
|
||||
render loop and show as visible stutter on the panel. Add it only if you prefer
|
||||
degraded output to a restart:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
MemoryHigh=70%
|
||||
```
|
||||
|
||||
## Keep your logs
|
||||
|
||||
These images default to volatile journald storage, so every reboot destroys the
|
||||
logs — including the ones explaining why the board rebooted. `first_time_install.sh`
|
||||
enables persistent storage capped at 64 MB. To confirm:
|
||||
|
||||
```bash
|
||||
journalctl --list-boots
|
||||
```
|
||||
|
||||
More than one boot listed means logs are surviving reboots. If only one is
|
||||
listed, journald is still writing to `/run` (tmpfs).
|
||||
@@ -170,6 +170,47 @@ Default returns `False`.
|
||||
List of display modes to show during a live takeover. Default returns the
|
||||
plugin's `display_modes` from its manifest.
|
||||
|
||||
#### `get_vegas_priority_weight() -> Optional[int]`
|
||||
|
||||
How many slots per Vegas cycle this plugin should get. Default returns
|
||||
`None`, which defers to the core.
|
||||
|
||||
The Vegas ticker is otherwise a strict round robin — every plugin appears
|
||||
exactly once per cycle — so with a dozen plugins enabled a live score can be
|
||||
minutes stale by the time it comes round. A weight of *N* gives the plugin
|
||||
*N* slots per cycle, spread evenly through it rather than clumped.
|
||||
|
||||
**You usually do not need this.** When the hook returns `None`, the core
|
||||
already gives a plugin `vegas_scroll.live_weight` whenever
|
||||
`has_live_priority()` and `has_live_content()` are both true. Live sports get
|
||||
extra turns with no code at all.
|
||||
|
||||
Implement it only when the plugin knows something the core cannot. The
|
||||
motivating case is favorite teams — the core can see *that* a game is live,
|
||||
but not *whose*:
|
||||
|
||||
```python
|
||||
def get_vegas_priority_weight(self):
|
||||
if not (self.has_live_priority() and self.has_live_content()):
|
||||
return None # let the core decide
|
||||
vegas = self.global_config.get('display', {}).get('vegas_scroll', {})
|
||||
if self._favorite_is_live():
|
||||
return vegas.get('favorite_live_weight', 5)
|
||||
return vegas.get('live_weight', 3)
|
||||
```
|
||||
|
||||
The weight is per *plugin*, not per game: a scoreboard showing four live games
|
||||
still occupies one slot at a time and rotates its own games within it. Values
|
||||
are clamped to 1–10 by the caller. An exception here is caught and logged, and
|
||||
the core then falls back to its own live-content check — so a plugin whose
|
||||
weight calculation is broken still gets `live_weight` for a game that really
|
||||
is live, rather than being demoted to 1.
|
||||
|
||||
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
|
||||
default (`false`) live content preempts Vegas entirely and there is no ticker
|
||||
to be weighted within. See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||
|
||||
### Vegas scroll hooks
|
||||
|
||||
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
||||
|
||||
@@ -14,6 +14,7 @@ the one-shot installer. The pages here go deeper.
|
||||
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes
|
||||
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
|
||||
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
|
||||
8. [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md) — Pi Zero 2 W / 3B+ / 1GB Pi 4 memory limits
|
||||
|
||||
## I want to write a plugin
|
||||
|
||||
|
||||
@@ -20,7 +20,22 @@ The installation script:
|
||||
- Installs and configures `dnsmasq` (DHCP server for AP mode)
|
||||
- These services can interfere with normal WiFi client mode
|
||||
|
||||
### 3. Reboot After Installation
|
||||
### 3. The Board Ran Out of Memory
|
||||
|
||||
On a 512MB or 1GB board, memory exhaustion stops `sshd` being able to fork a
|
||||
session process. The connection is accepted and then closed immediately, before
|
||||
any banner:
|
||||
|
||||
```text
|
||||
kex_exchange_identification: Connection closed by remote host
|
||||
```
|
||||
|
||||
The giveaway is that the board is otherwise healthy — ping is clean and the web
|
||||
UI still responds — but nothing that needs to start a new process works, and
|
||||
the panel is usually dark. Only a power cycle clears it. See
|
||||
[LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md).
|
||||
|
||||
### 4. Reboot After Installation
|
||||
|
||||
If the script reboots the Pi (which it recommends), network services may restart in a different state, potentially triggering AP mode.
|
||||
|
||||
@@ -190,11 +205,23 @@ The web interface allows you to:
|
||||
|
||||
## Summary
|
||||
|
||||
**SSH becomes unavailable because**:
|
||||
**SSH becomes unavailable because** — two unrelated causes, and they need
|
||||
different responses:
|
||||
|
||||
*AP mode (most common):*
|
||||
- WiFi monitor service enables AP mode when WiFi disconnects
|
||||
- AP mode switches WiFi from client to access point mode
|
||||
- Pi loses connection to your original network
|
||||
|
||||
*Memory exhaustion (low-memory boards):*
|
||||
- The board runs out of memory, so `sshd` cannot fork a session process
|
||||
- The connection is accepted and closed before any banner
|
||||
- Ping still answers and the web UI still responds, so it looks healthy
|
||||
- The panel is usually dark and the service cannot restart
|
||||
- **Only a power cycle clears this** — there is no remote recovery, because
|
||||
every remote route needs a new process
|
||||
- Prevention and tuning: [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md)
|
||||
|
||||
**To regain SSH**:
|
||||
1. Connect to **LEDMatrix-Setup** AP network (password: `ledmatrix123`)
|
||||
2. SSH to `192.168.4.1`
|
||||
|
||||
@@ -206,6 +206,47 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the
|
||||
|
||||
The widget will be automatically rendered when the plugin configuration form is loaded.
|
||||
|
||||
## Labelling Enum Options (`x-options.labels`)
|
||||
|
||||
A plain `enum` renders as a dropdown whose option text is the value with
|
||||
underscores replaced and title case applied — `day_first` becomes "Day First".
|
||||
That is fine for values that read as their own label, and wrong for values that
|
||||
do not: `vs` becomes "Vs", and `abbrev` says nothing about the `Sep 19` it
|
||||
actually produces.
|
||||
|
||||
Supply `x-options.labels` to set the visible text. This is the same convention
|
||||
the `checkbox-group` widget uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"date_format": {
|
||||
"type": "string",
|
||||
"enum": ["abbrev", "numeric", "day_first"],
|
||||
"default": "abbrev",
|
||||
"x-options": {
|
||||
"labels": {
|
||||
"abbrev": "Sep 19",
|
||||
"numeric": "9/19",
|
||||
"day_first": "19 Sep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Labels are **display only** — the stored value is still the enum value, so
|
||||
adding them never changes a saved config. The map may be partial: any value
|
||||
without a label keeps the humanised fallback. Older cores that predate this
|
||||
support ignore `x-options` and render the fallback for every option, so a
|
||||
plugin can ship labels without requiring a core upgrade.
|
||||
|
||||
Array-table columns (`x-widget: array-table`) accept the same
|
||||
`x-options.labels` on a column definition, but their fallback is the **raw
|
||||
value** rather than the humanised one, because those columns hold values such
|
||||
as ticker symbols where `aapl` → "Aapl" would be wrong. Rows added in the
|
||||
browser use the labels too (`array-table.js`), so a column reads the same
|
||||
before and after a page reload.
|
||||
|
||||
## Marking Fields as Advanced (`x-advanced`)
|
||||
|
||||
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
||||
|
||||
@@ -1419,9 +1419,16 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/
|
||||
EOF
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
|
||||
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
|
||||
# when its output is a terminal. From that pager (less) a "!sh" is a root
|
||||
# shell -- the standard journalctl escalation. The web interface always passes
|
||||
# --no-pager, so nothing here needs it, but the rule cannot require a flag that
|
||||
# sits in the middle of the command line. NOEXEC stops the command executing
|
||||
# another program at all, which closes the hole without depending on wildcard
|
||||
# matching subtleties.
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
|
||||
EOF
|
||||
fi
|
||||
|
||||
@@ -1688,6 +1695,94 @@ else
|
||||
echo "✗ $CMDLINE_FILE not found; skipping isolcpus optimization"
|
||||
fi
|
||||
|
||||
# Enable the memory cgroup controller (idempotent).
|
||||
# The Pi firmware boots with cgroup_disable=memory, so systemd's MemoryMax= is
|
||||
# accepted and silently ignored — the display service then has no ceiling, and
|
||||
# a runaway takes the whole board down (sshd can no longer fork, the panel goes
|
||||
# dark) rather than just restarting the one service.
|
||||
if [ "$SKIP_PERF" != "1" ] && [ -f "$CMDLINE_FILE" ]; then
|
||||
# Both parameters are required for the memory controller, and they can get
|
||||
# separated -- an image, another tool or a half-applied earlier run can
|
||||
# leave one without the other. Checking only cgroup_enable=memory would
|
||||
# report success while MemoryMax= silently does nothing, so each is checked
|
||||
# and appended independently.
|
||||
cgroup_missing=""
|
||||
for cgroup_param in cgroup_enable=memory cgroup_memory=1; do
|
||||
if ! grep -qw "$cgroup_param" "$CMDLINE_FILE"; then
|
||||
cgroup_missing="$cgroup_missing $cgroup_param"
|
||||
fi
|
||||
done
|
||||
if [ -z "$cgroup_missing" ]; then
|
||||
echo "cgroup memory parameters already present in $CMDLINE_FILE"
|
||||
else
|
||||
echo "Adding${cgroup_missing} to $CMDLINE_FILE..."
|
||||
cp "$CMDLINE_FILE" "$CMDLINE_FILE.bak" 2>/dev/null || true
|
||||
# The kernel command line must stay on one line.
|
||||
sed -i "1 s|\$|${cgroup_missing}|" "$CMDLINE_FILE"
|
||||
echo " Takes effect after reboot. Verify with:"
|
||||
echo " grep memory /sys/fs/cgroup/cgroup.controllers"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Persist the journal (idempotent).
|
||||
# These images default to volatile storage: journald keeps everything in /run
|
||||
# (tmpfs), so every reboot destroys the logs — including the ones that would
|
||||
# explain why the board rebooted. Capped so an SD card is not worn out by logs.
|
||||
# A non-empty /var/log/journal does not prove journald is configured the way
|
||||
# this needs: the directory survives a switch back to volatile storage, and it
|
||||
# says nothing about whether a size cap is set. Read the effective
|
||||
# configuration instead, and only write the keys the user has not set
|
||||
# themselves so an explicit local limit is preserved.
|
||||
journald_effective() {
|
||||
# systemd-analyze merges journald.conf with every drop-in; grep is the
|
||||
# fallback for images that ship without it.
|
||||
if command -v systemd-analyze >/dev/null 2>&1 &&
|
||||
systemd-analyze cat-config systemd/journald.conf >/dev/null 2>&1; then
|
||||
systemd-analyze cat-config systemd/journald.conf 2>/dev/null
|
||||
else
|
||||
cat /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null
|
||||
fi
|
||||
}
|
||||
journald_conf="$(journald_effective)"
|
||||
journald_storage="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
||||
journald_cap="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*SystemMaxUse=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
||||
|
||||
if [ "$journald_storage" = "persistent" ] && [ -n "$journald_cap" ]; then
|
||||
echo "Persistent journald storage already configured (SystemMaxUse=$journald_cap)"
|
||||
else
|
||||
echo "Enabling persistent journald storage..."
|
||||
mkdir -p /etc/systemd/journald.conf.d
|
||||
{
|
||||
echo "# Installed by LEDMatrix first_time_install.sh"
|
||||
echo "[Journal]"
|
||||
echo "Storage=persistent"
|
||||
if [ -n "$journald_cap" ]; then
|
||||
echo "# SystemMaxUse left to your existing setting ($journald_cap)"
|
||||
else
|
||||
# Capped so logs cannot wear out or fill an SD card.
|
||||
echo "SystemMaxUse=64M"
|
||||
fi
|
||||
} > /etc/systemd/journald.conf.d/ledmatrix-persistent.conf
|
||||
mkdir -p /var/log/journal
|
||||
systemd-tmpfiles --create --prefix /var/log/journal >/dev/null 2>&1 || true
|
||||
systemctl restart systemd-journald >/dev/null 2>&1 || true
|
||||
|
||||
# Drop-ins are applied in lexical order, so a locally added file that sorts
|
||||
# after ledmatrix-persistent.conf (zz-local.conf and friends) still wins.
|
||||
# Writing the file is not evidence it took effect -- re-read and say so
|
||||
# plainly rather than reporting success we cannot confirm.
|
||||
journald_now="$(journald_effective | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
||||
if [ "$journald_now" = "persistent" ]; then
|
||||
echo " Persistent journald storage active"
|
||||
else
|
||||
echo " WARNING: journald storage is still '${journald_now:-unset}' after"
|
||||
echo " writing /etc/systemd/journald.conf.d/ledmatrix-persistent.conf."
|
||||
echo " Another drop-in that sorts later is overriding it. Check:"
|
||||
echo " systemd-analyze cat-config systemd/journald.conf | grep -n Storage="
|
||||
echo " Logs will not survive a reboot until that is resolved."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure dtparam=audio=off in config.txt (idempotent)
|
||||
if [ "$SKIP_PERF" = "1" ]; then
|
||||
: # skipped
|
||||
|
||||
@@ -10,16 +10,13 @@ python_functions = test_*
|
||||
testpaths = test
|
||||
|
||||
# Output options
|
||||
# Note: Coverage options require pytest-cov to be installed
|
||||
# Run: pip install pytest-cov
|
||||
# Coverage is deliberately NOT configured here: a bare local `pytest` should
|
||||
# be fast and dependency-light. Coverage is measured and enforced in exactly
|
||||
# one place — the unit-tests job in .github/workflows/test.yml.
|
||||
addopts =
|
||||
-v
|
||||
--strict-markers
|
||||
--tb=short
|
||||
--cov=src
|
||||
--cov-report=term-missing
|
||||
--cov-report=html
|
||||
--cov-fail-under=30
|
||||
|
||||
# Markers
|
||||
markers =
|
||||
|
||||
@@ -4,4 +4,6 @@ pytest>=9.0.3,<10.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
pytest-mock>=3.11.0,<4.0.0
|
||||
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
|
||||
# /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)
|
||||
|
||||
@@ -41,6 +41,7 @@ from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
)
|
||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||
check_empty_claimed,
|
||||
check_scale_up,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
@@ -115,6 +116,11 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||
fill_strict = spec.get("fill_check") == "strict"
|
||||
# A mode that renders nothing without returning False is never skipped by
|
||||
# the display controller, so it holds a blank panel for its whole duration.
|
||||
# Warn-only by default: a scroll mode's first frame is legitimately its
|
||||
# blank scroll-in buffer.
|
||||
empty_strict = spec.get("empty_check") == "strict"
|
||||
|
||||
# Every run: the base config, plus one per harness.json "variant" —
|
||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
||||
@@ -142,6 +148,7 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
compare_to_goldens(results, golden_dir)
|
||||
|
||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
||||
check_empty_claimed(results, strict=empty_strict)
|
||||
|
||||
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||
if variant_name:
|
||||
@@ -178,6 +185,9 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
# warn-only underfill: big panel left mostly empty
|
||||
ex, ey = r.fill_extent
|
||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
||||
if r.empty_claimed and r.empty_ok is None:
|
||||
detail += (f" (empty warn: drew nothing but display() returned"
|
||||
f" {r.display_returned!r}, so the mode is not skipped)")
|
||||
else:
|
||||
everything_ok = False
|
||||
if r.error is not None:
|
||||
@@ -191,6 +201,11 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||
status = "FAIL"
|
||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
||||
elif r.empty_ok is False:
|
||||
status = "FAIL"
|
||||
detail = (f" drew nothing but display() returned"
|
||||
f" {r.display_returned!r}; return False so the"
|
||||
f" controller skips the mode")
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
|
||||
@@ -24,9 +24,29 @@ echo "========================================"
|
||||
# Auto-detect latest version if needed
|
||||
if [ "$PIXLET_VERSION" = "latest" ]; then
|
||||
echo "Detecting latest version..."
|
||||
PIXLET_VERSION=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if [ -z "$PIXLET_VERSION" ]; then
|
||||
echo "Failed to detect latest version, using fallback"
|
||||
# When this response arrives on a single line -- as it did on the device
|
||||
# where Starlark apps were failing -- `grep '"tag_name"'` matches the whole
|
||||
# document and a greedy `sed 's/.*"([^"]+)".*/\1/'` captures the LAST
|
||||
# quoted token in it rather than the tag. That resolved to
|
||||
# "mentions_count", which built a download URL for a release that does not
|
||||
# exist. (The API is pretty-printed by default, which is why the old
|
||||
# command looks correct when you try it by hand -- but the formatting is
|
||||
# not something to depend on.) Match the field itself and take the value
|
||||
# after it, which is right for either shape.
|
||||
PIXLET_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
|
||||
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||
| head -n1 \
|
||||
| sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')
|
||||
|
||||
# A wrong-but-non-empty value is what made the old bug silent, so check the
|
||||
# shape rather than just that something came back. Anchored at both ends: a
|
||||
# partial match would accept "v0.53garbage" or "0.53" and build a URL for a
|
||||
# release that cannot exist, which is the failure this check is here to
|
||||
# stop. Every tronbyt/pixlet release to date is vX.Y.Z; the optional suffix
|
||||
# leaves room for a future -rc.1 or +build tag.
|
||||
if ! printf '%s' "$PIXLET_VERSION" \
|
||||
| grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
|
||||
echo "Could not detect the latest version (got: '${PIXLET_VERSION:-<empty>}'), using fallback"
|
||||
PIXLET_VERSION="v0.50.2"
|
||||
fi
|
||||
fi
|
||||
@@ -67,8 +87,26 @@ download_binary() {
|
||||
temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX)
|
||||
local temp_file="$temp_dir/$archive_name"
|
||||
|
||||
if ! curl -L -o "$temp_file" "$url" 2>/dev/null; then
|
||||
echo "✗ Failed to download $arch"
|
||||
# -f so an HTTP error is a failure. Without it curl writes the 404 body
|
||||
# to the file and exits 0, and the first sign of trouble is tar saying
|
||||
# "not in gzip format" about what is actually a page of HTML.
|
||||
if ! curl -fL -o "$temp_file" "$url" 2>/dev/null; then
|
||||
echo "✗ Failed to download $arch from $url"
|
||||
rm -rf "$temp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Belt and braces: a mirror or proxy can return 200 with an error page.
|
||||
if ! gzip -t "$temp_file" 2>/dev/null; then
|
||||
echo "✗ Downloaded file is not a gzip archive: $url"
|
||||
# These bytes come from whatever answered the request, so strip
|
||||
# everything non-printable before echoing them: an error page carrying
|
||||
# terminal escapes would otherwise be able to rewrite this output or
|
||||
# bury it in a CI log. Printable characters are kept rather than
|
||||
# hex-encoding the lot, because "<!DOCTYPE html>" is the diagnostic.
|
||||
local first_bytes
|
||||
first_bytes=$(head -c 60 "$temp_file" | tr -cd '[:print:]')
|
||||
printf ' (first bytes: %s)\n' "$first_bytes"
|
||||
rm -rf "$temp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -100,10 +100,15 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
||||
|
||||
# Optional: journalctl (non-critical — skip if not found)
|
||||
#
|
||||
# NOEXEC, matching first_time_install.sh. These rules end in a wildcard and
|
||||
# journalctl starts a pager, so without it the caller can reach a shell:
|
||||
# less runs "!command" as the user the pager belongs to, which here is
|
||||
# root. NOEXEC stops the granted command executing anything of its own.
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *"
|
||||
fi
|
||||
|
||||
# Required: python3, bash
|
||||
|
||||
@@ -25,9 +25,44 @@ if [ "$EUID" -eq 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve command paths against a fixed PATH, and check what we resolved.
|
||||
#
|
||||
# Every path found here is written into a sudoers file as a NOPASSWD grant, so
|
||||
# whoever controls the binary at that path controls root. first_time_install.sh
|
||||
# re-execs itself with `sudo -E`, which preserves the invoking user's
|
||||
# environment -- PATH included -- so without pinning it, `which nmcli` can
|
||||
# resolve to anything on that PATH: a writable directory early in it turns a
|
||||
# compromise of the low-privilege web user into permanent root.
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export PATH
|
||||
|
||||
# A binary named in a sudoers rule must be root-owned and writable by nobody
|
||||
# else, or the grant hands root to whoever can rewrite it.
|
||||
require_trusted_binary() {
|
||||
local label="$1" path="$2"
|
||||
if [ ! -x "$path" ]; then
|
||||
echo "✗ $label: $path is not an executable file"
|
||||
exit 1
|
||||
fi
|
||||
local owner perms
|
||||
owner=$(stat -c '%u' "$path") || exit 1
|
||||
perms=$(stat -c '%a' "$path") || exit 1
|
||||
if [ "$owner" != "0" ]; then
|
||||
echo "✗ $label: $path is not owned by root (uid $owner); refusing to"
|
||||
echo " grant it NOPASSWD sudo."
|
||||
exit 1
|
||||
fi
|
||||
# Group- or world-writable means someone other than root can replace it.
|
||||
case "$perms" in
|
||||
*[2367]) echo "✗ $label: $path is writable by group or other ($perms);"
|
||||
echo " refusing to grant it NOPASSWD sudo."
|
||||
exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Get the full paths to commands
|
||||
NMCLI_PATH=$(which nmcli || echo "/usr/bin/nmcli")
|
||||
SYSTEMCTL_PATH=$(which systemctl)
|
||||
NMCLI_PATH=$(command -v nmcli || echo "/usr/bin/nmcli")
|
||||
SYSTEMCTL_PATH=$(command -v systemctl)
|
||||
|
||||
echo "Command paths:"
|
||||
echo " nmcli: $NMCLI_PATH"
|
||||
@@ -37,6 +72,18 @@ echo " systemctl: $SYSTEMCTL_PATH"
|
||||
echo ""
|
||||
echo "Step 1: Configuring sudo permissions for nmcli..."
|
||||
SUDOERS_FILE="/etc/sudoers.d/ledmatrix_wifi"
|
||||
SYSCTL_PATH=$(command -v sysctl || echo /usr/sbin/sysctl)
|
||||
NFT_PATH=$(command -v nft || echo /usr/sbin/nft)
|
||||
RFKILL_PATH=$(command -v rfkill || echo /usr/sbin/rfkill)
|
||||
MKDIR_PATH=$(command -v mkdir || echo /usr/bin/mkdir)
|
||||
|
||||
# Checked before any of them reaches the sudoers file.
|
||||
require_trusted_binary "nmcli" "$NMCLI_PATH"
|
||||
require_trusted_binary "systemctl" "$SYSTEMCTL_PATH"
|
||||
require_trusted_binary "sysctl" "$SYSCTL_PATH"
|
||||
require_trusted_binary "nft" "$NFT_PATH"
|
||||
require_trusted_binary "rfkill" "$RFKILL_PATH"
|
||||
require_trusted_binary "mkdir" "$MKDIR_PATH"
|
||||
|
||||
# Create a temporary sudoers file using mktemp (handles permissions better)
|
||||
TEMP_SUDOERS=$(mktemp) || {
|
||||
@@ -62,6 +109,36 @@ $WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start dnsmasq
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop dnsmasq
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart dnsmasq
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart NetworkManager
|
||||
# The captive portal turns IP forwarding on while the access point is up and
|
||||
# restores the previous value when it comes down (wifi_manager._setup_iptables_
|
||||
# redirect / _teardown_iptables_redirect). Without this rule that sudo call
|
||||
# needs a password, so forwarding stays off and clients associate to the AP but
|
||||
# cannot route. It goes unnoticed on a stock Raspberry Pi image, where
|
||||
# /etc/sudoers.d/010_pi-nopasswd grants the default user blanket NOPASSWD and
|
||||
# masks every gap in this file -- it only bites once that blanket rule is
|
||||
# removed.
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSCTL_PATH -w net.ipv4.ip_forward=0
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSCTL_PATH -w net.ipv4.ip_forward=1
|
||||
# The portal's redirect lives in its own nftables table, created when the AP
|
||||
# comes up and deleted when it goes down, and the radio has to be unblocked
|
||||
# before the AP can start at all. Same story as the sysctl rules above: called
|
||||
# with sudo, never granted here, and invisible on a stock Pi image.
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $NFT_PATH add table ip ledmatrix
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $NFT_PATH delete table ip ledmatrix
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $RFKILL_PATH unblock wifi
|
||||
# NetworkManager's dnsmasq drop-in directory, exact path.
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $MKDIR_PATH -p /etc/NetworkManager/dnsmasq-shared.d
|
||||
#
|
||||
# iptables is deliberately NOT granted here. Its rules are built from the live
|
||||
# interface name and port, so a rule covering them needs a trailing wildcard --
|
||||
# and `iptables --modprobe=/path/to/anything` runs that path as root, so
|
||||
# `NOPASSWD: iptables *` is a root shell for the web user by another name. That
|
||||
# is a worse outcome than the gap it would close, which today is masked anyway
|
||||
# by the blanket NOPASSWD rule on stock Pi images.
|
||||
#
|
||||
# Closing it safely means a wrapper script that builds the rules itself and
|
||||
# takes only an interface and a port, granted the way safe_plugin_rm.sh already
|
||||
# is. That belongs in its own change rather than being smuggled into this one.
|
||||
|
||||
# Allow copying hostapd and dnsmasq config files into place
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
|
||||
|
||||
@@ -383,10 +383,15 @@ class SportsCore(ABC):
|
||||
ctx = skin_runtime.build_context(self, game, size=size)
|
||||
card = skin.render_vegas_card(ctx, dict(game))
|
||||
if card is not None:
|
||||
# A successful render clears accumulated strikes, mirroring
|
||||
# _render_game — transient failures must not add up across
|
||||
# the session and disable a working skin.
|
||||
self._skin_failures = 0
|
||||
return card
|
||||
ctx = skin_runtime.build_context(self, game, size=size)
|
||||
render = getattr(skin, f"render_{self.SKIN_MODE}")
|
||||
if render(ctx, dict(game)):
|
||||
self._skin_failures = 0
|
||||
return ctx.canvas
|
||||
except Exception:
|
||||
# Card failures count toward the same 3-strike session disable
|
||||
|
||||
@@ -12,6 +12,8 @@ Follows LEDMatrix configuration management patterns:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
import json
|
||||
from typing import Dict, Any, Optional, List
|
||||
@@ -43,9 +45,34 @@ class BaseOddsManager:
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
||||
|
||||
# This path used a bare requests.get, so it identified itself as
|
||||
# python-requests/x.y -- the one thing ESPN is known to reject. Around
|
||||
# 2026-08-04 it began 403ing browser strings and bare custom tokens
|
||||
# alike; what it accepts is a token with a URL that says who is
|
||||
# calling. Every other ESPN caller in the tree already sends this
|
||||
# (src/common/api_helper.py, src/base_classes/data_sources.py); the
|
||||
# odds path was simply missed, and it is the one whose failures cost
|
||||
# the caller its whole update budget.
|
||||
#
|
||||
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
||||
# request_timeout, which is set to 5s precisely to stay inside that
|
||||
# budget. One try, then the cooldown below.
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
|
||||
# Configuration with defaults
|
||||
self.update_interval = 3600 # 1 hour default
|
||||
self.request_timeout = 30 # 30 seconds default
|
||||
# Well under the plugin executor's 30s operation budget. At 30s a
|
||||
# single stalled ESPN request consumed the entire budget and the whole
|
||||
# update() was killed -- and odds are fetched per live game, inside the
|
||||
# live update loop, with show_odds defaulting on. Losing one game's
|
||||
# odds beats losing the update that carries every game's score.
|
||||
self.request_timeout = 5
|
||||
# Set when a request fails; until then, skip the network entirely.
|
||||
self._skip_network_until = 0.0
|
||||
self.cache_ttl = 1800 # 30 minutes default
|
||||
|
||||
# Load configuration if available
|
||||
@@ -73,6 +100,14 @@ class BaseOddsManager:
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}")
|
||||
|
||||
# After a network failure, stop trying for this long and serve cache only.
|
||||
# A short per-request timeout bounds one stall, but a full Sunday slate is
|
||||
# ~16 games fetched in a loop, so 16 consecutive timeouts still blow the
|
||||
# budget. When ESPN is unreachable it is unreachable for all of them, so
|
||||
# the first failure is enough to know: skip the rest of this pass and try
|
||||
# again shortly.
|
||||
_FAILURE_COOLDOWN = 60.0
|
||||
|
||||
def get_odds(self, sport: str | None, league: str | None, event_id: str,
|
||||
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -101,6 +136,16 @@ class BaseOddsManager:
|
||||
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
|
||||
return cached_data
|
||||
|
||||
if time.monotonic() < self._skip_network_until:
|
||||
# A recent request failed, so ESPN is very likely still unreachable.
|
||||
# Returning now keeps the caller's update inside its time budget
|
||||
# instead of paying the timeout again for every remaining game.
|
||||
self.logger.debug(
|
||||
"Skipping odds fetch for %s: a recent request failed, holding off "
|
||||
"for another %.0fs", cache_key,
|
||||
self._skip_network_until - time.monotonic())
|
||||
return None
|
||||
|
||||
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
|
||||
|
||||
try:
|
||||
@@ -117,10 +162,12 @@ class BaseOddsManager:
|
||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
||||
self.logger.info(f"Requesting odds from URL: {url}")
|
||||
|
||||
response = requests.get(url, timeout=self.request_timeout)
|
||||
response = self.session.get(url, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
raw_data = response.json()
|
||||
|
||||
self._skip_network_until = 0.0 # reachable again
|
||||
|
||||
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
|
||||
|
||||
odds_data = self._extract_espn_data(raw_data)
|
||||
@@ -140,7 +187,11 @@ class BaseOddsManager:
|
||||
return odds_data
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}")
|
||||
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
|
||||
self.logger.error(
|
||||
"Error fetching odds from ESPN API for %s: %s. Holding off on odds "
|
||||
"for %.0fs so a slate of games does not pay this timeout each.",
|
||||
cache_key, e, self._FAILURE_COOLDOWN)
|
||||
except json.JSONDecodeError:
|
||||
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
||||
|
||||
@@ -163,19 +214,25 @@ class BaseOddsManager:
|
||||
item = data["items"][0]
|
||||
self.logger.debug(f"First item keys: {list(item.keys())}")
|
||||
|
||||
# The ESPN API returns odds data directly in the item, not in a providers array
|
||||
# Extract the odds data directly from the item
|
||||
# The ESPN API returns odds data directly in the item, not in a
|
||||
# providers array. ESPN sends explicit JSON nulls for absent
|
||||
# sides ("homeTeamOdds": null), so every level uses `or {}` —
|
||||
# .get's default only applies when the key is missing entirely.
|
||||
home = item.get("homeTeamOdds") or {}
|
||||
away = item.get("awayTeamOdds") or {}
|
||||
extracted_data = {
|
||||
"details": item.get("details"),
|
||||
"over_under": item.get("overUnder"),
|
||||
"spread": item.get("spread"),
|
||||
"home_team_odds": {
|
||||
"money_line": item.get("homeTeamOdds", {}).get("moneyLine"),
|
||||
"spread_odds": item.get("homeTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
||||
"money_line": home.get("moneyLine"),
|
||||
"spread_odds": ((home.get("current") or {})
|
||||
.get("pointSpread") or {}).get("value")
|
||||
},
|
||||
"away_team_odds": {
|
||||
"money_line": item.get("awayTeamOdds", {}).get("moneyLine"),
|
||||
"spread_odds": item.get("awayTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
||||
"money_line": away.get("moneyLine"),
|
||||
"spread_odds": ((away.get("current") or {})
|
||||
.get("pointSpread") or {}).get("value")
|
||||
}
|
||||
}
|
||||
self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}")
|
||||
@@ -260,7 +317,11 @@ class BaseOddsManager:
|
||||
Returns:
|
||||
Formatted odds summary string
|
||||
"""
|
||||
if not self.is_odds_available(odds_data):
|
||||
# Gate only on truly-empty / negative-cached data. is_odds_available
|
||||
# deliberately ignores money lines (its callers decide whether to
|
||||
# RENDER an odds widget), but a summary of money-line-only odds is
|
||||
# still meaningful — the parts loop below handles them.
|
||||
if not odds_data or odds_data.get('no_odds'):
|
||||
return "No odds available"
|
||||
|
||||
parts = []
|
||||
|
||||
@@ -14,6 +14,13 @@ import zlib
|
||||
from typing import Dict, Any, Optional, Protocol
|
||||
from datetime import datetime
|
||||
|
||||
# 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
|
||||
# any in-flight write while still clearing the same day's debris. Deliberately
|
||||
# not tied to the retention policies: those describe how long data stays
|
||||
# useful, and a half-written file was never useful.
|
||||
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
|
||||
|
||||
|
||||
|
||||
class CacheStrategyProtocol(Protocol):
|
||||
@@ -112,6 +119,22 @@ class DiskCache:
|
||||
record_ts = None
|
||||
|
||||
now = time.time()
|
||||
|
||||
# An explicit per-entry ttl wins over the caller's max_age. The
|
||||
# caller that wrote the record knows what its data is; max_age is
|
||||
# inferred from substrings in the key ("live", "odds", "stock") and
|
||||
# is only a fallback for records that never said. Until now the ttl
|
||||
# was stored and ignored, so `set(key, data, ttl=...)` did nothing
|
||||
# at all -- 48 plugin call sites and 4 in the core were writing a
|
||||
# number no read path consulted.
|
||||
effective_max_age = max_age
|
||||
if isinstance(record, dict):
|
||||
stored_ttl = record.get('ttl')
|
||||
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
|
||||
and stored_ttl >= 0:
|
||||
effective_max_age = stored_ttl
|
||||
max_age = effective_max_age
|
||||
|
||||
# max_age=None means "never expires" (mirrors MemoryCache and the
|
||||
# cache_manager docstring). Guard it explicitly — otherwise the
|
||||
# comparison below raises TypeError and the record is treated as a
|
||||
@@ -331,6 +354,23 @@ class DiskCache:
|
||||
"""Get the cache directory path."""
|
||||
return self.cache_dir
|
||||
|
||||
@staticmethod
|
||||
def _is_orphaned_temp(filename: str) -> bool:
|
||||
"""Whether a name is one of set()'s temp files rather than real data.
|
||||
|
||||
Matches only what this class creates: mkstemp with a prefix of
|
||||
".<cache filename>." , so ".weather.json.a1b2c3d4". The shape is
|
||||
checked rather than just the leading dot, because this predicate
|
||||
deletes things -- a stray dotfile someone left in the cache directory
|
||||
is not ours to remove, and a completed ".json" never is either.
|
||||
"""
|
||||
if not filename.startswith('.') or filename.endswith('.json'):
|
||||
return False
|
||||
head, sep, suffix = filename.rpartition('.json.')
|
||||
# head is the key (non-empty after the leading dot), suffix is
|
||||
# mkstemp's random component.
|
||||
return bool(sep) and len(head) > 1 and bool(suffix)
|
||||
|
||||
def cleanup_expired_files(self, cache_strategy: CacheStrategyProtocol, retention_policies: Dict[str, int]) -> Dict[str, Any]:
|
||||
"""
|
||||
Clean up expired cache files based on retention policies.
|
||||
@@ -365,12 +405,51 @@ class DiskCache:
|
||||
try:
|
||||
with self._lock:
|
||||
# Get snapshot of files while holding lock briefly
|
||||
filenames = [f for f in os.listdir(self.cache_dir) if f.endswith('.json')]
|
||||
entries = os.listdir(self.cache_dir)
|
||||
except OSError as list_error:
|
||||
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
|
||||
stats['errors'] += 1
|
||||
return stats
|
||||
|
||||
filenames = [f for f in entries if f.endswith('.json')]
|
||||
|
||||
# Sweep temp files abandoned by a write that never finished. set()
|
||||
# removes its own in a finally, so these are the ones where the
|
||||
# process died between mkstemp and os.replace -- a SIGKILL, a lost
|
||||
# restart race, a power cut. Nothing ever collected them: they are
|
||||
# named ".<key>.json.<random>", and the scan above only matches
|
||||
# names ending in .json, so they accumulated indefinitely. Measured
|
||||
# on a live rig: 76 files, 1,050 MB, 81% of the whole cache
|
||||
# directory, the oldest six months old.
|
||||
stats['orphan_temp_files_deleted'] = 0
|
||||
for filename in (f for f in entries if self._is_orphaned_temp(f)):
|
||||
# Counted as scanned like any other candidate, so files_deleted
|
||||
# can never exceed files_scanned and the summary line reads
|
||||
# honestly ("77/8864", not "77/0").
|
||||
stats['files_scanned'] += 1
|
||||
path = os.path.join(self.cache_dir, filename)
|
||||
try:
|
||||
# An in-flight write lives for milliseconds, so anything
|
||||
# this old is certainly abandoned rather than in progress.
|
||||
if (current_time - os.path.getmtime(path)) <= _ORPHAN_TEMP_MAX_AGE_SECONDS:
|
||||
continue
|
||||
with self._lock:
|
||||
size = os.path.getsize(path)
|
||||
os.remove(path)
|
||||
stats['files_deleted'] += 1
|
||||
stats['orphan_temp_files_deleted'] += 1
|
||||
stats['space_freed_bytes'] += size
|
||||
except FileNotFoundError:
|
||||
continue # another sweep got there first
|
||||
except OSError as e:
|
||||
stats['errors'] += 1
|
||||
self.logger.warning("Error deleting orphaned temp file %s: %s", filename, e)
|
||||
|
||||
if stats['orphan_temp_files_deleted']:
|
||||
self.logger.info(
|
||||
"Removed %d abandoned cache temp file(s)",
|
||||
stats['orphan_temp_files_deleted'])
|
||||
|
||||
# Process files outside the lock to avoid blocking get/set operations
|
||||
for filename in filenames:
|
||||
stats['files_scanned'] += 1
|
||||
|
||||
@@ -4,11 +4,58 @@ Memory Cache
|
||||
Handles in-memory caching with TTL support, size limits, and automatic cleanup.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# Historical fixed ceiling, kept as the fallback when RAM cannot be read.
|
||||
DEFAULT_MAX_SIZE = 1000
|
||||
|
||||
|
||||
def _total_memory_mb() -> Optional[float]:
|
||||
"""Physical RAM in MB, or None where /proc/meminfo is unavailable."""
|
||||
try:
|
||||
with open('/proc/meminfo', 'r', encoding='utf-8') as fh:
|
||||
for line in fh:
|
||||
if line.startswith('MemTotal:'):
|
||||
return int(line.split()[1]) / 1024
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def default_max_size() -> int:
|
||||
"""Entry ceiling scaled to this machine's RAM.
|
||||
|
||||
One fixed ceiling cannot serve both a 512 MB Pi Zero 2 W and an 8 GB Pi 5.
|
||||
Entries here are parsed API payloads that routinely run tens of kilobytes
|
||||
each, so a thousand of them is a comfortable cache on a large board and a
|
||||
substantial fraction of total RAM on a small one — where the process
|
||||
competing for that RAM is also driving the panel. Set
|
||||
LEDMATRIX_CACHE_MAX_ENTRIES to override.
|
||||
"""
|
||||
override = os.environ.get('LEDMATRIX_CACHE_MAX_ENTRIES')
|
||||
if override:
|
||||
try:
|
||||
value = int(override)
|
||||
if value > 0:
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total_mb = _total_memory_mb()
|
||||
if total_mb is None:
|
||||
return DEFAULT_MAX_SIZE
|
||||
if total_mb < 1536: # 512 MB and 1 GB boards
|
||||
return 150
|
||||
if total_mb < 3072: # 2 GB
|
||||
return 400
|
||||
if total_mb < 6144: # 4 GB
|
||||
return 800
|
||||
return 1500 # 8 GB and up
|
||||
|
||||
|
||||
class MemoryCache:
|
||||
"""Manages in-memory cache with TTL and size limits."""
|
||||
@@ -57,6 +104,16 @@ class MemoryCache:
|
||||
if timestamp is None:
|
||||
return None
|
||||
|
||||
# An explicit per-entry ttl wins over the caller's max_age, matching
|
||||
# DiskCache. max_age is inferred from substrings in the key and is
|
||||
# only a fallback for records that did not say what they wanted.
|
||||
record = self._cache[key]
|
||||
if isinstance(record, dict):
|
||||
stored_ttl = record.get('ttl')
|
||||
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
|
||||
and stored_ttl >= 0:
|
||||
max_age = stored_ttl
|
||||
|
||||
# Check expiration
|
||||
if max_age is not None and (now - timestamp) > max_age:
|
||||
# Expired - remove it
|
||||
@@ -77,6 +134,32 @@ class MemoryCache:
|
||||
with self._lock:
|
||||
self._cache[key] = value
|
||||
self._timestamps[key] = time.time()
|
||||
# Enforce the ceiling here rather than leaving it to the periodic
|
||||
# cleanup, which only runs every cleanup_interval seconds (300 by
|
||||
# default). A burst of inserts between two sweeps could otherwise
|
||||
# take the cache far past _max_size, which is the memory growth this
|
||||
# limit exists to prevent -- and on a 1GB board that is the
|
||||
# difference between a bounded cache and an unreachable Pi.
|
||||
self._evict_over_limit_locked()
|
||||
|
||||
def _evict_over_limit_locked(self) -> int:
|
||||
"""Drop oldest entries until the cache is within _max_size.
|
||||
|
||||
Caller must hold self._lock. Returns the number of entries removed.
|
||||
"""
|
||||
excess = len(self._cache) - self._max_size
|
||||
if excess <= 0:
|
||||
return 0
|
||||
oldest = sorted(
|
||||
self._timestamps.items(),
|
||||
key=lambda item: float(item[1]) if isinstance(item[1], (int, float)) else 0.0
|
||||
)
|
||||
removed = 0
|
||||
for key, _ in oldest[:excess]:
|
||||
self._cache.pop(key, None)
|
||||
self._timestamps.pop(key, None)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
def clear(self, key: Optional[str] = None) -> None:
|
||||
"""
|
||||
@@ -133,22 +216,8 @@ class MemoryCache:
|
||||
self._timestamps.pop(key, None)
|
||||
removed_count += 1
|
||||
|
||||
# Enforce size limit by removing oldest entries if cache is too large
|
||||
if len(self._cache) > self._max_size:
|
||||
# Sort by timestamp (oldest first)
|
||||
sorted_entries = sorted(
|
||||
self._timestamps.items(),
|
||||
key=lambda x: float(x[1]) if isinstance(x[1], (int, float)) else 0
|
||||
)
|
||||
|
||||
# Remove oldest entries until we're under the limit
|
||||
excess_count = len(self._cache) - self._max_size
|
||||
for i in range(excess_count):
|
||||
if i < len(sorted_entries):
|
||||
key = sorted_entries[i][0]
|
||||
self._cache.pop(key, None)
|
||||
self._timestamps.pop(key, None)
|
||||
removed_count += 1
|
||||
# Same ceiling enforcement set() uses, so the two cannot drift.
|
||||
removed_count += self._evict_over_limit_locked()
|
||||
|
||||
self._last_cleanup = current_time
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import logging
|
||||
import threading
|
||||
import tempfile
|
||||
from src.exceptions import CacheError
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache.memory_cache import MemoryCache, default_max_size
|
||||
from src.cache.disk_cache import DiskCache
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
from src.cache.cache_metrics import CacheMetrics
|
||||
@@ -47,6 +47,20 @@ from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-e
|
||||
class CacheManager:
|
||||
"""Manages caching of API responses to reduce API calls."""
|
||||
|
||||
# Which cache directories already have a cleanup thread in this process.
|
||||
#
|
||||
# The sweep is directory-scoped work -- it lists a directory and deletes
|
||||
# from it -- so one per directory is the right number no matter how many
|
||||
# managers exist. Nothing enforced that before: every instance started its
|
||||
# own, and because the loop closes over `self`, a discarded manager could
|
||||
# never be collected and its thread woke to re-scan the same directory
|
||||
# every 24 hours for the life of the process. Startup validation runs
|
||||
# twice and built a throwaway manager each time, so a display process
|
||||
# carried three threads for one cache.
|
||||
_cleanup_owners: Dict[str, 'CacheManager'] = {}
|
||||
_cleanup_owners_lock = threading.Lock()
|
||||
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Initialize logger first
|
||||
self.logger: logging.Logger = get_logger(__name__)
|
||||
@@ -70,7 +84,9 @@ class CacheManager:
|
||||
self.logger.warning("ConfigManager not available, using default cache intervals")
|
||||
|
||||
# Initialize cache components using composition
|
||||
self._memory_cache_component = MemoryCache(max_size=1000, cleanup_interval=300.0)
|
||||
self._memory_cache_component = MemoryCache(
|
||||
max_size=default_max_size(), cleanup_interval=300.0
|
||||
)
|
||||
self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger)
|
||||
self._strategy_component = CacheStrategy(config_manager=self.config_manager, logger=self.logger)
|
||||
self._metrics_component = CacheMetrics(logger=self.logger)
|
||||
@@ -594,8 +610,10 @@ class CacheManager:
|
||||
Args:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
ttl: Optional time-to-live in seconds (stored for compatibility but
|
||||
expiration is still controlled via max_age when reading)
|
||||
ttl: Time-to-live in seconds for this entry. Takes precedence over
|
||||
the max_age a reader would otherwise apply, which is inferred
|
||||
from the key and is only a fallback for entries that did not
|
||||
say. Omit it to keep that inferred behaviour.
|
||||
"""
|
||||
cache_data = {
|
||||
'data': data,
|
||||
@@ -716,11 +734,29 @@ class CacheManager:
|
||||
}
|
||||
|
||||
def start_cleanup_thread(self) -> None:
|
||||
"""Start background thread for periodic disk cache cleanup."""
|
||||
"""Start background thread for periodic disk cache cleanup.
|
||||
|
||||
At most one thread per cache directory per process: the sweep is
|
||||
directory-scoped, so a second one only duplicates the scan.
|
||||
"""
|
||||
if self._cleanup_thread and self._cleanup_thread.is_alive():
|
||||
self.logger.debug("Cleanup thread already running")
|
||||
return
|
||||
|
||||
with CacheManager._cleanup_owners_lock:
|
||||
owner = CacheManager._cleanup_owners.get(self.cache_dir)
|
||||
if owner is not None and owner is not self:
|
||||
thread = owner._cleanup_thread
|
||||
if thread is not None and thread.is_alive():
|
||||
self.logger.debug(
|
||||
"Cleanup thread for %s already owned by another cache "
|
||||
"manager in this process; not starting a second",
|
||||
self.cache_dir)
|
||||
return
|
||||
# The owner's thread died or was stopped -- take over.
|
||||
CacheManager._cleanup_owners[self.cache_dir] = self
|
||||
|
||||
|
||||
def cleanup_loop():
|
||||
"""Background loop that runs cleanup periodically."""
|
||||
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
|
||||
@@ -768,6 +804,13 @@ class CacheManager:
|
||||
Signals the thread to stop and waits for it to finish (with timeout).
|
||||
This allows for clean shutdown during testing or application termination.
|
||||
"""
|
||||
# Release ownership first and unconditionally, so a manager that never
|
||||
# started a thread (or whose thread already exited) cannot keep the
|
||||
# directory claimed and block a live manager from sweeping it.
|
||||
with CacheManager._cleanup_owners_lock:
|
||||
if CacheManager._cleanup_owners.get(self.cache_dir) is self:
|
||||
del CacheManager._cleanup_owners[self.cache_dir]
|
||||
|
||||
if not self._cleanup_thread or not self._cleanup_thread.is_alive():
|
||||
self.logger.debug("Cleanup thread not running")
|
||||
return
|
||||
|
||||
@@ -273,19 +273,34 @@ class APIHelper:
|
||||
"""
|
||||
Clear cache data.
|
||||
|
||||
Uses CacheManager's real surface (clear_cache / delete /
|
||||
list_cache_files); safely no-ops on managers without it. The old
|
||||
implementation guarded on a nonexistent ``clear`` method, so it
|
||||
silently never cleared anything.
|
||||
|
||||
Args:
|
||||
pattern: Optional pattern to match cache keys
|
||||
pattern: Optional substring to match cache keys; only matching
|
||||
entries are deleted.
|
||||
"""
|
||||
if self.cache_manager:
|
||||
if hasattr(self.cache_manager, 'clear'):
|
||||
if not self.cache_manager:
|
||||
return
|
||||
if pattern:
|
||||
# Clear only keys matching pattern
|
||||
keys = self.cache_manager.keys()
|
||||
for key in keys:
|
||||
if pattern in key:
|
||||
if (hasattr(self.cache_manager, 'list_cache_files')
|
||||
and hasattr(self.cache_manager, 'delete')):
|
||||
for entry in self.cache_manager.list_cache_files():
|
||||
key = entry.get('key') if isinstance(entry, dict) else None
|
||||
if key and pattern in key:
|
||||
self.cache_manager.delete(key)
|
||||
else:
|
||||
self.logger.debug(
|
||||
"Cache manager lacks list_cache_files/delete; "
|
||||
"cannot clear by pattern")
|
||||
elif hasattr(self.cache_manager, 'clear_cache'):
|
||||
self.cache_manager.clear_cache()
|
||||
elif hasattr(self.cache_manager, 'clear'):
|
||||
self.cache_manager.clear()
|
||||
else:
|
||||
self.logger.debug("Cache manager exposes no clear method; no-op")
|
||||
|
||||
def _get_from_cache(self, key: str) -> Optional[Any]:
|
||||
"""Get data from cache."""
|
||||
|
||||
@@ -5,6 +5,7 @@ Handles configuration management and validation for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -160,17 +161,20 @@ class ConfigHelper:
|
||||
override_config: Configuration to merge in (takes precedence)
|
||||
|
||||
Returns:
|
||||
Merged configuration dictionary
|
||||
Merged configuration dictionary (fully independent of both
|
||||
inputs — a shallow copy would alias un-overridden nested dicts,
|
||||
so mutating the result would mutate the caller's base config).
|
||||
"""
|
||||
merged = base_config.copy()
|
||||
merged = copy.deepcopy(base_config)
|
||||
|
||||
for key, value in override_config.items():
|
||||
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||
# Recursively merge nested dictionaries
|
||||
merged[key] = self.merge_configs(merged[key], value)
|
||||
else:
|
||||
# Override with new value
|
||||
merged[key] = value
|
||||
# Override with new value — deep-copied so mutating the
|
||||
# merged result can't reach back into override_config.
|
||||
merged[key] = copy.deepcopy(value)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
@@ -115,18 +115,14 @@ class DisplayHelper:
|
||||
if home_logo and away_logo:
|
||||
self._draw_logos(main_img, home_logo, away_logo)
|
||||
|
||||
# Draw status/period text (top center)
|
||||
if status_text or period_text:
|
||||
status_display = f"{period_text} {status_text}".strip()
|
||||
if status_display:
|
||||
self._draw_centered_text(draw, status_display,
|
||||
# Draw one combined top line (period/status/clock all share y=1 —
|
||||
# drawing them separately overprinted each other).
|
||||
top_line = " ".join(p for p in [period_text, status_text, clock] if p)
|
||||
if top_line:
|
||||
self._draw_centered_text(draw, top_line,
|
||||
fonts.get('time', fonts.get('status')),
|
||||
y_position=1)
|
||||
|
||||
# Draw clock if available
|
||||
if clock:
|
||||
self._draw_centered_text(draw, clock, fonts.get('time'), y_position=1)
|
||||
|
||||
# Draw scores (center)
|
||||
score_text = f"{away_score}-{home_score}"
|
||||
self._draw_centered_text(draw, score_text, fonts.get('score'),
|
||||
@@ -153,12 +149,18 @@ class DisplayHelper:
|
||||
"""
|
||||
Draw a ticker/scrolling text layout.
|
||||
|
||||
Renders a single static frame with the text at the left edge; the
|
||||
caller advances the scroll by re-rendering or shifting. The
|
||||
scroll_speed parameter is accepted for API compatibility but does
|
||||
not affect this frame. (Previously the text was drawn starting at
|
||||
x=display_width — entirely off-canvas — so every frame was blank.)
|
||||
|
||||
Args:
|
||||
text: Text to display
|
||||
font: Font to use
|
||||
background_color: Background color
|
||||
text_color: Text color
|
||||
scroll_speed: Pixels to scroll per frame
|
||||
scroll_speed: Accepted for compatibility; unused per-frame
|
||||
|
||||
Returns:
|
||||
PIL Image with ticker layout
|
||||
@@ -166,11 +168,7 @@ class DisplayHelper:
|
||||
img = self.create_base_image(background_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Start text off-screen to the right
|
||||
x_position = self.display_width
|
||||
|
||||
# Draw text
|
||||
self._draw_text_with_outline(draw, text, (x_position, self.display_height // 2 - 6),
|
||||
self._draw_text_with_outline(draw, text, (0, self.display_height // 2 - 6),
|
||||
font, fill=text_color)
|
||||
|
||||
return img
|
||||
@@ -214,15 +212,9 @@ class DisplayHelper:
|
||||
Returns:
|
||||
PIL Image with error message
|
||||
"""
|
||||
img = self.create_base_image((50, 0, 0)) # Dark red background
|
||||
|
||||
# Use default font
|
||||
# Dark red background, white text
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Draw centered error message
|
||||
self._draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
|
||||
|
||||
return img
|
||||
return self.draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
|
||||
|
||||
def draw_no_data_message(self, message: str = "No Data") -> Image.Image:
|
||||
"""
|
||||
@@ -234,11 +226,8 @@ class DisplayHelper:
|
||||
Returns:
|
||||
PIL Image with no data message
|
||||
"""
|
||||
img = self.create_base_image((0, 0, 0))
|
||||
font = ImageFont.load_default()
|
||||
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||
|
||||
return img
|
||||
return self.draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||
|
||||
def get_display_dimensions(self) -> Tuple[int, int]:
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,8 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
@@ -19,6 +21,10 @@ from src.common.permission_utils import (
|
||||
)
|
||||
|
||||
|
||||
# Well above any real team logo; bounds what a remote URL can write to disk.
|
||||
MAX_LOGO_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
class LogoHelper:
|
||||
"""
|
||||
Helper class for logo loading, caching, and resizing.
|
||||
@@ -226,7 +232,10 @@ class LogoHelper:
|
||||
return {
|
||||
'cached_logos': len(self._logo_cache),
|
||||
'cache_size_limit': self.cache_size,
|
||||
'cache_usage_percent': (len(self._logo_cache) / self.cache_size) * 100
|
||||
'cache_usage_percent': (
|
||||
(len(self._logo_cache) / self.cache_size) * 100
|
||||
if self.cache_size else 0
|
||||
),
|
||||
}
|
||||
|
||||
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
|
||||
@@ -258,17 +267,60 @@ class LogoHelper:
|
||||
self._cache_order.append(cache_key)
|
||||
|
||||
def _download_logo(self, url: str, file_path: Path) -> None:
|
||||
"""Download logo from URL."""
|
||||
"""Download logo from URL.
|
||||
|
||||
The response size is capped and the saved file is verified as a
|
||||
decodable image before it is left on disk: a logo URL is remote
|
||||
input, and without this an oversized or malformed response would
|
||||
be cached for every later load_logo() call to trip over.
|
||||
|
||||
The body is streamed and counted as it arrives rather than read
|
||||
through response.content, which buffers the whole thing first —
|
||||
a server that omits Content-Length and never stops sending would
|
||||
exhaust memory before any size check could run. Nothing lands at
|
||||
file_path until the download completes and decodes, so a failed
|
||||
download cannot leave a truncated logo behind either.
|
||||
"""
|
||||
# Ensure directory exists with proper permissions
|
||||
ensure_directory_permissions(file_path.parent, get_assets_dir_mode())
|
||||
|
||||
# Download with timeout
|
||||
response = self.session.get(url, timeout=30)
|
||||
# A unique temp name, not a fixed "<name>.part": two plugins can
|
||||
# ask for the same logo at once, and a shared name would let them
|
||||
# interleave writes into one file, publish the mixture, or delete
|
||||
# each other's partial. Same directory, so os.replace stays atomic.
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(file_path.parent), prefix=file_path.name + '.', suffix='.part')
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
# fdopen outermost so the descriptor mkstemp handed back is
|
||||
# always adopted and closed, including when the request itself
|
||||
# raises — load_logo_with_download swallows that, so a leak
|
||||
# here would accumulate quietly on a URL that keeps failing.
|
||||
with os.fdopen(fd, 'wb') as f:
|
||||
with self.session.get(url, timeout=30, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
downloaded = 0
|
||||
for chunk in response.iter_content(chunk_size=64 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
downloaded += len(chunk)
|
||||
if downloaded > MAX_LOGO_BYTES:
|
||||
raise ValueError(
|
||||
f"Logo at {url} exceeds the "
|
||||
f"{MAX_LOGO_BYTES}-byte limit; not saved")
|
||||
f.write(chunk)
|
||||
|
||||
# Save to file
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
# Verify it decodes before it becomes the cached logo. PIL
|
||||
# raises DecompressionBombError past its own pixel limit; a
|
||||
# partial or non-image response raises UnidentifiedImageError
|
||||
# (an OSError subclass).
|
||||
with Image.open(tmp_path) as probe:
|
||||
probe.load()
|
||||
|
||||
os.replace(tmp_path, file_path)
|
||||
except BaseException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
# Set proper file permissions after saving
|
||||
ensure_file_permissions(file_path, get_assets_file_mode())
|
||||
|
||||
@@ -19,6 +19,7 @@ Port default: 5765 (UDP). Open this port on both Pis if ufw is active:
|
||||
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
@@ -37,6 +38,13 @@ _RAW_MAGIC = b'SYNC_RAW'
|
||||
_RAW_HEADER = struct.Struct('<HH') # width, height (uint16 LE)
|
||||
|
||||
|
||||
# Upper bound on a decoded frame/scroll image. Generous for any real scroll
|
||||
# image (a leader's full cycle is long but only panel-height tall), and low
|
||||
# enough that a crafted image from any host on the LAN cannot force a large
|
||||
# allocation on the render thread. Applied on both receive paths — the TCP
|
||||
# image server and the follower's legacy-PNG UDP fallback.
|
||||
_MAX_FRAME_W, _MAX_FRAME_H = 100_000, 256
|
||||
|
||||
SYNC_PORT = 5765
|
||||
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
|
||||
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
||||
@@ -101,6 +109,7 @@ class DisplaySyncManager:
|
||||
self._peer_chain: int = 0
|
||||
self._last_heartbeat_time: float = 0.0
|
||||
self._leader_width: int = 0 # set by display_controller after init
|
||||
self._oversized_frame_warned: bool = False
|
||||
|
||||
# Follower state
|
||||
self._follower_state = FollowerState.STANDALONE
|
||||
@@ -174,6 +183,10 @@ class DisplaySyncManager:
|
||||
continue
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync leader recv error: %s", exc)
|
||||
# Brief backoff: a socket left in a bad state raises
|
||||
# immediately, which would otherwise spin this thread at
|
||||
# 100% CPU logging the same error.
|
||||
time.sleep(0.1)
|
||||
|
||||
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
||||
hw = self._hw_config
|
||||
@@ -273,11 +286,10 @@ class DisplaySyncManager:
|
||||
break
|
||||
data.extend(chunk)
|
||||
img = Image.open(io.BytesIO(data))
|
||||
_MAX_W, _MAX_H = 100_000, 256 # generous for any real scroll image
|
||||
if img.width > _MAX_W or img.height > _MAX_H:
|
||||
if img.width > _MAX_FRAME_W or img.height > _MAX_FRAME_H:
|
||||
self.logger.warning(
|
||||
"Sync: rejected oversized scroll image %dx%d (max %dx%d) from %s",
|
||||
img.width, img.height, _MAX_W, _MAX_H, addr,
|
||||
img.width, img.height, _MAX_FRAME_W, _MAX_FRAME_H, addr,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
@@ -396,7 +408,7 @@ class DisplaySyncManager:
|
||||
data = header + arr.tobytes()
|
||||
if len(data) <= 65000:
|
||||
self._send_sock.sendto(data, (self._peer_ip, self.port))
|
||||
elif not getattr(self, '_oversized_frame_warned', False):
|
||||
elif not self._oversized_frame_warned:
|
||||
self._oversized_frame_warned = True
|
||||
self.logger.warning(
|
||||
"Sync: frame too large for UDP (%d bytes, max 65000) — "
|
||||
@@ -451,25 +463,8 @@ class DisplaySyncManager:
|
||||
)
|
||||
self.write_status_file()
|
||||
|
||||
def _follower_recv_loop(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
data, addr = self._recv_sock.recvfrom(65535)
|
||||
sender_ip = addr[0]
|
||||
|
||||
if data[:8] == _RAW_MAGIC or len(data) > 512:
|
||||
# Frame data: prefer magic-tagged raw RGB; fall back to legacy PNG
|
||||
try:
|
||||
if data[:8] == _RAW_MAGIC:
|
||||
w, h = _RAW_HEADER.unpack(data[8:12])
|
||||
raw = data[12:]
|
||||
img = Image.frombuffer(
|
||||
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
||||
)
|
||||
else:
|
||||
# Fallback: try legacy PNG
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.load()
|
||||
def _handle_received_frame(self, img: Image.Image, sender_ip: str) -> None:
|
||||
"""Record a decoded leader frame and enter follower mode if needed."""
|
||||
with self._frame_lock:
|
||||
self._latest_frame = img
|
||||
self._last_leader_frame_time = time.time()
|
||||
@@ -482,12 +477,62 @@ class DisplaySyncManager:
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
|
||||
def _follower_recv_loop(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
data, addr = self._recv_sock.recvfrom(65535)
|
||||
sender_ip = addr[0]
|
||||
|
||||
if data[:8] == _RAW_MAGIC:
|
||||
# Magic-tagged raw RGB frame — self-describing, no guessing.
|
||||
try:
|
||||
w, h = _RAW_HEADER.unpack(data[8:12])
|
||||
raw = data[12:]
|
||||
img = Image.frombuffer(
|
||||
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
||||
)
|
||||
self._handle_received_frame(img, sender_ip)
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: frame decode error: %s", exc)
|
||||
else:
|
||||
# Control message
|
||||
# No magic prefix. Whether the payload parses as JSON
|
||||
# decides between a control message and a legacy
|
||||
# (pre-magic) PNG frame — both wire formats are
|
||||
# self-describing, so no size heuristic is needed. A
|
||||
# >512-byte control message used to be misrouted into
|
||||
# image decode and silently dropped.
|
||||
try:
|
||||
msg = json.loads(data.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Not JSON — try a legacy PNG frame.
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
if img.width > _MAX_FRAME_W or img.height > _MAX_FRAME_H:
|
||||
# Same cap the TCP image path applies: decode
|
||||
# is deferred until load(), so check first.
|
||||
self.logger.debug(
|
||||
"Sync: rejected oversized legacy frame %dx%d from %s",
|
||||
img.width, img.height, sender_ip,
|
||||
)
|
||||
continue
|
||||
img.load()
|
||||
self._handle_received_frame(img, sender_ip)
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: frame decode error: %s", exc)
|
||||
continue
|
||||
|
||||
# It parsed, so it is a control message and never a
|
||||
# frame. Read and validate its fields under a guard —
|
||||
# a UDP payload is attacker-shaped, so a non-object
|
||||
# body makes .get() raise AttributeError and an "sx"
|
||||
# carrying a non-numeric x raises ValueError/TypeError
|
||||
# — but dispatch the callback *outside* it. Running
|
||||
# the callback in here would let a fault in someone
|
||||
# else's code read as a malformed packet and be
|
||||
# logged as one.
|
||||
fire_new_cycle = False
|
||||
try:
|
||||
t = msg.get("t")
|
||||
if t == "hello_ack":
|
||||
self._leader_ip = sender_ip
|
||||
@@ -501,7 +546,17 @@ class DisplaySyncManager:
|
||||
self.write_status_file()
|
||||
elif t == "sx":
|
||||
# Vegas scroll-position sync — tiny message, renders locally
|
||||
self._latest_scroll_x = float(msg["x"])
|
||||
scroll_x = float(msg["x"])
|
||||
if not math.isfinite(scroll_x):
|
||||
# json.loads accepts the NaN/Infinity literals,
|
||||
# and float("nan") accepts the strings, so a
|
||||
# non-finite x reaches here intact. Left alone
|
||||
# it poisons every offset computed from it —
|
||||
# NaN comparisons are all false, so the
|
||||
# follower renders a frame it can never scroll
|
||||
# back from. Treat it as malformed.
|
||||
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
||||
self._latest_scroll_x = scroll_x
|
||||
self._last_leader_frame_time = time.time()
|
||||
self._leader_ip = sender_ip
|
||||
if self._follower_state == FollowerState.STANDALONE:
|
||||
@@ -511,19 +566,22 @@ class DisplaySyncManager:
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
if self._on_new_cycle:
|
||||
self._on_new_cycle() # build initial scroll image
|
||||
fire_new_cycle = True # build initial scroll image
|
||||
elif t == "nc":
|
||||
# Leader started a new scroll cycle — rebuild local image
|
||||
if self._on_new_cycle:
|
||||
fire_new_cycle = True
|
||||
except (KeyError, AttributeError, TypeError, ValueError) as exc:
|
||||
self.logger.debug("Sync: malformed control message: %s", exc)
|
||||
continue
|
||||
|
||||
if fire_new_cycle and self._on_new_cycle:
|
||||
self._on_new_cycle()
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync follower recv error: %s", exc)
|
||||
time.sleep(0.1)
|
||||
|
||||
def _follower_announce_loop(self) -> None:
|
||||
hw = self._hw_config
|
||||
|
||||
@@ -106,14 +106,9 @@ class ConfigManager:
|
||||
Returns:
|
||||
SaveResult with status and details
|
||||
"""
|
||||
# Load current secrets to preserve them
|
||||
secrets_content = {}
|
||||
if os.path.exists(self.secrets_path):
|
||||
try:
|
||||
with open(self.secrets_path, 'r') as f_secrets:
|
||||
secrets_content = json.load(f_secrets)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not load secrets file {self.secrets_path} during save: {e}")
|
||||
# Load current secrets to preserve them (raises if unreadable — see
|
||||
# _load_secrets_for_save)
|
||||
secrets_content = self._load_secrets_for_save()
|
||||
|
||||
# Strip secrets from main config before saving
|
||||
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
|
||||
@@ -274,35 +269,86 @@ class ConfigManager:
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||
|
||||
@staticmethod
|
||||
def _is_parallel_secrets_list(value: Any) -> bool:
|
||||
"""True for the parallel-placeholder list shape emitted by
|
||||
``secret_helpers.separate_secrets`` for array-item secrets: a
|
||||
non-empty list whose elements are ALL dicts (``{}`` marks an item
|
||||
with no secrets). Any other list-shaped secrets value is a
|
||||
whole-key secret (e.g. a list of secret scalars)."""
|
||||
return (isinstance(value, list) and bool(value)
|
||||
and all(isinstance(item, dict) for item in value))
|
||||
|
||||
def _strip_secrets_recursive(self, data_to_filter: Dict[str, Any], secrets: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Recursively remove secret keys from a dictionary."""
|
||||
result = {}
|
||||
for key, value in data_to_filter.items():
|
||||
if key in secrets:
|
||||
if isinstance(value, dict) and isinstance(secrets[key], dict):
|
||||
# This key is a shared group, recurse
|
||||
stripped_sub_dict = self._strip_secrets_recursive(value, secrets[key])
|
||||
if stripped_sub_dict: # Only add if there's non-secret data left
|
||||
result[key] = stripped_sub_dict
|
||||
# Else, it's a secret key at this level, so we skip it
|
||||
else:
|
||||
if key not in secrets:
|
||||
# This key is not in secrets, so we keep it
|
||||
result[key] = value
|
||||
continue
|
||||
sec = secrets[key]
|
||||
if isinstance(value, dict) and isinstance(sec, dict):
|
||||
# This key is a shared group, recurse
|
||||
stripped_sub_dict = self._strip_secrets_recursive(value, sec)
|
||||
if stripped_sub_dict: # Only add if there's non-secret data left
|
||||
result[key] = stripped_sub_dict
|
||||
elif isinstance(value, list) and self._is_parallel_secrets_list(sec):
|
||||
# Parallel-list shape from separate_secrets: sec[i] holds the
|
||||
# secret fields of value[i] ({} = item i has none). Strip each
|
||||
# item and ALWAYS keep the list — indices must survive so the
|
||||
# merge-on-load can realign secrets with their items. The
|
||||
# regular list's length is authoritative: extra secrets
|
||||
# entries are ignored.
|
||||
stripped_items = []
|
||||
for i, item in enumerate(value):
|
||||
s_item = sec[i] if i < len(sec) else {}
|
||||
if isinstance(item, dict) and s_item:
|
||||
stripped_items.append(self._strip_secrets_recursive(item, s_item))
|
||||
else:
|
||||
stripped_items.append(item)
|
||||
result[key] = stripped_items
|
||||
# Else: whole-key secret (scalar, list of secret scalars, or a
|
||||
# shape mismatch) -> drop the key entirely. Never leak.
|
||||
return result
|
||||
|
||||
def save_config(self, new_config_data: Dict[str, Any]) -> None:
|
||||
"""Save configuration to the main JSON file, stripping out secrets."""
|
||||
secrets_content = {}
|
||||
if os.path.exists(self.secrets_path):
|
||||
def _load_secrets_for_save(self) -> Dict[str, Any]:
|
||||
"""Load config_secrets.json for stripping before a save.
|
||||
|
||||
A missing secrets file is fine (nothing to strip). But a file that
|
||||
EXISTS and cannot be read or parsed means stripping is impossible —
|
||||
and the in-memory config being saved has secrets deep-merged into it,
|
||||
so proceeding would write them into config.json in plaintext. That
|
||||
was the historical behavior; it is now a hard refusal. The save
|
||||
raises so the caller (and user) fixes the secrets file instead of
|
||||
silently leaking its contents into the world-readable main config.
|
||||
"""
|
||||
if not os.path.exists(self.secrets_path):
|
||||
return {}
|
||||
try:
|
||||
with open(self.secrets_path, 'r') as f_secrets:
|
||||
secrets_content = json.load(f_secrets)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not load secrets file {self.secrets_path} during save: {e}")
|
||||
# Continue without stripping if secrets can't be loaded, or handle as critical error
|
||||
# For now, we'll proceed cautiously and save the full new_config_data if secrets are unreadable
|
||||
# to prevent accidental data loss if the secrets file is temporarily corrupt.
|
||||
# A more robust approach might be to fail the save or use a cached version of secrets.
|
||||
return json.load(f_secrets)
|
||||
# Only the expected read/parse failures — an unexpected implementation
|
||||
# error should propagate as itself, not masquerade as a secrets-file
|
||||
# problem. (JSONDecodeError and UnicodeDecodeError are ValueErrors.)
|
||||
except (OSError, ValueError, RecursionError) as e:
|
||||
error_msg = (
|
||||
f"Refusing to save config: secrets file {self.secrets_path} exists "
|
||||
f"but could not be loaded ({e}). Saving without it would write "
|
||||
f"merged secret values into config.json in plaintext. Fix or "
|
||||
f"remove the secrets file, then retry."
|
||||
)
|
||||
self.logger.error("[Config] %s", error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.secrets_path) from e
|
||||
|
||||
def save_config(self, new_config_data: Dict[str, Any]) -> None:
|
||||
"""Save configuration to the main JSON file, stripping out secrets.
|
||||
|
||||
Raises ConfigError when the secrets file exists but cannot be loaded,
|
||||
because stripping would be impossible and secrets would leak into
|
||||
config.json.
|
||||
"""
|
||||
secrets_content = self._load_secrets_for_save()
|
||||
|
||||
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
|
||||
|
||||
@@ -339,11 +385,39 @@ class ConfigManager:
|
||||
return None
|
||||
|
||||
def _deep_merge(self, target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||
"""Deep merge source dict into target dict."""
|
||||
"""Deep merge source dict into target dict.
|
||||
|
||||
Sole call site: merging config_secrets.json into the loaded config.
|
||||
Understands the parallel-list shape separate_secrets emits for
|
||||
array-item secrets (see _is_parallel_secrets_list): each secrets
|
||||
list item is merged into the config list item at the same index
|
||||
({} placeholders skipped). The config list's length is
|
||||
authoritative — a user deleting an array item from config.json
|
||||
must not have it resurrected from a stale secrets entry."""
|
||||
for key, value in source.items():
|
||||
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||
self._deep_merge(target[key], value)
|
||||
elif (key in target and isinstance(target[key], list)
|
||||
and self._is_parallel_secrets_list(value)):
|
||||
tlist = target[key]
|
||||
for i, s_item in enumerate(value):
|
||||
if i >= len(tlist):
|
||||
# Interpolate only config-side data here — nothing
|
||||
# iterated out of the secrets dict (not even the key
|
||||
# name) may reach the log.
|
||||
self.logger.warning(
|
||||
"A secrets list is longer than the config list it "
|
||||
"parallels (config has %d item(s)); ignoring the "
|
||||
"extra entries", len(tlist))
|
||||
break
|
||||
if not s_item:
|
||||
continue # {} placeholder: item i has no secrets
|
||||
if isinstance(tlist[i], dict):
|
||||
self._deep_merge(tlist[i], s_item)
|
||||
else:
|
||||
tlist[i] = s_item # shape drift; the secret wins
|
||||
else:
|
||||
# Scalars AND whole-secret scalar arrays: replace (legacy).
|
||||
target[key] = value
|
||||
|
||||
def _create_config_from_template(self) -> None:
|
||||
|
||||
@@ -44,6 +44,20 @@ from src.common.sync_manager import DisplaySyncManager, SyncRole
|
||||
# Get logger with consistent configuration
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# How long startup will wait for plugins to fetch their first data before
|
||||
# showing anything. Each plugin's update blocks for up to the executor's 30s
|
||||
# timeout and they run one after another, so the uncapped total is the sum of
|
||||
# every slow plugin: 82 seconds on the worst boot measured, with a blank panel
|
||||
# throughout. Whatever does not finish in time is picked up by the scheduled
|
||||
# update tick moments later, with the display already running.
|
||||
_INITIAL_UPDATE_BUDGET_SECONDS = 20.0
|
||||
|
||||
# The least budget worth starting a plugin with. Below this the plugin is
|
||||
# deferred instead: granting it a floor would let the pass run past its
|
||||
# deadline, and granting it the true remainder would record a timeout for a
|
||||
# slot it never had a chance to use.
|
||||
_MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
# Vegas mode import (lazy loaded to avoid circular imports)
|
||||
_vegas_mode_imported = False
|
||||
VegasModeCoordinator = None
|
||||
@@ -90,7 +104,8 @@ class DisplayController:
|
||||
# Validate startup configuration
|
||||
try:
|
||||
from src.startup_validator import StartupValidator
|
||||
validator = StartupValidator(self.config_manager)
|
||||
validator = StartupValidator(self.config_manager,
|
||||
cache_manager=self.cache_manager)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
|
||||
if warnings:
|
||||
@@ -258,7 +273,8 @@ class DisplayController:
|
||||
# Validate plugins after plugin manager is created
|
||||
try:
|
||||
from src.startup_validator import StartupValidator
|
||||
validator = StartupValidator(self.config_manager, self.plugin_manager)
|
||||
validator = StartupValidator(self.config_manager, self.plugin_manager,
|
||||
cache_manager=self.cache_manager)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
|
||||
if warnings:
|
||||
@@ -461,7 +477,7 @@ class DisplayController:
|
||||
# Initial data update for plugins (ensures data available on first display)
|
||||
logger.info("Performing initial plugin data update...")
|
||||
update_start = time.time()
|
||||
self._update_modules()
|
||||
self._update_modules(deadline=update_start + _INITIAL_UPDATE_BUDGET_SECONDS)
|
||||
logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start)
|
||||
|
||||
# Initialize Vegas mode coordinator
|
||||
@@ -817,14 +833,42 @@ class DisplayController:
|
||||
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||
return normal_brightness
|
||||
|
||||
def _update_modules(self):
|
||||
"""Update all plugin modules."""
|
||||
def _update_modules(self, deadline: Optional[float] = None):
|
||||
"""Update all plugin modules.
|
||||
|
||||
Args:
|
||||
deadline: Wall-clock time after which remaining plugins are left
|
||||
for the scheduled update tick instead of being waited on. Each
|
||||
update blocks this thread for up to the executor's timeout, and
|
||||
they run one after another, so without a bound the total is the
|
||||
sum of every slow plugin on the system. Measured at startup on
|
||||
a live rig: 82 seconds, 55 and 26 on the two boots before -- all
|
||||
of it with nothing on the panel.
|
||||
"""
|
||||
if not self.plugin_manager:
|
||||
return
|
||||
|
||||
# Update all loaded plugins
|
||||
plugins_dict = getattr(self.plugin_manager, 'loaded_plugins', None) or getattr(self.plugin_manager, 'plugins', {})
|
||||
deferred = []
|
||||
for plugin_id, plugin_instance in plugins_dict.items():
|
||||
update_timeout = None
|
||||
if deadline is not None:
|
||||
update_timeout = deadline - time.time()
|
||||
if update_timeout < _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS:
|
||||
# Too little left to be worth starting. Deferring rather
|
||||
# than granting a floor keeps the budget a real ceiling --
|
||||
# clamping up to a minimum let a plugin that began with a
|
||||
# sliver left run on past the deadline -- and a plugin
|
||||
# handed a slot it cannot use would just be recorded as
|
||||
# having timed out.
|
||||
#
|
||||
# Nothing is lost either way: a plugin that has never
|
||||
# updated is immediately due, so run_scheduled_updates()
|
||||
# picks it up within seconds, with the display already
|
||||
# running.
|
||||
deferred.append(plugin_id)
|
||||
continue
|
||||
# Check circuit breaker before attempting update
|
||||
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||
if self.plugin_manager.health_tracker.should_skip_plugin(plugin_id):
|
||||
@@ -833,7 +877,13 @@ class DisplayController:
|
||||
|
||||
# Use PluginExecutor if available for safe execution
|
||||
if hasattr(self.plugin_manager, 'plugin_executor'):
|
||||
success = self.plugin_manager.plugin_executor.execute_update(plugin_instance, plugin_id)
|
||||
# The remaining budget is the timeout, so the pass cannot
|
||||
# run past its deadline. Bounding the loop alone did not do
|
||||
# it: the last plugin to start could still block for the
|
||||
# executor's full 30s, which turned a 20s budget into a 31.8s
|
||||
# pass on the rig.
|
||||
success = self.plugin_manager.plugin_executor.execute_update(
|
||||
plugin_instance, plugin_id, timeout=update_timeout)
|
||||
if success and hasattr(self.plugin_manager, 'plugin_last_update'):
|
||||
self.plugin_manager.plugin_last_update[plugin_id] = time.time()
|
||||
else:
|
||||
@@ -852,6 +902,12 @@ class DisplayController:
|
||||
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||
self.plugin_manager.health_tracker.record_failure(plugin_id, exc)
|
||||
|
||||
if deferred:
|
||||
logger.info(
|
||||
"Initial update budget spent; %d plugin(s) left to the update "
|
||||
"tick so the display can start: %s",
|
||||
len(deferred), ", ".join(deferred))
|
||||
|
||||
def _tick_plugin_updates_for_vegas(self) -> None:
|
||||
"""Run scheduled plugin updates and tell Vegas mode which plugins
|
||||
actually got fresh data, so it can hot-swap them into the scroll
|
||||
@@ -1638,6 +1694,12 @@ class DisplayController:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return live
|
||||
|
||||
def _vegas_keeps_live_in_ticker(self) -> bool:
|
||||
"""Whether live content should stay in the ticker instead of preempting it."""
|
||||
coordinator = getattr(self, 'vegas_coordinator', None)
|
||||
config = getattr(coordinator, 'vegas_config', None)
|
||||
return bool(getattr(config, 'live_in_ticker', False))
|
||||
|
||||
def _check_live_priority(self, advance=False):
|
||||
"""Return the live-priority mode to display, or None if nothing is live.
|
||||
|
||||
@@ -1851,14 +1913,24 @@ class DisplayController:
|
||||
# Check for live priority content and switch to it immediately.
|
||||
# advance=True so multiple simultaneously-live games take turns
|
||||
# (round-robin) instead of pinning to the first plugin.
|
||||
if not self.on_demand_active and not wifi_status_data:
|
||||
# Skipped when the ticker is keeping live content: switching
|
||||
# the rotation underneath Vegas would move current_mode_index
|
||||
# and stash a resume point for a takeover that never happens.
|
||||
if (not self.on_demand_active and not wifi_status_data
|
||||
and not (self._is_vegas_mode_active()
|
||||
and self._vegas_keeps_live_in_ticker())):
|
||||
live_priority_mode = self._check_live_priority(advance=True)
|
||||
self._apply_live_priority(live_priority_mode)
|
||||
|
||||
# Vegas scroll mode - continuous ticker across all plugins
|
||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||
if self._is_vegas_mode_active() and not wifi_status_data:
|
||||
live_mode = self._check_live_priority()
|
||||
# Live content normally preempts the ticker entirely. With
|
||||
# vegas_scroll.live_in_ticker the marquee keeps running and
|
||||
# the live plugin takes extra turns inside it instead --
|
||||
# see StreamManager._apply_priority_weights.
|
||||
live_mode = (None if self._vegas_keeps_live_in_ticker()
|
||||
else self._check_live_priority())
|
||||
if not live_mode:
|
||||
try:
|
||||
# Run Vegas mode iteration
|
||||
|
||||
@@ -25,6 +25,7 @@ the same object.
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
if os.getenv("EMULATOR", "false") == "true":
|
||||
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
|
||||
@@ -258,6 +259,26 @@ class DisplayManager:
|
||||
# Initialize managers
|
||||
# Calendar manager is now initialized by DisplayController
|
||||
|
||||
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
|
||||
# "normal" needs no suffix since 0 degrees is the identity transform.
|
||||
_ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
|
||||
|
||||
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
|
||||
"""Compose the raw pixel_mapper_config string with the orientation setting.
|
||||
|
||||
`pixel_mapper_config` stays available as a free-form advanced field (e.g.
|
||||
for "U-mapper" chain layouts); `orientation` is the user-facing dropdown
|
||||
for physical mounting (e.g. panels mounted upside down) and is appended as
|
||||
a "Rotate:<deg>" mapper rather than overwriting any existing config.
|
||||
"""
|
||||
base_mapper = (hardware_config.get('pixel_mapper_config') or '').strip()
|
||||
orientation = hardware_config.get('orientation', 'normal')
|
||||
degrees = self._ORIENTATION_ROTATE_DEGREES.get(orientation)
|
||||
if degrees is None:
|
||||
return base_mapper
|
||||
rotate_mapper = f'Rotate:{degrees}'
|
||||
return f'{base_mapper};{rotate_mapper}' if base_mapper else rotate_mapper
|
||||
|
||||
def _setup_matrix(self):
|
||||
"""Initialize the RGB matrix with configuration settings."""
|
||||
_init_error_str = None
|
||||
@@ -283,7 +304,7 @@ class DisplayManager:
|
||||
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
||||
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
||||
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
||||
options.pixel_mapper_config = hardware_config.get('pixel_mapper_config', '')
|
||||
options.pixel_mapper_config = self._build_pixel_mapper_config(hardware_config)
|
||||
options.row_address_type = hardware_config.get('row_address_type', 0)
|
||||
options.multiplexing = hardware_config.get('multiplexing', 0)
|
||||
options.panel_type = hardware_config.get('panel_type', '')
|
||||
@@ -497,6 +518,91 @@ class DisplayManager:
|
||||
logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
|
||||
return -1
|
||||
|
||||
@staticmethod
|
||||
def _local_ip() -> Optional[str]:
|
||||
"""This device's address on the network it routes through, or None.
|
||||
|
||||
Deliberately not `hostname -I` or a systemctl probe for AP mode, which
|
||||
is how the web launcher does it: both spawn processes with multi-second
|
||||
timeouts, and this runs on the startup path the rest of this change
|
||||
exists to shorten. Connecting a UDP socket sends no packets -- it only
|
||||
asks the kernel which source address it would use -- so it costs
|
||||
microseconds and works with the network down, as long as a route
|
||||
exists.
|
||||
"""
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(0.2)
|
||||
sock.connect(("8.8.8.8", 80)) # nosec B104 - no traffic; selects a route
|
||||
ip = sock.getsockname()[0]
|
||||
return ip if ip and not ip.startswith("127.") else None
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _fitting_font(self, lines, width):
|
||||
"""The largest font from the usual ladder that fits every line."""
|
||||
candidates = [self.font,
|
||||
("assets/fonts/4x6-font.ttf", 6)]
|
||||
for candidate in candidates:
|
||||
try:
|
||||
font = candidate
|
||||
if isinstance(candidate, tuple):
|
||||
font = ImageFont.truetype(candidate[0], candidate[1])
|
||||
if all(self.draw.textlength(t, font=font) <= width for t in lines):
|
||||
return font
|
||||
except (OSError, ValueError, AttributeError):
|
||||
continue
|
||||
return self.font
|
||||
|
||||
def _draw_startup_banner(self, lines, width: int, height: int) -> None:
|
||||
"""Centre `lines` over whatever the test pattern already drew.
|
||||
|
||||
This screen stays on the panel for the whole initial plugin update, and
|
||||
on a headless Pi it is the only place the device's address appears
|
||||
without going looking for it -- so it has to be readable off a wall,
|
||||
not merely present.
|
||||
|
||||
The font is chosen to fit rather than fixed at 8px: "Initializing" is
|
||||
96px in PressStart2P, which ran off the side of a 64px panel even
|
||||
before an address was added. And the pattern is punched out behind the
|
||||
text, because the diagonal runs through the middle of the panel, which
|
||||
is exactly where this sits.
|
||||
|
||||
The text stays blue. It is not decoration: the pattern draws one pure
|
||||
channel per element -- red border, green diagonal, blue text -- so that
|
||||
a glance at the panel says whether led_rgb_sequence is right. Swap the
|
||||
wiring to BGR and the border comes up blue and this text red. Drawing
|
||||
it white would light all three channels and destroy the only blue
|
||||
reference on the screen, which is why it is worth a comment rather
|
||||
than a quiet preference.
|
||||
"""
|
||||
if not lines:
|
||||
return
|
||||
font = self._fitting_font(lines, width - 2)
|
||||
line_height = self.draw.textbbox((0, 0), "Ag", font=font)[3] + 1
|
||||
block_height = line_height * len(lines)
|
||||
block_top = max(1, (height - block_height) // 2)
|
||||
block_width = max(self.draw.textlength(t, font=font) for t in lines)
|
||||
block_left = max(0, (width - block_width) // 2)
|
||||
|
||||
self.draw.rectangle(
|
||||
[block_left - 2, block_top - 1,
|
||||
block_left + block_width + 1, block_top + block_height],
|
||||
fill=(0, 0, 0))
|
||||
|
||||
for row, line in enumerate(lines):
|
||||
line_width = self.draw.textlength(line, font=font)
|
||||
self.draw.text(
|
||||
(max(0, (width - line_width) // 2), block_top + row * line_height),
|
||||
line, font=font, fill=(0, 0, 255))
|
||||
|
||||
def _draw_test_pattern(self):
|
||||
"""Draw a test pattern to verify the display is working."""
|
||||
try:
|
||||
@@ -516,8 +622,11 @@ class DisplayManager:
|
||||
# Draw a diagonal line
|
||||
self.draw.line([0, 0, self.matrix.width-1, self.matrix.height-1], fill=(0, 255, 0))
|
||||
|
||||
# Draw some text - changed from "TEST" to "Initializing" with smaller font
|
||||
self.draw.text((10, 10), "Initializing", font=self.font, fill=(0, 0, 255))
|
||||
lines = ["Initializing"]
|
||||
ip = self._local_ip()
|
||||
if ip:
|
||||
lines.append(ip)
|
||||
self._draw_startup_banner(lines, self.matrix.width, self.matrix.height)
|
||||
|
||||
# Update the display once after everything is drawn
|
||||
self.update_display()
|
||||
|
||||
@@ -168,9 +168,13 @@ class DynamicTeamResolver:
|
||||
# Sort by ranking (1, 2, 3, etc.)
|
||||
sorted_rankings = dict(sorted(rankings.items(), key=lambda x: x[1]))
|
||||
|
||||
# Cache the results
|
||||
self._rankings_cache = sorted_rankings
|
||||
self._cache_timestamp = current_time
|
||||
# Cache the results ON THE CLASS. Assigning through self
|
||||
# would create instance attributes that shadow the shared
|
||||
# class-level cache, making it per-instance — and every
|
||||
# scoreboard constructs its own resolver, so the cache
|
||||
# would never actually be shared.
|
||||
DynamicTeamResolver._rankings_cache = sorted_rankings
|
||||
DynamicTeamResolver._cache_timestamp = current_time
|
||||
|
||||
self.logger.info(f"Fetched rankings for {len(sorted_rankings)} teams")
|
||||
return sorted_rankings
|
||||
@@ -216,9 +220,11 @@ class DynamicTeamResolver:
|
||||
return any(pattern in team_name.upper() for pattern in dynamic_patterns)
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the rankings cache to force fresh data on next request."""
|
||||
self._rankings_cache = {}
|
||||
self._cache_timestamp = 0
|
||||
"""Clear the SHARED rankings cache to force fresh data on next
|
||||
request. Writes through the class — assigning via self would only
|
||||
shadow the shared cache for this instance."""
|
||||
DynamicTeamResolver._rankings_cache = {}
|
||||
DynamicTeamResolver._cache_timestamp = 0
|
||||
self.logger.info("Cleared dynamic team rankings cache")
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Provides consistent logging configuration across the LEDMatrix application.
|
||||
Supports structured logging with context information and appropriate log levels.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
@@ -65,8 +66,12 @@ class ContextualFormatter(logging.Formatter):
|
||||
self.include_context = include_context
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record with context."""
|
||||
# Add context to message if present
|
||||
"""Format log record with context.
|
||||
|
||||
Works on a shallow copy of the record: a record is formatted once
|
||||
PER HANDLER, so mutating record.msg in place (the old behavior)
|
||||
prepended the context prefix again for every additional handler.
|
||||
"""
|
||||
if self.include_context:
|
||||
context_parts = []
|
||||
|
||||
@@ -81,6 +86,7 @@ class ContextualFormatter(logging.Formatter):
|
||||
context_parts.append(f"[{key}: {value}]")
|
||||
|
||||
if context_parts:
|
||||
record = copy.copy(record)
|
||||
record.msg = ' '.join(context_parts) + ' ' + str(record.msg)
|
||||
|
||||
return super().format(record)
|
||||
@@ -224,8 +230,11 @@ def log_warning(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||
|
||||
|
||||
def log_error(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||
"""Log error message with context."""
|
||||
log_with_context(logger, logging.ERROR, message, **kwargs, exc_info=True)
|
||||
"""Log error message with context. Defaults exc_info=True; a caller
|
||||
passing exc_info explicitly wins (the old hardcoded keyword raised
|
||||
TypeError on that duplicate)."""
|
||||
kwargs.setdefault('exc_info', True)
|
||||
log_with_context(logger, logging.ERROR, message, **kwargs)
|
||||
|
||||
|
||||
def log_debug(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||
|
||||
@@ -364,8 +364,10 @@ class BasePlugin(ABC):
|
||||
# Handle None case
|
||||
if duration is None:
|
||||
pass # Fall through to config
|
||||
# Try to convert to float if it's a number or numeric string
|
||||
elif isinstance(duration, (int, float)):
|
||||
# Try to convert to float if it's a number or numeric string.
|
||||
# bool is excluded: it's an int subclass, and True would
|
||||
# otherwise read as a 1-second duration.
|
||||
elif isinstance(duration, (int, float)) and not isinstance(duration, bool):
|
||||
if duration > 0:
|
||||
return float(duration)
|
||||
else:
|
||||
@@ -403,8 +405,9 @@ class BasePlugin(ABC):
|
||||
# Fall back to config
|
||||
config_duration = self.config.get("display_duration", 15.0)
|
||||
try:
|
||||
# Ensure config value is also a valid float
|
||||
if isinstance(config_duration, (int, float)):
|
||||
# Ensure config value is also a valid float (bool excluded — an
|
||||
# int subclass that would otherwise read True as 1 second)
|
||||
if isinstance(config_duration, (int, float)) and not isinstance(config_duration, bool):
|
||||
if config_duration > 0:
|
||||
return float(config_duration)
|
||||
else:
|
||||
@@ -552,6 +555,48 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def get_vegas_priority_weight(self) -> Optional[int]:
|
||||
"""How many slots per Vegas cycle this plugin should get, or None.
|
||||
|
||||
The Vegas ticker is otherwise a strict round robin: every plugin
|
||||
appears exactly once per cycle. With a dozen plugins enabled that puts
|
||||
minutes between a live score and its next appearance. A weight of N
|
||||
gives the plugin N slots per cycle, spread evenly through it rather
|
||||
than clumped together.
|
||||
|
||||
Return ``None`` (the default) to let the core decide. It gives a
|
||||
plugin ``vegas_scroll.live_weight`` when ``has_live_priority()`` and
|
||||
``has_live_content()`` are both true, and 1 otherwise -- so live sports
|
||||
already get extra turns without implementing this at all.
|
||||
|
||||
Implement it only when the plugin knows something the core cannot. The
|
||||
motivating case is favorite teams: the core can see *that* a game is
|
||||
live but not *whose*, so a scoreboard that wants its favorite's game
|
||||
shown more often than other live games has to say so::
|
||||
|
||||
def get_vegas_priority_weight(self):
|
||||
if not (self.has_live_priority() and self.has_live_content()):
|
||||
return None # let the core decide
|
||||
cfg = self.global_config.get('display', {}).get('vegas_scroll', {})
|
||||
if self._favorite_is_live():
|
||||
return cfg.get('favorite_live_weight', 5)
|
||||
return cfg.get('live_weight', 3)
|
||||
|
||||
The weight is per *plugin*, not per game. A scoreboard showing four
|
||||
live games still occupies one slot at a time and rotates its own games
|
||||
within that slot; this controls how often the plugin itself comes
|
||||
round.
|
||||
|
||||
Raising is safe: the core logs it and falls back to its own
|
||||
live-content check, so a broken weight calculation costs the plugin
|
||||
the favorite distinction but not the live boost.
|
||||
|
||||
Returns:
|
||||
Slots per cycle (clamped to 1..10 by the caller), or None to
|
||||
defer to the core's own live-content weighting.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_live_modes(self) -> List[str]:
|
||||
"""
|
||||
Get list of display modes that should be used during live priority takeover.
|
||||
@@ -794,10 +839,12 @@ class BasePlugin(ABC):
|
||||
self.logger.error("'enabled' must be a boolean")
|
||||
return False
|
||||
|
||||
# Check display_duration if present
|
||||
# Check display_duration if present. bool is excluded explicitly:
|
||||
# it's an int subclass, and get_display_duration rejects it too.
|
||||
if "display_duration" in self.config:
|
||||
duration = self.config["display_duration"]
|
||||
if not isinstance(duration, (int, float)) or duration <= 0:
|
||||
if (not isinstance(duration, (int, float))
|
||||
or isinstance(duration, bool) or duration <= 0):
|
||||
self.logger.error("'display_duration' must be a positive number")
|
||||
return False
|
||||
|
||||
|
||||
@@ -180,6 +180,45 @@ def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def is_update_available(installed_version: str, latest_version: str) -> bool:
|
||||
"""Return True when the registry's ``latest_version`` is strictly newer
|
||||
than the installed version.
|
||||
|
||||
THE shared comparator for "should this plugin be updated?" — used by both
|
||||
the web UI's update badge (`api_v3._is_plugin_update_available`) and the
|
||||
store's `update_plugin` reinstall decision, so the two can never disagree.
|
||||
|
||||
Uses PEP 440-aware comparison (``packaging``), which also normalizes
|
||||
equivalent spellings: ``v1.2.0`` == ``1.2.0`` and ``1.2`` == ``1.2.0``, so
|
||||
cosmetic differences never trigger a reinstall — and a locally modified
|
||||
plugin whose version is *ahead* of the registry is never "updated"
|
||||
(downgraded). If either version string can't be parsed the mismatch is
|
||||
surfaced (True) so the user can reconcile, rather than silently hiding a
|
||||
potential update.
|
||||
"""
|
||||
if not installed_version or not latest_version:
|
||||
return False
|
||||
if not isinstance(installed_version, str) or not isinstance(latest_version, str):
|
||||
# A malformed manifest/registry can carry a number (1.2) or worse;
|
||||
# packaging would raise TypeError. Surface the mismatch instead.
|
||||
return True
|
||||
if installed_version == latest_version:
|
||||
return False
|
||||
try:
|
||||
from packaging.version import parse as _parse_version, InvalidVersion
|
||||
except ImportError:
|
||||
# packaging is a core dependency, but if it's somehow unavailable we
|
||||
# can't compare semantically — surface the mismatch we already know
|
||||
# exists (the two strings differ).
|
||||
return True
|
||||
try:
|
||||
return _parse_version(latest_version) > _parse_version(installed_version)
|
||||
except InvalidVersion:
|
||||
# Unparseable version string: we can't tell direction, so surface the
|
||||
# mismatch rather than silently hiding a potential update.
|
||||
return True
|
||||
|
||||
|
||||
def check(manifest: Dict[str, Any], core_version: str) -> Tuple[bool, Optional[str]]:
|
||||
"""Return ``(compatible, reason)``.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ and circuit breaker state. Provides automatic recovery mechanisms.
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Optional, Any
|
||||
from typing import Dict, Optional, Any, Tuple
|
||||
from enum import Enum
|
||||
|
||||
|
||||
@@ -64,10 +64,48 @@ class PluginHealthTracker:
|
||||
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
||||
)
|
||||
|
||||
if cached:
|
||||
return cached
|
||||
if isinstance(cached, dict) and cached:
|
||||
# Complete it rather than trusting it: a persisted record can be
|
||||
# missing fields the callers index directly (a partial write, a
|
||||
# restored backup, an older schema), and returning it verbatim makes
|
||||
# record_success / record_failure raise KeyError, which takes the
|
||||
# display down in a restart loop that survives reboots because the
|
||||
# bad entry is on disk.
|
||||
state, repaired = self._repair_health_state(cached)
|
||||
if repaired:
|
||||
self.logger.warning(
|
||||
f"Repaired health state for {plugin_id}: "
|
||||
f"{sorted(repaired)} missing or invalid, using defaults for those."
|
||||
)
|
||||
return state
|
||||
|
||||
# Default state
|
||||
# Not a dict at all: written by something other than
|
||||
# _save_health_state (a key collision, a corrupted entry). Nothing to
|
||||
# salvage.
|
||||
if cached is not None and not isinstance(cached, dict):
|
||||
self.logger.warning(
|
||||
f"Discarding malformed health state for {plugin_id}: expected "
|
||||
f"dict, got {type(cached).__name__}. Falling back to defaults."
|
||||
)
|
||||
|
||||
return self._default_health_state()
|
||||
|
||||
def _save_health_state(self, plugin_id: str, state: Dict[str, Any]) -> None:
|
||||
"""Save health state to cache."""
|
||||
cache_key = self._get_health_key(plugin_id)
|
||||
self.cache_manager.set(cache_key, state) # Persist indefinitely
|
||||
self._health_state[plugin_id] = state
|
||||
|
||||
# The fields callers index directly (state['circuit_state'] and friends).
|
||||
# A cached dict missing any of them raises KeyError deep in record_success /
|
||||
# record_failure, so the value is completed before it is handed out.
|
||||
_COUNTER_FIELDS = ('consecutive_failures', 'total_failures', 'total_successes')
|
||||
_TIMESTAMP_FIELDS = ('last_success_time', 'last_failure_time',
|
||||
'circuit_opened_time', 'half_open_start_time')
|
||||
|
||||
@staticmethod
|
||||
def _default_health_state() -> Dict[str, Any]:
|
||||
"""A fresh state with every field the callers expect."""
|
||||
return {
|
||||
'consecutive_failures': 0,
|
||||
'total_failures': 0,
|
||||
@@ -77,14 +115,55 @@ class PluginHealthTracker:
|
||||
'circuit_state': CircuitState.CLOSED.value,
|
||||
'circuit_opened_time': None,
|
||||
'half_open_start_time': None,
|
||||
'last_error': None
|
||||
'last_error': None,
|
||||
}
|
||||
|
||||
def _save_health_state(self, plugin_id: str, state: Dict[str, Any]) -> None:
|
||||
"""Save health state to cache."""
|
||||
cache_key = self._get_health_key(plugin_id)
|
||||
self.cache_manager.set(cache_key, state) # Persist indefinitely
|
||||
self._health_state[plugin_id] = state
|
||||
@classmethod
|
||||
def _repair_health_state(cls, cached: Dict[str, Any]) -> Tuple[Dict[str, Any], list]:
|
||||
"""Return `cached` completed against the defaults, plus what was repaired.
|
||||
|
||||
Per-field rather than all-or-nothing: a record that has real failure
|
||||
counts but is missing `last_error` should keep the counts, not be reset
|
||||
to healthy. Only values that are absent or the wrong type fall back to
|
||||
the default, so a partial or older-schema record survives with whatever
|
||||
it does carry, while every field the callers index is guaranteed present
|
||||
and of a usable type.
|
||||
"""
|
||||
state = cls._default_health_state()
|
||||
repaired = []
|
||||
for field, default in state.items():
|
||||
if field not in cached:
|
||||
repaired.append(field)
|
||||
continue
|
||||
value = cached[field]
|
||||
if field in cls._COUNTER_FIELDS:
|
||||
ok = isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
||||
elif field in cls._TIMESTAMP_FIELDS:
|
||||
# bool is a subclass of int, so True would pass as a timestamp
|
||||
# and then compare as 1.0 -- expiring a cooldown the instant it
|
||||
# opens, or (False) making the elapsed check never fire.
|
||||
ok = value is None or (
|
||||
isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
)
|
||||
elif field == 'circuit_state':
|
||||
# Membership first requires the value to be hashable: a list or
|
||||
# dict here would raise TypeError out of the repair itself,
|
||||
# which is the crash this whole path exists to prevent.
|
||||
ok = isinstance(value, str) and value in {
|
||||
member.value for member in CircuitState
|
||||
}
|
||||
else: # last_error
|
||||
ok = value is None or isinstance(value, str)
|
||||
if ok:
|
||||
state[field] = value
|
||||
else:
|
||||
repaired.append(field)
|
||||
# Anything the schema has since grown (degraded, degraded_reason) is
|
||||
# read with .get() by its callers, so carry it through untouched.
|
||||
for field, value in cached.items():
|
||||
if field not in state:
|
||||
state[field] = value
|
||||
return state, repaired
|
||||
|
||||
def get_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||
"""Get current health state for a plugin.
|
||||
|
||||
@@ -14,7 +14,7 @@ import sys
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple, Type
|
||||
from typing import Dict, Any, List, Optional, Tuple, Type
|
||||
import logging
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
@@ -45,6 +45,76 @@ def requirements_has_real_deps(requirements_file: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _extra_dependencies(dist_name: str, extras) -> Optional[List[Requirement]]:
|
||||
"""Dependencies a distribution declares *only* behind the given extras.
|
||||
|
||||
Returns None when the installed metadata cannot be read or parsed, so the
|
||||
caller can fall back to running pip rather than assuming anything.
|
||||
"""
|
||||
try:
|
||||
meta = importlib.metadata.metadata(dist_name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
gated: List[Requirement] = []
|
||||
for raw in meta.get_all('Requires-Dist') or []:
|
||||
try:
|
||||
dep = Requirement(raw)
|
||||
except InvalidRequirement:
|
||||
return None
|
||||
if dep.marker is None:
|
||||
continue
|
||||
# Keep only what the distribution gates behind an extra we asked for:
|
||||
# satisfied when `extra` is that name, but not when no extra is
|
||||
# requested. A marker that holds either way (python_version, sys_platform)
|
||||
# belongs to the base install and is already covered by the version check.
|
||||
if dep.marker.evaluate({'extra': ''}):
|
||||
continue
|
||||
if any(dep.marker.evaluate({'extra': extra}) for extra in extras):
|
||||
gated.append(dep)
|
||||
return gated
|
||||
|
||||
|
||||
def _extras_are_satisfied(req: Requirement, _visited: Optional[set] = None) -> bool:
|
||||
"""Check the dependencies pulled in by req's extras are installed.
|
||||
|
||||
Follows extras through nested extras. A gated dependency can itself request
|
||||
one (`requests[socks]`), and checking only that `requests` is installed at
|
||||
an acceptable version says nothing about whether the socks extra's own
|
||||
dependency is there -- so the caller would skip pip and the plugin would
|
||||
fail at import instead. Plain dependencies are still checked one level
|
||||
deep, which is all that is needed to tell "the extra was installed" from
|
||||
"the extra was never installed".
|
||||
|
||||
`_visited` carries the (distribution, extras) pairs already seen, so a
|
||||
dependency cycle between extras terminates instead of recursing forever.
|
||||
Anything unreadable returns False, so the caller still falls through to pip.
|
||||
"""
|
||||
if _visited is None:
|
||||
_visited = set()
|
||||
marker = (req.name.lower(), frozenset(e.lower() for e in req.extras))
|
||||
if marker in _visited:
|
||||
# Already accounted for higher up the chain; treating a cycle as
|
||||
# satisfied here is safe because the outer frame still has to pass.
|
||||
return True
|
||||
_visited.add(marker)
|
||||
|
||||
gated = _extra_dependencies(req.name, req.extras)
|
||||
if gated is None:
|
||||
return False
|
||||
|
||||
for dep in gated:
|
||||
try:
|
||||
dep_version = importlib.metadata.version(dep.name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return False
|
||||
if dep.specifier and not dep.specifier.contains(dep_version, prereleases=True):
|
||||
return False
|
||||
if dep.extras and not _extras_are_satisfied(dep, _visited):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def requirements_are_satisfied(requirements_file: str) -> bool:
|
||||
"""
|
||||
Check whether every real requirement line in requirements.txt is already
|
||||
@@ -76,9 +146,6 @@ def requirements_are_satisfied(requirements_file: str) -> bool:
|
||||
except InvalidRequirement:
|
||||
return False
|
||||
|
||||
if req.extras:
|
||||
return False # verifying extras' sub-dependencies isn't worth it here
|
||||
|
||||
if req.marker is not None and not req.marker.evaluate():
|
||||
continue # not applicable on this platform/interpreter
|
||||
|
||||
@@ -90,6 +157,9 @@ def requirements_are_satisfied(requirements_file: str) -> bool:
|
||||
if req.specifier and not req.specifier.contains(installed_version, prereleases=True):
|
||||
return False
|
||||
|
||||
if req.extras and not _extras_are_satisfied(req):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -125,6 +125,14 @@ class PluginManager:
|
||||
self._plugin_locks: Dict[str, threading.Lock] = {}
|
||||
self._plugin_locks_guard = threading.Lock()
|
||||
self._update_worker: Optional[threading.Thread] = None
|
||||
# Plugin ids whose update() has finished since the last time anyone
|
||||
# asked. Updates are dispatched to a worker thread, so a caller that
|
||||
# wants to know "whose data just changed" cannot learn it by diffing
|
||||
# plugin_last_update around run_scheduled_updates() -- that call only
|
||||
# enqueues, and the timestamp is stamped later, on the worker. See
|
||||
# run_scheduled_updates_with_changes().
|
||||
self._completed_updates: set = set()
|
||||
self._completed_updates_lock = threading.Lock()
|
||||
self._synchronous_updates = False
|
||||
if self.config_manager is not None:
|
||||
try:
|
||||
@@ -1025,6 +1033,7 @@ class PluginManager:
|
||||
if success:
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update[plugin_id] = scheduled_time
|
||||
self._note_update_completed(plugin_id)
|
||||
self.state_manager.record_update(plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
if self.health_tracker:
|
||||
@@ -1089,28 +1098,41 @@ class PluginManager:
|
||||
|
||||
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
|
||||
"""
|
||||
Like run_scheduled_updates(), but also returns the plugin_ids whose
|
||||
plugin_last_update timestamp actually advanced during this call.
|
||||
Like run_scheduled_updates(), but also reports which plugins have
|
||||
fresh data -- the ids whose update() has finished since the last
|
||||
call, not necessarily the ones enqueued by this one.
|
||||
|
||||
The before/after snapshots and the update pass itself are each
|
||||
individually lock-protected against concurrent plugin_last_update
|
||||
mutation (Vegas mode calls this from its own background
|
||||
update-tick thread, racing the main render loop's plugin updates),
|
||||
so callers get an atomic "who got fresh data" answer without
|
||||
reaching into plugin_last_update themselves. The lock is not held
|
||||
across the update pass so slow/blocking plugin update() calls don't
|
||||
serialize against other plugin_last_update readers.
|
||||
That distinction is the whole point. This used to snapshot
|
||||
plugin_last_update, call run_scheduled_updates(), and diff. But
|
||||
run_scheduled_updates() only *enqueues*: the work runs on the
|
||||
update worker and the timestamp is stamped there, after this method
|
||||
has already returned. The two snapshots were therefore always
|
||||
identical and the result was always empty, so Vegas never learned
|
||||
that any plugin's data had changed and kept scrolling whatever a
|
||||
segment was first built from -- last night's live game still drawn
|
||||
as live the next morning. The only path that ever worked was the
|
||||
synchronous kill-switch, where update() runs inline.
|
||||
|
||||
Reporting completions instead of enqueues costs a poll's worth of
|
||||
latency (the Vegas tick runs every ~4s) and is correct regardless of
|
||||
which side of the queue the work lands on.
|
||||
"""
|
||||
with self._plugin_last_update_lock:
|
||||
old_times = dict(self.plugin_last_update)
|
||||
|
||||
self.run_scheduled_updates(current_time)
|
||||
return self.drain_completed_updates()
|
||||
|
||||
with self._plugin_last_update_lock:
|
||||
return [
|
||||
plugin_id for plugin_id, new_time in self.plugin_last_update.items()
|
||||
if new_time > old_times.get(plugin_id, 0.0)
|
||||
]
|
||||
def _note_update_completed(self, plugin_id: str) -> None:
|
||||
"""Record that a plugin's update() finished, for the next poll."""
|
||||
with self._completed_updates_lock:
|
||||
self._completed_updates.add(plugin_id)
|
||||
|
||||
def drain_completed_updates(self) -> List[str]:
|
||||
"""Return and clear the plugin ids whose update() has since finished."""
|
||||
with self._completed_updates_lock:
|
||||
if not self._completed_updates:
|
||||
return []
|
||||
done = sorted(self._completed_updates)
|
||||
self._completed_updates.clear()
|
||||
return done
|
||||
|
||||
def update_all_plugins(self) -> None:
|
||||
"""
|
||||
@@ -1135,6 +1157,7 @@ class PluginManager:
|
||||
if success:
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update[plugin_id] = time.time()
|
||||
self._note_update_completed(plugin_id)
|
||||
self.state_manager.record_update(plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
else:
|
||||
|
||||
@@ -6,6 +6,7 @@ Manages saved GitHub repository URLs for easy plugin discovery and installation.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
@@ -43,20 +44,45 @@ class SavedRepositoriesManager:
|
||||
return []
|
||||
|
||||
def _save_repositories(self) -> bool:
|
||||
"""Save repositories to file."""
|
||||
"""Save repositories to file atomically.
|
||||
|
||||
Writes to a temp file in the same directory and os.replace()s it
|
||||
over the target, so a failed write can never truncate or
|
||||
half-overwrite an existing saved_repositories.json.
|
||||
"""
|
||||
tmp_path = self.config_path.with_suffix(self.config_path.suffix + '.tmp')
|
||||
try:
|
||||
# Ensure directory exists
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(self.config_path, 'w') as f:
|
||||
with open(tmp_path, 'w') as f:
|
||||
json.dump(self.repositories, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, self.config_path)
|
||||
|
||||
self.logger.info(f"Saved {len(self.repositories)} repositories to {self.config_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving repositories: {e}")
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _clean_url(repo_url: str) -> str:
|
||||
"""Normalize a repo URL: strip whitespace, trailing slashes, and a
|
||||
trailing ``.git`` suffix ONLY. (The old ``.replace('.git', '')``
|
||||
was an unanchored substring replace that mangled URLs merely
|
||||
containing ``.git``, e.g. ``https://github.com/user/my.github.io``.)
|
||||
"""
|
||||
repo_url = repo_url.strip().rstrip('/')
|
||||
if repo_url.endswith('.git'):
|
||||
repo_url = repo_url[:-4]
|
||||
return repo_url
|
||||
|
||||
def get_all(self) -> List[Dict[str, str]]:
|
||||
"""Get all saved repositories."""
|
||||
return self.repositories.copy()
|
||||
@@ -72,8 +98,7 @@ class SavedRepositoriesManager:
|
||||
Returns:
|
||||
True if added successfully
|
||||
"""
|
||||
# Clean URL
|
||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
||||
repo_url = self._clean_url(repo_url)
|
||||
|
||||
# Check if already exists
|
||||
for repo in self.repositories:
|
||||
@@ -96,7 +121,12 @@ class SavedRepositoriesManager:
|
||||
'type': 'registry' if 'plugins.json' in repo_url or 'ledmatrix-plugins' in repo_url.lower() else 'single'
|
||||
})
|
||||
|
||||
return self._save_repositories()
|
||||
if not self._save_repositories():
|
||||
# Keep memory consistent with disk: a failed save must not leave
|
||||
# a phantom entry that only this process can see.
|
||||
self.repositories.pop()
|
||||
return False
|
||||
return True
|
||||
|
||||
def remove(self, repo_url: str) -> bool:
|
||||
"""
|
||||
@@ -108,21 +138,25 @@ class SavedRepositoriesManager:
|
||||
Returns:
|
||||
True if removed successfully
|
||||
"""
|
||||
# Clean URL
|
||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
||||
repo_url = self._clean_url(repo_url)
|
||||
|
||||
original_count = len(self.repositories)
|
||||
self.repositories = [r for r in self.repositories if r.get('url') != repo_url]
|
||||
previous = self.repositories
|
||||
remaining = [r for r in previous if r.get('url') != repo_url]
|
||||
|
||||
if len(self.repositories) < original_count:
|
||||
return self._save_repositories()
|
||||
if len(remaining) < len(previous):
|
||||
self.repositories = remaining
|
||||
if not self._save_repositories():
|
||||
# Failed save: restore so memory matches disk.
|
||||
self.repositories = previous
|
||||
return False
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"Repository not found: {repo_url}")
|
||||
return False
|
||||
|
||||
def has(self, repo_url: str) -> bool:
|
||||
"""Check if a repository is already saved."""
|
||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
||||
repo_url = self._clean_url(repo_url)
|
||||
return any(r.get('url') == repo_url for r in self.repositories)
|
||||
|
||||
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
||||
|
||||
@@ -3000,7 +3000,10 @@ class PluginStoreManager:
|
||||
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
|
||||
|
||||
# Compare local manifest version against registry latest_version
|
||||
# to avoid unnecessary reinstalls for monorepo plugins
|
||||
# to avoid unnecessary reinstalls for monorepo plugins. Uses the
|
||||
# same semantic comparator as the web UI's update badge, so
|
||||
# equivalent spellings ("v1.2.0" vs "1.2.0") never trigger a
|
||||
# reinstall and a locally-ahead version is never downgraded.
|
||||
try:
|
||||
local_manifest_path = plugin_path / "manifest.json"
|
||||
if local_manifest_path.exists():
|
||||
@@ -3008,8 +3011,16 @@ class PluginStoreManager:
|
||||
local_manifest = json.load(f)
|
||||
local_version = local_manifest.get('version', '')
|
||||
remote_version = plugin_info_remote.get('latest_version', '')
|
||||
if local_version and remote_version and local_version == remote_version:
|
||||
self.logger.info(f"Plugin {plugin_id} already at latest version {local_version}")
|
||||
from src.plugin_system.compatibility import is_update_available
|
||||
# No truthiness gate: the shared comparator already treats
|
||||
# a missing version on either side as "no update", and the
|
||||
# store must agree with the UI badge in that case too. A
|
||||
# missing manifest (not just a missing version field)
|
||||
# still falls through to the reinstall recovery path.
|
||||
if not is_update_available(local_version, remote_version):
|
||||
self.logger.info(
|
||||
f"Plugin {plugin_id} already at latest version "
|
||||
f"(installed {local_version}, registry {remote_version})")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
|
||||
|
||||
@@ -73,6 +73,11 @@ class RenderResult:
|
||||
golden_ok: Optional[bool] = None
|
||||
golden_diff_pixels: int = 0
|
||||
golden_max_delta: int = 0
|
||||
# what display() handed back; the controller skips a mode only on False
|
||||
display_returned: Any = None
|
||||
# empty-frame check: rendered nothing while not reporting "no content"
|
||||
empty_claimed: Optional[bool] = None # True when that happened
|
||||
empty_ok: Optional[bool] = None # False only in strict mode
|
||||
# fill / scale-up check (populated only for sizes >= 2x the design size)
|
||||
fill_checked: bool = False
|
||||
fill_ok: Optional[bool] = None # False only in strict mode
|
||||
@@ -92,6 +97,8 @@ class RenderResult:
|
||||
return False
|
||||
if self.fill_ok is False:
|
||||
return False
|
||||
if self.empty_ok is False:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -132,21 +139,25 @@ def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||
return plugin_instance
|
||||
|
||||
|
||||
def _render_mode(plugin_instance: Any, mode: str) -> None:
|
||||
def _render_mode(plugin_instance: Any, mode: str) -> Any:
|
||||
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||
drive the plugin's internal mode state machine (first display() call renders
|
||||
modes[current_mode_index] when current_display_mode is None)."""
|
||||
modes[current_mode_index] when current_display_mode is None).
|
||||
|
||||
Returns whatever display() returned. The display controller skips a mode
|
||||
whose display() returns False, so that value decides whether an empty mode
|
||||
is rotated past or sat on -- which makes it worth reporting rather than
|
||||
discarding."""
|
||||
sig = inspect.signature(plugin_instance.display)
|
||||
if "display_mode" in sig.parameters:
|
||||
plugin_instance.display(force_clear=True, display_mode=mode)
|
||||
return
|
||||
return plugin_instance.display(force_clear=True, display_mode=mode)
|
||||
|
||||
modes = getattr(plugin_instance, "modes", None)
|
||||
if modes and mode in modes:
|
||||
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||
if hasattr(plugin_instance, "current_display_mode"):
|
||||
plugin_instance.current_display_mode = None
|
||||
plugin_instance.display(force_clear=False)
|
||||
return plugin_instance.display(force_clear=False)
|
||||
|
||||
|
||||
def _freeze(freeze_time: Optional[str]):
|
||||
@@ -234,7 +245,7 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||
plugin_id, mode, e)
|
||||
if result.error is None:
|
||||
_render_mode(inst, mode)
|
||||
result.display_returned = _render_mode(inst, mode)
|
||||
result.image = dm.get_image()
|
||||
result.overflow = dm.check_overflow()
|
||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||
@@ -341,6 +352,44 @@ def fill_metrics(image: Image.Image) -> Tuple[float, float, float]:
|
||||
return (extent_x, extent_y, ink)
|
||||
|
||||
|
||||
def check_empty_claimed(results: List[RenderResult],
|
||||
strict: bool = False) -> List[RenderResult]:
|
||||
"""Flag a mode that rendered nothing without reporting "no content".
|
||||
|
||||
The display controller skips a mode whose ``display()`` returns False, and
|
||||
treats anything else -- including None -- as "content was shown". A mode
|
||||
that draws nothing and does not return False therefore holds whatever is on
|
||||
the panel for its whole display duration. Since a mode switch clears first,
|
||||
that is a blank screen. Two sports plugins shipped exactly this: their
|
||||
``display()`` returned None on every path, so an out-of-season league sat
|
||||
blank for its full duration rather than being rotated past.
|
||||
|
||||
Warn-only by default, because a blank frame is not automatically wrong: a
|
||||
scroll mode whose first frame is its blank scroll-in buffer renders empty
|
||||
and is behaving correctly. ``strict=True`` sets ``empty_claimed`` such that
|
||||
``RenderResult.ok`` fails -- opt in per plugin via harness.json
|
||||
``{"empty_check": "strict"}`` once its modes are known to draw on the
|
||||
fixture data.
|
||||
|
||||
Note this can only catch what the fixtures actually render. A plugin whose
|
||||
harness fixture seeds content never exercises its empty path here; the
|
||||
source-level gate in the plugins repo covers that case.
|
||||
"""
|
||||
for r in results:
|
||||
if r.image is None or r.error is not None:
|
||||
continue
|
||||
# An explicit False is the plugin correctly saying "nothing to show".
|
||||
if r.display_returned is False:
|
||||
continue
|
||||
if r.image.convert("L").point(
|
||||
lambda p: 255 if p > _LIT_THRESHOLD else 0).getbbox() is not None:
|
||||
continue
|
||||
r.empty_claimed = True
|
||||
if strict:
|
||||
r.empty_ok = False
|
||||
return results
|
||||
|
||||
|
||||
def check_scale_up(results: List[RenderResult],
|
||||
design_size: Tuple[int, int] = (128, 32),
|
||||
min_extent: float = _MIN_FILL_EXTENT,
|
||||
|
||||
@@ -15,16 +15,23 @@ from src.logging_config import get_logger
|
||||
class StartupValidator:
|
||||
"""Validates system state on startup."""
|
||||
|
||||
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None) -> None:
|
||||
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None,
|
||||
cache_manager: Optional[Any] = None) -> None:
|
||||
"""
|
||||
Initialize the startup validator.
|
||||
|
||||
Args:
|
||||
config_manager: ConfigManager instance
|
||||
plugin_manager: Optional PluginManager instance
|
||||
cache_manager: The CacheManager the application will actually use.
|
||||
Pass it. Without one this validator builds its own just to read
|
||||
a directory path, which reports on a cache the app does not
|
||||
use and leaves behind a cleanup thread that nothing stops --
|
||||
validation runs twice per startup, so that was two of them.
|
||||
"""
|
||||
self.config_manager = config_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
self.cache_manager = cache_manager
|
||||
self.logger = get_logger(__name__)
|
||||
self.errors: List[str] = []
|
||||
self.warnings: List[str] = []
|
||||
@@ -38,6 +45,11 @@ class StartupValidator:
|
||||
"""
|
||||
self.logger.info("Starting startup validation...")
|
||||
|
||||
# Fresh lists each run — without this, calling validate_all() twice
|
||||
# duplicated every message.
|
||||
self.errors = []
|
||||
self.warnings = []
|
||||
|
||||
# Validate configuration
|
||||
self._validate_config()
|
||||
|
||||
@@ -51,6 +63,9 @@ class StartupValidator:
|
||||
if self.plugin_manager:
|
||||
self._validate_plugins()
|
||||
|
||||
# Warn when the running systemd unit no longer matches the repo's
|
||||
self._validate_systemd_units()
|
||||
|
||||
is_valid = len(self.errors) == 0
|
||||
|
||||
if is_valid:
|
||||
@@ -62,6 +77,80 @@ class StartupValidator:
|
||||
|
||||
return (is_valid, self.errors.copy(), self.warnings.copy())
|
||||
|
||||
#: Units this project installs, and where each is installed to.
|
||||
_UNITS = (
|
||||
("systemd/ledmatrix.service", "/etc/systemd/system/ledmatrix.service"),
|
||||
("systemd/ledmatrix-web.service", "/etc/systemd/system/ledmatrix-web.service"),
|
||||
)
|
||||
|
||||
def _validate_systemd_units(self) -> None:
|
||||
"""Warn when an installed unit has drifted from the repo's template.
|
||||
|
||||
Nothing re-applies these after the first install. `git pull` -- which is
|
||||
what the web UI's update button runs -- brings a new template into the
|
||||
checkout, but nothing copies it to /etc/systemd/system and nothing runs
|
||||
`systemctl daemon-reload`, so the unit that actually runs is whatever
|
||||
first_time_install.sh wrote on day one.
|
||||
|
||||
That makes every hardening added to a unit inert on existing installs.
|
||||
Measured on one rig: the installed unit was thirteen days older than the
|
||||
repo's and differed in content, so a MemoryMax the repo had specified
|
||||
was not being enforced at all -- `systemctl show` reported
|
||||
MemoryMax=infinity.
|
||||
|
||||
A warning rather than an error, and certainly not a silent rewrite:
|
||||
editing files under /etc and restarting services is the installer's job,
|
||||
not something a display process should do to a machine while it boots.
|
||||
The remedy is to re-run scripts/install/install_service.sh.
|
||||
"""
|
||||
try:
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
for template_rel, installed_path in self._UNITS:
|
||||
template = project_root / template_rel
|
||||
installed = Path(installed_path)
|
||||
if not template.is_file() or not installed.is_file():
|
||||
continue
|
||||
|
||||
# The template carries placeholders the installer substitutes,
|
||||
# so compare the substituted form rather than the raw file.
|
||||
expected = template.read_text(encoding="utf-8")
|
||||
expected = expected.replace("__PROJECT_ROOT_DIR__", str(project_root))
|
||||
expected = expected.replace("__USER__", "root")
|
||||
|
||||
try:
|
||||
actual = installed.read_text(encoding="utf-8")
|
||||
except PermissionError:
|
||||
continue
|
||||
|
||||
if self._unit_body(expected) != self._unit_body(actual):
|
||||
self.warnings.append(
|
||||
f"{installed.name} differs from {template_rel}; the "
|
||||
"installed unit is not refreshed by an update, so "
|
||||
"settings added to the template are not in effect. "
|
||||
"Re-run scripts/install/install_service.sh to apply them."
|
||||
)
|
||||
except OSError as e:
|
||||
self.logger.debug("Could not compare systemd units: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def _unit_body(text: str) -> str:
|
||||
"""A unit's meaningful lines, in order: no comments, no blanks.
|
||||
|
||||
Order is preserved deliberately. This used to sort, which made the
|
||||
comparison insensitive to two changes that matter in a systemd unit:
|
||||
repeated directives such as ExecStartPre= and ExecStartPost= run in
|
||||
the order they appear, and a directive that moves between [Unit],
|
||||
[Service] and [Install] means something different -- or nothing --
|
||||
where it lands. A drift check that normalises those away reports no
|
||||
drift for a unit that has genuinely changed.
|
||||
"""
|
||||
lines = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate configuration files."""
|
||||
try:
|
||||
@@ -86,8 +175,20 @@ class StartupValidator:
|
||||
def _validate_cache_directory(self) -> None:
|
||||
"""Validate cache directory permissions."""
|
||||
try:
|
||||
cache_manager = self.cache_manager
|
||||
if cache_manager is None:
|
||||
# No caller supplied one (older embedders, direct use in a
|
||||
# script). Build one, but do not leave its cleanup thread
|
||||
# running behind us -- this instance is discarded on the next
|
||||
# line but the thread is a closure over it, so it would never
|
||||
# be collected.
|
||||
from src.cache_manager import CacheManager
|
||||
cache_manager = CacheManager()
|
||||
try:
|
||||
cache_dir = cache_manager.get_cache_dir()
|
||||
finally:
|
||||
cache_manager.stop_cleanup_thread()
|
||||
else:
|
||||
cache_dir = cache_manager.get_cache_dir()
|
||||
|
||||
if not cache_dir:
|
||||
|
||||
@@ -104,15 +104,53 @@ class VegasModeConfig:
|
||||
overflow_mode: str = "rotate"
|
||||
|
||||
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
||||
# A single ticker returning 7,000px would otherwise hold the panel for over
|
||||
# two minutes. Overflow is deferred to later cycles rather than discarded.
|
||||
# 0 disables the cap.
|
||||
max_plugin_width_ratio: float = 3.0
|
||||
# 0 (the default) disables the cap, so every plugin contributes all of its
|
||||
# content and is always entered at its beginning.
|
||||
#
|
||||
# Capping was the default until it proved to cost more than it bought.
|
||||
# Measured over a 17-plugin fleet on a 512px panel, only four plugins were
|
||||
# ever wide enough to hit a 3.0 cap; for those four it produced two visible
|
||||
# faults. Content resumed mid-item on each appearance (a news ticker entered
|
||||
# at column 6027 of its own strip), and the final window of a rotation was
|
||||
# whatever happened to be left — 348px of a 1840px stocks ticker, seven
|
||||
# seconds of panel time. Both read as the display being broken rather than
|
||||
# as deferral working.
|
||||
#
|
||||
# A wide plugin does hold the panel for a long time uncapped: set the cap
|
||||
# per plugin with vegas_max_width_screens where that matters, rather than
|
||||
# globally where it mostly hurts plugins that were never the problem.
|
||||
max_plugin_width_ratio: float = 0.0
|
||||
|
||||
# Plugin management
|
||||
plugin_order: List[str] = field(default_factory=list)
|
||||
excluded_plugins: Set[str] = field(default_factory=set)
|
||||
|
||||
# --- Live content in the ticker -------------------------------------
|
||||
#
|
||||
# By default a live game preempts Vegas entirely: the display controller
|
||||
# refuses to run the ticker while any plugin reports live priority, and you
|
||||
# get the full-screen scoreboard instead. Set live_in_ticker to keep the
|
||||
# marquee running and let live content take extra turns within it.
|
||||
#
|
||||
# The rotation is otherwise a strict round robin -- every plugin appears
|
||||
# exactly once per cycle -- so with a dozen plugins enabled a live score
|
||||
# comes round once a lap and can be minutes old on screen. Weighting lets a
|
||||
# plugin claim several slots per cycle instead.
|
||||
#
|
||||
# Weights are per plugin, not per game: a scoreboard showing four live
|
||||
# games still occupies one slot at a time, and rotates its own games within
|
||||
# that slot using its own favorite_live_boost.
|
||||
live_in_ticker: bool = False
|
||||
|
||||
# Slots per cycle for a plugin reporting live content. 1 disables the boost
|
||||
# and restores the plain round robin.
|
||||
live_weight: int = 3
|
||||
|
||||
# Slots per cycle for a plugin whose live content involves a favorite team.
|
||||
# Only plugins implementing get_vegas_priority_weight() can claim this --
|
||||
# the core cannot tell whose game is on, so the plugin reports it.
|
||||
favorite_live_weight: int = 5
|
||||
|
||||
# Performance settings
|
||||
target_fps: int = 125 # Target frame rate
|
||||
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
||||
@@ -159,10 +197,16 @@ class VegasModeConfig:
|
||||
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
|
||||
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
||||
max_plugin_width_ratio=float(
|
||||
vegas_config.get('max_plugin_width_ratio', 3.0)),
|
||||
vegas_config.get('max_plugin_width_ratio', 0.0)),
|
||||
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||
live_in_ticker=bool(vegas_config.get('live_in_ticker', False)),
|
||||
# Clamped: a weight below 1 would drop the plugin from the rotation
|
||||
# entirely, and a very large one starves everything else.
|
||||
live_weight=max(1, min(10, int(vegas_config.get('live_weight', 3)))),
|
||||
favorite_live_weight=max(
|
||||
1, min(10, int(vegas_config.get('favorite_live_weight', 5)))),
|
||||
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
||||
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
||||
@@ -192,6 +236,9 @@ class VegasModeConfig:
|
||||
'lead_in_width': self.lead_in_width,
|
||||
'plugins_per_cycle': self.plugins_per_cycle,
|
||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
||||
'live_in_ticker': self.live_in_ticker,
|
||||
'live_weight': self.live_weight,
|
||||
'favorite_live_weight': self.favorite_live_weight,
|
||||
'overflow_mode': self.overflow_mode,
|
||||
'plugin_order': self.plugin_order,
|
||||
'excluded_plugins': list(self.excluded_plugins),
|
||||
@@ -359,6 +406,15 @@ class VegasModeConfig:
|
||||
|
||||
if 'enabled' in vegas_config:
|
||||
self.enabled = vegas_config['enabled']
|
||||
if 'live_in_ticker' in vegas_config:
|
||||
self.live_in_ticker = bool(vegas_config['live_in_ticker'])
|
||||
# Clamped exactly as from_config does: a weight below 1 would drop the
|
||||
# plugin from the rotation, and a huge one starves everything else.
|
||||
if 'live_weight' in vegas_config:
|
||||
self.live_weight = max(1, min(10, int(vegas_config['live_weight'])))
|
||||
if 'favorite_live_weight' in vegas_config:
|
||||
self.favorite_live_weight = max(
|
||||
1, min(10, int(vegas_config['favorite_live_weight'])))
|
||||
if 'scroll_speed' in vegas_config:
|
||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||
if 'separator_width' in vegas_config:
|
||||
|
||||
@@ -12,6 +12,7 @@ Supports three display modes per plugin:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import threading
|
||||
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
|
||||
@@ -30,6 +31,21 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _percentile(ordered: List[float], fraction: float) -> float:
|
||||
"""Nearest-rank percentile of an already-sorted list.
|
||||
|
||||
Index ceil(n * fraction) - 1, so 100 samples at 0.99 give the 99th-ranked
|
||||
value. The obvious int(n * fraction) is off by one and, at exactly 100
|
||||
samples, lands on the maximum -- which is the number already reported
|
||||
alongside this one as the worst frame, so the two columns would agree
|
||||
precisely when the sample was smallest.
|
||||
"""
|
||||
if not ordered:
|
||||
return 0.0
|
||||
index = math.ceil(len(ordered) * fraction) - 1
|
||||
return ordered[min(len(ordered) - 1, max(0, index))]
|
||||
|
||||
|
||||
class VegasModeCoordinator:
|
||||
"""
|
||||
Orchestrates Vegas scroll mode operation.
|
||||
@@ -382,6 +398,12 @@ class VegasModeCoordinator:
|
||||
fps_log_interval = 5.0 # Log FPS every 5 seconds
|
||||
last_fps_log_time = start_time
|
||||
fps_frame_count = 0
|
||||
# A mean hides stutter completely. At 120fps a five-second window is
|
||||
# ~600 frames, so a 200ms freeze -- plainly visible on a marquee --
|
||||
# moves the average from 120.0 to 115.4 and reads as healthy. What a
|
||||
# viewer actually notices is the worst frame, so track that too.
|
||||
frame_worst = 0.0
|
||||
frame_times: List[float] = []
|
||||
|
||||
logger.info("Starting Vegas iteration for %.1fs", duration)
|
||||
|
||||
@@ -417,6 +439,11 @@ class VegasModeCoordinator:
|
||||
frame_elapsed = time.time() - frame_started
|
||||
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
||||
|
||||
# Measured before the sleep: time spent working, not pacing.
|
||||
if frame_elapsed > frame_worst:
|
||||
frame_worst = frame_elapsed
|
||||
frame_times.append(frame_elapsed)
|
||||
|
||||
# Increment frame count and check for interrupt periodically
|
||||
frame_count += 1
|
||||
fps_frame_count += 1
|
||||
@@ -425,12 +452,16 @@ class VegasModeCoordinator:
|
||||
current_time = time.time()
|
||||
if current_time - last_fps_log_time >= fps_log_interval:
|
||||
fps = fps_frame_count / (current_time - last_fps_log_time)
|
||||
p99 = _percentile(sorted(frame_times), 0.99)
|
||||
logger.info(
|
||||
"Vegas FPS: %.1f (target: %d, frames: %d)",
|
||||
fps, self.vegas_config.target_fps, fps_frame_count
|
||||
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
||||
fps, self.vegas_config.target_fps, fps_frame_count,
|
||||
p99 * 1000.0, frame_worst * 1000.0
|
||||
)
|
||||
last_fps_log_time = current_time
|
||||
fps_frame_count = 0
|
||||
frame_worst = 0.0
|
||||
frame_times.clear()
|
||||
|
||||
if (self._interrupt_check and
|
||||
frame_count % self._interrupt_check_interval == 0):
|
||||
@@ -497,6 +528,12 @@ class VegasModeCoordinator:
|
||||
if not self._live_priority_check:
|
||||
return False
|
||||
|
||||
if self.vegas_config.live_in_ticker:
|
||||
# The ticker keeps live content rather than yielding to it; the
|
||||
# extra turns are arranged in the rotation itself, so there is
|
||||
# nothing to pause for.
|
||||
return False
|
||||
|
||||
try:
|
||||
live_mode = self._live_priority_check()
|
||||
if live_mode:
|
||||
|
||||
@@ -68,6 +68,21 @@ class PluginAdapter:
|
||||
# always the same opening items.
|
||||
self._item_offsets: dict = {}
|
||||
|
||||
# What the matching entry in _item_offsets is an offset *into*, as
|
||||
# (kind, size). An offset only means anything against the content it
|
||||
# was derived from, and there are three incompatible kinds:
|
||||
#
|
||||
# ('rows', n) index into a list of n images
|
||||
# ('cuts', n) index into the n item boundaries of one image
|
||||
# ('cols', w) pixel column in a w-wide image with no item boundaries
|
||||
#
|
||||
# Without this the offsets were reused across kinds — a plugin that
|
||||
# returned one wide image on one fetch and several rows on the next had
|
||||
# a pixel column of 1400 read back as a row index — and across content
|
||||
# changes, where a column recorded against a 9,793px news strip pointed
|
||||
# into unrelated headlines once the strip refreshed to 9,505px.
|
||||
self._offset_shapes: dict = {}
|
||||
|
||||
logger.info(
|
||||
"PluginAdapter initialized: display=%dx%d",
|
||||
self.display_width, self.display_height
|
||||
@@ -398,6 +413,88 @@ class PluginAdapter:
|
||||
return 0
|
||||
return int(self.display_width * ratio)
|
||||
|
||||
def _resume_offset(self, plugin_id: str, shape: Tuple[str, int]) -> int:
|
||||
"""
|
||||
The plugin's stored rotation offset, if it still applies.
|
||||
|
||||
An offset is only meaningful against content shaped the way it was
|
||||
when the offset was recorded. When the shape has changed — a different
|
||||
number of rows, a re-rendered strip with different item boundaries —
|
||||
the stored value points somewhere arbitrary, so rotation restarts.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
shape: (kind, size) describing what an offset would index into now
|
||||
|
||||
Returns:
|
||||
The stored offset, or 0 when it no longer applies
|
||||
"""
|
||||
if self._offset_shapes.get(plugin_id) != shape:
|
||||
if plugin_id in self._item_offsets:
|
||||
logger.info(
|
||||
"[%s] Content is %s now, was %s — restarting the rotation "
|
||||
"rather than resuming at a position that no longer means "
|
||||
"anything", plugin_id, shape,
|
||||
self._offset_shapes.get(plugin_id))
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
self._offset_shapes[plugin_id] = shape
|
||||
return 0
|
||||
return self._item_offsets.get(plugin_id, 0)
|
||||
|
||||
def _record_offset(
|
||||
self, plugin_id: str, offset: int, shape: Tuple[str, int]
|
||||
) -> None:
|
||||
"""Store where the next window should resume, with what it indexes."""
|
||||
if offset:
|
||||
self._item_offsets[plugin_id] = offset
|
||||
self._offset_shapes[plugin_id] = shape
|
||||
else:
|
||||
# A wrapped-to-zero rotation is the same as no state at all, and
|
||||
# keeping the key would report a window as active when the next
|
||||
# pass starts from the top anyway.
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
self._offset_shapes.pop(plugin_id, None)
|
||||
|
||||
def _clear_offset(self, plugin_id: str) -> None:
|
||||
"""Forget any rotation state for a plugin."""
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
self._offset_shapes.pop(plugin_id, None)
|
||||
|
||||
def _merge_trailing_runt(self, end: int, width: int, budget: int) -> int:
|
||||
"""
|
||||
Extend a window to the end of the content when what would be left over
|
||||
is too small to be worth its own pass.
|
||||
|
||||
Windows were placed by walking forward from the last one, which makes
|
||||
the final window whatever happens to remain. Measured on a live panel
|
||||
that produced a 1,840px stocks ticker splitting 1,492 + 348 — the
|
||||
second pass showing seven seconds of content before cutting, which
|
||||
reads as the display failing rather than as a rotation.
|
||||
|
||||
Absorbing the remainder overruns the budget by less than one window
|
||||
floor, which is a better trade than a fragment: the budget is a guard
|
||||
against one plugin holding the panel for minutes, not a hard limit.
|
||||
|
||||
Args:
|
||||
end: Column the window would otherwise end at
|
||||
width: Full content width
|
||||
budget: Width budget being applied
|
||||
|
||||
Returns:
|
||||
``end``, or ``width`` when the remainder is below the floor
|
||||
"""
|
||||
remainder = width - end
|
||||
# Measured against the budget rather than the panel: snapping to item
|
||||
# boundaries means an ordinary window already lands short of the budget
|
||||
# (a 512px budget over 182px-pitch items yields 348px windows), so an
|
||||
# absolute floor would merge windows that were never fragments. Half a
|
||||
# budget separates "a short last pass" from "a sliver", and caps the
|
||||
# overrun this can cause at 1.5 budgets.
|
||||
floor = budget // 2
|
||||
if 0 < remainder < floor:
|
||||
return width
|
||||
return end
|
||||
|
||||
def _apply_width_budget(
|
||||
self, images: List[Image.Image], plugin_id: str,
|
||||
plugin: Optional['BasePlugin'] = None
|
||||
@@ -435,31 +532,47 @@ class PluginAdapter:
|
||||
|
||||
if not budget or total <= budget:
|
||||
# Fits, so reset rotation — the whole segment is being shown.
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
self._clear_offset(plugin_id)
|
||||
return images
|
||||
|
||||
if len(images) == 1:
|
||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
||||
|
||||
shape = ('rows', len(images))
|
||||
if mode == 'truncate':
|
||||
# Ordered content: always show from the top. Deliberately does not
|
||||
# advance the offset, so the same opening items appear every time
|
||||
# rather than the viewer being shown the middle of a ranked list.
|
||||
start = 0
|
||||
else:
|
||||
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
||||
start = self._resume_offset(plugin_id, shape) % len(images)
|
||||
selected: List[Image.Image] = []
|
||||
used = 0
|
||||
consumed = 0
|
||||
|
||||
# Walk forward from the rotation offset, taking whole items only, so a
|
||||
# cut never lands in the middle of one.
|
||||
#
|
||||
# A window may overrun the budget while it is still shorter than the
|
||||
# runt floor, for the same reason _merge_trailing_runt exists on the
|
||||
# single-image path: a pass far shorter than its neighbours reads as
|
||||
# the display failing rather than as a rotation. Rows of 450, 450 and
|
||||
# 100 against a 512px budget used to give the 100 a pass of its own --
|
||||
# two seconds against nine. Wrapping does not prevent that, because it
|
||||
# only helps when the row wrapped to actually fits.
|
||||
floor = budget // 2
|
||||
for step in range(len(images)):
|
||||
img = images[(start + step) % len(images)]
|
||||
cost = img.width
|
||||
if selected:
|
||||
cost += self._row_gap(selected[-1], img)
|
||||
if selected and used + cost > budget:
|
||||
# Keep the overrun bounded at the same 1.5 budgets the
|
||||
# single-image path allows. A next row too wide to absorb
|
||||
# leaves a short window standing -- better than a window of
|
||||
# 1.9 budgets, and the same trade the always-take-the-first
|
||||
# rule below already makes.
|
||||
if used >= floor or used + cost > budget + floor:
|
||||
break
|
||||
selected.append(img)
|
||||
used += cost
|
||||
@@ -472,7 +585,8 @@ class PluginAdapter:
|
||||
plugin_id, budget, len(selected), len(images), used
|
||||
)
|
||||
else:
|
||||
self._item_offsets[plugin_id] = (start + consumed) % len(images)
|
||||
self._record_offset(
|
||||
plugin_id, (start + consumed) % len(images), shape)
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
||||
"from offset %d; remainder deferred to a later cycle",
|
||||
@@ -490,16 +604,13 @@ class PluginAdapter:
|
||||
|
||||
The cut is snapped to the nearest blank column so it does not slice
|
||||
through a glyph or logo and leave half a character at the panel edge.
|
||||
"""
|
||||
if mode == 'truncate':
|
||||
# Always the start of the strip, so a ranked table is never entered
|
||||
# from the middle.
|
||||
offset = 0
|
||||
else:
|
||||
offset = self._item_offsets.get(plugin_id, 0)
|
||||
if offset >= img.width:
|
||||
offset = 0
|
||||
|
||||
Rotation is tracked as an index into the strip's item boundaries rather
|
||||
than as a pixel column, because a ticker re-renders between fetches. A
|
||||
column recorded against one render points at unrelated content in the
|
||||
next as soon as anything ahead of it changes width — a digit in a
|
||||
price, a shorter headline. The Nth boundary stays the Nth boundary.
|
||||
"""
|
||||
# Cut only where the plugin left a real gap between items. Snapping to
|
||||
# any blank column used to pick the single-column gaps between
|
||||
# characters, splitting a word and orphaning its tail into the next
|
||||
@@ -514,9 +625,17 @@ class PluginAdapter:
|
||||
# budget exactly. The gap rule exists to protect discrete items
|
||||
# (words, ticker entries); it would be wrong to let a solid image
|
||||
# escape the cap in its name.
|
||||
end = min(offset + budget, img.width)
|
||||
#
|
||||
# With no items to index, the offset here has to stay a column, so
|
||||
# it is only reusable while the image keeps its width.
|
||||
shape = ('cols', img.width)
|
||||
offset = 0 if mode == 'truncate' else self._resume_offset(
|
||||
plugin_id, shape)
|
||||
end = self._merge_trailing_runt(
|
||||
min(offset + budget, img.width), img.width, budget)
|
||||
if mode != 'truncate':
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
self._record_offset(
|
||||
plugin_id, 0 if end >= img.width else end, shape)
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
||||
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
|
||||
@@ -528,8 +647,15 @@ class PluginAdapter:
|
||||
# Cut mid-gap so the content either side keeps some breathing room.
|
||||
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
|
||||
|
||||
start = max((c for c in cuts if c <= offset), default=0)
|
||||
later = [c for c in cuts if c > start]
|
||||
shape = ('cuts', len(cuts))
|
||||
index = 0 if mode == 'truncate' else self._resume_offset(
|
||||
plugin_id, shape)
|
||||
# Clamped rather than wrapped: a stale index past the end means the
|
||||
# strip shrank, and restarting reads better than landing near the end.
|
||||
start_index = index if 0 <= index < len(cuts) - 1 else 0
|
||||
start = cuts[start_index]
|
||||
|
||||
later = cuts[start_index + 1:]
|
||||
if not later:
|
||||
end = img.width
|
||||
else:
|
||||
@@ -537,15 +663,22 @@ class PluginAdapter:
|
||||
# No boundary inside the budget: take the next one and overrun,
|
||||
# because the alternative is cutting through an item.
|
||||
end = max(within) if within else min(later)
|
||||
end = self._merge_trailing_runt(end, img.width, budget)
|
||||
# Every candidate for `end` came from `cuts` (which includes img.width),
|
||||
# so this always resolves; the fallback is defensive only.
|
||||
end_index = cuts.index(end) if end in cuts else len(cuts) - 1
|
||||
|
||||
if mode != 'truncate':
|
||||
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
# Next cycle resumes at the boundary this one stopped on; wrap when
|
||||
# the strip ends.
|
||||
self._record_offset(
|
||||
plugin_id, 0 if end >= img.width else end_index, shape)
|
||||
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
||||
"(%dpx) at item boundaries, %s",
|
||||
"(%dpx) at item boundaries %d-%d of %d, %s",
|
||||
plugin_id, budget, img.width, start, end, end - start,
|
||||
start_index, end_index, len(cuts) - 1,
|
||||
"showing the start only (overflow=truncate)"
|
||||
if mode == 'truncate' else "window advances next cycle"
|
||||
)
|
||||
|
||||
@@ -406,6 +406,8 @@ class StreamManager:
|
||||
)
|
||||
logger.info("Ordered plugins: %s", ordered_plugins)
|
||||
|
||||
ordered_plugins = self._apply_priority_weights(ordered_plugins)
|
||||
|
||||
# Atomically update shared state under lock to avoid races with prefetchers
|
||||
with self._buffer_lock:
|
||||
self._ordered_plugins = ordered_plugins
|
||||
@@ -417,6 +419,143 @@ class StreamManager:
|
||||
|
||||
logger.info("=" * 60)
|
||||
|
||||
def _plugin_weight(self, plugin_id: str) -> int:
|
||||
"""Slots per cycle for one plugin.
|
||||
|
||||
A plugin may answer for itself via get_vegas_priority_weight() -- the
|
||||
only way favorite-team awareness can reach here, since the core can see
|
||||
that a game is live but not whose. When it declines (returns None, the
|
||||
default), live content earns ``live_weight`` and everything else 1.
|
||||
"""
|
||||
plugin = None
|
||||
try:
|
||||
plugin = self.plugin_manager.plugins.get(plugin_id)
|
||||
except (AttributeError, TypeError):
|
||||
return 1
|
||||
if plugin is None:
|
||||
return 1
|
||||
|
||||
try:
|
||||
if hasattr(plugin, 'get_vegas_priority_weight'):
|
||||
declared = plugin.get_vegas_priority_weight()
|
||||
if declared is not None:
|
||||
return max(1, min(10, int(declared)))
|
||||
except Exception:
|
||||
# Deliberately falls through to the core's own live check rather
|
||||
# than demoting to 1. The plugin's weight calculation is broken,
|
||||
# but has_live_priority() and has_live_content() are separate
|
||||
# methods guarded separately below -- a plugin that genuinely has
|
||||
# a live game should still get live_weight for it.
|
||||
logger.exception("[%s] get_vegas_priority_weight() failed", plugin_id)
|
||||
|
||||
try:
|
||||
if (hasattr(plugin, 'has_live_priority')
|
||||
and hasattr(plugin, 'has_live_content')
|
||||
and plugin.has_live_priority()
|
||||
and plugin.has_live_content()):
|
||||
return self.config.live_weight
|
||||
except Exception:
|
||||
logger.exception("[%s] live-content check failed", plugin_id)
|
||||
return 1
|
||||
|
||||
def _apply_priority_weights(self, ordered: List[str]) -> List[str]:
|
||||
"""Expand the rotation so weighted plugins take several turns per cycle.
|
||||
|
||||
Smooth Weighted Round-Robin, the same scheduler the sports plugins use
|
||||
to rotate their own games: a plugin of weight N appears N times per
|
||||
cycle, and the repeats are spaced through the cycle rather than
|
||||
clumped, so a live score is never three-in-a-row followed by a long
|
||||
silence.
|
||||
|
||||
Returns the input unchanged when nothing is weighted, which is both the
|
||||
common case and the pre-existing behaviour.
|
||||
"""
|
||||
if not ordered or not self.config.live_in_ticker:
|
||||
return ordered
|
||||
|
||||
weights = {pid: self._plugin_weight(pid) for pid in ordered}
|
||||
total = sum(weights.values())
|
||||
if total <= len(ordered):
|
||||
return ordered # nothing boosted; plain round robin
|
||||
|
||||
current = {pid: 0 for pid in ordered}
|
||||
schedule: List[str] = []
|
||||
for _ in range(total):
|
||||
for pid in ordered:
|
||||
current[pid] += weights[pid]
|
||||
picked = max(current, key=lambda p: current[p])
|
||||
current[picked] -= total
|
||||
schedule.append(picked)
|
||||
|
||||
schedule = self._unclump_seam(schedule)
|
||||
|
||||
boosted = {p: w for p, w in weights.items() if w > 1}
|
||||
logger.info(
|
||||
"Vegas rotation weighted: %d slots for %d plugins (boosted: %s)",
|
||||
len(schedule), len(ordered), boosted)
|
||||
return schedule
|
||||
|
||||
@staticmethod
|
||||
def _unclump_seam(schedule: List[str]) -> List[str]:
|
||||
"""Stop the heaviest plugin sitting on both ends of the cycle.
|
||||
|
||||
Smooth Weighted Round-Robin spaces repeats well *within* a pass, but
|
||||
it schedules the heaviest item first and often last too. The strip
|
||||
loops, so those two are neighbours: the one place the marquee shows
|
||||
the same plugin twice running is the seam between cycles.
|
||||
|
||||
Rotating the list cannot fix this. Rotation preserves the cyclic order
|
||||
exactly, so it only moves where the seam is drawn, not the adjacency
|
||||
itself. The trailing entry has to be swapped with one from the middle
|
||||
whose neighbours differ from it, which breaks the pair without
|
||||
creating another.
|
||||
|
||||
Left alone when no such position exists -- a rotation short enough or
|
||||
lopsided enough to have none is one where the plugin is unavoidably
|
||||
adjacent to itself anyway.
|
||||
"""
|
||||
if len(schedule) < 3 or schedule[0] != schedule[-1]:
|
||||
return schedule
|
||||
|
||||
repeated = schedule[-1]
|
||||
size = len(schedule)
|
||||
|
||||
def cyclic_doubles(seq) -> int:
|
||||
return sum(1 for i in range(size) if seq[i] == seq[(i + 1) % size])
|
||||
|
||||
def clearance(seq, value) -> int:
|
||||
"""Smallest cyclic gap between appearances of `value`."""
|
||||
at = [i for i, v in enumerate(seq) if v == value]
|
||||
if len(at) < 2:
|
||||
return size
|
||||
return min(min((b - a) % size, (a - b) % size)
|
||||
for i, a in enumerate(at) for b in at[i + 1:])
|
||||
|
||||
# Try each swap and judge the result, rather than reasoning about which
|
||||
# neighbours the two moved elements will end up with. That reasoning is
|
||||
# where the first version went wrong: it guarded the slot `repeated`
|
||||
# moves into but not the one the displaced element lands in, so
|
||||
# ['a','b','c','d','x','y','x','a'] came back ending ['x','x'] -- the
|
||||
# seam duplicate traded for a fresh one.
|
||||
best = None
|
||||
best_clearance = -1
|
||||
for j in range(1, size - 1):
|
||||
candidate = list(schedule)
|
||||
candidate[j], candidate[-1] = candidate[-1], candidate[j]
|
||||
if cyclic_doubles(candidate):
|
||||
continue
|
||||
# Among the repairs that work, prefer the one that leaves the
|
||||
# boosted plugin most evenly spread; taking the first that merely
|
||||
# fits moved a repeat from a gap of 7 into a gap of 2.
|
||||
spread = clearance(candidate, repeated)
|
||||
if spread > best_clearance:
|
||||
best, best_clearance = candidate, spread
|
||||
|
||||
# None exists when the value is unavoidably adjacent to itself -- a
|
||||
# plugin holding most of the slots has to be. Schedule it as it is
|
||||
# rather than refuse.
|
||||
return best if best is not None else schedule
|
||||
|
||||
def _prefetch_content(self, count: int = 1) -> None:
|
||||
"""
|
||||
Prefetch content for upcoming plugins.
|
||||
|
||||
@@ -30,16 +30,14 @@ def success_response(
|
||||
"""
|
||||
response_data = create_success_response(data, message, metadata)
|
||||
|
||||
# Add request metadata if available
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
|
||||
# Add timing if request start time is available
|
||||
# Timing is merged into whatever the caller passed, without inventing a
|
||||
# metadata block for responses that have neither.
|
||||
enriched = dict(metadata) if metadata is not None else {}
|
||||
if hasattr(request, 'start_time'):
|
||||
metadata['response_time_ms'] = int((time.time() - request.start_time) * 1000)
|
||||
enriched['response_time_ms'] = int((time.time() - request.start_time) * 1000)
|
||||
|
||||
if metadata:
|
||||
response_data['metadata'] = metadata
|
||||
if metadata is not None or enriched:
|
||||
response_data['metadata'] = enriched
|
||||
|
||||
return jsonify(response_data)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Centralized error handling for web interface.
|
||||
Provides helpers for consistent error responses across API endpoints.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from flask import jsonify
|
||||
|
||||
@@ -16,6 +17,97 @@ from src.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Credentials that turn up inside exception text. A requests error quotes the
|
||||
# URL it failed on, and plugins that authenticate by query string put their key
|
||||
# there, so echoing an exception verbatim can hand out an API key. Redact the
|
||||
# value, keep the parameter name -- knowing *which* credential was involved is
|
||||
# part of the diagnosis.
|
||||
_REDACT_CREDENTIAL = re.compile(
|
||||
r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|'
|
||||
r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# `Authorization: <scheme> <credential>`. The scheme name is kept because it
|
||||
# says which kind of credential failed; the credential goes. Any scheme
|
||||
# matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and
|
||||
# whatever a plugin's API invents next are all credentials, and a list would
|
||||
# silently leak the ones nobody thought of. Not covered by the generic pattern
|
||||
# above, whose value part stops at whitespace and so would keep the credential
|
||||
# once a space follows the scheme.
|
||||
_REDACT_AUTH_HEADER = re.compile(
|
||||
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
|
||||
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
|
||||
r'([^\s,"\'<>}]+)', # the credential, redacted
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Credentials embedded in a URL: https://user:password@host. requests quotes
|
||||
# the full URL in its exceptions, so this is a realistic leak. The username is
|
||||
# kept -- it identifies which account failed without being the secret.
|
||||
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
|
||||
re.IGNORECASE)
|
||||
|
||||
# Long enough for an errno string with a path, short enough not to dump a
|
||||
# parser's worth of context into a JSON field.
|
||||
_MAX_DETAIL_LENGTH = 400
|
||||
|
||||
|
||||
def describe_exception(exc: BaseException,
|
||||
max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
||||
"""
|
||||
One-line, safe-to-return description of an exception.
|
||||
|
||||
The generic "an error occurred; see logs for details" tells a user nothing
|
||||
and, when the failure is bad enough, the logs are unreachable too: a device
|
||||
whose storage was failing returned that message from every endpoint
|
||||
*including* the log viewer, because journalctl could not be executed. The
|
||||
underlying `[Errno 5] Input/output error` named the fault immediately.
|
||||
|
||||
Returns "TypeName: message", credentials redacted and length capped. The
|
||||
type alone is worth carrying -- a bare PermissionError says more than any
|
||||
generic sentence.
|
||||
|
||||
Args:
|
||||
exc: The exception to describe
|
||||
max_length: Truncate beyond this many characters
|
||||
|
||||
Returns:
|
||||
A single-line description, never empty
|
||||
"""
|
||||
message = str(exc).strip()
|
||||
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
||||
return redact_text(text, max_length)
|
||||
|
||||
|
||||
def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
||||
"""Make arbitrary text safe to hand back over HTTP.
|
||||
|
||||
Split out of describe_exception because exceptions are not the only thing
|
||||
worth returning: a subprocess's stderr, or a message a helper script
|
||||
printed, is just as useful to a user and just as capable of carrying a
|
||||
token or a password in it.
|
||||
|
||||
Args:
|
||||
text: The text to redact
|
||||
max_length: Truncate beyond this many characters
|
||||
|
||||
Returns:
|
||||
A single line, credentials replaced, length capped.
|
||||
"""
|
||||
text = text or ''
|
||||
# Order matters: the URL and header forms are more specific than the
|
||||
# generic key=value pattern, which would otherwise chew the scheme.
|
||||
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
||||
text = _REDACT_AUTH_HEADER.sub(r'\1<redacted>', text)
|
||||
text = _REDACT_CREDENTIAL.sub(r'\1<redacted>', text)
|
||||
# Collapse newlines/tabs so the detail stays one line in a JSON field.
|
||||
text = ' '.join(text.split())
|
||||
if len(text) > max_length:
|
||||
text = text[:max_length - 1].rstrip() + '…'
|
||||
return text
|
||||
|
||||
|
||||
def create_error_response(
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
@@ -69,13 +161,16 @@ def create_success_response(
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
# All three use `is not None` rather than truthiness: "" and {} are
|
||||
# values a caller chose to send, and dropping them silently would make
|
||||
# the response shape depend on the data.
|
||||
if data is not None:
|
||||
response["data"] = data
|
||||
|
||||
if message:
|
||||
if message is not None:
|
||||
response["message"] = message
|
||||
|
||||
if metadata:
|
||||
if metadata is not None:
|
||||
response["metadata"] = metadata
|
||||
|
||||
return response
|
||||
|
||||
@@ -89,7 +89,11 @@ class WebInterfaceError:
|
||||
self.category = category or self._infer_category(error_code)
|
||||
self.details = details
|
||||
self.context = context or {}
|
||||
self.suggested_fixes = suggested_fixes or self._get_default_suggestions(error_code)
|
||||
# `is None`, not truthiness: an explicit [] means "this caller has
|
||||
# no suggestions to offer", which the default list would override.
|
||||
self.suggested_fixes = (
|
||||
suggested_fixes if suggested_fixes is not None
|
||||
else self._get_default_suggestions(error_code))
|
||||
self.original_error = original_error
|
||||
|
||||
def _infer_category(self, error_code: ErrorCode) -> ErrorCategory:
|
||||
|
||||
@@ -5,7 +5,7 @@ Provides functions for identifying, masking, separating, and filtering
|
||||
secret fields in plugin configurations based on JSON Schema x-secret markers.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Set, Tuple
|
||||
from typing import Any, Dict, Optional, Set, Tuple
|
||||
|
||||
|
||||
def find_secret_fields(properties: Dict[str, Any], prefix: str = '') -> Set[str]:
|
||||
@@ -143,6 +143,12 @@ def mask_secret_fields(config: Dict[str, Any], schema_properties: Dict[str, Any]
|
||||
return result
|
||||
|
||||
|
||||
#: What a masked secret looks like on the wire. Named because the write path
|
||||
#: has to recognise it coming back: a client that renders the mask and posts
|
||||
#: it unchanged must not store the mask as if it were the secret.
|
||||
SECRET_MASK = '\u2022' * 8
|
||||
|
||||
|
||||
def mask_all_secret_values(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Blanket-mask every non-empty value in a secrets config dict.
|
||||
|
||||
@@ -156,15 +162,25 @@ def mask_all_secret_values(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
Returns:
|
||||
A copy with all real values replaced by ``'••••••••'``.
|
||||
"""
|
||||
masked: Dict[str, Any] = {}
|
||||
for k, v in config.items():
|
||||
if isinstance(v, dict):
|
||||
masked[k] = mask_all_secret_values(v)
|
||||
elif v not in (None, '') and not (isinstance(v, str) and v.startswith('YOUR_')):
|
||||
masked[k] = '••••••••'
|
||||
else:
|
||||
masked[k] = v
|
||||
return masked
|
||||
return {k: _mask_value(v) for k, v in config.items()}
|
||||
|
||||
|
||||
def _mask_value(value: Any) -> Any:
|
||||
"""Mask one value, recursing through dicts and lists.
|
||||
|
||||
A list used to be masked as though it were a scalar, so
|
||||
``accounts: [{"name": "a", "token": "..."}]`` came back as a single
|
||||
``'••••••••'``. Nothing leaked, but the caller could no longer see how
|
||||
many entries there were or any of their non-secret fields, and the raw
|
||||
editor was shown a string where the file holds an array.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return {k: _mask_value(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_mask_value(item) for item in value]
|
||||
if value in (None, '') or (isinstance(value, str) and value.startswith('YOUR_')):
|
||||
return value
|
||||
return SECRET_MASK
|
||||
|
||||
|
||||
def remove_empty_secrets(secrets: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -186,6 +202,133 @@ def remove_empty_secrets(secrets: Dict[str, Any]) -> Dict[str, Any]:
|
||||
nested = remove_empty_secrets(v)
|
||||
if nested:
|
||||
result[k] = nested
|
||||
elif isinstance(v, list):
|
||||
# Lists used to fall through to the scalar branch below and be
|
||||
# kept verbatim, blanks and all. Because lists merge by
|
||||
# *replacement*, saving any unrelated setting then wrote
|
||||
# [{"token": ""}, ...] straight over the stored list and
|
||||
# destroyed every credential in it.
|
||||
pruned = _prune_secret_list(v)
|
||||
if pruned is not None:
|
||||
result[k] = pruned
|
||||
elif v is not None and not (isinstance(v, str) and v.strip() == ''):
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
|
||||
def _prune_secret_list(items: list) -> Optional[list]:
|
||||
"""Strip blanks from inside a list of secrets, preserving every index.
|
||||
|
||||
The rest of the system treats a secrets list as *parallel* to the regular
|
||||
one -- ``sec[i]`` holds the secret fields of item ``i``, and ``{}`` means
|
||||
"item i has none" (see ConfigManager._strip_secrets_recursive). So an
|
||||
emptied dict item stays ``{}``: putting ``None`` there makes that list stop
|
||||
looking parallel, and the stripper then drops the whole key from the main
|
||||
config, taking the non-secret fields with it.
|
||||
|
||||
A blank *scalar* becomes ``None``, meaning "no update at this index" --
|
||||
:func:`merge_secrets` substitutes whatever is stored there. Returns
|
||||
``None`` when nothing in the list carries a real value, so the caller drops
|
||||
the key and leaves the stored list untouched.
|
||||
"""
|
||||
pruned: list = []
|
||||
has_real_value = False
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
kept = remove_empty_secrets(item)
|
||||
pruned.append(kept)
|
||||
has_real_value = has_real_value or bool(kept)
|
||||
elif isinstance(item, list):
|
||||
sub = _prune_secret_list(item)
|
||||
pruned.append(sub if sub is not None else [])
|
||||
has_real_value = has_real_value or sub is not None
|
||||
elif item is not None and not (isinstance(item, str) and item.strip() == ''):
|
||||
pruned.append(item)
|
||||
has_real_value = True
|
||||
else:
|
||||
pruned.append(None)
|
||||
return pruned if has_real_value else None
|
||||
|
||||
|
||||
def merge_secrets(stored: Any, incoming: Any) -> Any:
|
||||
"""Merge submitted secrets over stored ones, element-wise inside lists.
|
||||
|
||||
``deep_merge`` replaces a list wholesale. For secrets that is destructive:
|
||||
an incoming list that carries a real value for one entry and ``None`` for
|
||||
the rest would drop the stored credentials of every other entry. Here a
|
||||
list merges by index, and ``None`` means "keep what is stored".
|
||||
|
||||
Entries are matched by *position*, which is what the config form gives us
|
||||
-- there is no schema-declared identity to key on, and it is the same
|
||||
contract ConfigManager._strip_secrets_recursive already relies on. The
|
||||
incoming list's length wins, so deleting an item deletes its secrets;
|
||||
an item the client left blank keeps whatever is stored at that index.
|
||||
"""
|
||||
if isinstance(stored, dict) and isinstance(incoming, dict):
|
||||
merged = dict(stored)
|
||||
for key, value in incoming.items():
|
||||
merged[key] = (merge_secrets(stored[key], value)
|
||||
if key in stored else value)
|
||||
return merged
|
||||
if isinstance(stored, list) and isinstance(incoming, list):
|
||||
# The incoming list sets the length -- the regular config's list is
|
||||
# authoritative about how many items exist, and this one runs parallel
|
||||
# to it. Removing an entry must therefore remove its secrets too.
|
||||
merged_list = []
|
||||
for index, item in enumerate(incoming):
|
||||
stored_item = stored[index] if index < len(stored) else None
|
||||
merged_list.append(stored_item if item is None
|
||||
else merge_secrets(stored_item, item))
|
||||
return merged_list
|
||||
if incoming is None:
|
||||
return stored
|
||||
return incoming
|
||||
|
||||
|
||||
def strip_masked_values(secrets: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Remove values a client echoed back rather than changed.
|
||||
|
||||
The counterpart to :func:`mask_all_secret_values`. A client that GETs the
|
||||
masked secrets, edits one field and POSTs the whole object back is sending
|
||||
``SECRET_MASK`` for every field it did not touch. Storing those would
|
||||
replace each untouched credential with eight bullet characters.
|
||||
|
||||
Drops the mask and, like :func:`remove_empty_secrets`, blank values -- so
|
||||
the caller can merge the result onto what is already stored and have
|
||||
"unchanged" mean unchanged. Empty nested dicts are pruned.
|
||||
"""
|
||||
result: Dict[str, Any] = {}
|
||||
for k, v in secrets.items():
|
||||
if isinstance(v, dict):
|
||||
nested = strip_masked_values(v)
|
||||
if nested:
|
||||
result[k] = nested
|
||||
elif isinstance(v, list):
|
||||
# A list is merged by replacement, not element by element -- there
|
||||
# is no identity to match entries on -- so a list that still holds
|
||||
# a mask cannot be merged safely: keeping it would store bullets,
|
||||
# and keeping the submitted entries alone would drop whichever the
|
||||
# client did not send back. Dropping the key leaves the stored
|
||||
# list untouched, which is what an untouched list should do.
|
||||
#
|
||||
# The consequence, deliberately: editing one secret inside a list
|
||||
# through this endpoint requires sending real values for all of
|
||||
# them. Sending some masks leaves the whole list as it was.
|
||||
if not _contains_mask(v):
|
||||
result[k] = v
|
||||
elif v is None:
|
||||
continue
|
||||
elif isinstance(v, str) and (v.strip() == '' or v == SECRET_MASK):
|
||||
continue
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
|
||||
def _contains_mask(value: Any) -> bool:
|
||||
"""True when a mask sentinel survives anywhere inside ``value``."""
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_mask(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_mask(item) for item in value)
|
||||
return value == SECRET_MASK
|
||||
|
||||
@@ -43,10 +43,15 @@ def validate_image_url(url: str) -> Tuple[bool, Optional[str]]:
|
||||
if any(handler in url_lower for handler in ['onerror=', 'onload=', 'onclick=']):
|
||||
return False, "Event handlers not allowed in URLs"
|
||||
|
||||
# Reject directory traversal anywhere, not only in relative paths:
|
||||
# http://host/../secret is as much a traversal attempt as /../secret.
|
||||
if '..' in url:
|
||||
return False, "Invalid path: directory traversal not allowed"
|
||||
|
||||
# Allow relative paths starting with /
|
||||
if url.startswith('/'):
|
||||
# Validate it's a safe relative path (no directory traversal)
|
||||
if '..' in url or url.startswith('//'):
|
||||
# // would be a protocol-relative URL, not a local path
|
||||
if url.startswith('//'):
|
||||
return False, "Invalid relative path"
|
||||
return True, None
|
||||
|
||||
@@ -104,10 +109,11 @@ def validate_file_upload(filename: str, max_size_mb: int = 10,
|
||||
if '..' in filename or '/' in filename or '\\' in filename:
|
||||
return False, "Filename contains invalid characters"
|
||||
|
||||
# Check extension if specified
|
||||
# Check extension if specified. Both sides are lowercased: the caller's
|
||||
# list is as likely to hold '.TTF' as the filename is.
|
||||
if allowed_extensions:
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
if file_ext not in allowed_extensions:
|
||||
if file_ext not in [ext.lower() for ext in allowed_extensions]:
|
||||
return False, f"File extension must be one of: {', '.join(allowed_extensions)}"
|
||||
|
||||
return True, None
|
||||
@@ -147,7 +153,8 @@ def validate_numeric_range(value: float, min_val: Optional[float] = None,
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not isinstance(value, (int, float)):
|
||||
# bool is an int subclass, so True would otherwise validate as 1.
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
return False, "Value must be a number"
|
||||
|
||||
if min_val is not None and value < min_val:
|
||||
@@ -183,7 +190,15 @@ def validate_string_length(text: str, min_length: Optional[int] = None,
|
||||
|
||||
def sanitize_plugin_config(config: dict) -> dict:
|
||||
"""
|
||||
Sanitize plugin configuration input to prevent injection.
|
||||
Restrict a plugin config to safe key names and value types.
|
||||
|
||||
Drops keys that are not plain identifiers and values that are not
|
||||
JSON-ish scalars, lists, or dicts, recursing into the latter two.
|
||||
|
||||
String values are returned **unescaped**: output escaping is the
|
||||
template layer's job, and escaping here would store the escaped form
|
||||
in config.json. Do not read this function as XSS protection for
|
||||
rendered output.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
|
||||
@@ -10,8 +10,8 @@ WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/wifi_monitor_daemon.py --interval 30
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=ledmatrix-wifi-monitor
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -8,9 +8,38 @@ Type=simple
|
||||
User=root
|
||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
# glibc gives each allocating thread its own malloc arena, up to 8 x CPU count,
|
||||
# and an arena that has grown is never handed back to the OS. This process runs
|
||||
# 9 threads on a 3-core Pi, so the ceiling is 24 arenas -- and a rig measured at
|
||||
# 1030 MB resident held 23 large anonymous mappings on 64 MB-aligned addresses,
|
||||
# 920 MB of them, while the live data it was actually holding (widest scroll
|
||||
# strip seen: 35,746 x 64) accounts for roughly 15 MB. That gap is arena bloat,
|
||||
# not leaked objects: RSS was flat across repeated sampling, not climbing.
|
||||
#
|
||||
# Capping the arenas trades a little allocator concurrency for a large amount of
|
||||
# resident memory on a device that has neither to spare. 2 is the usual value;
|
||||
# raise it if frame times regress.
|
||||
Environment=MALLOC_ARENA_MAX=2
|
||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/run.py
|
||||
Restart=on-failure
|
||||
# Restart=always, not on-failure: run.py exiting 0 (a clean shutdown path taken
|
||||
# for a reason that no longer applies, e.g. a config reload) would otherwise leave
|
||||
# the service stopped and the panel dark indefinitely, with systemd considering
|
||||
# that a successful outcome and never bringing it back.
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# Memory ceiling as a share of physical RAM, so one unit file suits a 512 MB
|
||||
# Pi Zero 2 W and an 8 GB Pi 5 alike. This is a backstop, not a tuning knob: it
|
||||
# turns "the board runs out of memory, stops being able to fork, and takes sshd
|
||||
# and the panel down together until someone pulls the plug" into "this one
|
||||
# service restarts".
|
||||
#
|
||||
# NOTE: Raspberry Pi firmware boots the kernel with cgroup_disable=memory, and
|
||||
# systemd accepts this setting and then silently ignores it. Verify with:
|
||||
# grep memory /sys/fs/cgroup/cgroup.controllers
|
||||
# If that prints nothing, add "cgroup_enable=memory cgroup_memory=1" to
|
||||
# /boot/firmware/cmdline.txt (all on line 1) and reboot. first_time_install.sh
|
||||
# does this for you.
|
||||
MemoryMax=85%
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=ledmatrix
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Shared scaffolding for api_v3 blueprint tests.
|
||||
|
||||
Not a test module (the leading underscore keeps pytest from collecting
|
||||
it). It is the pytest-fixture equivalent of ``_make_client()`` in
|
||||
test_uninstall_and_reconcile_endpoint.py, which is unittest-style and
|
||||
requires ``self.addCleanup``.
|
||||
|
||||
The api_v3 blueprint keeps its managers as attributes on a module-level
|
||||
singleton, not in Flask app state, so replacing them with mocks leaks
|
||||
into every later test that imports api_v3 unless the originals are put
|
||||
back. ``api_v3_client`` snapshots and restores them around each test.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
|
||||
# Every manager attribute the blueprint reads. Anything missing here keeps
|
||||
# whatever a previously-run test left on the singleton.
|
||||
API_V3_MANAGER_ATTRS = (
|
||||
'config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager',
|
||||
)
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
def build_app(blueprint):
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
app.config['SECRET_KEY'] = 'test'
|
||||
app.register_blueprint(blueprint, url_prefix='/api/v3')
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_v3_module():
|
||||
"""The api_v3 module with every manager replaced by a MagicMock.
|
||||
|
||||
Restores the original attributes afterwards. Tests point individual
|
||||
managers at real objects (a ConfigManager over tmp_path, say) or set
|
||||
them to None to exercise the not-initialized branches.
|
||||
"""
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
|
||||
originals = {
|
||||
name: getattr(module.api_v3, name, _SENTINEL)
|
||||
for name in API_V3_MANAGER_ATTRS
|
||||
}
|
||||
for name in API_V3_MANAGER_ATTRS:
|
||||
setattr(module.api_v3, name, MagicMock())
|
||||
# Default to the direct path; queue tests opt in explicitly.
|
||||
module.api_v3.operation_queue = None
|
||||
|
||||
yield module
|
||||
|
||||
for name, original in originals.items():
|
||||
if original is _SENTINEL:
|
||||
if hasattr(module.api_v3, name):
|
||||
try:
|
||||
delattr(module.api_v3, name)
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
setattr(module.api_v3, name, original)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_v3_client(api_v3_module):
|
||||
"""Flask test client wired to the mocked blueprint."""
|
||||
return build_app(api_v3_module.api_v3).test_client()
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "CI Fixture Plugin",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"display_duration": {
|
||||
"type": "number",
|
||||
"default": 5
|
||||
},
|
||||
"border_color": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 0, "maximum": 255},
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"default": [0, 255, 0],
|
||||
"description": "RGB color of the border rectangle."
|
||||
},
|
||||
"diagonal_color": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 0, "maximum": 255},
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"default": [255, 0, 0],
|
||||
"description": "RGB color of the diagonals."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
CI fixture plugin.
|
||||
|
||||
Exists so the plugin safety harness (test/plugins/test_plugin_matrix.py and
|
||||
the plugin-safety CI job) always has at least one real plugin to load and
|
||||
render — without it, an empty plugins/ directory turns the whole job into a
|
||||
green no-op. The render is deliberately trivial and fully deterministic:
|
||||
a border rectangle plus both diagonals, sized from the display manager's
|
||||
declared dimensions. No fonts, no network, no time dependence, so golden
|
||||
images are stable across platforms.
|
||||
"""
|
||||
|
||||
from PIL import ImageDraw
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
|
||||
class CIFixturePlugin(BasePlugin):
|
||||
"""Deterministic CI-only fixture plugin: renders a border + diagonals
|
||||
pattern sized from the display's declared dimensions. Never shipped to
|
||||
devices; exists solely so the plugin safety harness has a real plugin
|
||||
to exercise in CI."""
|
||||
|
||||
def update(self) -> None:
|
||||
"""Nothing to fetch — the render is self-contained."""
|
||||
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
self.display_manager.clear()
|
||||
width = self.display_manager.matrix.width
|
||||
height = self.display_manager.matrix.height
|
||||
border = tuple(self.config.get("border_color", [0, 255, 0]))
|
||||
diagonal = tuple(self.config.get("diagonal_color", [255, 0, 0]))
|
||||
|
||||
image = self.display_manager.image
|
||||
draw = ImageDraw.Draw(image)
|
||||
# Blank only the declared panel area, then draw edge-to-edge content:
|
||||
# the border proves the plugin reads dynamic dimensions (any overflow
|
||||
# or underfill at any size is a harness bug or a dimensions bug), the
|
||||
# diagonals make golden comparisons sensitive to size/offset drift.
|
||||
draw.rectangle([0, 0, width - 1, height - 1], fill=(0, 0, 0))
|
||||
draw.rectangle([0, 0, width - 1, height - 1], outline=border)
|
||||
draw.line([0, 0, width - 1, height - 1], fill=diagonal)
|
||||
draw.line([0, height - 1, width - 1, 0], fill=diagonal)
|
||||
self.display_manager.update_display()
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "ci-fixture-plugin",
|
||||
"name": "CI Fixture Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Bundled test fixture so the plugin safety harness always has at least one real plugin to render in CI. Draws a deterministic border + diagonals pattern at any panel size. Not installable from the store and never shipped to devices.",
|
||||
"author": "LEDMatrix",
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "CIFixturePlugin",
|
||||
"display_modes": ["ci-fixture"],
|
||||
"update_interval": 3600,
|
||||
"min_ledmatrix_version": "2.0.0",
|
||||
"compatible_versions": [">=2.0.0"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# No dependencies — the fixture must load in any environment.
|
||||
#
|
||||
# Pillow is deliberately NOT pinned here even though manager.py imports
|
||||
# PIL: it is a core LEDMatrix dependency (see the repo-root
|
||||
# requirements.txt), so it is always present wherever the harness runs,
|
||||
# and the harness loads plugins with install_deps=False anyway. Pinning
|
||||
# it here would only invite a needless pip install during test runs.
|
||||
|
After Width: | Height: | Size: 359 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 849 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 319 B |
|
After Width: | Height: | Size: 466 B |
|
After Width: | Height: | Size: 454 B |
@@ -23,9 +23,17 @@ os.environ['EMULATOR'] = 'true'
|
||||
def plugins_dir() -> Path:
|
||||
"""Get the plugins directory path.
|
||||
|
||||
Checks plugins/ first, then falls back to plugin-repos/
|
||||
for monorepo development environments.
|
||||
Honors LEDMATRIX_PLUGINS_DIR (first entry) when set — the same override
|
||||
test_plugin_matrix.py uses, so CI can point every plugin suite at the
|
||||
bundled fixture plugins. Otherwise checks plugins/ first, then falls
|
||||
back to plugin-repos/ for monorepo development environments.
|
||||
"""
|
||||
env = os.environ.get('LEDMATRIX_PLUGINS_DIR')
|
||||
if env:
|
||||
first = env.split(os.pathsep)[0]
|
||||
if first:
|
||||
return Path(first)
|
||||
|
||||
plugins_path = project_root / 'plugins'
|
||||
plugin_repos_path = project_root / 'plugin-repos'
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
Integration tests for basketball-scoreboard plugin.
|
||||
|
||||
Requires the real plugin to be installed (plugins/ or plugin-repos/,
|
||||
or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it,
|
||||
every test here skips by design. CI covers plugin safety with the
|
||||
bundled fixture plugin via test_plugin_matrix.py instead.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
Integration tests for calendar plugin.
|
||||
|
||||
Requires the real plugin to be installed (plugins/ or plugin-repos/,
|
||||
or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it,
|
||||
every test here skips by design. CI covers plugin safety with the
|
||||
bundled fixture plugin via test_plugin_matrix.py instead.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
Integration tests for clock-simple plugin.
|
||||
|
||||
Requires the real plugin to be installed (plugins/ or plugin-repos/,
|
||||
or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it,
|
||||
every test here skips by design. CI covers plugin safety with the
|
||||
bundled fixture plugin via test_plugin_matrix.py instead.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
Integration tests for odds-ticker plugin.
|
||||
|
||||
Requires the real plugin to be installed (plugins/ or plugin-repos/,
|
||||
or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it,
|
||||
every test here skips by design. CI covers plugin safety with the
|
||||
bundled fixture plugin via test_plugin_matrix.py instead.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
Integration tests for soccer-scoreboard plugin.
|
||||
|
||||
Requires the real plugin to be installed (plugins/ or plugin-repos/,
|
||||
or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it,
|
||||
every test here skips by design. CI covers plugin safety with the
|
||||
bundled fixture plugin via test_plugin_matrix.py instead.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
Integration tests for text-display plugin.
|
||||
|
||||
Requires the real plugin to be installed (plugins/ or plugin-repos/,
|
||||
or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it,
|
||||
every test here skips by design. CI covers plugin safety with the
|
||||
bundled fixture plugin via test_plugin_matrix.py instead.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Tests for src/common/api_helper.py (APIHelper).
|
||||
|
||||
Covers rate limiting, cached GETs, ESPN URL/cache-key construction,
|
||||
session header defaults and per-call merging, the retry adapter, and the
|
||||
fixed clear_cache() behavior (real CacheManager surface: clear_cache /
|
||||
delete / list_cache_files, with safe no-ops elsewhere).
|
||||
|
||||
No real network: helper.session.get/post are always replaced with mocks.
|
||||
"""
|
||||
|
||||
import types
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from freezegun import freeze_time
|
||||
|
||||
import src.common.api_helper as api_helper_module
|
||||
from src.common.api_helper import APIHelper
|
||||
|
||||
|
||||
def _make_response(payload):
|
||||
response = MagicMock()
|
||||
response.json.return_value = payload
|
||||
response.raise_for_status.return_value = None
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache():
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
return cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def helper(cache):
|
||||
helper = APIHelper(cache_manager=cache)
|
||||
# Default min interval is 1.0s and would really sleep between requests.
|
||||
helper.set_rate_limit(0)
|
||||
return helper
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRateLimiting:
|
||||
def test_sleeps_for_remaining_interval(self, helper, monkeypatch):
|
||||
fake_time = MagicMock()
|
||||
fake_time.time.side_effect = [102.0, 105.0]
|
||||
monkeypatch.setattr(api_helper_module, 'time', fake_time)
|
||||
|
||||
helper.set_rate_limit(5)
|
||||
helper._last_request_time = 100.0
|
||||
helper._enforce_rate_limit()
|
||||
|
||||
# 2s elapsed of a 5s interval -> sleep the remaining 3s.
|
||||
fake_time.sleep.assert_called_once()
|
||||
assert fake_time.sleep.call_args[0][0] == pytest.approx(3.0)
|
||||
assert helper._last_request_time == 105.0
|
||||
|
||||
def test_no_sleep_when_interval_elapsed(self, helper, monkeypatch):
|
||||
fake_time = MagicMock()
|
||||
fake_time.time.side_effect = [200.0, 201.0]
|
||||
monkeypatch.setattr(api_helper_module, 'time', fake_time)
|
||||
|
||||
helper.set_rate_limit(5)
|
||||
helper._last_request_time = 100.0
|
||||
helper._enforce_rate_limit()
|
||||
|
||||
fake_time.sleep.assert_not_called()
|
||||
assert helper._last_request_time == 201.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGet:
|
||||
def test_cache_hit_skips_request_and_rate_limit(self, helper, cache):
|
||||
cache.get.return_value = {'cached': True}
|
||||
helper.session.get = Mock()
|
||||
rate_spy = Mock()
|
||||
helper._enforce_rate_limit = rate_spy
|
||||
|
||||
result = helper.get('https://example.com/api', cache_key='k')
|
||||
|
||||
assert result == {'cached': True}
|
||||
helper.session.get.assert_not_called()
|
||||
rate_spy.assert_not_called()
|
||||
|
||||
def test_cache_miss_fetches_and_caches_without_ttl(self, helper, cache):
|
||||
cache.get.return_value = None
|
||||
helper.session.get = Mock(return_value=_make_response({'a': 1}))
|
||||
|
||||
result = helper.get('https://example.com/api', cache_key='k',
|
||||
cache_ttl=999)
|
||||
|
||||
assert result == {'a': 1}
|
||||
# Pin the ttl-dropped contract: CacheManager.set is called with
|
||||
# (key, data) only — the cache_ttl argument is discarded.
|
||||
cache.set.assert_called_once_with('k', {'a': 1})
|
||||
|
||||
def test_request_exception_returns_none_and_caches_nothing(
|
||||
self, helper, cache):
|
||||
helper.session.get = Mock(
|
||||
side_effect=requests.exceptions.RequestException('boom'))
|
||||
|
||||
result = helper.get('https://example.com/api', cache_key='k')
|
||||
|
||||
assert result is None
|
||||
cache.set.assert_not_called()
|
||||
|
||||
def test_timeout_zero_falls_back_to_default(self, helper):
|
||||
# Quirk pin: `timeout or self.default_timeout` treats an explicit
|
||||
# timeout=0 as falsy, so the default (30) is used instead.
|
||||
helper.session.get = Mock(return_value=_make_response({}))
|
||||
|
||||
helper.get('https://example.com/api', timeout=0)
|
||||
|
||||
assert helper.session.get.call_args.kwargs['timeout'] == 30
|
||||
|
||||
def test_per_call_headers_merge_over_session_headers(self, helper):
|
||||
helper.session.get = Mock(return_value=_make_response({}))
|
||||
|
||||
helper.get('https://example.com/api', headers={'X-Custom': 'yes'})
|
||||
|
||||
sent = helper.session.get.call_args.kwargs['headers']
|
||||
# Merged, not replaced: session defaults survive alongside the
|
||||
# per-call header.
|
||||
assert sent['X-Custom'] == 'yes'
|
||||
assert sent['User-Agent'] == (
|
||||
'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)')
|
||||
assert sent['Accept'] == 'application/json'
|
||||
# The session's own headers are not polluted by the per-call ones.
|
||||
assert 'X-Custom' not in helper.session.headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ESPN helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEspnHelpers:
|
||||
@freeze_time('2026-08-07')
|
||||
def test_fetch_espn_scoreboard_url_params_and_cache_key(self, helper):
|
||||
helper.get = Mock(return_value={'ok': 1})
|
||||
|
||||
result = helper.fetch_espn_scoreboard('football', 'nfl')
|
||||
|
||||
assert result == {'ok': 1}
|
||||
helper.get.assert_called_once_with(
|
||||
'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard',
|
||||
params={'dates': '20260807', 'limit': 1000},
|
||||
cache_key='espn_football_nfl_20260807',
|
||||
cache_ttl=300,
|
||||
)
|
||||
|
||||
def test_fetch_espn_scoreboard_explicit_date(self, helper):
|
||||
helper.get = Mock(return_value=None)
|
||||
|
||||
helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115')
|
||||
|
||||
kwargs = helper.get.call_args.kwargs
|
||||
assert kwargs['params'] == {'dates': '20250115', 'limit': 1000}
|
||||
assert kwargs['cache_key'] == 'espn_basketball_nba_20250115'
|
||||
|
||||
def test_fetch_espn_standings_url_and_cache_key(self, helper):
|
||||
helper.get = Mock(return_value={'ok': 1})
|
||||
|
||||
helper.fetch_espn_standings('football', 'nfl')
|
||||
|
||||
helper.get.assert_called_once_with(
|
||||
'https://site.api.espn.com/apis/site/v2/sports/football/nfl/standings',
|
||||
cache_key='espn_standings_football_nfl',
|
||||
cache_ttl=3600,
|
||||
)
|
||||
|
||||
def test_fetch_espn_rankings_url_and_cache_key(self, helper):
|
||||
helper.get = Mock(return_value={'ok': 1})
|
||||
|
||||
helper.fetch_espn_rankings('football', 'college-football')
|
||||
|
||||
helper.get.assert_called_once_with(
|
||||
'https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings',
|
||||
cache_key='espn_rankings_football_college-football',
|
||||
cache_ttl=3600,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSessionSetup:
|
||||
def test_user_agent_exact(self, helper):
|
||||
# Regression guard: ESPN began 403ing other user agents; this exact
|
||||
# string must be sent on every request.
|
||||
assert helper.session.headers['User-Agent'] == (
|
||||
'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)')
|
||||
|
||||
def test_retry_adapter_configuration(self):
|
||||
helper = APIHelper(cache_manager=None, max_retries=7)
|
||||
|
||||
retries = helper.session.get_adapter('https://x').max_retries
|
||||
assert retries.total == 7
|
||||
assert {429, 500, 502, 503, 504} <= set(retries.status_forcelist)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clear_cache (fixed behavior: real CacheManager surface)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestClearCache:
|
||||
def test_no_pattern_uses_clear_cache_method(self):
|
||||
manager = types.SimpleNamespace(clear_cache=Mock())
|
||||
helper = APIHelper(cache_manager=manager)
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
helper.clear_cache()
|
||||
|
||||
manager.clear_cache.assert_called_once_with()
|
||||
|
||||
def test_no_pattern_falls_back_to_clear(self):
|
||||
manager = types.SimpleNamespace(clear=Mock())
|
||||
helper = APIHelper(cache_manager=manager)
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
helper.clear_cache()
|
||||
|
||||
manager.clear.assert_called_once_with()
|
||||
|
||||
def test_no_pattern_manager_without_any_clear_is_noop(self):
|
||||
helper = APIHelper(cache_manager=object())
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
helper.clear_cache() # must not raise
|
||||
|
||||
def test_pattern_deletes_only_matching_keys(self):
|
||||
manager = types.SimpleNamespace(
|
||||
list_cache_files=Mock(return_value=[
|
||||
{'key': 'espn_nfl_x'},
|
||||
{'key': 'other'},
|
||||
]),
|
||||
delete=Mock(),
|
||||
)
|
||||
helper = APIHelper(cache_manager=manager)
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
helper.clear_cache(pattern='espn')
|
||||
|
||||
manager.delete.assert_called_once_with('espn_nfl_x')
|
||||
|
||||
def test_pattern_manager_without_list_cache_files_is_noop(self):
|
||||
helper = APIHelper(cache_manager=object())
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
helper.clear_cache(pattern='espn') # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No cache manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNoCacheManager:
|
||||
def test_all_cache_operations_safe_without_manager(self):
|
||||
helper = APIHelper(cache_manager=None)
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
assert helper.get_cache('k') is None
|
||||
assert helper._get_from_cache('k') is None
|
||||
assert helper.set_cache('k', {'a': 1}) is None
|
||||
assert helper.clear_cache() is None
|
||||
assert helper.clear_cache(pattern='espn') is None
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Endpoint tests for POST /plugins/calendar/upload-credentials.
|
||||
|
||||
The endpoint takes an uploaded Google OAuth credentials file, writes it
|
||||
into the calendar plugin's directory as credentials.json at mode 0600, and
|
||||
copies any previous file aside first. It had no tests.
|
||||
|
||||
Regression coverage for two fixed bugs:
|
||||
- The OAuth-shape check sat inside `except Exception: pass`, so a valid
|
||||
JSON document that is not an object — a bare `42`, a list, a string —
|
||||
raised TypeError on the membership test, was swallowed, and got saved
|
||||
as credentials.json anyway.
|
||||
- Each overwrite created a timestamped backup and nothing ever removed
|
||||
them, so every re-upload left another complete copy of the user's OAuth
|
||||
client credentials in the plugin directory, indefinitely.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
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/plugins/calendar/upload-credentials"
|
||||
|
||||
VALID_CREDENTIALS = {
|
||||
"installed": {
|
||||
"client_id": "abc.apps.googleusercontent.com",
|
||||
"client_secret": "shh",
|
||||
"redirect_uris": ["http://localhost"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(tmp_path, api_v3_module):
|
||||
directory = tmp_path / "plugins" / "calendar"
|
||||
directory.mkdir(parents=True)
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory)
|
||||
return directory
|
||||
|
||||
|
||||
def upload(client, content, filename="credentials.json"):
|
||||
# bytes are sent verbatim (to exercise malformed input); anything else
|
||||
# is serialized, so None becomes the JSON literal null rather than an
|
||||
# empty body.
|
||||
payload = content if isinstance(content, bytes) else json.dumps(content).encode()
|
||||
return client.post(
|
||||
URL,
|
||||
data={"file": (io.BytesIO(payload), filename)},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
|
||||
def backups(plugin_dir):
|
||||
return sorted(plugin_dir.glob("credentials.json.backup.*"))
|
||||
|
||||
|
||||
class TestRequestValidation:
|
||||
def test_no_file_part_is_a_400(self, api_v3_client, plugin_dir):
|
||||
response = api_v3_client.post(URL, data={}, content_type="multipart/form-data")
|
||||
assert response.status_code == 400
|
||||
assert "No file provided" in response.get_json()["message"]
|
||||
|
||||
def test_empty_filename_is_a_400(self, api_v3_client, plugin_dir):
|
||||
response = upload(api_v3_client, VALID_CREDENTIALS, filename="")
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize("filename", ["creds.txt", "creds.pem", "creds"])
|
||||
def test_non_json_extension_is_a_400(self, api_v3_client, plugin_dir, filename):
|
||||
response = upload(api_v3_client, VALID_CREDENTIALS, filename=filename)
|
||||
assert response.status_code == 400
|
||||
assert "JSON file" in response.get_json()["message"]
|
||||
|
||||
def test_uppercase_json_extension_accepted(self, api_v3_client, plugin_dir):
|
||||
assert upload(api_v3_client, VALID_CREDENTIALS,
|
||||
filename="CREDENTIALS.JSON").status_code == 200
|
||||
|
||||
def test_oversized_file_is_a_400(self, api_v3_client, plugin_dir):
|
||||
response = upload(api_v3_client, b"x" * (1024 * 1024 + 1))
|
||||
assert response.status_code == 400
|
||||
assert "1MB" in response.get_json()["message"]
|
||||
assert not (plugin_dir / "credentials.json").exists()
|
||||
|
||||
def test_invalid_json_is_a_400(self, api_v3_client, plugin_dir):
|
||||
response = upload(api_v3_client, b"{not json")
|
||||
assert response.status_code == 400
|
||||
assert "not valid JSON" in response.get_json()["message"]
|
||||
assert not (plugin_dir / "credentials.json").exists()
|
||||
|
||||
def test_missing_plugin_directory_is_a_404(self, api_v3_client, api_v3_module, tmp_path):
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(
|
||||
tmp_path / "not-installed")
|
||||
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 404
|
||||
|
||||
|
||||
class TestOAuthShapeValidation:
|
||||
def test_installed_key_accepted(self, api_v3_client, plugin_dir):
|
||||
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 200
|
||||
|
||||
def test_web_key_accepted(self, api_v3_client, plugin_dir):
|
||||
assert upload(api_v3_client, {"web": {"client_id": "x"}}).status_code == 200
|
||||
|
||||
def test_object_without_oauth_keys_is_a_400(self, api_v3_client, plugin_dir):
|
||||
response = upload(api_v3_client, {"something": "else"})
|
||||
assert response.status_code == 400
|
||||
assert "valid Google OAuth" in response.get_json()["message"]
|
||||
assert not (plugin_dir / "credentials.json").exists()
|
||||
|
||||
@pytest.mark.parametrize("content", [42, "a string", [1, 2, 3], True, None])
|
||||
def test_valid_json_that_is_not_an_object_is_rejected(
|
||||
self, api_v3_client, plugin_dir, content):
|
||||
# Regression: `'installed' not in 42` raises TypeError, which the
|
||||
# bare `except Exception: pass` swallowed — the file was then saved
|
||||
# as credentials.json despite being unusable as credentials.
|
||||
response = upload(api_v3_client, content)
|
||||
assert response.status_code == 400
|
||||
assert "valid Google OAuth" in response.get_json()["message"]
|
||||
assert not (plugin_dir / "credentials.json").exists()
|
||||
|
||||
|
||||
class TestSaving:
|
||||
def test_file_written_with_contents_intact(self, api_v3_client, plugin_dir):
|
||||
response = upload(api_v3_client, VALID_CREDENTIALS)
|
||||
assert response.status_code == 200
|
||||
saved = json.loads((plugin_dir / "credentials.json").read_text())
|
||||
assert saved == VALID_CREDENTIALS
|
||||
|
||||
def test_response_reports_the_path(self, api_v3_client, plugin_dir):
|
||||
body = upload(api_v3_client, VALID_CREDENTIALS).get_json()
|
||||
assert body["path"].endswith("credentials.json")
|
||||
|
||||
def test_permissions_are_owner_only(self, api_v3_client, plugin_dir):
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
mode = stat.S_IMODE((plugin_dir / "credentials.json").stat().st_mode)
|
||||
assert mode == 0o600
|
||||
|
||||
def test_first_upload_creates_no_backup(self, api_v3_client, plugin_dir):
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
assert backups(plugin_dir) == []
|
||||
|
||||
def test_overwrite_backs_up_the_previous_file(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"old": 1}}))
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
assert len(backups(plugin_dir)) == 1
|
||||
assert json.loads(backups(plugin_dir)[0].read_text()) == {"installed": {"old": 1}}
|
||||
assert json.loads((plugin_dir / "credentials.json").read_text()) == VALID_CREDENTIALS
|
||||
|
||||
|
||||
class TestBackupPruning:
|
||||
def _seed(self, plugin_dir, count):
|
||||
"""Create `count` backups with distinct, increasing mtimes."""
|
||||
now = int(time.time())
|
||||
for i in range(count):
|
||||
path = plugin_dir / f"credentials.json.backup.{now - (count - i) * 10}"
|
||||
path.write_text(json.dumps({"installed": {"gen": i}}))
|
||||
os.utime(path, (now - (count - i) * 10, now - (count - i) * 10))
|
||||
|
||||
def test_old_backups_are_pruned(self, api_v3_client, plugin_dir):
|
||||
# Regression: nothing ever removed these, so a plugin directory
|
||||
# accumulated one full copy of the user's OAuth credentials per
|
||||
# re-upload, forever.
|
||||
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
|
||||
self._seed(plugin_dir, 7)
|
||||
assert len(backups(plugin_dir)) == 7
|
||||
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
assert len(backups(plugin_dir)) == 5
|
||||
|
||||
def test_the_newest_backups_are_the_ones_kept(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
|
||||
self._seed(plugin_dir, 7)
|
||||
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
remaining = backups(plugin_dir)
|
||||
# The just-created backup (of "cur") plus the four newest seeds.
|
||||
contents = [json.loads(p.read_text()) for p in remaining]
|
||||
assert {"installed": {"cur": 1}} in contents
|
||||
assert {"installed": {"gen": 0}} not in contents # oldest seed gone
|
||||
|
||||
def test_under_the_limit_nothing_is_removed(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
|
||||
self._seed(plugin_dir, 2)
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
assert len(backups(plugin_dir)) == 3 # 2 seeded + 1 new
|
||||
|
||||
def test_repeated_uploads_stay_bounded(
|
||||
self, api_v3_client, plugin_dir, api_v3_module, monkeypatch):
|
||||
# The backup filename carries int(time.time()), so uploads inside
|
||||
# the same second all write the same name and overwrite each other.
|
||||
# Advance a fake clock a second per round — otherwise this never
|
||||
# reaches six backups and the bound holds for the wrong reason.
|
||||
clock = {"now": int(time.time())}
|
||||
monkeypatch.setattr(
|
||||
api_v3_module, "time", SimpleNamespace(time=lambda: clock["now"]))
|
||||
for i in range(10):
|
||||
clock["now"] += 1
|
||||
upload(api_v3_client, {"installed": {"round": i}})
|
||||
os.utime(plugin_dir / "credentials.json",
|
||||
(clock["now"], clock["now"]))
|
||||
remaining = backups(plugin_dir)
|
||||
assert len(remaining) == 5
|
||||
# And they are the five most recent rounds, not an arbitrary five.
|
||||
kept = sorted(int(p.name.rsplit(".", 1)[1]) for p in remaining)
|
||||
assert kept == [clock["now"] - 4 + i for i in range(5)]
|
||||
|
||||
def test_unremovable_backup_does_not_fail_the_upload(
|
||||
self, api_v3_client, plugin_dir, monkeypatch):
|
||||
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
|
||||
self._seed(plugin_dir, 7)
|
||||
|
||||
def refuse(self):
|
||||
raise OSError("read-only filesystem")
|
||||
monkeypatch.setattr(Path, "unlink", refuse)
|
||||
|
||||
# Pruning is housekeeping; failing it must not lose the upload.
|
||||
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 200
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Endpoint tests for /plugins/authenticate/spotify and .../ytm.
|
||||
|
||||
The Spotify step-2 handler writes a Python wrapper script to a temp file
|
||||
with the user's redirect URL embedded in it, then runs that file through
|
||||
subprocess. That is the most dangerous shape in the blueprint and had no
|
||||
tests: the URL is user input reaching generated source code.
|
||||
|
||||
The two endpoints are NOT symmetrical, despite the matching names. Only
|
||||
Spotify has a two-step flow, a wrapper script, and a redirect_url; YTM
|
||||
just runs its script directly.
|
||||
|
||||
Regression coverage for one fixed bug: the wrapper file was unlinked in
|
||||
the success/failure branch and again in the TimeoutExpired handler, so
|
||||
any other failure from subprocess.run — the interpreter missing, a fork
|
||||
failure, an interrupted call — left a temp file containing the user's
|
||||
redirect URL behind.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(tmp_path, api_v3_module):
|
||||
"""A plugin directory containing both auth scripts."""
|
||||
directory = tmp_path / "plugins" / "ledmatrix-music"
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "authenticate_spotify.py").write_text("print('spotify')\n")
|
||||
(directory / "authenticate_ytm.py").write_text("print('ytm')\n")
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory)
|
||||
return directory
|
||||
|
||||
|
||||
def completed(returncode=0, stdout="ok", stderr=""):
|
||||
return subprocess.CompletedProcess(
|
||||
args=["python3"], returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
class TestSpotifyPreconditions:
|
||||
URL = "/api/v3/plugins/authenticate/spotify"
|
||||
|
||||
def test_missing_plugin_directory_is_404(self, api_v3_client, api_v3_module, tmp_path):
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(
|
||||
tmp_path / "not-installed")
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 404
|
||||
assert response.get_json()["message"] == "Plugin not found"
|
||||
|
||||
def test_none_plugin_directory_is_404(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = None
|
||||
assert api_v3_client.post(self.URL, json={}).status_code == 404
|
||||
|
||||
def test_missing_auth_script_is_404(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "authenticate_spotify.py").unlink()
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 404
|
||||
assert "script not found" in response.get_json()["message"]
|
||||
|
||||
|
||||
class TestSpotifyStepTwo:
|
||||
"""redirect_url present — the wrapper-script path."""
|
||||
|
||||
URL = "/api/v3/plugins/authenticate/spotify"
|
||||
|
||||
def test_success(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed(0, "done")):
|
||||
response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["status"] == "success"
|
||||
assert body["output"] == "done"
|
||||
|
||||
def test_script_failure_is_a_400_with_combined_output(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed(1, "out", "err")):
|
||||
response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["output"] == "outerr"
|
||||
|
||||
def test_timeout_is_a_408(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run",
|
||||
side_effect=subprocess.TimeoutExpired("python3", 120)):
|
||||
response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
assert response.status_code == 408
|
||||
assert "timed out" in response.get_json()["message"]
|
||||
|
||||
def test_runs_a_list_argv_never_a_shell(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed()) as run:
|
||||
api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
args, kwargs = run.call_args
|
||||
assert isinstance(args[0], list)
|
||||
assert args[0][0] == "python3"
|
||||
assert kwargs.get("shell") in (None, False)
|
||||
|
||||
def test_timeout_is_bounded(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed()) as run:
|
||||
api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
assert run.call_args.kwargs["timeout"] == 120
|
||||
|
||||
|
||||
class TestSpotifyWrapperCleanup:
|
||||
URL = "/api/v3/plugins/authenticate/spotify"
|
||||
|
||||
def _wrapper_paths_after(self, api_v3_client, run_mock):
|
||||
"""Run the endpoint and return the wrapper path subprocess saw."""
|
||||
seen = {}
|
||||
|
||||
def capture(args, **kwargs):
|
||||
seen["path"] = args[1]
|
||||
return run_mock(args, **kwargs)
|
||||
|
||||
with patch.object(subprocess, "run", side_effect=capture):
|
||||
api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
return seen["path"]
|
||||
|
||||
def test_removed_after_success(self, api_v3_client, plugin_dir):
|
||||
path = self._wrapper_paths_after(api_v3_client, lambda *a, **kw: completed())
|
||||
assert not os.path.exists(path)
|
||||
|
||||
def test_removed_after_script_failure(self, api_v3_client, plugin_dir):
|
||||
path = self._wrapper_paths_after(
|
||||
api_v3_client, lambda *a, **kw: completed(1, "out", "err"))
|
||||
assert not os.path.exists(path)
|
||||
|
||||
def test_removed_after_timeout(self, api_v3_client, plugin_dir):
|
||||
def raise_timeout(*a, **kw):
|
||||
raise subprocess.TimeoutExpired("python3", 120)
|
||||
path = self._wrapper_paths_after(api_v3_client, raise_timeout)
|
||||
assert not os.path.exists(path)
|
||||
|
||||
def test_removed_when_subprocess_cannot_start(self, api_v3_client, plugin_dir):
|
||||
# Regression: cleanup lived in the success/failure branch and in the
|
||||
# TimeoutExpired handler only. An OSError from subprocess.run itself
|
||||
# — no interpreter, fork failure — skipped both and left the wrapper,
|
||||
# which contains the user's redirect URL, on disk.
|
||||
def raise_oserror(*a, **kw):
|
||||
raise OSError("[Errno 12] Cannot allocate memory")
|
||||
path = self._wrapper_paths_after(api_v3_client, raise_oserror)
|
||||
assert not os.path.exists(path)
|
||||
|
||||
|
||||
class TestSpotifyRedirectUrlIsNotInjectable:
|
||||
"""The wrapper embeds redirect_url into generated Python source."""
|
||||
|
||||
URL = "/api/v3/plugins/authenticate/spotify"
|
||||
|
||||
ADVERSARIAL = [
|
||||
'''http://cb/?code=x"''',
|
||||
"""http://cb/?code=x'""",
|
||||
'http://cb/?code=x\\',
|
||||
'http://cb/?code=x\nimport os; os.system("id")',
|
||||
'http://cb/?code=x"""\nimport os\n"""',
|
||||
"http://cb/?code=x'''",
|
||||
'http://cb/?code=x\\"\\n',
|
||||
'"; import os; os.system("id"); "',
|
||||
]
|
||||
|
||||
def _wrapper_source(self, api_v3_client, redirect_url):
|
||||
captured = {}
|
||||
|
||||
def capture(args, **kwargs):
|
||||
captured["source"] = Path(args[1]).read_text()
|
||||
return completed()
|
||||
|
||||
with patch.object(subprocess, "run", side_effect=capture):
|
||||
api_v3_client.post(self.URL, json={"redirect_url": redirect_url})
|
||||
return captured["source"]
|
||||
|
||||
@pytest.mark.parametrize("redirect_url", ADVERSARIAL)
|
||||
def test_wrapper_is_still_valid_python(self, api_v3_client, plugin_dir, redirect_url):
|
||||
# If escaping failed, the generated file would not parse at all.
|
||||
source = self._wrapper_source(api_v3_client, redirect_url)
|
||||
ast.parse(source)
|
||||
|
||||
@pytest.mark.parametrize("redirect_url", ADVERSARIAL)
|
||||
def test_url_survives_as_one_string_literal(
|
||||
self, api_v3_client, plugin_dir, redirect_url):
|
||||
# Stronger than "it parses": the URL must still be a single string
|
||||
# assigned to redirect_url, not code that escaped into statements.
|
||||
source = self._wrapper_source(api_v3_client, redirect_url)
|
||||
tree = ast.parse(source)
|
||||
assigned = [
|
||||
node.value.value for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Assign)
|
||||
and isinstance(node.value, ast.Constant)
|
||||
and any(getattr(t, "id", None) == "redirect_url" for t in node.targets)
|
||||
]
|
||||
assert assigned == [redirect_url.strip()]
|
||||
|
||||
def test_injected_call_does_not_become_a_statement(self, api_v3_client, plugin_dir):
|
||||
source = self._wrapper_source(
|
||||
api_v3_client, 'http://cb/\nimport os; os.system("id")')
|
||||
tree = ast.parse(source)
|
||||
imported = {
|
||||
alias.name for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Import) for alias in node.names
|
||||
}
|
||||
# The wrapper legitimately imports sys, subprocess and os; what it
|
||||
# must not gain is a *call* smuggled in through the URL.
|
||||
calls = [
|
||||
node for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "system"
|
||||
]
|
||||
assert calls == []
|
||||
|
||||
|
||||
class TestSpotifyStepOne:
|
||||
"""No redirect_url — the OAuth-URL path, which imports the script."""
|
||||
|
||||
URL = "/api/v3/plugins/authenticate/spotify"
|
||||
|
||||
def test_script_without_credentials_helper_is_an_error(
|
||||
self, api_v3_client, plugin_dir):
|
||||
# The stub script defines neither get_auth_url nor
|
||||
# load_spotify_credentials, so no URL can be produced.
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code in (400, 500)
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
def test_unusable_credentials_do_not_leak_into_the_response(
|
||||
self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "authenticate_spotify.py").write_text(
|
||||
"def load_spotify_credentials():\n"
|
||||
" return ('id-abc', 'super-secret-value', None)\n"
|
||||
)
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert "super-secret-value" not in response.get_data(as_text=True)
|
||||
|
||||
def test_script_raising_on_import_is_handled(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "authenticate_spotify.py").write_text("raise RuntimeError('boom')\n")
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
def test_bodyless_post_reaches_step_one(self, api_v3_client, plugin_dir):
|
||||
# Covered by the silent=True fix: previously a 500 from body parsing.
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code in (400, 500)
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
def test_whitespace_redirect_url_is_treated_as_absent(
|
||||
self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed()) as run:
|
||||
api_v3_client.post(self.URL, json={"redirect_url": " "})
|
||||
# Step 2 never runs, so no wrapper is executed.
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
class TestYouTubeMusic:
|
||||
"""No wrapper script and no redirect_url — deliberately not symmetric."""
|
||||
|
||||
URL = "/api/v3/plugins/authenticate/ytm"
|
||||
|
||||
def test_missing_plugin_directory_is_404(self, api_v3_client, api_v3_module, tmp_path):
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(
|
||||
tmp_path / "not-installed")
|
||||
assert api_v3_client.post(self.URL).status_code == 404
|
||||
|
||||
def test_missing_script_is_404(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "authenticate_ytm.py").unlink()
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code == 404
|
||||
assert "script not found" in response.get_json()["message"]
|
||||
|
||||
def test_success(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed(0, "authorized")):
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["output"] == "authorized"
|
||||
|
||||
def test_failure_is_a_400_with_combined_output(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed(1, "out", "err")):
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["output"] == "outerr"
|
||||
|
||||
def test_timeout_is_a_408(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run",
|
||||
side_effect=subprocess.TimeoutExpired("python3", 60)):
|
||||
assert api_v3_client.post(self.URL).status_code == 408
|
||||
|
||||
def test_runs_the_script_directly_without_a_shell(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed()) as run:
|
||||
api_v3_client.post(self.URL)
|
||||
args, kwargs = run.call_args
|
||||
assert args[0][0] == "python3"
|
||||
assert args[0][1].endswith("authenticate_ytm.py")
|
||||
assert kwargs.get("shell") in (None, False)
|
||||
assert kwargs["timeout"] == 60
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Regression tests: POST endpoints whose body is optional must accept a
|
||||
request that has no body at all.
|
||||
|
||||
Six handlers in api_v3 read their body as ``request.get_json() or {}``.
|
||||
The ``or {}`` states the intent plainly — every field is optional, so a
|
||||
bodyless POST should fall back to defaults. But ``get_json()`` without
|
||||
``silent=True`` raises ``UnsupportedMediaType`` when the request carries
|
||||
no JSON Content-Type, and it raises *before* ``or {}`` is evaluated. Each
|
||||
handler's catch-all then turned that into a 500.
|
||||
|
||||
So the natural way to call these endpoints — a POST with no body, which
|
||||
is what curl, a fetch() without options, and most HTTP clients send by
|
||||
default — failed on every one of them. The shipped UI always sends a JSON
|
||||
object, which is why this went unnoticed.
|
||||
|
||||
This file covers the endpoints whose bodyless behaviour is not already
|
||||
tested in their own suite.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestOnDemandStart:
|
||||
URL = "/api/v3/display/on-demand/start"
|
||||
|
||||
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
|
||||
response = api_v3_client.post(self.URL)
|
||||
# The endpoint may still reject the request on its own terms (no
|
||||
# plugin_id, nothing to display); what it must not do is fail with
|
||||
# a 500 raised out of body parsing.
|
||||
assert response.status_code != 500
|
||||
|
||||
def test_json_body_still_works(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL, json={}).status_code != 500
|
||||
|
||||
|
||||
class TestResetPluginConfig:
|
||||
URL = "/api/v3/plugins/config/reset"
|
||||
|
||||
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL).status_code != 500
|
||||
|
||||
def test_json_body_still_works(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL, json={}).status_code != 500
|
||||
|
||||
|
||||
class TestDeleteOfTheDayJson:
|
||||
URL = "/api/v3/plugins/of-the-day/json/delete"
|
||||
|
||||
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL).status_code != 500
|
||||
|
||||
def test_json_body_still_works(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL, json={}).status_code != 500
|
||||
|
||||
|
||||
class TestPluginLimits:
|
||||
URL = "/api/v3/plugins/clock/limits"
|
||||
|
||||
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL).status_code != 500
|
||||
|
||||
|
||||
class TestMissingBodyGivesTheDeclaredError:
|
||||
"""Handlers that answer "No data provided" must actually be able to.
|
||||
|
||||
A second group of handlers reads `data = request.get_json()` and then
|
||||
guards with `if not data: return 400`. That guard is unreachable for a
|
||||
request with no JSON body, because get_json() raises first — so the
|
||||
caller got a 500 "an error occurred; see logs for details" instead of
|
||||
the 400 the handler plainly intends to send.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"/api/v3/plugins/install",
|
||||
"/api/v3/plugins/install-from-url",
|
||||
"/api/v3/plugins/registry-from-url",
|
||||
"/api/v3/config/raw/main",
|
||||
"/api/v3/config/raw/secrets",
|
||||
"/api/v3/cache/delete",
|
||||
])
|
||||
def test_bodyless_post_gets_a_400_not_a_500(self, api_v3_client, api_v3_module, url):
|
||||
response = api_v3_client.post(url)
|
||||
assert response.status_code == 400, (
|
||||
f"{url} answered {response.status_code}: "
|
||||
f"{response.get_data(as_text=True)[:200]}")
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"/api/v3/plugins/install",
|
||||
"/api/v3/config/raw/main",
|
||||
])
|
||||
def test_malformed_json_gets_a_400_not_a_500(self, api_v3_client, api_v3_module, url):
|
||||
response = api_v3_client.post(
|
||||
url, data="{not json", content_type="application/json")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestNoBodyReadContradictsItsOwnGuard:
|
||||
SOURCE = Path(__file__).parent.parent / "web_interface/blueprints/api_v3.py"
|
||||
|
||||
def test_no_or_default_read_is_unguarded(self):
|
||||
"""`get_json() or <default>` is a contradiction without silent=True.
|
||||
|
||||
Writing `or {}` declares the body optional; omitting silent=True
|
||||
means the call raises before the default can apply.
|
||||
"""
|
||||
offenders = [
|
||||
line.strip() for line in self.SOURCE.read_text().splitlines()
|
||||
if "request.get_json()" in line and " or " in line
|
||||
]
|
||||
assert offenders == [], (
|
||||
"these reads declare a default but raise before reaching it; "
|
||||
f"use get_json(silent=True): {offenders}")
|
||||
|
||||
def test_no_not_data_guard_is_unreachable(self):
|
||||
"""A `if not data:` guard needs a read that can actually return None."""
|
||||
lines = self.SOURCE.read_text().splitlines()
|
||||
offenders = []
|
||||
for i, line in enumerate(lines):
|
||||
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
|
||||
window = "\n".join(lines[i + 1:i + 3])
|
||||
if re.search(r"if\s+(not\s+data\b|data\s+is\s+None)", window):
|
||||
offenders.append(f"line {i + 1}: {line.strip()}")
|
||||
assert offenders == [], (
|
||||
"these handlers guard on a missing body but raise before the "
|
||||
f"guard runs; use get_json(silent=True): {offenders}")
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Endpoint tests for POST /plugins/install and POST /plugins/install-from-url.
|
||||
|
||||
Both were only ever tested at the PluginStoreManager layer, so the route
|
||||
logic — the queue-vs-direct branch, schema invalidation, plugin discovery,
|
||||
state and history recording — was unexercised.
|
||||
|
||||
/plugins/install carries the same install logic twice: once inside the
|
||||
operation-queue callback and once in the direct fallback. The paired
|
||||
tests below assert both branches produce the same side effects, so the
|
||||
duplication cannot quietly drift.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
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
|
||||
|
||||
INSTALL = "/api/v3/plugins/install"
|
||||
FROM_URL = "/api/v3/plugins/install-from-url"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queued(api_v3_module):
|
||||
"""Enable the operation queue and run its callback synchronously."""
|
||||
queue = MagicMock()
|
||||
|
||||
def enqueue(operation_type, plugin_id, operation_callback=None):
|
||||
queue.callback_result = operation_callback(MagicMock())
|
||||
return "op-123"
|
||||
|
||||
queue.enqueue_operation.side_effect = enqueue
|
||||
api_v3_module.api_v3.operation_queue = queue
|
||||
return queue
|
||||
|
||||
|
||||
def side_effects(module):
|
||||
"""The manager calls a successful install is expected to make."""
|
||||
api = module.api_v3
|
||||
return {
|
||||
"schema_invalidated": api.schema_manager.invalidate_cache.call_args_list,
|
||||
"discovered": api.plugin_manager.discover_plugins.call_count,
|
||||
"loaded": api.plugin_manager.load_plugin.call_args_list,
|
||||
"state_set": api.plugin_state_manager.set_plugin_installed.call_args_list,
|
||||
"history": api.operation_history.record_operation.call_args_list,
|
||||
}
|
||||
|
||||
|
||||
class TestInstallValidation:
|
||||
def test_uninitialized_store_manager_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager = None
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 500
|
||||
assert "not initialized" in response.get_json()["message"]
|
||||
|
||||
def test_missing_plugin_id_is_a_400(self, api_v3_client, api_v3_module):
|
||||
response = api_v3_client.post(INSTALL, json={})
|
||||
assert response.status_code == 400
|
||||
assert "plugin_id required" in response.get_json()["message"]
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.assert_not_called()
|
||||
|
||||
def test_empty_body_is_a_400(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(INSTALL, json=None).status_code == 400
|
||||
|
||||
|
||||
class TestInstallDirectPath:
|
||||
"""operation_queue is None — the fallback branch."""
|
||||
|
||||
def test_success(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["status"] == "success"
|
||||
|
||||
def test_success_side_effects(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
effects = side_effects(api_v3_module)
|
||||
assert effects["schema_invalidated"] == [(("clock",), {})]
|
||||
assert effects["discovered"] == 1
|
||||
assert effects["loaded"] == [(("clock",), {})]
|
||||
assert effects["state_set"] == [(("clock",), {})]
|
||||
assert effects["history"][0].kwargs["status"] == "success"
|
||||
|
||||
def test_branch_forwarded_to_the_manager(self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.install_plugin.return_value = True
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
||||
manager.install_plugin.assert_called_once_with("clock", branch="dev")
|
||||
|
||||
def test_branch_named_in_the_message(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
||||
assert "(branch: dev)" in response.get_json()["message"]
|
||||
|
||||
def test_failure_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 500
|
||||
assert "Failed to install" in response.get_json()["message"]
|
||||
|
||||
def test_failure_mentions_missing_registry_entry(self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.install_plugin.return_value = False
|
||||
manager.get_plugin_info.return_value = None
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "ghost"})
|
||||
assert "not found in registry" in response.get_json()["message"]
|
||||
|
||||
def test_failure_omits_registry_note_when_plugin_is_known(
|
||||
self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.install_plugin.return_value = False
|
||||
manager.get_plugin_info.return_value = {"id": "clock"}
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert "not found in registry" not in response.get_json()["message"]
|
||||
|
||||
def test_failure_recorded_in_history(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
record = api_v3_module.api_v3.operation_history.record_operation.call_args
|
||||
assert record.kwargs["status"] == "failed"
|
||||
|
||||
def test_no_side_effects_on_failure(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
effects = side_effects(api_v3_module)
|
||||
assert effects["schema_invalidated"] == []
|
||||
assert effects["loaded"] == []
|
||||
assert effects["state_set"] == []
|
||||
|
||||
|
||||
class TestInstallQueuedPath:
|
||||
"""operation_queue present — the callback branch."""
|
||||
|
||||
def test_returns_an_operation_id(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"]["operation_id"] == "op-123"
|
||||
|
||||
def test_message_says_queued(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert "queued" in response.get_json()["message"]
|
||||
|
||||
def test_callback_success_side_effects(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
effects = side_effects(api_v3_module)
|
||||
assert effects["schema_invalidated"] == [(("clock",), {})]
|
||||
assert effects["discovered"] == 1
|
||||
assert effects["loaded"] == [(("clock",), {})]
|
||||
assert effects["state_set"] == [(("clock",), {})]
|
||||
assert effects["history"][0].kwargs["status"] == "success"
|
||||
|
||||
def test_callback_reports_success(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert queued.callback_result["success"] is True
|
||||
|
||||
def test_callback_failure_raises_for_the_queue(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
# The callback signals failure by raising, so the queue can mark the
|
||||
# operation failed; the route's catch-all turns it into a 500.
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 500
|
||||
|
||||
def test_callback_failure_recorded_in_history(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
record = api_v3_module.api_v3.operation_history.record_operation.call_args
|
||||
assert record.kwargs["status"] == "failed"
|
||||
|
||||
def test_branch_forwarded_from_the_callback(self, api_v3_client, api_v3_module, queued):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.install_plugin.return_value = True
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
||||
manager.install_plugin.assert_called_once_with("clock", branch="dev")
|
||||
|
||||
|
||||
class TestInstallPathsAgree:
|
||||
"""The queue callback and the direct fallback duplicate the same logic."""
|
||||
|
||||
def _run(self, client, module, install_ok, queue):
|
||||
module.api_v3.plugin_store_manager.install_plugin.return_value = install_ok
|
||||
client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
||||
return side_effects(module)
|
||||
|
||||
def test_success_side_effects_match(self, api_v3_client, api_v3_module):
|
||||
direct = self._run(api_v3_client, api_v3_module, True, None)
|
||||
|
||||
# Reset and re-run through the queue.
|
||||
for mock in (api_v3_module.api_v3.schema_manager,
|
||||
api_v3_module.api_v3.plugin_manager,
|
||||
api_v3_module.api_v3.plugin_state_manager,
|
||||
api_v3_module.api_v3.operation_history):
|
||||
mock.reset_mock()
|
||||
queue = MagicMock()
|
||||
queue.enqueue_operation.side_effect = (
|
||||
lambda t, p, operation_callback=None: operation_callback(MagicMock()) and "op")
|
||||
api_v3_module.api_v3.operation_queue = queue
|
||||
queued = self._run(api_v3_client, api_v3_module, True, queue)
|
||||
|
||||
assert direct["schema_invalidated"] == queued["schema_invalidated"]
|
||||
assert direct["discovered"] == queued["discovered"]
|
||||
assert direct["loaded"] == queued["loaded"]
|
||||
assert direct["state_set"] == queued["state_set"]
|
||||
assert (direct["history"][0].kwargs["status"]
|
||||
== queued["history"][0].kwargs["status"])
|
||||
assert (direct["history"][0].kwargs["details"]
|
||||
== queued["history"][0].kwargs["details"])
|
||||
|
||||
def test_only_the_message_wording_differs(self, api_v3_client, api_v3_module):
|
||||
# Characterized: the direct path says "Plugin installed
|
||||
# successfully" while the queue callback says "Plugin clock
|
||||
# installed successfully". Cosmetic, and the queue's text is
|
||||
# internal to the operation record rather than the HTTP response.
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
direct = api_v3_client.post(INSTALL, json={"plugin_id": "clock"}).get_json()
|
||||
assert direct["message"] == "Plugin installed successfully"
|
||||
|
||||
|
||||
class TestInstallFromUrl:
|
||||
def test_uninitialized_store_manager_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager = None
|
||||
assert api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).status_code == 500
|
||||
|
||||
def test_missing_repo_url_is_a_400(self, api_v3_client, api_v3_module):
|
||||
response = api_v3_client.post(FROM_URL, json={})
|
||||
assert response.status_code == 400
|
||||
assert "repo_url required" in response.get_json()["message"]
|
||||
|
||||
def test_success(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": True, "plugin_id": "clock", "name": "Clock"}
|
||||
response = api_v3_client.post(FROM_URL, json={"repo_url": "https://github.com/o/r"})
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["plugin_id"] == "clock"
|
||||
assert body["name"] == "Clock"
|
||||
|
||||
def test_all_optional_arguments_forwarded(self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.install_from_url.return_value = {"success": True, "plugin_id": "clock"}
|
||||
api_v3_client.post(FROM_URL, json={
|
||||
"repo_url": " https://github.com/o/r ",
|
||||
"plugin_id": "clock",
|
||||
"plugin_path": "plugins/clock",
|
||||
"branch": "dev",
|
||||
})
|
||||
manager.install_from_url.assert_called_once_with(
|
||||
repo_url="https://github.com/o/r",
|
||||
plugin_id="clock",
|
||||
plugin_path="plugins/clock",
|
||||
branch="dev",
|
||||
)
|
||||
|
||||
def test_success_invalidates_schema_and_loads_plugin(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": True, "plugin_id": "clock"}
|
||||
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
||||
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_called_once_with("clock")
|
||||
api_v3_module.api_v3.plugin_manager.load_plugin.assert_called_once_with("clock")
|
||||
|
||||
def test_success_without_plugin_id_skips_discovery(self, api_v3_client, api_v3_module):
|
||||
# install_from_url can succeed without naming the plugin; there is
|
||||
# then nothing to invalidate or load.
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": True, "plugin_id": None}
|
||||
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
||||
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_not_called()
|
||||
api_v3_module.api_v3.plugin_manager.load_plugin.assert_not_called()
|
||||
|
||||
def test_branch_from_result_included(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": True, "plugin_id": "clock", "branch": "dev"}
|
||||
body = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).get_json()
|
||||
assert body["branch"] == "dev"
|
||||
assert "(branch: dev)" in body["message"]
|
||||
|
||||
def test_failure_reports_the_managers_error(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": False, "error": "repo not found"}
|
||||
response = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["message"] == "repo not found"
|
||||
|
||||
def test_failure_without_error_uses_fallback_text(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": False}
|
||||
response = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
||||
assert "Failed to install plugin from URL" in response.get_json()["message"]
|
||||
|
||||
def test_manager_exception_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.side_effect = (
|
||||
RuntimeError("boom"))
|
||||
assert api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).status_code == 500
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Endpoint tests for the plugin-registry routes in api_v3:
|
||||
POST /plugins/store/refresh and POST /plugins/registry-from-url.
|
||||
|
||||
Both reach out to the network through PluginStoreManager (mocked here) and
|
||||
had no endpoint-level coverage; registry-from-url in particular takes a
|
||||
user-supplied URL and hands it straight to the manager.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestRefreshPluginStore:
|
||||
URL = "/api/v3/plugins/store/refresh"
|
||||
|
||||
def test_uninitialized_manager_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager = None
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 500
|
||||
assert "not initialized" in response.get_json()["message"]
|
||||
|
||||
def test_success_reports_plugin_count(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {
|
||||
"plugins": [{"id": "a"}, {"id": "b"}, {"id": "c"}]}
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["plugin_count"] == 3
|
||||
|
||||
def test_forces_a_refresh_rather_than_using_cache(self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.fetch_registry.return_value = {"plugins": []}
|
||||
api_v3_client.post(self.URL, json={})
|
||||
manager.fetch_registry.assert_called_once_with(force_refresh=True)
|
||||
|
||||
def test_empty_registry_reports_zero(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {}
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.get_json()["plugin_count"] == 0
|
||||
|
||||
def test_no_body_is_accepted(self, api_v3_client, api_v3_module):
|
||||
# Regression: `request.get_json() or {}` says a missing body is
|
||||
# fine, but get_json() raises UnsupportedMediaType before `or {}`
|
||||
# is reached, so a bodyless POST — the natural way to call a
|
||||
# refresh endpoint — came back 500.
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
assert api_v3_client.post(self.URL).status_code == 200
|
||||
|
||||
def test_body_without_json_content_type_is_accepted(
|
||||
self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
response = api_v3_client.post(self.URL, data="", content_type="text/plain")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_malformed_json_body_falls_back_to_defaults(
|
||||
self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
response = api_v3_client.post(
|
||||
self.URL, data="{not json", content_type="application/json")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.parametrize("key", ["fetch_commit_info", "fetch_latest_versions"])
|
||||
def test_either_commit_info_key_extends_the_message(
|
||||
self, api_v3_client, api_v3_module, key):
|
||||
# fetch_latest_versions is the older spelling; both must work.
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
response = api_v3_client.post(self.URL, json={key: True})
|
||||
assert "commit metadata" in response.get_json()["message"]
|
||||
|
||||
def test_message_stays_plain_without_the_flag(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.get_json()["message"] == "Plugin store refreshed"
|
||||
|
||||
def test_network_failure_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.side_effect = (
|
||||
ConnectionError("github unreachable"))
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["message"] == "An error occurred; see logs for details"
|
||||
|
||||
def test_failure_body_carries_no_traceback_or_paths(
|
||||
self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.side_effect = (
|
||||
RuntimeError("failed at /home/user/LEDMatrix/src/secret.py line 42"))
|
||||
body = api_v3_client.post(self.URL, json={}).get_json()
|
||||
assert "Traceback" not in str(body)
|
||||
# `details` is describe_exception output: one line, type-named,
|
||||
# credential-redacted. It may quote the message, but never a stack.
|
||||
assert body["details"].startswith("RuntimeError:")
|
||||
assert "\n" not in body["details"]
|
||||
|
||||
|
||||
class TestRegistryFromUrl:
|
||||
URL = "/api/v3/plugins/registry-from-url"
|
||||
|
||||
def test_uninitialized_manager_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager = None
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": "http://x"})
|
||||
assert response.status_code == 500
|
||||
|
||||
def test_missing_repo_url_is_a_400(self, api_v3_client, api_v3_module):
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 400
|
||||
assert "repo_url required" in response.get_json()["message"]
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.assert_not_called()
|
||||
|
||||
def test_success_returns_the_plugin_list(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = {
|
||||
"plugins": [{"id": "clock"}]}
|
||||
response = api_v3_client.post(
|
||||
self.URL, json={"repo_url": "https://github.com/o/r"})
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["plugins"] == [{"id": "clock"}]
|
||||
assert body["registry_url"] == "https://github.com/o/r"
|
||||
|
||||
def test_url_is_trimmed_before_use(self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.fetch_registry_from_url.return_value = {"plugins": []}
|
||||
api_v3_client.post(self.URL, json={"repo_url": " https://github.com/o/r "})
|
||||
manager.fetch_registry_from_url.assert_called_once_with("https://github.com/o/r")
|
||||
|
||||
def test_registry_without_plugins_key_returns_empty_list(
|
||||
self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = {
|
||||
"other": 1}
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": "http://x"})
|
||||
assert response.get_json()["plugins"] == []
|
||||
|
||||
def test_no_registry_found_is_a_400(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = None
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": "http://x/not-a-registry"})
|
||||
assert response.status_code == 400
|
||||
assert "Failed to fetch registry" in response.get_json()["message"]
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"not a url",
|
||||
"javascript:alert(1)",
|
||||
"file:///etc/passwd",
|
||||
"http://localhost:8080/admin",
|
||||
])
|
||||
def test_unusable_urls_fail_cleanly(self, api_v3_client, api_v3_module, url):
|
||||
# Characterization: the handler performs no URL validation of its
|
||||
# own — whatever the manager makes of the URL decides the outcome.
|
||||
# What is pinned here is that a rejected URL produces a clean 400
|
||||
# rather than a traceback or a 500.
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = None
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": url})
|
||||
assert response.status_code == 400
|
||||
assert "Traceback" not in str(response.get_json())
|
||||
|
||||
def test_fetch_exception_is_a_500_without_internals(
|
||||
self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.side_effect = (
|
||||
ValueError("parse failed in /srv/app/internal.py"))
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": "http://x"})
|
||||
assert response.status_code == 500
|
||||
body = response.get_json()
|
||||
assert body["message"] == "An error occurred; see logs for details"
|
||||
assert "Traceback" not in str(body)
|
||||
|
||||
def test_non_string_repo_url_is_rejected(self, api_v3_client, api_v3_module):
|
||||
# Regression: .strip() on a non-string raised, and the catch-all
|
||||
# reported the caller's own mistake as a server fault.
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": 12345})
|
||||
assert response.status_code == 400
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.assert_not_called()
|
||||
|
||||
def test_blank_repo_url_is_rejected(self, api_v3_client, api_v3_module):
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": " "})
|
||||
assert response.status_code == 400
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.assert_not_called()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Endpoint tests for the /wifi/* routes in api_v3.
|
||||
|
||||
These routes drive the host's actual networking — connecting, dropping a
|
||||
connection, switching the radio off — and had no endpoint-level tests at
|
||||
all. WiFiManager is mocked throughout; nothing here may touch real
|
||||
networking.
|
||||
|
||||
Each handler does `from src.wifi_manager import WiFiManager` inside the
|
||||
function body, so the patch target is the class at its definition site.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wifi_manager():
|
||||
"""Patch WiFiManager where it is defined; yield the instance mock."""
|
||||
with patch("src.wifi_manager.WiFiManager") as cls:
|
||||
instance = MagicMock()
|
||||
cls.return_value = instance
|
||||
yield instance
|
||||
|
||||
|
||||
class TestConnect:
|
||||
URL = "/api/v3/wifi/connect"
|
||||
|
||||
def test_success(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "pw"})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["message"] == "Connected to HomeNet"
|
||||
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "pw")
|
||||
|
||||
def test_missing_body_rejected(self, api_v3_client, wifi_manager):
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 400
|
||||
wifi_manager.connect_to_network.assert_not_called()
|
||||
|
||||
def test_missing_ssid_rejected(self, api_v3_client, wifi_manager):
|
||||
response = api_v3_client.post(self.URL, json={"password": "pw"})
|
||||
assert response.status_code == 400
|
||||
assert "SSID is required" in response.get_json()["message"]
|
||||
wifi_manager.connect_to_network.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("ssid", ["", " ", "\t"])
|
||||
def test_blank_ssid_rejected(self, api_v3_client, wifi_manager, ssid):
|
||||
response = api_v3_client.post(self.URL, json={"ssid": ssid})
|
||||
assert response.status_code == 400
|
||||
wifi_manager.connect_to_network.assert_not_called()
|
||||
|
||||
def test_ssid_is_trimmed(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (True, "ok")
|
||||
api_v3_client.post(self.URL, json={"ssid": " HomeNet "})
|
||||
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "")
|
||||
|
||||
def test_missing_password_becomes_empty_string(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (True, "ok")
|
||||
api_v3_client.post(self.URL, json={"ssid": "OpenNet"})
|
||||
wifi_manager.connect_to_network.assert_called_once_with("OpenNet", "")
|
||||
|
||||
def test_null_password_becomes_empty_string(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (True, "ok")
|
||||
api_v3_client.post(self.URL, json={"ssid": "OpenNet", "password": None})
|
||||
wifi_manager.connect_to_network.assert_called_once_with("OpenNet", "")
|
||||
|
||||
def test_failure_reports_the_managers_reason(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (False, "Bad password")
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "Bad password"
|
||||
|
||||
def test_failure_without_reason_uses_fallback_text(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (False, None)
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "Failed to connect to network"
|
||||
|
||||
def test_manager_exception_is_a_500_without_leaking_internals(
|
||||
self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.side_effect = RuntimeError(
|
||||
"/usr/lib/secret/path blew up")
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
assert response.status_code == 500
|
||||
body = response.get_json()
|
||||
assert body["message"] == "An error occurred; see logs for details"
|
||||
# `details` comes from describe_exception, which is deliberately
|
||||
# safe to return (redacted, capped) — it names the type.
|
||||
assert "RuntimeError" in body["details"]
|
||||
|
||||
|
||||
class TestDisconnect:
|
||||
URL = "/api/v3/wifi/disconnect"
|
||||
|
||||
def test_success(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disconnect_from_network.return_value = (True, "Disconnected")
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["message"] == "Disconnected"
|
||||
|
||||
def test_failure(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disconnect_from_network.return_value = (False, "Not connected")
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "Not connected"
|
||||
|
||||
def test_failure_without_reason_uses_fallback(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disconnect_from_network.return_value = (False, "")
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.get_json()["message"] == "Failed to disconnect from network"
|
||||
|
||||
def test_exception_is_a_500(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disconnect_from_network.side_effect = OSError("nmcli missing")
|
||||
assert api_v3_client.post(self.URL).status_code == 500
|
||||
|
||||
|
||||
class TestApMode:
|
||||
ENABLE = "/api/v3/wifi/ap/enable"
|
||||
DISABLE = "/api/v3/wifi/ap/disable"
|
||||
|
||||
def test_enable_success(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.enable_ap_mode.return_value = (True, "AP enabled")
|
||||
response = api_v3_client.post(self.ENABLE, json={})
|
||||
assert response.status_code == 200
|
||||
wifi_manager.enable_ap_mode.assert_called_once_with(force=False)
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(True, True), (False, False),
|
||||
("true", True), ("TRUE", True), ("1", True),
|
||||
("false", False), ("no", False), ("yes", False),
|
||||
(1, False), # only real True or the listed strings count
|
||||
])
|
||||
def test_force_coercion(self, api_v3_client, wifi_manager, raw, expected):
|
||||
wifi_manager.enable_ap_mode.return_value = (True, "ok")
|
||||
api_v3_client.post(self.ENABLE, json={"force": raw})
|
||||
wifi_manager.enable_ap_mode.assert_called_once_with(force=expected)
|
||||
|
||||
def test_enable_without_body(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.enable_ap_mode.return_value = (True, "ok")
|
||||
assert api_v3_client.post(self.ENABLE).status_code == 200
|
||||
|
||||
def test_enable_failure(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.enable_ap_mode.return_value = (False, "hostapd missing")
|
||||
response = api_v3_client.post(self.ENABLE, json={})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "hostapd missing"
|
||||
|
||||
def test_disable_success(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disable_ap_mode.return_value = (True, "AP disabled")
|
||||
assert api_v3_client.post(self.DISABLE).status_code == 200
|
||||
|
||||
def test_disable_failure(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disable_ap_mode.return_value = (False, "not running")
|
||||
assert api_v3_client.post(self.DISABLE).status_code == 400
|
||||
|
||||
def test_enable_exception_is_a_500(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.enable_ap_mode.side_effect = RuntimeError("boom")
|
||||
assert api_v3_client.post(self.ENABLE, json={}).status_code == 500
|
||||
|
||||
|
||||
class TestRadio:
|
||||
URL = "/api/v3/wifi/radio"
|
||||
|
||||
def test_get_state(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.get_wifi_radio_state.return_value = {
|
||||
"enabled": True, "ethernet_connected": False}
|
||||
response = api_v3_client.get(self.URL)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"]["enabled"] is True
|
||||
|
||||
def test_get_state_exception_is_a_500(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.get_wifi_radio_state.side_effect = OSError("rfkill missing")
|
||||
assert api_v3_client.get(self.URL).status_code == 500
|
||||
|
||||
def test_enabled_is_required(self, api_v3_client, wifi_manager):
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 400
|
||||
assert "enabled is required" in response.get_json()["message"]
|
||||
wifi_manager.set_wifi_radio.assert_not_called()
|
||||
|
||||
def test_enable_success(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.set_wifi_radio.return_value = (True, "Radio on", None)
|
||||
wifi_manager.get_wifi_radio_state.return_value = {"enabled": True}
|
||||
response = api_v3_client.post(self.URL, json={"enabled": True})
|
||||
assert response.status_code == 200
|
||||
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=False)
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(True, True), ("true", True), ("1", True), ("yes", True),
|
||||
(False, False), ("false", False), ("off", False), (0, False),
|
||||
])
|
||||
def test_enabled_coercion_is_string_aware(
|
||||
self, api_v3_client, wifi_manager, raw, expected):
|
||||
# bool("false") is True, so the endpoint parses strings explicitly
|
||||
# rather than trusting truthiness — it is a public contract, not
|
||||
# only the shipped UI which always sends real JSON booleans.
|
||||
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)
|
||||
|
||||
def test_force_passed_through(self, api_v3_client, wifi_manager):
|
||||
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": False, "force": "true"})
|
||||
wifi_manager.set_wifi_radio.assert_called_once_with(False, force=True)
|
||||
|
||||
def test_refusal_reports_reason(self, api_v3_client, wifi_manager):
|
||||
# Disabling the radio without Ethernet would lock the user out of
|
||||
# this very interface, so the manager can refuse with a reason.
|
||||
wifi_manager.set_wifi_radio.return_value = (
|
||||
False, "Refusing: no wired fallback", "no_ethernet")
|
||||
response = api_v3_client.post(self.URL, json={"enabled": False})
|
||||
assert response.status_code == 400
|
||||
body = response.get_json()
|
||||
assert body["reason"] == "no_ethernet"
|
||||
assert "Refusing" in body["message"]
|
||||
|
||||
def test_exception_is_a_500(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.set_wifi_radio.side_effect = RuntimeError("boom")
|
||||
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
|
||||
|
||||
|
||||
class TestNoRealNetworking:
|
||||
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
|
||||
# Guard against a future refactor moving the import to module level,
|
||||
# where the fixture's patch of the definition site would stop
|
||||
# applying and the tests would start driving real networking.
|
||||
with patch("src.wifi_manager.WiFiManager") as cls:
|
||||
cls.return_value.disconnect_from_network.return_value = (True, "ok")
|
||||
api_v3_client.post("/api/v3/wifi/disconnect")
|
||||
assert cls.called
|
||||
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
Tests for src/base_odds_manager.py (BaseOddsManager).
|
||||
|
||||
Covers get_odds validation/caching/URL construction, the null-safe
|
||||
_extract_espn_data fix (ESPN sends explicit JSON nulls for absent sides),
|
||||
the no_odds sentinel, stale-cache fallback on request failure,
|
||||
is_odds_available's ML-blind truth table, the fixed format_odds_summary
|
||||
gate (money-line-only odds now format), get_odds_for_games, and
|
||||
configuration loading.
|
||||
|
||||
No real network: requests.Session.get is always patched. The odds path sends
|
||||
its requests through a session so it can identify itself to ESPN, so patching
|
||||
the module-level requests.get would no longer intercept anything.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from src.base_odds_manager import BaseOddsManager
|
||||
|
||||
|
||||
FULL_ITEM = {
|
||||
'details': 'DAL -3.5',
|
||||
'overUnder': 47.5,
|
||||
'spread': -3.5,
|
||||
'homeTeamOdds': {'moneyLine': -150, 'current': {'pointSpread': {'value': -3.5}}},
|
||||
'awayTeamOdds': {'moneyLine': 130, 'current': {'pointSpread': {'value': 3.5}}},
|
||||
}
|
||||
|
||||
FULL_EXTRACTED = {
|
||||
'details': 'DAL -3.5',
|
||||
'over_under': 47.5,
|
||||
'spread': -3.5,
|
||||
'home_team_odds': {'money_line': -150, 'spread_odds': -3.5},
|
||||
'away_team_odds': {'money_line': 130, 'spread_odds': 3.5},
|
||||
}
|
||||
|
||||
|
||||
def _make_response(payload):
|
||||
response = MagicMock()
|
||||
response.json.return_value = payload
|
||||
response.raise_for_status.return_value = None
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_manager():
|
||||
cm = MagicMock()
|
||||
# A bare MagicMock returns truthy Mocks from every call, so every
|
||||
# get_odds() would look like a cache hit. Explicitly wire a miss.
|
||||
cm.get_with_auto_strategy.return_value = None
|
||||
return cm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(cache_manager):
|
||||
return BaseOddsManager(cache_manager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get():
|
||||
with patch('src.base_odds_manager.requests.Session.get') as m:
|
||||
m.return_value = _make_response({'items': [dict(FULL_ITEM)]})
|
||||
yield m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_odds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetOdds:
|
||||
def test_none_sport_raises(self, manager):
|
||||
with pytest.raises(ValueError):
|
||||
manager.get_odds(None, 'nfl', '1')
|
||||
|
||||
def test_none_league_raises(self, manager):
|
||||
with pytest.raises(ValueError):
|
||||
manager.get_odds('football', None, '1')
|
||||
|
||||
def test_cache_key_and_url(self, manager, cache_manager, mock_get):
|
||||
manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
cache_manager.get_with_auto_strategy.assert_called_once_with(
|
||||
'odds_espn_football_nfl_401')
|
||||
url = mock_get.call_args[0][0]
|
||||
# Event id appears twice: /events/<id>/competitions/<id>/odds
|
||||
assert '/events/401/competitions/401/odds' in url
|
||||
assert url == ('https://sports.core.api.espn.com/v2/sports/football/'
|
||||
'leagues/nfl/events/401/competitions/401/odds')
|
||||
# The number matters less than the property: a single stalled request
|
||||
# must not be able to consume the plugin executor's 30s operation
|
||||
# budget, since odds are fetched per live game inside update().
|
||||
assert mock_get.call_args.kwargs['timeout'] == 5
|
||||
assert mock_get.call_args.kwargs['timeout'] < 30
|
||||
|
||||
def test_ncaa_fb_maps_to_college_football(self, manager, mock_get):
|
||||
manager.get_odds('football', 'ncaa_fb', '401')
|
||||
|
||||
url = mock_get.call_args[0][0]
|
||||
assert '/leagues/college-football/' in url
|
||||
|
||||
def test_unknown_league_passes_through(self, manager, mock_get):
|
||||
manager.get_odds('football', 'xfl', '401')
|
||||
|
||||
assert '/leagues/xfl/' in mock_get.call_args[0][0]
|
||||
|
||||
def test_cache_hit_skips_http(self, manager, cache_manager, mock_get):
|
||||
cache_manager.get_with_auto_strategy.return_value = {'spread': -3.0}
|
||||
|
||||
result = manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
assert result == {'spread': -3.0}
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_cached_no_odds_sentinel_returned_verbatim(
|
||||
self, manager, cache_manager, mock_get):
|
||||
cache_manager.get_with_auto_strategy.return_value = {'no_odds': True}
|
||||
|
||||
result = manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
assert result == {'no_odds': True}
|
||||
mock_get.assert_not_called()
|
||||
assert manager.is_odds_available(result) is False
|
||||
|
||||
def test_success_caches_extracted_data_with_interval_ttl(
|
||||
self, manager, cache_manager, mock_get):
|
||||
result = manager.get_odds('football', 'nfl', '401',
|
||||
update_interval_seconds=100)
|
||||
|
||||
assert result == FULL_EXTRACTED
|
||||
cache_manager.set.assert_called_once_with(
|
||||
'odds_espn_football_nfl_401', FULL_EXTRACTED, ttl=100)
|
||||
|
||||
def test_no_odds_caches_sentinel(self, manager, cache_manager, mock_get):
|
||||
mock_get.return_value = _make_response({'count': 0, 'items': []})
|
||||
|
||||
result = manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
assert result is None
|
||||
cache_manager.set.assert_called_once_with(
|
||||
'odds_espn_football_nfl_401', {'no_odds': True}, ttl=3600)
|
||||
|
||||
def test_zero_interval_falls_back_to_default(
|
||||
self, manager, cache_manager, mock_get):
|
||||
# Quirk pin: `update_interval_seconds or self.update_interval`
|
||||
# treats an explicit 0 as falsy, so the 3600 default wins.
|
||||
manager.get_odds('football', 'nfl', '401', update_interval_seconds=0)
|
||||
|
||||
assert cache_manager.set.call_args.kwargs['ttl'] == 3600
|
||||
|
||||
def test_request_exception_falls_back_to_stale_cache(
|
||||
self, manager, cache_manager, mock_get):
|
||||
cache_manager.get_with_auto_strategy.side_effect = [
|
||||
None, {'stale': True}]
|
||||
mock_get.side_effect = requests.exceptions.RequestException('boom')
|
||||
|
||||
result = manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
assert result == {'stale': True}
|
||||
assert cache_manager.get_with_auto_strategy.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_espn_data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractEspnData:
|
||||
def test_full_item_extracts_all_fields(self, manager):
|
||||
result = manager._extract_espn_data({'items': [dict(FULL_ITEM)]})
|
||||
assert result == FULL_EXTRACTED
|
||||
|
||||
def test_explicit_nulls_do_not_raise(self, manager):
|
||||
# Post-fix: ESPN sends explicit JSON nulls for absent sides
|
||||
# ("homeTeamOdds": null, "current": null); extraction must not
|
||||
# raise and yields None fields.
|
||||
payload = {'items': [{
|
||||
'homeTeamOdds': None,
|
||||
'awayTeamOdds': {'moneyLine': 150, 'current': None},
|
||||
}]}
|
||||
|
||||
result = manager._extract_espn_data(payload)
|
||||
|
||||
assert result is not None
|
||||
assert result['home_team_odds']['money_line'] is None
|
||||
assert result['home_team_odds']['spread_odds'] is None
|
||||
assert result['away_team_odds']['money_line'] == 150
|
||||
assert result['away_team_odds']['spread_odds'] is None
|
||||
|
||||
def test_valid_empty_response_returns_none(self, manager):
|
||||
assert manager._extract_espn_data({'count': 0, 'items': []}) is None
|
||||
|
||||
def test_unexpected_structure_returns_none(self, manager):
|
||||
assert manager._extract_espn_data({'unexpected': True}) is None
|
||||
|
||||
def test_item_without_odds_fields_cached_as_data_not_sentinel(
|
||||
self, manager, cache_manager, mock_get):
|
||||
# Characterization pin: an item with no odds fields still extracts
|
||||
# to a truthy dict of all-None values, so get_odds caches it as
|
||||
# real data (NOT the no_odds sentinel) — but is_odds_available
|
||||
# correctly reports False for it.
|
||||
mock_get.return_value = _make_response({'items': [{}]})
|
||||
|
||||
result = manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
assert result == {
|
||||
'details': None,
|
||||
'over_under': None,
|
||||
'spread': None,
|
||||
'home_team_odds': {'money_line': None, 'spread_odds': None},
|
||||
'away_team_odds': {'money_line': None, 'spread_odds': None},
|
||||
}
|
||||
cache_manager.set.assert_called_once_with(
|
||||
'odds_espn_football_nfl_401', result, ttl=3600)
|
||||
assert manager.is_odds_available(result) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_odds_available
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsOddsAvailable:
|
||||
def test_none_is_false(self, manager):
|
||||
assert manager.is_odds_available(None) is False
|
||||
|
||||
def test_empty_dict_is_false(self, manager):
|
||||
assert manager.is_odds_available({}) is False
|
||||
|
||||
def test_no_odds_sentinel_is_false(self, manager):
|
||||
assert manager.is_odds_available({'no_odds': True}) is False
|
||||
|
||||
def test_spread_is_true(self, manager):
|
||||
assert manager.is_odds_available({'spread': -3.5}) is True
|
||||
|
||||
def test_over_under_is_true(self, manager):
|
||||
assert manager.is_odds_available({'over_under': 47.5}) is True
|
||||
|
||||
def test_nested_home_spread_odds_is_true(self, manager):
|
||||
assert manager.is_odds_available(
|
||||
{'home_team_odds': {'spread_odds': -3.5}}) is True
|
||||
|
||||
def test_nested_away_spread_odds_is_true(self, manager):
|
||||
assert manager.is_odds_available(
|
||||
{'away_team_odds': {'spread_odds': 3.5}}) is True
|
||||
|
||||
def test_moneyline_only_is_false(self, manager):
|
||||
# Pinned ML-blind contract: is_odds_available ignores money lines
|
||||
# (its callers decide whether to render an odds widget). Note that
|
||||
# format_odds_summary deliberately uses a DIFFERENT gate — it will
|
||||
# still format money-line-only odds (see TestFormatOddsSummary).
|
||||
ml_only = {
|
||||
'home_team_odds': {'money_line': -120},
|
||||
'away_team_odds': {'money_line': 100},
|
||||
}
|
||||
assert manager.is_odds_available(ml_only) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_odds_summary (fixed gate: empty / no_odds only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatOddsSummary:
|
||||
def test_moneyline_only_formats(self, manager):
|
||||
result = manager.format_odds_summary({
|
||||
'home_team_odds': {'money_line': -120},
|
||||
'away_team_odds': {'money_line': 100},
|
||||
})
|
||||
assert result == 'Home ML: -120 | Away ML: 100'
|
||||
|
||||
def test_full_data_formats_all_parts(self, manager):
|
||||
result = manager.format_odds_summary(FULL_EXTRACTED)
|
||||
assert result == 'Spread: -3.5 | O/U: 47.5 | Home ML: -150 | Away ML: 130'
|
||||
|
||||
def test_none_is_no_odds(self, manager):
|
||||
assert manager.format_odds_summary(None) == 'No odds available'
|
||||
|
||||
def test_empty_dict_is_no_odds(self, manager):
|
||||
assert manager.format_odds_summary({}) == 'No odds available'
|
||||
|
||||
def test_no_odds_sentinel_is_no_odds(self, manager):
|
||||
assert manager.format_odds_summary(
|
||||
{'no_odds': True}) == 'No odds available'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_odds_for_games
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetOddsForGames:
|
||||
def test_missing_fields_get_none_odds_without_http(self, manager, mock_get):
|
||||
games = [
|
||||
{'sport': 'football'},
|
||||
{'league': 'nfl'},
|
||||
{'id': '9'},
|
||||
{},
|
||||
]
|
||||
|
||||
result = manager.get_odds_for_games(games)
|
||||
|
||||
assert all(g['odds'] is None for g in result)
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_per_game_exception_continues_loop(self, manager, monkeypatch):
|
||||
def fake_get_odds(sport, league, event_id,
|
||||
update_interval_seconds=None):
|
||||
if event_id == 'bad':
|
||||
raise RuntimeError('boom')
|
||||
return {'spread': -1.0}
|
||||
|
||||
monkeypatch.setattr(manager, 'get_odds', fake_get_odds)
|
||||
games = [
|
||||
{'sport': 'football', 'league': 'nfl', 'id': 'bad'},
|
||||
{'sport': 'football', 'league': 'nfl', 'id': 'ok'},
|
||||
]
|
||||
|
||||
result = manager.get_odds_for_games(games)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]['odds'] is None
|
||||
assert result[1]['odds'] == {'spread': -1.0}
|
||||
|
||||
def test_input_dicts_mutated_in_place_and_returned(self, manager, mock_get):
|
||||
# Pin: get_odds_for_games mutates the caller's game dicts in place
|
||||
# and returns the same objects, not copies.
|
||||
game = {'sport': 'football', 'league': 'nfl', 'id': '401'}
|
||||
|
||||
result = manager.get_odds_for_games([game])
|
||||
|
||||
assert result[0] is game
|
||||
assert game['odds'] == FULL_EXTRACTED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLoadConfiguration:
|
||||
def test_loads_values_from_config(self, cache_manager):
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_config.return_value = {
|
||||
'base_odds_manager': {
|
||||
'update_interval': 100,
|
||||
'timeout': 5,
|
||||
'cache_ttl': 42,
|
||||
}
|
||||
}
|
||||
|
||||
manager = BaseOddsManager(cache_manager, config_manager=config_manager)
|
||||
|
||||
assert manager.update_interval == 100
|
||||
# Key/attr mismatch pin: the config key is 'timeout' but the
|
||||
# attribute is request_timeout.
|
||||
assert manager.request_timeout == 5
|
||||
assert manager.cache_ttl == 42
|
||||
|
||||
def test_get_config_raising_keeps_defaults(self, cache_manager):
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_config.side_effect = RuntimeError('boom')
|
||||
|
||||
manager = BaseOddsManager(cache_manager, config_manager=config_manager)
|
||||
|
||||
assert manager.update_interval == 3600
|
||||
assert manager.request_timeout == 5
|
||||
assert manager.cache_ttl == 1800
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Tests for BasePlugin.get_display_duration — ~100 lines of type coercion that
|
||||
every plugin's rotation slot depends on, previously untested.
|
||||
|
||||
The contract: a positive number wins wherever it comes from; everything else
|
||||
falls through instance attr → config → the 15.0 default, logging on the way.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
|
||||
class _MinimalPlugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
|
||||
def make_plugin(config=None, instance_duration="__unset__"):
|
||||
plugin = _MinimalPlugin(
|
||||
plugin_id="duration-test",
|
||||
config=config or {},
|
||||
display_manager=MagicMock(),
|
||||
cache_manager=MagicMock(),
|
||||
plugin_manager=MagicMock(),
|
||||
)
|
||||
if instance_duration != "__unset__":
|
||||
plugin.display_duration = instance_duration
|
||||
return plugin
|
||||
|
||||
|
||||
class TestInstanceVariable:
|
||||
def test_positive_int_wins(self):
|
||||
assert make_plugin(instance_duration=30).get_display_duration() == 30.0
|
||||
|
||||
def test_positive_float_wins(self):
|
||||
assert make_plugin(instance_duration=12.5).get_display_duration() == 12.5
|
||||
|
||||
def test_returns_float_type(self):
|
||||
result = make_plugin(instance_duration=30).get_display_duration()
|
||||
assert isinstance(result, float)
|
||||
|
||||
def test_numeric_string_wins(self):
|
||||
assert make_plugin(instance_duration="25").get_display_duration() == 25.0
|
||||
|
||||
def test_zero_falls_through_to_config(self):
|
||||
plugin = make_plugin(config={"display_duration": 20},
|
||||
instance_duration=0)
|
||||
assert plugin.get_display_duration() == 20.0
|
||||
|
||||
def test_negative_falls_through_to_config(self):
|
||||
plugin = make_plugin(config={"display_duration": 20},
|
||||
instance_duration=-5)
|
||||
assert plugin.get_display_duration() == 20.0
|
||||
|
||||
def test_none_falls_through_to_config(self):
|
||||
plugin = make_plugin(config={"display_duration": 20},
|
||||
instance_duration=None)
|
||||
assert plugin.get_display_duration() == 20.0
|
||||
|
||||
def test_garbage_string_falls_through(self):
|
||||
plugin = make_plugin(config={"display_duration": 20},
|
||||
instance_duration="abc")
|
||||
assert plugin.get_display_duration() == 20.0
|
||||
|
||||
def test_non_positive_string_falls_through(self):
|
||||
plugin = make_plugin(config={"display_duration": 20},
|
||||
instance_duration="0")
|
||||
assert plugin.get_display_duration() == 20.0
|
||||
|
||||
def test_unexpected_type_falls_through(self):
|
||||
plugin = make_plugin(config={"display_duration": 20},
|
||||
instance_duration=[30])
|
||||
assert plugin.get_display_duration() == 20.0
|
||||
|
||||
def test_bool_true_falls_through_like_any_non_number(self):
|
||||
# bool is an int subclass, but a boolean is not a duration: True
|
||||
# must NOT read as 1 second — it falls through to config/default.
|
||||
assert make_plugin(instance_duration=True).get_display_duration() == 15.0
|
||||
|
||||
def test_bool_true_falls_through_to_config(self):
|
||||
plugin = make_plugin(config={"display_duration": 20},
|
||||
instance_duration=True)
|
||||
assert plugin.get_display_duration() == 20.0
|
||||
|
||||
def test_bool_false_still_falls_through(self):
|
||||
plugin = make_plugin(config={"display_duration": 20},
|
||||
instance_duration=False)
|
||||
assert plugin.get_display_duration() == 20.0
|
||||
|
||||
|
||||
class TestConfigFallback:
|
||||
def test_config_number(self):
|
||||
assert make_plugin({"display_duration": 20}).get_display_duration() == 20.0
|
||||
|
||||
def test_config_numeric_string(self):
|
||||
assert make_plugin({"display_duration": "12.5"}).get_display_duration() == 12.5
|
||||
|
||||
def test_missing_config_uses_default(self):
|
||||
assert make_plugin({}).get_display_duration() == 15.0
|
||||
|
||||
def test_config_zero_uses_default(self):
|
||||
assert make_plugin({"display_duration": 0}).get_display_duration() == 15.0
|
||||
|
||||
def test_config_negative_uses_default(self):
|
||||
assert make_plugin({"display_duration": -10}).get_display_duration() == 15.0
|
||||
|
||||
def test_config_garbage_string_uses_default(self):
|
||||
assert make_plugin({"display_duration": "soon"}).get_display_duration() == 15.0
|
||||
|
||||
def test_config_unexpected_type_uses_default(self):
|
||||
assert make_plugin({"display_duration": {"s": 5}}).get_display_duration() == 15.0
|
||||
|
||||
def test_config_none_uses_default(self):
|
||||
assert make_plugin({"display_duration": None}).get_display_duration() == 15.0
|
||||
|
||||
def test_config_bool_uses_default(self):
|
||||
assert make_plugin({"display_duration": True}).get_display_duration() == 15.0
|
||||
assert make_plugin({"display_duration": False}).get_display_duration() == 15.0
|
||||
|
||||
|
||||
class TestValidateConfigDuration:
|
||||
# validate_config must agree with get_display_duration about what a
|
||||
# valid duration is — a config it accepts must not then be rejected
|
||||
# (or silently defaulted) when the duration is actually read.
|
||||
|
||||
def test_positive_number_valid(self):
|
||||
assert make_plugin({"display_duration": 20}).validate_config() is True
|
||||
|
||||
def test_zero_and_negative_invalid(self):
|
||||
assert make_plugin({"display_duration": 0}).validate_config() is False
|
||||
assert make_plugin({"display_duration": -5}).validate_config() is False
|
||||
|
||||
def test_bool_invalid(self):
|
||||
# bool is an int subclass; True would otherwise pass as "positive
|
||||
# number" here while get_display_duration rejects it.
|
||||
assert make_plugin({"display_duration": True}).validate_config() is False
|
||||
assert make_plugin({"display_duration": False}).validate_config() is False
|
||||
|
||||
def test_missing_duration_valid(self):
|
||||
assert make_plugin({}).validate_config() is True
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Tests that one cache directory gets one cleanup thread per process.
|
||||
|
||||
The sweep lists a directory and deletes from it, so a second thread over the
|
||||
same directory only duplicates the scan. Nothing enforced that: every
|
||||
CacheManager started its own, and since the loop closes over `self`, a
|
||||
discarded manager could never be collected -- its thread stayed alive and
|
||||
re-scanned the same directory every 24 hours for the life of the process.
|
||||
|
||||
On the dev rig a display process carried three, for one cache directory:
|
||||
|
||||
14:22:59.954 display_controller (the real one)
|
||||
14:22:59.973 startup validation, run 1 (discarded)
|
||||
14:23:01.055 startup validation, run 2 (discarded)
|
||||
|
||||
Startup validation runs twice and built a throwaway manager each time, purely
|
||||
to read a directory path.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
CacheManager._cleanup_owners.clear()
|
||||
yield
|
||||
for owner in list(CacheManager._cleanup_owners.values()):
|
||||
owner.stop_cleanup_thread()
|
||||
CacheManager._cleanup_owners.clear()
|
||||
|
||||
|
||||
def _live_cleanup_threads():
|
||||
return [t for t in threading.enumerate()
|
||||
if t.name == 'DiskCacheCleanup' and t.is_alive()]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(tmp_path, monkeypatch):
|
||||
"""A CacheManager pinned to a temp dir, so tests never touch the real one."""
|
||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
|
||||
lambda self: str(tmp_path))
|
||||
return CacheManager
|
||||
|
||||
|
||||
class TestOneThreadPerDirectory:
|
||||
def test_a_single_manager_starts_one(self, manager):
|
||||
before = len(_live_cleanup_threads())
|
||||
m = manager()
|
||||
try:
|
||||
assert len(_live_cleanup_threads()) == before + 1
|
||||
finally:
|
||||
m.stop_cleanup_thread()
|
||||
|
||||
def test_three_managers_still_start_one(self, manager):
|
||||
# Exactly the rig's shape: the real manager plus two throwaways.
|
||||
before = len(_live_cleanup_threads())
|
||||
managers = [manager() for _ in range(3)]
|
||||
try:
|
||||
assert len(_live_cleanup_threads()) == before + 1
|
||||
finally:
|
||||
for m in managers:
|
||||
m.stop_cleanup_thread()
|
||||
|
||||
def test_the_first_one_owns_it(self, manager):
|
||||
first, second = manager(), manager()
|
||||
try:
|
||||
assert CacheManager._cleanup_owners[first.cache_dir] is first
|
||||
assert second._cleanup_thread is None
|
||||
finally:
|
||||
first.stop_cleanup_thread()
|
||||
second.stop_cleanup_thread()
|
||||
|
||||
def test_the_survivor_can_take_over(self, manager):
|
||||
first = manager()
|
||||
first.stop_cleanup_thread()
|
||||
assert not _live_cleanup_threads()
|
||||
|
||||
second = manager()
|
||||
try:
|
||||
# Ownership was released, so the directory is swept again rather
|
||||
# than being left permanently unclaimed by a dead owner.
|
||||
assert len(_live_cleanup_threads()) == 1
|
||||
assert CacheManager._cleanup_owners[second.cache_dir] is second
|
||||
finally:
|
||||
second.stop_cleanup_thread()
|
||||
|
||||
def test_stopping_a_non_owner_does_not_unclaim_the_directory(self, manager):
|
||||
first, second = manager(), manager()
|
||||
try:
|
||||
second.stop_cleanup_thread() # never owned it
|
||||
assert CacheManager._cleanup_owners[first.cache_dir] is first
|
||||
assert len(_live_cleanup_threads()) == 1
|
||||
finally:
|
||||
first.stop_cleanup_thread()
|
||||
|
||||
def test_separate_directories_get_separate_threads(self, tmp_path, monkeypatch):
|
||||
a, b = tmp_path / 'a', tmp_path / 'b'
|
||||
a.mkdir()
|
||||
b.mkdir()
|
||||
dirs = iter([str(a), str(b)])
|
||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
|
||||
lambda self: next(dirs))
|
||||
first, second = CacheManager(), CacheManager()
|
||||
try:
|
||||
assert first.cache_dir != second.cache_dir
|
||||
assert len(_live_cleanup_threads()) == 2
|
||||
finally:
|
||||
first.stop_cleanup_thread()
|
||||
second.stop_cleanup_thread()
|
||||
|
||||
def test_no_thread_leaks_across_many_constructions(self, manager):
|
||||
before = len(_live_cleanup_threads())
|
||||
made = [manager() for _ in range(12)]
|
||||
try:
|
||||
assert len(_live_cleanup_threads()) == before + 1
|
||||
finally:
|
||||
for m in made:
|
||||
m.stop_cleanup_thread()
|
||||
assert len(_live_cleanup_threads()) == before
|
||||
|
||||
|
||||
class TestValidatorDoesNotBuildItsOwn:
|
||||
def test_it_uses_the_cache_manager_it_is_given(self, manager):
|
||||
from src.startup_validator import StartupValidator
|
||||
|
||||
shared = manager()
|
||||
try:
|
||||
before = len(_live_cleanup_threads())
|
||||
v = StartupValidator(config_manager=object(), cache_manager=shared)
|
||||
v._validate_cache_directory()
|
||||
assert len(_live_cleanup_threads()) == before, (
|
||||
"validation started another cleanup thread")
|
||||
finally:
|
||||
shared.stop_cleanup_thread()
|
||||
|
||||
def test_without_one_it_cleans_up_after_itself(self, manager):
|
||||
from src.startup_validator import StartupValidator
|
||||
|
||||
before = len(_live_cleanup_threads())
|
||||
v = StartupValidator(config_manager=object())
|
||||
v._validate_cache_directory()
|
||||
assert len(_live_cleanup_threads()) == before, (
|
||||
"the fallback manager left its cleanup thread running")
|
||||
@@ -458,3 +458,26 @@ class TestDiskCacheWriteEconomy:
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("k", {"when": datetime(2026, 7, 12, 10, 30)})
|
||||
assert cache.get("k") == {"when": "2026-07-12T10:30:00"}
|
||||
|
||||
|
||||
# --- the ceiling has to hold between cleanup sweeps ---------------------------
|
||||
|
||||
def test_memory_cache_enforces_ceiling_on_every_write():
|
||||
"""_cleanup_memory_cache only runs every cleanup_interval seconds (300 by
|
||||
default). If set() accepted entries without bound in between, a burst could
|
||||
take the cache far past max_size -- which is the unbounded growth the limit
|
||||
exists to prevent, and on a 1GB board the difference between a bounded cache
|
||||
and a Pi that cannot fork.
|
||||
"""
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
|
||||
cache = MemoryCache(max_size=150, cleanup_interval=300.0)
|
||||
for i in range(1000):
|
||||
cache.set(f"k{i}", {"v": i})
|
||||
|
||||
assert len(cache._cache) <= 150
|
||||
# The timestamp map has to be evicted alongside the values, or it becomes
|
||||
# the leak instead.
|
||||
assert len(cache._timestamps) <= 150
|
||||
assert cache.get("k999") is not None, "the newest write must survive"
|
||||
assert cache.get("k0") is None, "the oldest must be the one evicted"
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests that abandoned cache temp files get collected.
|
||||
|
||||
DiskCache.set() writes through tempfile.mkstemp and os.replace, removing its
|
||||
own temp file in a finally. That covers a failed write, but not a process that
|
||||
dies between the two -- a SIGKILL, a lost restart race, a power cut, all
|
||||
ordinary on a Pi. Nothing collected what was left behind: the temp names are
|
||||
".<key>.json.<random>", and the expiry sweep only listed names ending in
|
||||
.json, so they accumulated for as long as the card had been in service.
|
||||
|
||||
Measured on a live rig before this fix: 76 orphans totalling 1,050 MB -- 81%
|
||||
of the entire cache directory -- the oldest six months old.
|
||||
|
||||
The predicate that decides what to delete is tested harder than the sweep
|
||||
itself, because a false positive here destroys real data.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache.disk_cache import DiskCache, _ORPHAN_TEMP_MAX_AGE_SECONDS
|
||||
|
||||
|
||||
class FakeStrategy:
|
||||
@staticmethod
|
||||
def get_data_type_from_key(key):
|
||||
return 'default'
|
||||
|
||||
|
||||
POLICIES = {'default': 30}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache(tmp_path):
|
||||
return DiskCache(str(tmp_path))
|
||||
|
||||
|
||||
def _age(path, seconds):
|
||||
old = time.time() - seconds
|
||||
os.utime(path, (old, old))
|
||||
|
||||
|
||||
def _write(tmp_path, name, body='{}'):
|
||||
p = tmp_path / name
|
||||
p.write_text(body, encoding='utf-8')
|
||||
return p
|
||||
|
||||
|
||||
class TestWhatCountsAsAnOrphan:
|
||||
@pytest.mark.parametrize('name', [
|
||||
'.weather.json.a1b2c3d4',
|
||||
'.odds_espn_football_nfl_401.json.xyz00000',
|
||||
'.a.json.b',
|
||||
])
|
||||
def test_our_temp_files_are_orphans(self, name):
|
||||
assert DiskCache._is_orphaned_temp(name)
|
||||
|
||||
@pytest.mark.parametrize('name', [
|
||||
'weather.json', # real data
|
||||
'.weather.json', # a dotted key that completed
|
||||
'.gitignore', # not ours
|
||||
'.hidden', # not ours
|
||||
'weather.json.bak', # no leading dot: someone else's
|
||||
'.json.abc', # no key between the dot and .json.
|
||||
'.weather.json.', # no random component
|
||||
'notes.txt',
|
||||
])
|
||||
def test_everything_else_is_left_alone(self, name):
|
||||
assert not DiskCache._is_orphaned_temp(name)
|
||||
|
||||
def test_the_names_set_actually_creates_are_matched(self, cache, tmp_path):
|
||||
"""Guard against the predicate and the writer drifting apart."""
|
||||
created = []
|
||||
real = os.replace
|
||||
|
||||
def capture(src, dst):
|
||||
created.append(os.path.basename(src))
|
||||
return real(src, dst)
|
||||
|
||||
import src.cache.disk_cache as mod
|
||||
mod.os.replace = capture
|
||||
try:
|
||||
cache.set('weather', {'v': 1})
|
||||
finally:
|
||||
mod.os.replace = real
|
||||
|
||||
assert created, "set() did not go through the temp-file path"
|
||||
assert all(DiskCache._is_orphaned_temp(n) for n in created), created
|
||||
|
||||
|
||||
class TestTheSweep:
|
||||
def test_an_old_orphan_is_removed(self, cache, tmp_path):
|
||||
p = _write(tmp_path, '.weather.json.a1b2c3d4', 'x' * 5000)
|
||||
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert not p.exists()
|
||||
assert stats['orphan_temp_files_deleted'] == 1
|
||||
assert stats['space_freed_bytes'] >= 5000
|
||||
|
||||
def test_an_in_flight_write_is_not_snatched_away(self, cache, tmp_path):
|
||||
# The whole risk of this sweep: deleting a temp file another thread is
|
||||
# about to os.replace into place.
|
||||
p = _write(tmp_path, '.weather.json.inflight')
|
||||
|
||||
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert p.exists()
|
||||
|
||||
def test_real_cache_files_survive(self, cache, tmp_path):
|
||||
fresh = _write(tmp_path, 'weather.json')
|
||||
dotted = _write(tmp_path, '.weather.json')
|
||||
_age(dotted, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert fresh.exists()
|
||||
assert dotted.exists(), "a completed .json was treated as a temp file"
|
||||
|
||||
def test_unrelated_dotfiles_survive(self, cache, tmp_path):
|
||||
keep = _write(tmp_path, '.gitignore')
|
||||
_age(keep, 400 * 86400)
|
||||
|
||||
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert keep.exists()
|
||||
|
||||
def test_expiry_still_works_alongside_it(self, cache, tmp_path):
|
||||
stale = _write(tmp_path, 'old.json')
|
||||
_age(stale, 40 * 86400) # past the 30-day default
|
||||
orphan = _write(tmp_path, '.old.json.zz999999')
|
||||
_age(orphan, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert not stale.exists()
|
||||
assert not orphan.exists()
|
||||
assert stats['files_deleted'] == 2
|
||||
assert stats['orphan_temp_files_deleted'] == 1
|
||||
|
||||
def test_the_rig_scenario(self, cache, tmp_path):
|
||||
"""76 orphans of assorted ages, none of them reachable before."""
|
||||
for i in range(76):
|
||||
p = _write(tmp_path, '.sched_%d.json.r%06d' % (i, i), 'x' * 1000)
|
||||
_age(p, (i + 2) * 86400)
|
||||
keep = _write(tmp_path, 'sched.json')
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert stats['orphan_temp_files_deleted'] == 76
|
||||
assert keep.exists()
|
||||
assert not list(tmp_path.glob('.sched_*'))
|
||||
# The summary line is "<deleted>/<scanned>", so an orphan that is
|
||||
# deleted but never counted as scanned renders as "76/1".
|
||||
assert stats['files_scanned'] == 77
|
||||
assert stats['files_deleted'] <= stats['files_scanned']
|
||||
|
||||
def test_deleted_never_exceeds_scanned(self, cache, tmp_path):
|
||||
p = _write(tmp_path, '.only.json.a1b2c3d4')
|
||||
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert stats['files_deleted'] == 1
|
||||
assert stats['files_scanned'] == 1
|
||||
|
||||
def test_a_missing_file_mid_sweep_is_not_an_error(self, cache, tmp_path):
|
||||
p = _write(tmp_path, '.weather.json.a1b2c3d4')
|
||||
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
import src.cache.disk_cache as mod
|
||||
real = mod.os.path.getsize
|
||||
|
||||
def vanish(path):
|
||||
if path.endswith('.a1b2c3d4'):
|
||||
os.remove(path)
|
||||
raise FileNotFoundError(path)
|
||||
return real(path)
|
||||
|
||||
mod.os.path.getsize = vanish
|
||||
try:
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
finally:
|
||||
mod.os.path.getsize = real
|
||||
|
||||
assert stats['errors'] == 0
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests that a per-entry ttl actually controls expiry.
|
||||
|
||||
Regression under test: `CacheManager.set(key, data, ttl=...)` stored the value
|
||||
and no read path ever consulted it. Expiry came from a `max_age` inferred from
|
||||
substrings in the key ("live", "odds", "stock"), so every caller passing `ttl=`
|
||||
-- 48 sites across the plugins and 4 in the core -- was writing a number that
|
||||
did nothing. The old docstring admitted as much: "stored for compatibility but
|
||||
expiration is still controlled via max_age when reading".
|
||||
|
||||
Measured against a real device's cache (8,873 entries carrying a ttl), the
|
||||
inferred value and the intended one disagreed almost everywhere:
|
||||
|
||||
stocks max_age 600 vs ttl 1800 4903 entries
|
||||
news max_age 3600 vs ttl 600 1770 entries
|
||||
odds max_age 1800 vs ttl 3600 1301 entries
|
||||
images max_age 300 vs ttl 2592000 20 entries
|
||||
|
||||
No `sports_live` entry carries a ttl, so live scores keep their inferred
|
||||
30-second freshness either way.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache.disk_cache import DiskCache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disk(tmp_path):
|
||||
return DiskCache(cache_dir=str(tmp_path))
|
||||
|
||||
|
||||
def _record(ttl=None, age=0.0):
|
||||
rec = {"data": {"v": 1}, "timestamp": time.time() - age}
|
||||
if ttl is not None:
|
||||
rec["ttl"] = ttl
|
||||
return rec
|
||||
|
||||
|
||||
class TestDiskCacheHonoursTtl:
|
||||
def test_ttl_longer_than_max_age_keeps_the_entry(self, disk):
|
||||
# The odds case: written wanting an hour, expired at 30 minutes.
|
||||
disk.set("odds_espn_football_nfl_401", _record(ttl=3600, age=1900))
|
||||
assert disk.get("odds_espn_football_nfl_401", max_age=1800) is not None
|
||||
|
||||
def test_ttl_shorter_than_max_age_expires_the_entry(self, disk):
|
||||
# The news case: written wanting 10 minutes, kept for an hour.
|
||||
disk.set("news_NHL_1", _record(ttl=600, age=900))
|
||||
assert disk.get("news_NHL_1", max_age=3600) is None
|
||||
|
||||
def test_without_a_ttl_max_age_still_applies(self, disk):
|
||||
disk.set("plain_key", _record(age=400))
|
||||
assert disk.get("plain_key", max_age=300) is None
|
||||
disk.set("plain_key2", _record(age=100))
|
||||
assert disk.get("plain_key2", max_age=300) is not None
|
||||
|
||||
def test_a_fresh_entry_within_its_ttl_survives(self, disk):
|
||||
disk.set("k", _record(ttl=600, age=10))
|
||||
assert disk.get("k", max_age=30) is not None
|
||||
|
||||
def test_ttl_zero_expires_immediately(self, disk):
|
||||
# 0 means zero seconds, not "forever" -- max_age=None is how a caller
|
||||
# asks for no expiry.
|
||||
disk.set("k", _record(ttl=0, age=1))
|
||||
assert disk.get("k", max_age=99999) is None
|
||||
|
||||
@pytest.mark.parametrize("bad", ["600", None, True, False, -5, {"a": 1}])
|
||||
def test_a_nonsense_ttl_falls_back_to_max_age(self, disk, bad):
|
||||
# Including bools: True is an int in Python and must not become a 1s ttl.
|
||||
rec = _record(age=400)
|
||||
rec["ttl"] = bad
|
||||
disk.set("k_%s" % type(bad).__name__, rec)
|
||||
assert disk.get("k_%s" % type(bad).__name__, max_age=300) is None
|
||||
|
||||
|
||||
class TestMemoryCacheHonoursTtl:
|
||||
def test_ttl_longer_than_max_age_keeps_the_entry(self):
|
||||
m = MemoryCache()
|
||||
m.set("k", _record(ttl=3600))
|
||||
m._timestamps["k"] = time.time() - 1900
|
||||
assert m.get("k", max_age=1800) is not None
|
||||
|
||||
def test_ttl_shorter_than_max_age_expires_the_entry(self):
|
||||
m = MemoryCache()
|
||||
m.set("k", _record(ttl=600))
|
||||
m._timestamps["k"] = time.time() - 900
|
||||
assert m.get("k", max_age=3600) is None
|
||||
|
||||
def test_without_a_ttl_max_age_still_applies(self):
|
||||
m = MemoryCache()
|
||||
m.set("k", _record())
|
||||
m._timestamps["k"] = time.time() - 400
|
||||
assert m.get("k", max_age=300) is None
|
||||
|
||||
def test_both_layers_agree(self, tmp_path):
|
||||
"""A record must not be live in one layer and expired in the other."""
|
||||
rec = _record(ttl=3600, age=1900)
|
||||
d = DiskCache(cache_dir=str(tmp_path))
|
||||
d.set("k", rec)
|
||||
m = MemoryCache()
|
||||
m.set("k", rec)
|
||||
m._timestamps["k"] = rec["timestamp"]
|
||||
assert (d.get("k", max_age=1800) is not None) == (m.get("k", max_age=1800) is not None)
|
||||
|
||||
|
||||
class TestEndToEnd:
|
||||
def test_set_then_get_respects_the_ttl(self, tmp_path, monkeypatch):
|
||||
"""The behaviour a caller of CacheManager.set(ttl=...) expects."""
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
cm = CacheManager()
|
||||
cm._disk_cache_component = DiskCache(cache_dir=str(tmp_path))
|
||||
cm._memory_cache_component = MemoryCache()
|
||||
|
||||
cm.set("odds_espn_football_nfl_401", {"spread": 6.5}, ttl=3600)
|
||||
|
||||
# Age the stored record past the inferred max_age for odds (1800s) but
|
||||
# within the ttl the caller asked for.
|
||||
path = cm._disk_cache_component.get_cache_path("odds_espn_football_nfl_401")
|
||||
import json
|
||||
rec = json.load(open(path))
|
||||
rec["timestamp"] = time.time() - 1900
|
||||
json.dump(rec, open(path, "w"))
|
||||
cm._memory_cache_component.clear() if hasattr(
|
||||
cm._memory_cache_component, "clear") else None
|
||||
|
||||
got = cm.get_with_auto_strategy("odds_espn_football_nfl_401")
|
||||
assert got is not None, "the ttl the caller asked for was ignored"
|
||||
@@ -0,0 +1,113 @@
|
||||
"""A checkbox group must not post back options it cannot show.
|
||||
|
||||
The enum that lets the widget draw checkboxes is also what validates the
|
||||
saved value. When a league retires a team code -- OAK for the Athletics, ARI
|
||||
for the Coyotes -- or a schema drops an option, a config that still holds the
|
||||
old value has nothing to render for it. The value stayed in the hidden
|
||||
``_data`` input regardless, because that input is seeded from the stored array
|
||||
and only rebuilt by ``updateCheckboxGroupData()`` on change. Editing any other
|
||||
field on that plugin therefore posted the stale value back, the schema
|
||||
rejected it, and the save endpoint returned 400
|
||||
``CONFIG_VALIDATION_FAILED`` -- so the whole plugin became uneditable until
|
||||
the user worked out which invisible entry was at fault.
|
||||
|
||||
Runtime was never affected: plugin loading treats schema violations as
|
||||
warn/degrade, and the stale code already matched no team. Only the web UI
|
||||
blocked.
|
||||
|
||||
These tests render the checkbox-group block lifted *out of the shipped
|
||||
template*, following test_enum_option_labels.py, so they exercise the
|
||||
production expression rather than a copy that could drift from it.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import DictLoader, Environment
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG_FORM = (PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'partials'
|
||||
/ 'plugin_config.html')
|
||||
|
||||
# The checkbox-group branch: from its `{% elif %}` guard through the sentinel
|
||||
# hidden input that closes it. Anchored on the guard so the match cannot run on
|
||||
# into a neighbouring widget branch.
|
||||
BLOCK_RE = re.compile(
|
||||
r"\{%\s*elif x_widget == 'checkbox-group'\s*%\}(.*?)"
|
||||
r"<input type=\"hidden\" name=\"\{\{ full_key \}\}\[\]\" value=\"\">",
|
||||
re.S,
|
||||
)
|
||||
|
||||
|
||||
def _shipped_block() -> str:
|
||||
"""Return the live checkbox-group block lifted from plugin_config.html."""
|
||||
source = CONFIG_FORM.read_text(encoding='utf-8')
|
||||
match = BLOCK_RE.search(source)
|
||||
assert match, (
|
||||
'could not find the checkbox-group block in plugin_config.html — the '
|
||||
'template changed shape and this guard needs updating'
|
||||
)
|
||||
block = match.group(1)
|
||||
assert 'data-option-value' in block, 'extracted the wrong branch'
|
||||
assert '{% elif' not in block, 'extraction ran past the checkbox-group branch'
|
||||
return block
|
||||
|
||||
|
||||
def _render(prop: dict, value=None) -> str:
|
||||
env = Environment(loader=DictLoader({'f': _shipped_block()}), autoescape=True)
|
||||
return env.get_template('f').render(
|
||||
prop=prop, value=value, field_id='fid', full_key='k'
|
||||
)
|
||||
|
||||
|
||||
def _submitted(html: str) -> list:
|
||||
"""The array the form will actually post: the hidden _data input."""
|
||||
match = re.search(r'id="fid_data"[^>]*\svalue=\'([^\']*)\'', html)
|
||||
assert match, f'hidden _data input not found in:\n{html}'
|
||||
return json.loads(match.group(1).replace(''', "'"))
|
||||
|
||||
|
||||
def _checked(html: str) -> list:
|
||||
return re.findall(r'data-option-value="([^"]+)"[^>]*checked', html)
|
||||
|
||||
|
||||
MLB = {'type': 'array', 'items': {'type': 'string', 'enum': ['NYY', 'BOS', 'ATH']},
|
||||
'x-widget': 'checkbox-group'}
|
||||
|
||||
|
||||
def test_a_retired_code_is_not_posted_back() -> None:
|
||||
"""The regression: OAK became ATH, and OAK used to ride along on save."""
|
||||
html = _render(MLB, ['NYY', 'OAK'])
|
||||
assert _submitted(html) == ['NYY'], 'stale value would still be submitted'
|
||||
|
||||
|
||||
def test_the_dropped_value_is_named_rather_than_vanishing() -> None:
|
||||
html = _render(MLB, ['NYY', 'OAK'])
|
||||
assert 'OAK' in html
|
||||
assert 'data-stale-options' in html
|
||||
|
||||
|
||||
def test_valid_values_are_untouched_and_still_checked() -> None:
|
||||
html = _render(MLB, ['NYY', 'ATH'])
|
||||
assert _submitted(html) == ['NYY', 'ATH']
|
||||
assert sorted(_checked(html)) == ['ATH', 'NYY']
|
||||
assert 'data-stale-options' not in html
|
||||
|
||||
|
||||
def test_an_all_stale_selection_clears_rather_than_blocking() -> None:
|
||||
html = _render(MLB, ['OAK', 'SD'])
|
||||
assert _submitted(html) == []
|
||||
|
||||
|
||||
def test_an_empty_enum_leaves_the_value_alone() -> None:
|
||||
"""No options means nothing to validate against — filtering would wipe it."""
|
||||
prop = {'type': 'array', 'items': {'type': 'string'}, 'x-widget': 'checkbox-group'}
|
||||
html = _render(prop, ['ANYTHING', 'GOES'])
|
||||
assert _submitted(html) == ['ANYTHING', 'GOES']
|
||||
|
||||
|
||||
def test_unset_value_falls_back_to_the_default() -> None:
|
||||
prop = dict(MLB, default=['BOS'])
|
||||
html = _render(prop, None)
|
||||
assert _submitted(html) == ['BOS']
|
||||
assert _checked(html) == ['BOS']
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Tests for src/plugin_system/compatibility.py — the "can this plugin run on
|
||||
this core?" gate used by both the plugin loader (advisory) and the store
|
||||
manager (blocking at install/update time).
|
||||
|
||||
This module had zero direct test coverage despite guarding every install.
|
||||
These tests pin the documented contract: refuse only on evidence, resolve
|
||||
every uncertain case (unparseable versions, missing fields, untrustworthy
|
||||
core) to compatible.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.compatibility import (
|
||||
TRUSTWORTHY_FLOOR,
|
||||
parse_semver,
|
||||
_parse_strict,
|
||||
_satisfies_range,
|
||||
satisfies_compatible_versions,
|
||||
declared_min_version,
|
||||
check,
|
||||
)
|
||||
|
||||
|
||||
class TestParseSemver:
|
||||
def test_plain_triplet(self):
|
||||
assert parse_semver("1.2.3") == (1, 2, 3)
|
||||
|
||||
def test_leading_v_tolerated(self):
|
||||
assert parse_semver("v3.2.1") == (3, 2, 1)
|
||||
|
||||
def test_prerelease_suffix_stripped(self):
|
||||
# "3.2.0-rc1" must NOT parse as (3, 2, 1) — a release candidate must
|
||||
# not rank above its own release.
|
||||
assert parse_semver("3.2.0-rc1") == (3, 2, 0)
|
||||
|
||||
def test_build_suffix_stripped(self):
|
||||
# "3.2.0+build42" must NOT parse as (3, 2, 42).
|
||||
assert parse_semver("3.2.0+build42") == (3, 2, 0)
|
||||
|
||||
def test_two_part_version_pads_zero(self):
|
||||
assert parse_semver("1.2") == (1, 2, 0)
|
||||
|
||||
def test_one_part_version_pads_zeros(self):
|
||||
assert parse_semver("2") == (2, 0, 0)
|
||||
|
||||
def test_extra_parts_ignored(self):
|
||||
assert parse_semver("1.2.3.4") == (1, 2, 3)
|
||||
|
||||
def test_non_string_returns_none(self):
|
||||
assert parse_semver(None) is None
|
||||
assert parse_semver(123) is None
|
||||
assert parse_semver((1, 2, 3)) is None
|
||||
|
||||
def test_garbage_with_no_digits_is_lenient_zero(self):
|
||||
# Documented leniency: digit-scraping yields (0, 0, 0) for pure
|
||||
# garbage. Fine for a floor (0.0.0 never blocks), wrong for ranges —
|
||||
# which is why ranges go through _parse_strict instead.
|
||||
assert parse_semver("garbage") == (0, 0, 0)
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
assert parse_semver(" 1.2.3 ") == (1, 2, 3)
|
||||
|
||||
|
||||
class TestParseStrict:
|
||||
def test_accepts_real_versions(self):
|
||||
assert _parse_strict("1.2.3") == (1, 2, 3)
|
||||
assert _parse_strict("v1.2.3-rc1") == (1, 2, 3)
|
||||
assert _parse_strict("2.0") == (2, 0, 0)
|
||||
|
||||
def test_rejects_garbage(self):
|
||||
assert _parse_strict("not-a-version") is None
|
||||
assert _parse_strict("") is None
|
||||
|
||||
def test_rejects_non_string(self):
|
||||
assert _parse_strict(None) is None
|
||||
|
||||
|
||||
class TestSatisfiesRange:
|
||||
CORE = (3, 1, 0)
|
||||
|
||||
@pytest.mark.parametrize("spec,expected", [
|
||||
(">=3.0.0", True),
|
||||
(">=3.1.0", True),
|
||||
(">=3.2.0", False),
|
||||
("<=3.1.0", True),
|
||||
("<=3.0.9", False),
|
||||
(">3.0.9", True),
|
||||
(">3.1.0", False),
|
||||
("<3.2.0", True),
|
||||
("<3.1.0", False),
|
||||
])
|
||||
def test_comparison_operators(self, spec, expected):
|
||||
assert _satisfies_range(self.CORE, spec) is expected
|
||||
|
||||
def test_tilde_allows_patch_only(self):
|
||||
# ~3.1.0 means >=3.1.0, <3.2.0
|
||||
assert _satisfies_range((3, 1, 5), "~3.1.0") is True
|
||||
assert _satisfies_range((3, 2, 0), "~3.1.0") is False
|
||||
assert _satisfies_range((3, 0, 9), "~3.1.0") is False
|
||||
|
||||
def test_caret_allows_minor_and_patch(self):
|
||||
# ^3.1.0 means >=3.1.0, <4.0.0
|
||||
assert _satisfies_range((3, 9, 9), "^3.1.0") is True
|
||||
assert _satisfies_range((4, 0, 0), "^3.1.0") is False
|
||||
assert _satisfies_range((3, 0, 0), "^3.1.0") is False
|
||||
|
||||
def test_bare_exact_version(self):
|
||||
assert _satisfies_range((3, 1, 0), "3.1.0") is True
|
||||
assert _satisfies_range((3, 1, 1), "3.1.0") is False
|
||||
|
||||
def test_inclusive_dash_range(self):
|
||||
assert _satisfies_range((2, 5, 0), "2.0.0 - 3.1.0") is True
|
||||
assert _satisfies_range((2, 0, 0), "2.0.0 - 3.1.0") is True
|
||||
assert _satisfies_range((3, 1, 0), "2.0.0 - 3.1.0") is True
|
||||
assert _satisfies_range((3, 1, 1), "2.0.0 - 3.1.0") is False
|
||||
|
||||
def test_unparseable_spec_returns_none_not_false(self):
|
||||
# Garbage must read as "no evidence", never as a refusal — an
|
||||
# unrecognised spelling must not cost a user a working install.
|
||||
assert _satisfies_range(self.CORE, "banana") is None
|
||||
assert _satisfies_range(self.CORE, ">=banana") is None
|
||||
assert _satisfies_range(self.CORE, "") is None
|
||||
assert _satisfies_range(self.CORE, "banana - 3.0.0") is None
|
||||
|
||||
|
||||
class TestSatisfiesCompatibleVersions:
|
||||
def test_any_entry_satisfying_wins(self):
|
||||
manifest = {"compatible_versions": ["<1.0.0", ">=3.0.0"]}
|
||||
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is True
|
||||
|
||||
def test_all_entries_failing_is_false(self):
|
||||
manifest = {"compatible_versions": ["<1.0.0", "2.0.0 - 2.9.9"]}
|
||||
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is False
|
||||
|
||||
def test_absent_field_returns_none(self):
|
||||
assert satisfies_compatible_versions({}, (3, 1, 0)) is None
|
||||
|
||||
def test_empty_list_returns_none(self):
|
||||
assert satisfies_compatible_versions(
|
||||
{"compatible_versions": []}, (3, 1, 0)) is None
|
||||
|
||||
def test_non_list_returns_none(self):
|
||||
assert satisfies_compatible_versions(
|
||||
{"compatible_versions": ">=2.0.0"}, (3, 1, 0)) is None
|
||||
|
||||
def test_all_unparseable_entries_returns_none(self):
|
||||
manifest = {"compatible_versions": ["banana", 42, None]}
|
||||
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is None
|
||||
|
||||
def test_mixed_parseable_and_garbage_uses_parseable(self):
|
||||
manifest = {"compatible_versions": ["banana", ">=3.0.0"]}
|
||||
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is True
|
||||
|
||||
|
||||
class TestDeclaredMinVersion:
|
||||
def test_top_level_field(self):
|
||||
assert declared_min_version({"min_ledmatrix_version": "2.1.0"}) == "2.1.0"
|
||||
|
||||
def test_requires_dict_fallback(self):
|
||||
manifest = {"requires": {"min_ledmatrix_version": "2.2.0"}}
|
||||
assert declared_min_version(manifest) == "2.2.0"
|
||||
|
||||
def test_versions_array_fallback(self):
|
||||
manifest = {"versions": [{"ledmatrix_min_version": "2.3.0"}]}
|
||||
assert declared_min_version(manifest) == "2.3.0"
|
||||
|
||||
def test_versions_array_deprecated_spelling(self):
|
||||
manifest = {"versions": [{"ledmatrix_min": "2.4.0"}]}
|
||||
assert declared_min_version(manifest) == "2.4.0"
|
||||
|
||||
def test_top_level_wins_over_versions_array(self):
|
||||
manifest = {
|
||||
"min_ledmatrix_version": "2.1.0",
|
||||
"versions": [{"ledmatrix_min_version": "9.9.9"}],
|
||||
}
|
||||
assert declared_min_version(manifest) == "2.1.0"
|
||||
|
||||
def test_requires_as_list_does_not_raise(self):
|
||||
# A hand-edited manifest can carry `requires` as a list; this used to
|
||||
# raise AttributeError and one malformed manifest would take down the
|
||||
# whole install path.
|
||||
assert declared_min_version({"requires": ["something"]}) is None
|
||||
|
||||
def test_versions_as_dict_does_not_raise(self):
|
||||
# Same for `versions` as a mapping (used to raise KeyError).
|
||||
assert declared_min_version({"versions": {"0": {}}}) is None
|
||||
|
||||
def test_nothing_declared_returns_none(self):
|
||||
assert declared_min_version({}) is None
|
||||
|
||||
|
||||
class TestCheck:
|
||||
def test_compatible_when_nothing_declared(self):
|
||||
assert check({}, "3.1.0") == (True, None)
|
||||
|
||||
def test_min_version_blocks_older_core(self):
|
||||
manifest = {"name": "Test Plugin", "min_ledmatrix_version": "3.2.0"}
|
||||
ok, reason = check(manifest, "3.1.0")
|
||||
assert ok is False
|
||||
assert "3.2.0" in reason and "3.1.0" in reason
|
||||
|
||||
def test_min_version_allows_equal_core(self):
|
||||
manifest = {"min_ledmatrix_version": "3.1.0"}
|
||||
assert check(manifest, "3.1.0") == (True, None)
|
||||
|
||||
def test_compatible_versions_upper_bound_blocks(self):
|
||||
# A range is the only field that can express "not compatible with
|
||||
# newer cores" — it must win even when the floor passes.
|
||||
manifest = {
|
||||
"name": "Old Plugin",
|
||||
"min_ledmatrix_version": "2.0.0",
|
||||
"compatible_versions": ["2.0.0 - 2.9.9"],
|
||||
}
|
||||
ok, reason = check(manifest, "3.1.0")
|
||||
assert ok is False
|
||||
assert "2.0.0 - 2.9.9" in reason
|
||||
|
||||
def test_unparseable_core_with_high_floor_is_blocked(self):
|
||||
manifest = {"min_ledmatrix_version": "3.2.0",
|
||||
"compatible_versions": [">=3.2.0"]}
|
||||
# An unparseable core version is "unknown", not "old"... but note
|
||||
# parse_semver("garbage") == (0,0,0) which is below TRUSTWORTHY_FLOOR,
|
||||
# so this rides the untrustworthy-core branch: floor > 2.0.0 blocks.
|
||||
ok, reason = check(manifest, "garbage")
|
||||
assert ok is False
|
||||
assert "too old to identify reliably" in reason
|
||||
|
||||
def test_untrustworthy_core_allows_ecosystem_baseline_floor(self):
|
||||
# A core reporting 1.0.0 may really be v3.1.0 (which shipped with a
|
||||
# wrong __version__). Floors at or below TRUSTWORTHY_FLOOR must not
|
||||
# block, or that population could install nothing.
|
||||
manifest = {"min_ledmatrix_version": "2.0.0",
|
||||
"compatible_versions": [">=2.0.0"]}
|
||||
assert check(manifest, "1.0.0") == (True, None)
|
||||
|
||||
def test_untrustworthy_core_blocks_floor_above_baseline(self):
|
||||
# But a floor above 2.0.0 needs modules that no core reporting below
|
||||
# the floor can have — the one refusal on that branch.
|
||||
manifest = {"name": "New Plugin", "min_ledmatrix_version": "3.2.0"}
|
||||
ok, reason = check(manifest, "1.0.0")
|
||||
assert ok is False
|
||||
assert "too old to identify reliably" in reason
|
||||
|
||||
def test_untrustworthy_core_ignores_compatible_versions(self):
|
||||
# On the untrustworthy branch only the declared floor is consulted;
|
||||
# ranges cannot be evaluated against a version that isn't evidence.
|
||||
manifest = {"compatible_versions": ["2.0.0 - 2.9.9"]}
|
||||
assert check(manifest, "1.0.0") == (True, None)
|
||||
|
||||
def test_floor_exactly_at_trustworthy_floor_is_allowed(self):
|
||||
floor = ".".join(str(n) for n in TRUSTWORTHY_FLOOR)
|
||||
manifest = {"min_ledmatrix_version": floor}
|
||||
assert check(manifest, "1.0.0") == (True, None)
|
||||
|
||||
def test_reason_uses_manifest_name(self):
|
||||
manifest = {"name": "Fancy Clock", "min_ledmatrix_version": "9.0.0"}
|
||||
ok, reason = check(manifest, "3.1.0")
|
||||
assert ok is False
|
||||
assert reason.startswith("Fancy Clock")
|
||||
|
||||
def test_reason_falls_back_to_id(self):
|
||||
manifest = {"id": "fancy-clock", "min_ledmatrix_version": "9.0.0"}
|
||||
ok, reason = check(manifest, "3.1.0")
|
||||
assert ok is False
|
||||
assert reason.startswith("fancy-clock")
|
||||
|
||||
def test_prerelease_core_compares_equal_to_release(self):
|
||||
# Documented: prereleases compare equal to their release.
|
||||
manifest = {"min_ledmatrix_version": "3.2.0"}
|
||||
assert check(manifest, "3.2.0-rc1") == (True, None)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Tests for src/common/config_helper.py — pins the ConfigHelper contract.
|
||||
|
||||
Covers: load/save round trips (missing/malformed files return {} rather
|
||||
than raising, non-ASCII preserved via ensure_ascii=False, top-level JSON
|
||||
lists returned as-is), dot-notation get/set including the silent-failure
|
||||
contract when an intermediate key holds a non-dict, merge_configs deep
|
||||
semantics with NO aliasing of the base config (the fixed bug — the old
|
||||
shallow copy let mutations of the merged result leak into base's nested
|
||||
dicts), simplified schema validation including the caught-TypeError path
|
||||
when a schema 'type' is given as a string, plugin config key conventions
|
||||
('{plugin_id}_config', enabled defaults True), and required-key checks
|
||||
where a key present with value None counts as present.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common.config_helper import ConfigHelper
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def helper():
|
||||
return ConfigHelper()
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
def test_missing_file_returns_empty_dict(self, helper, tmp_path):
|
||||
assert helper.load_config(tmp_path / "nope.json") == {}
|
||||
|
||||
def test_malformed_json_returns_empty_dict(self, helper, tmp_path):
|
||||
path = tmp_path / "bad.json"
|
||||
path.write_text("{ this is not json", encoding="utf-8")
|
||||
assert helper.load_config(path) == {}
|
||||
|
||||
def test_top_level_list_returned_as_is(self, helper, tmp_path):
|
||||
# load_config does not enforce a dict shape: a JSON list comes
|
||||
# straight back. Pinned as a characterization of current behavior.
|
||||
path = tmp_path / "list.json"
|
||||
path.write_text("[1, 2, 3]", encoding="utf-8")
|
||||
assert helper.load_config(path) == [1, 2, 3]
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
def test_round_trip(self, helper, tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
config = {'display': {'hardware': {'rows': 32}}, 'timezone': 'UTC'}
|
||||
assert helper.save_config(config, path) is True
|
||||
assert helper.load_config(path) == config
|
||||
|
||||
def test_creates_parent_directories(self, helper, tmp_path):
|
||||
path = tmp_path / "deep" / "nested" / "config.json"
|
||||
assert helper.save_config({'a': 1}, path) is True
|
||||
assert path.exists()
|
||||
assert helper.load_config(path) == {'a': 1}
|
||||
|
||||
def test_non_ascii_survives_round_trip(self, helper, tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
config = {'city': 'Zürich', 'note': 'météo ☀'}
|
||||
assert helper.save_config(config, path) is True
|
||||
assert helper.load_config(path) == config
|
||||
# ensure_ascii=False: characters are written raw, not \u-escaped
|
||||
assert 'Zürich' in path.read_text(encoding='utf-8')
|
||||
|
||||
def test_directory_path_returns_false_not_raise(self, helper, tmp_path):
|
||||
assert helper.save_config({'a': 1}, tmp_path) is False
|
||||
|
||||
|
||||
class TestGetConfigValue:
|
||||
def test_dot_notation_hit(self, helper):
|
||||
config = {'display': {'hardware': {'rows': 32}}}
|
||||
assert helper.get_config_value(config, 'display.hardware.rows') == 32
|
||||
|
||||
def test_missing_returns_default(self, helper):
|
||||
sentinel = object()
|
||||
assert helper.get_config_value({}, 'display.rows', default=sentinel) is sentinel
|
||||
|
||||
def test_intermediate_non_dict_returns_default(self, helper):
|
||||
config = {'display': 'not-a-dict'}
|
||||
assert helper.get_config_value(config, 'display.hardware.rows', default=64) == 64
|
||||
|
||||
def test_required_missing_raises_keyerror(self, helper):
|
||||
with pytest.raises(KeyError):
|
||||
helper.get_config_value({}, 'display.rows', required=True)
|
||||
|
||||
|
||||
class TestSetConfigValue:
|
||||
def test_sets_top_level(self, helper):
|
||||
config = {}
|
||||
helper.set_config_value(config, 'timezone', 'UTC')
|
||||
assert config == {'timezone': 'UTC'}
|
||||
|
||||
def test_auto_creates_intermediates(self, helper):
|
||||
config = {}
|
||||
helper.set_config_value(config, 'display.hardware.rows', 32)
|
||||
assert config == {'display': {'hardware': {'rows': 32}}}
|
||||
|
||||
def test_silent_failure_on_non_dict_intermediate(self, helper):
|
||||
# 'a' exists but holds an int; the assignment attempt raises
|
||||
# TypeError internally, which set_config_value swallows and logs.
|
||||
# The config is left unchanged — pinned silent-failure contract.
|
||||
config = {'a': 5}
|
||||
helper.set_config_value(config, 'a.b', 1)
|
||||
assert config == {'a': 5}
|
||||
|
||||
|
||||
class TestMergeConfigs:
|
||||
def test_nested_dicts_merge_recursively(self, helper):
|
||||
base = {'display': {'rows': 32, 'cols': 64}, 'timezone': 'UTC'}
|
||||
override = {'display': {'cols': 128, 'brightness': 90}}
|
||||
merged = helper.merge_configs(base, override)
|
||||
assert merged == {
|
||||
'display': {'rows': 32, 'cols': 128, 'brightness': 90},
|
||||
'timezone': 'UTC',
|
||||
}
|
||||
|
||||
def test_scalar_override_wins_over_dict(self, helper):
|
||||
merged = helper.merge_configs({'display': {'rows': 32}}, {'display': 7})
|
||||
assert merged['display'] == 7
|
||||
|
||||
def test_dict_override_wins_over_scalar(self, helper):
|
||||
merged = helper.merge_configs({'display': 7}, {'display': {'rows': 32}})
|
||||
assert merged['display'] == {'rows': 32}
|
||||
|
||||
def test_no_aliasing_of_base(self, helper):
|
||||
# Post-fix: merge deep-copies base, so mutating the result never
|
||||
# leaks back into the caller's base config.
|
||||
base = {'display': {'x': 1}}
|
||||
merged = helper.merge_configs(base, {})
|
||||
assert merged['display'] is not base['display']
|
||||
merged['display']['x'] = 99
|
||||
assert base['display']['x'] == 1
|
||||
|
||||
def test_inputs_unchanged(self, helper):
|
||||
base = {'a': {'b': 1}}
|
||||
override = {'a': {'c': 2}}
|
||||
helper.merge_configs(base, override)
|
||||
assert base == {'a': {'b': 1}}
|
||||
assert override == {'a': {'c': 2}}
|
||||
|
||||
def test_no_aliasing_of_override_values(self, helper):
|
||||
# The non-recursive branch must deep-copy the override value too:
|
||||
# mutating a merged-in list or dict must not reach back into
|
||||
# override_config.
|
||||
override = {'teams': ['A', 'B'], 'nested': {'x': [1]}}
|
||||
merged = helper.merge_configs({}, override)
|
||||
merged['teams'].append('C')
|
||||
merged['nested']['x'].append(2)
|
||||
assert override == {'teams': ['A', 'B'], 'nested': {'x': [1]}}
|
||||
|
||||
|
||||
class TestValidateConfig:
|
||||
def test_no_schema_dict_is_valid(self, helper):
|
||||
assert helper.validate_config({'a': 1}) is True
|
||||
|
||||
def test_no_schema_list_is_invalid(self, helper):
|
||||
assert helper.validate_config([1, 2]) is False
|
||||
|
||||
def test_required_key_missing_is_invalid(self, helper):
|
||||
schema = {'rows': {'required': True, 'type': int}}
|
||||
assert helper.validate_config({}, schema) is False
|
||||
|
||||
def test_optional_key_missing_is_valid(self, helper):
|
||||
schema = {'rows': {'required': False, 'type': int}}
|
||||
assert helper.validate_config({}, schema) is True
|
||||
|
||||
def test_wrong_type_is_invalid(self, helper):
|
||||
schema = {'rows': {'type': int}}
|
||||
assert helper.validate_config({'rows': 'thirty-two'}, schema) is False
|
||||
assert helper.validate_config({'rows': 32}, schema) is True
|
||||
|
||||
def test_allowed_values_violation_is_invalid(self, helper):
|
||||
schema = {'mode': {'allowed_values': ['clock', 'weather']}}
|
||||
assert helper.validate_config({'mode': 'stocks'}, schema) is False
|
||||
assert helper.validate_config({'mode': 'clock'}, schema) is True
|
||||
|
||||
def test_string_type_in_schema_is_invalid_via_typeerror(self, helper):
|
||||
# 'type' given as the STRING "int" makes isinstance() raise
|
||||
# TypeError; validate_config catches it and returns False rather
|
||||
# than raising. Pinned characterization.
|
||||
schema = {'rows': {'type': 'int'}}
|
||||
assert helper.validate_config({'rows': 32}, schema) is False
|
||||
|
||||
|
||||
class TestPluginConfigHelpers:
|
||||
def test_get_plugin_config_uses_suffixed_key(self, helper):
|
||||
plugin_cfg = {'enabled': True, 'display_duration': 30}
|
||||
assert helper.get_plugin_config({'clock_config': plugin_cfg}, 'clock') == plugin_cfg
|
||||
|
||||
def test_get_plugin_config_bare_id_key_not_found(self, helper):
|
||||
# Only '{plugin_id}_config' is consulted — a bare 'clock' section
|
||||
# is invisible to this helper. Pinned key contract.
|
||||
assert helper.get_plugin_config({'clock': {'enabled': True}}, 'clock') == {}
|
||||
|
||||
def test_create_default_config_wraps_in_suffixed_key(self, helper):
|
||||
defaults = {'enabled': True}
|
||||
assert helper.create_default_config('clock', defaults) == {'clock_config': defaults}
|
||||
|
||||
def test_is_plugin_enabled_defaults_true_for_unknown(self, helper):
|
||||
assert helper.is_plugin_enabled({}, 'clock') is True
|
||||
|
||||
def test_is_plugin_enabled_false_when_disabled(self, helper):
|
||||
config = {'clock_config': {'enabled': False}}
|
||||
assert helper.is_plugin_enabled(config, 'clock') is False
|
||||
|
||||
def test_is_plugin_enabled_ignores_bare_id_key(self, helper):
|
||||
# Disabled under the wrong key -> still reported enabled (default).
|
||||
config = {'clock': {'enabled': False}}
|
||||
assert helper.is_plugin_enabled(config, 'clock') is True
|
||||
|
||||
|
||||
class TestSportsAndDisplayHelpers:
|
||||
def test_get_display_config(self, helper):
|
||||
display = {'hardware': {'rows': 32}}
|
||||
assert helper.get_display_config({'display': display}) == display
|
||||
assert helper.get_display_config({}) == {}
|
||||
|
||||
def test_get_sports_config_uses_scoreboard_suffix(self, helper):
|
||||
sport_cfg = {'favorite_teams': ['TB']}
|
||||
config = {'football_scoreboard': sport_cfg}
|
||||
assert helper.get_sports_config(config, 'football') == sport_cfg
|
||||
assert helper.get_sports_config(config, 'hockey') == {}
|
||||
|
||||
def test_get_favorite_teams(self, helper):
|
||||
config = {'football_scoreboard': {'favorite_teams': ['TB', 'DAL']}}
|
||||
assert helper.get_favorite_teams(config, 'football') == ['TB', 'DAL']
|
||||
assert helper.get_favorite_teams({}, 'football') == []
|
||||
|
||||
def test_get_display_modes(self, helper):
|
||||
modes = {'live': True, 'recent': False}
|
||||
config = {'football_scoreboard': {'display_modes': modes}}
|
||||
assert helper.get_display_modes(config, 'football') == modes
|
||||
assert helper.get_display_modes({}, 'football') == {}
|
||||
|
||||
|
||||
class TestValidateRequiredKeys:
|
||||
def test_returns_missing_subset(self, helper):
|
||||
config = {'a': 1, 'c': {'d': 2}}
|
||||
missing = helper.validate_required_keys(config, ['a', 'b', 'c.d', 'c.e'])
|
||||
assert missing == ['b', 'c.e']
|
||||
|
||||
def test_dot_notation_present(self, helper):
|
||||
config = {'display': {'hardware': {'rows': 32}}}
|
||||
assert helper.validate_required_keys(config, ['display.hardware.rows']) == []
|
||||
|
||||
def test_empty_requirements(self, helper):
|
||||
assert helper.validate_required_keys({'a': 1}, []) == []
|
||||
|
||||
def test_present_with_none_counts_as_present(self, helper):
|
||||
# _has_key checks key membership, not truthiness — a key set to
|
||||
# None is NOT reported missing. Pinned semantics.
|
||||
assert helper.validate_required_keys({'a': None}, ['a']) == []
|
||||
@@ -0,0 +1,143 @@
|
||||
"""GET /config/main must not hand out credentials.
|
||||
|
||||
The endpoint returned the raw config to anyone who could reach the port, and
|
||||
this web interface has no authentication of any kind. Measured against a live
|
||||
rig, an unauthenticated request returned:
|
||||
|
||||
github.api_token 40 chars
|
||||
incoming-packages.ha_token 183 chars
|
||||
jellyfin-now-playing.api_key 32 chars
|
||||
ledmatrix-weather.api_key 32 chars
|
||||
on-air.mqtt_password 8 chars
|
||||
youtube.api_key 20 chars
|
||||
youtube-stats.api_key 39 chars
|
||||
|
||||
A GitHub token and a Home Assistant long-lived token among them.
|
||||
|
||||
The x-secret masking the plugin config endpoints use does not apply here: this
|
||||
endpoint never consults a schema, and core keys such as github.api_token have
|
||||
no schema to carry the marker. Several of those fields *are* tagged x-secret in
|
||||
their plugin's schema and were still returned in full, which is what makes the
|
||||
schema route the wrong one to rely on for this endpoint.
|
||||
|
||||
Matching on field name is blunt. For a whole-config dump it is the right
|
||||
default: anything named like a credential should not leave the process, and a
|
||||
new plugin that adds a differently-shaped secret is covered without anyone
|
||||
remembering to tag it.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
_looks_like_a_credential,
|
||||
_redact_credentials,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
"password", "mqtt_password", "opensky_password", "passwd",
|
||||
"api_key", "apikey", "API_KEY", "flightaware_api_key",
|
||||
"token", "ha_token", "api_token", "access_token",
|
||||
"secret", "client_secret", "spotify_client_secret",
|
||||
"access_key", "private_key",
|
||||
])
|
||||
def test_credential_names_are_recognised(name):
|
||||
assert _looks_like_a_credential(name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
"timezone", "city", "brightness", "enabled", "update_interval",
|
||||
"favorite_teams", "display_duration", "keyword",
|
||||
])
|
||||
def test_ordinary_names_are_left_alone(name):
|
||||
assert not _looks_like_a_credential(name)
|
||||
|
||||
|
||||
def test_the_measured_leak_is_closed():
|
||||
"""The exact shape taken off the rig."""
|
||||
config = {
|
||||
"github": {"api_token": "ghp_" + "x" * 36},
|
||||
"incoming-packages": {"ha_token": "y" * 183, "enabled": True},
|
||||
"jellyfin-now-playing": {"api_key": "z" * 32},
|
||||
"on-air": {"mqtt_password": "hunter22"},
|
||||
"youtube": {"api_key": "k" * 20},
|
||||
"timezone": "America/New_York",
|
||||
}
|
||||
out = _redact_credentials(config)
|
||||
assert out["github"]["api_token"] == ""
|
||||
assert out["incoming-packages"]["ha_token"] == ""
|
||||
assert out["jellyfin-now-playing"]["api_key"] == ""
|
||||
assert out["on-air"]["mqtt_password"] == ""
|
||||
assert out["youtube"]["api_key"] == ""
|
||||
# Everything else survives, or the config editor breaks.
|
||||
assert out["timezone"] == "America/New_York"
|
||||
assert out["incoming-packages"]["enabled"] is True
|
||||
|
||||
|
||||
def test_nested_and_listed_credentials_are_reached():
|
||||
config = {"a": {"b": {"c": {"password": "p"}}},
|
||||
"feeds": [{"name": "x", "api_key": "k"}, {"name": "y"}]}
|
||||
out = _redact_credentials(config)
|
||||
assert out["a"]["b"]["c"]["password"] == ""
|
||||
assert out["feeds"][0]["api_key"] == ""
|
||||
assert out["feeds"][0]["name"] == "x"
|
||||
|
||||
|
||||
def test_the_original_is_not_mutated():
|
||||
"""The caller holds the live config; redaction must not edit it in place."""
|
||||
config = {"github": {"api_token": "keepme"}}
|
||||
_redact_credentials(config)
|
||||
assert config["github"]["api_token"] == "keepme"
|
||||
|
||||
|
||||
def test_a_credential_shaped_container_is_still_walked():
|
||||
"""`secrets: {...}` is a section name, not a value to blank."""
|
||||
config = {"secrets": {"api_key": "k", "note": "keep"}}
|
||||
out = _redact_credentials(config)
|
||||
assert out["secrets"]["api_key"] == ""
|
||||
assert out["secrets"]["note"] == "keep"
|
||||
|
||||
|
||||
def test_non_dict_input_passes_through():
|
||||
assert _redact_credentials("plain") == "plain"
|
||||
assert _redact_credentials(7) == 7
|
||||
assert _redact_credentials(None) is None
|
||||
|
||||
|
||||
def test_the_endpoint_itself_redacts():
|
||||
"""Through the view function, not the helper.
|
||||
|
||||
The helper tests above all passed with the route still returning
|
||||
`config` -- reverting the one line that calls the redactor changed
|
||||
nothing, because nothing exercised the route. A property asserted on a
|
||||
helper is not a property asserted on the endpoint, and it is the endpoint
|
||||
that is exposed to the network.
|
||||
"""
|
||||
import json as _json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import flask
|
||||
|
||||
from web_interface.blueprints import api_v3 as mod
|
||||
|
||||
raw = {"github": {"api_token": "ghp_secret_value"},
|
||||
"timezone": "America/New_York"}
|
||||
|
||||
manager = MagicMock()
|
||||
manager.load_config.return_value = raw
|
||||
previous = getattr(mod.api_v3, "config_manager", None)
|
||||
mod.api_v3.config_manager = manager
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
try:
|
||||
with app.test_request_context("/config/main"):
|
||||
response = mod.get_main_config()
|
||||
payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data)
|
||||
finally:
|
||||
mod.api_v3.config_manager = previous
|
||||
|
||||
data = payload["data"]
|
||||
assert data["github"]["api_token"] == "", (
|
||||
"the endpoint returned the token; the redactor is not wired in")
|
||||
assert data["timezone"] == "America/New_York"
|
||||
# And the config the manager handed over is untouched.
|
||||
assert raw["github"]["api_token"] == "ghp_secret_value"
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Tests for the ConfigManager secrets round-trip and the load_config fast path.
|
||||
|
||||
The contract under test: config_secrets.json values are deep-merged INTO the
|
||||
in-memory config at load time, and stripped back OUT before anything is
|
||||
written to config.json — so secrets live in exactly one file on disk. This
|
||||
suite pins that round-trip plus its sharp edges, including the guard that a
|
||||
save REFUSES (ConfigError) when the secrets file exists but can't be loaded,
|
||||
rather than leaking merged secrets into config.json in plaintext.
|
||||
|
||||
Complements test_config_manager.py, which covers loading/migration/validation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config_manager import ConfigManager
|
||||
from src.exceptions import ConfigError
|
||||
|
||||
|
||||
def make_manager(tmp_path, config=None, secrets=None):
|
||||
"""A ConfigManager over tmp_path files, template migration neutralized."""
|
||||
config_file = tmp_path / "config.json"
|
||||
secrets_file = tmp_path / "config_secrets.json"
|
||||
config_file.write_text(json.dumps(config if config is not None else {}))
|
||||
if secrets is not None:
|
||||
secrets_file.write_text(json.dumps(secrets))
|
||||
manager = ConfigManager(config_path=str(config_file),
|
||||
secrets_path=str(secrets_file))
|
||||
# Point the (CWD-relative) template at nothing so migration never runs —
|
||||
# these tests assert exact on-disk contents.
|
||||
manager.template_path = str(tmp_path / "no-template.json")
|
||||
return manager
|
||||
|
||||
|
||||
class TestLoadMergesSecrets:
|
||||
def test_secrets_deep_merged_into_config(self, tmp_path):
|
||||
manager = make_manager(
|
||||
tmp_path,
|
||||
config={"weather": {"city": "Austin"}, "timezone": "UTC"},
|
||||
secrets={"weather": {"api_key": "s3cret"}},
|
||||
)
|
||||
loaded = manager.load_config()
|
||||
assert loaded["weather"] == {"city": "Austin", "api_key": "s3cret"}
|
||||
assert loaded["timezone"] == "UTC"
|
||||
|
||||
def test_secret_scalar_overrides_config_value(self, tmp_path):
|
||||
manager = make_manager(
|
||||
tmp_path,
|
||||
config={"weather": {"api_key": "YOUR_API_KEY"}},
|
||||
secrets={"weather": {"api_key": "real-key"}},
|
||||
)
|
||||
assert manager.load_config()["weather"]["api_key"] == "real-key"
|
||||
|
||||
def test_missing_secrets_file_loads_config_fine(self, tmp_path):
|
||||
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
||||
assert manager.load_config() == {"timezone": "UTC"}
|
||||
|
||||
def test_corrupt_secrets_file_loads_config_without_secrets(self, tmp_path):
|
||||
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
||||
(tmp_path / "config_secrets.json").write_text("{not json")
|
||||
loaded = manager.load_config()
|
||||
assert loaded["timezone"] == "UTC"
|
||||
|
||||
|
||||
class TestSaveStripsSecrets:
|
||||
def test_round_trip_keeps_secrets_out_of_config_json(self, tmp_path):
|
||||
manager = make_manager(
|
||||
tmp_path,
|
||||
config={"weather": {"city": "Austin"}},
|
||||
secrets={"weather": {"api_key": "s3cret"}},
|
||||
)
|
||||
loaded = manager.load_config()
|
||||
assert loaded["weather"]["api_key"] == "s3cret" # merged in memory
|
||||
|
||||
manager.save_config(loaded)
|
||||
|
||||
on_disk = json.loads((tmp_path / "config.json").read_text())
|
||||
assert "api_key" not in on_disk.get("weather", {})
|
||||
assert on_disk["weather"]["city"] == "Austin"
|
||||
# In-memory config still carries the secret for runtime use.
|
||||
assert manager.config["weather"]["api_key"] == "s3cret"
|
||||
|
||||
def test_group_dropped_when_only_secrets_remain(self, tmp_path):
|
||||
# _strip_secrets_recursive drops a group entirely when nothing
|
||||
# non-secret is left in it.
|
||||
manager = make_manager(
|
||||
tmp_path,
|
||||
config={},
|
||||
secrets={"weather": {"api_key": "s3cret"}},
|
||||
)
|
||||
manager.save_config({"weather": {"api_key": "s3cret"}, "timezone": "UTC"})
|
||||
on_disk = json.loads((tmp_path / "config.json").read_text())
|
||||
assert on_disk == {"timezone": "UTC"}
|
||||
|
||||
def test_scalar_secret_key_stripped_at_top_level(self, tmp_path):
|
||||
manager = make_manager(tmp_path, config={}, secrets={"token": "t"})
|
||||
manager.save_config({"token": "t", "timezone": "UTC"})
|
||||
on_disk = json.loads((tmp_path / "config.json").read_text())
|
||||
assert on_disk == {"timezone": "UTC"}
|
||||
|
||||
def test_corrupt_secrets_file_refuses_save_no_plaintext_leak(self, tmp_path):
|
||||
# Regression guard: when the secrets file exists but is corrupt at
|
||||
# save time, stripping is impossible — the save must raise instead of
|
||||
# writing the merged secrets into config.json in plaintext (the
|
||||
# historical behavior).
|
||||
manager = make_manager(
|
||||
tmp_path,
|
||||
config={"weather": {"city": "Austin"}},
|
||||
secrets={"weather": {"api_key": "s3cret"}},
|
||||
)
|
||||
loaded = manager.load_config()
|
||||
(tmp_path / "config_secrets.json").write_text("{corrupt")
|
||||
|
||||
with pytest.raises(ConfigError):
|
||||
manager.save_config(loaded)
|
||||
|
||||
# On-disk config untouched: no secret leaked.
|
||||
on_disk = json.loads((tmp_path / "config.json").read_text())
|
||||
assert "api_key" not in on_disk.get("weather", {})
|
||||
|
||||
def test_corrupt_secrets_file_refuses_atomic_save_too(self, tmp_path):
|
||||
# Same refusal on the atomic save path, which shared the leak.
|
||||
manager = make_manager(
|
||||
tmp_path,
|
||||
config={"weather": {"city": "Austin"}},
|
||||
secrets={"weather": {"api_key": "s3cret"}},
|
||||
)
|
||||
loaded = manager.load_config()
|
||||
(tmp_path / "config_secrets.json").write_text("{corrupt")
|
||||
|
||||
with pytest.raises(ConfigError):
|
||||
manager.save_config_atomic(loaded)
|
||||
|
||||
on_disk = json.loads((tmp_path / "config.json").read_text())
|
||||
assert "api_key" not in on_disk.get("weather", {})
|
||||
|
||||
|
||||
class TestLoadFastPath:
|
||||
def test_unchanged_files_return_cached_dict(self, tmp_path):
|
||||
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
||||
first = manager.load_config()
|
||||
second = manager.load_config()
|
||||
assert second is first # same aliased dict, no re-read
|
||||
|
||||
def test_touching_secrets_file_invalidates_cache(self, tmp_path):
|
||||
manager = make_manager(
|
||||
tmp_path,
|
||||
config={"weather": {}},
|
||||
secrets={"weather": {"api_key": "old"}},
|
||||
)
|
||||
assert manager.load_config()["weather"]["api_key"] == "old"
|
||||
|
||||
secrets_file = tmp_path / "config_secrets.json"
|
||||
secrets_file.write_text(json.dumps({"weather": {"api_key": "new"}}))
|
||||
# Force a different mtime_ns in case the write landed within the
|
||||
# filesystem's timestamp granularity.
|
||||
os.utime(secrets_file, ns=(1, 1))
|
||||
|
||||
assert manager.load_config()["weather"]["api_key"] == "new"
|
||||
|
||||
def test_same_mtime_same_size_change_served_stale(self, tmp_path):
|
||||
# Characterized fast-path blind spot: the signature is (mtime_ns,
|
||||
# size) only, so a same-length content swap with a forged identical
|
||||
# mtime is not detected. Real writes bump mtime_ns, so this is
|
||||
# acceptable — but it is a contract worth pinning.
|
||||
manager = make_manager(tmp_path, config={"timezone": "AAA"})
|
||||
config_file = tmp_path / "config.json"
|
||||
os.utime(config_file, ns=(1_000_000_000, 1_000_000_000))
|
||||
manager._loaded_sig = None
|
||||
first = manager.load_config()
|
||||
assert first["timezone"] == "AAA"
|
||||
|
||||
config_file.write_text(json.dumps({"timezone": "BBB"})) # same length
|
||||
os.utime(config_file, ns=(1_000_000_000, 1_000_000_000))
|
||||
|
||||
assert manager.load_config()["timezone"] == "AAA" # stale, by design
|
||||
|
||||
|
||||
class TestArraySecretStripAndMerge:
|
||||
"""Array-item secrets round-trip (parallel-placeholder lists).
|
||||
|
||||
secret_helpers.separate_secrets emits array secrets as a list parallel
|
||||
to the regular list, with {} for items that carry no secrets. Strip
|
||||
must remove the secret fields from config.json while preserving item
|
||||
indices; load must merge them back into the right items. The regular
|
||||
list's length is authoritative in both directions.
|
||||
"""
|
||||
|
||||
def test_strip_removes_array_item_secrets_keeps_indices(self, tmp_path):
|
||||
manager = make_manager(tmp_path)
|
||||
data = {"plugin": {"accounts": [
|
||||
{"name": "a", "token": "ta"},
|
||||
{"name": "b"},
|
||||
]}}
|
||||
secrets = {"plugin": {"accounts": [{"token": "ta"}, {}]}}
|
||||
stripped = manager._strip_secrets_recursive(data, secrets)
|
||||
assert stripped == {"plugin": {"accounts": [{"name": "a"}, {"name": "b"}]}}
|
||||
|
||||
def test_strip_keeps_all_placeholder_items(self, tmp_path):
|
||||
# Even when every item strips to nothing extra, the list survives
|
||||
# with its indices — required for merge-on-load alignment.
|
||||
manager = make_manager(tmp_path)
|
||||
data = {"accounts": [{"token": "t1"}, {"token": "t2"}]}
|
||||
secrets = {"accounts": [{"token": "t1"}, {"token": "t2"}]}
|
||||
stripped = manager._strip_secrets_recursive(data, secrets)
|
||||
assert stripped == {"accounts": [{}, {}]}
|
||||
|
||||
def test_strip_whole_scalar_array_secret_drops_key(self, tmp_path):
|
||||
# A list of secret scalars is a whole-key secret, not the parallel
|
||||
# shape — the key must vanish from config.json entirely.
|
||||
manager = make_manager(tmp_path)
|
||||
data = {"recovery_codes": ["a", "b"], "city": "Austin"}
|
||||
secrets = {"recovery_codes": ["a", "b"]}
|
||||
stripped = manager._strip_secrets_recursive(data, secrets)
|
||||
assert stripped == {"city": "Austin"}
|
||||
|
||||
def test_strip_shape_mismatch_drops_key(self, tmp_path):
|
||||
# Conservative contract: if the shapes disagree, never leak.
|
||||
manager = make_manager(tmp_path)
|
||||
data = {"accounts": {"name": "not-a-list"}}
|
||||
secrets = {"accounts": [{"token": "t"}]}
|
||||
stripped = manager._strip_secrets_recursive(data, secrets)
|
||||
assert stripped == {}
|
||||
|
||||
def test_strip_ignores_extra_secrets_entries(self, tmp_path):
|
||||
# Regular list length is authoritative: a user deleted an item.
|
||||
manager = make_manager(tmp_path)
|
||||
data = {"accounts": [{"name": "a", "token": "ta"}]}
|
||||
secrets = {"accounts": [{"token": "ta"}, {"token": "tb"}]}
|
||||
stripped = manager._strip_secrets_recursive(data, secrets)
|
||||
assert stripped == {"accounts": [{"name": "a"}]}
|
||||
|
||||
def test_merge_restores_array_item_secrets(self, tmp_path):
|
||||
manager = make_manager(tmp_path)
|
||||
target = {"accounts": [{"name": "a"}, {"name": "b"}]}
|
||||
manager._deep_merge(target, {"accounts": [{"token": "ta"}, {}]})
|
||||
assert target == {"accounts": [
|
||||
{"name": "a", "token": "ta"},
|
||||
{"name": "b"},
|
||||
]}
|
||||
|
||||
def test_merge_ignores_extra_secrets_entries_with_warning(self, tmp_path, caplog):
|
||||
manager = make_manager(tmp_path)
|
||||
target = {"accounts": [{"name": "a"}]}
|
||||
with caplog.at_level("WARNING"):
|
||||
manager._deep_merge(
|
||||
target, {"accounts": [{"token": "ta"}, {"token": "ghost"}]})
|
||||
assert target == {"accounts": [{"name": "a", "token": "ta"}]}
|
||||
assert any("longer than the config list" in r.message for r in caplog.records)
|
||||
|
||||
def test_merge_non_dict_item_replaced_by_secret(self, tmp_path):
|
||||
# Shape drift inside the list: the secret wins for that index.
|
||||
manager = make_manager(tmp_path)
|
||||
target = {"accounts": ["oddball", {"name": "b"}]}
|
||||
manager._deep_merge(target, {"accounts": [{"token": "ta"}, {}]})
|
||||
assert target == {"accounts": [{"token": "ta"}, {"name": "b"}]}
|
||||
|
||||
def test_merge_whole_scalar_array_still_replaces(self, tmp_path):
|
||||
# Legacy behavior preserved: a non-parallel list replaces wholesale.
|
||||
manager = make_manager(tmp_path)
|
||||
target = {"recovery_codes": ["old"]}
|
||||
manager._deep_merge(target, {"recovery_codes": ["new1", "new2"]})
|
||||
assert target == {"recovery_codes": ["new1", "new2"]}
|
||||
|
||||
def test_full_save_load_round_trip(self, tmp_path):
|
||||
# End to end on real files: save strips array secrets out of
|
||||
# config.json; load merges them back into the right items.
|
||||
manager = make_manager(
|
||||
tmp_path,
|
||||
config={"plugin": {"accounts": [
|
||||
{"name": "a", "token": "s3cret-a"},
|
||||
{"name": "b", "token": "s3cret-b"},
|
||||
]}},
|
||||
secrets={"plugin": {"accounts": [
|
||||
{"token": "s3cret-a"}, {"token": "s3cret-b"},
|
||||
]}},
|
||||
)
|
||||
loaded = manager.load_config()
|
||||
assert loaded["plugin"]["accounts"][0]["token"] == "s3cret-a"
|
||||
|
||||
manager.save_config(loaded)
|
||||
|
||||
raw = (tmp_path / "config.json").read_text()
|
||||
assert "s3cret" not in raw
|
||||
on_disk = json.loads(raw)
|
||||
assert on_disk["plugin"]["accounts"] == [{"name": "a"}, {"name": "b"}]
|
||||
|
||||
# A fresh manager (constructed directly — make_manager would
|
||||
# overwrite the just-saved config.json) re-merges from the secrets
|
||||
# file on load.
|
||||
fresh = ConfigManager(config_path=str(tmp_path / "config.json"),
|
||||
secrets_path=str(tmp_path / "config_secrets.json"))
|
||||
fresh.template_path = str(tmp_path / "no-template.json")
|
||||
reloaded = fresh.load_config()
|
||||
assert reloaded["plugin"]["accounts"] == [
|
||||
{"name": "a", "token": "s3cret-a"},
|
||||
{"name": "b", "token": "s3cret-b"},
|
||||
]
|
||||
|
||||
def test_whole_item_secret_list_never_leaks_values(self, tmp_path):
|
||||
# When the ENTIRE array item is secret (schema marks both key[]
|
||||
# and key[].field), separate_secrets stores the full item dicts in
|
||||
# the secrets file. That shape also matches the parallel-list
|
||||
# discriminator — which is safe: strip drops every leaf key that
|
||||
# appears in the secret item, so only empty {} skeletons (item
|
||||
# count, no values) can reach config.json, and merge-on-load
|
||||
# restores the full items from those skeletons.
|
||||
from src.web_interface.secret_helpers import (
|
||||
find_secret_fields, separate_secrets)
|
||||
schema_props = {"accounts": {
|
||||
"type": "array",
|
||||
"items": {"type": "object", "x-secret": True, "properties": {
|
||||
"id": {"type": "string"},
|
||||
"token": {"type": "string", "x-secret": True},
|
||||
}},
|
||||
}}
|
||||
paths = find_secret_fields(schema_props)
|
||||
assert paths == {"accounts[]", "accounts[].token"}
|
||||
full = {"accounts": [{"id": "i1", "token": "s3cret-a"},
|
||||
{"id": "i2", "token": "s3cret-b"}]}
|
||||
_, secrets = separate_secrets(full, paths)
|
||||
assert secrets == full # whole items are secret
|
||||
|
||||
manager = make_manager(tmp_path)
|
||||
stripped = manager._strip_secrets_recursive(full, secrets)
|
||||
assert stripped == {"accounts": [{}, {}]}
|
||||
|
||||
raw = json.dumps(stripped)
|
||||
assert "s3cret" not in raw and "i1" not in raw
|
||||
|
||||
manager._deep_merge(stripped, secrets)
|
||||
assert stripped == full # round trip restores the items
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Drift guard: three components independently answer "where is plugin X?" and
|
||||
their answers must stay coherent — plus the `.standalone-backup-` naming
|
||||
contract that install/rollback shares with discovery.
|
||||
|
||||
The three resolvers:
|
||||
1. PluginManager._scan_directory_for_plugins — scans ONLY the configured dir.
|
||||
2. PluginStoreManager._find_plugin_path — configured dir, then a sibling
|
||||
`plugins/` fallback derived from the configured dir's parent.
|
||||
3. SchemaManager.get_schema_path — configured dir, then project-root
|
||||
`plugins/`, then `plugin-repos/`, then case-insensitive scans.
|
||||
|
||||
The divergence is characterized (a plugin visible to the store/schema
|
||||
fallbacks but invisible to discovery is a real support-issue shape) so any
|
||||
change to the fallback chains is a deliberate one.
|
||||
|
||||
The `.standalone-backup-` contract: store_manager renames a plugin dir aside
|
||||
with that substring during install/rollback; discovery MUST skip such dirs
|
||||
or a half-finished install would surface a ghost plugin. The substring is
|
||||
duplicated as a literal in both files — this test breaks if either side
|
||||
changes it unilaterally.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
|
||||
def _write_plugin(base: Path, plugin_id: str, dir_name: str = None):
|
||||
plugin_dir = base / (dir_name or plugin_id)
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps({
|
||||
"id": plugin_id, "name": plugin_id, "version": "1.0.0",
|
||||
}))
|
||||
(plugin_dir / "config_schema.json").write_text(json.dumps({
|
||||
"type": "object", "properties": {"enabled": {"type": "boolean"}},
|
||||
}))
|
||||
return plugin_dir
|
||||
|
||||
|
||||
def _scanner():
|
||||
"""A PluginManager stripped to just its discovery machinery — the full
|
||||
constructor wires config/schema/health managers this test doesn't need."""
|
||||
pm = object.__new__(PluginManager)
|
||||
pm.logger = logging.getLogger("test_discovery_path_contract")
|
||||
pm._discovery_lock = threading.Lock()
|
||||
pm.plugin_manifests = {}
|
||||
pm.plugin_directories = {}
|
||||
return pm
|
||||
|
||||
|
||||
class TestResolversAgreeOnConfiguredDir:
|
||||
def test_all_three_find_a_plugin_in_the_configured_dir(self, tmp_path):
|
||||
plugins_dir = tmp_path / "plugin-repos"
|
||||
plugin_dir = _write_plugin(plugins_dir, "demo-plugin")
|
||||
|
||||
found = _scanner()._scan_directory_for_plugins(plugins_dir)
|
||||
assert found == ["demo-plugin"]
|
||||
|
||||
store = PluginStoreManager(
|
||||
plugins_dir=str(plugins_dir),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
assert store._find_plugin_path("demo-plugin") == plugin_dir
|
||||
|
||||
schema = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
|
||||
assert schema.get_schema_path("demo-plugin") == \
|
||||
plugin_dir / "config_schema.json"
|
||||
|
||||
|
||||
class TestFallbackDivergence:
|
||||
def test_plugin_only_in_plugins_dir_fallback(self, tmp_path):
|
||||
"""Characterized divergence: configured dir is plugin-repos/, but the
|
||||
plugin sits in a sibling plugins/. The store and schema fallbacks
|
||||
find it; discovery does NOT — so the plugin is installable/
|
||||
configurable but never loads. Pinned so a change to any fallback
|
||||
chain shows up here."""
|
||||
configured = tmp_path / "plugin-repos"
|
||||
configured.mkdir()
|
||||
legacy_dir = _write_plugin(tmp_path / "plugins", "legacy-plugin")
|
||||
|
||||
# Discovery: invisible.
|
||||
assert _scanner()._scan_directory_for_plugins(configured) == []
|
||||
|
||||
# Store fallback: visible (parent-of-configured / 'plugins').
|
||||
store = PluginStoreManager(
|
||||
plugins_dir=str(configured),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
assert store._find_plugin_path("legacy-plugin") == legacy_dir
|
||||
|
||||
# Schema fallback: visible (project_root / 'plugins').
|
||||
schema = SchemaManager(plugins_dir=configured, project_root=tmp_path)
|
||||
assert schema.get_schema_path("legacy-plugin") == \
|
||||
legacy_dir / "config_schema.json"
|
||||
|
||||
def test_schema_manager_probes_plugins_before_plugin_repos(self, tmp_path):
|
||||
# Documented order (also in CLAUDE.md): plugins/ wins over
|
||||
# plugin-repos/ when the same id exists in both.
|
||||
in_plugins = _write_plugin(tmp_path / "plugins", "dupe")
|
||||
_write_plugin(tmp_path / "plugin-repos", "dupe")
|
||||
schema = SchemaManager(plugins_dir=None, project_root=tmp_path)
|
||||
assert schema.get_schema_path("dupe") == \
|
||||
in_plugins / "config_schema.json"
|
||||
|
||||
def test_schema_manager_case_insensitive_fallback(self, tmp_path):
|
||||
plugin_dir = _write_plugin(tmp_path / "plugins", "MyPlugin",
|
||||
dir_name="MyPlugin")
|
||||
schema = SchemaManager(plugins_dir=None, project_root=tmp_path)
|
||||
assert schema.get_schema_path("myplugin") == \
|
||||
plugin_dir / "config_schema.json"
|
||||
|
||||
|
||||
class TestStandaloneBackupContract:
|
||||
def test_discovery_skips_backup_dirs(self, tmp_path):
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
_write_plugin(plugins_dir, "real-plugin")
|
||||
# A rollback-in-progress dir with a valid manifest must NOT surface.
|
||||
_write_plugin(plugins_dir, "real-plugin",
|
||||
dir_name="real-plugin.standalone-backup-migrating")
|
||||
|
||||
found = _scanner()._scan_directory_for_plugins(plugins_dir)
|
||||
assert found == ["real-plugin"]
|
||||
|
||||
def test_backup_substring_literal_matches_across_files(self):
|
||||
"""The substring is duplicated in plugin_manager (skip check) and
|
||||
store_manager (rename-aside names). If either side changes it, the
|
||||
other silently stops honoring the contract — this test is the
|
||||
tripwire."""
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
pm_text = (root / "src/plugin_system/plugin_manager.py").read_text()
|
||||
sm_text = (root / "src/plugin_system/store_manager.py").read_text()
|
||||
assert "'.standalone-backup-'" in pm_text.replace('"', "'")
|
||||
assert ".standalone-backup-" in sm_text
|
||||
|
||||
|
||||
class TestSkinTargetResolution:
|
||||
def _store(self, tmp_path):
|
||||
return PluginStoreManager(
|
||||
plugins_dir=str(tmp_path / "plugins"),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
|
||||
def test_valid_skin_id_resolves_inside_skins_dir(self, tmp_path):
|
||||
from src.skin_system import skin_runtime
|
||||
store = self._store(tmp_path)
|
||||
target = store._resolve_skin_target("my-skin")
|
||||
assert target is not None
|
||||
assert target.parent == skin_runtime.get_skins_directory().resolve()
|
||||
|
||||
@pytest.mark.parametrize("bad_id", [
|
||||
"../evil",
|
||||
"..",
|
||||
"a/../../etc",
|
||||
"/etc/passwd",
|
||||
"skin/../../outside",
|
||||
"",
|
||||
None,
|
||||
123,
|
||||
])
|
||||
def test_traversal_and_malformed_ids_rejected(self, tmp_path, bad_id):
|
||||
store = self._store(tmp_path)
|
||||
assert store._resolve_skin_target(bad_id) is None
|
||||
@@ -15,13 +15,6 @@ class TestDisplayControllerInitialization:
|
||||
assert test_display_controller.plugin_manager is not None
|
||||
assert test_display_controller.available_modes == []
|
||||
|
||||
@pytest.mark.skip(reason="No assertions; init logic is covered by test_init_success and fixture setup")
|
||||
def test_plugin_discovery_and_loading(self, test_display_controller):
|
||||
"""Test plugin discovery and loading during initialization."""
|
||||
pm = test_display_controller.plugin_manager
|
||||
pm.discover_plugins.return_value = ["plugin1", "plugin2"]
|
||||
pm.get_plugin.return_value = MagicMock()
|
||||
|
||||
|
||||
class TestDisplayControllerModeRotation:
|
||||
"""Test display mode rotation logic."""
|
||||
@@ -345,29 +338,46 @@ class TestDisplayControllerSchedule:
|
||||
"""Test schedule management."""
|
||||
|
||||
def test_schedule_disabled(self, test_display_controller):
|
||||
"""Test when schedule is disabled."""
|
||||
"""schedule.enabled=False keeps the display active even outside the
|
||||
configured window. (This test used to patch config_service, which
|
||||
_check_schedule never reads — it asserted the init default.)"""
|
||||
controller = test_display_controller
|
||||
schedule_config = {"schedule": {"enabled": False}}
|
||||
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
|
||||
controller.config['schedule'] = {
|
||||
"enabled": False,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00",
|
||||
}
|
||||
controller._schedule_checked_minute = None
|
||||
controller._tz = None
|
||||
controller.is_display_active = False # prove the method flips it back
|
||||
|
||||
with patch('src.display_controller.datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
||||
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
|
||||
mock_datetime.strptime = datetime.strptime
|
||||
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is True
|
||||
|
||||
def test_active_hours(self, test_display_controller):
|
||||
"""Test active hours check."""
|
||||
"""A time inside the window activates the display. (This test used
|
||||
to patch config_service, which _check_schedule never reads — it
|
||||
asserted the init default.)"""
|
||||
controller = test_display_controller
|
||||
controller.config['schedule'] = {
|
||||
"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00",
|
||||
}
|
||||
controller._schedule_checked_minute = None
|
||||
controller._tz = None
|
||||
controller.is_display_active = False # prove the method flips it on
|
||||
|
||||
with patch('src.display_controller.datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
||||
mock_datetime.now.return_value.time.return_value = datetime.strptime("12:00", "%H:%M").time()
|
||||
mock_datetime.strptime = datetime.strptime
|
||||
|
||||
schedule_config = {
|
||||
"schedule": {
|
||||
"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00"
|
||||
}
|
||||
}
|
||||
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is True
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Behavioral tests for DisplayController._check_schedule and
|
||||
_check_dim_schedule — the on/off window and night-dimming logic.
|
||||
|
||||
test_display_controller_optimizations.py::TestScheduleMinuteGate already
|
||||
covers the once-per-minute gating; this file covers what it doesn't:
|
||||
midnight-crossing windows, mode selection (global / per-day / legacy
|
||||
inference), per-day disabled days, invalid time strings, unknown
|
||||
timezones, boundary equality, and the transition-tracking flags.
|
||||
|
||||
Both methods read only self.config and a handful of instance attributes,
|
||||
so a bare stub via object.__new__ (the test_display_controller_vegas_tick
|
||||
pattern) is enough — no managers needed.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from src.display_controller import DisplayController # noqa: E402
|
||||
|
||||
|
||||
def make_controller(config=None, *, normal_brightness=90):
|
||||
dc = object.__new__(DisplayController)
|
||||
dc.config = config or {}
|
||||
dc._tz = None
|
||||
dc._schedule_checked_minute = None
|
||||
dc.is_display_active = True
|
||||
dc._was_display_active = True
|
||||
dc._normal_brightness = normal_brightness
|
||||
dc._dim_checked_minute = None
|
||||
dc._cached_target_brightness = None
|
||||
dc.is_dimmed = False
|
||||
dc._was_dimmed = False
|
||||
return dc
|
||||
|
||||
|
||||
def at(time_str, day="monday"):
|
||||
"""Context manager patching the controller module's clock."""
|
||||
patcher = patch("src.display_controller.datetime")
|
||||
mock_dt = patcher.start()
|
||||
mock_dt.strptime = datetime.strptime
|
||||
mock_dt.now.return_value.time.return_value = (
|
||||
datetime.strptime(time_str, "%H:%M").time())
|
||||
mock_dt.now.return_value.strftime.return_value.lower.return_value = day
|
||||
mock_dt.now.return_value.hour = int(time_str.split(":")[0])
|
||||
mock_dt.now.return_value.minute = int(time_str.split(":")[1])
|
||||
return patcher
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock():
|
||||
patchers = []
|
||||
|
||||
def _at(time_str, day="monday"):
|
||||
patchers.append(p := at(time_str, day))
|
||||
return p
|
||||
|
||||
yield _at
|
||||
for p in patchers:
|
||||
p.stop()
|
||||
|
||||
|
||||
def check_at(dc, time_str, day="monday", clock=None):
|
||||
"""Run _check_schedule at a mocked wall time, resetting the minute gate."""
|
||||
dc._schedule_checked_minute = None
|
||||
p = at(time_str, day)
|
||||
try:
|
||||
dc._check_schedule()
|
||||
finally:
|
||||
p.stop()
|
||||
return dc.is_display_active
|
||||
|
||||
|
||||
def dim_at(dc, time_str, day="monday"):
|
||||
dc._dim_checked_minute = None
|
||||
p = at(time_str, day)
|
||||
try:
|
||||
return dc._check_dim_schedule()
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
|
||||
class TestScheduleWindows:
|
||||
def _config(self, start, end, **extra):
|
||||
return {"schedule": {"enabled": True, "start_time": start,
|
||||
"end_time": end, **extra},
|
||||
"timezone": "UTC"}
|
||||
|
||||
def test_same_day_window(self):
|
||||
dc = make_controller(self._config("09:00", "17:00"))
|
||||
assert check_at(dc, "12:00") is True
|
||||
assert check_at(dc, "20:00") is False
|
||||
assert check_at(dc, "08:59") is False
|
||||
|
||||
def test_boundaries_are_inclusive(self):
|
||||
dc = make_controller(self._config("09:00", "17:00"))
|
||||
assert check_at(dc, "09:00") is True # now == start
|
||||
assert check_at(dc, "17:00") is True # now == end
|
||||
|
||||
def test_midnight_crossing_window(self):
|
||||
# 21:00 -> 07:00: active late evening AND early morning, inactive
|
||||
# mid-day.
|
||||
dc = make_controller(self._config("21:00", "07:00"))
|
||||
assert check_at(dc, "23:00") is True
|
||||
assert check_at(dc, "03:00") is True
|
||||
assert check_at(dc, "12:00") is False
|
||||
assert check_at(dc, "21:00") is True # boundary
|
||||
assert check_at(dc, "07:00") is True # boundary
|
||||
|
||||
def test_no_schedule_config_is_always_active(self):
|
||||
dc = make_controller({"timezone": "UTC"})
|
||||
dc.is_display_active = False
|
||||
dc._check_schedule()
|
||||
assert dc.is_display_active is True
|
||||
|
||||
def test_invalid_time_string_falls_back_to_active(self):
|
||||
dc = make_controller(self._config("9 o'clock", "17:00"))
|
||||
dc.is_display_active = False
|
||||
assert check_at(dc, "03:00") is True # ValueError -> stay on
|
||||
|
||||
def test_unknown_timezone_falls_back_to_utc(self):
|
||||
dc = make_controller({"schedule": {"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00"},
|
||||
"timezone": "Mars/Olympus_Mons"})
|
||||
assert check_at(dc, "12:00") is True
|
||||
import pytz
|
||||
assert dc._tz is pytz.UTC
|
||||
|
||||
|
||||
class TestScheduleModes:
|
||||
DAYS = {
|
||||
"monday": {"enabled": True, "start_time": "10:00",
|
||||
"end_time": "18:00"},
|
||||
"tuesday": {"enabled": False},
|
||||
}
|
||||
|
||||
def test_global_mode_ignores_days(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True, "mode": "global",
|
||||
"start_time": "09:00", "end_time": "17:00",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
# 09:30 is inside the global window but outside monday's per-day one.
|
||||
assert check_at(dc, "09:30", day="monday") is True
|
||||
|
||||
def test_per_day_mode_uses_day_window(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True, "mode": "per-day",
|
||||
"start_time": "09:00", "end_time": "17:00",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
assert check_at(dc, "09:30", day="monday") is False # before 10:00
|
||||
assert check_at(dc, "12:00", day="monday") is True
|
||||
|
||||
def test_per_day_underscore_spelling_accepted(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True, "mode": "per_day",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
assert check_at(dc, "12:00", day="monday") is True
|
||||
|
||||
def test_legacy_no_mode_infers_per_day_from_days_config(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True,
|
||||
"start_time": "09:00", "end_time": "17:00",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
assert check_at(dc, "09:30", day="monday") is False # per-day won
|
||||
|
||||
def test_per_day_disabled_day_turns_display_off(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True, "mode": "per-day",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
assert check_at(dc, "12:00", day="tuesday") is False
|
||||
|
||||
def test_per_day_missing_day_falls_back_to_global(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True, "mode": "per-day",
|
||||
"start_time": "09:00", "end_time": "17:00",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
# Wednesday has no per-day entry -> global window applies.
|
||||
assert check_at(dc, "09:30", day="wednesday") is True
|
||||
|
||||
def test_missing_enabled_key_means_enabled(self):
|
||||
# Backward compat: schedules written before the enabled flag.
|
||||
dc = make_controller({"schedule": {
|
||||
"start_time": "09:00", "end_time": "17:00"}, "timezone": "UTC"})
|
||||
assert check_at(dc, "20:00") is False
|
||||
|
||||
|
||||
class TestScheduleTransitions:
|
||||
def test_was_display_active_tracks_state(self):
|
||||
dc = make_controller({"schedule": {"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00"},
|
||||
"timezone": "UTC"})
|
||||
check_at(dc, "12:00")
|
||||
assert dc._was_display_active is True
|
||||
check_at(dc, "20:00")
|
||||
assert dc._was_display_active is False
|
||||
check_at(dc, "12:05")
|
||||
assert dc._was_display_active is True
|
||||
|
||||
|
||||
class TestDimSchedule:
|
||||
def _config(self, start="20:00", end="07:00", **extra):
|
||||
return {"dim_schedule": {"enabled": True, "start_time": start,
|
||||
"end_time": end, "dim_brightness": 25,
|
||||
**extra},
|
||||
"timezone": "UTC"}
|
||||
|
||||
def test_disabled_by_default(self):
|
||||
dc = make_controller({"dim_schedule": {"start_time": "20:00",
|
||||
"end_time": "07:00"},
|
||||
"timezone": "UTC"})
|
||||
# Unlike the on/off schedule, dimming defaults to DISABLED when the
|
||||
# enabled key is missing.
|
||||
assert dim_at(dc, "23:00") == 90
|
||||
assert dc.is_dimmed is False
|
||||
|
||||
def test_overnight_dim_window(self):
|
||||
dc = make_controller(self._config())
|
||||
assert dim_at(dc, "23:00") == 25
|
||||
assert dc.is_dimmed is True
|
||||
assert dim_at(dc, "03:00") == 25
|
||||
assert dim_at(dc, "12:00") == 90
|
||||
assert dc.is_dimmed is False
|
||||
|
||||
def test_dim_brightness_defaults_to_30(self):
|
||||
dc = make_controller({"dim_schedule": {"enabled": True,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"},
|
||||
"timezone": "UTC"})
|
||||
assert dim_at(dc, "23:00") == 30
|
||||
|
||||
def test_inactive_display_short_circuits_undimmed(self):
|
||||
dc = make_controller(self._config())
|
||||
dc.is_display_active = False
|
||||
dc.is_dimmed = True
|
||||
assert dim_at(dc, "23:00") == 90
|
||||
assert dc.is_dimmed is False
|
||||
|
||||
def test_per_day_mode(self):
|
||||
dc = make_controller(self._config(mode="per-day", days={
|
||||
"monday": {"enabled": True, "start_time": "22:00",
|
||||
"end_time": "06:00"},
|
||||
"tuesday": {"enabled": False},
|
||||
}))
|
||||
assert dim_at(dc, "23:00", day="monday") == 25
|
||||
assert dim_at(dc, "21:00", day="monday") == 90 # before per-day start
|
||||
assert dim_at(dc, "23:00", day="tuesday") == 90 # day disabled
|
||||
assert dc.is_dimmed is False
|
||||
|
||||
def test_no_legacy_inference_for_dim(self):
|
||||
# Unlike _check_schedule, dim mode defaults to GLOBAL even when a
|
||||
# days config exists — no legacy inference.
|
||||
dc = make_controller(self._config(days={
|
||||
"monday": {"enabled": True, "start_time": "22:00",
|
||||
"end_time": "06:00"},
|
||||
}))
|
||||
# 21:00 is inside the global 20:00-07:00 window but outside monday's
|
||||
# per-day 22:00 start; global mode wins.
|
||||
assert dim_at(dc, "21:00", day="monday") == 25
|
||||
|
||||
def test_invalid_time_string_returns_normal(self):
|
||||
dc = make_controller(self._config(start="late"))
|
||||
assert dim_at(dc, "23:00") == 90
|
||||
|
||||
def test_was_dimmed_tracks_transitions(self):
|
||||
dc = make_controller(self._config())
|
||||
dim_at(dc, "23:00")
|
||||
assert dc._was_dimmed is True
|
||||
dim_at(dc, "12:00")
|
||||
assert dc._was_dimmed is False
|
||||
@@ -11,9 +11,18 @@ orphaning VegasModeCoordinator.mark_plugin_updated() -- it has had zero
|
||||
callers since.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# display_controller imports display_manager, which imports the hardware
|
||||
# rgbmatrix module unless EMULATOR=true was set before import. Use the
|
||||
# emulator (same convention as test_display_dirty_tracking.py and
|
||||
# test/plugins/conftest.py) so this file collects on machines without the
|
||||
# hardware library — and so display_manager gets the emulator binding no
|
||||
# matter which test module imports it first.
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from src.display_controller import DisplayController
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Tests for src/common/display_helper.py (DisplayHelper).
|
||||
|
||||
Pure-PIL tests, no hardware or mocks required. Pixel assertions rely on
|
||||
getbbox()/getpixel() rather than exact text pixel counts, because the
|
||||
default-font metrics vary across Pillow versions.
|
||||
|
||||
These tests pin the FIXED behaviors on this branch:
|
||||
- draw_error_message / draw_no_data_message return a rendered image
|
||||
(they previously crashed with AttributeError),
|
||||
- draw_scorebug_layout draws period/status/clock as one combined top
|
||||
line (previously overprinted at the same y),
|
||||
- draw_ticker_layout draws at x=0 (previously started at
|
||||
x=display_width, i.e. entirely off-canvas -> blank frames).
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from src.common.display_helper import DisplayHelper
|
||||
|
||||
|
||||
def default_font():
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def make_helper(width=128, height=32):
|
||||
return DisplayHelper(width, height)
|
||||
|
||||
|
||||
class TestCreateBaseImage:
|
||||
def test_default_is_black_rgb_display_sized(self):
|
||||
helper = make_helper()
|
||||
img = helper.create_base_image()
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.getpixel((0, 0)) == (0, 0, 0)
|
||||
assert img.getpixel((127, 31)) == (0, 0, 0)
|
||||
# Entirely black -> no bounding box in luminance
|
||||
assert img.convert('L').getbbox() is None
|
||||
|
||||
def test_custom_background_color(self):
|
||||
helper = make_helper()
|
||||
img = helper.create_base_image(background_color=(10, 20, 30))
|
||||
assert img.getpixel((0, 0)) == (10, 20, 30)
|
||||
assert img.getpixel((64, 16)) == (10, 20, 30)
|
||||
|
||||
def test_mode_rgba_is_honored(self):
|
||||
helper = make_helper()
|
||||
img = helper.create_base_image(mode='RGBA')
|
||||
assert img.mode == 'RGBA'
|
||||
assert img.size == (128, 32)
|
||||
|
||||
|
||||
class TestCreateOverlay:
|
||||
def test_overlay_is_transparent_rgba(self):
|
||||
helper = make_helper()
|
||||
overlay = helper.create_overlay()
|
||||
assert overlay.mode == 'RGBA'
|
||||
assert overlay.size == (128, 32)
|
||||
assert overlay.getpixel((0, 0)) == (0, 0, 0, 0)
|
||||
assert overlay.getpixel((127, 31)) == (0, 0, 0, 0)
|
||||
|
||||
|
||||
class TestCompositeImages:
|
||||
def test_rgb_inputs_are_upconverted_and_result_is_rgba(self):
|
||||
helper = make_helper()
|
||||
base = Image.new('RGB', (128, 32), (0, 0, 0))
|
||||
overlay = Image.new('RGB', (128, 32), (255, 0, 0))
|
||||
result = helper.composite_images(base, overlay)
|
||||
assert result.mode == 'RGBA'
|
||||
assert result.size == base.size
|
||||
# RGB->RGBA conversion yields a fully opaque overlay
|
||||
assert result.getpixel((0, 0)) == (255, 0, 0, 255)
|
||||
|
||||
def test_transparent_overlay_leaves_base_visible(self):
|
||||
helper = make_helper()
|
||||
base = Image.new('RGB', (128, 32), (5, 6, 7))
|
||||
overlay = helper.create_overlay()
|
||||
result = helper.composite_images(base, overlay)
|
||||
assert result.mode == 'RGBA'
|
||||
assert result.getpixel((64, 16)) == (5, 6, 7, 255)
|
||||
|
||||
|
||||
class TestScorebugLayout:
|
||||
def test_full_game_data_renders(self):
|
||||
helper = make_helper()
|
||||
font = default_font()
|
||||
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
|
||||
game_data = {
|
||||
'home_score': 3, 'away_score': 2,
|
||||
'home_abbr': 'NYY', 'away_abbr': 'BOS',
|
||||
'status_text': 'LIVE', 'period_text': 'T9', 'clock': '2:30',
|
||||
}
|
||||
img = helper.draw_scorebug_layout(game_data, fonts)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.size == (128, 32)
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_empty_game_data_uses_defaults_without_raising(self):
|
||||
helper = make_helper()
|
||||
font = default_font()
|
||||
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
|
||||
img = helper.draw_scorebug_layout({}, fonts)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.size == (128, 32)
|
||||
# Defaults '0'/'HOME'/'AWAY' actually render something
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_empty_fonts_dict_falls_back_to_default_font(self):
|
||||
# Pin: fonts={} must not raise — PIL falls back to the default
|
||||
# font when font=None is passed through.
|
||||
helper = make_helper()
|
||||
img = helper.draw_scorebug_layout(
|
||||
{'status_text': 'FINAL', 'period_text': 'Q4', 'clock': '0:00'}, {})
|
||||
assert img.size == (128, 32)
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_top_line_is_one_combined_centered_draw(self):
|
||||
# FIXED behavior: period/status/clock are joined into a single
|
||||
# top line drawn once at y=1 instead of three overprinted draws.
|
||||
helper = make_helper()
|
||||
calls = []
|
||||
original = helper._draw_centered_text
|
||||
|
||||
def spy(draw, text, font, y_position):
|
||||
calls.append({'text': text, 'y_position': y_position})
|
||||
original(draw, text, font, y_position)
|
||||
|
||||
helper._draw_centered_text = spy
|
||||
font = default_font()
|
||||
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
|
||||
helper.draw_scorebug_layout(
|
||||
{'period_text': 'Q4', 'status_text': 'LIVE', 'clock': '2:30'},
|
||||
fonts)
|
||||
|
||||
top_calls = [c for c in calls if c['y_position'] == 1]
|
||||
assert len(top_calls) == 1
|
||||
text = top_calls[0]['text']
|
||||
assert 'Q4' in text
|
||||
assert 'LIVE' in text
|
||||
assert '2:30' in text
|
||||
|
||||
def test_no_top_line_when_all_parts_empty(self):
|
||||
helper = make_helper()
|
||||
calls = []
|
||||
original = helper._draw_centered_text
|
||||
|
||||
def spy(draw, text, font, y_position):
|
||||
calls.append(y_position)
|
||||
original(draw, text, font, y_position)
|
||||
|
||||
helper._draw_centered_text = spy
|
||||
font = default_font()
|
||||
helper.draw_scorebug_layout({}, {'score': font, 'team': font})
|
||||
assert 1 not in calls # no combined top line drawn
|
||||
|
||||
def test_logo_positions_bleed_off_edges(self):
|
||||
# Home logo pastes at x = width - logo.width + 10 (right edge,
|
||||
# bleeding off-screen right); away at x = -10 (bleeding left).
|
||||
helper = make_helper()
|
||||
home_logo = Image.new('RGBA', (20, 20), (0, 0, 255, 255)) # blue
|
||||
away_logo = Image.new('RGBA', (20, 20), (255, 0, 0, 255)) # red
|
||||
# Empty abbrs/status so text can't land on the probed pixels.
|
||||
game_data = {'home_abbr': '', 'away_abbr': ''}
|
||||
font = default_font()
|
||||
img = helper.draw_scorebug_layout(game_data, {'score': font},
|
||||
home_logo=home_logo,
|
||||
away_logo=away_logo)
|
||||
# center_y = 16; logos span y 6..25 -> probe y=16 at both edges.
|
||||
assert img.getpixel((0, 16)) == (255, 0, 0) # away (left edge)
|
||||
assert img.getpixel((127, 16)) == (0, 0, 255) # home (right edge)
|
||||
# And the off-screen parts are truly clipped: image is still 128 wide
|
||||
assert img.size == (128, 32)
|
||||
|
||||
|
||||
class TestTickerLayout:
|
||||
def test_frame_is_not_blank(self):
|
||||
# FIXED behavior: text now starts at x=0. Previously it was drawn
|
||||
# at x=display_width, entirely off-canvas, so frames were blank.
|
||||
helper = make_helper()
|
||||
img = helper.draw_ticker_layout('HELLO WORLD', default_font())
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_text_starts_at_left_edge(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_ticker_layout('HELLO', default_font())
|
||||
bbox = img.convert('L').getbbox()
|
||||
assert bbox is not None
|
||||
# Text is positioned at x=0 (outline extends 1px left, clipped),
|
||||
# so ink begins hugging the left edge. Allow a couple of pixels of
|
||||
# slack for font-dependent left-side bearing.
|
||||
assert bbox[0] <= 2
|
||||
|
||||
def test_scroll_speed_does_not_affect_frame(self):
|
||||
# Pin: scroll_speed is accepted for API compatibility only.
|
||||
helper = make_helper()
|
||||
font = default_font()
|
||||
img1 = helper.draw_ticker_layout('SCROLLING', font, scroll_speed=1)
|
||||
img5 = helper.draw_ticker_layout('SCROLLING', font, scroll_speed=5)
|
||||
assert img1.tobytes() == img5.tobytes()
|
||||
|
||||
def test_custom_colors(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_ticker_layout('X', default_font(),
|
||||
background_color=(0, 0, 40),
|
||||
text_color=(0, 255, 0))
|
||||
assert img.getpixel((127, 0)) == (0, 0, 40) # background corner
|
||||
colors = {img.getpixel((x, y))
|
||||
for x in range(img.width) for y in range(img.height)}
|
||||
# Text color appears somewhere (anti-aliasing may blend it, so
|
||||
# check for a green-dominant pixel rather than the exact color).
|
||||
assert any(g > 150 and r < 100 for (r, g, b) in colors)
|
||||
|
||||
|
||||
class TestCenteredText:
|
||||
def test_renders_centered_text_on_background(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_centered_text('HI', default_font(),
|
||||
background_color=(0, 0, 60),
|
||||
text_color=(255, 255, 0))
|
||||
assert img.size == (128, 32)
|
||||
assert img.convert('L').getbbox() is not None
|
||||
# Corners stay pure background
|
||||
assert img.getpixel((0, 0)) == (0, 0, 60)
|
||||
assert img.getpixel((127, 0)) == (0, 0, 60)
|
||||
assert img.getpixel((0, 31)) == (0, 0, 60)
|
||||
assert img.getpixel((127, 31)) == (0, 0, 60)
|
||||
|
||||
|
||||
class TestErrorAndNoDataMessages:
|
||||
def test_draw_error_message_returns_rendered_image(self):
|
||||
# FIXED behavior: used to crash with AttributeError; now returns
|
||||
# a rendered image on a dark red background.
|
||||
helper = make_helper()
|
||||
img = helper.draw_error_message('Boom')
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.convert('L').getbbox() is not None
|
||||
assert img.getpixel((0, 0)) == (50, 0, 0) # dark red background
|
||||
|
||||
def test_draw_error_message_default_text(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_error_message()
|
||||
assert img.size == (128, 32)
|
||||
assert img.getpixel((127, 31)) == (50, 0, 0)
|
||||
|
||||
def test_draw_no_data_message_returns_rendered_image(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_no_data_message()
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.convert('L').getbbox() is not None
|
||||
assert img.getpixel((0, 0)) == (0, 0, 0) # black background
|
||||
|
||||
|
||||
class TestDrawTextWithOutline:
|
||||
def test_fill_color_appears_in_output(self):
|
||||
helper = make_helper()
|
||||
img = Image.new('RGB', (40, 20), (0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
helper._draw_text_with_outline(draw, 'X', (5, 2), default_font(),
|
||||
fill=(255, 0, 0))
|
||||
pixels = {img.getpixel((x, y))
|
||||
for x in range(img.width) for y in range(img.height)}
|
||||
# Anti-aliased fonts blend edge pixels, so look for red-dominant
|
||||
# (fill) and near-black (outline) pixels rather than exact colors.
|
||||
assert any(r > 150 and g < 50 for (r, g, b) in pixels) # fill
|
||||
assert any(max(p) < 80 for p in pixels) # outline
|
||||
|
||||
def test_default_fill_is_white(self):
|
||||
helper = make_helper()
|
||||
img = Image.new('RGB', (40, 20), (0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
helper._draw_text_with_outline(draw, 'X', (5, 2), default_font())
|
||||
pixels = {img.getpixel((x, y))
|
||||
for x in range(img.width) for y in range(img.height)}
|
||||
# White-dominant pixel present (exact white may be anti-aliased)
|
||||
assert any(r > 200 and g > 200 for (r, g, b) in pixels)
|
||||
|
||||
|
||||
class TestOrientationAndDimensions:
|
||||
def test_landscape_display(self):
|
||||
helper = DisplayHelper(128, 32)
|
||||
assert helper.is_landscape() is True
|
||||
assert helper.is_portrait() is False
|
||||
|
||||
def test_portrait_display(self):
|
||||
helper = DisplayHelper(32, 128)
|
||||
assert helper.is_portrait() is True
|
||||
assert helper.is_landscape() is False
|
||||
|
||||
def test_square_display_is_neither(self):
|
||||
# Pin: a square display is neither portrait nor landscape.
|
||||
helper = DisplayHelper(64, 64)
|
||||
assert helper.is_portrait() is False
|
||||
assert helper.is_landscape() is False
|
||||
|
||||
def test_get_center_position(self):
|
||||
assert DisplayHelper(128, 32).get_center_position() == (64, 16)
|
||||
|
||||
def test_get_center_position_floors_odd_dimensions(self):
|
||||
assert DisplayHelper(65, 33).get_center_position() == (32, 16)
|
||||
|
||||
def test_get_display_dimensions(self):
|
||||
assert DisplayHelper(128, 32).get_display_dimensions() == (128, 32)
|
||||
assert DisplayHelper(64, 64).get_display_dimensions() == (64, 64)
|
||||