Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7603728e5a | ||
|
|
6c23994b2f | ||
|
|
f12d11334a | ||
|
|
be7a7b7baf | ||
|
|
406e68fba9 | ||
|
|
ea54e56bed | ||
|
|
696acdbc7b | ||
|
|
91d15a8943 | ||
|
|
6bea1a7c21 | ||
|
|
0730d95200 | ||
|
|
32d637a446 | ||
|
|
bc2dbf3824 | ||
|
|
cb0545ecb3 | ||
|
|
300cdaa250 | ||
|
|
10da2f97fd | ||
|
|
92f9d06af9 | ||
|
|
6e361e05cc | ||
|
|
a686932c7e | ||
|
|
154525beb8 | ||
|
|
9b522d412c | ||
|
|
cbc540a679 | ||
|
|
4aeb0033e0 | ||
|
|
eae063700f | ||
|
|
af96bd5cb0 | ||
|
|
5e5979973e | ||
|
|
bdced206dc | ||
|
|
39e7f8cbe0 | ||
|
|
f90638a9ec | ||
|
|
333fd17d28 | ||
|
|
a4a55a23fc | ||
|
|
085fb93a87 | ||
|
|
c321b94085 | ||
|
|
5a1f121e6b | ||
|
|
6138a3cbef | ||
|
|
568cb6d77f | ||
|
|
2f42d179f6 | ||
|
|
37fc1b56b5 | ||
|
|
acc55ef119 | ||
|
|
1a0864e5d4 | ||
|
|
732c7d1a30 | ||
|
|
d42593e7ce | ||
|
|
f0bef7784c | ||
|
|
986f74e38b | ||
|
|
e450a6dfb6 | ||
|
|
5133643600 | ||
|
|
5929190e36 | ||
|
|
79ba93f5a6 | ||
|
|
e499efb1f0 | ||
|
|
6b74506695 | ||
|
|
5f29243e87 | ||
|
|
1fbe244e49 | ||
|
|
863e4a1ecd | ||
|
|
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 | ||
|
|
cd7e16e58e | ||
|
|
47e3021fc3 | ||
|
|
e319540c6e |
@@ -4,6 +4,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
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.
|
# Both jobs only check out the repo and run pytest.
|
||||||
permissions:
|
permissions:
|
||||||
@@ -13,6 +16,12 @@ jobs:
|
|||||||
plugin-safety:
|
plugin-safety:
|
||||||
name: Plugin safety harness + unit tests
|
name: Plugin safety harness + unit tests
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
@@ -29,12 +38,9 @@ jobs:
|
|||||||
pip install -r requirements.txt -r requirements-test.txt
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
pip install RGBMatrixEmulator
|
pip install RGBMatrixEmulator
|
||||||
|
|
||||||
- name: Run harness + visual rendering tests
|
- name: Run plugin safety harness
|
||||||
run: |
|
run: |
|
||||||
pytest --no-cov \
|
pytest --no-cov test/plugins/
|
||||||
test/plugins/test_harness.py \
|
|
||||||
test/plugins/test_visual_rendering.py \
|
|
||||||
test/plugins/test_plugin_matrix.py
|
|
||||||
|
|
||||||
unit-tests:
|
unit-tests:
|
||||||
name: Core unit tests
|
name: Core unit tests
|
||||||
@@ -55,35 +61,15 @@ jobs:
|
|||||||
pip install -r requirements.txt -r requirements-test.txt
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
pip install RGBMatrixEmulator
|
pip install RGBMatrixEmulator
|
||||||
|
|
||||||
# Safety net for the shared sports/scroll/style infrastructure. These
|
# Run the ENTIRE test tree (except test/plugins, which the
|
||||||
# suites existed but were not enrolled in CI, so a refactor of
|
# plugin-safety job owns). New test files are enrolled automatically;
|
||||||
# src/base_classes or src/common could regress them silently. Enrolled
|
# excluding anything requires a visible, commented --ignore here.
|
||||||
# explicitly (not `pytest test/`) so known hardware-only suites don't
|
# Coverage is measured and enforced only in this step — pytest.ini
|
||||||
# break CI; grow this list as more suites are made headless.
|
# deliberately carries no coverage flags so local runs stay fast.
|
||||||
- name: Run core unit suites
|
- name: Run core unit suites
|
||||||
run: |
|
run: |
|
||||||
pytest --no-cov \
|
pytest -m "not hardware" test/ \
|
||||||
test/test_skin_system.py \
|
--ignore=test/plugins \
|
||||||
test/test_font_manager.py \
|
--cov=src --cov=web_interface \
|
||||||
test/test_data_sources.py \
|
--cov-report=term \
|
||||||
test/test_api_extractors.py \
|
--cov-fail-under=52
|
||||||
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/web_interface/test_cache.py
|
|
||||||
|
|||||||
@@ -17,6 +17,81 @@ release that ships it.
|
|||||||
accepts both, but the store flags the old spelling as deprecated
|
accepts both, but the store flags the old spelling as deprecated
|
||||||
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||||
|
|
||||||
|
## 3.3.0
|
||||||
|
|
||||||
|
**The release the sports scoreboards floor on to delete their bundled copies.**
|
||||||
|
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
|
||||||
|
enforceable; this ships the last three shared modules and completes the store
|
||||||
|
gate, so a scoreboard can now floor here and carry no fallback at all.
|
||||||
|
|
||||||
|
New modules a plugin may import via `src.*` and floor on 3.3.0 for:
|
||||||
|
|
||||||
|
- `src/common/sports_card.py` — settings, colour, font and date helpers for a
|
||||||
|
scoreboard's `game_renderer.py`. Free functions taking `config`/`fonts`
|
||||||
|
explicitly, so nothing about the caller's class is assumed.
|
||||||
|
- `src/common/sports_game_renderer.py` — `SportsGameRendererMixin`: scroll/Vegas
|
||||||
|
card geometry (centre gap, logo slot and cache key, layout offsets, the
|
||||||
|
upcoming-card date and time layout). No `__init__` and no state, so adoption
|
||||||
|
is one line on the class statement.
|
||||||
|
- `src/common/sports_shared.py` — `SportsCoreSharedMixin`,
|
||||||
|
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` bodies
|
||||||
|
byte-identical in all eight lineage-sharing scoreboards.
|
||||||
|
|
||||||
|
All three sit under `src/common/` rather than `src/base_classes/sports/`,
|
||||||
|
deliberately: importing that package pulls `core.py` → `DisplayManager` →
|
||||||
|
`rgbmatrix`, and these are pure logic. Plugins importing them must not acquire a
|
||||||
|
hardware dependency.
|
||||||
|
|
||||||
|
Three notes for anyone adopting `sports_shared`:
|
||||||
|
|
||||||
|
- `SportsRecentSharedMixin` defines `__init__`. Its bare `super()` binds to the
|
||||||
|
mixin, so it reaches the host only when the mixin is listed **first** in the
|
||||||
|
bases. Reversing that order silently skips the host constructor.
|
||||||
|
- Methods that resolve the plugin's `config_schema.json` use `_plugin_dir()`,
|
||||||
|
which walks the MRO rather than reading `__file__` — `__file__` is now
|
||||||
|
`src/common/`. It walks because `SportsCore` is an ABC: a subclass built with
|
||||||
|
`type(name, bases, ns)` reports `__module__` as `"abc"`.
|
||||||
|
- `_get_timezone`, `_extract_game_details` and `_fetch_data` are byte-identical
|
||||||
|
across the eight but stay in the plugins. The first binds a per-plugin
|
||||||
|
timezone module whose contents differ; the other two are the abstract stubs
|
||||||
|
that define the sport.
|
||||||
|
|
||||||
|
**The store's compatibility gate is now on every registry-managed route.** 3.2.0
|
||||||
|
gated `install_plugin`. This release gates the git-pull update path and
|
||||||
|
`install_from_url`, so a plugin whose floor the core cannot meet is refused
|
||||||
|
after download with no partial directory left behind.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ESPN 403s.** `site.api` began rejecting the User-Agent strings this repo
|
||||||
|
sent on 2026-08-04; every shared-data-source scoreboard returned
|
||||||
|
`403 Forbidden`. Requests now send an identifying token with a project URL —
|
||||||
|
browser-style strings and bare custom tokens are both refused.
|
||||||
|
- **Low-memory boards becoming unreachable under load** while still answering
|
||||||
|
pings and serving the web UI. Fetched payloads are released after delivery
|
||||||
|
rather than pinned on the completed request for up to an hour, malloc arenas
|
||||||
|
are capped, and log volume and SD writes are reduced. Available memory is now
|
||||||
|
reported in Tools diagnostics.
|
||||||
|
- **A failed logo download pinning a team to a grey box**: the placeholder was
|
||||||
|
written under the real logo's filename, so later attempts found a file and
|
||||||
|
reported success without retrying.
|
||||||
|
- **A plugin enabled but never loaded is retried** rather than staying absent
|
||||||
|
with `error = null`.
|
||||||
|
- `ttl` now controls cache expiry; abandoned cache writes no longer leave temp
|
||||||
|
files; one cache-cleanup thread per directory rather than per manager.
|
||||||
|
- Odds are fetched for the games displayed, not the whole schedule window, and a
|
||||||
|
stalled ESPN no longer stalls the whole plugin update.
|
||||||
|
- Array-item secrets are no longer wiped or logged.
|
||||||
|
- A restored backup matches the device it was taken from.
|
||||||
|
- The web UI reports the real error instead of "unknown", rejects non-finite
|
||||||
|
JSON numbers, and stops checkbox groups posting back hidden options.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `display.hardware.orientation` for panels mounted upside down.
|
||||||
|
- Vegas keeps live content in the ticker rather than being preempted by it.
|
||||||
|
- Schemas can label enum dropdown options.
|
||||||
|
|
||||||
## 3.2.0
|
## 3.2.0
|
||||||
|
|
||||||
**The first release shipping the unified sports library.** This is the version
|
**The first release shipping the unified sports library.** This is the version
|
||||||
|
|||||||
@@ -161,9 +161,11 @@ The system supports live, recent, and upcoming game information for multiple spo
|
|||||||
### LED Matrix Panels
|
### LED Matrix Panels
|
||||||
(2x in a horizontal chain is recommended)
|
(2x in a horizontal chain is recommended)
|
||||||
- [Adafruit 64×32](https://www.adafruit.com/product/2278) – designed for 128×32 but works with dynamic scaling on many displays (pixel pitch is user preference)
|
- [Adafruit 64×32](https://www.adafruit.com/product/2278) – designed for 128×32 but works with dynamic scaling on many displays (pixel pitch is user preference)
|
||||||
|
**Warning: Lately the Waveshare Panels have had different variations - only some are compatible with this project. I hope to identify what is different to fix it but so far there is a decent chance you get a mis-matched set of panels if you don't buy them all at once! **
|
||||||
- [Waveshare 64×32](https://amzn.to/3Kw55jK) - Does not require E addressable pad
|
- [Waveshare 64×32](https://amzn.to/3Kw55jK) - Does not require E addressable pad
|
||||||
- [Waveshare 96×48](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
|
- [Waveshare 96×48](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
|
||||||
> Amazon Affiliate Link – ChuckBuilds receives a small commission on purchases
|
- There are some Panels on Aliexpress that have worked fine for me, shop around! I think Adafruit is probably the "safest" but they do have some limitation on resolution and layout.
|
||||||
|
> Amazon Affiliate Links – ChuckBuilds receives a small commission on purchases
|
||||||
|
|
||||||
### Power Supply
|
### Power Supply
|
||||||
- [5V 4A DC Power Supply](https://www.adafruit.com/product/658) (good for 2 -3 displays, depending on brightness and pixel density, you'll need higher amperage for more)
|
- [5V 4A DC Power Supply](https://www.adafruit.com/product/658) (good for 2 -3 displays, depending on brightness and pixel density, you'll need higher amperage for more)
|
||||||
@@ -365,6 +367,12 @@ sudo bash ./first_time_install.sh
|
|||||||
|
|
||||||
This single script installs services, dependencies, configures permissions and sudoers, and validates the setup.
|
This single script installs services, dependencies, configures permissions and sudoers, and validates the setup.
|
||||||
|
|
||||||
|
It finishes by asking whether to reboot. If you run it non-interactively — piped, over a script, or with `-y` — there is no one to ask, so **it reboots immediately without prompting**. Pass `--no-reboot-prompt` to install without rebooting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash ./first_time_install.sh -y --no-reboot-prompt
|
||||||
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -594,6 +602,14 @@ These settings are typically only needed for non-standard panels or custom confi
|
|||||||
- Leave empty unless you need custom mapping
|
- Leave empty unless you need custom mapping
|
||||||
- See rpi-rgb-led-matrix documentation for full options
|
- 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)
|
- **`row_address_type`** (integer, default: 0)
|
||||||
- How rows are addressed on the panel
|
- How rows are addressed on the panel
|
||||||
- Most panels use 0 (direct addressing)
|
- Most panels use 0 (direct addressing)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 467 B |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 105 KiB |
@@ -112,6 +112,7 @@
|
|||||||
"led_rgb_sequence": "RGB",
|
"led_rgb_sequence": "RGB",
|
||||||
"limit_refresh_rate_hz": 100,
|
"limit_refresh_rate_hz": 100,
|
||||||
"pixel_mapper_config": "",
|
"pixel_mapper_config": "",
|
||||||
|
"orientation": "normal",
|
||||||
"row_address_type": 0,
|
"row_address_type": 0,
|
||||||
"multiplexing": 0,
|
"multiplexing": 0,
|
||||||
"panel_type": ""
|
"panel_type": ""
|
||||||
@@ -129,6 +130,9 @@
|
|||||||
"plugin_rotation_order": [],
|
"plugin_rotation_order": [],
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
|
"live_in_ticker": false,
|
||||||
|
"live_weight": 3,
|
||||||
|
"favorite_live_weight": 5,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"scroll_speed": 50,
|
"scroll_speed": 50,
|
||||||
"separator_width": 32,
|
"separator_width": 32,
|
||||||
@@ -149,7 +153,7 @@
|
|||||||
"min_plugin_width": 8,
|
"min_plugin_width": 8,
|
||||||
"lead_in_width": 0,
|
"lead_in_width": 0,
|
||||||
"plugins_per_cycle": 6,
|
"plugins_per_cycle": 6,
|
||||||
"max_plugin_width_ratio": 3.0,
|
"max_plugin_width_ratio": 0.0,
|
||||||
"overflow_mode": "rotate",
|
"overflow_mode": "rotate",
|
||||||
"dynamic_duration_enabled": true,
|
"dynamic_duration_enabled": true,
|
||||||
"min_cycle_duration": 60,
|
"min_cycle_duration": 60,
|
||||||
|
|||||||
@@ -64,10 +64,98 @@ JSON is optional.
|
|||||||
| `target_fps` | `125` | Target frame rate |
|
| `target_fps` | `125` | Target frame rate |
|
||||||
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
| `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
|
total. See the full list in
|
||||||
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
|
[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
|
### Per-Plugin Configuration
|
||||||
|
|
||||||
Override Vegas behavior for specific plugins:
|
Override Vegas behavior for specific plugins:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ tooling against it.
|
|||||||
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
||||||
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
||||||
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
|
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
|
||||||
| `location` | object | `city` / `state` / `country`, offered to plugins that need a location (weather, etc.) | plugins via merged config |
|
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. | `SchemaManager.apply_device_location()`, then plugins via merged config |
|
||||||
|
|
||||||
## `schedule` — display on/off hours
|
## `schedule` — display on/off hours
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
|
|||||||
| `led_rgb_sequence` | string, `"RGB"` |
|
| `led_rgb_sequence` | string, `"RGB"` |
|
||||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
||||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate: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 |
|
| `row_address_type` | int, `0` — non-standard panel row addressing |
|
||||||
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
||||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
|
| `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
|
## `display.vegas_scroll` — continuous scroll mode
|
||||||
|
|
||||||
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
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 |
|
| Key | Type / default |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -127,13 +129,16 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
|||||||
| `min_plugin_width` | int, `8` |
|
| `min_plugin_width` | int, `8` |
|
||||||
| `lead_in_width` | int, `0` |
|
| `lead_in_width` | int, `0` |
|
||||||
| `plugins_per_cycle` | int, `6` |
|
| `plugins_per_cycle` | int, `6` |
|
||||||
| `max_plugin_width_ratio` | float, `3.0` |
|
| `max_plugin_width_ratio` | float, `0.0` |
|
||||||
| `overflow_mode` | string, `"rotate"` |
|
| `overflow_mode` | string, `"rotate"` |
|
||||||
| `dynamic_duration_enabled` | bool, `true` |
|
| `dynamic_duration_enabled` | bool, `true` |
|
||||||
| `min_cycle_duration` | int, `60` |
|
| `min_cycle_duration` | int, `60` |
|
||||||
| `max_cycle_duration` | int, `240` |
|
| `max_cycle_duration` | int, `240` |
|
||||||
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
||||||
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
| `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
|
## `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
|
List of display modes to show during a live takeover. Default returns the
|
||||||
plugin's `display_modes` from its manifest.
|
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 scroll hooks
|
||||||
|
|
||||||
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
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
|
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes
|
||||||
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
|
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
|
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
|
## I want to write a plugin
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ These are independent concerns. Conflating them is what produces god classes.
|
|||||||
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
||||||
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
||||||
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
||||||
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. Nothing enforces that floor today, so the copy also waits for the B6 gate below. |
|
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. The store enforces that floor on every registry-managed install and on both supported update paths (sideloading via `install_from_url` is not gated), but a floor cannot reach a user who never updates, so the copy also waits for the B6 gate below. |
|
||||||
|
|
||||||
The core API is **additive-only**. A method the plugins call is never removed or
|
The core API is **additive-only**. A method the plugins call is never removed or
|
||||||
given a new required parameter; new behavior arrives as new methods with
|
given a new required parameter; new behavior arrives as new methods with
|
||||||
@@ -204,7 +204,7 @@ legacy compatibility rather than the mechanism.
|
|||||||
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
||||||
**rollout**, and it splits into three phases with very different risk profiles.
|
**rollout**, and it splits into three phases with very different risk profiles.
|
||||||
The original plan folded the last two together; they are separated here because
|
The original plan folded the last two together; they are separated here because
|
||||||
one of them is safe by construction and the other is not.
|
one of them cannot break a user on an old core and the other can.
|
||||||
|
|
||||||
| Phase | Scope | Status | Gate |
|
| Phase | Scope | Status | Gate |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -212,9 +212,9 @@ one of them is safe by construction and the other is not.
|
|||||||
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
|
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
|
||||||
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
||||||
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
|
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
|
||||||
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ⏳ **next** | Tag, release, and `src.__version__` agree; compatibility gate merged |
|
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ✅ | Released 2026-08-03; tag, release and `src.__version__` agree; compatibility gate merged (#428, #431, #433) |
|
||||||
| **B5** | Adoption — guarded core imports: three pilots, then the remaining six. **Bundled copies stay.** | after B4 | Per plugin: harness + goldens byte-identical, then a device soak |
|
| **B5** | Adoption — guarded core imports, all eight. **Bundled copies stay.** | ✅ | All eight adopted; harness byte-identical; see the B5 retrospective below — four shipped broken and were repaired in plugins #251 |
|
||||||
| **B6** | Sunset — delete the bundled copies | **blocked** | B4's gate shipped *and* in users' hands (see below) |
|
| **B6** | Sunset — delete the bundled copies | ✅ | Ran 2026-09-01, all eight. Floors at 3.2.0; the store refuses on all three routes in (#431/#433, #508, #510). See "B6 — what actually happened" |
|
||||||
|
|
||||||
### B4 — what "ship 3.2.0" actually requires
|
### B4 — what "ship 3.2.0" actually requires
|
||||||
|
|
||||||
@@ -234,6 +234,20 @@ a floor can be trusted against, and today it is not:
|
|||||||
compares the plugin's manifest version against the registry's
|
compares the plugin's manifest version against the registry's
|
||||||
`latest_version` and nothing else.
|
`latest_version` and nothing else.
|
||||||
|
|
||||||
|
*Fixed, in two parts.* `install_plugin` gained the gate in #431/#433, which
|
||||||
|
covers every registry-managed install and, through `_reinstall_with_rollback`,
|
||||||
|
the update path that re-downloads.
|
||||||
|
`update_plugin`'s git branch pulls in place and re-downloads nothing, so it
|
||||||
|
stayed ungated until `_gate_pulled_commit` closed it — checked after the pull
|
||||||
|
(the registry carries no floor field, so the incoming floor is unknowable
|
||||||
|
before it) and undone with `git reset --hard` to the pre-pull commit. That
|
||||||
|
route is rare in practice, since monorepo plugins install as archives; it was
|
||||||
|
closed because the sunset rule in the plugins repo's
|
||||||
|
`08-shared-sports-code.md` states as **condition 3** that the core enforces
|
||||||
|
the floor "at install/update time", and B6 rests on that being true rather
|
||||||
|
than merely written down. `install_from_url` — sideloading a plugin from a
|
||||||
|
URL — is still ungated.
|
||||||
|
|
||||||
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
|
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
|
||||||
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
|
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
|
||||||
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
|
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
|
||||||
@@ -262,13 +276,21 @@ default is the more restrictive.
|
|||||||
`compatible_versions`. No manifest still carries it, so there is nothing to
|
`compatible_versions`. No manifest still carries it, so there is nothing to
|
||||||
migrate there.)
|
migrate there.)
|
||||||
|
|
||||||
### B5 — adoption is safe by construction
|
### B5 — the *fallback* is safe by construction; the modern path is not
|
||||||
|
|
||||||
|
The heading matters, because the unqualified version of this claim is false and
|
||||||
|
this document proves it two sections down: four of the eight adopted plugins
|
||||||
|
shipped with scroll mode broken on a 3.2.0 core. What is safe by construction is
|
||||||
|
narrower than "adoption".
|
||||||
|
|
||||||
A plugin adopting core imports keeps its bundled copy and reaches it through the
|
A plugin adopting core imports keeps its bundled copy and reaches it through the
|
||||||
guarded import (see the Upgradability table above). On a core that ships the
|
guarded import (see the Upgradability table above). On a core that doesn't ship
|
||||||
module the plugin uses core code; on one that doesn't it falls back and behaves
|
the module the plugin falls back and behaves exactly as it does today. That
|
||||||
exactly as it does today. There is no version of this step that breaks a user,
|
fallback compatibility — and only that — is safe by construction. On a core that
|
||||||
which is why it does not wait for B6's gate.
|
*does* ship the module, correctness is not automatic — object-level and scroll-mode validation
|
||||||
|
(building both classes and comparing, per the retrospective below) is required
|
||||||
|
to prove full behavior. There is no version of this step that breaks a user *on
|
||||||
|
an old core*, which is why it does not wait for B6's gate.
|
||||||
|
|
||||||
The hockey scroll-display pilot is **already validated**: adopted against a core
|
The hockey scroll-display pilot is **already validated**: adopted against a core
|
||||||
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
|
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
|
||||||
@@ -297,7 +319,8 @@ been in users' hands long enough that the population running a core without it
|
|||||||
is small.** The bundled copies cost disk space; deleting them early costs
|
is small.** The bundled copies cost disk space; deleting them early costs
|
||||||
scoreboards, silently. That trade is not close.
|
scoreboards, silently. That trade is not close.
|
||||||
|
|
||||||
Before the first sunset, add a **compatibility regression test**. It has to
|
Before the first sunset, add a **compatibility regression test**. **Built:**
|
||||||
|
core `test/test_sports_sunset_matrix.py` (#505). It has to
|
||||||
cover four cases, not one — B5's safety claim and B6's failure mode are
|
cover four cases, not one — B5's safety claim and B6's failure mode are
|
||||||
different propositions and only the second is obvious:
|
different propositions and only the second is obvious:
|
||||||
|
|
||||||
@@ -321,35 +344,144 @@ gate rather than trusting the failure to be noticed.
|
|||||||
The same suite should exercise the install/update gate, since it is the other
|
The same suite should exercise the install/update gate, since it is the other
|
||||||
half of the guarantee.
|
half of the guarantee.
|
||||||
|
|
||||||
|
### B6 — what actually happened
|
||||||
|
|
||||||
|
**Ran 2026-09-01, across all eight scoreboards.** Held from 2026-08-05 to
|
||||||
|
2026-09-01 on the argument below, which is kept because the reasoning applies to
|
||||||
|
the next module, not because it is still in force.
|
||||||
|
|
||||||
|
**The hold, and why it lifted.** The stated gate was evidence of 3.2.0 uptake —
|
||||||
|
"a few months of it being the default download, or store-side install data".
|
||||||
|
That evidence never arrived and could not: the core updates by
|
||||||
|
`git pull --rebase`, so release-asset counts cannot measure uptake, and no
|
||||||
|
store-side telemetry exists. What changed instead is that the *risk* the gate
|
||||||
|
protected against was closed directly. The store now refuses a plugin whose
|
||||||
|
floor exceeds the running core on **all three** routes in:
|
||||||
|
|
||||||
|
| route | gated by |
|
||||||
|
|---|---|
|
||||||
|
| `install_plugin` — every path that re-downloads, `_reinstall_with_rollback` included | #431, #433 |
|
||||||
|
| `update_plugin`'s git branch — pulls in place, re-downloads nothing | #508 |
|
||||||
|
| `install_from_url` — sideloading | #510 |
|
||||||
|
|
||||||
|
With all three closed a pre-3.2.0 user cannot receive a sunset plugin at all;
|
||||||
|
they keep the version they already run. The population the hold existed to
|
||||||
|
protect is protected by refusal rather than by a bundled copy — which is what
|
||||||
|
the copy was standing in for.
|
||||||
|
|
||||||
|
**What shipped.** Eight plugins, ~5,800 lines of frozen fallback deleted. Each:
|
||||||
|
copy removed, guarded import collapsed to a plain one, floor raised to 3.2.0,
|
||||||
|
`test_core_fallback.py` rewritten as `test_core_scroll.py` asserting the sunset
|
||||||
|
rather than the fallback. `scripts/check_scroll_adoption.py` gained
|
||||||
|
`sunset_violations` and a `SUNSET_PLUGINS` set naming all eight, so a
|
||||||
|
resurrected copy or a returned guard fails CI.
|
||||||
|
|
||||||
|
Delivered as plugins #346 (hockey, later folded into #351), #349 (football),
|
||||||
|
#350 (baseball), #351 (the remaining six).
|
||||||
|
|
||||||
|
**Two things found by doing it, both worth carrying forward:**
|
||||||
|
|
||||||
|
- **Only one fallback held orchestration logic the core lacked.** baseball's
|
||||||
|
`_configure_scroll_helper` reinterpreted `scroll_speed` as pixels-per-*frame*
|
||||||
|
when `speed × delay` fell outside the 0.1–5.0 window — measured, 10–20×
|
||||||
|
faster than configured for a speed between 1.0 and 5.0. Standardised onto the
|
||||||
|
core's behaviour (honour the documented px/sec, clamp) rather than preserved.
|
||||||
|
Every other difference across the eight was a docstring, an unreachable
|
||||||
|
`scroll_helper is None` guard, or an equivalent diagnostic.
|
||||||
|
- **Two tests had been leaning on the guard without anyone knowing.**
|
||||||
|
`soccer/test_live_screens.py` stubbed `src` in a way that shadowed the core,
|
||||||
|
so its guarded import fell back and the test had been exercising the *frozen
|
||||||
|
copy* rather than the shipping class since B5. Before sunsetting anything else
|
||||||
|
that carries a guarded core import, grep for tests that stub `src`.
|
||||||
|
|
||||||
|
**The floor-raising traps still apply** to any future sunset: four plugins
|
||||||
|
declare their floor top-level, where editing `versions[0]` is a silent no-op,
|
||||||
|
and the floor has three live spellings (`min_ledmatrix_version`,
|
||||||
|
`requires.min_ledmatrix_version`, `versions[].ledmatrix_min_version`, plus
|
||||||
|
deprecated `ledmatrix_min`). See
|
||||||
|
`src/plugin_system/compatibility.py:declared_min_version` for the resolution
|
||||||
|
order any floor-raising tool must reproduce — and note the name is **inverted**
|
||||||
|
between the top level and `versions[]`.
|
||||||
|
|
||||||
|
**Still not adopted, deliberately:** `data_sources.py`, `game_renderer.py` and
|
||||||
|
`base_odds_manager.py`. The standing decision held them until B6 closed; it now
|
||||||
|
has, so they can be reconsidered — with B5's lesson applied, which is to build
|
||||||
|
the object and diff rendered output rather than trust a static check.
|
||||||
|
|
||||||
|
### B5 retrospective — what the adoption actually cost
|
||||||
|
|
||||||
|
Recorded because it is the evidence behind the two decisions above, and because
|
||||||
|
"the adoption went fine" is not what happened.
|
||||||
|
|
||||||
|
**Four of the eight shipped with scroll mode broken** on a 3.2.0 core, and were
|
||||||
|
repaired in plugins-repo #251. The restructure lifted the content methods
|
||||||
|
verbatim but left the state they read off `self` behind: separator-icon
|
||||||
|
constants (hockey, basketball, lacrosse) and the game-renderer cache (afl).
|
||||||
|
hockey/basketball/lacrosse could not construct the scroll display at all; afl
|
||||||
|
raised inside `prepare_scroll_content`, which the core base *catches*, so its
|
||||||
|
only symptom was scroll mode silently drawing nothing.
|
||||||
|
|
||||||
|
Three things are worth carrying forward:
|
||||||
|
|
||||||
|
- **The bundled fallback did not protect anyone from this.** The break was on
|
||||||
|
the modern path, which the fallback never touches. Carrying the second copy
|
||||||
|
bought nothing against the actual defect while creating the divergence that
|
||||||
|
produced it. That is an argument *for* B6, not against it.
|
||||||
|
- **Every gate was green.** The safety harness renders the scoreboard screens,
|
||||||
|
not scroll mode; `test_core_fallback.py` checked that methods existed and that
|
||||||
|
their *globals* resolved, and `self.NHL_SEPARATOR_ICON` is an attribute read,
|
||||||
|
invisible to an AST scan for `Name` loads. The fix was to stop reasoning about
|
||||||
|
source and **build the object**: construct both classes on both paths, compare
|
||||||
|
the separator icons they end up with, and assert the adopted class ends up
|
||||||
|
with every instance attribute the bundled one sets.
|
||||||
|
- **Test what the change touches, not what is convenient to render.** Scroll
|
||||||
|
mode had no coverage because the harness could not reach it. A comparison
|
||||||
|
harness that renders the same games through both paths and diffs the pixels
|
||||||
|
needs no per-sport knowledge of the right answer, only that adopting core code
|
||||||
|
did not change it.
|
||||||
|
|
||||||
|
**The ledger.** Before adoption, eight duplicated copies totalled 5,685 lines.
|
||||||
|
After adoption plus the frozen legacy copies it was 10,610; removing the dead
|
||||||
|
inline duplication (plugins #252) brought it to roughly 8,620. B6 would take it
|
||||||
|
to about 3,300 including the shared core module — some 2,400 fewer than before
|
||||||
|
this project started. **Until B6 runs, the adoption is net negative on disk**,
|
||||||
|
and its one delivered user-visible gain is that adopted plugins honour the
|
||||||
|
global `target_fps` instead of hardcoding ~100 FPS.
|
||||||
|
|
||||||
|
### Decision: stop adopting further modules until B6 closes
|
||||||
|
|
||||||
|
`data_sources.py` (9 copies), `game_renderer.py` (8) and `base_odds_manager.py`
|
||||||
|
are the obvious next candidates. **Do not adopt them yet.** Each adoption adds
|
||||||
|
carrying cost — a second copy to keep in step — against a payoff that is
|
||||||
|
contingent on B6, and B6 is gated on an installed base we cannot currently
|
||||||
|
measure. Consolidate what is already committed; revisit when B6 does.
|
||||||
|
|
||||||
## What's next
|
## What's next
|
||||||
|
|
||||||
In order. Each step is independently useful and independently revertible.
|
Steps 1–5 of the original plan are **done**: 3.2.0 is tagged and published with
|
||||||
|
a version number CI now asserts (#428), the compatibility gate is in
|
||||||
|
`install_plugin` and reads `compatible_versions` as well as the floor
|
||||||
|
(#431, #433), the newest manifest entry is required to use `ledmatrix_min_version`
|
||||||
|
(plugins #244), and all eight plugins have adopted the scroll orchestration
|
||||||
|
(plugins #245–#249, repaired in #251, tidied in #252).
|
||||||
|
|
||||||
1. **Tag and publish v3.2.0.** The code is already on `main` (`21825cbf`).
|
What actually remains, smallest first:
|
||||||
Nothing else blocks this, and it is what makes `ledmatrix_min_version:
|
|
||||||
"3.2.0"` refer to something real.
|
1. **Soak the adoptions on hardware.** football and hockey have been run on a
|
||||||
2. **Make the version number honest.** Have the release process assert that the
|
live rig through real games; baseball was watched through one earlier. The
|
||||||
tag, the GitHub release, and `src.__version__` agree — a check in CI is
|
rest are proven by harness, unit tests and pixel comparison. Out-of-season
|
||||||
cheaper than the confusion of the last two releases. Then revisit the
|
sports cannot be soaked until their season starts. When you do, **check the
|
||||||
`< 2.0.0` skip in `_warn_if_incompatible`, which currently silences the
|
rig's `*_display_mode` first** — a board in `switch` mode will happily load a
|
||||||
warning for the users who most need it.
|
sunset plugin and tell you nothing about the scroll code the sunset changed.
|
||||||
3. **Add the compatibility gate** to `StoreManager.install_plugin` and
|
2. **Cut 3.3.0.** Not required by B6 — its floors are 3.2.0, which is released —
|
||||||
`.update_plugin`: refuse a plugin whose declared floor exceeds
|
but `calendar` 1.2.3 floors at 3.3.0 for the device-authorization endpoints
|
||||||
`src.__version__`, and surface the reason in the store UI rather than only
|
that landed after 3.2.0, so it is un-installable until the release exists.
|
||||||
the log. This is the single change that turns the floor from documentation
|
3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`,
|
||||||
into a guarantee, and B6 depends on it.
|
`base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is
|
||||||
4. **Migrate the manifests** to `ledmatrix_min_version`, and reconcile them with
|
the largest single duplication left: ~11,500 lines across eight plugins, with
|
||||||
`compatible_versions` (see above — that field is the required, canonical one,
|
~36,500 more in the eight `sports.py`. Note that core already ships
|
||||||
and the gate does not read it yet). Currently 28 plugins spell the floor both
|
`src/base_classes/sports/` (~143KB, promoted in B1/B2) that **no plugin
|
||||||
ways across their `versions[]` entries, 12 use only the old spelling, and 2
|
imports** — check whether it has drifted before treating it as the target.
|
||||||
only the new. Scope the sweep to the nine sports plugins if a 42-plugin
|
|
||||||
version-bump wave isn't worth it — but the `compatible_versions` half has to
|
|
||||||
cover every manifest the gate can refuse, or define explicit legacy handling,
|
|
||||||
before the gate is allowed to block anything.
|
|
||||||
5. **Run B5 adoption** — hockey, soccer, football, then the remaining six.
|
|
||||||
Bundled copies stay. Byte-identical harness output per plugin, then a soak.
|
|
||||||
6. **Only then plan B6**, with the compatibility regression test described above
|
|
||||||
in CI first.
|
|
||||||
|
|
||||||
## How to keep this project healthy
|
## How to keep this project healthy
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,22 @@ The installation script:
|
|||||||
- Installs and configures `dnsmasq` (DHCP server for AP mode)
|
- Installs and configures `dnsmasq` (DHCP server for AP mode)
|
||||||
- These services can interfere with normal WiFi client 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.
|
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
|
## 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
|
- WiFi monitor service enables AP mode when WiFi disconnects
|
||||||
- AP mode switches WiFi from client to access point mode
|
- AP mode switches WiFi from client to access point mode
|
||||||
- Pi loses connection to your original network
|
- 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**:
|
**To regain SSH**:
|
||||||
1. Connect to **LEDMatrix-Setup** AP network (password: `ledmatrix123`)
|
1. Connect to **LEDMatrix-Setup** AP network (password: `ledmatrix123`)
|
||||||
2. SSH to `192.168.4.1`
|
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.
|
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`)
|
## Marking Fields as Advanced (`x-advanced`)
|
||||||
|
|
||||||
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
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
|
EOF
|
||||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
|
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
|
# when its output is a terminal. From that pager (less) a "!sh" is a root
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
|
# 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
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1480,27 +1487,54 @@ if [ -f "$PROJECT_ROOT_DIR/config/config.json" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Set proper permissions for secrets file (restrictive: owner rw, group r)
|
# Set proper permissions for secrets file (restrictive: owner rw, group r)
|
||||||
# If service runs as root, set ownership to root so it can read as owner
|
# Owned by whoever WRITES the file, which is the web interface.
|
||||||
# Otherwise, use ACTUAL_USER and rely on group membership
|
#
|
||||||
|
# This used to read the User= of ledmatrix.service — the display service —
|
||||||
|
# and, finding root, hand the file to root:ledmatrix 640. But the display
|
||||||
|
# service only ever reads secrets, and root can read any file regardless of
|
||||||
|
# mode. The account that *writes* them is the web interface: it saves config
|
||||||
|
# edits and performs backup restores, and it deliberately does not run as root
|
||||||
|
# (a web server should not). So a root-owned, group-read-only file left the web
|
||||||
|
# UI unable to write its own secrets, and restoring a backup failed with
|
||||||
|
# "Permission denied: config_secrets.json" while every other file in the same
|
||||||
|
# backup restored fine.
|
||||||
|
#
|
||||||
|
# Owning by the writer keeps the tighter 640 rather than loosening to
|
||||||
|
# group-writable, and root still reads it as superuser.
|
||||||
if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
||||||
# Check if service runs as root (from service file or template)
|
# The web service is the writer; fall back to the display service, then to
|
||||||
SERVICE_USER="root"
|
# the installing user, so an unusual layout still lands somewhere sensible.
|
||||||
if [ -f "/etc/systemd/system/ledmatrix.service" ]; then
|
SECRETS_OWNER=""
|
||||||
SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix.service | cut -d'=' -f2 || echo "root")
|
for unit in "/etc/systemd/system/ledmatrix-web.service" \
|
||||||
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" ]; then
|
"$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service"; do
|
||||||
SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" | cut -d'=' -f2 || echo "root")
|
if [ -f "$unit" ]; then
|
||||||
|
SECRETS_OWNER=$(grep -m1 "^User=" "$unit" | cut -d'=' -f2)
|
||||||
|
[ -n "$SECRETS_OWNER" ] && break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$SECRETS_OWNER" ]; then
|
||||||
|
SECRETS_OWNER="$ACTUAL_USER"
|
||||||
fi
|
fi
|
||||||
|
SECRETS_FILE="$PROJECT_ROOT_DIR/config/config_secrets.json"
|
||||||
if [ "$SERVICE_USER" = "root" ]; then
|
# A root-owned file is only correct when the writer really is root.
|
||||||
# Service runs as root - set ownership to root so it can read as owner
|
if ! chown "$SECRETS_OWNER:$LEDMATRIX_GROUP" "$SECRETS_FILE"; then
|
||||||
chown "root:$LEDMATRIX_GROUP" "$PROJECT_ROOT_DIR/config/config_secrets.json" || true
|
echo "✗ ERROR: Failed to set ownership on $SECRETS_FILE to $SECRETS_OWNER:$LEDMATRIX_GROUP" >&2
|
||||||
echo "✓ Secrets file permissions set (root:ledmatrix for root service)"
|
echo " Try: sudo chown $SECRETS_OWNER:$LEDMATRIX_GROUP $SECRETS_FILE" >&2
|
||||||
else
|
exit 1
|
||||||
# Service runs as regular user - use ACTUAL_USER and rely on group membership
|
|
||||||
chown "$ACTUAL_USER:$LEDMATRIX_GROUP" "$PROJECT_ROOT_DIR/config/config_secrets.json" || true
|
|
||||||
echo "✓ Secrets file permissions set ($ACTUAL_USER:ledmatrix)"
|
|
||||||
fi
|
fi
|
||||||
chmod 640 "$PROJECT_ROOT_DIR/config/config_secrets.json"
|
if ! chmod 640 "$SECRETS_FILE"; then
|
||||||
|
echo "✗ ERROR: Failed to set permissions on $SECRETS_FILE to 640" >&2
|
||||||
|
echo " Try: sudo chmod 640 $SECRETS_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ACTUAL_OWNERSHIP=$(stat -c '%U:%G' "$SECRETS_FILE" 2>/dev/null || echo "unknown")
|
||||||
|
ACTUAL_MODE=$(stat -c '%a' "$SECRETS_FILE" 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$ACTUAL_OWNERSHIP" != "$SECRETS_OWNER:$LEDMATRIX_GROUP" ] || [ "$ACTUAL_MODE" != "640" ]; then
|
||||||
|
echo "✗ ERROR: $SECRETS_FILE ended up as $ACTUAL_OWNERSHIP mode $ACTUAL_MODE, expected $SECRETS_OWNER:$LEDMATRIX_GROUP mode 640" >&2
|
||||||
|
echo " The web interface may be unable to read or write config_secrets.json." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ Secrets file owned by the web service user ($SECRETS_OWNER:$LEDMATRIX_GROUP, mode 640)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set proper permissions for YTM auth file (readable by all users including root service)
|
# Set proper permissions for YTM auth file (readable by all users including root service)
|
||||||
@@ -1661,6 +1695,97 @@ else
|
|||||||
echo "✗ $CMDLINE_FILE not found; skipping isolcpus optimization"
|
echo "✗ $CMDLINE_FILE not found; skipping isolcpus optimization"
|
||||||
fi
|
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 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
journald_conf="$(journald_effective)"
|
||||||
|
# Both settings are optional, and stock images ship them commented out. grep
|
||||||
|
# exits 1 on no match, which pipefail turns fatal under set -e — an absent
|
||||||
|
# setting must read as empty, not abort the install.
|
||||||
|
journald_storage="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||||
|
journald_cap="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*SystemMaxUse=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||||
|
|
||||||
|
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:]' || true)"
|
||||||
|
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)
|
# Ensure dtparam=audio=off in config.txt (idempotent)
|
||||||
if [ "$SKIP_PERF" = "1" ]; then
|
if [ "$SKIP_PERF" = "1" ]; then
|
||||||
: # skipped
|
: # skipped
|
||||||
|
|||||||
@@ -10,16 +10,13 @@ python_functions = test_*
|
|||||||
testpaths = test
|
testpaths = test
|
||||||
|
|
||||||
# Output options
|
# Output options
|
||||||
# Note: Coverage options require pytest-cov to be installed
|
# Coverage is deliberately NOT configured here: a bare local `pytest` should
|
||||||
# Run: pip install pytest-cov
|
# be fast and dependency-light. Coverage is measured and enforced in exactly
|
||||||
addopts =
|
# one place — the unit-tests job in .github/workflows/test.yml.
|
||||||
|
addopts =
|
||||||
-v
|
-v
|
||||||
--strict-markers
|
--strict-markers
|
||||||
--tb=short
|
--tb=short
|
||||||
--cov=src
|
|
||||||
--cov-report=term-missing
|
|
||||||
--cov-report=html
|
|
||||||
--cov-fail-under=30
|
|
||||||
|
|
||||||
# Markers
|
# Markers
|
||||||
markers =
|
markers =
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ pytest>=9.0.3,<10.0.0
|
|||||||
pytest-cov>=4.1.0,<5.0.0
|
pytest-cov>=4.1.0,<5.0.0
|
||||||
pytest-mock>=3.11.0,<4.0.0
|
pytest-mock>=3.11.0,<4.0.0
|
||||||
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
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)
|
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
|
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||||
|
check_empty_claimed,
|
||||||
check_scale_up,
|
check_scale_up,
|
||||||
)
|
)
|
||||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
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", {})
|
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||||
fill_strict = spec.get("fill_check") == "strict"
|
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" —
|
# Every run: the base config, plus one per harness.json "variant" —
|
||||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
# 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)
|
compare_to_goldens(results, golden_dir)
|
||||||
|
|
||||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
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.
|
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||||
if variant_name:
|
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
|
# warn-only underfill: big panel left mostly empty
|
||||||
ex, ey = r.fill_extent
|
ex, ey = r.fill_extent
|
||||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
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:
|
else:
|
||||||
everything_ok = False
|
everything_ok = False
|
||||||
if r.error is not None:
|
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)
|
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||||
status = "FAIL"
|
status = "FAIL"
|
||||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
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:
|
else:
|
||||||
status, detail = "FAIL", ""
|
status, detail = "FAIL", ""
|
||||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||||
|
|||||||
@@ -24,9 +24,29 @@ echo "========================================"
|
|||||||
# Auto-detect latest version if needed
|
# Auto-detect latest version if needed
|
||||||
if [ "$PIXLET_VERSION" = "latest" ]; then
|
if [ "$PIXLET_VERSION" = "latest" ]; then
|
||||||
echo "Detecting latest version..."
|
echo "Detecting latest version..."
|
||||||
PIXLET_VERSION=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
# When this response arrives on a single line -- as it did on the device
|
||||||
if [ -z "$PIXLET_VERSION" ]; then
|
# where Starlark apps were failing -- `grep '"tag_name"'` matches the whole
|
||||||
echo "Failed to detect latest version, using fallback"
|
# 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"
|
PIXLET_VERSION="v0.50.2"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -67,8 +87,26 @@ download_binary() {
|
|||||||
temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX)
|
temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX)
|
||||||
local temp_file="$temp_dir/$archive_name"
|
local temp_file="$temp_dir/$archive_name"
|
||||||
|
|
||||||
if ! curl -L -o "$temp_file" "$url" 2>/dev/null; then
|
# -f so an HTTP error is a failure. Without it curl writes the 404 body
|
||||||
echo "✗ Failed to download $arch"
|
# 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"
|
rm -rf "$temp_dir"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -100,10 +100,15 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
|||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
||||||
|
|
||||||
# Optional: journalctl (non-critical — skip if not found)
|
# 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
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Required: python3, bash
|
# Required: python3, bash
|
||||||
|
|||||||
@@ -25,9 +25,44 @@ if [ "$EUID" -eq 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# Get the full paths to commands
|
||||||
NMCLI_PATH=$(which nmcli || echo "/usr/bin/nmcli")
|
NMCLI_PATH=$(command -v nmcli || echo "/usr/bin/nmcli")
|
||||||
SYSTEMCTL_PATH=$(which systemctl)
|
SYSTEMCTL_PATH=$(command -v systemctl)
|
||||||
|
|
||||||
echo "Command paths:"
|
echo "Command paths:"
|
||||||
echo " nmcli: $NMCLI_PATH"
|
echo " nmcli: $NMCLI_PATH"
|
||||||
@@ -37,6 +72,18 @@ echo " systemctl: $SYSTEMCTL_PATH"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Step 1: Configuring sudo permissions for nmcli..."
|
echo "Step 1: Configuring sudo permissions for nmcli..."
|
||||||
SUDOERS_FILE="/etc/sudoers.d/ledmatrix_wifi"
|
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)
|
# Create a temporary sudoers file using mktemp (handles permissions better)
|
||||||
TEMP_SUDOERS=$(mktemp) || {
|
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 stop dnsmasq
|
||||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart dnsmasq
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart dnsmasq
|
||||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart NetworkManager
|
$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
|
# Allow copying hostapd and dnsmasq config files into place
|
||||||
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
|
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ def main() -> int:
|
|||||||
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
||||||
parser.add_argument('--skip-update', action='store_true',
|
parser.add_argument('--skip-update', action='store_true',
|
||||||
help='Skip calling update() (render display only)')
|
help='Skip calling update() (render display only)')
|
||||||
|
parser.add_argument('--display-mode', default=None,
|
||||||
|
help='Display mode to render, for plugins that declare '
|
||||||
|
'more than one in their manifest (e.g. nrl_live). '
|
||||||
|
'Omitted, the plugin picks its own default.')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -141,8 +145,26 @@ def main() -> int:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("update() raised: %s — continuing to display()", e)
|
logger.warning("update() raised: %s — continuing to display()", e)
|
||||||
|
|
||||||
|
# A plugin that declares several display modes usually renders nothing
|
||||||
|
# useful without being told which one to draw: the scoreboards keep their
|
||||||
|
# state on per-mode sub-managers and their no-argument path returns False.
|
||||||
|
# Only pass the argument when asked for, so the many plugins whose display()
|
||||||
|
# takes no display_mode keep working untouched.
|
||||||
try:
|
try:
|
||||||
plugin_instance.display(force_clear=True)
|
if args.display_mode:
|
||||||
|
try:
|
||||||
|
plugin_instance.display(display_mode=args.display_mode,
|
||||||
|
force_clear=True)
|
||||||
|
except TypeError as error:
|
||||||
|
if ("unexpected keyword argument" not in str(error)
|
||||||
|
or "display_mode" not in str(error)):
|
||||||
|
raise
|
||||||
|
logger.warning(
|
||||||
|
"%s.display() does not accept display_mode; rendering its "
|
||||||
|
"default screen instead", args.plugin)
|
||||||
|
plugin_instance.display(force_clear=True)
|
||||||
|
else:
|
||||||
|
plugin_instance.display(force_clear=True)
|
||||||
logger.debug("display() completed")
|
logger.debug("display() completed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error in display(): %s", e)
|
logger.error("Error in display(): %s", e)
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
|||||||
Core source package for the LED Matrix Display project.
|
Core source package for the LED Matrix Display project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.2.0"
|
__version__ = "3.3.0"
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ Key Features:
|
|||||||
- Memory-efficient data storage
|
- Memory-efficient data storage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import itertools
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import requests
|
import requests
|
||||||
from typing import Dict, Any, Optional, Callable
|
from typing import Dict, Any, Optional, Callable, List
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import queue
|
import queue
|
||||||
@@ -50,14 +51,33 @@ class FetchRequest:
|
|||||||
max_retries: int = 3
|
max_retries: int = 3
|
||||||
priority: int = 1 # Higher number = higher priority
|
priority: int = 1 # Higher number = higher priority
|
||||||
callback: Optional[Callable] = None
|
callback: Optional[Callable] = None
|
||||||
|
# Callbacks from submitters that JOINED this fetch instead of starting a
|
||||||
|
# duplicate one. The primary `callback` above belongs to whoever created
|
||||||
|
# the request; these belong to everyone who asked for the same cache_key
|
||||||
|
# while it was still in flight.
|
||||||
|
extra_callbacks: List[Callable] = field(default_factory=list)
|
||||||
created_at: float = field(default_factory=time.time)
|
created_at: float = field(default_factory=time.time)
|
||||||
status: FetchStatus = FetchStatus.PENDING
|
status: FetchStatus = FetchStatus.PENDING
|
||||||
|
# Set once the worker has decided this response will be cached, while it
|
||||||
|
# still holds the lock. From that point cancelling is refused: the write
|
||||||
|
# is already authorised, and abandoning it here would put the payload in
|
||||||
|
# the cache with the callbacks suppressed -- joiners waiting forever for a
|
||||||
|
# fetch that did, in fact, succeed.
|
||||||
|
commit_claimed: bool = False
|
||||||
result: Optional[Any] = None
|
result: Optional[Any] = None
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FetchResult:
|
class FetchResult:
|
||||||
"""Result of a background fetch operation."""
|
"""Result of a background fetch operation.
|
||||||
|
|
||||||
|
``data`` survives on the stored result only for requests submitted without
|
||||||
|
a ``callback``, where polling ``get_result()`` is the sole way to collect
|
||||||
|
it. When a callback was given, the payload has already been delivered and
|
||||||
|
the service releases it -- see :meth:`BackgroundDataService._release_payload`.
|
||||||
|
Either way the data remains in the cache under the request's ``cache_key``,
|
||||||
|
which is where consumers read it from.
|
||||||
|
"""
|
||||||
request_id: str
|
request_id: str
|
||||||
success: bool
|
success: bool
|
||||||
data: Optional[Any] = None
|
data: Optional[Any] = None
|
||||||
@@ -66,6 +86,10 @@ class FetchResult:
|
|||||||
fetch_time: float = 0.0
|
fetch_time: float = 0.0
|
||||||
retry_count: int = 0
|
retry_count: int = 0
|
||||||
completed_at: float = field(default_factory=time.time) # Timestamp when request completed
|
completed_at: float = field(default_factory=time.time) # Timestamp when request completed
|
||||||
|
# The request's final status, recorded so a finished request can still be
|
||||||
|
# reported accurately. Without it a caller can only be told COMPLETED or
|
||||||
|
# FAILED, which turns "you cancelled this" into "this errored".
|
||||||
|
final_status: Optional[FetchStatus] = None
|
||||||
|
|
||||||
class BackgroundDataService:
|
class BackgroundDataService:
|
||||||
"""
|
"""
|
||||||
@@ -90,6 +114,20 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
# Thread management
|
# Thread management
|
||||||
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData")
|
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData")
|
||||||
|
# cache_key -> request_id for fetches currently in flight. Submitting
|
||||||
|
# the same key twice used to start two identical fetches: request_id
|
||||||
|
# carries a millisecond timestamp, so every submit looked new, and
|
||||||
|
# active_requests is keyed by it rather than by what is being fetched.
|
||||||
|
# On a real board the season-schedule key is requested by both the
|
||||||
|
# Recent and the Upcoming manager, which miss the cache in the same
|
||||||
|
# millisecond and each download and parse the same payload.
|
||||||
|
self._inflight_by_cache_key: Dict[str, str] = {}
|
||||||
|
# request_id was sport_year_milliseconds, which is not unique: two
|
||||||
|
# submits inside the same millisecond produced the SAME id, so one
|
||||||
|
# silently replaced the other in active_requests and completed_requests.
|
||||||
|
# Rare before, but dedupe hands this id back to every joiner as their
|
||||||
|
# handle for get_result(), so it has to be unique. A counter is enough.
|
||||||
|
self._request_seq = itertools.count()
|
||||||
self.active_requests: Dict[str, FetchRequest] = {}
|
self.active_requests: Dict[str, FetchRequest] = {}
|
||||||
self.completed_requests: Dict[str, FetchResult] = {}
|
self.completed_requests: Dict[str, FetchResult] = {}
|
||||||
self.request_queue = queue.PriorityQueue()
|
self.request_queue = queue.PriorityQueue()
|
||||||
@@ -177,7 +215,9 @@ class BackgroundDataService:
|
|||||||
if cache_key is None:
|
if cache_key is None:
|
||||||
cache_key = self.get_sport_cache_key(sport)
|
cache_key = self.get_sport_cache_key(sport)
|
||||||
|
|
||||||
request_id = f"{sport}_{year}_{int(time.time() * 1000)}"
|
with self._lock:
|
||||||
|
request_id = (f"{sport}_{year}_{int(time.time() * 1000)}"
|
||||||
|
f"_{next(self._request_seq)}")
|
||||||
|
|
||||||
# Check cache first
|
# Check cache first
|
||||||
cached_data = self.cache_manager.get(cache_key)
|
cached_data = self.cache_manager.get(cache_key)
|
||||||
@@ -191,14 +231,19 @@ class BackgroundDataService:
|
|||||||
cached=True,
|
cached=True,
|
||||||
fetch_time=0.0
|
fetch_time=0.0
|
||||||
)
|
)
|
||||||
|
# Filed before the callback runs, as it always was: a callback
|
||||||
|
# that queries get_result()/is_request_complete() for its own
|
||||||
|
# request must still find it. Releasing afterwards mutates the
|
||||||
|
# same object the dict holds.
|
||||||
self.completed_requests[request_id] = result
|
self.completed_requests[request_id] = result
|
||||||
|
|
||||||
if callback:
|
if callback:
|
||||||
try:
|
try:
|
||||||
callback(result)
|
callback(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in callback for request {request_id}: {e}")
|
logger.error(f"Error in callback for request {request_id}: {e}")
|
||||||
|
self._release_payload(result)
|
||||||
|
|
||||||
logger.debug(f"Cache hit for {sport} {year} data")
|
logger.debug(f"Cache hit for {sport} {year} data")
|
||||||
return request_id
|
return request_id
|
||||||
|
|
||||||
@@ -218,7 +263,29 @@ class BackgroundDataService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
existing_id = self._inflight_by_cache_key.get(cache_key)
|
||||||
|
existing = self.active_requests.get(existing_id) if existing_id else None
|
||||||
|
if existing_id and existing is None:
|
||||||
|
# Stranded index entry: the request it names is gone. Drop it and
|
||||||
|
# fetch normally. Looking the request up rather than trusting the
|
||||||
|
# id is what stops a stale entry wedging a key forever.
|
||||||
|
del self._inflight_by_cache_key[cache_key]
|
||||||
|
if existing is not None:
|
||||||
|
# Someone is already fetching this key. Ride along rather than
|
||||||
|
# duplicating the download, the parse and the resident copy.
|
||||||
|
if callback:
|
||||||
|
existing.extra_callbacks.append(callback)
|
||||||
|
self.stats['deduplicated_requests'] = (
|
||||||
|
self.stats.get('deduplicated_requests', 0) + 1
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Joined in-flight fetch %s for %s (cache_key=%s) instead of "
|
||||||
|
"starting a duplicate", existing_id, sport, cache_key
|
||||||
|
)
|
||||||
|
return existing_id
|
||||||
|
|
||||||
self.active_requests[request_id] = request
|
self.active_requests[request_id] = request
|
||||||
|
self._inflight_by_cache_key[cache_key] = request_id
|
||||||
self.stats['total_requests'] += 1
|
self.stats['total_requests'] += 1
|
||||||
self.stats['cache_misses'] += 1
|
self.stats['cache_misses'] += 1
|
||||||
|
|
||||||
@@ -243,7 +310,31 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
request.status = FetchStatus.IN_PROGRESS
|
# A request cancelled while it sat in the executor queue must
|
||||||
|
# stay cancelled. Overwriting the status here undid the cancel
|
||||||
|
# outright: the worker went on to download, cache and call back
|
||||||
|
# for work the caller had already withdrawn.
|
||||||
|
if request.status == FetchStatus.CANCELLED:
|
||||||
|
cancelled_before_start = True
|
||||||
|
else:
|
||||||
|
cancelled_before_start = False
|
||||||
|
request.status = FetchStatus.IN_PROGRESS
|
||||||
|
if cancelled_before_start:
|
||||||
|
logger.info(
|
||||||
|
"Request %s was cancelled before its worker started; "
|
||||||
|
"skipping the fetch entirely", request.id
|
||||||
|
)
|
||||||
|
# Assign before returning: the finally block stores `result`
|
||||||
|
# in completed_requests, so building a fresh one here would
|
||||||
|
# file the untouched placeholder instead of this outcome.
|
||||||
|
result = FetchResult(
|
||||||
|
request_id=request.id,
|
||||||
|
success=False,
|
||||||
|
error="cancelled",
|
||||||
|
fetch_time=time.time() - start_time,
|
||||||
|
retry_count=request.retry_count
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
logger.info(f"Starting background fetch for {request.sport} {request.year}")
|
logger.info(f"Starting background fetch for {request.sport} {request.year}")
|
||||||
|
|
||||||
@@ -269,6 +360,37 @@ class BackgroundDataService:
|
|||||||
# Log data validation
|
# Log data validation
|
||||||
logger.debug(f"Validated {len(events)} events for {request.sport} {request.year}")
|
logger.debug(f"Validated {len(events)} events for {request.sport} {request.year}")
|
||||||
|
|
||||||
|
# A cancelled request must not commit anything. Cancelling
|
||||||
|
# releases the cache_key, so a replacement fetch for the same key
|
||||||
|
# may already be in flight or finished -- writing this response to
|
||||||
|
# the cache now would overwrite fresher data with the response
|
||||||
|
# nobody wanted. The worker has no way to abort the HTTP call, so
|
||||||
|
# this is where the work gets discarded.
|
||||||
|
with self._lock:
|
||||||
|
cancelled = request.status == FetchStatus.CANCELLED
|
||||||
|
if not cancelled:
|
||||||
|
# Claim the commit in the same critical section that read
|
||||||
|
# the status, so a cancel cannot slip in between the check
|
||||||
|
# and the cache write below. The write itself stays outside
|
||||||
|
# the lock: it serialises a multi-megabyte payload to the
|
||||||
|
# SD card, and holding the service lock across that would
|
||||||
|
# stall every submit, status query and cancel behind it.
|
||||||
|
request.commit_claimed = True
|
||||||
|
if cancelled:
|
||||||
|
logger.info(
|
||||||
|
"Discarding response for cancelled request %s; %s may "
|
||||||
|
"already belong to a replacement fetch",
|
||||||
|
request.id, request.cache_key
|
||||||
|
)
|
||||||
|
result = FetchResult(
|
||||||
|
request_id=request.id,
|
||||||
|
success=False,
|
||||||
|
error="cancelled",
|
||||||
|
fetch_time=time.time() - start_time,
|
||||||
|
retry_count=request.retry_count
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
# Cache the data
|
# Cache the data
|
||||||
self.cache_manager.set(request.cache_key, data)
|
self.cache_manager.set(request.cache_key, data)
|
||||||
|
|
||||||
@@ -294,7 +416,12 @@ class BackgroundDataService:
|
|||||||
logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}")
|
logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}")
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
request.status = FetchStatus.FAILED
|
# Don't relabel a cancelled request. The callback gate in the
|
||||||
|
# finally block only suppresses CANCELLED, so promoting it to
|
||||||
|
# FAILED here delivered an error callback for a fetch nobody
|
||||||
|
# was waiting on any more.
|
||||||
|
if request.status != FetchStatus.CANCELLED:
|
||||||
|
request.status = FetchStatus.FAILED
|
||||||
request.error = error_msg
|
request.error = error_msg
|
||||||
|
|
||||||
result = FetchResult(
|
result = FetchResult(
|
||||||
@@ -308,9 +435,26 @@ class BackgroundDataService:
|
|||||||
finally:
|
finally:
|
||||||
# Store result and clean up
|
# Store result and clean up
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
result.final_status = request.status
|
||||||
self.completed_requests[request.id] = result
|
self.completed_requests[request.id] = result
|
||||||
if request.id in self.active_requests:
|
if request.id in self.active_requests:
|
||||||
del self.active_requests[request.id]
|
del self.active_requests[request.id]
|
||||||
|
# Stop accepting joiners and take the callback list in the same
|
||||||
|
# critical section. A submitter that arrives after this point
|
||||||
|
# finds no in-flight entry and either hits the cache (written
|
||||||
|
# above, before the result was built) or starts a fresh fetch --
|
||||||
|
# what it must never do is join a fetch whose callbacks have
|
||||||
|
# already run and then never be called.
|
||||||
|
if self._inflight_by_cache_key.get(request.cache_key) == request.id:
|
||||||
|
del self._inflight_by_cache_key[request.cache_key]
|
||||||
|
# A cancelled request delivers nothing: its joiners were told
|
||||||
|
# about a fetch that has been abandoned, and a replacement will
|
||||||
|
# call them via its own request.
|
||||||
|
if request.status == FetchStatus.CANCELLED:
|
||||||
|
callbacks = []
|
||||||
|
else:
|
||||||
|
callbacks = ([request.callback] if request.callback else [])
|
||||||
|
callbacks.extend(request.extra_callbacks)
|
||||||
|
|
||||||
# Update statistics
|
# Update statistics
|
||||||
if result.success:
|
if result.success:
|
||||||
@@ -327,14 +471,53 @@ class BackgroundDataService:
|
|||||||
# Periodic cleanup after storing result
|
# Periodic cleanup after storing result
|
||||||
self._cleanup_completed_requests()
|
self._cleanup_completed_requests()
|
||||||
|
|
||||||
# Call callback if provided
|
# Call every callback: the original submitter's and any that joined
|
||||||
if request.callback:
|
# this fetch. One raising must not stop the others being delivered.
|
||||||
|
for cb in callbacks:
|
||||||
try:
|
try:
|
||||||
request.callback(result)
|
cb(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in callback for request {request.id}: {e}")
|
logger.error(f"Error in callback for request {request.id}: {e}")
|
||||||
|
|
||||||
|
# Released AFTER the loop, not inside it. Every callback here holds
|
||||||
|
# the same FetchResult, so releasing per-delivery handed the first
|
||||||
|
# one the data and every joiner `result.data is None` -- which is
|
||||||
|
# not a quiet degradation: they read `result.data.get('events')` and
|
||||||
|
# raise AttributeError, which this very loop catches and logs, so
|
||||||
|
# the symptom was one ERROR line and a manager that silently never
|
||||||
|
# got its schedule. Deduplication is the normal case, not a corner:
|
||||||
|
# a sport's recent, upcoming and live managers all ride one season
|
||||||
|
# fetch.
|
||||||
|
#
|
||||||
|
# Guarded on `callbacks`, because a request submitted without one
|
||||||
|
# has no other way to collect its payload than polling get_result().
|
||||||
|
# The old per-delivery release got that right by accident: an empty
|
||||||
|
# list never entered the loop body.
|
||||||
|
if callbacks:
|
||||||
|
self._release_payload(result)
|
||||||
|
request.result = None
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _release_payload(result: FetchResult) -> None:
|
||||||
|
"""Drop a delivered payload, keeping the result's status and timings.
|
||||||
|
|
||||||
|
Only called once EVERY callback has been handed the data -- callers
|
||||||
|
that joined an in-flight fetch share this object, so releasing between
|
||||||
|
deliveries strips the payload out from under the ones still queued.
|
||||||
|
Consumers read fetched data back from the cache under ``cache_key``;
|
||||||
|
the copy carried
|
||||||
|
here was pinning a parsed season schedule -- 946 games for NCAA
|
||||||
|
football, roughly a tenth of total RAM on a 1GB Pi -- in memory until
|
||||||
|
the hourly sweep.
|
||||||
|
|
||||||
|
The cache-hit path matters most: it runs once per update interval per
|
||||||
|
sport, mints a fresh request_id each time, and a memory-tier miss
|
||||||
|
re-parses the payload from disk. Those were genuinely separate copies
|
||||||
|
accumulating toward the 500-entry cap, not shared references.
|
||||||
|
"""
|
||||||
|
result.data = None
|
||||||
|
|
||||||
def _make_request_with_retry(self, request: FetchRequest) -> requests.Response:
|
def _make_request_with_retry(self, request: FetchRequest) -> requests.Response:
|
||||||
"""
|
"""
|
||||||
@@ -422,6 +605,8 @@ class BackgroundDataService:
|
|||||||
return self.active_requests[request_id].status
|
return self.active_requests[request_id].status
|
||||||
elif request_id in self.completed_requests:
|
elif request_id in self.completed_requests:
|
||||||
result = self.completed_requests[request_id]
|
result = self.completed_requests[request_id]
|
||||||
|
if result.final_status is not None:
|
||||||
|
return result.final_status
|
||||||
return FetchStatus.COMPLETED if result.success else FetchStatus.FAILED
|
return FetchStatus.COMPLETED if result.success else FetchStatus.FAILED
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -438,8 +623,22 @@ class BackgroundDataService:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
if request_id in self.active_requests:
|
if request_id in self.active_requests:
|
||||||
request = self.active_requests[request_id]
|
request = self.active_requests[request_id]
|
||||||
|
if request.commit_claimed:
|
||||||
|
# Too late: the worker holds an authorised commit. Report
|
||||||
|
# the failure rather than half-cancelling a request whose
|
||||||
|
# data is about to land in the cache.
|
||||||
|
logger.debug(
|
||||||
|
"Not cancelling %s: its response is already being "
|
||||||
|
"committed", request_id
|
||||||
|
)
|
||||||
|
return False
|
||||||
request.status = FetchStatus.CANCELLED
|
request.status = FetchStatus.CANCELLED
|
||||||
del self.active_requests[request_id]
|
del self.active_requests[request_id]
|
||||||
|
# Cancelling is the other way a request leaves active_requests,
|
||||||
|
# so the in-flight index has to be released here too or the key
|
||||||
|
# stays pointed at a request that no longer exists.
|
||||||
|
if self._inflight_by_cache_key.get(request.cache_key) == request_id:
|
||||||
|
del self._inflight_by_cache_key[request.cache_key]
|
||||||
logger.info(f"Cancelled request {request_id}")
|
logger.info(f"Cancelled request {request_id}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
import socket
|
import socket
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -82,6 +83,10 @@ BUNDLED_FONTS: frozenset[str] = frozenset({
|
|||||||
_CONFIG_REL = Path("config/config.json")
|
_CONFIG_REL = Path("config/config.json")
|
||||||
_SECRETS_REL = Path("config/config_secrets.json")
|
_SECRETS_REL = Path("config/config_secrets.json")
|
||||||
_WIFI_REL = Path("config/wifi_config.json")
|
_WIFI_REL = Path("config/wifi_config.json")
|
||||||
|
# Sits in config/ next to the three above and is pure user state — a
|
||||||
|
# YouTube Music session that has to be re-authenticated by hand if lost.
|
||||||
|
# It was omitted from backups, so a restore silently signed the user out.
|
||||||
|
_YTM_REL = Path("config/ytm_auth.json")
|
||||||
_FONTS_REL = Path("assets/fonts")
|
_FONTS_REL = Path("assets/fonts")
|
||||||
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
||||||
_STATE_REL = Path("data/plugin_state.json")
|
_STATE_REL = Path("data/plugin_state.json")
|
||||||
@@ -303,6 +308,9 @@ def create_backup(
|
|||||||
if (project_root / _WIFI_REL).exists():
|
if (project_root / _WIFI_REL).exists():
|
||||||
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
|
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
|
||||||
contents.append("wifi")
|
contents.append("wifi")
|
||||||
|
if (project_root / _YTM_REL).exists():
|
||||||
|
zf.write(project_root / _YTM_REL, _YTM_REL.as_posix())
|
||||||
|
contents.append("ytm_auth")
|
||||||
|
|
||||||
# User-uploaded fonts.
|
# User-uploaded fonts.
|
||||||
user_fonts = iter_user_fonts(project_root)
|
user_fonts = iter_user_fonts(project_root)
|
||||||
@@ -348,6 +356,7 @@ def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
|
|||||||
"has_config": (project_root / _CONFIG_REL).exists(),
|
"has_config": (project_root / _CONFIG_REL).exists(),
|
||||||
"has_secrets": (project_root / _SECRETS_REL).exists(),
|
"has_secrets": (project_root / _SECRETS_REL).exists(),
|
||||||
"has_wifi": (project_root / _WIFI_REL).exists(),
|
"has_wifi": (project_root / _WIFI_REL).exists(),
|
||||||
|
"has_ytm_auth": (project_root / _YTM_REL).exists(),
|
||||||
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
|
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
|
||||||
"plugin_uploads": len(iter_plugin_uploads(project_root)),
|
"plugin_uploads": len(iter_plugin_uploads(project_root)),
|
||||||
"plugins": list_installed_plugins(project_root),
|
"plugins": list_installed_plugins(project_root),
|
||||||
@@ -429,6 +438,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
|||||||
detected.append("secrets")
|
detected.append("secrets")
|
||||||
if _WIFI_REL.as_posix() in names:
|
if _WIFI_REL.as_posix() in names:
|
||||||
detected.append("wifi")
|
detected.append("wifi")
|
||||||
|
if _YTM_REL.as_posix() in names:
|
||||||
|
detected.append("ytm_auth")
|
||||||
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
||||||
detected.append("fonts")
|
detected.append("fonts")
|
||||||
if any(
|
if any(
|
||||||
@@ -481,8 +492,61 @@ def _extract_zip_safe(zip_path: Path, dest_dir: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _copy_file(src: Path, dst: Path) -> None:
|
def _copy_file(src: Path, dst: Path) -> None:
|
||||||
|
"""Replace ``dst`` with ``src``, atomically, without needing to own ``dst``.
|
||||||
|
|
||||||
|
``shutil.copy2`` opens the destination for writing, so it needs write
|
||||||
|
permission on the *existing file*. Several config files are installed
|
||||||
|
root-owned and group-readable while the web interface — which is what runs
|
||||||
|
a restore — deliberately runs as a non-root user. Restoring those failed
|
||||||
|
with EACCES even though the account could create files in the same
|
||||||
|
directory perfectly well.
|
||||||
|
|
||||||
|
Writing a temporary file alongside and renaming over the target needs only
|
||||||
|
directory permission, which the web user has. It is also atomic: a crash
|
||||||
|
mid-restore can no longer leave a half-written config behind.
|
||||||
|
|
||||||
|
The destination's existing mode is preserved when there is one, so
|
||||||
|
restoring secrets does not silently widen them to the umask default.
|
||||||
|
"""
|
||||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(src, dst)
|
|
||||||
|
existing_mode: Optional[int] = None
|
||||||
|
existing_owner: Optional[Tuple[int, int]] = None
|
||||||
|
if dst.exists():
|
||||||
|
try:
|
||||||
|
info = dst.stat()
|
||||||
|
existing_mode = stat.S_IMODE(info.st_mode)
|
||||||
|
existing_owner = (info.st_uid, info.st_gid)
|
||||||
|
except OSError:
|
||||||
|
existing_mode = None
|
||||||
|
existing_owner = None
|
||||||
|
|
||||||
|
fd, tmp_name = tempfile.mkstemp(dir=str(dst.parent), prefix=f".{dst.name}.", suffix=".tmp")
|
||||||
|
os.close(fd)
|
||||||
|
tmp_path = Path(tmp_name)
|
||||||
|
try:
|
||||||
|
shutil.copyfile(src, tmp_path)
|
||||||
|
if existing_mode is not None:
|
||||||
|
os.chmod(tmp_path, existing_mode)
|
||||||
|
else:
|
||||||
|
shutil.copymode(src, tmp_path)
|
||||||
|
if existing_owner is not None:
|
||||||
|
# Replacing a file creates a new inode owned by whoever is running,
|
||||||
|
# which would silently move a root-owned config to the web user.
|
||||||
|
# Carry the previous owner across when the OS permits it — only
|
||||||
|
# root can hand a file to another user, so this is best-effort and
|
||||||
|
# a plain restore as the web user simply keeps its own ownership.
|
||||||
|
try:
|
||||||
|
os.chown(tmp_path, existing_owner[0], existing_owner[1])
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
pass
|
||||||
|
os.replace(tmp_path, dst)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
tmp_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def restore_backup(
|
def restore_backup(
|
||||||
@@ -513,7 +577,8 @@ def restore_backup(
|
|||||||
try:
|
try:
|
||||||
_extract_zip_safe(Path(zip_path), tmp_dir)
|
_extract_zip_safe(Path(zip_path), tmp_dir)
|
||||||
except (ValueError, zipfile.BadZipFile, OSError) as e:
|
except (ValueError, zipfile.BadZipFile, OSError) as e:
|
||||||
result.errors.append(f"Failed to extract backup: {e}")
|
logger.error("[Backup] Failed to extract backup: %s", e, exc_info=True)
|
||||||
|
result.errors.append("Failed to extract backup")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Main config.
|
# Main config.
|
||||||
@@ -522,7 +587,8 @@ def restore_backup(
|
|||||||
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL)
|
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL)
|
||||||
result.restored.append("config")
|
result.restored.append("config")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
result.errors.append(f"Failed to restore config.json: {e}")
|
logger.error("[Backup] Failed to restore config.json: %s", e, exc_info=True)
|
||||||
|
result.errors.append("Failed to restore config.json")
|
||||||
elif (tmp_dir / _CONFIG_REL).exists():
|
elif (tmp_dir / _CONFIG_REL).exists():
|
||||||
result.skipped.append("config")
|
result.skipped.append("config")
|
||||||
|
|
||||||
@@ -532,7 +598,10 @@ def restore_backup(
|
|||||||
_copy_file(tmp_dir / _SECRETS_REL, project_root / _SECRETS_REL)
|
_copy_file(tmp_dir / _SECRETS_REL, project_root / _SECRETS_REL)
|
||||||
result.restored.append("secrets")
|
result.restored.append("secrets")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
result.errors.append(f"Failed to restore config_secrets.json: {e}")
|
logger.error(
|
||||||
|
"[Backup] Failed to restore config_secrets.json: %s", e, exc_info=True
|
||||||
|
)
|
||||||
|
result.errors.append("Failed to restore config_secrets.json")
|
||||||
elif (tmp_dir / _SECRETS_REL).exists():
|
elif (tmp_dir / _SECRETS_REL).exists():
|
||||||
result.skipped.append("secrets")
|
result.skipped.append("secrets")
|
||||||
|
|
||||||
@@ -542,10 +611,26 @@ def restore_backup(
|
|||||||
_copy_file(tmp_dir / _WIFI_REL, project_root / _WIFI_REL)
|
_copy_file(tmp_dir / _WIFI_REL, project_root / _WIFI_REL)
|
||||||
result.restored.append("wifi")
|
result.restored.append("wifi")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
result.errors.append(f"Failed to restore wifi_config.json: {e}")
|
logger.error(
|
||||||
|
"[Backup] Failed to restore wifi_config.json: %s", e, exc_info=True
|
||||||
|
)
|
||||||
|
result.errors.append("Failed to restore wifi_config.json")
|
||||||
elif (tmp_dir / _WIFI_REL).exists():
|
elif (tmp_dir / _WIFI_REL).exists():
|
||||||
result.skipped.append("wifi")
|
result.skipped.append("wifi")
|
||||||
|
|
||||||
|
# YouTube Music session. Follows restore_wifi rather than getting its
|
||||||
|
# own flag: it is device-local auth in the same sense, and a separate
|
||||||
|
# toggle for one file would be noise in the restore dialog.
|
||||||
|
if options.restore_wifi and (tmp_dir / _YTM_REL).exists():
|
||||||
|
try:
|
||||||
|
_copy_file(tmp_dir / _YTM_REL, project_root / _YTM_REL)
|
||||||
|
result.restored.append("ytm_auth")
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("[Backup] Failed to restore ytm_auth.json: %s", e, exc_info=True)
|
||||||
|
result.errors.append("Failed to restore ytm_auth.json")
|
||||||
|
elif (tmp_dir / _YTM_REL).exists():
|
||||||
|
result.skipped.append("ytm_auth")
|
||||||
|
|
||||||
# User fonts — skip anything that collides with a bundled font.
|
# User fonts — skip anything that collides with a bundled font.
|
||||||
tmp_fonts = tmp_dir / _FONTS_REL
|
tmp_fonts = tmp_dir / _FONTS_REL
|
||||||
if options.restore_fonts and tmp_fonts.exists():
|
if options.restore_fonts and tmp_fonts.exists():
|
||||||
@@ -560,7 +645,10 @@ def restore_backup(
|
|||||||
_copy_file(font, project_root / _FONTS_REL / font.name)
|
_copy_file(font, project_root / _FONTS_REL / font.name)
|
||||||
restored_count += 1
|
restored_count += 1
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
result.errors.append(f"Failed to restore font {font.name}: {e}")
|
logger.error(
|
||||||
|
"[Backup] Failed to restore font %s: %s", font.name, e, exc_info=True
|
||||||
|
)
|
||||||
|
result.errors.append(f"Failed to restore font {font.name}")
|
||||||
if restored_count:
|
if restored_count:
|
||||||
result.restored.append(f"fonts ({restored_count})")
|
result.restored.append(f"fonts ({restored_count})")
|
||||||
elif tmp_fonts.exists():
|
elif tmp_fonts.exists():
|
||||||
@@ -581,7 +669,8 @@ def restore_backup(
|
|||||||
_copy_file(src, project_root / rel)
|
_copy_file(src, project_root / rel)
|
||||||
count += 1
|
count += 1
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
result.errors.append(f"Failed to restore {rel}: {e}")
|
logger.error("[Backup] Failed to restore %s: %s", rel, e, exc_info=True)
|
||||||
|
result.errors.append(f"Failed to restore {rel}")
|
||||||
if count:
|
if count:
|
||||||
result.restored.append(f"plugin_uploads ({count})")
|
result.restored.append(f"plugin_uploads ({count})")
|
||||||
elif tmp_uploads.exists():
|
elif tmp_uploads.exists():
|
||||||
@@ -599,7 +688,8 @@ def restore_backup(
|
|||||||
if isinstance(p, dict) and p.get("plugin_id")
|
if isinstance(p, dict) and p.get("plugin_id")
|
||||||
]
|
]
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
result.errors.append(f"Could not read plugins.json: {e}")
|
logger.error("[Backup] Could not read plugins.json: %s", e, exc_info=True)
|
||||||
|
result.errors.append("Could not read plugins.json")
|
||||||
|
|
||||||
result.success = not result.errors
|
result.success = not result.errors
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -383,10 +383,15 @@ class SportsCore(ABC):
|
|||||||
ctx = skin_runtime.build_context(self, game, size=size)
|
ctx = skin_runtime.build_context(self, game, size=size)
|
||||||
card = skin.render_vegas_card(ctx, dict(game))
|
card = skin.render_vegas_card(ctx, dict(game))
|
||||||
if card is not None:
|
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
|
return card
|
||||||
ctx = skin_runtime.build_context(self, game, size=size)
|
ctx = skin_runtime.build_context(self, game, size=size)
|
||||||
render = getattr(skin, f"render_{self.SKIN_MODE}")
|
render = getattr(skin, f"render_{self.SKIN_MODE}")
|
||||||
if render(ctx, dict(game)):
|
if render(ctx, dict(game)):
|
||||||
|
self._skin_failures = 0
|
||||||
return ctx.canvas
|
return ctx.canvas
|
||||||
except Exception:
|
except Exception:
|
||||||
# Card failures count toward the same 3-strike session disable
|
# Card failures count toward the same 3-strike session disable
|
||||||
|
|||||||
@@ -145,8 +145,7 @@ class SportsUpcoming(SportsCore):
|
|||||||
if (game['home_abbr'] in self.favorite_teams or
|
if (game['home_abbr'] in self.favorite_teams or
|
||||||
game['away_abbr'] in self.favorite_teams):
|
game['away_abbr'] in self.favorite_teams):
|
||||||
favorite_games_found += 1
|
favorite_games_found += 1
|
||||||
if self.show_odds:
|
# Odds are NOT fetched here -- see after selection below.
|
||||||
self._fetch_odds(game)
|
|
||||||
|
|
||||||
# Enhanced logging for debugging
|
# Enhanced logging for debugging
|
||||||
self.logger.info(f"Found {all_upcoming_games} total upcoming games in data")
|
self.logger.info(f"Found {all_upcoming_games} total upcoming games in data")
|
||||||
@@ -190,6 +189,20 @@ class SportsUpcoming(SportsCore):
|
|||||||
# Limit to the specified number of upcoming games
|
# Limit to the specified number of upcoming games
|
||||||
team_games = team_games[:self.upcoming_games_to_show]
|
team_games = team_games[:self.upcoming_games_to_show]
|
||||||
|
|
||||||
|
# Odds are fetched here, for the games that survived selection,
|
||||||
|
# rather than in the loop that collects them. That loop walks every
|
||||||
|
# upcoming game in the schedule window, and for a college league
|
||||||
|
# the window is enormous -- a live rig logged 946 upcoming games in
|
||||||
|
# one cycle and displayed 1 of them. The comment up there claimed
|
||||||
|
# odds were fetched "only for games that will be displayed", but
|
||||||
|
# the only narrowing it applied was show_favorite_teams_only, which
|
||||||
|
# is not the default; in the usual case nothing narrowed it at all
|
||||||
|
# and every game cost a separate ESPN request on a Pi that is also
|
||||||
|
# driving the panel.
|
||||||
|
if self.show_odds:
|
||||||
|
for game in team_games:
|
||||||
|
self._fetch_odds(game)
|
||||||
|
|
||||||
# Log changes or periodically
|
# Log changes or periodically
|
||||||
should_log = (
|
should_log = (
|
||||||
current_time - self.last_log_time >= self.log_interval or
|
current_time - self.last_log_time >= self.log_interval or
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Follows LEDMatrix configuration management patterns:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
@@ -42,10 +44,35 @@ class BaseOddsManager:
|
|||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
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
|
# Configuration with defaults
|
||||||
self.update_interval = 3600 # 1 hour default
|
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
|
self.cache_ttl = 1800 # 30 minutes default
|
||||||
|
|
||||||
# Load configuration if available
|
# Load configuration if available
|
||||||
@@ -73,6 +100,14 @@ class BaseOddsManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Failed to load BaseOddsManager configuration: {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,
|
def get_odds(self, sport: str | None, league: str | None, event_id: str,
|
||||||
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]:
|
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -101,8 +136,18 @@ class BaseOddsManager:
|
|||||||
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
|
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
|
||||||
return cached_data
|
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}")
|
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Map league names to ESPN API format
|
# Map league names to ESPN API format
|
||||||
league_mapping = {
|
league_mapping = {
|
||||||
@@ -117,10 +162,12 @@ class BaseOddsManager:
|
|||||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
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}")
|
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()
|
response.raise_for_status()
|
||||||
raw_data = response.json()
|
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)}")
|
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
|
||||||
|
|
||||||
odds_data = self._extract_espn_data(raw_data)
|
odds_data = self._extract_espn_data(raw_data)
|
||||||
@@ -140,7 +187,11 @@ class BaseOddsManager:
|
|||||||
return odds_data
|
return odds_data
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
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:
|
except json.JSONDecodeError:
|
||||||
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
||||||
|
|
||||||
@@ -163,19 +214,25 @@ class BaseOddsManager:
|
|||||||
item = data["items"][0]
|
item = data["items"][0]
|
||||||
self.logger.debug(f"First item keys: {list(item.keys())}")
|
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
|
# The ESPN API returns odds data directly in the item, not in a
|
||||||
# Extract the odds data directly from the item
|
# 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 = {
|
extracted_data = {
|
||||||
"details": item.get("details"),
|
"details": item.get("details"),
|
||||||
"over_under": item.get("overUnder"),
|
"over_under": item.get("overUnder"),
|
||||||
"spread": item.get("spread"),
|
"spread": item.get("spread"),
|
||||||
"home_team_odds": {
|
"home_team_odds": {
|
||||||
"money_line": item.get("homeTeamOdds", {}).get("moneyLine"),
|
"money_line": home.get("moneyLine"),
|
||||||
"spread_odds": item.get("homeTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
"spread_odds": ((home.get("current") or {})
|
||||||
|
.get("pointSpread") or {}).get("value")
|
||||||
},
|
},
|
||||||
"away_team_odds": {
|
"away_team_odds": {
|
||||||
"money_line": item.get("awayTeamOdds", {}).get("moneyLine"),
|
"money_line": away.get("moneyLine"),
|
||||||
"spread_odds": item.get("awayTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
"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)}")
|
self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}")
|
||||||
@@ -260,9 +317,13 @@ class BaseOddsManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Formatted odds summary string
|
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"
|
return "No odds available"
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
# Add spread information
|
# Add spread information
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import zlib
|
|||||||
from typing import Dict, Any, Optional, Protocol
|
from typing import Dict, Any, Optional, Protocol
|
||||||
from datetime import datetime
|
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):
|
class CacheStrategyProtocol(Protocol):
|
||||||
@@ -112,6 +119,22 @@ class DiskCache:
|
|||||||
record_ts = None
|
record_ts = None
|
||||||
|
|
||||||
now = time.time()
|
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
|
# max_age=None means "never expires" (mirrors MemoryCache and the
|
||||||
# cache_manager docstring). Guard it explicitly — otherwise the
|
# cache_manager docstring). Guard it explicitly — otherwise the
|
||||||
# comparison below raises TypeError and the record is treated as a
|
# comparison below raises TypeError and the record is treated as a
|
||||||
@@ -331,6 +354,23 @@ class DiskCache:
|
|||||||
"""Get the cache directory path."""
|
"""Get the cache directory path."""
|
||||||
return self.cache_dir
|
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]:
|
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.
|
Clean up expired cache files based on retention policies.
|
||||||
@@ -365,11 +405,50 @@ class DiskCache:
|
|||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Get snapshot of files while holding lock briefly
|
# 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:
|
except OSError as list_error:
|
||||||
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
|
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
|
||||||
stats['errors'] += 1
|
stats['errors'] += 1
|
||||||
return stats
|
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
|
# Process files outside the lock to avoid blocking get/set operations
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
|
|||||||
@@ -4,11 +4,58 @@ Memory Cache
|
|||||||
Handles in-memory caching with TTL support, size limits, and automatic cleanup.
|
Handles in-memory caching with TTL support, size limits, and automatic cleanup.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Optional
|
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:
|
class MemoryCache:
|
||||||
"""Manages in-memory cache with TTL and size limits."""
|
"""Manages in-memory cache with TTL and size limits."""
|
||||||
@@ -57,6 +104,16 @@ class MemoryCache:
|
|||||||
if timestamp is None:
|
if timestamp is None:
|
||||||
return 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
|
# Check expiration
|
||||||
if max_age is not None and (now - timestamp) > max_age:
|
if max_age is not None and (now - timestamp) > max_age:
|
||||||
# Expired - remove it
|
# Expired - remove it
|
||||||
@@ -77,6 +134,32 @@ class MemoryCache:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._cache[key] = value
|
self._cache[key] = value
|
||||||
self._timestamps[key] = time.time()
|
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:
|
def clear(self, key: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -133,22 +216,8 @@ class MemoryCache:
|
|||||||
self._timestamps.pop(key, None)
|
self._timestamps.pop(key, None)
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
|
|
||||||
# Enforce size limit by removing oldest entries if cache is too large
|
# Same ceiling enforcement set() uses, so the two cannot drift.
|
||||||
if len(self._cache) > self._max_size:
|
removed_count += self._evict_over_limit_locked()
|
||||||
# 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
|
|
||||||
|
|
||||||
self._last_cleanup = current_time
|
self._last_cleanup = current_time
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
import tempfile
|
import tempfile
|
||||||
from src.exceptions import CacheError
|
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.disk_cache import DiskCache
|
||||||
from src.cache.cache_strategy import CacheStrategy
|
from src.cache.cache_strategy import CacheStrategy
|
||||||
from src.cache.cache_metrics import CacheMetrics
|
from src.cache.cache_metrics import CacheMetrics
|
||||||
@@ -46,7 +46,21 @@ from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-e
|
|||||||
|
|
||||||
class CacheManager:
|
class CacheManager:
|
||||||
"""Manages caching of API responses to reduce API calls."""
|
"""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:
|
def __init__(self) -> None:
|
||||||
# Initialize logger first
|
# Initialize logger first
|
||||||
self.logger: logging.Logger = get_logger(__name__)
|
self.logger: logging.Logger = get_logger(__name__)
|
||||||
@@ -70,7 +84,9 @@ class CacheManager:
|
|||||||
self.logger.warning("ConfigManager not available, using default cache intervals")
|
self.logger.warning("ConfigManager not available, using default cache intervals")
|
||||||
|
|
||||||
# Initialize cache components using composition
|
# 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._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._strategy_component = CacheStrategy(config_manager=self.config_manager, logger=self.logger)
|
||||||
self._metrics_component = CacheMetrics(logger=self.logger)
|
self._metrics_component = CacheMetrics(logger=self.logger)
|
||||||
@@ -594,8 +610,10 @@ class CacheManager:
|
|||||||
Args:
|
Args:
|
||||||
key: Cache key
|
key: Cache key
|
||||||
data: Data to cache
|
data: Data to cache
|
||||||
ttl: Optional time-to-live in seconds (stored for compatibility but
|
ttl: Time-to-live in seconds for this entry. Takes precedence over
|
||||||
expiration is still controlled via max_age when reading)
|
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 = {
|
cache_data = {
|
||||||
'data': data,
|
'data': data,
|
||||||
@@ -716,11 +734,29 @@ class CacheManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def start_cleanup_thread(self) -> None:
|
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():
|
if self._cleanup_thread and self._cleanup_thread.is_alive():
|
||||||
self.logger.debug("Cleanup thread already running")
|
self.logger.debug("Cleanup thread already running")
|
||||||
return
|
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():
|
def cleanup_loop():
|
||||||
"""Background loop that runs cleanup periodically."""
|
"""Background loop that runs cleanup periodically."""
|
||||||
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
|
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
|
||||||
@@ -768,10 +804,17 @@ class CacheManager:
|
|||||||
Signals the thread to stop and waits for it to finish (with timeout).
|
Signals the thread to stop and waits for it to finish (with timeout).
|
||||||
This allows for clean shutdown during testing or application termination.
|
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():
|
if not self._cleanup_thread or not self._cleanup_thread.is_alive():
|
||||||
self.logger.debug("Cleanup thread not running")
|
self.logger.debug("Cleanup thread not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.logger.info("Stopping disk cache cleanup thread...")
|
self.logger.info("Stopping disk cache cleanup thread...")
|
||||||
self._cleanup_stop_event.set() # Signal thread to stop
|
self._cleanup_stop_event.set() # Signal thread to stop
|
||||||
|
|
||||||
|
|||||||
@@ -272,20 +272,35 @@ class APIHelper:
|
|||||||
def clear_cache(self, pattern: Optional[str] = None) -> None:
|
def clear_cache(self, pattern: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Clear cache data.
|
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:
|
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 not self.cache_manager:
|
||||||
if hasattr(self.cache_manager, 'clear'):
|
return
|
||||||
if pattern:
|
if pattern:
|
||||||
# Clear only keys matching pattern
|
if (hasattr(self.cache_manager, 'list_cache_files')
|
||||||
keys = self.cache_manager.keys()
|
and hasattr(self.cache_manager, 'delete')):
|
||||||
for key in keys:
|
for entry in self.cache_manager.list_cache_files():
|
||||||
if pattern in key:
|
key = entry.get('key') if isinstance(entry, dict) else None
|
||||||
self.cache_manager.delete(key)
|
if key and pattern in key:
|
||||||
else:
|
self.cache_manager.delete(key)
|
||||||
self.cache_manager.clear()
|
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]:
|
def _get_from_cache(self, key: str) -> Optional[Any]:
|
||||||
"""Get data from cache."""
|
"""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.
|
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -160,17 +161,20 @@ class ConfigHelper:
|
|||||||
override_config: Configuration to merge in (takes precedence)
|
override_config: Configuration to merge in (takes precedence)
|
||||||
|
|
||||||
Returns:
|
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():
|
for key, value in override_config.items():
|
||||||
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||||
# Recursively merge nested dictionaries
|
# Recursively merge nested dictionaries
|
||||||
merged[key] = self.merge_configs(merged[key], value)
|
merged[key] = self.merge_configs(merged[key], value)
|
||||||
else:
|
else:
|
||||||
# Override with new value
|
# Override with new value — deep-copied so mutating the
|
||||||
merged[key] = value
|
# merged result can't reach back into override_config.
|
||||||
|
merged[key] = copy.deepcopy(value)
|
||||||
|
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|||||||
@@ -115,17 +115,13 @@ class DisplayHelper:
|
|||||||
if home_logo and away_logo:
|
if home_logo and away_logo:
|
||||||
self._draw_logos(main_img, home_logo, away_logo)
|
self._draw_logos(main_img, home_logo, away_logo)
|
||||||
|
|
||||||
# Draw status/period text (top center)
|
# Draw one combined top line (period/status/clock all share y=1 —
|
||||||
if status_text or period_text:
|
# drawing them separately overprinted each other).
|
||||||
status_display = f"{period_text} {status_text}".strip()
|
top_line = " ".join(p for p in [period_text, status_text, clock] if p)
|
||||||
if status_display:
|
if top_line:
|
||||||
self._draw_centered_text(draw, status_display,
|
self._draw_centered_text(draw, top_line,
|
||||||
fonts.get('time', fonts.get('status')),
|
fonts.get('time', fonts.get('status')),
|
||||||
y_position=1)
|
y_position=1)
|
||||||
|
|
||||||
# Draw clock if available
|
|
||||||
if clock:
|
|
||||||
self._draw_centered_text(draw, clock, fonts.get('time'), y_position=1)
|
|
||||||
|
|
||||||
# Draw scores (center)
|
# Draw scores (center)
|
||||||
score_text = f"{away_score}-{home_score}"
|
score_text = f"{away_score}-{home_score}"
|
||||||
@@ -153,26 +149,28 @@ class DisplayHelper:
|
|||||||
"""
|
"""
|
||||||
Draw a ticker/scrolling text layout.
|
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:
|
Args:
|
||||||
text: Text to display
|
text: Text to display
|
||||||
font: Font to use
|
font: Font to use
|
||||||
background_color: Background color
|
background_color: Background color
|
||||||
text_color: Text color
|
text_color: Text color
|
||||||
scroll_speed: Pixels to scroll per frame
|
scroll_speed: Accepted for compatibility; unused per-frame
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL Image with ticker layout
|
PIL Image with ticker layout
|
||||||
"""
|
"""
|
||||||
img = self.create_base_image(background_color)
|
img = self.create_base_image(background_color)
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
# Start text off-screen to the right
|
self._draw_text_with_outline(draw, text, (0, self.display_height // 2 - 6),
|
||||||
x_position = self.display_width
|
|
||||||
|
|
||||||
# Draw text
|
|
||||||
self._draw_text_with_outline(draw, text, (x_position, self.display_height // 2 - 6),
|
|
||||||
font, fill=text_color)
|
font, fill=text_color)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
def draw_centered_text(self, text: str, font: ImageFont.ImageFont,
|
def draw_centered_text(self, text: str, font: ImageFont.ImageFont,
|
||||||
@@ -214,15 +212,9 @@ class DisplayHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
PIL Image with error message
|
PIL Image with error message
|
||||||
"""
|
"""
|
||||||
img = self.create_base_image((50, 0, 0)) # Dark red background
|
# Dark red background, white text
|
||||||
|
|
||||||
# Use default font
|
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
|
return self.draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
|
||||||
# Draw centered error message
|
|
||||||
self._draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
|
|
||||||
|
|
||||||
return img
|
|
||||||
|
|
||||||
def draw_no_data_message(self, message: str = "No Data") -> Image.Image:
|
def draw_no_data_message(self, message: str = "No Data") -> Image.Image:
|
||||||
"""
|
"""
|
||||||
@@ -234,11 +226,8 @@ class DisplayHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
PIL Image with no data message
|
PIL Image with no data message
|
||||||
"""
|
"""
|
||||||
img = self.create_base_image((0, 0, 0))
|
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
return self.draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||||
|
|
||||||
return img
|
|
||||||
|
|
||||||
def get_display_dimensions(self) -> Tuple[int, int]:
|
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 logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Union
|
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:
|
class LogoHelper:
|
||||||
"""
|
"""
|
||||||
Helper class for logo loading, caching, and resizing.
|
Helper class for logo loading, caching, and resizing.
|
||||||
@@ -137,8 +143,10 @@ class LogoHelper:
|
|||||||
"""
|
"""
|
||||||
logo_path = Path(logo_path)
|
logo_path = Path(logo_path)
|
||||||
|
|
||||||
# Try to load existing logo first
|
# Try to load existing logo first. A placeholder written by a previous
|
||||||
if logo_path.exists():
|
# failed download does not count: it wears the real logo's filename, so
|
||||||
|
# trusting the file's existence is what left teams as grey boxes.
|
||||||
|
if logo_path.exists() and not self._is_stale_placeholder(logo_path):
|
||||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
||||||
|
|
||||||
# Download if URL provided and file doesn't exist
|
# Download if URL provided and file doesn't exist
|
||||||
@@ -146,13 +154,61 @@ class LogoHelper:
|
|||||||
try:
|
try:
|
||||||
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
||||||
self._download_logo(logo_url, logo_path)
|
self._download_logo(logo_url, logo_path)
|
||||||
|
# The file on disk just changed. Any cached image for it is the
|
||||||
|
# placeholder we came here to replace, and load_logo() answers
|
||||||
|
# from the cache before touching the disk -- so without this the
|
||||||
|
# real logo would not appear until the process restarted.
|
||||||
|
self._invalidate_cached_logo(team_abbr, logo_path)
|
||||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
||||||
|
# The retry failed, so restart the back-off. The stale
|
||||||
|
# placeholder is still on disk with its old timestamp, and
|
||||||
|
# leaving it there means the next call retries immediately --
|
||||||
|
# a download attempt per call, which is what the back-off
|
||||||
|
# exists to prevent.
|
||||||
|
self._refresh_stale_placeholder(logo_path)
|
||||||
|
|
||||||
# Create placeholder if all else fails
|
# Create placeholder if all else fails
|
||||||
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
||||||
|
|
||||||
|
def _invalidate_cached_logo(self, team_abbr: str, logo_path: Path) -> None:
|
||||||
|
"""Drop every cached size of one logo after its file changed on disk."""
|
||||||
|
prefix = f"{team_abbr}_{logo_path}_"
|
||||||
|
for key in [k for k in self._logo_cache if k.startswith(prefix)]:
|
||||||
|
self._logo_cache.pop(key, None)
|
||||||
|
if key in self._cache_order:
|
||||||
|
self._cache_order.remove(key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _refresh_stale_placeholder(logo_path: Path) -> None:
|
||||||
|
"""Restart the retry back-off after a failed download attempt."""
|
||||||
|
try:
|
||||||
|
from src.logo_downloader import refresh_placeholder_timestamp
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
refresh_placeholder_timestamp(logo_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_stale_placeholder(logo_path: Path) -> bool:
|
||||||
|
"""True if the file is a placeholder old enough to be worth retrying.
|
||||||
|
|
||||||
|
Imported lazily so this module keeps working against a core build whose
|
||||||
|
logo_downloader predates placeholder marking.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.logo_downloader import (
|
||||||
|
PLACEHOLDER_RETRY_SECONDS,
|
||||||
|
is_placeholder_logo,
|
||||||
|
placeholder_age_seconds,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
if not is_placeholder_logo(logo_path):
|
||||||
|
return False
|
||||||
|
age = placeholder_age_seconds(logo_path)
|
||||||
|
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
|
||||||
|
|
||||||
def get_logo_variations(self, team_abbr: str) -> List[str]:
|
def get_logo_variations(self, team_abbr: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get possible filename variations for a team abbreviation.
|
Get possible filename variations for a team abbreviation.
|
||||||
@@ -226,7 +282,10 @@ class LogoHelper:
|
|||||||
return {
|
return {
|
||||||
'cached_logos': len(self._logo_cache),
|
'cached_logos': len(self._logo_cache),
|
||||||
'cache_size_limit': self.cache_size,
|
'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,
|
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
|
||||||
@@ -258,21 +317,64 @@ class LogoHelper:
|
|||||||
self._cache_order.append(cache_key)
|
self._cache_order.append(cache_key)
|
||||||
|
|
||||||
def _download_logo(self, url: str, file_path: Path) -> None:
|
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 exists with proper permissions
|
||||||
ensure_directory_permissions(file_path.parent, get_assets_dir_mode())
|
ensure_directory_permissions(file_path.parent, get_assets_dir_mode())
|
||||||
|
|
||||||
# Download with timeout
|
# A unique temp name, not a fixed "<name>.part": two plugins can
|
||||||
response = self.session.get(url, timeout=30)
|
# ask for the same logo at once, and a shared name would let them
|
||||||
response.raise_for_status()
|
# interleave writes into one file, publish the mixture, or delete
|
||||||
|
# each other's partial. Same directory, so os.replace stays atomic.
|
||||||
# Save to file
|
fd, tmp_name = tempfile.mkstemp(
|
||||||
with open(file_path, 'wb') as f:
|
dir=str(file_path.parent), prefix=file_path.name + '.', suffix='.part')
|
||||||
f.write(response.content)
|
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)
|
||||||
|
|
||||||
|
# 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
|
# Set proper file permissions after saving
|
||||||
ensure_file_permissions(file_path, get_assets_file_mode())
|
ensure_file_permissions(file_path, get_assets_file_mode())
|
||||||
|
|
||||||
self.logger.debug(f"Downloaded logo to {file_path}")
|
self.logger.debug(f"Downloaded logo to {file_path}")
|
||||||
|
|
||||||
def _create_placeholder_logo(self, team_abbr: str,
|
def _create_placeholder_logo(self, team_abbr: str,
|
||||||
|
|||||||
@@ -328,7 +328,11 @@ class ScrollHelper:
|
|||||||
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
||||||
# The image already includes display_width padding, so we only need total_scroll_width
|
# The image already includes display_width padding, so we only need total_scroll_width
|
||||||
required_total_distance = self.total_scroll_width
|
required_total_distance = self.total_scroll_width
|
||||||
self.logger.info(
|
# Progress telemetry, emitted every few seconds for the whole of
|
||||||
|
# every scroll. It says how far along a marquee is, which is what
|
||||||
|
# you turn debug on to watch and not something an operator needs
|
||||||
|
# in the journal on a device that scrolls all day.
|
||||||
|
self.logger.debug(
|
||||||
"Scroll progress: elapsed=%.2fs, target=%.2fs, total_scrolled=%.0f/%d px (%.1f%%)",
|
"Scroll progress: elapsed=%.2fs, target=%.2fs, total_scrolled=%.0f/%d px (%.1f%%)",
|
||||||
elapsed_time,
|
elapsed_time,
|
||||||
self.calculated_duration,
|
self.calculated_duration,
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
"""Card-drawing helpers shared by every sports scoreboard plugin.
|
||||||
|
|
||||||
|
The eight scoreboards each carried byte-identical copies of the functions
|
||||||
|
below: the colour pickers, the settings lookup, the date and time formatting,
|
||||||
|
the favourite-team rules and the font-size grid snapping. One fix had to be
|
||||||
|
made eight times, and a new scoreboard started by copying them a ninth.
|
||||||
|
|
||||||
|
Everything here is a **free function taking explicit arguments**, not a base
|
||||||
|
class. Adoption is therefore per-function and reversible: a plugin keeps its
|
||||||
|
method and delegates the body, so the call sites and the override points are
|
||||||
|
untouched. That is also why `config`, `logger` and `fonts` are parameters
|
||||||
|
rather than attributes -- the helper never reaches back into the caller.
|
||||||
|
|
||||||
|
The bodies are the plugins' own code, moved rather than rewritten. The one
|
||||||
|
deliberate difference is `crisp_size`, which takes the seven-plugin guard
|
||||||
|
(`not desired`) instead of football's: they agree on every real input, and
|
||||||
|
the extra guard only stops a None size raising TypeError.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
|
||||||
|
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
|
||||||
|
"scroll_card_option", "element_color", "font_color", "coerce_rgb",
|
||||||
|
"score_color_for", "recent_score_color", "favorite_teams_for",
|
||||||
|
"side_is_favorite", "side_score", "favorite_result",
|
||||||
|
"card_tzinfo", "weekday_for", "format_game_date", "format_game_time",
|
||||||
|
"vs_text", "upcoming_center_mode", "crisp_size", "schema_font_size",
|
||||||
|
"resolve_font_size", "unshare_element_fonts",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Which customization element owns each font key, for colour resolution.
|
||||||
|
ELEMENT_FOR_FONT: Dict[str, str] = {
|
||||||
|
"score": "score_text",
|
||||||
|
"time": "period_text",
|
||||||
|
"team": "team_name",
|
||||||
|
"status": "status_text",
|
||||||
|
"detail": "detail_text",
|
||||||
|
"rank": "rank_text",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Fallback colours when favourite_result_colors is on but a slot is unset.
|
||||||
|
FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = {
|
||||||
|
"win": (0, 255, 0),
|
||||||
|
"loss": (255, 0, 0),
|
||||||
|
"tie": (255, 200, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Family aliases the web UI may write, mapped to the shipped filename.
|
||||||
|
FONT_NAME_ALIASES: Dict[str, str] = {
|
||||||
|
"press_start": "PressStart2P-Regular.ttf",
|
||||||
|
"four_by_six": "4x6-font.ttf",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which
|
||||||
|
#: on an LED matrix is a dim lamp rather than a soft edge.
|
||||||
|
FONT_PIXEL_GRID: Dict[str, int] = {
|
||||||
|
"PressStart2P-Regular.ttf": 8,
|
||||||
|
"4x6-font.ttf": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings lookup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def scroll_card_option(config: Optional[Dict[str, Any]], key: str,
|
||||||
|
default: Any = None) -> Any:
|
||||||
|
"""Read one key from the scroll_card config block."""
|
||||||
|
block = (config or {}).get("scroll_card")
|
||||||
|
if isinstance(block, dict) and block.get(key) is not None:
|
||||||
|
return block.get(key)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def vs_text(config: Optional[Dict[str, Any]]) -> str:
|
||||||
|
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
||||||
|
return str(scroll_card_option(config, "vs_text", "VS"))
|
||||||
|
|
||||||
|
|
||||||
|
def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
|
||||||
|
"""Middle of an upcoming card: 'vs', 'date_time' or 'none'."""
|
||||||
|
mode = str(scroll_card_option(config, "upcoming_center", "vs") or "vs").lower()
|
||||||
|
return mode if mode in ("vs", "date_time", "none") else "vs"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colour
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def element_color(config: Optional[Dict[str, Any]], element: str,
|
||||||
|
default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
|
"""Per-element text colour from customization.<element>.text_color."""
|
||||||
|
try:
|
||||||
|
cfg = (config or {}).get("customization", {}).get(element, {})
|
||||||
|
value = cfg.get("text_color")
|
||||||
|
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||||
|
return tuple(max(0, min(255, int(c))) for c in value)
|
||||||
|
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
|
||||||
|
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
||||||
|
font, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
|
"""Colour for whichever element owns this face.
|
||||||
|
|
||||||
|
Matched on identity, and deliberately gives up when one object is
|
||||||
|
shared: the last-resort font path can hand the same face to several
|
||||||
|
keys, and there is no right answer for which element's colour that is.
|
||||||
|
White is what those draws used before, so ambiguity costs nothing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fonts = fonts or {}
|
||||||
|
matches = [element for key, element in ELEMENT_FOR_FONT.items()
|
||||||
|
if fonts.get(key) is font]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return element_color(config, matches[0], default)
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_rgb(value, fallback):
|
||||||
|
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
||||||
|
# Checked before unpacking: a 3-character string ("123") would otherwise
|
||||||
|
# iterate into three digits and yield a colour rather than the fallback.
|
||||||
|
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
r, g, b = (max(0, min(255, int(channel))) for channel in value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
return (r, g, b)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Favourite teams
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def favorite_teams_for(config: Dict[str, Any], game: Dict[str, Any]) -> list:
|
||||||
|
"""Favorite teams that apply to this game.
|
||||||
|
|
||||||
|
Both sources are used. Games carry the league manager's *resolved*
|
||||||
|
favorites, which is the only place dynamic groups such as AP_TOP_25
|
||||||
|
appear expanded; the config is read as well so an edit takes effect on
|
||||||
|
already-fetched games, and so hand-built game dicts (tests, other
|
||||||
|
callers) still work.
|
||||||
|
"""
|
||||||
|
favorites = list(game.get("favorite_teams") or [])
|
||||||
|
league_config = config.get(str(game.get("league", "") or ""))
|
||||||
|
if isinstance(league_config, dict):
|
||||||
|
favorites += list(league_config.get("favorite_teams") or [])
|
||||||
|
else:
|
||||||
|
favorites += list(config.get("favorite_teams") or [])
|
||||||
|
return favorites
|
||||||
|
|
||||||
|
|
||||||
|
def side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
|
||||||
|
"""Is the home/away side of this game a favorite team?
|
||||||
|
|
||||||
|
Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``)
|
||||||
|
payload shapes, and matches on the ESPN id too, because a couple of
|
||||||
|
leagues (NRL) key favorites by id where abbreviations collide.
|
||||||
|
"""
|
||||||
|
candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")]
|
||||||
|
team = game.get(f"{side}_team")
|
||||||
|
if isinstance(team, dict):
|
||||||
|
candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")]
|
||||||
|
for value in candidates:
|
||||||
|
if value is not None and str(value).strip().upper() in favorites:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def side_score(game: Dict[str, Any], side: str) -> Optional[int]:
|
||||||
|
"""Numeric score for one side, from either payload shape."""
|
||||||
|
raw = None
|
||||||
|
team = game.get(f"{side}_team")
|
||||||
|
if isinstance(team, dict) and team.get("score") is not None:
|
||||||
|
raw = team.get("score")
|
||||||
|
if raw is None:
|
||||||
|
raw = game.get(f"{side}_score")
|
||||||
|
try:
|
||||||
|
return int(float(str(raw).strip()))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def favorite_result(config: Dict[str, Any], game: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""Say how the favorite team did in a finished game.
|
||||||
|
|
||||||
|
Returns 'win', 'loss' or 'tie', or None when there is no single team
|
||||||
|
to root for: no favorites configured, neither side is a favorite, or
|
||||||
|
*both* are -- a favorite-vs-favorite game has no losing side worth
|
||||||
|
flagging in red. Also None when the scores are not usable numbers.
|
||||||
|
"""
|
||||||
|
favorites = {
|
||||||
|
str(team).strip().upper()
|
||||||
|
for team in favorite_teams_for(config, game)
|
||||||
|
if str(team).strip()
|
||||||
|
}
|
||||||
|
if not favorites:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_fav = side_is_favorite(game, "home", favorites)
|
||||||
|
away_fav = side_is_favorite(game, "away", favorites)
|
||||||
|
if home_fav == away_fav:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_score = side_score(game, "home")
|
||||||
|
away_score = side_score(game, "away")
|
||||||
|
if home_score is None or away_score is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if home_score == away_score:
|
||||||
|
return "tie"
|
||||||
|
favorite_score, other_score = (
|
||||||
|
(home_score, away_score) if home_fav else (away_score, home_score)
|
||||||
|
)
|
||||||
|
return "win" if favorite_score > other_score else "loss"
|
||||||
|
|
||||||
|
|
||||||
|
def recent_score_color(config: Dict[str, Any], logger, game: Dict[str, Any], default):
|
||||||
|
"""Fill color for a finished game's score, per favorite_result_colors."""
|
||||||
|
try:
|
||||||
|
settings = (config.get("customization") or {}).get(
|
||||||
|
"favorite_result_colors"
|
||||||
|
) or {}
|
||||||
|
if not settings.get("enabled", False):
|
||||||
|
return default
|
||||||
|
result = favorite_result(config, game)
|
||||||
|
if result is None:
|
||||||
|
return default
|
||||||
|
return coerce_rgb(
|
||||||
|
settings.get(f"{result}_color"),
|
||||||
|
FAVORITE_RESULT_COLOR_DEFAULTS[result],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not resolve favorite result color", exc_info=True)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def score_color_for(config: Dict[str, Any], logger, game: Dict[str, Any],
|
||||||
|
game_type: str, default=None):
|
||||||
|
"""Fill color for a game card's score. Only finished games are tinted.
|
||||||
|
|
||||||
|
The default is the configured score colour rather than a flat white,
|
||||||
|
so customization.score_text.text_color shows on games the favourite
|
||||||
|
tint does not apply to. The tint still wins where it applies.
|
||||||
|
"""
|
||||||
|
if default is None:
|
||||||
|
default = element_color(config, 'score_text')
|
||||||
|
if game_type != "recent":
|
||||||
|
return default
|
||||||
|
return recent_score_color(config, logger, game, default)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Date and time
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def card_tzinfo(config: Optional[Dict[str, Any]], logger):
|
||||||
|
"""Timezone for weekday/24h conversions; falls back to UTC."""
|
||||||
|
configured = (config or {}).get("timezone")
|
||||||
|
if configured:
|
||||||
|
try:
|
||||||
|
return ZoneInfo(configured)
|
||||||
|
except (KeyError, ValueError, TypeError, OSError) as exc:
|
||||||
|
# KeyError covers ZoneInfoNotFoundError. A bad zone name in
|
||||||
|
# config should fall back to UTC, not blank the card.
|
||||||
|
logger.debug("Unusable timezone %r: %s", configured, exc)
|
||||||
|
return timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def weekday_for(config: Optional[Dict[str, Any]], logger,
|
||||||
|
game: Optional[Dict]) -> str:
|
||||||
|
"""Weekday abbreviation from the game's start time, or ''."""
|
||||||
|
if not game:
|
||||||
|
return ""
|
||||||
|
raw = game.get("start_time_utc") or game.get("start_time")
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
||||||
|
str(raw).replace("Z", "+00:00"))
|
||||||
|
return WEEKDAY_ABBR[start.astimezone(card_tzinfo(config, logger)).weekday()]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||||
|
game: Optional[Dict] = None) -> str:
|
||||||
|
"""Format an upcoming card's date per scroll_card.date_format."""
|
||||||
|
raw = str(date_text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||||
|
if fmt == "numeric":
|
||||||
|
return raw
|
||||||
|
parts = raw.replace("-", "/").split("/")
|
||||||
|
if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()):
|
||||||
|
return raw
|
||||||
|
month, day = int(parts[0]), int(parts[1])
|
||||||
|
if not 1 <= month <= 12:
|
||||||
|
return raw
|
||||||
|
name = MONTH_ABBR[month - 1]
|
||||||
|
if fmt == "numeric_day_first":
|
||||||
|
return f"{day}/{month}"
|
||||||
|
if fmt == "day_first":
|
||||||
|
return f"{day} {name}"
|
||||||
|
if fmt == "weekday":
|
||||||
|
weekday = weekday_for(config, logger, game)
|
||||||
|
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
|
||||||
|
return f"{name} {day}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
|
||||||
|
"""Return the time as-is (12h) or converted to 24h."""
|
||||||
|
raw = str(time_text or "").strip()
|
||||||
|
if not raw or str(scroll_card_option(config, "time_format", "12h")) != "24h":
|
||||||
|
return raw
|
||||||
|
cleaned = raw.upper().replace(" ", "")
|
||||||
|
meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else ""
|
||||||
|
if not meridiem:
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
hh, _, mm = cleaned[:-2].partition(":")
|
||||||
|
hour, minute = int(hh), int(mm or 0)
|
||||||
|
except ValueError:
|
||||||
|
return raw
|
||||||
|
if not (0 <= hour <= 12 and 0 <= minute <= 59):
|
||||||
|
return raw
|
||||||
|
hour = hour % 12 + (12 if meridiem == "PM" else 0)
|
||||||
|
return f"{hour:02d}:{minute:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Font sizing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Per-schema caches, keyed by the schema's absolute path. Keyed rather than
|
||||||
|
#: global because each plugin declares its own defaults; keyed rather than
|
||||||
|
#: per-class because the helper has no class to hang it on.
|
||||||
|
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def crisp_size(font_file, desired, aliases=None, grid_table=None):
|
||||||
|
"""Snap *desired* to the nearest size *font_file* renders crisply at.
|
||||||
|
|
||||||
|
A face with no known grid is returned unchanged, so a user-supplied
|
||||||
|
font is never second-guessed.
|
||||||
|
|
||||||
|
``aliases`` and ``grid_table`` default to the shared tables; a plugin
|
||||||
|
that ships an extra face can pass its own without forking this.
|
||||||
|
"""
|
||||||
|
aliases = FONT_NAME_ALIASES if aliases is None else aliases
|
||||||
|
grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table
|
||||||
|
font_file = aliases.get(font_file, font_file)
|
||||||
|
grid = grid_table.get(font_file)
|
||||||
|
if not grid or not desired or desired <= 0:
|
||||||
|
return desired
|
||||||
|
return max(grid, int(round(float(desired) / grid)) * grid)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||||
|
"""The font_size this plugin's config_schema.json declares, or None.
|
||||||
|
|
||||||
|
Cached per schema path. The plugins cached this on their own class; the
|
||||||
|
path is the same distinction expressed without one, so two plugins never
|
||||||
|
share an entry.
|
||||||
|
"""
|
||||||
|
if not element_key:
|
||||||
|
return None
|
||||||
|
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
|
||||||
|
if cache is None:
|
||||||
|
cache = {}
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
with open(schema_path) as fh:
|
||||||
|
schema = json.load(fh)
|
||||||
|
props = (schema.get('properties', {})
|
||||||
|
.get('customization', {})
|
||||||
|
.get('properties', {}))
|
||||||
|
for key, spec in props.items():
|
||||||
|
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||||
|
if size is not None:
|
||||||
|
cache[key] = int(size)
|
||||||
|
except Exception as exc:
|
||||||
|
# See sports_shared._schema_font_size: an unreadable schema
|
||||||
|
# silently disables the pixel-grid snap for every element.
|
||||||
|
# Built once per schema path, so this cannot repeat per frame.
|
||||||
|
logger.warning(
|
||||||
|
"could not read %s (%s: %s); font sizes will skip their "
|
||||||
|
"pixel grid snap and may render a pixel narrow",
|
||||||
|
schema_path, type(exc).__name__, exc)
|
||||||
|
cache = {}
|
||||||
|
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
|
||||||
|
return cache.get(element_key)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_font_size(schema_path: str, element_config, element_key,
|
||||||
|
default_size, font_name, aliases=None, grid_table=None):
|
||||||
|
"""Size to render at: the user's choice, or a grid-snapped default.
|
||||||
|
|
||||||
|
A configured size counts as a real choice only when it differs from
|
||||||
|
the schema default. The web UI writes the whole schema default block
|
||||||
|
on every save, so "font_size == schema default" carries no intent and
|
||||||
|
would otherwise pin every install to an anti-aliased size forever.
|
||||||
|
"""
|
||||||
|
configured = (element_config or {}).get('font_size')
|
||||||
|
if configured is not None:
|
||||||
|
try:
|
||||||
|
configured = int(configured)
|
||||||
|
if configured != schema_font_size(schema_path, element_key):
|
||||||
|
return configured
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return crisp_size(font_name, default_size, aliases, grid_table)
|
||||||
|
|
||||||
|
|
||||||
|
def unshare_element_fonts(logger, fonts):
|
||||||
|
"""Give each colourable element its own face object.
|
||||||
|
|
||||||
|
The colour a draw gets is resolved from the face it was handed, and
|
||||||
|
several of these loaders legitimately hand one object to more than one
|
||||||
|
element -- a size resolver that lands two elements on the same face, a
|
||||||
|
fallback that fills every key from one default, football's narrowing
|
||||||
|
step that deliberately shrinks the clock along with the score. Sharing
|
||||||
|
the object makes the element ambiguous and the colour unresolvable.
|
||||||
|
|
||||||
|
Re-instantiating from the same path and size gives a distinct object
|
||||||
|
with identical metrics, so nothing about the rendering changes; only
|
||||||
|
the ability to tell two elements apart does. Faces that cannot be
|
||||||
|
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
||||||
|
path) are left shared, and their draws stay white as before.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import ImageFont as _IF
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
return fonts
|
||||||
|
seen = {}
|
||||||
|
for key in ELEMENT_FOR_FONT:
|
||||||
|
font = fonts.get(key)
|
||||||
|
if font is None:
|
||||||
|
continue
|
||||||
|
if id(font) not in seen:
|
||||||
|
seen[id(font)] = key
|
||||||
|
continue
|
||||||
|
path, size = getattr(font, "path", None), getattr(font, "size", None)
|
||||||
|
if not path or not size:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fonts[key] = _IF.truetype(path, size)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
logger.debug(
|
||||||
|
"Could not un-share the %s face; it keeps the default colour", key)
|
||||||
|
return fonts
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""The scroll/Vegas card geometry the sports scoreboards all share.
|
||||||
|
|
||||||
|
Eight scoreboards -- afl, baseball, basketball, football, hockey, lacrosse,
|
||||||
|
nrl and soccer -- each carried their own ``game_renderer.py``. After the card
|
||||||
|
helpers moved to ``src/common/sports_card.py`` the settings lookups were
|
||||||
|
shared, but the *geometry* was not: nine methods that decide how wide the
|
||||||
|
centre gap is, how much room a logo gets, and where an upcoming card's date
|
||||||
|
and time land were still eight separate copies. Five were byte-identical
|
||||||
|
across all eight plugins; the other four were identical in seven, with a
|
||||||
|
different single outlier each time.
|
||||||
|
|
||||||
|
That last detail is why this is a mixin rather than free functions. There is
|
||||||
|
no per-sport branching to write -- baseball needs its own
|
||||||
|
``_draw_upcoming_game_status`` and ``_logo_slot_width``, hockey its own
|
||||||
|
``_upcoming_date_and_time``, football its own ``_score_reserve_width``, and
|
||||||
|
every one of those is an ordinary override. The seven that agree inherit and
|
||||||
|
say nothing.
|
||||||
|
|
||||||
|
It is deliberately *only* a mixin: no ``__init__``, no state of its own. The
|
||||||
|
plugins' constructors differ in six ways and none of that difference is worth
|
||||||
|
unifying, so adoption is one line on the class statement plus deleting the
|
||||||
|
methods that now come from here.
|
||||||
|
|
||||||
|
What a host class must provide
|
||||||
|
------------------------------
|
||||||
|
Attributes: ``display_width``, ``display_height``, ``config``, ``fonts``,
|
||||||
|
``logger``, ``_team_rankings_cache``.
|
||||||
|
|
||||||
|
Methods: ``_draw_text_with_outline(draw, text, position, font, fill=None,
|
||||||
|
outline_color=(0, 0, 0))`` -- the one hook whose body genuinely varies -- plus
|
||||||
|
the ``sports_card`` delegations ``_scroll_card_option``,
|
||||||
|
``_upcoming_center_mode``, ``_vs_text``, ``_element_color``,
|
||||||
|
``_format_game_date`` and ``_format_game_time``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import ClassVar, Dict, Tuple
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
|
||||||
|
class SportsGameRendererMixin:
|
||||||
|
"""Shared card geometry for the sports scoreboards. See module docstring."""
|
||||||
|
|
||||||
|
#: Centre strip as a fraction of card width, before clamping.
|
||||||
|
CENTER_GAP_RATIO: ClassVar[float] = 0.28
|
||||||
|
#: Clamp floor for the derived centre gap.
|
||||||
|
CENTER_GAP_MIN_PX: ClassVar[int] = 22
|
||||||
|
#: Clamp ceiling for the derived centre gap.
|
||||||
|
CENTER_GAP_MAX_PX: ClassVar[int] = 40
|
||||||
|
#: Breathing room kept between the score and each logo.
|
||||||
|
_SCORE_LOGO_GUTTER_PX: ClassVar[int] = 4
|
||||||
|
#: Widest score the centre strip must fit. Leagues that can reach three
|
||||||
|
#: digits a side override this with "000-000".
|
||||||
|
_SCORE_PROBE: ClassVar[str] = "00-00"
|
||||||
|
|
||||||
|
# ---- geometry ------------------------------------------------------
|
||||||
|
|
||||||
|
# Non-finite settings are rejected before any int()/round(): "inf" reaches
|
||||||
|
# these from config as a float or a string, passes an `isinstance` plus
|
||||||
|
# `>= 0` check unharmed, and then raises OverflowError out of int() --
|
||||||
|
# which the old `except (TypeError, ValueError)` did not catch, so it
|
||||||
|
# aborted the whole card render. Present in all eight plugins before this
|
||||||
|
# moved to the core; fixing it here fixes it in all eight.
|
||||||
|
|
||||||
|
def _score_reserve_width(self) -> int:
|
||||||
|
"""Centre strip the score actually needs, measured rather than assumed.
|
||||||
|
|
||||||
|
The gap was derived from the card width alone (width x
|
||||||
|
CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size
|
||||||
|
comes from config and the element-style resolver. Nothing compared the
|
||||||
|
two, so any score wider than the clamp was drawn over the logos.
|
||||||
|
Measuring it keeps the strip wide enough for whatever font is in play.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
|
||||||
|
width = probe.textlength(self._SCORE_PROBE, font=self.fonts['score'])
|
||||||
|
return int(width) + 2 * self._SCORE_LOGO_GUTTER_PX
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("Score reserve measurement failed", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _center_gap_width(self) -> int:
|
||||||
|
"""Width of the middle strip kept clear of logos.
|
||||||
|
|
||||||
|
``scroll_card.center_gap`` pins it outright; otherwise it scales with
|
||||||
|
the card width between the configurable min and max. 0 restores
|
||||||
|
edge-to-edge logos.
|
||||||
|
"""
|
||||||
|
configured = self._scroll_card_option("center_gap")
|
||||||
|
if (isinstance(configured, (int, float))
|
||||||
|
and math.isfinite(configured) and configured >= 0):
|
||||||
|
return int(configured)
|
||||||
|
ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO)
|
||||||
|
low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX)
|
||||||
|
high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX)
|
||||||
|
try:
|
||||||
|
ratio, low, high = float(ratio), float(low), float(high)
|
||||||
|
if not (math.isfinite(ratio) and math.isfinite(low)
|
||||||
|
and math.isfinite(high)):
|
||||||
|
return self.CENTER_GAP_MIN_PX
|
||||||
|
scaled = round(self.display_width * ratio)
|
||||||
|
derived = int(max(int(low), min(int(high), scaled)))
|
||||||
|
# A strip narrower than the score is the bug, not a style choice.
|
||||||
|
# An explicit ``center_gap`` is still honoured above, including 0.
|
||||||
|
return max(derived, self._score_reserve_width())
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return self.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
def _logo_slot_width(self) -> int:
|
||||||
|
"""Per-side logo slot, leaving the center gap clear.
|
||||||
|
|
||||||
|
No longer capped at display_height: the card is sized as two
|
||||||
|
full-height logos plus the measured gap, so what is left after the gap
|
||||||
|
is exactly the logo's share. The cap was what froze the logos at 46px
|
||||||
|
on the old flat 128px card.
|
||||||
|
"""
|
||||||
|
available = (self.display_width - self._center_gap_width()) // 2
|
||||||
|
return max(8, available)
|
||||||
|
|
||||||
|
def _logo_cache_key(self, name: str) -> str:
|
||||||
|
"""Cache key scoped to the logo slot.
|
||||||
|
|
||||||
|
One cache dict is shared by renderers built for different card widths,
|
||||||
|
so a logo sized for a wide slot must not be handed to a narrow one.
|
||||||
|
"""
|
||||||
|
return f"{name}@{self._logo_slot_width()}x{self.display_height}"
|
||||||
|
|
||||||
|
def _layout_offset(self, element: str, axis: str, default: int = 0) -> int:
|
||||||
|
"""X/Y nudge for one element, from customization.layout.
|
||||||
|
|
||||||
|
Same block the full-screen scorebug reads (sports.py
|
||||||
|
_get_layout_offset), so a nudge configured in the web UI now moves
|
||||||
|
the element on the scroll/Vegas card too -- previously the schema
|
||||||
|
advertised these offsets but this renderer ignored them.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
layout = (self.config or {}).get("customization", {}).get("layout", {})
|
||||||
|
value = (layout.get(element) or {}).get(axis, default)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return int(value) if math.isfinite(value) else default
|
||||||
|
if isinstance(value, str):
|
||||||
|
parsed = float(value)
|
||||||
|
return int(parsed) if math.isfinite(parsed) else default
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
# ---- upcoming cards ------------------------------------------------
|
||||||
|
|
||||||
|
def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]:
|
||||||
|
"""(date, time) for an upcoming card, from the extractor's flat keys."""
|
||||||
|
return (
|
||||||
|
str(game.get("game_date", "") or ""),
|
||||||
|
str(game.get("game_time", "") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
|
||||||
|
"""Draw the middle of an upcoming card.
|
||||||
|
|
||||||
|
Never a score: an upcoming game has not started, so the extractor's
|
||||||
|
0-0 is noise. Either the VS text (default), the date and time stacked,
|
||||||
|
or nothing at all.
|
||||||
|
"""
|
||||||
|
mode = self._upcoming_center_mode()
|
||||||
|
if mode == "none":
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode == "vs":
|
||||||
|
vs_text = self._vs_text()
|
||||||
|
if not vs_text:
|
||||||
|
return
|
||||||
|
vs_width = draw.textlength(vs_text, font=self.fonts['score'])
|
||||||
|
vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset')
|
||||||
|
vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, vs_text, (vs_x, vs_y), self.fonts['score'],
|
||||||
|
fill=self._element_color('score_text')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
date_text, time_text = self._upcoming_date_and_time(game)
|
||||||
|
lines = []
|
||||||
|
if self._scroll_card_option("show_date", True):
|
||||||
|
lines.append(self._format_game_date(date_text, game))
|
||||||
|
if self._scroll_card_option("show_time", True):
|
||||||
|
lines.append(self._format_game_time(time_text))
|
||||||
|
lines = [t for t in lines if t]
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
line_h = 7
|
||||||
|
top = (self.display_height // 2) - (len(lines) * line_h) // 2
|
||||||
|
top += self._layout_offset('score', 'y_offset')
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
width = draw.textlength(line, font=font)
|
||||||
|
x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, line, (x, top + i * line_h), font,
|
||||||
|
fill=self._element_color('detail_text')
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_upcoming_game_status(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
|
||||||
|
"""Draw the date and time around an upcoming card.
|
||||||
|
|
||||||
|
Time top and date bottom by default; scroll_card.swap_date_time puts
|
||||||
|
the date on top instead. Skipped when the pair is stacked in the
|
||||||
|
middle, which would otherwise print them twice.
|
||||||
|
"""
|
||||||
|
if self._upcoming_center_mode() == "date_time":
|
||||||
|
return
|
||||||
|
|
||||||
|
date_raw, time_raw = self._upcoming_date_and_time(game)
|
||||||
|
date_text = (self._format_game_date(date_raw, game)
|
||||||
|
if self._scroll_card_option("show_date", True) else "")
|
||||||
|
time_text = (self._format_game_time(time_raw)
|
||||||
|
if self._scroll_card_option("show_time", True) else "")
|
||||||
|
|
||||||
|
if self._scroll_card_option("swap_date_time", False):
|
||||||
|
top_text, top_el, bottom_text, bottom_el = (
|
||||||
|
date_text, 'date', time_text, 'time')
|
||||||
|
top_font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
bottom_font = self.fonts['time']
|
||||||
|
top_color, bottom_color = 'detail_text', 'period_text'
|
||||||
|
else:
|
||||||
|
top_text, top_el, bottom_text, bottom_el = (
|
||||||
|
time_text, 'time', date_text, 'date')
|
||||||
|
top_font = self.fonts['time']
|
||||||
|
bottom_font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
top_color, bottom_color = 'period_text', 'detail_text'
|
||||||
|
|
||||||
|
if top_text:
|
||||||
|
top_width = draw.textlength(top_text, font=top_font)
|
||||||
|
top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset')
|
||||||
|
top_y = 1 + self._layout_offset(top_el, 'y_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, top_text, (top_x, top_y), top_font,
|
||||||
|
fill=self._element_color(top_color)
|
||||||
|
)
|
||||||
|
|
||||||
|
if bottom_text:
|
||||||
|
bottom_width = draw.textlength(bottom_text, font=bottom_font)
|
||||||
|
bottom_x = ((self.display_width - bottom_width) // 2
|
||||||
|
+ self._layout_offset(bottom_el, 'x_offset'))
|
||||||
|
# Measured, not a fixed -7: the detail font is 6px in most plugins
|
||||||
|
# but 10px in soccer and nrl, where "Sep 19" ran past the card.
|
||||||
|
ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3]
|
||||||
|
bottom_y = (max(0, self.display_height - ink_bottom - 1)
|
||||||
|
+ self._layout_offset(bottom_el, 'y_offset'))
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, bottom_text, (bottom_x, bottom_y), bottom_font,
|
||||||
|
fill=self._element_color(bottom_color)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- rankings ------------------------------------------------------
|
||||||
|
|
||||||
|
def set_rankings_cache(self, rankings: Dict[str, int]) -> None:
|
||||||
|
"""Set the team rankings cache for display."""
|
||||||
|
self._team_rankings_cache = rankings
|
||||||
@@ -19,6 +19,7 @@ Port default: 5765 (UDP). Open this port on both Pis if ufw is active:
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
@@ -37,6 +38,13 @@ _RAW_MAGIC = b'SYNC_RAW'
|
|||||||
_RAW_HEADER = struct.Struct('<HH') # width, height (uint16 LE)
|
_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
|
SYNC_PORT = 5765
|
||||||
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
|
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
|
||||||
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
||||||
@@ -101,6 +109,7 @@ class DisplaySyncManager:
|
|||||||
self._peer_chain: int = 0
|
self._peer_chain: int = 0
|
||||||
self._last_heartbeat_time: float = 0.0
|
self._last_heartbeat_time: float = 0.0
|
||||||
self._leader_width: int = 0 # set by display_controller after init
|
self._leader_width: int = 0 # set by display_controller after init
|
||||||
|
self._oversized_frame_warned: bool = False
|
||||||
|
|
||||||
# Follower state
|
# Follower state
|
||||||
self._follower_state = FollowerState.STANDALONE
|
self._follower_state = FollowerState.STANDALONE
|
||||||
@@ -174,6 +183,10 @@ class DisplaySyncManager:
|
|||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync leader recv error: %s", 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:
|
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
||||||
hw = self._hw_config
|
hw = self._hw_config
|
||||||
@@ -273,11 +286,10 @@ class DisplaySyncManager:
|
|||||||
break
|
break
|
||||||
data.extend(chunk)
|
data.extend(chunk)
|
||||||
img = Image.open(io.BytesIO(data))
|
img = Image.open(io.BytesIO(data))
|
||||||
_MAX_W, _MAX_H = 100_000, 256 # generous for any real scroll image
|
if img.width > _MAX_FRAME_W or img.height > _MAX_FRAME_H:
|
||||||
if img.width > _MAX_W or img.height > _MAX_H:
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Sync: rejected oversized scroll image %dx%d (max %dx%d) from %s",
|
"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
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -396,7 +408,7 @@ class DisplaySyncManager:
|
|||||||
data = header + arr.tobytes()
|
data = header + arr.tobytes()
|
||||||
if len(data) <= 65000:
|
if len(data) <= 65000:
|
||||||
self._send_sock.sendto(data, (self._peer_ip, self.port))
|
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._oversized_frame_warned = True
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Sync: frame too large for UDP (%d bytes, max 65000) — "
|
"Sync: frame too large for UDP (%d bytes, max 65000) — "
|
||||||
@@ -451,43 +463,76 @@ class DisplaySyncManager:
|
|||||||
)
|
)
|
||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
|
|
||||||
|
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()
|
||||||
|
self._leader_ip = sender_ip
|
||||||
|
|
||||||
|
if self._follower_state == FollowerState.STANDALONE:
|
||||||
|
self._follower_state = FollowerState.FOLLOWER
|
||||||
|
self.logger.info(
|
||||||
|
"Sync: leader active at %s — switching to follower mode",
|
||||||
|
sender_ip,
|
||||||
|
)
|
||||||
|
self.write_status_file()
|
||||||
|
|
||||||
def _follower_recv_loop(self) -> None:
|
def _follower_recv_loop(self) -> None:
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
data, addr = self._recv_sock.recvfrom(65535)
|
data, addr = self._recv_sock.recvfrom(65535)
|
||||||
sender_ip = addr[0]
|
sender_ip = addr[0]
|
||||||
|
|
||||||
if data[:8] == _RAW_MAGIC or len(data) > 512:
|
if data[:8] == _RAW_MAGIC:
|
||||||
# Frame data: prefer magic-tagged raw RGB; fall back to legacy PNG
|
# Magic-tagged raw RGB frame — self-describing, no guessing.
|
||||||
try:
|
try:
|
||||||
if data[:8] == _RAW_MAGIC:
|
w, h = _RAW_HEADER.unpack(data[8:12])
|
||||||
w, h = _RAW_HEADER.unpack(data[8:12])
|
raw = data[12:]
|
||||||
raw = data[12:]
|
img = Image.frombuffer(
|
||||||
img = Image.frombuffer(
|
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
||||||
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
)
|
||||||
)
|
self._handle_received_frame(img, sender_ip)
|
||||||
else:
|
|
||||||
# Fallback: try legacy PNG
|
|
||||||
img = Image.open(io.BytesIO(data))
|
|
||||||
img.load()
|
|
||||||
with self._frame_lock:
|
|
||||||
self._latest_frame = img
|
|
||||||
self._last_leader_frame_time = time.time()
|
|
||||||
self._leader_ip = sender_ip
|
|
||||||
|
|
||||||
if self._follower_state == FollowerState.STANDALONE:
|
|
||||||
self._follower_state = FollowerState.FOLLOWER
|
|
||||||
self.logger.info(
|
|
||||||
"Sync: leader active at %s — switching to follower mode",
|
|
||||||
sender_ip,
|
|
||||||
)
|
|
||||||
self.write_status_file()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync: frame decode error: %s", exc)
|
self.logger.debug("Sync: frame decode error: %s", exc)
|
||||||
else:
|
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:
|
try:
|
||||||
msg = json.loads(data.decode("utf-8"))
|
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")
|
t = msg.get("t")
|
||||||
if t == "hello_ack":
|
if t == "hello_ack":
|
||||||
self._leader_ip = sender_ip
|
self._leader_ip = sender_ip
|
||||||
@@ -501,7 +546,17 @@ class DisplaySyncManager:
|
|||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
elif t == "sx":
|
elif t == "sx":
|
||||||
# Vegas scroll-position sync — tiny message, renders locally
|
# 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._last_leader_frame_time = time.time()
|
||||||
self._leader_ip = sender_ip
|
self._leader_ip = sender_ip
|
||||||
if self._follower_state == FollowerState.STANDALONE:
|
if self._follower_state == FollowerState.STANDALONE:
|
||||||
@@ -511,19 +566,22 @@ class DisplaySyncManager:
|
|||||||
sender_ip,
|
sender_ip,
|
||||||
)
|
)
|
||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
if self._on_new_cycle:
|
fire_new_cycle = True # build initial scroll image
|
||||||
self._on_new_cycle() # build initial scroll image
|
|
||||||
elif t == "nc":
|
elif t == "nc":
|
||||||
# Leader started a new scroll cycle — rebuild local image
|
# Leader started a new scroll cycle — rebuild local image
|
||||||
if self._on_new_cycle:
|
fire_new_cycle = True
|
||||||
self._on_new_cycle()
|
except (KeyError, AttributeError, TypeError, ValueError) as exc:
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError, KeyError):
|
self.logger.debug("Sync: malformed control message: %s", exc)
|
||||||
pass
|
continue
|
||||||
|
|
||||||
|
if fire_new_cycle and self._on_new_cycle:
|
||||||
|
self._on_new_cycle()
|
||||||
|
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync follower recv error: %s", exc)
|
self.logger.debug("Sync follower recv error: %s", exc)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
def _follower_announce_loop(self) -> None:
|
def _follower_announce_loop(self) -> None:
|
||||||
hw = self._hw_config
|
hw = self._hw_config
|
||||||
|
|||||||
@@ -106,18 +106,13 @@ class ConfigManager:
|
|||||||
Returns:
|
Returns:
|
||||||
SaveResult with status and details
|
SaveResult with status and details
|
||||||
"""
|
"""
|
||||||
# Load current secrets to preserve them
|
# Load current secrets to preserve them (raises if unreadable — see
|
||||||
secrets_content = {}
|
# _load_secrets_for_save)
|
||||||
if os.path.exists(self.secrets_path):
|
secrets_content = self._load_secrets_for_save()
|
||||||
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}")
|
|
||||||
|
|
||||||
# Strip secrets from main config before saving
|
# Strip secrets from main config before saving
|
||||||
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
|
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
|
||||||
|
|
||||||
# Use atomic manager to save
|
# Use atomic manager to save
|
||||||
atomic_mgr = self._get_atomic_manager()
|
atomic_mgr = self._get_atomic_manager()
|
||||||
result = atomic_mgr.save_config_atomic(
|
result = atomic_mgr.save_config_atomic(
|
||||||
@@ -274,35 +269,86 @@ class ConfigManager:
|
|||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
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]:
|
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."""
|
"""Recursively remove secret keys from a dictionary."""
|
||||||
result = {}
|
result = {}
|
||||||
for key, value in data_to_filter.items():
|
for key, value in data_to_filter.items():
|
||||||
if key in secrets:
|
if key not 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:
|
|
||||||
# This key is not in secrets, so we keep it
|
# This key is not in secrets, so we keep it
|
||||||
result[key] = value
|
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
|
return result
|
||||||
|
|
||||||
|
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:
|
||||||
|
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:
|
def save_config(self, new_config_data: Dict[str, Any]) -> None:
|
||||||
"""Save configuration to the main JSON file, stripping out secrets."""
|
"""Save configuration to the main JSON file, stripping out secrets.
|
||||||
secrets_content = {}
|
|
||||||
if os.path.exists(self.secrets_path):
|
Raises ConfigError when the secrets file exists but cannot be loaded,
|
||||||
try:
|
because stripping would be impossible and secrets would leak into
|
||||||
with open(self.secrets_path, 'r') as f_secrets:
|
config.json.
|
||||||
secrets_content = json.load(f_secrets)
|
"""
|
||||||
except Exception as e:
|
secrets_content = self._load_secrets_for_save()
|
||||||
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.
|
|
||||||
|
|
||||||
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
|
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
|
||||||
|
|
||||||
@@ -339,11 +385,39 @@ class ConfigManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _deep_merge(self, target: Dict[str, Any], source: Dict[str, Any]) -> 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():
|
for key, value in source.items():
|
||||||
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||||
self._deep_merge(target[key], value)
|
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:
|
else:
|
||||||
|
# Scalars AND whole-secret scalar arrays: replace (legacy).
|
||||||
target[key] = value
|
target[key] = value
|
||||||
|
|
||||||
def _create_config_from_template(self) -> None:
|
def _create_config_from_template(self) -> None:
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ from src.common.sync_manager import DisplaySyncManager, SyncRole
|
|||||||
# Get logger with consistent configuration
|
# Get logger with consistent configuration
|
||||||
logger = get_logger(__name__)
|
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 import (lazy loaded to avoid circular imports)
|
||||||
_vegas_mode_imported = False
|
_vegas_mode_imported = False
|
||||||
VegasModeCoordinator = None
|
VegasModeCoordinator = None
|
||||||
@@ -90,7 +104,8 @@ class DisplayController:
|
|||||||
# Validate startup configuration
|
# Validate startup configuration
|
||||||
try:
|
try:
|
||||||
from src.startup_validator import StartupValidator
|
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()
|
is_valid, errors, warnings = validator.validate_all()
|
||||||
|
|
||||||
if warnings:
|
if warnings:
|
||||||
@@ -166,6 +181,16 @@ class DisplayController:
|
|||||||
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
||||||
self.mode_to_plugin_id: Dict[str, str] = {}
|
self.mode_to_plugin_id: Dict[str, str] = {}
|
||||||
self.plugin_display_modes: Dict[str, List[str]] = {}
|
self.plugin_display_modes: Dict[str, List[str]] = {}
|
||||||
|
# plugin_display_modes is mutated only by _register_loaded_plugin /
|
||||||
|
# _unregister_plugin on the render thread, but the config-watcher
|
||||||
|
# thread reads it in _enabled_plugin_not_running. Both mutation sites
|
||||||
|
# run during reconcile (rare), so this lock never touches the per-frame
|
||||||
|
# path -- the hot-path reads are same-thread as the writes.
|
||||||
|
self._plugin_modes_lock = threading.Lock()
|
||||||
|
# Guards the consume-and-clear of _pending_plugin_reconcile. Only taken
|
||||||
|
# when a reconcile is actually pending or a config change arrives, both
|
||||||
|
# rare -- the per-frame path just reads the bool.
|
||||||
|
self._reconcile_flag_lock = threading.Lock()
|
||||||
# Per-plugin config-change callbacks, kept so we can unsubscribe a
|
# Per-plugin config-change callbacks, kept so we can unsubscribe a
|
||||||
# plugin when it is disabled live.
|
# plugin when it is disabled live.
|
||||||
self._plugin_config_callbacks: Dict[str, Callable] = {}
|
self._plugin_config_callbacks: Dict[str, Callable] = {}
|
||||||
@@ -258,7 +283,8 @@ class DisplayController:
|
|||||||
# Validate plugins after plugin manager is created
|
# Validate plugins after plugin manager is created
|
||||||
try:
|
try:
|
||||||
from src.startup_validator import StartupValidator
|
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()
|
is_valid, errors, warnings = validator.validate_all()
|
||||||
|
|
||||||
if warnings:
|
if warnings:
|
||||||
@@ -447,8 +473,10 @@ class DisplayController:
|
|||||||
self._refresh_config_cache(new_config)
|
self._refresh_config_cache(new_config)
|
||||||
# If a plugin was enabled/disabled, flag a reconcile for the main
|
# If a plugin was enabled/disabled, flag a reconcile for the main
|
||||||
# loop to apply (loading/unloading off the watcher thread is unsafe).
|
# loop to apply (loading/unloading off the watcher thread is unsafe).
|
||||||
if self._enabled_set_changed(old_config, new_config):
|
if (self._enabled_set_changed(old_config, new_config)
|
||||||
self._pending_plugin_reconcile = True
|
or self._enabled_plugin_not_running(new_config)):
|
||||||
|
with self._reconcile_flag_lock:
|
||||||
|
self._pending_plugin_reconcile = True
|
||||||
|
|
||||||
self.config_service.subscribe(_controller_config_change)
|
self.config_service.subscribe(_controller_config_change)
|
||||||
|
|
||||||
@@ -461,7 +489,7 @@ class DisplayController:
|
|||||||
# Initial data update for plugins (ensures data available on first display)
|
# Initial data update for plugins (ensures data available on first display)
|
||||||
logger.info("Performing initial plugin data update...")
|
logger.info("Performing initial plugin data update...")
|
||||||
update_start = time.time()
|
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)
|
logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start)
|
||||||
|
|
||||||
# Initialize Vegas mode coordinator
|
# Initialize Vegas mode coordinator
|
||||||
@@ -817,14 +845,42 @@ class DisplayController:
|
|||||||
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||||
return normal_brightness
|
return normal_brightness
|
||||||
|
|
||||||
def _update_modules(self):
|
def _update_modules(self, deadline: Optional[float] = None):
|
||||||
"""Update all plugin modules."""
|
"""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:
|
if not self.plugin_manager:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update all loaded plugins
|
# Update all loaded plugins
|
||||||
plugins_dict = getattr(self.plugin_manager, 'loaded_plugins', None) or getattr(self.plugin_manager, '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():
|
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
|
# Check circuit breaker before attempting update
|
||||||
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||||
if self.plugin_manager.health_tracker.should_skip_plugin(plugin_id):
|
if self.plugin_manager.health_tracker.should_skip_plugin(plugin_id):
|
||||||
@@ -833,7 +889,13 @@ class DisplayController:
|
|||||||
|
|
||||||
# Use PluginExecutor if available for safe execution
|
# Use PluginExecutor if available for safe execution
|
||||||
if hasattr(self.plugin_manager, 'plugin_executor'):
|
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'):
|
if success and hasattr(self.plugin_manager, 'plugin_last_update'):
|
||||||
self.plugin_manager.plugin_last_update[plugin_id] = time.time()
|
self.plugin_manager.plugin_last_update[plugin_id] = time.time()
|
||||||
else:
|
else:
|
||||||
@@ -852,6 +914,12 @@ class DisplayController:
|
|||||||
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||||
self.plugin_manager.health_tracker.record_failure(plugin_id, exc)
|
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:
|
def _tick_plugin_updates_for_vegas(self) -> None:
|
||||||
"""Run scheduled plugin updates and tell Vegas mode which plugins
|
"""Run scheduled plugin updates and tell Vegas mode which plugins
|
||||||
actually got fresh data, so it can hot-swap them into the scroll
|
actually got fresh data, so it can hot-swap them into the scroll
|
||||||
@@ -1638,6 +1706,12 @@ class DisplayController:
|
|||||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||||
return live
|
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):
|
def _check_live_priority(self, advance=False):
|
||||||
"""Return the live-priority mode to display, or None if nothing is live.
|
"""Return the live-priority mode to display, or None if nothing is live.
|
||||||
|
|
||||||
@@ -1687,11 +1761,12 @@ class DisplayController:
|
|||||||
# rebuilding available_modes happens here on the render thread so
|
# rebuilding available_modes happens here on the render thread so
|
||||||
# it can't race with rendering. Deferred while on-demand is active
|
# it can't race with rendering. Deferred while on-demand is active
|
||||||
# (the flag stays set) so we don't fight its temporary-enable.
|
# (the flag stays set) so we don't fight its temporary-enable.
|
||||||
|
# The lock-free read is a fast path only; it can be a false
|
||||||
|
# negative (the watcher setting the flag just after it is read
|
||||||
|
# is seen next iteration), never a false positive that loses a
|
||||||
|
# request.
|
||||||
if self._pending_plugin_reconcile and not self.on_demand_active:
|
if self._pending_plugin_reconcile and not self.on_demand_active:
|
||||||
# Only clear the flag on success -- a retryable failure
|
self._service_pending_reconcile()
|
||||||
# (e.g. discovery) leaves it set so the request isn't lost.
|
|
||||||
if self._reconcile_enabled_plugins():
|
|
||||||
self._pending_plugin_reconcile = False
|
|
||||||
|
|
||||||
if not self.available_modes:
|
if not self.available_modes:
|
||||||
# Nothing to render yet. Re-check _pending_plugin_reconcile
|
# Nothing to render yet. Re-check _pending_plugin_reconcile
|
||||||
@@ -1851,14 +1926,24 @@ class DisplayController:
|
|||||||
# Check for live priority content and switch to it immediately.
|
# Check for live priority content and switch to it immediately.
|
||||||
# advance=True so multiple simultaneously-live games take turns
|
# advance=True so multiple simultaneously-live games take turns
|
||||||
# (round-robin) instead of pinning to the first plugin.
|
# (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)
|
live_priority_mode = self._check_live_priority(advance=True)
|
||||||
self._apply_live_priority(live_priority_mode)
|
self._apply_live_priority(live_priority_mode)
|
||||||
|
|
||||||
# Vegas scroll mode - continuous ticker across all plugins
|
# Vegas scroll mode - continuous ticker across all plugins
|
||||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||||
if self._is_vegas_mode_active() and not wifi_status_data:
|
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:
|
if not live_mode:
|
||||||
try:
|
try:
|
||||||
# Run Vegas mode iteration
|
# Run Vegas mode iteration
|
||||||
@@ -2741,7 +2826,8 @@ class DisplayController:
|
|||||||
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
|
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
|
||||||
if not (isinstance(display_modes, list) and display_modes):
|
if not (isinstance(display_modes, list) and display_modes):
|
||||||
display_modes = [plugin_id]
|
display_modes = [plugin_id]
|
||||||
self.plugin_display_modes[plugin_id] = list(display_modes)
|
with self._plugin_modes_lock:
|
||||||
|
self.plugin_display_modes[plugin_id] = list(display_modes)
|
||||||
|
|
||||||
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
|
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
|
||||||
# and instance as defaults so each plugin's callback targets its own
|
# and instance as defaults so each plugin's callback targets its own
|
||||||
@@ -2775,7 +2861,8 @@ class DisplayController:
|
|||||||
def _unregister_plugin(self, plugin_id: str) -> None:
|
def _unregister_plugin(self, plugin_id: str) -> None:
|
||||||
"""Remove a plugin's modes, config subscription and instance, then
|
"""Remove a plugin's modes, config subscription and instance, then
|
||||||
unload it. Used by live disable hot-reload."""
|
unload it. Used by live disable hot-reload."""
|
||||||
modes = self.plugin_display_modes.pop(plugin_id, [])
|
with self._plugin_modes_lock:
|
||||||
|
modes = self.plugin_display_modes.pop(plugin_id, [])
|
||||||
for mode in modes:
|
for mode in modes:
|
||||||
if mode in self.available_modes:
|
if mode in self.available_modes:
|
||||||
self.available_modes.remove(mode)
|
self.available_modes.remove(mode)
|
||||||
@@ -2820,6 +2907,67 @@ class DisplayController:
|
|||||||
}
|
}
|
||||||
return enabled_map(old_config) != enabled_map(new_config)
|
return enabled_map(old_config) != enabled_map(new_config)
|
||||||
|
|
||||||
|
def _service_pending_reconcile(self) -> None:
|
||||||
|
"""Consume a pending reconcile request and run it.
|
||||||
|
|
||||||
|
The request is consumed BEFORE reconciling, not cleared after. Clearing
|
||||||
|
after would drop any config change that lands while reconcile is
|
||||||
|
running: reconcile has already read its config by then, so the clear
|
||||||
|
erases a request it never served and the newest config never
|
||||||
|
reconciles -- the same "your save did nothing" failure this whole path
|
||||||
|
exists to prevent. Consuming first means such a request stays set and
|
||||||
|
is picked up on the next pass.
|
||||||
|
|
||||||
|
A retryable failure (e.g. discovery) re-arms the flag.
|
||||||
|
"""
|
||||||
|
with self._reconcile_flag_lock:
|
||||||
|
pending = self._pending_plugin_reconcile
|
||||||
|
self._pending_plugin_reconcile = False
|
||||||
|
if pending and not self._reconcile_enabled_plugins():
|
||||||
|
with self._reconcile_flag_lock:
|
||||||
|
self._pending_plugin_reconcile = True
|
||||||
|
|
||||||
|
def _enabled_plugin_not_running(self, new_config: Dict[str, Any]) -> bool:
|
||||||
|
"""True when a discovered plugin is enabled in config but not running.
|
||||||
|
|
||||||
|
``_enabled_set_changed`` compares only top-level ``enabled`` flags, which
|
||||||
|
misses the case that strands a plugin: one whose ``validate_config()``
|
||||||
|
returned False is absent from the running set, and the edit that fixes it
|
||||||
|
(enabling a league, filling in an API key) lives *nested* inside that
|
||||||
|
plugin's own section. No top-level flag changes, so no reconcile is
|
||||||
|
queued, and the save that should have fixed it appears to do nothing --
|
||||||
|
only toggling some unrelated plugin recovers it. hockey-scoreboard sat
|
||||||
|
enabled-but-absent on a live rig for four days this way.
|
||||||
|
|
||||||
|
Deliberately narrow: it fires only for ids the plugin manager has
|
||||||
|
actually discovered, so non-plugin sections that carry their own
|
||||||
|
``enabled`` flag (``schedule``, ``display``, ...) don't queue a reconcile
|
||||||
|
on every save. In the steady state -- everything enabled is loaded --
|
||||||
|
this is False and costs nothing. That matters because reconcile runs
|
||||||
|
``discover_plugins()`` on the render thread, where a needless
|
||||||
|
filesystem scan per config save would show up as a frame hitch.
|
||||||
|
|
||||||
|
Runs on the config-watcher thread, so both mappings it reads are
|
||||||
|
snapshotted under the lock that guards their writes.
|
||||||
|
"""
|
||||||
|
if self.plugin_manager is None:
|
||||||
|
return False
|
||||||
|
# Two snapshots, each taken under its own lock and never nested, so a
|
||||||
|
# half-written mapping is never observed and this can't deadlock
|
||||||
|
# against discovery (which holds the discovery lock while rebuilding).
|
||||||
|
try:
|
||||||
|
known = self.plugin_manager.discovered_plugin_ids()
|
||||||
|
except AttributeError:
|
||||||
|
# Older manager without the accessor: fall back to a plain read.
|
||||||
|
known = set(getattr(self.plugin_manager, 'plugin_manifests', ()) or ())
|
||||||
|
with self._plugin_modes_lock:
|
||||||
|
running = set(self.plugin_display_modes)
|
||||||
|
for key, value in new_config.items():
|
||||||
|
if (key in known and isinstance(value, dict)
|
||||||
|
and value.get('enabled', False) and key not in running):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _reconcile_enabled_plugins(self) -> bool:
|
def _reconcile_enabled_plugins(self) -> bool:
|
||||||
"""Load/unload plugins so the running set matches the enabled set in
|
"""Load/unload plugins so the running set matches the enabled set in
|
||||||
config. Runs on the main display thread (never the config-watcher
|
config. Runs on the main display thread (never the config-watcher
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ the same object.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import tempfile
|
import tempfile
|
||||||
if os.getenv("EMULATOR", "false") == "true":
|
if os.getenv("EMULATOR", "false") == "true":
|
||||||
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
|
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
|
||||||
@@ -258,6 +259,26 @@ class DisplayManager:
|
|||||||
# Initialize managers
|
# Initialize managers
|
||||||
# Calendar manager is now initialized by DisplayController
|
# 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):
|
def _setup_matrix(self):
|
||||||
"""Initialize the RGB matrix with configuration settings."""
|
"""Initialize the RGB matrix with configuration settings."""
|
||||||
_init_error_str = None
|
_init_error_str = None
|
||||||
@@ -283,7 +304,7 @@ class DisplayManager:
|
|||||||
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
||||||
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
||||||
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
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.row_address_type = hardware_config.get('row_address_type', 0)
|
||||||
options.multiplexing = hardware_config.get('multiplexing', 0)
|
options.multiplexing = hardware_config.get('multiplexing', 0)
|
||||||
options.panel_type = hardware_config.get('panel_type', '')
|
options.panel_type = hardware_config.get('panel_type', '')
|
||||||
@@ -343,6 +364,7 @@ class DisplayManager:
|
|||||||
# Create image with the (logical) display dimensions
|
# Create image with the (logical) display dimensions
|
||||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||||||
|
|
||||||
# Initialize font with Press Start 2P
|
# Initialize font with Press Start 2P
|
||||||
@@ -382,6 +404,7 @@ class DisplayManager:
|
|||||||
|
|
||||||
self.image = Image.new('RGB', (fallback_width, fallback_height))
|
self.image = Image.new('RGB', (fallback_width, fallback_height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
# Simple fallback visualization so web UI shows a realistic canvas
|
# Simple fallback visualization so web UI shows a realistic canvas
|
||||||
try:
|
try:
|
||||||
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
||||||
@@ -497,6 +520,91 @@ class DisplayManager:
|
|||||||
logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
|
logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
|
||||||
return -1
|
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):
|
def _draw_test_pattern(self):
|
||||||
"""Draw a test pattern to verify the display is working."""
|
"""Draw a test pattern to verify the display is working."""
|
||||||
try:
|
try:
|
||||||
@@ -516,8 +624,11 @@ class DisplayManager:
|
|||||||
# Draw a diagonal line
|
# Draw a diagonal line
|
||||||
self.draw.line([0, 0, self.matrix.width-1, self.matrix.height-1], fill=(0, 255, 0))
|
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
|
lines = ["Initializing"]
|
||||||
self.draw.text((10, 10), "Initializing", font=self.font, fill=(0, 0, 255))
|
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
|
# Update the display once after everything is drawn
|
||||||
self.update_display()
|
self.update_display()
|
||||||
@@ -596,6 +707,7 @@ class DisplayManager:
|
|||||||
# self.image, so swapping the buffer below is enough on its own.
|
# self.image, so swapping the buffer below is enough on its own.
|
||||||
self.image = Image.new('RGB', (target_w, target_h))
|
self.image = Image.new('RGB', (target_w, target_h))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
self.matrix = real_matrix
|
self.matrix = real_matrix
|
||||||
@@ -705,6 +817,7 @@ class DisplayManager:
|
|||||||
|
|
||||||
self.image = Image.new('RGB', (width, height))
|
self.image = Image.new('RGB', (width, height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
logger.debug("Cleared display in fallback mode")
|
logger.debug("Cleared display in fallback mode")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -716,6 +829,7 @@ class DisplayManager:
|
|||||||
# Create a new black image
|
# Create a new black image
|
||||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
|
|
||||||
if not self._capture_mode_active:
|
if not self._capture_mode_active:
|
||||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||||
@@ -1149,6 +1263,7 @@ class DisplayManager:
|
|||||||
try:
|
try:
|
||||||
self.image = Image.new('RGB', (self.width, self.height))
|
self.image = Image.new('RGB', (self.width, self.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
except (OSError, RuntimeError, ValueError, MemoryError):
|
except (OSError, RuntimeError, ValueError, MemoryError):
|
||||||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
||||||
# Reset the singleton state when cleaning up
|
# Reset the singleton state when cleaning up
|
||||||
|
|||||||
@@ -167,10 +167,14 @@ class DynamicTeamResolver:
|
|||||||
|
|
||||||
# Sort by ranking (1, 2, 3, etc.)
|
# Sort by ranking (1, 2, 3, etc.)
|
||||||
sorted_rankings = dict(sorted(rankings.items(), key=lambda x: x[1]))
|
sorted_rankings = dict(sorted(rankings.items(), key=lambda x: x[1]))
|
||||||
|
|
||||||
# Cache the results
|
# Cache the results ON THE CLASS. Assigning through self
|
||||||
self._rankings_cache = sorted_rankings
|
# would create instance attributes that shadow the shared
|
||||||
self._cache_timestamp = current_time
|
# 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")
|
self.logger.info(f"Fetched rankings for {len(sorted_rankings)} teams")
|
||||||
return sorted_rankings
|
return sorted_rankings
|
||||||
@@ -216,9 +220,11 @@ class DynamicTeamResolver:
|
|||||||
return any(pattern in team_name.upper() for pattern in dynamic_patterns)
|
return any(pattern in team_name.upper() for pattern in dynamic_patterns)
|
||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
"""Clear the rankings cache to force fresh data on next request."""
|
"""Clear the SHARED rankings cache to force fresh data on next
|
||||||
self._rankings_cache = {}
|
request. Writes through the class — assigning via self would only
|
||||||
self._cache_timestamp = 0
|
shadow the shared cache for this instance."""
|
||||||
|
DynamicTeamResolver._rankings_cache = {}
|
||||||
|
DynamicTeamResolver._cache_timestamp = 0
|
||||||
self.logger.info("Cleared dynamic team rankings cache")
|
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.
|
Supports structured logging with context information and appropriate log levels.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
@@ -65,24 +66,29 @@ class ContextualFormatter(logging.Formatter):
|
|||||||
self.include_context = include_context
|
self.include_context = include_context
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
"""Format log record with context."""
|
"""Format log record with context.
|
||||||
# Add context to message if present
|
|
||||||
|
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:
|
if self.include_context:
|
||||||
context_parts = []
|
context_parts = []
|
||||||
|
|
||||||
if hasattr(record, 'plugin_id'):
|
if hasattr(record, 'plugin_id'):
|
||||||
context_parts.append(f"[Plugin: {record.plugin_id}]")
|
context_parts.append(f"[Plugin: {record.plugin_id}]")
|
||||||
|
|
||||||
if hasattr(record, 'operation_id'):
|
if hasattr(record, 'operation_id'):
|
||||||
context_parts.append(f"[Op: {record.operation_id}]")
|
context_parts.append(f"[Op: {record.operation_id}]")
|
||||||
|
|
||||||
if hasattr(record, 'context') and isinstance(record.context, dict):
|
if hasattr(record, 'context') and isinstance(record.context, dict):
|
||||||
for key, value in record.context.items():
|
for key, value in record.context.items():
|
||||||
context_parts.append(f"[{key}: {value}]")
|
context_parts.append(f"[{key}: {value}]")
|
||||||
|
|
||||||
if context_parts:
|
if context_parts:
|
||||||
|
record = copy.copy(record)
|
||||||
record.msg = ' '.join(context_parts) + ' ' + str(record.msg)
|
record.msg = ' '.join(context_parts) + ' ' + str(record.msg)
|
||||||
|
|
||||||
return super().format(record)
|
return super().format(record)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,7 +130,12 @@ def setup_logging(
|
|||||||
# Console handler (always add)
|
# Console handler (always add)
|
||||||
console_handler = logging.StreamHandler(sys.stdout)
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
console_handler.setLevel(level)
|
console_handler.setLevel(level)
|
||||||
console_handler.setFormatter(formatter)
|
# Under systemd, tag each line so the journal records the real severity
|
||||||
|
# rather than filing everything as informational. The file handler below
|
||||||
|
# keeps the plain formatter: the prefix is meaningful to journald and noise
|
||||||
|
# anywhere else.
|
||||||
|
console_handler.setFormatter(
|
||||||
|
JournalPriorityFormatter(formatter) if _under_systemd() else formatter)
|
||||||
root_logger.addHandler(console_handler)
|
root_logger.addHandler(console_handler)
|
||||||
|
|
||||||
# File handler (if specified)
|
# File handler (if specified)
|
||||||
@@ -139,6 +150,84 @@ def setup_logging(
|
|||||||
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
|
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
|
||||||
|
|
||||||
|
|
||||||
|
#: syslog priorities, which is what systemd parses from a "<N>" prefix on
|
||||||
|
#: stdout. Mapped from Python's levels.
|
||||||
|
_SYSLOG_PRIORITY = {
|
||||||
|
logging.CRITICAL: 2, # LOG_CRIT
|
||||||
|
logging.ERROR: 3, # LOG_ERR
|
||||||
|
logging.WARNING: 4, # LOG_WARNING
|
||||||
|
logging.INFO: 6, # LOG_INFO
|
||||||
|
logging.DEBUG: 7, # LOG_DEBUG
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JournalPriorityFormatter(logging.Formatter):
|
||||||
|
"""Wraps a formatter, prefixing each line with its syslog priority.
|
||||||
|
|
||||||
|
Under systemd everything this process writes to stdout lands in the journal
|
||||||
|
as PRIORITY=6, whatever the Python level was. Measured on a live rig: 55
|
||||||
|
ERROR lines and 13 WARNING lines in a day, every one of them recorded as
|
||||||
|
informational, so `journalctl -p err -u ledmatrix` returned nothing at all
|
||||||
|
while errors were being logged. Anyone triaging has to grep the message
|
||||||
|
text instead, which is both slower and wrong -- a search for "oom" matches
|
||||||
|
the radar logging "zoom=9".
|
||||||
|
|
||||||
|
systemd reads a leading "<N>" on each line and uses it as the priority
|
||||||
|
(sd-daemon(3)), so this needs no extra dependency. Multi-line records get
|
||||||
|
the prefix on every line, since the journal splits them and an unprefixed
|
||||||
|
continuation would fall back to the default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, inner: logging.Formatter):
|
||||||
|
super().__init__()
|
||||||
|
self._inner = inner
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inner(self) -> logging.Formatter:
|
||||||
|
"""The formatter doing the actual work.
|
||||||
|
|
||||||
|
Whether journald tagging is applied depends on JOURNAL_STREAM, so it is
|
||||||
|
on under systemd and off in a terminal -- and anything asserting which
|
||||||
|
formatter setup_logging() selected would otherwise get a different
|
||||||
|
answer in CI than on a developer's machine. Exposing the inner one lets
|
||||||
|
those checks stay about format_type, which is what they mean.
|
||||||
|
"""
|
||||||
|
return self._inner
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
text = self._inner.format(record)
|
||||||
|
prefix = f"<{_SYSLOG_PRIORITY.get(record.levelno, 6)}>"
|
||||||
|
return "\n".join(prefix + line for line in text.split("\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def _under_systemd() -> bool:
|
||||||
|
"""True when stdout really is the journal.
|
||||||
|
|
||||||
|
systemd sets JOURNAL_STREAM to "dev:ino" for services whose output it
|
||||||
|
captures. Presence alone is not enough to act on: the variable is
|
||||||
|
inherited by child processes and survives redirection, so a subprocess
|
||||||
|
whose stdout is a pipe or a file still sees it and would emit the "<N>"
|
||||||
|
priority prefixes as literal noise into that output. systemd's own
|
||||||
|
guidance is to fstat the descriptor and compare st_dev/st_ino, which is
|
||||||
|
what distinguishes "the journal is somewhere in my ancestry" from "my
|
||||||
|
stdout is the journal".
|
||||||
|
"""
|
||||||
|
declared = os.environ.get("JOURNAL_STREAM")
|
||||||
|
if not declared:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
dev_text, ino_text = declared.split(":", 1)
|
||||||
|
declared_ids = (int(dev_text), int(ino_text))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
stat_result = os.fstat(sys.stdout.fileno())
|
||||||
|
except (OSError, ValueError, AttributeError):
|
||||||
|
# No usable stdout: captured by pytest, detached, or already closed.
|
||||||
|
return False
|
||||||
|
return (stat_result.st_dev, stat_result.st_ino) == declared_ids
|
||||||
|
|
||||||
|
|
||||||
class PluginLoggerAdapter(logging.LoggerAdapter):
|
class PluginLoggerAdapter(logging.LoggerAdapter):
|
||||||
"""LoggerAdapter that stamps every record with its plugin_id.
|
"""LoggerAdapter that stamps every record with its plugin_id.
|
||||||
|
|
||||||
@@ -224,8 +313,11 @@ def log_warning(logger: logging.Logger, message: str, **kwargs) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def log_error(logger: logging.Logger, message: str, **kwargs) -> None:
|
def log_error(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||||
"""Log error message with context."""
|
"""Log error message with context. Defaults exc_info=True; a caller
|
||||||
log_with_context(logger, logging.ERROR, message, **kwargs, exc_info=True)
|
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:
|
def log_debug(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import json
|
|||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from PIL.PngImagePlugin import PngInfo
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
@@ -25,6 +26,96 @@ from src.common.permission_utils import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: PNG text key stamped into a generated placeholder so a later run can tell it
|
||||||
|
#: apart from a real logo that happens to be small.
|
||||||
|
PLACEHOLDER_MARKER = "ledmatrix_placeholder"
|
||||||
|
|
||||||
|
#: Geometry of a generated placeholder, used to recognise ones written before
|
||||||
|
#: the marker existed. Those are already on users' disks and would otherwise
|
||||||
|
#: never be retried.
|
||||||
|
PLACEHOLDER_SIZE = (64, 64)
|
||||||
|
PLACEHOLDER_BG = (100, 100, 100, 255)
|
||||||
|
|
||||||
|
#: How long a placeholder is trusted before the real logo is attempted again.
|
||||||
|
#: A placeholder means the download failed, and download failures are usually
|
||||||
|
#: transient (no network at boot, ESPN blipping). Retrying every frame would
|
||||||
|
#: hammer the API from a Pi that is also driving a panel; never retrying leaves
|
||||||
|
#: the team a grey box forever, which is the bug this exists to avoid.
|
||||||
|
PLACEHOLDER_RETRY_SECONDS = 6 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def is_placeholder_logo(filepath: Path) -> bool:
|
||||||
|
"""True if the file at ``filepath`` is a generated placeholder, not a logo.
|
||||||
|
|
||||||
|
Checks the marker first, then falls back to matching the placeholder's
|
||||||
|
exact geometry and background colour so files written before the marker was
|
||||||
|
introduced are still recognised.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Image.open(filepath) as img:
|
||||||
|
if img.info.get(PLACEHOLDER_MARKER):
|
||||||
|
return True
|
||||||
|
if img.size != PLACEHOLDER_SIZE:
|
||||||
|
return False
|
||||||
|
return img.convert("RGBA").getpixel((0, 0)) == PLACEHOLDER_BG
|
||||||
|
except Exception:
|
||||||
|
# Unreadable file: not provably a placeholder, and the caller's own
|
||||||
|
# error handling is better placed to deal with it.
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def should_attempt_download(filepath: Path, force_download: bool = False) -> bool:
|
||||||
|
"""Whether a real logo is worth (re)fetching for ``filepath``.
|
||||||
|
|
||||||
|
True when nothing is there, when the caller forced it, or when what is
|
||||||
|
there is a placeholder old enough to retry. A *fresh* placeholder says a
|
||||||
|
download just failed, so retrying it immediately would hammer the API for
|
||||||
|
a result that is very unlikely to have changed.
|
||||||
|
"""
|
||||||
|
if force_download or not filepath.exists():
|
||||||
|
return True
|
||||||
|
if not is_placeholder_logo(filepath):
|
||||||
|
return False
|
||||||
|
age = placeholder_age_seconds(filepath)
|
||||||
|
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_placeholder_timestamp(filepath: Path) -> bool:
|
||||||
|
"""Restamp a placeholder so a failed retry restarts the back-off clock.
|
||||||
|
|
||||||
|
Without this a stale placeholder stays stale: every later call sees an
|
||||||
|
expired timestamp, retries, fails, and leaves the timestamp untouched --
|
||||||
|
which is a download attempt per call, the opposite of what the back-off is
|
||||||
|
for.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not is_placeholder_logo(filepath):
|
||||||
|
return False
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
|
||||||
|
with Image.open(filepath) as img:
|
||||||
|
img.copy().save(filepath, "PNG", pnginfo=metadata)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not refresh placeholder timestamp for %s", filepath,
|
||||||
|
exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def placeholder_age_seconds(filepath: Path) -> Optional[float]:
|
||||||
|
"""Seconds since a placeholder was written, or None if unknown."""
|
||||||
|
try:
|
||||||
|
with Image.open(filepath) as img:
|
||||||
|
stamped = img.info.get(PLACEHOLDER_MARKER)
|
||||||
|
if stamped and stamped != "1":
|
||||||
|
return max(0.0, time.time() - float(stamped))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return max(0.0, time.time() - filepath.stat().st_mtime)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
class LogoDownloader:
|
class LogoDownloader:
|
||||||
"""Centralized logo downloader for team logos from ESPN API."""
|
"""Centralized logo downloader for team logos from ESPN API."""
|
||||||
|
|
||||||
@@ -499,8 +590,10 @@ class LogoDownloader:
|
|||||||
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
||||||
filepath = Path(logo_dir) / filename
|
filepath = Path(logo_dir) / filename
|
||||||
|
|
||||||
# Skip if already exists and not forcing download
|
# A placeholder does not count as existing -- it is a previous
|
||||||
if filepath.exists() and not force_download:
|
# failure, and a bulk pass is exactly where it should get another
|
||||||
|
# chance, subject to the same back-off as everywhere else.
|
||||||
|
if not should_attempt_download(filepath, force_download):
|
||||||
logger.debug(f"Skipping {display_name}: {filename} already exists")
|
logger.debug(f"Skipping {display_name}: {filename} already exists")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -559,8 +652,9 @@ class LogoDownloader:
|
|||||||
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
||||||
filepath = Path(logo_dir) / filename
|
filepath = Path(logo_dir) / filename
|
||||||
|
|
||||||
# Skip if already exists and not forcing download
|
# Same eligibility rule as every other download site: a stale
|
||||||
if filepath.exists() and not force_download:
|
# placeholder is a failed download, not a logo.
|
||||||
|
if not should_attempt_download(filepath, force_download):
|
||||||
logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists")
|
logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -674,11 +768,16 @@ class LogoDownloader:
|
|||||||
# Fallback without font
|
# Fallback without font
|
||||||
draw.text((16, 24), text, fill=(255, 255, 255, 255))
|
draw.text((16, 24), text, fill=(255, 255, 255, 255))
|
||||||
|
|
||||||
logo.save(filepath)
|
# Stamp it so a later run can tell this apart from a real logo and
|
||||||
|
# retry the download, instead of treating the file's existence as
|
||||||
|
# proof the logo was fetched.
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
|
||||||
|
logo.save(filepath, "PNG", pnginfo=metadata)
|
||||||
|
|
||||||
# Set proper file permissions after saving
|
# Set proper file permissions after saving
|
||||||
ensure_file_permissions(filepath, get_assets_file_mode())
|
ensure_file_permissions(filepath, get_assets_file_mode())
|
||||||
|
|
||||||
logger.info(f"Created placeholder logo for {team_abbreviation} at {filepath}")
|
logger.info(f"Created placeholder logo for {team_abbreviation} at {filepath}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -770,9 +869,18 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
|||||||
# Use the exact filepath that was passed in (respects config settings)
|
# Use the exact filepath that was passed in (respects config settings)
|
||||||
filepath = logo_path
|
filepath = logo_path
|
||||||
|
|
||||||
if filepath.exists():
|
if filepath.exists() and not should_attempt_download(filepath):
|
||||||
|
# Either a real logo, or a placeholder too fresh to be worth retrying.
|
||||||
logger.debug(f"Logo already exists for {team_abbreviation} ({league})")
|
logger.debug(f"Logo already exists for {team_abbreviation} ({league})")
|
||||||
return True
|
return True
|
||||||
|
if filepath.exists():
|
||||||
|
# A placeholder is a *failed* download wearing the real logo's
|
||||||
|
# filename. Treating it as "already exists" is what pinned a team to a
|
||||||
|
# grey box permanently after one transient failure.
|
||||||
|
logger.info(
|
||||||
|
"Logo for %s (%s) is a placeholder from a failed download; "
|
||||||
|
"retrying the real logo", team_abbreviation, league,
|
||||||
|
)
|
||||||
|
|
||||||
# Try to download the real logo first
|
# Try to download the real logo first
|
||||||
logger.info(f"Attempting to download logo for {team_abbreviation} from {league}")
|
logger.info(f"Attempting to download logo for {team_abbreviation} from {league}")
|
||||||
|
|||||||
@@ -364,8 +364,10 @@ class BasePlugin(ABC):
|
|||||||
# Handle None case
|
# Handle None case
|
||||||
if duration is None:
|
if duration is None:
|
||||||
pass # Fall through to config
|
pass # Fall through to config
|
||||||
# Try to convert to float if it's a number or numeric string
|
# Try to convert to float if it's a number or numeric string.
|
||||||
elif isinstance(duration, (int, float)):
|
# 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:
|
if duration > 0:
|
||||||
return float(duration)
|
return float(duration)
|
||||||
else:
|
else:
|
||||||
@@ -403,8 +405,9 @@ class BasePlugin(ABC):
|
|||||||
# Fall back to config
|
# Fall back to config
|
||||||
config_duration = self.config.get("display_duration", 15.0)
|
config_duration = self.config.get("display_duration", 15.0)
|
||||||
try:
|
try:
|
||||||
# Ensure config value is also a valid float
|
# Ensure config value is also a valid float (bool excluded — an
|
||||||
if isinstance(config_duration, (int, float)):
|
# 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:
|
if config_duration > 0:
|
||||||
return float(config_duration)
|
return float(config_duration)
|
||||||
else:
|
else:
|
||||||
@@ -552,6 +555,48 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
return False
|
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]:
|
def get_live_modes(self) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get list of display modes that should be used during live priority takeover.
|
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")
|
self.logger.error("'enabled' must be a boolean")
|
||||||
return False
|
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:
|
if "display_duration" in self.config:
|
||||||
duration = self.config["display_duration"]
|
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")
|
self.logger.error("'display_duration' must be a positive number")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
# Below this, the core's self-reported version is not evidence of anything.
|
# Below this, the core's self-reported version is not evidence of anything.
|
||||||
@@ -35,6 +36,45 @@ from typing import Any, Dict, Optional, Tuple
|
|||||||
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
|
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ``src/__init__.py`` relative to this file: src/plugin_system/ -> src/
|
||||||
|
_VERSION_FILE = Path(__file__).resolve().parent.parent / "__init__.py"
|
||||||
|
_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def current_core_version() -> str:
|
||||||
|
"""The core version as it is on disk right now, not as it was at import.
|
||||||
|
|
||||||
|
``from src import __version__`` binds whatever the process loaded at start.
|
||||||
|
The web UI runs as its own long-lived service (``ledmatrix-web.service``),
|
||||||
|
and updating the core replaces files on disk without restarting it -- the
|
||||||
|
update route says so explicitly and asks the user to restart. Its prompt
|
||||||
|
names the *display* service, so a user who follows it leaves the web
|
||||||
|
process holding the old number.
|
||||||
|
|
||||||
|
The plugin store's gate lives in that web process. Stale by one release is
|
||||||
|
exactly the case that matters: every plugin flooring on the release you
|
||||||
|
just installed gets refused, with a message blaming a core version that is
|
||||||
|
already correct on disk. 3.3.0 is the first release where that hits a whole
|
||||||
|
plugin family at once -- all eight sports scoreboards floor there.
|
||||||
|
|
||||||
|
Reading the file costs one stat and a small read per call, and only on the
|
||||||
|
install/update path. Any failure falls back to the imported value, so this
|
||||||
|
can only ever be as wrong as before, never worse.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = _VERSION_FILE.read_text(encoding="utf-8")
|
||||||
|
match = _VERSION_RE.search(text)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from src import __version__ as imported
|
||||||
|
return imported
|
||||||
|
except Exception: # noqa: BLE001 - never let this raise
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||||
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
|
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
|
||||||
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
|
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
|
||||||
@@ -180,6 +220,45 @@ def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
|
|||||||
return None
|
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]]:
|
def check(manifest: Dict[str, Any], core_version: str) -> Tuple[bool, Optional[str]]:
|
||||||
"""Return ``(compatible, reason)``.
|
"""Return ``(compatible, reason)``.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ and circuit breaker state. Provides automatic recovery mechanisms.
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Optional, Any
|
from typing import Dict, Optional, Any, Tuple
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
@@ -64,10 +64,48 @@ class PluginHealthTracker:
|
|||||||
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
||||||
)
|
)
|
||||||
|
|
||||||
if cached:
|
if isinstance(cached, dict) and cached:
|
||||||
return cached
|
# Complete it rather than trusting it: a persisted record can be
|
||||||
|
# missing fields the callers index directly (a partial write, a
|
||||||
# Default state
|
# 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
|
||||||
|
|
||||||
|
# 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 {
|
return {
|
||||||
'consecutive_failures': 0,
|
'consecutive_failures': 0,
|
||||||
'total_failures': 0,
|
'total_failures': 0,
|
||||||
@@ -77,15 +115,56 @@ class PluginHealthTracker:
|
|||||||
'circuit_state': CircuitState.CLOSED.value,
|
'circuit_state': CircuitState.CLOSED.value,
|
||||||
'circuit_opened_time': None,
|
'circuit_opened_time': None,
|
||||||
'half_open_start_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:
|
@classmethod
|
||||||
"""Save health state to cache."""
|
def _repair_health_state(cls, cached: Dict[str, Any]) -> Tuple[Dict[str, Any], list]:
|
||||||
cache_key = self._get_health_key(plugin_id)
|
"""Return `cached` completed against the defaults, plus what was repaired.
|
||||||
self.cache_manager.set(cache_key, state) # Persist indefinitely
|
|
||||||
self._health_state[plugin_id] = state
|
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]:
|
def get_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||||
"""Get current health state for a plugin.
|
"""Get current health state for a plugin.
|
||||||
|
|
||||||
@@ -99,11 +178,21 @@ class PluginHealthTracker:
|
|||||||
)
|
)
|
||||||
return self._health_state[plugin_id]
|
return self._health_state[plugin_id]
|
||||||
|
|
||||||
|
# Fields the circuit breaker is rebuilt from after a restart. Everything
|
||||||
|
# else in a health record is reporting, read only for display.
|
||||||
|
_DURABLE_FIELDS = ('consecutive_failures', 'circuit_state',
|
||||||
|
'circuit_opened_time', 'half_open_start_time')
|
||||||
|
|
||||||
|
def _durable(self, state: Dict[str, Any]) -> tuple:
|
||||||
|
"""The part of a health record whose loss would change behaviour."""
|
||||||
|
return tuple(state.get(field) for field in self._DURABLE_FIELDS)
|
||||||
|
|
||||||
def record_success(self, plugin_id: str) -> None:
|
def record_success(self, plugin_id: str) -> None:
|
||||||
"""Record a successful plugin execution."""
|
"""Record a successful plugin execution."""
|
||||||
state = self.get_health_state(plugin_id)
|
state = self.get_health_state(plugin_id)
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
durable_before = self._durable(state)
|
||||||
|
|
||||||
# Reset consecutive failures
|
# Reset consecutive failures
|
||||||
state['consecutive_failures'] = 0
|
state['consecutive_failures'] = 0
|
||||||
state['total_successes'] = state.get('total_successes', 0) + 1
|
state['total_successes'] = state.get('total_successes', 0) + 1
|
||||||
@@ -119,9 +208,20 @@ class PluginHealthTracker:
|
|||||||
# Shouldn't happen, but handle it
|
# Shouldn't happen, but handle it
|
||||||
state['circuit_state'] = CircuitState.CLOSED.value
|
state['circuit_state'] = CircuitState.CLOSED.value
|
||||||
state['circuit_opened_time'] = None
|
state['circuit_opened_time'] = None
|
||||||
|
|
||||||
self._save_health_state(plugin_id, state)
|
# A healthy plugin reports success every cycle, and in that steady state
|
||||||
|
# the only fields changed above are a counter and a timestamp that
|
||||||
|
# nothing reads back after a restart. Persisting them anyway rewrites a
|
||||||
|
# small file per plugin per cycle: on a rig running 24 plugins, a
|
||||||
|
# five-minute sample measured 22 rewrites, about 4.4 a minute or 6,300 a
|
||||||
|
# day. Those land on an SD card, where the cost is an erase-block cycle
|
||||||
|
# rather than the 400 bytes involved, and where wear is what eventually
|
||||||
|
# kills the card.
|
||||||
|
# In-memory state is still updated every time, so the health API and web
|
||||||
|
# UI show exactly what they did before; only the write is skipped.
|
||||||
|
if self._durable(state) != durable_before:
|
||||||
|
self._save_health_state(plugin_id, state)
|
||||||
|
|
||||||
def record_failure(self, plugin_id: str, error: Optional[Exception] = None) -> None:
|
def record_failure(self, plugin_id: str, error: Optional[Exception] = None) -> None:
|
||||||
"""Record a failed plugin execution."""
|
"""Record a failed plugin execution."""
|
||||||
state = self.get_health_state(plugin_id)
|
state = self.get_health_state(plugin_id)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import sys
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, Tuple, Type
|
from typing import Dict, Any, List, Optional, Tuple, Type
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from packaging.requirements import InvalidRequirement, Requirement
|
from packaging.requirements import InvalidRequirement, Requirement
|
||||||
@@ -45,6 +45,76 @@ def requirements_has_real_deps(requirements_file: str) -> bool:
|
|||||||
return False
|
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:
|
def requirements_are_satisfied(requirements_file: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check whether every real requirement line in requirements.txt is already
|
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:
|
except InvalidRequirement:
|
||||||
return False
|
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():
|
if req.marker is not None and not req.marker.evaluate():
|
||||||
continue # not applicable on this platform/interpreter
|
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):
|
if req.specifier and not req.specifier.contains(installed_version, prereleases=True):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if req.extras and not _extras_are_satisfied(req):
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -702,8 +772,8 @@ class PluginLoader:
|
|||||||
newer than the running core. Advisory only — never raises — so a
|
newer than the running core. Advisory only — never raises — so a
|
||||||
plugin that guards optional features with try/except keeps working.
|
plugin that guards optional features with try/except keeps working.
|
||||||
"""
|
"""
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
compatible, _reason = compatibility.check(manifest, core_version)
|
compatible, _reason = compatibility.check(manifest, core_version)
|
||||||
if compatible:
|
if compatible:
|
||||||
|
|||||||
@@ -71,11 +71,15 @@ class PluginManager:
|
|||||||
self.plugin_loader = PluginLoader(logger=self.logger)
|
self.plugin_loader = PluginLoader(logger=self.logger)
|
||||||
self.plugin_executor = PluginExecutor(default_timeout=30.0, logger=self.logger)
|
self.plugin_executor = PluginExecutor(default_timeout=30.0, logger=self.logger)
|
||||||
self.state_manager = PluginStateManager(logger=self.logger)
|
self.state_manager = PluginStateManager(logger=self.logger)
|
||||||
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, logger=self.logger)
|
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, logger=self.logger,
|
||||||
|
config_manager=self.config_manager)
|
||||||
|
|
||||||
# Lock protecting plugin_manifests and plugin_directories from
|
# Lock protecting plugin_manifests and plugin_directories from
|
||||||
# concurrent mutation (background reconciliation) and reads (requests).
|
# concurrent mutation (background reconciliation) and reads (requests).
|
||||||
self._discovery_lock = threading.RLock()
|
self._discovery_lock = threading.RLock()
|
||||||
|
#: Directories already reported as unloadable, so the warning is
|
||||||
|
#: emitted once rather than on every discovery scan.
|
||||||
|
self._skip_reported: set = set()
|
||||||
|
|
||||||
# Lock protecting plugin_last_update from concurrent mutation/iteration.
|
# Lock protecting plugin_last_update from concurrent mutation/iteration.
|
||||||
# It's written from run_scheduled_updates()/update_all_plugins() (main
|
# It's written from run_scheduled_updates()/update_all_plugins() (main
|
||||||
@@ -125,6 +129,14 @@ class PluginManager:
|
|||||||
self._plugin_locks: Dict[str, threading.Lock] = {}
|
self._plugin_locks: Dict[str, threading.Lock] = {}
|
||||||
self._plugin_locks_guard = threading.Lock()
|
self._plugin_locks_guard = threading.Lock()
|
||||||
self._update_worker: Optional[threading.Thread] = None
|
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
|
self._synchronous_updates = False
|
||||||
if self.config_manager is not None:
|
if self.config_manager is not None:
|
||||||
try:
|
try:
|
||||||
@@ -187,18 +199,59 @@ class PluginManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
manifest_path = item / "manifest.json"
|
manifest_path = item / "manifest.json"
|
||||||
if manifest_path.exists():
|
if not manifest_path.exists():
|
||||||
try:
|
# Once per directory per process. Discovery runs on every
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
# web UI page load and every config reconcile, so warning
|
||||||
manifest = json.load(f)
|
# unconditionally would put a line in the journal each
|
||||||
plugin_id = manifest.get('id')
|
# time someone opened a page -- the same log-volume
|
||||||
if plugin_id:
|
# problem this is meant to help diagnose.
|
||||||
plugin_ids.append(plugin_id)
|
# A directory here that carries no manifest is not a
|
||||||
new_manifests[plugin_id] = manifest
|
# plugin. Said once, because the alternative is a plugin
|
||||||
new_directories[plugin_id] = item
|
# that is enabled in config, enabled in plugin state,
|
||||||
except (json.JSONDecodeError, PermissionError, OSError) as e:
|
# present on disk, and simply absent from the running
|
||||||
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
|
# process with nothing anywhere to say why. Working that
|
||||||
continue
|
# out afterwards means reading cache-file mtimes.
|
||||||
|
if item.name not in self._skip_reported:
|
||||||
|
self._skip_reported.add(item.name)
|
||||||
|
self.logger.warning(
|
||||||
|
"Skipping %s: no manifest.json, so it cannot be "
|
||||||
|
"loaded as a plugin", item.name)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
except (json.JSONDecodeError, PermissionError, OSError) as e:
|
||||||
|
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# json.load accepts any JSON value, so a manifest holding
|
||||||
|
# null, [] or "text" parses and then raises AttributeError on
|
||||||
|
# .get(). Nothing here catches that -- the outer handler takes
|
||||||
|
# OSError/PermissionError only -- so a single malformed
|
||||||
|
# manifest aborted the whole scan and every other plugin on
|
||||||
|
# disk, however healthy, silently failed to register.
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
if item.name not in self._skip_reported:
|
||||||
|
self._skip_reported.add(item.name)
|
||||||
|
self.logger.warning(
|
||||||
|
"Skipping %s: its manifest.json is %s, not a JSON "
|
||||||
|
"object", item.name, type(manifest).__name__)
|
||||||
|
continue
|
||||||
|
|
||||||
|
plugin_id = manifest.get('id')
|
||||||
|
if not plugin_id:
|
||||||
|
# Parsed but unusable. This was the quietest path of all:
|
||||||
|
# the manifest is read successfully and then dropped.
|
||||||
|
if item.name not in self._skip_reported:
|
||||||
|
self._skip_reported.add(item.name)
|
||||||
|
self.logger.warning(
|
||||||
|
"Skipping %s: its manifest.json has no \"id\", so "
|
||||||
|
"there is nothing to register it under", item.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
plugin_ids.append(plugin_id)
|
||||||
|
new_manifests[plugin_id] = manifest
|
||||||
|
new_directories[plugin_id] = item
|
||||||
except (OSError, PermissionError) as e:
|
except (OSError, PermissionError) as e:
|
||||||
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
|
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
|
||||||
|
|
||||||
@@ -578,6 +631,17 @@ class PluginManager:
|
|||||||
|
|
||||||
return self.load_plugin(plugin_id)
|
return self.load_plugin(plugin_id)
|
||||||
|
|
||||||
|
def discovered_plugin_ids(self) -> set:
|
||||||
|
"""Snapshot of the discovered plugin ids, taken under the discovery lock.
|
||||||
|
|
||||||
|
Callers on other threads (the config watcher) must not iterate
|
||||||
|
``plugin_manifests`` directly: discovery rebuilds it entry by entry, so
|
||||||
|
an unsynchronised reader can see a half-populated mapping or raise
|
||||||
|
"dictionary changed size during iteration".
|
||||||
|
"""
|
||||||
|
with self._discovery_lock:
|
||||||
|
return set(self.plugin_manifests)
|
||||||
|
|
||||||
def get_plugin(self, plugin_id: str) -> Optional[Any]:
|
def get_plugin(self, plugin_id: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
Get a loaded plugin instance by ID.
|
Get a loaded plugin instance by ID.
|
||||||
@@ -1025,6 +1089,7 @@ class PluginManager:
|
|||||||
if success:
|
if success:
|
||||||
with self._plugin_last_update_lock:
|
with self._plugin_last_update_lock:
|
||||||
self.plugin_last_update[plugin_id] = scheduled_time
|
self.plugin_last_update[plugin_id] = scheduled_time
|
||||||
|
self._note_update_completed(plugin_id)
|
||||||
self.state_manager.record_update(plugin_id)
|
self.state_manager.record_update(plugin_id)
|
||||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||||
if self.health_tracker:
|
if self.health_tracker:
|
||||||
@@ -1089,28 +1154,41 @@ class PluginManager:
|
|||||||
|
|
||||||
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
|
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
|
Like run_scheduled_updates(), but also reports which plugins have
|
||||||
plugin_last_update timestamp actually advanced during this call.
|
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
|
That distinction is the whole point. This used to snapshot
|
||||||
individually lock-protected against concurrent plugin_last_update
|
plugin_last_update, call run_scheduled_updates(), and diff. But
|
||||||
mutation (Vegas mode calls this from its own background
|
run_scheduled_updates() only *enqueues*: the work runs on the
|
||||||
update-tick thread, racing the main render loop's plugin updates),
|
update worker and the timestamp is stamped there, after this method
|
||||||
so callers get an atomic "who got fresh data" answer without
|
has already returned. The two snapshots were therefore always
|
||||||
reaching into plugin_last_update themselves. The lock is not held
|
identical and the result was always empty, so Vegas never learned
|
||||||
across the update pass so slow/blocking plugin update() calls don't
|
that any plugin's data had changed and kept scrolling whatever a
|
||||||
serialize against other plugin_last_update readers.
|
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)
|
self.run_scheduled_updates(current_time)
|
||||||
|
return self.drain_completed_updates()
|
||||||
|
|
||||||
with self._plugin_last_update_lock:
|
def _note_update_completed(self, plugin_id: str) -> None:
|
||||||
return [
|
"""Record that a plugin's update() finished, for the next poll."""
|
||||||
plugin_id for plugin_id, new_time in self.plugin_last_update.items()
|
with self._completed_updates_lock:
|
||||||
if new_time > old_times.get(plugin_id, 0.0)
|
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:
|
def update_all_plugins(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -1135,6 +1213,7 @@ class PluginManager:
|
|||||||
if success:
|
if success:
|
||||||
with self._plugin_last_update_lock:
|
with self._plugin_last_update_lock:
|
||||||
self.plugin_last_update[plugin_id] = time.time()
|
self.plugin_last_update[plugin_id] = time.time()
|
||||||
|
self._note_update_completed(plugin_id)
|
||||||
self.state_manager.record_update(plugin_id)
|
self.state_manager.record_update(plugin_id)
|
||||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -6,14 +6,40 @@ with state transitions and queries.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, Deque, List, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
# The history is diagnostic only -- nothing reads the entries themselves, just
|
||||||
|
# their count -- but it is appended to on the hot scheduling path: every update
|
||||||
|
# cycle records RUNNING on reserve and ENABLED on finish. Unbounded, that is
|
||||||
|
# 2,880 entries per plugin per day at the default 60s interval, which on a 1 GB
|
||||||
|
# Pi exhausts memory in weeks.
|
||||||
|
#
|
||||||
|
# Two limits, because a single entry count answers the wrong question. What a
|
||||||
|
# reader wants is "the last couple of hours", and how many transitions that is
|
||||||
|
# depends entirely on the plugin's update interval -- which on a real board
|
||||||
|
# spans 2s to 3600s. A flat 200 entries is 4.2 days for the slowest plugin and
|
||||||
|
# 3.3 minutes for the fastest, so the plugin churning hardest, the one worth
|
||||||
|
# looking at, keeps the least history.
|
||||||
|
#
|
||||||
|
# So: trim by AGE first, which makes the retained window comparable across
|
||||||
|
# plugins whatever their cadence...
|
||||||
|
STATE_HISTORY_MAX_AGE_SECONDS = 2 * 60 * 60
|
||||||
|
|
||||||
|
# ...and cap by COUNT second, purely as a memory ceiling for the fast pollers
|
||||||
|
# whose age window would otherwise run to thousands of entries. At ~230 bytes
|
||||||
|
# an entry this is ~0.5 MB per plugin worst case, and only plugins updating
|
||||||
|
# faster than roughly every 4s can reach it.
|
||||||
|
MAX_STATE_HISTORY_PER_PLUGIN = 2000
|
||||||
|
|
||||||
|
|
||||||
class PluginState(Enum):
|
class PluginState(Enum):
|
||||||
"""Plugin state enumeration."""
|
"""Plugin state enumeration."""
|
||||||
UNLOADED = "unloaded" # Plugin not loaded
|
UNLOADED = "unloaded" # Plugin not loaded
|
||||||
@@ -37,11 +63,43 @@ class PluginStateManager:
|
|||||||
self.logger = logger or get_logger(__name__)
|
self.logger = logger or get_logger(__name__)
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._states: Dict[str, PluginState] = {}
|
self._states: Dict[str, PluginState] = {}
|
||||||
self._state_history: Dict[str, list] = {}
|
# (monotonic timestamp, transition). The clock is monotonic so a DST
|
||||||
|
# shift or an NTP step cannot make entries look old and flush the
|
||||||
|
# history; the human-readable timestamp lives inside the transition.
|
||||||
|
self._state_history: Dict[str, Deque[Tuple[float, Dict[str, Any]]]] = {}
|
||||||
|
# Lifetime transition totals, kept separately so the count reported by
|
||||||
|
# get_state_info() stays truthful once the history above starts rolling.
|
||||||
|
self._state_transition_counts: Dict[str, int] = {}
|
||||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||||
self._last_update: Dict[str, datetime] = {}
|
self._last_update: Dict[str, datetime] = {}
|
||||||
self._last_display: Dict[str, datetime] = {}
|
self._last_display: Dict[str, datetime] = {}
|
||||||
|
|
||||||
|
def _record_transition(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
transition: Dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Append a transition to the plugin's bounded history.
|
||||||
|
|
||||||
|
Callers must already hold ``_lock``. The deque discards its oldest
|
||||||
|
entry once it is full, so the history cannot grow without bound; the
|
||||||
|
lifetime total is tracked separately for get_state_info().
|
||||||
|
"""
|
||||||
|
history = self._state_history.get(plugin_id)
|
||||||
|
if history is None:
|
||||||
|
history = deque(maxlen=MAX_STATE_HISTORY_PER_PLUGIN)
|
||||||
|
self._state_history[plugin_id] = history
|
||||||
|
now = time.monotonic()
|
||||||
|
history.append((now, transition))
|
||||||
|
# Age out first; the deque's maxlen is the backstop for plugins that
|
||||||
|
# produce more than the ceiling within the window.
|
||||||
|
cutoff = now - STATE_HISTORY_MAX_AGE_SECONDS
|
||||||
|
while history and history[0][0] < cutoff:
|
||||||
|
history.popleft()
|
||||||
|
self._state_transition_counts[plugin_id] = (
|
||||||
|
self._state_transition_counts.get(plugin_id, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
def set_state(
|
def set_state(
|
||||||
self,
|
self,
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
@@ -60,16 +118,13 @@ class PluginStateManager:
|
|||||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||||
self._states[plugin_id] = state
|
self._states[plugin_id] = state
|
||||||
|
|
||||||
if plugin_id not in self._state_history:
|
|
||||||
self._state_history[plugin_id] = []
|
|
||||||
|
|
||||||
transition = {
|
transition = {
|
||||||
'timestamp': datetime.now(),
|
'timestamp': datetime.now(),
|
||||||
'from': old_state.value,
|
'from': old_state.value,
|
||||||
'to': state.value,
|
'to': state.value,
|
||||||
'error': str(error) if error else None
|
'error': str(error) if error else None
|
||||||
}
|
}
|
||||||
self._state_history[plugin_id].append(transition)
|
self._record_transition(plugin_id, transition)
|
||||||
|
|
||||||
# Store error info if transitioning to ERROR state
|
# Store error info if transitioning to ERROR state
|
||||||
if state == PluginState.ERROR and error:
|
if state == PluginState.ERROR and error:
|
||||||
@@ -126,17 +181,29 @@ class PluginStateManager:
|
|||||||
state = self.get_state(plugin_id)
|
state = self.get_state(plugin_id)
|
||||||
return state == PluginState.ENABLED
|
return state == PluginState.ENABLED
|
||||||
|
|
||||||
def get_state_history(self, plugin_id: str) -> list:
|
def get_state_history(self, plugin_id: str) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get state transition history for a plugin.
|
Get state transition history for a plugin.
|
||||||
|
|
||||||
|
Retention is by age first -- transitions older than
|
||||||
|
STATE_HISTORY_MAX_AGE_SECONDS are dropped -- and by count second, at
|
||||||
|
MAX_STATE_HISTORY_PER_PLUGIN, which only binds for plugins updating
|
||||||
|
fast enough to exceed it inside that window.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of state transitions
|
List of recent state transitions, oldest first. Both the list and
|
||||||
|
the transition dicts are copies, so callers cannot mutate the
|
||||||
|
manager's own history. The values inside a transition are all
|
||||||
|
immutable, so a shallow copy per entry is enough.
|
||||||
"""
|
"""
|
||||||
return self._state_history.get(plugin_id, [])
|
with self._lock:
|
||||||
|
return [
|
||||||
|
dict(transition)
|
||||||
|
for _stamp, transition in self._state_history.get(plugin_id, ())
|
||||||
|
]
|
||||||
|
|
||||||
def set_error_info(self, plugin_id: str, error_info: Dict[str, Any]) -> None:
|
def set_error_info(self, plugin_id: str, error_info: Dict[str, Any]) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -179,9 +246,7 @@ class PluginStateManager:
|
|||||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||||
self._states[plugin_id] = state
|
self._states[plugin_id] = state
|
||||||
|
|
||||||
if plugin_id not in self._state_history:
|
self._record_transition(plugin_id, {
|
||||||
self._state_history[plugin_id] = []
|
|
||||||
self._state_history[plugin_id].append({
|
|
||||||
'timestamp': datetime.now(),
|
'timestamp': datetime.now(),
|
||||||
'from': old_state.value,
|
'from': old_state.value,
|
||||||
'to': state.value,
|
'to': state.value,
|
||||||
@@ -241,26 +306,40 @@ class PluginStateManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with state information
|
Dictionary with state information
|
||||||
"""
|
"""
|
||||||
state = self.get_state(plugin_id)
|
# One snapshot, one critical section. Each field was read under its own
|
||||||
info = {
|
# lock, so an unload running concurrently could be observed half-done:
|
||||||
'state': state.value,
|
# 'state' read before clear_state() removed it and
|
||||||
'is_loaded': self.is_loaded(plugin_id),
|
# 'state_history_count' read after, giving a caller a plugin that is
|
||||||
'is_enabled': self.is_enabled(plugin_id),
|
# ENABLED with zero transitions. _lock is an RLock, so the helpers
|
||||||
'is_running': self.is_running(plugin_id),
|
# below can still take it.
|
||||||
'is_error': self.is_error(plugin_id),
|
with self._lock:
|
||||||
'can_execute': self.can_execute(plugin_id),
|
state = self.get_state(plugin_id)
|
||||||
'last_update': self.get_last_update(plugin_id),
|
info = {
|
||||||
'last_display': self.get_last_display(plugin_id),
|
'state': state.value,
|
||||||
'error_info': self.get_error_info(plugin_id),
|
'is_loaded': self.is_loaded(plugin_id),
|
||||||
'state_history_count': len(self.get_state_history(plugin_id))
|
'is_enabled': self.is_enabled(plugin_id),
|
||||||
}
|
'is_running': self.is_running(plugin_id),
|
||||||
|
'is_error': self.is_error(plugin_id),
|
||||||
|
'can_execute': self.can_execute(plugin_id),
|
||||||
|
'last_update': self.get_last_update(plugin_id),
|
||||||
|
'last_display': self.get_last_display(plugin_id),
|
||||||
|
'error_info': self.get_error_info(plugin_id),
|
||||||
|
'state_history_count': self._state_transition_counts.get(plugin_id, 0)
|
||||||
|
}
|
||||||
return info
|
return info
|
||||||
|
|
||||||
def clear_state(self, plugin_id: str) -> None:
|
def clear_state(self, plugin_id: str) -> None:
|
||||||
"""Clear all state information for a plugin."""
|
"""Clear all state information for a plugin.
|
||||||
self._states.pop(plugin_id, None)
|
|
||||||
self._state_history.pop(plugin_id, None)
|
Held under ``_lock`` so the five dicts are dropped as one unit: every
|
||||||
self._error_info.pop(plugin_id, None)
|
other mutator takes the lock, and without it a concurrent set_state()
|
||||||
self._last_update.pop(plugin_id, None)
|
could interleave and leave a plugin with history but no state.
|
||||||
self._last_display.pop(plugin_id, None)
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._states.pop(plugin_id, None)
|
||||||
|
self._state_history.pop(plugin_id, None)
|
||||||
|
self._state_transition_counts.pop(plugin_id, None)
|
||||||
|
self._error_info.pop(plugin_id, None)
|
||||||
|
self._last_update.pop(plugin_id, None)
|
||||||
|
self._last_display.pop(plugin_id, None)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import time
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from typing import Dict, Optional, Any, Callable
|
from typing import Dict, Optional, Any, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field, fields
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
@@ -49,6 +49,20 @@ class ResourceMetrics:
|
|||||||
self.total_execution_time = self.total_execution_time / self.call_count
|
self.total_execution_time = self.total_execution_time / self.call_count
|
||||||
|
|
||||||
|
|
||||||
|
#: How often a plugin's metrics are written to the cache, in seconds.
|
||||||
|
#:
|
||||||
|
#: Persisting on every call meant a small file rewritten roughly nine times a
|
||||||
|
#: minute per plugin. On a rig with fourteen active plugins that was ~126
|
||||||
|
#: writes a minute for metrics alone, and since each ~350-byte file costs a
|
||||||
|
#: 4KB block plus an ext4 journal entry, it dominated the device's write
|
||||||
|
#: volume -- on an SD card, which wears out.
|
||||||
|
#:
|
||||||
|
#: The in-memory copy stays authoritative and exact; only the cross-process
|
||||||
|
#: snapshot the web UI reads is delayed, and telemetry up to half a minute old
|
||||||
|
#: is still a fair description of a long-running plugin.
|
||||||
|
_METRICS_PERSIST_INTERVAL = 30.0
|
||||||
|
|
||||||
|
|
||||||
class PluginResourceMonitor:
|
class PluginResourceMonitor:
|
||||||
"""
|
"""
|
||||||
Monitors resource usage for plugins.
|
Monitors resource usage for plugins.
|
||||||
@@ -75,6 +89,10 @@ class PluginResourceMonitor:
|
|||||||
# Resource metrics per plugin
|
# Resource metrics per plugin
|
||||||
self._metrics: Dict[str, ResourceMetrics] = {}
|
self._metrics: Dict[str, ResourceMetrics] = {}
|
||||||
self._limits: Dict[str, ResourceLimits] = {}
|
self._limits: Dict[str, ResourceLimits] = {}
|
||||||
|
# When each plugin's metrics last reached the cache. Metrics change on
|
||||||
|
# every call, so they cannot be de-duplicated the way health state can;
|
||||||
|
# they are rate-limited instead. See _METRICS_PERSIST_INTERVAL.
|
||||||
|
self._metrics_persisted_at: Dict[str, float] = {}
|
||||||
|
|
||||||
# Thread-local storage for execution tracking
|
# Thread-local storage for execution tracking
|
||||||
self._local = threading.local()
|
self._local = threading.local()
|
||||||
@@ -102,6 +120,66 @@ class PluginResourceMonitor:
|
|||||||
"psutil not available - resource monitoring will be limited to execution time only"
|
"psutil not available - resource monitoring will be limited to execution time only"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _metrics_from_cache(self, plugin_id: str, cached: Any) -> "ResourceMetrics":
|
||||||
|
"""Build metrics from a cached record, ignoring anything unrecognised.
|
||||||
|
|
||||||
|
ResourceMetrics(**cached) raises TypeError on a single unexpected key,
|
||||||
|
and that exception escapes into plugin_manager, which reports it as
|
||||||
|
"plugin <id> operation failed". Every plugin fails, and the plugin
|
||||||
|
system never finishes initialising.
|
||||||
|
|
||||||
|
Seen on a live rig: every plugin failing with
|
||||||
|
|
||||||
|
ResourceMetrics.__init__() got an unexpected keyword argument
|
||||||
|
'consecutive_failures'
|
||||||
|
|
||||||
|
which is a plugin_health field, not a metrics one. How a health-shaped
|
||||||
|
record came to sit under a plugin_metrics key on that machine is not
|
||||||
|
established -- a restored backup that mixed two machines' caches is the
|
||||||
|
likeliest explanation -- but the loader should not be brittle enough for
|
||||||
|
it to matter. plugin_health already repairs its records field by field
|
||||||
|
rather than trusting whatever is on disk; this does the same.
|
||||||
|
|
||||||
|
Unknown keys are dropped and named once, so a genuine schema change is
|
||||||
|
visible in the log instead of silently discarded.
|
||||||
|
"""
|
||||||
|
if not isinstance(cached, dict):
|
||||||
|
self.logger.warning(
|
||||||
|
"Ignoring cached metrics for %s: expected a mapping, got %s",
|
||||||
|
plugin_id, type(cached).__name__)
|
||||||
|
return ResourceMetrics()
|
||||||
|
|
||||||
|
known = {f.name for f in fields(ResourceMetrics)}
|
||||||
|
unknown = sorted(set(cached) - known)
|
||||||
|
if unknown:
|
||||||
|
self.logger.warning(
|
||||||
|
"Dropping unrecognised field(s) from cached metrics for %s: %s",
|
||||||
|
plugin_id, ", ".join(unknown))
|
||||||
|
# A dataclass does not enforce its annotations, so
|
||||||
|
# ResourceMetrics(call_count="not a number") builds happily and only
|
||||||
|
# blows up later, deep inside monitor_call ("can only concatenate str
|
||||||
|
# (not \"int\") to str"). Coerce here, where there is still a cache
|
||||||
|
# key to name in the warning.
|
||||||
|
declared = {f.name: f.type for f in fields(ResourceMetrics)}
|
||||||
|
usable = {}
|
||||||
|
for key, value in cached.items():
|
||||||
|
if key not in known:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
usable[key] = int(value) if declared[key] in ('int', int) else float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.logger.warning(
|
||||||
|
"Cached metrics for %s have a bad %s (%r); starting fresh",
|
||||||
|
plugin_id, key, value)
|
||||||
|
return ResourceMetrics()
|
||||||
|
try:
|
||||||
|
return ResourceMetrics(**usable)
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
self.logger.warning(
|
||||||
|
"Cached metrics for %s unusable (%s); starting fresh",
|
||||||
|
plugin_id, e)
|
||||||
|
return ResourceMetrics()
|
||||||
|
|
||||||
def _get_metrics_key(self, plugin_id: str) -> str:
|
def _get_metrics_key(self, plugin_id: str) -> str:
|
||||||
"""Get cache key for plugin metrics."""
|
"""Get cache key for plugin metrics."""
|
||||||
return f"plugin_metrics:{plugin_id}"
|
return f"plugin_metrics:{plugin_id}"
|
||||||
@@ -126,7 +204,7 @@ class PluginResourceMonitor:
|
|||||||
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
||||||
)
|
)
|
||||||
if cached:
|
if cached:
|
||||||
metrics = ResourceMetrics(**cached)
|
metrics = self._metrics_from_cache(plugin_id, cached)
|
||||||
else:
|
else:
|
||||||
metrics = ResourceMetrics()
|
metrics = ResourceMetrics()
|
||||||
self._metrics[plugin_id] = metrics
|
self._metrics[plugin_id] = metrics
|
||||||
@@ -232,18 +310,8 @@ class PluginResourceMonitor:
|
|||||||
# CPU is harder to measure per-call, so we track it separately
|
# CPU is harder to measure per-call, so we track it separately
|
||||||
metrics.cpu_percent = self._get_process_cpu_percent()
|
metrics.cpu_percent = self._get_process_cpu_percent()
|
||||||
|
|
||||||
# Persist metrics
|
# Persist metrics, at most once per interval per plugin.
|
||||||
cache_key = self._get_metrics_key(plugin_id)
|
self._persist_metrics(plugin_id, metrics)
|
||||||
self.cache_manager.set(cache_key, {
|
|
||||||
'memory_mb': metrics.memory_mb,
|
|
||||||
'cpu_percent': metrics.cpu_percent,
|
|
||||||
'execution_time': metrics.execution_time,
|
|
||||||
'call_count': metrics.call_count,
|
|
||||||
'total_execution_time': metrics.total_execution_time,
|
|
||||||
'max_execution_time': metrics.max_execution_time,
|
|
||||||
'min_execution_time': metrics.min_execution_time if metrics.min_execution_time != float('inf') else 0.0,
|
|
||||||
'last_update_time': metrics.last_update_time
|
|
||||||
})
|
|
||||||
|
|
||||||
# Check limits
|
# Check limits
|
||||||
if limits:
|
if limits:
|
||||||
@@ -363,6 +431,44 @@ class PluginResourceMonitor:
|
|||||||
summaries[plugin_id] = self.get_metrics_summary(plugin_id)
|
summaries[plugin_id] = self.get_metrics_summary(plugin_id)
|
||||||
return summaries
|
return summaries
|
||||||
|
|
||||||
|
def _persist_metrics(self, plugin_id: str, metrics: ResourceMetrics,
|
||||||
|
force: bool = False) -> None:
|
||||||
|
"""Write a plugin's metrics to the cache, at most once per interval.
|
||||||
|
|
||||||
|
Caller must hold ``self._lock``.
|
||||||
|
"""
|
||||||
|
# Monotonic, not wall clock: these devices have no RTC, so the clock
|
||||||
|
# jumps by however far off boot-time was the moment NTP first syncs.
|
||||||
|
# A forward jump would allow an early write, a backward one would
|
||||||
|
# stall the snapshot well past the interval.
|
||||||
|
#
|
||||||
|
# The sentinel for "never written" is None, not 0.0. monotonic() is
|
||||||
|
# time since boot on Linux, and systemd starts this service *at* boot,
|
||||||
|
# so `now - 0.0 < 30` was true for the first half-minute of every
|
||||||
|
# single run -- the throttle swallowed the very first snapshot, which
|
||||||
|
# is the one that matters most after a restart.
|
||||||
|
now = time.monotonic()
|
||||||
|
last_written = self._metrics_persisted_at.get(plugin_id)
|
||||||
|
if (not force and last_written is not None
|
||||||
|
and now - last_written < _METRICS_PERSIST_INTERVAL):
|
||||||
|
return
|
||||||
|
cache_key = self._get_metrics_key(plugin_id)
|
||||||
|
self.cache_manager.set(cache_key, {
|
||||||
|
'memory_mb': metrics.memory_mb,
|
||||||
|
'cpu_percent': metrics.cpu_percent,
|
||||||
|
'execution_time': metrics.execution_time,
|
||||||
|
'call_count': metrics.call_count,
|
||||||
|
'total_execution_time': metrics.total_execution_time,
|
||||||
|
'max_execution_time': metrics.max_execution_time,
|
||||||
|
'min_execution_time': (metrics.min_execution_time
|
||||||
|
if metrics.min_execution_time != float('inf')
|
||||||
|
else 0.0),
|
||||||
|
'last_update_time': metrics.last_update_time,
|
||||||
|
})
|
||||||
|
# Only after the write lands. Marking it first would mean a failed
|
||||||
|
# set() bought the next interval's silence without leaving a snapshot.
|
||||||
|
self._metrics_persisted_at[plugin_id] = now
|
||||||
|
|
||||||
def reset_metrics(self, plugin_id: str) -> None:
|
def reset_metrics(self, plugin_id: str) -> None:
|
||||||
"""Reset metrics for a plugin."""
|
"""Reset metrics for a plugin."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -370,4 +476,7 @@ class PluginResourceMonitor:
|
|||||||
self._metrics[plugin_id] = ResourceMetrics()
|
self._metrics[plugin_id] = ResourceMetrics()
|
||||||
cache_key = self._get_metrics_key(plugin_id)
|
cache_key = self._get_metrics_key(plugin_id)
|
||||||
self.cache_manager.delete(cache_key)
|
self.cache_manager.delete(cache_key)
|
||||||
|
# Let the next call persist immediately rather than leaving the
|
||||||
|
# deleted key absent for the rest of the interval.
|
||||||
|
self._metrics_persisted_at.pop(plugin_id, None)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Manages saved GitHub repository URLs for easy plugin discovery and installation.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
@@ -43,20 +44,45 @@ class SavedRepositoriesManager:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def _save_repositories(self) -> bool:
|
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:
|
try:
|
||||||
# Ensure directory exists
|
# Ensure directory exists
|
||||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
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)
|
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}")
|
self.logger.info(f"Saved {len(self.repositories)} repositories to {self.config_path}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error saving repositories: {e}")
|
self.logger.error(f"Error saving repositories: {e}")
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
return False
|
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]]:
|
def get_all(self) -> List[Dict[str, str]]:
|
||||||
"""Get all saved repositories."""
|
"""Get all saved repositories."""
|
||||||
return self.repositories.copy()
|
return self.repositories.copy()
|
||||||
@@ -72,15 +98,14 @@ class SavedRepositoriesManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if added successfully
|
True if added successfully
|
||||||
"""
|
"""
|
||||||
# Clean URL
|
repo_url = self._clean_url(repo_url)
|
||||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
|
||||||
|
|
||||||
# Check if already exists
|
# Check if already exists
|
||||||
for repo in self.repositories:
|
for repo in self.repositories:
|
||||||
if repo.get('url') == repo_url:
|
if repo.get('url') == repo_url:
|
||||||
self.logger.warning(f"Repository already exists: {repo_url}")
|
self.logger.warning(f"Repository already exists: {repo_url}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Extract name from URL if not provided
|
# Extract name from URL if not provided
|
||||||
if not name:
|
if not name:
|
||||||
parts = repo_url.split('/')
|
parts = repo_url.split('/')
|
||||||
@@ -88,15 +113,20 @@ class SavedRepositoriesManager:
|
|||||||
name = parts[-1]
|
name = parts[-1]
|
||||||
else:
|
else:
|
||||||
name = repo_url
|
name = repo_url
|
||||||
|
|
||||||
# Add repository
|
# Add repository
|
||||||
self.repositories.append({
|
self.repositories.append({
|
||||||
'url': repo_url,
|
'url': repo_url,
|
||||||
'name': name,
|
'name': name,
|
||||||
'type': 'registry' if 'plugins.json' in repo_url or 'ledmatrix-plugins' in repo_url.lower() else 'single'
|
'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:
|
def remove(self, repo_url: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -108,21 +138,25 @@ class SavedRepositoriesManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if removed successfully
|
True if removed successfully
|
||||||
"""
|
"""
|
||||||
# Clean URL
|
repo_url = self._clean_url(repo_url)
|
||||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
|
||||||
|
previous = self.repositories
|
||||||
original_count = len(self.repositories)
|
remaining = [r for r in previous if r.get('url') != repo_url]
|
||||||
self.repositories = [r for r in self.repositories if r.get('url') != repo_url]
|
|
||||||
|
if len(remaining) < len(previous):
|
||||||
if len(self.repositories) < original_count:
|
self.repositories = remaining
|
||||||
return self._save_repositories()
|
if not self._save_repositories():
|
||||||
|
# Failed save: restore so memory matches disk.
|
||||||
|
self.repositories = previous
|
||||||
|
return False
|
||||||
|
return True
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Repository not found: {repo_url}")
|
self.logger.warning(f"Repository not found: {repo_url}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def has(self, repo_url: str) -> bool:
|
def has(self, repo_url: str) -> bool:
|
||||||
"""Check if a repository is already saved."""
|
"""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)
|
return any(r.get('url') == repo_url for r in self.repositories)
|
||||||
|
|
||||||
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
||||||
|
|||||||
@@ -26,7 +26,25 @@ class SchemaManager:
|
|||||||
- Cache invalidation on plugin changes
|
- Cache invalidation on plugin changes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, plugins_dir: Optional[Path] = None, project_root: Optional[Path] = None, logger: Optional[logging.Logger] = None):
|
# Plugin config keys that mean "where this device is". A plugin declaring
|
||||||
|
# any of these in its schema gets the device-wide ``location`` block from
|
||||||
|
# config.json as the *default* for that field, instead of whatever city the
|
||||||
|
# plugin author happened to ship. A value the user set on the plugin itself
|
||||||
|
# always wins -- this only ever replaces the schema default, so an explicit
|
||||||
|
# per-plugin location is still honoured.
|
||||||
|
#
|
||||||
|
# Only these fully-namespaced keys are substituted. A bare ``state`` or
|
||||||
|
# ``city`` key is deliberately left alone: plugins use those for unrelated
|
||||||
|
# things (ledmatrix-elections' ``state`` is a two-letter code, not a place
|
||||||
|
# name), and silently rewriting them would break those plugins.
|
||||||
|
DEVICE_LOCATION_KEYS: Dict[str, str] = {
|
||||||
|
'location_city': 'city',
|
||||||
|
'location_state': 'state',
|
||||||
|
'location_country': 'country',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, plugins_dir: Optional[Path] = None, project_root: Optional[Path] = None,
|
||||||
|
logger: Optional[logging.Logger] = None, config_manager: Optional[Any] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the Schema Manager.
|
Initialize the Schema Manager.
|
||||||
|
|
||||||
@@ -34,10 +52,14 @@ class SchemaManager:
|
|||||||
plugins_dir: Base plugins directory path
|
plugins_dir: Base plugins directory path
|
||||||
project_root: Project root directory path
|
project_root: Project root directory path
|
||||||
logger: Optional logger instance
|
logger: Optional logger instance
|
||||||
|
config_manager: Optional config manager, used to resolve the
|
||||||
|
device-wide ``location`` that seeds plugin location defaults.
|
||||||
|
Omitting it simply leaves schema defaults untouched.
|
||||||
"""
|
"""
|
||||||
self.logger = logger or logging.getLogger(__name__)
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
self.plugins_dir = plugins_dir
|
self.plugins_dir = plugins_dir
|
||||||
self.project_root = project_root or Path.cwd()
|
self.project_root = project_root or Path.cwd()
|
||||||
|
self.config_manager = config_manager
|
||||||
|
|
||||||
# Schema cache: plugin_id -> schema dict
|
# Schema cache: plugin_id -> schema dict
|
||||||
self._schema_cache: Dict[str, Dict[str, Any]] = {}
|
self._schema_cache: Dict[str, Dict[str, Any]] = {}
|
||||||
@@ -212,10 +234,70 @@ class SchemaManager:
|
|||||||
|
|
||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
|
def get_device_location(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Return the device-wide ``location`` block from config.json, or None.
|
||||||
|
|
||||||
|
This is the City/State/Country the user sets once under General
|
||||||
|
settings. Returns None when there is no config manager wired, the
|
||||||
|
config can't be read, or no location has been configured.
|
||||||
|
"""
|
||||||
|
if self.config_manager is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
config = self.config_manager.load_config()
|
||||||
|
except Exception as e:
|
||||||
|
# A config that can't be read must never stop defaults being
|
||||||
|
# generated -- the plugin's own schema defaults still apply.
|
||||||
|
self.logger.debug(f"Could not read device location from config: {e}")
|
||||||
|
return None
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return None
|
||||||
|
location = config.get('location')
|
||||||
|
return location if isinstance(location, dict) else None
|
||||||
|
|
||||||
|
def apply_device_location(self, defaults: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Replace location-shaped schema defaults with the device's own location.
|
||||||
|
|
||||||
|
Without this, a plugin that ships ``"location_city": "Dallas"`` as its
|
||||||
|
schema default silently reports Dallas weather (and centres its radar
|
||||||
|
there) for every user who never opened that plugin's config form --
|
||||||
|
even though they set their real city under General settings. The
|
||||||
|
substituted value is still only a *default*: ``merge_with_defaults``
|
||||||
|
lets any per-plugin value the user saved win over it.
|
||||||
|
|
||||||
|
Mutates and returns ``defaults`` for convenience.
|
||||||
|
"""
|
||||||
|
if not defaults:
|
||||||
|
return defaults
|
||||||
|
if not any(key in defaults for key in self.DEVICE_LOCATION_KEYS):
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
location = self.get_device_location()
|
||||||
|
if not location:
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
for key, field in self.DEVICE_LOCATION_KEYS.items():
|
||||||
|
if key not in defaults:
|
||||||
|
continue
|
||||||
|
value = location.get(field)
|
||||||
|
# Only a non-empty string is a real answer; a blank or missing
|
||||||
|
# field means "not configured", which leaves the schema default.
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
defaults[key] = value.strip()
|
||||||
|
|
||||||
|
return defaults
|
||||||
|
|
||||||
def generate_default_config(self, plugin_id: str, use_cache: bool = True) -> Dict[str, Any]:
|
def generate_default_config(self, plugin_id: str, use_cache: bool = True) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Generate default configuration for a plugin from its schema.
|
Generate default configuration for a plugin from its schema.
|
||||||
|
|
||||||
|
Location fields (see ``DEVICE_LOCATION_KEYS``) default to the device's
|
||||||
|
configured location rather than the plugin author's. That substitution
|
||||||
|
is applied on the way out rather than being cached, so changing the
|
||||||
|
device location takes effect without invalidating the defaults cache.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
use_cache: If True, return cached defaults if available
|
use_cache: If True, return cached defaults if available
|
||||||
@@ -225,7 +307,7 @@ class SchemaManager:
|
|||||||
"""
|
"""
|
||||||
# Check cache first
|
# Check cache first
|
||||||
if use_cache and plugin_id in self._defaults_cache:
|
if use_cache and plugin_id in self._defaults_cache:
|
||||||
return self._defaults_cache[plugin_id].copy()
|
return self.apply_device_location(self._defaults_cache[plugin_id].copy())
|
||||||
|
|
||||||
schema = self.load_schema(plugin_id, use_cache=use_cache)
|
schema = self.load_schema(plugin_id, use_cache=use_cache)
|
||||||
if not schema:
|
if not schema:
|
||||||
@@ -249,10 +331,11 @@ class SchemaManager:
|
|||||||
if 'live_priority' not in defaults:
|
if 'live_priority' not in defaults:
|
||||||
defaults['live_priority'] = schema.get('properties', {}).get('live_priority', {}).get('default', False)
|
defaults['live_priority'] = schema.get('properties', {}).get('live_priority', {}).get('default', False)
|
||||||
|
|
||||||
# Cache the defaults
|
# Cache the defaults *before* the device location is layered on, so a
|
||||||
|
# later change to the device location is picked up by the next call.
|
||||||
self._defaults_cache[plugin_id] = defaults.copy()
|
self._defaults_cache[plugin_id] = defaults.copy()
|
||||||
|
|
||||||
return defaults
|
return self.apply_device_location(defaults)
|
||||||
|
|
||||||
def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[str, Any],
|
def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[str, Any],
|
||||||
plugin_id: Optional[str] = None) -> Tuple[bool, List[str]]:
|
plugin_id: Optional[str] = None) -> Tuple[bool, List[str]]:
|
||||||
|
|||||||
@@ -1143,7 +1143,7 @@ class PluginStoreManager:
|
|||||||
"""
|
"""
|
||||||
registry = self.fetch_registry()
|
registry = self.fetch_registry()
|
||||||
plugins = registry.get('plugins', []) or []
|
plugins = registry.get('plugins', []) or []
|
||||||
plugin_info = next((p for p in plugins if p['id'] == plugin_id), None)
|
plugin_info = self._match_registry_entry(plugins, plugin_id)
|
||||||
|
|
||||||
if not plugin_info:
|
if not plugin_info:
|
||||||
return None
|
return None
|
||||||
@@ -1183,6 +1183,37 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
return plugin_info
|
return plugin_info
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match_registry_entry(plugins: List[Dict], plugin_id: str) -> Optional[Dict]:
|
||||||
|
"""Find a registry entry by its id, or by the directory it installs to.
|
||||||
|
|
||||||
|
Four shipped plugins have a registry ``id`` that differs from the ``id``
|
||||||
|
in their own manifest: ``weather`` installs to ``plugins/ledmatrix-weather``,
|
||||||
|
and likewise stocks, music and leaderboard. Installation already prefers
|
||||||
|
the manifest id for the directory name, so on disk, in ``config.json``
|
||||||
|
and in a backup manifest those plugins are called ``ledmatrix-weather``.
|
||||||
|
|
||||||
|
Only the registry calls them ``weather``, and nothing resolved that in
|
||||||
|
reverse: restoring a backup asked the store for ``ledmatrix-weather``
|
||||||
|
and got "Plugin not found in registry", silently dropping four enabled
|
||||||
|
plugins from a restored device.
|
||||||
|
|
||||||
|
Matching ``plugin_path`` fixes it without renaming any published id,
|
||||||
|
which would orphan ``plugin_state.json`` entries keyed on the old ones.
|
||||||
|
Exact id always wins, so an entry whose *path* happens to collide with
|
||||||
|
another entry's id cannot shadow it.
|
||||||
|
"""
|
||||||
|
if not plugin_id:
|
||||||
|
return None
|
||||||
|
exact = next((p for p in plugins if p.get('id') == plugin_id), None)
|
||||||
|
if exact is not None:
|
||||||
|
return exact
|
||||||
|
for entry in plugins:
|
||||||
|
path = (entry.get('plugin_path') or '').rstrip('/')
|
||||||
|
if path and path.rsplit('/', 1)[-1] == plugin_id:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Get plugin information from the registry cache only (no GitHub API calls).
|
Get plugin information from the registry cache only (no GitHub API calls).
|
||||||
@@ -1198,7 +1229,7 @@ class PluginStoreManager:
|
|||||||
"""
|
"""
|
||||||
registry = self.fetch_registry()
|
registry = self.fetch_registry()
|
||||||
plugins = registry.get('plugins', []) or []
|
plugins = registry.get('plugins', []) or []
|
||||||
return next((p for p in plugins if p.get('id') == plugin_id), None)
|
return self._match_registry_entry(plugins, plugin_id)
|
||||||
|
|
||||||
def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool:
|
def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool:
|
||||||
"""Install a plugin, keeping any existing install until the new one is
|
"""Install a plugin, keeping any existing install until the new one is
|
||||||
@@ -1436,8 +1467,11 @@ class PluginStoreManager:
|
|||||||
# already had. Allowing it costs them a plugin that raises
|
# already had. Allowing it costs them a plugin that raises
|
||||||
# ModuleNotFoundError at load and is reported only as one line
|
# ModuleNotFoundError at load and is reported only as one line
|
||||||
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
|
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
from src.plugin_system import compatibility
|
||||||
|
# On disk, not as imported: this process may predate the core
|
||||||
|
# update that made the plugin compatible. See
|
||||||
|
# compatibility.current_core_version.
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
compatible, reason = compatibility.check(manifest, core_version)
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
if not compatible:
|
if not compatible:
|
||||||
@@ -1571,6 +1605,26 @@ class PluginStoreManager:
|
|||||||
'error': f'Manifest missing required fields: {", ".join(missing_fields)}'
|
'error': f'Manifest missing required fields: {", ".join(missing_fields)}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Refuse a plugin that needs a newer core than this one, exactly as
|
||||||
|
# _install_plugin_impl does after its download. Sideloading is an
|
||||||
|
# explicit act rather than an automatic store update, but the floor
|
||||||
|
# is not advice about intent -- it is a statement that the plugin
|
||||||
|
# cannot run here, and letting it through produces the same silent
|
||||||
|
# PluginState.ERROR at load. This was the last of the three routes
|
||||||
|
# in that skipped the check.
|
||||||
|
#
|
||||||
|
# Before the move, so the `finally` below removes the temp tree and
|
||||||
|
# nothing half-installed is left behind.
|
||||||
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
|
if not compatible:
|
||||||
|
self.logger.error(
|
||||||
|
"Refusing to install %s from %s: %s",
|
||||||
|
plugin_id, repo_url, reason)
|
||||||
|
return {'success': False, 'error': reason}
|
||||||
|
|
||||||
# Validate version fields consistency (warnings only, not required)
|
# Validate version fields consistency (warnings only, not required)
|
||||||
validation_errors = self._validate_manifest_version_fields(manifest)
|
validation_errors = self._validate_manifest_version_fields(manifest)
|
||||||
if validation_errors:
|
if validation_errors:
|
||||||
@@ -2552,6 +2606,85 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _gate_pulled_commit(self, plugin_id: str, plugin_path: Path,
|
||||||
|
previous_sha: Optional[str]) -> bool:
|
||||||
|
"""Apply the compatibility gate to a commit that arrived via git pull.
|
||||||
|
|
||||||
|
Every other route into an installed plugin goes through
|
||||||
|
``install_plugin``, which gates in ``_install_plugin_impl``. This one
|
||||||
|
did not: a ``git pull`` could deliver a manifest flooring above this
|
||||||
|
core and nothing would notice until the plugin failed to load, which
|
||||||
|
surfaces as one line in the journal and a scoreboard that silently
|
||||||
|
stopped appearing.
|
||||||
|
|
||||||
|
Checked after the pull rather than before it, for the same reason
|
||||||
|
``_install_plugin_impl`` checks after the download: the registry
|
||||||
|
carries no compatibility field, so the incoming floor is only knowable
|
||||||
|
once the new commit is on disk.
|
||||||
|
|
||||||
|
Undone with ``git reset --hard`` rather than by removing the directory.
|
||||||
|
This is a live checkout, the previous commit is still in the object
|
||||||
|
store, and the reset leaves the user on the exact version they were
|
||||||
|
already running -- the same promise ``_reinstall_with_rollback`` makes,
|
||||||
|
reached by the means this path actually has. It is also the gentler
|
||||||
|
option: no window in which the plugin directory does not exist, and no
|
||||||
|
``.standalone-backup-`` debris if the process dies mid-way.
|
||||||
|
|
||||||
|
A manifest that cannot be read is not evidence of incompatibility, so
|
||||||
|
it allows. ``compatibility.check`` refuses only on evidence for the
|
||||||
|
same reason: a wrong refusal breaks a working install, while a wrong
|
||||||
|
allowance degrades to exactly the behaviour this path had before the
|
||||||
|
gate existed.
|
||||||
|
"""
|
||||||
|
manifest_path = plugin_path / "manifest.json"
|
||||||
|
try:
|
||||||
|
with open(manifest_path, 'r', encoding='utf-8') as mf:
|
||||||
|
manifest = json.load(mf)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
self.logger.warning(
|
||||||
|
"Could not read %s after updating %s (%s); allowing the "
|
||||||
|
"update, as an unreadable manifest declares no floor",
|
||||||
|
manifest_path, plugin_id, e)
|
||||||
|
return True
|
||||||
|
|
||||||
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
|
if compatible:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.logger.error("Refusing the update to %s: %s", plugin_id, reason)
|
||||||
|
|
||||||
|
if not previous_sha:
|
||||||
|
self.logger.error(
|
||||||
|
"Cannot roll %s back: the commit it was on before the pull is "
|
||||||
|
"unknown. It is now on a version this core cannot run — "
|
||||||
|
"reinstall it from the plugin store.", plugin_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Safe by construction: update_plugin returns before pulling unless the
|
||||||
|
# tree was clean or successfully stashed, so there are no uncommitted
|
||||||
|
# tracked edits for --hard to discard. The stash is not popped on the
|
||||||
|
# success path either, so the reset leaves the working tree exactly
|
||||||
|
# where a successful pull would have. Say "commit", not "changes".
|
||||||
|
reset = subprocess.run(
|
||||||
|
['git', '-C', str(plugin_path), 'reset', '--hard', previous_sha],
|
||||||
|
capture_output=True, text=True, timeout=60, check=False)
|
||||||
|
if reset.returncode != 0:
|
||||||
|
self.logger.error(
|
||||||
|
"CRITICAL: could not roll %s back to commit %s: %s. It is left "
|
||||||
|
"on a version this core cannot run; "
|
||||||
|
"`git -C %s reset --hard %s` restores it.",
|
||||||
|
plugin_id, previous_sha[:7],
|
||||||
|
(reset.stderr or reset.stdout or '').strip(),
|
||||||
|
plugin_path, previous_sha)
|
||||||
|
else:
|
||||||
|
self.logger.info(
|
||||||
|
"Rolled %s back to commit %s; it stays on the version it was "
|
||||||
|
"already running.", plugin_id, previous_sha[:7])
|
||||||
|
return False
|
||||||
|
|
||||||
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
||||||
"""Replace an installed plugin with a fresh install, atomically.
|
"""Replace an installed plugin with a fresh install, atomically.
|
||||||
|
|
||||||
@@ -2829,6 +2962,8 @@ class PluginStoreManager:
|
|||||||
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
||||||
|
|
||||||
stash_info = ""
|
stash_info = ""
|
||||||
|
# Whether the pull can be undone without destroying work.
|
||||||
|
tree_is_recoverable = not has_changes
|
||||||
if has_changes:
|
if has_changes:
|
||||||
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
||||||
try:
|
try:
|
||||||
@@ -2842,12 +2977,37 @@ class PluginStoreManager:
|
|||||||
)
|
)
|
||||||
if stash_result.returncode == 0:
|
if stash_result.returncode == 0:
|
||||||
stash_info = " (local changes were stashed)"
|
stash_info = " (local changes were stashed)"
|
||||||
|
tree_is_recoverable = True
|
||||||
self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}")
|
self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}")
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}")
|
self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}")
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self.logger.warning(f"Stash operation timed out for {plugin_id}, proceeding with pull")
|
self.logger.warning(f"Stash operation timed out for {plugin_id}, proceeding with pull")
|
||||||
|
|
||||||
|
# Do not pull what cannot be un-pulled.
|
||||||
|
#
|
||||||
|
# The compatibility gate below can refuse the commit this
|
||||||
|
# pull brings down, and its only way back is `git reset
|
||||||
|
# --hard`, which discards uncommitted tracked edits. Those
|
||||||
|
# edits are exactly what the stash above exists to protect,
|
||||||
|
# so a stash that failed or timed out leaves the rollback
|
||||||
|
# unable to run without destroying them.
|
||||||
|
#
|
||||||
|
# A pull does not necessarily refuse on a dirty tree -- git
|
||||||
|
# merges happily as long as the incoming commit touches
|
||||||
|
# different files -- so without this the update would
|
||||||
|
# succeed, the gate would refuse, and the reset would take
|
||||||
|
# the user's work with it. Refusing here costs an update in
|
||||||
|
# a case that already went wrong; the alternative costs
|
||||||
|
# data.
|
||||||
|
if not tree_is_recoverable:
|
||||||
|
self.logger.error(
|
||||||
|
"Refusing to update %s: it has local changes that could "
|
||||||
|
"not be stashed, and an incompatible update could then "
|
||||||
|
"only be rolled back by discarding them. Commit or stash "
|
||||||
|
"them by hand, then update.", plugin_id)
|
||||||
|
return False
|
||||||
|
|
||||||
# Pull from the determined remote branch
|
# Pull from the determined remote branch
|
||||||
self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...")
|
self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...")
|
||||||
pull_result = subprocess.run(
|
pull_result = subprocess.run(
|
||||||
@@ -2870,6 +3030,14 @@ class PluginStoreManager:
|
|||||||
elif updated_sha:
|
elif updated_sha:
|
||||||
self.logger.info(f"Plugin {plugin_id} updated to commit {updated_sha[:7]}{stash_info}")
|
self.logger.info(f"Plugin {plugin_id} updated to commit {updated_sha[:7]}{stash_info}")
|
||||||
|
|
||||||
|
# The install gate, at the only point on this path where
|
||||||
|
# it can be answered. Every other route in goes through
|
||||||
|
# install_plugin, which gates in _install_plugin_impl; this
|
||||||
|
# one did not, so a pull could deliver a manifest flooring
|
||||||
|
# above this core and nothing would notice.
|
||||||
|
if not self._gate_pulled_commit(plugin_id, plugin_path, local_sha):
|
||||||
|
return False
|
||||||
|
|
||||||
self._install_dependencies(plugin_path)
|
self._install_dependencies(plugin_path)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -2969,7 +3137,10 @@ class PluginStoreManager:
|
|||||||
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
|
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
|
||||||
|
|
||||||
# Compare local manifest version against registry latest_version
|
# 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:
|
try:
|
||||||
local_manifest_path = plugin_path / "manifest.json"
|
local_manifest_path = plugin_path / "manifest.json"
|
||||||
if local_manifest_path.exists():
|
if local_manifest_path.exists():
|
||||||
@@ -2977,8 +3148,16 @@ class PluginStoreManager:
|
|||||||
local_manifest = json.load(f)
|
local_manifest = json.load(f)
|
||||||
local_version = local_manifest.get('version', '')
|
local_version = local_manifest.get('version', '')
|
||||||
remote_version = plugin_info_remote.get('latest_version', '')
|
remote_version = plugin_info_remote.get('latest_version', '')
|
||||||
if local_version and remote_version and local_version == remote_version:
|
from src.plugin_system.compatibility import is_update_available
|
||||||
self.logger.info(f"Plugin {plugin_id} already at latest version {local_version}")
|
# 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
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug(f"Could not compare versions for {plugin_id}: {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_ok: Optional[bool] = None
|
||||||
golden_diff_pixels: int = 0
|
golden_diff_pixels: int = 0
|
||||||
golden_max_delta: 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 / scale-up check (populated only for sizes >= 2x the design size)
|
||||||
fill_checked: bool = False
|
fill_checked: bool = False
|
||||||
fill_ok: Optional[bool] = None # False only in strict mode
|
fill_ok: Optional[bool] = None # False only in strict mode
|
||||||
@@ -92,6 +97,8 @@ class RenderResult:
|
|||||||
return False
|
return False
|
||||||
if self.fill_ok is False:
|
if self.fill_ok is False:
|
||||||
return False
|
return False
|
||||||
|
if self.empty_ok is False:
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -132,21 +139,25 @@ def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
|||||||
return plugin_instance
|
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
|
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||||
drive the plugin's internal mode state machine (first display() call renders
|
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)
|
sig = inspect.signature(plugin_instance.display)
|
||||||
if "display_mode" in sig.parameters:
|
if "display_mode" in sig.parameters:
|
||||||
plugin_instance.display(force_clear=True, display_mode=mode)
|
return plugin_instance.display(force_clear=True, display_mode=mode)
|
||||||
return
|
|
||||||
|
|
||||||
modes = getattr(plugin_instance, "modes", None)
|
modes = getattr(plugin_instance, "modes", None)
|
||||||
if modes and mode in modes:
|
if modes and mode in modes:
|
||||||
plugin_instance.current_mode_index = list(modes).index(mode)
|
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||||
if hasattr(plugin_instance, "current_display_mode"):
|
if hasattr(plugin_instance, "current_display_mode"):
|
||||||
plugin_instance.current_display_mode = None
|
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]):
|
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",
|
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||||
plugin_id, mode, e)
|
plugin_id, mode, e)
|
||||||
if result.error is None:
|
if result.error is None:
|
||||||
_render_mode(inst, mode)
|
result.display_returned = _render_mode(inst, mode)
|
||||||
result.image = dm.get_image()
|
result.image = dm.get_image()
|
||||||
result.overflow = dm.check_overflow()
|
result.overflow = dm.check_overflow()
|
||||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||||
@@ -341,6 +352,44 @@ def fill_metrics(image: Image.Image) -> Tuple[float, float, float]:
|
|||||||
return (extent_x, extent_y, ink)
|
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],
|
def check_scale_up(results: List[RenderResult],
|
||||||
design_size: Tuple[int, int] = (128, 32),
|
design_size: Tuple[int, int] = (128, 32),
|
||||||
min_extent: float = _MIN_FILL_EXTENT,
|
min_extent: float = _MIN_FILL_EXTENT,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class VisualTestDisplayManager:
|
|||||||
# Canvas
|
# Canvas
|
||||||
self.image = Image.new('RGB', (width, height), (0, 0, 0))
|
self.image = Image.new('RGB', (width, height), (0, 0, 0))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||||
|
|
||||||
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
||||||
self.matrix = _MatrixProxy(width, height)
|
self.matrix = _MatrixProxy(width, height)
|
||||||
@@ -184,6 +185,7 @@ class VisualTestDisplayManager:
|
|||||||
self.clear_called = True
|
self.clear_called = True
|
||||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||||
|
|
||||||
def update_display(self):
|
def update_display(self):
|
||||||
"""No-op for hardware; marks that display was updated."""
|
"""No-op for hardware; marks that display was updated."""
|
||||||
@@ -211,6 +213,7 @@ class VisualTestDisplayManager:
|
|||||||
self.matrix = _MatrixProxy(target_w, target_h)
|
self.matrix = _MatrixProxy(target_w, target_h)
|
||||||
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
|
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
self._width, self._height = prev_w, prev_h
|
self._width, self._height = prev_w, prev_h
|
||||||
@@ -569,6 +572,7 @@ class VisualTestDisplayManager:
|
|||||||
self.draw_calls = []
|
self.draw_calls = []
|
||||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
'is_scrolling': False,
|
'is_scrolling': False,
|
||||||
'last_scroll_activity': 0,
|
'last_scroll_activity': 0,
|
||||||
@@ -582,3 +586,4 @@ class VisualTestDisplayManager:
|
|||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||||
|
|||||||
@@ -15,16 +15,23 @@ from src.logging_config import get_logger
|
|||||||
class StartupValidator:
|
class StartupValidator:
|
||||||
"""Validates system state on startup."""
|
"""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.
|
Initialize the startup validator.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_manager: ConfigManager instance
|
config_manager: ConfigManager instance
|
||||||
plugin_manager: Optional PluginManager 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.config_manager = config_manager
|
||||||
self.plugin_manager = plugin_manager
|
self.plugin_manager = plugin_manager
|
||||||
|
self.cache_manager = cache_manager
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
self.errors: List[str] = []
|
self.errors: List[str] = []
|
||||||
self.warnings: List[str] = []
|
self.warnings: List[str] = []
|
||||||
@@ -37,7 +44,12 @@ class StartupValidator:
|
|||||||
Tuple of (is_valid, errors, warnings)
|
Tuple of (is_valid, errors, warnings)
|
||||||
"""
|
"""
|
||||||
self.logger.info("Starting startup validation...")
|
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
|
# Validate configuration
|
||||||
self._validate_config()
|
self._validate_config()
|
||||||
|
|
||||||
@@ -50,6 +62,9 @@ class StartupValidator:
|
|||||||
# Validate plugins if plugin manager is available
|
# Validate plugins if plugin manager is available
|
||||||
if self.plugin_manager:
|
if self.plugin_manager:
|
||||||
self._validate_plugins()
|
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
|
is_valid = len(self.errors) == 0
|
||||||
|
|
||||||
@@ -62,6 +77,80 @@ class StartupValidator:
|
|||||||
|
|
||||||
return (is_valid, self.errors.copy(), self.warnings.copy())
|
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:
|
def _validate_config(self) -> None:
|
||||||
"""Validate configuration files."""
|
"""Validate configuration files."""
|
||||||
try:
|
try:
|
||||||
@@ -86,9 +175,21 @@ class StartupValidator:
|
|||||||
def _validate_cache_directory(self) -> None:
|
def _validate_cache_directory(self) -> None:
|
||||||
"""Validate cache directory permissions."""
|
"""Validate cache directory permissions."""
|
||||||
try:
|
try:
|
||||||
from src.cache_manager import CacheManager
|
cache_manager = self.cache_manager
|
||||||
cache_manager = CacheManager()
|
if cache_manager is None:
|
||||||
cache_dir = cache_manager.get_cache_dir()
|
# 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:
|
if not cache_dir:
|
||||||
self.warnings.append("Cache directory not available - caching will be disabled")
|
self.warnings.append("Cache directory not available - caching will be disabled")
|
||||||
|
|||||||
@@ -104,15 +104,53 @@ class VegasModeConfig:
|
|||||||
overflow_mode: str = "rotate"
|
overflow_mode: str = "rotate"
|
||||||
|
|
||||||
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
# 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
|
# 0 (the default) disables the cap, so every plugin contributes all of its
|
||||||
# two minutes. Overflow is deferred to later cycles rather than discarded.
|
# content and is always entered at its beginning.
|
||||||
# 0 disables the cap.
|
#
|
||||||
max_plugin_width_ratio: float = 3.0
|
# 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 management
|
||||||
plugin_order: List[str] = field(default_factory=list)
|
plugin_order: List[str] = field(default_factory=list)
|
||||||
excluded_plugins: Set[str] = field(default_factory=set)
|
excluded_plugins: Set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
# --- 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
|
# Performance settings
|
||||||
target_fps: int = 125 # Target frame rate
|
target_fps: int = 125 # Target frame rate
|
||||||
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
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)),
|
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
|
||||||
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
||||||
max_plugin_width_ratio=float(
|
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')),
|
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
||||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||||
|
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)),
|
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||||
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
||||||
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
||||||
@@ -192,6 +236,9 @@ class VegasModeConfig:
|
|||||||
'lead_in_width': self.lead_in_width,
|
'lead_in_width': self.lead_in_width,
|
||||||
'plugins_per_cycle': self.plugins_per_cycle,
|
'plugins_per_cycle': self.plugins_per_cycle,
|
||||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
'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,
|
'overflow_mode': self.overflow_mode,
|
||||||
'plugin_order': self.plugin_order,
|
'plugin_order': self.plugin_order,
|
||||||
'excluded_plugins': list(self.excluded_plugins),
|
'excluded_plugins': list(self.excluded_plugins),
|
||||||
@@ -359,6 +406,15 @@ class VegasModeConfig:
|
|||||||
|
|
||||||
if 'enabled' in vegas_config:
|
if 'enabled' in vegas_config:
|
||||||
self.enabled = vegas_config['enabled']
|
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:
|
if 'scroll_speed' in vegas_config:
|
||||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||||
if 'separator_width' in vegas_config:
|
if 'separator_width' in vegas_config:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Supports three display modes per plugin:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
|
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
|
||||||
@@ -30,6 +31,33 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
#: Degradation threshold, as a fraction of target_fps. A marquee jitters a
|
||||||
|
#: little all the time, so "anything under target" would report constantly and
|
||||||
|
#: mean nothing; 90% of target is the point where a shortfall is real. At a
|
||||||
|
#: 60fps target that is 54fps -- 55fps is a normal wobble and stays at DEBUG,
|
||||||
|
#: which is deliberate, not an off-by-one.
|
||||||
|
_FPS_HEALTHY_FRACTION = 0.9
|
||||||
|
|
||||||
|
#: A healthy marquee still reports this often, so silence means stopped
|
||||||
|
#: rather than fine.
|
||||||
|
_FPS_HEARTBEAT_INTERVAL = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
class VegasModeCoordinator:
|
||||||
"""
|
"""
|
||||||
Orchestrates Vegas scroll mode operation.
|
Orchestrates Vegas scroll mode operation.
|
||||||
@@ -80,6 +108,11 @@ class VegasModeCoordinator:
|
|||||||
self._is_active = False
|
self._is_active = False
|
||||||
self._is_paused = False
|
self._is_paused = False
|
||||||
self._should_stop = False
|
self._should_stop = False
|
||||||
|
# Frame-rate health, tracked across run_iteration() calls so the
|
||||||
|
# heartbeat is one-per-interval rather than one-per-cycle, and so a
|
||||||
|
# recovery spanning two cycles is still reported. Reset on start().
|
||||||
|
self._fps_last_health_log = 0.0
|
||||||
|
self._fps_was_degraded = False
|
||||||
self._state_lock = threading.Lock()
|
self._state_lock = threading.Lock()
|
||||||
|
|
||||||
# Live priority tracking
|
# Live priority tracking
|
||||||
@@ -232,6 +265,11 @@ class VegasModeCoordinator:
|
|||||||
self._is_active = True
|
self._is_active = True
|
||||||
self._should_stop = False
|
self._should_stop = False
|
||||||
self._start_time = time.time()
|
self._start_time = time.time()
|
||||||
|
# A fresh run starts with a clean health slate: no stale
|
||||||
|
# "was degraded" from the previous run, and a heartbeat that is
|
||||||
|
# due immediately so the first sample confirms the marquee is up.
|
||||||
|
self._fps_last_health_log = 0.0
|
||||||
|
self._fps_was_degraded = False
|
||||||
|
|
||||||
# Line up the next group immediately, so the first extension is already
|
# Line up the next group immediately, so the first extension is already
|
||||||
# warm rather than stalling the scroll to fetch it.
|
# warm rather than stalling the scroll to fetch it.
|
||||||
@@ -379,14 +417,36 @@ class VegasModeCoordinator:
|
|||||||
duration = self.render_pipeline.get_dynamic_duration()
|
duration = self.render_pipeline.get_dynamic_duration()
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
frame_count = 0
|
frame_count = 0
|
||||||
fps_log_interval = 5.0 # Log FPS every 5 seconds
|
fps_log_interval = 5.0 # Sample FPS every 5 seconds
|
||||||
last_fps_log_time = start_time
|
# Health state lives on the coordinator, not here: run_iteration() is
|
||||||
|
# called once per cycle, so locals reset every few seconds. That made
|
||||||
|
# `last_fps_health_log = 0.0` fire the "heartbeat" on the first sample
|
||||||
|
# of every iteration rather than once per interval, and a recovery
|
||||||
|
# that crossed an iteration boundary was never reported at all --
|
||||||
|
# was_degraded had already gone back to False.
|
||||||
|
# Monotonic, and deliberately not start_time: start_time is wall
|
||||||
|
# clock and is used below to report the iteration's duration. Mixing
|
||||||
|
# the two here would make every delta hugely negative and silence the
|
||||||
|
# frame-rate reporting altogether.
|
||||||
|
last_fps_log_time = time.monotonic()
|
||||||
fps_frame_count = 0
|
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)
|
logger.info("Starting Vegas iteration for %.1fs", duration)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
frame_started = time.time()
|
# Monotonic, like the FPS window below. These devices have no RTC,
|
||||||
|
# so the wall clock jumps by however wrong boot time was the moment
|
||||||
|
# NTP first syncs. A backward jump makes frame_elapsed negative,
|
||||||
|
# and `frame_interval - frame_elapsed` then sleeps for longer than
|
||||||
|
# the whole budget -- the render loop stalls for the size of the
|
||||||
|
# correction. A forward jump inflates p99 and worst-frame instead.
|
||||||
|
frame_started = time.monotonic()
|
||||||
|
|
||||||
# Check for STATIC mode plugin that should pause scroll
|
# Check for STATIC mode plugin that should pause scroll
|
||||||
static_plugin = self._check_static_plugin_trigger()
|
static_plugin = self._check_static_plugin_trigger()
|
||||||
@@ -414,23 +474,58 @@ class VegasModeCoordinator:
|
|||||||
# quarter of the budget spent not rendering. Subtracting the work
|
# quarter of the budget spent not rendering. Subtracting the work
|
||||||
# already done keeps the pacing target while reclaiming that time,
|
# already done keeps the pacing target while reclaiming that time,
|
||||||
# and yields the GIL either way so other threads still run.
|
# and yields the GIL either way so other threads still run.
|
||||||
frame_elapsed = time.time() - frame_started
|
frame_elapsed = time.monotonic() - frame_started
|
||||||
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
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
|
# Increment frame count and check for interrupt periodically
|
||||||
frame_count += 1
|
frame_count += 1
|
||||||
fps_frame_count += 1
|
fps_frame_count += 1
|
||||||
|
|
||||||
# Periodic FPS logging
|
# Periodic FPS logging. Reported at INFO only when the frame rate
|
||||||
current_time = time.time()
|
# is actually worth an operator's attention -- a shortfall against
|
||||||
|
# target, or the recovery from one -- with a slow heartbeat so a
|
||||||
|
# healthy marquee still shows a pulse.
|
||||||
|
#
|
||||||
|
# Measured over two hours on a running rig: 1410 samples, 98.5%
|
||||||
|
# of them within 10% of target. The 1.5% that were not included a
|
||||||
|
# reading of 8.6fps against a target of 60 -- a real stall, and
|
||||||
|
# completely invisible inside 1389 lines reading "59.6".
|
||||||
|
# Monotonic: every use of this value in the block below is a
|
||||||
|
# duration, and these devices have no RTC, so the wall clock jumps
|
||||||
|
# by however wrong boot time was the moment NTP first syncs. That
|
||||||
|
# would not only mis-fire the heartbeat, it would corrupt the
|
||||||
|
# frame rate itself, since fps is frames divided by this delta.
|
||||||
|
current_time = time.monotonic()
|
||||||
if current_time - last_fps_log_time >= fps_log_interval:
|
if current_time - last_fps_log_time >= fps_log_interval:
|
||||||
fps = fps_frame_count / (current_time - last_fps_log_time)
|
fps = fps_frame_count / (current_time - last_fps_log_time)
|
||||||
logger.info(
|
p99 = _percentile(sorted(frame_times), 0.99)
|
||||||
"Vegas FPS: %.1f (target: %d, frames: %d)",
|
target = self.vegas_config.target_fps
|
||||||
fps, self.vegas_config.target_fps, fps_frame_count
|
degraded = target > 0 and fps < target * _FPS_HEALTHY_FRACTION
|
||||||
)
|
due = (current_time - self._fps_last_health_log
|
||||||
|
>= _FPS_HEARTBEAT_INTERVAL)
|
||||||
|
if degraded or self._fps_was_degraded or due:
|
||||||
|
logger.info(
|
||||||
|
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
||||||
|
fps, target, fps_frame_count,
|
||||||
|
p99 * 1000.0, frame_worst * 1000.0
|
||||||
|
)
|
||||||
|
self._fps_last_health_log = current_time
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
||||||
|
fps, target, fps_frame_count,
|
||||||
|
p99 * 1000.0, frame_worst * 1000.0
|
||||||
|
)
|
||||||
|
self._fps_was_degraded = degraded
|
||||||
last_fps_log_time = current_time
|
last_fps_log_time = current_time
|
||||||
fps_frame_count = 0
|
fps_frame_count = 0
|
||||||
|
frame_worst = 0.0
|
||||||
|
frame_times.clear()
|
||||||
|
|
||||||
if (self._interrupt_check and
|
if (self._interrupt_check and
|
||||||
frame_count % self._interrupt_check_interval == 0):
|
frame_count % self._interrupt_check_interval == 0):
|
||||||
@@ -497,6 +592,12 @@ class VegasModeCoordinator:
|
|||||||
if not self._live_priority_check:
|
if not self._live_priority_check:
|
||||||
return False
|
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:
|
try:
|
||||||
live_mode = self._live_priority_check()
|
live_mode = self._live_priority_check()
|
||||||
if live_mode:
|
if live_mode:
|
||||||
|
|||||||
@@ -68,7 +68,22 @@ class PluginAdapter:
|
|||||||
# always the same opening items.
|
# always the same opening items.
|
||||||
self._item_offsets: dict = {}
|
self._item_offsets: dict = {}
|
||||||
|
|
||||||
logger.info(
|
# 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.debug(
|
||||||
"PluginAdapter initialized: display=%dx%d",
|
"PluginAdapter initialized: display=%dx%d",
|
||||||
self.display_width, self.display_height
|
self.display_width, self.display_height
|
||||||
)
|
)
|
||||||
@@ -94,7 +109,7 @@ class PluginAdapter:
|
|||||||
Returns:
|
Returns:
|
||||||
List of PIL Images representing plugin content, or None if no content
|
List of PIL Images representing plugin content, or None if no content
|
||||||
"""
|
"""
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Getting content (class=%s)",
|
"[%s] Getting content (class=%s)",
|
||||||
plugin_id, plugin.__class__.__name__
|
plugin_id, plugin.__class__.__name__
|
||||||
)
|
)
|
||||||
@@ -103,7 +118,7 @@ class PluginAdapter:
|
|||||||
cached = self._get_cached(plugin_id)
|
cached = self._get_cached(plugin_id)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
total_width = sum(img.width for img in cached)
|
total_width = sum(img.width for img in cached)
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Using cached content: %d images, %dpx total",
|
"[%s] Using cached content: %d images, %dpx total",
|
||||||
plugin_id, len(cached), total_width
|
plugin_id, len(cached), total_width
|
||||||
)
|
)
|
||||||
@@ -111,46 +126,46 @@ class PluginAdapter:
|
|||||||
|
|
||||||
# Try native Vegas content method first
|
# Try native Vegas content method first
|
||||||
has_native = hasattr(plugin, 'get_vegas_content')
|
has_native = hasattr(plugin, 'get_vegas_content')
|
||||||
logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native)
|
logger.debug("[%s] Has get_vegas_content: %s", plugin_id, has_native)
|
||||||
if has_native:
|
if has_native:
|
||||||
content = self._get_native_content(plugin, plugin_id, offscreen_only)
|
content = self._get_native_content(plugin, plugin_id, offscreen_only)
|
||||||
if content:
|
if content:
|
||||||
total_width = sum(img.width for img in content)
|
total_width = sum(img.width for img in content)
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Native content SUCCESS: %d images, %dpx total",
|
"[%s] Native content SUCCESS: %d images, %dpx total",
|
||||||
plugin_id, len(content), total_width
|
plugin_id, len(content), total_width
|
||||||
)
|
)
|
||||||
return self._finalize(content, plugin_id, 'native', plugin)
|
return self._finalize(content, plugin_id, 'native', plugin)
|
||||||
logger.info("[%s] Native content returned None", plugin_id)
|
logger.debug("[%s] Native content returned None", plugin_id)
|
||||||
|
|
||||||
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
|
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
|
||||||
has_scroll_helper = hasattr(plugin, 'scroll_helper')
|
has_scroll_helper = hasattr(plugin, 'scroll_helper')
|
||||||
logger.info("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
|
logger.debug("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
|
||||||
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
|
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
|
||||||
if content:
|
if content:
|
||||||
total_width = sum(img.width for img in content)
|
total_width = sum(img.width for img in content)
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
|
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
|
||||||
plugin_id, len(content), total_width
|
plugin_id, len(content), total_width
|
||||||
)
|
)
|
||||||
return self._finalize(content, plugin_id, 'scroll_helper', plugin)
|
return self._finalize(content, plugin_id, 'scroll_helper', plugin)
|
||||||
if has_scroll_helper:
|
if has_scroll_helper:
|
||||||
logger.info("[%s] ScrollHelper content returned None", plugin_id)
|
logger.debug("[%s] ScrollHelper content returned None", plugin_id)
|
||||||
|
|
||||||
if offscreen_only:
|
if offscreen_only:
|
||||||
# Display capture needs the shared canvas; leave it to the caller.
|
# Display capture needs the shared canvas; leave it to the caller.
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Needs display capture, deferring to the render thread",
|
"[%s] Needs display capture, deferring to the render thread",
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Fall back to display capture
|
# Fall back to display capture
|
||||||
logger.info("[%s] Trying fallback display capture...", plugin_id)
|
logger.debug("[%s] Trying fallback display capture...", plugin_id)
|
||||||
content = self._capture_display_content(plugin, plugin_id)
|
content = self._capture_display_content(plugin, plugin_id)
|
||||||
if content:
|
if content:
|
||||||
total_width = sum(img.width for img in content)
|
total_width = sum(img.width for img in content)
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
||||||
plugin_id, len(content), total_width
|
plugin_id, len(content), total_width
|
||||||
)
|
)
|
||||||
@@ -211,7 +226,7 @@ class PluginAdapter:
|
|||||||
kept.append(result.image)
|
kept.append(result.image)
|
||||||
|
|
||||||
if not kept:
|
if not kept:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] All %d image(s) from %s were blank — contributing nothing",
|
"[%s] All %d image(s) from %s were blank — contributing nothing",
|
||||||
plugin_id, len(images), source
|
plugin_id, len(images), source
|
||||||
)
|
)
|
||||||
@@ -220,14 +235,14 @@ class PluginAdapter:
|
|||||||
trimmed_width = sum(img.width for img in kept)
|
trimmed_width = sum(img.width for img in kept)
|
||||||
|
|
||||||
if trimmed_width < self.config.min_plugin_width:
|
if trimmed_width < self.config.min_plugin_width:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Trimmed content %dpx is below min_plugin_width %dpx — skipping",
|
"[%s] Trimmed content %dpx is below min_plugin_width %dpx — skipping",
|
||||||
plugin_id, trimmed_width, self.config.min_plugin_width
|
plugin_id, trimmed_width, self.config.min_plugin_width
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if trimmed_width != original_width or dropped_blank:
|
if trimmed_width != original_width or dropped_blank:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Trimmed %s content: %dpx -> %dpx (%.0f%% reclaimed), "
|
"[%s] Trimmed %s content: %dpx -> %dpx (%.0f%% reclaimed), "
|
||||||
"%d image(s) kept, %d blank dropped",
|
"%d image(s) kept, %d blank dropped",
|
||||||
plugin_id, source, original_width, trimmed_width,
|
plugin_id, source, original_width, trimmed_width,
|
||||||
@@ -398,6 +413,88 @@ class PluginAdapter:
|
|||||||
return 0
|
return 0
|
||||||
return int(self.display_width * ratio)
|
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.debug(
|
||||||
|
"[%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(
|
def _apply_width_budget(
|
||||||
self, images: List[Image.Image], plugin_id: str,
|
self, images: List[Image.Image], plugin_id: str,
|
||||||
plugin: Optional['BasePlugin'] = None
|
plugin: Optional['BasePlugin'] = None
|
||||||
@@ -435,45 +532,62 @@ class PluginAdapter:
|
|||||||
|
|
||||||
if not budget or total <= budget:
|
if not budget or total <= budget:
|
||||||
# Fits, so reset rotation — the whole segment is being shown.
|
# Fits, so reset rotation — the whole segment is being shown.
|
||||||
self._item_offsets.pop(plugin_id, None)
|
self._clear_offset(plugin_id)
|
||||||
return images
|
return images
|
||||||
|
|
||||||
if len(images) == 1:
|
if len(images) == 1:
|
||||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
||||||
|
|
||||||
|
shape = ('rows', len(images))
|
||||||
if mode == 'truncate':
|
if mode == 'truncate':
|
||||||
# Ordered content: always show from the top. Deliberately does not
|
# Ordered content: always show from the top. Deliberately does not
|
||||||
# advance the offset, so the same opening items appear every time
|
# advance the offset, so the same opening items appear every time
|
||||||
# rather than the viewer being shown the middle of a ranked list.
|
# rather than the viewer being shown the middle of a ranked list.
|
||||||
start = 0
|
start = 0
|
||||||
else:
|
else:
|
||||||
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
start = self._resume_offset(plugin_id, shape) % len(images)
|
||||||
selected: List[Image.Image] = []
|
selected: List[Image.Image] = []
|
||||||
used = 0
|
used = 0
|
||||||
consumed = 0
|
consumed = 0
|
||||||
|
|
||||||
# Walk forward from the rotation offset, taking whole items only, so a
|
# Walk forward from the rotation offset, taking whole items only, so a
|
||||||
# cut never lands in the middle of one.
|
# 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)):
|
for step in range(len(images)):
|
||||||
img = images[(start + step) % len(images)]
|
img = images[(start + step) % len(images)]
|
||||||
cost = img.width
|
cost = img.width
|
||||||
if selected:
|
if selected:
|
||||||
cost += self._row_gap(selected[-1], img)
|
cost += self._row_gap(selected[-1], img)
|
||||||
if selected and used + cost > budget:
|
if selected and used + cost > budget:
|
||||||
break
|
# 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)
|
selected.append(img)
|
||||||
used += cost
|
used += cost
|
||||||
consumed += 1
|
consumed += 1
|
||||||
|
|
||||||
if mode == 'truncate':
|
if mode == 'truncate':
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Width budget %dpx: showing the first %d of %d row(s) "
|
"[%s] Width budget %dpx: showing the first %d of %d row(s) "
|
||||||
"(%dpx incl. gaps); the rest are not shown (overflow=truncate)",
|
"(%dpx incl. gaps); the rest are not shown (overflow=truncate)",
|
||||||
plugin_id, budget, len(selected), len(images), used
|
plugin_id, budget, len(selected), len(images), used
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._item_offsets[plugin_id] = (start + consumed) % len(images)
|
self._record_offset(
|
||||||
logger.info(
|
plugin_id, (start + consumed) % len(images), shape)
|
||||||
|
logger.debug(
|
||||||
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
||||||
"from offset %d; remainder deferred to a later cycle",
|
"from offset %d; remainder deferred to a later cycle",
|
||||||
plugin_id, budget, len(selected), len(images), used, start
|
plugin_id, budget, len(selected), len(images), used, start
|
||||||
@@ -490,16 +604,13 @@ class PluginAdapter:
|
|||||||
|
|
||||||
The cut is snapped to the nearest blank column so it does not slice
|
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.
|
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
|
# Cut only where the plugin left a real gap between items. Snapping to
|
||||||
# any blank column used to pick the single-column gaps between
|
# any blank column used to pick the single-column gaps between
|
||||||
# characters, splitting a word and orphaning its tail into the next
|
# characters, splitting a word and orphaning its tail into the next
|
||||||
@@ -514,10 +625,18 @@ class PluginAdapter:
|
|||||||
# budget exactly. The gap rule exists to protect discrete items
|
# budget exactly. The gap rule exists to protect discrete items
|
||||||
# (words, ticker entries); it would be wrong to let a solid image
|
# (words, ticker entries); it would be wrong to let a solid image
|
||||||
# escape the cap in its name.
|
# 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':
|
if mode != 'truncate':
|
||||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
self._record_offset(
|
||||||
logger.info(
|
plugin_id, 0 if end >= img.width else end, shape)
|
||||||
|
logger.debug(
|
||||||
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
||||||
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
|
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
|
||||||
plugin_id, budget, img.width, offset, end, min_run,
|
plugin_id, budget, img.width, offset, end, min_run,
|
||||||
@@ -528,8 +647,15 @@ class PluginAdapter:
|
|||||||
# Cut mid-gap so the content either side keeps some breathing room.
|
# 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})
|
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)
|
shape = ('cuts', len(cuts))
|
||||||
later = [c for c in cuts if c > start]
|
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:
|
if not later:
|
||||||
end = img.width
|
end = img.width
|
||||||
else:
|
else:
|
||||||
@@ -537,15 +663,22 @@ class PluginAdapter:
|
|||||||
# No boundary inside the budget: take the next one and overrun,
|
# No boundary inside the budget: take the next one and overrun,
|
||||||
# because the alternative is cutting through an item.
|
# because the alternative is cutting through an item.
|
||||||
end = max(within) if within else min(later)
|
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':
|
if mode != 'truncate':
|
||||||
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
# Next cycle resumes at the boundary this one stopped on; wrap when
|
||||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
# the strip ends.
|
||||||
|
self._record_offset(
|
||||||
|
plugin_id, 0 if end >= img.width else end_index, shape)
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
"[%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,
|
plugin_id, budget, img.width, start, end, end - start,
|
||||||
|
start_index, end_index, len(cuts) - 1,
|
||||||
"showing the start only (overflow=truncate)"
|
"showing the start only (overflow=truncate)"
|
||||||
if mode == 'truncate' else "window advances next cycle"
|
if mode == 'truncate' else "window advances next cycle"
|
||||||
)
|
)
|
||||||
@@ -565,7 +698,7 @@ class PluginAdapter:
|
|||||||
List of images or None
|
List of images or None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
|
logger.debug("[%s] Native: calling get_vegas_content()", plugin_id)
|
||||||
|
|
||||||
# Tell the plugin how much width the ticker wants it to use, and
|
# Tell the plugin how much width the ticker wants it to use, and
|
||||||
# narrow the canvas for the duration of the call. A plugin that
|
# narrow the canvas for the duration of the call. A plugin that
|
||||||
@@ -574,7 +707,7 @@ class PluginAdapter:
|
|||||||
# be explicit can read get_vegas_render_width().
|
# be explicit can read get_vegas_render_width().
|
||||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||||
if render_width != self.display_width:
|
if render_width != self.display_width:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Native: requesting %dpx instead of %dpx",
|
"[%s] Native: requesting %dpx instead of %dpx",
|
||||||
plugin_id, render_width, self.display_width
|
plugin_id, render_width, self.display_width
|
||||||
)
|
)
|
||||||
@@ -602,19 +735,19 @@ class PluginAdapter:
|
|||||||
plugin._vegas_render_width = None
|
plugin._vegas_render_width = None
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
|
logger.debug("[%s] Native: get_vegas_content() returned None", plugin_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Normalize to list
|
# Normalize to list
|
||||||
if isinstance(result, Image.Image):
|
if isinstance(result, Image.Image):
|
||||||
images = [result]
|
images = [result]
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Native: got single Image %dx%d",
|
"[%s] Native: got single Image %dx%d",
|
||||||
plugin_id, result.width, result.height
|
plugin_id, result.width, result.height
|
||||||
)
|
)
|
||||||
elif isinstance(result, (list, tuple)):
|
elif isinstance(result, (list, tuple)):
|
||||||
images = list(result)
|
images = list(result)
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Native: got %d items in list/tuple",
|
"[%s] Native: got %d items in list/tuple",
|
||||||
plugin_id, len(images)
|
plugin_id, len(images)
|
||||||
)
|
)
|
||||||
@@ -635,14 +768,14 @@ class PluginAdapter:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Native: item[%d] is %dx%d, mode=%s",
|
"[%s] Native: item[%d] is %dx%d, mode=%s",
|
||||||
plugin_id, i, img.width, img.height, img.mode
|
plugin_id, i, img.width, img.height, img.mode
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure correct height
|
# Ensure correct height
|
||||||
if img.height != self.display_height:
|
if img.height != self.display_height:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Native: resizing item[%d]: %dx%d -> %dx%d",
|
"[%s] Native: resizing item[%d]: %dx%d -> %dx%d",
|
||||||
plugin_id, i, img.width, img.height,
|
plugin_id, i, img.width, img.height,
|
||||||
img.width, self.display_height
|
img.width, self.display_height
|
||||||
@@ -660,13 +793,13 @@ class PluginAdapter:
|
|||||||
|
|
||||||
if valid_images:
|
if valid_images:
|
||||||
total_width = sum(img.width for img in valid_images)
|
total_width = sum(img.width for img in valid_images)
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Native: SUCCESS - %d images, %dpx total width",
|
"[%s] Native: SUCCESS - %d images, %dpx total width",
|
||||||
plugin_id, len(valid_images), total_width
|
plugin_id, len(valid_images), total_width
|
||||||
)
|
)
|
||||||
return valid_images
|
return valid_images
|
||||||
|
|
||||||
logger.info("[%s] Native: no valid images after validation", plugin_id)
|
logger.debug("[%s] Native: no valid images after validation", plugin_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except (AttributeError, TypeError, ValueError, OSError) as e:
|
except (AttributeError, TypeError, ValueError, OSError) as e:
|
||||||
@@ -700,20 +833,20 @@ class PluginAdapter:
|
|||||||
logger.debug("[%s] No scroll_helper attribute", plugin_id)
|
logger.debug("[%s] No scroll_helper attribute", plugin_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Found scroll_helper: %s",
|
"[%s] Found scroll_helper: %s",
|
||||||
plugin_id, type(scroll_helper).__name__
|
plugin_id, type(scroll_helper).__name__
|
||||||
)
|
)
|
||||||
|
|
||||||
cached_image = getattr(scroll_helper, 'cached_image', None)
|
cached_image = getattr(scroll_helper, 'cached_image', None)
|
||||||
if cached_image is None:
|
if cached_image is None:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] scroll_helper.cached_image is None, triggering content generation",
|
"[%s] scroll_helper.cached_image is None, triggering content generation",
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
if offscreen_only:
|
if offscreen_only:
|
||||||
# Generating it calls display(), which needs the canvas.
|
# Generating it calls display(), which needs the canvas.
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] scroll_helper cache empty; deferring generation "
|
"[%s] scroll_helper cache empty; deferring generation "
|
||||||
"to the render thread", plugin_id
|
"to the render thread", plugin_id
|
||||||
)
|
)
|
||||||
@@ -726,13 +859,13 @@ class PluginAdapter:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if not isinstance(cached_image, Image.Image):
|
if not isinstance(cached_image, Image.Image):
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] scroll_helper.cached_image is not an Image: %s",
|
"[%s] scroll_helper.cached_image is not an Image: %s",
|
||||||
plugin_id, type(cached_image).__name__
|
plugin_id, type(cached_image).__name__
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] scroll_helper.cached_image found: %dx%d, mode=%s",
|
"[%s] scroll_helper.cached_image found: %dx%d, mode=%s",
|
||||||
plugin_id, cached_image.width, cached_image.height, cached_image.mode
|
plugin_id, cached_image.width, cached_image.height, cached_image.mode
|
||||||
)
|
)
|
||||||
@@ -755,7 +888,7 @@ class PluginAdapter:
|
|||||||
|
|
||||||
# Ensure correct height
|
# Ensure correct height
|
||||||
if img.height != self.display_height:
|
if img.height != self.display_height:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Resizing scroll_helper content: %dx%d -> %dx%d",
|
"[%s] Resizing scroll_helper content: %dx%d -> %dx%d",
|
||||||
plugin_id, img.width, img.height,
|
plugin_id, img.width, img.height,
|
||||||
img.width, self.display_height
|
img.width, self.display_height
|
||||||
@@ -769,7 +902,7 @@ class PluginAdapter:
|
|||||||
if img.mode != 'RGB':
|
if img.mode != 'RGB':
|
||||||
img = img.convert('RGB')
|
img = img.convert('RGB')
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] ScrollHelper content ready: %dx%d",
|
"[%s] ScrollHelper content ready: %dx%d",
|
||||||
plugin_id, img.width, img.height
|
plugin_id, img.width, img.height
|
||||||
)
|
)
|
||||||
@@ -869,7 +1002,7 @@ class PluginAdapter:
|
|||||||
with self._capture():
|
with self._capture():
|
||||||
# Method 1: Try _create_scrolling_display (stocks pattern)
|
# Method 1: Try _create_scrolling_display (stocks pattern)
|
||||||
if hasattr(plugin, '_create_scrolling_display'):
|
if hasattr(plugin, '_create_scrolling_display'):
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Triggering via _create_scrolling_display()",
|
"[%s] Triggering via _create_scrolling_display()",
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
@@ -877,7 +1010,7 @@ class PluginAdapter:
|
|||||||
plugin._create_scrolling_display()
|
plugin._create_scrolling_display()
|
||||||
cached_image = getattr(scroll_helper, 'cached_image', None)
|
cached_image = getattr(scroll_helper, 'cached_image', None)
|
||||||
if cached_image is not None and isinstance(cached_image, Image.Image):
|
if cached_image is not None and isinstance(cached_image, Image.Image):
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] _create_scrolling_display() SUCCESS: %dx%d",
|
"[%s] _create_scrolling_display() SUCCESS: %dx%d",
|
||||||
plugin_id, cached_image.width, cached_image.height
|
plugin_id, cached_image.width, cached_image.height
|
||||||
)
|
)
|
||||||
@@ -889,7 +1022,7 @@ class PluginAdapter:
|
|||||||
|
|
||||||
# Method 2: Try display(force_clear=True) which typically builds scroll content
|
# Method 2: Try display(force_clear=True) which typically builds scroll content
|
||||||
if hasattr(plugin, 'display'):
|
if hasattr(plugin, 'display'):
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Triggering via display(force_clear=True)",
|
"[%s] Triggering via display(force_clear=True)",
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
@@ -898,12 +1031,12 @@ class PluginAdapter:
|
|||||||
plugin.display(force_clear=True)
|
plugin.display(force_clear=True)
|
||||||
cached_image = getattr(scroll_helper, 'cached_image', None)
|
cached_image = getattr(scroll_helper, 'cached_image', None)
|
||||||
if cached_image is not None and isinstance(cached_image, Image.Image):
|
if cached_image is not None and isinstance(cached_image, Image.Image):
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] display(force_clear=True) SUCCESS: %dx%d",
|
"[%s] display(force_clear=True) SUCCESS: %dx%d",
|
||||||
plugin_id, cached_image.width, cached_image.height
|
plugin_id, cached_image.width, cached_image.height
|
||||||
)
|
)
|
||||||
return cached_image
|
return cached_image
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] display(force_clear=True) did not populate cached_image",
|
"[%s] display(force_clear=True) did not populate cached_image",
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
@@ -912,7 +1045,7 @@ class PluginAdapter:
|
|||||||
"[%s] display(force_clear=True) failed", plugin_id
|
"[%s] display(force_clear=True) failed", plugin_id
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Could not trigger scroll content generation",
|
"[%s] Could not trigger scroll content generation",
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
@@ -944,15 +1077,15 @@ class PluginAdapter:
|
|||||||
try:
|
try:
|
||||||
# Save current display state
|
# Save current display state
|
||||||
original_image = self.display_manager.image.copy()
|
original_image = self.display_manager.image.copy()
|
||||||
logger.info("[%s] Fallback: saved original display state", plugin_id)
|
logger.debug("[%s] Fallback: saved original display state", plugin_id)
|
||||||
|
|
||||||
# Ensure plugin has fresh data before capturing
|
# Ensure plugin has fresh data before capturing
|
||||||
has_update_data = hasattr(plugin, 'update_data')
|
has_update_data = hasattr(plugin, 'update_data')
|
||||||
logger.info("[%s] Fallback: has update_data=%s", plugin_id, has_update_data)
|
logger.debug("[%s] Fallback: has update_data=%s", plugin_id, has_update_data)
|
||||||
if has_update_data:
|
if has_update_data:
|
||||||
try:
|
try:
|
||||||
plugin.update_data()
|
plugin.update_data()
|
||||||
logger.info("[%s] Fallback: update_data() called", plugin_id)
|
logger.debug("[%s] Fallback: update_data() called", plugin_id)
|
||||||
except (AttributeError, RuntimeError, OSError):
|
except (AttributeError, RuntimeError, OSError):
|
||||||
logger.exception("[%s] Fallback: update_data() failed", plugin_id)
|
logger.exception("[%s] Fallback: update_data() failed", plugin_id)
|
||||||
|
|
||||||
@@ -964,41 +1097,41 @@ class PluginAdapter:
|
|||||||
# arrangement rather than one that has to be cropped afterwards.
|
# arrangement rather than one that has to be cropped afterwards.
|
||||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||||
if render_width != self.display_width:
|
if render_width != self.display_width:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Fallback: rendering at %dpx instead of %dpx",
|
"[%s] Fallback: rendering at %dpx instead of %dpx",
|
||||||
plugin_id, render_width, self.display_width
|
plugin_id, render_width, self.display_width
|
||||||
)
|
)
|
||||||
|
|
||||||
with self._capture(), self._render_at(render_width):
|
with self._capture(), self._render_at(render_width):
|
||||||
self.display_manager.clear()
|
self.display_manager.clear()
|
||||||
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
|
logger.debug("[%s] Fallback: display cleared, calling display()", plugin_id)
|
||||||
|
|
||||||
# First try without force_clear (some plugins behave better this way)
|
# First try without force_clear (some plugins behave better this way)
|
||||||
try:
|
try:
|
||||||
plugin.display()
|
plugin.display()
|
||||||
logger.info("[%s] Fallback: display() called successfully", plugin_id)
|
logger.debug("[%s] Fallback: display() called successfully", plugin_id)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# Plugin may require force_clear argument
|
# Plugin may require force_clear argument
|
||||||
logger.info("[%s] Fallback: display() failed, trying with force_clear=True", plugin_id)
|
logger.debug("[%s] Fallback: display() failed, trying with force_clear=True", plugin_id)
|
||||||
plugin.display(force_clear=True)
|
plugin.display(force_clear=True)
|
||||||
|
|
||||||
# Capture the result
|
# Capture the result
|
||||||
captured = self.display_manager.image.copy()
|
captured = self.display_manager.image.copy()
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Fallback: captured frame %dx%d, mode=%s",
|
"[%s] Fallback: captured frame %dx%d, mode=%s",
|
||||||
plugin_id, captured.width, captured.height, captured.mode
|
plugin_id, captured.width, captured.height, captured.mode
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if captured image has content (not all black)
|
# Check if captured image has content (not all black)
|
||||||
is_blank, bright_ratio = self._is_blank_image(captured, return_ratio=True)
|
is_blank, bright_ratio = self._is_blank_image(captured, return_ratio=True)
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Fallback: brightness check - %.3f%% bright pixels (threshold=0.5%%)",
|
"[%s] Fallback: brightness check - %.3f%% bright pixels (threshold=0.5%%)",
|
||||||
plugin_id, bright_ratio * 100
|
plugin_id, bright_ratio * 100
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_blank:
|
if is_blank:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Fallback: first capture blank, retrying with force_clear",
|
"[%s] Fallback: first capture blank, retrying with force_clear",
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
@@ -1009,7 +1142,7 @@ class PluginAdapter:
|
|||||||
captured = self.display_manager.image.copy()
|
captured = self.display_manager.image.copy()
|
||||||
|
|
||||||
is_blank, bright_ratio = self._is_blank_image(captured, return_ratio=True)
|
is_blank, bright_ratio = self._is_blank_image(captured, return_ratio=True)
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Fallback: retry brightness - %.3f%% bright pixels",
|
"[%s] Fallback: retry brightness - %.3f%% bright pixels",
|
||||||
plugin_id, bright_ratio * 100
|
plugin_id, bright_ratio * 100
|
||||||
)
|
)
|
||||||
@@ -1026,7 +1159,7 @@ class PluginAdapter:
|
|||||||
if captured.mode != 'RGB':
|
if captured.mode != 'RGB':
|
||||||
captured = captured.convert('RGB')
|
captured = captured.convert('RGB')
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Fallback: SUCCESS - captured %dx%d",
|
"[%s] Fallback: SUCCESS - captured %dx%d",
|
||||||
plugin_id, captured.width, captured.height
|
plugin_id, captured.width, captured.height
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -406,6 +406,8 @@ class StreamManager:
|
|||||||
)
|
)
|
||||||
logger.info("Ordered plugins: %s", ordered_plugins)
|
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
|
# Atomically update shared state under lock to avoid races with prefetchers
|
||||||
with self._buffer_lock:
|
with self._buffer_lock:
|
||||||
self._ordered_plugins = ordered_plugins
|
self._ordered_plugins = ordered_plugins
|
||||||
@@ -417,6 +419,143 @@ class StreamManager:
|
|||||||
|
|
||||||
logger.info("=" * 60)
|
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:
|
def _prefetch_content(self, count: int = 1) -> None:
|
||||||
"""
|
"""
|
||||||
Prefetch content for upcoming plugins.
|
Prefetch content for upcoming plugins.
|
||||||
|
|||||||
@@ -29,18 +29,16 @@ def success_response(
|
|||||||
Flask jsonify response
|
Flask jsonify response
|
||||||
"""
|
"""
|
||||||
response_data = create_success_response(data, message, metadata)
|
response_data = create_success_response(data, message, metadata)
|
||||||
|
|
||||||
# Add request metadata if available
|
# Timing is merged into whatever the caller passed, without inventing a
|
||||||
if metadata is None:
|
# metadata block for responses that have neither.
|
||||||
metadata = {}
|
enriched = dict(metadata) if metadata is not None else {}
|
||||||
|
|
||||||
# Add timing if request start time is available
|
|
||||||
if hasattr(request, 'start_time'):
|
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:
|
if metadata is not None or enriched:
|
||||||
response_data['metadata'] = metadata
|
response_data['metadata'] = enriched
|
||||||
|
|
||||||
return jsonify(response_data)
|
return jsonify(response_data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Centralized error handling for web interface.
|
|||||||
Provides helpers for consistent error responses across API endpoints.
|
Provides helpers for consistent error responses across API endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
|
|
||||||
@@ -16,6 +17,97 @@ from src.logging_config import get_logger
|
|||||||
logger = get_logger(__name__)
|
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(
|
def create_error_response(
|
||||||
error_code: ErrorCode,
|
error_code: ErrorCode,
|
||||||
message: str,
|
message: str,
|
||||||
@@ -69,14 +161,17 @@ def create_success_response(
|
|||||||
"status": "success"
|
"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:
|
if data is not None:
|
||||||
response["data"] = data
|
response["data"] = data
|
||||||
|
|
||||||
if message:
|
if message is not None:
|
||||||
response["message"] = message
|
response["message"] = message
|
||||||
|
|
||||||
if metadata:
|
if metadata is not None:
|
||||||
response["metadata"] = metadata
|
response["metadata"] = metadata
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ class WebInterfaceError:
|
|||||||
self.category = category or self._infer_category(error_code)
|
self.category = category or self._infer_category(error_code)
|
||||||
self.details = details
|
self.details = details
|
||||||
self.context = context or {}
|
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
|
self.original_error = original_error
|
||||||
|
|
||||||
def _infer_category(self, error_code: ErrorCode) -> ErrorCategory:
|
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.
|
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]:
|
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
|
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]:
|
def mask_all_secret_values(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Blanket-mask every non-empty value in a secrets config dict.
|
"""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:
|
Returns:
|
||||||
A copy with all real values replaced by ``'••••••••'``.
|
A copy with all real values replaced by ``'••••••••'``.
|
||||||
"""
|
"""
|
||||||
masked: Dict[str, Any] = {}
|
return {k: _mask_value(v) for k, v in config.items()}
|
||||||
for k, v in config.items():
|
|
||||||
if isinstance(v, dict):
|
|
||||||
masked[k] = mask_all_secret_values(v)
|
def _mask_value(value: Any) -> Any:
|
||||||
elif v not in (None, '') and not (isinstance(v, str) and v.startswith('YOUR_')):
|
"""Mask one value, recursing through dicts and lists.
|
||||||
masked[k] = '••••••••'
|
|
||||||
else:
|
A list used to be masked as though it were a scalar, so
|
||||||
masked[k] = v
|
``accounts: [{"name": "a", "token": "..."}]`` came back as a single
|
||||||
return masked
|
``'••••••••'``. 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]:
|
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)
|
nested = remove_empty_secrets(v)
|
||||||
if nested:
|
if nested:
|
||||||
result[k] = 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() == ''):
|
elif v is not None and not (isinstance(v, str) and v.strip() == ''):
|
||||||
result[k] = v
|
result[k] = v
|
||||||
return result
|
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=']):
|
if any(handler in url_lower for handler in ['onerror=', 'onload=', 'onclick=']):
|
||||||
return False, "Event handlers not allowed in URLs"
|
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 /
|
# Allow relative paths starting with /
|
||||||
if url.startswith('/'):
|
if url.startswith('/'):
|
||||||
# Validate it's a safe relative path (no directory traversal)
|
# // would be a protocol-relative URL, not a local path
|
||||||
if '..' in url or url.startswith('//'):
|
if url.startswith('//'):
|
||||||
return False, "Invalid relative path"
|
return False, "Invalid relative path"
|
||||||
return True, None
|
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:
|
if '..' in filename or '/' in filename or '\\' in filename:
|
||||||
return False, "Filename contains invalid characters"
|
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:
|
if allowed_extensions:
|
||||||
file_ext = Path(filename).suffix.lower()
|
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 False, f"File extension must be one of: {', '.join(allowed_extensions)}"
|
||||||
|
|
||||||
return True, None
|
return True, None
|
||||||
@@ -147,7 +153,8 @@ def validate_numeric_range(value: float, min_val: Optional[float] = None,
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (is_valid, error_message)
|
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"
|
return False, "Value must be a number"
|
||||||
|
|
||||||
if min_val is not None and value < min_val:
|
if min_val is not None and value < min_val:
|
||||||
@@ -183,11 +190,19 @@ def validate_string_length(text: str, min_length: Optional[int] = None,
|
|||||||
|
|
||||||
def sanitize_plugin_config(config: dict) -> dict:
|
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:
|
Args:
|
||||||
config: Configuration dictionary
|
config: Configuration dictionary
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sanitized configuration dictionary
|
Sanitized 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
|
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/wifi_monitor_daemon.py --interval 30
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
StandardOutput=syslog
|
StandardOutput=journal
|
||||||
StandardError=syslog
|
StandardError=journal
|
||||||
SyslogIdentifier=ledmatrix-wifi-monitor
|
SyslogIdentifier=ledmatrix-wifi-monitor
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -8,9 +8,38 @@ Type=simple
|
|||||||
User=root
|
User=root
|
||||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
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
|
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
|
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
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
SyslogIdentifier=ledmatrix
|
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:
|
def plugins_dir() -> Path:
|
||||||
"""Get the plugins directory path.
|
"""Get the plugins directory path.
|
||||||
|
|
||||||
Checks plugins/ first, then falls back to plugin-repos/
|
Honors LEDMATRIX_PLUGINS_DIR (first entry) when set — the same override
|
||||||
for monorepo development environments.
|
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'
|
plugins_path = project_root / 'plugins'
|
||||||
plugin_repos_path = project_root / 'plugin-repos'
|
plugin_repos_path = project_root / 'plugin-repos'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for basketball-scoreboard plugin.
|
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
|
import pytest
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for calendar plugin.
|
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
|
import pytest
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for clock-simple plugin.
|
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
|
import pytest
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for odds-ticker plugin.
|
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
|
import pytest
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for soccer-scoreboard plugin.
|
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
|
import pytest
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for text-display plugin.
|
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
|
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
|
||||||