mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-07 07:36:37 +00:00
Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19549fc320 | ||
|
|
7d2db5eb06 | ||
|
|
f5e7f2fc6d | ||
|
|
e40bc47d28 | ||
|
|
3bdb5bff3b | ||
|
|
09a846a717 | ||
|
|
dd749f59ed | ||
|
|
e745ae8060 | ||
|
|
11cfa639ee | ||
|
|
ec95340d4a | ||
|
|
a669d781f5 | ||
|
|
c59a779381 | ||
|
|
c20c0beac2 | ||
|
|
6fb2dc3595 | ||
|
|
fd0a4b50f9 | ||
|
|
b638b91169 | ||
|
|
c461f4efdb | ||
|
|
bb475a79ea | ||
|
|
3f920f2899 | ||
|
|
0577c807eb | ||
|
|
5a7893b11a | ||
|
|
3866aa4519 | ||
|
|
26cae3e5d6 | ||
|
|
294bd522bd | ||
|
|
80048fe78e | ||
|
|
378478124c | ||
|
|
d447cdd965 | ||
|
|
ee7c3389f9 | ||
|
|
caec9f5bf5 | ||
|
|
a74b5a2f0f | ||
|
|
57d7df6705 | ||
|
|
e09e251553 | ||
|
|
7026eeb156 | ||
|
|
2236ff3081 | ||
|
|
5ad5e9aa59 | ||
|
|
8a0cce1aaf | ||
|
|
0b039c875f | ||
|
|
07abd87d5e | ||
|
|
e32d177cbd | ||
|
|
d18e4d3c9d | ||
|
|
04f0d8d134 | ||
|
|
064b9c9912 | ||
|
|
a6e9e3ef1c | ||
|
|
41192b9588 | ||
|
|
ef69201770 | ||
|
|
ed0753c1ca | ||
|
|
7bb85c0356 | ||
|
|
0d179fdf12 | ||
|
|
ee789775b4 | ||
|
|
05deb1ee7d | ||
|
|
f841fa36b6 | ||
|
|
515248b34e | ||
|
|
6bf7c3fa51 | ||
|
|
76ad71d1c5 | ||
|
|
a5ec645d25 | ||
|
|
084697346b | ||
|
|
a7b3f33952 | ||
|
|
c14002edc3 | ||
|
|
8136a2d525 | ||
|
|
5aa7a63127 | ||
|
|
85be4bf25d | ||
|
|
0e78e06eb9 | ||
|
|
746dcfcadb | ||
|
|
f72d69c2b0 | ||
|
|
1ecf3aba03 | ||
|
|
f5793e2134 | ||
|
|
34be83d595 | ||
|
|
41db488c73 | ||
|
|
77e0ea91ae | ||
|
|
6c6394a1a7 | ||
|
|
4be53d048b | ||
|
|
9edeb6da14 | ||
|
|
a21650e746 | ||
|
|
eb8128a981 | ||
|
|
16b566e14f | ||
|
|
4ddc3a3620 | ||
|
|
2601cb4cbb | ||
|
|
695ff92009 | ||
|
|
74696d2108 | ||
|
|
3ad0438e75 | ||
|
|
c6701ac00d | ||
|
|
795834811f | ||
|
|
dfd67c7c8b | ||
|
|
7f06cc9c3b | ||
|
|
a12be7c3c5 | ||
|
|
f4bda50710 | ||
|
|
56947298d6 | ||
|
|
596809acc3 | ||
|
|
77862b631b |
@@ -10,3 +10,6 @@
|
||||
# Generated by scripts/build_css.py; collapsed in diffs, not hand-edited.
|
||||
web_interface/static/v3/tailwind.css linguist-generated=true
|
||||
web_interface/static/v3/plugin-frame.css linguist-generated=true
|
||||
|
||||
# Installed as an executable (its shebang runs it) by install_service.sh.
|
||||
scripts/install/ledmatrix_refresh_units.py text eol=lf
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
|
||||
# No dependencies: the script reads src/__init__.py and CHANGELOG.md only.
|
||||
- name: Assert the tag, CHANGELOG and src.__version__ agree
|
||||
|
||||
@@ -14,8 +14,14 @@ permissions:
|
||||
|
||||
jobs:
|
||||
plugin-safety:
|
||||
name: Plugin safety harness + unit tests
|
||||
name: Plugin safety harness + unit tests (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
# The two Pythons the installer supports: Raspberry Pi OS Bookworm ships
|
||||
# 3.11 and Trixie 3.13.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.11", "3.13"]
|
||||
env:
|
||||
# The bundled fixture plugin gives the harness at least one real plugin
|
||||
# to render, and REQUIRE_PLUGINS turns "discovered zero plugins" into a
|
||||
@@ -29,7 +35,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -43,8 +49,13 @@ jobs:
|
||||
pytest --no-cov test/plugins/
|
||||
|
||||
unit-tests:
|
||||
name: Core unit tests
|
||||
name: Core unit tests (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
# Bookworm's Python (3.11) and Trixie's (3.13); see plugin-safety.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.11", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
@@ -52,7 +63,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -84,7 +95,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
@@ -123,7 +134,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
|
||||
# Downloads the pinned standalone Tailwind CLI (SHA-256 checked; no
|
||||
# Node), rebuilds static/v3/tailwind.css and plugin-frame.css from the
|
||||
@@ -142,7 +153,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
|
||||
# The runtime requirements are installed so mypy sees the real types of
|
||||
@@ -181,7 +192,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
|
||||
# Stdlib only; exits 0 whatever it finds.
|
||||
- name: Report method-family drift across the nine scoreboards
|
||||
|
||||
+1704
-4
File diff suppressed because it is too large
Load Diff
@@ -151,6 +151,11 @@ The system supports live, recent, and upcoming game information for multiple spo
|
||||
- **1GB models (Pi 3B / 3B+), the 512MB Pi Zero 2 W and other low-memory boards**: supported, but the `rpi-rgb-led-matrix` C++ build needs more memory than the Pi has. The installer detects this automatically, compiles with fewer parallel jobs, and adds a temporary swapfile for the build which it removes afterwards. Expect that step to take 15-25 minutes instead of 2-5, and leave at least **3GB free** on the SD card. If you manage swap yourself, opt out with `--skip-swap`. To pin the compiler down further, use `--build-jobs 1`. Once running, keep an eye on memory: see [docs/LOW_MEMORY_BOARDS.md](docs/LOW_MEMORY_BOARDS.md).
|
||||
|
||||
|
||||
### Operating system
|
||||
- **Raspberry Pi OS Lite, Trixie (Debian 13) or Bookworm (Debian 12)**, 64-bit recommended. Trixie is the current release and the one to pick for a new SD card; an existing Bookworm install works as it is, no upgrade needed. The installer checks this first and stops with directions on anything else (Bullseye and older, the desktop edition, other distributions).
|
||||
- **Python**: whatever the OS ships, 3.13 on Trixie and 3.11 on Bookworm. Don't install a different Python; the installer and the services use the system `python3`.
|
||||
- **Networking**: NetworkManager, the default on both. Choosing a WiFi network from the web page and the `LEDMatrix-Setup` hotspot need it; if you switched to dhcpcd in `raspi-config`, switch back (Advanced Options → Network Config → NetworkManager).
|
||||
|
||||
### RGB Matrix Bonnet / HAT
|
||||
- [Adafruit RGB Matrix Bonnet/HAT](https://www.adafruit.com/product/3211) – supports one “chain” of horizontally connected displays
|
||||
- [Adafruit Triple LED Matrix Bonnet](https://www.adafruit.com/product/6358) – supports up to 3 vertical “chains” of horizontally connected displays *(use `regular` as hardware mapping)*
|
||||
@@ -249,7 +254,7 @@ These are not required and you can probably rig up something basic with stuff yo
|
||||
|
||||
<img width="512" height="361" alt="Step 2 Other " src="https://github.com/user-attachments/assets/166a22e8-8067-48df-9f80-50c91f573356" />
|
||||
|
||||
5. Then choose Raspbian OS (64-bit) Lite (Trixie)
|
||||
5. Then choose Raspbian OS (64-bit) Lite (Trixie). Bookworm Lite (listed as Legacy) also works; see [Operating system](#operating-system) below
|
||||
|
||||
<img width="512" height="361" alt="Step 4 Trixie Lite 64" src="https://github.com/user-attachments/assets/3b8590ce-b810-4dfe-9253-26e0d4f8ed1e" />
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
"plugin_rotation_order": [],
|
||||
"use_short_date_format": true,
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": false,
|
||||
"live_in_ticker": true,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5,
|
||||
"enabled": false,
|
||||
@@ -174,6 +174,16 @@
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos"
|
||||
},
|
||||
"fetch_service": {
|
||||
"enabled": true,
|
||||
"max_wait_seconds": 2,
|
||||
"rate_limits": {
|
||||
"*.espn.com": {
|
||||
"per_second": 20,
|
||||
"burst": 200
|
||||
}
|
||||
}
|
||||
},
|
||||
"web-ui-info": {
|
||||
"enabled": true,
|
||||
"display_duration": 10
|
||||
|
||||
+38
-43
@@ -75,17 +75,31 @@ total. See the full list in
|
||||
|
||||
### 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.
|
||||
By default (since 3.8.0) live content **stays in the ticker** and takes
|
||||
**extra turns inside it**, and a scoreboard that supports live cards updates
|
||||
the score on a card already crossing the screen (`live_refresh`, "Update live
|
||||
content while it scrolls").
|
||||
|
||||
Set `live_in_ticker` to keep the ticker running and let live content take
|
||||
**extra turns inside it** instead:
|
||||
To get the old behaviour back -- live content **preempts** Vegas mode: while
|
||||
any plugin reports live priority the ticker stops and that plugin's
|
||||
full-screen display is shown instead -- untick **Keep live games in the
|
||||
ticker** under Vegas mode, or set `live_in_ticker` to `false`:
|
||||
|
||||
```json
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": false
|
||||
}
|
||||
```
|
||||
|
||||
Until 3.8.0 `false` was the default and every config held it, copied from
|
||||
the template. The first start on 3.8.0 turns it on once (a backup of the
|
||||
config is kept as `config.json.backup`, and `live_in_ticker_migrated` records
|
||||
that it ran); a `false` set after that is left alone.
|
||||
|
||||
The weights below apply while live content is in the ticker:
|
||||
|
||||
```json
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": true,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5
|
||||
}
|
||||
@@ -635,10 +649,11 @@ When nothing is running on demand, `data.state` is
|
||||
> on-demand machinery is internal — drive it through the REST endpoints
|
||||
> above (or the web UI buttons). The API handlers
|
||||
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||
> `web_interface/blueprints/api_v3/display.py`) write a request into the cache
|
||||
> manager under the `display_on_demand_request` key, which
|
||||
> `DisplayController._poll_on_demand_requests()`
|
||||
> (`src/display_controller.py`) picks up. A separate
|
||||
> `web_interface/blueprints/api_v3/display.py`) send the request over the
|
||||
> display's control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)),
|
||||
> the only way in (the `display_on_demand_request` cache-key mailbox is
|
||||
> gone). A plugin asks for the screen with `BasePlugin.request_on_demand()`
|
||||
> (see [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). A separate
|
||||
> `display_on_demand_config` key is used by the controller itself
|
||||
> during activation (`_activate_on_demand()`) to track what's
|
||||
> currently running, and is cleared by `_clear_on_demand()`.
|
||||
@@ -721,23 +736,12 @@ keys helps troubleshoot stuck states.
|
||||
|
||||
### Cache Keys
|
||||
|
||||
**1. display_on_demand_request** (TTL: 1 hour)
|
||||
```json
|
||||
{
|
||||
"request_id": "uuid-string",
|
||||
"action": "start|stop",
|
||||
"plugin_id": "plugin-name",
|
||||
"mode": "mode-name",
|
||||
"duration": 30.0,
|
||||
"pinned": true,
|
||||
"timestamp": 1234567890.123
|
||||
}
|
||||
```
|
||||
**Purpose:** Communication from web interface to display controller
|
||||
**When Set:** API endpoint receives request
|
||||
**Auto-Cleared:** After processing or 1 hour TTL
|
||||
Requests are not cache keys: they go over the control socket. The
|
||||
`display_on_demand_request` and `display_on_demand_processed_id` keys of
|
||||
earlier releases are no longer written or read; a leftover file is
|
||||
harmless and can be deleted.
|
||||
|
||||
**2. display_on_demand_config** (No TTL)
|
||||
**1. display_on_demand_config** (No TTL)
|
||||
```json
|
||||
{
|
||||
"mode": "mode-name",
|
||||
@@ -749,7 +753,7 @@ keys helps troubleshoot stuck states.
|
||||
**When Set:** Controller processes start request
|
||||
**Auto-Cleared:** When on-demand stops
|
||||
|
||||
**3. display_on_demand_state** (Continuously updated)
|
||||
**2. display_on_demand_state** (Continuously updated)
|
||||
```json
|
||||
{
|
||||
"active": true,
|
||||
@@ -763,31 +767,24 @@ keys helps troubleshoot stuck states.
|
||||
**When Set:** Every display loop iteration
|
||||
**Auto-Cleared:** Never (continuously updated)
|
||||
|
||||
**4. display_on_demand_processed_id** (TTL: 1 hour)
|
||||
```text
|
||||
"uuid-string-of-last-processed-request"
|
||||
```
|
||||
**Purpose:** Prevents duplicate request processing
|
||||
**When Set:** After processing request
|
||||
**Auto-Cleared:** After 1 hour TTL
|
||||
|
||||
### When Manual Clearing is Needed
|
||||
|
||||
**Scenario 1: Stuck in On-Demand State**
|
||||
- Symptom: Display stays on one plugin, won't return to rotation
|
||||
- Clear: `config`, `state`, `request`
|
||||
- Clear: `config`, `state`
|
||||
|
||||
**Scenario 2: Mode Switching Issues**
|
||||
- Symptom: Can't change to different plugin
|
||||
- Clear: `request`, `processed_id`, `state`
|
||||
- Clear: `state`, then restart the display
|
||||
|
||||
**Scenario 3: On-Demand Not Activating**
|
||||
- Symptom: Button click does nothing
|
||||
- Clear: `processed_id`, `request`
|
||||
- Symptom: Button click does nothing, or answers an error
|
||||
- Check the error's `socket_error` (see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md),
|
||||
"Checking it on a device"); no cache key is involved
|
||||
|
||||
**Scenario 4: After Service Crash**
|
||||
- Symptom: Strange behavior after crash/restart
|
||||
- Clear: All four keys
|
||||
- Clear: both keys
|
||||
|
||||
### Manual Recovery Procedures
|
||||
|
||||
@@ -824,8 +821,6 @@ from src.cache_manager import CacheManager
|
||||
cache = CacheManager()
|
||||
cache.clear_cache('display_on_demand_config')
|
||||
cache.clear_cache('display_on_demand_state')
|
||||
cache.clear_cache('display_on_demand_request')
|
||||
cache.clear_cache('display_on_demand_processed_id')
|
||||
```
|
||||
|
||||
> `CacheManager` also has a `delete(key)` method — a thin wrapper over
|
||||
|
||||
@@ -27,7 +27,7 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
|
||||
|
||||
The Display Manager's icon methods — `draw_weather_icon()`, `draw_sun()`,
|
||||
`draw_cloud()`, `draw_rain()`, `draw_snow()` and `draw_text_with_icons()` —
|
||||
are deprecated, removed in 3.8.0. Draw your own icons instead: render them
|
||||
were removed in 3.8.0. Draw your own icons instead: render them
|
||||
onto a PIL image and paste it onto `self.display_manager.image`, or ship
|
||||
icon images with the plugin. The weather plugin's `WeatherIcons` class is an
|
||||
example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||
@@ -194,7 +194,7 @@ def update(self):
|
||||
sport_key = "nhl"
|
||||
cache_key = f"{self.plugin_id}_{sport_key}_games"
|
||||
|
||||
# get_background_cached_data() is deprecated, removed in 3.8.0 — use get()
|
||||
# get_background_cached_data() was removed in 3.8.0 — use get()
|
||||
cached = self.cache_manager.get(cache_key, max_age=60)
|
||||
|
||||
if cached:
|
||||
@@ -596,8 +596,8 @@ def update(self):
|
||||
|
||||
```python
|
||||
def update(self):
|
||||
# get_enabled_plugins() is deprecated, removed in 3.8.0 — check the
|
||||
# instance's `enabled` flag instead
|
||||
# get_enabled_plugins() was removed in 3.8.0 — check the instance's
|
||||
# `enabled` flag instead
|
||||
weather_plugin = self.plugin_manager.get_plugin("weather")
|
||||
if weather_plugin is not None and weather_plugin.enabled:
|
||||
# Use weather data
|
||||
|
||||
+31
-13
@@ -41,23 +41,34 @@ each other. They share three things:
|
||||
|
||||
| State | Where | Written by | Read by |
|
||||
|---|---|---|---|
|
||||
| On-demand request | cache `display_on_demand_request` | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py) | display: `_poll_on_demand_requests()` |
|
||||
| On-demand command | control socket `/run/ledmatrix/control.sock` ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py), via [`src/ipc/client.py`](../src/ipc/client.py) | display: [`src/ipc/server.py`](../src/ipc/server.py) acks; the render thread applies it in `_poll_on_demand_requests()` |
|
||||
| On-demand request from a plugin | in memory: `BasePlugin.request_on_demand()` / `end_on_demand()` | a plugin in the display process | display: `submit_plugin_on_demand()` queues it; the render thread applies it in `_poll_on_demand_requests()` |
|
||||
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
|
||||
| Current screen | cache `display_current_state` | display | web: `/api/v3/display/current-status` |
|
||||
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
|
||||
| Error clear | cache `plugin_error_clear_request` | web | display |
|
||||
| Error clear | control socket `errors.clear` | web: `POST /api/v3/errors/clear` | display: applied before the socket answers |
|
||||
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
||||
| Fetch statistics (requests per plugin and host) | cache `fetch_stats_snapshot` | display: `FetchStatsPublisher` ([`src/common/fetch_service.py`](../src/common/fetch_service.py)), at most once a minute on change | web: `read_fetch_stats()` for `/api/v3/plugins/fetch-stats` |
|
||||
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
||||
| Plugin runtime (loaded, state, last error, version) | cache `plugin_runtime_snapshot` | display: `PluginRuntimePublisher` ([`src/plugin_system/plugin_runtime.py`](../src/plugin_system/plugin_runtime.py)) | web: `read_plugin_runtime()` for `/api/v3/plugins/installed`, `/plugins/state`, reconciliation |
|
||||
| Preview frame | `/tmp/led_matrix_preview.png` | display: `DisplayManager`, gated by [`snapshot_policy`](../src/common/snapshot_policy.py) | web: display SSE stream, `/api/v3/health` (file age) |
|
||||
| Preview viewer marker | `/tmp/led_matrix_preview_viewer` | web, while a preview is open | display: writes full-rate snapshots only while it is fresh |
|
||||
| Preview frame | `/tmp/led_matrix_preview.png` | display: `DisplayManager`, gated by [`snapshot_policy`](../src/common/snapshot_policy.py): a changed frame at most once a second with a viewer, every 30 s without | web: display SSE stream (checks the mtime every 0.25 s), `/api/v3/health` (file age) |
|
||||
| Preview viewer marker | `/tmp/led_matrix_preview_viewer` | web, about once a second while a preview is open | display: writes viewer-rate snapshots only while it is fresh (5 s) |
|
||||
| Hardware init status | `/tmp/led_matrix_hw_status.json` | display | web: `/api/v3/hardware/status` |
|
||||
| Render-loop heartbeat | `/run/ledmatrix/display-heartbeat.json` (tmpfs) | display: the render thread, via [`display_watchdog`](../src/display_watchdog.py) | web: `/api/v3/health` (`checks.display_loop`); the update health check |
|
||||
|
||||
The on-demand start route starts `ledmatrix.service` when it is not running
|
||||
(`start_service`, on by default) but never restarts a running one: the display
|
||||
reads the mailbox every `ON_DEMAND_POLL_INTERVAL` (0.25s), from its dwell
|
||||
sleep, its render loops and Vegas's interrupt check as well as the main loop.
|
||||
(`start_service`, on by default) but never restarts a running one. The routes
|
||||
send the command over the display's control socket and get an ack. That is
|
||||
the only way in: the cache-key mailboxes (`display_on_demand_request`,
|
||||
`plugin_error_clear_request`) are gone, and a write to either is dropped
|
||||
with a warning. When no display is listening yet, the start route starts the
|
||||
service (if asked) and answers `202`; the web process's dispatcher
|
||||
([`on_demand_dispatch.py`](../web_interface/on_demand_dispatch.py)) sends the
|
||||
request once the socket is up, and the status routes report the outcome.
|
||||
Any other failure is answered as an error. Socket commands and plugins'
|
||||
in-process requests end in the same handler, `_handle_on_demand_request()`.
|
||||
The socket's handlers only queue; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)
|
||||
for the protocol and the permission model.
|
||||
|
||||
### Web and display processes: who runs plugins
|
||||
|
||||
@@ -79,7 +90,8 @@ How a web-side change reaches the running plugins:
|
||||
| Plugin enabled or disabled | `ConfigService` → `_controller_config_change` flags a reconcile; `_reconcile_enabled_plugins` loads it (fresh from disk) or unloads it on the render thread |
|
||||
| Plugin uninstalled (config removed) | the removed section flips its `enabled` flag, and the reconcile unloads it |
|
||||
| Plugin installed, not enabled | nothing to do until it is enabled, which loads it |
|
||||
| Plugin installed while already enabled, updated while enabled, or uninstalled with its config kept | **not picked up**: the display keeps running what it loaded. The route answers `restart_required: true` and the UI shows its restart banner |
|
||||
| Plugin updated while enabled | the update route asks the display over the control socket (`plugin.reload`) to reload it on the render thread, and answers `restart_required: false` once the new code runs. Without the socket, as the next row |
|
||||
| Plugin installed while already enabled, updated while enabled and not reloaded, or uninstalled with its config kept | **not picked up**: the display keeps running what it loaded. The route answers `restart_required: true` and the UI shows its restart banner |
|
||||
|
||||
`display_restart_required()` in `plugin_catalog.py` holds that last rule;
|
||||
routes return it as `restart_required` (with the banner's wording in
|
||||
@@ -103,9 +115,11 @@ other web-UI action runs its script as a subprocess. A later, explicit
|
||||
**plugin web-entry contract** -- a declared entry point for plugin web code
|
||||
-- replaces that function.
|
||||
|
||||
Next stages: a **control socket** from the web process to the display
|
||||
(reload one plugin, ask for its state) in place of `restart_required` and
|
||||
the cache-key mailboxes, and the plugin web-entry contract above.
|
||||
The **control socket** from the web process to the display
|
||||
([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) carries on-demand
|
||||
commands, reloads an updated plugin and streams the display's state; it
|
||||
replaced the cache-key mailboxes. The plugin web-entry
|
||||
contract above is still to come.
|
||||
|
||||
### Plugin state: desired, observed, and who owns it
|
||||
|
||||
@@ -181,7 +195,8 @@ the scheduler), and sets up Vegas mode.
|
||||
enable/disable, poll on-demand requests, run scheduled plugin updates, check
|
||||
the on/off schedule and brightness, then show one screen. Priority is
|
||||
on-demand, then WiFi status messages, then live priority, then Vegas mode,
|
||||
then normal rotation.
|
||||
then normal rotation. [RUN_LOOP_REDESIGN.md](RUN_LOOP_REDESIGN.md) is the
|
||||
plan for restructuring this loop and lists its golden trace tests.
|
||||
|
||||
- **Rotation.** `available_modes` is the ordered list of display modes;
|
||||
`current_mode_index` advances after each screen.
|
||||
@@ -203,7 +218,10 @@ then normal rotation.
|
||||
to it, rotating between several live games.
|
||||
- **Schedule and dim schedule.** `_check_schedule()` reads `schedule`;
|
||||
`_check_dim_schedule()` reads `dim_schedule` and
|
||||
`display.hardware.brightness`. Both are re-evaluated once a minute.
|
||||
`display.hardware.brightness`. Both are re-evaluated once a minute, and
|
||||
both windows are half-open: on (or dimmed) from the start time, off at
|
||||
the end time. When an on-demand session ends, the on/off schedule is
|
||||
re-checked at once rather than at the next minute.
|
||||
- **Long screens.** While a screen is showing (a dwell, a scroll, a Vegas
|
||||
iteration), `_service_pending_changes()` repeats the on-demand, schedule
|
||||
and brightness checks every 0.25 s, so a change does not wait for the
|
||||
|
||||
@@ -31,6 +31,13 @@ tooling against it.
|
||||
| `start_time` / `end_time` | `"HH:MM"`, `07:00`–`23:00` | Global-mode on/off times |
|
||||
| `days.<weekday>.{enabled,start_time,end_time}` | per-day objects | Per-day-mode overrides |
|
||||
|
||||
The display is on from `start_time` up to, but not including, `end_time`:
|
||||
with `07:00`–`23:00` it turns on at 07:00 and off at 23:00. An end earlier
|
||||
than the start crosses midnight (`22:00`–`07:00` is on overnight). In
|
||||
per-day mode, the entry for the current day decides. An on-demand session
|
||||
keeps the display on during off hours; once it ends or is stopped, the
|
||||
display blanks within about a second.
|
||||
|
||||
Read by `DisplayController._check_schedule()` (`src/display_controller.py`).
|
||||
Managed in the web UI under Schedule.
|
||||
|
||||
@@ -44,7 +51,9 @@ Same shape as `schedule` (the template sets its `mode` to `"global"`), plus:
|
||||
|
||||
Read by `DisplayController._check_dim_schedule()` (`src/display_controller.py`;
|
||||
saved via `POST /api/v3/config/dim-schedule`). The display returns to
|
||||
`display.hardware.brightness` outside the window.
|
||||
`display.hardware.brightness` outside the window. The window has the same
|
||||
boundaries as `schedule`: dimmed from `start_time` up to, but not including,
|
||||
`end_time`.
|
||||
|
||||
## `display.hardware` — matrix panel hardware
|
||||
|
||||
@@ -132,6 +141,10 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||
| `prefetch_gate` | bool, `true` — let that background thread run Python only while the render thread is waiting for the panel, so the render thread never waits for the GIL when a refresh comes round. Only takes effect with the rebuilt rgbmatrix binding (`scripts/build_rgbmatrix_nogil.sh`). See [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||
| `switch_interval_ms` | float, `0` — experimental: shorten Python's GIL switch interval to this many ms while Vegas runs. `0` leaves the default (5 ms) alone |
|
||||
| `live_refresh` | bool, `true` — live elements: a plugin that supports them (scores, the flight map) has what is already scrolling updated when its data changes, instead of freezing each card as it was drawn. Always off under multi-display sync, in swap mode and with `offscreen_prefetch` off. `false` restores the frozen behaviour exactly. Per plugin: `vegas_live` in the plugin's section |
|
||||
| `live_max_hz` | float, `5` (0–10) — ceiling on how often an animated live element (a moving aircraft) is redrawn; `0` keeps data updates and turns animation off. Capped at 1 Hz without the rebuilt rgbmatrix binding |
|
||||
| `live_min_interval` | float, `2` (0.5–60) — shortest time between two data redraws of one plugin; a faster plugin is redrawn at this rate, never skipped |
|
||||
| `live_lead_screens` | float, `1` (0–5) — how far ahead of the screen, in screen widths, an animated element starts being redrawn |
|
||||
| `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
|
||||
| `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on |
|
||||
| `extend_threshold_screens` | float, `2.0` |
|
||||
@@ -148,7 +161,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `max_cycle_duration` | int, `240` |
|
||||
| `frame_based_scrolling` | bool, `true` — does not step or set a frame rate; motion is by elapsed time either way. When `true`, `scroll_speed` passes through a clamp of 0.1–5 px per `scroll_delay` (see next row) |
|
||||
| `scroll_delay` | float, `0.02` — not a frame period. Only used with `frame_based_scrolling`: the applied speed is `clamp(scroll_speed × scroll_delay, 0.1, 5) / scroll_delay` px/s, so at `0.02` speeds under 5 px/s run at 5, and at `0.001` nothing runs slower than 100 px/s |
|
||||
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
|
||||
| `live_in_ticker` | bool, `true` — keep scrolling during live games instead of handing the display to a full-screen scoreboard. `false` was the default before 3.8.0; the first start on 3.8.0 turns a stored `false` on once and sets `live_in_ticker_migrated` |
|
||||
| `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 |
|
||||
|
||||
|
||||
+22
-22
@@ -2,8 +2,8 @@
|
||||
|
||||
Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it (see [How to re-run](#how-to-re-run)).
|
||||
|
||||
- Scanned: 2026-09-30, core 3.7.0
|
||||
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4327c2e4), 46 plugins
|
||||
- Scanned: 2026-10-01, core 3.7.0
|
||||
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4de1d134), 46 plugins
|
||||
- Third-party plugins: 8 with their own repo in `plugins.json` (f1-live, gif-player, pga-tour-leaderboard, plex-marquee, ledmatrix-dresden-departures, tidbyt-baseball-scoreboard, sleeper-fantasy, ledmatrix-nascar)
|
||||
|
||||
**37 deprecated methods: 36 unused, 1 still used, 0 need review.**
|
||||
@@ -78,19 +78,19 @@ File paths are relative to the plugin's directory (core: the repo root).
|
||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:63 | test call | `assert cm.get_memory_cache_stats()["last_cleanup"] >= before` |
|
||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:68 | test call | `stats = cm.get_memory_cache_stats()` |
|
||||
| `DisplayManager.draw_sun` | core | src/plugin_system/testing/visual_display_manager.py:417 | unrelated | `def draw_sun(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1356 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1371 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1359 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1374 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/plugin_system/testing/visual_display_manager.py:421 | unrelated | `def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):` |
|
||||
| `DisplayManager.draw_cloud` | ledmatrix-weather | weather_icons.py:184 | unrelated | `def draw_cloud(draw: ImageDraw, x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)):` |
|
||||
| `DisplayManager.draw_rain` | core | src/plugin_system/testing/visual_display_manager.py:425 | unrelated | `def draw_rain(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_snow` | core | src/plugin_system/testing/visual_display_manager.py:429 | unrelated | `def draw_snow(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/display_manager.py:1515 | internal (in `DisplayManager.draw_text_with_icons`) | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/display_manager.py:1518 | internal (in `DisplayManager.draw_text_with_icons`) | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:510 | unrelated | `def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:533 | unrelated | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:76 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1265 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1544 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1635 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:84 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1280 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1559 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1650 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | weather_icons.py:168 | unrelated | `def draw_weather_icon(image: Image.Image, icon_code: str, x: int, y: int, size: int = DEFAULT_SIZE):` |
|
||||
| `DisplayManager.draw_text_with_icons` | core | src/plugin_system/testing/visual_display_manager.py:526 | unrelated | `def draw_text_with_icons(self, text: str, icons: List[tuple] = None,` |
|
||||
| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:229 | test call | `assert fm.get_font_catalog() == fm.font_catalog` |
|
||||
@@ -106,12 +106,12 @@ File paths are relative to the plugin's directory (core: the repo root).
|
||||
|
||||
| Source | Group | Python files | Hits |
|
||||
|---|---|---|---|
|
||||
| core | core | 164 | 20 |
|
||||
| core tests | core-tests | 323 | 17 |
|
||||
| core | core | 172 | 20 |
|
||||
| core tests | core-tests | 347 | 17 |
|
||||
| 7-segment-clock | monorepo | 3 | 0 |
|
||||
| afl-scoreboard | monorepo | 34 | 0 |
|
||||
| baseball-scoreboard | monorepo | 60 | 0 |
|
||||
| basketball-scoreboard | monorepo | 48 | 0 |
|
||||
| afl-scoreboard | monorepo | 35 | 0 |
|
||||
| baseball-scoreboard | monorepo | 61 | 0 |
|
||||
| basketball-scoreboard | monorepo | 49 | 0 |
|
||||
| birdnet-go | monorepo | 2 | 0 |
|
||||
| blackjack | monorepo | 7 | 2 |
|
||||
| calendar | monorepo | 5 | 1 |
|
||||
@@ -121,37 +121,37 @@ File paths are relative to the plugin's directory (core: the repo root).
|
||||
| cricket-scoreboard | monorepo | 8 | 0 |
|
||||
| f1-scoreboard | monorepo | 15 | 0 |
|
||||
| fantasy-blitz | monorepo | 13 | 0 |
|
||||
| football-scoreboard | monorepo | 73 | 0 |
|
||||
| football-scoreboard | monorepo | 74 | 0 |
|
||||
| geochron | monorepo | 10 | 0 |
|
||||
| hello-world | monorepo | 2 | 0 |
|
||||
| hockey-scoreboard | monorepo | 51 | 0 |
|
||||
| hockey-scoreboard | monorepo | 52 | 0 |
|
||||
| incoming-packages | monorepo | 8 | 0 |
|
||||
| jellyfin-now-playing | monorepo | 4 | 0 |
|
||||
| lacrosse-scoreboard | monorepo | 39 | 0 |
|
||||
| lacrosse-scoreboard | monorepo | 40 | 0 |
|
||||
| ledmatrix-elections | monorepo | 12 | 0 |
|
||||
| ledmatrix-flights | monorepo | 45 | 0 |
|
||||
| ledmatrix-flights | monorepo | 48 | 0 |
|
||||
| ledmatrix-leaderboard | monorepo | 9 | 0 |
|
||||
| ledmatrix-music | monorepo | 11 | 0 |
|
||||
| ledmatrix-stocks | monorepo | 7 | 0 |
|
||||
| ledmatrix-weather | monorepo | 14 | 6 |
|
||||
| ledmatrix-weather | monorepo | 15 | 6 |
|
||||
| march-madness | monorepo | 4 | 0 |
|
||||
| masters-tournament | monorepo | 10 | 0 |
|
||||
| mqtt-notifications | monorepo | 4 | 0 |
|
||||
| news | monorepo | 6 | 0 |
|
||||
| nfl-draft | monorepo | 3 | 0 |
|
||||
| nfl-stat-leaders | monorepo | 8 | 0 |
|
||||
| nrl-scoreboard | monorepo | 29 | 0 |
|
||||
| nrl-scoreboard | monorepo | 30 | 0 |
|
||||
| odds-ticker | monorepo | 9 | 0 |
|
||||
| of-the-day | monorepo | 14 | 0 |
|
||||
| olympics | monorepo | 16 | 1 |
|
||||
| on-air | monorepo | 2 | 0 |
|
||||
| pomodoro-timer | monorepo | 3 | 0 |
|
||||
| soccer-scoreboard | monorepo | 46 | 0 |
|
||||
| soccer-scoreboard | monorepo | 47 | 0 |
|
||||
| static-image | monorepo | 3 | 0 |
|
||||
| stock-news | monorepo | 3 | 0 |
|
||||
| text-display | monorepo | 4 | 0 |
|
||||
| tide-display | monorepo | 3 | 0 |
|
||||
| ufc-scoreboard | monorepo | 34 | 0 |
|
||||
| ufc-scoreboard | monorepo | 38 | 0 |
|
||||
| web-ui-info | monorepo | 2 | 0 |
|
||||
| youtube-stats | monorepo | 5 | 0 |
|
||||
| f1-live | third-party | 10 | 0 |
|
||||
|
||||
@@ -54,8 +54,8 @@ rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||
|
||||
# Weather icons: draw_weather_icon() is deprecated, removed in 3.8.0 —
|
||||
# draw your own icons (the weather plugin ships WeatherIcons)
|
||||
# Weather icons: draw_weather_icon() was removed in 3.8.0 — draw your
|
||||
# own icons (the weather plugin ships WeatherIcons)
|
||||
|
||||
# Scrolling state
|
||||
display_manager.set_scrolling_state(True)
|
||||
@@ -78,7 +78,7 @@ strategy = cache_manager.get_cache_strategy("weather")
|
||||
```
|
||||
|
||||
`get_background_cached_data()` (use `get()`) and `get_sport_live_interval()`
|
||||
are deprecated, removed in 3.8.0. See
|
||||
were removed in 3.8.0. See
|
||||
[Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||
|
||||
## Plugin Manager Quick Methods
|
||||
@@ -87,8 +87,8 @@ are deprecated, removed in 3.8.0. See
|
||||
# Get plugins
|
||||
plugin = plugin_manager.get_plugin("plugin-id")
|
||||
all_plugins = plugin_manager.get_all_plugins()
|
||||
# get_enabled_plugins() is deprecated, removed in 3.8.0 — check `enabled`
|
||||
# on the entries in plugin_manager.plugins
|
||||
# get_enabled_plugins() was removed in 3.8.0 — check `enabled` on the
|
||||
# entries in plugin_manager.plugins
|
||||
|
||||
# Get info
|
||||
info = plugin_manager.get_plugin_info("plugin-id")
|
||||
|
||||
@@ -17,13 +17,13 @@ The LEDMatrix emulator allows you to run and test LEDMatrix displays on your com
|
||||
## Prerequisites
|
||||
|
||||
### System Requirements
|
||||
- Python 3.10 or higher
|
||||
- Python 3.11 or higher (3.11 and 3.13 are tested)
|
||||
- Windows, macOS, or Linux
|
||||
- At least 2GB RAM (4GB recommended)
|
||||
- Internet connection for plugin downloads
|
||||
|
||||
### Required Software
|
||||
- Python 3.10+
|
||||
- Python 3.11+
|
||||
- pip (Python package manager)
|
||||
- Git (for plugin management)
|
||||
|
||||
|
||||
+11
-9
@@ -13,10 +13,9 @@
|
||||
BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records
|
||||
which plugin uses which font so the web UI can show it.
|
||||
|
||||
Several methods are deprecated and will be removed in LEDMatrix 3.8.0; they
|
||||
log a warning on first call. They are listed in
|
||||
[Deprecated methods](#deprecated-methods) below, and the full set is pinned in
|
||||
[`test/test_deprecation.py`](../test/test_deprecation.py).
|
||||
Several methods were removed in LEDMatrix 3.8.0 after a release of
|
||||
deprecation warnings; [Removed methods](#removed-methods) below lists them
|
||||
with what to use instead.
|
||||
|
||||
## Getting the FontManager
|
||||
|
||||
@@ -128,8 +127,8 @@ font = self.font_manager.resolve_font(
|
||||
|
||||
`resolve_font()` still honours `config/font_overrides.json` (a map of
|
||||
element key to `family` and/or `size_px`), which is read once at start-up.
|
||||
The methods that edit it — `set_override()`, `remove_override()`,
|
||||
`get_overrides()` — are deprecated, and there is no web UI or REST endpoint
|
||||
The methods that edited it — `set_override()`, `remove_override()`,
|
||||
`get_overrides()` — were removed in 3.8.0, and there is no web UI or REST endpoint
|
||||
for overrides (the override editor and `/api/v3/fonts/overrides` were
|
||||
removed). To let users choose a font, add a field to your plugin's config
|
||||
schema.
|
||||
@@ -204,12 +203,14 @@ Current methods:
|
||||
| `measure_text(text, font)` | `(width, height, baseline)` |
|
||||
| `get_font_height(font)` | Line height |
|
||||
| `register_plugin_fonts(plugin_id, font_manifest)` | Register a plugin's fonts (core calls it at load) |
|
||||
| `forget_plugin_fonts(plugin_id)` | Drop a plugin's manifest fonts and their cached objects (core calls it when a plugin unloads) |
|
||||
| `clear_cache()` | Drop cached fonts and metrics |
|
||||
| `font_catalog` (attribute) | Family name → file path |
|
||||
|
||||
### Deprecated methods
|
||||
### Removed methods
|
||||
|
||||
Removed in 3.8.0. Each logs a warning on first call.
|
||||
Removed in 3.8.0, after logging a deprecation warning on first call since
|
||||
3.5.0.
|
||||
|
||||
| Method | Use instead |
|
||||
|---|---|
|
||||
@@ -218,5 +219,6 @@ Removed in 3.8.0. Each logs a warning on first call.
|
||||
| `get_performance_stats()` | — |
|
||||
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
|
||||
| `get_manager_fonts()`, `get_detected_fonts()` | — |
|
||||
| `get_plugin_fonts()`, `unregister_plugin_fonts()` | — |
|
||||
| `get_plugin_fonts()` | — |
|
||||
| `unregister_plugin_fonts()` | `forget_plugin_fonts()` (core calls it on unload) |
|
||||
| `add_font()`, `remove_font()`, `validate_font()` | the web UI's Fonts tab |
|
||||
|
||||
+19
-1
@@ -15,6 +15,12 @@ This guide will help you set up your LEDMatrix display for the first time and ge
|
||||
- Power supply (5V, 4A minimum recommended)
|
||||
- MicroSD card (16GB minimum)
|
||||
|
||||
**Software:**
|
||||
- Raspberry Pi OS Lite, Trixie (Debian 13) or Bookworm (Debian 12). Trixie
|
||||
is the current release; Bookworm is listed as Legacy in Raspberry Pi
|
||||
Imager. No other system is supported, and the installer says so up front.
|
||||
- The OS's own Python: 3.13 on Trixie, 3.11 on Bookworm
|
||||
|
||||
**Network:**
|
||||
- WiFi network (or Ethernet cable)
|
||||
- Computer with web browser on same network
|
||||
@@ -28,7 +34,8 @@ This guide will help you set up your LEDMatrix display for the first time and ge
|
||||
There is no prebuilt SD card image — you install LEDMatrix onto stock
|
||||
Raspberry Pi OS Lite yourself:
|
||||
|
||||
1. Flash Raspberry Pi OS Lite to the MicroSD card (Raspberry Pi Imager)
|
||||
1. Flash Raspberry Pi OS Lite (Trixie, or Bookworm) to the MicroSD card
|
||||
(Raspberry Pi Imager)
|
||||
2. Connect the LED matrix to your Raspberry Pi, insert the card, and
|
||||
power on
|
||||
3. SSH into the Pi and run the one-shot installer:
|
||||
@@ -39,6 +46,17 @@ Raspberry Pi OS Lite yourself:
|
||||
[README Installation Steps / Quick Install](../README.md#installation-steps)
|
||||
for full details
|
||||
|
||||
The one-shot installer installs the newest release (the **stable** update
|
||||
channel). To run the newest, unreleased code from `main` instead (the
|
||||
**beta** channel), put `LEDMATRIX_CHANNEL=beta` in front of `bash`:
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh | LEDMATRIX_CHANNEL=beta bash
|
||||
```
|
||||
A manual clone starts on `main`; add `--beta` to `first_time_install.sh`
|
||||
to stay on it, or leave it off and the first update after the next
|
||||
release moves the device onto releases. You can switch channels later on
|
||||
the General tab.
|
||||
|
||||
**Expected Behavior after install:**
|
||||
- LED matrix will light up
|
||||
- A fresh install ships only the bundled `starlark-apps` and
|
||||
|
||||
@@ -170,6 +170,31 @@ pytest test/test_config_manager.py
|
||||
pytest
|
||||
```
|
||||
|
||||
### Web UI JavaScript Tests
|
||||
|
||||
The suites in `test/js` need node; the DOM ones also need jsdom and a running
|
||||
web interface (details in [`test/js/README.md`](../test/js/README.md)):
|
||||
|
||||
```bash
|
||||
npm install --no-audit --no-fund --prefix test/js # jsdom; node_modules is gitignored
|
||||
EMULATOR=true python3 web_interface/app.py # in another shell
|
||||
BASE=http://localhost:5000 REQUIRE_DOM=1 node test/js/run_all.js
|
||||
```
|
||||
|
||||
`pytest test/test_js_unit_suites.py` runs just the unit suites.
|
||||
|
||||
### Plugin Config Form Parity
|
||||
|
||||
`test/test_field_model_parity.py` checks `build_field_model` against the
|
||||
`render_field` macro for every plugin schema it finds
|
||||
([WEB_FRONTEND_ARCHITECTURE.md](WEB_FRONTEND_ARCHITECTURE.md)). It always
|
||||
covers `plugin-repos/` and the test fixtures; point it at a checkout of the
|
||||
official plugins to cover those too:
|
||||
|
||||
```bash
|
||||
LEDMATRIX_MONOREPO_PLUGINS=../ledmatrix-plugins/plugins pytest test/test_field_model_parity.py
|
||||
```
|
||||
|
||||
### Debug a Failing Test
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
# Control socket (web → display)
|
||||
|
||||
The display process serves a Unix socket that the web interface uses to send
|
||||
it commands and get an answer back. It replaced the cache-file "mailboxes" on
|
||||
the SD card one command at a time. Stage 1 carries on-demand start, stop and
|
||||
status. Stage 2 makes those commands land within a frame on every kind of
|
||||
screen, and adds `brightness.set` and `plugin.reload`. Stage 3 adds a state
|
||||
stream (`state.get`, `state.subscribe`), so the web interface reads what the
|
||||
display is doing from the socket instead of from cache files the display
|
||||
display wrote to the SD card. Stage 4 makes the socket the only way a command goes
|
||||
while it works, and `errors.clear` replaces the last command that always
|
||||
went through a mailbox. Stage 5 removes the mailboxes: the web interface no
|
||||
longer writes them and the display no longer reads them, so the socket is
|
||||
the only way a command reaches the display (see "Without the socket"). The
|
||||
cache keys the display writes for readers stay as their fallback.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Socket | `/run/ledmatrix/control.sock` (tmpfs) |
|
||||
| Served by | the display process ([`src/ipc/server.py`](../src/ipc/server.py)), started by `DisplayController.run()` |
|
||||
| Used by | the web interface ([`src/ipc/client.py`](../src/ipc/client.py)): `POST /api/v3/display/on-demand/start` and `/stop`, `POST /api/v3/plugins/update` (reload), `POST /api/v3/config/main` (brightness), `POST /api/v3/errors/clear`; and through [`web_interface/display_state.py`](../web_interface/display_state.py) (the state stream), `GET /api/v3/display/current-status`, `/display/on-demand/status`, `/plugins/installed` (`runtime`), `/plugins/state` and the reconciliations, `/health` (`display_loop`) |
|
||||
| Contract | [`src/ipc/contract.py`](../src/ipc/contract.py): messages, versions, framing and the socket path; both sides import it |
|
||||
| Override | `LEDMATRIX_CONTROL_SOCKET=/some/path.sock` for both processes, or `=off` to disable it |
|
||||
|
||||
## Why
|
||||
|
||||
Before the socket, the web interface sent commands by writing a cache key
|
||||
(`display_on_demand_request`) that the display read every 0.25 s.
|
||||
|
||||
- **No acknowledgement.** The route answered "success" once the file was
|
||||
written, whether or not a display was running to read it.
|
||||
- **Lost requests.** The display had to read the request and then delete it.
|
||||
A request written between those two steps could be thrown away (see
|
||||
`_consume_on_demand_request`). The cache has no atomic claim to prevent it.
|
||||
- **Fragile.** Each channel repeated its own permission, atomic-write,
|
||||
staleness and in-memory-cache rules. Two of them caused bugs: a `memory_ttl`
|
||||
bug ignored every on-demand request after the first for an hour, and a
|
||||
stopped display was still reported as "active" for two minutes.
|
||||
|
||||
The socket answers every command, carries one request per message (so nothing
|
||||
can overwrite it), and belongs to the display process. If the display is not
|
||||
running, the socket does not exist, and the web interface knows right away.
|
||||
|
||||
## Protocol (version 1)
|
||||
|
||||
Stage 3 is still version 1: `state.get` and `state.subscribe` are new
|
||||
commands, and a stage-2 display answers them `unknown_command`, which the
|
||||
web interface treats as "no socket" and falls back from.
|
||||
|
||||
|
||||
**Framing.** One JSON object per line (newline-delimited JSON), UTF-8, at
|
||||
most 64 KiB per line (`MAX_MESSAGE_BYTES`). Senders encode with
|
||||
`ensure_ascii`, so a newline never appears inside a message. A connection
|
||||
can carry several requests. Each request gets exactly one response, in order.
|
||||
|
||||
**Request**
|
||||
|
||||
```json
|
||||
{"v": 1, "id": "5f0c…", "cmd": "on_demand.start",
|
||||
"args": {"plugin_id": "clock", "mode": null, "duration": 30, "pinned": false}}
|
||||
```
|
||||
|
||||
- `v` is the protocol version.
|
||||
- `id` is a printable string of 1-128 characters. It is echoed back in the
|
||||
response, and for on-demand commands it is also the on-demand `request_id`.
|
||||
- `cmd` is a command name.
|
||||
- `args` is an object. It may be omitted when a command takes no arguments.
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{"v": 1, "id": "5f0c…", "ok": true, "result": {"accepted": true, "request_id": "5f0c…", "queued": 1}}
|
||||
{"v": 1, "id": "5f0c…", "ok": false, "error": {"code": "busy", "message": "…"}}
|
||||
```
|
||||
|
||||
`id` is `null` only when the request could not be parsed far enough to have
|
||||
one. Clients branch on `error.code`, never on the message text.
|
||||
|
||||
**Commands**
|
||||
|
||||
| `cmd` | `args` | `result` | Kind |
|
||||
|---|---|---|---|
|
||||
| `hello` | `{versions: [int], client?: str}` | `{version, versions, commands, max_message_bytes, server}` | answered directly |
|
||||
| `ping` | — | `{pong: true}` | answered directly |
|
||||
| `on_demand.start` | `{plugin_id?, mode?, duration?, pinned?}` (at least one of `plugin_id` and `mode`) | ack | queued |
|
||||
| `on_demand.stop` | — | ack | queued |
|
||||
| `on_demand.status` | — | `{on_demand: {...}, current_mode, display_active}` | answered directly |
|
||||
| `brightness.set` | `{brightness: int 0-100}` | `{brightness, panel_brightness, dimmed, display_active}` | queued, awaited (2 s) |
|
||||
| `plugin.reload` | `{plugin_id}` | `{plugin_id, reloaded: true, version, modes}` | queued, awaited (10 s) |
|
||||
| `state.get` | `{since?, epoch?}` | a state snapshot (see "The state stream") | answered directly |
|
||||
| `state.subscribe` | — | a state snapshot, then pushed `state` / `tick` events | answered directly, then a stream |
|
||||
| `errors.clear` | `{cutoff: number}` (epoch seconds, finite, ≥ 0) | `{request_id, cutoff, cleared}` | answered directly, once applied |
|
||||
|
||||
`errors.clear` (stage 4) forgets the plugin errors the display recorded at
|
||||
or before `cutoff` and rewrites its error snapshot (`plugin_error_snapshot`)
|
||||
before it answers, so the web interface's next read already has it. The
|
||||
request `id` is the clear's id, which the snapshot reports as
|
||||
`applied_clear_id`. It is answered on the connection thread by a handler the
|
||||
display registers (`ControlServer(handlers=...)`, the contract's
|
||||
`DIRECT_COMMANDS`): the error aggregator and its publisher have their own
|
||||
locks, and nothing the render thread owns is touched. A display that has no
|
||||
handler answers `unknown_command`, as an older display does.
|
||||
|
||||
`duration` is a number of seconds, or a numeric string. `0`, `null` or `""`
|
||||
mean "until stopped". `pinned` must be a real boolean: the REST route has
|
||||
already converted strings like `"false"` before it sends the command. The
|
||||
`on_demand` object in `on_demand.status` is the same dict the display
|
||||
publishes to `display_on_demand_state`.
|
||||
|
||||
`brightness.set` sets the panel's normal brightness. It is transient: it
|
||||
writes nothing to `config.json`, and the next config the display's watcher
|
||||
loads (or a restart) puts the configured value back. The web interface
|
||||
sends it after it has saved the setting, so the two agree. The dim schedule
|
||||
still applies on top, so `panel_brightness` is the dim level while the
|
||||
schedule dims. While the schedule has the display off, the new level is
|
||||
kept for when it comes back on.
|
||||
|
||||
`plugin.reload` loads a plugin the display is running again from disk,
|
||||
manifest included: the steps of disabling it live and enabling it again,
|
||||
with its modes kept in their place in the rotation. Only a running plugin
|
||||
can be reloaded (`not_loaded` otherwise), so the id never makes the display
|
||||
import anything new. A plugin loaded only for an on-demand session gets
|
||||
`busy`. A new version that fails to load gets `failed` and stays out of the
|
||||
rotation, as it would after a restart. The load runs off the render thread,
|
||||
so the panel keeps scrolling while it happens (see below).
|
||||
|
||||
**Acknowledgements.** A queued on-demand command is *accepted*, not *done*.
|
||||
`{"accepted": true, "request_id": …}` means the command is waiting in the
|
||||
render thread's queue. Since stage 2 the render thread waits on that queue
|
||||
instead of sleeping, so it applies the command within one frame on every
|
||||
kind of screen (see below). Any outcome is published as before
|
||||
(`display_on_demand_state`, and `status`/`error` for a bad plugin or mode),
|
||||
and it can be read with `on_demand.status`.
|
||||
|
||||
**Awaited commands.** `brightness.set` and `plugin.reload` are answered only
|
||||
once the render thread has applied them, with their result or their error.
|
||||
The connection thread waits for that (2 s and 10 s, `AWAIT_SECONDS` in the
|
||||
contract); the render thread never waits for a client. When the render thread
|
||||
has not got to the command in time, the answer is `pending`: the command
|
||||
stays queued and is still applied, so a client treats `pending` as "not known
|
||||
to be done", not as a refusal. The client's own timeout is one second longer
|
||||
than the display's wait, so `pending` arrives before the client gives up.
|
||||
|
||||
**Versions.** Every request carries `v`. For any command except `hello`, a
|
||||
`v` the display does not speak gets `unsupported_version`. `hello` is checked
|
||||
by its `versions` list instead, and its result names the highest version both
|
||||
sides share, so a client can find out what a display supports before it
|
||||
relies on anything newer. The client sends `v: 1`, and a route answers an
|
||||
error when the display refuses it. It does not send `hello` first, which
|
||||
saves a round trip.
|
||||
|
||||
New commands are added within a version, so stage 2 is still version 1. A
|
||||
display that does not know a command answers `unknown_command`, which the
|
||||
web interface treats like any other socket failure and falls back from, and
|
||||
`hello` lists the commands a display knows.
|
||||
(Since stage 5, a command the display does not know is an error the route
|
||||
reports, telling the user to restart the display; there is no mailbox left
|
||||
to fall back to.) The version changes only when the
|
||||
envelope or the meaning of an existing command changes.
|
||||
|
||||
**Events.** `state.subscribe` is the one command with more than one message
|
||||
in reply. After its response, the display pushes events on the same
|
||||
connection until either side hangs up:
|
||||
|
||||
```json
|
||||
{"v": 1, "id": "<the subscribe id>", "event": "state", "result": {...a state snapshot...}}
|
||||
{"v": 1, "id": "<the subscribe id>", "event": "tick", "result": {"version": 7, "epoch": "…", "pid": 812, "served_at": 1790000000.1, "changed": false, "loop": {...}, "volatile": {"display": {"last_updated": 1790000000.0}, "...": "..."}}}
|
||||
```
|
||||
|
||||
An event has `event` where a response has `ok`, which is how a reader tells
|
||||
them apart. The client sends nothing after the subscribe; anything it does
|
||||
send is ignored.
|
||||
|
||||
**Error codes:** `bad_json`, `bad_request`, `message_too_large`,
|
||||
`unsupported_version`, `unknown_command`, `invalid_args`, `busy` (queue full,
|
||||
or too many connections), `forbidden` (peer credentials refused), `internal`.
|
||||
Stage 2 adds `pending` (accepted, not applied in time, still queued),
|
||||
`not_loaded` (`plugin.reload` of a plugin the display is not running) and
|
||||
`failed` (the render thread tried, and it did not work).
|
||||
|
||||
Try it on a device:
|
||||
|
||||
```bash
|
||||
python3 - <<'EOF'
|
||||
from src.ipc import client # run from the project directory
|
||||
print(client.on_demand_status())
|
||||
print(client.brightness_set(60))
|
||||
EOF
|
||||
```
|
||||
|
||||
## The state stream (stage 3)
|
||||
|
||||
Before stage 3 the web interface learned what the display was doing by
|
||||
reading files the display kept writing:
|
||||
|
||||
| What | Written by the display | How often | Medium |
|
||||
|---|---|---|---|
|
||||
| current mode, plugin, `is_display_active`, `on_demand_active` | `display_current_state` | every mode change, every flag change, and every 30 s | cache (SD card) |
|
||||
| on-demand session | `display_on_demand_state` | on each on-demand event | cache (SD card) |
|
||||
| plugin runtime snapshot (#690) | `plugin_runtime_snapshot` | on a change (at most every 10 s), else every 60 s | cache (SD card) |
|
||||
| render-loop liveness (#687) | `display-heartbeat.json` | every 5 s | tmpfs |
|
||||
|
||||
Now the display also keeps the same state in memory and serves it on the
|
||||
socket.
|
||||
|
||||
**The snapshot.** `state.get` and `state.subscribe` answer with one object:
|
||||
|
||||
```json
|
||||
{"schema": 1, "version": 42, "epoch": "3f9c0d1e2a4b5c6d", "pid": 812,
|
||||
"served_at": 1790000000.1, "changed": true,
|
||||
"loop": {"heartbeat_age_seconds": 1.8, "armed": true, "stale_after": 60.0},
|
||||
"state": {
|
||||
"display": {"mode": "nfl_live", "plugin_id": "football-scoreboard", "mode_index": 3,
|
||||
"total_modes": 9, "on_demand_active": false, "is_display_active": true,
|
||||
"last_updated": 1790000000.0},
|
||||
"on_demand": {"active": false, "status": "idle", "...": "as display_on_demand_state"},
|
||||
"brightness": {"brightness": 80, "panel_brightness": 40, "dimmed": true},
|
||||
"plugins": {"schema": 1, "running": true, "published_at": 1789999998.5, "...": "as plugin_runtime_snapshot"},
|
||||
"loop": {"heartbeat_age_seconds": 1.8, "armed": true, "stale_after": 60.0}
|
||||
}}
|
||||
```
|
||||
|
||||
- `display` and `on_demand` are the dicts the cache keys hold (`on_demand`
|
||||
includes `request_id`, the request it answers), `plugins` is
|
||||
the runtime snapshot (`build_runtime_snapshot`), and `brightness` is the
|
||||
configured level, what the panel shows now, and whether the dim schedule
|
||||
has it dimmed. A section not published yet is `null`.
|
||||
- `loop` is not published: the display measures it when it answers, from
|
||||
the render thread's last beat in memory (`RenderWatchdog.liveness()`),
|
||||
the same beat that writes the heartbeat file. So it keeps ageing while the
|
||||
render thread is stuck, and the socket's connection threads still answer.
|
||||
`heartbeat_age_seconds` is `null` until the loop has drawn its first frame.
|
||||
- `version` goes up whenever a section changes, ignoring the timestamps that
|
||||
move on every publish (`last_updated`, `remaining`, `published_at`). It
|
||||
counts within an `epoch`, one run of the display process, so a reader that
|
||||
sees a new `epoch` has a restarted display.
|
||||
- `state.get` with `since` and `epoch` from an earlier answer gets just
|
||||
`{changed: false, version, epoch, pid, served_at, loop, volatile}` while
|
||||
nothing has changed. `volatile` is `{section: {key: value}}`: the current
|
||||
values of those ignored timestamps, which the reader merges into the copy
|
||||
it has. They don't make a new version, but they are still news:
|
||||
`display.last_updated` is how a reader knows the render thread is still
|
||||
publishing, and `plugins.published_at` the runtime publisher. Without
|
||||
them a reader's copy kept the timestamps of the last real change, so a
|
||||
mode on screen for over 120 s read as unknown.
|
||||
- A snapshot that would not fit in a message (hundreds of plugins) is sent
|
||||
without `plugins`, and `truncated: ["plugins"]` says so. Readers then use
|
||||
the cache for that section only.
|
||||
|
||||
**The stream.** `state.subscribe` answers with the snapshot, then:
|
||||
|
||||
- a `state` event (a full snapshot) whenever the version changes, and
|
||||
- a `tick` at least every 5 s (`SUBSCRIBE_KEEPALIVE_SECONDS`) when nothing
|
||||
changed. It is the short `changed: false` answer, so it carries `loop`
|
||||
(a stalled render loop shows up within one tick) and `volatile` (the
|
||||
timestamps stay as fresh as the writers keep them), and it tells the
|
||||
reader the connection is alive.
|
||||
|
||||
A slow reader is never sent a backlog: each event is the latest version, so
|
||||
one that falls behind skips the versions in between. A reader that has heard
|
||||
nothing for 15 s (three keepalives) stops trusting its copy.
|
||||
|
||||
**Who publishes, and when.** All of it is in memory, with no disk writes:
|
||||
|
||||
- the render thread, at the places it already published the cache keys:
|
||||
`display` and `brightness` on every pass of
|
||||
`_publish_current_mode_state_if_changed()` (every loop pass, and every
|
||||
`_service_pending_changes()` in a dwell, a scrolling screen or Vegas), and
|
||||
`on_demand` in `_publish_on_demand_state()`. Every pass refreshes
|
||||
`display.last_updated`, so a reader can tell when the render thread has
|
||||
stopped publishing, just as the cache key's 120 s `max_age` does.
|
||||
- the plugin runtime publisher's thread, on every 5 s tick: the snapshot is
|
||||
rebuilt when the state machine changed, otherwise only its `published_at`
|
||||
moves. A change reaches subscribers within a tick, without the cache's
|
||||
10 s throttle.
|
||||
|
||||
Publishing is a hand-off, as the command queue is in the other direction.
|
||||
The hub (`StateHub` in [`src/ipc/server.py`](../src/ipc/server.py)) holds a
|
||||
lock only to swap a dict reference, compare it with the last one and bump the
|
||||
version. Every socket write happens on the subscriber's own connection
|
||||
thread. The render thread never waits for a reader.
|
||||
|
||||
### Readers in the web interface
|
||||
|
||||
[`web_interface/display_state.py`](../web_interface/display_state.py) holds
|
||||
one `state.subscribe` connection per web process
|
||||
(`src.ipc.client.StateSubscription`, a daemon thread, started on the first
|
||||
read and reconnecting with a backoff of 1 s up to 30 s). A route answers
|
||||
from the latest pushed snapshot in memory. Before the subscription has one,
|
||||
the route asks once with `state.get` (0.5 s timeout). When neither works, it
|
||||
reads the cache keys and the heartbeat file as before:
|
||||
|
||||
| Route | From the socket | Fallback |
|
||||
|---|---|---|
|
||||
| `GET /api/v3/display/current-status` | `state.display` | `display_current_state` |
|
||||
| `GET /api/v3/display/on-demand/status` | `state.on_demand`, with `remaining` worked out from `expires_at` now | `display_on_demand_state` |
|
||||
| `GET /api/v3/plugins/installed` (`runtime`), `/plugins/state`, `POST /plugins/state/reconcile` and the startup reconciliation | `state.plugins` + `state.loop` | `plugin_runtime_snapshot` + `display-heartbeat.json` |
|
||||
| `GET /api/v3/health` (`checks.display_loop`) | `state.loop` | `display-heartbeat.json` |
|
||||
|
||||
Each answer says where it came from: `source: "socket" | "cache"` (or
|
||||
`"heartbeat_file"` for the health check).
|
||||
|
||||
The SSE display stream (`/api/v3/stream/display`) reads the preview frame
|
||||
file, not a cache key, so it does not change.
|
||||
|
||||
**The same verdicts either way.** The socket's answers are judged by the
|
||||
rules the cache readers apply (#726):
|
||||
|
||||
- the runtime view is `stalled` when the render loop's heartbeat age is at
|
||||
least `HEARTBEAT_STALE_SECONDS` (60 s, the health check's threshold), and
|
||||
then reports no per-plugin facts;
|
||||
- it is `stale` when the snapshot is older than its `stale_after` (the
|
||||
publisher thread stopped);
|
||||
- with no beat yet, the snapshot is judged on its own;
|
||||
- there is no pid check, because the display that answered is alive;
|
||||
- a `display` section the render thread has not refreshed for 120 s reads
|
||||
as unknown, as the cache key does once it ages out.
|
||||
|
||||
The age a reader uses is the age the display measured, plus the time since
|
||||
the snapshot arrived.
|
||||
|
||||
### Fewer SD writes
|
||||
|
||||
The cache keys are still written, for one release, as the fallback. While
|
||||
the socket serves the readers, the display writes two of them less often.
|
||||
"Serves the readers" means a subscriber is connected, or a `state.get` came
|
||||
within the last 60 s (`StateHub.readers_active()`):
|
||||
|
||||
- `display_current_state` is no longer written on every mode change: once
|
||||
every 60 s (`CURRENT_STATE_RELAXED_REFRESH_SECONDS`, inside the readers'
|
||||
120 s `max_age`), and at once when `is_display_active` or
|
||||
`on_demand_active` changes.
|
||||
- `plugin_runtime_snapshot`'s refresh goes from 60 s to 120 s
|
||||
(`RELAXED_REFRESH_INTERVAL`), and the snapshot says so in its own
|
||||
`refresh_interval` and `stale_after` (360 s). Changes are still written at
|
||||
once, at most every 10 s.
|
||||
|
||||
`display_on_demand_state` is written only on events, so it is unchanged.
|
||||
The heartbeat file is on tmpfs, so it costs no SD writes, and it stays: the
|
||||
automatic update's health check reads it.
|
||||
|
||||
This is safe because the relaxed rate only applies while readers are using
|
||||
the socket. If they stop (the web interface loses the socket, or is stopped),
|
||||
the next publish after the reader window writes a changed mode at once, and
|
||||
the runtime refresh goes back to 60 s. A fallback reader in that window sees
|
||||
a mode up to 60 s old, never one older than its `max_age`.
|
||||
|
||||
Measured with fake clocks (`test_cache_writes_per_minute_with_and_without_socket_readers`
|
||||
in `test/test_state_stream_readers.py`), for a rotation of 15 s screens:
|
||||
|
||||
| Key | Writes/min, no socket readers | Writes/min, socket readers |
|
||||
|---|---|---|
|
||||
| `display_current_state` | 4.0 | 1.0 |
|
||||
| `plugin_runtime_snapshot` | 1.0 | 0.5 |
|
||||
| Total | 5.0 | 1.5 |
|
||||
|
||||
That is 70% fewer writes for these keys: about 2,200 a day instead of 7,200.
|
||||
Shorter screens save more, because the old rate followed the mode changes.
|
||||
A display that rarely changes mode (one plugin, a long live game) saves less. Plugin
|
||||
data caches, the error snapshot and font usage are written by other code
|
||||
and are not affected.
|
||||
|
||||
## How the display applies a command
|
||||
|
||||
The server's threads never touch rendering. A connection thread parses the
|
||||
request, validates it against the contract, and then does one of two things:
|
||||
|
||||
- For a command that changes the panel, it puts a `QueuedCommand` on a
|
||||
bounded queue (16 entries) and answers with the ack, or, for an awaited
|
||||
command, with the outcome the render thread reports back through the
|
||||
command's `CommandOutcome`.
|
||||
- For a query, it answers from a status snapshot the display provides
|
||||
(`DisplayController._control_status`). The snapshot only reads attributes.
|
||||
|
||||
The render thread drains the queue in `_poll_on_demand_requests()`:
|
||||
|
||||
- An on-demand command goes to `_handle_on_demand_request()`, which also
|
||||
handles plugins' own requests. The two paths share all of their code:
|
||||
activation, the request-id guard, error publishing, and resuming the
|
||||
rotation afterwards.
|
||||
- `brightness.set` is applied there and then (`_apply_control_brightness`),
|
||||
and the current frame is pushed again so the panel shows it.
|
||||
- `plugin.reload` starts at the top of the next loop pass, the place where
|
||||
plugins are enabled and disabled live, because there no `display()` and no
|
||||
Vegas iteration is on the stack (`_apply_pending_plugin_reloads`). Until
|
||||
then the current screen ends early, as it does for a WiFi notice: the
|
||||
frame loops, the dwell and Vegas's interrupt check all treat a pending
|
||||
reload as a reason to stop (the frame loops through the Arbiter's
|
||||
mid-screen check, `Source.RELOAD`; the dwell through
|
||||
`_plugin_reload_pending`).
|
||||
- Only the quick half of the reload runs on the render thread
|
||||
(`_start_plugin_reload`): the plugin's modes leave the rotation, its
|
||||
config subscription is dropped, and `PluginManager.detach_plugin` takes
|
||||
the instance out of `plugins`. After that nothing new calls the old
|
||||
instance: no `update()`, and no Vegas fetch. The rotation then advances
|
||||
(Vegas resumes its strip), and frames keep coming.
|
||||
- The slow half runs on a `plugin-reload-<id>` thread (`_PluginReloadJob`).
|
||||
It waits for the plugin's lock, then tears the old instance down
|
||||
(`unload_detached_plugin`) and loads the new one (`reload_plugin`). The
|
||||
lock can be held for seconds by a Vegas render of the old instance. On
|
||||
ledpi the render thread used to wait for it here, and a football reload
|
||||
froze the panel for 3.0 s.
|
||||
- The new instance joins the rotation between two frames
|
||||
(`_finish_plugin_reloads`, from `_service_pending_changes` or the top of
|
||||
the loop). Its modes go back to their old places, Vegas is told to fetch
|
||||
it again, and the command is answered.
|
||||
- While the plugin reloads, it is out of the rotation. Vegas scrolls what
|
||||
its strip already holds of it. An on-demand request for it gets
|
||||
`plugin-reloading`. A config reconcile neither loads it a second time nor
|
||||
unloads it mid-load; a disable saved meanwhile is applied once the
|
||||
reload is done. A second reload of the same plugin runs after the first.
|
||||
|
||||
Draining the queue costs no disk read, so it has no floor. A queued command
|
||||
also lets `_service_pending_changes()` skip its own 0.25 s floor.
|
||||
|
||||
### Waking the render thread (stage 2)
|
||||
|
||||
Stage 1 made the socket answer, but not land sooner: a queued command waited
|
||||
for the same polls the mailbox did. Measured on ledpi (Pi 4, 24 fps Vegas),
|
||||
a start took 1.02 s on a static screen and about 0.4 s in Vegas either way.
|
||||
Now the queue wakes the render thread:
|
||||
|
||||
- **The waits.** The server sets a `threading.Event` whenever it queues a
|
||||
command. The render thread waits on it (`ControlServer.wait_for_command`)
|
||||
where it used to sleep: the static screen's 1 s frame sleep
|
||||
(`_wait_frame_interval`) and the dwell's 0.25 s ticks
|
||||
(`_sleep_with_plugin_updates`, which also covers scheduled-off and the
|
||||
empty-rotation pause). On a wake it applies the command at once. A command
|
||||
that does not end the screen, such as a brightness, does not cut the frame
|
||||
short: the wait carries on to the end of the interval, so the plugin is
|
||||
still drawn once a second.
|
||||
- **Vegas.** The coordinator still runs its interrupt check every 10 frames,
|
||||
and now also at any frame where `urgent()` is true. The display passes
|
||||
"a control socket command is queued", which is one `Event.is_set()` per
|
||||
frame.
|
||||
- **Scrolling screens** already service pending changes every frame.
|
||||
|
||||
So a command lands within a millisecond or so on a static screen and in a
|
||||
dwell, and within one frame in Vegas and on a scrolling screen. Commands still run only on the render thread: the
|
||||
connection threads only queue them and set the event. The one exception is
|
||||
the slow half of `plugin.reload` (tearing down and loading the plugin),
|
||||
which runs on its own thread. Every change to the display's state still
|
||||
happens on the render thread.
|
||||
|
||||
The waits are timed `Event.wait()` calls: no polling, and no more wake-ups
|
||||
than the sleeps they replace when nothing arrives. Measured under WSL
|
||||
(Python 3.12, 20 s runs in the order before, after, after, before, with the
|
||||
socket's accept thread up), the idle process used 0.015–0.018% of a core
|
||||
before and 0.019–0.021% after on a static screen, and 0.035–0.037% before and
|
||||
0.047% after in a dwell: about 25 µs more per wait, from `Event.wait`'s own
|
||||
bookkeeping. A client's send to the render thread waking took 0.72 ms median
|
||||
(1.04 ms max), and a whole `brightness.set` round trip 0.64 ms median.
|
||||
|
||||
Without a socket (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) the waits are the
|
||||
plain sleeps they were.
|
||||
|
||||
**Exactly once.** A request goes over the socket once. The web route sends
|
||||
it again only while no display is listening (see "Without the socket"), so
|
||||
a display gets it at most once; a start with a request id the display has
|
||||
just processed is dropped anyway (`on_demand_request_id`). The persisted
|
||||
`display_on_demand_processed_id` guard against a mailbox replayed after a
|
||||
restart went with the mailbox.
|
||||
|
||||
## Without the socket (stage 5)
|
||||
|
||||
The client tells a request the display never had from one it had and then
|
||||
failed. `ControlError.sent` is True once the whole request was written to a
|
||||
connected display; a refusal the display sends before reading anything
|
||||
(`forbidden`, too many connections) carries no request id, and leaves it
|
||||
False. `src.ipc.client.display_not_listening()` picks out the one case a
|
||||
later retry can fix: nothing is listening (`no_socket`, `refused`) and the
|
||||
request was never sent.
|
||||
|
||||
| What happened | Example reasons | On-demand start | On-demand stop | `errors.clear` |
|
||||
|---|---|---|---|---|
|
||||
| No display listening | `no_socket`, `refused` | service stopped: `400` without `start_service`; with it, start the service and answer `202` (`status: "starting"`) at once; the dispatcher sends the request until the display takes it (up to 45 s), else `start-timeout`. Service running (still starting): the same `202`, sent for up to 10 s | `503` ("not running" / "may still be starting"); with `stop_service` the service is stopped and the route succeeds | `503` ("not running"; its errors are the last run's, and the next run starts with none) |
|
||||
| A display too old to know the command | `unknown_command`, `unsupported_version` | `503` | `503` | `503`, "restart it" |
|
||||
| No socket in this process | `disabled`, `unsupported` (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) | `503` | `503` | `503` |
|
||||
| The display had it and failed, turned it away, or never answered | `busy`, `invalid_args`, `internal`, a timeout, a hang-up, `bad_response`, `forbidden` | `503` (`400` for `invalid_args`) | `503` (with `stop_service`: stopped anyway) | `503` |
|
||||
|
||||
Every error answer carries `socket_error` (a reason code, or `other`).
|
||||
Nothing is written to the cache in any of these cases.
|
||||
|
||||
**Waiting for a display that is starting.** The socket comes up when the
|
||||
display's run loop starts, after every plugin has loaded, which can take
|
||||
longer than a client waits (the MQTT bridge gives up after 15 s). So the
|
||||
start route never waits: it answers `202` with `status: "starting"`, and
|
||||
hands the request to the web process's one dispatcher
|
||||
([`web_interface/on_demand_dispatch.py`](../web_interface/on_demand_dispatch.py)).
|
||||
Its worker thread sends the request every 0.5 s while nothing is listening,
|
||||
until the display acknowledges it or the wait runs out (45 s after a cold
|
||||
start, `START_WAIT_SECONDS`; 10 s for a service that was already running,
|
||||
`ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS`). Any other failure ends it at once.
|
||||
One start is pending at a time: a newer start replaces it, and a stop
|
||||
cancels it (the stop then succeeds even with no display listening, and
|
||||
reports `cancelled_request_id`).
|
||||
|
||||
The outcome is reported where clients already look:
|
||||
`GET /display/on-demand/status` answers the pending start's state
|
||||
(`source: "web"`, `status: "starting"`, or `status: "error"` with `error:
|
||||
"start-timeout"` or the socket's reason) until the display publishes
|
||||
something newer, and `GET /display/current-status` adds it as
|
||||
`on_demand_pending`.
|
||||
|
||||
A delivered start keeps reading as `status: "starting"`, now with
|
||||
`delivered: true`, until the display publishes the state that answers it.
|
||||
The display acknowledges a start as soon as its socket opens, but its run
|
||||
loop acts on it only after the first screen is built (about 5 s on ledpi,
|
||||
while Vegas renders its first strip), and meanwhile it publishes its own
|
||||
idle state. The display's on-demand state names the request it answers
|
||||
(`request_id`), so "answers it" means the id matches. A display older than
|
||||
that field answers with any state published after the delivery. Either
|
||||
way the delivered start is reported for at most 30 s
|
||||
(`DELIVERED_SHOWN_SECONDS`).
|
||||
|
||||
Brightness and plugin reload never had a mailbox: without the socket, the
|
||||
config watcher applies the saved brightness and a reload becomes the
|
||||
restart banner, as before.
|
||||
|
||||
### The mailboxes are gone
|
||||
|
||||
| Former mailbox | Last written by | Now |
|
||||
|---|---|---|
|
||||
| `display_on_demand_request` | the web interface on fallback (stage 4); plugins that predate `BasePlugin.request_on_demand()` | not read. A write is dropped by `CacheManager.save_cache` (`RETIRED_MAILBOX_KEYS`) and logged once per writer as a warning that names the plugin when it can be told (the plugin instance on the call stack, else the `plugin_id` in the request) |
|
||||
| `plugin_error_clear_request` | the web interface on fallback (stage 4) | not read; a write is dropped and logged the same way |
|
||||
|
||||
A file left on the SD card by an older version is never read again, so it
|
||||
is harmless. `MailboxWatch` and
|
||||
`CacheManager.file_signature`, which made a look at a mailbox one `stat()`,
|
||||
went with them.
|
||||
|
||||
The four plugins that wrote `display_on_demand_request` (birdnet-go,
|
||||
mqtt-notifications, on-air, pomodoro-timer) use `request_on_demand()` on a
|
||||
core that has it, and fall back to the mailbox only when that method is
|
||||
missing or answers `None` (no display in the process, or a full queue).
|
||||
On a stage-5 core such a fallback write is dropped with the warning above.
|
||||
|
||||
### Plugins in the display process
|
||||
|
||||
A plugin asks for the screen with `BasePlugin.request_on_demand()` and gives
|
||||
it back with `end_on_demand()` (see "On-demand display" in
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). Neither goes through
|
||||
the socket or a file: `PluginManager` hands the on-demand request,
|
||||
marked `source: 'plugin'`, to `DisplayController.submit_plugin_on_demand`,
|
||||
which queues it in memory (at most `PLUGIN_ON_DEMAND_QUEUE_SIZE`, 32) from
|
||||
whatever thread the plugin called on, and wakes the render thread through
|
||||
the socket's queue flag (`ControlServer.wake()`). The render thread applies
|
||||
it in `_drain_control_commands`, after the socket's commands, through the
|
||||
same `_handle_on_demand_request`, so it lands within a frame like a socket
|
||||
command. Without a socket it lands on the next pending-changes pass. A
|
||||
plugin's stop ends only a session that plugin owns.
|
||||
|
||||
## Robustness
|
||||
|
||||
All of this runs inside the display process, so nothing a client does may
|
||||
block the render loop or crash it:
|
||||
|
||||
- **Bounded connections.** Each connection gets its own daemon thread, with
|
||||
at most 8 at once. One more is answered `busy` and closed.
|
||||
- **Timeouts.** Each read and write times out after 2 s. A message must
|
||||
arrive whole within 5 s of its first byte. An idle connection is closed
|
||||
after 10 s. A slow or stuck client costs one thread for a few seconds.
|
||||
- **Malformed input.** A line that is not JSON gets `bad_json`, and the
|
||||
connection carries on. A line longer than 64 KiB gets `message_too_large`,
|
||||
and the connection is closed, because the next message boundary cannot be
|
||||
found. A client that disconnects mid-message is dropped silently. No
|
||||
exception from a handler leaves the connection thread.
|
||||
- **Full queue.** When the queue is full, the client gets `busy`, and the web
|
||||
interface answers `503`. A full queue means the render thread
|
||||
is stuck, and the systemd watchdog deals with that.
|
||||
- **Awaited commands.** The wait for an awaited command's outcome happens on
|
||||
its connection thread and is bounded (`AWAIT_SECONDS`), so a stuck render
|
||||
thread costs that client `pending` and one connection slot for at most
|
||||
10 s. The render thread settles an outcome without blocking; one nobody is
|
||||
waiting for any more is simply dropped.
|
||||
- **Startup.** The server binds under a temporary name, sets the mode and the
|
||||
group, then renames the socket into place, so it never appears with the
|
||||
umask's permissions. It removes a stale socket (a file that nothing is
|
||||
listening on). It never removes a live socket or a file that is not a
|
||||
socket. `close()` removes the socket only if it is still the one this
|
||||
process created.
|
||||
- **Never fatal.** If the server cannot start (Windows, no `AF_UNIX`, a bind
|
||||
failure, `LEDMATRIX_CONTROL_SOCKET=off`), it logs that and the display runs
|
||||
as before. The web interface then cannot send it commands (the routes
|
||||
answer `503`), and reads the cache keys and the heartbeat file.
|
||||
- **Subscribers (stage 3).** A `state.subscribe` connection gives its request
|
||||
slot back and takes one of 4 subscriber slots (`MAX_SUBSCRIBERS`). A fifth
|
||||
gets `busy`. So a few browsers' web processes holding streams can never
|
||||
use up the 8 slots that commands need. Each subscriber has its own thread.
|
||||
A send that cannot finish within the 2 s IO timeout (a reader that stopped
|
||||
reading) drops that subscriber. Nothing else waits for it, and the render
|
||||
thread only publishes to the hub. `close()` wakes every subscriber, so
|
||||
they end at once.
|
||||
|
||||
## Security model
|
||||
|
||||
The display runs as root and the web interface as the installing user (see
|
||||
[PERMISSIONS.md](PERMISSIONS.md)). The socket admits exactly those two, plus
|
||||
anything else in the group they share:
|
||||
|
||||
1. **The directory.** `/run/ledmatrix` is created by `RuntimeDirectory=ledmatrix`
|
||||
in `ledmatrix.service` (#687): root-owned, `0755`, on tmpfs, and removed
|
||||
when the display stops. Under an older unit, the display creates the
|
||||
directory itself as root, as it does for the heartbeat. No installer
|
||||
change is needed.
|
||||
2. **The socket file.** The file is `root:<shared group>` with mode `0660`,
|
||||
and the kernel refuses `connect()` to anyone without write permission on
|
||||
it. The shared group is the cache directory's group whenever that
|
||||
directory is group-writable. That is `ledmatrix` on an installed device
|
||||
(`/var/cache/ledmatrix` is `root:ledmatrix 2775`), and it is the same rule
|
||||
DiskCache uses for every file the two services share. Otherwise the group
|
||||
is the project directory's (`get_shared_group_gid()`, which config files
|
||||
use). With neither, the mode is `0600` and only root can connect.
|
||||
3. **Peer credentials.** Where the kernel reports them (`SO_PEERCRED`, on
|
||||
Linux), the server checks every connection again. It accepts root, the
|
||||
display's own user, or a member of the shared group: the peer's primary
|
||||
gid, or a supplementary group read from `/proc/<pid>/status`. If `/proc`
|
||||
is unreadable, it uses the group database. Any other peer gets `forbidden`
|
||||
and is disconnected. This covers a socket mode that someone loosened by
|
||||
hand.
|
||||
|
||||
The commands are deliberately narrow. They start or stop on-demand display,
|
||||
read its state, set the brightness, and reload a plugin the display is
|
||||
already running, all of which anyone who can reach the web UI can already do
|
||||
(the last by restarting the display). Nothing on the socket runs a shell,
|
||||
writes a file, or names a path, and `plugin.reload` cannot make the display
|
||||
import a plugin it was not running. Stages 2 and 3 changed none of the
|
||||
access rules above. The state stream carries what the cache keys already
|
||||
held, and those are readable by the same group. A subscriber goes through
|
||||
the same connect-time and peer-credential checks as any other connection.
|
||||
|
||||
**Development.** A display that is not root and cannot write to
|
||||
`/run/ledmatrix`, such as `python3 run.py -e` from a checkout, serves the
|
||||
socket at `$TMPDIR/ledmatrix-<uid>/control.sock`. That directory is private
|
||||
(`0700`), and the server refuses it if another user owns it. The web
|
||||
interface, run by the same user, looks there after `/run/ledmatrix`. The test
|
||||
suite sets `LEDMATRIX_CONTROL_SOCKET=off` (`test/conftest.py`), so a run on a
|
||||
device never touches the live display.
|
||||
|
||||
## Stage plan
|
||||
|
||||
1. **On-demand, with acks (done, #706).** Contract, server, client.
|
||||
`on_demand.start`/`stop`/`status`, `hello`, `ping`. The REST routes tried
|
||||
the socket first and reported `transport: "socket" | "mailbox"` (plus
|
||||
`socket_error` on fallback).
|
||||
2. **Commands that were restarts or polls (done).**
|
||||
- The render thread waits on the queue instead of sleeping, and Vegas
|
||||
checks it every frame, so a command lands within a frame on every kind
|
||||
of screen (see "Waking the render thread").
|
||||
- `brightness.set`, transient and with no `config.json` write. `POST
|
||||
/api/v3/config/main` sends it after saving a brightness and reports
|
||||
`brightness_transport`; without the socket the config watcher applies
|
||||
the saved value, as before.
|
||||
- `plugin.reload`, which replaces the `restart_required` answer from #688
|
||||
for a store update of an enabled plugin. `POST /api/v3/plugins/update`
|
||||
answers `restart_required: false, reloaded: true` once the new code
|
||||
runs, and falls back to the restart banner (with `reload_error`)
|
||||
otherwise.
|
||||
- `config.reload` was left out. Its only gain over the config watcher
|
||||
would be skipping the watcher's 2 s mtime poll, and the one setting
|
||||
where those seconds show, brightness, now has its own command. Plugin
|
||||
settings already reach the running plugin through the watcher, and the
|
||||
"which sections changed" ack had no reader: the web interface knows
|
||||
what it saved. A reload from the socket thread would also run every
|
||||
config subscriber on a second thread beside the watcher's.
|
||||
3. **A state stream (done).** `state.get` (a versioned snapshot) and
|
||||
`state.subscribe` (the snapshot, then pushed changes and keepalive ticks)
|
||||
carry the current mode, the on-demand state (including the outcome of an
|
||||
acked on-demand command), the brightness, the plugin runtime snapshot and
|
||||
the render loop's liveness, all served from memory (see "The state
|
||||
stream"). The web interface's readers use it and fall back to the cache
|
||||
keys and the heartbeat file. `display_current_state` and
|
||||
`plugin_runtime_snapshot` are written less often while it serves them.
|
||||
The keys remain for one release.
|
||||
- Left for later: the outcome of a `plugin.reload` that answered
|
||||
`pending` is visible only as the plugin's new `loaded_version` in
|
||||
`state.plugins`, not as an event of its own.
|
||||
- Left for later: the SSE display stream reads the preview frame, not
|
||||
state, so nothing relays the stream to the browser yet. A browser still
|
||||
polls the REST routes, which now answer from memory.
|
||||
- Left for later: the store's install of an already-enabled plugin, and
|
||||
an uninstall that keeps its config, still answer `restart_required`.
|
||||
They can now use a load/unload command and report the result the same
|
||||
way the update route does.
|
||||
4. **The mailboxes become a fallback (done).**
|
||||
- The web interface writes a mailbox only when the socket could not carry
|
||||
the request (`should_fall_back`); a display that had it and failed is
|
||||
answered as that.
|
||||
- `errors.clear` replaces `plugin_error_clear_request` as the way a clear
|
||||
reaches the display.
|
||||
- The display looks at the on-demand mailbox once a second while the
|
||||
socket is up, reads either mailbox only when its file changed, and logs
|
||||
who still writes the on-demand one.
|
||||
- Not changed, deliberately: config saves (the schedule, the dim
|
||||
schedule, plugin settings) still reach the display through
|
||||
`config.json` and its watcher, which is the setting itself rather than
|
||||
a message; see `config.reload` under stage 2. The preview viewer marker
|
||||
(`/tmp/led_matrix_preview_viewer`) is a presence signal the display
|
||||
already stats at most once a second. Plugin health and metrics resets
|
||||
write the persisted record the display publishes and do not reach the
|
||||
running display (their routes say so); they are not mailboxes.
|
||||
5. **Remove the mailboxes (done, the release after 3.8.1).** The web
|
||||
interface no longer writes `display_on_demand_request` or
|
||||
`plugin_error_clear_request`, and the display no longer reads them (see
|
||||
"Without the socket"). When no display is listening, the start route
|
||||
starts the service if asked, answers `202`, and the web process's
|
||||
dispatcher sends the request once the socket is up; every other failure
|
||||
is an error the route reports. A write to
|
||||
either key is dropped with a one-time warning naming the writer. The
|
||||
display still writes `display_current_state`, `display_on_demand_state`
|
||||
and `plugin_runtime_snapshot`: the web interface reads them whenever the
|
||||
socket cannot answer (a stopped or starting display, a web user not yet
|
||||
in the socket's group, a platform without Unix sockets), and
|
||||
`display_on_demand_config` is the display's own record for resuming a
|
||||
session after a restart. Retiring those keys is left for later.
|
||||
|
||||
## Checking it on a device
|
||||
|
||||
```bash
|
||||
ls -l /run/ledmatrix/control.sock # srw-rw---- root ledmatrix
|
||||
sudo journalctl -u ledmatrix | grep "Control socket"
|
||||
curl -s -X POST localhost:5000/api/v3/display/on-demand/start \
|
||||
-H 'Content-Type: application/json' -d '{"plugin_id":"clock","duration":20}'
|
||||
# ... "transport": "socket"
|
||||
```
|
||||
|
||||
On an error, `socket_error` gives the reason. `no_socket` means the display
|
||||
is stopped or still starting. `refused` usually means the web user is not in
|
||||
the socket's group, which takes effect when the web service restarts after
|
||||
the user is added. `busy`, `timeout` and the like mean the display had the
|
||||
request and did not take it. Nothing is ever written to a mailbox.
|
||||
|
||||
An error clear:
|
||||
|
||||
```bash
|
||||
curl -s -X POST localhost:5000/api/v3/errors/clear \
|
||||
-H 'Content-Type: application/json' -d '{"all":true}'
|
||||
# ... "applied": true, "transport": "socket"
|
||||
sudo journalctl -u ledmatrix | grep -E "Cleared .* plugin error|retired"
|
||||
```
|
||||
|
||||
Brightness and a plugin reload:
|
||||
|
||||
```bash
|
||||
curl -s -X POST localhost:5000/api/v3/config/main \
|
||||
-H 'Content-Type: application/json' -d '{"brightness":40}'
|
||||
# ... "brightness_transport": "socket"
|
||||
curl -s -X POST localhost:5000/api/v3/plugins/update \
|
||||
-H 'Content-Type: application/json' -d '{"plugin_id":"clock-simple"}'
|
||||
# after a real update of an enabled plugin: "restart_required": false, "reloaded": true
|
||||
sudo journalctl -u ledmatrix | grep -E "Brightness set|Reload(ing|ed) plugin"
|
||||
```
|
||||
|
||||
`unknown_command` in `brightness_socket_error` or `reload_error` means the
|
||||
display runs a stage-1 build: restart it once to pick up this one.
|
||||
|
||||
The state stream:
|
||||
|
||||
```bash
|
||||
curl -s localhost:5000/api/v3/display/current-status # ... "source": "socket"
|
||||
curl -s localhost:5000/api/v3/health | python3 -m json.tool | grep -A3 display_loop
|
||||
python3 - <<'EOF'
|
||||
from src.ipc import client # run from the project directory
|
||||
snap = client.state_get()
|
||||
print(snap['version'], snap['epoch'], snap['loop'], snap['state']['display'])
|
||||
EOF
|
||||
```
|
||||
|
||||
`"source": "cache"` means the web interface could not use the socket: the
|
||||
display is stopped, predates stage 3, or the web user is not in the
|
||||
socket's group.
|
||||
+116
-150
@@ -1,9 +1,10 @@
|
||||
# Offscreen Rendering
|
||||
|
||||
**Status (2026-09-24):** step 1, offscreen rendering, is implemented
|
||||
**Status (2026-09-30):** offscreen rendering is implemented
|
||||
(`DisplayManager.offscreen()`, the adapter on the prefetch thread, the plugin
|
||||
lock). Steps 2 and 3 are proposed. When all three land, this file becomes the
|
||||
reference for how plugin content is rendered off the render thread.
|
||||
lock), and so are live elements, which grew out of steps 2 and 3 below: see
|
||||
*Live elements*. The segment strip proposed as step 2 was not needed; *Why not
|
||||
a SegmentStrip* says why.
|
||||
|
||||
First soak of step 1 on hdpi (50 px/s, `pwm_bits` 7, preview open, 8-minute
|
||||
runs, A/B/B/A):
|
||||
@@ -153,136 +154,101 @@ particular keeps presenting while a plugin draws elsewhere.
|
||||
fetch left is the inline fallback when no prepared group is ready, which in
|
||||
practice is the first extension. Prefetching at start removes that too.
|
||||
|
||||
## Keeping live content fresh
|
||||
## Live elements: content that changes while it scrolls
|
||||
|
||||
Offscreen rendering is also what makes fresh sports scores possible. Today a
|
||||
plugin's segment is drawn when its group is prefetched, and the strip carries
|
||||
7,000–10,000 px of content ahead of the viewport (hdpi logs: "7153px still
|
||||
ahead", "9842px ahead"). At ~100 px/s, a score drawn now reaches the screen
|
||||
70–100 seconds later. When a plugin reports new data, Vegas only drops its
|
||||
cache (`invalidate_pending_updates`), so the change is drawn on the plugin's
|
||||
*next* turn, several minutes later. A segment already in the strip scrolls by
|
||||
with the data it was drawn with.
|
||||
Offscreen rendering is also what makes fresh content possible. A plugin's
|
||||
segment is drawn when its group is prefetched, and the strip carries
|
||||
7,000-10,000 px of content ahead of the viewport, so at ~100 px/s a score drawn
|
||||
then reaches the screen 70-100 seconds later -- and once in the strip it never
|
||||
changed: when a plugin reported new data, Vegas only dropped its caches, so the
|
||||
change appeared on the plugin's *next* turn, minutes later.
|
||||
|
||||
That was the right trade while every redraw of a canvas-bound plugin stalled
|
||||
the scroll. Off the render thread a redraw costs the scroll nothing, so the
|
||||
strip can afford three things.
|
||||
A plugin can now hand Vegas **live elements** instead of pictures
|
||||
(`BasePlugin.get_vegas_elements()`, see "Live Vegas elements" in
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#live-vegas-elements)):
|
||||
named, fixed-width pieces of content -- one per game card, one for a map. Vegas
|
||||
records where each lands in the strip and, when the plugin's data changes,
|
||||
redraws just the changed ones off the render thread and copies their pixels
|
||||
over the old ones between two frames. A card already crossing the panel
|
||||
changes; nothing next to it moves.
|
||||
|
||||
### 1. Refresh at the gate
|
||||
### Why not redraw every frame
|
||||
|
||||
Before a segment enters the viewport, check whether its plugin has updated
|
||||
since the segment was drawn. If it has, redraw it offscreen and replace it
|
||||
while it is still out of sight. Width changes are fine here, because
|
||||
everything from that segment onward is still invisible.
|
||||
On a Pi the render thread has about 4 ms of slack per refresh at 512x64 after
|
||||
the ~6 ms blit, and a scoreboard card is ~29 ms of Pillow work that holds the
|
||||
GIL. Drawing on the render thread is out of the question at any rate, so the
|
||||
render thread only ever *copies* pixels that are already drawn. Measured on a
|
||||
Pi 4 (ledpi): writing a 35 KB card into a 20,000 px strip takes 8.5 µs, a
|
||||
101 KB map 17 µs, four cards (the per-frame cap) 34 µs -- against 124 µs for
|
||||
the viewport slice every frame already does.
|
||||
|
||||
The gate sits `lead` pixels ahead of the viewport's right edge:
|
||||
`lead = max(one screen, speed × (render time + margin))`. The render time is
|
||||
the plugin's own, measured on each render (sports cards take the longest,
|
||||
hundreds of ms up to seconds per the prefetch notes). A plugin whose render
|
||||
does not finish before its segment reaches the viewport keeps the old segment.
|
||||
The scroll never waits for it.
|
||||
### How an update reaches the screen
|
||||
|
||||
Content is then at most `lead / speed` seconds old when it appears, a few
|
||||
seconds instead of minutes, without changing how far ahead the rotation
|
||||
fetches.
|
||||
1. A plugin's `update()` completes. The update worker calls
|
||||
`PluginManager._note_update_completed`, which calls the update listeners
|
||||
(`add_update_listener`) there and then, with the plugin's lock still held.
|
||||
Vegas's listener moves the plugin's **epoch** on
|
||||
(`src/vegas_mode/elements.py`, `LiveEpochs`) and wakes the live worker.
|
||||
2. The **live worker** (`src/vegas_mode/live_worker.py`), the one background
|
||||
thread that draws for the strip once it holds a live element, finds the
|
||||
plugin's elements whose recorded epoch is older than its current one,
|
||||
nearest the screen first, and calls `get_vegas_elements()` under the
|
||||
plugin's lock (0.25 s wait, then a 1 s backoff). Elements whose `version`
|
||||
is unchanged cost nothing; the rest are pinned and checksummed, and each
|
||||
whose pixels changed becomes a patch in a one-per-element slot (the latest
|
||||
wins).
|
||||
3. Between two frames the render thread
|
||||
(`RenderPipeline.apply_live_patches`, from `coordinator.run_frame`) pops at
|
||||
most four patches or two screens of bytes and copies each into the strip
|
||||
with `ScrollHelper.patch_columns`. It takes no lock and draws nothing. A
|
||||
patch made for an older strip, for an element trimmed away or already
|
||||
behind the screen, or from older data than the strip shows, is dropped.
|
||||
|
||||
### 2. Replace ahead of the screen
|
||||
End to end, a new score reaches a card already on screen within one poll of
|
||||
the data source (30 s for live games) plus about a second: the listener is
|
||||
immediate, and while live elements exist the update tick that schedules
|
||||
plugins runs every second instead of every four.
|
||||
|
||||
When a plugin reports new data (the Vegas update tick already names them), any
|
||||
of its segments that are **anywhere ahead of the viewport** are redrawn and
|
||||
replaced straight away, not only at the gate. That covers the long stretch of
|
||||
strip between prefetch and the gate.
|
||||
Elements that change with **time** rather than data (an aircraft moving
|
||||
between position reports) ask for `refresh_hz`; the worker calls
|
||||
`redraw_vegas_element()` -- without the plugin's lock, from state the plugin
|
||||
publishes in one assignment -- that often while the element is on or within
|
||||
`live_lead_screens` of the screen, capped by `live_max_hz` (5), at 1 Hz
|
||||
without the render gate, and halved for an element whose redraws average over
|
||||
50 ms.
|
||||
|
||||
### 3. Update on screen
|
||||
### Geometry
|
||||
|
||||
A segment that is already **visible** is patched in place when the redrawn
|
||||
version has the same geometry: the same total width, and the same width for
|
||||
each card (a sports plugin returns one image per game, joined with
|
||||
`intra_plugin_gap`). Scoreboard cards keep a fixed layout, so a score change
|
||||
patches in and the digits update as the card scrolls past. The patch is a
|
||||
pixel copy of one card (a 150×64 card is ~29 KB) applied by the render thread
|
||||
between frames, so a frame never shows half of a patch.
|
||||
A live element is never trimmed to its ink: the adapter pads it with
|
||||
`content_padding` black columns either side and pins its width, and a redraw
|
||||
at any other width is refused (it shows the next time the plugin comes round).
|
||||
Records keep **absolute** strip columns -- the strip column plus everything
|
||||
trimmed off the front since the strip was composed -- so a trim moves one
|
||||
origin rather than every record. Nothing on screen is ever moved, inserted or
|
||||
resized; a game added to a slate appears on the plugin's next turn.
|
||||
|
||||
When the geometry differs (a game added or dropped, a card that grew), the
|
||||
visible part cannot change without a jump. Only the cards not yet on screen
|
||||
are replaced, and only if the geometry up to that point is unchanged. Otherwise
|
||||
the segment keeps its snapshot until it has scrolled off.
|
||||
### Why not a SegmentStrip
|
||||
|
||||
### Avoiding wasted work
|
||||
The proposal here was to replace the single strip with a list of segments.
|
||||
In-place patching of the single strip meets every goal without that: the
|
||||
patch is O(element) and the strip layout never changes. What a segment list
|
||||
would still buy is cheaper extensions, and most of that came from making the
|
||||
strip's PIL copy lazy instead (`ScrollHelper.cached_image`: an extension used
|
||||
to rebuild it twice, 1.7-3.8 ms each on a Pi 4). The `extend` row of
|
||||
`frame_soak.py`'s "after work" table says whether the rest is worth it.
|
||||
|
||||
- **Change detection.** `run_scheduled_updates_with_changes()` names a plugin
|
||||
whenever its `update()` ran, not when its data changed. On hdpi
|
||||
`clock-simple` and `ledmatrix-music` are named on every 4-second tick. A
|
||||
redraw whose pixels hash the same as the segment's is discarded without a
|
||||
swap.
|
||||
- **Redraw on real updates only.** Vegas makes no API calls. Each plugin
|
||||
fetches on its own schedule, and a redraw is triggered only when the
|
||||
plugin's `update()` has run since its segment was drawn. On hdpi live
|
||||
football, baseball and hockey poll every 30 s (live odds every 60 s,
|
||||
everything else hourly), so a live sports card is redrawn once per poll.
|
||||
- **Floor.** A plugin is redrawn at most once per
|
||||
`vegas_scroll.refresh_min_interval` (proposed 10 s), and never while its
|
||||
previous redraw is still running. The floor never holds back a sports card
|
||||
polling every 30 s. It exists for chatty plugins: `clock-simple` updates
|
||||
every second and `ledmatrix-music` polls every 2 s.
|
||||
- **One worker.** Redraws go through the same background worker as prefetch,
|
||||
one plugin at a time at `nice 10`, under the plugin's lock.
|
||||
### When it is off
|
||||
|
||||
Data freshness is still bounded by each plugin's own fetch interval (how often
|
||||
it polls live scores). Drawing faster cannot beat the data source.
|
||||
|
||||
### The strip becomes a list of segments
|
||||
|
||||
All three need the strip to be replaceable by segment. Today it is one
|
||||
image (`ScrollHelper.cached_array`, 8,000–20,000 px wide, 1.5–3.8 MB), and
|
||||
`append_content()` rebuilds the whole thing on the render thread for every
|
||||
appended block. That is also a pause source.
|
||||
|
||||
Proposed `SegmentStrip`, used by Vegas in place of the single image:
|
||||
|
||||
- an ordered list of segments: plugin id, card boundaries, a pixel array, the
|
||||
render time, and the plugin data version it was drawn from, plus its
|
||||
x-offset in the strip;
|
||||
- `visible(x, width)` assembles the viewport by slicing across at most a few
|
||||
segments: the same ~100 KB copy per frame that slicing the single image
|
||||
costs today;
|
||||
- append and trim become O(block) list operations, not a copy of the strip;
|
||||
- replace swaps one list entry and shifts the offsets of the segments after it
|
||||
(dozens at most). A same-geometry patch copies pixels into the existing array.
|
||||
|
||||
Every mutation is prepared off the render thread and applied by the render
|
||||
thread at a frame boundary, so the strip the render loop reads is never
|
||||
half-changed.
|
||||
|
||||
### Multi-display sync
|
||||
|
||||
The follower renders from its own copy of the strip, offset from the leader's
|
||||
scroll position. Today the leader sends that copy whole, and only in
|
||||
`start_new_cycle()` (`send_scroll_image`), plus the scroll position every
|
||||
frame. Continuous scroll, the default, extends and trims the strip without
|
||||
starting a new cycle, and nothing sends those changes. From reading the code,
|
||||
the follower therefore probably falls out of step after the first extension
|
||||
already, before any of this design. That is untested; it needs a two-Pi rig.
|
||||
|
||||
With a segment strip, keeping the follower identical becomes **replaying the
|
||||
leader's operations**:
|
||||
|
||||
- Every strip mutation (append, trim, replace, patch) is one operation in
|
||||
strip coordinates. The leader applies it and sends the same operation to the
|
||||
follower over the existing TCP channel. Segments are small: a card is ~29 KB
|
||||
raw and compresses well.
|
||||
- Operations on off-screen segments apply on arrival. A patch to a segment
|
||||
that is on either panel carries an *apply at scroll position X* stamp a
|
||||
couple of hundred milliseconds ahead. Both sides apply it when their scroll
|
||||
position passes X, so both panels change on the same frame, within the
|
||||
existing position-sync jitter.
|
||||
- Each operation carries a sequence number. A follower that sees a gap (a
|
||||
reconnect, a dropped message) asks for a full snapshot, which is today's
|
||||
`send_scroll_image` path.
|
||||
|
||||
That also fixes the probable continuous-mode gap as a side effect, since
|
||||
appends and trims become operations too. Until it is in place, fresh-content
|
||||
updates are disabled while sync is active.
|
||||
- `display.vegas_scroll.live_refresh: false` (the kill switch; also in the
|
||||
web UI), or `vegas_live: false` in one plugin's section.
|
||||
- Always under multi-display sync: the follower mirrors whole strips only, so
|
||||
a patch would never reach it. (Continuous-mode sync has a separate problem:
|
||||
the follower is not sent extensions or trims at all.)
|
||||
- In swap mode (`continuous_scroll: false`) and with `offscreen_prefetch:
|
||||
false`.
|
||||
- For plugins without the hook, which are drawn and placed exactly as before,
|
||||
and on the paths that fetch without the plugin's lock (the first strip of a
|
||||
run, the render thread's fallback fetch), which use `get_vegas_content()`.
|
||||
|
||||
## Risks, and what was checked
|
||||
|
||||
@@ -328,11 +294,10 @@ updates are disabled while sync is active.
|
||||
source of the single-refresh late frames. Holding frames for two refreshes
|
||||
(≈50 px/s) doubles the budget. Cutting the blit itself is the native-presenter
|
||||
step.
|
||||
- **Live refreshes pushed from `update()`.** Some sports plugins call
|
||||
`display()` and `update_display()` from inside `update()`, which runs on the
|
||||
update worker and can push to the panel mid-Vegas. That is a separate
|
||||
hazard. `offscreen()` gives a tool for it (run the update worker offscreen
|
||||
while Vegas owns the panel), but it is out of scope here.
|
||||
- **Multi-display sync in continuous mode.** The follower is sent the whole
|
||||
strip only at a new cycle and on connect, never the extensions and trims of
|
||||
continuous mode, so it drifts from the leader after the first extension.
|
||||
Live elements stay off under sync for that reason.
|
||||
|
||||
## Test plan
|
||||
|
||||
@@ -348,14 +313,15 @@ updates are disabled while sync is active.
|
||||
- **Emulator integration:** a stub canvas-bound plugin whose `display()` sleeps
|
||||
300 ms. The Vegas render loop never goes a frame without presenting (frame
|
||||
timing recorder: zero freezes).
|
||||
- **Unit, `SegmentStrip`:** the viewport assembled across segment boundaries
|
||||
matches slicing one concatenated image, pixel for pixel. Append, trim,
|
||||
replace-ahead and same-geometry patch each leave every other column
|
||||
unchanged. A geometry-changing patch of a visible segment is refused.
|
||||
- **Freshness:** a stub sports plugin whose score changes every second. The
|
||||
score on screen is never older than `lead / speed` plus the plugin's fetch
|
||||
interval. A visible card's digits change without the frame-timing recorder
|
||||
seeing a late frame. An unchanged redraw is discarded.
|
||||
- **Live elements** (`test/test_vegas_live_*.py`,
|
||||
`test/test_vegas_elements_*.py`, `test/test_scroll_helper_patch.py`): every
|
||||
record points at exactly its element's pixels through any sequence of
|
||||
compose, extend and trim; a patch changes only its element's columns (a
|
||||
property test against a twin strip that is never patched); the render
|
||||
thread's apply takes no lock and draws nothing; the worker's priorities,
|
||||
floors, backoff and hand-over; and, end to end on the emulator with the stub
|
||||
plugin (`test/fixtures/plugins/vegas-live-stub`), an update changes a card
|
||||
already in the strip and an animated element moves with no update at all.
|
||||
- **Hardware:** an hdpi soak, A/B against the #628 build, alternating order.
|
||||
Targets: no freezes, an empty 6+ bucket, the 3–5 bucket near zero, and the late
|
||||
rate below 0.66%. Plus, for freshness: log each segment's age when it enters
|
||||
@@ -363,26 +329,26 @@ updates are disabled while sync is active.
|
||||
|
||||
## Rollout
|
||||
|
||||
Three changes, each soaked on hdpi before the next:
|
||||
1. **Offscreen rendering** (shipped): `offscreen()`, the adapter on the
|
||||
prefetch thread, and the plugin lock. Removed the render-thread pauses.
|
||||
2. **Measurement and the lazy strip image:** late frames attributed to the
|
||||
render-thread work before them (`FrameTimingRecorder.note_op`, the "after
|
||||
work" table), and extensions no longer rebuilding the strip's PIL copy.
|
||||
3. **Live elements:** the plugin API, the records, the worker and in-place
|
||||
patches, with the sports scoreboards and the flight map adopting it.
|
||||
4. **Live games in the ticker by default:** `live_in_ticker` true, so a live
|
||||
game's cards update in the marquee instead of the full-screen scoreboard
|
||||
replacing it; existing configs are switched once (`ConfigManager`).
|
||||
|
||||
1. **Offscreen rendering:** `offscreen()`, the adapter on the prefetch thread,
|
||||
and the plugin lock. Removes the render-thread pauses.
|
||||
2. **`SegmentStrip`:** Vegas's strip becomes a list of segments. Removes the
|
||||
whole-strip copy on append. No visible behaviour change.
|
||||
3. **Fresh content:** refresh at the gate, replace ahead, patch on screen,
|
||||
with change detection and the rate limit.
|
||||
|
||||
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores today's
|
||||
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores the
|
||||
deferred path when `false`, and `display.vegas_scroll.live_refresh` (default
|
||||
`true`) turns off step 3. Keep both for one release, then delete the old paths.
|
||||
`true`) turns live elements off. Keep both for one release, then delete the
|
||||
old paths.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Keep the kill switch, or ship without one?
|
||||
2. Plugin lock timeout: skip the plugin and keep its cached segment (proposed),
|
||||
or wait longer?
|
||||
3. `refresh_min_interval`: 10 s proposed. It only limits chatty plugins;
|
||||
live sports are redrawn once per 30 s poll regardless.
|
||||
4. Multi-display sync: is there a two-Pi rig to test on? Operation replay is
|
||||
proposed as part of the segment strip (step 2), with fresh content
|
||||
disabled under sync until it has been verified on real hardware.
|
||||
1. Multi-display sync: is there a two-Pi rig to test on? Replaying strip
|
||||
operations to the follower (append, trim, patch, in absolute columns) would
|
||||
fix continuous-mode sync and let live elements run under it.
|
||||
2. Is the `extend` cost worth a segment list after the lazy image? The soak's
|
||||
"after work" table answers it per rig.
|
||||
|
||||
+22
-1
@@ -29,8 +29,13 @@ in again (services pick them up on restart).
|
||||
| `assets/` | web user | dirs `755`, files `644` | Root writes downloaded logos regardless |
|
||||
| `/var/cache/ledmatrix/` | `root:ledmatrix` | `2775` (setgid) | Shared cache: see below |
|
||||
| Cache files | creator : `ledmatrix` | `660` | |
|
||||
| `/run/ledmatrix/` | `root` | `755` | tmpfs; `RuntimeDirectory=` in `ledmatrix.service`, removed when the display stops |
|
||||
| `/run/ledmatrix/control.sock` | `root` : cache directory's group (`ledmatrix`) | `660` | The display's control socket; only root and that group can connect. See [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md#security-model) |
|
||||
| `scripts/fix_perms/safe_plugin_rm.sh`, `safe_pip_install.sh` | `root:root` | `755` | Run as root through sudo, so the web user must not be able to edit them |
|
||||
| `/etc/sudoers.d/ledmatrix_web`, `ledmatrix_wifi` | `root` | `440` | |
|
||||
| `/usr/local/sbin/ledmatrix-refresh-units` | `root:root` | `755` | Copy of `scripts/install/ledmatrix_refresh_units.py`, installed by `install_service.sh`. Outside the project so the web user cannot edit what sudo runs |
|
||||
| `/var/lib/ledmatrix/unit-backup/` | `root` | `700` | The units the last refresh replaced, for the automatic update's rollback |
|
||||
| `/etc/systemd/system/ledmatrix*.service`, `.path` | `root:root` | `644` | Readable so the web interface can compare them with the templates after an update |
|
||||
|
||||
What keeps it that way at runtime:
|
||||
|
||||
@@ -84,6 +89,20 @@ password:
|
||||
- `journalctl -u ledmatrix.service *`, `-u ledmatrix *`, `-t ledmatrix *`,
|
||||
tagged `NOEXEC`: journalctl opens a pager on a terminal, and a shell
|
||||
escape from that pager would be a root shell
|
||||
- `/usr/local/sbin/ledmatrix-refresh-units ""` and
|
||||
`/usr/local/sbin/ledmatrix-refresh-units --restore` — exactly these two
|
||||
command lines (`""` means "no arguments"). After an update the first
|
||||
installs the systemd units whose templates changed and runs
|
||||
`systemctl daemon-reload`; the automatic update's rollback runs the second
|
||||
to put the previous units back. The helper takes nothing from the caller:
|
||||
the project folder and the web user come from the installed, root-owned
|
||||
`ledmatrix.service` and `ledmatrix-web.service`. It only replaces the four
|
||||
units `install_service.sh` installs, only if they are already installed,
|
||||
and refuses a template that would change a unit's `User=` (root for the
|
||||
display, the web user for the rest) or `WorkingDirectory=`, or that is a
|
||||
symlink, not a regular file, or over 64 KB. It grants nothing new: the
|
||||
templates are files the web user can edit, but so is `run.py`, which the
|
||||
display service already runs as root.
|
||||
|
||||
### `/etc/sudoers.d/ledmatrix_wifi`
|
||||
|
||||
@@ -134,7 +153,9 @@ directory.
|
||||
| `safe_plugin_rm.sh`, `safe_pip_install.sh` | — | Called by the web interface through sudo | Not for manual use |
|
||||
|
||||
To reinstall the sudoers rules, run
|
||||
`./scripts/install/configure_web_sudo.sh` (web rules) or
|
||||
`./scripts/install/configure_web_sudo.sh` (web rules; the
|
||||
`ledmatrix-refresh-units` rules also need the helper itself, which
|
||||
`sudo ./scripts/install/install_service.sh` installs) or
|
||||
`./scripts/install/configure_wifi_permissions.sh` (WiFi rules and polkit) as
|
||||
the web user, not with `sudo`.
|
||||
|
||||
|
||||
+277
-117
@@ -14,6 +14,7 @@ Complete API reference for plugin developers. This document describes all method
|
||||
- [Display Manager](#display-manager)
|
||||
- [Cache Manager](#cache-manager)
|
||||
- [Plugin Manager](#plugin-manager)
|
||||
- [Fetching data](#fetching-data)
|
||||
- [Deprecated APIs](#deprecated-apis)
|
||||
|
||||
---
|
||||
@@ -308,9 +309,9 @@ 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
|
||||
Only consulted while `vegas_scroll.live_in_ticker` is on (the default since
|
||||
3.8.0). With it off 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
|
||||
@@ -387,6 +388,79 @@ The width Vegas wants this plugin's content to occupy, from the plugin's
|
||||
`display_manager` while it asks for content, so a plugin that sizes itself
|
||||
from `display_manager.width` does not need to read this.
|
||||
|
||||
#### Live Vegas elements
|
||||
|
||||
*New in core 3.8.0.* Content from `get_vegas_content()` is baked into the
|
||||
ticker's strip when the plugin's turn is prefetched, so a score drawn then
|
||||
scrolls past with that score however many goals are scored while it crosses
|
||||
the panel. A plugin that returns **live elements** instead gets them updated
|
||||
in place: after its `update()` the ticker asks again, compares each element
|
||||
with what the strip holds, and swaps the changed ones in between two frames
|
||||
-- on screen included -- without anything next to them moving.
|
||||
|
||||
```python
|
||||
try:
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
except ImportError: # core older than 3.8.0: the hook is never called
|
||||
VegasElement = None
|
||||
|
||||
class MyScoreboard(BasePlugin):
|
||||
def get_vegas_elements(self):
|
||||
if VegasElement is None:
|
||||
return None
|
||||
return [VegasElement(key=f"game:{g['id']}",
|
||||
image=self._card(g), # cache by fingerprint
|
||||
version=self._fingerprint(g)) # changes iff pixels would
|
||||
for g in self.games]
|
||||
```
|
||||
|
||||
`VegasElement(key, image, version=None, live=True, refresh_hz=0.0)`:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `key` | Names the element across redraws; unique in the list, stable for the same logical item (`"game:nfl:401547417"`, `"map"`). |
|
||||
| `image` | The element now, at the display's height. A live element's **width must not depend on its data**: a redraw at another width is never swapped in (it appears the next time the plugin comes round), because nothing on screen may move. |
|
||||
| `version` | Anything hashable that changes exactly when the pixels would. Handed back with the **same image object** as last time, it lets the ticker skip converting the element; a new image is always converted and compared by its pixels, so a redraw for new settings is never missed. `None` means "compare pixels". |
|
||||
| `live` | `False` places it as plain content (trimmed, never refreshed): separators, decoration. |
|
||||
| `refresh_hz` | For content that changes with **time** rather than data (an aircraft moving between position reports): the ticker calls `redraw_vegas_element()` about this often while the element is on or near the screen, capped by `vegas_scroll.live_max_hz` and at 1 Hz without the rebuilt rgbmatrix binding. |
|
||||
|
||||
**`get_vegas_elements() -> Optional[List[VegasElement]]`** — called on the
|
||||
ticker's background thread under the plugin's lock (never while `update()`
|
||||
runs), on a canvas of its own and told its render width, exactly like
|
||||
`get_vegas_content()`. It is called after every `update()` while any of the
|
||||
plugin's elements is on or ahead of the screen, so it must be cheap when
|
||||
nothing changed (cache images by version), idempotent, and must not fetch.
|
||||
Return `None` to use `get_vegas_content()`, which a plugin must keep working
|
||||
for older cores and for the paths that do not ask for elements (the ticker's
|
||||
first strip, multi-display sync, the `live_refresh` switch).
|
||||
|
||||
**`redraw_vegas_element(key, width, height, at) -> Optional[PIL.Image]`** —
|
||||
only for elements with `refresh_hz`. Called **without** the plugin's lock,
|
||||
possibly while `update()` runs, so read only state `update()` replaces in one
|
||||
assignment (an immutable snapshot), never state it mutates in place. `at` is
|
||||
the `time.monotonic()` the pixels are expected on the panel: draw the element
|
||||
as it should look then. Return exactly `width` x `height`, or `None` to skip
|
||||
the tick.
|
||||
|
||||
**`notify_vegas_data_changed()`** — data that arrives outside `update()` (a
|
||||
background thread, a push callback) calls this so the ticker redraws without
|
||||
waiting for the next `update()`. Safe from any thread.
|
||||
|
||||
Live elements are never trimmed to their ink: the ticker pads each with
|
||||
`content_padding` black columns either side, the margin trimming would have
|
||||
left. A single element wider than the plugin's width budget
|
||||
(`vegas_max_width_screens`, not counting that padding) is cropped like any
|
||||
other content and scrolls by as plain, no longer live. The user can turn them off per plugin with `vegas_live: false` (a
|
||||
core-owned property) or for the whole ticker with
|
||||
`display.vegas_scroll.live_refresh: false`; they are always off under
|
||||
multi-display sync.
|
||||
|
||||
`scripts/check_plugin.py` checks the contract for any plugin that implements
|
||||
the hook (unique keys, height, width stable with no new data, redraw size,
|
||||
slow calls) and prints a `vegas elements` row; the checks are in
|
||||
`src/plugin_system/testing/vegas.py`. `test/fixtures/plugins/vegas-live-stub`
|
||||
is a small working example.
|
||||
|
||||
#### Legacy: `get_vegas_content_type()` and `get_vegas_display_mode()`
|
||||
|
||||
Superseded by participation, and still read to derive it when neither the
|
||||
@@ -414,6 +488,88 @@ working for the plugin itself. `get_vegas_segment_width()` read the
|
||||
`vegas_panel_count` config value, which has never affected Vegas — a card's
|
||||
width comes from `get_vegas_content()` and `vegas_width_pct`.
|
||||
|
||||
### On-demand display
|
||||
|
||||
A plugin that reacts to something outside the rotation (an MQTT message, a
|
||||
timer, a detection) can take the screen for it, and give it back. Both
|
||||
methods are safe from any thread, including an MQTT callback: they only
|
||||
queue the request, and the display applies it on its render thread within a
|
||||
frame or so, exactly like an on-demand start or stop from the web interface.
|
||||
|
||||
#### `request_on_demand(mode=None, duration=None, pinned=False) -> Optional[str]`
|
||||
|
||||
Show this plugin now.
|
||||
|
||||
- `mode`: one of the plugin's display modes; `None` for its first.
|
||||
- `duration`: seconds before the rotation resumes; `None` (or `0`) for no
|
||||
limit, until `end_on_demand()` or the user stops it.
|
||||
- `pinned`: stay on `mode` instead of cycling through the plugin's other
|
||||
modes.
|
||||
|
||||
Returns the request id once the display has queued it, or `None` when
|
||||
there is no display in this process to ask (the web interface's plugin
|
||||
manager, `scripts/check_plugin.py`) or its queue is full. A bad argument
|
||||
(a `mode` that is not a string, a `duration` that is not a number) raises
|
||||
`ValueError`.
|
||||
|
||||
#### `end_on_demand() -> Optional[str]`
|
||||
|
||||
Give the screen back. Ends only a session this plugin owns: a session the
|
||||
user started for another plugin, or one that already ended, is left alone.
|
||||
Returns the request id once queued, or `None` as above.
|
||||
|
||||
#### Older cores: feature detection
|
||||
|
||||
These methods are new after core 3.8.0 (see `CHANGELOG.md`). Before them,
|
||||
plugins wrote the `display_on_demand_request` cache key (the "mailbox")
|
||||
themselves. **The mailbox is gone** (see
|
||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5): the display no
|
||||
longer reads it, and a write to it is dropped with a warning in the log,
|
||||
once per plugin:
|
||||
|
||||
```
|
||||
Ignored a write to the retired 'display_on_demand_request' cache key by plugin 'my-plugin': ...
|
||||
```
|
||||
|
||||
A plugin that only needs to run on cores with these methods calls them and
|
||||
treats `None` as "no display took it":
|
||||
|
||||
```python
|
||||
def _show_alert(self):
|
||||
if self.request_on_demand(mode="my_alert", duration=15) is None:
|
||||
self.logger.info("No display to show the alert on")
|
||||
```
|
||||
|
||||
A plugin that must also work on cores before them can keep the mailbox
|
||||
write as its fallback, guarded by `hasattr`: on those cores the display
|
||||
still reads it, and on a current core the write is only dropped and logged
|
||||
(when the method is missing it never runs at all). Raise
|
||||
`ledmatrix_min_version` to the release that added the methods once you no
|
||||
longer need it.
|
||||
|
||||
```python
|
||||
import time, uuid
|
||||
|
||||
def _show_alert(self):
|
||||
if hasattr(self, "request_on_demand"):
|
||||
self.request_on_demand(mode="my_alert", duration=15)
|
||||
return
|
||||
# A core older than request_on_demand(): the mailbox it still reads.
|
||||
self.cache_manager.set("display_on_demand_request", {
|
||||
"request_id": str(uuid.uuid4()), "action": "start",
|
||||
"plugin_id": self.plugin_id, "mode": "my_alert",
|
||||
"duration": 15, "pinned": False, "timestamp": time.time(),
|
||||
})
|
||||
```
|
||||
|
||||
`end_on_demand()` ends only the plugin's own session; a stop written to the
|
||||
mailbox on an older core ends any session, whoever started it.
|
||||
|
||||
Both methods answer a request id only when the plugin manager returned a
|
||||
string, so a test that gives the plugin a `MagicMock()` plugin manager gets
|
||||
`None`. To test the path where the display takes the request, set
|
||||
`plugin_manager.request_on_demand.return_value = "some-id"`.
|
||||
|
||||
> The full source for `BasePlugin` lives in
|
||||
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
||||
> source, the source wins — please open an issue or PR to fix the doc.
|
||||
@@ -555,18 +711,6 @@ self.display_manager.update_display()
|
||||
|
||||
This is the canonical way to render arbitrary images.
|
||||
|
||||
### Weather Icons (deprecated)
|
||||
|
||||
> Deprecated, removed in 3.8.0 — draw your own icons (the weather plugin
|
||||
> ships `WeatherIcons`). See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
- `draw_weather_icon(condition, x, y, size=16)` — icon for a condition
|
||||
string such as `"clear"`, `"clouds"`, `"rain"`, `"snow"`, `"storm"`
|
||||
- `draw_sun(x, y, size=16)`, `draw_cloud(x, y, size=16, color=(200, 200, 200))`,
|
||||
`draw_rain(x, y, size=16)`, `draw_snow(x, y, size=16)`
|
||||
- `draw_text_with_icons(text, icons=None, x=None, y=None, color=(255, 255, 255))`
|
||||
— text plus a list of `(icon_type, x, y)` icons; calls `update_display()`
|
||||
|
||||
### Scrolling State Management
|
||||
|
||||
For plugins that implement scrolling content, use these methods to coordinate with the display system.
|
||||
@@ -657,20 +801,6 @@ Process any deferred updates if not currently scrolling. Called automatically by
|
||||
|
||||
**Note**: Plugins typically don't need to call this directly.
|
||||
|
||||
#### `get_scrolling_stats() -> dict`
|
||||
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get current scrolling statistics for debugging.
|
||||
|
||||
**Returns**: Dictionary with scrolling state information
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
stats = self.display_manager.get_scrolling_stats()
|
||||
self.logger.debug(f"Scrolling: {stats['is_scrolling']}, Deferred: {stats['deferred_count']}")
|
||||
```
|
||||
|
||||
### Available Fonts
|
||||
|
||||
The Display Manager provides several pre-loaded fonts:
|
||||
@@ -800,27 +930,6 @@ Get data with automatic strategy detection from cache key.
|
||||
data = self.cache_manager.get_with_auto_strategy("nhl_live_scores")
|
||||
```
|
||||
|
||||
#### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]`
|
||||
|
||||
> Deprecated, removed in 3.8.0 — use `get()`. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get background service cached data with sport-specific intervals.
|
||||
|
||||
**Parameters**:
|
||||
- `key` (str): Cache key
|
||||
- `sport_key` (str, optional): Sport identifier (e.g., 'nhl', 'nba') for live interval lookup
|
||||
|
||||
**Returns**: Cached data, or `None` if not found or stale
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
# Uses sport-specific live_update_interval from config
|
||||
games = self.cache_manager.get_background_cached_data(
|
||||
"nhl_games",
|
||||
sport_key="nhl"
|
||||
)
|
||||
```
|
||||
|
||||
### Strategy Methods
|
||||
|
||||
#### `get_cache_strategy(data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]`
|
||||
@@ -839,23 +948,6 @@ strategy = self.cache_manager.get_cache_strategy("sports_live", sport_key="nhl")
|
||||
max_age = strategy['max_age'] # Get configured max age
|
||||
```
|
||||
|
||||
#### `get_sport_live_interval(sport_key: str) -> int`
|
||||
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get the live_update_interval for a specific sport from config.
|
||||
|
||||
**Parameters**:
|
||||
- `sport_key` (str): Sport identifier (e.g., 'nhl', 'nba')
|
||||
|
||||
**Returns**: Live update interval in seconds
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
interval = self.cache_manager.get_sport_live_interval("nhl")
|
||||
# Returns configured live_update_interval for NHL
|
||||
```
|
||||
|
||||
#### `get_data_type_from_key(key: str) -> str`
|
||||
|
||||
Extract data type from cache key to determine appropriate cache strategy.
|
||||
@@ -865,17 +957,6 @@ Extract data type from cache key to determine appropriate cache strategy.
|
||||
|
||||
**Returns**: Inferred data type string
|
||||
|
||||
#### `get_sport_key_from_cache_key(key: str) -> Optional[str]`
|
||||
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Extract sport key from cache key for sport-specific strategies.
|
||||
|
||||
**Parameters**:
|
||||
- `key` (str): Cache key
|
||||
|
||||
**Returns**: Sport identifier, or `None` if not found
|
||||
|
||||
### Utility Methods
|
||||
|
||||
#### `clear_cache(key: Optional[str] = None) -> None`
|
||||
@@ -913,30 +994,6 @@ for file_info in files:
|
||||
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
|
||||
```
|
||||
|
||||
### Metrics Methods (deprecated)
|
||||
|
||||
#### `get_cache_metrics() -> Dict[str, Any]`
|
||||
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get cache performance metrics.
|
||||
|
||||
**Returns**: Dictionary with cache statistics (`total_requests`, `cache_hit_rate`, `background_hit_rate`, `api_calls_saved`, `average_fetch_time`, etc.)
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
metrics = self.cache_manager.get_cache_metrics()
|
||||
self.logger.info(f"Cache hit rate: {metrics['cache_hit_rate']:.2%}")
|
||||
```
|
||||
|
||||
#### `get_memory_cache_stats() -> Dict[str, Any]`
|
||||
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get memory cache statistics.
|
||||
|
||||
**Returns**: Dictionary with memory cache stats (size, max_size, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Plugin Manager
|
||||
@@ -975,14 +1032,6 @@ for plugin_id, plugin in all_plugins.items():
|
||||
self.logger.info(f"Plugin {plugin_id} is loaded")
|
||||
```
|
||||
|
||||
#### `get_enabled_plugins() -> List[str]`
|
||||
|
||||
> Deprecated, removed in 3.8.0 — check `enabled` on the instances in `plugin_manager.plugins`. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get list of enabled plugin IDs.
|
||||
|
||||
**Returns**: List of plugin identifier strings
|
||||
|
||||
#### `get_plugin_info(plugin_id: str) -> Optional[Dict[str, Any]]`
|
||||
|
||||
Get plugin information including manifest and runtime info.
|
||||
@@ -999,6 +1048,14 @@ if info:
|
||||
self.logger.info(f"Plugin: {info['name']}, Version: {info.get('version')}")
|
||||
```
|
||||
|
||||
#### `request_on_demand(plugin_id, mode=None, duration=None, pinned=False)` / `end_on_demand(plugin_id)`
|
||||
|
||||
What `BasePlugin.request_on_demand()` and `end_on_demand()` call, with the
|
||||
plugin's own id. Call those instead; see
|
||||
[On-demand display](#on-demand-display). The display controller routes them
|
||||
to itself with `set_on_demand_handler()`; a plugin manager without a
|
||||
display behind it answers `None`.
|
||||
|
||||
#### `get_all_plugin_info() -> List[Dict[str, Any]]`
|
||||
|
||||
Get information for all plugins.
|
||||
@@ -1064,6 +1121,103 @@ if weather is not None and weather.enabled:
|
||||
|
||||
---
|
||||
|
||||
## Fetching data
|
||||
|
||||
Use the core helpers for HTTP rather than a `requests.Session` of your own:
|
||||
`APIHelper` (`from src.common import APIHelper`) for JSON APIs, and
|
||||
`fetch_espn_scoreboard()` (`src.common.espn_dates`) or
|
||||
`BackgroundDataService` for ESPN scoreboards. Since the release after 3.7.0
|
||||
these go through the core **fetch service** (`src/common/fetch_service.py`),
|
||||
so a plugin that uses them gets the following with no code change. Return
|
||||
values, exceptions and retries are what they were.
|
||||
|
||||
- **Shared connections.** Core sessions with the same retry policy share one
|
||||
connection pool per host, instead of one pool per helper.
|
||||
- **Merged requests.** Identical GETs in flight at the same time (same URL
|
||||
and query, headers, timeout and retry policy) go to the network once, and
|
||||
every caller gets its own copy of the response, or the same exception.
|
||||
- **Host budgets.** A host can have a token-bucket budget. A request past it
|
||||
waits for a token, but never longer than `max_wait_seconds` (2 s by
|
||||
default). Only ESPN hosts have one by default (20 requests a second, burst
|
||||
200), which normal use never reaches.
|
||||
- **Conditional GET.** When a server sends `ETag` or `Last-Modified`, the
|
||||
next identical request revalidates, and a `304 Not Modified` comes back to
|
||||
your code as the original `200` with its body. ESPN currently sends
|
||||
neither, so this does nothing there.
|
||||
- **Response cache.** A response whose server says `Cache-Control:
|
||||
max-age=N` answers an identical GET for those N seconds without a
|
||||
request (ESPN sends 1 to ~500 s). It never hands you a response older
|
||||
than you accept: pass `cache_max_age=<your TTL>` to `fetch_get()` or
|
||||
`fetch_espn_scoreboard()` (0 always asks the network); without it a
|
||||
response is reused for at most 30 seconds.
|
||||
- **Counters.** Requests, merged requests, bytes, 304s, errors and time spent
|
||||
waiting, and requests answered without the network (`memo_hits` from the
|
||||
response cache, `cache_hits` from a shared scoreboard cache entry), are
|
||||
counted per plugin and per host, and published for the web UI
|
||||
at `GET /api/v3/plugins/fetch-stats` (see
|
||||
[REST_API_REFERENCE.md](REST_API_REFERENCE.md#get-fetch-statistics)). A
|
||||
request is counted against your plugin when it runs inside your
|
||||
`update()`/`display()`, your constructor or `on_enable()`, or anywhere in
|
||||
code under your plugin's directory, including threads you start.
|
||||
|
||||
What is not covered yet: requests a plugin makes with its own `requests.get()`
|
||||
or `Session.get()` calls. They work as before but are invisible to the
|
||||
budgets and counters.
|
||||
|
||||
### One cache key per ESPN scoreboard
|
||||
|
||||
Cache an ESPN scoreboard under `espn_scoreboard_cache_key(sport, league,
|
||||
dates)` (`src.common.espn_dates`), not a key of your own, so every plugin
|
||||
showing that league shares one fetch and one cached copy. `sport` and
|
||||
`league` are ESPN's path segments (`football`, `college-football`), and
|
||||
`dates` is what you send as `dates=` (`"20261004"`, `"202610"`,
|
||||
`"20260925-20261016"`, a `date`, or `None` for the undated scoreboard).
|
||||
|
||||
```python
|
||||
from src.common.espn_dates import get_espn_scoreboard
|
||||
|
||||
data = get_espn_scoreboard(
|
||||
self.session, "football", "nfl", "20261004",
|
||||
cache_manager=self.cache_manager,
|
||||
max_age=300, # your TTL: nothing older comes back
|
||||
legacy_keys=["my_old_key_20261004"], # read once while upgrading
|
||||
)
|
||||
```
|
||||
|
||||
`get_espn_scoreboard` returns a cached copy at most `max_age` seconds old,
|
||||
whoever wrote it, and otherwise fetches with `fetch_espn_scoreboard`
|
||||
(`limit=500`, ranges split the way ESPN requires) and caches the result
|
||||
without a ttl, so each reader applies its own age limit. `max_age=0` always
|
||||
fetches but still leaves the copy for others. For a two-step read, use
|
||||
`read_espn_scoreboard_cache()` and `store_espn_scoreboard_cache()` around
|
||||
your own fetch. Scoreboards built on `SportsFetchMixin` get
|
||||
`_schedule_cache_key(datestring)` and `_cached_schedule(key, legacy_keys)`
|
||||
for their schedule windows. All of this is in the core release after 3.8.0.
|
||||
|
||||
The settings live in `config.json` under `fetch_service`, read when the
|
||||
display starts and on a config reload:
|
||||
|
||||
```json
|
||||
"fetch_service": {
|
||||
"enabled": true,
|
||||
"max_wait_seconds": 2,
|
||||
"rate_limits": {
|
||||
"*.espn.com": {"per_second": 20, "burst": 200},
|
||||
"api.example.com": {"per_second": 1, "burst": 5}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`rate_limits` keys are a host or a `*.domain` pattern (which also matches
|
||||
the bare domain); `"per_second": 0` removes a budget. `"enabled": false`
|
||||
turns the whole service into a plain `session.get()`. Two further switches,
|
||||
`"single_flight": false` and `"conditional_get": false`, turn off merging and
|
||||
revalidation. `"response_cache": {"enabled": false}` turns off the response
|
||||
cache; its `default_max_age` (30) is the limit for callers that pass no
|
||||
`cache_max_age`.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Caching
|
||||
@@ -1148,13 +1302,19 @@ if weather is not None and weather.enabled:
|
||||
|
||||
## Deprecated APIs
|
||||
|
||||
These still work but log a warning the first time they are called
|
||||
(`journalctl -u ledmatrix` shows which one), and are **removed in 3.8.0**
|
||||
(first announced for 3.7.0, which shipped with them still in place).
|
||||
[DEPRECATIONS_3.8.md](DEPRECATIONS_3.8.md) is the usage scan behind that
|
||||
decision: which of these the official plugins, the registry's third-party
|
||||
plugins and core still call or override. Only methods that scan reports unused
|
||||
are removed in 3.8.0; the rest stay until their callers migrate.
|
||||
A deprecated method still works but logs a warning the first time it is
|
||||
called (`journalctl -u ledmatrix` shows which one), until the release that
|
||||
removes it. [DEPRECATIONS_3.8.md](DEPRECATIONS_3.8.md) is the usage scan
|
||||
behind each removal: which of the deprecated methods the official plugins,
|
||||
the registry's third-party plugins and core still call or override. Only
|
||||
methods that scan reports unused are removed; the rest stay until their
|
||||
callers migrate.
|
||||
|
||||
### Removed in 3.8.0
|
||||
|
||||
Deprecated in 3.5.0 with a warning on first call, and gone in 3.8.0:
|
||||
the scan found no caller in any official or third-party plugin. Calling one
|
||||
now raises `AttributeError`.
|
||||
|
||||
| Object | Methods | Instead |
|
||||
|---|---|---|
|
||||
|
||||
@@ -47,6 +47,17 @@ is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`):
|
||||
`src/plugin_system/base_plugin.py`; see
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-participation)
|
||||
|
||||
6. **`vegas_live`** (boolean; no default, unset means on)
|
||||
- Description: for a plugin with live Vegas elements (it implements
|
||||
`get_vegas_elements()`), whether the ticker changes what is already
|
||||
scrolling when the plugin's data changes. `false` shows each card as it
|
||||
was when drawn, as before live elements existed
|
||||
- Ignored by plugins without live elements, and whenever live elements
|
||||
are off for the whole ticker (`display.vegas_scroll.live_refresh`)
|
||||
- Read by `PluginAdapter.is_live_capable()` in
|
||||
`src/vegas_mode/plugin_adapter.py`; see
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#live-vegas-elements)
|
||||
|
||||
`skin` and `skin_options` were core properties until the skin system was
|
||||
removed. A plugin config saved with them still loads and saves; the keys are
|
||||
dropped on the next save (see `RETIRED_PLUGIN_KEYS` in `schema_manager.py`).
|
||||
|
||||
@@ -519,15 +519,12 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
|
||||
- `draw_text()` - Text rendering. For images, paste directly onto
|
||||
`display_manager.image` (a PIL Image) and call `update_display()`;
|
||||
there is no `draw_image()` helper method.
|
||||
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons
|
||||
(deprecated, removed in 3.8.0 — draw your own icons)
|
||||
- `get_text_width()`, `get_font_height()` - Text utilities
|
||||
- `set_scrolling_state()`, `defer_update()` - Scrolling state management
|
||||
|
||||
**Cache Manager** (`self.cache_manager`):
|
||||
- `get()`, `set()`, `delete()` - Basic caching
|
||||
- `get_cached_data_with_strategy()` - Advanced caching with strategies
|
||||
- `get_background_cached_data()` - deprecated, removed in 3.8.0 — use `get()`
|
||||
|
||||
**Plugin Manager** (`self.plugin_manager`):
|
||||
- `get_plugin()`, `get_all_plugins()` - Access other plugins
|
||||
|
||||
@@ -72,6 +72,8 @@ Going deeper:
|
||||
## Contributing to LEDMatrix itself
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — processes, display loop, plugin system, web UI; where to start reading
|
||||
- [WEB_FRONTEND_ARCHITECTURE.md](WEB_FRONTEND_ARCHITECTURE.md) — the web UI's ES modules, page lifecycle and form model, and the page-by-page migration to them
|
||||
- [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md) — the display's control socket: protocol, security model, stage plan
|
||||
- [DEVELOPMENT.md](DEVELOPMENT.md) — environment setup
|
||||
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite
|
||||
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace
|
||||
|
||||
+233
-35
@@ -159,11 +159,21 @@ there an unchecked checkbox — which the browser omits — is saved as
|
||||
}
|
||||
```
|
||||
|
||||
`restart_required` is always true here: display hardware, rotation,
|
||||
durations and general settings take effect when the display restarts, and
|
||||
the web UI shows its restart banner on the flag. (Plugin sections saved
|
||||
through this route reach the running plugin live, like
|
||||
`POST /plugins/config`.)
|
||||
`restart_required` is true when the save changed a setting that takes
|
||||
effect when the display restarts: display hardware, rotation order,
|
||||
timezone, general settings and the rest. The web UI shows its restart banner
|
||||
on the flag. It is false when the save changed only what the running display
|
||||
applies by itself, or nothing: `brightness`, the per-mode durations
|
||||
(`duration__<mode>`, `display.display_durations`) and plugin sections, which
|
||||
reach the running plugin live, like `POST /plugins/config`.
|
||||
|
||||
A saved `brightness` reaches the panel without a restart. The route also
|
||||
sends it to the running display over the control
|
||||
socket (`brightness.set`), which puts it on the panel at once, and the
|
||||
response adds `"brightness_transport": "socket"`. Otherwise it is
|
||||
`"config"`, with `brightness_socket_error` giving the reason, and the
|
||||
display's config watcher applies the saved value within a few seconds, as
|
||||
before.
|
||||
|
||||
Invalid values (e.g. an out-of-range `target_fps`, a hardware option the
|
||||
Raspberry Pi 5 driver cannot use) are rejected with `400` and nothing is
|
||||
@@ -238,7 +248,10 @@ Replace the schedule configuration.
|
||||
```
|
||||
|
||||
A day whose `<day>_enabled` key is absent counts as enabled, with default
|
||||
times `07:00`-`23:00`. At least one day must be enabled.
|
||||
times `07:00`-`23:00`. An enabled schedule needs at least one day enabled; a
|
||||
disabled one (`"enabled": false`) may have every day off, as
|
||||
`config.template.json` ships it. A day that is off keeps the times sent for
|
||||
it, when they are valid `HH:MM`.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -323,12 +336,23 @@ by the display process (stale after 120 seconds).
|
||||
"data": {
|
||||
"mode": "nfl_live",
|
||||
"plugin_id": "football-scoreboard",
|
||||
"last_updated": 1234567890.123
|
||||
"last_updated": 1234567890.123,
|
||||
"source": "socket"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When nothing has been published, every field is `null`.
|
||||
When nothing has been published, every field is `null`. `source` is
|
||||
`socket` when the answer came from the display's state stream over the
|
||||
control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), and `cache`
|
||||
when it came from the `display_current_state` cache key (no socket: the
|
||||
display is stopped or older, or this is Windows). A display whose render
|
||||
loop has not refreshed its state for 120 seconds is reported with every
|
||||
field `null`, either way. So is a stopped display: when the socket does not
|
||||
answer and the render loop's heartbeat
|
||||
(`/run/ledmatrix/display-heartbeat.json`) is absent, stale or from a process
|
||||
that is gone, the cache's last entry is not used. A display still beating
|
||||
without a socket, Windows, or a socket switched off reads the cache.
|
||||
|
||||
### List Display Modes
|
||||
|
||||
@@ -405,11 +429,16 @@ Get the current on-demand display state.
|
||||
"returncode": 0,
|
||||
"stdout": "active",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
"source": "socket"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`source` is `socket` (the display's state stream, with `remaining` worked
|
||||
out at the time of the request) or `cache` (the `display_on_demand_state`
|
||||
cache key).
|
||||
|
||||
With no on-demand request, `state` is
|
||||
`{"active": false, "status": "idle", "last_updated": null}`.
|
||||
|
||||
@@ -435,7 +464,7 @@ Request a specific plugin to display on-demand.
|
||||
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
||||
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
||||
- `pinned` (boolean, optional): Pin display (pause rotation)
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within about a quarter of a second. When false and the service is stopped, the route returns 400.
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within a frame over its control socket. A stopped one is started, and the route answers `202` at once (see below); the request is sent once the display's socket is up, which can take as long as the display takes to load its plugins. When false and the service is stopped, the route returns 400.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -447,13 +476,63 @@ Request a specific plugin to display on-demand.
|
||||
"mode": "nfl_live",
|
||||
"duration": 45,
|
||||
"pinned": true,
|
||||
"service": { "active": true, "returncode": 0, "stdout": "", "stderr": "" }
|
||||
"service": { "active": true, "returncode": 0, "stdout": "", "stderr": "" },
|
||||
"transport": "socket"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`service` is `null` when `start_service` is false.
|
||||
|
||||
`transport` is always `"socket"`: the display's control socket acknowledged
|
||||
the request (it is queued for the render thread, which wakes for it and
|
||||
applies it within a frame; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)).
|
||||
The `"mailbox"` value earlier releases could answer is gone with the
|
||||
mailbox: nothing is written to the cache.
|
||||
|
||||
**No display listening yet** (`no_socket`, `refused`: the service is
|
||||
stopped, or still loading its plugins). With the service stopped and
|
||||
`start_service` false, `400`. Otherwise the route starts the service if
|
||||
needed and answers at once:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "starting",
|
||||
"message": "The display service is starting; ...",
|
||||
"data": {"request_id": "uuid-here", "plugin_id": "football-scoreboard", "mode": "nfl_live",
|
||||
"duration": 45, "pinned": true, "service": {"active": true, "started": true},
|
||||
"transport": "socket", "socket_error": "no_socket",
|
||||
"pending": true, "wait_seconds": 45.0}
|
||||
}
|
||||
```
|
||||
|
||||
with HTTP `202`. The web process sends the request until the display takes
|
||||
it, for up to `wait_seconds` (45 after a cold start, 10 when the service was
|
||||
already running). Follow it with `GET /api/v3/display/on-demand/status`:
|
||||
its `state` is `{status: "starting", source: "web", request_id, ...}` while
|
||||
it waits, and still `starting` with `delivered: true` once the display has
|
||||
acknowledged it but not yet published the state for that `request_id` (at
|
||||
most 30 s), then the display's own state, or `{status: "error",
|
||||
error: "start-timeout"}` (or the socket's reason) if it never was;
|
||||
`GET /api/v3/display/current-status` carries the same as
|
||||
`on_demand_pending`. A newer start replaces a pending one; a stop cancels it.
|
||||
|
||||
Otherwise, when the display did not take the request, the route answers an
|
||||
error with `status: "error"` and `data: {request_id, transport: "socket",
|
||||
socket_error}`:
|
||||
|
||||
- a full queue (`busy`), no answer after the request was sent (`timeout`,
|
||||
`closed`), a display older than the command (`unknown_command`), no
|
||||
socket in the web process (`disabled`, `unsupported`): `503` at once;
|
||||
- bad arguments (`invalid_args`): `400`.
|
||||
|
||||
The stop route answers the same errors (`503` when no display is
|
||||
listening, with a message saying whether the service is stopped), except
|
||||
that with `stop_service: true` it still stops the service and answers
|
||||
success, with `socket_error` set, and that a stop which cancelled a pending
|
||||
start succeeds with `cancelled_request_id` even when no display is
|
||||
listening.
|
||||
|
||||
### Stop On-Demand Display
|
||||
|
||||
**POST** `/api/v3/display/on-demand/stop`
|
||||
@@ -476,11 +555,14 @@ Stop the current on-demand display.
|
||||
"status": "success",
|
||||
"data": {
|
||||
"request_id": "uuid-here",
|
||||
"service": null
|
||||
"service": null,
|
||||
"transport": "socket"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`transport` and the errors are as for start; a stop is never retried.
|
||||
|
||||
---
|
||||
|
||||
## Plugins
|
||||
@@ -529,7 +611,9 @@ List all installed plugins with their status and metadata.
|
||||
"status": "live",
|
||||
"published_at": 1790000030.0,
|
||||
"age_seconds": 12.4,
|
||||
"stale_after": 180.0
|
||||
"stale_after": 180.0,
|
||||
"heartbeat_age_seconds": 2.1,
|
||||
"source": "socket"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -550,10 +634,18 @@ until the display restarts. A plugin a live snapshot does not list is
|
||||
`loaded: false`, `state: "unloaded"`.
|
||||
|
||||
`runtime.status` says whether to believe them: `live` (fresh snapshot from
|
||||
a running display), `stale` (not refreshed within `stale_after` seconds: the
|
||||
display is hung or died), `stopped` (the display shut down) or `unknown`
|
||||
a running display), `stalled` (fresh snapshot, but the same process's
|
||||
render-loop heartbeat is 60 s or older -- the render loop is hung, as
|
||||
[`/health`](#health-check)'s `display_loop: stalled` says), `stale` (not refreshed
|
||||
within `stale_after` seconds, or the process that wrote it no longer exists:
|
||||
the display is hung or died), `stopped` (the display shut down) or `unknown`
|
||||
(nothing published yet). Unless it is `live`, every one of those fields is
|
||||
`null`. Health and metrics are at [`/plugins/health`](#get-plugin-health)
|
||||
`null`. `heartbeat_age_seconds` is the heartbeat's age when it was taken into
|
||||
account, `null` otherwise (no heartbeat, as on the dev server, or one from
|
||||
another process). `runtime.source` is `socket` when the snapshot and the
|
||||
heartbeat age came from the display's state stream over the control socket,
|
||||
and `cache` when they came from the `plugin_runtime_snapshot` cache key and
|
||||
the heartbeat file; the rules above are the same for both. Health and metrics are at [`/plugins/health`](#get-plugin-health)
|
||||
and `/plugins/metrics`.
|
||||
|
||||
`vegas_participation` is what Vegas mode does with the plugin: `"scroll"`,
|
||||
@@ -799,8 +891,29 @@ Update a plugin to the latest version. Runs synchronously.
|
||||
```
|
||||
|
||||
`update_status` is `updated`, `up_to_date` or `local_only`.
|
||||
`restart_required` is true when the plugin changed and is enabled: the
|
||||
running display keeps the code it loaded until it restarts.
|
||||
|
||||
When the plugin changed and is enabled, the route asks the running display
|
||||
to reload it over the control socket (`plugin.reload`, see
|
||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)). Once the new code is
|
||||
running, the answer is:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plugin football-scoreboard updated to version 2.1.0; the display is running the new version",
|
||||
"restart_required": false,
|
||||
"reloaded": true,
|
||||
"reloaded_version": "2.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
If the display could not reload it, `restart_required` is true (the running
|
||||
display keeps the code it loaded until it restarts) and `reload_error` says
|
||||
why: `no_socket` (the display is stopped or predates the socket),
|
||||
`unknown_command` (a display older than this command), `not_loaded`,
|
||||
`failed` (the new version did not load; it is out of the rotation),
|
||||
`pending` (not done within 10 s; it will still be reloaded), or another
|
||||
transport reason.
|
||||
|
||||
An update this core cannot run answers `409` with `Plugin update refused:`
|
||||
and the reason; the installed version is left as it was.
|
||||
@@ -967,6 +1080,76 @@ Metrics for one plugin; `data` has the same fields as one entry above.
|
||||
|
||||
Reset metrics for a plugin.
|
||||
|
||||
### Get Fetch Statistics
|
||||
|
||||
**GET** `/api/v3/plugins/fetch-stats`
|
||||
|
||||
Network requests made through the core fetch service
|
||||
(`src/common/fetch_service.py`), per plugin and per host, cumulative since
|
||||
the display started. Read-only. The display publishes the counters at most
|
||||
once a minute when they change (every 10 minutes otherwise), so they can be
|
||||
up to a minute old. Requests a plugin makes with its own `requests` calls,
|
||||
outside `APIHelper`, `espn_dates`, `BackgroundDataService` and
|
||||
`BaseOddsManager`, are not counted yet.
|
||||
|
||||
`data.status` is `live`, `stale` (no publish for longer than
|
||||
`stale_after`), `stopped` (the display exited; the last counters are kept)
|
||||
or `unknown` (nothing published; `data.data` is `null`).
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"status": "live",
|
||||
"age_seconds": 12.4,
|
||||
"data": {
|
||||
"schema": 1,
|
||||
"running": true,
|
||||
"published_at": 1790000000.0,
|
||||
"stale_after": 720.0,
|
||||
"since": 1789990000.0,
|
||||
"totals": {"requests": 412, "merged": 3, "not_modified": 0,
|
||||
"errors": 1, "http_errors": 2, "retries": 0,
|
||||
"throttled": 0, "overruns": 0, "bytes": 18234011,
|
||||
"wait_seconds": 0.0, "memo_hits": 21, "cache_hits": 40,
|
||||
"legacy_cache_hits": 2},
|
||||
"plugins": {
|
||||
"football-scoreboard": {"requests": 240, "merged": 2, "bytes": 9120330,
|
||||
"hosts": {"site.api.espn.com": 180,
|
||||
"sports.core.api.espn.com": 62},
|
||||
"...": "the other counters, as in totals"}
|
||||
},
|
||||
"hosts": {
|
||||
"site.api.espn.com": {"requests": 301, "...": "as in totals"}
|
||||
},
|
||||
"validators": {"entries": 0, "bytes": 0},
|
||||
"response_cache": {"entries": 3, "bytes": 412004},
|
||||
"config": {"enabled": true, "single_flight": true,
|
||||
"conditional_get": true, "max_wait_seconds": 2.0,
|
||||
"rate_limits": {"*.espn.com": {"per_second": 20.0, "burst": 200.0}},
|
||||
"response_cache": true, "default_max_age": 30.0}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`requests` counts round trips sent (retries inside the HTTP adapter are in
|
||||
`retries`), `merged` requests answered by an identical one already in
|
||||
flight, `not_modified` 304s served from the stored body, `errors` transport
|
||||
failures and `http_errors` responses with status 400 or above. `bytes` is the
|
||||
decoded body size. `core` is everything no plugin made.
|
||||
|
||||
Three counters are requests that never reached the network: `memo_hits`
|
||||
were answered from the short response cache (a response still inside the
|
||||
`Cache-Control: max-age` its server gave it), and `cache_hits` were
|
||||
scoreboard fetches answered from a shared ESPN scoreboard cache entry
|
||||
(`espn_scoreboard_cache_key`). `legacy_cache_hits` counts reads served from a
|
||||
key that predates the shared one; it should fall to zero within a day of an
|
||||
upgrade. A plugin's `hosts` counts are requests plus merged requests,
|
||||
`memo_hits` and `cache_hits`: everything it asked for.
|
||||
`response_cache` is the size of the response cache now.
|
||||
|
||||
### Get/Set Plugin Limits
|
||||
|
||||
**GET** `/api/v3/plugins/limits/<plugin_id>`
|
||||
@@ -1038,7 +1221,7 @@ it is neither installed nor configured).
|
||||
"last_updated": "2025-01-15T10:30:00"
|
||||
}
|
||||
},
|
||||
"runtime": {"status": "live", "published_at": 1790000030.0, "age_seconds": 12.4, "stale_after": 180.0}
|
||||
"runtime": {"status": "live", "published_at": 1790000030.0, "age_seconds": 12.4, "stale_after": 180.0, "heartbeat_age_seconds": 2.1}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -2044,7 +2227,7 @@ Every response below adds three fields to the shape it always had:
|
||||
|---|---|
|
||||
| `snapshot_available` | `false` until the display service has reported (for example, it is not running). Counts are then zero. |
|
||||
| `generated_at` | When the display service produced the snapshot (ISO, the Pi's local time), or `null`. |
|
||||
| `clear_pending` | A clear has been requested and the display service has not applied it yet. |
|
||||
| `clear_pending` | Always `false`: a clear is applied before its route answers. Kept for compatibility. |
|
||||
|
||||
### Get Error Summary
|
||||
|
||||
@@ -2104,13 +2287,11 @@ error with `"all": true` (`max_age_hours` is then ignored).
|
||||
}
|
||||
```
|
||||
|
||||
The clear is asynchronous. The web interface records a request
|
||||
(`plugin_error_clear_request` in the shared cache), and the display service
|
||||
applies it within about 5 seconds, rebuilding its counts from the errors it
|
||||
keeps and republishing. Reads hide the cleared errors from the moment the
|
||||
request is recorded. Until the display service applies an age-based clear,
|
||||
`recent_errors` and `active_patterns` are already filtered but the counts
|
||||
are the old ones, and `clear_pending` is `true`.
|
||||
The clear goes to the display service over its control socket
|
||||
(`errors.clear`, see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), which
|
||||
applies it, rebuilding its counts from the errors it keeps, and republishes
|
||||
before it answers: `applied` is `true`, `transport` is `"socket"`, and
|
||||
`cleared_count` is the display's own count.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2118,17 +2299,26 @@ are the old ones, and `clear_pending` is `true`.
|
||||
"data": {
|
||||
"cleared_count": 13,
|
||||
"clear_requested": true,
|
||||
"applied": true,
|
||||
"transport": "socket",
|
||||
"request_id": "5f0c1e...",
|
||||
"cutoff": "2026-09-23T09:59:02.310000"
|
||||
},
|
||||
"message": "Clear of all errors requested; the display service applies it within about 5 seconds"
|
||||
"message": "Cleared all errors"
|
||||
}
|
||||
```
|
||||
|
||||
`cleared_count` is how many of the reported errors the clear hides. It is
|
||||
`null` when that cannot be known before the display service applies it (an
|
||||
age-based clear over more errors than the report lists). A request that
|
||||
could not be written to the shared cache answers `500`.
|
||||
`clear_requested`, `applied` and `transport` are always `true`, `true` and
|
||||
`"socket"`, and are kept for compatibility.
|
||||
|
||||
When the socket cannot carry the clear, the route answers `503` with
|
||||
`context.socket_error`, and nothing is cleared or recorded: the
|
||||
`plugin_error_clear_request` mailbox earlier releases fell back to is gone.
|
||||
The message says why: the display service is not running (its errors are
|
||||
then the last run's, and its next run starts with none), it is too old for
|
||||
`errors.clear` (restart it), the web process has no socket, or the display
|
||||
had the request and failed it (`internal`, a timeout after the request was
|
||||
sent).
|
||||
|
||||
---
|
||||
|
||||
@@ -2146,7 +2336,14 @@ display snapshot. `data.status` is `healthy` or `degraded`, with
|
||||
(with `heartbeat_age_seconds`), `stalled` (no heartbeat for 60s: the panel is
|
||||
frozen even if the service is active; the status turns `degraded`), or
|
||||
`not_reported` when the display writes none (not started yet, the dev server,
|
||||
Windows), which does not affect the status.
|
||||
Windows), which does not affect the status, or `stopped` (with `source:
|
||||
"service"`) when the display service is not active, the control socket does
|
||||
not answer and there is no live heartbeat; the status then turns
|
||||
`degraded`. A platform with no control socket (Windows) or a socket switched
|
||||
off never reports `stopped`. Its `source` is `socket` when the
|
||||
age came from the display's state stream over the control socket (measured
|
||||
in memory by the display) and `heartbeat_file` when it came from
|
||||
`/run/ledmatrix/display-heartbeat.json`.
|
||||
|
||||
Open even when the web login is on, for uptime monitors; a caller that is not
|
||||
logged in (and has no token) then gets only `{"status": "success", "data":
|
||||
@@ -2206,8 +2403,9 @@ Replace the dim schedule. `dim_brightness` is 0-100 (default 30). In
|
||||
`per-day` mode the days can be sent either as the `days` object that GET
|
||||
returns, or as the web form's flat fields (`monday_enabled`,
|
||||
`monday_start`, `monday_end`, ...). A day that is not sent counts as
|
||||
enabled with default times `20:00`-`07:00`; at least one day must be
|
||||
enabled.
|
||||
enabled with default times `20:00`-`07:00`. As for the schedule above, an
|
||||
enabled dim schedule needs at least one day enabled and a disabled one may
|
||||
have every day off.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
# Restructuring `DisplayController.run()`
|
||||
|
||||
`run()` in [`src/display_controller.py`](../src/display_controller.py) decides
|
||||
what the panel shows and runs it. This document is the plan for turning it
|
||||
from one long loop into three parts with clear jobs: an **Arbiter** that
|
||||
decides, a **ScreenRunner** that runs one screen, and **Sources** that each
|
||||
know about one kind of content. It covers the target design, the stages that
|
||||
get there, and how each stage is checked.
|
||||
|
||||
The goal is to change how the control flow is organised, not to move code
|
||||
into more files. Each stage ships as its own PR, and none of them changes
|
||||
what the panel shows unless that PR says so and updates the golden traces
|
||||
on purpose.
|
||||
|
||||
## Why
|
||||
|
||||
- **The priority order is written in branch order, twice.** It is
|
||||
Follower, on-demand, WiFi notice, live priority, Vegas, rotation. In
|
||||
`run()` that order exists only as the order of `if` blocks. Vegas
|
||||
repeats part of it in its interrupt callback (`_check_vegas_interrupt`).
|
||||
- **Preemption is found by re-checking.** A screen ends early when
|
||||
something else changed `current_display_mode` or `is_display_active`
|
||||
underneath it. `run()` notices with five separate
|
||||
`current_display_mode != active_mode` checks: after an empty pass, in each
|
||||
of the two frame loops, after the frame loops, and before rotating.
|
||||
- **Most recent fixes were ordering bugs** between these branches (#618,
|
||||
#644, #649, #652): a lost mode switch, rotating past an on-demand request,
|
||||
spinning when every mode is empty.
|
||||
- **It could not be tested** without threads, real sleeps and stopping the
|
||||
loop by raising from a patched method.
|
||||
|
||||
## What `run()` does today
|
||||
|
||||
Each pass, in order:
|
||||
|
||||
1. `loop_pass()` (watchdog). Apply a pending plugin enable/disable, then
|
||||
any plugin reloads the control socket asked for
|
||||
(`_apply_pending_plugin_reloads`; a pending reload ends the screen
|
||||
before it, like a WiFi notice, as `Source.RELOAD` at the runner's
|
||||
service points). The static screen's frame sleep and the dwell wait on
|
||||
the socket's queue instead of sleeping (`_wait_frame_interval`,
|
||||
`_sleep_with_plugin_updates`); without a socket, as in the golden
|
||||
traces, they are the plain sleeps.
|
||||
2. With no modes: dwell 1 s, next pass.
|
||||
3. Poll on-demand requests and expiry, release plugins loaded only for
|
||||
on-demand, tick plugin updates, drop an expired WiFi notice, evaluate
|
||||
the schedule (an on-demand session overrides scheduled-off), apply the
|
||||
brightness target. Then gather the Arbiter's inputs
|
||||
(`_arbiter_inputs`) and call `Arbiter.decide()`.
|
||||
4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off`
|
||||
5. **Follower:** render one frame from the leader. `_run_follower_frame`
|
||||
6. **WiFi notice** (unless on-demand): draw it, dwell 0.5 s. `_show_wifi_notice`.
|
||||
It also ends a running screen within about a second (the runner's
|
||||
service points), and a screen cut short resumes after it.
|
||||
7. **The Sources below the notice:** read whether Vegas is on and make the
|
||||
live-priority scan (`_arbiter_inputs_below_wifi`, where run() always
|
||||
read them), and call `decide()` again. It answers OnDemand (the
|
||||
session's current mode), Live (the next live mode, round-robin; a game
|
||||
that goes live during a screen takes over at the next service point, at
|
||||
most once a second), Vegas (`LEGACY`) or Rotation. `_take_plan` applies
|
||||
the answer: a live claim or the resume when live priority ends, the
|
||||
on-demand index.
|
||||
8. **Vegas** (`_run_vegas_iteration`): one iteration of up to
|
||||
`max_cycle_duration`. A completed iteration ends the pass, and so does
|
||||
one that yielded for the schedule, a reload or a WiFi notice. Any other
|
||||
interrupted one asks `decide()` once more (`vegas_yielded`): a game that
|
||||
stopped the ticker, or an on-demand session that started, shows next.
|
||||
9. **One screen:** pick the plugin (`_plugin_for_mode`) and hand the plan
|
||||
to the `ScreenRunner` (`src/screen_runner.py`). It draws the first frame
|
||||
through the executor (`_dispatch_first_frame`), has the controller fill
|
||||
in the plugin's durations, dynamic flag and frame policy
|
||||
(`_complete_plan`), runs the 125 Hz or 1 Hz frame loop with a service
|
||||
point after each frame, makes up the minimum duration, and returns an
|
||||
`Outcome`. On `PREEMPTED` the pass ends without advancing. On no
|
||||
content, rotate at once (`_note_empty_pass`, `_skip_failed_plugin_modes`).
|
||||
Otherwise `ArbiterState.after()` picks the next mode
|
||||
(`_advance_after_screen`).
|
||||
|
||||
## Design
|
||||
|
||||
```python
|
||||
def run(self):
|
||||
while True:
|
||||
inputs = self._arbiter_inputs() # schedule, follower, notice
|
||||
plan = Arbiter.decide(self._arbiter_state(), inputs, now)
|
||||
... # off / follower / notice
|
||||
plan = self._take_plan(Arbiter.decide(state, self._arbiter_inputs_below_wifi(inputs), now))
|
||||
if plan.source is Source.LEGACY: # Vegas, until stage 4
|
||||
plan = self._run_vegas_iteration(...)
|
||||
outcome = runner.run(plan, plugin) # ExitReason + elapsed
|
||||
if outcome.exit_reason is not ExitReason.PREEMPTED:
|
||||
self._advance_after_screen(plan, outcome) # ArbiterState.after
|
||||
```
|
||||
|
||||
The controller's attributes (`current_display_mode`, `current_mode_index`,
|
||||
`on_demand_*`, `_live_resume_index`) stay the record that the web UI, the
|
||||
control socket and the on-demand cache read. `_arbiter_state()` snapshots
|
||||
them into a frozen `ArbiterState`; the transitions are pure methods on it,
|
||||
and the controller writes their result back (`_adopt_state`).
|
||||
|
||||
### Sources
|
||||
|
||||
Each kind of content is a Source. A Source looks at the state and the
|
||||
inputs and either offers a screen or passes. The Arbiter asks them in this
|
||||
order:
|
||||
|
||||
| Order | Source | Offers a screen when | Code |
|
||||
|---|---|---|---|
|
||||
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | `decide` |
|
||||
| 1 | Follower | a sync leader is driving this panel | `decide` |
|
||||
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_on_demand_plan` |
|
||||
| 3 | Wifi | a status message is pending and on-demand is not active | `decide` |
|
||||
| 4 | Live | a live-priority plugin has live content (round-robin across several) | `live_pick` |
|
||||
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | `LEGACY`, run by `_run_vegas_iteration` |
|
||||
| 6 | Rotation | always: the rotation's current mode | `rotation_plan` |
|
||||
|
||||
ScheduledOff is a gate in front of the Sources because that is how it works
|
||||
today: a scheduled-off panel stays blank even for a follower, and only an
|
||||
on-demand session overrides it.
|
||||
|
||||
The Rotation answers `state.current_mode`, not
|
||||
`available_modes[current_mode_index]`: the two agree except where something
|
||||
moved the panel off the list and the rotation carries on from there (a live
|
||||
mode no rotation entry names, or None after a session ended with no enabled
|
||||
mode to resume to), and `run()` always showed `current_display_mode`.
|
||||
|
||||
### Arbiter
|
||||
|
||||
```python
|
||||
Arbiter.decide(state, inputs, now, running=None) -> ScreenPlan
|
||||
```
|
||||
|
||||
`decide` is a pure function: it does no I/O, takes no locks and does not
|
||||
sleep. It can be tested with plain tables of (state, inputs, now) mapped to
|
||||
an expected plan.
|
||||
|
||||
- `ArbiterState`: the current mode; the rotation and its index; the
|
||||
on-demand session's modes, index, expiry and pin; the live resume point;
|
||||
whether a mid-screen takeover has not shown yet. Transitions:
|
||||
`next_on_demand`, `showing`, `claim_live`, `release_live`, `after`.
|
||||
- `ArbiterInputs`: whether the schedule has the panel on, an on-demand
|
||||
session, a follower, the WiFi notice, the live modes (None where no scan
|
||||
was made), whether Vegas is on and keeps live content in its ticker,
|
||||
whether this pass's Vegas iteration has yielded, and (mid-screen) whether
|
||||
a plugin reload is waiting.
|
||||
- `ScreenPlan`:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `source` | which Source won |
|
||||
| `mode`, `plugin` | what to draw (None for a blank or follower plan); the plugin id once resolved |
|
||||
| `min_duration`, `max_duration` | from `_resolve_durations` and the on-demand bound (`on_demand_bound`), filled in after the first frame; an on-demand plan's `max_duration` is what is left of the session at `now` |
|
||||
| `dynamic` | run until the plugin's cycle completes, between min and max |
|
||||
| `frame_policy` | `HIGH_FPS` or `STATIC`, today `_needs_high_fps`; see stage 5 |
|
||||
| `preemptible_by` | the Sources allowed to interrupt this plan mid-screen |
|
||||
| `notice`, `deadline`, `ends_live` | the WiFi notice; the on-demand expiry for the bound; "live priority just ended, resume the rotation first" |
|
||||
|
||||
`decide` cannot ask a plugin anything, so the fields a plugin answers are
|
||||
filled in by the controller after the first frame, where they were always
|
||||
read (`_complete_plan`).
|
||||
|
||||
With `running`, `decide` answers the mid-screen question instead: `running`
|
||||
itself while the screen holds, else the plan that ends it
|
||||
(`_hold_or_preempt`), in the order the frame loops always checked:
|
||||
|
||||
1. Live: a game went live while a non-live screen runs. It is the one
|
||||
preemption that changes the state (the rotation moves to the live mode
|
||||
and remembers where it was), and it is claimed even when a WiFi notice
|
||||
is also pending; the next pass shows the notice, then the game.
|
||||
2. The panel's mode moved under the screen (on-demand started, ended or
|
||||
changed mode; the rotation was rebuilt).
|
||||
3. The schedule turned the panel off.
|
||||
4. A WiFi notice (unless on-demand outranks it), compared with its expiry.
|
||||
5. A plugin reload is waiting (between frames only).
|
||||
|
||||
Every screen is preemptible by the gate, OnDemand, Wifi, Live, Rotation and
|
||||
a reload (`SCREEN_PREEMPTERS`), except that a live screen leaves Live out
|
||||
(`LIVE_PREEMPTERS`): live games take turns between screens. A follower and
|
||||
Vegas are looked at only between screens.
|
||||
|
||||
### ScreenRunner
|
||||
|
||||
```python
|
||||
ScreenRunner(clock: FrameClock, host: ScreenHost).run(plan, plugin) -> Outcome
|
||||
```
|
||||
|
||||
The ScreenRunner draws the first frame (`_dispatch_first_frame`), runs the
|
||||
frame loop that the plan's frame policy selects, services pending changes
|
||||
between frames, and returns one `ExitReason`:
|
||||
|
||||
| ExitReason | Golden-trace exit |
|
||||
|---|---|
|
||||
| `DURATION` | target duration reached (`duration`) |
|
||||
| `CYCLE_COMPLETE` | dynamic plugin finished after its minimum (`cycle-complete`) |
|
||||
| `EMPTY` | first frame returned False, or no plugin (`empty`; `raised` when display() raised inside the executor; `no-plugin`, `breaker`) |
|
||||
| `ERROR` | the dispatch itself raised (`error`) |
|
||||
| `DISPLAY_FALSE` | a later frame returned False (`display-false`) |
|
||||
| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `live`, `wifi`, ...) |
|
||||
| `RELOAD` | a plugin reload is waiting: the screen ends early but counts as shown, and the rotation advances |
|
||||
|
||||
`PREEMPTED` replaces the five `current_display_mode != active_mode` checks.
|
||||
The runner asks its host at named service points (`Checkpoint`): `FRAME`
|
||||
after each frame (and when a socket command wakes the 1 Hz wait),
|
||||
`AFTER_LOOP` / `AFTER_COMPLETED_LOOP` when the frame loop ends,
|
||||
`after_dwell` after the make-up dwell, and `FINAL` before the rotation
|
||||
advances. Each is one `decide(..., running=plan)` call
|
||||
(`DisplayController._screen_check`). The checkpoint says whether a pending
|
||||
reload counts there and when the WiFi notice file is read (`NoticeRead`):
|
||||
the read is throttled to once a second and deletes an expired file, so it
|
||||
happens exactly where the loop always read it.
|
||||
|
||||
In the 125 Hz loop the live-priority scan is made before the frame's sleep
|
||||
(`_screen_service`), at the moments it always was, and weighed by the
|
||||
service point after the sleep, where the loop always decided to end the
|
||||
screen.
|
||||
|
||||
`FrameClock` provides `time()`, `perf_counter()` and `sleep()`, the shape of
|
||||
the `time` module. In production it is `_ModuleClock`, which looks up
|
||||
`src.display_controller.time` on each call, so the golden traces' fake clock
|
||||
drives the runner as it drove the inline loops. The runner's log lines use
|
||||
the controller's logger, so they keep their source in the journal.
|
||||
|
||||
## Stages
|
||||
|
||||
| Stage | Change | Behaviour change | Verified by |
|
||||
|---|---|---|---|
|
||||
| 1 | Golden traces; extract helpers from `run()` | none | traces generated on main pass unchanged; mutation check |
|
||||
| 2 | Arbiter with Follower and Wifi Sources | none | traces unchanged; Arbiter unit tables; ledpi smoke |
|
||||
| 3 | ScreenRunner, FrameClock, ExitReason, `PREEMPTED`; OnDemand, Live, Rotation Sources | none | traces unchanged; ledpi frame soak A/B |
|
||||
| 4 | Vegas as a Source driven by `run_frame()` | none intended | traces against the real coordinator; ledpi Vegas soak A/B |
|
||||
| 5 | Plugins declare `frame_policy` | DEBUG instead of INFO for the FPS line | traces; soak on a static-heavy rotation |
|
||||
|
||||
### Stage 1 (#704)
|
||||
|
||||
- `test/_run_loop_harness.py` builds a real `DisplayController` through
|
||||
`__init__` on in-memory fakes (plugins, cache, config service, plugin
|
||||
manager, sync manager, display manager). It swaps the module's `time` and
|
||||
`datetime` for one fake clock and runs the real `run()` until a horizon.
|
||||
The first frame of each screen still goes through the real
|
||||
`PluginExecutor` and the per-plugin locks.
|
||||
- `test/test_run_loop_golden.py` has 15 scenarios, each compared with
|
||||
`test/fixtures/run_loop_golden/<scenario>.json`:
|
||||
- plain rotation (display_durations override, a high-FPS scroller, a
|
||||
plugin whose `display()` takes no `display_mode`)
|
||||
- empty modes and a mode with no plugin; an all-empty rotation (the 1 s
|
||||
pause)
|
||||
- plugin errors and the circuit breaker
|
||||
- dynamic duration (cycle complete, plugin cap, global cap)
|
||||
- live priority taking over and handing back; live round-robin
|
||||
- on-demand start/stop/expiry; pinned on-demand; a session resumed after
|
||||
a restart, and one that cannot resume (its plugin did not load); a
|
||||
request naming a live mode the plugin's live check would drop
|
||||
- schedule off and dim, with an on-demand override during downtime
|
||||
- WiFi notice; sync follower
|
||||
- Vegas, with and without `live_in_ticker`
|
||||
- Each trace row is `[start, mode, duration, exit_reason, frames,
|
||||
force_clear]`. The exit reason is the event that decided what came next.
|
||||
- All 18 tests run in under a second. The goldens were generated from
|
||||
main's `run()` before any code moved.
|
||||
- Vegas uses `FakeVegas`, which implements only the contract the controller
|
||||
depends on: `run_iteration()` returns True after its duration and False
|
||||
when the interrupt or live check asks it to yield, checking at the real
|
||||
coordinator's cadence. Running the real coordinator on the fake clock
|
||||
belongs to stage 4.
|
||||
- Twelve helpers were extracted from `run()` (listed under "What `run()`
|
||||
does today"). Breaking any one of them fails at least one golden trace.
|
||||
|
||||
### Stage 2: Arbiter, starting with Follower and Wifi (done)
|
||||
|
||||
1. Add `ScreenPlan` and an `Arbiter` with the ScheduledOff gate, Follower
|
||||
and Wifi. Every other case returns a `LEGACY` plan, which means "carry on
|
||||
with the existing code" (steps 7-9).
|
||||
2. `run()` calls `decide()` after the bookkeeping in step 3 and dispatches
|
||||
on `plan.source`: blank, `_run_follower_frame()`, the WiFi notice, or the
|
||||
existing path. Inputs that Sources read (follower active, the pending
|
||||
WiFi message, schedule state) are collected first, so `decide()` stays
|
||||
pure.
|
||||
3. Unit-test `decide()` with tables. The golden traces must not change.
|
||||
The Wifi Source must keep the mid-screen preemption described in step 6
|
||||
of "What `run()` does today".
|
||||
|
||||
Follower and Wifi go first because each is one self-contained branch that
|
||||
ends the pass. They prove the plumbing without touching the frame loops.
|
||||
|
||||
What shipped:
|
||||
|
||||
- `src/display_arbiter.py` (on the mypy ratchet) holds `Source`
|
||||
(`SCHEDULED_OFF`, `FOLLOWER`, `WIFI`, `LEGACY`), `ArbiterInputs`,
|
||||
`ArbiterState`, `WifiNotice`, `ScreenPlan` and `Arbiter.decide`.
|
||||
`ScreenPlan` has only the fields stage 2 uses: `source`, `max_duration`
|
||||
(60 s for the blank, 0.5 s for the notice, the constants `run()` used to
|
||||
hard-code) and `notice`. `mode`, `plugin`, the other durations,
|
||||
`frame_policy` and `preemptible_by` arrive with the Sources that need them.
|
||||
- `ArbiterState` is empty: no stage-2 Source remembers anything between
|
||||
passes. `now` is passed but not read, because the top-of-pass WiFi check
|
||||
never compared the expiry and must not start (the table pins this).
|
||||
- `ArbiterInputs` holds `schedule_on`, `on_demand_active`,
|
||||
`follower_active` and `wifi_notice`. `_arbiter_inputs` derives
|
||||
`schedule_on` as `is_display_active and not on_demand_schedule_override`,
|
||||
so the gate (blank when the schedule is off and no on-demand session
|
||||
overrides it) blanks exactly when `is_display_active` is False, as before,
|
||||
including #714's on-demand ending in off hours. It reads the WiFi notice
|
||||
only when the notice could win, because `_check_wifi_status_message` has
|
||||
side effects (its 1 Hz throttle, deleting an expired file) that those
|
||||
passes never had.
|
||||
- The mid-screen rule is `wifi_notice_preempts(notice, on_demand, now)`,
|
||||
which `_wifi_notice_pending` calls; it does compare the expiry.
|
||||
- `run()` still calls `_publish_current_mode_state_if_changed`,
|
||||
`_apply_pending_vegas_init` and `process_deferred_updates` at the same
|
||||
points relative to the branches, so the order of side effects in a pass
|
||||
is unchanged.
|
||||
- `test/test_display_arbiter.py`: the 16-row table (every combination of
|
||||
the four inputs, written out), the mid-screen table, purity checks (no
|
||||
clock reads, nothing mutated, no I/O imports), and the controller's
|
||||
snapshot through an on-demand session that overrides the schedule and
|
||||
ends. A mutation run broke 23 pieces once each (the gate, the order, each
|
||||
Source, the dwells, the expiry comparison, the snapshot's reads, each
|
||||
dispatch in `run()`); every one failed a test.
|
||||
|
||||
### Stage 3: ScreenRunner and `PREEMPTED` (done; awaiting the ledpi soak)
|
||||
|
||||
The plan, from where stage 2 left off:
|
||||
|
||||
1. `ArbiterState` gains the rotation index, the on-demand mode list, index,
|
||||
expiry and pin, and the live resume point. `ArbiterInputs` gains the
|
||||
live modes and whether Vegas is enabled and keeps live content in the
|
||||
ticker.
|
||||
2. OnDemand returns its current mode with the session's bound, reading
|
||||
`now` for the expiry. Live returns the next live mode (round-robin).
|
||||
Rotation returns the rotation's mode. `ScreenPlan` gains `mode`,
|
||||
`plugin`, `min_duration`, `max_duration`, `dynamic`, `frame_policy` and
|
||||
`preemptible_by`.
|
||||
3. `ScreenRunner.run(plan)` returns an `ExitReason`; `state.after(outcome)`
|
||||
replaces `_advance_after_screen`'s step and the live-resume bookkeeping.
|
||||
Each mid-screen check asks `decide()` whether a Source in
|
||||
`plan.preemptible_by` now wins.
|
||||
4. The control socket (`_drain_control_commands`, `_wait_for_control`) and
|
||||
state publishing stay where they are; the runner calls them at its
|
||||
service points.
|
||||
|
||||
What shipped, one commit each: the runner; then the OnDemand, Live and
|
||||
Rotation Sources; then one `decide()` call at the service points.
|
||||
|
||||
- `src/screen_runner.py` (on the mypy ratchet): `ScreenRunner`,
|
||||
`FrameClock`, `ExitReason`, `Outcome`, `Checkpoint`, `NoticeRead`,
|
||||
`Screen` and the `ScreenHost` protocol, which `DisplayController`
|
||||
implements through `_ScreenHost` (one-line forwards to its own methods).
|
||||
The two frame loops, the make-up dwell and the dynamic-duration exit
|
||||
moved in unchanged, pacing included.
|
||||
- `src/display_arbiter.py`: `Source` gains `ON_DEMAND`, `LIVE`, `ROTATION`
|
||||
and `RELOAD`; `LEGACY` means only Vegas. `FramePolicy`. `ArbiterState`
|
||||
and `ArbiterInputs` as listed under "Arbiter". The pure helpers
|
||||
`on_demand_bound` (`_clamp_to_on_demand`), `live_pick`
|
||||
(`_check_live_priority`'s pick), `live_takeover` (the mid-screen claim)
|
||||
and `rotation_plan`.
|
||||
- A pass asks `decide()` twice: once with the inputs every pass reads, and
|
||||
once, only when nothing above the notice took the panel, with the Vegas
|
||||
check and the live scan, read where run() always read them (the scan
|
||||
asks every live-priority plugin, and the Vegas check applies queued
|
||||
Vegas config, so reading them earlier, on a follower or notice pass,
|
||||
would be a change). A Vegas iteration that yields asks a third time.
|
||||
- `_resolve_active_mode`, `_clamp_to_on_demand` and `_screen_preempted` are
|
||||
gone. `_apply_live_priority`, `_check_live_priority`,
|
||||
`_check_live_takeover` and `_wifi_notice_pending` remain (Vegas, the
|
||||
dwell sleep and the tests call them), built on the same pure rules.
|
||||
- `_sleep_with_plugin_updates` keeps its own break rules. It also serves
|
||||
the blank, the notice and the idle wait, which are not screens, and its
|
||||
rules are edge-triggered (an on-demand session starting on the mode
|
||||
already showing ends a dwell but not a frame loop); folding them into
|
||||
`decide()` would change behaviour.
|
||||
|
||||
Behaviour, checked three ways:
|
||||
|
||||
- Golden traces: unchanged, no regeneration.
|
||||
- Every harness run in the suite (67: the goldens plus the live-takeover,
|
||||
WiFi+live, socket-wake, plugin-reload, schedule and tick tests) was
|
||||
captured with every sleep, `display()` call, WiFi read, live scan,
|
||||
publish, dwell and scroll-state call logged, and diffed against
|
||||
`origin/main`. Identical, except:
|
||||
- a Vegas pass used to scan the live plugins twice at the same instant
|
||||
(step 7, then step 8's "is anything live?"); it scans once;
|
||||
- `_apply_live_priority(None)` calls that changed nothing are not made;
|
||||
- throttled WiFi reads that returned the cached answer (no side effect)
|
||||
after a notice had already ended the screen are not made;
|
||||
- in the 125 Hz loop the live scan still runs before the frame's sleep,
|
||||
but the claim is made by the service point after it, so the "live"
|
||||
state change happens 8 ms later. The screen ends at the same frame as
|
||||
before.
|
||||
- Tables: `test/test_display_arbiter.py` (OnDemand, Live, Vegas/Rotation,
|
||||
`after`, the 24-row mid-screen table, `live_takeover`) and
|
||||
`test/test_screen_runner.py` (the runner on a scripted host and fake
|
||||
clock; the controller's service point and the reads it makes). A
|
||||
mutation run broke each moved or new piece once; see the PR.
|
||||
|
||||
This stage touches frame pacing (the 8 ms deadline sleep, the 1 ms yield),
|
||||
so it needs a frame soak on ledpi, A/B against main, before it merges.
|
||||
Coordinate with whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
|
||||
|
||||
### Stage 4: Vegas as a Source
|
||||
|
||||
The controller calls `coordinator.run_frame()` once per frame from the
|
||||
ScreenRunner instead of handing over to `run_iteration()` for up to
|
||||
`max_cycle_duration`. The interrupt callback and the second copy of the
|
||||
priority order go away, because preemption becomes `PREEMPTED`. The
|
||||
`vegas-plugin-tick` thread that is spawned every 4 s becomes the
|
||||
controller's normal update tick. Extend the harness to drive the real
|
||||
coordinator on the fake clock, which means patching its `time` and running
|
||||
its prefetch inline. Verify with a Vegas soak on ledpi, A/B.
|
||||
|
||||
### Stage 5: `frame_policy`
|
||||
|
||||
Plugins declare `frame_policy` (STATIC, PERIODIC(hz), ANIMATED(fps),
|
||||
SCROLL). `_needs_high_fps` becomes the mapping for legacy plugins
|
||||
(`needs_high_fps`, the `static-image` special case, `enable_scrolling`),
|
||||
and its per-screen INFO line drops to DEBUG. It is already read twice per
|
||||
screen: once quietly before the first frame, so `_dispatch_first_frame` can
|
||||
end the previous scroll for a screen that runs the 1 Hz loop
|
||||
(`_start_screen_handover`), and once after it to pick the loop. A declared
|
||||
policy answers both.
|
||||
|
||||
## How each stage is verified
|
||||
|
||||
- **Golden traces.** Run `python -m pytest test/test_run_loop_golden.py`;
|
||||
it takes about a second. A refactoring stage must leave every trace
|
||||
unchanged. A deliberate behaviour change regenerates them with
|
||||
`LEDMATRIX_REGEN_GOLDEN=1` in its own commit, and the commit message
|
||||
explains each changed row. A new scenario's golden is generated against
|
||||
main's `run()` first, then checked against the branch.
|
||||
- **Mutation check.** Break each moved or new piece once, for example take
|
||||
`max` of the caps instead of `min`, or skip the live hold. At least one
|
||||
trace must fail each time. Stage 1 did this for all twelve helpers.
|
||||
- **Full suite.** Diff the FAILED/ERROR ids against a baseline run of main
|
||||
in a separate worktree. The Windows host has a stable set of
|
||||
pre-existing failures, so never compare against zero.
|
||||
- **ledpi soak** (stages 2-5). With the service running the branch:
|
||||
`python3 scripts/frame_soak.py --preview` for 10 minutes on a scrolling
|
||||
rotation, and on Vegas for stages 3-4. Alternate which build goes first.
|
||||
Compare late-frame rate and freezes with main. Also check by hand that
|
||||
on-demand start, stop and expiry, a live game taking over and handing
|
||||
back, and the schedule turning the panel off and on all behave as before.
|
||||
|
||||
## Behaviour the traces pin down that may be wrong
|
||||
|
||||
Stage 1 recorded six behaviours as they were, each to be fixed in its own
|
||||
PR that updates the affected trace and explains why. All six are fixed:
|
||||
|
||||
- A WiFi notice was only checked between screens, and Vegas yielded to one
|
||||
and then showed a rotation screen instead. Notices now preempt within
|
||||
about a second, and Vegas yields straight to them (#712; `wifi_notice`,
|
||||
`vegas`).
|
||||
- A live game only took over between screens, and Vegas yielded to one and
|
||||
then showed a rotation screen first. Games now take over within about a
|
||||
second, and Vegas yields straight to them (#713; `live_priority`,
|
||||
`vegas`).
|
||||
- An on-demand session that ended during scheduled-off kept the panel on
|
||||
until the next minute, and a schedule window's end minute counted as on
|
||||
only sometimes. Windows are now half-open `[start, end)`, and the panel
|
||||
blanks as soon as on-demand ends in off hours (#714; `schedule`).
|
||||
|
||||
A new one found later goes the same way: record it here with the trace that
|
||||
shows it, then fix it in its own PR, not inside a restructure stage.
|
||||
|
||||
Open:
|
||||
|
||||
- Vegas stops for a sync follower (its interrupt check includes
|
||||
`is_follower_active`), but the yield path never looks at a follower, so a
|
||||
full rotation screen (20 s in the test) runs before the next pass hands
|
||||
the panel to the leader. Found by stage 3's mutation run;
|
||||
`test_screen_runner.py::TestThroughRun::test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first`
|
||||
pins it. Stage 4, which drops the interrupt callback, is the natural
|
||||
place to fix it.
|
||||
+68
-15
@@ -73,6 +73,10 @@ Sample ladder for a 100 Hz panel:
|
||||
100.0 px/s (1px every 1 refresh = 100.0 fps, smooth)
|
||||
```
|
||||
|
||||
The Vegas **Scroll Speed** slider in the web UI shows the same thing live: a
|
||||
line under it says what your speed will run as on this panel, and links to the
|
||||
nearest smooth speeds.
|
||||
|
||||
### How a slow speed stays crisp
|
||||
|
||||
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
|
||||
@@ -242,8 +246,16 @@ mean exactly 10 ms, so a ticker stalling on half its frames still averages to a
|
||||
healthy 100 fps. The stats line reports the tail for that reason — read the
|
||||
percentiles, not the fps.
|
||||
|
||||
Every scroller emits one line every 5 seconds covering *every* frame in that
|
||||
window, tagged with the plugin it came from:
|
||||
Every scroller summarises each 5-second window, covering *every* frame in it,
|
||||
in one line tagged with the plugin it came from. At the default log level the
|
||||
line reaches the journal only when it is worth reading: a **degraded** window
|
||||
(frame rate below 90% of the rate the window was locked to, i.e. 1 / its own
|
||||
median -- the same 0.9 Vegas's `Vegas FPS` line uses -- or more than 1% of its
|
||||
frames stalled), the first window after one (the recovery), and otherwise once
|
||||
every 5 minutes per scroller as a heartbeat, so silence means stopped rather
|
||||
than fine. Every window is logged at DEBUG: to see them all, run the display
|
||||
with `-d` or `LEDMATRIX_DEBUG=true` (see
|
||||
[CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md#enable-debug-logging)).
|
||||
|
||||
```bash
|
||||
journalctl -u ledmatrix --since "-10min" --no-pager | grep "Scroll frame stats"
|
||||
@@ -282,6 +294,10 @@ journalctl -u ledmatrix --since "-3h" --no-pager | grep "Scroll frame stats" \
|
||||
| sort -k7 -rn
|
||||
```
|
||||
|
||||
At the default log level that ranks the windows the journal kept -- the
|
||||
degraded ones, recoveries and heartbeats -- so it over-weights bad windows;
|
||||
rank a debug run for an unbiased average, or soak the rig (below).
|
||||
|
||||
The `$2 < 1000` guard drops windows whose median is a whole second or more.
|
||||
Those are not frames. Until the idle-gap fix in `log_frame_rate()`, the first
|
||||
frame of every scroll was timed against the end of the *previous* scroll, so
|
||||
@@ -331,17 +347,24 @@ python3 scripts/frame_soak.py --json a.json # keep the report to compare later
|
||||
It runs as any user next to the display service and stops nothing. It needs
|
||||
something to *scroll* during the run: a live game holding a static scoreboard
|
||||
on screen gives no verdict. `--preview` keeps the web preview's viewer marker
|
||||
fresh, which puts the preview's PNG encoding at full rate -- run it as the web
|
||||
service's user.
|
||||
fresh, which puts the preview's PNG encoding at the viewer rate, as an open
|
||||
preview does -- run it as the web service's user. That rate is at most one
|
||||
frame a second. Through 3.8.0 it was up to five, so a `--preview` soak taken
|
||||
before that change is not comparable with one taken after it (the hdpi
|
||||
results below are from before it): take both sides of an A/B pair on
|
||||
the same side of it.
|
||||
|
||||
| line | what it tells you |
|
||||
|---|---|
|
||||
| **Late frames** | Frames presented one or more refreshes after they were due: the panel showed the previous frame again, a visible hitch. **The pass/fail number**, 0.1% by default (`--max-late-pct`). Only intervals between two scrolling frames count, and a frame held for `frame_hold` refreshes is due `frame_hold` refreshes after the last. |
|
||||
| **Freezes** | Gaps of 250 ms or more inside a scroll: recomposes, plugin handovers, blocking calls on the render thread. Reported but not failed on, because some are handovers between plugins rather than faults. A gap still counts when the display's scroll state went missing for one frame across it, as long as scrolling resumes within 1 s: both of that frame's intervals count. Two static frames in a row end the scroll. (The state expires after 2 s without scroll activity, and plugins can clear it from their own `display()`.) The late and early rates are over frames judged against a known refresh period, which the recorder adopts once two windows in a row agree on it. |
|
||||
| **Freezes** | Gaps of 250 ms or more inside a scroll: recomposes, plugin handovers the display controller does not tag (see *Handover gaps*), blocking calls on the render thread. Reported but not failed on, because some are handovers between plugins rather than faults. A gap still counts when the display's scroll state went missing for one frame across it, as long as scrolling resumes within 1 s: both of that frame's intervals count. Two static frames in a row end the scroll. (The state expires after 2 s without scroll activity, and plugins can clear it from their own `display()`.) The late and early rates are over frames judged against a known refresh period, which the recorder adopts once two windows in a row agree on it. Handovers to a static screen no longer show up here: the display controller ends the scroll state after a static screen's first frame, where it used to linger for 2 s and turn the 1 Hz loop's second frame into a ~1 s "freeze" (17 of 31 `Render stall over` lines on ledpi, 2026-09-15 to 10-01). **Freeze counts from before and after that change are not comparable.** |
|
||||
| **Handover gaps** | Gaps of 250 ms or more from a scroll's last frame to the next screen's first: the next plugin drawing, not a scroll stalling. The display controller tags that first frame `handover`, and these gaps are counted here instead of under Freezes (`handover_freezes` in the stats; the `handover` row under *after work* counts the same ones in its freezes column). Every turn's first frame is tagged, also when the rotation comes back to the same mode (a one-mode rotation, a pinned on-demand mode, live priority holding a screen), so a scroller rebuilding its content at the start of a turn is counted here; measure work on that rebuild with this line, not Freezes. Missing from stats written by an older service, whose freezes include them. |
|
||||
| **blit** | Copying the frame into the matrix canvas (`SetImage`). It grows with width × height × `pwm_bits`: ~5.5 ms at 512×64 with 8 bits on a Pi 4. It is the biggest fixed cost, and it sets the refresh rates a rig can hold one pixel per refresh at. |
|
||||
| **wait** | Time blocked in `SwapOnVSync`, i.e. the slack left in each refresh. A p50 near zero means the rig has no headroom and anything extra lands a frame late. |
|
||||
| **work** | Everything else between two frames: drawing, scrolling, and waiting for the GIL. A wide gap between its p50 and p99 is another thread getting in the way. |
|
||||
| **Garbage collection** | Python's cyclic collector stops every thread while it runs. Collections per generation in the run and the time they took, how many took 20 ms or more, and the longest since the service started. A long one tags the next frame `gc` (see *after work*), and a `Render stall` dump says when one ran inside the stall. Diagnostic only: nothing tunes the collector. Missing from stats written by an older service. |
|
||||
| **Binding** | `STOCK` means the rgbmatrix binding holds the GIL through the vsync wait, which starves every other thread. See *Rebuilding the binding*. |
|
||||
| **after work** | Frames presented straight after tagged render-thread work, with their own late rate: `extend` and `compose` (Vegas building its strip), `patch` (live elements, once they land), `handover` (a new screen's first frame), `gc` (a garbage collection of 20 ms or more ran since the frame before). A kind whose late rate sits well above the overall one is the work making frames late. Shown only when something tagged its work. |
|
||||
|
||||
The refresh rate is estimated from the frames themselves (swaps that block on
|
||||
vsync can only land on refresh boundaries). Cross-check it with
|
||||
@@ -355,10 +378,13 @@ A/B two of them. A live-API workload drifts over time.
|
||||
The soak says how often; the service's log says why. A scroll that presents no
|
||||
frame for 250 ms logs `Render stall:` with the stack of the render thread and
|
||||
the top of every other thread's, and whether the whole interpreter was blocked
|
||||
(C code holding the GIL) rather than one thread. To see what is behind the
|
||||
shorter hitches, run the service with `LEDMATRIX_STALL_WATCHDOG_MS=30`, which
|
||||
dumps at three refreshes late instead: its extra polling costs a little GIL
|
||||
time of its own, so do that on a diagnostic run, not a soak you are grading.
|
||||
(C code holding the GIL) rather than one thread. A stall while the next
|
||||
screen's first `display()` is still drawing says `in a handover gap` instead of
|
||||
`mid-scroll`; that call runs on a thread named `display-<plugin id>`. To see
|
||||
what is behind the shorter hitches, run the service with
|
||||
`LEDMATRIX_STALL_WATCHDOG_MS=30`, which dumps at three refreshes late instead:
|
||||
its extra polling costs a little GIL time of its own, so do that on a
|
||||
diagnostic run, not a soak you are grading.
|
||||
`LEDMATRIX_STALL_WATCHDOG=0` turns it off.
|
||||
|
||||
### Results: hdpi, 2026-09-24
|
||||
@@ -406,6 +432,10 @@ sudo python3 scripts/render_bench.py --speed 50 # a held (frame_hold 2) spe
|
||||
sudo python3 scripts/render_bench.py --busy 2 # with threads imitating plugin updates
|
||||
sudo python3 scripts/render_bench.py --json /tmp/pi4-512x64.json
|
||||
|
||||
# render-thread strip work, each tagged so the report gives it a late rate:
|
||||
sudo python3 scripts/render_bench.py --patch-bytes 101376 --patch-every 25 # a live map patch
|
||||
sudo python3 scripts/render_bench.py --strip-screens 30 --extend-every-screens 6 # Vegas extensions
|
||||
|
||||
sudo systemctl start ledmatrix
|
||||
```
|
||||
|
||||
@@ -468,6 +498,8 @@ refreshes" comes from.
|
||||
| `duplicate` | frames that advanced no pixels. A crisp fixed-step scroll should show none; any at all means the loop is presenting faster than the strip is moving. |
|
||||
| `blank` | frames with no visible slice to draw: the helper had no content. Should be zero. |
|
||||
| `restarts` | how many times the strip was scrolled through end to end. Informational: the bench restarts the strip where a plugin would hand over to the next one. |
|
||||
| `patches` | `--patch-bytes N --patch-every K`: N bytes of columns written into the strip in place every K frames, on screen or (`--patch-where ahead`) just past it -- what a live element update costs the render thread. Their frames are the `patch` row under *after work*. |
|
||||
| `extensions` | `--extend-every-screens N`: a block appended and the scrolled-past columns trimmed every N screens, as continuous Vegas does. The cost is a copy of the whole strip, so size it like Vegas's with `--strip-screens` (8,000-20,000px). Their frames are the `extend` row. |
|
||||
|
||||
`--json` writes the full report plus the panel geometry, the solved speed and
|
||||
these counters, so two rigs (or one rig before and after a change) can be
|
||||
@@ -510,19 +542,43 @@ On the 2×128×64 chain above, which refreshes at about 130 Hz flat out
|
||||
|
||||
### What the display does about it
|
||||
|
||||
At one pixel per refresh, the fastest crisp speed, the step is exactly one
|
||||
refresh's worth of motion, so it can be cancelled: show one half of the panel
|
||||
The step is the motion of one refresh, so it can be cancelled: show one half of the panel
|
||||
a refresh behind the other -- the half whose row at the seam lights at the
|
||||
start of each refresh. The two rows either side of the seam then show the same
|
||||
moment again. What is left is a
|
||||
lean of one pixel per half from top to bottom, continuous across the panel,
|
||||
which reads as nothing where the step read as a tear. `DisplayManager` does
|
||||
this while something scrolls at one frame per refresh
|
||||
this while something scrolls
|
||||
(`display.scan_order_compensation`, `"auto"` by default, `"off"` to disable;
|
||||
the geometry is in `src/scan_order.py`). The lagging rows come from the
|
||||
previous frame the display presented, so it works for Vegas and every plugin
|
||||
ticker without knowing how they scroll.
|
||||
|
||||
A frame held for several refreshes (any crisp speed below the panel's full
|
||||
refresh rate, e.g. 60 px/s at 120 Hz) is presented as two swaps instead of one:
|
||||
the lagging half shows the previous frame for the first refresh and the new one
|
||||
for the rest, so it steps one refresh after the rest rather than one frame.
|
||||
That costs a second blit inside the refresh after the first swap, so it is
|
||||
skipped when a blit takes more than half a refresh.
|
||||
|
||||
A plugin screen that runs the 1 Hz loop after a scroll is not composed: the
|
||||
display controller calls `DisplayManager.end_scroll_for_static_screen()` before
|
||||
its first `display()`, so the frames that call presents go out as drawn, in one
|
||||
swap each, instead of with the lagging half taken from the scroller's last
|
||||
frame.
|
||||
|
||||
The controller's own screens -- the blank shown when the schedule turns the
|
||||
panel off, and the WiFi status message -- end the scroll state before they are
|
||||
drawn, so they go out as drawn and are timed as static frames, not as freezes
|
||||
of the old scroll. A scroller that resumes after a WiFi notice sets the state
|
||||
again on its next frame.
|
||||
|
||||
One screen that follows a scroll is still composed while the scroll state lasts
|
||||
(it expires 2 s after the scroller's last frame): a screen that runs the
|
||||
high-FPS loop without scrolling (an older `static-image`, which is forced into
|
||||
it). Its first frame takes its lagging half from the scroller's last frame, for
|
||||
one refresh after a held scroll and otherwise until its next frame.
|
||||
|
||||
Checked on hdpi (4×128×64 on one chain, rotated 180, 2026-09-24) before it was
|
||||
written: `scan_mode: 1` (interlaced) made the step vanish but turned moving
|
||||
edges grainy, and halving the speed halved it, so it is the scan and not a torn
|
||||
@@ -530,9 +586,6 @@ frame. With the compensation the step is gone at 90 px/s.
|
||||
|
||||
It is left off where the row order is unknown or the maths does not hold:
|
||||
|
||||
- **Slower speeds**, where each frame is held for two or more refreshes. The
|
||||
offset there is half a pixel or less, and cancelling it would need a lag of
|
||||
a fraction of a frame.
|
||||
- **Other layouts:** pixel mappers other than a 0 or 180 degree rotation
|
||||
(U-mapper, 90/270), non-zero `multiplexing`, interlaced `scan_mode`, and a
|
||||
canvas remapped to another height (double-sided mode).
|
||||
|
||||
+121
-27
@@ -87,6 +87,12 @@ more. Shared sports code lives in `src/common`:
|
||||
| `sports_celebration.py` | 3.7.0 | `SportsCelebrationMixin` — draws the score/win takeover; colour helpers |
|
||||
| `sports_fetch.py` | 3.7.0 | `SportsFetchMixin` — season fetch, live lookback and live-odds decisions |
|
||||
| `sports_card_wrappers.py` | 3.7.0 | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations |
|
||||
| `sports_plugin_host.py` | 3.8.0 | `SportsPluginHostMixin` — the plugin class's (`manager.py`) identical helpers: Vegas weight, off-thread switch refresh |
|
||||
| `sports_live_scroll.py` | 3.8.0 | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place |
|
||||
| `sports_display_rules.py` | 3.8.0 | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell |
|
||||
| `sports_font_path.py` | 3.8.0 | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
|
||||
| `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) |
|
||||
| `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) |
|
||||
|
||||
Each is described in [src/common/README.md](../src/common/README.md).
|
||||
|
||||
@@ -98,7 +104,8 @@ modules taken from the plugin copies, each a **new module** rather than growth
|
||||
on an existing one: a plugin that deletes a method copy and relies on an older
|
||||
module having gained it fails at runtime with an `AttributeError`, while a
|
||||
missing module fails at load, where the version checks can see it.
|
||||
`sports_helpers.py` holds `_favorite_key`, the override point listed below.
|
||||
`sports_helpers.py` holds `_favorite_key`, the override point listed below;
|
||||
`sports_favorites.py` is what calls it.
|
||||
Each promoted module has a parity test that compares its bodies against the
|
||||
plugin copies when `LEDMATRIX_PLUGINS` points at a checkout
|
||||
(`test_sports_helpers.py`, `test_sports_stage3_parity.py`), and
|
||||
@@ -121,7 +128,7 @@ deprecation cycle.
|
||||
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
|
||||
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
|
||||
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
||||
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
|
||||
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["<side>_abbr"]`. nrl returns the ESPN team id, `None` when it is missing |
|
||||
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
|
||||
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
||||
|
||||
@@ -130,8 +137,7 @@ constants rather than behavior:
|
||||
|
||||
| Attribute | Meaning | Default |
|
||||
|---|---|---|
|
||||
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
|
||||
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
|
||||
| `FINAL_PERIOD` | Period from which a 0:00 clock ends a game (`sports_game_over`) | `None`: the clock never ends a game (afl, nrl, soccer, baseball, ufc). Hockey sets `3`; basketball, football and lacrosse `4` |
|
||||
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
|
||||
|
||||
### Why these are seams and not branches
|
||||
@@ -142,11 +148,14 @@ so NRL matches favorites on team ID. Flattening every plugin to abbreviations
|
||||
would silently select the wrong club for NRL users. The base declares the seam,
|
||||
NRL fills it, and core never learns the string `"nrl"`.
|
||||
|
||||
`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a
|
||||
`FINAL_PERIOD` exists for the same reason in the opposite direction: a
|
||||
soccer clock reading `0:00` means the match has not kicked off, so running the
|
||||
clock-expiry branch there would evict live games.
|
||||
clock-expiry rule there would evict live games. Those sports declare `None`,
|
||||
and so do baseball (innings, not a clock) and ufc (a bout ends only on ESPN's
|
||||
final status). One attribute covers both questions, whether the clock can end
|
||||
a game and from which period, so no separate count-down flag was added.
|
||||
|
||||
`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one
|
||||
`COALESCE_SCORING_SEQUENCE` is another of the same kind. In football one
|
||||
scoring play arrives as two score updates, so the follow-up must be folded into
|
||||
the first celebration; in soccer two increments a few seconds apart are two real
|
||||
goals, and folding them would swallow one. Neither default is "right" — which is
|
||||
@@ -259,6 +268,84 @@ gave pixel-identical output for all 399 frames (192 harness screens across the
|
||||
nine plugins at the eight default sizes, 72 scroll/Vegas cards, 135
|
||||
celebration frames), with a parent-vs-parent rerun as the determinism control.
|
||||
|
||||
### Stage 4: the identical sweep (core done; adoption waits for a release)
|
||||
|
||||
Re-measured on ledmatrix-plugins `56c4f15` (2026-09-30) the report still
|
||||
lists 58 families identical in every copy. Stage 4 moves the ones that are
|
||||
identical across the nine, or across eight with the ninth lacking the
|
||||
method, into four new modules: `sports_plugin_host` (ten `manager.py`
|
||||
helpers, all nine), `sports_live_scroll` (eight `manager.py` methods, every
|
||||
plugin with a live strip, so not ufc), `sports_display_rules` (four
|
||||
`sports.py` methods, in two mixins because their carriers differ) and
|
||||
`sports_font_path`. The parity test (`test/test_sports_stage4_parity.py`)
|
||||
compares each with every plugin copy using this report's own normalisation,
|
||||
plus decorators and constant values, which the normalisation drops.
|
||||
|
||||
`_resolve_font_path` was meant to be replaced by
|
||||
`font_layout.resolve_asset_path`, but that never looks in the cwd, and the
|
||||
plugins' copy does first, so the swap would change which font a process
|
||||
started from another checkout loads. `resolve_font_path` is the copy's
|
||||
behaviour on a core that ships it, checked path for path against all 17
|
||||
copies (`test/test_sports_font_path.py`).
|
||||
|
||||
Left in the plugins, though identical:
|
||||
|
||||
- `_get_timezone`, `_extract_game_details`, `_fetch_data` (nine): a
|
||||
per-plugin import and the abstract contract, as in stage 3.
|
||||
- `_schema_font_size`, `_resolve_font_size` (eight renderers): they read the
|
||||
plugin's own `_SCHEMA_PATH`, as in stage 3.
|
||||
- The 29 families carried by seven plugins or fewer: the afl/nrl/soccer
|
||||
lineage's own helpers (`_swrr_advance`, `_refresh_switch_mode_managers`,
|
||||
`_initialize_logo_dir`, ...), the multi-league helpers
|
||||
(`_resolve_managers_for_mode`, `_extract_mode_type`, ...), and eleven
|
||||
two-plugin helpers. Each is one lineage's code; most go when
|
||||
family 13 or 14 reconciles the code around them. `_odds_color` (seven
|
||||
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
|
||||
wants it can inherit that.
|
||||
|
||||
### Family 5: the game-over check (core done; adoption waits for a release)
|
||||
|
||||
The pilot of the method below. ledmatrix-plugins `scripts/test_game_over_check.py`
|
||||
(#621) pinned 3,115 answers across the nine plugins first; the reconcile
|
||||
(ledmatrix-plugins #625) made the five bodies one and
|
||||
changed only the cells the owner's decisions under
|
||||
[Product decisions](#product-decisions-each-family-needs) explain: ufc's
|
||||
clock rule (65 cells), baseball's dormant one (53, every one a game with a
|
||||
`period` baseball's games never carry), and a level score at 0:00 (five
|
||||
cells in hockey, basketball, football and lacrosse). The harness renders
|
||||
were pixel-identical. `src/common/sports_game_over.py` holds the body;
|
||||
`test/test_sports_game_over_parity.py` compares it, and each plugin's
|
||||
`FINAL_PERIOD`, with the plugin copies.
|
||||
|
||||
### Family 6: favourite matching (core done; adoption waits for a release)
|
||||
|
||||
ledmatrix-plugins `scripts/test_favourite_matching.py` (#634) pinned 204 rows
|
||||
across the nine plugins first: `_is_favorite_game` on each manager role, the
|
||||
two selection methods, the real `update()` with favourites-only on and off,
|
||||
and the INFO summary; the reconcile extends it to 217 (a lower-case and a
|
||||
padded favourite through `update()`, and the live favourite boost). The reconcile (ledmatrix-plugins
|
||||
#635) made `_is_favorite_game` one body on `SportsCore`
|
||||
(afl and soccer's `SportsUpcoming` copies and five `SportsLive` copies, all
|
||||
redundant, are gone), added `_favorite_code` beside it, and gave nrl a
|
||||
`_favorite_key` override instead of its own copies. So that a lower-case
|
||||
favourite works on a favourites-only Upcoming board, the Upcoming `update()`'s
|
||||
favourites-only pre-filter and the basketball, hockey and lacrosse live boost
|
||||
now ask `_is_favorite_game` too (a one-line change each; `update()` itself is
|
||||
family 13). Of 3,897 cells only those the decisions above explain changed:
|
||||
case and spaces in eight plugins (30-40 each), the id-less duplicate fix (6-8
|
||||
each), nrl's key (6) and its "None" match (6), and the INFO line in baseball,
|
||||
football and ufc. The harness renders were byte-identical. `src/common/sports_favorites.py` holds the
|
||||
bodies, one mixin per carrying class; `test/test_sports_favorites_parity.py`
|
||||
compares them with the plugin copies and checks that only nrl overrides
|
||||
`_favorite_key`.
|
||||
|
||||
Left for later families: the live screens' favourites-only filter
|
||||
(`_classify_live_game` and its inline copies) and favourites-first sort still
|
||||
compare abbreviations exactly, and
|
||||
`SportsCoreSharedMixin._round_robin_favorites` groups favourites by raw
|
||||
abbreviation (or by `_team_in` where a plugin has one) instead of through
|
||||
`_favorite_key`. The result-colour helpers also wait (decision above).
|
||||
|
||||
### Why the method changes
|
||||
|
||||
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
||||
@@ -294,8 +381,8 @@ game-over check); the report measures each method in it. The procedure:
|
||||
line in each plugin.
|
||||
- *A per-sport fact* (hockey ends in period 3; a soccer clock counts up).
|
||||
Make it a declared class constant or override point with a default, as
|
||||
`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`, `COALESCE_SCORING_SEQUENCE` and
|
||||
`_favorite_key` are, and add it to the tables above. Never a sport-name
|
||||
`FINAL_PERIOD`, `COALESCE_SCORING_SEQUENCE` and `_favorite_key` are,
|
||||
and add it to the tables above. Never a sport-name
|
||||
branch: core must not learn sport names.
|
||||
- *A product difference*: anything a user can see (which games show, a
|
||||
colour, a date, a badge, how long a screen stays). The owner picks the
|
||||
@@ -342,9 +429,9 @@ release.
|
||||
|
||||
| # | Family | Methods (variants) | Why here |
|
||||
|---|---|---|---|
|
||||
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) is replaced by core's `font_layout.resolve_asset_path` rather than promoted |
|
||||
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; its seams (`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`) were designed in B1. The pilot for the procedure |
|
||||
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam |
|
||||
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) |
|
||||
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; adoption waits for the release that ships it. See [Family 5](#family-5-the-game-over-check-core-done-adoption-waits-for-a-release) |
|
||||
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; adoption waits for the release that ships it. See [Family 6](#family-6-favourite-matching-core-done-adoption-waits-for-a-release) |
|
||||
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack |
|
||||
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it |
|
||||
| 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins |
|
||||
@@ -377,21 +464,28 @@ family 9 prepares.
|
||||
Owner calls to make before (or while) reconciling. Items marked *verify* are
|
||||
suspected behaviour that needs a payload or a rig to confirm first.
|
||||
|
||||
- **5, game-over check.** Which rule each sport gets: the clock never ends a
|
||||
game in afl, nrl and soccer (`CLOCK_COUNTS_DOWN = False`); hockey ends at
|
||||
0:00 from period 3, basketball, football and lacrosse from period 4.
|
||||
baseball and ufc share a copy that reads a missing clock as "0:00": dormant
|
||||
in baseball (its games carry no `period`), and not triggered by ufc's round
|
||||
breaks either. ESPN sends a break as `STATUS_END_OF_ROUND` with displayClock
|
||||
`-`, not `0:00` (verified against recorded payloads; ledmatrix-plugins#580
|
||||
pins it). Whatever rule ufc gets must not read `-` as `0:00`. Decide ufc's
|
||||
rule: no clock rule (ESPN's `STATUS_FINAL` is the only end signal it needs;
|
||||
this also closes a ~1 s window at the horn when the ticking clock reads
|
||||
`0:00`), or its own final period.
|
||||
- **6, favourite matching.** NRL keeps matching favourites by team id
|
||||
(abbreviations collide: NEW, CAN), through `_favorite_key` rather than its
|
||||
own copies of the selection methods. Six plugins log the recent-games
|
||||
selection at INFO; baseball, football and ufc do not.
|
||||
- **5, game-over check. Decided 2026-10-05, done:** one seam,
|
||||
`FINAL_PERIOD`: hockey 3; basketball, football and lacrosse 4; `None` (the
|
||||
clock never ends a game) for afl, nrl and soccer (clocks that count up),
|
||||
baseball (its games carry no `period`, so the old rule was dormant) and
|
||||
ufc (a bout ends only on ESPN's final status, which also closes the ~1 s
|
||||
window at the horn when the ticking clock reads `0:00`; ESPN's round-break
|
||||
displayClock `-` was never a zero clock, ledmatrix-plugins#580). Only a
|
||||
non-empty clock string counts (the baseball/ufc copy read a missing clock
|
||||
as `0:00`). A score level at 0:00 is not over: the game stays live through
|
||||
the break before overtime, and one that really ends tied ends on its final
|
||||
status. Baseball keeps its postponed/suspended override in `BaseballLive`.
|
||||
- **6, favourite matching. Decided 2026-10-05, done:** each side of a game is
|
||||
named by `_favorite_key` (the abbreviation; NRL overrides it with the ESPN
|
||||
team id, and `None` for a missing id, which fixes a favourite typed "None"
|
||||
matching every game without one) and compared with `favorite_teams`
|
||||
stripped and upper-cased, so " bos" matches BOS. NRL's ambiguous "NEW"
|
||||
still matches nothing and is logged; routing the result-colour helpers
|
||||
(`side_is_favorite`, which tint both NEW clubs) through `_favorite_key` is
|
||||
left for a later family. The recent-games selection logs at INFO in all
|
||||
nine. ufc stays on the shared body, dormant: its favourites are fighters,
|
||||
which its MMA managers match themselves (a follow-up). Fix ported: only a
|
||||
game with an id can be a duplicate in the selection methods.
|
||||
- **7, other-games rotation.** football advances the rotation window under
|
||||
`_games_lock` (update() and display() both advance it; interleaved, a
|
||||
window of games is skipped) and fixes a favourites-only pool that recomposed
|
||||
|
||||
+91
-3
@@ -84,6 +84,43 @@ python3 web_interface/start.py
|
||||
|
||||
### Installation & Build Issues
|
||||
|
||||
#### "This version of Raspberry Pi OS is not supported"
|
||||
|
||||
LEDMatrix installs on Raspberry Pi OS Lite **Trixie** (Debian 13, Python
|
||||
3.13) or **Bookworm** (Debian 12, Python 3.11). The installer checks
|
||||
`/etc/os-release` before it changes anything and stops on anything else.
|
||||
|
||||
**Check what you have:**
|
||||
```bash
|
||||
grep -E '^(PRETTY_NAME|VERSION_ID)=' /etc/os-release
|
||||
python3 --version
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
- `VERSION_ID="11"` (Bullseye) or older: flash a new card with Raspberry Pi
|
||||
Imager, choosing Raspberry Pi OS Lite (64-bit). Trixie is recommended;
|
||||
Bookworm (Legacy) also works. An in-place upgrade from Bullseye is not
|
||||
supported by Raspberry Pi and is not worth the risk.
|
||||
- "Desktop environment detected": use the Lite image, not the desktop one.
|
||||
- "python3 is Python 3.x; LEDMatrix needs Python 3.11 or newer": something
|
||||
has replaced the system `python3`. Point it back at the OS's own Python
|
||||
(`/usr/bin/python3` should be 3.11 on Bookworm, 3.13 on Trixie).
|
||||
- `sudo bash scripts/check_system_compatibility.sh` runs the same checks
|
||||
without installing anything.
|
||||
|
||||
#### "This Pi manages its network with dhcpcd, not NetworkManager"
|
||||
|
||||
A warning, not an error: the install carries on and the display works. But
|
||||
choosing a WiFi network from the web page and the `LEDMatrix-Setup` hotspot
|
||||
both need NetworkManager, the default on Bookworm and Trixie. It appears
|
||||
when dhcpcd was selected in `raspi-config`. Switch back with a keyboard and
|
||||
screen attached (or over Ethernet), since the WiFi connection drops briefly:
|
||||
|
||||
```bash
|
||||
sudo raspi-config # Advanced Options -> Network Config -> NetworkManager
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
#### Step 6 fails: "Failed building wheel for rgbmatrix"
|
||||
|
||||
**Symptoms:**
|
||||
@@ -328,6 +365,49 @@ commit, then switches to releases on its own.
|
||||
3. **Local changes after a channel switch:** edits that no longer fit the new
|
||||
version are kept in the git stash rather than lost; `git stash list`
|
||||
shows them as "LEDMatrix autostash before update".
|
||||
4. **A new install is on a release, not `main`.** The one-shot installer
|
||||
checks out the newest release. For the newest code instead, install with
|
||||
`LEDMATRIX_CHANNEL=beta`:
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh | LEDMATRIX_CHANNEL=beta bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Issue: "service settings ... are not applied yet" after an update
|
||||
|
||||
**Symptoms:**
|
||||
- Update Code's message, or the web interface log, says an update changes
|
||||
service settings that are not applied yet, and to run the installer
|
||||
- The display logs `ledmatrix.service differs from systemd/ledmatrix.service`
|
||||
at startup
|
||||
|
||||
**Explanation:** updates install the systemd units a new version changes
|
||||
through the root helper `/usr/local/sbin/ledmatrix-refresh-units`, which the
|
||||
installer sets up and grants to the web user in
|
||||
`/etc/sudoers.d/ledmatrix_web`. A device installed before that has neither,
|
||||
so the new unit settings (for example the display's watchdog) wait for a
|
||||
reinstall. The update itself is fine.
|
||||
|
||||
**Solution:** re-run the installer once, as root:
|
||||
```bash
|
||||
cd ~/LEDMatrix
|
||||
sudo ./first_time_install.sh
|
||||
# or, lighter: install the units and helper, then the sudo rules
|
||||
sudo ./scripts/install/install_service.sh
|
||||
./scripts/install/configure_web_sudo.sh
|
||||
```
|
||||
Check it worked:
|
||||
```bash
|
||||
ls -l /usr/local/sbin/ledmatrix-refresh-units # root root, rwxr-xr-x
|
||||
sudo -l | grep ledmatrix-refresh-units # the two rules
|
||||
```
|
||||
A message that the helper **refused** a unit (`refusing to install it`)
|
||||
means a template in `systemd/` was edited so that it would run as another
|
||||
account or from another folder. The message names the template. Look at
|
||||
what changed with `git diff -- systemd/`, save any edit you want to keep,
|
||||
then restore only that file, for example
|
||||
`git checkout -- systemd/ledmatrix-web.service`.
|
||||
|
||||
---
|
||||
|
||||
@@ -364,9 +444,16 @@ commit, then switches to releases on its own.
|
||||
|
||||
5. **Check required services:**
|
||||
```bash
|
||||
systemctl is-active NetworkManager # must say "active"
|
||||
sudo systemctl status hostapd
|
||||
sudo systemctl status dnsmasq
|
||||
```
|
||||
On a fresh install `hostapd` shows as **masked**. That is expected, on
|
||||
Bookworm and Trixie alike: Debian's hostapd package masks the service
|
||||
when it is installed without a configuration, so the hotspot is brought
|
||||
up through NetworkManager instead (look for `nmcli hotspot fallback` in
|
||||
`journalctl -u ledmatrix-wifi-monitor`). If NetworkManager is not
|
||||
active, see "This Pi manages its network with dhcpcd" above.
|
||||
|
||||
6. **Manually enable AP mode:**
|
||||
```bash
|
||||
@@ -590,9 +677,10 @@ stack into the log, so it says which plugin was stuck.
|
||||
apart, so a plugin that hangs on every start does not restart the display
|
||||
hundreds of times an hour.
|
||||
|
||||
4. **Is the watchdog installed?** Installs from before it keep their old unit
|
||||
until the installer is re-run (a startup warning says the unit differs
|
||||
from its template):
|
||||
4. **Is the watchdog installed?** Updates install new unit settings once the
|
||||
installer has set up `ledmatrix-refresh-units`; installs from before that
|
||||
keep their old unit until the installer is re-run (a startup warning says
|
||||
the unit differs from its template):
|
||||
```bash
|
||||
systemctl show -p WatchdogUSec ledmatrix # 2min once running; 0 = not installed
|
||||
sudo ./scripts/install/install_service.sh
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
# Web frontend architecture
|
||||
|
||||
This page covers where the web UI's JavaScript is going and how it gets
|
||||
there one page at a time. The UI is Flask + HTMX + Alpine.js. Templates
|
||||
live in `web_interface/templates/v3/` and static files in
|
||||
`web_interface/static/v3/`.
|
||||
|
||||
Two rules hold at every step:
|
||||
|
||||
- **The Pi never builds anything.** It serves the files that are committed.
|
||||
CI builds the generated CSS (`scripts/build_css.py`, see #685) and checks
|
||||
it. The JavaScript needs no build at all: it is native ES modules that the
|
||||
browser loads as they are.
|
||||
- **Every page keeps working, and so does every plugin.** Third-party plugin
|
||||
forms, `x-widget` scripts and plugin web UIs use the existing `window.*`
|
||||
names. Each name keeps working as an alias until a release announces that
|
||||
it will be removed.
|
||||
|
||||
## Where it started
|
||||
|
||||
- About 195 `window.*` globals. Their load order is held together by comments
|
||||
repeated in the headers of `app-early.js`, `app-shell.js` and
|
||||
`plugins_manager.js`.
|
||||
- About 5,700 lines of inline `<script>` in the tab partials.
|
||||
`js/htmx-config.js` re-runs every one of them after every htmx swap, so
|
||||
each partial's code had to cope with running twice.
|
||||
- The installed-plugin list is kept in four places.
|
||||
- Plugin config forms are drawn by the `render_field` macro in
|
||||
`partials/plugin_config.html`, which is about 1,100 lines of Jinja. It
|
||||
duplicates the JS widgets. The server then needs about 430 lines to
|
||||
rebuild JSON from the flat dotted keys the form posts. The soccer form
|
||||
renders to 1.2 MB of HTML.
|
||||
- `plugins_manager.js` is 3,800 lines. The owner decided that it needs a
|
||||
namespace refactor before it is split, which is what this plan provides.
|
||||
|
||||
## Target
|
||||
|
||||
```
|
||||
static/v3/js/
|
||||
core/ ES modules ("type": "module" in core/package.json)
|
||||
boot.js entry point; base.html loads it with <script type="module">
|
||||
registry.js page lifecycle: init/destroy on htmx swaps
|
||||
api.js fetch wrapper for /api/v3 (JSON envelope, login redirect)
|
||||
facade.js window.LEDMatrix and deprecated aliases
|
||||
visibility.js ctx.visibility: a page's timers run only while it is on screen
|
||||
(later) escape.js, notify.js, dialog.js, streams.js,
|
||||
store.js (the one installed-plugin store), form/renderer.js
|
||||
pages/ one module per tab partial
|
||||
cache.js export init(root, ctx), destroy(root, ctx)
|
||||
durations.js, operation-history.js, raw-json.js, backup-restore.js
|
||||
...
|
||||
```
|
||||
|
||||
### The page lifecycle
|
||||
|
||||
A converted partial has no `<script>`. Its root element names its page:
|
||||
|
||||
```html
|
||||
<div class="..." data-page="cache"> ... </div>
|
||||
```
|
||||
|
||||
`core/boot.js` lists each page with a loader,
|
||||
`'cache': page(function() { return import('../pages/cache.js'); })`, and
|
||||
registers them all. A page's module is fetched only when its partial first
|
||||
appears. `page()` remembers the module once loaded, so the alias of an old
|
||||
synchronous global (`validateJSON` returns a boolean) still answers
|
||||
synchronously while its page is on screen.
|
||||
|
||||
The conventions the converted pages share:
|
||||
|
||||
- **Buttons name an action.** A partial's buttons carry `data-action` (and
|
||||
any argument as another `data-*` attribute) instead of an `onclick` that
|
||||
names a global. One delegated listener on the page root handles them all,
|
||||
including rows drawn later.
|
||||
- **Server data is drawn with `textContent`**, never a markup string.
|
||||
- **Reads are cancelled, writes are not.** Loads pass `ctx.signal`, so a swap
|
||||
cancels them. Saves, deletes, exports and restores do not: the server
|
||||
finishes them anyway, so the page still reports the result in a
|
||||
notification but draws nothing into a page that has gone.
|
||||
- **Old globals become aliases.** Each `window.*` name a page used to define
|
||||
is made in `boot.js` with `alias(page, name, replacement)`, which forwards
|
||||
to the module's export of the same name and warns once.
|
||||
- **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot
|
||||
undo by itself.
|
||||
- **Polling goes through `ctx.visibility`.** A refresh that repeats
|
||||
(`ctx.visibility.every(ms, fn)`) or work that should run only while the
|
||||
page is on screen (`ctx.visibility.whileVisible(start, stop)`) is
|
||||
registered there, never with a bare `setInterval`. It runs only while the
|
||||
page's tab is the active tab and the browser tab is visible, and it ends
|
||||
when the page is destroyed, with no code in `destroy()`.
|
||||
- **A page reports its own htmx saves.** A form whose result a page module
|
||||
shows (an `htmx:afterRequest` listener on the page root, in place of an
|
||||
`hx-on` attribute naming a global) carries `data-reports-result`. `app.js`
|
||||
then leaves the server's message to the page, as it does for a form with
|
||||
an `hx-on` after-request handler, so a save shows one notification.
|
||||
- **Server data for the module goes in `data-*` attributes**, as JSON where
|
||||
it is structured (`data-schedule-config='{{ schedule_config | tojson }}'`),
|
||||
not templated into a script.
|
||||
|
||||
`core/registry.js` handles the rest:
|
||||
|
||||
| Event | What the registry does |
|
||||
|---|---|
|
||||
| `htmx:beforeSwap` (on `document`, so it runs after the body-level handlers that can veto a swap) | If `detail.shouldSwap` is still true, destroys every mounted page inside the swap target |
|
||||
| `htmx:afterSwap` | Destroys any mounted page whose root has left the document, then mounts every `data-page` root not mounted yet |
|
||||
| `LEDMatrix.pages.refresh()` | Same as afterSwap. `loadPartialDirect` (the no-htmx fallback in `base.html`) calls it |
|
||||
| `start()` | Mounts whatever is already on the page. Module scripts run deferred, so a partial may arrive first |
|
||||
|
||||
Mounting is idempotent: a root is never initialised twice.
|
||||
|
||||
Each mount gets a `ctx` object:
|
||||
|
||||
| Field | Contents |
|
||||
|---|---|
|
||||
| `ctx.root` | The page's root element |
|
||||
| `ctx.name` | The page name |
|
||||
| `ctx.signal` | An `AbortSignal` that is aborted after `destroy()` |
|
||||
| `ctx.state` | A per-mount object for the page's own state |
|
||||
| `ctx.api` | Shared service from `boot.js` |
|
||||
| `ctx.notify` | Shared service from `boot.js` |
|
||||
| `ctx.visibility` | This page's handle on `core/visibility.js` (below), made per mount by `boot.js` through the registry's `mountContext` option |
|
||||
|
||||
A page that passes `{ signal: ctx.signal }` to `addEventListener` and
|
||||
`fetch` needs no teardown code. Its listeners and in-flight requests go
|
||||
away when the partial is swapped out. `pages/cache.js` is the worked
|
||||
example: its delete buttons use one delegated listener, rows are built with
|
||||
`textContent` rather than markup strings, and a newer load supersedes an
|
||||
older one.
|
||||
|
||||
### Page visibility
|
||||
|
||||
`core/visibility.js` gives each mounted page `ctx.visibility`:
|
||||
|
||||
| Member | What it does |
|
||||
|---|---|
|
||||
| `whileVisible(start, stop)` | Runs `start()` when the page comes on screen (at once, if it mounts on screen) and `stop()` when it leaves. Returns a function that ends the registration, running `stop()` first if needed |
|
||||
| `every(ms, fn)` | `fn()` at once, then every `ms` while on screen. The interval is cleared while hidden and restarted, with an immediate `fn()`, when the page is back. Returns the same kind of end function |
|
||||
| `isVisible()` | True while the page is on screen |
|
||||
| `tab` | The tab the page belongs to: its name, or `forPage(ctx, { tab })` |
|
||||
|
||||
"On screen" means the page's tab is the active tab and the browser tab is
|
||||
visible. Everything a page registered ends when its `ctx.signal` aborts,
|
||||
after `destroy()`, so a swapped-out partial leaves no interval behind.
|
||||
|
||||
The answer comes from `window.LEDVisibility` (`app-shell.js`), read at call
|
||||
time, so the page modules and the classic partials that still call it
|
||||
(Overview, Logs, Tools) agree on the active tab, and the SSE streams keep
|
||||
pausing with them. Each registration takes its own `LEDVisibility` key, so
|
||||
registrations never replace each other or a classic partial's. Without
|
||||
`LEDVisibility` (a page outside `base.html`), the browser tab's visibility
|
||||
alone decides. Moving the tracker itself into the module (the shell table
|
||||
below) changes only `core/visibility.js`.
|
||||
|
||||
### One facade
|
||||
|
||||
`window.LEDMatrix` is the only global the module code adds:
|
||||
|
||||
| Member | What it is |
|
||||
|---|---|
|
||||
| `api` | `core/api.js`: `get`/`post`/`put`/`del`. Resolves to the JSON body, rejects with an `ApiError` |
|
||||
| `pages` | `register`, `refresh`, `list` |
|
||||
| `notify(message, type)` | Calls `window.showNotification`, looked up at call time |
|
||||
| `escape` | Read-through to `window.LEDEscape` |
|
||||
| `widgets` | Read-through to `window.LEDMatrixWidgets` |
|
||||
| `deprecate(name, target, replacement)` | Keeps an old `window.*` name working. It warns once in the console, then forwards |
|
||||
|
||||
`ApiError` carries `status`, `body`, `network` and `loginRequired`.
|
||||
|
||||
`escape`, `widgets` and `notify` are read at call time. The classic scripts
|
||||
that define them are deferred, and a plugin may replace them.
|
||||
|
||||
Login: `base.html` wraps `window.fetch` before any other script runs, and
|
||||
the wrapper sends a 401 with `X-LEDMatrix-Login` to the login page (#683).
|
||||
`api.js` calls `window.fetch` at call time, so its requests get the same
|
||||
redirect. It also rejects that answer quietly with `loginRequired`, so no
|
||||
error message flashes up while the page navigates away.
|
||||
|
||||
### Serving modules from the Pi
|
||||
|
||||
- **MIME type.** A browser runs a module only when it is served with a
|
||||
JavaScript MIME type. `app.py` pins `.js` and `.mjs` to `text/javascript`
|
||||
rather than trusting the host's mimetypes table, and
|
||||
`test/web_interface/test_es_modules.py` checks it.
|
||||
- **Caching.** `url_for` adds `?v=<mtime>` to the entry script, but modules
|
||||
import each other by plain relative URL, without the version. A static
|
||||
`.js` request without `v` is therefore served `Cache-Control: no-cache`
|
||||
(revalidated, so 304 when unchanged) instead of being cached as immutable
|
||||
for a year. Versioned URLs keep the long cache. A later optimisation is an
|
||||
import map that maps each module to its versioned URL.
|
||||
- **Load order.** `boot.js` loads after every classic script. Modules are
|
||||
deferred and run in document order with the deferred classic scripts.
|
||||
Nothing classic may depend on a module at load time. A classic script that
|
||||
needs a module service calls `window.LEDMatrix` at run time.
|
||||
|
||||
### One form model
|
||||
|
||||
`src/plugin_system/field_model.py` provides
|
||||
`build_field_model(schema, config, plugin_id)`. It walks a plugin's schema
|
||||
once and returns a JSON tree with these keys for each field:
|
||||
|
||||
- path, label, help, widget
|
||||
- starting value, default
|
||||
- constraints, options, secret flag
|
||||
- the exact form controls the macro posts today (`inputs`)
|
||||
- the JS widget it mounts (`mount`)
|
||||
|
||||
`test/test_field_model_parity.py` renders the real macro for every schema it
|
||||
can find and checks that the model names the same controls, with the same
|
||||
starting values, in the same order, and the same widget mounts. The schemas
|
||||
come from `plugin-repos/`, `test/fixtures/plugins/`, the ledmatrix-plugins
|
||||
monorepo when a checkout is present, and a synthetic schema that reaches
|
||||
every branch of the macro. The test was mutation-checked when it was
|
||||
written. Each of these deliberate model bugs makes it fail:
|
||||
|
||||
- dropping the checkbox-group sentinel
|
||||
- dropping a table's `00:00` time default
|
||||
- picking the first matching `<select>` option instead of the last
|
||||
- dropping the `None` quirk
|
||||
- missing all-hidden objects
|
||||
|
||||
The model mirrors the macro's quirks on purpose. The parity run surfaced
|
||||
these:
|
||||
|
||||
- 83 number fields whose schema default is `null` render `value="None"`.
|
||||
- Four array fields name an `x-widget` the core does not ship (`color`,
|
||||
`tag-input`) and fall back to a comma-separated text box.
|
||||
- A list-typed `type` uses its first entry, so `["null", "string"]` draws a
|
||||
text box.
|
||||
- Eleven objects with no properties and no widget render nothing.
|
||||
|
||||
These get fixed once, in the renderer, after the switch below.
|
||||
|
||||
## Switching forms to the model, behind a flag
|
||||
|
||||
Stage 1 (this change) only proves the model is complete. Rendering does not
|
||||
change. The switch is staged so either path can be turned back on at any
|
||||
point:
|
||||
|
||||
1. **Model endpoint.** `GET /api/v3/plugins/config/model?plugin_id=<id>`
|
||||
returns `build_field_model(schema, prepared_masked_config)`. It uses the
|
||||
same preparation as the partial: defaults merged, secrets masked.
|
||||
2. **Renderer module.** `core/form/renderer.js` walks the model. It draws
|
||||
plain fields itself and hands every widget to `LEDMatrixWidgets` through
|
||||
one `mount(el, field)` adapter. The adapter keeps plugin widgets' existing
|
||||
`render(container, config, value, options)` signature (the hard
|
||||
constraint in PRODUCT.md). `getValue()` results are assembled into one
|
||||
JSON object.
|
||||
3. **Flag.** `plugin_config.html` renders the macro unless the form-model
|
||||
flag is on. The flag is a `web_interface.form_model` setting in
|
||||
`config.json` (default off), plus a per-browser override
|
||||
(`localStorage.ledmatrixFormModel`) so a tester can compare both paths on
|
||||
one device. With the flag on, the partial renders only a
|
||||
`<div data-page="plugin-config" data-plugin-id="...">` root, and
|
||||
`pages/plugin-config.js` fetches the model and renders it.
|
||||
4. **JSON submit.** With the flag on, Save posts
|
||||
`Content-Type: application/json` to the existing
|
||||
`POST /api/v3/plugins/config` JSON path (`plugin_config.py`, `is_json`).
|
||||
That path already validates against the schema and keeps secrets. No
|
||||
dotted keys, no `__rendered_section`, no checkbox reconstruction.
|
||||
5. **Save parity test.** This gates turning the flag on by default. For every
|
||||
schema, posting the macro form's data and posting the renderer's JSON
|
||||
must store the same config.
|
||||
6. **Retire.** Once the flag has been on by default for a release with no
|
||||
regressions, the macro shrinks to a no-JS fallback for plain fields, and
|
||||
the form-encoded reconstruction (`_parse_form_value_with_schema`,
|
||||
`_set_nested_value`, `_set_missing_booleans_to_false` and friends in
|
||||
`api_v3/__init__.py`) is deleted. The settings search index is then built
|
||||
from the model instead of from rendered HTML.
|
||||
|
||||
## Migration order
|
||||
|
||||
Smallest and most isolated first. `plugins_manager.js` goes last. Line counts
|
||||
are the inline script in each partial today.
|
||||
|
||||
| # | Page | Inline JS | Why it is here |
|
||||
|---|---|---|---|
|
||||
| 1 | Cache (`cache.html`) | 163 lines, now 0 | **Done in stage 1.** One endpoint pair, no globals other pages use. The reference conversion |
|
||||
| 2 | Rotation (`durations.html`) | 29 lines, now 0 | **Done in stage 2.** The form stays plain htmx; the page starts the shared rotation-order widget, whose plugin-list request now takes `ctx.signal`. Its `hx-on` and `onsubmit` attributes call shared globals (`showSaveResult`, `fixInvalidNumberInputs`) and move with step 6 |
|
||||
| 3 | Operation History | 293 lines, now 0 | **Done in stage 2.** Read-only list; rows drawn with `textContent`, the search debounce cleared on destroy. The "Showing x to y" counters now also reset when nothing matches |
|
||||
| 4 | Config Editor (`raw_json.html`) | 212 lines, now 0 | **Done in stage 2.** Plain textareas (no CodeMirror on this page). It defined 5 globals after all (`formatJson`, `manualValidateJson`, `validateJSON`, `saveMainConfig`, `saveSecretsConfig`); nothing else used them, and they are deprecated aliases now. The live "Invalid JSON" line no longer puts the parser's message into `innerHTML` |
|
||||
| 5 | Backup & Restore | 232 lines, now 0 | **Done in stage 2.** Its 5 globals (`exportBackup`, `loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`) are deprecated aliases; the buttons are delegated `data-action`s. Uploads go through `ctx.api.request(..., { body: formData })` (`api.js` gained a raw `body` option) |
|
||||
| 6 | Schedule | 193 lines, now 0 | **Done in stage 3.** Its 2 `hx-on` response handlers (`handleScheduleResponse`, `handleDimScheduleResponse`) are one `htmx:afterRequest` listener on the page root, and deprecated aliases. The forms are marked `data-reports-result` so `app.js` does not repeat the server's message. The saved schedules reach the module as JSON in `data-schedule-config` / `data-dim-schedule-config` instead of being templated into the script |
|
||||
| 7 | General | 153 lines, now 0 | **Done in stage 3.** The Security section's three forms and two buttons are delegated `data-action`s (one submit and one click listener); `window.webLogin` is a deprecated alias of an object with its five methods. Login requests go through `ctx.api`, so the login redirect is quiet. The settings form keeps its `hx-on` call to the shared `showSaveResult`, as Rotation's does |
|
||||
| 8 | Display | 292 lines (2 scripts), now 0 | **Done in stage 4.** The first page with a timer: the 5 s multi-display sync poll is `ctx.visibility.every(5000, ...)` (above), so it runs only while the tab is on screen and stops when the partial is swapped out. Its one global, `updateSyncUI` (the Role menu's `onchange`), is a deprecated alias; the Advanced section's `onclick` is a delegated `data-action="toggle-section"` that calls the shared `toggleSection`. The status poll and the scroll-speed hint go through `ctx.api` with `ctx.signal`, as does the Vegas order widget's plugin-list request. The settings form keeps its `hx-on` call to `showSaveResult` and its `onsubmit` call to `fixInvalidNumberInputs`, as Rotation's does |
|
||||
| 9 | Overview | 410 (4 scripts) | First-run surface: Getting Started, update banner, live preview. Five globals |
|
||||
| 10 | WiFi | 364 | `x-data="wifiSetup()"` is defined by its own script. Moves to `Alpine.data()` registered from the module. AP-mode first screen, so it needs the AP-mode test on a real device |
|
||||
| 11 | Fonts | 681 | Large, but self-contained (6 globals) |
|
||||
| 12 | Logs | 801 | 14 globals, a stream and timers. Uses the visibility service from step 8 |
|
||||
| 13 | Tools | 1,022 | 21 globals, MQTT bridge, Pixlet editor, diagnostics polling |
|
||||
| 14 | Starlark app config, plugin config (`plugin_config.html`) | 123 + 294 | Plugin panels sit inside an Alpine `x-if` that removes them without an htmx swap. The registry's sweep covers that on the next swap; this step adds a MutationObserver or an `x-if` hook. Then the form-model flag (above) |
|
||||
| 15 | Plugin Manager (`plugins.html` + `plugins_manager.js`) | 3,836-line file | Last. Split along the seams that already exist (installed grid, store, registries, Starlark section, on-demand) into `pages/plugins/*.js`. Its 42 globals become aliases. The four installed-plugin stores merge into one `core/store.js`, and `window.installedPlugins` becomes a getter over it |
|
||||
|
||||
The shell moves in parallel, a service at a time, with no page depending on
|
||||
the order:
|
||||
|
||||
| Service | Current home | New module |
|
||||
|---|---|---|
|
||||
| `showNotification` | 4 versions | `core/notify.js` |
|
||||
| The modal helper | `utils/dialog.js` | `core/dialog.js` |
|
||||
| SSE streams | `app-shell.js` | `core/streams.js` |
|
||||
| `LEDVisibility` | `app-shell.js` | `core/visibility.js` (the page-facing `ctx.visibility` is there since step 8; it reads the tracker from `app-shell.js`) |
|
||||
|
||||
Each move leaves the old global as an alias. When the last inline script is
|
||||
gone, the script re-execution in `htmx-config.js` and the "HTMX never
|
||||
loaded" fallbacks in `base.html` can go too (keep the captive-page path).
|
||||
|
||||
## How the tests cover each step
|
||||
|
||||
The JS suites live in `test/js` (see `test/js/README.md` and
|
||||
`docs/HOW_TO_RUN_TESTS.md`). In CI, the **Web UI JS tests** job installs
|
||||
jsdom, starts the web interface and runs `node test/js/run_all.js` with
|
||||
`REQUIRE_DOM=1`, so a skipped DOM suite fails the job.
|
||||
`test/test_js_unit_suites.py` also runs every unit suite under pytest.
|
||||
|
||||
Unit suites need only node. They import the shipped modules directly:
|
||||
`core/package.json` and `pages/package.json` mark those directories
|
||||
`"type": "module"`.
|
||||
|
||||
| Suite | Kind | What it covers |
|
||||
|---|---|---|
|
||||
| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment, `mountContext` fields per mount |
|
||||
| `dom/test_visibility_service.js` | DOM: real `LEDVisibility` from `app-shell.js`, real registry, no server | `whileVisible` and `every` start and stop with the active tab and the browser tab's visibility; no interval runs while hidden or after a swap-out; one interval after five swaps; registrations never replace each other or a classic partial's; a destroyed page registers nothing; a throwing `start()` is contained; the no-`LEDVisibility` fallback |
|
||||
| `unit/test_core_modules.js` | Unit | `api.js` (envelope, errors, abort, login redirect, path check) and `facade.js` (facade, aliases) |
|
||||
| `dom/test_cache_page.js` | DOM: real partial, real API shape | No inline script; one request per swap and per Refresh after five swaps; a cancelled request draws nothing; hostile keys stay text; delete, empty, error, network and login states |
|
||||
| `dom/test_durations_page.js` | DOM: real partial, real widget, real API shape | One plugin-list request per swap; Move down moves one place after five swaps; the swap cancels a request in flight; a late-loading widget is waited for, and a page swapped away while waiting starts nothing; hostile names stay text |
|
||||
| `dom/test_operation_history_page.js` | DOM: real partial, real API shape | One history request per swap and per Refresh; the plugin filter filled once (from `PluginAPI`'s cache when loaded); paging, filters, debounced search, Clear (one DELETE), error/network/login states, cancel on swap; hostile ids, users and errors stay text |
|
||||
| `dom/test_raw_json_page.js` | DOM: real partial, real config | One POST per Save after five swaps, to the right file; Format and Validate act once; invalid JSON never sent and its message stays text; a save survives a swap and is still reported; the old globals' entry points |
|
||||
| `dom/test_schedule_page.js` | DOM: real partial, real widget | Both pickers drawn once per swap from the saved config; after five swaps each form's answer is one notification (message, fallback, refused, non-JSON, `null`), a request from outside the forms none; the brightness label; a late widget waited for, a page swapped away while waiting draws nothing; the old globals' entry points |
|
||||
| `dom/test_display_page.js` | DOM: real partial, real widget, real `LEDVisibility`, real API shape | After five swaps one page, one sync interval, the Vegas order drawn once and each control acting once (brightness, resolution, the two show/hide toggles, the Advanced toggle, one debounced hint request); the sync poll only while on screen and never after a swap-out; sync states and hostile peer names as text, failure and login answers; a late widget waited for; `updateSyncUI`'s entry point |
|
||||
| `dom/test_general_page.js` | DOM: real partial, real widget, real API shape | The timezone picker drawn once per swap with the saved zone; the settings form left to htmx; after five swaps each Security action makes one request (create, copy, revoke and its cancel, password and its mismatch); hostile token names stay text; refused, network and login answers; a create made before a swap is still reported and draws nothing; `webLogin`'s entry points |
|
||||
| `dom/test_backup_restore_page.js` | DOM: real partial, real API shape | One request per Refresh, Delete, Export (busy button ignores a second click), Inspect and Restore after five swaps; the upload's fields and the six restore options; reads cancelled by a swap, writes not; hostile file and host names stay text; the old globals' entry points |
|
||||
| `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; the converted pages are exactly the registered ones, each with its module, `init`, and one root in the rendered partial; a converted partial has no `<script>` and no `onclick`; every moved global is aliased in `boot.js` and exported by its module, and no template defines it any more |
|
||||
| `test/test_field_model_parity.py` | pytest | The model against the macro for every available schema |
|
||||
|
||||
What each future step adds:
|
||||
|
||||
- **A page conversion** adds `dom/test_<page>_page.js`, built like the cache
|
||||
suite: the real partial from the server, the real API's payload shape, N
|
||||
swaps followed by one action that must make exactly one request, the
|
||||
destroy and cancel behaviour, and escaping. `test_es_modules.py` picks up
|
||||
the new page automatically. A unit suite that today slices a function out
|
||||
of a template or `plugins_manager.js` and `eval`s it is rewritten to
|
||||
import the module once that code moves (stage C of the plan).
|
||||
- **A shell service move** adds a unit suite for the module and an alias
|
||||
test showing the old global still works.
|
||||
- **The form switch** adds the save parity test (macro form data and
|
||||
renderer JSON store the same config, for every schema) and a DOM suite
|
||||
for `pages/plugin-config.js`. Both run with the flag on and off.
|
||||
- **`plugins_manager.js`.** The existing DOM suites (`test_installed_dom.js`,
|
||||
`test_store_dom.js`, `test_no_double_fetch.js`) already test the real
|
||||
Plugin Manager in jsdom. They stay green throughout the split and are the
|
||||
gate for it, alongside the unit suites that pin its card rendering and
|
||||
escaping.
|
||||
+160
-47
@@ -47,38 +47,51 @@ if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
|
||||
echo "Raspberry Pi 5 detected — will verify RP1 library support."
|
||||
fi
|
||||
|
||||
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
||||
# Check OS version - must be Raspberry Pi OS Lite, Bookworm or Trixie.
|
||||
# The rules live in scripts/install/lib_os.sh, shared with
|
||||
# scripts/check_system_compatibility.sh.
|
||||
echo ""
|
||||
echo "Checking operating system requirements..."
|
||||
echo "----------------------------------------"
|
||||
OS_CHECK_FAILED=0
|
||||
OS_RELEASE=""
|
||||
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
echo "Detected OS: $PRETTY_NAME"
|
||||
echo "Version ID: ${VERSION_ID:-unknown}"
|
||||
|
||||
# Check if it's Raspberry Pi OS or Debian
|
||||
if [[ "$ID" != "raspbian" ]] && [[ "$ID" != "debian" ]]; then
|
||||
echo "✗ ERROR: This script requires Raspberry Pi OS (raspbian/debian)"
|
||||
echo " Detected OS ID: $ID"
|
||||
OS_CHECK_FAILED=1
|
||||
fi
|
||||
|
||||
# Check if it's Debian 13 (Trixie)
|
||||
if [ "${VERSION_ID:-0}" != "13" ]; then
|
||||
echo "✗ ERROR: This script requires Raspberry Pi OS Lite (Trixie) - Debian 13"
|
||||
echo " Detected version: ${VERSION_ID:-unknown}"
|
||||
echo " Please upgrade to Raspberry Pi OS Lite (Trixie) before continuing"
|
||||
OS_CHECK_FAILED=1
|
||||
OS_LIB="$(cd "$(dirname "$0")" && pwd)/scripts/install/lib_os.sh"
|
||||
if [ ! -f "$OS_LIB" ]; then
|
||||
echo "✗ ERROR: $OS_LIB is missing, so the operating system cannot be checked."
|
||||
echo " Your LEDMatrix download is incomplete. Download it again and re-run this script:"
|
||||
echo " git clone https://github.com/ChuckBuilds/LEDMatrix.git"
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=scripts/install/lib_os.sh
|
||||
. "$OS_LIB"
|
||||
|
||||
if [ -r "$LM_OS_RELEASE_FILE" ]; then
|
||||
echo "Detected OS: $(lm_os_field PRETTY_NAME)"
|
||||
OS_VERSION_ID=$(lm_os_field VERSION_ID)
|
||||
echo "Version ID: ${OS_VERSION_ID:-unknown}"
|
||||
|
||||
if OS_RELEASE=$(lm_os_release); then
|
||||
echo "✓ $(lm_release_label "$OS_RELEASE") detected"
|
||||
else
|
||||
echo "✓ Debian 13 (Trixie) detected"
|
||||
OS_ID=$(lm_os_field ID)
|
||||
if [[ "$OS_ID" != "raspbian" ]] && [[ "$OS_ID" != "debian" ]]; then
|
||||
echo "✗ ERROR: This script requires Raspberry Pi OS (raspbian/debian)"
|
||||
echo " Detected OS ID: ${OS_ID:-unknown}"
|
||||
else
|
||||
echo "✗ ERROR: This version of Raspberry Pi OS is not supported"
|
||||
echo " Detected version: ${OS_VERSION_ID:-unknown}"
|
||||
echo " Supported: Trixie (Debian 13) and Bookworm (Debian 12)"
|
||||
fi
|
||||
OS_CHECK_FAILED=1
|
||||
fi
|
||||
|
||||
|
||||
# Check if it's the Lite version (no desktop environment)
|
||||
# Check for desktop packages or desktop services
|
||||
DESKTOP_DETECTED=0
|
||||
if dpkg -l | grep -qE "^ii.*raspberrypi-ui-mods|^ii.*lxde|^ii.*xfce|^ii.*gnome|^ii.*kde"; then
|
||||
# grep without -q: -q exits at the first match, dpkg then dies of SIGPIPE,
|
||||
# and pipefail turns a found desktop into "not found".
|
||||
if dpkg -l | grep -E "^ii.*raspberrypi-ui-mods|^ii.*lxde|^ii.*xfce|^ii.*gnome|^ii.*kde" >/dev/null; then
|
||||
DESKTOP_DETECTED=1
|
||||
fi
|
||||
if systemctl list-units --type=service --state=running 2>/dev/null | grep -qE "lightdm|gdm3|sddm|lxdm"; then
|
||||
@@ -96,23 +109,52 @@ if [ -f /etc/os-release ]; then
|
||||
echo "✓ Lite version confirmed (no desktop environment)"
|
||||
fi
|
||||
else
|
||||
echo "✗ ERROR: Could not detect OS version (/etc/os-release not found)"
|
||||
echo "✗ ERROR: Could not detect OS version ($LM_OS_RELEASE_FILE not found)"
|
||||
OS_CHECK_FAILED=1
|
||||
fi
|
||||
|
||||
# Python: whatever python3 the release ships (3.11 on Bookworm, 3.13 on
|
||||
# Trixie). Checked only when python3 is already there -- Step 1 installs it
|
||||
# otherwise, and on a supported release that brings the release's own version.
|
||||
if [ "$OS_CHECK_FAILED" -eq 0 ]; then
|
||||
if PYTHON3_VERSION=$(lm_python_version); then
|
||||
case "$(lm_python_check "$PYTHON3_VERSION")" in
|
||||
ok)
|
||||
echo "✓ Python $PYTHON3_VERSION detected"
|
||||
;;
|
||||
too-old)
|
||||
echo "✗ ERROR: python3 is Python $PYTHON3_VERSION; LEDMatrix needs Python 3.$LM_PYTHON_MIN_MINOR or newer"
|
||||
echo " $(lm_release_label "$OS_RELEASE") ships Python $(lm_release_python "$OS_RELEASE"). Something on this"
|
||||
echo " system has changed which Python 'python3' runs; point it back at the system Python."
|
||||
OS_CHECK_FAILED=1
|
||||
;;
|
||||
*)
|
||||
echo "⚠ python3 is Python $PYTHON3_VERSION, which LEDMatrix has not been tested with"
|
||||
echo " (tested: 3.$LM_PYTHON_MIN_MINOR to 3.$LM_PYTHON_MAX_MINOR). Continuing anyway."
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo "python3 not found yet; Step 1 installs it."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$OS_CHECK_FAILED" -eq 1 ]; then
|
||||
echo ""
|
||||
echo "Installation cannot continue. Please install Raspberry Pi OS Lite (Trixie) and try again."
|
||||
echo ""
|
||||
echo "To install Raspberry Pi OS Lite (Trixie):"
|
||||
echo " 1. Download from: https://www.raspberrypi.com/software/operating-systems/"
|
||||
echo " 2. Select 'Raspberry Pi OS Lite (64-bit)' with Debian 13 (Trixie)"
|
||||
echo " 3. Flash to SD card using Raspberry Pi Imager"
|
||||
echo " 4. Boot and run this script again"
|
||||
echo "Installation cannot continue."
|
||||
lm_print_supported_os_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ OS requirements met"
|
||||
|
||||
# WiFi setup (the web page's WiFi tab and the LEDMatrix-Setup hotspot) needs
|
||||
# NetworkManager. Both releases use it by default; say so plainly if this Pi
|
||||
# does not, but carry on -- the display itself does not depend on it.
|
||||
case "$(lm_network_stack)" in
|
||||
networkmanager) echo "✓ NetworkManager is managing the network" ;;
|
||||
dhcpcd) lm_print_dhcpcd_advice ;;
|
||||
*) echo "⚠ Could not tell which service manages the network; WiFi setup from the web page needs NetworkManager" ;;
|
||||
esac
|
||||
echo ""
|
||||
|
||||
# The user who ran the installer: SUDO_USER once we are running under sudo
|
||||
@@ -190,6 +232,51 @@ _sync_rgb_submodule() {
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# LEDMatrix's own changes to the library live in patches/rpi-rgb-led-matrix/ and
|
||||
# are applied only for the build: _apply_rgb_patches before it, _revert_rgb_patches
|
||||
# after it, success or not. The checkout is left exactly as it was, so `git pull`
|
||||
# and _sync_rgb_submodule never meet local modifications in the submodule.
|
||||
# A patch that no longer applies (a submodule bump, a hand-edited checkout) is
|
||||
# reported and skipped -- the unpatched library still builds and works, so it is
|
||||
# never fatal. One that is already applied is left alone and not reverted.
|
||||
_RGB_APPLIED_PATCHES=()
|
||||
|
||||
_apply_rgb_patches() {
|
||||
local sub="$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
|
||||
local dir="$PROJECT_ROOT_DIR/patches/rpi-rgb-led-matrix" patch name
|
||||
_RGB_APPLIED_PATCHES=()
|
||||
[ -d "$dir" ] || return 0
|
||||
for patch in "$dir"/*.patch; do
|
||||
[ -f "$patch" ] || continue
|
||||
name=$(basename "$patch")
|
||||
if _git_as_repo_owner -C "$sub" apply --check "$patch" >/dev/null 2>&1; then
|
||||
if _git_as_repo_owner -C "$sub" apply "$patch"; then
|
||||
_RGB_APPLIED_PATCHES+=("$patch")
|
||||
echo "Applied library patch $name"
|
||||
else
|
||||
echo "⚠ Could not apply library patch $name; building without it"
|
||||
fi
|
||||
elif _git_as_repo_owner -C "$sub" apply --reverse --check "$patch" >/dev/null 2>&1; then
|
||||
echo "Library patch $name is already applied"
|
||||
else
|
||||
echo "⚠ Library patch $name does not apply to this checkout; building without it"
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
_revert_rgb_patches() {
|
||||
local sub="$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" i
|
||||
# Last applied first, in case two patches touch the same file.
|
||||
for ((i = ${#_RGB_APPLIED_PATCHES[@]} - 1; i >= 0; i--)); do
|
||||
if ! _git_as_repo_owner -C "$sub" apply --reverse "${_RGB_APPLIED_PATCHES[i]}"; then
|
||||
echo "⚠ Could not revert $(basename "${_RGB_APPLIED_PATCHES[i]}"); restore the checkout with: git -C $sub checkout -- ."
|
||||
fi
|
||||
done
|
||||
_RGB_APPLIED_PATCHES=()
|
||||
return 0
|
||||
}
|
||||
# --- end rpi-rgb-led-matrix checkout helpers ---------------------------------
|
||||
|
||||
# Determine the Project Root Directory (where this script is located)
|
||||
@@ -223,6 +310,8 @@ SKIP_SWAP=${LEDMATRIX_SKIP_SWAP:-0}
|
||||
BUILD_JOBS_OVERRIDE=${LEDMATRIX_BUILD_JOBS:-}
|
||||
# Weekly automatic updates: 1 on, 0 off, empty = ask (interactive) or leave as is.
|
||||
AUTO_UPDATE=${LEDMATRIX_AUTO_UPDATE:-}
|
||||
# Update channel written to config.json: stable, beta, or empty = leave as is.
|
||||
UPDATE_CHANNEL=$(printf '%s' "${LEDMATRIX_CHANNEL:-}" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
@@ -240,12 +329,18 @@ Options:
|
||||
--enable-auto-update Turn on weekly automatic updates (with health
|
||||
check and automatic rollback)
|
||||
--no-auto-update Leave weekly automatic updates off
|
||||
--beta Follow main, the newest code (the beta update
|
||||
channel). Without it, updates follow releases
|
||||
(stable). It sets the channel; it does not move
|
||||
this checkout -- the one-shot installer picks the
|
||||
version, and so does the next update.
|
||||
-h, --help Show this help message and exit
|
||||
|
||||
Environment variables (same effect as flags):
|
||||
LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1,
|
||||
LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=1,
|
||||
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N, LEDMATRIX_AUTO_UPDATE=1|0
|
||||
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N, LEDMATRIX_AUTO_UPDATE=1|0,
|
||||
LEDMATRIX_CHANNEL=stable|beta
|
||||
|
||||
Low-memory devices:
|
||||
On a Pi with under 2GB of RAM the C++ build is limited to fewer parallel
|
||||
@@ -265,6 +360,7 @@ while [ $# -gt 0 ]; do
|
||||
--skip-swap) SKIP_SWAP=1 ;;
|
||||
--enable-auto-update) AUTO_UPDATE=1 ;;
|
||||
--no-auto-update) AUTO_UPDATE=0 ;;
|
||||
--beta) UPDATE_CHANNEL=beta ;;
|
||||
--build-jobs)
|
||||
shift
|
||||
if [ $# -eq 0 ]; then echo "--build-jobs requires a number"; usage; exit 1; fi
|
||||
@@ -291,10 +387,12 @@ else
|
||||
lm_remove_build_swap() { return 0; }
|
||||
fi
|
||||
|
||||
# Remove the temporary build swapfile no matter how the script ends. Step 6
|
||||
# tears it down itself; this is the backstop for the error path, since
|
||||
# on_error ends in `exit` and EXIT traps still run.
|
||||
trap 'lm_remove_build_swap' EXIT
|
||||
# Remove the temporary build swapfile, and take any library patches back out
|
||||
# of the submodule, no matter how the script ends. Step 6 does both itself;
|
||||
# this is the backstop for the error path (on_error ends in `exit` and EXIT
|
||||
# traps still run) and for an interrupted build. _revert_rgb_patches only
|
||||
# touches patches it applied, so running it twice is harmless.
|
||||
trap 'lm_remove_build_swap; _revert_rgb_patches' EXIT
|
||||
|
||||
# Helpers
|
||||
retry() {
|
||||
@@ -873,15 +971,25 @@ if [ -z "$AUTO_UPDATE" ] && [ "$ASSUME_YES" != "1" ] && [ -t 0 ]; then
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then AUTO_UPDATE=1; else AUTO_UPDATE=0; fi
|
||||
fi
|
||||
if [ "$AUTO_UPDATE" = "1" ] || [ "$AUTO_UPDATE" = "0" ]; then
|
||||
if python3 - "$PROJECT_ROOT_DIR/config/config.json" "$AUTO_UPDATE" <<'PY'
|
||||
case "$UPDATE_CHANNEL" in
|
||||
stable|beta|"") ;;
|
||||
*) echo "⚠ LEDMATRIX_CHANNEL=$UPDATE_CHANNEL is not stable or beta; leaving the update channel as it is"
|
||||
UPDATE_CHANNEL="" ;;
|
||||
esac
|
||||
# The update channel, likewise only when asked for (--beta / LEDMATRIX_CHANNEL).
|
||||
if [ "$AUTO_UPDATE" = "1" ] || [ "$AUTO_UPDATE" = "0" ] || [ -n "$UPDATE_CHANNEL" ]; then
|
||||
if python3 - "$PROJECT_ROOT_DIR/config/config.json" "$AUTO_UPDATE" "$UPDATE_CHANNEL" <<'PY'
|
||||
import json, os, sys, tempfile
|
||||
path, enabled = sys.argv[1], sys.argv[2] == "1"
|
||||
path, enabled = sys.argv[1], sys.argv[2]
|
||||
channel = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
if not isinstance(config.get("auto_update"), dict):
|
||||
config["auto_update"] = {}
|
||||
config["auto_update"]["enabled"] = enabled
|
||||
if enabled in ("0", "1"):
|
||||
config["auto_update"]["enabled"] = enabled == "1"
|
||||
if channel:
|
||||
config["auto_update"]["channel"] = channel
|
||||
# Written beside the original and swapped in whole: the display service's
|
||||
# config watcher may be running and must never read a half-written file.
|
||||
original = os.stat(path)
|
||||
@@ -902,9 +1010,11 @@ except BaseException:
|
||||
raise
|
||||
PY
|
||||
then
|
||||
if [ "$AUTO_UPDATE" = "1" ]; then echo "✓ Weekly automatic updates enabled"; else echo "✓ Weekly automatic updates off"; fi
|
||||
if [ "$AUTO_UPDATE" = "1" ]; then echo "✓ Weekly automatic updates enabled"
|
||||
elif [ "$AUTO_UPDATE" = "0" ]; then echo "✓ Weekly automatic updates off"; fi
|
||||
if [ -n "$UPDATE_CHANNEL" ]; then echo "✓ Update channel: $UPDATE_CHANNEL"; fi
|
||||
else
|
||||
echo "⚠ Could not set auto_update in config/config.json; turn it on from the General tab instead"
|
||||
echo "⚠ Could not set auto_update in config/config.json; set it from the General tab instead"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1226,9 +1336,11 @@ else
|
||||
fi
|
||||
BUILD_OUTPUT=$(mktemp)
|
||||
BUILD_SUCCESS=false
|
||||
_apply_rgb_patches
|
||||
if run_rgbmatrix_build "$BUILD_JOBS" "$BUILD_OUTPUT"; then
|
||||
BUILD_SUCCESS=true
|
||||
fi
|
||||
_revert_rgb_patches
|
||||
cat "$BUILD_OUTPUT" >> "$LOG_FILE"
|
||||
if [ "$BUILD_SUCCESS" != true ]; then
|
||||
print_rgbmatrix_build_failure "$BUILD_OUTPUT"
|
||||
@@ -1349,15 +1461,16 @@ if ! command -v setcap >/dev/null 2>&1; then
|
||||
echo "⚠ setcap not found, skipping capability configuration"
|
||||
echo " Install libcap2-bin if you need hardware timing capabilities"
|
||||
else
|
||||
# Find the Python binary and resolve symlinks to get the real binary
|
||||
# The binary the services run (ExecStart=/usr/bin/python3), symlinks
|
||||
# resolved: python3.11 on Bookworm, python3.13 on Trixie. This used to
|
||||
# prefer /usr/bin/python3.13 whenever it existed, which would set the
|
||||
# capability on an interpreter the services never run if python3 pointed
|
||||
# elsewhere.
|
||||
PYTHON_BIN=""
|
||||
PYTHON_VER=""
|
||||
if [ -f "/usr/bin/python3.13" ]; then
|
||||
PYTHON_BIN=$(readlink -f /usr/bin/python3.13)
|
||||
PYTHON_VER="3.13"
|
||||
elif [ -f "/usr/bin/python3" ]; then
|
||||
if [ -f "/usr/bin/python3" ]; then
|
||||
PYTHON_BIN=$(readlink -f /usr/bin/python3)
|
||||
PYTHON_VER=$(python3 --version 2>&1 | grep -oP '(?<=Python )\d+\.\d+' || echo "unknown")
|
||||
PYTHON_VER=$(lm_python_version /usr/bin/python3) || PYTHON_VER="unknown"
|
||||
fi
|
||||
|
||||
if [ -n "$PYTHON_BIN" ] && [ -f "$PYTHON_BIN" ]; then
|
||||
|
||||
@@ -22,6 +22,7 @@ src/common/api_helper.py
|
||||
src/common/bdf_font.py
|
||||
src/common/espn_dates.py
|
||||
src/common/favorite_team_check.py
|
||||
src/common/fetch_service.py
|
||||
src/common/font_layout.py
|
||||
src/common/frame_timing.py
|
||||
src/common/json_body.py
|
||||
@@ -34,23 +35,37 @@ src/common/snapshot_policy.py
|
||||
src/common/sports_card.py
|
||||
src/common/sports_card_wrappers.py
|
||||
src/common/sports_celebration.py
|
||||
src/common/sports_display_rules.py
|
||||
src/common/sports_favorites.py
|
||||
src/common/sports_fetch.py
|
||||
src/common/sports_font_path.py
|
||||
src/common/sports_game_over.py
|
||||
src/common/sports_live_scroll.py
|
||||
src/common/sports_plugin_host.py
|
||||
src/common/sports_scroll.py
|
||||
src/common/sports_timezone.py
|
||||
src/common/sports_vegas.py
|
||||
src/config_service.py
|
||||
src/core_config_keys.py
|
||||
src/deprecation.py
|
||||
src/device_location.py
|
||||
src/display_arbiter.py
|
||||
src/display_geometry.py
|
||||
src/dynamic_team_resolver.py
|
||||
src/exceptions.py
|
||||
src/font_usage.py
|
||||
src/ipc/__init__.py
|
||||
src/ipc/client.py
|
||||
src/ipc/contract.py
|
||||
src/ipc/server.py
|
||||
src/logging_config.py
|
||||
src/logo_downloader.py
|
||||
src/malloc_tuning.py
|
||||
src/matrix_support.py
|
||||
src/pi5_matrix_support.py
|
||||
src/plugin_system/__init__.py
|
||||
src/plugin_system/compatibility.py
|
||||
src/plugin_system/field_model.py
|
||||
src/plugin_system/operation_history.py
|
||||
src/plugin_system/operation_queue.py
|
||||
src/plugin_system/operation_types.py
|
||||
@@ -72,13 +87,18 @@ src/plugin_system/testing/loading.py
|
||||
src/plugin_system/testing/mocks.py
|
||||
src/plugin_system/testing/plugin_test_base.py
|
||||
src/plugin_system/testing/sizes.py
|
||||
src/plugin_system/testing/vegas.py
|
||||
src/plugin_system/vegas_elements.py
|
||||
src/redaction.py
|
||||
src/scan_order.py
|
||||
src/screen_runner.py
|
||||
src/startup_validator.py
|
||||
src/vegas_mode/__init__.py
|
||||
src/vegas_mode/config.py
|
||||
src/vegas_mode/coordinator.py
|
||||
src/vegas_mode/elements.py
|
||||
src/vegas_mode/geometry.py
|
||||
src/vegas_mode/live_worker.py
|
||||
src/vegas_mode/stream_manager.py
|
||||
src/web_interface/api_helpers.py
|
||||
src/web_interface/config_arrays.py
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
files = src
|
||||
exclude = (^|/)(test|__pycache__)/
|
||||
|
||||
# Python version
|
||||
python_version = 3.10
|
||||
# Python version: the oldest the installer supports (Raspberry Pi OS
|
||||
# Bookworm ships 3.11; Trixie ships 3.13).
|
||||
python_version = 3.11
|
||||
|
||||
# Platform (Linux/Raspberry Pi)
|
||||
platform = linux
|
||||
@@ -103,8 +104,8 @@ ignore_missing_imports = True
|
||||
|
||||
|
||||
# numpy's own stubs (numpy>=2.3) use Python 3.12 `type` statements, which mypy
|
||||
# refuses to parse under python_version = 3.10 -- and 3.10 is the floor this
|
||||
# code has to run on, so it stays. Treat numpy as Any instead: skip it, and
|
||||
# refuses to parse under python_version = 3.11 -- and 3.11 (Bookworm) is the
|
||||
# floor this code has to run on, so it stays. Treat numpy as Any instead: skip it, and
|
||||
# follow_imports_for_stubs makes the skip apply to its .pyi files too.
|
||||
[mypy-numpy.*]
|
||||
follow_imports = skip
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
Faster SetImage for rpi-rgb-led-matrix (applied by first_time_install.sh at build
|
||||
time; the submodule itself stays at its pinned commit).
|
||||
|
||||
Copying a frame into the panel buffer was the biggest CPU cost LEDMatrix owns on
|
||||
large panels: the binding's SetPixelsPillow walked the image column by column
|
||||
and called SetPixel per pixel, and each SetPixel read-modify-writes one word per
|
||||
PWM bit plane, 2KB apart, so consecutive pixels were a whole double-row apart and
|
||||
almost every write missed the cache. This patch:
|
||||
|
||||
* FrameCanvas gets its own SetPixelsPillow: row by row, one bulk SetPixels call
|
||||
per row;
|
||||
* Framebuffer::SetPixels clips once, looks colours up once per pixel, walks each
|
||||
row's designators in order and writes the bit planes branch-free;
|
||||
* the base Canvas.SetPixelsPillow loop (RGBMatrix.SetImage) is row-major.
|
||||
|
||||
The bit-plane buffer is byte-identical to the old code's (882 memcmp checks over
|
||||
noise/gradient/solid/low-value/sparse images, clipped offsets, pwm 7/8/11,
|
||||
brightness 1/50/90/100, inverse colours, luminance correction off and a pixel
|
||||
mapper). Measured on a Pi 4 at 512x64: 6.0-6.3 ms -> 1.8 ms per frame through
|
||||
the Python binding; on hdpi (Pi 4, 4x128x64) frame copy 6.57 -> 2.21 ms and the
|
||||
display process 139% -> 103% of a core.
|
||||
|
||||
LEDMatrix always draws into the canvas that is not on screen and swaps it in
|
||||
(DisplayManager.update_display), so the write order cannot show as tearing.
|
||||
|
||||
Against hzeller/rpi-rgb-led-matrix 1ee4f76.
|
||||
|
||||
diff --git a/bindings/python/rgbmatrix/core.pyx b/bindings/python/rgbmatrix/core.pyx
|
||||
index 230d87f..babc3bb 100644
|
||||
--- a/bindings/python/rgbmatrix/core.pyx
|
||||
+++ b/bindings/python/rgbmatrix/core.pyx
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from libcpp cimport bool
|
||||
from libc.stdint cimport uint8_t, uint32_t, uintptr_t
|
||||
+from libc.stdlib cimport malloc, free
|
||||
import cython
|
||||
|
||||
cdef extern from "Python.h":
|
||||
@@ -59,8 +60,9 @@ cdef class Canvas:
|
||||
|
||||
buffer = get_pillow_buffer(image_capsule)
|
||||
|
||||
- for col in range(max(0, -xstart), min(width, frame_width - xstart)):
|
||||
- for row in range(max(0, -ystart), min(height, frame_height - ystart)):
|
||||
+ # Row-major: walks both the image and the bitplane buffer sequentially.
|
||||
+ for row in range(max(0, -ystart), min(height, frame_height - ystart)):
|
||||
+ for col in range(max(0, -xstart), min(width, frame_width - xstart)):
|
||||
pixel = buffer[row][col]
|
||||
r = (pixel ) & 0xFF
|
||||
g = (pixel >> 8) & 0xFF
|
||||
@@ -86,6 +88,41 @@ cdef class FrameCanvas(Canvas):
|
||||
def SetPixel(self, int x, int y, uint8_t red, uint8_t green, uint8_t blue):
|
||||
(<cppinc.FrameCanvas*>self._getCanvas()).SetPixel(x, y, red, green, blue)
|
||||
|
||||
+ @cython.boundscheck(False)
|
||||
+ @cython.wraparound(False)
|
||||
+ def SetPixelsPillow(self, int xstart, int ystart, int width, int height, object image_capsule):
|
||||
+ # Same result as Canvas.SetPixelsPillow(), but hands each image row
|
||||
+ # to the C++ bulk FrameCanvas::SetPixels() instead of calling the
|
||||
+ # virtual SetPixel() once per pixel.
|
||||
+ cdef cppinc.FrameCanvas* my_canvas = <cppinc.FrameCanvas*>self._getCanvas()
|
||||
+ cdef int col_start = max(0, -xstart)
|
||||
+ cdef int col_end = min(width, my_canvas.width() - xstart)
|
||||
+ cdef int row_start = max(0, -ystart)
|
||||
+ cdef int row_end = min(height, my_canvas.height() - ystart)
|
||||
+ cdef int row, col, pixel
|
||||
+ cdef int *src
|
||||
+ cdef cppinc.Color *line
|
||||
+ cdef int **buffer
|
||||
+
|
||||
+ if col_end <= col_start or row_end <= row_start:
|
||||
+ return
|
||||
+ buffer = get_pillow_buffer(image_capsule)
|
||||
+ line = <cppinc.Color*>malloc((col_end - col_start) * sizeof(cppinc.Color))
|
||||
+ if line == NULL:
|
||||
+ raise MemoryError()
|
||||
+ try:
|
||||
+ for row in range(row_start, row_end):
|
||||
+ src = buffer[row]
|
||||
+ for col in range(col_start, col_end):
|
||||
+ pixel = src[col]
|
||||
+ line[col - col_start].r = pixel & 0xFF
|
||||
+ line[col - col_start].g = (pixel >> 8) & 0xFF
|
||||
+ line[col - col_start].b = (pixel >> 16) & 0xFF
|
||||
+ my_canvas.SetPixels(xstart + col_start, ystart + row,
|
||||
+ col_end - col_start, 1, line)
|
||||
+ finally:
|
||||
+ free(line)
|
||||
+
|
||||
|
||||
property width:
|
||||
def __get__(self): return (<cppinc.FrameCanvas*>self._getCanvas()).width()
|
||||
diff --git a/bindings/python/rgbmatrix/cppinc.pxd b/bindings/python/rgbmatrix/cppinc.pxd
|
||||
index 8bec241..314332d 100644
|
||||
--- a/bindings/python/rgbmatrix/cppinc.pxd
|
||||
+++ b/bindings/python/rgbmatrix/cppinc.pxd
|
||||
@@ -25,6 +25,7 @@ cdef extern from "led-matrix.h" namespace "rgb_matrix":
|
||||
FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t)
|
||||
|
||||
cdef cppclass FrameCanvas(Canvas):
|
||||
+ void SetPixels(int, int, int, int, Color*) nogil
|
||||
bool SetPWMBits(uint8_t)
|
||||
uint8_t pwmbits()
|
||||
void SetBrightness(uint8_t)
|
||||
diff --git a/lib/framebuffer.cc b/lib/framebuffer.cc
|
||||
index 36d138b..aee62ca 100644
|
||||
--- a/lib/framebuffer.cc
|
||||
+++ b/lib/framebuffer.cc
|
||||
@@ -807,11 +807,60 @@ void Framebuffer::SetPixel(int x, int y, uint8_t r, uint8_t g, uint8_t b) {
|
||||
}
|
||||
}
|
||||
|
||||
+// Bulk version of SetPixel(); produces exactly the same bitplane content.
|
||||
+// Faster because it hoists the per-pixel work out of the loop: the color
|
||||
+// mapping becomes one 256-entry table built per call (each channel maps
|
||||
+// independently through the same function), the pixel designators of a row
|
||||
+// are contiguous in the PixelDesignatorMap, and the bit-plane loop is
|
||||
+// branchless (the color bits are effectively random, so the branches in
|
||||
+// SetPixel() mispredict a lot).
|
||||
void Framebuffer::SetPixels(int x, int y, int width, int height, Color *colors) {
|
||||
- for (int iy = 0; iy < height; ++iy) {
|
||||
- for (int ix = 0; ix < width; ++ix) {
|
||||
- SetPixel(x + ix, y + iy, colors->r, colors->g, colors->b);
|
||||
- ++colors;
|
||||
+ PixelDesignatorMap *const mapper = *shared_mapper_;
|
||||
+ const int ix_start = std::max(0, -x);
|
||||
+ const int ix_end = std::min(width, mapper->width() - x);
|
||||
+ const int iy_start = std::max(0, -y);
|
||||
+ const int iy_end = std::min(height, mapper->height() - y);
|
||||
+ if (ix_start >= ix_end || iy_start >= iy_end) return;
|
||||
+
|
||||
+ // Common case (luminance correction, no inversion): use the precomputed
|
||||
+ // table directly; otherwise build one. Cheap enough to do per call, which
|
||||
+ // matters for callers that send one row at a time.
|
||||
+ uint16_t local_map[256];
|
||||
+ const uint16_t *color_map;
|
||||
+ if (do_luminance_correct_ && !inverse_color_) {
|
||||
+ color_map = ColorLookupTable::GetLookup(brightness_).color;
|
||||
+ } else {
|
||||
+ for (int c = 0; c < 256; ++c) {
|
||||
+ uint16_t unused1, unused2;
|
||||
+ MapColors(c, 0, 0, &local_map[c], &unused1, &unused2);
|
||||
+ }
|
||||
+ color_map = local_map;
|
||||
+ }
|
||||
+
|
||||
+ const int min_bit_plane = kBitPlanes - pwm_bits_;
|
||||
+ gpio_bits_t *const plane_start = bitplane_buffer_ + columns_ * min_bit_plane;
|
||||
+ for (int iy = iy_start; iy < iy_end; ++iy) {
|
||||
+ const Color *c = colors + iy * width + ix_start;
|
||||
+ const PixelDesignator *designator = mapper->get(x + ix_start, y + iy);
|
||||
+ for (int ix = ix_start; ix < ix_end; ++ix, ++c, ++designator) {
|
||||
+ const long pos = designator->gpio_word;
|
||||
+ if (pos < 0) continue; // non-used pixel marker.
|
||||
+ const uint16_t red = color_map[c->r];
|
||||
+ const uint16_t green = color_map[c->g];
|
||||
+ const uint16_t blue = color_map[c->b];
|
||||
+ const gpio_bits_t r_bits = designator->r_bit;
|
||||
+ const gpio_bits_t g_bits = designator->g_bit;
|
||||
+ const gpio_bits_t b_bits = designator->b_bit;
|
||||
+ const gpio_bits_t designator_mask = designator->mask;
|
||||
+ gpio_bits_t *bits = plane_start + pos;
|
||||
+ for (int plane = min_bit_plane; plane < kBitPlanes; ++plane) {
|
||||
+ const gpio_bits_t color_bits =
|
||||
+ (r_bits & -(gpio_bits_t)((red >> plane) & 1))
|
||||
+ | (g_bits & -(gpio_bits_t)((green >> plane) & 1))
|
||||
+ | (b_bits & -(gpio_bits_t)((blue >> plane) & 1));
|
||||
+ *bits = (*bits & designator_mask) | color_bits;
|
||||
+ bits += columns_;
|
||||
+ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# LEDMatrix Core Dependencies
|
||||
# Compatible with Python 3.10, 3.11, 3.12, and 3.13
|
||||
# Compatible with Python 3.11, 3.12 and 3.13; CI tests 3.11 and 3.13
|
||||
# Tested on Raspbian OS 12 (Bookworm) and 13 (Trixie)
|
||||
|
||||
# Image processing
|
||||
|
||||
@@ -14,6 +14,12 @@ project_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if project_dir not in sys.path:
|
||||
sys.path.insert(0, project_dir)
|
||||
|
||||
# Cap glibc's malloc arenas before any thread exists (arenas already made
|
||||
# stay): the in-process twin of the unit's MALLOC_ARENA_MAX=2, for units
|
||||
# installed before that line. A no-op off glibc. See src/malloc_tuning.py.
|
||||
from src import malloc_tuning
|
||||
malloc_tuning.cap_arenas()
|
||||
|
||||
# Under systemd the watchdog clock is already running, and start-up (plugin
|
||||
# loads, initial updates) takes far longer than the render loop's limit. Widen
|
||||
# it before anything slow is imported; the render loop narrows it again once
|
||||
|
||||
@@ -72,6 +72,7 @@ from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
||||
)
|
||||
from src.plugin_system.testing.vegas import check_plugin_vegas_elements # noqa: E402
|
||||
|
||||
logger = get_logger("[Check Plugin]")
|
||||
|
||||
@@ -193,6 +194,24 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
|
||||
all_run_results.extend(results)
|
||||
|
||||
# Live Vegas elements, for a plugin that has them: checked once, at the
|
||||
# first size, with the base config.
|
||||
width, height = effective_sizes[0]
|
||||
try:
|
||||
vegas = check_plugin_vegas_elements(
|
||||
plugin_id, plugin_dir, full_config, effective_mock_data, width, height,
|
||||
run_update=effective_run_update)
|
||||
except Exception as exc: # noqa: BLE001 - one plugin must not end an --all run
|
||||
all_run_results.append(RenderResult(
|
||||
plugin_id, width, height, "vegas elements",
|
||||
error=f"the element check itself failed: {exc!r}"))
|
||||
return all_run_results
|
||||
if vegas.implemented:
|
||||
all_run_results.append(RenderResult(
|
||||
plugin_id, width, height, "vegas elements",
|
||||
error="; ".join(vegas.errors) or None,
|
||||
notes=[f"{vegas.elements} element(s), {vegas.live} live"] + vegas.warnings))
|
||||
|
||||
return all_run_results
|
||||
|
||||
|
||||
@@ -236,6 +255,8 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
f" controller skips the mode")
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
if r.notes:
|
||||
detail += f" ({'; '.join(r.notes)})"
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
print()
|
||||
return everything_ok
|
||||
|
||||
@@ -53,26 +53,35 @@ else
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check OS version
|
||||
# Check OS version. The supported releases come from the same library the
|
||||
# installer uses, so the two cannot disagree.
|
||||
echo "2. Checking Operating System Version..."
|
||||
echo "---------------------------------------"
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
echo "OS: $PRETTY_NAME"
|
||||
echo "Version ID: ${VERSION_ID:-unknown}"
|
||||
|
||||
# first_time_install.sh refuses anything but Raspberry Pi OS / Debian 13
|
||||
# (Trixie), so anything else is an error here too, not a warning.
|
||||
if [[ "$ID" == "raspbian" ]] || [[ "$ID" == "debian" ]]; then
|
||||
if [ "${VERSION_ID:-0}" = "13" ]; then
|
||||
print_success "Detected Debian 13 Trixie - supported"
|
||||
elif [ "${VERSION_ID:-0}" = "12" ]; then
|
||||
print_error "Debian 12 Bookworm is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
||||
else
|
||||
print_error "Debian/Raspbian ${VERSION_ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
||||
fi
|
||||
OS_LIB="$(cd "$(dirname "$0")" && pwd)/install/lib_os.sh"
|
||||
OS_LIB_LOADED=0
|
||||
OS_RELEASE=""
|
||||
if [ -f "$OS_LIB" ]; then
|
||||
# shellcheck source=scripts/install/lib_os.sh
|
||||
. "$OS_LIB"
|
||||
OS_LIB_LOADED=1
|
||||
fi
|
||||
|
||||
if [ "$OS_LIB_LOADED" = "0" ]; then
|
||||
print_error "$OS_LIB is missing - download LEDMatrix again"
|
||||
elif [ -r "$LM_OS_RELEASE_FILE" ]; then
|
||||
OS_ID=$(lm_os_field ID)
|
||||
OS_VERSION_ID=$(lm_os_field VERSION_ID)
|
||||
echo "OS: $(lm_os_field PRETTY_NAME)"
|
||||
echo "Version ID: ${OS_VERSION_ID:-unknown}"
|
||||
|
||||
# first_time_install.sh refuses anything else, so this is an error here
|
||||
# too, not a warning.
|
||||
if OS_RELEASE=$(lm_os_release); then
|
||||
print_success "Detected $(lm_release_label "$OS_RELEASE") - supported"
|
||||
elif [[ "$OS_ID" == "raspbian" ]] || [[ "$OS_ID" == "debian" ]]; then
|
||||
print_error "Debian/Raspbian ${OS_VERSION_ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite, Trixie (Debian 13) or Bookworm (Debian 12)"
|
||||
else
|
||||
print_error "${ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
||||
print_error "${OS_ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite, Trixie (Debian 13) or Bookworm (Debian 12)"
|
||||
fi
|
||||
else
|
||||
print_error "Could not detect OS version"
|
||||
@@ -92,7 +101,7 @@ if [ "$KERNEL_MAJOR" -ge "6" ]; then
|
||||
print_success "Kernel version is compatible (6.x or newer)"
|
||||
|
||||
if [ "$KERNEL_MAJOR" -eq "6" ] && [ "$KERNEL_MINOR" -ge "12" ]; then
|
||||
print_success "Running latest Trixie kernel (6.12 LTS)"
|
||||
print_success "Running a 6.12 LTS or newer kernel"
|
||||
fi
|
||||
elif [ "$KERNEL_MAJOR" -eq "5" ] && [ "$KERNEL_MINOR" -ge "10" ]; then
|
||||
print_success "Kernel version is compatible (5.10+)"
|
||||
@@ -104,25 +113,34 @@ echo ""
|
||||
# Check Python version
|
||||
echo "4. Checking Python Version..."
|
||||
echo "-----------------------------"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")')
|
||||
PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)')
|
||||
PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)')
|
||||
|
||||
if [ "$OS_LIB_LOADED" = "1" ] && command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON_VERSION=$(python3 -c 'import sys; print("%d.%d.%d" % sys.version_info[:3])')
|
||||
PYTHON_MINOR_VERSION=$(lm_python_version) || PYTHON_MINOR_VERSION=""
|
||||
PYTHON_RANGE="3.${LM_PYTHON_MIN_MINOR}-3.${LM_PYTHON_MAX_MINOR}"
|
||||
|
||||
echo "Python: $PYTHON_VERSION"
|
||||
|
||||
if [ "$PYTHON_MAJOR" -eq "3" ]; then
|
||||
if [ "$PYTHON_MINOR" -ge "10" ] && [ "$PYTHON_MINOR" -le "13" ]; then
|
||||
print_success "Python version is supported (3.10-3.13)"
|
||||
elif [ "$PYTHON_MINOR" -ge "14" ]; then
|
||||
print_warning "Python 3.${PYTHON_MINOR} is very new - some packages may not be compatible yet"
|
||||
else
|
||||
# Pillow 12 and the pinned test tools need 3.10+, so this won't install.
|
||||
print_error "Python 3.${PYTHON_MINOR} is too old - Python 3.10+ is required"
|
||||
fi
|
||||
else
|
||||
print_error "Python 2.x detected - Python 3.10+ is required"
|
||||
|
||||
case "$(lm_python_check "$PYTHON_MINOR_VERSION")" in
|
||||
ok)
|
||||
print_success "Python version is supported ($PYTHON_RANGE)"
|
||||
;;
|
||||
too-old)
|
||||
# The rgbmatrix bindings declare requires-python >=3.11, so the
|
||||
# display cannot be built on anything older.
|
||||
print_error "Python $PYTHON_MINOR_VERSION is too old - Python 3.${LM_PYTHON_MIN_MINOR}+ is required"
|
||||
;;
|
||||
too-new)
|
||||
print_warning "Python $PYTHON_MINOR_VERSION is newer than LEDMatrix has been tested with ($PYTHON_RANGE)"
|
||||
;;
|
||||
*)
|
||||
print_warning "Could not read the Python version"
|
||||
;;
|
||||
esac
|
||||
if [ -n "$OS_RELEASE" ] && [ "$PYTHON_MINOR_VERSION" != "$(lm_release_python "$OS_RELEASE")" ]; then
|
||||
print_warning "$(lm_release_label "$OS_RELEASE") ships Python $(lm_release_python "$OS_RELEASE"), but python3 runs $PYTHON_MINOR_VERSION"
|
||||
fi
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
print_warning "Cannot check the Python version without $OS_LIB"
|
||||
else
|
||||
print_error "Python 3 not found - installation required"
|
||||
fi
|
||||
@@ -268,6 +286,22 @@ if command -v ping >/dev/null 2>&1; then
|
||||
else
|
||||
print_warning "Ping command not available - cannot verify network"
|
||||
fi
|
||||
|
||||
# WiFi setup from the web page and the LEDMatrix-Setup hotspot drive
|
||||
# NetworkManager, the default on both Bookworm and Trixie.
|
||||
if [ "$OS_LIB_LOADED" = "1" ]; then
|
||||
case "$(lm_network_stack)" in
|
||||
networkmanager)
|
||||
print_success "NetworkManager manages the network (needed for WiFi setup)"
|
||||
;;
|
||||
dhcpcd)
|
||||
print_warning "dhcpcd manages the network - WiFi setup from the web page and the setup hotspot need NetworkManager (sudo raspi-config -> Advanced Options -> Network Config)"
|
||||
;;
|
||||
*)
|
||||
print_warning "Could not tell which service manages the network - WiFi setup from the web page needs NetworkManager"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Print summary
|
||||
|
||||
+54
-3
@@ -200,12 +200,21 @@ def api_plugin_defaults(plugin_id):
|
||||
return jsonify({'defaults': defaults})
|
||||
|
||||
|
||||
#: /api/render "vegas" values: the plugin's block of the Vegas strip, built
|
||||
#: from its live elements (falling back to its Vegas content, as the ticker
|
||||
#: does) or from its ordinary Vegas content only.
|
||||
VEGAS_VIEWS = ('live', 'plain')
|
||||
|
||||
|
||||
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
|
||||
skip_update):
|
||||
skip_update, vegas=None):
|
||||
"""Render one plugin at one size. Returns the /api/render response dict.
|
||||
|
||||
A fresh plugin instance per call, mirroring the safety harness, so sizes
|
||||
never share state.
|
||||
never share state. With ``vegas`` set ('live' or 'plain') the image is the
|
||||
plugin's block of the Vegas strip instead of its display(), laid out by
|
||||
the ticker's own code (src/plugin_system/testing/vegas.py), and the
|
||||
response lists where each live element sits in it.
|
||||
"""
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
@@ -243,6 +252,10 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
|
||||
logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
|
||||
warnings.append(f"update() raised: {type(e).__name__} — see server log")
|
||||
|
||||
if vegas:
|
||||
return _vegas_response(plugin_id, plugin_instance, display_manager, vegas,
|
||||
start_time, errors, warnings)
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
@@ -262,6 +275,40 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
|
||||
}
|
||||
|
||||
|
||||
def _vegas_response(plugin_id, plugin_instance, display_manager, vegas, start_time,
|
||||
errors, warnings):
|
||||
"""The /api/render response for the Vegas strip view."""
|
||||
import base64
|
||||
import io
|
||||
|
||||
from src.plugin_system.testing.vegas import render_vegas_strip
|
||||
|
||||
block, layout = None, []
|
||||
try:
|
||||
block, layout = render_vegas_strip(plugin_instance, plugin_id, display_manager,
|
||||
live=(vegas == 'live'))
|
||||
except Exception as e:
|
||||
logger.warning("Vegas render raised for plugin %s", plugin_id, exc_info=True)
|
||||
errors.append(f"Vegas render raised: {type(e).__name__} — see server log")
|
||||
if block is None:
|
||||
if not errors:
|
||||
errors.append("The plugin has no Vegas content")
|
||||
block = display_manager.image
|
||||
elif vegas == 'live' and not layout:
|
||||
warnings.append("No live elements: this is the plugin's ordinary Vegas content")
|
||||
buffer = io.BytesIO()
|
||||
block.convert('RGB').save(buffer, format='PNG')
|
||||
return {
|
||||
'image': 'data:image/png;base64,' + base64.b64encode(buffer.getvalue()).decode('ascii'),
|
||||
'width': block.width,
|
||||
'height': block.height,
|
||||
'render_time_ms': round((time.time() - start_time) * 1000, 1),
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
'live_elements': [{'key': key, 'x': x, 'width': width} for x, key, width in layout],
|
||||
}
|
||||
|
||||
|
||||
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
|
||||
"""Re-derive a plugin directory from the search dirs' own listings.
|
||||
|
||||
@@ -333,6 +380,10 @@ def api_render():
|
||||
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
||||
|
||||
vegas = data.get('vegas') or None
|
||||
if vegas is not None and vegas not in VEGAS_VIEWS:
|
||||
return jsonify({'error': f'vegas must be one of {", ".join(VEGAS_VIEWS)}'}), 400
|
||||
|
||||
try:
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
@@ -345,7 +396,7 @@ def api_render():
|
||||
|
||||
try:
|
||||
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
|
||||
mock_data, width, height, skip_update)
|
||||
mock_data, width, height, skip_update, vegas=vegas)
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during render')
|
||||
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
||||
|
||||
+93
-3
@@ -9,7 +9,10 @@ reports the difference. Nothing is stopped, restarted or drawn.
|
||||
python3 scripts/frame_soak.py
|
||||
|
||||
# the same with the web preview open (the preview's PNG encodes are one of
|
||||
# the things that used to make the render loop miss refreshes)
|
||||
# the things that used to make the render loop miss refreshes). An open
|
||||
# preview is encoded at most once a second; through 3.8.0 it was up to
|
||||
# five times, so a --preview run from before that change is not comparable
|
||||
# with one from after it
|
||||
python3 scripts/frame_soak.py --preview
|
||||
|
||||
# quick look at the totals since the service started
|
||||
@@ -27,14 +30,24 @@ What the numbers mean
|
||||
late frames frames that reached the panel one or more refreshes after they
|
||||
were due -- the panel showed the previous frame again, which on
|
||||
a moving strip is a visible hitch. This is the pass/fail number.
|
||||
freezes gaps of 250ms+ inside a scroll: recomposes, plugin handovers,
|
||||
freezes gaps of 250ms+ inside a scroll: recomposes, plugin handovers
|
||||
the display controller does not tag (see handover gaps),
|
||||
blocking calls on the render thread. Reported, not failed on,
|
||||
since some are handovers between plugins rather than faults.
|
||||
handover gaps the same length of gap where the display controller had just
|
||||
started a screen's turn (also the same mode's again): its
|
||||
first display() drawing. Counted here instead of under
|
||||
freezes. Stats from a service older than this count have no
|
||||
such line, and their freezes include these, so do not
|
||||
compare freeze counts across that change.
|
||||
blit copying the frame into the matrix canvas (rgbmatrix SetImage).
|
||||
Grows with width x height x pwm_bits.
|
||||
wait blocked in SwapOnVSync, i.e. slack before the refresh.
|
||||
work everything else between two frames: drawing, scrolling, and
|
||||
waiting for the GIL.
|
||||
after work frames presented straight after tagged render-thread work
|
||||
(Vegas strip extensions, live-element patches), with their own
|
||||
late rate. Shown only when something tagged its work.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -55,7 +68,8 @@ from src.common.frame_timing import ( # noqa: E402
|
||||
)
|
||||
|
||||
#: Touched by the web UI while someone has the preview open; a fresh marker
|
||||
#: puts the display service's snapshot writer at full rate. Same path as
|
||||
#: puts the display service's snapshot writer at the viewer rate
|
||||
#: (snapshot_policy.VIEWER_INTERVAL). Same path as
|
||||
#: DisplayManager._viewer_marker_path.
|
||||
VIEWER_MARKER = "/tmp/led_matrix_preview_viewer" # nosec B108 - fixed path shared with the service
|
||||
|
||||
@@ -126,6 +140,56 @@ def _edge(index: int, bucket_ms: float):
|
||||
return round((index + 1) * bucket_ms, 2)
|
||||
|
||||
|
||||
def op_rows(totals: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Per kind of noted render-thread work: how often its frame was late.
|
||||
|
||||
A kind's frames are the ones presented straight after that work ran (see
|
||||
"Operations" in src/common/frame_timing.py). Stats from a recorder that
|
||||
predates the counters have none, and give an empty table.
|
||||
"""
|
||||
frames = totals.get("op_frames") or {}
|
||||
late = totals.get("late_op_frames") or {}
|
||||
freezes = totals.get("op_freezes") or {}
|
||||
moved = totals.get("op_bytes") or {}
|
||||
rows = {}
|
||||
for kind in sorted(set(frames) | set(freezes)):
|
||||
count = frames.get(kind, 0)
|
||||
if not count and not freezes.get(kind, 0):
|
||||
continue
|
||||
rows[kind] = {
|
||||
"frames": count,
|
||||
"late": late.get(kind, 0),
|
||||
"late_pct": (round(100.0 * late.get(kind, 0) / count, 3)
|
||||
if count else None),
|
||||
"freezes": freezes.get(kind, 0),
|
||||
"bytes": moved.get(kind, 0),
|
||||
}
|
||||
return rows
|
||||
|
||||
|
||||
def gc_window(before: Dict[str, Any], after: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Garbage collection over the run, or None from a service without the
|
||||
monitor. The counters are cumulative since the service started, so they
|
||||
are differenced like the totals; the longest is since the start."""
|
||||
ga = after.get("gc")
|
||||
if not ga:
|
||||
return None
|
||||
gb = before.get("gc") or {}
|
||||
def minus(key):
|
||||
return [a - b for a, b in zip(ga.get(key, []),
|
||||
gb.get(key) or [0] * len(ga.get(key, [])))]
|
||||
seconds = minus("seconds")
|
||||
return {
|
||||
"collections": minus("collections"),
|
||||
"ms": [round(x * 1000.0, 1) for x in seconds],
|
||||
"long_pauses": ga.get("long_pauses", 0) - gb.get("long_pauses", 0),
|
||||
"long_ms": round((ga.get("long_seconds", 0.0)
|
||||
- gb.get("long_seconds", 0.0)) * 1000.0, 1),
|
||||
"threshold_ms": ga.get("threshold_ms"),
|
||||
"max_ms_since_start": ga.get("max_ms"),
|
||||
}
|
||||
|
||||
|
||||
def build_report(before, after, preview: bool) -> Dict[str, Any]:
|
||||
delta = diff(before, after)
|
||||
totals = delta["totals"]
|
||||
@@ -155,10 +219,15 @@ def build_report(before, after, preview: bool) -> Dict[str, Any]:
|
||||
"freezes": totals["freezes"],
|
||||
"freezes_per_hour": round(totals["freezes"] / hours, 1) if hours else None,
|
||||
"freeze_seconds": round(totals["freeze_seconds"], 2),
|
||||
# None from a service that predates the count: its handovers are
|
||||
# among the freezes above.
|
||||
"handover_freezes": totals.get("handover_freezes"),
|
||||
"worst_interval_ms": (round(totals["worst_interval_ms"], 1)
|
||||
if totals["worst_interval_ms"] else None),
|
||||
"timing_ms": {name: percentiles(h, bucket_ms)
|
||||
for name, h in delta["histograms"].items()},
|
||||
"ops": op_rows(totals),
|
||||
"gc": gc_window(before, after),
|
||||
}
|
||||
# The rate the panel held while rendering: the typical frame's interval
|
||||
# per refresh held. A few percent under the idle rate is normal (the Pi is
|
||||
@@ -209,6 +278,16 @@ def print_report(report: Dict[str, Any], limit: float) -> None:
|
||||
if report["freezes"]:
|
||||
print(" by length: " + ", ".join(
|
||||
f"{k}: {v}" for k, v in report["freeze_by"].items()))
|
||||
if report.get("handover_freezes") is not None:
|
||||
print(f"Handover gaps {report['handover_freezes']}"
|
||||
" >=250ms before a new screen's first frame; not in the freezes")
|
||||
gc_stats = report.get("gc")
|
||||
if gc_stats:
|
||||
counts, ms = gc_stats["collections"], gc_stats["ms"]
|
||||
print(f"Garbage collection gen0/1/2 {counts[0]}/{counts[1]}/{counts[2]}"
|
||||
f" ({ms[0]}/{ms[1]}/{ms[2]} ms) >={gc_stats['threshold_ms']:g}ms: "
|
||||
f"{gc_stats['long_pauses']} ({gc_stats['long_ms']} ms)"
|
||||
f" longest since start {gc_stats['max_ms_since_start']} ms")
|
||||
print()
|
||||
print(f"{'ms':<18}{'p50':>8}{'p95':>8}{'p99':>8}{'max':>8}")
|
||||
for name in ("blit", "wait", "work", "interval_per_hold"):
|
||||
@@ -216,6 +295,17 @@ def print_report(report: Dict[str, Any], limit: float) -> None:
|
||||
print(f"{name:<18}" + "".join(f"{str(row.get(k, '-')):>8}"
|
||||
for k in ("p50", "p95", "p99", "max")))
|
||||
print()
|
||||
ops = report.get("ops") or {}
|
||||
if ops:
|
||||
# Frames presented straight after render-thread work of each kind. A
|
||||
# late rate well above the overall one points at that work.
|
||||
print(f"{'after work':<18}{'frames':>8}{'late':>8}{'late %':>8}"
|
||||
f"{'freezes':>9}{'MB moved':>10}")
|
||||
for kind, row in ops.items():
|
||||
pct = "-" if row["late_pct"] is None else f"{row['late_pct']:g}"
|
||||
print(f"{kind:<18}{row['frames']:>8}{row['late']:>8}{pct:>8}"
|
||||
f"{row['freezes']:>9}{row['bytes'] / 1e6:>10.2f}")
|
||||
print()
|
||||
if report["late_pct"] is None:
|
||||
print("RESULT nothing scrolled - no verdict")
|
||||
elif not locked(report, limit):
|
||||
|
||||
@@ -5,11 +5,18 @@ This directory contains scripts for installing and configuring the LEDMatrix sys
|
||||
## Scripts
|
||||
|
||||
- **`one-shot-install.sh`** - Single-command installer; clones the
|
||||
repo, checks prerequisites, then runs `first_time_install.sh`.
|
||||
Invoked via `curl ... | bash` from the project root README.
|
||||
repo, checks out the newest release (or `main` with
|
||||
`LEDMATRIX_CHANNEL=beta`), checks prerequisites, then runs
|
||||
`first_time_install.sh`. Invoked via `curl ... | bash` from the project
|
||||
root README. Re-running it never moves a checkout to an older version.
|
||||
- **`install_service.sh`** - Installs, enables and starts the display
|
||||
service (`ledmatrix.service`), the web interface service
|
||||
(`ledmatrix-web.service`) and the update-verify units (systemd)
|
||||
(`ledmatrix-web.service`) and the update-verify units (systemd), and
|
||||
installs `/usr/local/sbin/ledmatrix-refresh-units`
|
||||
- **`ledmatrix_refresh_units.py`** - Not run from here: `install_service.sh`
|
||||
installs a root-owned copy as `/usr/local/sbin/ledmatrix-refresh-units`,
|
||||
which updates run through sudo to install changed units (and the
|
||||
automatic update's rollback, with `--restore`, to put them back)
|
||||
- **`install_web_service.sh`** - Installs only the web interface service
|
||||
and the update-verify units (systemd)
|
||||
- **`install_wifi_monitor.sh`** - Installs the WiFi monitor daemon service
|
||||
@@ -34,6 +41,9 @@ Libraries (sourced, not run):
|
||||
script that renders a unit from `systemd/*.service`
|
||||
- **`lib_lowmem.sh`** - Build-job sizing and temporary swap for the C++
|
||||
build on low-memory Pis (`first_time_install.sh` Step 6)
|
||||
- **`lib_os.sh`** - Which releases (Bookworm, Trixie) and Python versions
|
||||
(3.11-3.13) the installer accepts, and which service runs the network;
|
||||
shared by `first_time_install.sh` and `scripts/check_system_compatibility.sh`
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ echo "- View system logs via journalctl"
|
||||
echo "- Reboot and shutdown the system"
|
||||
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
|
||||
echo "- Install plugin/base requirements.txt as root (so ledmatrix.service can see them)"
|
||||
echo "- Install the LEDMatrix systemd units an update changed, and restore them on rollback"
|
||||
echo " (/usr/local/sbin/ledmatrix-refresh-units, installed by install_service.sh)"
|
||||
echo ""
|
||||
|
||||
# Ask for confirmation
|
||||
|
||||
@@ -143,6 +143,30 @@ for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path;
|
||||
fi
|
||||
done
|
||||
|
||||
# The helper updates run (through sudo, see lib_sudoers.sh) to install these
|
||||
# same units when a new version changes their templates, and to put the old
|
||||
# ones back if the automatic update rolls back. Root-owned and outside the
|
||||
# checkout, so the web user who owns the checkout cannot change what sudo runs.
|
||||
# Not fatal: without it, updates leave the units for the next reinstall.
|
||||
REFRESH_UNITS_SRC="$PROJECT_ROOT_DIR/scripts/install/ledmatrix_refresh_units.py"
|
||||
REFRESH_UNITS_DEST=/usr/local/sbin/ledmatrix-refresh-units
|
||||
if [ -f "$REFRESH_UNITS_SRC" ]; then
|
||||
if sudo install -D -o root -g root -m 0755 "$REFRESH_UNITS_SRC" "$REFRESH_UNITS_DEST"; then
|
||||
echo "Installed $REFRESH_UNITS_DEST (lets updates refresh these units)"
|
||||
else
|
||||
echo "WARNING: could not install $REFRESH_UNITS_DEST; updates will not refresh the systemd units" >&2
|
||||
fi
|
||||
fi
|
||||
# The units above are copied from mktemp files, which are 0600. 0644 is what
|
||||
# first_time_install.sh (Step 8.1) sets, and lets the web interface compare
|
||||
# them with the templates after an update without root.
|
||||
for INSTALLED_UNIT in ledmatrix.service ledmatrix-web.service \
|
||||
ledmatrix-update-verify.service ledmatrix-update-verify.path; do
|
||||
if [ -f "/etc/systemd/system/$INSTALLED_UNIT" ]; then
|
||||
sudo chmod 644 "/etc/systemd/system/$INSTALLED_UNIT" || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Reloading systemd daemon for web service..."
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
|
||||
Executable
+451
@@ -0,0 +1,451 @@
|
||||
#!/usr/bin/python3 -I
|
||||
"""Refresh the installed LEDMatrix systemd units from the checkout's templates.
|
||||
|
||||
Installed by scripts/install/install_service.sh as a root-owned copy,
|
||||
/usr/local/sbin/ledmatrix-refresh-units, and granted to the web interface's
|
||||
user by /etc/sudoers.d/ledmatrix_web (scripts/install/lib_sudoers.sh) with
|
||||
exactly two command lines:
|
||||
|
||||
ledmatrix-refresh-units (no arguments)
|
||||
ledmatrix-refresh-units --restore
|
||||
|
||||
An update (Update Code, or the weekly automatic update) pulls new unit
|
||||
templates into systemd/, but the units systemd runs are the copies in
|
||||
/etc/systemd/system, which only the installer used to write. So a setting
|
||||
added to a template -- the render-loop watchdog, a memory limit -- never
|
||||
reached a device that was already installed. After an update the web
|
||||
interface runs this, and the next restart picks the new units up.
|
||||
|
||||
* **No arguments:** render each installed unit from systemd/<unit> exactly as
|
||||
install_service.sh does (__PROJECT_ROOT_DIR__ and __USER__ replaced
|
||||
literally), and install the ones whose content differs (comments and blank
|
||||
lines aside, as src/startup_validator.py compares them), then
|
||||
``systemctl daemon-reload``. The units replaced are saved first, so the
|
||||
automatic update's rollback can put them back.
|
||||
* ``--restore``: put back the units the last refresh replaced, and
|
||||
daemon-reload. Nothing saved means nothing to do.
|
||||
* ``--check``: print the units that would change, one per line. Needs no
|
||||
root and changes nothing.
|
||||
|
||||
What it trusts, and why. It takes no other input: the project directory and
|
||||
the web interface's user come from the installed, root-owned
|
||||
ledmatrix.service and ledmatrix-web.service, not from the caller, and sudo
|
||||
strips the caller's environment (``-I`` ignores the PYTHON* variables too).
|
||||
It only replaces units that are already installed, only the four
|
||||
install_service.sh installs, and only with a rendering that keeps each unit's
|
||||
User= (root for the display, the web user for the others) and
|
||||
WorkingDirectory=. The templates are files the web user can edit -- but so is
|
||||
run.py, which ledmatrix.service already runs as root, so a template grants
|
||||
nothing that user did not have; the checks keep a damaged or hostile template
|
||||
from changing who a unit runs as, and keep this from reading anything but a
|
||||
regular file under the checkout's systemd/ folder.
|
||||
|
||||
Standard library only, and no imports from the checkout: the installed copy
|
||||
must not run code the web user can change.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess # nosec B404 - fixed argv, no shell # nosemgrep
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
SYSTEMD_DIR = '/etc/systemd/system'
|
||||
#: Root-only: the units the last refresh replaced, for --restore.
|
||||
BACKUP_DIR = '/var/lib/ledmatrix/unit-backup'
|
||||
MANIFEST = 'manifest.json'
|
||||
INSTALLED_PATH = '/usr/local/sbin/ledmatrix-refresh-units'
|
||||
|
||||
DISPLAY_UNIT = 'ledmatrix.service'
|
||||
WEB_UNIT = 'ledmatrix-web.service'
|
||||
VERIFY_SERVICE = 'ledmatrix-update-verify.service'
|
||||
VERIFY_PATH = 'ledmatrix-update-verify.path'
|
||||
#: What install_service.sh installs, in its order. Nothing else is touched.
|
||||
UNITS = (DISPLAY_UNIT, WEB_UNIT, VERIFY_SERVICE, VERIFY_PATH)
|
||||
|
||||
MAX_TEMPLATE_BYTES = 64 * 1024
|
||||
_USER_RE = re.compile(r'^[a-z_][a-z0-9_-]{0,31}$')
|
||||
#: systemd expands % specifiers, and a quote, backslash or line break would
|
||||
#: be reinterpreted in a unit file (src/auto_update_setup.py refuses the same).
|
||||
#: (On Windows, where the tests also run, a backslash is the path separator.)
|
||||
_UNSAFE_PATH_CHARS = set('%"') | ({'\\'} if os.sep == '/' else set())
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_FAILED = 1
|
||||
EXIT_USAGE = 2
|
||||
|
||||
|
||||
class RefreshError(Exception):
|
||||
"""Why the units were left alone, in words for the web interface's log."""
|
||||
|
||||
|
||||
class UnitsUnreadable(RefreshError):
|
||||
"""An installed unit is not readable by this (unprivileged) user.
|
||||
|
||||
install_service.sh used to leave units mode 0600 (first_time_install.sh's
|
||||
Step 8.1 makes them 0644), so ``--check`` as the web user cannot always
|
||||
tell; the root helper itself can.
|
||||
"""
|
||||
|
||||
|
||||
def directive_values(text, key):
|
||||
"""Every value of ``key=`` in a unit's text, in order (systemd allows spaces around ``=``)."""
|
||||
return [m.group(1).strip() for m in re.finditer(rf'^[ \t]*{key}[ \t]*=(.*)$', text or '', re.M)]
|
||||
|
||||
|
||||
def layout_problem(text, section, keys):
|
||||
"""What would make ``directive_values`` misread the unit as systemd reads it, or None.
|
||||
|
||||
A ``User=`` inside a backslash-continued line is part of the line before,
|
||||
and one under [Unit] is ignored, so either could pass a check that systemd
|
||||
then does not apply. Neither appears in the shipped templates.
|
||||
"""
|
||||
current = None
|
||||
for raw in (text or '').splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith(('#', ';')):
|
||||
continue
|
||||
if line.endswith('\\'):
|
||||
return 'continues a line with a backslash'
|
||||
if line.startswith('[') and line.endswith(']'):
|
||||
current = line[1:-1]
|
||||
continue
|
||||
key = line.split('=', 1)[0].strip()
|
||||
if key in keys and current != section:
|
||||
return f'sets {key}= outside [{section}]'
|
||||
return None
|
||||
|
||||
|
||||
def unit_body(text):
|
||||
"""A unit's meaningful lines in order: no comments, no blank lines.
|
||||
|
||||
The same comparison src/startup_validator.py uses for its drift warning,
|
||||
so what this refreshes is exactly what that warns about.
|
||||
"""
|
||||
lines = []
|
||||
for line in (text or '').splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
lines.append(line)
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def render(template, project_root, user):
|
||||
"""install_service.sh's ``sed "s|__PROJECT_ROOT_DIR__|...|g; s|__USER__|...|g"``."""
|
||||
return template.replace('__PROJECT_ROOT_DIR__', project_root).replace('__USER__', user)
|
||||
|
||||
|
||||
def _read_regular(path, limit=MAX_TEMPLATE_BYTES, dir_fd=None):
|
||||
"""A regular file's text, never through a symlink, a FIFO or a device."""
|
||||
flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
|
||||
kwargs = {'dir_fd': dir_fd} if dir_fd is not None else {}
|
||||
fd = os.open(path, flags, **kwargs)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise RefreshError(f'{path} is not a regular file')
|
||||
if info.st_size > limit:
|
||||
raise RefreshError(f'{path} is larger than {limit} bytes')
|
||||
data = b''
|
||||
while True:
|
||||
chunk = os.read(fd, limit + 1 - len(data))
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if len(data) > limit:
|
||||
raise RefreshError(f'{path} is larger than {limit} bytes')
|
||||
finally:
|
||||
os.close(fd)
|
||||
if b'\0' in data:
|
||||
raise RefreshError(f'{path} is not a text file')
|
||||
try:
|
||||
return data.decode('utf-8')
|
||||
except UnicodeDecodeError as e:
|
||||
raise RefreshError(f'{path} is not UTF-8') from e
|
||||
|
||||
|
||||
def _read_installed(systemd_dir, name):
|
||||
path = os.path.join(systemd_dir, name)
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except PermissionError as e:
|
||||
raise UnitsUnreadable(f'cannot read the installed {name}: {e}') from e
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
raise RefreshError(f'cannot read the installed {name}: {e}') from e
|
||||
|
||||
|
||||
def _lookup_user(user):
|
||||
try:
|
||||
import pwd
|
||||
except ImportError: # not a POSIX host (the tests on Windows)
|
||||
return True
|
||||
try:
|
||||
pwd.getpwnam(user)
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
|
||||
class Refresher:
|
||||
def __init__(self, systemd_dir=SYSTEMD_DIR, backup_dir=BACKUP_DIR, run=subprocess.run,
|
||||
is_root=None, user_exists=_lookup_user, log=None):
|
||||
self.systemd_dir = systemd_dir
|
||||
self.backup_dir = backup_dir
|
||||
self.run = run
|
||||
self.is_root = is_root or (lambda: hasattr(os, 'geteuid') and os.geteuid() == 0)
|
||||
self.user_exists = user_exists
|
||||
self.log = log or (lambda msg: print(msg, flush=True))
|
||||
|
||||
# -- what the installed units say -------------------------------------
|
||||
|
||||
def context(self, installed):
|
||||
"""(project root, web user) from the installed, root-owned units."""
|
||||
display = installed.get(DISPLAY_UNIT)
|
||||
if display is None:
|
||||
raise RefreshError(f'{DISPLAY_UNIT} is not installed; run scripts/install/install_service.sh')
|
||||
roots = directive_values(display, 'WorkingDirectory')
|
||||
if len(roots) != 1:
|
||||
raise RefreshError(f'the installed {DISPLAY_UNIT} does not name one WorkingDirectory')
|
||||
root = roots[0]
|
||||
if (not os.path.isabs(root) or any(ch in _UNSAFE_PATH_CHARS or ord(ch) < 32 for ch in root)
|
||||
or os.path.normpath(root) != root):
|
||||
raise RefreshError(f'the installed {DISPLAY_UNIT} runs from {root!r}, which cannot be used')
|
||||
if not os.path.isdir(root):
|
||||
raise RefreshError(f'{root} (the installed {DISPLAY_UNIT} WorkingDirectory) does not exist')
|
||||
|
||||
user = None
|
||||
web = installed.get(WEB_UNIT)
|
||||
if web is not None:
|
||||
users = directive_values(web, 'User')
|
||||
user = users[0] if len(users) == 1 else ('root' if not users else None)
|
||||
if user is None or not _USER_RE.match(user) or not self.user_exists(user):
|
||||
raise RefreshError(f'the installed {WEB_UNIT} runs as an account that cannot be used')
|
||||
if directive_values(web, 'WorkingDirectory') != [root]:
|
||||
raise RefreshError(f'the installed {WEB_UNIT} and {DISPLAY_UNIT} run from different folders')
|
||||
return root, user
|
||||
|
||||
@staticmethod
|
||||
def expected_user(name, web_user):
|
||||
return 'root' if name == DISPLAY_UNIT else web_user
|
||||
|
||||
def _template(self, root, name):
|
||||
"""systemd/<name> under the checkout, as a regular file, never via a symlink."""
|
||||
dir_flags = os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0)
|
||||
if os.open in getattr(os, 'supports_dir_fd', set()):
|
||||
try:
|
||||
dfd = os.open(os.path.join(root, 'systemd'), dir_flags)
|
||||
except OSError as e:
|
||||
raise RefreshError(f'cannot open {root}/systemd: {e}') from e
|
||||
try:
|
||||
return _read_regular(name, dir_fd=dfd)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as e:
|
||||
raise RefreshError(f'cannot read systemd/{name}: {e}') from e
|
||||
finally:
|
||||
os.close(dfd)
|
||||
path = os.path.join(root, 'systemd', name)
|
||||
if os.path.islink(os.path.join(root, 'systemd')):
|
||||
raise RefreshError(f'{root}/systemd is a symlink')
|
||||
try:
|
||||
return _read_regular(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as e:
|
||||
raise RefreshError(f'cannot read systemd/{name}: {e}') from e
|
||||
|
||||
def _validate(self, name, rendered, root, user):
|
||||
problem = layout_problem(rendered, 'Service', ('User', 'WorkingDirectory'))
|
||||
if problem:
|
||||
raise RefreshError(f'systemd/{name} {problem}; refusing to install it')
|
||||
if directive_values(rendered, 'User') != [user]:
|
||||
raise RefreshError(f'systemd/{name} would not run as {user}; refusing to install it')
|
||||
if directive_values(rendered, 'WorkingDirectory') != [root]:
|
||||
raise RefreshError(f'systemd/{name} would not run from {root}; refusing to install it')
|
||||
|
||||
def plan(self):
|
||||
"""{unit: (installed text, new text)} for every installed unit that would change.
|
||||
|
||||
Raises RefreshError, and so changes nothing, if any unit cannot be
|
||||
rendered safely: four units refreshed as a set or not at all.
|
||||
"""
|
||||
installed = {name: _read_installed(self.systemd_dir, name) for name in UNITS}
|
||||
root, web_user = self.context(installed)
|
||||
changes = {}
|
||||
for name in UNITS:
|
||||
current = installed[name]
|
||||
if current is None:
|
||||
continue # never installed here: installing is the installer's job
|
||||
user = self.expected_user(name, web_user)
|
||||
if user is None:
|
||||
continue # the web unit is not installed, so neither is its user
|
||||
template = self._template(root, name)
|
||||
if template is None:
|
||||
continue # a version without this unit leaves the installed one alone
|
||||
rendered = render(template, root, user)
|
||||
# A path unit runs nothing itself; what matters is what it starts.
|
||||
if name.endswith('.service'):
|
||||
self._validate(name, rendered, root, user)
|
||||
else:
|
||||
self._validate_path(name, rendered)
|
||||
if unit_body(rendered) != unit_body(current):
|
||||
changes[name] = (current, rendered)
|
||||
return changes
|
||||
|
||||
def _validate_path(self, name, rendered):
|
||||
problem = layout_problem(rendered, 'Path', ('Unit',))
|
||||
if problem:
|
||||
raise RefreshError(f'systemd/{name} {problem}; refusing to install it')
|
||||
if directive_values(rendered, 'Unit') != [VERIFY_SERVICE]:
|
||||
raise RefreshError(f'systemd/{name} does not start {VERIFY_SERVICE}; refusing to install it')
|
||||
if directive_values(rendered, 'User'):
|
||||
raise RefreshError(f'systemd/{name} sets User=; refusing to install it')
|
||||
|
||||
# -- writing ------------------------------------------------------------
|
||||
|
||||
def _write_unit(self, name, text):
|
||||
fd, tmp = tempfile.mkstemp(dir=self.systemd_dir, prefix=f'.{name}.')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write(text)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, os.path.join(self.systemd_dir, name))
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _backup_dir(self):
|
||||
"""The backup folder, created root-only; refused if it is not a plain folder."""
|
||||
os.makedirs(os.path.dirname(self.backup_dir), mode=0o755, exist_ok=True)
|
||||
try:
|
||||
os.mkdir(self.backup_dir, 0o700)
|
||||
except FileExistsError:
|
||||
pass
|
||||
info = os.lstat(self.backup_dir)
|
||||
if not stat.S_ISDIR(info.st_mode):
|
||||
raise RefreshError(f'{self.backup_dir} is not a folder')
|
||||
if hasattr(os, 'geteuid') and info.st_uid != os.geteuid():
|
||||
raise RefreshError(f'{self.backup_dir} is not owned by root')
|
||||
return self.backup_dir
|
||||
|
||||
def _clear_backup(self, folder):
|
||||
for entry in os.listdir(folder):
|
||||
path = os.path.join(folder, entry)
|
||||
if os.path.isfile(path) or os.path.islink(path):
|
||||
os.unlink(path)
|
||||
|
||||
def _systemctl(self, *args):
|
||||
result = self.run(['systemctl', *args], capture_output=True, text=True, timeout=60)
|
||||
if result.returncode != 0:
|
||||
raise RefreshError(f'"systemctl {" ".join(args)}" failed: '
|
||||
f'{(result.stderr or result.stdout or "").strip()}')
|
||||
|
||||
def _restart_path_unit_if_active(self, names):
|
||||
"""A rewritten path unit watches the old path until it is restarted."""
|
||||
if VERIFY_PATH not in names:
|
||||
return
|
||||
state = self.run(['systemctl', 'is-active', VERIFY_PATH], capture_output=True, text=True, timeout=30)
|
||||
if (state.stdout or '').strip() == 'active':
|
||||
self._systemctl('restart', VERIFY_PATH)
|
||||
|
||||
def refresh(self):
|
||||
if not self.is_root():
|
||||
raise RefreshError('must run as root (sudo)')
|
||||
changes = self.plan()
|
||||
folder = self._backup_dir()
|
||||
# Always reset: the backup belongs to this refresh, so a --restore
|
||||
# after an update that changed nothing restores nothing.
|
||||
self._clear_backup(folder)
|
||||
if not changes:
|
||||
self.log('units: up to date')
|
||||
return []
|
||||
for name, (current, _) in changes.items():
|
||||
with open(os.path.join(folder, name), 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write(current)
|
||||
with open(os.path.join(folder, MANIFEST), 'w', encoding='utf-8') as f:
|
||||
json.dump({'units': sorted(changes)}, f)
|
||||
try:
|
||||
for name, (_, rendered) in changes.items():
|
||||
self._write_unit(name, rendered)
|
||||
self._systemctl('daemon-reload')
|
||||
except BaseException:
|
||||
# A failed refresh is reported as a failure, so the update records
|
||||
# no units_refreshed and a rollback would not --restore. Put the
|
||||
# replaced units back now, rather than leave a half-written set
|
||||
# under the old code.
|
||||
self._undo(changes, folder)
|
||||
raise
|
||||
self._restart_path_unit_if_active(changes)
|
||||
self.log('units refreshed: ' + ' '.join(sorted(changes)))
|
||||
return sorted(changes)
|
||||
|
||||
def _undo(self, changes, folder):
|
||||
"""Best effort: reinstall the units a failed refresh replaced."""
|
||||
undone = True
|
||||
for name, (current, _) in changes.items():
|
||||
try:
|
||||
self._write_unit(name, current)
|
||||
except OSError as e:
|
||||
undone = False
|
||||
self.log(f'units: could not put back {name}: {e}')
|
||||
try:
|
||||
self.run(['systemctl', 'daemon-reload'], capture_output=True, text=True, timeout=60)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
self.log(f'units: daemon-reload after putting units back failed: {e}')
|
||||
if undone:
|
||||
# Nothing is left to restore; keep the backup only if a unit could
|
||||
# not be put back, so a manual --restore still can.
|
||||
self._clear_backup(folder)
|
||||
|
||||
def restore(self):
|
||||
if not self.is_root():
|
||||
raise RefreshError('must run as root (sudo)')
|
||||
folder = self._backup_dir()
|
||||
try:
|
||||
manifest = json.loads(_read_regular(os.path.join(folder, MANIFEST)))
|
||||
except FileNotFoundError:
|
||||
self.log('units: nothing to restore')
|
||||
return []
|
||||
names = [n for n in (manifest or {}).get('units', []) if n in UNITS]
|
||||
for name in names:
|
||||
self._write_unit(name, _read_regular(os.path.join(folder, name)))
|
||||
self._systemctl('daemon-reload')
|
||||
self._restart_path_unit_if_active(names)
|
||||
self._clear_backup(folder)
|
||||
self.log('units restored: ' + ' '.join(names))
|
||||
return names
|
||||
|
||||
|
||||
def main(argv, refresher=None):
|
||||
args = argv[1:]
|
||||
if args not in ([], ['--restore'], ['--check']):
|
||||
print('usage: ledmatrix-refresh-units [--restore | --check]', file=sys.stderr)
|
||||
return EXIT_USAGE
|
||||
refresher = refresher or Refresher()
|
||||
try:
|
||||
if args == ['--check']:
|
||||
for name in sorted(refresher.plan()):
|
||||
print(name)
|
||||
elif args == ['--restore']:
|
||||
refresher.restore()
|
||||
else:
|
||||
refresher.refresh()
|
||||
except (RefreshError, OSError, subprocess.SubprocessError, ValueError) as e:
|
||||
print(f'ledmatrix-refresh-units: {e}', file=sys.stderr)
|
||||
return EXIT_FAILED
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Only as the installed program: sudo already sets a secure PATH, and
|
||||
# this pins the one systemctl comes from. (Not in main(), which the
|
||||
# tests call in-process.)
|
||||
os.environ['PATH'] = '/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/bin/bash
|
||||
# Which operating systems and Python versions LEDMatrix installs on.
|
||||
#
|
||||
# Sourced by first_time_install.sh and scripts/check_system_compatibility.sh,
|
||||
# so the installer and the compatibility checker cannot disagree about what
|
||||
# is supported. Pure functions: nothing here installs, changes or exits --
|
||||
# the callers decide what to do with the answers.
|
||||
#
|
||||
# Supported (Lite, no desktop):
|
||||
# Raspberry Pi OS / Debian 12 "Bookworm" -- Python 3.11
|
||||
# Raspberry Pi OS / Debian 13 "Trixie" -- Python 3.13
|
||||
#
|
||||
# Everything the installer asks apt for (python3-pip, python3-venv,
|
||||
# python-dev-is-python3, python3-pil, python3-pil.imagetk, build-essential,
|
||||
# python3-setuptools, python3-wheel, cmake, ninja-build, git, curl, wget,
|
||||
# unzip, and hostapd, dnsmasq, network-manager for WiFi setup) has the same
|
||||
# name on both releases. Both ship a pip (23.0.1 and 25.1.1) that is PEP 668
|
||||
# "externally managed" and accepts --break-system-packages, and a cmake (3.25
|
||||
# and 3.31) new enough for the rgbmatrix build (3.22). So no step needs a
|
||||
# per-release branch today; if one ever does, the release name comes from
|
||||
# lm_os_release below.
|
||||
|
||||
# Test hook: the os-release file to read.
|
||||
LM_OS_RELEASE_FILE="${LM_OS_RELEASE_FILE:-/etc/os-release}"
|
||||
|
||||
# Oldest and newest python3 minor versions the installer accepts. 3.11 is
|
||||
# Bookworm's, and also the floor of the rgbmatrix bindings (requires-python
|
||||
# >=3.11 in rpi-rgb-led-matrix-master/pyproject.toml); 3.13 is Trixie's.
|
||||
LM_PYTHON_MIN_MINOR=11
|
||||
LM_PYTHON_MAX_MINOR=13
|
||||
|
||||
# lm_os_field KEY -- one value from os-release with its quotes removed; empty
|
||||
# when the key or the file is missing. Parsed rather than sourced so that
|
||||
# os-release's ID, VERSION and friends do not land in the caller's variables.
|
||||
lm_os_field() {
|
||||
[ -r "$LM_OS_RELEASE_FILE" ] || return 0
|
||||
sed -n "/^$1=/{s/^$1=//;s/^[\"']//;s/[\"']\$//;p;q;}" "$LM_OS_RELEASE_FILE"
|
||||
}
|
||||
|
||||
# lm_os_release -- print "bookworm" or "trixie" and succeed on a supported
|
||||
# release; print nothing and fail on anything else. VERSION_ID decides; the
|
||||
# codename is used only when VERSION_ID is missing.
|
||||
lm_os_release() {
|
||||
local id version
|
||||
id=$(lm_os_field ID)
|
||||
version=$(lm_os_field VERSION_ID)
|
||||
[ -n "$version" ] || version=$(lm_os_field VERSION_CODENAME)
|
||||
case "$id" in
|
||||
raspbian|debian) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
case "$version" in
|
||||
12|bookworm) echo bookworm ;;
|
||||
13|trixie) echo trixie ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# lm_release_label RELEASE -- how to name a release to a person.
|
||||
lm_release_label() {
|
||||
case "$1" in
|
||||
bookworm) echo "Debian 12 (Bookworm)" ;;
|
||||
trixie) echo "Debian 13 (Trixie)" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# lm_release_python RELEASE -- the python3 version a release ships, e.g. 3.11.
|
||||
lm_release_python() {
|
||||
case "$1" in
|
||||
bookworm) echo 3.11 ;;
|
||||
trixie) echo 3.13 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# lm_python_version [PYTHON] -- "3.11" and so on for python3 (or PYTHON);
|
||||
# prints nothing and fails when it cannot be run.
|
||||
lm_python_version() {
|
||||
"${1:-python3}" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null
|
||||
}
|
||||
|
||||
# lm_python_check VERSION -- print "ok", "too-old", "too-new" or "unknown"
|
||||
# for a version such as 3.11. Always succeeds, so it is safe under set -e.
|
||||
lm_python_check() {
|
||||
local major minor
|
||||
major=${1%%.*}
|
||||
minor=${1#*.}
|
||||
minor=${minor%%.*}
|
||||
case "$major:$minor" in
|
||||
*[!0-9:]*|:*|*:) echo unknown; return 0 ;;
|
||||
esac
|
||||
if [ "$major" -lt 3 ] || { [ "$major" -eq 3 ] && [ "$minor" -lt "$LM_PYTHON_MIN_MINOR" ]; }; then
|
||||
echo too-old
|
||||
elif [ "$major" -gt 3 ] || [ "$minor" -gt "$LM_PYTHON_MAX_MINOR" ]; then
|
||||
echo too-new
|
||||
else
|
||||
echo ok
|
||||
fi
|
||||
}
|
||||
|
||||
# lm_network_stack -- which service runs the network: "networkmanager",
|
||||
# "dhcpcd" or "unknown". Raspberry Pi OS uses NetworkManager on both Bookworm
|
||||
# and Trixie; dhcpcd appears when someone switched back to it in raspi-config.
|
||||
lm_network_stack() {
|
||||
if systemctl is-active --quiet NetworkManager 2>/dev/null; then
|
||||
echo networkmanager
|
||||
elif systemctl is-active --quiet dhcpcd 2>/dev/null; then
|
||||
echo dhcpcd
|
||||
else
|
||||
echo unknown
|
||||
fi
|
||||
}
|
||||
|
||||
# lm_print_dhcpcd_advice -- the explanation for a Pi running dhcpcd. WiFi
|
||||
# setup from the web page and the LEDMatrix-Setup hotspot both drive
|
||||
# NetworkManager (nmcli). The installer does not switch the network stack
|
||||
# itself: doing that over SSH can cut the connection it is running on.
|
||||
lm_print_dhcpcd_advice() {
|
||||
echo "⚠ This Pi manages its network with dhcpcd, not NetworkManager."
|
||||
echo " LEDMatrix installs and the display works, but choosing a WiFi network"
|
||||
echo " from the web page and the LEDMatrix-Setup hotspot both need NetworkManager."
|
||||
echo " To switch (with a keyboard and screen attached, or over Ethernet):"
|
||||
echo " sudo raspi-config -> Advanced Options -> Network Config -> NetworkManager"
|
||||
echo " then reboot."
|
||||
}
|
||||
|
||||
# lm_print_supported_os_help -- what to do on an unsupported system.
|
||||
lm_print_supported_os_help() {
|
||||
echo "LEDMatrix needs Raspberry Pi OS Lite: Trixie (Debian 13) or Bookworm (Debian 12)."
|
||||
echo ""
|
||||
echo "To install Raspberry Pi OS Lite:"
|
||||
echo " 1. Download Raspberry Pi Imager from: https://www.raspberrypi.com/software/"
|
||||
echo " 2. Choose 'Raspberry Pi OS Lite (64-bit)'. Trixie is the current version and"
|
||||
echo " is recommended; Bookworm (listed as Legacy) also works"
|
||||
echo " 3. Flash it to the SD card"
|
||||
echo " 4. Boot the Pi and run this script again"
|
||||
}
|
||||
@@ -10,6 +10,11 @@
|
||||
#
|
||||
# Add or remove a grant here and nowhere else.
|
||||
|
||||
# Root-owned copy of scripts/install/ledmatrix_refresh_units.py, installed by
|
||||
# install_service.sh. Outside the checkout on purpose: the web user owns the
|
||||
# checkout, so a granted file inside it could be rewritten and run as root.
|
||||
LEDMATRIX_REFRESH_UNITS_PATH=/usr/local/sbin/ledmatrix-refresh-units
|
||||
|
||||
# web_sudoers_rules WEB_USER PROJECT_ROOT SYSTEMCTL_PATH BASH_PATH REBOOT_PATH POWEROFF_PATH JOURNALCTL_PATH
|
||||
#
|
||||
# Print the ledmatrix_web sudoers rules to stdout.
|
||||
@@ -58,6 +63,10 @@ $WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pl
|
||||
# Install a requirements.txt as root via vetted helper, so packages are visible
|
||||
# to root-run ledmatrix.service (not just the web interface's own user).
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *
|
||||
# After an update, install the new systemd units (no arguments: "" allows none)
|
||||
# and, on the automatic update's rollback, put the previous ones back.
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $LEDMATRIX_REFRESH_UNITS_PATH ""
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $LEDMATRIX_REFRESH_UNITS_PATH --restore
|
||||
EOF
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
cat << EOF
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
# LED Matrix One-Shot Installation Script
|
||||
# This script provides a single-command installation experience
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh | bash
|
||||
#
|
||||
# A new install runs the newest release (the stable update channel). For the
|
||||
# newest code from main instead (the beta channel), set LEDMATRIX_CHANNEL=beta:
|
||||
# curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh | LEDMATRIX_CHANNEL=beta bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
@@ -205,6 +209,114 @@ check_sudo() {
|
||||
print_success "Sudo access confirmed"
|
||||
}
|
||||
|
||||
# --- release checkout helpers ------------------------------------------------
|
||||
# Which version an install runs. The rules are web_interface/update_channel.py's,
|
||||
# so the installer and the web interface's updates agree:
|
||||
# stable (default) the newest vX.Y.Z tag by semantic version; pre-releases
|
||||
# (v3.8.0-rc1), leading zeros and other tags are ignored
|
||||
# beta main, the newest code
|
||||
# Never backwards: an existing checkout moves to a release only when that
|
||||
# release contains its current commit (git merge-base --is-ancestor).
|
||||
# Never fatal: whatever goes wrong, the install carries on with the checkout
|
||||
# as it is.
|
||||
|
||||
# Print "stable" or "beta": LEDMATRIX_CHANNEL when it is set, else the
|
||||
# existing install's auto_update.channel (CONFIG_FILE), else stable.
|
||||
_lm_channel() {
|
||||
local config_file="${1:-}" value
|
||||
value=$(printf '%s' "${LEDMATRIX_CHANNEL:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
||||
case "$value" in
|
||||
stable|beta) printf '%s\n' "$value"; return 0 ;;
|
||||
"") ;;
|
||||
*) print_warning "LEDMATRIX_CHANNEL=${LEDMATRIX_CHANNEL} is not stable or beta; using stable" >&2
|
||||
printf 'stable\n'; return 0 ;;
|
||||
esac
|
||||
if [ -n "$config_file" ] && [ -f "$config_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||
value=$(python3 - "$config_file" 2>/dev/null <<'PY' || true
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
section = json.load(f).get("auto_update")
|
||||
value = section.get("channel") if isinstance(section, dict) else None
|
||||
print(value.strip().lower() if isinstance(value, str) else "")
|
||||
except Exception:
|
||||
print("")
|
||||
PY
|
||||
)
|
||||
if [ "$value" = "beta" ]; then
|
||||
printf 'beta\n'
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
printf 'stable\n'
|
||||
}
|
||||
|
||||
# Print the newest release tag of the repository in the current directory,
|
||||
# or nothing when it has none.
|
||||
_lm_newest_release_tag() {
|
||||
git tag --list 'v*' 2>/dev/null \
|
||||
| grep -E '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \
|
||||
| sort -t. -k1.2,1n -k2,2n -k3,3n \
|
||||
| tail -n 1 || true
|
||||
}
|
||||
|
||||
# A fresh clone (on main): move to the newest release unless beta was asked for.
|
||||
_lm_checkout_release_after_clone() {
|
||||
local channel tag
|
||||
channel=$(_lm_channel "")
|
||||
if [ "$channel" = "beta" ]; then
|
||||
print_success "Beta channel: installing the newest code from main"
|
||||
return 0
|
||||
fi
|
||||
tag=$(_lm_newest_release_tag)
|
||||
if [ -z "$tag" ]; then
|
||||
print_warning "No release found; installing the newest code from main"
|
||||
return 0
|
||||
fi
|
||||
if git -c advice.detachedHead=false checkout --quiet --detach "${tag}^{commit}"; then
|
||||
print_success "Installing release $tag (stable channel)"
|
||||
else
|
||||
print_warning "Could not check out release $tag; installing the newest code from main"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# An existing checkout: move it forward along its channel, never backwards.
|
||||
# Returns 1 when it should be updated the way it always was (a fast-forward
|
||||
# pull of its branch): beta, or stable on a branch newer than every release.
|
||||
_lm_update_existing_checkout() {
|
||||
local channel tag head tag_sha
|
||||
channel=$(_lm_channel "config/config.json")
|
||||
if [ "$channel" = "beta" ]; then
|
||||
return 1
|
||||
fi
|
||||
if ! git fetch --quiet --tags --force origin >/dev/null 2>&1; then
|
||||
print_warning "Could not fetch release tags; keeping the current version"
|
||||
return 0
|
||||
fi
|
||||
tag=$(_lm_newest_release_tag)
|
||||
head=$(git rev-parse --verify --quiet HEAD 2>/dev/null || true)
|
||||
if [ -n "$tag" ] && [ -n "$head" ] && git merge-base --is-ancestor "$head" "$tag" 2>/dev/null; then
|
||||
tag_sha=$(git rev-parse --verify --quiet "${tag}^{commit}" 2>/dev/null || true)
|
||||
if [ "$head" = "$tag_sha" ]; then
|
||||
print_success "Already on the newest release, $tag"
|
||||
elif git -c advice.detachedHead=false checkout --quiet --detach "${tag}^{commit}"; then
|
||||
print_success "Updated to release $tag (stable channel)"
|
||||
else
|
||||
print_warning "Could not move to release $tag (local changes?); keeping the current version"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if git symbolic-ref --quiet HEAD >/dev/null 2>&1; then
|
||||
# Newer than the newest release (or no release yet): follow the branch
|
||||
# until a release includes this version, as updates do.
|
||||
return 1
|
||||
fi
|
||||
print_success "This checkout is newer than the newest release${tag:+ ($tag)}; leaving it as it is"
|
||||
return 0
|
||||
}
|
||||
# --- end release checkout helpers --------------------------------------------
|
||||
|
||||
# Main installation function
|
||||
main() {
|
||||
print_step "LED Matrix One-Shot Installation"
|
||||
@@ -292,7 +404,10 @@ main() {
|
||||
|
||||
# Try to safely update current branch first (fast-forward only to avoid unintended merges)
|
||||
PULL_SUCCESS=false
|
||||
if git pull --ff-only origin "$CURRENT_BRANCH" >/dev/null 2>&1; then
|
||||
# Stable: the newest release, if it contains this version.
|
||||
if _lm_update_existing_checkout; then
|
||||
PULL_SUCCESS=true
|
||||
elif git pull --ff-only origin "$CURRENT_BRANCH" >/dev/null 2>&1; then
|
||||
print_success "Repository updated successfully (branch: $CURRENT_BRANCH)"
|
||||
PULL_SUCCESS=true
|
||||
else
|
||||
@@ -323,10 +438,12 @@ main() {
|
||||
rm -rf "$REPO_DIR"
|
||||
print_success "Cloning repository..."
|
||||
retry git clone "$REPO_URL" "$REPO_DIR"
|
||||
(cd "$REPO_DIR" && _lm_checkout_release_after_clone) || print_warning "Could not choose a release; installing the newest code from main"
|
||||
fi
|
||||
else
|
||||
print_success "Cloning repository to $REPO_DIR..."
|
||||
retry git clone "$REPO_URL" "$REPO_DIR"
|
||||
(cd "$REPO_DIR" && _lm_checkout_release_after_clone) || print_warning "Could not choose a release; installing the newest code from main"
|
||||
fi
|
||||
|
||||
# Verify repository is accessible
|
||||
@@ -397,6 +514,7 @@ main() {
|
||||
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 \
|
||||
LEDMATRIX_APT_UPDATED="${LEDMATRIX_APT_UPDATED:-0}" \
|
||||
LEDMATRIX_AUTO_UPDATE="${LEDMATRIX_AUTO_UPDATE:-}" \
|
||||
LEDMATRIX_CHANNEL="${LEDMATRIX_CHANNEL:-}" \
|
||||
bash ./first_time_install.sh -y </dev/null
|
||||
fi
|
||||
INSTALL_EXIT_CODE=$?
|
||||
|
||||
@@ -4,13 +4,14 @@ Alternative dependency installer that tries apt packages first,
|
||||
then falls back to pip with --break-system-packages
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
# How many trailing lines of a failed command's output to keep for the
|
||||
# end-of-run failure summary. Keeps the root cause near the end of the log,
|
||||
@@ -81,6 +82,8 @@ def install_via_pip(package_name: str) -> Tuple[bool, str]:
|
||||
|
||||
Returns (success, output).
|
||||
"""
|
||||
# pip knows PIL as Pillow; the others are asked for by their own name.
|
||||
package_name = _dist_name(package_name)
|
||||
print(f"Installing {package_name} via pip...")
|
||||
success, output = _run([
|
||||
sys.executable, '-m', 'pip', 'install',
|
||||
@@ -99,26 +102,66 @@ IMPORT_NAME_MAP = {
|
||||
'freetype-py': 'freetype',
|
||||
}
|
||||
|
||||
# Minimum versions that must be met for an already-installed package to count
|
||||
# as satisfied. Debian Bookworm's python3-freetype is 2.3.0, below the
|
||||
# freetype-py>=2.5.1 pin in requirements.txt, so an import-only check would
|
||||
# wrongly skip the pip upgrade.
|
||||
MIN_VERSIONS = {
|
||||
'freetype-py': (2, 5, 1),
|
||||
# The packages above are keyed by what main() lists; these are the ones whose
|
||||
# pip distribution name differs from that key.
|
||||
DIST_NAME_MAP = {
|
||||
'PIL': 'Pillow',
|
||||
}
|
||||
|
||||
REQUIREMENTS_FILE = Path(__file__).resolve().parent.parent / 'web_interface' / 'requirements.txt'
|
||||
|
||||
|
||||
def _version_tuple(text: str) -> tuple:
|
||||
parts = []
|
||||
for part in text.split('.'):
|
||||
digits = ''.join(ch for ch in part if ch.isdigit())
|
||||
if not digits:
|
||||
break
|
||||
parts.append(int(digits))
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def _requirement_floors(path: Path = REQUIREMENTS_FILE) -> Dict[str, tuple]:
|
||||
"""``>=`` floors from a requirements file, keyed by lower-cased name.
|
||||
|
||||
The apt copies of these packages are older than the pins on both
|
||||
supported releases -- Bookworm ships Flask and Werkzeug 2.2.2, Pillow 9.4,
|
||||
requests 2.28, psutil 5.9, pytz 2022.7 and freetype-py 2.3; Trixie ships
|
||||
Flask 3.1.1, Werkzeug 3.1.3, Pillow 11.1 and requests 2.32 --
|
||||
so a package that merely imports is not enough. Read from the file rather
|
||||
than copied here so the two cannot drift.
|
||||
"""
|
||||
floors: Dict[str, tuple] = {}
|
||||
try:
|
||||
lines = path.read_text(encoding='utf-8').splitlines()
|
||||
except OSError:
|
||||
return floors
|
||||
for line in lines:
|
||||
match = re.match(r'\s*([A-Za-z0-9][A-Za-z0-9._-]*)[^#]*?>=\s*([0-9][0-9.]*)', line)
|
||||
if match:
|
||||
floors[match.group(1).lower()] = _version_tuple(match.group(2))
|
||||
return floors
|
||||
|
||||
|
||||
def _dist_name(package_name: str) -> str:
|
||||
return DIST_NAME_MAP.get(package_name, package_name)
|
||||
|
||||
|
||||
def _minimum_version(package_name: str) -> tuple:
|
||||
"""The required floor for ``package_name``, or () when there is none."""
|
||||
return MIN_VERSIONS.get(_dist_name(package_name).lower(), ())
|
||||
|
||||
|
||||
# Minimum versions that must be met for an already-installed package to count
|
||||
# as satisfied.
|
||||
MIN_VERSIONS = _requirement_floors()
|
||||
|
||||
|
||||
def _installed_version_tuple(dist_name: str) -> tuple:
|
||||
"""Return the installed distribution version as an int tuple, or () if unknown."""
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
parts = []
|
||||
for part in version(dist_name).split('.'):
|
||||
digits = ''.join(ch for ch in part if ch.isdigit())
|
||||
if not digits:
|
||||
break
|
||||
parts.append(int(digits))
|
||||
return tuple(parts)
|
||||
return _version_tuple(version(dist_name))
|
||||
except Exception:
|
||||
return ()
|
||||
|
||||
@@ -134,9 +177,9 @@ def check_package_installed(package_name: str) -> bool:
|
||||
__import__(import_name)
|
||||
except ImportError:
|
||||
return False
|
||||
minimum = MIN_VERSIONS.get(package_name)
|
||||
minimum = _minimum_version(package_name)
|
||||
if minimum:
|
||||
installed = _installed_version_tuple(package_name)
|
||||
installed = _installed_version_tuple(_dist_name(package_name))
|
||||
if not installed or installed < minimum:
|
||||
print(f"{package_name} is installed but below the required "
|
||||
f"{'.'.join(map(str, minimum))}; will upgrade via pip")
|
||||
@@ -188,10 +231,11 @@ def main():
|
||||
continue
|
||||
|
||||
# Try apt first, then pip. An apt install only counts if it also
|
||||
# satisfies any minimum version (Debian's python3-freetype can be
|
||||
# older than the freetype-py pin), otherwise fall through to pip.
|
||||
# satisfies the requirements floor (the apt copies of most of these
|
||||
# are older than the pins on both Bookworm and Trixie), otherwise
|
||||
# fall through to pip.
|
||||
ok, apt_output = install_via_apt(package)
|
||||
if ok and package in MIN_VERSIONS and not check_package_installed(package):
|
||||
if ok and _minimum_version(package) and not check_package_installed(package):
|
||||
ok = False
|
||||
apt_output = f"apt version of {package} is below the required minimum"
|
||||
if not ok:
|
||||
|
||||
+193
-7
@@ -25,6 +25,14 @@ against another) and for A/B testing a change to the render path.
|
||||
sudo python3 scripts/render_bench.py --busy 2 # with background load
|
||||
sudo python3 scripts/render_bench.py --json /tmp/pi4.json
|
||||
|
||||
# the cost of changing pixels under a moving strip (Vegas live elements):
|
||||
# a 101KB write into the visible columns every 25 frames
|
||||
sudo python3 scripts/render_bench.py --patch-bytes 101376 --patch-every 25
|
||||
|
||||
# the cost of extending a Vegas-sized strip on the render thread: a 30-screen
|
||||
# strip, extended by 6 screens (and trimmed) every 6 screens scrolled
|
||||
sudo python3 scripts/render_bench.py --strip-screens 30 --extend-every-screens 6
|
||||
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
Like scripts/scroll_speeds.py, this never starts or stops the service itself,
|
||||
@@ -92,13 +100,17 @@ def load_config() -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def build_strip(width: int, height: int, label: str):
|
||||
"""A marquee strip a few screens wide, with text and colour.
|
||||
def build_strip(width: int, height: int, label: str, screens: float = 4.0):
|
||||
"""A marquee strip about ``screens`` screens wide, with text and colour.
|
||||
|
||||
Deliberately not plain white text on black: how long ``SetImage`` takes
|
||||
depends on how many subpixels are lit, so a strip that is mostly dark
|
||||
flatters the panel and hides exactly the regression this benchmark exists
|
||||
to catch.
|
||||
|
||||
The width matters to the extension mode, whose cost is a copy of the whole
|
||||
strip: Vegas carries 8,000-20,000 columns, so measure extension against a
|
||||
strip that wide (``--strip-screens``), not the four-screen default.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
@@ -123,7 +135,7 @@ def build_strip(width: int, height: int, label: str):
|
||||
text_width = max(1, box[2] - box[0])
|
||||
text_height = box[3] - box[1]
|
||||
|
||||
reps = max(2, (width * 4) // text_width + 1)
|
||||
reps = max(2, int(width * screens) // text_width + 1)
|
||||
strip = Image.new("RGB", (text_width * reps, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(strip)
|
||||
draw.fontmode = "1" # the panel has no partial brightness; see DisplayManager
|
||||
@@ -179,6 +191,119 @@ class BackgroundLoad:
|
||||
zlib.compress(image.tobytes(), 1)
|
||||
|
||||
|
||||
class StripWork:
|
||||
"""Render-thread work a Vegas strip does between frames, on a schedule.
|
||||
|
||||
Patching writes a block of columns into the strip in place, as a live
|
||||
element update does. Extending appends a block and trims what has scrolled
|
||||
past, as continuous Vegas does (render_pipeline.extend_scroll_content).
|
||||
Both run where Vegas runs them -- on the frame loop, before the next frame
|
||||
is drawn -- and are tagged with ``FrameTimingRecorder.note_op``, so the
|
||||
report shows how often the frame straight after each one was late.
|
||||
|
||||
The content comes from the benchmark's own strip, prepared before the run:
|
||||
in Vegas it is drawn off the render thread, so drawing it here would time
|
||||
work the render thread never does.
|
||||
"""
|
||||
|
||||
def __init__(self, helper, recorder, source, *, patch_bytes: int = 0,
|
||||
patch_every: int = 25, patch_where: str = "visible",
|
||||
extend_every_screens: float = 0.0, extend_width: int = 0,
|
||||
separator: int = 32) -> None:
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
self.helper = helper
|
||||
self.recorder = recorder
|
||||
self.width = helper.display_width
|
||||
self.height = helper.display_height
|
||||
self.patch_every = max(1, int(patch_every))
|
||||
self.patch_where = patch_where
|
||||
self.separator = max(0, int(separator))
|
||||
self.patches = 0
|
||||
self.patched_bytes = 0
|
||||
self.extensions = 0
|
||||
self._frames = 0
|
||||
self._position_at_extend = 0.0
|
||||
|
||||
pixels = np.asarray(source.convert("RGB"))
|
||||
source_width = pixels.shape[1]
|
||||
|
||||
def columns(count: int, offset: int):
|
||||
# Wraps around the source, so any width can be cut from it.
|
||||
return np.ascontiguousarray(
|
||||
pixels[:, (np.arange(count) + offset) % source_width])
|
||||
|
||||
# Two versions to alternate between, so every patch changes pixels.
|
||||
self._patches = []
|
||||
if patch_bytes > 0:
|
||||
count = max(1, int(patch_bytes) // (self.height * 3))
|
||||
self._patches = [columns(count, 0), columns(count, count)]
|
||||
|
||||
self.extend_every = (int(extend_every_screens * self.width)
|
||||
if extend_every_screens > 0 else 0)
|
||||
self._blocks = []
|
||||
if self.extend_every:
|
||||
# By default each append (block plus its separator) replaces
|
||||
# exactly what scrolled past since the last one, so the strip
|
||||
# holds its width, as Vegas's does in the steady state.
|
||||
count = int(extend_width) or max(1, self.extend_every - self.separator)
|
||||
self._blocks = [Image.fromarray(columns(count, 0)),
|
||||
Image.fromarray(columns(count, count))]
|
||||
|
||||
def reset(self) -> None:
|
||||
"""The strip was restarted from the beginning."""
|
||||
self._position_at_extend = self.helper.scroll_position
|
||||
|
||||
def before_frame(self) -> None:
|
||||
"""Do whatever work is due before the next frame is drawn."""
|
||||
if self._blocks:
|
||||
self._extend_if_due()
|
||||
if self._patches:
|
||||
self._frames += 1
|
||||
if self._frames % self.patch_every == 0:
|
||||
self._patch()
|
||||
|
||||
def _extend_if_due(self) -> None:
|
||||
helper = self.helper
|
||||
if helper.scroll_position < self._position_at_extend:
|
||||
self._position_at_extend = helper.scroll_position
|
||||
# Due on a fixed cadence rather than N screens after the last one ran,
|
||||
# which would drift by the overshoot of a multi-pixel step each time.
|
||||
due_at = self._position_at_extend + self.extend_every
|
||||
if helper.scroll_position < due_at:
|
||||
return
|
||||
block = self._blocks[self.extensions % 2]
|
||||
helper.append_content([block], item_gap=self.separator, element_gap=0)
|
||||
moved = helper.cached_array.nbytes
|
||||
# One screen kept behind the viewport, as Vegas does. The trim shifts
|
||||
# every strip coordinate, the cadence's included.
|
||||
cut = helper.drop_scrolled_prefix(keep_before=self.width)
|
||||
if cut:
|
||||
moved += helper.cached_array.nbytes
|
||||
self._position_at_extend = due_at - cut
|
||||
self.extensions += 1
|
||||
self.recorder.note_op("extend", moved)
|
||||
|
||||
def _patch(self) -> None:
|
||||
patch = self._patches[self.patches % 2]
|
||||
strip = self.helper.cached_array
|
||||
count = patch.shape[1]
|
||||
if strip is None or count > strip.shape[1]:
|
||||
return
|
||||
start = int(self.helper.scroll_position)
|
||||
if self.patch_where == "visible":
|
||||
x = start + max(0, (self.width - count) // 2)
|
||||
else:
|
||||
# Just past the right edge: a change to content not yet on screen.
|
||||
x = start + self.width + 16
|
||||
x = max(0, min(x, strip.shape[1] - count))
|
||||
strip[:, x:x + count] = patch
|
||||
self.patches += 1
|
||||
self.patched_bytes += patch.nbytes
|
||||
self.recorder.note_op("patch", patch.nbytes)
|
||||
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -202,8 +327,36 @@ def main(argv=None) -> int:
|
||||
help="also write the report as JSON, for comparing rigs")
|
||||
parser.add_argument("--label", default=None,
|
||||
help="name for this run in the JSON report (default: hostname)")
|
||||
parser.add_argument("--strip-screens", type=float, default=4.0, metavar="S",
|
||||
help="strip width in screens (default 4). Vegas strips are "
|
||||
"8,000-20,000px; the extension cost scales with it")
|
||||
parser.add_argument("--patch-bytes", type=int, default=0, metavar="N",
|
||||
help="write N bytes of columns into the strip in place "
|
||||
"every --patch-every frames, as a Vegas live element "
|
||||
"update does (a 150x64 card is ~29KB, a 512x64 map "
|
||||
"~100KB)")
|
||||
parser.add_argument("--patch-every", type=int, default=25, metavar="K",
|
||||
help="frames between patches (default 25; 1 = every frame)")
|
||||
parser.add_argument("--patch-where", choices=("visible", "ahead"),
|
||||
default="visible",
|
||||
help="patch the columns on screen, or just past its right "
|
||||
"edge (default visible)")
|
||||
parser.add_argument("--extend-every-screens", type=float, default=0.0,
|
||||
metavar="N",
|
||||
help="append a block and trim the strip every N screens "
|
||||
"scrolled, as continuous Vegas does")
|
||||
parser.add_argument("--extend-width", type=int, default=0, metavar="W",
|
||||
help="width of each appended block in px (default: N "
|
||||
"screens less the separator, so the strip holds its "
|
||||
"width)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.extend_every_screens > 0 and args.strip_screens < args.extend_every_screens + 3:
|
||||
# The strip must stay ahead of the viewport between extensions.
|
||||
args.strip_screens = args.extend_every_screens + 3
|
||||
print(f"strip widened to {args.strip_screens:g} screens so extensions "
|
||||
"keep ahead of the viewport")
|
||||
|
||||
# Everything the display service logs would otherwise land in the middle of
|
||||
# the report; the benchmark's own output is the point. The stall watchdog
|
||||
# is the exception: a stack dump naming what held a frame up belongs here.
|
||||
@@ -272,8 +425,9 @@ def main(argv=None) -> int:
|
||||
print(f"asked for {requested:.1f} px/s -> {choice.describe()}")
|
||||
|
||||
helper.set_sub_pixel_scrolling(False)
|
||||
helper.set_scrolling_image(
|
||||
build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s"))
|
||||
strip = build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s",
|
||||
screens=args.strip_screens)
|
||||
helper.set_scrolling_image(strip)
|
||||
|
||||
# The display service's own recorder, owned outright here: never flushed to
|
||||
# the service's stats file, drained exactly at the start and end of the
|
||||
@@ -284,12 +438,28 @@ def main(argv=None) -> int:
|
||||
flush_interval=float("inf"),
|
||||
info=display._frame_timing_info(), # pylint: disable=protected-access
|
||||
refresh_hz=idle_hz,
|
||||
gc_monitor=frame_timing.install_gc_monitor(),
|
||||
)
|
||||
recorder.scrolling_now = display._scrolling_now # pylint: disable=protected-access
|
||||
display.frame_timing = recorder
|
||||
|
||||
print(f"scrolling {width}x{height} for {args.seconds:.0f}s"
|
||||
+ (f" with {args.busy} background worker(s)" if args.busy else "")
|
||||
work = StripWork(helper, recorder, strip,
|
||||
patch_bytes=args.patch_bytes, patch_every=args.patch_every,
|
||||
patch_where=args.patch_where,
|
||||
extend_every_screens=args.extend_every_screens,
|
||||
extend_width=args.extend_width)
|
||||
|
||||
doing = []
|
||||
if args.busy:
|
||||
doing.append(f"{args.busy} background worker(s)")
|
||||
if args.patch_bytes > 0:
|
||||
doing.append(f"a {args.patch_bytes}B {args.patch_where} patch every "
|
||||
f"{args.patch_every} frame(s)")
|
||||
if work.extend_every:
|
||||
doing.append(f"an extension every {args.extend_every_screens:g} screens")
|
||||
print(f"scrolling {width}x{height} ({helper.cached_array.shape[1]}px strip) "
|
||||
f"for {args.seconds:.0f}s"
|
||||
+ (" with " + ", ".join(doing) if doing else "")
|
||||
+ " ...", flush=True)
|
||||
|
||||
frames = 0
|
||||
@@ -309,8 +479,11 @@ def main(argv=None) -> int:
|
||||
before = recorder.snapshot()
|
||||
run_started = now
|
||||
frames = duplicates = blanks = restarts = 0
|
||||
work.patches = work.patched_bytes = work.extensions = 0
|
||||
if run_started is not None and now - run_started >= args.seconds:
|
||||
break
|
||||
# Where Vegas does its strip work: before the frame is drawn.
|
||||
work.before_frame()
|
||||
helper.update_scroll_position()
|
||||
if helper.is_scroll_complete():
|
||||
# The helper parks at the end of the strip and stops
|
||||
@@ -320,6 +493,7 @@ def main(argv=None) -> int:
|
||||
# the benchmark measures a still image for the rest of the
|
||||
# run and reports a smoothness it never demonstrated.
|
||||
helper.reset_scroll()
|
||||
work.reset()
|
||||
restarts += 1
|
||||
visible = helper.get_visible_portion()
|
||||
column = int(helper.scroll_position)
|
||||
@@ -375,6 +549,11 @@ def main(argv=None) -> int:
|
||||
if restarts:
|
||||
print(f"restarts {restarts} (the strip was scrolled through "
|
||||
f"{restarts} time{'s' if restarts != 1 else ''})")
|
||||
if work.patches:
|
||||
print(f"patches {work.patches} ({work.patched_bytes / 1e6:.1f} MB "
|
||||
"written into the strip)")
|
||||
if work.extensions:
|
||||
print(f"extensions {work.extensions}")
|
||||
|
||||
if args.json_path:
|
||||
report.update({
|
||||
@@ -388,6 +567,13 @@ def main(argv=None) -> int:
|
||||
"duplicate_frames": duplicates,
|
||||
"blank_frames": blanks,
|
||||
"strip_restarts": restarts,
|
||||
"strip_screens": args.strip_screens,
|
||||
"patch_bytes": args.patch_bytes,
|
||||
"patch_every": args.patch_every,
|
||||
"patch_where": args.patch_where,
|
||||
"patches": work.patches,
|
||||
"extend_every_screens": args.extend_every_screens,
|
||||
"extensions": work.extensions,
|
||||
"max_late_pct": args.max_late_pct,
|
||||
"passed": frame_soak.passed(report, args.max_late_pct),
|
||||
})
|
||||
|
||||
@@ -56,9 +56,33 @@ def main() -> int:
|
||||
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.')
|
||||
parser.add_argument('--vegas', action='store_true',
|
||||
help="Render the plugin's block of the Vegas ticker strip "
|
||||
"instead of display(): its live elements if it has "
|
||||
"them, else its Vegas content, laid out as the "
|
||||
"ticker lays them out. Also writes the live "
|
||||
"elements' keys and columns to <output>.json")
|
||||
parser.add_argument('--no-live', action='store_true',
|
||||
help="With --vegas: ignore live elements and render the "
|
||||
"plugin's ordinary Vegas content (for before/after)")
|
||||
parser.add_argument('--timeline', type=int, default=0, metavar='ROWS',
|
||||
help="With --vegas: render ROWS rows, each the block a "
|
||||
"--timeline-step later as the ticker would update it "
|
||||
"in place (animated elements redrawn for that moment)")
|
||||
parser.add_argument('--timeline-step', type=float, default=0.25, metavar='SECONDS',
|
||||
help="Seconds between --timeline rows (default 0.25)")
|
||||
parser.add_argument('--timeline-update', action='store_true',
|
||||
help="With --timeline: run update() before each row and "
|
||||
"redraw every live element from the new data")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.timeline > 1 and args.no_live:
|
||||
# A timeline shows live elements changing; plain content never does.
|
||||
parser.error("--timeline shows live elements; it cannot be combined with --no-live")
|
||||
if (args.timeline or args.no_live) and not args.vegas:
|
||||
parser.error("--timeline and --no-live need --vegas")
|
||||
|
||||
if not (MIN_DIMENSION <= args.width <= MAX_DIMENSION):
|
||||
print(f"Error: --width must be between {MIN_DIMENSION} and {MAX_DIMENSION} (got {args.width})")
|
||||
raise SystemExit(1)
|
||||
@@ -145,6 +169,39 @@ def main() -> int:
|
||||
except Exception as e:
|
||||
logger.warning("update() raised: %s — continuing to display()", e)
|
||||
|
||||
if args.vegas:
|
||||
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.vegas and args.timeline > 1:
|
||||
from src.plugin_system.testing.vegas import render_vegas_timeline
|
||||
image, rows = render_vegas_timeline(
|
||||
plugin_instance, args.plugin, display_manager, steps=args.timeline,
|
||||
step_seconds=args.timeline_step, run_update=args.timeline_update)
|
||||
if image is None:
|
||||
logger.error("Plugin '%s' has no Vegas content", args.plugin)
|
||||
return 1
|
||||
image.save(args.output)
|
||||
logger.info("Saved a %d-row Vegas timeline (%dx%d) to %s",
|
||||
rows, image.width, image.height, args.output)
|
||||
return 0
|
||||
|
||||
if args.vegas:
|
||||
from src.plugin_system.testing.vegas import render_vegas_strip
|
||||
block, layout = render_vegas_strip(
|
||||
plugin_instance, args.plugin, display_manager, live=not args.no_live)
|
||||
if block is None:
|
||||
logger.error("Plugin '%s' has no Vegas content", args.plugin)
|
||||
return 1
|
||||
block.save(args.output)
|
||||
sidecar = Path(args.output).with_suffix('.json')
|
||||
sidecar.write_text(json.dumps(
|
||||
{"width": block.width, "height": block.height,
|
||||
"live_elements": [{"key": k, "x": x, "width": w} for x, k, w in layout]},
|
||||
indent=2) + "\n", encoding="utf-8")
|
||||
logger.info("Saved Vegas strip %dx%d (%d live element(s)) to %s and %s",
|
||||
block.width, block.height, len(layout), args.output, sidecar)
|
||||
return 0
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -140,6 +140,14 @@ class _Canonical(ast.NodeTransformer):
|
||||
node.annotation = None
|
||||
return node
|
||||
|
||||
def visit_AnnAssign(self, node):
|
||||
# ``x: T = v`` is ``x = v``; a bare ``x: T`` does nothing at runtime.
|
||||
self.generic_visit(node)
|
||||
if node.value is None:
|
||||
return None
|
||||
return ast.copy_location(
|
||||
ast.Assign(targets=[node.target], value=node.value), node)
|
||||
|
||||
|
||||
class _Folded(_Canonical):
|
||||
"""Canonical, plus sport names folded out of identifiers and strings."""
|
||||
|
||||
@@ -247,6 +247,19 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- What to render: the plugin's screen, or its block of the Vegas strip -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="viewSelect" class="text-xs whitespace-nowrap" style="color: var(--text-secondary);">View</label>
|
||||
<select id="viewSelect" onchange="onConfigChange()"
|
||||
class="flex-1 px-2 py-1.5 rounded-lg text-xs"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
|
||||
title="Vegas strip: the plugin's block of the Vegas ticker, laid out as the ticker lays it out">
|
||||
<option value="">Display</option>
|
||||
<option value="live">Vegas strip (live elements)</option>
|
||||
<option value="plain">Vegas strip (plain Vegas content)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Render buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="renderPlugin()" id="renderBtn"
|
||||
@@ -489,6 +502,7 @@
|
||||
width: width,
|
||||
height: height,
|
||||
mock_data: mockData,
|
||||
vegas: document.getElementById('viewSelect').value || null,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -510,8 +524,11 @@
|
||||
updateZoom();
|
||||
|
||||
// Show render time
|
||||
const live = data.live_elements;
|
||||
document.getElementById('renderTimeText').textContent =
|
||||
`${data.render_time_ms}ms`;
|
||||
`${data.render_time_ms}ms` + (live ? ` · ${data.width}px strip, ` +
|
||||
`${live.length} live element(s)` +
|
||||
(live.length ? `: ${live.map(e => e.key).join(', ')}` : '') : '');
|
||||
|
||||
// Show warnings/errors
|
||||
showMessages(data.errors || [], data.warnings || []);
|
||||
|
||||
@@ -15,7 +15,8 @@ The updater leaves data/auto_update_pending.json:
|
||||
|
||||
{"status": "pending", "old_head": ..., "new_head": ...,
|
||||
"old_ref": "main" | "" (detached) | absent (older updaters),
|
||||
"display_was_active": bool, "dependency_failures": [...], "created_at": ...}
|
||||
"display_was_active": bool, "dependency_failures": [...],
|
||||
"units_refreshed": bool (absent from older updaters), "created_at": ...}
|
||||
|
||||
This moves its status to "verifying" and then to one of "success",
|
||||
"rolled_back" or "rollback_failed", with "reason" and "detail" saying why.
|
||||
@@ -74,6 +75,10 @@ BASH_CANDIDATES = ('/usr/bin/bash', '/bin/bash')
|
||||
#: ...and, like it, moves to the next one only when sudo refused the command
|
||||
#: line (permission_utils.SUDO_REFUSAL_PHRASES), never after pip itself ran.
|
||||
SUDO_REFUSAL_PHRASES = ('a password is required', 'is not allowed to run', 'no tty present')
|
||||
#: The root-owned helper that installed the update's systemd units
|
||||
#: (web_interface/unit_refresh.py); ``--restore`` puts the previous ones back.
|
||||
REFRESH_UNITS_PATH = '/usr/local/sbin/ledmatrix-refresh-units'
|
||||
UNIT_RESTORE_TIMEOUT_SECONDS = 90
|
||||
|
||||
#: The longest one health check can take: restart and wait, roll back
|
||||
#: (diff, reset, reinstalls), restart and wait again. A wait's last poll can
|
||||
@@ -81,7 +86,8 @@ SUDO_REFUSAL_PHRASES = ('a password is required', 'is not allowed to run', 'no t
|
||||
_WAIT_WORST_SECONDS = (HEALTH_TIMEOUT_SECONDS + STABLE_SECONDS + WEB_CHECK_TIMEOUT_SECONDS
|
||||
+ 2 * SYSTEMCTL_QUERY_TIMEOUT_SECONDS + POLL_SECONDS)
|
||||
WORST_CASE_SECONDS = (2 * (2 * RESTART_TIMEOUT_SECONDS + _WAIT_WORST_SECONDS)
|
||||
+ GIT_TIMEOUT_SECONDS + GIT_RESET_TIMEOUT_SECONDS + PIP_BUDGET_SECONDS)
|
||||
+ GIT_TIMEOUT_SECONDS + GIT_RESET_TIMEOUT_SECONDS + UNIT_RESTORE_TIMEOUT_SECONDS
|
||||
+ PIP_BUDGET_SECONDS)
|
||||
|
||||
#: What a command that could not run at all reports: its callers only read
|
||||
#: these three fields, the same ones a completed subprocess has.
|
||||
@@ -300,12 +306,26 @@ class Verifier:
|
||||
if result.returncode != 0:
|
||||
return False, (f'"git reset --hard {old}" failed: '
|
||||
f'{(result.stderr or result.stdout or "").strip()}')
|
||||
notes = []
|
||||
# The update also installed its own systemd units: put the previous
|
||||
# ones back before anything restarts onto the rolled-back code.
|
||||
if pending.get('units_refreshed') and not self.restore_units():
|
||||
notes.append('restoring the previous service settings failed; run '
|
||||
'"sudo ./scripts/install/install_service.sh" in the LEDMatrix folder')
|
||||
deadline = self.clock() + PIP_BUDGET_SECONDS
|
||||
failed = [rel for rel in requirements if not self.install_requirements(rel, deadline)]
|
||||
if failed:
|
||||
return True, ('reinstalling the previous dependencies from ' + ', '.join(failed)
|
||||
+ ' failed; run Install Base Requirements from the Tools tab')
|
||||
return True, ''
|
||||
notes.append('reinstalling the previous dependencies from ' + ', '.join(failed)
|
||||
+ ' failed; run Install Base Requirements from the Tools tab')
|
||||
return True, '; '.join(notes)
|
||||
|
||||
def restore_units(self):
|
||||
"""Reinstall the systemd units the update replaced. True on success."""
|
||||
result = self._run(['sudo', '-n', REFRESH_UNITS_PATH, '--restore'],
|
||||
timeout=UNIT_RESTORE_TIMEOUT_SECONDS)
|
||||
if result.returncode != 0:
|
||||
self.log(f'restoring the previous systemd units failed: {(result.stderr or "").strip()}')
|
||||
return result.returncode == 0
|
||||
|
||||
# -- the check itself -------------------------------------------------
|
||||
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.7.0"
|
||||
__version__ = "3.8.2"
|
||||
|
||||
|
||||
+34
-12
@@ -28,6 +28,7 @@ freetype.Face, so it drops straight into DisplayManager.draw_text().
|
||||
"""
|
||||
|
||||
import logging
|
||||
import weakref
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||
@@ -332,9 +333,10 @@ class LayoutContext:
|
||||
# a plugin fitting changing text (a live game clock, a ticker) on a
|
||||
# 24/7 service would otherwise grow this without bound.
|
||||
self._fit_cache: "OrderedDict[Any, FitResult]" = OrderedDict()
|
||||
# LRU-bounded (images are big). Entries hold a strong reference to
|
||||
# the source image when keyed by id() so the id can't be recycled
|
||||
# out from under the cache.
|
||||
# LRU-bounded (images are big). An id()-keyed entry watches its
|
||||
# source image through a weak reference and is dropped when the
|
||||
# source is freed (see fit_image), so the id can't be recycled out
|
||||
# from under the cache and the cache never keeps the source alive.
|
||||
self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict()
|
||||
|
||||
_IMAGE_CACHE_MAX = 64
|
||||
@@ -536,8 +538,15 @@ class LayoutContext:
|
||||
cached per (image, box size, options) for this panel size.
|
||||
|
||||
Prefer a stable ``cache_key`` (e.g. "logo:KC") for images that get
|
||||
reloaded — the default id()-based key is safe (the entry pins the
|
||||
source image) but misses across reloads of the same content.
|
||||
reloaded — the default id()-based key misses across reloads of the
|
||||
same content.
|
||||
|
||||
An id()-keyed entry lives only as long as its source image: it holds
|
||||
a weak reference and is dropped when the source is freed. It used to
|
||||
pin the source instead, so a plugin passing a freshly loaded image
|
||||
each frame (``draw_image(Image.open(path), box)``, the documented
|
||||
one-liner) never hit and kept the last 64 sources alive — ~64MB for
|
||||
500x500 RGBA team logos, the median size under assets/sports.
|
||||
"""
|
||||
from src.adaptive_images import fit_image as _fit_image
|
||||
|
||||
@@ -547,18 +556,31 @@ class LayoutContext:
|
||||
key = ("image", identity, img.size, box_w, box_h, mode,
|
||||
crop_to_ink, anchor, resample_name, upscale)
|
||||
|
||||
cached = self._image_cache.get(key)
|
||||
if cached is not None:
|
||||
self._image_cache.move_to_end(key)
|
||||
cache = self._image_cache
|
||||
cached = cache.get(key)
|
||||
# An id()-keyed hit must still be this very image; the callback below
|
||||
# normally removes a dead source's entry before its id can recur.
|
||||
if cached is not None and (cache_key is not None or cached[1]() is img):
|
||||
cache.move_to_end(key)
|
||||
return cached[0]
|
||||
|
||||
result = _fit_image(img, (box_w, box_h), mode=mode,
|
||||
crop_to_ink=crop_to_ink, anchor=anchor,
|
||||
resample=resample, upscale=upscale)
|
||||
# Pin the source only for id()-keyed entries (see docstring).
|
||||
self._image_cache[key] = (result, img if cache_key is None else None)
|
||||
while len(self._image_cache) > self._IMAGE_CACHE_MAX:
|
||||
self._image_cache.popitem(last=False)
|
||||
source = None
|
||||
if cache_key is None:
|
||||
def _forget(ref: Any, key: Any = key) -> None:
|
||||
entry = cache.get(key)
|
||||
if entry is not None and entry[1] is ref:
|
||||
cache.pop(key, None)
|
||||
try:
|
||||
source = weakref.ref(img, _forget)
|
||||
except TypeError:
|
||||
# Not weak-referenceable: pin it, as before.
|
||||
source = lambda img=img: img # noqa: E731
|
||||
cache[key] = (result, source)
|
||||
while len(cache) > self._IMAGE_CACHE_MAX:
|
||||
cache.popitem(last=False)
|
||||
return result
|
||||
|
||||
# ---- text utilities ------------------------------------------------
|
||||
|
||||
@@ -27,6 +27,13 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
import pytz
|
||||
from src.cache_manager import CacheManager
|
||||
from src.common.json_body import response_json
|
||||
from src.common.fetch_service import (
|
||||
current_plugin_id,
|
||||
fetch_get,
|
||||
get_fetch_service,
|
||||
plugin_scope,
|
||||
share_connection_pool,
|
||||
)
|
||||
from src.common.espn_dates import (
|
||||
RANGE_RETRY_SECONDS,
|
||||
_note_range_rejected,
|
||||
@@ -78,6 +85,9 @@ class FetchRequest:
|
||||
commit_claimed: bool = False
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
# The plugin that submitted the request, so the fetch service counts the
|
||||
# worker's requests against it (fetch_service, caller identity).
|
||||
owner: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
@@ -119,6 +129,12 @@ class _ConnectionRetryingSession:
|
||||
def __init__(self, session):
|
||||
self._session = session
|
||||
|
||||
@property
|
||||
def fetch_identity_session(self):
|
||||
"""The wrapped Session, whose headers and adapter the fetch service
|
||||
reads to key this request (src/common/fetch_service.py)."""
|
||||
return self._session
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
for attempt in range(self.ATTEMPTS):
|
||||
try:
|
||||
@@ -196,9 +212,12 @@ class BackgroundDataService:
|
||||
# connection errors three times, a dead network cost up to 16
|
||||
# connection attempts per request and held one of the few worker
|
||||
# threads for all of them.
|
||||
#
|
||||
# The adapter is the fetch service's shared no-retry one: the same
|
||||
# max_retries=0, with the connection pool shared with the other core
|
||||
# sessions that do not retry (the odds managers).
|
||||
self.session = requests.Session()
|
||||
self.session.mount('http://', requests.adapters.HTTPAdapter(max_retries=0))
|
||||
self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=0))
|
||||
share_connection_pool(self.session, max_retries=0)
|
||||
|
||||
# Default headers: core's shared set (real User-Agent, no hand-set
|
||||
# Accept-Encoding) -- see src/common/api_helper.py.
|
||||
@@ -299,6 +318,10 @@ class BackgroundDataService:
|
||||
if url.split('?', 1)[0].rstrip('/').endswith('/scoreboard'):
|
||||
params = clamp_espn_limit(params)
|
||||
|
||||
# Who asked, resolved on the submitting thread: the worker thread
|
||||
# runs no plugin code, so it could not tell (fetch_service).
|
||||
owner = current_plugin_id()
|
||||
|
||||
# Create fetch request
|
||||
request = FetchRequest(
|
||||
id=request_id,
|
||||
@@ -311,7 +334,8 @@ class BackgroundDataService:
|
||||
timeout=timeout or self.request_timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=callback
|
||||
callback=callback,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
@@ -330,6 +354,7 @@ class BackgroundDataService:
|
||||
self.stats['deduplicated_requests'] = (
|
||||
self.stats.get('deduplicated_requests', 0) + 1
|
||||
)
|
||||
get_fetch_service().note_merged(url, owner)
|
||||
logger.info(
|
||||
"Joined in-flight fetch %s for %s (cache_key=%s) instead of "
|
||||
"starting a duplicate", existing_id, sport, cache_key
|
||||
@@ -357,6 +382,11 @@ class BackgroundDataService:
|
||||
Returns:
|
||||
Fetch result with data or error information
|
||||
"""
|
||||
with plugin_scope(request.owner):
|
||||
return self._fetch_data_worker_scoped(request)
|
||||
|
||||
def _fetch_data_worker_scoped(self, request: FetchRequest) -> FetchResult:
|
||||
"""_fetch_data_worker's body, run with the submitter as the caller."""
|
||||
start_time = time.time()
|
||||
result = FetchResult(request_id=request.id, success=False, retry_count=request.retry_count)
|
||||
|
||||
@@ -621,8 +651,14 @@ class BackgroundDataService:
|
||||
|
||||
for attempt in range(request.max_retries + 1):
|
||||
try:
|
||||
response = self.session.get(
|
||||
# Not shared with an identical request in flight: this
|
||||
# service cancels and replaces fetches, and a replacement
|
||||
# must not join the one it replaced. Its own cache_key
|
||||
# dedup already merges what should be merged.
|
||||
response = fetch_get(
|
||||
self.session,
|
||||
request.url,
|
||||
share_in_flight=False,
|
||||
params=request.params,
|
||||
headers=request.headers,
|
||||
timeout=request.timeout
|
||||
|
||||
@@ -213,8 +213,9 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||
The plugins are the ``manifest.json`` files in the configured plugin
|
||||
directory (see :func:`_plugins_directory`), with the manifest's version;
|
||||
``enabled`` is config.json's flag by the display's rule (a missing flag
|
||||
is disabled). A restore reinstalls every listed plugin and takes enabled
|
||||
state from the restored config.json, so ``enabled`` is informational.
|
||||
is disabled). A restore installs each listed plugin that is missing and
|
||||
takes enabled state from the restored config.json, so ``enabled`` is
|
||||
informational.
|
||||
|
||||
``data/plugin_state.json`` is not read: it only ever repeated config's
|
||||
enabled flags and the manifests' versions, and is retired (nothing
|
||||
|
||||
+33
-14
@@ -19,6 +19,8 @@ import json
|
||||
from typing import Dict, Any, Optional, List, cast
|
||||
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
from src.common.fetch_service import fetch_get, share_connection_pool
|
||||
from src.common.json_body import response_json
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +61,13 @@ class BaseOddsManager:
|
||||
# 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.
|
||||
#
|
||||
# Every scoreboard league manager builds one of these, so the session
|
||||
# mounts the fetch service's shared no-retry adapter: the same single
|
||||
# try, over one connection pool per host for all of them instead of
|
||||
# one pool per instance.
|
||||
self.session = requests.Session()
|
||||
share_connection_pool(self.session, max_retries=0)
|
||||
self.session.headers.update(DEFAULT_HTTP_HEADERS)
|
||||
|
||||
# Configuration with defaults
|
||||
@@ -139,7 +147,7 @@ class BaseOddsManager:
|
||||
if _is_no_odds_marker(cached_data):
|
||||
self.logger.debug("Cached no-odds marker for %s", cache_key)
|
||||
return None
|
||||
self.logger.debug(f"Using cached odds from ESPN for {cache_key}")
|
||||
self.logger.debug("Using cached odds from ESPN for %s", cache_key)
|
||||
return cached_data
|
||||
|
||||
if time.monotonic() < self._skip_network_until:
|
||||
@@ -152,7 +160,7 @@ class BaseOddsManager:
|
||||
self._skip_network_until - time.monotonic())
|
||||
return None
|
||||
|
||||
self.logger.debug(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
|
||||
self.logger.debug("Cache miss - fetching fresh odds from ESPN for %s", cache_key)
|
||||
|
||||
try:
|
||||
# Map league names to ESPN API format
|
||||
@@ -166,23 +174,30 @@ class BaseOddsManager:
|
||||
|
||||
espn_league = league_mapping.get(league, league)
|
||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
||||
self.logger.debug(f"Requesting odds from URL: {url}")
|
||||
self.logger.debug("Requesting odds from URL: %s", url)
|
||||
|
||||
response = self.session.get(url, timeout=self.request_timeout)
|
||||
# The response cache may answer only inside this caller's own
|
||||
# interval, the age at which its cached odds expire anyway.
|
||||
response = fetch_get(self.session, url, timeout=self.request_timeout,
|
||||
cache_max_age=interval)
|
||||
response.raise_for_status()
|
||||
raw_data = response.json()
|
||||
raw_data = response_json(response)
|
||||
|
||||
self._skip_network_until = 0.0 # reachable again
|
||||
|
||||
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
|
||||
# Guarded, not just %-style: the json.dumps argument would still be
|
||||
# built for every response with DEBUG off.
|
||||
if self.logger.isEnabledFor(logging.DEBUG):
|
||||
self.logger.debug("Received raw odds data from ESPN: %s",
|
||||
json.dumps(raw_data, indent=2))
|
||||
|
||||
odds_data = self._extract_espn_data(raw_data)
|
||||
if odds_data:
|
||||
self.logger.debug(f"Successfully extracted odds data: {odds_data}")
|
||||
self.logger.debug("Successfully extracted odds data: %s", odds_data)
|
||||
self.cache_manager.set(cache_key, odds_data, ttl=interval)
|
||||
self.logger.debug(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
|
||||
self.logger.debug("Saved odds data to cache for %s with TTL %ss", cache_key, interval)
|
||||
else:
|
||||
self.logger.debug(f"No odds data available for {cache_key}")
|
||||
self.logger.debug("No odds data available for %s", cache_key)
|
||||
# Cache the absence too, so the game is not re-requested
|
||||
# on every update until the interval passes.
|
||||
self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)
|
||||
@@ -216,12 +231,12 @@ class BaseOddsManager:
|
||||
Returns:
|
||||
Formatted odds data dictionary or None
|
||||
"""
|
||||
self.logger.debug(f"Extracting ESPN odds data. Data keys: {list(data.keys())}")
|
||||
self.logger.debug("Extracting ESPN odds data. Data keys: %s", list(data.keys()))
|
||||
|
||||
if "items" in data and data["items"]:
|
||||
self.logger.debug(f"Found {len(data['items'])} items in odds data")
|
||||
self.logger.debug("Found %d items in odds data", len(data['items']))
|
||||
item = data["items"][0]
|
||||
self.logger.debug(f"First item keys: {list(item.keys())}")
|
||||
self.logger.debug("First item keys: %s", list(item.keys()))
|
||||
|
||||
# The ESPN API returns odds data directly in the item, not in a
|
||||
# providers array. ESPN sends explicit JSON nulls for absent
|
||||
@@ -244,13 +259,17 @@ class BaseOddsManager:
|
||||
.get("pointSpread") or {}).get("value")
|
||||
}
|
||||
}
|
||||
self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}")
|
||||
if self.logger.isEnabledFor(logging.DEBUG):
|
||||
self.logger.debug("Returning extracted odds data: %s",
|
||||
json.dumps(extracted_data, indent=2))
|
||||
return extracted_data
|
||||
|
||||
# Check if this is a valid empty response or an unexpected structure
|
||||
if "count" in data and data["count"] == 0 and "items" in data and data["items"] == []:
|
||||
# This is a valid empty response - no odds available for this game
|
||||
self.logger.debug(f"No odds available for this game. Response: {json.dumps(data, indent=2)}")
|
||||
if self.logger.isEnabledFor(logging.DEBUG):
|
||||
self.logger.debug("No odds available for this game. Response: %s",
|
||||
json.dumps(data, indent=2))
|
||||
return None
|
||||
else:
|
||||
# This is an unexpected response structure
|
||||
|
||||
Vendored
+221
-42
@@ -4,6 +4,7 @@ Disk Cache
|
||||
Handles persistent disk-based caching with atomic writes and error recovery.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
@@ -14,7 +15,7 @@ import tempfile
|
||||
import logging
|
||||
import threading
|
||||
import zlib
|
||||
from typing import Dict, Any, Optional, Protocol
|
||||
from typing import Dict, Any, Optional, Protocol, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
from src.common.path_safety import safe_path_component
|
||||
@@ -31,6 +32,35 @@ except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
||||
# useful, and a half-written file was never useful.
|
||||
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
|
||||
|
||||
# Longest key, in UTF-8 bytes, used verbatim as a filename stem. ext4 caps a
|
||||
# name at 255 bytes and set()'s temp file is ".<stem>.json.<8 random>", 15
|
||||
# bytes longer than the stem, so anything near the cap could never be written:
|
||||
# the calendar plugin's key joins every calendar id and passed 300 bytes on a
|
||||
# real install, failing every write with ENAMETOOLONG. Longer keys keep this
|
||||
# many bytes as a readable prefix and end in a hash of the whole key.
|
||||
_MAX_KEY_FILENAME_BYTES = 200
|
||||
_KEY_HASH_CHARS = 16
|
||||
|
||||
|
||||
def _filename_stem(key: str) -> str:
|
||||
"""The filename stem for a key that is already a safe path component.
|
||||
|
||||
Short keys are used as they are, so every file already on disk keeps its
|
||||
name. A long one becomes its first bytes plus a hash of the full key: the
|
||||
prefix keeps the stem recognisable (and keeps the data-type words that
|
||||
cleanup's retention lookup reads from it), the hash keeps two keys that
|
||||
share a long prefix apart. The result is itself short, so a stem read back
|
||||
from a filename -- which is how the web UI names a key it deletes -- maps to
|
||||
the same file.
|
||||
"""
|
||||
encoded = key.encode('utf-8')
|
||||
if len(encoded) <= _MAX_KEY_FILENAME_BYTES:
|
||||
return key
|
||||
digest = hashlib.sha256(encoded).hexdigest()[:_KEY_HASH_CHARS]
|
||||
keep = _MAX_KEY_FILENAME_BYTES - _KEY_HASH_CHARS - 1
|
||||
prefix = encoded[:keep].decode('utf-8', errors='ignore')
|
||||
return f"{prefix}-{digest}"
|
||||
|
||||
|
||||
|
||||
class CacheStrategyProtocol(Protocol):
|
||||
@@ -111,18 +141,91 @@ _HEAD_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _stale_from_head(head: bytes, max_age: Optional[int], now: float) -> bool:
|
||||
# UNCHANGED RE-SAVES: THE FILE'S MTIME CARRIES THE NEWER TIMESTAMP
|
||||
# ----------------------------------------------------------------
|
||||
# Plugins re-save unchanged API data every update cycle, and every one of
|
||||
# those saves was a full rewrite on the SD card. DiskCache.set skips the write
|
||||
# when the payload matches the last one it wrote for the key -- but
|
||||
# CacheManager.set stamps each record with time.time(), so for set() the
|
||||
# payload never matched and the skip never fired.
|
||||
#
|
||||
# The digest now leaves out a header-first record's timestamp, so an unchanged
|
||||
# set() is skipped. What the skip must not do is make the record look older
|
||||
# than it is: the timestamp inside the file is from the last real write, and
|
||||
# a reader in another process (the web interface, with memory_ttl=0) or after
|
||||
# a restart would call fresh data stale. So the newer timestamp goes where it
|
||||
# costs no data write -- the file's mtime -- and readers take a record's age
|
||||
# from the newer of the two. The invariant that makes that safe:
|
||||
#
|
||||
# a file's mtime is the timestamp of the newest record saved for its key
|
||||
#
|
||||
# real write mtime is set to the record's own timestamp, so a record saved
|
||||
# with an old timestamp (data as of some earlier time) cannot
|
||||
# borrow freshness from the moment it hit the disk
|
||||
# skip mtime is set to the skipped record's timestamp -- exactly what
|
||||
# a rewrite would have stored, without the rewrite
|
||||
#
|
||||
# Readers of the on-disk timestamp, all of which go through _effective_timestamp:
|
||||
# DiskCache.get (the header check and the full parse; it also returns the
|
||||
# record with 'timestamp' set to the effective value, so CacheManager.get's
|
||||
# max_age path, the memory tier hydrated from disk, and any plugin reading
|
||||
# record['timestamp'] all see it). Readers that use mtime alone already see the
|
||||
# newer value: the retention sweep below, CacheManager.list_cache_files (the
|
||||
# web UI's cache list). Nothing else opens cache files: web_interface and
|
||||
# scripts reach them only through CacheManager.
|
||||
#
|
||||
# Something other than this class can also move an mtime forward -- a copy
|
||||
# without -p, an rsync without -t, a `touch`. (backup_manager.py does not
|
||||
# back up or restore the cache directory, so the in-tree restore cannot.) That
|
||||
# must not make old data fresh, so the lift is bounded: a reader never takes
|
||||
# the mtime as more than _MAX_TIMESTAMP_LIFT past the embedded timestamp, and
|
||||
# set() rewrites the file for real once a skip would need more than that, so
|
||||
# an honest lift never reaches the bound. A file copied a day after it was
|
||||
# written therefore reads at most an hour fresher than its contents say, and a
|
||||
# 30-second live-score record from yesterday stays stale. CacheManager.set
|
||||
# records written before this change have mtime == write time == embedded
|
||||
# timestamp, give or take the write itself, and read exactly as before; a
|
||||
# file an older version wrote or touched later than its embedded timestamp
|
||||
# says reads at most the same hour fresher, once, until it is next saved.
|
||||
|
||||
#: Longest a skipped write may stand in for a real one, and so the furthest a
|
||||
#: file's mtime is ever trusted past the record's own timestamp. Unchanged data
|
||||
#: is rewritten at least this often, at most once an hour per key instead of
|
||||
#: once per update cycle.
|
||||
_MAX_TIMESTAMP_LIFT = 3600.0
|
||||
|
||||
|
||||
def _record_timestamp(value: Any) -> Optional[float]:
|
||||
"""A record's timestamp as a finite float, or None if it has no usable one."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
value = float(value)
|
||||
return value if math.isfinite(value) else None
|
||||
|
||||
|
||||
def _effective_timestamp(embedded: float, mtime: Optional[float]) -> float:
|
||||
"""When a record was last saved: its timestamp, or the file's mtime if a
|
||||
later unchanged save moved that forward -- never by more than
|
||||
_MAX_TIMESTAMP_LIFT. See "UNCHANGED RE-SAVES" above."""
|
||||
if mtime is None:
|
||||
return embedded
|
||||
return max(embedded, min(mtime, embedded + _MAX_TIMESTAMP_LIFT))
|
||||
|
||||
|
||||
def _stale_from_head(head: bytes, max_age: Optional[int], now: float,
|
||||
mtime: Optional[float] = None) -> bool:
|
||||
"""True when a record's header alone shows it has expired.
|
||||
|
||||
Mirrors the expiry rule in DiskCache.get: a per-entry ttl wins over the
|
||||
caller's max_age, and no limit at all means never stale. False whenever the
|
||||
header cannot be read, so the full parse decides as it always did.
|
||||
header cannot be read, so the full parse decides as it always did. ``mtime``
|
||||
is the file's, which may carry a newer save than the header does.
|
||||
"""
|
||||
match = _HEAD_RE.match(head)
|
||||
if not match:
|
||||
return False
|
||||
try:
|
||||
timestamp = float(match.group(1))
|
||||
timestamp = _effective_timestamp(float(match.group(1)), mtime)
|
||||
limit = max_age
|
||||
if match.group(2) is not None:
|
||||
ttl = float(match.group(2))
|
||||
@@ -179,7 +282,7 @@ else:
|
||||
# --------------------------------------------
|
||||
# The display service runs as root and the web interface as the installing
|
||||
# user, and the web interface reads records only the display writes
|
||||
# (display_current_state, display_on_demand_state, plugin_metrics:*). Files are
|
||||
# (display_current_state, display_on_demand_state, plugin_metrics_snapshot). Files are
|
||||
# written 0660, so the web interface can read one only through its group.
|
||||
#
|
||||
# The installers rely on the directory's setgid bit to set that group. That is
|
||||
@@ -248,11 +351,14 @@ class DiskCache:
|
||||
self.cache_dir = cache_dir
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self._lock = threading.Lock()
|
||||
# key -> adler32 of the last payload successfully written to the
|
||||
# primary cache path; lets set() skip rewriting identical data
|
||||
# (per-process only — worst case another process rewrites, never
|
||||
# a missed write). Guarded by _lock.
|
||||
self._write_digests: Dict[str, int] = {}
|
||||
# key -> what set() last put at the primary cache path: the adler32 of
|
||||
# the payload (less a header-first timestamp), the timestamp the file
|
||||
# holds (None for records without one), and the file's inode and size.
|
||||
# Lets set() skip rewriting identical data. Per-process only, and the
|
||||
# inode/size check means another process's write is never mistaken
|
||||
# for ours -- worst case a redundant write, never a missed one.
|
||||
# Guarded by _lock.
|
||||
self._write_digests: Dict[str, Tuple[int, Optional[float], int, int]] = {}
|
||||
|
||||
def get_cache_path(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -267,6 +373,8 @@ class DiskCache:
|
||||
derives them), so rejecting anything with a path component turns
|
||||
away only inputs that could never have been written here.
|
||||
|
||||
A key too long to be a filename is shortened by _filename_stem.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
@@ -280,7 +388,7 @@ class DiskCache:
|
||||
if safe_key is None:
|
||||
self.logger.warning("Rejected unsafe cache key %r", key)
|
||||
return None
|
||||
return os.path.join(self.cache_dir, f"{safe_key}.json")
|
||||
return os.path.join(self.cache_dir, f"{_filename_stem(safe_key)}.json")
|
||||
|
||||
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -301,32 +409,41 @@ class DiskCache:
|
||||
try:
|
||||
with self._lock:
|
||||
with open(cache_path, 'rb') as f:
|
||||
# The open file's mtime, not the path's: the file a skipped
|
||||
# write touched is the one being read.
|
||||
mtime = os.fstat(f.fileno()).st_mtime
|
||||
# Decide staleness from the header before paying for the
|
||||
# parse. A stale read is the common case for the biggest
|
||||
# records (a season schedule is re-fetched when its cache
|
||||
# expires), and parsing 53MB to throw it away held the GIL
|
||||
# for ~1.8s -- a visible freeze on the panel.
|
||||
if _stale_from_head(f.read(_HEAD_BYTES), max_age, time.time()):
|
||||
if _stale_from_head(f.read(_HEAD_BYTES), max_age, time.time(), mtime):
|
||||
return None
|
||||
f.seek(0)
|
||||
record = _loads(f.read())
|
||||
|
||||
# Determine record timestamp (prefer embedded, else file mtime)
|
||||
|
||||
# Determine record timestamp: the embedded one, moved forward by a
|
||||
# later unchanged save if there was one (see "UNCHANGED RE-SAVES"),
|
||||
# else the file mtime.
|
||||
record_ts = None
|
||||
if isinstance(record, dict):
|
||||
record_ts = record.get('timestamp')
|
||||
if record_ts is None:
|
||||
try:
|
||||
record_ts = os.path.getmtime(cache_path)
|
||||
except OSError:
|
||||
record_ts = None
|
||||
|
||||
if record_ts is not None:
|
||||
try:
|
||||
record_ts = float(record_ts)
|
||||
except (TypeError, ValueError):
|
||||
record_ts = None
|
||||
|
||||
record_ts = mtime
|
||||
else:
|
||||
embedded_ts = _record_timestamp(record_ts)
|
||||
if embedded_ts is None:
|
||||
try:
|
||||
record_ts = float(record_ts)
|
||||
except (TypeError, ValueError):
|
||||
record_ts = None
|
||||
else:
|
||||
record_ts = _effective_timestamp(embedded_ts, mtime)
|
||||
if record_ts != embedded_ts:
|
||||
# Hand the record back as a rewrite would have left it,
|
||||
# so callers that age it themselves agree with us.
|
||||
record['timestamp'] = record_ts
|
||||
|
||||
now = time.time()
|
||||
|
||||
# An explicit per-entry ttl wins over the caller's max_age. The
|
||||
@@ -403,7 +520,12 @@ class DiskCache:
|
||||
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
||||
return
|
||||
|
||||
digest = zlib.adler32(payload)
|
||||
timestamp = _record_timestamp(data.get('timestamp')) if isinstance(data, dict) else None
|
||||
# A header-first record (CacheManager.set's layout) is compared without
|
||||
# its timestamp, which differs on every save; see "UNCHANGED RE-SAVES".
|
||||
# Any other layout is compared whole, as before.
|
||||
head = _HEAD_RE.match(payload) if timestamp is not None else None
|
||||
digest = zlib.adler32(memoryview(payload)[head.end(1):] if head else payload)
|
||||
|
||||
try:
|
||||
# Atomic write to avoid partial/corrupt files
|
||||
@@ -411,16 +533,10 @@ class DiskCache:
|
||||
# Skip the disk entirely when this exact payload was already
|
||||
# written for this key (plugins re-save unchanged API data
|
||||
# every update cycle — each write is real SD-card wear).
|
||||
# Refresh the file mtime so records that rely on it for TTL
|
||||
# (no embedded 'timestamp') don't expire early; a metadata
|
||||
# touch is journal-cheap compared to rewriting the data.
|
||||
if self._write_digests.get(key) == digest:
|
||||
try:
|
||||
os.utime(cache_path, None)
|
||||
return
|
||||
except OSError:
|
||||
# File vanished or perms changed — fall through and write
|
||||
self._write_digests.pop(key, None)
|
||||
# A metadata touch is journal-cheap compared to rewriting
|
||||
# the data.
|
||||
if self._skip_unchanged(key, cache_path, digest, timestamp):
|
||||
return
|
||||
|
||||
tmp_dir = os.path.dirname(cache_path)
|
||||
# Try to create temp file in cache directory first
|
||||
@@ -458,7 +574,7 @@ class DiskCache:
|
||||
# opened it in between was refused.
|
||||
_share_open_file(tmp_file.fileno(), _shared_group(tmp_dir))
|
||||
os.replace(tmp_path, cache_path)
|
||||
self._write_digests[key] = digest
|
||||
self._remember_write(key, cache_path, digest, timestamp)
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
@@ -471,13 +587,13 @@ class DiskCache:
|
||||
with open(cache_path, 'wb') as cache_file:
|
||||
cache_file.write(payload)
|
||||
_share_open_file(cache_file.fileno(), _shared_group(tmp_dir))
|
||||
self._write_digests[key] = digest
|
||||
self._remember_write(key, cache_path, digest, timestamp)
|
||||
self.logger.debug("Wrote cache for %s directly (non-atomic)", key)
|
||||
except (IOError, OSError, PermissionError) as write_error:
|
||||
# If direct write also fails, try fallback location
|
||||
self.logger.warning("Direct write failed for key '%s' to %s: %s", key, cache_path, write_error)
|
||||
raise # Re-raise to trigger fallback logic
|
||||
except (IOError, OSError, PermissionError):
|
||||
except (IOError, OSError, PermissionError) as primary_error:
|
||||
# Attempt one-time fallback write to user's home cache directory
|
||||
try:
|
||||
# Try user's home cache directory as fallback
|
||||
@@ -503,11 +619,14 @@ class DiskCache:
|
||||
self.logger.debug("Fallback cache write also failed for key '%s': %s", key, e2)
|
||||
|
||||
# If all write attempts failed, log warning but don't raise exception
|
||||
# Cache is a performance optimization, not critical for operation
|
||||
# Cache is a performance optimization, not critical for operation.
|
||||
# Name the real error: this used to say "permission denied"
|
||||
# whatever happened, which sent a too-long filename off to
|
||||
# be debugged as a directory-ownership problem.
|
||||
self.logger.warning(
|
||||
"Could not write cache for key '%s' to %s (permission denied). "
|
||||
"Could not write cache for key '%s' to %s (%s). "
|
||||
"Cache will be unavailable for this key, but application will continue.",
|
||||
key, cache_path
|
||||
key, cache_path, primary_error.strerror or primary_error
|
||||
)
|
||||
return # Exit gracefully without raising exception
|
||||
|
||||
@@ -520,6 +639,66 @@ class DiskCache:
|
||||
)
|
||||
return # Exit gracefully without raising exception
|
||||
|
||||
def _skip_unchanged(self, key: str, cache_path: str, digest: int,
|
||||
timestamp: Optional[float]) -> bool:
|
||||
"""Stand in for a write of an unchanged record by touching the file.
|
||||
|
||||
True when the file already holds this record bar its timestamp and the
|
||||
touch landed; False means write it. The touch sets mtime to the
|
||||
record's timestamp -- what a rewrite would have stored -- or to now
|
||||
for a record without one, whose age readers already take from mtime.
|
||||
Caller holds _lock.
|
||||
"""
|
||||
last = self._write_digests.get(key)
|
||||
if last is None or last[0] != digest:
|
||||
return False
|
||||
_, written_ts, ino, size = last
|
||||
if (timestamp is None) != (written_ts is None):
|
||||
return False
|
||||
if timestamp is not None and written_ts is not None:
|
||||
# Never backwards (a rewrite would make the record older), and
|
||||
# never further than readers will trust the mtime: past that the
|
||||
# record is rewritten, so its own timestamp catches up.
|
||||
if not written_ts <= timestamp <= written_ts + _MAX_TIMESTAMP_LIFT:
|
||||
return False
|
||||
try:
|
||||
st = os.stat(cache_path)
|
||||
if (st.st_ino, st.st_size) != (ino, size):
|
||||
# Replaced since our write (another process, a restore):
|
||||
# its contents are not the ones the digest describes.
|
||||
self._write_digests.pop(key, None)
|
||||
return False
|
||||
# Setting an explicit time needs the file's owner; a file someone
|
||||
# else wrote fails here and is rewritten (as our own file) instead.
|
||||
os.utime(cache_path, None if timestamp is None else (timestamp, timestamp))
|
||||
return True
|
||||
except OSError:
|
||||
# File vanished or perms changed — fall through and write
|
||||
self._write_digests.pop(key, None)
|
||||
return False
|
||||
|
||||
def _remember_write(self, key: str, cache_path: str, digest: int,
|
||||
timestamp: Optional[float]) -> None:
|
||||
"""After a real write: pin mtime to the record's timestamp and note
|
||||
what was written, so the next unchanged save can be skipped.
|
||||
|
||||
Pinning keeps a record saved with an older timestamp from looking as
|
||||
fresh as the moment it was written (see "UNCHANGED RE-SAVES"); for
|
||||
CacheManager.set's records the two differ only by the write itself.
|
||||
A timestamp in the future is left alone, mtime already being older.
|
||||
Never raises: the data is on disk, and anything failing here only
|
||||
costs the next save its skip. Caller holds _lock.
|
||||
"""
|
||||
self._write_digests.pop(key, None)
|
||||
try:
|
||||
if timestamp is not None and timestamp <= time.time():
|
||||
os.utime(cache_path, (timestamp, timestamp))
|
||||
st = os.stat(cache_path)
|
||||
except OSError as e:
|
||||
self.logger.debug("Could not pin mtime of %s: %s", cache_path, e)
|
||||
return
|
||||
self._write_digests[key] = (digest, timestamp, st.st_ino, st.st_size)
|
||||
|
||||
def clear(self, key: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clear cache entry or all entries.
|
||||
|
||||
+145
-270
@@ -25,6 +25,7 @@ Typical plugin usage::
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
@@ -37,13 +38,97 @@ from src.cache.disk_cache import DiskCache
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
from src.cache.cache_metrics import CacheMetrics
|
||||
from src.logging_config import get_logger
|
||||
from src.deprecation import deprecated
|
||||
|
||||
# Canonical implementation lives in src.cache.disk_cache; re-exported here
|
||||
# because this module's docstring documents it and external code may import
|
||||
# it from either path.
|
||||
from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-export
|
||||
|
||||
# CacheManager.config_manager not built yet (None means "not available").
|
||||
_UNSET: Any = object()
|
||||
|
||||
|
||||
def _outlived(record: Any, max_age: Optional[float], now: float) -> bool:
|
||||
"""Whether a record's own timestamp puts it past max_age.
|
||||
|
||||
The memory tier times an entry from when it was put there, and a record
|
||||
loaded from disk is put there when it is read, not when it was written: a
|
||||
record 290 s old, read after a restart, could be served for another
|
||||
max_age from memory. This is the age check DiskCache.get makes, with the
|
||||
same rule that a stored ttl wins over the caller's max_age. A record that
|
||||
carries no timestamp is left to the memory tier's own clock.
|
||||
"""
|
||||
if not isinstance(record, dict):
|
||||
return False
|
||||
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
|
||||
stamp = record.get('timestamp')
|
||||
if max_age is None or stamp is None or isinstance(stamp, bool):
|
||||
return False
|
||||
try:
|
||||
return now - float(stamp) > max_age
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
#: Cache keys that were file "mailboxes" from the web interface (and some
|
||||
#: plugins) to the display. The control socket replaced them, and nothing
|
||||
#: reads them any more, so a write is refused rather than left on the SD card
|
||||
#: for nobody: see :func:`_refuse_retired_mailbox_write`.
|
||||
RETIRED_MAILBOX_KEYS = frozenset({'display_on_demand_request', 'plugin_error_clear_request'})
|
||||
|
||||
#: (key, writer) pairs already warned about, so a plugin that writes on every
|
||||
#: event logs once per process, not once per write.
|
||||
_retired_writers_warned: set = set()
|
||||
_retired_writers_lock = threading.Lock()
|
||||
|
||||
|
||||
def _retired_mailbox_writer(data: Any) -> str:
|
||||
"""Name whoever is writing a retired mailbox key, as well as can be told.
|
||||
|
||||
The plugin instance on the call stack when there is one (a ``self`` with
|
||||
a string ``plugin_id`` and a ``cache_manager``: what BasePlugin gives
|
||||
every plugin), else the ``plugin_id`` the request itself names, else
|
||||
``'unknown'``.
|
||||
"""
|
||||
frame = sys._getframe(2) # pylint: disable=protected-access
|
||||
depth = 0
|
||||
while frame is not None and depth < 25:
|
||||
owner = frame.f_locals.get('self')
|
||||
plugin_id = getattr(owner, 'plugin_id', None) if owner is not None else None
|
||||
if isinstance(plugin_id, str) and plugin_id and hasattr(owner, 'cache_manager'):
|
||||
return f"plugin '{plugin_id}'"
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
payload = data.get('data', data) if isinstance(data, dict) else None
|
||||
named = payload.get('plugin_id') if isinstance(payload, dict) else None
|
||||
if isinstance(named, str) and named:
|
||||
return f"plugin '{named}' (named in the request)"
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _refuse_retired_mailbox_write(logger: logging.Logger, key: str, data: Any) -> None:
|
||||
"""Warn, once per writer, that a write to a retired mailbox key was dropped."""
|
||||
try:
|
||||
writer = _retired_mailbox_writer(data)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
writer = 'unknown'
|
||||
with _retired_writers_lock:
|
||||
if (key, writer) in _retired_writers_warned:
|
||||
return
|
||||
_retired_writers_warned.add((key, writer))
|
||||
if key == 'display_on_demand_request':
|
||||
hint = ("call self.request_on_demand() / self.end_on_demand() instead "
|
||||
"(BasePlugin, LEDMatrix 3.8.1 and later)")
|
||||
else:
|
||||
hint = "clear errors through POST /api/v3/errors/clear instead"
|
||||
logger.warning("Ignored a write to the retired '%s' cache key by %s: the display no "
|
||||
"longer reads this file mailbox. Update it to %s. (Logged once per writer.)",
|
||||
key, writer, hint)
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Manages caching of API responses to reduce API calls."""
|
||||
|
||||
@@ -74,21 +159,19 @@ class CacheManager:
|
||||
self.logger.error("Could not find or create a writable cache directory. Caching will be disabled.")
|
||||
self.cache_dir = None
|
||||
|
||||
# Initialize config manager for sport-specific intervals
|
||||
try:
|
||||
from src.config_manager import ConfigManager
|
||||
self.config_manager: Optional[Any] = ConfigManager()
|
||||
self.config_manager.load_config()
|
||||
except ImportError:
|
||||
self.config_manager: Optional[Any] = None
|
||||
self.logger.warning("ConfigManager not available, using default cache intervals")
|
||||
|
||||
# The config manager is built on first use of self.config_manager; see
|
||||
# the property. Nothing in the cache reads it any more.
|
||||
self._config_manager: Any = _UNSET
|
||||
self._config_manager_lock = threading.Lock()
|
||||
|
||||
# Initialize cache components using composition
|
||||
self._memory_cache_component = MemoryCache(
|
||||
max_size=default_max_size(), cleanup_interval=300.0
|
||||
)
|
||||
self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger)
|
||||
self._strategy_component = CacheStrategy(config_manager=self.config_manager, logger=self.logger)
|
||||
# No config manager: CacheStrategy keeps the parameter for callers but
|
||||
# reads nothing from it, and passing ours would build it eagerly.
|
||||
self._strategy_component = CacheStrategy(logger=self.logger)
|
||||
self._metrics_component = CacheMetrics(logger=self.logger)
|
||||
|
||||
# Disk cleanup configuration
|
||||
@@ -116,6 +199,44 @@ class CacheManager:
|
||||
if self.cache_dir:
|
||||
self.start_cleanup_thread()
|
||||
|
||||
@property
|
||||
def config_manager(self) -> Optional[Any]:
|
||||
"""A loaded ConfigManager, built the first time it is asked for.
|
||||
|
||||
Every CacheManager used to build one and load the whole config in
|
||||
__init__, for a cache strategy that stopped reading it -- startup paid
|
||||
a config load (and the web interface another) per manager for nothing.
|
||||
It is still public: the sports plugins resolve the global timezone and
|
||||
display settings through ``cache_manager.config_manager``, and they get
|
||||
the same object they always did, on first access instead of at
|
||||
construction. None when ConfigManager cannot be imported, as before.
|
||||
Assigning replaces it, as assigning the attribute always did.
|
||||
"""
|
||||
# getattr: a manager made with __new__ (some tests) has no slot yet.
|
||||
value = getattr(self, '_config_manager', _UNSET)
|
||||
if value is not _UNSET:
|
||||
return value
|
||||
lock = getattr(self, '_config_manager_lock', None) or threading.Lock()
|
||||
with lock:
|
||||
value = getattr(self, '_config_manager', _UNSET)
|
||||
if value is _UNSET:
|
||||
try:
|
||||
from src.config_manager import ConfigManager
|
||||
except ImportError:
|
||||
self.logger.warning("ConfigManager not available, using default cache intervals")
|
||||
value = None
|
||||
else:
|
||||
value = ConfigManager()
|
||||
# Raises as it did from __init__; nothing is kept, so the
|
||||
# next access tries again.
|
||||
value.load_config()
|
||||
self._config_manager = value
|
||||
return value
|
||||
|
||||
@config_manager.setter
|
||||
def config_manager(self, value: Optional[Any]) -> None:
|
||||
self._config_manager = value
|
||||
|
||||
def _get_writable_cache_dir(self) -> Optional[str]:
|
||||
"""Tries to find or create a writable cache directory, preferring a system path when available."""
|
||||
# Attempt 1: System-wide persistent cache directory (preferred for services)
|
||||
@@ -231,7 +352,7 @@ class CacheManager:
|
||||
def _get_cache_path(self, key: str) -> Optional[str]:
|
||||
"""Get the path for a cache file."""
|
||||
return self._disk_cache_component.get_cache_path(key)
|
||||
|
||||
|
||||
def get_cached_data(self, key: str, max_age: int = 300, memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache (memory first, then disk) honoring TTLs.
|
||||
|
||||
@@ -246,7 +367,11 @@ class CacheManager:
|
||||
# 1) Memory cache
|
||||
cached = self._memory_cache_component.get(key, max_age=in_memory_ttl)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if not _outlived(cached, max_age, time.time()):
|
||||
return cached
|
||||
# Too old for this reader. Disk may hold a newer write (from the
|
||||
# other process), and if it does not, the miss is the right answer.
|
||||
self._memory_cache_component.clear(key)
|
||||
|
||||
# 2) Disk cache
|
||||
record = self._disk_cache_component.get(key, max_age=max_age)
|
||||
@@ -265,6 +390,10 @@ class CacheManager:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
"""
|
||||
if key in RETIRED_MAILBOX_KEYS:
|
||||
_refuse_retired_mailbox_write(self.logger, key, data)
|
||||
return
|
||||
|
||||
# Periodic cleanup before adding new entries
|
||||
self._cleanup_memory_cache()
|
||||
|
||||
@@ -280,7 +409,9 @@ class CacheManager:
|
||||
# Check memory cache first (1 minute TTL)
|
||||
cached = self._memory_cache_component.get(key, max_age=60)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if not _outlived(cached, 3600, time.time()):
|
||||
return cached
|
||||
self._memory_cache_component.clear(key)
|
||||
|
||||
# Check disk cache
|
||||
data = self._disk_cache_component.get(key, max_age=3600) # 1 hour for load_cache
|
||||
@@ -408,122 +539,6 @@ class CacheManager:
|
||||
"""Get the cache directory path."""
|
||||
return self.cache_dir
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def has_data_changed(self, data_type: str, new_data: Dict[str, Any]) -> bool:
|
||||
"""Check if data has changed from cached version."""
|
||||
cached_data = self.load_cache(data_type)
|
||||
if not cached_data:
|
||||
return True
|
||||
|
||||
if data_type == 'weather':
|
||||
return self._has_weather_changed(cached_data, new_data)
|
||||
elif data_type == 'stocks':
|
||||
return self._has_stocks_changed(cached_data, new_data)
|
||||
elif data_type == 'stock_news':
|
||||
return self._has_news_changed(cached_data, new_data)
|
||||
elif data_type == 'nhl':
|
||||
return self._has_nhl_changed(cached_data, new_data)
|
||||
elif data_type == 'mlb':
|
||||
return self._has_mlb_changed(cached_data, new_data)
|
||||
|
||||
return True
|
||||
|
||||
def _has_weather_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
|
||||
"""Check if weather data has changed."""
|
||||
# Handle new cache structure where data is nested under 'data' key
|
||||
if 'data' in cached:
|
||||
cached = cached['data']
|
||||
|
||||
# Handle case where cached data might be the weather data directly
|
||||
if 'current' in cached:
|
||||
# This is the new structure with 'current' and 'forecast' keys
|
||||
current_weather = cached.get('current', {})
|
||||
if current_weather and 'main' in current_weather and 'weather' in current_weather:
|
||||
cached_temp = round(current_weather['main']['temp'])
|
||||
cached_condition = current_weather['weather'][0]['main']
|
||||
return (cached_temp != new.get('temp') or
|
||||
cached_condition != new.get('condition'))
|
||||
|
||||
# Handle old structure where temp and condition are directly accessible
|
||||
return (cached.get('temp') != new.get('temp') or
|
||||
cached.get('condition') != new.get('condition'))
|
||||
|
||||
def _has_stocks_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
|
||||
"""Check if stock data has changed."""
|
||||
if not self._is_market_open():
|
||||
return False
|
||||
return cached.get('price') != new.get('price')
|
||||
|
||||
def _has_news_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
|
||||
"""Check if news data has changed."""
|
||||
# Handle both dictionary and list formats
|
||||
if isinstance(new, list):
|
||||
# If new data is a list, cached data should also be a list
|
||||
if not isinstance(cached, list):
|
||||
return True
|
||||
# Compare lengths and content
|
||||
if len(cached) != len(new):
|
||||
return True
|
||||
# Compare titles since they're unique enough for our purposes
|
||||
cached_titles = set(item.get('title', '') for item in cached)
|
||||
new_titles = set(item.get('title', '') for item in new)
|
||||
return cached_titles != new_titles
|
||||
else:
|
||||
# Original dictionary format handling
|
||||
cached_headlines = set(h.get('id') for h in cached.get('headlines', []))
|
||||
new_headlines = set(h.get('id') for h in new.get('headlines', []))
|
||||
return not cached_headlines.issuperset(new_headlines)
|
||||
|
||||
def _has_nhl_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
|
||||
"""Check if NHL data has changed."""
|
||||
return (cached.get('game_status') != new.get('game_status') or
|
||||
cached.get('score') != new.get('score'))
|
||||
|
||||
def _has_mlb_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
|
||||
"""Check if MLB game data has changed."""
|
||||
if not cached or not new:
|
||||
return True
|
||||
|
||||
# Check if any games have changed status or score
|
||||
for game_id, new_game in new.items():
|
||||
cached_game = cached.get(game_id)
|
||||
if not cached_game:
|
||||
return True
|
||||
|
||||
# Check for score changes
|
||||
if (new_game['away_score'] != cached_game['away_score'] or
|
||||
new_game['home_score'] != cached_game['home_score']):
|
||||
return True
|
||||
|
||||
# Check for status changes
|
||||
if new_game['status'] != cached_game['status']:
|
||||
return True
|
||||
|
||||
# For live games, check inning and count
|
||||
if new_game['status'] == 'in':
|
||||
if (new_game['inning'] != cached_game['inning'] or
|
||||
new_game['inning_half'] != cached_game['inning_half'] or
|
||||
new_game['balls'] != cached_game['balls'] or
|
||||
new_game['strikes'] != cached_game['strikes'] or
|
||||
new_game['bases_occupied'] != cached_game['bases_occupied']):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_market_open(self) -> bool:
|
||||
"""Check if the US stock market is currently open."""
|
||||
return self._strategy_component.is_market_open()
|
||||
|
||||
@deprecated("3.8.0", "use set()")
|
||||
def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
|
||||
"""Update cache with new data."""
|
||||
cache_data = {
|
||||
# Header first; see DiskCache's stale check.
|
||||
'timestamp': time.time(),
|
||||
'data': data,
|
||||
}
|
||||
return self.save_cache(data_type, cache_data)
|
||||
|
||||
def get(self, key: str, max_age: Optional[int] = 300,
|
||||
memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache if it exists and is not stale.
|
||||
@@ -564,42 +579,6 @@ class CacheManager:
|
||||
cache_data['data'] = data
|
||||
self.save_cache(key, cache_data)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def setup_persistent_cache(self) -> bool:
|
||||
"""
|
||||
Set up a persistent cache directory with proper permissions.
|
||||
This should be run once with sudo to create the directory.
|
||||
"""
|
||||
try:
|
||||
# Try to create /var/cache/ledmatrix with proper permissions
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_cache_dir_mode
|
||||
)
|
||||
cache_dir = '/var/cache/ledmatrix'
|
||||
cache_dir_path = Path(cache_dir)
|
||||
ensure_directory_permissions(cache_dir_path, get_cache_dir_mode())
|
||||
|
||||
# Set ownership to the real user (not root)
|
||||
real_user = os.environ.get('SUDO_USER')
|
||||
if real_user:
|
||||
import pwd
|
||||
try:
|
||||
uid = pwd.getpwnam(real_user).pw_uid
|
||||
gid = pwd.getpwnam(real_user).pw_gid
|
||||
os.chown(cache_dir, uid, gid)
|
||||
self.logger.info(f"Set ownership of {cache_dir} to {real_user}")
|
||||
except (OSError, KeyError) as e:
|
||||
self.logger.warning(f"Could not set ownership for {cache_dir}: {e}", exc_info=True)
|
||||
|
||||
self.logger.info(f"Successfully set up persistent cache directory: {cache_dir}")
|
||||
return True
|
||||
|
||||
except (OSError, IOError, PermissionError) as e:
|
||||
self.logger.error(f"Failed to set up persistent cache directory {cache_dir}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def cleanup_disk_cache(self, force: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Clean up expired disk cache files based on retention policies.
|
||||
@@ -776,14 +755,6 @@ class CacheManager:
|
||||
else:
|
||||
self.logger.info("Disk cache cleanup thread stopped successfully")
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_sport_live_interval(self, sport_key: str) -> int:
|
||||
"""
|
||||
Get the live_update_interval for a specific sport from config.
|
||||
Falls back to default values if config is not available.
|
||||
"""
|
||||
return self._strategy_component.get_sport_live_interval(sport_key)
|
||||
|
||||
def get_cache_strategy(self, data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache strategy for different data types.
|
||||
@@ -798,13 +769,6 @@ class CacheManager:
|
||||
"""
|
||||
return self._strategy_component.get_data_type_from_key(key)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Extract sport key from cache key to determine appropriate live_update_interval.
|
||||
"""
|
||||
return self._strategy_component.get_sport_key_from_cache_key(key)
|
||||
|
||||
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get data from cache using data-type-specific strategy.
|
||||
@@ -838,58 +802,6 @@ class CacheManager:
|
||||
data_type = self.get_data_type_from_key(key)
|
||||
return self.get_cached_data_with_strategy(key, data_type)
|
||||
|
||||
@deprecated("3.8.0", "use get()")
|
||||
def get_background_cached_data(self, key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get data from background service cache with appropriate strategy.
|
||||
This method is specifically designed for Recent/Upcoming managers
|
||||
to use data cached by the background service.
|
||||
|
||||
Args:
|
||||
key: Cache key to retrieve
|
||||
sport_key: Sport key for determining appropriate cache strategy
|
||||
|
||||
Returns:
|
||||
Cached data if available and fresh, None otherwise
|
||||
"""
|
||||
# Determine the appropriate cache strategy
|
||||
data_type = self.get_data_type_from_key(key)
|
||||
strategy = self.get_cache_strategy(data_type, sport_key)
|
||||
|
||||
# For Recent/Upcoming managers, we want to use the background service cache
|
||||
# which should have longer TTLs than the individual manager caches
|
||||
max_age = strategy['max_age']
|
||||
memory_ttl = strategy.get('memory_ttl', max_age)
|
||||
|
||||
# Get the cached data
|
||||
cached_data = self.get_cached_data(key, max_age, memory_ttl)
|
||||
|
||||
if cached_data:
|
||||
# Record cache hit for performance monitoring
|
||||
self.record_cache_hit('background')
|
||||
# Unwrap if stored in { 'data': ..., 'timestamp': ... } format
|
||||
if isinstance(cached_data, dict) and 'data' in cached_data:
|
||||
return cached_data['data']
|
||||
return cached_data
|
||||
|
||||
# Record cache miss for performance monitoring
|
||||
self.record_cache_miss('background')
|
||||
return None
|
||||
|
||||
@deprecated("3.8.0", "use get()")
|
||||
def is_background_data_available(self, key: str, sport_key: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if background service has fresh data available.
|
||||
This helps Recent/Upcoming managers determine if they should
|
||||
wait for background data or fetch immediately.
|
||||
"""
|
||||
data_type = self.get_data_type_from_key(key)
|
||||
strategy = self.get_cache_strategy(data_type, sport_key)
|
||||
|
||||
# Check if we have data that's still fresh according to background service TTL
|
||||
cached_data = self.get_cached_data(key, strategy['max_age'])
|
||||
return cached_data is not None
|
||||
|
||||
def generate_sport_cache_key(self, sport: str, date_str: Optional[str] = None) -> str:
|
||||
"""
|
||||
Centralized cache key generation for sports data.
|
||||
@@ -906,45 +818,8 @@ class CacheManager:
|
||||
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
|
||||
return f"{sport}_{date_str}"
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def record_cache_hit(self, cache_type: str = 'regular') -> None:
|
||||
"""Record a cache hit for performance monitoring."""
|
||||
self._metrics_component.record_hit(cache_type)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def record_cache_miss(self, cache_type: str = 'regular') -> None:
|
||||
"""Record a cache miss for performance monitoring."""
|
||||
self._metrics_component.record_miss(cache_type)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def record_fetch_time(self, duration: float) -> None:
|
||||
"""Record fetch operation duration for performance monitoring."""
|
||||
self._metrics_component.record_fetch_time(duration)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_cache_metrics(self) -> Dict[str, Any]:
|
||||
"""Get current cache performance metrics."""
|
||||
return self._metrics_component.get_metrics()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def log_cache_metrics(self) -> None:
|
||||
"""Log current cache performance metrics."""
|
||||
self._metrics_component.log_metrics()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_memory_cache_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the memory cache.
|
||||
|
||||
Returns:
|
||||
Dictionary with memory cache statistics
|
||||
"""
|
||||
return self._memory_cache_component.get_stats()
|
||||
|
||||
def log_memory_cache_stats(self) -> None:
|
||||
"""Log current memory cache statistics."""
|
||||
# Not get_memory_cache_stats(): that is deprecated, and core must not
|
||||
# trip its own deprecation warning every time memory logging runs.
|
||||
stats = self._memory_cache_component.get_stats()
|
||||
self.logger.info(f"Memory Cache - Size: {stats['size']}/{stats['max_size']} "
|
||||
f"({stats['usage_percent']:.1f}%), "
|
||||
|
||||
+125
-5
@@ -17,7 +17,9 @@ Rules for the package:
|
||||
- `from src.common import ...` re-exports `APIHelper`, `ScrollHelper`,
|
||||
`LogoHelper`, `TextHelper`, `scroll_config` (plus `ScrollSettings`,
|
||||
`configure_scroll`, `resolve_scroll_settings`, `refresh_hz_from_config`) and
|
||||
the adaptive layout names below ([`__init__.py`](__init__.py)).
|
||||
the adaptive layout names below ([`__init__.py`](__init__.py)). Each is
|
||||
imported on first use, so `import src.common` or a submodule import stays
|
||||
cheap; add a new re-export to `_LAZY` there as well as `__all__`.
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -27,6 +29,7 @@ Rules for the package:
|
||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
|
||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
|
||||
| [`fetch_service`](#fetch_service) | Pooled, merged, budgeted and counted HTTP for core fetch paths | No, core-internal (reached through `api_helper` and `espn_dates`) | n/a |
|
||||
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
|
||||
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | 3.5.0 |
|
||||
@@ -40,11 +43,18 @@ Rules for the package:
|
||||
| [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | 3.7.0 |
|
||||
| [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | 3.7.0 |
|
||||
| [`sports_display_rules`](#sports_display_rules) | Which games a scoreboard shows, for how long, and its scorebug date line | Yes (scoreboards) | 3.8.0 |
|
||||
| [`sports_favorites`](#sports_favorites) | Which games involve a favourite team, and the favourites-only picks | Yes (scoreboards) | 3.8.2 |
|
||||
| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 |
|
||||
| [`sports_font_path`](#sports_font_path) | Find a scoreboard's bundled font whatever the cwd | Yes (scoreboards) | 3.8.0 |
|
||||
| [`sports_game_over`](#sports_game_over) | Whether a game ESPN still lists as live has ended | Yes (scoreboards) | 3.8.1 |
|
||||
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
|
||||
| [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | 3.8.0 |
|
||||
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
|
||||
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
|
||||
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
||||
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
||||
@@ -101,9 +111,14 @@ and the plugin test harness all use it. Most plugins get BDF text through
|
||||
[`espn_dates.py`](espn_dates.py). ESPN's site API rejects `dates=` ranges
|
||||
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
|
||||
splits a range into month and day requests ESPN accepts and merges the
|
||||
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
||||
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
||||
Scoreboard plugins also bundle a copy for older cores.
|
||||
results; `espn_date_chunks()`, `espn_request_chunks()`,
|
||||
`fetch_espn_date_chunks()`, `clamp_espn_limit()` and
|
||||
`merge_scoreboard_payloads()` are the pieces. A window's partial edge months
|
||||
are asked whole and trimmed to its days (US Eastern), and chunk requests share
|
||||
one process-wide cap of `ESPN_CHUNK_WORKERS` in flight.
|
||||
Every request goes through [`fetch_service`](#fetch_service), the chunks
|
||||
counted against the plugin that asked. Scoreboard plugins also bundle a copy
|
||||
for older cores.
|
||||
|
||||
### favorite_team_check
|
||||
|
||||
@@ -116,6 +131,23 @@ says the league has nothing on yet; `reset()` re-arms it after a config edit.
|
||||
Diagnostics only: every failure is swallowed. Scoreboard plugins also bundle
|
||||
a copy for older cores.
|
||||
|
||||
### fetch_service
|
||||
|
||||
[`fetch_service.py`](fetch_service.py). Core-internal for now. Every core
|
||||
fetch path -- `APIHelper.get`/`post`, `espn_dates` (so every scoreboard's
|
||||
ESPN scoreboard fetch and `SportsFetchMixin`), `BackgroundDataService` and
|
||||
`BaseOddsManager` -- calls `fetch_get(session, url, ...)` instead of
|
||||
`session.get(url, ...)`. Same arguments, return value and exceptions; on top
|
||||
it shares one connection pool per host per retry policy
|
||||
(`share_connection_pool`), merges identical GETs in flight, applies per-host
|
||||
token buckets (`fetch_service.rate_limits` in config.json; ESPN gets 20/s,
|
||||
burst 200), revalidates with server-sent `ETag`/`Last-Modified` and counts
|
||||
requests per plugin and per host. The display publishes the counters
|
||||
(`FetchStatsPublisher`) for `GET /api/v3/plugins/fetch-stats`. Which plugin
|
||||
made a request comes from `plugin_scope()`, set by the plugin executor, or
|
||||
else from the plugin directory on the stack. See
|
||||
[docs/PLUGIN_API_REFERENCE.md](../../docs/PLUGIN_API_REFERENCE.md#fetching-data).
|
||||
|
||||
### font_layout
|
||||
|
||||
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
|
||||
@@ -206,7 +238,8 @@ rather than the `set_*` methods. Vegas mode reads a plugin's
|
||||
[`snapshot_policy.py`](snapshot_policy.py). Core-internal. `decide()`
|
||||
tells `DisplayManager` whether to write `/tmp/led_matrix_preview.png`, only
|
||||
touch its mtime, or skip, based on whether a browser is watching the preview.
|
||||
The web health check reads the file's age.
|
||||
The web health check reads the file's age, and the web preview stream checks
|
||||
its mtime every `VIEWER_POLL_INTERVAL`.
|
||||
|
||||
### sports_card
|
||||
|
||||
@@ -238,6 +271,29 @@ The colour helpers are free functions (`logo_palette()`, `lift_color()`,
|
||||
`mix_color()`, ...). Deciding *when* to celebrate stays in the plugin, which
|
||||
builds the celebration dict the docstring describes.
|
||||
|
||||
### sports_display_rules
|
||||
|
||||
[`sports_display_rules.py`](sports_display_rules.py). Two `SportsCore`
|
||||
mixins: `SportsCardOptionsMixin` (`_card_option()`, which never lets the
|
||||
upcoming scorebug lose both its date and time, and `_recent_date_text()`;
|
||||
list it before `SportsCoreSharedMixin`) and `SportsGameRulesMixin`
|
||||
(`_filtered_or_all()`, the no-favourites quality filter that fails open, and
|
||||
`_effective_live_duration()`, the shorter dwell for a non-favourite live
|
||||
game).
|
||||
|
||||
### sports_favorites
|
||||
|
||||
[`sports_favorites.py`](sports_favorites.py). Sports family 6, one mixin per
|
||||
class that carried the methods: `SportsFavoritesMixin` (`SportsCore`:
|
||||
`_is_favorite_game(game)` and `_favorite_code(value)`),
|
||||
`SportsUpcomingFavoritesMixin` (`_select_games_for_display`) and
|
||||
`SportsRecentFavoritesMixin` (`_select_recent_games_for_display`). Each side
|
||||
of a game is named by `_favorite_key` (from `SportsHelpersMixin`; NRL
|
||||
overrides it with the team id) and compared with `favorite_teams` stripped and
|
||||
upper-cased. The selection methods give each favourite up to the per-team
|
||||
limit, count a game between two favourites for both, and treat only games
|
||||
with an id as possible duplicates.
|
||||
|
||||
### sports_fetch
|
||||
|
||||
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
|
||||
@@ -246,6 +302,23 @@ methods that decide which requests a scoreboard makes --
|
||||
`_background_fetches_espn_ranges()`, `_needs_previous_day()` (the live
|
||||
lookback) and `_wants_live_odds()` (odds only for games near the screen).
|
||||
|
||||
### sports_font_path
|
||||
|
||||
[`sports_font_path.py`](sports_font_path.py). `resolve_font_path(path)`: the
|
||||
path as given when it exists (relative to the cwd), else
|
||||
`font_layout.resolve_asset_path(path)`. What the scoreboards'
|
||||
`_resolve_font_path` copies return on a core that ships it.
|
||||
|
||||
### sports_game_over
|
||||
|
||||
[`sports_game_over.py`](sports_game_over.py). `SportsGameOverMixin`:
|
||||
`_is_game_really_over(game)`, the `SportsLive` check that drops a game ESPN
|
||||
still lists as live (`SportsLiveSharedMixin._detect_stale_games` calls it).
|
||||
Over on a final period text, or on a 0:00 clock from period `FINAL_PERIOD`
|
||||
on unless the score is level. `FINAL_PERIOD` is a class attribute the host
|
||||
sets per sport; the default `None` means the clock never ends a game. List
|
||||
it before `SportsLiveSharedMixin`.
|
||||
|
||||
### sports_game_renderer
|
||||
|
||||
[`sports_game_renderer.py`](sports_game_renderer.py).
|
||||
@@ -263,6 +336,25 @@ what differs.
|
||||
`_odds_color` and `_upcoming_date_and_time_text` under their existing names.
|
||||
Nothing in core uses it.
|
||||
|
||||
### sports_live_scroll
|
||||
|
||||
[`sports_live_scroll.py`](sports_live_scroll.py). `SportsLiveScrollMixin`:
|
||||
keeps a live scroll strip current. It fingerprints the live games (the clock
|
||||
and the display pipeline's own keys excluded, via the host's
|
||||
`LIVE_VOLATILE_FIELDS`), rebuilds when they change, rate-limited by what a
|
||||
rebuild costs, and `_preserving_scroll_position()` keeps the marquee where
|
||||
it was. Pairs with `SportsPluginHostMixin`, whose `_dispatch_switch_refresh()`
|
||||
it uses.
|
||||
|
||||
### sports_plugin_host
|
||||
|
||||
[`sports_plugin_host.py`](sports_plugin_host.py). `SportsPluginHostMixin`:
|
||||
helpers of a scoreboard's `BasePlugin` subclass. `get_vegas_priority_weight()`
|
||||
(more Vegas slots while a favourite plays, found across every plugin's data
|
||||
shape), `_dispatch_switch_refresh()` (a manager refresh on a daemon thread, so
|
||||
`display()` never waits on the network), `get_vegas_content_type()` and small
|
||||
dynamic-duration helpers. List it before `BasePlugin`.
|
||||
|
||||
### sports_scroll
|
||||
|
||||
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
|
||||
@@ -270,6 +362,10 @@ Nothing in core uses it.
|
||||
scoreboards share (Vegas items, dynamic duration, frame loop), paced through
|
||||
`scroll_config`. Subclasses supply `prepare_scroll_content()` and set
|
||||
`SCROLL_LEAGUE_KEYS`; see the module docstring for an example.
|
||||
`prepare_and_display()` rewinds a recent or upcoming strip whose games,
|
||||
rankings, config, panel size and date are unchanged instead of calling
|
||||
`prepare_scroll_content()` again, with one display per slate (game type and
|
||||
leagues).
|
||||
|
||||
### sports_shared
|
||||
|
||||
@@ -280,6 +376,17 @@ fonts, colours, dates, the switch-mode upcoming card). The docstring lists
|
||||
the attributes the host class must have and the three methods deliberately
|
||||
left out.
|
||||
|
||||
### sports_vegas
|
||||
|
||||
[`sports_vegas.py`](sports_vegas.py). What a scoreboard needs for live Vegas
|
||||
cards (one element per game, swapped in place while it scrolls):
|
||||
`game_key()`, `game_fingerprint()`, `dedupe_games()`, `VegasCardCache` (draws
|
||||
a card only when its fingerprint changes), `StickyOdds` (keeps a card's odds
|
||||
through a live poll that left them out), and `finished_games()` /
|
||||
`with_finished_games()` (a game that just went final keeps its card, showing
|
||||
FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts
|
||||
them together; a scoreboard not built on it (UFC) uses them directly.
|
||||
|
||||
### sports_timezone
|
||||
|
||||
[`sports_timezone.py`](sports_timezone.py).
|
||||
@@ -308,6 +415,19 @@ Created by `DisplayController`; works with any plugin.
|
||||
`get_text_dimensions()`, `center_text()`, `wrap_text()`,
|
||||
`draw_multiline_text()`, `create_text_image()`.
|
||||
|
||||
`draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, 0),
|
||||
offsets=OUTLINE_SQUARE)` (3.8.1) draws the text in `outline_color` at
|
||||
each offset, then in `fill` on top: the same pixels as one `draw.text` per
|
||||
offset, but the string is rasterized once. `OUTLINE_SQUARE` is the
|
||||
eight-sided one-pixel outline the scoreboards draw, `OUTLINE_CROSS` the
|
||||
four-sided one. Fractional coordinates (a whole-pixel float such as `52.0`
|
||||
is fine), multiline text, fonts other than a plain `FreeTypeFont`, image modes
|
||||
other than RGB, RGBA and L, and a subclassed or replaced `draw.text` take
|
||||
the `draw.text` loop unchanged. `TextHelper.draw_text_with_outline()` and
|
||||
the scoreboards' `SportsCoreSharedMixin._draw_text_with_outline()` use it.
|
||||
A plugin that also runs on older cores should guard the import and keep its
|
||||
own loop as the fallback.
|
||||
|
||||
## Logging
|
||||
|
||||
Modules here create their logger with `logging.getLogger(__name__)`, which is
|
||||
|
||||
+103
-36
@@ -6,45 +6,90 @@ This package provides reusable functionality for plugins and core modules:
|
||||
- Logo helpers
|
||||
- Text/scroll helpers
|
||||
- Adaptive layout and image helpers
|
||||
|
||||
The names below are imported on first use (PEP 562), not when the package is
|
||||
imported. ``from src.common import ScrollHelper`` and
|
||||
``src.common.ScrollHelper`` work as before and return the same objects, but
|
||||
``import src.common`` -- or importing any submodule, such as
|
||||
``src.common.path_safety`` -- no longer loads numpy, requests and freetype
|
||||
along with every helper. The web interface imports src.common only for a few
|
||||
small modules and never needs those.
|
||||
"""
|
||||
|
||||
# Export commonly used utilities
|
||||
from src.common.api_helper import APIHelper
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.common import scroll_config
|
||||
from src.common.scroll_config import (
|
||||
ScrollSettings,
|
||||
configure as configure_scroll,
|
||||
resolve as resolve_scroll_settings,
|
||||
refresh_hz_from_config,
|
||||
)
|
||||
from src.common.logo_helper import LogoHelper
|
||||
from src.common.text_helper import TextHelper
|
||||
import importlib
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Adaptive layout & images (canonical homes: src.adaptive_layout /
|
||||
# src.adaptive_images — re-exported here so plugin authors find them in the
|
||||
# blessed-helpers package). See docs/ADAPTIVE_LAYOUT.md.
|
||||
from src.adaptive_layout import (
|
||||
Region,
|
||||
LayoutContext,
|
||||
FontStep,
|
||||
FontLadder,
|
||||
LADDER_GRID,
|
||||
LADDER_ARCADE,
|
||||
FitResult,
|
||||
draw_fitted_text,
|
||||
ScoreboardRegions,
|
||||
scoreboard_regions,
|
||||
MediaRow,
|
||||
media_row,
|
||||
)
|
||||
from src.adaptive_images import (
|
||||
ImageFitResult,
|
||||
fit_image,
|
||||
draw_fitted_image,
|
||||
RESAMPLE_LANCZOS,
|
||||
RESAMPLE_NEAREST,
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
# What mypy and editors see: the real names and their types.
|
||||
from src.common.api_helper import APIHelper
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.common import scroll_config
|
||||
from src.common.scroll_config import (
|
||||
ScrollSettings,
|
||||
configure as configure_scroll,
|
||||
resolve as resolve_scroll_settings,
|
||||
refresh_hz_from_config,
|
||||
)
|
||||
from src.common.logo_helper import LogoHelper
|
||||
from src.common.text_helper import TextHelper
|
||||
|
||||
# Adaptive layout & images (canonical homes: src.adaptive_layout /
|
||||
# src.adaptive_images — re-exported here so plugin authors find them in the
|
||||
# blessed-helpers package). See docs/ADAPTIVE_LAYOUT.md.
|
||||
from src.adaptive_layout import (
|
||||
Region,
|
||||
LayoutContext,
|
||||
FontStep,
|
||||
FontLadder,
|
||||
LADDER_GRID,
|
||||
LADDER_ARCADE,
|
||||
FitResult,
|
||||
draw_fitted_text,
|
||||
ScoreboardRegions,
|
||||
scoreboard_regions,
|
||||
MediaRow,
|
||||
media_row,
|
||||
)
|
||||
from src.adaptive_images import (
|
||||
ImageFitResult,
|
||||
fit_image,
|
||||
draw_fitted_image,
|
||||
RESAMPLE_LANCZOS,
|
||||
RESAMPLE_NEAREST,
|
||||
)
|
||||
|
||||
#: Exported name -> (module it lives in, attribute name there). An attribute
|
||||
#: of None means the name is the module itself. Keep in step with the
|
||||
#: TYPE_CHECKING imports above and with __all__.
|
||||
_LAZY: Dict[str, Tuple[str, Optional[str]]] = {
|
||||
'APIHelper': ('src.common.api_helper', 'APIHelper'),
|
||||
'ScrollHelper': ('src.common.scroll_helper', 'ScrollHelper'),
|
||||
'scroll_config': ('src.common.scroll_config', None),
|
||||
'ScrollSettings': ('src.common.scroll_config', 'ScrollSettings'),
|
||||
'configure_scroll': ('src.common.scroll_config', 'configure'),
|
||||
'resolve_scroll_settings': ('src.common.scroll_config', 'resolve'),
|
||||
'refresh_hz_from_config': ('src.common.scroll_config', 'refresh_hz_from_config'),
|
||||
'LogoHelper': ('src.common.logo_helper', 'LogoHelper'),
|
||||
'TextHelper': ('src.common.text_helper', 'TextHelper'),
|
||||
# adaptive layout & images
|
||||
'Region': ('src.adaptive_layout', 'Region'),
|
||||
'LayoutContext': ('src.adaptive_layout', 'LayoutContext'),
|
||||
'FontStep': ('src.adaptive_layout', 'FontStep'),
|
||||
'FontLadder': ('src.adaptive_layout', 'FontLadder'),
|
||||
'LADDER_GRID': ('src.adaptive_layout', 'LADDER_GRID'),
|
||||
'LADDER_ARCADE': ('src.adaptive_layout', 'LADDER_ARCADE'),
|
||||
'FitResult': ('src.adaptive_layout', 'FitResult'),
|
||||
'draw_fitted_text': ('src.adaptive_layout', 'draw_fitted_text'),
|
||||
'ScoreboardRegions': ('src.adaptive_layout', 'ScoreboardRegions'),
|
||||
'scoreboard_regions': ('src.adaptive_layout', 'scoreboard_regions'),
|
||||
'MediaRow': ('src.adaptive_layout', 'MediaRow'),
|
||||
'media_row': ('src.adaptive_layout', 'media_row'),
|
||||
'ImageFitResult': ('src.adaptive_images', 'ImageFitResult'),
|
||||
'fit_image': ('src.adaptive_images', 'fit_image'),
|
||||
'draw_fitted_image': ('src.adaptive_images', 'draw_fitted_image'),
|
||||
'RESAMPLE_LANCZOS': ('src.adaptive_images', 'RESAMPLE_LANCZOS'),
|
||||
'RESAMPLE_NEAREST': ('src.adaptive_images', 'RESAMPLE_NEAREST'),
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
'APIHelper',
|
||||
@@ -75,3 +120,25 @@ __all__ = [
|
||||
'RESAMPLE_LANCZOS',
|
||||
'RESAMPLE_NEAREST',
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Import an exported name on first access (PEP 562).
|
||||
|
||||
Only called for names not already in the module namespace, so after the
|
||||
first access the cached value below is returned directly. Unknown names
|
||||
raise AttributeError, which ``from src.common import <submodule>`` relies
|
||||
on to fall through to importing the submodule.
|
||||
"""
|
||||
try:
|
||||
module_name, attr = _LAZY[name]
|
||||
except KeyError:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
||||
module = importlib.import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import -- module_name comes from the fixed _LAZY table
|
||||
value = module if attr is None else getattr(module, attr)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> List[str]:
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
|
||||
+63
-22
@@ -10,11 +10,17 @@ import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT
|
||||
from src.common.espn_dates import (
|
||||
ESPN_MAX_LIMIT,
|
||||
espn_scoreboard_cache_key,
|
||||
read_espn_scoreboard_cache,
|
||||
store_espn_scoreboard_cache,
|
||||
)
|
||||
from src.common.fetch_service import fetch_get, fetch_post, share_connection_pool
|
||||
from src.common.json_body import response_json
|
||||
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -45,7 +51,11 @@ class APIHelper:
|
||||
|
||||
- Requests go through one ``requests.Session`` that retries GET, HEAD
|
||||
and OPTIONS on 429 and 5xx with exponential backoff, and sends
|
||||
:data:`DEFAULT_HTTP_HEADERS`.
|
||||
:data:`DEFAULT_HTTP_HEADERS`. Its connection pool is shared with every
|
||||
other helper using the same retry policy, and requests go through the
|
||||
core fetch service (``src/common/fetch_service.py``): identical GETs in
|
||||
flight are merged, hosts with a budget are paced, and requests are
|
||||
counted per plugin. Return values and errors are unchanged.
|
||||
- Consecutive requests from one helper are spaced at least
|
||||
``set_rate_limit()`` seconds apart (1 second by default). A cache hit
|
||||
does not count.
|
||||
@@ -81,9 +91,10 @@ class APIHelper:
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["GET", "HEAD", "OPTIONS"]
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.mount("http://", adapter)
|
||||
# The shared adapter for this retry policy: the same retries as a
|
||||
# private HTTPAdapter(max_retries=retry_strategy), with the connection
|
||||
# pool shared by every helper (fetch_service).
|
||||
share_connection_pool(self.session, retry_strategy)
|
||||
|
||||
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
|
||||
|
||||
@@ -112,6 +123,14 @@ class APIHelper:
|
||||
Returns:
|
||||
Response data as dictionary or None if request fails
|
||||
"""
|
||||
return self._get(url, params, headers, timeout, cache_key, cache_ttl,
|
||||
cache_ttl if cache_key else None)
|
||||
|
||||
def _get(self, url: str, params: Optional[Dict], headers: Optional[Dict],
|
||||
timeout: Optional[int], cache_key: Optional[str], cache_ttl: int,
|
||||
cache_max_age: Optional[float]) -> Optional[Dict]:
|
||||
""":meth:`get`, saying how old a response the fetch service's short
|
||||
response cache may hand back (``cache_max_age``, the caller's TTL)."""
|
||||
if cache_key and self.cache_manager:
|
||||
cached = self._get_from_cache(cache_key, cache_ttl)
|
||||
if cached is not None:
|
||||
@@ -128,16 +147,18 @@ class APIHelper:
|
||||
request_headers.update(headers)
|
||||
|
||||
# Make request
|
||||
response = self.session.get(
|
||||
response = fetch_get(
|
||||
self.session,
|
||||
url,
|
||||
params=params,
|
||||
headers=request_headers,
|
||||
timeout=timeout or self.default_timeout
|
||||
timeout=timeout or self.default_timeout,
|
||||
cache_max_age=cache_max_age,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse JSON response
|
||||
data: Dict[Any, Any] = response.json()
|
||||
data: Dict[Any, Any] = response_json(response)
|
||||
|
||||
# Cache response if cache key provided
|
||||
if cache_key and self.cache_manager:
|
||||
@@ -161,22 +182,23 @@ class APIHelper:
|
||||
sport: Sport name (e.g., 'basketball', 'football')
|
||||
league: League name (e.g., 'nba', 'nfl')
|
||||
date: Date in YYYYMMDD format (defaults to today)
|
||||
cache_key: Cache key for response
|
||||
cache_ttl: Cache time-to-live in seconds
|
||||
|
||||
cache_key: Cache key for response. By default the canonical
|
||||
``espn_scoreboard_cache_key(sport, league, date)``, shared
|
||||
with every other consumer of this scoreboard, with the key
|
||||
this used before (``espn_{sport}_{league}_{date}``) read as a
|
||||
fallback for one release. An explicit key works as before.
|
||||
cache_ttl: Cache time-to-live in seconds. A shared entry is
|
||||
returned only while it is at most this old.
|
||||
|
||||
Returns:
|
||||
ESPN API response data or None if request fails
|
||||
"""
|
||||
if date is None:
|
||||
date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
|
||||
# Build URL
|
||||
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard"
|
||||
|
||||
# Build cache key if not provided
|
||||
if cache_key is None:
|
||||
cache_key = f"espn_{sport}_{league}_{date}"
|
||||
|
||||
|
||||
# Set parameters
|
||||
# limit above 500 makes ESPN truncate instead of erroring: college
|
||||
# football came back with 25 of 68 games. See src/common/espn_dates.py.
|
||||
@@ -184,8 +206,26 @@ class APIHelper:
|
||||
'dates': date,
|
||||
'limit': ESPN_MAX_LIMIT
|
||||
}
|
||||
|
||||
return self.get(url, params=params, cache_key=cache_key, cache_ttl=cache_ttl)
|
||||
|
||||
if cache_key is not None:
|
||||
return self.get(url, params=params, cache_key=cache_key, cache_ttl=cache_ttl)
|
||||
|
||||
legacy_key = f"espn_{sport}_{league}_{date}"
|
||||
try:
|
||||
shared_key = espn_scoreboard_cache_key(sport, league, date)
|
||||
except ValueError:
|
||||
# Not a path or date the canonical key covers: the old key.
|
||||
return self.get(url, params=params, cache_key=legacy_key, cache_ttl=cache_ttl)
|
||||
if self.cache_manager:
|
||||
cached = read_espn_scoreboard_cache(
|
||||
self.cache_manager, shared_key, cache_ttl, legacy_keys=(legacy_key,))
|
||||
if cached is not None:
|
||||
self.logger.debug(f"Using cached response for {shared_key}")
|
||||
return cast(Dict[Any, Any], cached)
|
||||
data = self._get(url, params, None, None, None, cache_ttl, cache_ttl)
|
||||
if data is not None and self.cache_manager:
|
||||
store_espn_scoreboard_cache(self.cache_manager, shared_key, data)
|
||||
return data
|
||||
|
||||
def fetch_espn_standings(self, sport: str, league: str,
|
||||
cache_key: Optional[str] = None,
|
||||
@@ -255,7 +295,8 @@ class APIHelper:
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
response = self.session.post(
|
||||
response = fetch_post(
|
||||
self.session,
|
||||
url,
|
||||
data=data,
|
||||
json=json_data,
|
||||
@@ -264,7 +305,7 @@ class APIHelper:
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return cast(Optional[Dict[Any, Any]], response.json())
|
||||
return cast(Optional[Dict[Any, Any]], response_json(response))
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"POST request failed for {url}: {e}")
|
||||
|
||||
+491
-42
@@ -26,18 +26,58 @@ A month can hold more than 500 events (college baseball's March does), and
|
||||
ESPN answers that with exactly ``limit`` events and no hint that more exist. A
|
||||
month chunk that comes back full is therefore re-asked day by day.
|
||||
|
||||
A window's *partial* edge months are asked for whole, too, once the window
|
||||
covers ``ESPN_MONTH_COVER_MIN_DAYS`` or more of their days, and the answer is
|
||||
trimmed back to the window's days. A scoreboard's default fortnight either side
|
||||
of today (29 days, two partial months) was 29 day requests per league; it is
|
||||
now 2. Trimming needs ESPN's "game day", which is the event's start in US
|
||||
Eastern time -- checked against the live API on 2026-10-03: 417 of 417 soccer
|
||||
events across five leagues and three months (one of them spanning the end of
|
||||
daylight saving) came back from exactly the day query their Eastern date
|
||||
names. A short window (a live poll's one or two days) stays day by day, so it
|
||||
never downloads a whole month to read a day of it.
|
||||
|
||||
Chunk requests share one process-wide budget of ``ESPN_CHUNK_WORKERS`` in
|
||||
flight, however many windows are being fetched at once. Each window used to get
|
||||
its own six, so a scoreboard starting eight leagues -- each with a recent and
|
||||
an upcoming manager -- had ~40 requests in flight, every one beyond a session's
|
||||
pool a new connection and a new DNS lookup. On a Pi whose resolver could not
|
||||
keep up, that was ~90 ``NameResolutionError`` lines within a minute of every
|
||||
start.
|
||||
|
||||
Once a range has been rejected, later ranges skip straight to chunks for
|
||||
``RANGE_RETRY_SECONDS`` instead of spending a doomed request first -- live
|
||||
scoreboards ask every 30 seconds. After that the range is tried again, so the
|
||||
workaround retires itself if ESPN reverts.
|
||||
workaround retires itself if ESPN reverts. A process starts inside that
|
||||
period, as if a range had just been rejected.
|
||||
|
||||
ONE CACHE KEY PER SCOREBOARD
|
||||
----------------------------
|
||||
The same ESPN scoreboard used to be cached under a different key by every
|
||||
consumer: odds-ticker as ``scoreboard_data_{sport}_{league}_{date}``,
|
||||
``APIHelper`` as ``espn_{sport}_{league}_{date}``, the scoreboards as
|
||||
``{sport_key}_schedule_{window}`` -- so two plugins showing the same league
|
||||
fetched and stored it twice. :func:`espn_scoreboard_cache_key` is the one
|
||||
name for "this sport/league scoreboard for these dates", and
|
||||
:func:`get_espn_scoreboard` (or :func:`read_espn_scoreboard_cache` and
|
||||
:func:`store_espn_scoreboard_cache` around :func:`fetch_espn_scoreboard`)
|
||||
is the cache-through read every consumer can share. A read never returns an
|
||||
entry older than the reader's own ``max_age``, whoever wrote it and whatever
|
||||
ttl they stored with it. Old keys are passed as ``legacy_keys`` and read
|
||||
after the canonical one, so an upgrade does not refetch everything at once;
|
||||
they can go one release after the one that added this.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta, tzinfo
|
||||
from functools import partial
|
||||
from typing import Any, Dict, List, Optional, Tuple, cast
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast
|
||||
|
||||
try:
|
||||
from src.common.json_body import response_json
|
||||
@@ -47,6 +87,26 @@ except ImportError:
|
||||
def response_json(response: Any) -> Any:
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
# The core fetch service: counts, per-host budget, merging of identical
|
||||
# requests. Same call, same result and errors as ``session.get``.
|
||||
from src.common.fetch_service import fetch_get, get_fetch_service, pinned_caller
|
||||
_COUNTS_FETCHES = True
|
||||
except ImportError:
|
||||
# Bundled copies on cores without it call the session directly.
|
||||
import contextlib
|
||||
|
||||
def fetch_get(session: Any, url: str, *, share_in_flight: bool = True,
|
||||
cache_max_age: Optional[float] = None, **kwargs: Any) -> Any:
|
||||
return session.get(url, **kwargs)
|
||||
|
||||
def pinned_caller() -> Any:
|
||||
return contextlib.nullcontext()
|
||||
|
||||
_COUNTS_FETCHES = False
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Above this, ESPN returns a truncated list instead of an error. See module
|
||||
# docstring: 500 is the largest value measured to return complete data.
|
||||
ESPN_MAX_LIMIT = 500
|
||||
@@ -60,21 +120,77 @@ RANGE_RETRY_SECONDS = 6 * 60 * 60
|
||||
# pool_maxsize of 10 so the shared Session never has to discard connections.
|
||||
ESPN_CHUNK_WORKERS = 6
|
||||
|
||||
#: An edge month the window covers at least this many days of is asked for
|
||||
#: whole and trimmed, instead of one request per day (see module docstring).
|
||||
#: Below it the days are cheaper than the month: a whole month is two to
|
||||
#: three times the bytes of the half of it a fortnight window holds.
|
||||
ESPN_MONTH_COVER_MIN_DAYS = 7
|
||||
|
||||
# Every chunk request in the process holds one of these while it is in flight
|
||||
# -- the cap is per process, not per window (see module docstring).
|
||||
_chunk_slots = threading.BoundedSemaphore(ESPN_CHUNK_WORKERS)
|
||||
|
||||
|
||||
def _eastern_zone() -> Optional[tzinfo]:
|
||||
"""US Eastern, the zone ESPN's ``dates=YYYYMMDD`` means, or None when
|
||||
this Python has no time zone data (no edge month is trimmed then)."""
|
||||
zone: Optional[tzinfo] = None
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
zone = ZoneInfo("America/New_York")
|
||||
except Exception: # noqa: BLE001 - no zoneinfo module or no tz database
|
||||
zone = None
|
||||
if zone is not None:
|
||||
return zone
|
||||
try:
|
||||
import pytz
|
||||
return cast(tzinfo, pytz.timezone("America/New_York"))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
_EASTERN = _eastern_zone()
|
||||
|
||||
# What _fetch_one_chunk returns for a month that came back at the cap.
|
||||
_CAPPED: Any = object()
|
||||
|
||||
_range_lock = threading.Lock()
|
||||
_ranges_rejected_until = 0.0
|
||||
# A process starts out assuming ranges are still rejected, as they have been
|
||||
# since 2026-09-15, and tries one again RANGE_RETRY_SECONDS in. Starting
|
||||
# from "unknown" cost one doomed range request per window at every start --
|
||||
# eleven 400s at once from a soccer board, each fetching before any had
|
||||
# answered -- to learn what every start learns.
|
||||
_ranges_rejected_until = time.monotonic() + RANGE_RETRY_SECONDS
|
||||
|
||||
__all__ = [
|
||||
"ESPN_MAX_LIMIT",
|
||||
"ESPN_CHUNK_WORKERS",
|
||||
"ESPN_MONTH_COVER_MIN_DAYS",
|
||||
"RANGE_RETRY_SECONDS",
|
||||
"clamp_espn_limit",
|
||||
"parse_espn_date_range",
|
||||
"espn_date_chunks",
|
||||
"espn_request_chunks",
|
||||
"merge_scoreboard_payloads",
|
||||
"fetch_espn_date_chunks",
|
||||
"fetch_espn_scoreboard",
|
||||
"ESPN_SCOREBOARD_URL",
|
||||
"espn_scoreboard_url",
|
||||
"espn_scoreboard_cache_key",
|
||||
"espn_scoreboard_cache_key_for_url",
|
||||
"read_espn_scoreboard_cache",
|
||||
"store_espn_scoreboard_cache",
|
||||
"get_espn_scoreboard",
|
||||
]
|
||||
|
||||
#: The site-API scoreboard every sport and league shares.
|
||||
ESPN_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard"
|
||||
_ESPN_HOST_URL = "https://site.api.espn.com/"
|
||||
|
||||
_PATH_PART = re.compile(r"^[a-z0-9][a-z0-9.\-]*$")
|
||||
_DATES = re.compile(r"^\d{4}(?:\d{2}(?:\d{2})?)?$|^\d{8}-\d{8}$")
|
||||
_SCOREBOARD_PATH = re.compile(r"/sports/([^/?#]+)/([^/?#]+)/scoreboard/?$")
|
||||
|
||||
|
||||
def clamp_espn_limit(params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Return a copy of ``params`` with any ``limit`` over 500 pulled back to 500."""
|
||||
@@ -113,6 +229,12 @@ def parse_espn_date_range(dates: Any) -> Optional[Tuple[date, date]]:
|
||||
return start, end
|
||||
|
||||
|
||||
def _memo_kwargs(cache_max_age: Optional[float]) -> Dict[str, Any]:
|
||||
"""``cache_max_age`` for fetch_get, only when the caller gave one, so a
|
||||
call that did not say is the call it always was."""
|
||||
return {} if cache_max_age is None else {"cache_max_age": cache_max_age}
|
||||
|
||||
|
||||
def _ranges_known_rejected() -> bool:
|
||||
with _range_lock:
|
||||
return time.monotonic() < _ranges_rejected_until
|
||||
@@ -159,6 +281,79 @@ def espn_date_chunks(start: date, end: date) -> List[str]:
|
||||
return chunks
|
||||
|
||||
|
||||
def espn_request_chunks(
|
||||
start: date,
|
||||
end: date,
|
||||
month_cover_min_days: Optional[int] = None,
|
||||
) -> List[Tuple[str, Optional[Tuple[date, date]]]]:
|
||||
"""The requests that fetch ``[start, end]``, as ``(dates, trim)`` pairs.
|
||||
|
||||
:func:`espn_date_chunks`, except that a partial edge month with
|
||||
``month_cover_min_days`` (default ``ESPN_MONTH_COVER_MIN_DAYS``) or more
|
||||
of its days in the window becomes one ``YYYYMM`` request whose ``trim``
|
||||
is the first and last of those days: its events that start outside them
|
||||
(US Eastern) are dropped. ``trim`` is None for every other request.
|
||||
Without time zone data nothing can be trimmed, so the edge days stay day
|
||||
requests.
|
||||
"""
|
||||
if month_cover_min_days is None:
|
||||
month_cover_min_days = ESPN_MONTH_COVER_MIN_DAYS
|
||||
planned: List[Tuple[str, Optional[Tuple[date, date]]]] = []
|
||||
run: List[str] = []
|
||||
|
||||
def flush() -> None:
|
||||
if (_EASTERN is not None and month_cover_min_days > 0
|
||||
and len(run) >= month_cover_min_days):
|
||||
planned.append((run[0][:6], (_parse_day(run[0]), _parse_day(run[-1]))))
|
||||
else:
|
||||
planned.extend((day, None) for day in run)
|
||||
run.clear()
|
||||
|
||||
for chunk in espn_date_chunks(start, end):
|
||||
if run and (len(chunk) != 8 or chunk[:6] != run[0][:6]):
|
||||
flush()
|
||||
if len(chunk) == 8:
|
||||
run.append(chunk)
|
||||
else:
|
||||
planned.append((chunk, None))
|
||||
flush()
|
||||
return planned
|
||||
|
||||
|
||||
def _parse_day(text: str) -> date:
|
||||
return date(int(text[:4]), int(text[4:6]), int(text[6:8]))
|
||||
|
||||
|
||||
def _eastern_day(stamp: Any) -> Optional[date]:
|
||||
"""The US Eastern date of an ESPN event ``date`` ("2026-10-10T11:30Z"),
|
||||
or None when it cannot be read."""
|
||||
if not isinstance(stamp, str) or _EASTERN is None:
|
||||
return None
|
||||
try:
|
||||
moment = datetime.fromisoformat(stamp.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if moment.tzinfo is None:
|
||||
return None
|
||||
return moment.astimezone(_EASTERN).date()
|
||||
|
||||
|
||||
def _trim_to_days(payload: Any, first: date, last: date) -> Any:
|
||||
"""Drop the events of a month payload that start outside ``[first, last]``
|
||||
(US Eastern). An event whose date cannot be read is kept: its day query
|
||||
might well have returned it, and a game is never dropped on a guess.
|
||||
"""
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("events"), list):
|
||||
return payload
|
||||
kept = []
|
||||
for event in payload["events"]:
|
||||
day = _eastern_day(event.get("date")) if isinstance(event, dict) else None
|
||||
if day is None or first <= day <= last:
|
||||
kept.append(event)
|
||||
payload["events"] = kept
|
||||
return payload
|
||||
|
||||
|
||||
def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
|
||||
"""Fold chunk responses into one scoreboard payload.
|
||||
|
||||
@@ -188,51 +383,81 @@ def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
|
||||
|
||||
def _fetch_one_chunk(
|
||||
session, url: str, params: Dict[str, Any], headers, timeout, logger, chunk: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
cache_max_age: Optional[float] = None,
|
||||
trims: Optional[Dict[str, Tuple[date, date]]] = None,
|
||||
) -> Any:
|
||||
"""GET a single ``dates=`` chunk, or None when it failed.
|
||||
|
||||
One bad chunk must not sink the rest of the season, so every error is
|
||||
logged and swallowed here rather than raised to the gather below.
|
||||
|
||||
A month that comes back at the cap is truncated: it returns ``_CAPPED``,
|
||||
its payload dropped here before it is ever held beside the others. A
|
||||
month in ``trims`` loses its events outside the days given there.
|
||||
|
||||
The request holds one of the process-wide ``_chunk_slots`` while it runs.
|
||||
"""
|
||||
try:
|
||||
response = session.get(
|
||||
url,
|
||||
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return cast(Optional[Dict[str, Any]], response_json(response))
|
||||
with _chunk_slots:
|
||||
response = fetch_get(
|
||||
session,
|
||||
url,
|
||||
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
**_memo_kwargs(cache_max_age),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response_json(response)
|
||||
except Exception as exc: # noqa: BLE001 - see docstring
|
||||
if logger:
|
||||
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
||||
return None
|
||||
if len(chunk) == 6 and isinstance(payload, dict):
|
||||
if len(payload.get("events") or []) >= ESPN_MAX_LIMIT:
|
||||
return _CAPPED
|
||||
trim = (trims or {}).get(chunk)
|
||||
if trim is not None:
|
||||
payload = _trim_to_days(payload, *trim)
|
||||
return payload
|
||||
|
||||
|
||||
def _fetch_chunks(
|
||||
session, url: str, params: Dict[str, Any], headers, timeout, logger,
|
||||
chunks: List[str],
|
||||
) -> List[Optional[Dict[str, Any]]]:
|
||||
chunks: List[str], cache_max_age: Optional[float] = None,
|
||||
trims: Optional[Dict[str, Tuple[date, date]]] = None,
|
||||
) -> List[Any]:
|
||||
"""Fetch every chunk, returning payloads positionally aligned with ``chunks``.
|
||||
|
||||
Requests go out ``ESPN_CHUNK_WORKERS`` at a time because a cold season is
|
||||
over a hundred of them. The order they come back in is not significant --
|
||||
over a hundred of them -- and no more than that across every window the
|
||||
process is fetching, which ``_fetch_one_chunk``'s slot enforces. The order they come back in is not significant --
|
||||
callers keep ``chunks`` order from the returned list -- but it does mean
|
||||
the session is shared across threads, which is why this only ever issues
|
||||
GETs and never touches session state.
|
||||
|
||||
Each chunk runs in a copy of the caller's context, with the caller pinned
|
||||
into it, so the fetch service counts the chunks against the plugin that
|
||||
asked for the range rather than against the core.
|
||||
"""
|
||||
if not chunks:
|
||||
return []
|
||||
fetch = partial(
|
||||
_fetch_one_chunk, session, url, params, headers, timeout, logger,
|
||||
cache_max_age=cache_max_age, trims=trims,
|
||||
)
|
||||
if len(chunks) == 1:
|
||||
return [fetch(chunks[0])]
|
||||
workers = min(ESPN_CHUNK_WORKERS, len(chunks))
|
||||
with pinned_caller():
|
||||
# One copy per chunk: a Context cannot be entered by two threads.
|
||||
contexts = [contextvars.copy_context() for _ in chunks]
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=workers, thread_name_prefix="espn-chunk",
|
||||
) as pool:
|
||||
return list(pool.map(fetch, chunks))
|
||||
futures = [pool.submit(context.run, fetch, chunk)
|
||||
for context, chunk in zip(contexts, chunks)]
|
||||
return [future.result() for future in futures]
|
||||
|
||||
|
||||
def fetch_espn_date_chunks(
|
||||
@@ -242,6 +467,7 @@ def fetch_espn_date_chunks(
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 15,
|
||||
logger=None,
|
||||
cache_max_age: Optional[float] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch a ``YYYYMMDD-YYYYMMDD`` window as month and day chunks.
|
||||
|
||||
@@ -265,7 +491,9 @@ def fetch_espn_date_chunks(
|
||||
if span is None:
|
||||
return None
|
||||
|
||||
chunks = espn_date_chunks(*span)
|
||||
planned = espn_request_chunks(*span)
|
||||
chunks = [chunk for chunk, _ in planned]
|
||||
trims = {chunk: trim for chunk, trim in planned if trim is not None}
|
||||
if logger:
|
||||
logger.debug(
|
||||
"Fetching ESPN date range %s as %d month/day chunks",
|
||||
@@ -273,39 +501,38 @@ def fetch_espn_date_chunks(
|
||||
)
|
||||
|
||||
results = _fetch_chunks(
|
||||
session, url, params, headers, timeout, logger, chunks,
|
||||
session, url, params, headers, timeout, logger, chunks, cache_max_age,
|
||||
trims,
|
||||
)
|
||||
attempted = len(chunks)
|
||||
|
||||
# A month that came back at the cap is truncated; its days replace it in
|
||||
# place, so merged events stay in chunk order however the requests raced.
|
||||
# A month that came back at the cap is truncated; its days (only the
|
||||
# window's, for a trimmed edge month) replace it in place, so merged
|
||||
# events stay in chunk order however the requests raced. Its payload was
|
||||
# already dropped in the worker: a capped college-baseball month is ~2MB
|
||||
# of parsed JSON, and holding four of them through ~120 day requests added
|
||||
# ~25MB to the peak -- more than the concurrency itself. Low-memory boards
|
||||
# (docs/LOW_MEMORY_BOARDS.md) have under 200MB of headroom.
|
||||
slots: List[Any] = results
|
||||
capped: Dict[int, List[str]] = {}
|
||||
for index, chunk in enumerate(chunks):
|
||||
payload = slots[index]
|
||||
if payload is None or len(chunk) != 6:
|
||||
if slots[index] is not _CAPPED:
|
||||
continue
|
||||
events = payload.get("events") if isinstance(payload, dict) else None
|
||||
if len(events or []) >= ESPN_MAX_LIMIT:
|
||||
if logger:
|
||||
logger.info(
|
||||
"ESPN month %s hit the %d-event cap; re-asking it day by day",
|
||||
chunk, ESPN_MAX_LIMIT,
|
||||
)
|
||||
capped[index] = _days_of_month(chunk)
|
||||
# Drop the truncated month now rather than after its days arrive:
|
||||
# a capped college-baseball month is ~2MB of parsed JSON, and
|
||||
# holding four of them through ~120 day requests added ~25MB to
|
||||
# the peak -- more than the concurrency itself. Low-memory boards
|
||||
# (docs/LOW_MEMORY_BOARDS.md) have under 200MB of headroom.
|
||||
slots[index] = None
|
||||
payload = events = None
|
||||
if logger:
|
||||
logger.info(
|
||||
"ESPN month %s hit the %d-event cap; re-asking it day by day",
|
||||
chunk, ESPN_MAX_LIMIT,
|
||||
)
|
||||
trim = trims.get(chunk)
|
||||
capped[index] = (_days_of_month(chunk) if trim is None
|
||||
else espn_date_chunks(*trim))
|
||||
slots[index] = None
|
||||
|
||||
if capped:
|
||||
days = [day for index in sorted(capped) for day in capped[index]]
|
||||
attempted += len(days)
|
||||
by_day = dict(zip(days, _fetch_chunks(
|
||||
session, url, params, headers, timeout, logger, days,
|
||||
session, url, params, headers, timeout, logger, days, cache_max_age,
|
||||
)))
|
||||
for index, month_days in capped.items():
|
||||
slots[index] = [by_day.get(day) for day in month_days]
|
||||
@@ -338,6 +565,7 @@ def fetch_espn_scoreboard(
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 15,
|
||||
logger=None,
|
||||
cache_max_age: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""GET an ESPN scoreboard, re-asking in month/day chunks if a range 400s.
|
||||
|
||||
@@ -347,6 +575,10 @@ def fetch_espn_scoreboard(
|
||||
and later ranges go straight to chunks for ``RANGE_RETRY_SECONDS``. A 400 on
|
||||
a non-range request, any other error, and a range whose every chunk fails
|
||||
all raise as before.
|
||||
|
||||
``cache_max_age`` is the oldest response, in seconds, the caller takes
|
||||
from the fetch service's short response cache (its own TTL; 0 always
|
||||
asks ESPN). None leaves it to the service default.
|
||||
"""
|
||||
params = clamp_espn_limit(params)
|
||||
is_range = parse_espn_date_range(params.get("dates")) is not None
|
||||
@@ -355,7 +587,7 @@ def fetch_espn_scoreboard(
|
||||
if is_range and _ranges_known_rejected():
|
||||
data = fetch_espn_date_chunks(
|
||||
session, url, params=params, headers=headers,
|
||||
timeout=timeout, logger=logger,
|
||||
timeout=timeout, logger=logger, cache_max_age=cache_max_age,
|
||||
)
|
||||
if data is not None:
|
||||
return data
|
||||
@@ -363,7 +595,8 @@ def fetch_espn_scoreboard(
|
||||
# real error to log, without spending the chunks a second time.
|
||||
chunks_tried = True
|
||||
|
||||
response = session.get(url, params=params, headers=headers, timeout=timeout)
|
||||
response = fetch_get(session, url, params=params, headers=headers, timeout=timeout,
|
||||
**_memo_kwargs(cache_max_age))
|
||||
if is_range and response.status_code == 400 and not chunks_tried:
|
||||
_note_range_rejected()
|
||||
if logger:
|
||||
@@ -374,9 +607,225 @@ def fetch_espn_scoreboard(
|
||||
)
|
||||
data = fetch_espn_date_chunks(
|
||||
session, url, params=params, headers=headers,
|
||||
timeout=timeout, logger=logger,
|
||||
timeout=timeout, logger=logger, cache_max_age=cache_max_age,
|
||||
)
|
||||
if data is not None:
|
||||
return data
|
||||
response.raise_for_status()
|
||||
return cast(Dict[str, Any], response_json(response))
|
||||
|
||||
|
||||
# --- one cache key per scoreboard --------------------------------------------------
|
||||
|
||||
def espn_scoreboard_url(sport: str, league: str) -> str:
|
||||
"""The site-API scoreboard URL for an ESPN ``sport`` / ``league`` path."""
|
||||
return ESPN_SCOREBOARD_URL.format(sport=_path_part(sport, "sport"),
|
||||
league=_path_part(league, "league"))
|
||||
|
||||
|
||||
def _path_part(value: Any, what: str) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not _PATH_PART.match(text):
|
||||
raise ValueError(f"not an ESPN {what} path segment: {value!r}")
|
||||
return text
|
||||
|
||||
|
||||
def _day(value: Any) -> str:
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.strftime("%Y%m%d")
|
||||
text = str(value).strip()
|
||||
if len(text) != 8 or not text.isdigit():
|
||||
raise ValueError(f"not an ESPN day (YYYYMMDD): {value!r}")
|
||||
return text
|
||||
|
||||
|
||||
def _dates_part(dates: Any) -> str:
|
||||
"""``dates`` as ESPN spells it, or ``current`` for no ``dates`` at all."""
|
||||
if dates is None or dates == "":
|
||||
return "current"
|
||||
if isinstance(dates, (date, datetime)):
|
||||
return _day(dates)
|
||||
if isinstance(dates, (tuple, list)):
|
||||
if len(dates) != 2:
|
||||
raise ValueError(f"a date range is (start, end): {dates!r}")
|
||||
start, end = _day(dates[0]), _day(dates[1])
|
||||
return start if start == end else f"{start}-{end}"
|
||||
text = str(dates).strip()
|
||||
if isinstance(dates, bool) or not _DATES.match(text):
|
||||
raise ValueError(
|
||||
f"not an ESPN dates value (YYYY, YYYYMM, YYYYMMDD or "
|
||||
f"YYYYMMDD-YYYYMMDD): {dates!r}")
|
||||
return text
|
||||
|
||||
|
||||
def espn_scoreboard_cache_key(sport: str, league: str, dates: Any = None) -> str:
|
||||
"""The one cache key for an ESPN scoreboard, whoever caches it.
|
||||
|
||||
``sport`` and ``league`` are ESPN's own path segments -- ``football`` /
|
||||
``college-football``, ``soccer`` / ``eng.1`` -- not a plugin's
|
||||
``sport_key``, so every plugin showing a league names it the same way.
|
||||
``dates`` is what the request sends as ``dates=``: ``"YYYYMMDD"``,
|
||||
``"YYYYMM"``, ``"YYYY"``, ``"YYYYMMDD-YYYYMMDD"``, a ``date``, or a
|
||||
``(start, end)`` pair of either; None is the undated "current"
|
||||
scoreboard. Anything else raises ValueError rather than invent a key.
|
||||
|
||||
The key says nothing about ``limit``: a cached copy is meant to be a
|
||||
whole one (the helpers here always ask for ``ESPN_MAX_LIMIT``).
|
||||
"""
|
||||
return (f"espn_scoreboard_{_path_part(sport, 'sport')}_"
|
||||
f"{_path_part(league, 'league')}_{_dates_part(dates)}")
|
||||
|
||||
|
||||
def espn_scoreboard_cache_key_for_url(url: str, dates: Any = None) -> Optional[str]:
|
||||
""":func:`espn_scoreboard_cache_key` for a scoreboard URL, or None when
|
||||
``url`` is not ``.../sports/{sport}/{league}/scoreboard``."""
|
||||
match = _SCOREBOARD_PATH.search(str(url or "").split("?", 1)[0])
|
||||
if match is None:
|
||||
return None
|
||||
try:
|
||||
return espn_scoreboard_cache_key(match.group(1), match.group(2), dates)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _note_cache_hit(legacy: bool, avoided_request: bool = True) -> None:
|
||||
if not _COUNTS_FETCHES:
|
||||
return
|
||||
try:
|
||||
get_fetch_service().note_cache_hit(
|
||||
_ESPN_HOST_URL, legacy=legacy, avoided_request=avoided_request)
|
||||
except Exception: # noqa: BLE001 - counting never breaks a read
|
||||
_logger.debug("could not count a scoreboard cache hit", exc_info=True)
|
||||
|
||||
|
||||
def _fresh_cached(cache_manager: Any, key: str, max_age: Optional[float],
|
||||
now: float) -> Tuple[Optional[Dict[str, Any]], Optional[float]]:
|
||||
"""The data cached under ``key`` if it is at most ``max_age`` seconds
|
||||
old, and its age (None when the cache does not say).
|
||||
|
||||
The age is the stored record's own timestamp, checked here: CacheManager
|
||||
lets a ttl stored by the writer override the reader's max_age, and its
|
||||
memory tier times an entry from when it was loaded, not written. A key
|
||||
shared by readers with different TTLs can rely on neither.
|
||||
"""
|
||||
reader = getattr(cache_manager, "get_cached_data", None)
|
||||
limit = None if max_age is None else max(1, int(math.ceil(max_age)))
|
||||
if not callable(reader):
|
||||
# A cache without records (a test double, a plugin's own store).
|
||||
value = cache_manager.get(key, max_age=limit)
|
||||
return (value if isinstance(value, dict) else None), None
|
||||
record = reader(key, max_age=limit, memory_ttl=limit)
|
||||
if not isinstance(record, dict):
|
||||
return None, None
|
||||
if "data" not in record:
|
||||
return record, None # unwrapped; the cache already judged it by mtime
|
||||
stamp = record.get("timestamp")
|
||||
age: Optional[float] = None
|
||||
if not isinstance(stamp, bool) and isinstance(stamp, (int, float)):
|
||||
age = max(0.0, now - float(stamp))
|
||||
if max_age is not None and (age is None or age > max_age):
|
||||
return None, None
|
||||
data = record["data"]
|
||||
return (data if isinstance(data, dict) else None), age
|
||||
|
||||
|
||||
def read_espn_scoreboard_cache(
|
||||
cache_manager: Any,
|
||||
key: str,
|
||||
max_age: Optional[float],
|
||||
legacy_keys: Iterable[str] = (),
|
||||
now: Optional[float] = None,
|
||||
accept: Optional[Callable[[Dict[str, Any], Optional[float]], bool]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""The cached scoreboard under ``key``, or under the first of
|
||||
``legacy_keys`` that has one, if it is at most ``max_age`` seconds old.
|
||||
|
||||
None on a miss, a stale entry, ``max_age`` of 0 or less, no cache
|
||||
manager, or any cache error -- a read never raises. ``max_age=None``
|
||||
takes an entry of any age. ``accept(data, age_seconds)`` can turn down
|
||||
an entry the age alone would allow (a payload holding a live game wants
|
||||
a shorter limit); ``age_seconds`` is None when the cache cannot say. A
|
||||
hit is counted in the fetch statistics (``cache_hits``;
|
||||
``legacy_cache_hits`` too for an old key).
|
||||
"""
|
||||
if cache_manager is None:
|
||||
return None
|
||||
if max_age is not None and max_age <= 0:
|
||||
return None
|
||||
clock = time.time() if now is None else now
|
||||
for index, candidate in enumerate([key, *legacy_keys]):
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
data, age = _fresh_cached(cache_manager, candidate, max_age, clock)
|
||||
if data is not None and accept is not None and not accept(data, age):
|
||||
data = None
|
||||
except Exception: # noqa: BLE001 - a broken cache is a miss
|
||||
_logger.debug("scoreboard cache read failed for %s", candidate, exc_info=True)
|
||||
continue
|
||||
if data is not None:
|
||||
_note_cache_hit(legacy=index > 0)
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def store_espn_scoreboard_cache(cache_manager: Any, key: str, data: Any) -> None:
|
||||
"""Cache a fetched scoreboard under ``key``. Never raises.
|
||||
|
||||
No ttl is stored: each reader applies its own ``max_age`` (a live
|
||||
reader 30 s, a schedule reader an hour), and a stored ttl would
|
||||
override theirs in CacheManager.
|
||||
"""
|
||||
if cache_manager is None or data is None:
|
||||
return
|
||||
try:
|
||||
cache_manager.set(key, data)
|
||||
except Exception: # noqa: BLE001 - the caller still has its data
|
||||
_logger.warning("Could not cache scoreboard %s", key, exc_info=True)
|
||||
|
||||
|
||||
def get_espn_scoreboard(
|
||||
session: Any,
|
||||
sport: str,
|
||||
league: str,
|
||||
dates: Any = None,
|
||||
*,
|
||||
cache_manager: Any = None,
|
||||
max_age: Optional[float] = 300,
|
||||
legacy_keys: Iterable[str] = (),
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 15,
|
||||
logger: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""An ESPN scoreboard through the shared cache, fetched on a miss.
|
||||
|
||||
Reads :func:`espn_scoreboard_cache_key` (then ``legacy_keys``) and
|
||||
returns an entry at most ``max_age`` seconds old. Otherwise it fetches
|
||||
with :func:`fetch_espn_scoreboard` -- ``limit=ESPN_MAX_LIMIT``, ranges
|
||||
split as ESPN needs -- caches the result under the canonical key and
|
||||
returns it. ``max_age=0`` always fetches (and still caches, for other
|
||||
readers). Errors raise exactly as :func:`fetch_espn_scoreboard` does,
|
||||
and nothing is cached then. ``session=None`` uses the fetch service's
|
||||
pooled session for the ESPN host.
|
||||
"""
|
||||
key = espn_scoreboard_cache_key(sport, league, dates)
|
||||
cached = read_espn_scoreboard_cache(cache_manager, key, max_age, legacy_keys)
|
||||
if cached is not None:
|
||||
return cast(Dict[str, Any], cached)
|
||||
params: Dict[str, Any] = {"limit": ESPN_MAX_LIMIT}
|
||||
spelled = _dates_part(dates)
|
||||
if spelled != "current":
|
||||
params["dates"] = spelled
|
||||
data = fetch_espn_scoreboard(
|
||||
session,
|
||||
espn_scoreboard_url(sport, league),
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
logger=logger,
|
||||
# The response cache must not hand back anything older than the
|
||||
# cache read above would have accepted.
|
||||
cache_max_age=None if max_age is None else max(0.0, float(max_age)),
|
||||
)
|
||||
store_espn_scoreboard_cache(cache_manager, key, data)
|
||||
return data
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+288
-24
@@ -19,8 +19,9 @@ Only intervals between two consecutive *scrolling* frames count: a static
|
||||
screen that changes once a second has no timing to get wrong, and the first
|
||||
frame of a scroll has no predecessor worth measuring against.
|
||||
|
||||
"Scrolling" is DisplayManager's scroll state when the frame is presented, and
|
||||
that state can go missing in the middle of a scroll. It expires after 2s
|
||||
"Scrolling" is the scroll state ``DisplayManager.update_display`` acted on for
|
||||
the frame, sampled once before the blit and swap, and that state can go missing
|
||||
in the middle of a scroll. It expires after 2s
|
||||
without scroll activity, which a long enough stall outlasts, and any thread can
|
||||
clear it: plugins call ``set_scrolling_state(False)`` from their own
|
||||
``display()``, and Vegas captures some of those on the render thread between
|
||||
@@ -38,12 +39,20 @@ after the one before it. One that arrives a whole refresh or more after that is
|
||||
a visible hitch. ``missed_refreshes`` sums how many refreshes late.
|
||||
|
||||
An interval of ``FREEZE_SECONDS`` or more is a **freeze** instead -- a
|
||||
recompose, a plugin handover, a blocking call on the render thread. Those are
|
||||
counted separately, both because they are a different fault and because
|
||||
folding a single 400ms handover into the late count as "40 missed refreshes"
|
||||
would drown the jitter the late count exists to measure. ``freeze_by`` splits
|
||||
them by length. Intervals of ``GAP_SECONDS`` or more are ignored as not being
|
||||
frames of one scroll at all.
|
||||
recompose, a plugin handover nobody tagged (see below), a blocking call on the
|
||||
render thread. Those are counted separately, both because they are a different
|
||||
fault and because folding a single 400ms handover into the late count as "40
|
||||
missed refreshes" would drown the jitter the late count exists to measure.
|
||||
``freeze_by`` splits them by length. Intervals of ``GAP_SECONDS`` or more are
|
||||
ignored as not being frames of one scroll at all.
|
||||
|
||||
One kind of freeze is not a scroll stalling at all: the gap from one screen's
|
||||
last frame to the next screen's first, while the next screen draws. The
|
||||
display controller tags that frame ``handover`` (see "Operations") at the
|
||||
start of every turn, the same mode's again included, and a tagged freeze is
|
||||
counted in ``handover_freezes`` instead of ``freezes`` and ``freeze_by``.
|
||||
Stats written before that field existed have handovers among their freezes,
|
||||
so freeze counts from before and after it are not comparable.
|
||||
|
||||
A frame that arrives a whole refresh or more *early* means the swap did not
|
||||
wait for the panel: the emulator, the fallback display, or a hold that was not
|
||||
@@ -66,6 +75,35 @@ faster than the panel (every frame early) or sits at half its rate (every
|
||||
frame late), both of which look self-consistent to an estimate taken from
|
||||
their own intervals.
|
||||
|
||||
Operations
|
||||
----------
|
||||
The late count says how often, not which work did it. Render-thread work that
|
||||
happens between two frames -- extending the Vegas strip, patching a live
|
||||
element into it -- calls :meth:`FrameTimingRecorder.note_op` first, and the
|
||||
next presented frame carries the tag: the interval that frame ends is the one
|
||||
the work landed in. ``op_frames`` counts timed frames per kind,
|
||||
``late_op_frames`` the late ones among them, ``op_freezes`` those that were a
|
||||
freeze instead, and ``op_bytes`` what the work moved. A kind whose late rate
|
||||
sits well above the overall one is the work to look at.
|
||||
|
||||
``handover`` (:data:`HANDOVER_OP`) is noted off the render thread: the display
|
||||
controller notes it just before it starts a screen's first ``display()``,
|
||||
which presents from a thread of its own, and drops the note again with
|
||||
:meth:`FrameTimingRecorder.drop_op` once that call returns, so a first
|
||||
``display()`` that drew nothing cannot leave the tag for an unrelated frame.
|
||||
|
||||
Garbage collection
|
||||
------------------
|
||||
|
||||
Python's cyclic collector stops every thread while it runs. :class:`GcMonitor`
|
||||
times each collection from ``gc.callbacks``; the display manager installs one
|
||||
per process. A collection of ``GC_PAUSE_SECONDS`` or more tags the next
|
||||
presented frame ``gc`` (:data:`GC_OP`), so it shows in ``op_frames``,
|
||||
``late_op_frames`` and ``op_freezes`` like noted work, and the snapshot carries
|
||||
a ``gc`` block of cumulative counters: collections and seconds per generation,
|
||||
the longest, and the long ones. A stall dump says when a long collection ran
|
||||
inside the stall. Diagnostic only: nothing tunes or freezes the collector.
|
||||
|
||||
Stall watchdog
|
||||
--------------
|
||||
Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the
|
||||
@@ -83,7 +121,9 @@ three times per threshold, so keep it to diagnostic runs, not soaks.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import copy
|
||||
import gc
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -122,6 +162,16 @@ RESUME_SECONDS = 1.0
|
||||
FREEZE_BUCKETS = ((0.5, "<0.5s"), (1.0, "0.5-1s"), (2.0, "1-2s"),
|
||||
(float("inf"), "2s+"))
|
||||
|
||||
#: The op the display controller notes before a screen's first frame. A
|
||||
#: freeze it ends is a handover, counted apart from the freezes; see
|
||||
#: "What is counted".
|
||||
HANDOVER_OP = "handover"
|
||||
|
||||
#: The op a garbage collection of ``GC_PAUSE_SECONDS`` or more tags the next
|
||||
#: frame with; see "Garbage collection".
|
||||
GC_OP = "gc"
|
||||
GC_PAUSE_SECONDS = 0.020
|
||||
|
||||
#: A window may lower the refresh-period estimate by at most this fraction.
|
||||
MAX_REFRESH_DROP = 0.2
|
||||
|
||||
@@ -153,11 +203,128 @@ def default_stats_path() -> str:
|
||||
return os.path.join(base, STATS_FILENAME)
|
||||
|
||||
|
||||
class GcMonitor:
|
||||
"""Times every garbage collection, from ``gc.callbacks``.
|
||||
|
||||
Python's cyclic collector stops every thread for as long as a collection
|
||||
takes, and a full one over a large heap (a season of game dicts) can take
|
||||
longer than a frame. Nothing measured that, so a stall it caused looked
|
||||
like any other. The callback runs inside the collection, with the GIL
|
||||
held, and collections never overlap, so these plain counters need no
|
||||
lock: the render thread and the stats writer only read them.
|
||||
|
||||
Install it once per process with :func:`install_gc_monitor`.
|
||||
|
||||
Collections still run while the interpreter shuts down, after module
|
||||
globals such as ``time`` may already be torn down to ``None``. The clock
|
||||
and ``sys.is_finalizing`` are bound here so the callback never looks a
|
||||
global up, it does nothing once finalization has begun, and
|
||||
:func:`install_gc_monitor` unregisters it at exit anyway.
|
||||
"""
|
||||
|
||||
def __init__(self, threshold: float = GC_PAUSE_SECONDS,
|
||||
clock: Callable[[], float] = time.perf_counter):
|
||||
self.threshold = threshold
|
||||
self._clock = clock
|
||||
self._is_finalizing = sys.is_finalizing
|
||||
self._started: Optional[float] = None
|
||||
#: Per generation (0, 1, 2), since the monitor was installed.
|
||||
self.collections = [0, 0, 0]
|
||||
self.seconds = [0.0, 0.0, 0.0]
|
||||
self.max_seconds = 0.0
|
||||
#: Collections of ``threshold`` or more, and their total length. The
|
||||
#: recorder compares ``long_pauses`` with the count it last saw to tag
|
||||
#: the next frame.
|
||||
self.long_pauses = 0
|
||||
self.long_seconds = 0.0
|
||||
#: ``time.perf_counter()`` at the end of the last long collection,
|
||||
#: and its length, for the stall watchdog.
|
||||
self.last_long: Optional[Tuple[float, float]] = None
|
||||
|
||||
def __call__(self, phase: str, info: Dict[str, Any]) -> None:
|
||||
if self._is_finalizing():
|
||||
return
|
||||
now = self._clock()
|
||||
if phase == "start":
|
||||
self._started = now
|
||||
return
|
||||
started, self._started = self._started, None
|
||||
if started is None:
|
||||
return
|
||||
took = now - started
|
||||
generation = min(max(int(info.get("generation", 0)), 0), 2)
|
||||
self.collections[generation] += 1
|
||||
self.seconds[generation] += took
|
||||
if took > self.max_seconds:
|
||||
self.max_seconds = took
|
||||
if took >= self.threshold:
|
||||
self.long_seconds += took
|
||||
self.last_long = (now, took)
|
||||
self.long_pauses += 1
|
||||
|
||||
def snapshot(self) -> Dict[str, Any]:
|
||||
"""Cumulative counters for the stats file (all since installation)."""
|
||||
return {
|
||||
"threshold_ms": round(self.threshold * 1000.0, 3),
|
||||
"collections": list(self.collections),
|
||||
"seconds": [round(x, 6) for x in self.seconds],
|
||||
"max_ms": round(self.max_seconds * 1000.0, 3),
|
||||
"long_pauses": self.long_pauses,
|
||||
"long_seconds": round(self.long_seconds, 6),
|
||||
}
|
||||
|
||||
|
||||
_gc_monitor: Optional[GcMonitor] = None
|
||||
_gc_monitor_lock = threading.Lock()
|
||||
|
||||
|
||||
def install_gc_monitor() -> GcMonitor:
|
||||
"""The process's GcMonitor, installed in ``gc.callbacks`` on first call.
|
||||
|
||||
It is unregistered at exit (:func:`uninstall_gc_monitor`), before the
|
||||
interpreter tears module globals down.
|
||||
"""
|
||||
global _gc_monitor
|
||||
with _gc_monitor_lock:
|
||||
if _gc_monitor is None:
|
||||
_gc_monitor = GcMonitor()
|
||||
gc.callbacks.append(_gc_monitor)
|
||||
atexit.register(uninstall_gc_monitor)
|
||||
return _gc_monitor
|
||||
|
||||
|
||||
def uninstall_gc_monitor() -> None:
|
||||
"""Take the process's GcMonitor out of ``gc.callbacks``; safe to repeat.
|
||||
|
||||
A recorder that still holds the monitor keeps its counters; they just
|
||||
stop moving. The next :func:`install_gc_monitor` installs a fresh one.
|
||||
"""
|
||||
global _gc_monitor
|
||||
with _gc_monitor_lock:
|
||||
monitor, _gc_monitor = _gc_monitor, None
|
||||
if monitor is None:
|
||||
return
|
||||
atexit.unregister(uninstall_gc_monitor)
|
||||
try:
|
||||
gc.callbacks.remove(monitor)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
#: One presented frame's interval: (interval, blit, wait, hold, ops), where
|
||||
#: ops is the work noted before it (kind -> bytes) or None.
|
||||
_Frame = Tuple[float, float, float, int, Optional[Dict[str, int]]]
|
||||
|
||||
|
||||
def _bucket(seconds: float) -> int:
|
||||
index = int(seconds * 1000.0 / BUCKET_MS)
|
||||
return min(max(index, 0), BUCKET_COUNT - 1)
|
||||
|
||||
|
||||
def _bump(counter: Dict[str, int], key: str, by: int = 1) -> None:
|
||||
counter[key] = counter.get(key, 0) + by
|
||||
|
||||
|
||||
def binding_releases_gil() -> Optional[bool]:
|
||||
"""Whether the loaded rgbmatrix binding releases the GIL, or None.
|
||||
|
||||
@@ -239,23 +406,31 @@ class FrameTimingRecorder:
|
||||
flush_interval: float = FLUSH_INTERVAL,
|
||||
info: Optional[Dict[str, Any]] = None,
|
||||
refresh_hz: Optional[float] = None,
|
||||
gc_monitor: Optional[GcMonitor] = None,
|
||||
):
|
||||
"""
|
||||
:param refresh_hz: the panel's rate, measured independently (see the
|
||||
module docstring). Omit it to estimate from the frames alone, as
|
||||
the display service does.
|
||||
:param gc_monitor: tags frames after a long garbage collection and
|
||||
adds its counters to the stats (see "Garbage collection"). The
|
||||
display manager passes the process's :func:`install_gc_monitor`.
|
||||
"""
|
||||
self.path = path or default_stats_path()
|
||||
self.flush_interval = flush_interval
|
||||
self.info = dict(info or {})
|
||||
|
||||
# Render-thread state.
|
||||
self._pending: List[Tuple[float, float, float, int]] = []
|
||||
self._pending: List[_Frame] = []
|
||||
self._static_frames = 0
|
||||
self._previous: Optional[Tuple[float, bool, int]] = None
|
||||
# The interval ended by a static frame that followed a scrolling one,
|
||||
# until the next frame shows whether the scroll went on.
|
||||
self._unsure: Optional[Tuple[float, float, float, int]] = None
|
||||
self._unsure: Optional[_Frame] = None
|
||||
# Work noted since the last frame (kind -> bytes), for the next one.
|
||||
self._ops: Optional[Dict[str, int]] = None
|
||||
self.gc_monitor = gc_monitor
|
||||
self._gc_seen = gc_monitor.long_pauses if gc_monitor is not None else 0
|
||||
self._last_flush: Optional[float] = None
|
||||
self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
@@ -280,7 +455,16 @@ class FrameTimingRecorder:
|
||||
"freezes": 0,
|
||||
"freeze_seconds": 0.0,
|
||||
"freeze_by": {label: 0 for _, label in FREEZE_BUCKETS},
|
||||
# Freezes that ended a screen handover rather than stalled a
|
||||
# scroll: in neither of the two above. Additive; see "What is
|
||||
# counted".
|
||||
"handover_freezes": 0,
|
||||
"worst_interval_ms": 0.0,
|
||||
# Per kind of noted render-thread work; see "Operations".
|
||||
"op_frames": {},
|
||||
"late_op_frames": {},
|
||||
"op_freezes": {},
|
||||
"op_bytes": {},
|
||||
}
|
||||
self.histograms: Dict[str, Dict[int, int]] = {
|
||||
"blit": {}, "wait": {}, "work": {}, "interval_per_hold": {},
|
||||
@@ -305,6 +489,37 @@ class FrameTimingRecorder:
|
||||
|
||||
# -- render thread ------------------------------------------------------
|
||||
|
||||
def note_op(self, kind: str, nbytes: int = 0) -> None:
|
||||
"""Tag the next presented frame with work done before it.
|
||||
|
||||
Render thread only, like :meth:`record`, which consumes the tag: the
|
||||
interval the next frame ends is the one this work landed in. Several
|
||||
notes before one frame accumulate, per kind. See "Operations" (and
|
||||
:data:`HANDOVER_OP`, the one note made from another thread).
|
||||
|
||||
:param kind: a short name for the work, e.g. ``"extend"``, ``"patch"``.
|
||||
:param nbytes: how much the work moved, summed into ``op_bytes``.
|
||||
"""
|
||||
ops = self._ops
|
||||
if ops is None:
|
||||
ops = self._ops = {}
|
||||
ops[kind] = ops.get(kind, 0) + int(nbytes)
|
||||
|
||||
def drop_op(self, kind: str) -> None:
|
||||
"""Forget a note of ``kind`` that no frame has carried yet.
|
||||
|
||||
For work that may present nothing: the display controller notes a
|
||||
handover before a screen's first ``display()`` and drops it once that
|
||||
returns. When the call drew a frame, the frame already took the tag
|
||||
and this does nothing; when it drew nothing (no content), the tag
|
||||
would otherwise land on whatever frame came next -- seconds or minutes
|
||||
later, and nothing to do with the handover. Other kinds noted for the
|
||||
same frame are kept.
|
||||
"""
|
||||
ops = self._ops
|
||||
if ops is not None:
|
||||
ops.pop(kind, None)
|
||||
|
||||
def record(self, blit: float, wait: float, hold: int, scrolling: bool,
|
||||
presented_at: float) -> None:
|
||||
"""One frame reached the panel.
|
||||
@@ -312,12 +527,24 @@ class FrameTimingRecorder:
|
||||
:param blit: seconds spent copying the frame into the canvas.
|
||||
:param wait: seconds SwapOnVSync blocked.
|
||||
:param hold: the refreshes this frame was held for.
|
||||
:param scrolling: whether a scroll was running when it was presented.
|
||||
:param scrolling: whether a scroll was running for this frame: the
|
||||
scroll state ``update_display`` acted on, sampled once before the
|
||||
blit and swap.
|
||||
:param presented_at: ``time.perf_counter()`` when the swap returned.
|
||||
"""
|
||||
previous = self._previous
|
||||
self._previous = (presented_at, scrolling, hold)
|
||||
self.last_frame = (presented_at, scrolling, threading.get_ident())
|
||||
ops, self._ops = self._ops, None
|
||||
monitor = self.gc_monitor
|
||||
if monitor is not None and monitor.long_pauses != self._gc_seen:
|
||||
# A long collection ran since the last frame: the interval this
|
||||
# frame ends is the one it landed in. Read here rather than
|
||||
# noted, since note_op is the render thread's and a collection
|
||||
# runs on whichever thread triggered it.
|
||||
self._gc_seen = monitor.long_pauses
|
||||
ops = dict(ops) if ops else {}
|
||||
ops[GC_OP] = ops.get(GC_OP, 0)
|
||||
if not scrolling:
|
||||
self._static_frames += 1
|
||||
# The scroll ended, or its state went missing for this frame: the
|
||||
@@ -325,7 +552,8 @@ class FrameTimingRecorder:
|
||||
# state, so the interval is due at the scroll's own.
|
||||
self._unsure = None
|
||||
if previous is not None and previous[1]:
|
||||
self._unsure = (presented_at - previous[0], blit, wait, previous[2])
|
||||
self._unsure = (presented_at - previous[0], blit, wait,
|
||||
previous[2], ops)
|
||||
elif self.watchdog is None and self.scrolling_now is not None \
|
||||
and os.environ.get("LEDMATRIX_STALL_WATCHDOG", "1") != "0":
|
||||
self.watchdog = StallWatchdog(self, **watchdog_settings())
|
||||
@@ -335,14 +563,14 @@ class FrameTimingRecorder:
|
||||
unsure, self._unsure = self._unsure, None
|
||||
if previous[1]:
|
||||
if interval < GAP_SECONDS:
|
||||
self._pending.append((interval, blit, wait, hold))
|
||||
self._pending.append((interval, blit, wait, hold, ops))
|
||||
elif unsure is not None and interval < RESUME_SECONDS:
|
||||
# One static frame between two scrolling ones: the scroll never
|
||||
# stopped, only its state did. Both intervals were motion.
|
||||
self._static_frames -= 1
|
||||
if unsure[0] < GAP_SECONDS:
|
||||
self._pending.append(unsure)
|
||||
self._pending.append((interval, blit, wait, hold))
|
||||
self._pending.append((interval, blit, wait, hold, ops))
|
||||
|
||||
if self._last_flush is None:
|
||||
self._last_flush = presented_at
|
||||
@@ -382,15 +610,17 @@ class FrameTimingRecorder:
|
||||
except Exception: # never let telemetry take anything down
|
||||
logger.debug("Frame timing flush failed", exc_info=True)
|
||||
|
||||
def aggregate(self, batch: List[Tuple[float, float, float, int]],
|
||||
static: int) -> None:
|
||||
"""Fold one window of frames into the running totals."""
|
||||
def aggregate(self, batch: List[_Frame], static: int) -> None:
|
||||
"""Fold one window of frames into the running totals.
|
||||
|
||||
Each frame is ``(interval, blit, wait, hold, ops)``; ``ops`` (the work
|
||||
noted before it, or None) may be left off.
|
||||
"""
|
||||
totals = self.totals
|
||||
totals["static_frames"] += static
|
||||
|
||||
per_hold = sorted(interval / max(1, hold)
|
||||
for interval, _, _, hold in batch
|
||||
if interval < FREEZE_SECONDS)
|
||||
per_hold = sorted(frame[0] / max(1, frame[3]) for frame in batch
|
||||
if frame[0] < FREEZE_SECONDS)
|
||||
if len(per_hold) >= MIN_FRAMES_FOR_REFRESH:
|
||||
estimate = per_hold[len(per_hold) // 10]
|
||||
current = self.refresh_period
|
||||
@@ -412,10 +642,23 @@ class FrameTimingRecorder:
|
||||
period = self.refresh_period
|
||||
|
||||
histograms = self.histograms
|
||||
for interval, blit, wait, hold in batch:
|
||||
for frame in batch:
|
||||
interval, blit, wait, hold = frame[:4]
|
||||
ops = frame[4] if len(frame) > 4 else None
|
||||
totals["worst_interval_ms"] = max(totals["worst_interval_ms"],
|
||||
interval * 1000.0)
|
||||
if ops:
|
||||
for kind, nbytes in ops.items():
|
||||
_bump(totals["op_bytes"], kind, nbytes)
|
||||
if interval >= FREEZE_SECONDS:
|
||||
for kind in ops or ():
|
||||
_bump(totals["op_freezes"], kind)
|
||||
if ops and HANDOVER_OP in ops:
|
||||
# The next screen drawing its first frame, not a scroll
|
||||
# that stalled: counted apart, so the freezes keep
|
||||
# meaning the second. See "What is counted".
|
||||
totals["handover_freezes"] += 1
|
||||
continue
|
||||
totals["freezes"] += 1
|
||||
totals["freeze_seconds"] += interval
|
||||
label = next(name for limit, name in FREEZE_BUCKETS
|
||||
@@ -432,6 +675,10 @@ class FrameTimingRecorder:
|
||||
if period:
|
||||
totals["timed_frames"] += 1
|
||||
missed = round(interval / period) - hold
|
||||
for kind in ops or ():
|
||||
_bump(totals["op_frames"], kind)
|
||||
if missed >= 1:
|
||||
_bump(totals["late_op_frames"], kind)
|
||||
if missed >= 1:
|
||||
totals["late_frames"] += 1
|
||||
totals["missed_refreshes"] += missed
|
||||
@@ -460,6 +707,9 @@ class FrameTimingRecorder:
|
||||
"binding_releases_gil": self._binding_gil,
|
||||
"info": info,
|
||||
"totals": copy.deepcopy(self.totals),
|
||||
# Additive: absent from older files and when no monitor is set.
|
||||
**({"gc": self.gc_monitor.snapshot()}
|
||||
if self.gc_monitor is not None else {}),
|
||||
# JSON keys are strings; readers convert back.
|
||||
"histograms": {name: {str(k): v for k, v in sorted(h.items())}
|
||||
for name, h in self.histograms.items()},
|
||||
@@ -590,14 +840,28 @@ class StallWatchdog:
|
||||
return stall_from, dumped
|
||||
|
||||
def describe(self, ident: int, age: float, late: float) -> str:
|
||||
"""The stack dump: the stalled thread in full, the rest in brief."""
|
||||
"""The stack dump: the stalled thread in full, the rest in brief.
|
||||
|
||||
A stall while a ``handover`` note is still waiting for its frame is
|
||||
the next screen's first ``display()`` taking its time, not a scroll
|
||||
that stopped, and is labelled a handover gap.
|
||||
"""
|
||||
names = {t.ident: t.name for t in threading.enumerate()}
|
||||
frames = sys._current_frames()
|
||||
pending = getattr(self.recorder, "_ops", None)
|
||||
where = ("in a handover gap" if pending and HANDOVER_OP in pending
|
||||
else "mid-scroll")
|
||||
monitor = getattr(self.recorder, "gc_monitor", None)
|
||||
last_long = getattr(monitor, "last_long", None)
|
||||
gc_note = ""
|
||||
if last_long is not None and time.perf_counter() - last_long[0] <= age:
|
||||
gc_note = (f"; a {last_long[1] * 1000.0:.0f}ms garbage collection "
|
||||
"ran inside it")
|
||||
lines = [
|
||||
f"Render stall: no frame for {age * 1000.0:.0f}ms mid-scroll "
|
||||
f"Render stall: no frame for {age * 1000.0:.0f}ms {where} "
|
||||
f"(watchdog woke {late * 1000.0:.0f}ms late"
|
||||
+ ("; the interpreter itself was blocked" if late >= age / 2 else "")
|
||||
+ ")",
|
||||
+ gc_note + ")",
|
||||
f"-- {names.get(ident, ident)} (presents frames):",
|
||||
]
|
||||
stalled = frames.get(ident)
|
||||
|
||||
@@ -157,7 +157,13 @@ def crisp_ladder(
|
||||
#: when 30 was asked for -- being 11% slow is worth far less than looking bad.
|
||||
_STEP_PENALTY = 0.05
|
||||
_SLOW_FPS_PENALTY = 0.25 # below 20fps
|
||||
_LOWISH_FPS_PENALTY = 0.10 # below 25fps
|
||||
_LOWISH_FPS_PENALTY = 0.16 # below 30fps, i.e. "slightly stepped"
|
||||
# Up to 30fps, matching CrispSpeed.steppiness: a measured 125.7Hz panel makes
|
||||
# 50.3px/s (2px every 5 refreshes) 25.1fps, which a 25fps cutoff let through.
|
||||
# 0.16, not less: asked for 50px/s on a 120Hz panel, 48px/s (2px every 5
|
||||
# refreshes, 24fps) costs 0.04 + 0.05 + this, and has to lose to both 60px/s
|
||||
# and 40px/s (1px, smooth, 20% off = 0.20). At 0.10 it won and shipped a
|
||||
# visibly stepped scroll to anyone asking for the default.
|
||||
|
||||
|
||||
def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
|
||||
@@ -173,7 +179,7 @@ def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
|
||||
fps = candidate.frames_per_second
|
||||
if fps < 20:
|
||||
cost += _SLOW_FPS_PENALTY
|
||||
elif fps < 25:
|
||||
elif fps < 30:
|
||||
cost += _LOWISH_FPS_PENALTY
|
||||
return cost
|
||||
|
||||
@@ -474,3 +480,61 @@ def refresh_hz_from_config(global_config: Optional[Dict[str, Any]]) -> float:
|
||||
if not isinstance(hardware, dict):
|
||||
return DEFAULT_REFRESH_HZ
|
||||
return _coerce(hardware.get("limit_refresh_rate_hz")) or DEFAULT_REFRESH_HZ
|
||||
|
||||
|
||||
#: Smooth options offered next to a speed that is not one itself.
|
||||
_ADVICE_ALTERNATIVES = 2
|
||||
|
||||
|
||||
def speed_advice(
|
||||
requested_pixels_per_second: float,
|
||||
refresh_hz: float,
|
||||
min_pixels_per_second: float = MIN_PIXELS_PER_SECOND,
|
||||
max_pixels_per_second: float = MAX_PIXELS_PER_SECOND,
|
||||
) -> Dict[str, Any]:
|
||||
"""What the panel will do with a requested speed, for showing in a UI.
|
||||
|
||||
``applied`` is what :func:`solve_crisp` picks, i.e. what really runs.
|
||||
``smooth`` is true when that is single-pixel-ish, 30fps-or-better motion.
|
||||
``alternatives`` are the smooth ladder entries nearest the request inside
|
||||
the given range, for a click-to-apply suggestion; empty when the request
|
||||
already is one.
|
||||
"""
|
||||
hz = _coerce(refresh_hz) or DEFAULT_REFRESH_HZ
|
||||
requested = max(MIN_PIXELS_PER_SECOND,
|
||||
min(MAX_PIXELS_PER_SECOND, _coerce(requested_pixels_per_second) or 0.0))
|
||||
applied = solve_crisp(requested, hz)
|
||||
|
||||
def as_dict(c: CrispSpeed) -> Dict[str, Any]:
|
||||
return {
|
||||
"pixels_per_second": round(c.pixels_per_second, 1),
|
||||
"pixels_per_frame": c.pixels_per_frame,
|
||||
"frame_hold": c.frame_hold,
|
||||
"frames_per_second": round(c.frames_per_second, 1),
|
||||
"steppiness": c.steppiness,
|
||||
}
|
||||
|
||||
smooth_ladder = [
|
||||
c for c in crisp_ladder(hz)
|
||||
if c.steppiness == "smooth"
|
||||
and min_pixels_per_second <= c.pixels_per_second <= max_pixels_per_second
|
||||
]
|
||||
# 2%: a UI hands over whole numbers, and 63 asked of a 62.9 px/s panel is
|
||||
# as good as exact.
|
||||
exact = abs(applied.pixels_per_second - requested) <= max(0.05, 0.02 * requested)
|
||||
smooth = applied.steppiness == "smooth"
|
||||
alternatives: List[CrispSpeed] = []
|
||||
if not (exact and smooth):
|
||||
alternatives = sorted(
|
||||
smooth_ladder,
|
||||
key=lambda c: abs(c.pixels_per_second - requested),
|
||||
)[:_ADVICE_ALTERNATIVES]
|
||||
alternatives.sort(key=lambda c: c.pixels_per_second)
|
||||
return {
|
||||
"requested": round(requested, 1),
|
||||
"refresh_hz": round(hz, 1),
|
||||
"applied": as_dict(applied),
|
||||
"exact": exact,
|
||||
"smooth": smooth,
|
||||
"alternatives": [as_dict(c) for c in alternatives],
|
||||
}
|
||||
|
||||
+298
-68
@@ -18,7 +18,7 @@ Features:
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
@@ -28,6 +28,39 @@ import numpy as np
|
||||
# long over one frame, so a sample this large is an idle gap between scrolls.
|
||||
FPS_LOG_INTERVAL = 5.0
|
||||
|
||||
# The stats line goes to INFO only when a window is worth an operator's
|
||||
# attention, as Vegas's FPS line does (src/vegas_mode/coordinator.py): every
|
||||
# 5s from every scroller was most of the journal on a healthy rig. A window is
|
||||
# degraded when its frame rate falls below this fraction of the rate it was
|
||||
# locked to (1 / its own median frame time; same 0.9 as Vegas) ...
|
||||
STATS_HEALTHY_FRACTION = 0.9
|
||||
# ... or when more than this share of its frames stalled (past 1.5x the
|
||||
# median). A 1% stall rate barely moves the mean, so the fps test alone would
|
||||
# miss the judder this line exists to show.
|
||||
STATS_DEGRADED_STALL_RATE = 0.01
|
||||
# A healthy scroller still logs at INFO this often, so silence in the journal
|
||||
# means stopped rather than fine. Every window is still logged at DEBUG.
|
||||
STATS_HEARTBEAT_INTERVAL = 300.0
|
||||
|
||||
|
||||
def frame_stats_degraded(stats: Dict[str, Any]) -> bool:
|
||||
"""Whether one frame_stats() window is worth logging at INFO."""
|
||||
n = stats["frames"]
|
||||
if n == 0 or stats["median"] <= 0:
|
||||
return False
|
||||
locked_fps = 1.0 / stats["median"]
|
||||
return (stats["fps"] < locked_fps * STATS_HEALTHY_FRACTION
|
||||
or stats["stalls"] > n * STATS_DEGRADED_STALL_RATE)
|
||||
|
||||
|
||||
def _rgb_pixels(item) -> np.ndarray:
|
||||
"""An appended item's pixels as an RGB array, as pasting it would draw them."""
|
||||
if isinstance(item, np.ndarray):
|
||||
return item
|
||||
if item.mode != 'RGB':
|
||||
item = item.convert('RGB')
|
||||
return np.asarray(item)
|
||||
|
||||
|
||||
def frame_stats(frame_times: list) -> Dict[str, Any]:
|
||||
"""Summary statistics over one window of frame durations (seconds).
|
||||
@@ -111,9 +144,21 @@ class ScrollHelper:
|
||||
self.total_distance_scrolled = 0.0 # Track total distance including wrap-arounds
|
||||
self.scroll_speed = 1.0
|
||||
self.scroll_delay = 0.001 # Minimal delay for high FPS (1ms)
|
||||
self.cached_image: Optional[Image.Image] = None
|
||||
self.cached_image = None # see the property below
|
||||
self.cached_array: Optional[np.ndarray] = None # Numpy array cache for fast operations
|
||||
self.total_scroll_width = 0
|
||||
# An extended strip lives in a buffer with spare room after it, and
|
||||
# cached_array is a view of the buffer's live columns: an append writes
|
||||
# only the new columns, and a trim only moves the view's start. See
|
||||
# append_content. _strip_view is the view this helper last made; a
|
||||
# cached_array that is anything else was set from outside and is not
|
||||
# written through.
|
||||
self._strip_buffer: Optional[np.ndarray] = None
|
||||
self._strip_view: Optional[np.ndarray] = None
|
||||
self._strip_start = 0
|
||||
#: Bytes the last append_content / drop_scrolled_prefix copied, for
|
||||
#: frame-timing attribution (src/common/frame_timing.py note_op).
|
||||
self.last_copy_bytes = 0
|
||||
|
||||
# Pre-allocated buffer for output frame (reused to avoid allocations)
|
||||
self._frame_buffer: Optional[np.ndarray] = None
|
||||
@@ -168,11 +213,62 @@ class ScrollHelper:
|
||||
# Every frame time since the last stats line, so the 5s summary can
|
||||
# report the tail rather than one arbitrary sample. Cleared on log.
|
||||
self._window: list = []
|
||||
# INFO-level stats bookkeeping (see STATS_HEARTBEAT_INTERVAL). Kept
|
||||
# across reset_scroll(): a heartbeat per scroll start would bring the
|
||||
# chatter back. 0.0 so the first window after start-up is at INFO.
|
||||
self._stats_last_info_log = 0.0
|
||||
self._stats_was_degraded = False
|
||||
|
||||
# Scrolling state management
|
||||
self.is_scrolling = False
|
||||
self.scroll_complete = False
|
||||
|
||||
|
||||
# -- the strip as a PIL image ---------------------------------------------
|
||||
#
|
||||
# Every frame is cut from cached_array; nothing on the frame path reads the
|
||||
# PIL image's pixels. Extending and trimming a strip (append_content,
|
||||
# drop_scrolled_prefix) used to rebuild that image in full each time
|
||||
# anyway: Image.fromarray of a Vegas-sized strip is 1.7-3.8ms on a Pi 4,
|
||||
# twice per extension, on the render thread. Those two now leave it to be
|
||||
# built from the array on first read, which in Vegas means only by a
|
||||
# multi-display sync push -- and the strip is not held twice in memory.
|
||||
#
|
||||
# Assigning cached_image still stores exactly what was assigned; a lazy
|
||||
# image is only ever one the helper derived from its own array.
|
||||
|
||||
@property
|
||||
def cached_image(self) -> Optional[Image.Image]:
|
||||
"""The strip as a PIL image, built from ``cached_array`` if deferred."""
|
||||
image = self.__dict__.get('_cached_image')
|
||||
if image is not None:
|
||||
return image
|
||||
source = self.__dict__.get('_image_source')
|
||||
if source is None:
|
||||
return None
|
||||
# Built from the array this read started with. Another thread (the
|
||||
# sync push) may read while the render thread extends the strip; it
|
||||
# then gets the strip as it was, as it did when the image was built
|
||||
# eagerly, and the stale build is not kept.
|
||||
image = Image.fromarray(source)
|
||||
if self.__dict__.get('_image_source') is source:
|
||||
self._cached_image = image
|
||||
return image
|
||||
|
||||
@cached_image.setter
|
||||
def cached_image(self, image: Optional[Image.Image]) -> None:
|
||||
self._cached_image = image
|
||||
self._image_source = None
|
||||
|
||||
def _defer_image(self) -> None:
|
||||
"""The array just changed under the image: rebuild it only if read."""
|
||||
self._cached_image = None
|
||||
self._image_source = self.cached_array
|
||||
|
||||
def has_strip(self) -> bool:
|
||||
"""Whether there is a strip (an image, or one deferred), not reading it."""
|
||||
return (self.__dict__.get('_cached_image') is not None
|
||||
or self.__dict__.get('_image_source') is not None)
|
||||
|
||||
def create_scrolling_image(self, content_items: list,
|
||||
item_gap: int = 32,
|
||||
element_gap: int = 16,
|
||||
@@ -203,6 +299,7 @@ class ScrollHelper:
|
||||
self.total_scroll_width = 0
|
||||
self.cached_image = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
|
||||
self.cached_array = np.array(self.cached_image)
|
||||
self._forget_strip_buffer()
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
self.scroll_complete = False
|
||||
@@ -238,6 +335,7 @@ class ScrollHelper:
|
||||
self.cached_image = full_image
|
||||
# Convert to numpy array for fast operations
|
||||
self.cached_array = np.array(full_image)
|
||||
self._forget_strip_buffer()
|
||||
actual_image_width = full_image.width
|
||||
self.total_scroll_width = actual_image_width
|
||||
|
||||
@@ -283,7 +381,7 @@ class ScrollHelper:
|
||||
Otherwise the position advances by elapsed time at the configured
|
||||
speed.
|
||||
"""
|
||||
if not self.cached_image:
|
||||
if not self.has_strip():
|
||||
return
|
||||
|
||||
# Calculate frame time for consistent scroll speed regardless of FPS
|
||||
@@ -427,7 +525,7 @@ class ScrollHelper:
|
||||
Returns:
|
||||
PIL Image showing the visible portion, or None if no cached image
|
||||
"""
|
||||
if not self.cached_image or self.cached_array is None:
|
||||
if self.cached_array is None or not self.has_strip():
|
||||
return None
|
||||
|
||||
start_x_int = int(self.scroll_position)
|
||||
@@ -463,7 +561,7 @@ class ScrollHelper:
|
||||
width = self.display_width
|
||||
strip_width = self.cached_array.shape[1]
|
||||
|
||||
if start_x + width + 1 <= strip_width:
|
||||
if 0 <= start_x and start_x + width + 1 <= strip_width:
|
||||
# Slice the backing array directly. Going via
|
||||
# _get_visible_portion_integer would build two PIL images only for
|
||||
# them to be converted straight back to arrays, which measured 15x
|
||||
@@ -471,9 +569,10 @@ class ScrollHelper:
|
||||
near = self.cached_array[:, start_x:start_x + width]
|
||||
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
||||
else:
|
||||
# Close enough to the end that one of the slices wraps; let the
|
||||
# integer path handle that and pay the conversion. Continuous mode
|
||||
# extends the strip before reaching here, so this is the rare case.
|
||||
# One of the slices wraps (close to the end, or a strip narrower
|
||||
# than the panel); let the integer path handle that and pay the
|
||||
# conversion. Continuous mode extends the strip before reaching
|
||||
# here, so this is the rare case.
|
||||
near = np.asarray(
|
||||
self._get_visible_portion_integer(start_x, start_x + width))
|
||||
far = np.asarray(
|
||||
@@ -501,33 +600,35 @@ class ScrollHelper:
|
||||
slices (128×32 = 12 KB) used here.
|
||||
"""
|
||||
_size = (self.display_width, self.display_height)
|
||||
img_w = self.cached_image.width
|
||||
img_w = self.cached_array.shape[1]
|
||||
|
||||
if end_x <= img_w:
|
||||
# Normal case: single contiguous slice (fastest path)
|
||||
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
|
||||
return Image.frombytes('RGB', _size, frame_array.tobytes())
|
||||
if 0 <= start_x and end_x <= img_w:
|
||||
# Normal case: single contiguous slice (fastest path). tobytes()
|
||||
# on the column-slice view already returns C-order bytes, so
|
||||
# ascontiguousarray() first only added a second full-frame copy.
|
||||
return Image.frombytes(
|
||||
'RGB', _size,
|
||||
self.cached_array[:, start_x:end_x].tobytes())
|
||||
|
||||
# Ensure frame buffer is allocated for all non-simple paths
|
||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||
|
||||
if img_w == 0:
|
||||
self._frame_buffer[:] = 0
|
||||
else:
|
||||
# Ensure frame buffer is allocated for all non-simple paths
|
||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||
# The frame runs off the strip, so it carries on from the head:
|
||||
# frame column j is strip column (start_x + j) modulo the strip's
|
||||
# width -- the tail and then the head, and a strip narrower than
|
||||
# the panel repeated across it. Copying the tail and then the rest
|
||||
# of the frame from the head assumed the head was that wide, and
|
||||
# raised at every position for a strip narrower than the panel
|
||||
# (Vegas composes one, with no lead-in, when its content is
|
||||
# narrower than the chain).
|
||||
np.take(self.cached_array, np.arange(start_x, end_x), axis=1,
|
||||
mode='wrap', out=self._frame_buffer)
|
||||
|
||||
width1 = img_w - start_x
|
||||
if width1 > 0:
|
||||
# Wrap-around: tail of image + head of image
|
||||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
||||
remaining_width = self.display_width - width1
|
||||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
||||
else:
|
||||
# Edge case: start_x at or past image end — show from beginning,
|
||||
# clamped to available width (scroll_position should wrap before
|
||||
# reaching this state in normal operation).
|
||||
available = min(self.display_width, img_w)
|
||||
self._frame_buffer[:, :available] = self.cached_array[:, :available]
|
||||
if available < self.display_width:
|
||||
self._frame_buffer[:, available:] = 0
|
||||
|
||||
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||
|
||||
def calculate_dynamic_duration(self) -> int:
|
||||
"""
|
||||
@@ -634,7 +735,10 @@ class ScrollHelper:
|
||||
strip also defers completion, which is the intent.
|
||||
|
||||
Args:
|
||||
content_items: Images to append, in order
|
||||
content_items: Images to append, in order. An item may instead be
|
||||
its pixels already as an RGB array (``np.asarray`` of an RGB
|
||||
image), so a caller can do that conversion off the render
|
||||
thread (Vegas prepares its blocks with the group).
|
||||
item_gap: Gap between appended items, and between the existing
|
||||
content and the first appended item
|
||||
element_gap: Extra gap after each item, mirroring
|
||||
@@ -646,42 +750,103 @@ class ScrollHelper:
|
||||
if not content_items:
|
||||
return False
|
||||
|
||||
if self.cached_image is None or self.cached_array is None:
|
||||
if self.cached_array is None or not self.has_strip():
|
||||
# Nothing to extend yet — this is just the first build.
|
||||
self.create_scrolling_image(
|
||||
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
|
||||
[Image.fromarray(item) if isinstance(item, np.ndarray) else item
|
||||
for item in content_items],
|
||||
item_gap=item_gap, element_gap=element_gap, lead_gap=0)
|
||||
return True
|
||||
|
||||
gap = max(0, item_gap)
|
||||
addition_width = (
|
||||
sum(img.width for img in content_items)
|
||||
+ gap * len(content_items) # one leading gap per item
|
||||
+ element_gap * len(content_items)
|
||||
)
|
||||
|
||||
addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0))
|
||||
pieces = []
|
||||
x = 0
|
||||
for img in content_items:
|
||||
for item in content_items:
|
||||
x += gap # separate from whatever precedes
|
||||
addition.paste(img, (x, 0))
|
||||
x += img.width + element_gap
|
||||
pixels = _rgb_pixels(item)
|
||||
pieces.append((x, pixels))
|
||||
x += pixels.shape[1] + element_gap
|
||||
addition_width = x
|
||||
|
||||
# numpy concatenate then one conversion back, rather than allocating a
|
||||
# full-width PIL image and pasting twice: the strip can be tens of
|
||||
# thousands of columns wide and this runs on the render path.
|
||||
self.cached_array = np.concatenate(
|
||||
(self.cached_array, np.array(addition)), axis=1)
|
||||
self.cached_image = Image.fromarray(self.cached_array)
|
||||
self.total_scroll_width = self.cached_image.width
|
||||
# Each item is written straight into the spare room after the strip,
|
||||
# when there is some: the strip can be tens of thousands of columns
|
||||
# wide and this runs on the render thread, where copying all of it
|
||||
# (2-3 ms at 512x64 on a Pi 4) -- or even laying the items out in an
|
||||
# image of their own first (another 4-5 ms) -- cost the frame after
|
||||
# every extension. The PIL image is built from the array only if
|
||||
# something reads it (see cached_image).
|
||||
self.cached_array = self._extended_strip(pieces, addition_width)
|
||||
self._defer_image()
|
||||
self.total_scroll_width = self.cached_array.shape[1]
|
||||
self.scroll_complete = False
|
||||
|
||||
self.logger.info(
|
||||
# Debug: this runs on the render thread, and the caller (Vegas) logs
|
||||
# each extension itself.
|
||||
self.logger.debug(
|
||||
"Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f",
|
||||
len(content_items), addition_width, self.total_scroll_width,
|
||||
self.scroll_position
|
||||
)
|
||||
return True
|
||||
|
||||
#: Room an extended strip's buffer is given, as a multiple of what it
|
||||
#: holds when (re)allocated. Trims free columns at the front and appends
|
||||
#: use them at the back, so with 3x the buffer is reallocated -- the one
|
||||
#: full copy -- about once every two strip-lengths scrolled.
|
||||
STRIP_SPARE_FACTOR = 3.0
|
||||
|
||||
def _extended_strip(self, pieces: List[Tuple[int, np.ndarray]], added: int) -> np.ndarray:
|
||||
"""The strip with ``added`` black columns after it, ``pieces`` drawn in.
|
||||
|
||||
Each piece is ``(x, pixels)``, x counted from the old strip's end;
|
||||
written in place when the buffer has the room.
|
||||
"""
|
||||
live = self.cached_array
|
||||
if live is None:
|
||||
# append_content builds a first strip itself and never comes here.
|
||||
raise RuntimeError("no strip to extend")
|
||||
width = live.shape[1]
|
||||
buffer = self._strip_buffer
|
||||
if (live is self._strip_view and buffer is not None
|
||||
and self._strip_start + width + added <= buffer.shape[1]):
|
||||
end = self._strip_start + width
|
||||
self.last_copy_bytes = 0
|
||||
else:
|
||||
total = width + added
|
||||
buffer = np.empty((live.shape[0], max(total + 1, int(total * self.STRIP_SPARE_FACTOR)))
|
||||
+ live.shape[2:], dtype=live.dtype)
|
||||
buffer[:, :width] = live
|
||||
self._strip_buffer = buffer
|
||||
self._strip_start = 0
|
||||
end = width
|
||||
self.last_copy_bytes = live.nbytes
|
||||
rows = buffer.shape[0]
|
||||
region = buffer[:, end:end + added]
|
||||
# Black only where no piece lands -- the gaps, and below a short
|
||||
# piece: blanking the whole region first cost as much again as
|
||||
# writing the pieces (1.8 ms at 512x64 on a Pi 4).
|
||||
covered = 0
|
||||
for x, pixels in pieces:
|
||||
pixels = pixels[:rows]
|
||||
cols = pixels.shape[1]
|
||||
if x > covered:
|
||||
region[:, covered:x] = 0
|
||||
region[:pixels.shape[0], x:x + cols] = pixels
|
||||
if pixels.shape[0] < rows:
|
||||
region[pixels.shape[0]:, x:x + cols] = 0
|
||||
covered = max(covered, x + cols)
|
||||
if covered < added:
|
||||
region[:, covered:] = 0
|
||||
self.last_copy_bytes += region.nbytes
|
||||
self._strip_view = buffer[:, self._strip_start:self._strip_start + width + added]
|
||||
return self._strip_view
|
||||
|
||||
def _forget_strip_buffer(self) -> None:
|
||||
"""A new strip replaces the extended one: let its buffer go."""
|
||||
self._strip_buffer = None
|
||||
self._strip_view = None
|
||||
self._strip_start = 0
|
||||
|
||||
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
|
||||
"""
|
||||
Discard columns that have already scrolled past, to bound memory.
|
||||
@@ -699,14 +864,15 @@ class ScrollHelper:
|
||||
Returns:
|
||||
Number of columns actually removed
|
||||
"""
|
||||
if self.cached_image is None or self.cached_array is None:
|
||||
if self.cached_array is None or not self.has_strip():
|
||||
return 0
|
||||
strip_width = self.cached_array.shape[1]
|
||||
|
||||
# While the viewport wraps, get_visible_portion fills its right-hand side
|
||||
# from the *head* of the strip, so trimming the head would change what
|
||||
# is on screen. Continuous mode extends before ever reaching that state;
|
||||
# refusing here keeps "trimming is invisible" true unconditionally.
|
||||
if self.scroll_position + self.display_width > self.cached_image.width:
|
||||
if self.scroll_position + self.display_width > strip_width:
|
||||
return 0
|
||||
|
||||
cut = int(self.scroll_position) - max(0, keep_before)
|
||||
@@ -714,15 +880,24 @@ class ScrollHelper:
|
||||
return 0
|
||||
# Never trim so far that the remaining strip is narrower than the
|
||||
# viewport, or get_visible_portion has nothing to slice.
|
||||
cut = min(cut, max(0, self.cached_image.width - self.display_width))
|
||||
cut = min(cut, max(0, strip_width - self.display_width))
|
||||
if cut <= 0:
|
||||
return 0
|
||||
|
||||
# .copy() so the original buffer is released rather than kept alive by
|
||||
# a numpy view.
|
||||
self.cached_array = self.cached_array[:, cut:].copy()
|
||||
self.cached_image = Image.fromarray(self.cached_array)
|
||||
self.total_scroll_width = self.cached_image.width
|
||||
if self.cached_array is self._strip_view:
|
||||
# Only the view's start moves; the columns behind it are reused
|
||||
# when the buffer is next reallocated (append_content).
|
||||
self._strip_view = self.cached_array[:, cut:]
|
||||
self._strip_start += cut
|
||||
self.cached_array = self._strip_view
|
||||
self.last_copy_bytes = 0
|
||||
else:
|
||||
# Not a strip this helper extended: .copy() so the original
|
||||
# buffer is released rather than kept alive by a view.
|
||||
self.cached_array = self.cached_array[:, cut:].copy()
|
||||
self.last_copy_bytes = self.cached_array.nbytes
|
||||
self._defer_image()
|
||||
self.total_scroll_width = self.cached_array.shape[1]
|
||||
self.scroll_position -= cut
|
||||
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
|
||||
|
||||
@@ -732,9 +907,46 @@ class ScrollHelper:
|
||||
)
|
||||
return cut
|
||||
|
||||
def patch_columns(self, x: int, pixels: np.ndarray) -> int:
|
||||
"""Overwrite the strip's columns from ``x`` with ``pixels``, in place.
|
||||
|
||||
What a live Vegas element update is (src/vegas_mode/elements.py): the
|
||||
strip keeps its width, the scroll keeps its position, and only these
|
||||
columns change. Call it between frames on the thread that draws them;
|
||||
every frame copies its slice out of the strip (get_visible_portion),
|
||||
so no frame already handed on can see half a patch.
|
||||
|
||||
Clipped to the strip at both ends. Refused (0) for an array this
|
||||
helper may not write -- the multi-display follower adopts a read-only
|
||||
one -- or for pixels of another height. The PIL image is deferred, so
|
||||
a later read of cached_image shows the patch.
|
||||
|
||||
Args:
|
||||
x: Strip column of the first column of ``pixels``
|
||||
pixels: uint8 array (height, width, 3)
|
||||
|
||||
Returns:
|
||||
Bytes written.
|
||||
"""
|
||||
strip = self.cached_array
|
||||
if strip is None or not strip.flags.writeable:
|
||||
return 0
|
||||
if pixels.ndim != 3 or pixels.shape[0] != strip.shape[0] \
|
||||
or pixels.shape[2] != strip.shape[2]:
|
||||
return 0
|
||||
width = pixels.shape[1]
|
||||
lo, hi = max(0, int(x)), min(strip.shape[1], int(x) + width)
|
||||
if hi <= lo:
|
||||
return 0
|
||||
strip[:, lo:hi] = pixels[:, lo - int(x):hi - int(x)]
|
||||
if self.__dict__.get('_cached_image') is not None \
|
||||
or self.__dict__.get('_image_source') is not None:
|
||||
self._defer_image()
|
||||
return (hi - lo) * strip.shape[0] * strip.shape[2]
|
||||
|
||||
def remaining_unscrolled(self) -> int:
|
||||
"""Columns of strip still to the right of the viewport."""
|
||||
if self.cached_image is None:
|
||||
if not self.has_strip():
|
||||
return 0
|
||||
return max(0, self.total_scroll_width - int(self.scroll_position)
|
||||
- self.display_width)
|
||||
@@ -796,6 +1008,7 @@ class ScrollHelper:
|
||||
|
||||
# Convert to numpy array for fast operations (required for get_visible_portion)
|
||||
self.cached_array = np.array(image)
|
||||
self._forget_strip_buffer()
|
||||
|
||||
# Update scroll width
|
||||
self.total_scroll_width = image.width
|
||||
@@ -1026,10 +1239,23 @@ class ScrollHelper:
|
||||
# as an idle gap. There is nothing to report, and reporting the
|
||||
# gap itself is the bug above.
|
||||
if self._window:
|
||||
self.logger.info(
|
||||
"Scroll frame stats - %s",
|
||||
format_frame_stats(self._window),
|
||||
)
|
||||
# INFO when degraded, on the window that recovers from it, and
|
||||
# as a slow heartbeat; DEBUG otherwise.
|
||||
degraded = frame_stats_degraded(frame_stats(self._window))
|
||||
if (degraded or self._stats_was_degraded
|
||||
or current_time - self._stats_last_info_log
|
||||
>= STATS_HEARTBEAT_INTERVAL):
|
||||
self.logger.info(
|
||||
"Scroll frame stats - %s",
|
||||
format_frame_stats(self._window),
|
||||
)
|
||||
self._stats_last_info_log = current_time
|
||||
elif self.logger.isEnabledFor(logging.DEBUG):
|
||||
self.logger.debug(
|
||||
"Scroll frame stats - %s",
|
||||
format_frame_stats(self._window),
|
||||
)
|
||||
self._stats_was_degraded = degraded
|
||||
self.last_fps_log_time = current_time
|
||||
self.frame_count = 0
|
||||
self._window = []
|
||||
@@ -1053,6 +1279,7 @@ class ScrollHelper:
|
||||
"""
|
||||
self.cached_image = None
|
||||
self.cached_array = None
|
||||
self._forget_strip_buffer()
|
||||
self.total_scroll_width = 0
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
@@ -1082,5 +1309,8 @@ class ScrollHelper:
|
||||
'elapsed_time': (time.time() - self.scroll_start_time)
|
||||
if self.scroll_start_time
|
||||
else None,
|
||||
'cached_image_size': (self.cached_image.width, self.cached_image.height) if self.cached_image else None
|
||||
# From the array: reading cached_image would build a deferred one.
|
||||
'cached_image_size': ((self.cached_array.shape[1], self.cached_array.shape[0])
|
||||
if self.cached_array is not None and self.has_strip()
|
||||
else None)
|
||||
}
|
||||
|
||||
@@ -26,14 +26,33 @@ Policy:
|
||||
- Unchanged frames are never re-encoded; the mtime is touched every
|
||||
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
|
||||
|
||||
The writer and the SSE reader (web_interface/app.py) have two periods, not
|
||||
one shared value. The reader sends each write it sees, so the preview shows
|
||||
at most one frame per VIEWER_INTERVAL. (That was 0.2 s while the reader
|
||||
slept 1 s between reads, so four encodes in five were overwritten unread.)
|
||||
The reader checks the file's mtime every VIEWER_POLL_INTERVAL, which is only
|
||||
a stat, and so sends each write within that long of it landing. Equal
|
||||
periods would alias: two unsynchronised 1 s clocks leave the preview up to a
|
||||
second stale, and now and then 2 s between frames.
|
||||
|
||||
decide() is monotone in frame_changed: SKIP for a changed frame means SKIP
|
||||
for an unchanged one. DisplayManager relies on that to skip hashing the
|
||||
frame when even a changed one would be skipped; test_snapshot_policy.py
|
||||
checks it.
|
||||
|
||||
If any constant here changes, re-check the health threshold in
|
||||
api_v3/misc.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# Snapshot cadence with a browser preview open (seconds).
|
||||
VIEWER_INTERVAL = 0.2
|
||||
# Snapshot cadence with a browser preview open (seconds): the shortest gap
|
||||
# between two preview frames.
|
||||
VIEWER_INTERVAL = 1.0
|
||||
# How often the web SSE reader checks the snapshot's mtime (seconds). Must
|
||||
# stay well under VIEWER_INTERVAL -- half of it at most -- or the two clocks
|
||||
# alias (see above).
|
||||
VIEWER_POLL_INTERVAL = 0.25
|
||||
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
||||
IDLE_INTERVAL = 30.0
|
||||
# Max age of the last write/touch before bumping mtime for the health
|
||||
|
||||
@@ -18,7 +18,7 @@ the extra guard only stops a None size raising TypeError.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -338,10 +338,46 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||
if not raw:
|
||||
return ""
|
||||
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game))
|
||||
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game),
|
||||
game=game)
|
||||
|
||||
|
||||
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
||||
def _printed_weekday(game: Optional[Dict], month: int, day: int) -> str:
|
||||
"""The weekday of the date a card prints as month/day, or '' if unknown.
|
||||
|
||||
The extractor prints "M/D" in the plugin's resolved zone (its own setting,
|
||||
else the global one, else the system zone). The card cannot see that zone:
|
||||
it is handed the plugin's config, whose ``timezone`` ships as "", so
|
||||
card_tzinfo answers UTC and an evening kickoff in the Americas got the
|
||||
next day's weekday ("Sat Oct 2" for a Friday game). Every zone is within
|
||||
a day of UTC, so the printed date is the start's UTC date or a neighbour
|
||||
of it; the one with that month and day is the date on the card.
|
||||
"""
|
||||
if not isinstance(game, dict):
|
||||
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"))
|
||||
if start.utcoffset() is None:
|
||||
return "" # naive: no instant to place the date against
|
||||
utc_day = start.astimezone(timezone.utc).date()
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return ""
|
||||
for offset in (0, -1, 1):
|
||||
try:
|
||||
candidate = utc_day + timedelta(days=offset)
|
||||
except OverflowError:
|
||||
continue
|
||||
if (candidate.month, candidate.day) == (month, day):
|
||||
return WEEKDAY_ABBR[candidate.weekday()]
|
||||
return ""
|
||||
|
||||
|
||||
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR,
|
||||
game: Optional[Dict] = None) -> str:
|
||||
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
|
||||
|
||||
The body both date formatters share. They differ in which setting names the
|
||||
@@ -349,6 +385,9 @@ def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
||||
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
|
||||
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
||||
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
|
||||
With *game*, the "weekday" style names the printed date's own weekday
|
||||
(:func:`_printed_weekday`), and *weekday* is only the fallback for a
|
||||
date its start time cannot place.
|
||||
"""
|
||||
if fmt == "numeric":
|
||||
return raw
|
||||
@@ -364,7 +403,7 @@ def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
||||
if fmt == "day_first":
|
||||
return f"{day} {name}"
|
||||
if fmt == "weekday":
|
||||
day_name = weekday()
|
||||
day_name = _printed_weekday(game, month, day) or weekday()
|
||||
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
||||
return f"{name} {day}"
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Which games a scoreboard shows, for how long, and what its scorebug dates say.
|
||||
|
||||
Four ``sports.py`` methods are identical (executable AST, docstrings
|
||||
stripped, decorators compared) in every scoreboard that carries them, and
|
||||
were copied here from ledmatrix-plugins ``56c4f15`` (origin/main,
|
||||
2026-09-30) under their existing names. They split into two mixins because
|
||||
their carriers differ, and a plugin should not gain an override it did not
|
||||
have:
|
||||
|
||||
``SportsCardOptionsMixin`` -- afl, baseball, basketball, football, hockey,
|
||||
lacrosse, nrl and soccer (ufc draws no team scorebug):
|
||||
|
||||
- ``_card_option`` -- reads one ``scroll_card`` key through
|
||||
``SportsCoreSharedMixin._card_option``, but never lets the upcoming
|
||||
scorebug lose both its date and its time (the combination a settings-form
|
||||
bug saved for a whole cohort of boards);
|
||||
- ``_recent_date_text`` -- the date line of the full-screen recent scorebug.
|
||||
|
||||
``SportsGameRulesMixin`` -- all nine:
|
||||
|
||||
- ``_filtered_or_all`` (all but football, which has no such method) -- the
|
||||
quality and division filters on a board with no favourites, failing open
|
||||
to every game rather than a blank panel;
|
||||
- ``_effective_live_duration`` (all but ufc, which has none) -- how long a
|
||||
live game stays up: ``non_favorite_live_game_duration`` for a
|
||||
non-favourite when favourites are set, else ``game_display_duration``.
|
||||
afl, nrl and soccer carry it on ``SportsCore``, the other five on
|
||||
``SportsLive``; the bodies are the same.
|
||||
|
||||
The plugin missing a method gains one it never calls, which changes nothing:
|
||||
nothing in that plugin, nor in core, calls it.
|
||||
|
||||
A new module rather than more methods on ``sports_shared``, for the reason
|
||||
``sports_helpers`` gives: a missing module fails at load, where the version
|
||||
checks see it; a missing method fails mid-update.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
Derived by walking every ``self.<attr>`` the mixins read; the host-contract
|
||||
test in ``test/test_sports_display_rules.py`` fails if a read is added
|
||||
without being listed here.
|
||||
|
||||
``SportsCardOptionsMixin``:
|
||||
|
||||
- ``SportsCoreSharedMixin`` (``src.common.sports_shared``) in the MRO
|
||||
**after** this mixin: ``_card_option`` calls that mixin's ``_card_option``
|
||||
and ``_switch_upcoming_center`` by name, and ``_recent_date_text`` its
|
||||
``_format_game_date``. List this mixin first --
|
||||
``class SportsCore(SportsCardOptionsMixin, SportsGameRulesMixin,
|
||||
SportsFetchMixin, SportsCoreSharedMixin, SportsHelpersMixin, ABC)`` --
|
||||
or ``SportsCoreSharedMixin._card_option`` wins and the rescue is lost.
|
||||
Through it: ``config`` (the ``scroll_card`` block it reads).
|
||||
|
||||
``SportsGameRulesMixin``:
|
||||
|
||||
- ``_passes_other_filters(game)`` -- the plugin's own quality/division
|
||||
filter (``_filtered_or_all``).
|
||||
- ``_check_ranking_coverage(games)`` -- from ``SportsCoreSharedMixin``.
|
||||
- ``favorite_teams``, ``game_display_duration`` and
|
||||
``_is_favorite_game(game)``; ``non_favorite_live_game_duration`` read with
|
||||
``getattr`` (``_effective_live_duration``).
|
||||
|
||||
Neither mixin has an ``__init__`` or state. A method on the plugin's own
|
||||
class still wins over either.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from src.common.sports_shared import SportsCoreSharedMixin
|
||||
|
||||
|
||||
class SportsCardOptionsMixin:
|
||||
"""The scorebug's ``scroll_card`` reads. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only.
|
||||
_format_game_date: Callable[..., str]
|
||||
|
||||
def _card_option(self, key: str, default: Any = None) -> Any:
|
||||
"""Read one scroll_card key, never blanking the upcoming scorebug.
|
||||
|
||||
With the middle set to "date and time" and both of those lines
|
||||
switched off, the full-screen upcoming scorebug is two logos and
|
||||
"Next Game" with nothing to say when the game is. Nobody picks that
|
||||
on purpose -- "vs" and "none" are the settings for a card without the
|
||||
stack -- yet a whole cohort of boards has it: switch_show_date/_time
|
||||
shipped while the core's settings form still drew keys missing from
|
||||
the saved config as unchecked boxes, so the next Save wrote both as
|
||||
false (fixed in LEDMatrix #597). That one combination therefore reads
|
||||
as both on. Hiding either line alone, or both under "vs" or "none",
|
||||
is still honoured.
|
||||
"""
|
||||
# The mixin named outright, not super(): tests lift this method onto
|
||||
# stand-in classes that are not SportsCore subclasses.
|
||||
base = SportsCoreSharedMixin._card_option
|
||||
keys = ("switch_show_date", "switch_show_time")
|
||||
value = base(self, key, default) # type: ignore[arg-type]
|
||||
if (key in keys and not value
|
||||
and not any(base(self, k, True) for k in keys) # type: ignore[arg-type]
|
||||
and SportsCoreSharedMixin._switch_upcoming_center(self) == "date_time"): # type: ignore[arg-type]
|
||||
return True
|
||||
return value
|
||||
|
||||
def _recent_date_text(self, game: Optional[Dict]) -> str:
|
||||
"""When a finished game was played, for the full-screen scorebug.
|
||||
|
||||
Formatted by switch_date_format, like the upcoming scorebug, so the
|
||||
two dates on this display agree; its "numeric" default returns the
|
||||
extractor's "9/23" unchanged. ``switch_recent_show_date`` (default
|
||||
true) is the off switch.
|
||||
"""
|
||||
if not self._card_option("switch_recent_show_date", True):
|
||||
return ""
|
||||
return self._format_game_date(str((game or {}).get("game_date") or ""), game)
|
||||
|
||||
|
||||
class SportsGameRulesMixin:
|
||||
"""Which games are worth showing, and for how long. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only.
|
||||
favorite_teams: List[str]
|
||||
game_display_duration: float
|
||||
_passes_other_filters: Callable[[Dict], bool]
|
||||
_check_ranking_coverage: Callable[[List[Dict]], None]
|
||||
_is_favorite_game: Callable[[Dict], bool]
|
||||
|
||||
def _filtered_or_all(self, games: List[Dict]) -> List[Dict]:
|
||||
"""The games worth watching, or all of them if that leaves none.
|
||||
|
||||
With no favourites configured every game selected is a non-favourite
|
||||
game, so the quality and division settings have to apply here too. They
|
||||
governed only the top-up slice, which this branch never uses, so a
|
||||
board with an empty favourites list had both settings silently inert --
|
||||
it could ask for ranked games only and still get the next N kickoffs.
|
||||
|
||||
Fails open as a whole, not just per check. `_passes_other_filters`
|
||||
allows a game whose data could not be resolved, but a filter working
|
||||
exactly as asked can still match nothing on a given day, and here there
|
||||
is no favourite left to carry the mode -- an empty list is a blank
|
||||
panel rather than a short one.
|
||||
"""
|
||||
kept = [g for g in games if self._passes_other_filters(g)]
|
||||
self._check_ranking_coverage(games)
|
||||
return kept or games
|
||||
|
||||
def _effective_live_duration(self, game) -> float:
|
||||
"""How long the given live game should stay on screen before rotating.
|
||||
|
||||
Non-favorite live games use non_favorite_live_game_duration, but only
|
||||
when it is set (> 0) AND favorite teams are configured. With no favorites
|
||||
(or the knob at 0) every live game uses game_display_duration - identical
|
||||
to the prior single-duration behavior. When show_favorite_teams_only is
|
||||
on, non-favorite games are never shown, so this naturally never fires."""
|
||||
non_fav = getattr(self, "non_favorite_live_game_duration", 0) or 0
|
||||
if (
|
||||
non_fav > 0
|
||||
and self.favorite_teams
|
||||
and game is not None
|
||||
and not self._is_favorite_game(game)
|
||||
):
|
||||
return non_fav
|
||||
return self.game_display_duration
|
||||
|
||||
|
||||
__all__ = ["SportsCardOptionsMixin", "SportsGameRulesMixin"]
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Which games involve a favourite team, and which of them to show (sports family 6).
|
||||
|
||||
The scoreboards' favourite matching, reconciled in ledmatrix-plugins
|
||||
(family 6) from seven ``_is_favorite_game`` bodies, two
|
||||
``_select_games_for_display`` and three ``_select_recent_games_for_display``
|
||||
into one each, and copied here under their existing names:
|
||||
|
||||
- ``SportsFavoritesMixin`` (``SportsCore``): ``_is_favorite_game(game)``,
|
||||
asked by ``SportsCoreSharedMixin._favorites_first``, the switch-mode
|
||||
favourite boost (``SportsHelpersMixin._next_switch_index``), the
|
||||
non-favourite live dwell (``SportsGameRulesMixin._effective_live_duration``)
|
||||
and the plugins' live rotation; and ``_favorite_code(value)``, the
|
||||
normalisation both sides of every comparison go through.
|
||||
- ``SportsUpcomingFavoritesMixin`` (``SportsUpcoming``):
|
||||
``_select_games_for_display``, the favourites-only pick of upcoming games.
|
||||
- ``SportsRecentFavoritesMixin`` (``SportsRecent``):
|
||||
``_select_recent_games_for_display``, the same for finished games, most
|
||||
recent first.
|
||||
|
||||
Each mixin carries only what its class already had, so no manager gains a
|
||||
method it did not have.
|
||||
|
||||
THE RULE
|
||||
--------
|
||||
Each side of a game is named by ``_favorite_key(game, side)``, the override
|
||||
point ``SportsHelpersMixin`` (``src.common.sports_helpers``) has carried since
|
||||
3.5.0: the team abbreviation by default. A sport whose abbreviations are not
|
||||
unique overrides it -- NRL returns the ESPN team id (and None when the id is
|
||||
missing), because "NEW" is both Newcastle and New Zealand. That value and every
|
||||
entry of ``favorite_teams`` are compared as ``_favorite_code`` leaves them:
|
||||
stripped and upper-cased, a blank or missing value matching nothing. So
|
||||
" bos" in the config matches BOS.
|
||||
|
||||
The selection methods give each favourite team up to the per-team limit
|
||||
(``upcoming_games_to_show`` / ``recent_games_to_show``); a game between two
|
||||
favourites counts for both. Only a game with an id can be a duplicate: two
|
||||
games without one are two games.
|
||||
|
||||
A new module rather than more methods on ``sports_shared`` or
|
||||
``sports_helpers``, for the reason ``sports_helpers`` gives: a missing module
|
||||
fails at load, where the version checks see it; a missing method fails
|
||||
mid-update.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
Derived by walking every ``self.<attr>`` the mixins read; the host-contract
|
||||
test in ``test/test_sports_favorites.py`` fails if a read is added without
|
||||
being listed here.
|
||||
|
||||
- ``favorite_teams`` -- the resolved favourites list (``_is_favorite_game``).
|
||||
The selection methods are handed the list instead.
|
||||
- ``_favorite_key`` -- ``SportsHelpersMixin`` supplies the default.
|
||||
- ``_favorite_code`` -- from ``SportsFavoritesMixin``, which the Upcoming and
|
||||
Recent classes inherit through their ``SportsCore``.
|
||||
- ``logger`` -- the selection methods log each pick at DEBUG and a summary at
|
||||
INFO.
|
||||
- ``upcoming_games_to_show`` (Upcoming) and ``recent_games_to_show`` (Recent)
|
||||
-- the per-team limits.
|
||||
|
||||
The methods read the game dict's ``id`` and ``start_time_utc`` (selection),
|
||||
whatever ``_favorite_key`` reads (``home_abbr`` / ``away_abbr`` by default),
|
||||
and ``home_abbr`` / ``away_abbr`` again for the DEBUG line; any may be missing.
|
||||
|
||||
BASE ORDER
|
||||
----------
|
||||
No other mixin defines these methods, so the position in the bases does not
|
||||
change which body runs; a method on the plugin's own class still wins. The
|
||||
mixins have no ``__init__`` and no state.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
|
||||
class SportsFavoritesMixin:
|
||||
"""``SportsCore``'s favourite check. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only.
|
||||
favorite_teams: List[str]
|
||||
_favorite_key: Callable[[Dict, str], Optional[str]]
|
||||
|
||||
@staticmethod
|
||||
def _favorite_code(value) -> Optional[str]:
|
||||
"""``value`` as favourites are compared: stripped and upper-cased.
|
||||
|
||||
None for a missing or blank value, which matches nothing.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).strip().upper() or None
|
||||
|
||||
def _is_favorite_game(self, game: Dict) -> bool:
|
||||
"""Does either side of this game belong to a favourite team?
|
||||
|
||||
``_favorite_key`` names each side (the abbreviation; nrl overrides it
|
||||
with the ESPN team id), and both it and ``favorite_teams`` are compared
|
||||
as ``_favorite_code`` normalises them, so " bos" matches BOS.
|
||||
"""
|
||||
favorites = {self._favorite_code(team) for team in self.favorite_teams or ()}
|
||||
favorites.discard(None)
|
||||
return any(
|
||||
self._favorite_code(self._favorite_key(game, side)) in favorites
|
||||
for side in ("home", "away")
|
||||
)
|
||||
|
||||
|
||||
class SportsUpcomingFavoritesMixin:
|
||||
"""``SportsUpcoming``'s favourites-only pick. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only.
|
||||
logger: logging.Logger
|
||||
upcoming_games_to_show: int
|
||||
_favorite_key: Callable[[Dict, str], Optional[str]]
|
||||
_favorite_code: Callable[[object], Optional[str]]
|
||||
|
||||
def _select_games_for_display(
|
||||
self, processed_games: List[Dict], favorite_teams: List[str]
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Single-pass game selection with proper deduplication and counting.
|
||||
|
||||
When a game involves two favorite teams, it counts toward BOTH teams' limits.
|
||||
This prevents unexpected game counts from the multi-pass algorithm.
|
||||
Teams are matched as _is_favorite_game matches them. Only a game with
|
||||
an id can be a duplicate: two games without one are two games.
|
||||
"""
|
||||
sorted_games = sorted(
|
||||
processed_games,
|
||||
key=lambda g: g.get("start_time_utc")
|
||||
or datetime.max.replace(tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
if not favorite_teams:
|
||||
return sorted_games
|
||||
|
||||
selected_games = []
|
||||
selected_ids = set()
|
||||
team_counts: Dict[Optional[str], int] = {
|
||||
code: 0 for code in map(self._favorite_code, favorite_teams) if code
|
||||
}
|
||||
|
||||
for game in sorted_games:
|
||||
game_id = game.get("id")
|
||||
if game_id is not None and game_id in selected_ids:
|
||||
continue
|
||||
|
||||
home = self._favorite_code(self._favorite_key(game, "home"))
|
||||
away = self._favorite_code(self._favorite_key(game, "away"))
|
||||
|
||||
home_fav = home in team_counts
|
||||
away_fav = away in team_counts
|
||||
|
||||
if not home_fav and not away_fav:
|
||||
continue
|
||||
|
||||
home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show
|
||||
away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show
|
||||
|
||||
if home_needs or away_needs:
|
||||
selected_games.append(game)
|
||||
if game_id is not None:
|
||||
selected_ids.add(game_id)
|
||||
if home_fav:
|
||||
team_counts[home] += 1
|
||||
if away_fav:
|
||||
team_counts[away] += 1
|
||||
|
||||
self.logger.debug(
|
||||
f"Selected game {game.get('away_abbr')}@{game.get('home_abbr')}: "
|
||||
f"team_counts={team_counts}"
|
||||
)
|
||||
|
||||
if all(c >= self.upcoming_games_to_show for c in team_counts.values()):
|
||||
self.logger.debug("All favorite teams satisfied, stopping selection")
|
||||
break
|
||||
|
||||
self.logger.info(
|
||||
f"Selected {len(selected_games)} games for {len(favorite_teams)} "
|
||||
f"favorite teams: {team_counts}"
|
||||
)
|
||||
return selected_games
|
||||
|
||||
|
||||
class SportsRecentFavoritesMixin:
|
||||
"""``SportsRecent``'s favourites-only pick. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only.
|
||||
logger: logging.Logger
|
||||
recent_games_to_show: int
|
||||
_favorite_key: Callable[[Dict, str], Optional[str]]
|
||||
_favorite_code: Callable[[object], Optional[str]]
|
||||
|
||||
def _select_recent_games_for_display(
|
||||
self, processed_games: List[Dict], favorite_teams: List[str]
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Single-pass game selection for recent games with proper deduplication.
|
||||
|
||||
When a game involves two favorite teams, it counts toward BOTH teams' limits.
|
||||
Games are sorted by most recent first.
|
||||
Teams are matched as _is_favorite_game matches them. Only a game with
|
||||
an id can be a duplicate: two games without one are two games.
|
||||
"""
|
||||
sorted_games = sorted(
|
||||
processed_games,
|
||||
key=lambda g: g.get("start_time_utc")
|
||||
or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
if not favorite_teams:
|
||||
return sorted_games
|
||||
|
||||
selected_games = []
|
||||
selected_ids = set()
|
||||
team_counts: Dict[Optional[str], int] = {
|
||||
code: 0 for code in map(self._favorite_code, favorite_teams) if code
|
||||
}
|
||||
|
||||
for game in sorted_games:
|
||||
game_id = game.get("id")
|
||||
if game_id is not None and game_id in selected_ids:
|
||||
continue
|
||||
|
||||
home = self._favorite_code(self._favorite_key(game, "home"))
|
||||
away = self._favorite_code(self._favorite_key(game, "away"))
|
||||
|
||||
home_fav = home in team_counts
|
||||
away_fav = away in team_counts
|
||||
|
||||
if not home_fav and not away_fav:
|
||||
continue
|
||||
|
||||
home_needs = home_fav and team_counts[home] < self.recent_games_to_show
|
||||
away_needs = away_fav and team_counts[away] < self.recent_games_to_show
|
||||
|
||||
if home_needs or away_needs:
|
||||
selected_games.append(game)
|
||||
if game_id is not None:
|
||||
selected_ids.add(game_id)
|
||||
if home_fav:
|
||||
team_counts[home] += 1
|
||||
if away_fav:
|
||||
team_counts[away] += 1
|
||||
|
||||
self.logger.debug(
|
||||
f"Selected recent game {game.get('away_abbr')}@{game.get('home_abbr')}: "
|
||||
f"team_counts={team_counts}"
|
||||
)
|
||||
|
||||
if all(c >= self.recent_games_to_show for c in team_counts.values()):
|
||||
self.logger.debug("All favorite teams satisfied, stopping selection")
|
||||
break
|
||||
|
||||
self.logger.info(
|
||||
f"Selected {len(selected_games)} recent games for {len(favorite_teams)} "
|
||||
f"favorite teams: {team_counts}"
|
||||
)
|
||||
return selected_games
|
||||
|
||||
|
||||
__all__ = ["SportsFavoritesMixin", "SportsUpcomingFavoritesMixin", "SportsRecentFavoritesMixin"]
|
||||
@@ -40,6 +40,20 @@ listed here.
|
||||
- ``live_games``, read with ``getattr`` -- ``_needs_previous_day``.
|
||||
- ``background_service``, read with ``getattr`` --
|
||||
``_background_fetches_espn_ranges``.
|
||||
- ``sport`` and ``league`` (ESPN's path segments, e.g. ``football`` /
|
||||
``nfl``) -- ``_schedule_cache_key``, and ``_fetch_season_directly`` when
|
||||
it is given no key and cannot read one from its URL.
|
||||
|
||||
THE SCHEDULE CACHE KEY (fetch service stage 2)
|
||||
----------------------------------------------
|
||||
``_schedule_cache_key`` names a schedule window with the canonical
|
||||
``espn_scoreboard_cache_key`` instead of a plugin-built
|
||||
``{sport_key}_schedule_{window}``, and ``_cached_schedule`` reads it with the
|
||||
old key as a fallback for one release, so an upgrade serves the copy already
|
||||
on disk instead of refetching every league at once. The canonical key
|
||||
carries the window's dates, so it moves on a day as the window slides; a
|
||||
miss on it also deletes the copy for the day before, so a league keeps one
|
||||
window file instead of a week of them.
|
||||
|
||||
Add it as a base of the plugin's ``SportsCore``, e.g.
|
||||
``class SportsCore(SportsFetchMixin, SportsCoreSharedMixin,
|
||||
@@ -50,9 +64,31 @@ constant on the plugin's own class still wins over the mixin's.
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, ClassVar, Dict, Optional
|
||||
from typing import Any, ClassVar, Dict, Iterable, Optional
|
||||
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard
|
||||
from src.common.espn_dates import (
|
||||
ESPN_MAX_LIMIT,
|
||||
espn_scoreboard_cache_key,
|
||||
espn_scoreboard_cache_key_for_url,
|
||||
fetch_espn_scoreboard,
|
||||
parse_espn_date_range,
|
||||
)
|
||||
from src.common.fetch_service import get_fetch_service
|
||||
|
||||
_ESPN_SITE = "https://site.api.espn.com/"
|
||||
|
||||
|
||||
def _previous_window_key(cache_key: str) -> Optional[str]:
|
||||
"""The canonical key of the same window one day earlier, or None when
|
||||
``cache_key`` is not a canonical day-range key."""
|
||||
head, sep, dates = cache_key.rpartition("_")
|
||||
if not sep or not head.startswith("espn_scoreboard_"):
|
||||
return None
|
||||
span = parse_espn_date_range(dates)
|
||||
if span is None:
|
||||
return None
|
||||
start, end = (day - timedelta(days=1) for day in span)
|
||||
return f"{head}_{start.strftime('%Y%m%d')}-{end.strftime('%Y%m%d')}"
|
||||
|
||||
|
||||
class SportsFetchMixin:
|
||||
@@ -65,6 +101,8 @@ class SportsFetchMixin:
|
||||
cache_manager: Any
|
||||
logger: logging.Logger
|
||||
_games_lock: threading.RLock
|
||||
sport: str
|
||||
league: str
|
||||
|
||||
#: How many games past the one on screen keep their odds warm. One is
|
||||
#: enough for the line to be ready when the rotation advances; more just
|
||||
@@ -154,18 +192,66 @@ class SportsFetchMixin:
|
||||
service = getattr(self, "background_service", None)
|
||||
return bool(getattr(service, "handles_espn_date_ranges", False))
|
||||
|
||||
def _schedule_cache_key(self, datestring: str) -> str:
|
||||
"""The canonical cache key for this league's schedule over
|
||||
``datestring`` (``espn_scoreboard_cache_key``)."""
|
||||
return espn_scoreboard_cache_key(self.sport, self.league, datestring)
|
||||
|
||||
def _cached_schedule(self, cache_key: str, legacy_keys: Iterable[str] = ()) -> Any:
|
||||
"""What ``self.cache_manager.get(cache_key)`` returns, falling back
|
||||
to each of ``legacy_keys`` (the plugin's pre-canonical keys) in turn.
|
||||
|
||||
The same read the managers made before -- same default max age, a
|
||||
stored ttl still wins -- so moving to the canonical key changes
|
||||
where a schedule is cached, not for how long. A read from an old key
|
||||
is counted (``legacy_cache_hits``) so it is visible when the
|
||||
fallback can go. A miss on the canonical key also deletes the same
|
||||
window's copy from the day before (see the module docstring).
|
||||
"""
|
||||
cached = self.cache_manager.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
self._retire_previous_window(cache_key)
|
||||
for legacy in legacy_keys:
|
||||
if not legacy or legacy == cache_key:
|
||||
continue
|
||||
cached = self.cache_manager.get(legacy)
|
||||
if cached:
|
||||
try:
|
||||
get_fetch_service().note_cache_hit(
|
||||
_ESPN_SITE, legacy=True, avoided_request=False)
|
||||
except Exception: # noqa: BLE001 - counting never breaks a read
|
||||
pass
|
||||
return cached
|
||||
return None
|
||||
|
||||
def _retire_previous_window(self, cache_key: str) -> None:
|
||||
previous = _previous_window_key(cache_key)
|
||||
delete = getattr(self.cache_manager, "delete", None)
|
||||
if previous is None or not callable(delete):
|
||||
return
|
||||
try:
|
||||
delete(previous)
|
||||
except Exception as e: # noqa: BLE001 - housekeeping only
|
||||
self.logger.debug(f"Could not delete old schedule copy {previous}: {e}")
|
||||
|
||||
def _fetch_season_directly(
|
||||
self,
|
||||
url: str,
|
||||
datestring: str,
|
||||
cache_key: str,
|
||||
cache_key: Optional[str],
|
||||
label: str,
|
||||
ttl: Optional[int] = None,
|
||||
) -> Optional[Dict]:
|
||||
"""Fetch a season schedule on this thread, in chunks ESPN accepts, and cache it.
|
||||
|
||||
``label`` names the schedule in log lines, e.g. ``"2026 season"``.
|
||||
``cache_key=None`` caches it under the canonical key
|
||||
(``espn_scoreboard_cache_key`` for ``url``'s sport and league).
|
||||
"""
|
||||
if cache_key is None:
|
||||
cache_key = (espn_scoreboard_cache_key_for_url(url, datestring)
|
||||
or self._schedule_cache_key(datestring))
|
||||
try:
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Where a scoreboard's bundled font file is, whatever the working directory.
|
||||
|
||||
Every scoreboard's ``sports.py`` (nine) and ``game_renderer.py`` (eight)
|
||||
carries the same module-level ``_resolve_font_path``. It predates
|
||||
:func:`src.common.font_layout.resolve_asset_path`, and probes the core for
|
||||
it: the path as given when it exists (relative to the cwd), else the core's
|
||||
resolver (``FontManager._resolve_asset_path``, which delegates to
|
||||
``resolve_asset_path``), else the path joined to the install root, else the
|
||||
path unchanged so the caller's ``ImageFont.truetype`` raises and falls back
|
||||
as before.
|
||||
|
||||
On every core this module ships in, the probe always finds the resolver, and
|
||||
the install-root join repeats what the resolver already tried. What is left
|
||||
is two steps, and :func:`resolve_font_path` is exactly those: the cwd first,
|
||||
then ``resolve_asset_path``. ``test/test_sports_font_path.py`` checks that
|
||||
against the plugins' own copies, path for path. It is the same rule as
|
||||
``sports_shared._resolve_font_path``, made public so a plugin can import it.
|
||||
|
||||
Why not ``resolve_asset_path`` alone: it never consults the cwd, so a
|
||||
process started from another checkout would switch to the install root's
|
||||
fonts. Keeping the cwd first keeps that behaviour exactly.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from src.common.font_layout import resolve_asset_path
|
||||
|
||||
|
||||
def resolve_font_path(path: str) -> str:
|
||||
"""``path`` if it exists, else :func:`resolve_asset_path` of it.
|
||||
|
||||
Absolute paths that exist come back untouched; a relative path is tried
|
||||
against the cwd, then the install root; a path found nowhere comes back
|
||||
unchanged, so the caller still raises and falls back.
|
||||
"""
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return resolve_asset_path(path)
|
||||
|
||||
|
||||
__all__ = ["resolve_font_path"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Whether a game ESPN still lists as live has in fact ended (sports family 5).
|
||||
|
||||
``SportsGameOverMixin._is_game_really_over`` is the scoreboards'
|
||||
``SportsLive._is_game_really_over``, reconciled in ledmatrix-plugins
|
||||
#625 from five bodies into one and copied here under
|
||||
its existing name. ``SportsLiveSharedMixin._detect_stale_games``
|
||||
(``src.common.sports_shared``) calls it on every live game, and the plugins'
|
||||
live-priority filters call it too, to drop a game ESPN still reports as
|
||||
in progress.
|
||||
|
||||
A game is over when its period text says final. From period ``FINAL_PERIOD``
|
||||
on, a clock reading 0:00 ends it too, unless the score is level: a tie at the
|
||||
end of regulation goes to overtime (or a shootout), and a game that does end
|
||||
tied says final. Only a clock *string* is read ("0:00" and ":00" are zero;
|
||||
":40", "0.0" and ESPN's "-" between MMA rounds are not), and a missing or
|
||||
unreadable score leaves the decision to the clock.
|
||||
|
||||
``FINAL_PERIOD`` is the one per-sport fact, a class attribute rather than a
|
||||
sport-name branch. The scoreboards declare it on their ``SportsLive``:
|
||||
|
||||
- 3: hockey;
|
||||
- 4: basketball, football, lacrosse;
|
||||
- ``None`` (this default; the clock never ends a game): afl, nrl and soccer,
|
||||
whose clocks count up; baseball, which has innings; ufc, whose bouts end
|
||||
only on ESPN's final status.
|
||||
|
||||
A sport can still override the method and defer to it, as baseball's
|
||||
``BaseballLive`` does to end postponed and suspended games first.
|
||||
|
||||
A new module rather than another method on ``sports_shared``, for the reason
|
||||
``sports_helpers`` gives: a missing module fails at load, where the version
|
||||
checks see it; a missing method fails mid-update.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||
test in ``test/test_sports_game_over.py`` fails if a read is added without
|
||||
being listed here.
|
||||
|
||||
- ``logger`` -- a ``logging.Logger``; the method logs its verdict at DEBUG.
|
||||
- ``FINAL_PERIOD`` -- defaulted here to ``None``; set it on the host class.
|
||||
|
||||
The method reads the game dict's ``away_abbr``, ``home_abbr``,
|
||||
``period_text``, ``period``, ``clock``, ``away_score`` and ``home_score``
|
||||
(``_extract_game_details_common``'s keys); any of them may be missing or
|
||||
null.
|
||||
|
||||
BASE ORDER
|
||||
----------
|
||||
List the mixin before ``SportsLiveSharedMixin`` --
|
||||
``class SportsLive(SportsGameOverMixin, SportsLiveSharedMixin, SportsCore)`` --
|
||||
so the shared mixin's ``_detect_stale_games`` finds this method through the
|
||||
MRO. Neither shared mixin defines it, so the order does not change which body
|
||||
runs today; it keeps the method next to its caller should one ever be added
|
||||
there. A method on the plugin's own class still wins, and its ``super()``
|
||||
reaches this one. The mixin has no ``__init__`` and no state.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
class SportsGameOverMixin:
|
||||
"""The live manager's "is this game really over?" check. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only.
|
||||
logger: logging.Logger
|
||||
|
||||
#: Period from which a 0:00 clock ends a game; None: the clock never does.
|
||||
FINAL_PERIOD: Optional[int] = None
|
||||
|
||||
def _is_game_really_over(self, game: Dict) -> bool:
|
||||
"""Whether a game ESPN still lists as live has in fact ended.
|
||||
|
||||
It has when its period text says final. From period ``FINAL_PERIOD``
|
||||
on, a clock at 0:00 ends it too, unless the score is level: a tie at
|
||||
the end of regulation goes to overtime, and a game that does end tied
|
||||
says final. With ``FINAL_PERIOD = None`` the clock never ends a game.
|
||||
"""
|
||||
game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}"
|
||||
|
||||
# ESPN can send the key as null, and .get()'s default only covers a
|
||||
# missing key, so a None here crashed the whole live update.
|
||||
raw_period_text = game.get("period_text")
|
||||
period_text = raw_period_text.lower() if isinstance(raw_period_text, str) else ""
|
||||
if "final" in period_text:
|
||||
self.logger.debug(
|
||||
f"_is_game_really_over({game_str}): "
|
||||
f"returning True - 'final' in period_text='{period_text}'"
|
||||
)
|
||||
return True
|
||||
|
||||
# Same for a null or non-numeric period: treat it as period 0.
|
||||
try:
|
||||
period = int(game.get("period") or 0)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
period = 0
|
||||
# Only a clock string is read: "0:00" and ":00" are zero; ":40" is not.
|
||||
clock = game.get("clock")
|
||||
clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00")
|
||||
|
||||
if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero:
|
||||
try:
|
||||
tied = int(game["away_score"]) == int(game["home_score"])
|
||||
except (KeyError, TypeError, ValueError, OverflowError):
|
||||
tied = False # a missing or unreadable score leaves it to the clock
|
||||
if not tied:
|
||||
self.logger.debug(
|
||||
f"_is_game_really_over({game_str}): "
|
||||
f"returning True - clock at 0:00 (clock='{clock}', period={period})"
|
||||
)
|
||||
return True
|
||||
self.logger.debug(
|
||||
f"_is_game_really_over({game_str}): "
|
||||
f"returning False - tied at 0:00 (period={period}), overtime next"
|
||||
)
|
||||
return False
|
||||
|
||||
self.logger.debug(
|
||||
f"_is_game_really_over({game_str}): returning False"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["SportsGameOverMixin"]
|
||||
@@ -34,8 +34,9 @@ first core release that ships it (see ``CHANGELOG.md``).
|
||||
|
||||
``_favorite_key`` is the one method not taken from the plugins: it is the
|
||||
override point from the since-removed ``src/base_classes`` sports core,
|
||||
carried here so later phases (shared celebrations and game selection) have a
|
||||
hardware-free home for the seam. No plugin defines it today and nothing in this module calls it.
|
||||
carried here as the hardware-free home for the seam. ``sports_favorites``
|
||||
calls it; nrl overrides it with the ESPN team id. Nothing in this module
|
||||
calls it.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
@@ -47,8 +48,8 @@ listed here.
|
||||
- ``mode_config`` (dict) and ``logger`` -- ``_setting_int``. ``league`` is
|
||||
read with ``getattr`` for the warning text only.
|
||||
- ``games_list`` and ``current_game_index`` -- ``_next_switch_index``; plus
|
||||
``_is_favorite_game`` (called with a game), which stays per-plugin and is
|
||||
only called when
|
||||
``_is_favorite_game`` (called with a game; ``sports_favorites`` has the
|
||||
shared body), only called when
|
||||
``favorite_rotation_boost`` is above 1. ``favorite_rotation_boost`` itself
|
||||
defaults to 1 on the mixin.
|
||||
- ``last_game_switch`` -- ``_reset_dwell_on_reentry``, read with ``getattr``
|
||||
@@ -216,15 +217,12 @@ class SportsHelpersMixin:
|
||||
rather than a branch so core never has to learn the string "nrl"::
|
||||
|
||||
def _favorite_key(self, game, side):
|
||||
return str(game.get(f"{side}_id"))
|
||||
team_id = game.get(f"{side}_id")
|
||||
return None if team_id is None else str(team_id)
|
||||
|
||||
An override that stringifies should note that a missing id becomes the
|
||||
literal ``"None"``, which would spuriously match a favorites list
|
||||
containing that string. The default returns ``None`` for a missing
|
||||
abbreviation, which never matches.
|
||||
|
||||
Carried from the since-removed ``src/base_classes`` sports core for
|
||||
later phases; nothing in this module calls it yet.
|
||||
It returns ``None`` for a missing id rather than ``str(None)``, which
|
||||
would match a favourite typed "None". A ``None`` never matches.
|
||||
``sports_favorites`` compares the value stripped and upper-cased.
|
||||
"""
|
||||
return game.get(f"{side}_abbr")
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Keep a live scoreboard's scrolling strip current without restarting it.
|
||||
|
||||
In scroll mode a scoreboard renders its games into one wide image and
|
||||
scrolls it past the panel. The strip used to be rebuilt only when a cycle
|
||||
completed, so a score changed mid-cycle stayed frozen in the pixels until the
|
||||
marquee finished. Eight scoreboards -- afl, baseball, basketball, football,
|
||||
hockey, lacrosse, nrl and soccer (ufc has no live strip) -- carry the same
|
||||
fix in their ``manager.py``: fingerprint the live games, rebuild when the
|
||||
fingerprint changes (rate-limited, and never for the clock alone), and keep
|
||||
the marquee's position across the rebuild. Its eight methods and two class
|
||||
constants are identical (executable AST, docstrings stripped, decorators
|
||||
compared) in all eight and were copied here from ledmatrix-plugins
|
||||
``56c4f15`` (origin/main, 2026-09-30) under their existing names:
|
||||
|
||||
- ``_live_scroll_managers`` -- the live managers whose games are on the strip;
|
||||
- ``_refresh_live_scroll_managers`` -- let them refresh before they are
|
||||
fingerprinted, off the render thread;
|
||||
- ``_live_scroll_fields``, ``_fingerprint_games`` and
|
||||
``_live_scroll_fingerprint`` -- what the strip was drawn from;
|
||||
- ``_live_scroll_needs_rebuild`` (with ``LIVE_SCROLL_REBUILD_MIN_SECONDS``
|
||||
and ``LIVE_SCROLL_REBUILD_DUTY_DIVISOR``) and ``_note_live_scroll_built``
|
||||
-- when to rebuild;
|
||||
- ``_preserving_scroll_position`` -- a context manager that keeps the marquee
|
||||
where it was across a rebuild.
|
||||
|
||||
``LIVE_VOLATILE_FIELDS`` stays in each plugin: afl, nrl and soccer also
|
||||
exclude ``period_text``, which embeds the clock in those sports.
|
||||
|
||||
A separate module from ``sports_plugin_host`` because ufc has no live strip:
|
||||
it inherits that mixin and not this one, so none of this is in its MRO.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
Derived by walking every ``self.<attr>`` / ``cls.<attr>`` the mixin reads;
|
||||
the host-contract test in ``test/test_sports_live_scroll.py`` fails if a read
|
||||
is added without being listed here.
|
||||
|
||||
- ``LIVE_VOLATILE_FIELDS`` -- a class constant: the game-dict keys a rebuild
|
||||
ignores (the clock, and what the display pipeline adds).
|
||||
- ``_live_scroll_fingerprints``, ``_live_scroll_rebuilt_at`` and
|
||||
``_live_scroll_rebuild_cost`` -- empty dicts the host creates in
|
||||
``__init__``, keyed by scroll key.
|
||||
- ``logger``.
|
||||
- ``_dispatch_switch_refresh(manager)`` -- from ``SportsPluginHostMixin``.
|
||||
- ``_league_registry`` (``{league: {"enabled": bool, "managers": {"live":
|
||||
manager}}}``) or a ``_get_manager(mode_type)`` accessor, both read with
|
||||
``getattr`` -- ``_live_scroll_managers``. A host with neither gets no
|
||||
managers, which leaves the feature inert rather than wrong.
|
||||
- ``_scroll_manager``, read with ``getattr`` --
|
||||
``_preserving_scroll_position`` asks it for the mode's scroll helper.
|
||||
|
||||
Add it as a base of the plugin class beside ``SportsPluginHostMixin``, before
|
||||
``BasePlugin``: ``class SoccerScoreboardPlugin(SportsPluginHostMixin,
|
||||
SportsLiveScrollMixin, BasePlugin)``. The two define no name in common. No
|
||||
``__init__``; a method on the plugin's own class still wins over the mixin's.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable, ClassVar, Dict, FrozenSet, Iterator, List
|
||||
|
||||
|
||||
class SportsLiveScrollMixin:
|
||||
"""Mid-cycle rebuilds of a live scroll strip. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only: these create no
|
||||
# attributes, so the host's own values are what the methods read.
|
||||
logger: logging.Logger
|
||||
LIVE_VOLATILE_FIELDS: ClassVar[FrozenSet[str]]
|
||||
_live_scroll_fingerprints: Dict[Any, Any]
|
||||
_live_scroll_rebuilt_at: Dict[Any, float]
|
||||
_live_scroll_rebuild_cost: Dict[Any, float]
|
||||
_dispatch_switch_refresh: Callable[[Any], None]
|
||||
|
||||
#: Floor between mid-cycle strip rebuilds, and the duty-cycle cap that can
|
||||
#: raise it.
|
||||
#:
|
||||
#: A rebuild re-renders every card into one wide image, on the render
|
||||
#: thread, so the marquee is frozen for however long it takes. Measured on a
|
||||
#: Pi 4: 28ms for one game, 139ms for five, 435ms for fifteen. A fixed 5s
|
||||
#: floor is fine for one game and wrong for a full slate -- with fifteen
|
||||
#: live games a pitch lands somewhere every second or so, the fingerprint
|
||||
#: changes continuously, and 435ms every 5s is nearly a tenth of the time
|
||||
#: spent not scrolling.
|
||||
#:
|
||||
#: So the floor also scales with what the last rebuild actually cost: never
|
||||
#: spend more than 1/LIVE_SCROLL_REBUILD_DUTY_DIVISOR of wall time
|
||||
#: rebuilding. Fifteen games self-limits to a rebuild every ~8.7s; one game
|
||||
#: stays on the 5s floor. No per-sport tuning, and it adapts to slate size
|
||||
#: and panel width on its own.
|
||||
LIVE_SCROLL_REBUILD_MIN_SECONDS: ClassVar[float] = 5.0
|
||||
LIVE_SCROLL_REBUILD_DUTY_DIVISOR: ClassVar[float] = 20.0
|
||||
|
||||
def _live_scroll_managers(self, league=None):
|
||||
"""The live managers whose games are on the strip.
|
||||
|
||||
Two shapes across the scoreboard lineage: a _league_registry (baseball,
|
||||
basketball, hockey, lacrosse, soccer, football) and a _get_manager
|
||||
accessor on the single-league plugins (afl, nrl). Anything else returns
|
||||
nothing, which leaves this feature inert rather than wrong.
|
||||
"""
|
||||
registry = getattr(self, "_league_registry", None)
|
||||
if isinstance(registry, dict) and registry:
|
||||
managers = []
|
||||
for league_id, entry in registry.items():
|
||||
if league is not None and league_id != league:
|
||||
continue
|
||||
entry = entry or {}
|
||||
if not entry.get("enabled", False):
|
||||
continue
|
||||
manager = (entry.get("managers") or {}).get("live")
|
||||
if manager is not None:
|
||||
managers.append(manager)
|
||||
return managers
|
||||
getter = getattr(self, "_get_manager", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
# pylint: disable=not-callable
|
||||
# The lineages that lack _get_manager infer this as None, so a
|
||||
# static checker calls it uncallable. callable() above is the
|
||||
# runtime guard; the branch is simply dead in those plugins.
|
||||
manager = getter("live")
|
||||
except (AttributeError, KeyError, TypeError, ValueError, OSError):
|
||||
return []
|
||||
return [manager] if manager is not None else []
|
||||
return []
|
||||
|
||||
def _refresh_live_scroll_managers(self, league=None) -> None:
|
||||
"""Let the live managers refresh before their games are fingerprinted.
|
||||
|
||||
Switch mode stays current because _try_manager_display() calls
|
||||
_ensure_manager_updated() on every pass. Scroll mode had no equivalent:
|
||||
its only refresh sat inside the block gated by the rebuild decision, and
|
||||
that decision is computed from the data the refresh would replace. So
|
||||
once the first strip was built nothing could change it, and the score on
|
||||
the marquee stayed frozen until the process restarted.
|
||||
|
||||
The refresh runs off the render thread -- see _dispatch_switch_refresh().
|
||||
This is called on every scroll frame, and a due manager.update() is a
|
||||
network round trip: run inline, it froze the marquee for the length of
|
||||
the ESPN request. The refreshed games land a few frames later, and the
|
||||
fingerprint check that follows this call picks them up on the next frame
|
||||
after they do. Dispatches for a manager are rate-limited, so the frames
|
||||
where nothing is due cost a dict lookup and a clock read.
|
||||
|
||||
Deliberately NOT gated on mode_type == "live". A recent/upcoming strip
|
||||
never rebuilds from the fingerprint (_live_scroll_needs_rebuild returns
|
||||
early for those), so refreshing here looks like wasted work -- but with
|
||||
live_priority the plugin only switches TO live mode once it knows live
|
||||
games exist, and it learns that from these same managers. Refreshing
|
||||
only while live mode is on screen would rebuild the same circularity one
|
||||
level up, and a game that went live would wait for the background
|
||||
plugin update -- an hour, on a rig that sets update_interval: 3600.
|
||||
"""
|
||||
for manager in self._live_scroll_managers(league) or []:
|
||||
try:
|
||||
self._dispatch_switch_refresh(manager)
|
||||
except (AttributeError, KeyError, TypeError, ValueError, OSError,
|
||||
RuntimeError) as exc:
|
||||
# Narrow on purpose: the update itself runs on another thread,
|
||||
# and _ensure_manager_updated() swallows whatever it raises, so
|
||||
# anything arriving here is a lookup error or a thread that
|
||||
# could not be started, not a fetch failure.
|
||||
self.logger.debug("Live scroll refresh skipped: %s", exc)
|
||||
|
||||
@classmethod
|
||||
def _live_scroll_fields(cls, game) -> tuple:
|
||||
"""One game as sorted ``(key, value)`` strings, minus the volatile keys."""
|
||||
try:
|
||||
items = list(game.items())
|
||||
except AttributeError:
|
||||
return (("<not-a-dict>", str(game)),)
|
||||
return tuple(sorted((str(k), str(v)) for k, v in items
|
||||
if k not in cls.LIVE_VOLATILE_FIELDS))
|
||||
|
||||
@classmethod
|
||||
def _fingerprint_games(cls, games) -> tuple:
|
||||
"""Order-independent fingerprint of a list of games."""
|
||||
return tuple(sorted(cls._live_scroll_fields(g) for g in (games or [])))
|
||||
|
||||
def _live_scroll_fingerprint(self, league=None) -> tuple:
|
||||
"""Fingerprint of every live game the strip's managers hold now."""
|
||||
games: List[Any] = []
|
||||
for manager in self._live_scroll_managers(league):
|
||||
games.extend(getattr(manager, "live_games", None) or [])
|
||||
return self._fingerprint_games(games)
|
||||
|
||||
def _live_scroll_needs_rebuild(self, scroll_key, mode_type, league=None) -> bool:
|
||||
"""True when the live card would draw differently than the strip does.
|
||||
|
||||
_scroll_prepared is cleared only when the cycle *completes*, so a score
|
||||
scored mid-cycle stayed frozen in the rendered strip until the marquee
|
||||
finished -- minutes, for a long game list. Restarting the display forces
|
||||
a rebuild, which is the workaround users find.
|
||||
"""
|
||||
if mode_type != "live":
|
||||
return False
|
||||
known = self._live_scroll_fingerprints.get(scroll_key)
|
||||
if known is None:
|
||||
return False # nothing built yet; normal path
|
||||
if self._live_scroll_fingerprint(league) == known:
|
||||
return False
|
||||
last = self._live_scroll_rebuilt_at.get(scroll_key, 0.0)
|
||||
cost = self._live_scroll_rebuild_cost.get(scroll_key, 0.0)
|
||||
floor = max(self.LIVE_SCROLL_REBUILD_MIN_SECONDS,
|
||||
cost * self.LIVE_SCROLL_REBUILD_DUTY_DIVISOR)
|
||||
if time.time() - last < floor:
|
||||
return False # deferred, not dropped
|
||||
return True
|
||||
|
||||
def _note_live_scroll_built(self, scroll_key, mode_type, fingerprint=None,
|
||||
league=None) -> None:
|
||||
"""Record what the strip was built from.
|
||||
|
||||
Takes a fingerprint captured from the *managers* immediately before the
|
||||
render, not one computed from the games handed to the renderer. Those
|
||||
two are not comparable: _collect_games_for_scroll() decorates each game
|
||||
with extra keys ("league", "status"), so a fingerprint taken from its
|
||||
output can never equal one taken from the managers -- every check past
|
||||
the rate limiter would rebuild, defeating the clock exclusion entirely.
|
||||
That is not hypothetical; it is what the first version of this did, and
|
||||
an end-to-end simulation caught it rebuilding on a bare clock tick.
|
||||
|
||||
Capturing before the render also closes the race a plain re-read would
|
||||
open: a background update landing mid-render would otherwise be recorded
|
||||
as though the strip already contained it.
|
||||
"""
|
||||
if mode_type != "live":
|
||||
return
|
||||
self._live_scroll_fingerprints[scroll_key] = (
|
||||
fingerprint if fingerprint is not None
|
||||
else self._live_scroll_fingerprint(league))
|
||||
self._live_scroll_rebuilt_at[scroll_key] = time.time()
|
||||
|
||||
@contextmanager
|
||||
def _preserving_scroll_position(self, mode_type, active, scroll_key=None) -> Iterator[None]:
|
||||
"""Keep the marquee where it is across a mid-cycle rebuild.
|
||||
|
||||
ScrollHelper.set_scrolling_image() resets two counters and both matter:
|
||||
scroll_position (without it the marquee snaps back to the start, which
|
||||
looks worse than the stale score being fixed) and total_distance_scrolled
|
||||
(without it the cycle restarts, so a game that keeps scoring could stop
|
||||
the strip ever completing). Restored clamped to the new strip, since a
|
||||
score gaining a digit changes its card's width by a few pixels.
|
||||
|
||||
A no-op unless `active` -- a first build should start at zero.
|
||||
"""
|
||||
helper = None
|
||||
if active and getattr(self, "_scroll_manager", None):
|
||||
try:
|
||||
helper = self._scroll_manager.get_scroll_display(mode_type).scroll_helper # type: ignore[attr-defined]
|
||||
except Exception: # pragma: no cover - defensive
|
||||
helper = None
|
||||
position = getattr(helper, "scroll_position", None) if helper else None
|
||||
distance = getattr(helper, "total_distance_scrolled", None) if helper else None
|
||||
started = time.time()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# What this render cost, so the next floor can scale with it. Keyed by
|
||||
# scroll_key, which is what _live_scroll_needs_rebuild() reads --
|
||||
# they are only the same string in some of these plugins, and keying
|
||||
# by mode_type made the duty cap silently inert in the rest.
|
||||
self._live_scroll_rebuild_cost[scroll_key or mode_type] = time.time() - started
|
||||
if helper is not None and position is not None:
|
||||
width = max(getattr(helper, "total_scroll_width", 0) - 1, 0)
|
||||
helper.scroll_position = min(position, width)
|
||||
if distance is not None:
|
||||
helper.total_distance_scrolled = distance
|
||||
helper.scroll_complete = False
|
||||
self.logger.info(
|
||||
"[Scroll] Live card changed; rebuilt the %s strip in place "
|
||||
"at position %d", mode_type, int(helper.scroll_position))
|
||||
|
||||
|
||||
__all__ = ["SportsLiveScrollMixin"]
|
||||
@@ -0,0 +1,270 @@
|
||||
"""The scoreboard plugin class's helpers every ``manager.py`` copies.
|
||||
|
||||
Each scoreboard's ``manager.py`` holds its ``BasePlugin`` subclass (the
|
||||
"host": ``SoccerScoreboardPlugin``, ``UFCScoreboardPlugin``, ...). Ten of its
|
||||
methods, and the class constant one of them reads, are identical
|
||||
(executable AST, docstrings stripped, decorators compared) in all nine
|
||||
scoreboards -- afl, baseball, basketball, football, hockey, lacrosse, nrl,
|
||||
soccer and ufc -- and were copied here from ledmatrix-plugins ``56c4f15``
|
||||
(origin/main, 2026-09-30) under their existing names:
|
||||
|
||||
- ``_dispatch_switch_refresh`` (with ``_SWITCH_REFRESH_MIN_GAP_SECONDS``) --
|
||||
run a manager's refresh on a daemon thread so ``display()`` never blocks
|
||||
on the network;
|
||||
- ``get_vegas_priority_weight``, ``_favorite_team_is_live``,
|
||||
``_favorite_scan_targets``, ``_favorite_scan_games`` and
|
||||
``_game_involves`` -- how many Vegas slots the plugin asks for, and
|
||||
whether a configured favourite is playing live;
|
||||
- ``get_vegas_content_type`` -- ``'multi'``: a scoreboard is a list of games;
|
||||
- ``_dynamic_feature_enabled``, ``_get_total_games_for_manager`` and
|
||||
``_build_manager_key`` -- small dynamic-duration helpers.
|
||||
|
||||
This is stage 4 of the consolidation (docs/SPORTS_UNIFICATION.md): the
|
||||
families that needed no reconciling. The rest of ``manager.py`` has drifted
|
||||
and is reconciled one family per release before it moves.
|
||||
|
||||
A new module rather than more methods on an existing mixin, for the reason
|
||||
``sports_helpers`` gives: a missing module fails at load, where the version
|
||||
checks see it; a missing method fails mid-frame.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||
test in ``test/test_sports_plugin_host.py`` fails if a read is added without
|
||||
being listed here.
|
||||
|
||||
- ``_ensure_manager_updated(manager)`` -- ``_dispatch_switch_refresh`` runs
|
||||
it on the thread it starts. It must swallow its own errors: nothing joins
|
||||
the thread.
|
||||
- ``global_config``, ``has_live_priority()``, ``has_live_content()`` and
|
||||
``supports_dynamic_duration()`` -- all on ``BasePlugin``; the scoreboards
|
||||
override the last three.
|
||||
- ``is_enabled`` -- set by each scoreboard's ``__init__`` (``BasePlugin``
|
||||
calls its flag ``enabled``).
|
||||
- ``_switch_refresh_threads`` and ``_switch_refresh_at``, read with
|
||||
``getattr`` -- ``_dispatch_switch_refresh`` creates both on first use, so
|
||||
a host need not.
|
||||
- The live managers it scans for favourites are found through ``vars(self)``
|
||||
(``_favorite_scan_targets``): any attribute, or value of a dict
|
||||
attribute, with ``favorite_teams`` (or ``favorite_fighters``) and
|
||||
``live_games`` (or ``live_matches``, or an ``active_celebration`` dict
|
||||
holding a ``game``).
|
||||
|
||||
Add it as a base of the plugin class, **before** ``BasePlugin``, e.g.
|
||||
``class SoccerScoreboardPlugin(SportsPluginHostMixin, BasePlugin)``:
|
||||
``get_vegas_priority_weight`` and ``get_vegas_content_type`` override
|
||||
``BasePlugin``'s defaults. A method on the plugin's own class still wins over
|
||||
the mixin's. The mixin has no ``__init__`` and creates no class attributes
|
||||
beyond its one constant.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, ClassVar, Dict, Iterator, Optional
|
||||
|
||||
|
||||
class SportsPluginHostMixin:
|
||||
"""The scoreboard plugin class's identical helpers. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only: these create no
|
||||
# attributes, so the host's own values are what the methods read.
|
||||
logger: logging.Logger
|
||||
is_enabled: bool
|
||||
global_config: Dict[str, Any]
|
||||
_ensure_manager_updated: Callable[[Any], Any]
|
||||
has_live_priority: Callable[[], bool]
|
||||
has_live_content: Callable[[], bool]
|
||||
supports_dynamic_duration: Callable[[], bool]
|
||||
# Created on first use by _dispatch_switch_refresh, per instance.
|
||||
_switch_refresh_threads: Dict[int, threading.Thread]
|
||||
_switch_refresh_at: Dict[int, float]
|
||||
|
||||
#: Floor between two draw-time refresh dispatches for one manager. The
|
||||
#: manager's own update() still decides whether anything is fetched; this
|
||||
#: only stops display() starting a thread on every frame just to be told
|
||||
#: the interval has not elapsed.
|
||||
_SWITCH_REFRESH_MIN_GAP_SECONDS: ClassVar[float] = 5.0
|
||||
|
||||
def _dispatch_switch_refresh(self, manager) -> None:
|
||||
"""Run _ensure_manager_updated(manager) on a daemon thread.
|
||||
|
||||
Called from display(), so it must not block: when an update is due,
|
||||
manager.update() fetches rankings and the schedule over the network,
|
||||
and doing that inline stalled the frame for the length of the round
|
||||
trip. The refreshed games land in the manager a few frames later --
|
||||
still within the manager's own interval, which is the freshness the
|
||||
switch path was missing.
|
||||
|
||||
At most one refresh per manager runs at a time, and dispatches for the
|
||||
same manager are at least _SWITCH_REFRESH_MIN_GAP_SECONDS apart. Only
|
||||
the render thread touches the two bookkeeping dicts, so they need no
|
||||
lock; manager.update() stamps last_update before it fetches, so a
|
||||
concurrent background plugin.update() for the same manager returns
|
||||
early rather than fetching twice.
|
||||
"""
|
||||
threads: Optional[Dict[int, threading.Thread]] = getattr(self, "_switch_refresh_threads", None)
|
||||
if threads is None:
|
||||
threads = self._switch_refresh_threads = {}
|
||||
stamps: Optional[Dict[int, float]] = getattr(self, "_switch_refresh_at", None)
|
||||
if stamps is None:
|
||||
stamps = self._switch_refresh_at = {}
|
||||
|
||||
key = id(manager)
|
||||
running = threads.get(key)
|
||||
if running is not None and running.is_alive():
|
||||
return
|
||||
now = time.monotonic()
|
||||
last = stamps.get(key)
|
||||
if last is not None and now - last < self._SWITCH_REFRESH_MIN_GAP_SECONDS:
|
||||
return
|
||||
stamps[key] = now
|
||||
thread = threading.Thread(
|
||||
target=self._ensure_manager_updated,
|
||||
args=(manager,),
|
||||
daemon=True,
|
||||
name="SwitchRefresh-%s" % type(manager).__name__,
|
||||
)
|
||||
threads[key] = thread
|
||||
thread.start()
|
||||
|
||||
# ---- Vegas weighting: is a favourite playing? -----------------------
|
||||
#
|
||||
# With display.vegas_scroll.live_in_ticker set, the marquee keeps running
|
||||
# through a live game and plugins can claim more than one slot per cycle.
|
||||
# The core already gives any plugin with live content `live_weight`; this
|
||||
# exists for the one thing the core cannot work out for itself, which is
|
||||
# *whose* game is live. See PLUGIN_API_REFERENCE, "Vegas scroll hooks",
|
||||
# and ADVANCED_FEATURES, "Live content in the ticker".
|
||||
|
||||
def get_vegas_priority_weight(self):
|
||||
"""Slots per Vegas cycle: more when a favorite team is playing.
|
||||
|
||||
Returns None when nothing is live, which leaves the decision to the
|
||||
core rather than asserting a weight of 1 -- the core may have its own
|
||||
reason to boost this plugin later.
|
||||
"""
|
||||
try:
|
||||
if not (self.has_live_priority() and self.has_live_content()):
|
||||
return None
|
||||
vegas = (self.global_config or {}).get('display', {}).get(
|
||||
'vegas_scroll', {})
|
||||
if self._favorite_team_is_live():
|
||||
return vegas.get('favorite_live_weight', 5)
|
||||
return vegas.get('live_weight', 3)
|
||||
except Exception:
|
||||
# Never let a weighting question break the rotation; the core
|
||||
# treats an exception as weight 1 anyway, and None says the same
|
||||
# thing more cheaply.
|
||||
return None
|
||||
|
||||
def _favorite_team_is_live(self):
|
||||
"""Whether any live game or fight involves a configured favorite.
|
||||
|
||||
The sports plugins do not share one data shape, so this enumerates the
|
||||
real ones rather than assuming. An earlier version looked only for an
|
||||
attribute holding `live_games` alongside `favorite_teams`, which was
|
||||
true of five plugins and quietly false for four others -- they simply
|
||||
never reported a favorite, and no test noticed because the tests used
|
||||
the assumed shape rather than each plugin's own.
|
||||
|
||||
Handled:
|
||||
|
||||
* managers held directly on the plugin *and* inside a dict such as
|
||||
``self._managers`` (nrl, afl)
|
||||
* ``live_games`` (most) and ``live_matches`` (cricket)
|
||||
* ``favorite_teams`` (most) and ``favorite_fighters`` (ufc)
|
||||
* identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``,
|
||||
``fighter1_name``/``fighter2_name``, and cricket's nested
|
||||
``teams: [{name, abbr, short_name}]``
|
||||
* ``active_celebration["game"]``, a snapshot the live manager keeps
|
||||
precisely because the game leaves ``live_games`` while the
|
||||
celebration is still on screen
|
||||
"""
|
||||
for holder in self._favorite_scan_targets():
|
||||
favorites = (getattr(holder, 'favorite_teams', None)
|
||||
or getattr(holder, 'favorite_fighters', None))
|
||||
if not favorites:
|
||||
continue
|
||||
wanted = {str(f).strip().lower() for f in favorites if f}
|
||||
if not wanted:
|
||||
continue
|
||||
for game in self._favorite_scan_games(holder):
|
||||
if self._game_involves(game, wanted):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _favorite_scan_targets(self) -> Iterator[Any]:
|
||||
"""Objects that might carry live content: attributes, and dict values.
|
||||
|
||||
nrl and afl keep their per-league managers in a ``self._managers``
|
||||
dict, so walking attribute values alone finds the dict and stops.
|
||||
"""
|
||||
for value in list(vars(self).values()):
|
||||
yield value
|
||||
if isinstance(value, dict):
|
||||
for nested in list(value.values()):
|
||||
yield nested
|
||||
|
||||
@staticmethod
|
||||
def _favorite_scan_games(holder) -> Iterator[Dict[str, Any]]:
|
||||
"""Every game/fight on a holder that a favorite could be playing in."""
|
||||
for attr in ('live_games', 'live_matches'):
|
||||
for game in (getattr(holder, attr, None) or []):
|
||||
if isinstance(game, dict):
|
||||
yield game
|
||||
celebration = getattr(holder, 'active_celebration', None)
|
||||
if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict):
|
||||
yield celebration['game']
|
||||
|
||||
@staticmethod
|
||||
def _game_involves(game, wanted) -> bool:
|
||||
"""Whether a game/fight involves one of the wanted names."""
|
||||
for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id',
|
||||
'fighter1_name', 'fighter2_name'):
|
||||
value = game.get(field)
|
||||
if value is not None and str(value).strip().lower() in wanted:
|
||||
return True
|
||||
# Cricket nests its sides and matches on any of three names, by
|
||||
# substring -- "india" should match "India Women". Mirrors that
|
||||
# plugin's own _match_has_team rather than inventing a second rule.
|
||||
for team in (game.get('teams') or []):
|
||||
if not isinstance(team, dict):
|
||||
continue
|
||||
hay = " ".join(str(team.get(k) or '') for k in
|
||||
('name', 'abbr', 'short_name')).lower()
|
||||
if any(name in hay for name in wanted):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_vegas_content_type(self) -> str:
|
||||
"""Plugin provides multiple scrollable items (games)."""
|
||||
return 'multi'
|
||||
|
||||
# ---- dynamic duration ------------------------------------------------
|
||||
|
||||
def _dynamic_feature_enabled(self) -> bool:
|
||||
"""Dynamic duration applies: the plugin is enabled and supports it."""
|
||||
if not self.is_enabled:
|
||||
return False
|
||||
return self.supports_dynamic_duration()
|
||||
|
||||
@staticmethod
|
||||
def _get_total_games_for_manager(manager) -> int:
|
||||
"""How many games a manager holds, from the first list it carries."""
|
||||
if manager is None:
|
||||
return 0
|
||||
for attr in ("live_games", "games_list", "recent_games", "upcoming_games"):
|
||||
value = getattr(manager, attr, None)
|
||||
if isinstance(value, list):
|
||||
return len(value)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _build_manager_key(mode_name: str, manager) -> str:
|
||||
"""``"<mode>:<manager class>"``, the key progress is tracked under."""
|
||||
manager_name = manager.__class__.__name__ if manager else "None"
|
||||
return f"{mode_name}:{manager_name}"
|
||||
|
||||
|
||||
__all__ = ["SportsPluginHostMixin"]
|
||||
+556
-15
@@ -42,17 +42,22 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
import weakref
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.common import scroll_config
|
||||
from src.common import scroll_config, sports_vegas
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#: Defaults every copy agreed on. A subclass overrides
|
||||
#: :meth:`SportsScrollDisplay.scroll_settings_defaults` to change them —
|
||||
#: the soccer lineage uses a 24px gap and min/max duration keys instead.
|
||||
@@ -318,7 +323,7 @@ class SportsScrollDisplay:
|
||||
:returns: True if a frame was drawn; False when there is no content or
|
||||
the frame could not be rendered.
|
||||
"""
|
||||
if not self.scroll_helper.cached_image:
|
||||
if not self._has_strip():
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -411,7 +416,175 @@ class SportsScrollDisplay:
|
||||
|
||||
def has_cached_content(self) -> bool:
|
||||
"""Whether content is prepared and ready to scroll."""
|
||||
return bool(self.scroll_helper.cached_image)
|
||||
return self._has_strip()
|
||||
|
||||
def _has_strip(self) -> bool:
|
||||
"""Whether the helper holds a strip, without building its PIL image.
|
||||
|
||||
Reading ``cached_image`` after the strip was extended or trimmed builds
|
||||
the image from the array and keeps it, so the strip is held twice;
|
||||
display_scroll_frame asks this every frame. ``has_strip()`` answers
|
||||
from the helper's bookkeeping. A helper without it (a plugin's own, a
|
||||
test double) is asked the old way.
|
||||
"""
|
||||
helper = self.scroll_helper
|
||||
if callable(getattr(type(helper), "has_strip", None)):
|
||||
return bool(helper.has_strip())
|
||||
return bool(helper.cached_image)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Live Vegas cards
|
||||
# ------------------------------------------------------------------
|
||||
#
|
||||
# One live element per game (src/plugin_system/vegas_elements.py): the
|
||||
# ticker swaps a card in place when its game changes. A sport opts in by
|
||||
# implementing make_vegas_renderer(); everything else is here.
|
||||
|
||||
def make_vegas_renderer(self, card_width: int,
|
||||
rankings_cache: Optional[Dict[str, int]] = None) -> Any:
|
||||
"""The renderer this sport draws one game card with, at ``card_width``.
|
||||
|
||||
**Override point.** Return the object whose ``render_game_card(game,
|
||||
game_type)`` draws one card exactly ``card_width`` wide at the display's
|
||||
height -- the one prepare_scroll_content already builds -- without the
|
||||
black padding prepare_scroll_content adds around each card (the ticker
|
||||
adds its own). Raising NotImplementedError, the default, keeps the
|
||||
plugin on its ordinary Vegas content.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} has no live Vegas cards (make_vegas_renderer)")
|
||||
|
||||
def _determine_game_type(self, game: Dict[str, Any]) -> str:
|
||||
"""The card a game is drawn as: 'live', 'recent' or 'upcoming'.
|
||||
|
||||
From the game's state; a sport whose scroll display decides it
|
||||
differently (most define their own) overrides this.
|
||||
"""
|
||||
return {'in': 'live', 'post': 'recent'}.get(sports_vegas._state(game), 'upcoming')
|
||||
|
||||
def render_vegas_card(self, renderer: Any, game: Dict[str, Any]) -> Image.Image:
|
||||
"""Draw one game's card. Override only if the renderer is called differently."""
|
||||
card: Image.Image = renderer.render_game_card(game, self._determine_game_type(game))
|
||||
return card
|
||||
|
||||
def vegas_separator(self, league: str) -> Optional[Image.Image]:
|
||||
"""The league separator shown before a league's cards, if there is an icon."""
|
||||
icon = self._separator_icons.get(league)
|
||||
if icon is None:
|
||||
return None
|
||||
gap = self._vegas_settings(league).get("gap_between_games", 48)
|
||||
pad = max(4, int(gap) // 2)
|
||||
image = Image.new('RGB', (icon.width + pad * 2, self.display_height), (0, 0, 0))
|
||||
mask = icon if icon.mode == 'RGBA' else None
|
||||
image.paste(icon, (pad, (self.display_height - icon.height) // 2), mask)
|
||||
return image
|
||||
|
||||
def _vegas_memo(self) -> Dict[Any, Any]:
|
||||
"""Per-size, per-config memo for the live path; emptied when either changes."""
|
||||
stamp = (self.display_width, self.display_height, id(self.config))
|
||||
memo: Optional[Tuple[Any, Dict[Any, Any]]] = getattr(self, '_vegas_memo_store', None)
|
||||
if memo is None or memo[0] != stamp:
|
||||
memo = (stamp, {})
|
||||
self._vegas_memo_store = memo
|
||||
store: Dict[Any, Any] = memo[1]
|
||||
return store
|
||||
|
||||
def _vegas_settings(self, league: Optional[str]) -> Dict[str, Any]:
|
||||
"""A league's scroll settings, looked up once per size and config.
|
||||
|
||||
The live path asks after every update; a sport's settings lookup can
|
||||
be expensive (sizing the default card width builds probe renderers).
|
||||
"""
|
||||
memo = self._vegas_memo()
|
||||
key = ('settings', league)
|
||||
if key not in memo:
|
||||
memo[key] = dict(self._get_scroll_settings(league))
|
||||
settings: Dict[str, Any] = memo[key]
|
||||
return settings
|
||||
|
||||
def _vegas_renderer(self, card_width: int,
|
||||
rankings_cache: Optional[Dict[str, int]]) -> Any:
|
||||
"""The sport's renderer for one card width, built once rather than per slate.
|
||||
|
||||
Building one loads fonts and, for the default card width, probes the
|
||||
layout; the scroll path pays that on every prepare, which the live
|
||||
path would repeat on every update.
|
||||
"""
|
||||
memo = self._vegas_memo()
|
||||
key = ('renderer', card_width)
|
||||
if key not in memo:
|
||||
memo[key] = self.make_vegas_renderer(card_width, rankings_cache)
|
||||
renderer = memo[key]
|
||||
if hasattr(renderer, 'set_rankings_cache'):
|
||||
# Every time, empty included: the renderer is reused across
|
||||
# slates, and ranks cleared since must not stay drawn.
|
||||
renderer.set_rankings_cache(rankings_cache or {})
|
||||
return renderer
|
||||
|
||||
def build_vegas_elements(
|
||||
self,
|
||||
games: List[Dict[str, Any]],
|
||||
leagues: List[str],
|
||||
rankings_cache: Optional[Dict[str, int]] = None,
|
||||
fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||
now: Optional[float] = None,
|
||||
) -> Optional[List[Any]]:
|
||||
"""The slate as live Vegas elements: one card per game, separators between leagues.
|
||||
|
||||
Only cards whose fingerprint changed are drawn; the rest come from the
|
||||
cache. ``fingerprint(game)`` should return what the card draws (the
|
||||
plugin's own signature fields, the clock included for live games); by
|
||||
default the whole game dict is used, which redraws on any change. The
|
||||
teams' ranks from ``rankings_cache`` count too: the renderer draws
|
||||
them from there, not from the game.
|
||||
|
||||
Raises NotImplementedError when the sport has no make_vegas_renderer.
|
||||
"""
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
|
||||
games = sports_vegas.dedupe_games(games)
|
||||
if not games:
|
||||
return None
|
||||
# Settings follow each game's own league, not the slate's first one:
|
||||
# a card's width must not change because another league has no games
|
||||
# today (the ticker refuses a redraw of another width).
|
||||
first = self._vegas_settings(leagues[0] if leagues else None)
|
||||
|
||||
cards = getattr(self, '_vegas_cards', None)
|
||||
if cards is None:
|
||||
cards = self._vegas_cards = sports_vegas.VegasCardCache()
|
||||
odds = getattr(self, '_vegas_odds', None)
|
||||
if odds is None:
|
||||
odds = self._vegas_odds = sports_vegas.StickyOdds()
|
||||
fingerprint = fingerprint or sports_vegas.game_fingerprint
|
||||
|
||||
elements: List[Any] = []
|
||||
keys: List[str] = []
|
||||
current_league = None
|
||||
separators = 0
|
||||
for game in games:
|
||||
league = game.get("league")
|
||||
settings = self._vegas_settings(league) if league else first
|
||||
card_width = int(settings.get("game_card_width", self.display_width))
|
||||
if settings.get("show_league_separators", True) and league != current_league:
|
||||
separator = self.vegas_separator(league) if league else None
|
||||
if separator is not None:
|
||||
elements.append(VegasElement(
|
||||
key=f"sep:{separators}:{league}", image=separator, live=False))
|
||||
separators += 1
|
||||
current_league = league
|
||||
key = sports_vegas.game_key(game)
|
||||
drawn = odds.apply(key, game, now)
|
||||
ranks = (rankings_cache.get(str(drawn.get("home_abbr"))),
|
||||
rankings_cache.get(str(drawn.get("away_abbr")))) if rankings_cache else None
|
||||
renderer = self._vegas_renderer(card_width, rankings_cache)
|
||||
elements.append(cards.element(
|
||||
key, (fingerprint(drawn), ranks, card_width, self.display_height),
|
||||
functools.partial(self.render_vegas_card, renderer, drawn)))
|
||||
keys.append(key)
|
||||
cards.retain(keys)
|
||||
odds.retain(keys)
|
||||
return elements
|
||||
|
||||
def get_current_game_count(self) -> int:
|
||||
return len(self._current_games)
|
||||
@@ -433,16 +606,80 @@ class SportsScrollDisplay:
|
||||
return info
|
||||
|
||||
|
||||
class _StripSlot:
|
||||
"""One slate's display in a game type's pool, and what its strip shows.
|
||||
|
||||
``key`` is None when the strip must not be reused: never built, built
|
||||
from something that could not be fingerprinted, a build that failed, or
|
||||
released to stay inside the memory budget.
|
||||
"""
|
||||
|
||||
__slots__ = ("display", "key", "built_at", "strip", "last_used", "epoch")
|
||||
|
||||
def __init__(self, display: SportsScrollDisplay) -> None:
|
||||
self.display = display
|
||||
self.key: Optional[Tuple[Any, ...]] = None
|
||||
self.built_at = 0.0
|
||||
#: The helper's strip array when it was built, held weakly: a strip
|
||||
#: replaced or cleared by anything since (a Vegas build on the same
|
||||
#: display, a plugin calling clear()) no longer matches it.
|
||||
self.strip: Optional[Callable[[], Any]] = None
|
||||
self.last_used = 0.0
|
||||
#: Bumped by every forget(). A build records its key only if this is
|
||||
#: what it was when the build started: anything that forgot the slot
|
||||
#: meanwhile (another build on this display, which may finish first
|
||||
#: and leave its strip in the helper) means the strip in the helper
|
||||
#: is not known to be this build's.
|
||||
self.epoch = 0
|
||||
|
||||
def forget(self) -> None:
|
||||
self.key = None
|
||||
self.strip = None
|
||||
self.epoch += 1
|
||||
|
||||
|
||||
class SportsScrollDisplayManager:
|
||||
"""One :class:`SportsScrollDisplay` per game type ('live'/'recent'/'upcoming').
|
||||
|
||||
Subclasses set :attr:`display_class`; everything else was near-identical
|
||||
across the eight plugin copies.
|
||||
|
||||
A recent or upcoming strip that has not changed is reused rather than
|
||||
redrawn when its turn comes round again -- see :meth:`prepare_and_display`.
|
||||
"""
|
||||
|
||||
#: The SportsScrollDisplay subclass to instantiate per game type.
|
||||
display_class = SportsScrollDisplay
|
||||
|
||||
#: Game types whose strip is reused while nothing it is drawn from has
|
||||
#: changed. Live is left out: its games change every poll, so the check
|
||||
#: would never pay, and sports_live_scroll rebuilds a live strip in place
|
||||
#: mid-cycle around get_scroll_display('live'), which has to stay the one
|
||||
#: display it always was. Vegas's 'mixed' never comes through
|
||||
#: prepare_and_display.
|
||||
STRIP_MEMO_GAME_TYPES = frozenset({"recent", "upcoming"})
|
||||
|
||||
#: Oldest a reused strip may be. Not everything a card draws is in the
|
||||
#: game dicts -- a team logo that was missing at the first build appears
|
||||
#: only when the card is drawn again -- so an unchanged slate is still
|
||||
#: redrawn this often.
|
||||
STRIP_MEMO_MAX_AGE_S = 600.0
|
||||
|
||||
#: Displays kept per game type, one per slate (its leagues): the one on
|
||||
#: screen plus the most recently shown others. When all are taken, the
|
||||
#: least recently shown one draws the new slate, as the one shared display
|
||||
#: always did, so a rotation with more slates than this costs no more
|
||||
#: than before.
|
||||
STRIP_MEMO_SLATES_PER_TYPE = 4
|
||||
|
||||
#: Ceiling, per plugin, on the strips kept for displays not on screen, in
|
||||
#: bytes (the strip's array and image, and its Vegas items). Seven
|
||||
#: football games at 192x48 come to ~0.65MB, thirty at 512x64 to ~4MB.
|
||||
#: It bounds strip pixels only: each extra display also keeps its own
|
||||
#: logo and separator-icon caches and frame buffer, and each slot a frozen
|
||||
#: copy of the config in its key (~50KB), none of which is counted here.
|
||||
STRIP_MEMO_MAX_PARKED_BYTES = 6 * 1024 * 1024
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
display_manager,
|
||||
@@ -460,16 +697,34 @@ class SportsScrollDisplayManager:
|
||||
# either way, but two spellings of "nothing active" across two classes
|
||||
# is a trap for anyone comparing state between them.
|
||||
self._current_game_type: str = ""
|
||||
# Per game type, its displays by slate, least recently shown first.
|
||||
# _scroll_displays[game_type] is always the one on screen, so every
|
||||
# reader of it (display_frame, is_complete, the plugins' own
|
||||
# get_dynamic_duration and has_cached_content) sees what it did when
|
||||
# there was only one display per game type.
|
||||
self._strip_pools: Dict[str, "OrderedDict[Tuple[str, Tuple[Any, ...]], _StripSlot]"] = {}
|
||||
# Only the bookkeeping is under it (and creating a slate's display,
|
||||
# the first time that slate is drawn), never a build: a display()
|
||||
# call that outlived its timeout can still be building when the next
|
||||
# one starts.
|
||||
self._strip_lock = threading.RLock()
|
||||
|
||||
def _new_scroll_display(self) -> SportsScrollDisplay:
|
||||
return self.display_class(
|
||||
self.display_manager,
|
||||
self.config,
|
||||
self.logger,
|
||||
global_config=self.global_config,
|
||||
)
|
||||
|
||||
def get_scroll_display(self, game_type: str) -> SportsScrollDisplay:
|
||||
"""The display for ``game_type``, created on first use."""
|
||||
"""The display for ``game_type``, created on first use.
|
||||
|
||||
For a recent or upcoming game type, the display of the slate prepared
|
||||
last -- the strip display_frame() draws.
|
||||
"""
|
||||
if game_type not in self._scroll_displays:
|
||||
self._scroll_displays[game_type] = self.display_class(
|
||||
self.display_manager,
|
||||
self.config,
|
||||
self.logger,
|
||||
global_config=self.global_config,
|
||||
)
|
||||
self._scroll_displays[game_type] = self._new_scroll_display()
|
||||
return self._scroll_displays[game_type]
|
||||
|
||||
def prepare_and_display(
|
||||
@@ -479,8 +734,63 @@ class SportsScrollDisplayManager:
|
||||
leagues: List[str],
|
||||
rankings_cache: Optional[Dict[str, int]] = None,
|
||||
) -> bool:
|
||||
"""Build content for ``game_type`` and make it the active strip."""
|
||||
scroll_display = self.get_scroll_display(game_type)
|
||||
"""Build content for ``game_type`` and make it the active strip.
|
||||
|
||||
Building a strip draws every card while the render thread waits for
|
||||
it, with the panel frozen on its last frame: ~1.4s for seven football
|
||||
cards at 192x48 on a Pi 4, at the start of every turn and again each
|
||||
time the cycle completes. A recent or upcoming turn usually draws
|
||||
exactly the strip its slate drew last time. So when nothing the strip
|
||||
is drawn from has changed -- the games, the rankings, the config, the
|
||||
panel size and the date -- that strip is rewound and shown again
|
||||
instead, which leaves the plugin's prepare_scroll_content() uncalled.
|
||||
|
||||
Two leagues usually take turns on one game type (nfl_recent, then
|
||||
ncaa_fb_recent), so each slate keeps its own display rather than
|
||||
sharing one; see STRIP_MEMO_SLATES_PER_TYPE. Anything in doubt is
|
||||
drawn again: a live strip, a turn with no games, inputs that cannot
|
||||
be fingerprinted, a strip older than STRIP_MEMO_MAX_AGE_S, or one
|
||||
changed since it was built.
|
||||
"""
|
||||
keyed = self._strip_memo_key(games, game_type, leagues, rankings_cache)
|
||||
restore: Optional[SportsScrollDisplay] = None
|
||||
epoch = 0
|
||||
with self._strip_lock:
|
||||
if keyed is None:
|
||||
self._forget_shown_strip(game_type)
|
||||
scroll_display = self.get_scroll_display(game_type)
|
||||
else:
|
||||
reused = self._reuse_strip(game_type, *keyed)
|
||||
if reused is not None:
|
||||
# What a fresh build leaves: the strip at its start, a new
|
||||
# cycle not yet complete, this game type active.
|
||||
reused.reset_scroll()
|
||||
self._current_game_type = game_type
|
||||
self.logger.debug(
|
||||
"Reusing the unchanged %s strip for %s",
|
||||
game_type, ", ".join(map(str, keyed[0][1])))
|
||||
return True
|
||||
scroll_display, restore, epoch = self._display_to_build(
|
||||
game_type, keyed[0])
|
||||
# Before the build, so data that changes during it reads as changed.
|
||||
started = time.monotonic()
|
||||
success = self._prepare_on(
|
||||
scroll_display, games, game_type, leagues, rankings_cache)
|
||||
if keyed is not None:
|
||||
with self._strip_lock:
|
||||
self._note_strip_built(
|
||||
game_type, keyed, scroll_display, restore, success, started,
|
||||
epoch)
|
||||
return success
|
||||
|
||||
def _prepare_on(
|
||||
self,
|
||||
scroll_display: SportsScrollDisplay,
|
||||
games: List[Dict],
|
||||
game_type: str,
|
||||
leagues: List[str],
|
||||
rankings_cache: Optional[Dict[str, int]],
|
||||
) -> bool:
|
||||
try:
|
||||
success = scroll_display.prepare_scroll_content(
|
||||
games, game_type, leagues, rankings_cache
|
||||
@@ -498,6 +808,200 @@ class SportsScrollDisplayManager:
|
||||
self._current_game_type = game_type
|
||||
return success
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reusing an unchanged strip
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _strip_memo_key(
|
||||
self,
|
||||
games: Any,
|
||||
game_type: str,
|
||||
leagues: Any,
|
||||
rankings_cache: Any,
|
||||
) -> Optional[Tuple[Tuple[str, Tuple[Any, ...]], Tuple[Any, ...]]]:
|
||||
"""``(slate, key)``: which display, and everything its strip is drawn
|
||||
from. None when this strip must be drawn regardless.
|
||||
|
||||
The games go through sports_vegas.game_fingerprint, the same "has
|
||||
this card changed" the live Vegas cards are redrawn on: the whole
|
||||
game dict, so no field a card draws can be missed. The config is
|
||||
fingerprinted by value, not by identity, so a config edited in place
|
||||
counts as changed.
|
||||
"""
|
||||
if game_type not in self.STRIP_MEMO_GAME_TYPES or not games:
|
||||
return None
|
||||
# Iterated twice (here and by the build), so a one-shot iterable
|
||||
# would reach the build empty.
|
||||
if not isinstance(games, (list, tuple)) or not isinstance(leagues, (list, tuple)):
|
||||
return None
|
||||
try:
|
||||
slate = (game_type, tuple(leagues))
|
||||
hash(slate)
|
||||
key = (
|
||||
tuple(sports_vegas.game_fingerprint(game) for game in games),
|
||||
sports_vegas._freeze(rankings_cache),
|
||||
sports_vegas._freeze(self.config),
|
||||
(getattr(self.display_manager, "width", None),
|
||||
getattr(self.display_manager, "height", None)),
|
||||
# A backstop: no card reads the clock today (game dates come
|
||||
# in the game dicts), but a strip must not outlive its day.
|
||||
time.localtime()[:3],
|
||||
)
|
||||
except Exception:
|
||||
# A game dict changing size under a background update, say. The
|
||||
# build reads it anyway; only the reuse is given up.
|
||||
self.logger.debug("Strip for %s not reusable this turn", game_type,
|
||||
exc_info=True)
|
||||
return None
|
||||
return slate, key
|
||||
|
||||
def _strip_reusable(self, slot: _StripSlot, key: Tuple[Any, ...]) -> bool:
|
||||
if slot.key is None or slot.strip is None:
|
||||
return False
|
||||
if time.monotonic() - slot.built_at >= self.STRIP_MEMO_MAX_AGE_S:
|
||||
return False
|
||||
helper = getattr(slot.display, "scroll_helper", None)
|
||||
array = getattr(helper, "cached_array", None)
|
||||
if array is None or slot.strip() is not array:
|
||||
return False
|
||||
has_strip = getattr(helper, "has_strip", None)
|
||||
if callable(has_strip) and not has_strip():
|
||||
return False
|
||||
return bool(slot.key == key)
|
||||
|
||||
def _reuse_strip(
|
||||
self, game_type: str, slate: Tuple[str, Tuple[Any, ...]], key: Tuple[Any, ...],
|
||||
) -> Optional[SportsScrollDisplay]:
|
||||
"""The display already showing this exact strip, made the active one."""
|
||||
pool = self._strip_pools.get(game_type)
|
||||
slot = pool.get(slate) if pool else None
|
||||
if pool is None or slot is None or not self._strip_reusable(slot, key):
|
||||
return None
|
||||
pool.move_to_end(slate)
|
||||
slot.last_used = time.monotonic()
|
||||
# The display this replaces keeps its slot and strip for its own next
|
||||
# turn, within the parked-strip budget.
|
||||
self._scroll_displays[game_type] = slot.display
|
||||
self._trim_parked_strips()
|
||||
return slot.display
|
||||
|
||||
def _display_to_build(
|
||||
self, game_type: str, slate: Tuple[str, Tuple[Any, ...]],
|
||||
) -> Tuple[SportsScrollDisplay, Optional[SportsScrollDisplay], int]:
|
||||
"""The display to draw ``slate`` on, made the active one.
|
||||
|
||||
Returns it; when it replaced another as the active display, that
|
||||
one, to put back if the build fails -- a failed build left the
|
||||
previous strip showing when the game type had one display; and the
|
||||
slot's epoch the build must still find to record its key.
|
||||
"""
|
||||
pool = self._strip_pools.setdefault(game_type, OrderedDict())
|
||||
active = self._scroll_displays.get(game_type)
|
||||
slot = pool.get(slate)
|
||||
if slot is None:
|
||||
if active is not None and not any(s.display is active for s in pool.values()):
|
||||
# The game type's display, holding nothing reusable: this
|
||||
# slate is drawn on it, exactly as before slates had their own.
|
||||
slot = _StripSlot(active)
|
||||
elif len(pool) < max(1, self.STRIP_MEMO_SLATES_PER_TYPE):
|
||||
slot = _StripSlot(self._new_scroll_display())
|
||||
else:
|
||||
# The least recently shown slate's display draws this one.
|
||||
_, slot = pool.popitem(last=False)
|
||||
pool[slate] = slot
|
||||
# Whatever was reusable on it is about to be drawn over.
|
||||
slot.forget()
|
||||
pool.move_to_end(slate)
|
||||
slot.last_used = time.monotonic()
|
||||
self._scroll_displays[game_type] = slot.display
|
||||
replaced = active if active is not None and active is not slot.display else None
|
||||
return slot.display, replaced, slot.epoch
|
||||
|
||||
def _note_strip_built(
|
||||
self,
|
||||
game_type: str,
|
||||
keyed: Tuple[Tuple[str, Tuple[Any, ...]], Tuple[Any, ...]],
|
||||
scroll_display: SportsScrollDisplay,
|
||||
restore: Optional[SportsScrollDisplay],
|
||||
success: bool,
|
||||
started: float,
|
||||
epoch: int,
|
||||
) -> None:
|
||||
slate, key = keyed
|
||||
pool = self._strip_pools.get(game_type)
|
||||
slot = pool.get(slate) if pool else None
|
||||
if (slot is None or slot.display is not scroll_display or slot.epoch != epoch
|
||||
or self._scroll_displays.get(game_type) is not scroll_display):
|
||||
# Another prepare moved on while this one built, or drew on this
|
||||
# display too. Record nothing; the strip is drawn again next time.
|
||||
return
|
||||
array = getattr(scroll_display.scroll_helper, "cached_array", None)
|
||||
if success and array is not None:
|
||||
slot.key = key
|
||||
slot.built_at = started
|
||||
slot.strip = weakref.ref(array)
|
||||
elif not success and restore is not None:
|
||||
self._scroll_displays[game_type] = restore
|
||||
self._trim_parked_strips()
|
||||
|
||||
def _forget_shown_strip(self, game_type: str) -> None:
|
||||
"""A build this memo cannot key is about to draw on the active display."""
|
||||
active = self._scroll_displays.get(game_type)
|
||||
for slot in (self._strip_pools.get(game_type) or {}).values():
|
||||
if slot.display is active:
|
||||
slot.forget()
|
||||
|
||||
@staticmethod
|
||||
def _strip_bytes(scroll_display: SportsScrollDisplay) -> int:
|
||||
"""What keeping this display's strip costs: the strip's array, the
|
||||
image beside it, and its Vegas items."""
|
||||
array = getattr(scroll_display.scroll_helper, "cached_array", None)
|
||||
total = int(array.nbytes) * 2 if array is not None else 0
|
||||
for item in getattr(scroll_display, "_vegas_content_items", None) or ():
|
||||
total += item.width * item.height * len(item.getbands())
|
||||
return total
|
||||
|
||||
def _trim_parked_strips(self) -> None:
|
||||
"""Release the strips of displays not on screen until they fit
|
||||
STRIP_MEMO_MAX_PARKED_BYTES: those that can never be reused first (a
|
||||
failed build left an older slate's strip behind), then the least
|
||||
recently shown. The display itself is kept, so its slate's next turn
|
||||
draws on it again."""
|
||||
# list() first: get_scroll_display() can add a game type from another
|
||||
# thread (Vegas's 'mixed'), and a dict must not grow mid-iteration.
|
||||
on_screen = {id(display) for display in list(self._scroll_displays.values())}
|
||||
parked = []
|
||||
total = 0
|
||||
for pool in self._strip_pools.values():
|
||||
for slot in pool.values():
|
||||
if id(slot.display) in on_screen:
|
||||
continue
|
||||
try:
|
||||
size = self._strip_bytes(slot.display)
|
||||
except Exception:
|
||||
# Unmeasurable: assume it does not fit.
|
||||
size = self.STRIP_MEMO_MAX_PARKED_BYTES + 1
|
||||
if size:
|
||||
parked.append((slot.last_used, size, slot))
|
||||
total += size
|
||||
parked.sort(key=lambda entry: (entry[2].key is not None, entry[0]))
|
||||
for _, size, slot in parked:
|
||||
if total <= self.STRIP_MEMO_MAX_PARKED_BYTES:
|
||||
break
|
||||
slot.forget()
|
||||
self._release_strip(slot.display)
|
||||
total -= size
|
||||
|
||||
def _release_strip(self, scroll_display: SportsScrollDisplay) -> None:
|
||||
"""Drop a display's strip without SportsScrollDisplay.clear(), which
|
||||
also tells the display manager nothing is scrolling -- not this
|
||||
display's to say while another one is on screen."""
|
||||
try:
|
||||
scroll_display.scroll_helper.clear_cache()
|
||||
scroll_display._vegas_content_items = []
|
||||
except Exception:
|
||||
self.logger.debug("Could not release a parked strip", exc_info=True)
|
||||
|
||||
def display_frame(self, game_type: Optional[str] = None) -> bool:
|
||||
"""Advance the active strip (or a named one) by one frame."""
|
||||
game_type = game_type or self._current_game_type
|
||||
@@ -520,11 +1024,48 @@ class SportsScrollDisplayManager:
|
||||
return scroll_display.is_scroll_complete()
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Clear every display and forget which one was active."""
|
||||
for scroll_display in self._scroll_displays.values():
|
||||
"""Clear every display and forget which one was active.
|
||||
|
||||
The displays of slates not on screen too, and nothing cleared is
|
||||
reused: the next prepare draws its strip again.
|
||||
"""
|
||||
displays = list(self._scroll_displays.values())
|
||||
with self._strip_lock:
|
||||
for pool in self._strip_pools.values():
|
||||
for slot in pool.values():
|
||||
slot.forget()
|
||||
if not any(slot.display is shown for shown in displays):
|
||||
displays.append(slot.display)
|
||||
for scroll_display in displays:
|
||||
scroll_display.clear()
|
||||
self._current_game_type = ""
|
||||
|
||||
def get_vegas_elements_for(
|
||||
self,
|
||||
game_type: str,
|
||||
games: List[Dict[str, Any]],
|
||||
leagues: List[str],
|
||||
rankings_cache: Optional[Dict[str, int]] = None,
|
||||
fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||
) -> Optional[List[Any]]:
|
||||
"""Live Vegas cards for a slate, built on the ``game_type`` display.
|
||||
|
||||
None when the sport has no live cards (it does not implement
|
||||
make_vegas_renderer) or building them failed, so the plugin's
|
||||
get_vegas_elements() can return it and the ticker falls back to the
|
||||
plugin's ordinary Vegas content.
|
||||
"""
|
||||
scroll_display = self.get_scroll_display(game_type)
|
||||
try:
|
||||
return scroll_display.build_vegas_elements(
|
||||
games, leagues, rankings_cache, fingerprint)
|
||||
except NotImplementedError:
|
||||
return None
|
||||
except Exception:
|
||||
# Built straight from feed data, like prepare_scroll_content.
|
||||
self.logger.exception("Error building live Vegas cards")
|
||||
return None
|
||||
|
||||
def get_all_vegas_content_items(self) -> List[Image.Image]:
|
||||
"""Every display's Vegas items, for splicing into the marquee."""
|
||||
items: List[Image.Image] = []
|
||||
|
||||
+115
-22
@@ -57,7 +57,9 @@ Methods that stay per-plugin, because they are not identical across the eight
|
||||
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
|
||||
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
|
||||
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
|
||||
``_is_game_really_over``, ``_is_ranked_game``, ``_passes_other_filters``.
|
||||
``_is_ranked_game``, ``_passes_other_filters``. (``_is_game_really_over``,
|
||||
which ``_detect_stale_games`` below calls, was here too until the plugins
|
||||
reconciled it; it is now ``src.common.sports_game_over``.)
|
||||
|
||||
Of the fourteen shared class constants, thirteen are identical everywhere and
|
||||
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
|
||||
@@ -102,6 +104,7 @@ import requests
|
||||
from PIL import Image, ImageDraw
|
||||
from src.common import sports_card as _card
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
from src.common.text_helper import OUTLINE_SQUARE, draw_text_outlined
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -119,6 +122,32 @@ _DEFAULT_LIVE_IDLE_MAX_SECONDS = 900
|
||||
_KICKOFF_GRACE_SECONDS = 900
|
||||
#: Fallback cadence around a kickoff when the manager has no update_interval.
|
||||
_KICKOFF_POLL_FLOOR = 30
|
||||
#: How many kickoffs after the current one a live manager remembers. Only the
|
||||
#: earliest few can matter before the next look refreshes the list, so this
|
||||
#: bounds the memory without dropping a kickoff the board would wait for.
|
||||
_KICKOFF_QUEUE_MAX = 8
|
||||
|
||||
|
||||
def _current_scheduled_start(host: Any, now: float) -> Optional[float]:
|
||||
"""The kickoff a live manager is honouring now, promoting the next queued one.
|
||||
|
||||
``_next_scheduled_start_ts`` is the kickoff being honoured: the earliest
|
||||
one ahead of us, or one that has just passed and is inside its grace.
|
||||
Kickoffs behind it wait in ``_later_scheduled_starts``. When the current
|
||||
one's grace runs out, the earliest queued kickoff that is not itself past
|
||||
its grace takes over -- including one that has already passed, so a
|
||||
second kickoff inside the first one's grace still gets a grace of its own.
|
||||
"""
|
||||
current: Optional[float] = getattr(host, "_next_scheduled_start_ts", None)
|
||||
if current and current > now - _KICKOFF_GRACE_SECONDS:
|
||||
return current
|
||||
queued: Optional[List[float]] = getattr(host, "_later_scheduled_starts", None)
|
||||
if queued:
|
||||
alive = sorted(s for s in queued if s > now - _KICKOFF_GRACE_SECONDS)
|
||||
current = alive.pop(0) if alive else None
|
||||
host._later_scheduled_starts = alive
|
||||
host._next_scheduled_start_ts = current
|
||||
return current if current and current > now - _KICKOFF_GRACE_SECONDS else None
|
||||
|
||||
|
||||
def _resolve_font_path(path: str) -> str:
|
||||
@@ -359,14 +388,16 @@ class SportsCoreSharedMixin:
|
||||
The formatting is sports_card's. What differs from the card's
|
||||
``format_game_date`` is passed in: the setting (``switch_date_format``,
|
||||
see :meth:`_switch_date_format`) and the weekday, which comes from
|
||||
:meth:`_weekday_for` and so from this plugin's resolved timezone.
|
||||
:meth:`_weekday_for` and so from this plugin's resolved timezone
|
||||
when the game's start cannot place the printed date. The game goes
|
||||
in too, so both formatters name the printed date's own weekday.
|
||||
"""
|
||||
raw = str(date_text or "").strip()
|
||||
if not raw:
|
||||
return raw
|
||||
return _card._format_date_as(self._switch_date_format(), raw,
|
||||
lambda: self._weekday_for(game),
|
||||
self._MONTH_ABBR)
|
||||
self._MONTH_ABBR, game=game)
|
||||
|
||||
def _weekday_for(self, game: Optional[Dict]) -> str:
|
||||
"""Weekday abbreviation from the game's start time, or ''."""
|
||||
@@ -851,19 +882,12 @@ class SportsCoreSharedMixin:
|
||||
elif fill is None:
|
||||
fill = self._font_color(font)
|
||||
draw.fontmode = "1"
|
||||
x, y = position
|
||||
for dx, dy in [
|
||||
(-1, -1),
|
||||
(-1, 0),
|
||||
(-1, 1),
|
||||
(0, -1),
|
||||
(0, 1),
|
||||
(1, -1),
|
||||
(1, 0),
|
||||
(1, 1),
|
||||
]:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
# The eight-neighbour outline, then the text on top. Rasterized once
|
||||
# and stamped nine times rather than drawn nine times; the pixels are
|
||||
# the same (draw_text_outlined falls back to the nine draws wherever
|
||||
# that is not proven).
|
||||
draw_text_outlined(draw, position, text, font, fill, outline_color,
|
||||
OUTLINE_SQUARE)
|
||||
|
||||
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
|
||||
"""True at most once per ``cooldown`` seconds, for rate-limiting a
|
||||
@@ -1298,11 +1322,11 @@ class SportsLiveSharedMixin:
|
||||
otherwise look like another empty check and escalate the back-off
|
||||
again, right when the game is actually starting.
|
||||
"""
|
||||
start = getattr(self, "_next_scheduled_start_ts", None)
|
||||
now = time.time()
|
||||
start = _current_scheduled_start(self, now)
|
||||
if not start:
|
||||
return interval
|
||||
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
|
||||
now = time.time()
|
||||
if now < start:
|
||||
return max(live, min(interval, int(start - now)))
|
||||
if now - start <= _KICKOFF_GRACE_SECONDS:
|
||||
@@ -1317,6 +1341,16 @@ class SportsLiveSharedMixin:
|
||||
already has. Self-correcting: a stored start that has passed is
|
||||
replaced by the next one offered, so a postponed game cannot pin the
|
||||
cadence to a kickoff that never happens.
|
||||
|
||||
Every pending kickoff is honoured, not just the first. A kickoff that
|
||||
arrives while an earlier one is inside its grace is queued in
|
||||
``_later_scheduled_starts`` (the earliest _KICKOFF_QUEUE_MAX of them)
|
||||
and takes over when that grace ends, with a grace of its own. Keeping
|
||||
only the one kickoff dropped the second of two favourites starting
|
||||
within the grace of each other: it was refused while the first held
|
||||
the slot, and refused again once it had passed, so if ESPN had not
|
||||
flipped it live by the end of the first grace the back-off went
|
||||
straight back to its ceiling and the game was noticed up to that late.
|
||||
"""
|
||||
if not isinstance(details, dict):
|
||||
return
|
||||
@@ -1333,7 +1367,7 @@ class SportsLiveSharedMixin:
|
||||
now = time.time()
|
||||
if candidate <= now:
|
||||
return
|
||||
current = getattr(self, "_next_scheduled_start_ts", None)
|
||||
current = _current_scheduled_start(self, now)
|
||||
# A kickoff that has only just passed is *kept*, not replaced by the
|
||||
# next one on the card. Replacing it immediately is what made the grace
|
||||
# window in _clamp_to_scheduled_start dead code: the moment 13:00 came
|
||||
@@ -1343,10 +1377,69 @@ class SportsLiveSharedMixin:
|
||||
# polled at 13:00:45, found nothing live because ESPN had not flipped
|
||||
# the status yet, and then went quiet for the next quarter of an hour,
|
||||
# which is the behaviour this whole clamp exists to prevent.
|
||||
if (current is None
|
||||
or current <= now - _KICKOFF_GRACE_SECONDS
|
||||
or candidate < current):
|
||||
#
|
||||
# Nor is it forgotten: whichever kickoff loses is queued behind the
|
||||
# one honoured now, so it gets its own grace when that one's ends.
|
||||
if current is None:
|
||||
self._next_scheduled_start_ts = candidate
|
||||
return
|
||||
if candidate == current:
|
||||
return
|
||||
if candidate < current:
|
||||
self._next_scheduled_start_ts, candidate = candidate, current
|
||||
queued = getattr(self, "_later_scheduled_starts", None) or []
|
||||
if candidate not in queued:
|
||||
self._later_scheduled_starts = sorted(
|
||||
[*queued, candidate])[:_KICKOFF_QUEUE_MAX]
|
||||
|
||||
#: How long a game that finished live is still reported by
|
||||
#: finished_games_snapshot(): long enough for the recent-games list, which
|
||||
#: refreshes about hourly, to take it over well before most slates would.
|
||||
FINISHED_GAME_TTL = 900.0
|
||||
|
||||
def _record_finished_game(self, details: Dict) -> None:
|
||||
"""Remember a game that was live and has just gone final (or looks over).
|
||||
|
||||
A finished game leaves ``live_games`` at the next poll, and the recent
|
||||
list that will show it refreshes about hourly, so in between nothing
|
||||
holds the game's final score -- and a live Vegas card for it would keep
|
||||
its last live score. Call this wherever a poll drops a game as final
|
||||
or over. Only a game this manager had as live is taken; one already
|
||||
held takes the newer details (a game dropped by an "is it over"
|
||||
heuristic, then marked final by the feed) but keeps its expiry, so a
|
||||
feed that lists finals all day cannot keep one here all day.
|
||||
"""
|
||||
game_id = details.get("id") if isinstance(details, dict) else None
|
||||
if not game_id:
|
||||
return
|
||||
finished = self.__dict__.setdefault("_finished_games", {})
|
||||
held = finished.get(game_id)
|
||||
if held is not None:
|
||||
finished[game_id] = (held[0], dict(details))
|
||||
return
|
||||
if not any(g.get("id") == game_id for g in getattr(self, "live_games", ()) or ()):
|
||||
return
|
||||
finished[game_id] = (time.monotonic(), dict(details))
|
||||
|
||||
def finished_games_snapshot(self) -> List[Dict]:
|
||||
"""Games that went final here within FINISHED_GAME_TTL, newest data first.
|
||||
|
||||
Copies, safe to decorate. The caller dedupes them against its other
|
||||
lists (src/common/sports_vegas.dedupe_games keeps the liveliest copy,
|
||||
and a final beats nothing but a live one).
|
||||
"""
|
||||
finished = self.__dict__.get("_finished_games")
|
||||
if not finished:
|
||||
return []
|
||||
now = time.monotonic()
|
||||
# A copy first: a manager finishing its update in the background (off
|
||||
# the plugin's lock) may record a game while the ticker reads these.
|
||||
held = list(finished.items())
|
||||
for game_id, (seen, _game) in held:
|
||||
if now - seen > self.FINISHED_GAME_TTL:
|
||||
finished.pop(game_id, None)
|
||||
return [dict(game) for _id, (seen, game) in held
|
||||
if now - seen <= self.FINISHED_GAME_TTL]
|
||||
|
||||
def _note_live_fetch(self, found_live: bool) -> None:
|
||||
"""Record whether a look for live games found any."""
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Live Vegas cards for the sports scoreboards.
|
||||
|
||||
A scoreboard hands the Vegas ticker one card per game. As live elements
|
||||
(src/plugin_system/vegas_elements.py) those cards change on the panel while
|
||||
they scroll: a goal redraws its game's card and the ticker swaps it in place.
|
||||
This module is what every scoreboard needs for that and would otherwise write
|
||||
nine times:
|
||||
|
||||
- :func:`game_key` -- a stable key per game, so the ticker can tell which card
|
||||
a redraw belongs to however the slate is re-sorted.
|
||||
- :class:`VegasCardCache` -- draws a card only when what it shows changed
|
||||
(its fingerprint), so an unchanged slate costs a dictionary lookup per game
|
||||
and a changed one only the cards that changed.
|
||||
- :class:`StickyOdds` -- live odds are fetched only for games near the front
|
||||
of the rotation, so a card's odds come and go between polls; this keeps the
|
||||
last odds for a while instead of redrawing the card without them.
|
||||
- :func:`dedupe_games` -- a game present in two managers' lists (live and
|
||||
recent, around the final whistle) appears once, its liveliest copy.
|
||||
- :func:`finished_games` / :func:`with_finished_games` -- a game that has just
|
||||
gone final keeps its card, now showing FINAL, where its live card was,
|
||||
until the recent list (refreshed about hourly) takes it over.
|
||||
- :func:`game_fingerprint` -- what a card is redrawn on by default: the whole
|
||||
game dict, frozen hashable.
|
||||
|
||||
SportsScrollDisplay.build_vegas_elements (src/common/sports_scroll.py) puts
|
||||
them together; a plugin adopts it by implementing make_vegas_renderer().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
#: Which copy of a duplicated game wins: the liveliest.
|
||||
_STATE_PRIORITY = {'in': 3, 'post': 2, 'pre': 1}
|
||||
|
||||
|
||||
def _state(game: Dict[str, Any]) -> str:
|
||||
status = game.get('status')
|
||||
state = status.get('state') if isinstance(status, dict) else status
|
||||
if isinstance(state, str):
|
||||
return state
|
||||
if game.get('is_live'):
|
||||
return 'in'
|
||||
if game.get('is_final'):
|
||||
return 'post'
|
||||
return 'pre'
|
||||
|
||||
|
||||
def _freeze(value: Any) -> Any:
|
||||
"""A hashable, order-stable copy of feed data."""
|
||||
if isinstance(value, dict):
|
||||
return tuple(sorted((str(k), _freeze(v)) for k, v in value.items()))
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(_freeze(v) for v in value)
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return repr(value)
|
||||
|
||||
|
||||
def game_fingerprint(game: Dict[str, Any]) -> Hashable:
|
||||
"""Everything in a game dict, hashable: a card drawn from it changes only if this does.
|
||||
|
||||
The default card version. Nothing a card could draw is left out, so no
|
||||
field is ever frozen on the panel; the cost is a redraw when a field the
|
||||
card does not draw changes too, which feed data rarely does between polls.
|
||||
"""
|
||||
frozen: Hashable = _freeze(game)
|
||||
return frozen
|
||||
|
||||
|
||||
def game_key(game: Dict[str, Any]) -> str:
|
||||
"""A key that names this game and nothing else, across polls.
|
||||
|
||||
``game:<league>:<id>`` from the feed's own id. A game without one falls
|
||||
back to its teams and start time, which is stable for the life of a game.
|
||||
"""
|
||||
league = game.get('league') or 'game'
|
||||
game_id = game.get('id') or game.get('game_id')
|
||||
if game_id not in (None, ''):
|
||||
return f"game:{league}:{game_id}"
|
||||
away = game.get('away_abbr') or game.get('away_team') or '?'
|
||||
home = game.get('home_abbr') or game.get('home_team') or '?'
|
||||
start = game.get('start_time_utc') or game.get('start_time') or ''
|
||||
return f"game:{league}:{away}@{home}:{start}"
|
||||
|
||||
|
||||
def dedupe_games(games: Iterable[Dict[str, Any]],
|
||||
key_fn: Callable[[Dict[str, Any]], str] = game_key) -> List[Dict[str, Any]]:
|
||||
"""Each game once, in first-seen order, keeping its liveliest copy.
|
||||
|
||||
Around a final whistle a game can be in the live list (last poll) and the
|
||||
recent list (next poll) at once; two cards with one key would be refused
|
||||
by the ticker, and showing the game twice is wrong anyway.
|
||||
"""
|
||||
chosen: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
||||
for game in games:
|
||||
key = key_fn(game)
|
||||
current = chosen.get(key)
|
||||
if current is None or _STATE_PRIORITY.get(_state(game), 0) > \
|
||||
_STATE_PRIORITY.get(_state(current), 0):
|
||||
chosen[key] = game
|
||||
return list(chosen.values())
|
||||
|
||||
|
||||
def finished_games(
|
||||
live_managers: Iterable[Tuple[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Games that just left these live managers' lists, final ones as recent games.
|
||||
|
||||
``live_managers`` pairs each league with its live manager (None is
|
||||
skipped). Each manager reports what SportsLiveSharedMixin recorded
|
||||
(finished_games_snapshot, copies), with its league. A final game is
|
||||
drawn as a recent card. One a poll only judged over -- a tied end of
|
||||
regulation looks like that too -- keeps its last live state, so its card
|
||||
never says FINAL early; if play resumes the live list has it again, and
|
||||
dedupe_games keeps that copy.
|
||||
"""
|
||||
finished: List[Dict[str, Any]] = []
|
||||
for league, manager in live_managers:
|
||||
snapshot = getattr(manager, 'finished_games_snapshot', None)
|
||||
if not callable(snapshot):
|
||||
continue
|
||||
for game in snapshot():
|
||||
game['league'] = league
|
||||
if game.get('is_final'):
|
||||
status = game.get('status')
|
||||
status = dict(status) if isinstance(status, dict) else {}
|
||||
status['state'] = 'post'
|
||||
game.update(status=status, is_live=False)
|
||||
finished.append(game)
|
||||
return finished
|
||||
|
||||
|
||||
def with_finished_games(
|
||||
games: List[Dict[str, Any]], leagues: List[str],
|
||||
finished: List[Dict[str, Any]],
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
"""The slate with games that just went final where their live cards were.
|
||||
|
||||
A slate lists each league's games together, live ones first. Each
|
||||
finished game goes after its league's live games, ahead of the rest; a
|
||||
league with no games left in the slate is added at the end. A finished
|
||||
game the slate also has (the recent list caught up) is left for
|
||||
dedupe_games, which keeps one copy.
|
||||
"""
|
||||
if not finished:
|
||||
return list(games), list(leagues)
|
||||
pending: "OrderedDict[Any, List[Dict[str, Any]]]" = OrderedDict()
|
||||
for game in finished:
|
||||
pending.setdefault(game.get('league'), []).append(game)
|
||||
merged: List[Dict[str, Any]] = []
|
||||
for index, game in enumerate(games):
|
||||
league = game.get('league')
|
||||
if league in pending and _state(game) != 'in':
|
||||
merged.extend(pending.pop(league))
|
||||
merged.append(game)
|
||||
following = games[index + 1] if index + 1 < len(games) else None
|
||||
if league in pending and (following is None or following.get('league') != league):
|
||||
merged.extend(pending.pop(league)) # the league's games were all live
|
||||
leagues = list(leagues)
|
||||
for league, rest in pending.items():
|
||||
merged.extend(rest)
|
||||
if league not in leagues:
|
||||
leagues.append(league)
|
||||
return merged, leagues
|
||||
|
||||
|
||||
class VegasCardCache:
|
||||
"""Cards drawn once per fingerprint, kept for as long as their game is.
|
||||
|
||||
``element(key, fingerprint, render)`` returns a VegasElement whose image is
|
||||
``render()``'s -- called only when the fingerprint differs from the one the
|
||||
cached card was drawn for. The fingerprint is also the element's version,
|
||||
so the ticker skips unchanged cards without comparing pixels.
|
||||
|
||||
Bounded: keys not passed to :meth:`retain` after a slate are dropped, and
|
||||
at most ``max_entries`` are ever held (oldest first).
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int = 96) -> None:
|
||||
self.max_entries = max(1, int(max_entries))
|
||||
self._cards: "OrderedDict[str, Tuple[Hashable, Image.Image]]" = OrderedDict()
|
||||
self.renders = 0
|
||||
|
||||
def element(self, key: str, fingerprint: Hashable,
|
||||
render: Callable[[], Image.Image], live: bool = True) -> Any:
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
|
||||
cached = self._cards.get(key)
|
||||
if cached is not None and cached[0] == fingerprint:
|
||||
self._cards.move_to_end(key)
|
||||
image = cached[1]
|
||||
else:
|
||||
image = render()
|
||||
self.renders += 1
|
||||
self._cards[key] = (fingerprint, image)
|
||||
self._cards.move_to_end(key)
|
||||
while len(self._cards) > self.max_entries:
|
||||
self._cards.popitem(last=False)
|
||||
return VegasElement(key=key, image=image, version=fingerprint, live=live)
|
||||
|
||||
def retain(self, keys: Iterable[str]) -> None:
|
||||
"""Forget every card whose key is not in ``keys``."""
|
||||
keep = set(keys)
|
||||
for key in [k for k in self._cards if k not in keep]:
|
||||
self._cards.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._cards.clear()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cards)
|
||||
|
||||
|
||||
class StickyOdds:
|
||||
"""Keep a game's last odds on its card while a live poll leaves them out.
|
||||
|
||||
Live odds are fetched only for games near the front of the rotation
|
||||
(src/common/sports_fetch.py), so the same game's dict has odds on one poll
|
||||
and none on the next. Drawn as-is that redraws the card every poll with
|
||||
the odds flickering in and out. ``apply`` returns the game with its last
|
||||
non-empty odds put back, for up to ``ttl_s`` seconds after they were seen.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_s: float = 600.0) -> None:
|
||||
self.ttl_s = float(ttl_s)
|
||||
self._seen: Dict[str, Tuple[float, Any]] = {}
|
||||
|
||||
def apply(self, key: str, game: Dict[str, Any],
|
||||
now: Optional[float] = None) -> Dict[str, Any]:
|
||||
now = time.monotonic() if now is None else now
|
||||
odds = game.get('odds')
|
||||
if odds:
|
||||
self._seen[key] = (now, odds)
|
||||
return game
|
||||
seen = self._seen.get(key)
|
||||
if seen is None or now - seen[0] > self.ttl_s:
|
||||
self._seen.pop(key, None)
|
||||
return game
|
||||
refilled = dict(game)
|
||||
refilled['odds'] = seen[1]
|
||||
return refilled
|
||||
|
||||
def retain(self, keys: Iterable[str]) -> None:
|
||||
keep = set(keys)
|
||||
for key in [k for k in self._seen if k not in keep]:
|
||||
self._seen.pop(key, None)
|
||||
@@ -29,7 +29,6 @@ import time
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Callable, Optional
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from src.config_manager_atomic import _replace
|
||||
@@ -434,6 +433,12 @@ class DisplaySyncManager:
|
||||
return
|
||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
||||
return
|
||||
# numpy is imported here, not at module level: the web interface
|
||||
# imports this module for its constants (STATUS_FILE, SYNC_PORT) and
|
||||
# would otherwise load numpy for nothing. Only a connected leader
|
||||
# gets this far, and after the first frame the import is a
|
||||
# sys.modules lookup.
|
||||
import numpy as np
|
||||
try:
|
||||
arr = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
header = _RAW_MAGIC + _RAW_HEADER.pack(image.width, image.height)
|
||||
|
||||
+178
-10
@@ -7,7 +7,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
@@ -15,6 +15,174 @@ from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
|
||||
#: A one-pixel outline on all eight sides, in the order the scoreboards have
|
||||
#: always drawn it (dx outer, dy inner). The order can change pixels only
|
||||
#: where anti-aliased (fontmode "L") edges overlap; it is kept anyway.
|
||||
OUTLINE_SQUARE: Tuple[Tuple[int, int], ...] = (
|
||||
(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1))
|
||||
|
||||
#: A one-pixel outline on the four edge sides only, leaving the diagonal
|
||||
#: corners open: the thinner outline ufc's fight card draws.
|
||||
OUTLINE_CROSS: Tuple[Tuple[int, int], ...] = ((-1, 0), (1, 0), (0, -1), (0, 1))
|
||||
|
||||
# What the stamping path in draw_text_outlined is proven pixel-identical for
|
||||
# (test/test_text_helper.py compares it with the draw.text loop across every
|
||||
# combination). Anything else takes the loop. Compared with ``in`` on tuples
|
||||
# rather than sets so an unhashable fontmode falls back instead of raising.
|
||||
_STAMP_DRAW_MODES = ("RGB", "RGBA", "L")
|
||||
_STAMP_FONT_MODES = ("1", "L")
|
||||
|
||||
# ImageDraw.text as Pillow defines it, which the stamping path stands in for.
|
||||
# A draw whose text has been replaced since -- on the class or the instance,
|
||||
# as a test recording the strings drawn does -- takes the loop, so the
|
||||
# replacement still sees every call.
|
||||
_PILLOW_DRAW_TEXT = ImageDraw.ImageDraw.text
|
||||
|
||||
|
||||
def draw_text_outlined(draw: ImageDraw.ImageDraw, xy: Sequence[Any], text: Any,
|
||||
font: Any, fill: Any,
|
||||
outline_color: Any = (0, 0, 0),
|
||||
offsets: Iterable[Sequence[Any]] = OUTLINE_SQUARE) -> None:
|
||||
"""Draw ``text`` in ``outline_color`` at each of ``offsets``, then in ``fill`` on top.
|
||||
|
||||
The result is pixel-identical to the loop every outlined draw used to be::
|
||||
|
||||
x, y = xy
|
||||
for dx, dy in offsets:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
but each ``draw.text`` rasterizes the whole string through FreeType again,
|
||||
so the default nine draws did the same glyph work nine times, and on a
|
||||
scoreboard card that text work is much of the render. Here the string is
|
||||
rasterized once and the one mask is stamped at every offset, which is
|
||||
what ``draw.text`` itself does with the mask, so the pixels are the same.
|
||||
|
||||
That holds only where it has been checked: a plain ``ImageDraw`` whose
|
||||
``text`` is Pillow's, a ``FreeTypeFont``, one line of ``str``, whole-pixel
|
||||
``xy`` (an int, or a float with nothing after the point, which is what
|
||||
centring on a measured ``textlength`` with ``// 2`` gives) and int
|
||||
offsets, and the image and font modes in ``_STAMP_DRAW_MODES`` /
|
||||
``_STAMP_FONT_MODES``. Fractional coordinates change the raster itself
|
||||
(Pillow rasterizes at the sub-pixel start), and multiline text is laid
|
||||
out line by line. Every other case, and anything
|
||||
the stamping path cannot prepare, runs the loop above unchanged, so it
|
||||
behaves exactly as before, errors included.
|
||||
|
||||
Args:
|
||||
draw: The ``ImageDraw`` to draw on.
|
||||
xy: Top-left (x, y) of the text, as for ``draw.text``.
|
||||
text: The text.
|
||||
font: The font, as for ``draw.text``.
|
||||
fill: Colour of the text itself, drawn last.
|
||||
outline_color: Colour of the outline.
|
||||
offsets: (dx, dy) of each outline draw, in drawing order.
|
||||
:data:`OUTLINE_SQUARE` (the default) or :data:`OUTLINE_CROSS`.
|
||||
"""
|
||||
x, y = xy
|
||||
# Read once: the loop below may have to start over after the stamping
|
||||
# path looked at them.
|
||||
offsets = tuple(offsets)
|
||||
if _can_stamp(draw, x, y, text, font, offsets):
|
||||
if _stamp_outlined(draw, int(x), int(y), text, font, fill,
|
||||
outline_color, offsets):
|
||||
return
|
||||
for dx, dy in offsets:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
|
||||
def _can_stamp(draw: Any, x: Any, y: Any, text: Any, font: Any,
|
||||
offsets: Tuple[Any, ...]) -> bool:
|
||||
"""Whether draw_text_outlined may stamp one mask instead of drawing N times.
|
||||
|
||||
Exact types for the draw and the font, and Pillow's own ``draw.text``: a
|
||||
subclass may override ``text`` or ``getmask2``, or a test may replace
|
||||
``draw.text`` to record what is drawn, and stamping would skip either.
|
||||
"""
|
||||
return (
|
||||
type(draw) is ImageDraw.ImageDraw
|
||||
and ImageDraw.ImageDraw.text is _PILLOW_DRAW_TEXT
|
||||
and "text" not in vars(draw)
|
||||
and type(font) is ImageFont.FreeTypeFont
|
||||
and isinstance(text, str)
|
||||
and "\n" not in text
|
||||
and "\r" not in text
|
||||
and _whole_pixel(x)
|
||||
and _whole_pixel(y)
|
||||
and all(isinstance(o, (tuple, list)) and len(o) == 2
|
||||
and isinstance(o[0], int) and isinstance(o[1], int)
|
||||
for o in offsets)
|
||||
and draw.mode in _STAMP_DRAW_MODES
|
||||
and draw.fontmode in _STAMP_FONT_MODES
|
||||
)
|
||||
|
||||
|
||||
def _whole_pixel(v: Any) -> bool:
|
||||
"""An int, or a float on a whole pixel, as a draw.text coordinate.
|
||||
|
||||
For those, draw.text's ``int(x + dx)`` is ``int(x) + dx`` and its
|
||||
sub-pixel start is 0 (or -0.0, which renders the same), so one mask fits
|
||||
every offset. Floats are held well inside the range where ``x + dx`` is
|
||||
exact; nothing that far out is on any canvas, so the loop the rest take
|
||||
costs nothing that matters.
|
||||
"""
|
||||
if isinstance(v, int):
|
||||
return True
|
||||
return isinstance(v, float) and v.is_integer() and -2**31 < v < 2**31
|
||||
|
||||
|
||||
def _text_ink(draw: ImageDraw.ImageDraw, color: Any) -> Any:
|
||||
"""The ink ``ImageDraw.text`` resolves ``color`` to (its inner getink)."""
|
||||
ink, fill_ink = draw._getink(color)
|
||||
return fill_ink if ink is None else ink
|
||||
|
||||
|
||||
def _stamp_outlined(draw: ImageDraw.ImageDraw, x: int, y: int, text: str,
|
||||
font: ImageFont.FreeTypeFont, fill: Any, outline_color: Any,
|
||||
offsets: Tuple[Sequence[Any], ...]) -> bool:
|
||||
"""Rasterize once and stamp; False, with nothing drawn, to take the loop.
|
||||
|
||||
Replays what ``ImageDraw.text`` does for one line at an integer position
|
||||
(Pillow 11 and 12): ``font.getmask2`` with these arguments, then
|
||||
``draw.draw.draw_bitmap`` at the position plus the mask's offset.
|
||||
``draw.draw`` and ``draw._getink`` are Pillow internals, so everything up
|
||||
to the first pixel is guarded: if anything fails before then, nothing has
|
||||
been drawn and the loop runs instead, which then fails (or not) exactly
|
||||
as it always did -- a bad fill colour still raises after the outline is
|
||||
drawn, as it did from the last ``draw.text``.
|
||||
"""
|
||||
try:
|
||||
outline_ink = _text_ink(draw, outline_color)
|
||||
text_ink = _text_ink(draw, fill)
|
||||
# What draw.text passes for a single line with no anchor at a whole
|
||||
# pixel position, by keyword so a getmask2 with another parameter
|
||||
# order cannot shift them. ink only matters to an RGBA (colour-glyph)
|
||||
# mask, which the font modes allowed here never produce.
|
||||
mask, (ox, oy) = font.getmask2(
|
||||
text, draw.fontmode, direction=None, features=None,
|
||||
language=None, stroke_width=0, anchor="la", ink=text_ink,
|
||||
start=(0.0, 0.0), stroke_filled=True)
|
||||
draw_bitmap = draw.draw.draw_bitmap
|
||||
except Exception:
|
||||
return False
|
||||
stamped = False
|
||||
try:
|
||||
# draw.text returns without drawing when its ink resolves to None.
|
||||
if outline_ink is not None:
|
||||
for dx, dy in offsets:
|
||||
draw_bitmap((x + dx + ox, y + dy + oy), mask, outline_ink)
|
||||
stamped = True
|
||||
if text_ink is not None:
|
||||
draw_bitmap((x + ox, y + oy), mask, text_ink)
|
||||
except Exception:
|
||||
# A rejected call draws nothing, but one that got through has: never
|
||||
# draw the outline twice (anti-aliased edges would be blended twice).
|
||||
if stamped:
|
||||
raise
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class TextHelper:
|
||||
"""
|
||||
@@ -103,15 +271,15 @@ class TextHelper:
|
||||
outline_width: Width of outline in pixels
|
||||
"""
|
||||
x, y = position
|
||||
|
||||
# Draw outline by drawing text in outline color at offset positions
|
||||
for dx in range(-outline_width, outline_width + 1):
|
||||
for dy in range(-outline_width, outline_width + 1):
|
||||
if dx != 0 or dy != 0: # Skip center position
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||
|
||||
# Draw main text
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
# Outline: every offset up to outline_width away on each axis, centre
|
||||
# skipped, in the order this has always drawn them (OUTLINE_SQUARE at
|
||||
# width 1). The main text is drawn last, on top.
|
||||
offsets = [(dx, dy)
|
||||
for dx in range(-outline_width, outline_width + 1)
|
||||
for dy in range(-outline_width, outline_width + 1)
|
||||
if dx != 0 or dy != 0]
|
||||
draw_text_outlined(draw, (x, y), text, font, fill, outline_color, offsets)
|
||||
|
||||
def get_text_width(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||
"""
|
||||
|
||||
+88
-13
@@ -46,6 +46,35 @@ from src.common.permission_utils import (
|
||||
get_config_dir_mode
|
||||
)
|
||||
|
||||
|
||||
def _private_copy(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""A deep copy of ``config`` that shares nothing with it.
|
||||
|
||||
load_config() hands one out per call, and the saves keep one, so the
|
||||
cached config is never an object a caller holds. A web handler edits what
|
||||
it loaded, validates, and may refuse the save; when the cache was that
|
||||
same object, the refused edit stayed in it, and the next save of any
|
||||
other setting wrote it to config.json -- a nested secret included, in
|
||||
plain text, since it had never reached config_secrets.json to be
|
||||
stripped.
|
||||
|
||||
The config is JSON data, so only its dicts and lists need copying; every
|
||||
other value in it is immutable. On a Pi 4 with a real 60 KiB config this
|
||||
takes 2.1 ms against copy.deepcopy's 6.8 ms, on a path ~30 handlers call
|
||||
(a pickle round trip is no faster, 1.9 ms, and brings pickle into the
|
||||
config path for nothing).
|
||||
"""
|
||||
return _copy_containers(config)
|
||||
|
||||
|
||||
def _copy_containers(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: _copy_containers(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_copy_containers(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""
|
||||
Reads and writes the main application configuration files.
|
||||
@@ -126,9 +155,10 @@ class ConfigManager:
|
||||
validate_after_write=validate_after_write
|
||||
)
|
||||
|
||||
# Update in-memory config if save was successful
|
||||
# Update in-memory config if save was successful. A copy: the caller
|
||||
# still holds new_config_data (see _private_copy).
|
||||
if result.status == SaveResultStatus.SUCCESS:
|
||||
self.config = new_config_data
|
||||
self.config = _private_copy(new_config_data)
|
||||
# In-memory config now matches what was just written, so the
|
||||
# load_config fast path may return it. It still carries the
|
||||
# merged secrets that were stripped on disk; that matches a full
|
||||
@@ -208,14 +238,16 @@ class ConfigManager:
|
||||
|
||||
Fast path: when config.json, config_secrets.json and the template
|
||||
are all unchanged since the last successful load (mtime_ns + size),
|
||||
the already-parsed self.config is returned without touching the
|
||||
files — same aliasing semantics as the full path, which also
|
||||
returns self.config.
|
||||
a copy of the already-parsed self.config is returned without
|
||||
touching the files.
|
||||
|
||||
Either way the caller gets its own copy (see _private_copy): editing
|
||||
it changes nothing here until it is saved.
|
||||
"""
|
||||
try:
|
||||
current_sig = self._files_signature()
|
||||
if self.config and self._loaded_sig == current_sig:
|
||||
return self.config
|
||||
return _private_copy(self.config)
|
||||
|
||||
# Check if config file exists, if not create from template
|
||||
if not os.path.exists(self.config_path):
|
||||
@@ -249,8 +281,8 @@ class ConfigManager:
|
||||
# Signature taken AFTER load + migration (migration may write the
|
||||
# config back), so it reflects exactly what was read/written.
|
||||
self._loaded_sig = self._files_signature()
|
||||
return self.config
|
||||
|
||||
return _private_copy(self.config)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
# Only config.json can get here: a missing or unreadable secrets
|
||||
# file is handled where it is read.
|
||||
@@ -355,8 +387,9 @@ class ConfigManager:
|
||||
try:
|
||||
atomic_write_json(self.config_path, config_to_write)
|
||||
|
||||
# Update the in-memory config to the new state (which includes secrets for runtime)
|
||||
self.config = new_config_data
|
||||
# Update the in-memory config to the new state (which includes
|
||||
# secrets for runtime), as a copy -- see _private_copy
|
||||
self.config = _private_copy(new_config_data)
|
||||
self._loaded_sig = self._files_signature()
|
||||
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
||||
if secrets_content:
|
||||
@@ -451,8 +484,10 @@ class ConfigManager:
|
||||
template_config = json.load(f)
|
||||
|
||||
# Check if migration is needed
|
||||
if self._config_needs_migration(self.config, template_config):
|
||||
self.logger.info("Config migration needed - adding new configuration items with defaults")
|
||||
needs_merge = self._config_needs_migration(self.config, template_config)
|
||||
if needs_merge or self._live_in_ticker_needs_migration():
|
||||
if needs_merge:
|
||||
self.logger.info("Config migration needed - adding new configuration items with defaults")
|
||||
|
||||
# Create backup of current config
|
||||
backup_path = f"{self.config_path}.backup"
|
||||
@@ -461,7 +496,9 @@ class ConfigManager:
|
||||
self.logger.info(f"Created backup of current config at {os.path.abspath(backup_path)}")
|
||||
|
||||
# Merge template defaults into current config
|
||||
self._merge_template_defaults(self.config, template_config)
|
||||
if needs_merge:
|
||||
self._merge_template_defaults(self.config, template_config)
|
||||
self._migrate_live_in_ticker_default()
|
||||
|
||||
# save_config_atomic strips the merged secrets back out and
|
||||
# keeps the file's owner and mode.
|
||||
@@ -482,6 +519,44 @@ class ConfigManager:
|
||||
self.logger.error(f"Error during config migration: {e}")
|
||||
# Don't raise - continue with current config
|
||||
|
||||
#: Set in display.vegas_scroll once _migrate_live_in_ticker_default() has
|
||||
#: run. Never in the template: the template merge would add it first, and
|
||||
#: the flip would then never run.
|
||||
LIVE_IN_TICKER_MARKER = 'live_in_ticker_migrated'
|
||||
|
||||
def _vegas_scroll_section(self) -> Optional[Dict[str, Any]]:
|
||||
display = self.config.get('display')
|
||||
vegas = display.get('vegas_scroll') if isinstance(display, dict) else None
|
||||
return vegas if isinstance(vegas, dict) else None
|
||||
|
||||
def _live_in_ticker_needs_migration(self) -> bool:
|
||||
vegas = self._vegas_scroll_section()
|
||||
return vegas is not None and not vegas.get(self.LIVE_IN_TICKER_MARKER)
|
||||
|
||||
def _migrate_live_in_ticker_default(self) -> None:
|
||||
"""Turn on live_in_ticker for a config that only ever had the old default. Once.
|
||||
|
||||
LEDMatrix 3.8.0 makes ``display.vegas_scroll.live_in_ticker`` true:
|
||||
live games stay in the Vegas ticker, their cards updating while they
|
||||
scroll, instead of the ticker giving way to the full-screen
|
||||
scoreboard. Every existing config holds an explicit ``false`` copied
|
||||
from the template -- there was no control for it -- and the template
|
||||
merge only adds missing keys, so the new default would reach nobody.
|
||||
This rewrites that ``false`` once and marks the config, so a
|
||||
``false`` chosen afterwards (the Vegas checkbox, or by hand) stays.
|
||||
"""
|
||||
vegas = self._vegas_scroll_section()
|
||||
if vegas is None or vegas.get(self.LIVE_IN_TICKER_MARKER):
|
||||
return
|
||||
vegas[self.LIVE_IN_TICKER_MARKER] = True
|
||||
if vegas.get('live_in_ticker') is False:
|
||||
vegas['live_in_ticker'] = True
|
||||
self.logger.info(
|
||||
"Vegas mode now keeps live games in the ticker (the new default): "
|
||||
"display.vegas_scroll.live_in_ticker turned on, once. Untick "
|
||||
"\"Keep live games in the ticker\" under Vegas mode for the "
|
||||
"full-screen scoreboard.")
|
||||
|
||||
def _config_needs_migration(self, current_config: Dict[str, Any], template_config: Dict[str, Any]) -> bool:
|
||||
"""Check if config needs migration by comparing with template."""
|
||||
return self._has_new_keys(current_config, template_config)
|
||||
|
||||
+92
-42
@@ -14,7 +14,7 @@ import json
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from typing import Dict, Any, Optional, List, Callable, Tuple
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
import hashlib
|
||||
@@ -52,7 +52,18 @@ class ConfigService:
|
||||
|
||||
# Thread safety
|
||||
self._lock: threading.RLock = threading.RLock()
|
||||
|
||||
# Held across a whole reload -- read, swap, notify -- so one reload's
|
||||
# notifications finish before the next one's start. Subscribers run
|
||||
# under this lock and never under _lock: the display's per-plugin
|
||||
# subscriber can wait seconds for a busy plugin, and get_config(),
|
||||
# subscribe() and unsubscribe() -- called from the render thread --
|
||||
# must not wait behind it.
|
||||
self._notify_lock: threading.RLock = threading.RLock()
|
||||
# (key, callback, thread id) of the callback a notification is running,
|
||||
# so unsubscribe() can wait for that one call; signalled on its return.
|
||||
self._running_callback: Optional[Tuple[str, Callable[..., None], int]] = None
|
||||
self._callback_done = threading.Condition(self._lock)
|
||||
|
||||
# Current configuration
|
||||
self._current_config: Dict[str, Any] = {}
|
||||
self._current_checksum: Optional[str] = None
|
||||
@@ -87,32 +98,33 @@ class ConfigService:
|
||||
True if config changed, False otherwise
|
||||
"""
|
||||
try:
|
||||
new_config = self.config_manager.load_config()
|
||||
new_checksum = self._calculate_checksum(new_config)
|
||||
|
||||
with self._lock:
|
||||
# Check if config actually changed
|
||||
if new_checksum == self._current_checksum:
|
||||
self.logger.debug("Configuration unchanged, skipping reload")
|
||||
return False
|
||||
|
||||
# Store old config for change detection
|
||||
old_config = self._current_config.copy()
|
||||
|
||||
# Update current config
|
||||
self._current_config = new_config
|
||||
self._current_checksum = new_checksum
|
||||
|
||||
# Notify subscribers
|
||||
with self._notify_lock:
|
||||
new_config = self.config_manager.load_config()
|
||||
new_checksum = self._calculate_checksum(new_config)
|
||||
|
||||
with self._lock:
|
||||
# Check if config actually changed
|
||||
if new_checksum == self._current_checksum:
|
||||
self.logger.debug("Configuration unchanged, skipping reload")
|
||||
return False
|
||||
|
||||
# Store old config for change detection
|
||||
old_config = self._current_config.copy()
|
||||
|
||||
# Update current config
|
||||
self._current_config = new_config
|
||||
self._current_checksum = new_checksum
|
||||
|
||||
# Notify subscribers, outside _lock (see _notify_lock)
|
||||
self._notify_subscribers(old_config, new_config)
|
||||
|
||||
|
||||
self.logger.info(
|
||||
"Configuration reloaded (checksum: %s)",
|
||||
new_checksum[:8]
|
||||
)
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
except ConfigError as e:
|
||||
self.logger.error("Error loading configuration: %s", e, exc_info=True)
|
||||
return False
|
||||
@@ -127,35 +139,64 @@ class ConfigService:
|
||||
Args:
|
||||
old_config: Previous configuration
|
||||
new_config: New configuration
|
||||
|
||||
Called without _lock held. The subscriber lists are copied under it,
|
||||
and each callback is checked against them again just before it runs.
|
||||
"""
|
||||
with self._lock:
|
||||
subscribers = {key: list(callbacks) for key, callbacks in self._subscribers.items()}
|
||||
|
||||
# Notify global subscribers (key: '*')
|
||||
for callback in self._subscribers.get('*', []):
|
||||
try:
|
||||
callback(old_config, new_config)
|
||||
except Exception as e:
|
||||
self.logger.error("Error in global config change callback: %s", e, exc_info=True)
|
||||
|
||||
for callback in subscribers.get('*', []):
|
||||
self._call_subscriber('*', callback, old_config, new_config)
|
||||
|
||||
# Notify plugin-specific subscribers
|
||||
for plugin_id in self._subscribers.keys():
|
||||
for plugin_id, callbacks in subscribers.items():
|
||||
if plugin_id == '*':
|
||||
continue
|
||||
|
||||
|
||||
old_plugin_config = old_config.get(plugin_id, {})
|
||||
new_plugin_config = new_config.get(plugin_id, {})
|
||||
|
||||
|
||||
# Only notify if plugin config actually changed
|
||||
if old_plugin_config != new_plugin_config:
|
||||
for callback in self._subscribers[plugin_id]:
|
||||
try:
|
||||
callback(old_plugin_config, new_plugin_config)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
"Error in config change callback for %s: %s",
|
||||
plugin_id,
|
||||
e,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
for callback in callbacks:
|
||||
self._call_subscriber(plugin_id, callback,
|
||||
old_plugin_config, new_plugin_config)
|
||||
|
||||
def _call_subscriber(
|
||||
self,
|
||||
key: str,
|
||||
callback: Callable[[Dict[str, Any], Dict[str, Any]], None],
|
||||
old_config: Dict[str, Any],
|
||||
new_config: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Run one callback, unless it was unsubscribed since the snapshot.
|
||||
|
||||
unsubscribe() promises that once it returns the callback is neither
|
||||
running nor will run: the display unloads the plugin straight after.
|
||||
"""
|
||||
with self._lock:
|
||||
if callback not in self._subscribers.get(key, ()):
|
||||
return
|
||||
self._running_callback = (key, callback, threading.get_ident())
|
||||
try:
|
||||
callback(old_config, new_config)
|
||||
except Exception as e:
|
||||
if key == '*':
|
||||
self.logger.error("Error in global config change callback: %s", e, exc_info=True)
|
||||
else:
|
||||
self.logger.error(
|
||||
"Error in config change callback for %s: %s",
|
||||
key,
|
||||
e,
|
||||
exc_info=True
|
||||
)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._running_callback = None
|
||||
self._callback_done.notify_all()
|
||||
|
||||
def _check_file_changes(self) -> bool:
|
||||
"""
|
||||
Check if configuration files have been modified.
|
||||
@@ -276,6 +317,11 @@ class ConfigService:
|
||||
"""
|
||||
Unsubscribe from configuration changes.
|
||||
|
||||
Once this returns the callback is not running and will not be called
|
||||
again. A notification that is running this very callback is waited
|
||||
for (unless the callback is the caller); one running any other
|
||||
callback is not.
|
||||
|
||||
Args:
|
||||
callback: Callback function to remove
|
||||
plugin_id: Optional plugin ID (must match subscription)
|
||||
@@ -285,6 +331,10 @@ class ConfigService:
|
||||
if callback in self._subscribers[key]:
|
||||
self._subscribers[key].remove(callback)
|
||||
self.logger.debug("Unsubscribed from config changes for %s", key)
|
||||
while (self._running_callback is not None
|
||||
and self._running_callback[:2] == (key, callback)
|
||||
and self._running_callback[2] != threading.get_ident()):
|
||||
self._callback_done.wait()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shutdown the configuration service."""
|
||||
|
||||
@@ -29,6 +29,7 @@ CORE_CONFIG_KEYS = frozenset({
|
||||
'display',
|
||||
'sync',
|
||||
'plugin_system',
|
||||
'fetch_service',
|
||||
# Older or optional core sections still found in existing config files.
|
||||
'logging',
|
||||
'network',
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
"""What the panel shows next: the Arbiter of docs/RUN_LOOP_REDESIGN.md.
|
||||
|
||||
``Arbiter.decide(state, inputs, now)`` takes a snapshot that
|
||||
``DisplayController.run()`` gathers and returns a :class:`ScreenPlan` naming
|
||||
the Source that gets the panel. It is a pure function: no I/O, no clock
|
||||
reads (``now`` is passed in), no locks, and it changes nothing it is given.
|
||||
That is what lets a plain table of cases test the priority order, which
|
||||
used to exist only as the order of ``if`` blocks in ``run()``.
|
||||
|
||||
The order is
|
||||
|
||||
ScheduledOff (a gate), Follower, OnDemand, Wifi, Live, Vegas, Rotation
|
||||
|
||||
Every Source but Vegas is decided here (stage 3). Vegas is the ``LEGACY``
|
||||
plan: the Arbiter picks it, but its iteration is still run()'s own code
|
||||
until stage 4.
|
||||
|
||||
A pass asks twice: once with the inputs every pass reads (the gate,
|
||||
Follower, OnDemand, Wifi), and once more, only when nothing above the
|
||||
notice took the panel, with the inputs the Sources below it need (whether
|
||||
Vegas is on, the live-priority scan), read where run() always read them.
|
||||
|
||||
``decide(..., running=plan)`` is the other question, asked by the
|
||||
ScreenRunner (src/screen_runner.py) at its service points: does a Source
|
||||
in ``plan.preemptible_by`` now take the panel from the screen that is
|
||||
running? The mid-screen rules are :func:`_hold_or_preempt`.
|
||||
|
||||
The state transitions (the next on-demand mode, a live claim and its
|
||||
release, the rotation's step after a screen) are pure methods of
|
||||
:class:`ArbiterState`; the controller applies what they return.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import Enum
|
||||
from typing import FrozenSet, Optional, Protocol, Tuple
|
||||
|
||||
__all__ = [
|
||||
"Arbiter",
|
||||
"ArbiterInputs",
|
||||
"ArbiterState",
|
||||
"FramePolicy",
|
||||
"LIVE_PREEMPTERS",
|
||||
"SCHEDULED_OFF_DWELL",
|
||||
"SCREEN_PREEMPTERS",
|
||||
"ScreenEnd",
|
||||
"ScreenPlan",
|
||||
"Source",
|
||||
"WIFI_NOTICE_DWELL",
|
||||
"WifiNotice",
|
||||
"live_pick",
|
||||
"live_takeover",
|
||||
"on_demand_bound",
|
||||
"rotation_plan",
|
||||
"wifi_notice_preempts",
|
||||
]
|
||||
|
||||
# How long one scheduled-off pass blanks the panel. The dwell ends early when
|
||||
# on-demand starts or the schedule turns the panel back on.
|
||||
SCHEDULED_OFF_DWELL = 60.0
|
||||
|
||||
# How long one WiFi-notice pass holds the notice before the next pass looks
|
||||
# again; the notice stays up, pass after pass, until it expires.
|
||||
WIFI_NOTICE_DWELL = 0.5
|
||||
|
||||
|
||||
class Source(Enum):
|
||||
"""Who gets the panel this pass."""
|
||||
|
||||
SCHEDULED_OFF = "scheduled-off"
|
||||
FOLLOWER = "follower"
|
||||
ON_DEMAND = "on-demand"
|
||||
WIFI = "wifi"
|
||||
LIVE = "live"
|
||||
# Vegas: the Arbiter picks it, but its iteration is still run()'s own
|
||||
# code (and its interrupt callback a second copy of this order) until
|
||||
# stage 4 makes it a Source driven frame by frame.
|
||||
LEGACY = "legacy"
|
||||
ROTATION = "rotation"
|
||||
# Not a screen: a plugin reload waits at the top of the loop. It ends a
|
||||
# screen between frames (the screen counts as shown and the rotation
|
||||
# moves on), and the next pass reloads before it draws.
|
||||
RELOAD = "reload"
|
||||
|
||||
|
||||
class FramePolicy(Enum):
|
||||
"""How often a screen draws: today's two frame loops (see
|
||||
DisplayController._needs_high_fps). Stage 5 lets plugins declare it."""
|
||||
|
||||
#: The 125 Hz loop, paced to an 8 ms deadline: scrolling plugins.
|
||||
HIGH_FPS = "high-fps"
|
||||
#: The 1 Hz loop.
|
||||
STATIC = "static"
|
||||
|
||||
|
||||
class ScreenEnd(Protocol):
|
||||
"""What ArbiterState.after needs to know about how a screen ended
|
||||
(screen_runner.Outcome, filled in by the controller)."""
|
||||
|
||||
@property
|
||||
def on_demand_active(self) -> bool:
|
||||
"""An on-demand session was running when the screen ended."""
|
||||
|
||||
@property
|
||||
def still_live(self) -> bool:
|
||||
"""The mode's plugin still had live content: hold the rotation."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WifiNotice:
|
||||
"""A WiFi status message waiting to be drawn.
|
||||
|
||||
``expires_at`` is wall-clock time (``time.time()``), as written by the
|
||||
WiFi manager.
|
||||
"""
|
||||
|
||||
message: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArbiterState:
|
||||
"""What the Arbiter remembers between passes.
|
||||
|
||||
A snapshot of the controller's own fields, taken when decide() is
|
||||
called (DisplayController._arbiter_state); the transitions below return
|
||||
the next state, and the controller writes it back.
|
||||
|
||||
Attributes:
|
||||
current_mode: The mode on the panel or about to be
|
||||
(``current_display_mode``).
|
||||
on_demand_modes: The on-demand session's modes, in the order it
|
||||
shows them (a pinned mode already moved to the front when the
|
||||
session started, by _apply_on_demand_pin).
|
||||
on_demand_index: Which of them is showing.
|
||||
on_demand_expires_at: When the session ends (wall clock), or None
|
||||
for a session with no duration.
|
||||
on_demand_pinned: The session was started pinned. Carried for the
|
||||
snapshot; the pin itself is already in ``on_demand_modes``.
|
||||
rotation: The rotation's modes (``available_modes``).
|
||||
rotation_index: Where the rotation is (``current_mode_index``).
|
||||
live_resume_index: Where the rotation was when live priority took
|
||||
the panel, so it resumes there once nothing is live; None while
|
||||
live priority holds nothing.
|
||||
live_takeover_unshown: A mid-screen takeover chose current_mode and
|
||||
it has not been shown yet, so the next pass must not advance the
|
||||
live round-robin past it.
|
||||
"""
|
||||
|
||||
current_mode: Optional[str] = None
|
||||
on_demand_modes: Tuple[str, ...] = ()
|
||||
on_demand_index: int = 0
|
||||
on_demand_expires_at: Optional[float] = None
|
||||
on_demand_pinned: bool = False
|
||||
rotation: Tuple[str, ...] = ()
|
||||
rotation_index: int = 0
|
||||
live_resume_index: Optional[int] = None
|
||||
live_takeover_unshown: bool = False
|
||||
|
||||
def next_on_demand(self) -> "ArbiterState":
|
||||
"""The session's next mode, wrapping round. Needs a mode list."""
|
||||
index = (self.on_demand_index + 1) % len(self.on_demand_modes)
|
||||
return replace(self, on_demand_index=index,
|
||||
current_mode=self.on_demand_modes[index])
|
||||
|
||||
def claim_live(self, mode: str) -> "ArbiterState":
|
||||
"""Live priority takes the panel for ``mode``.
|
||||
|
||||
The rotation's position is saved only on the first claim, not on
|
||||
each re-check while the hold continues, so it resumes where live
|
||||
priority interrupted it instead of after the live mode (which would
|
||||
skip every mode between the two).
|
||||
"""
|
||||
if self.current_mode == mode:
|
||||
return self
|
||||
resume = self.rotation_index if self.live_resume_index is None else self.live_resume_index
|
||||
index = self.rotation.index(mode) if mode in self.rotation else self.rotation_index
|
||||
return replace(self, current_mode=mode, rotation_index=index,
|
||||
live_resume_index=resume)
|
||||
|
||||
def after(self, outcome: "ScreenEnd") -> "ArbiterState":
|
||||
"""The state once a screen has run its course: the next mode.
|
||||
|
||||
An on-demand session moves to its next mode. Otherwise the rotation
|
||||
advances -- unless the mode just shown is a live-priority mode that
|
||||
is still live, which holds the panel. A session with no modes left
|
||||
is ended by the controller before it asks (that is not pure: it
|
||||
resumes the rotation and clears the cache).
|
||||
"""
|
||||
if outcome.on_demand_active:
|
||||
return self.next_on_demand() if self.on_demand_modes else self
|
||||
if outcome.still_live or not self.rotation:
|
||||
return self
|
||||
index = (self.rotation_index + 1) % len(self.rotation)
|
||||
return replace(self, rotation_index=index, current_mode=self.rotation[index])
|
||||
|
||||
def release_live(self) -> "ArbiterState":
|
||||
"""Nothing is live any more: the rotation resumes where it was."""
|
||||
if self.live_resume_index is None or not self.rotation:
|
||||
return self
|
||||
index = self.live_resume_index % len(self.rotation)
|
||||
return replace(self, current_mode=self.rotation[index], rotation_index=index,
|
||||
live_resume_index=None)
|
||||
|
||||
def showing(self, plan: "ScreenPlan") -> "ArbiterState":
|
||||
"""The state once ``plan`` is on the panel.
|
||||
|
||||
An on-demand plan puts the session's index on the mode it shows (an
|
||||
index past the end of a shortened list starts it again at 0).
|
||||
"""
|
||||
state = replace(self, current_mode=plan.mode)
|
||||
if plan.source is Source.ON_DEMAND and self.on_demand_modes:
|
||||
state = replace(state, on_demand_index=_on_demand_index(self))
|
||||
return state
|
||||
|
||||
|
||||
def _on_demand_index(state: ArbiterState) -> int:
|
||||
"""The session's index, or 0 once it is past the end of its list."""
|
||||
index = state.on_demand_index
|
||||
return index if index < len(state.on_demand_modes) else 0
|
||||
|
||||
|
||||
def on_demand_bound(min_duration: float, max_duration: float,
|
||||
deadline: Optional[float],
|
||||
now: float) -> Optional[Tuple[float, float]]:
|
||||
"""Shorten a screen's (min, max) seconds to what is left of a timed
|
||||
on-demand session ending at ``deadline``. None when nothing is left.
|
||||
|
||||
The OnDemand Source's bound, applied after the screen's first frame,
|
||||
where it always was (``now`` is read then).
|
||||
"""
|
||||
if deadline is None:
|
||||
return min_duration, max_duration
|
||||
remaining = max(0.0, deadline - now)
|
||||
min_duration = min(min_duration, remaining)
|
||||
max_duration = min(max_duration, remaining)
|
||||
if max_duration <= 0:
|
||||
return None
|
||||
return min_duration, max_duration
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArbiterInputs:
|
||||
"""One pass's snapshot, gathered by run() before it calls decide().
|
||||
|
||||
Attributes:
|
||||
schedule_on: The display schedule has the panel on, not counting an
|
||||
on-demand override of a scheduled-off window.
|
||||
on_demand_active: An on-demand session is running.
|
||||
follower_active: A sync leader is driving this panel.
|
||||
wifi_notice: The pending WiFi notice, or None. run() reads it only
|
||||
when it could win (the panel is on, and neither a follower nor
|
||||
on-demand outranks it), because reading it has side effects: a
|
||||
1 Hz throttle and deleting an expired file.
|
||||
live_modes: The modes with live content, from a live-priority scan,
|
||||
in registration order; None when no scan was made (on-demand,
|
||||
Vegas keeping live content in its ticker, a throttled
|
||||
mid-screen check). A scan asks every live-priority plugin, so it
|
||||
is made only where run() always made it.
|
||||
vegas_enabled: Vegas mode is on (and no on-demand session holds it
|
||||
off).
|
||||
vegas_live_in_ticker: Vegas keeps live content in its ticker
|
||||
instead of yielding the panel to it.
|
||||
vegas_yielded: This pass's Vegas iteration has run and yielded, so
|
||||
the Vegas Source passes and the screen it fell through to is
|
||||
decided.
|
||||
reload_pending: Mid-screen only: a plugin reload is waiting for the
|
||||
top of the loop, at a service point where that ends the screen.
|
||||
"""
|
||||
|
||||
schedule_on: bool
|
||||
on_demand_active: bool
|
||||
follower_active: bool
|
||||
wifi_notice: Optional[WifiNotice] = None
|
||||
live_modes: Optional[Tuple[str, ...]] = None
|
||||
vegas_enabled: bool = False
|
||||
vegas_live_in_ticker: bool = False
|
||||
vegas_yielded: bool = False
|
||||
reload_pending: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScreenPlan:
|
||||
"""The Arbiter's answer for one pass.
|
||||
|
||||
decide() is pure, so it cannot ask a plugin anything: the fields a
|
||||
plugin answers (its durations, whether it runs a dynamic cycle, how
|
||||
often it draws) are filled in by the controller after the screen's first
|
||||
frame, when they have always been read (DisplayController.complete_plan).
|
||||
|
||||
Attributes:
|
||||
source: The Source that gets the panel.
|
||||
mode: The display mode to draw (None for a blank, follower or notice).
|
||||
plugin: The id of the plugin drawing ``mode``, once resolved.
|
||||
min_duration: Seconds the screen runs at least (dynamic duration).
|
||||
max_duration: How long the plan holds the panel, in seconds, at most
|
||||
(its dwell ends early when what the panel should show changes).
|
||||
None when the Source paces itself (a follower frame, Vegas), and
|
||||
for a rotation or live plan until its first frame.
|
||||
dynamic: Run until the plugin's cycle completes, between min and max.
|
||||
frame_policy: Which frame loop the screen runs.
|
||||
preemptible_by: The Sources that may end the screen mid-way.
|
||||
notice: The WiFi notice to draw, for a WIFI plan.
|
||||
deadline: For an on-demand plan, when the session ends (wall
|
||||
clock): after the first frame the screen's durations are cut to
|
||||
what is left (:func:`on_demand_bound`).
|
||||
ends_live: Nothing is live any more and live priority had
|
||||
interrupted the rotation: taking this plan resumes the rotation
|
||||
where it was (ArbiterState.release_live) before it shows.
|
||||
"""
|
||||
|
||||
source: Source
|
||||
mode: Optional[str] = None
|
||||
plugin: Optional[str] = None
|
||||
min_duration: Optional[float] = None
|
||||
max_duration: Optional[float] = None
|
||||
dynamic: bool = False
|
||||
frame_policy: Optional[FramePolicy] = None
|
||||
preemptible_by: FrozenSet[Source] = frozenset()
|
||||
notice: Optional[WifiNotice] = None
|
||||
deadline: Optional[float] = None
|
||||
ends_live: bool = False
|
||||
|
||||
|
||||
SCHEDULED_OFF_PLAN = ScreenPlan(Source.SCHEDULED_OFF, max_duration=SCHEDULED_OFF_DWELL)
|
||||
FOLLOWER_PLAN = ScreenPlan(Source.FOLLOWER)
|
||||
RELOAD_PLAN = ScreenPlan(Source.RELOAD)
|
||||
|
||||
#: What may end a screen mid-way: the schedule, an on-demand session
|
||||
#: starting or ending, a WiFi notice, a live game, the rotation moving
|
||||
#: under the screen, and a plugin reload. Not a follower or Vegas: those
|
||||
#: are only looked at between screens.
|
||||
SCREEN_PREEMPTERS: FrozenSet[Source] = frozenset(
|
||||
{Source.SCHEDULED_OFF, Source.ON_DEMAND, Source.WIFI, Source.LIVE, Source.ROTATION,
|
||||
Source.RELOAD})
|
||||
|
||||
#: A live screen is not preempted by Live: live games take turns between
|
||||
#: screens, never mid-screen.
|
||||
LIVE_PREEMPTERS: FrozenSet[Source] = SCREEN_PREEMPTERS - {Source.LIVE}
|
||||
|
||||
|
||||
class Arbiter:
|
||||
"""Decides which Source gets the panel. Stateless; see the module docstring."""
|
||||
|
||||
@staticmethod
|
||||
def decide(state: ArbiterState, inputs: ArbiterInputs, now: float,
|
||||
running: Optional[ScreenPlan] = None) -> ScreenPlan:
|
||||
"""The plan for this pass, from the Sources in priority order.
|
||||
|
||||
With ``running``, the question is the ScreenRunner's at one of its
|
||||
service points instead: does a Source in ``running.preemptible_by``
|
||||
now take the panel from that screen? The answer is ``running``
|
||||
itself (the same object) while it holds, else the plan that ends it.
|
||||
See :func:`_hold_or_preempt` for the rules.
|
||||
|
||||
Args:
|
||||
state: What the Arbiter remembers between passes.
|
||||
inputs: This pass's snapshot.
|
||||
now: Wall-clock time of the snapshot. The OnDemand Source reads
|
||||
it for what is left of a timed session, and the mid-screen
|
||||
WiFi rule (:func:`wifi_notice_preempts`) to compare with the
|
||||
notice's expiry. The top-of-pass WiFi check does not: it
|
||||
takes the notice as read.
|
||||
running: The screen on the panel, for a mid-screen check.
|
||||
|
||||
Returns:
|
||||
The winning Source's plan (LEGACY for Vegas), or ``running``.
|
||||
"""
|
||||
if running is not None:
|
||||
return _hold_or_preempt(state, inputs, now, running)
|
||||
|
||||
# ScheduledOff is a gate, not a Source: a scheduled-off panel stays
|
||||
# blank even for a follower, and only an on-demand session overrides
|
||||
# it (#714 -- one ending in off hours blanks at the next pass).
|
||||
if not inputs.schedule_on and not inputs.on_demand_active:
|
||||
return SCHEDULED_OFF_PLAN
|
||||
|
||||
# 1. Follower: a sync leader drives this panel, ahead of on-demand.
|
||||
if inputs.follower_active:
|
||||
return FOLLOWER_PLAN
|
||||
|
||||
# 2. OnDemand: the session's current mode. It outranks the notice.
|
||||
if inputs.on_demand_active:
|
||||
return _on_demand_plan(state, now)
|
||||
|
||||
# 3. Wifi: a pending notice, held for one short dwell per pass.
|
||||
if inputs.wifi_notice is not None:
|
||||
return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL,
|
||||
notice=inputs.wifi_notice)
|
||||
|
||||
# 4. Live: the next live game, round-robin across several. With
|
||||
# nothing live, a rotation that live priority interrupted resumes.
|
||||
ends_live = False
|
||||
if _live_applies(inputs):
|
||||
pick = live_pick(inputs.live_modes, state.current_mode,
|
||||
advance=not state.live_takeover_unshown)
|
||||
if pick is not None:
|
||||
return ScreenPlan(Source.LIVE, mode=pick, preemptible_by=LIVE_PREEMPTERS)
|
||||
ends_live = state.live_resume_index is not None and bool(state.rotation)
|
||||
|
||||
# 5. Vegas: one iteration of the ticker, run by run()'s own code
|
||||
# until stage 4. Passes once this pass's iteration has yielded.
|
||||
if inputs.vegas_enabled and not inputs.vegas_yielded:
|
||||
return ScreenPlan(Source.LEGACY, ends_live=ends_live)
|
||||
|
||||
# 6. Rotation: the rotation's current mode (after the resume, when
|
||||
# live priority just ended).
|
||||
return rotation_plan(state.release_live() if ends_live else state,
|
||||
ends_live=ends_live)
|
||||
|
||||
|
||||
def _hold_or_preempt(state: ArbiterState, inputs: ArbiterInputs, now: float,
|
||||
running: ScreenPlan) -> ScreenPlan:
|
||||
"""The mid-screen rules: ``running``, or the plan that ends it.
|
||||
|
||||
What the frame loops used to check one by one (_check_live_takeover,
|
||||
then _screen_preempted with _wifi_notice_pending in it, before stage 3),
|
||||
in their order:
|
||||
|
||||
1. Live: a game went live while a non-live screen runs (the inputs
|
||||
carry a scan only when one was due, at most once a second). Checked
|
||||
first because it is the one preemption that changes the state -- the
|
||||
rotation moves to the live mode and remembers where it was -- and it
|
||||
still happens when a WiFi notice is also pending: the next pass then
|
||||
shows the notice, and the game after it.
|
||||
2. The panel's mode moved under the screen: an on-demand session
|
||||
started, ended or changed mode, or the rotation was rebuilt (a
|
||||
plugin enabled, disabled or reloaded).
|
||||
3. The schedule turned the panel off.
|
||||
4. A WiFi notice arrived (unless on-demand outranks it), compared with
|
||||
its expiry because the read throttle can hand back a stale one.
|
||||
5. A plugin reload is waiting at the top of the loop.
|
||||
|
||||
A follower and Vegas are never mid-screen preemptions; they are looked
|
||||
at between screens.
|
||||
"""
|
||||
by = running.preemptible_by
|
||||
if Source.LIVE in by:
|
||||
takeover = live_takeover(state, inputs)
|
||||
if takeover is not None:
|
||||
return ScreenPlan(Source.LIVE, mode=takeover, preemptible_by=LIVE_PREEMPTERS)
|
||||
if state.current_mode != running.mode:
|
||||
source = Source.ON_DEMAND if inputs.on_demand_active else Source.ROTATION
|
||||
if source in by:
|
||||
return ScreenPlan(source, mode=state.current_mode, preemptible_by=SCREEN_PREEMPTERS)
|
||||
if (Source.SCHEDULED_OFF in by and not inputs.schedule_on
|
||||
and not inputs.on_demand_active):
|
||||
return SCHEDULED_OFF_PLAN
|
||||
notice = inputs.wifi_notice
|
||||
if (Source.WIFI in by and notice is not None
|
||||
and wifi_notice_preempts(notice, inputs.on_demand_active, now)):
|
||||
return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL, notice=notice)
|
||||
if Source.RELOAD in by and inputs.reload_pending:
|
||||
return RELOAD_PLAN
|
||||
return running
|
||||
|
||||
|
||||
def live_takeover(state: ArbiterState, inputs: ArbiterInputs) -> Optional[str]:
|
||||
"""The live mode that takes the panel mid-screen, or None.
|
||||
|
||||
The first live mode, when a scan found one and the panel is not on a
|
||||
live mode already. Never while on-demand holds the panel, while it is
|
||||
scheduled off, or while Vegas keeps live content in its ticker.
|
||||
"""
|
||||
if not _live_applies(inputs) or inputs.on_demand_active or not inputs.schedule_on:
|
||||
return None
|
||||
live = inputs.live_modes
|
||||
if not live or state.current_mode in live:
|
||||
return None
|
||||
return live[0]
|
||||
|
||||
|
||||
def _on_demand_plan(state: ArbiterState, now: float) -> ScreenPlan:
|
||||
"""The OnDemand Source: the session's current mode.
|
||||
|
||||
``max_duration`` is what is left of a timed session at ``now`` (None
|
||||
without a duration); ``deadline`` carries the expiry so the bound can be
|
||||
applied again after the first frame. A session with no modes left (its
|
||||
plugin was unloaded under it) gets a plan with no mode: the controller
|
||||
ends the session and shows the rotation's mode instead.
|
||||
"""
|
||||
modes = state.on_demand_modes
|
||||
if not modes:
|
||||
return ScreenPlan(Source.ON_DEMAND)
|
||||
expires_at = state.on_demand_expires_at
|
||||
remaining = None if expires_at is None else max(0.0, expires_at - now)
|
||||
return ScreenPlan(Source.ON_DEMAND, mode=modes[_on_demand_index(state)],
|
||||
max_duration=remaining, deadline=expires_at,
|
||||
preemptible_by=SCREEN_PREEMPTERS)
|
||||
|
||||
|
||||
def rotation_plan(state: ArbiterState, ends_live: bool = False) -> ScreenPlan:
|
||||
"""The Rotation Source: the mode the rotation is on.
|
||||
|
||||
That is ``state.current_mode``, which is ``rotation[rotation_index]``
|
||||
except where something moved the panel off the list and the rotation
|
||||
carries on from there: a live mode no rotation entry names, or None
|
||||
when a session ended with no enabled mode to resume to.
|
||||
"""
|
||||
return ScreenPlan(Source.ROTATION, mode=state.current_mode, ends_live=ends_live,
|
||||
preemptible_by=SCREEN_PREEMPTERS)
|
||||
|
||||
|
||||
def _live_applies(inputs: ArbiterInputs) -> bool:
|
||||
"""Whether the Live Source has a say: a scan was made, and Vegas is not
|
||||
keeping live content in its ticker (where the live plugin takes extra
|
||||
turns in the marquee instead of the panel)."""
|
||||
if inputs.live_modes is None:
|
||||
return False
|
||||
return not (inputs.vegas_enabled and inputs.vegas_live_in_ticker)
|
||||
|
||||
|
||||
def live_pick(live_modes: Optional[Tuple[str, ...]], current_mode: Optional[str],
|
||||
advance: bool) -> Optional[str]:
|
||||
"""The live mode to show, or None when nothing is live.
|
||||
|
||||
When several plugins are live at once this round-robins between them, so
|
||||
the panel alternates each dwell instead of pinning to the first one
|
||||
registered. The mode on the panel is the cursor, so this stays right as
|
||||
games start and end.
|
||||
|
||||
Args:
|
||||
live_modes: The live modes, in registration order.
|
||||
current_mode: The mode on the panel.
|
||||
advance: True for the rotation's pick (the live mode after the one
|
||||
showing). False for a peek (the one showing if it is still live,
|
||||
else the first), which Vegas uses to ask whether anything is.
|
||||
"""
|
||||
if not live_modes:
|
||||
return None
|
||||
if current_mode in live_modes:
|
||||
if advance:
|
||||
index = live_modes.index(current_mode)
|
||||
return live_modes[(index + 1) % len(live_modes)]
|
||||
return current_mode
|
||||
return live_modes[0]
|
||||
|
||||
|
||||
def wifi_notice_preempts(notice: Optional[WifiNotice], on_demand_active: bool,
|
||||
now: float) -> bool:
|
||||
"""Whether a WiFi notice should end the current screen early.
|
||||
|
||||
The Wifi Source's mid-screen rule, polled between frames, during dwells
|
||||
and when a Vegas iteration yields. On-demand outranks the notice, as in
|
||||
:meth:`Arbiter.decide`. Unlike the top-of-pass check it also compares
|
||||
``now`` with the expiry, because the 1 Hz read throttle can hand back a
|
||||
notice that has expired since it was read.
|
||||
"""
|
||||
if on_demand_active or notice is None:
|
||||
return False
|
||||
return now < notice.expires_at
|
||||
+2113
-912
File diff suppressed because it is too large
Load Diff
+232
-256
@@ -52,17 +52,16 @@ import threading
|
||||
import time
|
||||
from collections import OrderedDict, deque
|
||||
from typing import Dict, Any, List, Optional, Tuple, TYPE_CHECKING
|
||||
import math
|
||||
import zlib
|
||||
import freetype
|
||||
|
||||
from src.common import snapshot_policy
|
||||
from src import display_watchdog
|
||||
from src.common.frame_timing import FrameTimingRecorder
|
||||
from src.common.frame_timing import (
|
||||
FrameTimingRecorder, install_gc_monitor, uninstall_gc_monitor)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.common.render_gate import RenderGate
|
||||
from src.deprecation import deprecated
|
||||
from src.logging_config import get_logger
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
@@ -82,6 +81,15 @@ _CALENDAR_FONT_PX = 7
|
||||
#: frame, so a fault that persists would otherwise log ~100 lines a second.
|
||||
_UPDATE_ERROR_LOG_INTERVAL = 60.0
|
||||
|
||||
#: zlib level for the preview snapshot PNG. The fastest level: each file is
|
||||
#: read by the web UI and soon replaced by the next, so encode time (paid on
|
||||
#: the render thread for a static screen) matters more than its size.
|
||||
#: Lossless at any level. Against Pillow's default (6), on a desktop with
|
||||
#: Pillow 12.3, a text-dense 512x64 frame encoded in about half the time,
|
||||
#: into 12 KB instead of 7 KB; sparser frames saved less time (10-20%) and
|
||||
#: stayed under 2 KB.
|
||||
_SNAPSHOT_PNG_COMPRESS_LEVEL = 1
|
||||
|
||||
|
||||
def _bdf_native_size(face) -> int:
|
||||
"""The pixel height a BDF Face declares, or 0 if it does not say.
|
||||
@@ -225,6 +233,11 @@ def _per_thread_canvas_attr(name: str) -> property:
|
||||
|
||||
|
||||
|
||||
#: A held frame is only split when its blit takes less than this share of a
|
||||
#: refresh: the second blit has to land before the next vsync.
|
||||
_SPLIT_BLIT_FRACTION = 0.5
|
||||
|
||||
|
||||
class DisplayManager:
|
||||
"""
|
||||
Singleton hardware abstraction layer for the RGB LED matrix.
|
||||
@@ -293,8 +306,8 @@ class DisplayManager:
|
||||
self._TEXT_WIDTH_CACHE_MAX = 1024
|
||||
# Snapshot mirror for web preview + health check (service writes, web
|
||||
# reads). Cadence/skip decisions live in src/common/snapshot_policy.py:
|
||||
# full rate only while the web SSE broadcaster keeps the viewer marker
|
||||
# fresh; unchanged frames are never re-encoded, only mtime-touched.
|
||||
# the viewer rate only while the web SSE broadcaster keeps the viewer
|
||||
# marker fresh; unchanged frames are never re-encoded, only mtime-touched.
|
||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||
self._viewer_marker_path = "/tmp/led_matrix_preview_viewer" # nosec B108 - touched by web SSE broadcaster
|
||||
self._last_snapshot_ts = 0.0
|
||||
@@ -304,6 +317,11 @@ class DisplayManager:
|
||||
# is handed to the writer; this only once it has been saved, so an
|
||||
# mtime touch never vouches for a frame still waiting to be written.
|
||||
self._saved_snapshot_digest: Optional[int] = None
|
||||
# A changed frame reached _write_snapshot_if_due() inside the write
|
||||
# interval and was skipped. Nothing writes it unless update_display()
|
||||
# runs again, and a screen that draws once and holds never calls it
|
||||
# again -- see write_owed_snapshot().
|
||||
self._snapshot_owed = False
|
||||
self._snapshot_dir_prepared = False
|
||||
# Background writer used mid-scroll; see _write_snapshot_if_due.
|
||||
self._snapshot_cond = threading.Condition()
|
||||
@@ -341,6 +359,10 @@ class DisplayManager:
|
||||
# advances a whole pixel every Nth refresh instead of every one.
|
||||
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
|
||||
self._frame_hold = 1
|
||||
# True while a static screen draws its first frame after a scroll,
|
||||
# whose state is left set until then: those frames go out without
|
||||
# scan-order compensation. See end_scroll_for_static_screen().
|
||||
self._static_handover = False
|
||||
|
||||
# A src.common.render_gate.RenderGate while Vegas runs with
|
||||
# vegas_scroll.prefetch_gate on: opened around each swap so the
|
||||
@@ -349,7 +371,8 @@ class DisplayManager:
|
||||
|
||||
# Timing of every presented frame, whoever drew it, for
|
||||
# scripts/frame_soak.py. See src/common/frame_timing.py.
|
||||
self.frame_timing = FrameTimingRecorder(info=self._frame_timing_info())
|
||||
self.frame_timing = FrameTimingRecorder(
|
||||
info=self._frame_timing_info(), gc_monitor=install_gc_monitor())
|
||||
self.frame_timing.scrolling_now = self._scrolling_now
|
||||
|
||||
self._scrolling_state = {
|
||||
@@ -915,8 +938,7 @@ class DisplayManager:
|
||||
``display.dirty_tracking: false`` if a redraw issue is ever suspected.
|
||||
|
||||
Serialized via ``_update_lock``: plugins can call this directly from
|
||||
background threads (e.g. sports base classes push an immediate
|
||||
"live" refresh from inside update()), so without a lock two callers
|
||||
background threads of their own, so without a lock two callers
|
||||
could both pass the digest check before either writes it back,
|
||||
double-pushing a frame, or interleave the offscreen/current canvas
|
||||
swap below. The lock is scoped to this method, so callers never
|
||||
@@ -940,16 +962,32 @@ class DisplayManager:
|
||||
self._write_snapshot_if_due()
|
||||
return
|
||||
|
||||
# Asked once per frame and the answer reused below: the call
|
||||
# has side effects (it expires a stale scroll and drops its
|
||||
# frame hold), so asking again further down could disagree
|
||||
# with what this frame was already treated as. Asked first,
|
||||
# so the dirty check, the scan-order segments, the pacing gate,
|
||||
# the swaps and frame timing all see one answer and the frame
|
||||
# hold it leaves.
|
||||
scrolling = self.is_currently_scrolling()
|
||||
digest = None
|
||||
frame_checksum = None
|
||||
if self._dirty_tracking_enabled:
|
||||
# No digest mid-scroll. The skip it feeds is never taken while
|
||||
# scrolling (see below), so all it bought there was the
|
||||
# snapshot's changed-frame check -- a tobytes() plus adler32
|
||||
# over the whole framebuffer every frame (~0.17ms at 256x64
|
||||
# on a Pi 4, twice that at 512x64) for a decision acted on at
|
||||
# most once a second. _write_snapshot_if_due hashes for
|
||||
# itself when a write or touch is actually due. The cost: the
|
||||
# first static frame after a scroll is always pushed, once.
|
||||
if self._dirty_tracking_enabled and not scrolling:
|
||||
try:
|
||||
brightness = getattr(self.matrix, 'brightness', None)
|
||||
except AttributeError:
|
||||
brightness = None
|
||||
frame_checksum = zlib.adler32(self.image.tobytes())
|
||||
digest = (frame_checksum, brightness)
|
||||
if digest == self._last_pushed_digest and not self.is_currently_scrolling():
|
||||
if digest == self._last_pushed_digest:
|
||||
# Nothing changed since the last push — the panel is
|
||||
# already showing exactly this frame.
|
||||
#
|
||||
@@ -974,27 +1012,35 @@ class DisplayManager:
|
||||
# mode the logical screen is first tiled across the full chain.
|
||||
blit_started = time.perf_counter()
|
||||
if self._double_sided is not None:
|
||||
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
||||
segments = [(self._composite_double_sided(), self._frame_hold)]
|
||||
else:
|
||||
self.offscreen_canvas.SetImage(self._scan_compensated(self.image))
|
||||
blit_done = time.perf_counter()
|
||||
|
||||
# Swap buffers immediately. framerate_fraction holds the frame
|
||||
# for N refreshes; SwapOnVSync blocks for all of them, which is
|
||||
# what paces the render loop to the chosen frame rate.
|
||||
segments = self._scan_segments(self.image, scrolling)
|
||||
gate = self.render_gate
|
||||
blit_time = swap_time = 0.0
|
||||
# Usually one segment: the frame, held for _frame_hold
|
||||
# refreshes. SwapOnVSync blocks for all of them, which is what
|
||||
# paces the render loop to the chosen frame rate. Scan-order
|
||||
# compensation on a held frame splits it, so the lagging rows
|
||||
# change one refresh after the rest.
|
||||
if gate is not None:
|
||||
gate.before_swap(self._frame_hold)
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
|
||||
for index, (shown, hold) in enumerate(segments):
|
||||
if index:
|
||||
blit_started = time.perf_counter()
|
||||
self.offscreen_canvas.SetImage(shown)
|
||||
blit_done = time.perf_counter()
|
||||
blit_time += blit_done - blit_started
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas, hold)
|
||||
swap_time += time.perf_counter() - blit_done
|
||||
# Swap our canvas references
|
||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||
if gate is not None:
|
||||
gate.after_swap(self._frame_hold)
|
||||
presented_at = time.perf_counter()
|
||||
self._last_blit_seconds = blit_time / len(segments)
|
||||
self.frame_timing.record(
|
||||
blit_done - blit_started, presented_at - blit_done,
|
||||
self._frame_hold, self.is_currently_scrolling(), presented_at)
|
||||
|
||||
# Swap our canvas references
|
||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||
blit_time, swap_time,
|
||||
self._frame_hold, scrolling, presented_at)
|
||||
|
||||
self._last_pushed_digest = digest
|
||||
|
||||
@@ -1041,23 +1087,48 @@ class DisplayManager:
|
||||
", ".join(f"rows {top}-{bottom - 1} show {lag} refresh(es) behind"
|
||||
for top, bottom, lag in bands))
|
||||
|
||||
def _scan_compensated(self, image: Image.Image) -> Image.Image:
|
||||
"""The frame to present, with lagging rows taken from earlier frames.
|
||||
def _scan_segments(self, image: Image.Image,
|
||||
scrolling: Optional[bool] = None
|
||||
) -> List[Tuple[Image.Image, int]]:
|
||||
"""What to present for this frame: ``[(image, refreshes), ...]``.
|
||||
|
||||
Only mid-scroll at one frame per refresh: that is when consecutive
|
||||
frames are consecutive refreshes. At a longer hold, or on a static
|
||||
screen, the history is dropped and the frame goes out as it is.
|
||||
Mid-scroll with compensation on, lagging rows are taken from earlier
|
||||
refreshes (see src/scan_order.py). At one refresh per frame that is one
|
||||
image. A frame held longer is split at the refresh where the lagging
|
||||
rows catch up, so those rows step a refresh after the rest. The split
|
||||
needs a second blit inside the refresh that follows the first swap, so
|
||||
it is skipped when a blit is too slow to fit. A static screen goes out
|
||||
as it is, and drops the history. So does a static screen's first frame
|
||||
after a scroll, while the scroll state is still set (see
|
||||
end_scroll_for_static_screen): one segment, held for the scroll's
|
||||
hold, with no rows from the scroller's frames.
|
||||
|
||||
``scrolling`` is the caller's is_currently_scrolling() answer for this
|
||||
frame. update_display() asks once, before calling this, so the frame
|
||||
hold read here is the one that answer left (an expired scroll's hold
|
||||
is already dropped). None asks here.
|
||||
"""
|
||||
hold = self._frame_hold
|
||||
bands = getattr(self, '_scan_lag_bands', None)
|
||||
if not bands:
|
||||
return image
|
||||
if self._frame_hold != 1 or not self.is_currently_scrolling():
|
||||
self._scan_history.clear()
|
||||
return image
|
||||
presented = scan_order.compose(image, self._scan_history, bands)
|
||||
if (not bands
|
||||
or not (scrolling if scrolling is not None
|
||||
else self.is_currently_scrolling())
|
||||
or self._static_handover):
|
||||
if bands:
|
||||
self._scan_history.clear()
|
||||
return [(image, hold)]
|
||||
if hold > 1:
|
||||
blit = getattr(self, '_last_blit_seconds', 0.0)
|
||||
if blit > _SPLIT_BLIT_FRACTION / max(1.0, self.refresh_hz):
|
||||
self._scan_history.clear()
|
||||
return [(image, hold)]
|
||||
segments = [
|
||||
(scan_order.compose(image, self._scan_history, bands, backs), count)
|
||||
for backs, count in scan_order.refresh_plan(bands, hold)
|
||||
]
|
||||
# A copy: plugins draw into the same image object frame after frame.
|
||||
self._scan_history.appendleft(image.copy())
|
||||
return presented
|
||||
return segments
|
||||
|
||||
def clear(self):
|
||||
"""Clear the display completely."""
|
||||
@@ -1324,203 +1395,6 @@ class DisplayManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error drawing text: {e}", exc_info=True)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_sun(self, x: int, y: int, size: int = 16):
|
||||
"""Draw a sun icon using yellow circles and lines."""
|
||||
center = (x + size//2, y + size//2)
|
||||
radius = size//3
|
||||
|
||||
# Draw the center circle
|
||||
self.draw.ellipse([center[0]-radius, center[1]-radius,
|
||||
center[0]+radius, center[1]+radius],
|
||||
fill=(255, 255, 0)) # Yellow
|
||||
|
||||
# Draw the rays
|
||||
ray_length = size//4
|
||||
for angle in range(0, 360, 45):
|
||||
rad = math.radians(angle)
|
||||
start_x = center[0] + (radius * math.cos(rad))
|
||||
start_y = center[1] + (radius * math.sin(rad))
|
||||
end_x = center[0] + ((radius + ray_length) * math.cos(rad))
|
||||
end_y = center[1] + ((radius + ray_length) * math.sin(rad))
|
||||
self.draw.line([start_x, start_y, end_x, end_y], fill=(255, 255, 0), width=2)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_cloud(self, x: int, y: int, size: int = 16, color=(200, 200, 200)):
|
||||
"""Draw a cloud icon."""
|
||||
# Draw multiple circles to form a cloud shape
|
||||
self.draw.ellipse([x+size//4, y+size//3, x+size//4+size//2, y+size//3+size//2], fill=color)
|
||||
self.draw.ellipse([x+size//2, y+size//3, x+size//2+size//2, y+size//3+size//2], fill=color)
|
||||
self.draw.ellipse([x+size//3, y+size//6, x+size//3+size//2, y+size//6+size//2], fill=color)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_rain(self, x: int, y: int, size: int = 16):
|
||||
"""Draw rain icon with cloud and droplets."""
|
||||
# Draw cloud
|
||||
self.draw_cloud(x, y, size)
|
||||
|
||||
# Draw rain drops
|
||||
drop_color = (0, 0, 255) # Blue
|
||||
drop_size = size//6
|
||||
for i in range(3):
|
||||
drop_x = x + size//4 + (i * size//3)
|
||||
drop_y = y + size//2
|
||||
self.draw.line([drop_x, drop_y, drop_x, drop_y+drop_size],
|
||||
fill=drop_color, width=2)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_snow(self, x: int, y: int, size: int = 16):
|
||||
"""Draw snow icon with cloud and snowflakes."""
|
||||
# Draw cloud
|
||||
self.draw_cloud(x, y, size)
|
||||
|
||||
# Draw snowflakes
|
||||
snow_color = (200, 200, 255) # Light blue
|
||||
for i in range(3):
|
||||
center_x = x + size//4 + (i * size//3)
|
||||
center_y = y + size//2 + size//4
|
||||
# Draw a small star shape
|
||||
for angle in range(0, 360, 60):
|
||||
rad = math.radians(angle)
|
||||
end_x = center_x + (size//8 * math.cos(rad))
|
||||
end_y = center_y + (size//8 * math.sin(rad))
|
||||
self.draw.line([center_x, center_y, end_x, end_y],
|
||||
fill=snow_color, width=1)
|
||||
|
||||
# Weather icon color constants
|
||||
WEATHER_COLORS = {
|
||||
'sun': (255, 200, 0), # Bright yellow
|
||||
'cloud': (200, 200, 200), # Light gray
|
||||
'rain': (0, 100, 255), # Light blue
|
||||
'snow': (220, 220, 255), # Ice blue
|
||||
'storm': (255, 255, 0) # Lightning yellow
|
||||
}
|
||||
|
||||
def _draw_sun(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw a sun icon with rays."""
|
||||
center_x, center_y = x + size//2, y + size//2
|
||||
radius = size//4
|
||||
ray_length = size//3
|
||||
|
||||
# Draw the main sun circle
|
||||
self.draw.ellipse([center_x - radius, center_y - radius,
|
||||
center_x + radius, center_y + radius],
|
||||
fill=self.WEATHER_COLORS['sun'])
|
||||
|
||||
# Draw sun rays
|
||||
for angle in range(0, 360, 45):
|
||||
rad = math.radians(angle)
|
||||
start_x = center_x + int((radius + 2) * math.cos(rad))
|
||||
start_y = center_y + int((radius + 2) * math.sin(rad))
|
||||
end_x = center_x + int((radius + ray_length) * math.cos(rad))
|
||||
end_y = center_y + int((radius + ray_length) * math.sin(rad))
|
||||
self.draw.line([start_x, start_y, end_x, end_y],
|
||||
fill=self.WEATHER_COLORS['sun'], width=2)
|
||||
|
||||
def _draw_cloud(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw a cloud using multiple circles."""
|
||||
cloud_color = self.WEATHER_COLORS['cloud']
|
||||
base_y = y + size//2
|
||||
|
||||
# Draw main cloud body (3 overlapping circles)
|
||||
circle_radius = size//4
|
||||
positions = [
|
||||
(x + size//3, base_y), # Left circle
|
||||
(x + size//2, base_y - size//6), # Top circle
|
||||
(x + 2*size//3, base_y) # Right circle
|
||||
]
|
||||
|
||||
for cx, cy in positions:
|
||||
self.draw.ellipse([cx - circle_radius, cy - circle_radius,
|
||||
cx + circle_radius, cy + circle_radius],
|
||||
fill=cloud_color)
|
||||
|
||||
def _draw_rain(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw rain drops falling from a cloud."""
|
||||
self._draw_cloud(x, y, size)
|
||||
rain_color = self.WEATHER_COLORS['rain']
|
||||
|
||||
# Draw rain drops at an angle
|
||||
drop_size = size//8
|
||||
drops = [
|
||||
(x + size//4, y + 2*size//3),
|
||||
(x + size//2, y + 3*size//4),
|
||||
(x + 3*size//4, y + 2*size//3)
|
||||
]
|
||||
|
||||
for dx, dy in drops:
|
||||
# Draw angled rain drops
|
||||
self.draw.line([dx, dy, dx - drop_size//2, dy + drop_size],
|
||||
fill=rain_color, width=2)
|
||||
|
||||
def _draw_snow(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw snowflakes falling from a cloud."""
|
||||
self._draw_cloud(x, y, size)
|
||||
snow_color = self.WEATHER_COLORS['snow']
|
||||
|
||||
# Draw snowflakes
|
||||
flake_size = size//6
|
||||
flakes = [
|
||||
(x + size//4, y + 2*size//3),
|
||||
(x + size//2, y + 3*size//4),
|
||||
(x + 3*size//4, y + 2*size//3)
|
||||
]
|
||||
|
||||
for fx, fy in flakes:
|
||||
# Draw a snowflake (six-pointed star)
|
||||
for angle in range(0, 360, 60):
|
||||
rad = math.radians(angle)
|
||||
end_x = fx + int(flake_size * math.cos(rad))
|
||||
end_y = fy + int(flake_size * math.sin(rad))
|
||||
self.draw.line([fx, fy, end_x, end_y],
|
||||
fill=snow_color, width=1)
|
||||
|
||||
def _draw_storm(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw a storm cloud with lightning bolt."""
|
||||
self._draw_cloud(x, y, size)
|
||||
|
||||
# Draw lightning bolt
|
||||
bolt_color = self.WEATHER_COLORS['storm']
|
||||
bolt_points = [
|
||||
(x + size//2, y + size//2), # Top
|
||||
(x + 3*size//5, y + 2*size//3), # Middle right
|
||||
(x + 2*size//5, y + 2*size//3), # Middle left
|
||||
(x + size//2, y + 5*size//6) # Bottom
|
||||
]
|
||||
self.draw.polygon(bolt_points, fill=bolt_color)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
|
||||
"""Draw a weather icon based on the condition."""
|
||||
if condition.lower() in ['clear', 'sunny']:
|
||||
self._draw_sun(x, y, size)
|
||||
elif condition.lower() in ['clouds', 'cloudy', 'partly cloudy']:
|
||||
self._draw_cloud(x, y, size)
|
||||
elif condition.lower() in ['rain', 'drizzle', 'shower']:
|
||||
self._draw_rain(x, y, size)
|
||||
elif condition.lower() in ['snow', 'sleet', 'hail']:
|
||||
self._draw_snow(x, y, size)
|
||||
elif condition.lower() in ['thunderstorm', 'storm']:
|
||||
self._draw_storm(x, y, size)
|
||||
else:
|
||||
self._draw_sun(x, y, size)
|
||||
# Note: No update_display() here - let the caller handle the update
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_text_with_icons(self, text: str, icons: List[tuple] = None, x: int = None, y: int = None,
|
||||
color: tuple = (255, 255, 255)):
|
||||
"""Draw text with weather icons at specified positions."""
|
||||
# Draw the text
|
||||
self.draw_text(text, x, y, color)
|
||||
|
||||
# Draw any icons
|
||||
if icons:
|
||||
for icon_type, icon_x, icon_y in icons:
|
||||
self.draw_weather_icon(icon_type, icon_x, icon_y)
|
||||
|
||||
# Update the display once after everything is drawn
|
||||
self.update_display()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
if hasattr(self, '_snapshot_cond'):
|
||||
@@ -1539,6 +1413,8 @@ class DisplayManager:
|
||||
# The stall watchdog would otherwise outlive this manager.
|
||||
if getattr(self, 'frame_timing', None) is not None:
|
||||
self.frame_timing.close()
|
||||
# Installed with the recorder; stop timing collections with it.
|
||||
uninstall_gc_monitor()
|
||||
# Reset the singleton state when cleaning up
|
||||
DisplayManager._instance = None
|
||||
|
||||
@@ -1699,6 +1575,9 @@ class DisplayManager:
|
||||
# A plugin captured for Vegas calls this from its own display();
|
||||
# it must not change the live scroll's state or frame hold.
|
||||
return
|
||||
# A scroll starting or ending also ends a static screen's handover;
|
||||
# see end_scroll_for_static_screen.
|
||||
self._static_handover = False
|
||||
current_time = time.time()
|
||||
# Scrolling callers set this every frame; log transitions only.
|
||||
changed = self._scrolling_state['is_scrolling'] != is_scrolling
|
||||
@@ -1711,6 +1590,47 @@ class DisplayManager:
|
||||
if changed:
|
||||
logger.debug("Scrolling state set to: %s", is_scrolling)
|
||||
|
||||
def end_scroll_for_static_screen(self) -> None:
|
||||
"""Ready the panel for a static screen's first frame after a scroll.
|
||||
|
||||
The display controller calls this just before it dispatches the first
|
||||
frame of a screen that runs its 1 Hz loop, and
|
||||
``set_scrolling_state(False)`` once that dispatch returns. Nothing
|
||||
else ends a scroll at a handover: the state belongs to the screen
|
||||
before, and would only expire 2 s after its last frame.
|
||||
|
||||
Until then, the frames that dispatch presents go out as drawn, not
|
||||
scan-order composed: each as one segment, held for the scroll's hold.
|
||||
With the state still "scrolling", ``_scan_segments`` would take their
|
||||
lagging rows from the frame before: for the first, the scroller's last
|
||||
frame -- the bottom half of the old ticker under the new screen on a
|
||||
96x48 panel. At hold 1 that frame stays up for a whole second; at a
|
||||
longer hold its first refresh flashes the old rows. For a second frame
|
||||
in the same call, the rows would come from the first, shown for as long
|
||||
as the first's would be. Dirty tracking does not keep such a frame up
|
||||
past the screen's next redraw: a frame pushed while the scroll state
|
||||
is set leaves it no digest to match, so that redraw is pushed.
|
||||
|
||||
The rest of that scroll is left on purpose, until the controller ends
|
||||
it:
|
||||
|
||||
* the scroll state, so the gap from the scroller's last frame to this
|
||||
screen's first is still timed by the frame-timing recorder and
|
||||
watched by the stall watchdog, which is where a slow first
|
||||
``display()`` shows up;
|
||||
* its frame hold. On a frame that stays up for a second it only moves
|
||||
the swap to the scroll's next hold boundary, and it is the pacing
|
||||
that gap is due at: judged at hold 1, a handover that kept the
|
||||
scroller's own schedule would count as frames late.
|
||||
|
||||
The next ``set_scrolling_state()`` call, whoever makes it, ends this.
|
||||
One attribute store, so no lock: ``update_display`` reads it once per
|
||||
frame, under its own, and the history is dropped there.
|
||||
"""
|
||||
if self._writes_suppressed():
|
||||
return # a thread drawing off-screen cannot end the live scroll
|
||||
self._static_handover = True
|
||||
|
||||
def is_currently_scrolling(self) -> bool:
|
||||
"""Check if the display is currently in a scrolling state."""
|
||||
current_time = time.time()
|
||||
@@ -1832,18 +1752,6 @@ class DisplayManager:
|
||||
if removed_count > 0:
|
||||
logger.debug(f"Cleaned up {removed_count} expired deferred updates")
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_scrolling_stats(self) -> dict:
|
||||
"""Get current scrolling statistics for debugging."""
|
||||
return {
|
||||
'is_scrolling': self._scrolling_state['is_scrolling'],
|
||||
'last_activity': self._scrolling_state['last_scroll_activity'],
|
||||
'deferred_count': len(self._scrolling_state['deferred_updates']),
|
||||
'inactivity_threshold': self._scrolling_state['scroll_inactivity_threshold'],
|
||||
'max_deferred_updates': self._scrolling_state['max_deferred_updates'],
|
||||
'deferred_update_ttl': self._scrolling_state['deferred_update_ttl']
|
||||
}
|
||||
|
||||
def _viewer_is_fresh(self, now: float) -> bool:
|
||||
"""True when a browser preview is watching (marker file touched by
|
||||
the web SSE broadcaster). The marker is stat'd at most once per
|
||||
@@ -1865,11 +1773,14 @@ class DisplayManager:
|
||||
|
||||
Args:
|
||||
frame_checksum: adler32 of the current frame, when the caller has
|
||||
already computed one. Dirty tracking checksums every frame a
|
||||
few lines above the call site, and re-deriving it here meant a
|
||||
second tobytes() plus a second pass over the whole framebuffer
|
||||
on every single frame — ~0.17ms per frame of the two combined
|
||||
at 256x64, paid 100 times a second to reach the same number.
|
||||
already computed one. Dirty tracking checksums every static
|
||||
frame a few lines above the call site, and re-deriving it here
|
||||
meant a second tobytes() plus a second pass over the whole
|
||||
framebuffer on every single frame — ~0.17ms per frame of the
|
||||
two combined at 256x64, paid 100 times a second to reach the
|
||||
same number. None (mid-scroll, dirty tracking off, no
|
||||
hardware): the frame is hashed here, and only when the policy
|
||||
could act on it.
|
||||
"""
|
||||
try:
|
||||
now = time.time()
|
||||
@@ -1880,20 +1791,53 @@ class DisplayManager:
|
||||
self._last_snapshot_ts = 0.0
|
||||
self._viewer_was_fresh = viewer_fresh
|
||||
|
||||
digest = (frame_checksum if frame_checksum is not None
|
||||
else zlib.adler32(self.image.tobytes()))
|
||||
action = snapshot_policy.decide(
|
||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||
viewer_fresh, digest != self._last_snapshot_digest)
|
||||
if frame_checksum is not None:
|
||||
digest = frame_checksum
|
||||
frame_changed = digest != self._last_snapshot_digest
|
||||
action = snapshot_policy.decide(
|
||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||
viewer_fresh, frame_changed)
|
||||
else:
|
||||
# Ask as if the frame had changed before paying to find out.
|
||||
# decide() is monotone in frame_changed -- a SKIP for a
|
||||
# changed frame is a SKIP for an unchanged one too (its touch
|
||||
# branch ignores frame_changed) -- so returning here gives the
|
||||
# same answer the hash would have, and on most frames the hash
|
||||
# is never taken. test_snapshot_policy.py holds decide() to it.
|
||||
action = snapshot_policy.decide(
|
||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||
viewer_fresh, True)
|
||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||
# Not hashed, so not known to be unchanged: owed until a
|
||||
# later look finds it written or unchanged.
|
||||
self._snapshot_owed = True
|
||||
return
|
||||
digest = zlib.adler32(self.image.tobytes())
|
||||
frame_changed = digest != self._last_snapshot_digest
|
||||
if not frame_changed:
|
||||
# Unchanged after all: the decision an unchanged frame gets.
|
||||
action = snapshot_policy.decide(
|
||||
now, self._last_snapshot_ts,
|
||||
self._last_snapshot_touch_ts, viewer_fresh, False)
|
||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||
# A changed frame inside the write interval stays owed: the
|
||||
# next update_display() would write it, but a static screen
|
||||
# may not make one -- write_owed_snapshot() covers that.
|
||||
self._snapshot_owed = frame_changed
|
||||
return
|
||||
if (action is snapshot_policy.SnapshotAction.TOUCH
|
||||
and self._saved_snapshot_digest == digest):
|
||||
# mtime bump only: keeps the health check (snapshot age)
|
||||
# green without paying for a PNG encode of an unchanged frame
|
||||
# (this frame is already on disk, so nothing is owed).
|
||||
self._snapshot_owed = False
|
||||
os.utime(self._snapshot_path, None)
|
||||
self._last_snapshot_touch_ts = now
|
||||
return
|
||||
# Owed until the write below succeeds: if it raises, the frame
|
||||
# stays owed and write_owed_snapshot() retries it, rather than a
|
||||
# held screen leaving the preview stale after one failed write.
|
||||
self._snapshot_owed = True
|
||||
# (A TOUCH for a frame that isn't on disk yet -- still queued, or
|
||||
# its write failed -- is written instead: touching would make the
|
||||
# older file on disk look current.)
|
||||
@@ -1918,9 +1862,39 @@ class DisplayManager:
|
||||
self._last_snapshot_ts = now
|
||||
self._last_snapshot_touch_ts = now
|
||||
self._last_snapshot_digest = digest
|
||||
self._snapshot_owed = False
|
||||
except Exception as e:
|
||||
self._log_snapshot_failure(e)
|
||||
|
||||
def write_owed_snapshot(self) -> None:
|
||||
"""Write a frame the snapshot throttle skipped, once it is due.
|
||||
|
||||
The preview snapshot is only ever written from update_display(), and
|
||||
at most once per write interval (snapshot_policy). A frame pushed
|
||||
inside that interval is skipped, and is written by the next
|
||||
update_display() that comes after it -- but a screen that draws its
|
||||
card once and then holds it makes no further call. Its frame was on
|
||||
the panel and never in the preview: soccer's recent/upcoming cards
|
||||
skip redundant redraws, and the first one after an on-demand start
|
||||
(pushed a few milliseconds after the controller's clear) left
|
||||
/api/v3/display/current and the web preview black for the whole
|
||||
screen while the panel showed the card.
|
||||
|
||||
The render loop calls this after each frame. Cheap when nothing is
|
||||
owed (one attribute read); otherwise the usual policy decides, so
|
||||
the write still waits out the interval and an unchanged frame is
|
||||
never re-encoded.
|
||||
"""
|
||||
if not self._snapshot_owed:
|
||||
return
|
||||
try:
|
||||
if self._writes_suppressed():
|
||||
return
|
||||
with self._update_lock:
|
||||
self._write_snapshot_if_due()
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
self._log_snapshot_failure(e)
|
||||
|
||||
def _log_snapshot_failure(self, error: Exception) -> None:
|
||||
# Snapshot failures must never break display — but they must not
|
||||
# be silent either: the snapshot's mtime is the web UI's display
|
||||
@@ -1962,7 +1936,8 @@ class DisplayManager:
|
||||
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(_fd, "wb") as _f:
|
||||
image.save(_f, format='PNG')
|
||||
image.save(_f, format='PNG',
|
||||
compress_level=_SNAPSHOT_PNG_COMPRESS_LEVEL)
|
||||
os.chmod(tmp_path, 0o644)
|
||||
os.replace(tmp_path, self._snapshot_path)
|
||||
except Exception:
|
||||
@@ -1973,7 +1948,8 @@ class DisplayManager:
|
||||
except OSError:
|
||||
pass
|
||||
# Fallback to direct save if replace not supported
|
||||
image.save(self._snapshot_path, format='PNG')
|
||||
image.save(self._snapshot_path, format='PNG',
|
||||
compress_level=_SNAPSHOT_PNG_COMPRESS_LEVEL)
|
||||
# Set proper file permissions after saving
|
||||
try:
|
||||
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
|
||||
|
||||
@@ -216,6 +216,23 @@ class RenderWatchdog:
|
||||
def armed(self) -> bool:
|
||||
return self._armed
|
||||
|
||||
def liveness(self) -> Dict[str, Any]:
|
||||
"""The heartbeat, in memory: what the control socket's state stream
|
||||
reports as ``loop``.
|
||||
|
||||
``heartbeat_age_seconds`` is the age of the render thread's last beat,
|
||||
the beat that writes the heartbeat file, so it ages at the same rate
|
||||
and is judged by the same ``HEARTBEAT_STALE_SECONDS``. None until the
|
||||
loop has drawn its first frame. Any thread may call this: it only
|
||||
reads two attributes.
|
||||
"""
|
||||
last = self._last_beat
|
||||
age = None
|
||||
if self._armed and last is not None:
|
||||
age = max(self._clock() - last, 0.0)
|
||||
return {'heartbeat_age_seconds': age, 'armed': self._armed,
|
||||
'stale_after': HEARTBEAT_STALE_SECONDS}
|
||||
|
||||
def _on_render_thread(self) -> bool:
|
||||
return self._render_thread is not None and threading.get_ident() == self._render_thread
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import requests
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
from src.common.json_body import response_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -157,7 +158,7 @@ class DynamicTeamResolver:
|
||||
response = requests.get(rankings_url, headers=dict(DEFAULT_HTTP_HEADERS),
|
||||
timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
data = response_json(response)
|
||||
|
||||
rankings = {}
|
||||
rankings_data = data.get('rankings', [])
|
||||
|
||||
+102
-129
@@ -19,7 +19,7 @@ import uuid
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any, Callable, Tuple
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
import logging
|
||||
|
||||
from src.exceptions import LEDMatrixError
|
||||
@@ -485,32 +485,30 @@ def record_error(
|
||||
# and only the display service's ever records anything (plugin_executor runs
|
||||
# the plugins there). The web interface therefore reads a snapshot the display
|
||||
# service publishes to the shared cache directory -- the same channel, and the
|
||||
# same file permissions, as display_current_state and plugin_metrics:*: files
|
||||
# same file permissions, as display_current_state and plugin_metrics_snapshot: files
|
||||
# are 0660 and carry the cache directory's group, so root writes and the web
|
||||
# user reads, and the other way round for the clear request.
|
||||
# user reads.
|
||||
#
|
||||
# ERROR_SNAPSHOT_KEY written by the display service only
|
||||
# ERROR_CLEAR_REQUEST_KEY written by the web interface only
|
||||
#
|
||||
# A clear is asynchronous: the web interface records a request, and the
|
||||
# display service applies it (clear_before) on its next tick and republishes.
|
||||
# Until it has, the web interface hides whatever the snapshot shows from
|
||||
# before the cutoff, so a clear takes effect for readers immediately and a
|
||||
# snapshot published just before the request cannot bring old errors back.
|
||||
# The web interface never writes the snapshot itself: two writers would race,
|
||||
# and a snapshot owned by the web user is one more file root's write has to
|
||||
# replace.
|
||||
# A clear goes over the control socket (``errors.clear``): the display applies
|
||||
# it (clear_before) and republishes the snapshot before it answers. When the
|
||||
# socket cannot carry it, the clear fails and the route says so: the
|
||||
# ``plugin_error_clear_request`` file mailbox it used to fall back to is gone.
|
||||
# A stopped display's errors go anyway: its next run publishes an empty
|
||||
# snapshot over the old one. The web interface never writes the snapshot
|
||||
# itself: two writers would race, and a snapshot owned by the web user is one
|
||||
# more file root's write has to replace.
|
||||
|
||||
ERROR_SNAPSHOT_KEY = "plugin_error_snapshot"
|
||||
ERROR_CLEAR_REQUEST_KEY = "plugin_error_clear_request"
|
||||
|
||||
#: Shortest gap between two snapshot writes, in seconds. A plugin failing in
|
||||
#: a tight loop changes the aggregator many times a second; the snapshot is
|
||||
#: rewritten at most this often, and only when something changed.
|
||||
SNAPSHOT_MIN_INTERVAL = 10.0
|
||||
|
||||
#: How often the display service checks for changes and clear requests. A
|
||||
#: check is an in-memory comparison plus reading one small file.
|
||||
#: How often the display service checks for changes. A check is an
|
||||
#: in-memory comparison.
|
||||
SNAPSHOT_TICK_INTERVAL = 5.0
|
||||
|
||||
_SNAPSHOT_RECENT_ERRORS = 20
|
||||
@@ -579,8 +577,8 @@ class ErrorSnapshotPublisher:
|
||||
Runs in the display service only. tick() is the whole job; start() just
|
||||
calls it from a daemon thread every SNAPSHOT_TICK_INTERVAL seconds, which
|
||||
also means errors recorded while a write was being throttled still reach
|
||||
the cache once the interval has passed, and a clear request is applied
|
||||
even when no new error arrives to trigger a publish.
|
||||
the cache once the interval has passed. A clear (``errors.clear`` over
|
||||
the control socket) is applied by :meth:`clear_now`.
|
||||
|
||||
Nothing here raises: a failure to read or write the cache is logged at
|
||||
debug and retried on a later tick.
|
||||
@@ -602,46 +600,48 @@ class ErrorSnapshotPublisher:
|
||||
self._stop = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def _apply_clear_request(self) -> bool:
|
||||
"""Honour a clear request we have not applied yet. True if one was."""
|
||||
request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||
if not isinstance(request, dict):
|
||||
return False
|
||||
request_id = request.get("request_id")
|
||||
if not isinstance(request_id, str) or not request_id or request_id == self._applied_clear_id:
|
||||
return False
|
||||
try:
|
||||
cutoff = float(request.get("cutoff"))
|
||||
except (TypeError, ValueError):
|
||||
cutoff = float("nan")
|
||||
def _clear(self, request_id: str, cutoff: float) -> int:
|
||||
"""Apply one clear and remember it. Caller holds _tick_lock."""
|
||||
cleared = 0
|
||||
if math.isfinite(cutoff):
|
||||
cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff))
|
||||
_snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)",
|
||||
cleared, request_id)
|
||||
# A malformed request is acknowledged too, so it is not retried forever.
|
||||
self._applied_clear_id = request_id
|
||||
return True
|
||||
return cleared
|
||||
|
||||
def clear_now(self, request_id: str, cutoff: float) -> int:
|
||||
"""``errors.clear`` over the control socket: apply a clear at once and
|
||||
republish the snapshot, so the web interface's next read has it.
|
||||
Returns how many records were cleared. Raises when the snapshot
|
||||
could not be written, so the caller is not told it worked."""
|
||||
with self._tick_lock:
|
||||
cleared = self._clear(request_id, float(cutoff))
|
||||
self._publish(self.aggregator.version, self._clock())
|
||||
return cleared
|
||||
|
||||
def _publish(self, version: int, now: float) -> None:
|
||||
"""Write the snapshot. Caller holds _tick_lock."""
|
||||
# Stamp the attempt before writing: a cache that keeps failing
|
||||
# is retried at the throttled rate, not on every tick.
|
||||
self._last_attempt = now
|
||||
snapshot = self.aggregator.build_snapshot()
|
||||
snapshot["applied_clear_id"] = self._applied_clear_id
|
||||
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
||||
self._published_version = version
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Apply a pending clear and publish if due. True if a snapshot was written."""
|
||||
"""Publish if due. True if a snapshot was written."""
|
||||
with self._tick_lock:
|
||||
try:
|
||||
cleared = self._apply_clear_request()
|
||||
version = self.aggregator.version
|
||||
now = self._clock()
|
||||
if not cleared:
|
||||
if version == self._published_version:
|
||||
return False
|
||||
if (self._last_attempt is not None
|
||||
and now - self._last_attempt < self.min_interval):
|
||||
return False
|
||||
# Stamp the attempt before writing: a cache that keeps failing
|
||||
# is retried at the throttled rate, not on every tick.
|
||||
self._last_attempt = now
|
||||
snapshot = self.aggregator.build_snapshot()
|
||||
snapshot["applied_clear_id"] = self._applied_clear_id
|
||||
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
||||
self._published_version = version
|
||||
if version == self._published_version:
|
||||
return False
|
||||
if (self._last_attempt is not None
|
||||
and now - self._last_attempt < self.min_interval):
|
||||
return False
|
||||
self._publish(version, now)
|
||||
return True
|
||||
except Exception as err: # never let reporting break the display
|
||||
_snapshot_logger.debug("Could not publish the plugin error snapshot: %s",
|
||||
@@ -693,18 +693,31 @@ def start_error_snapshot_publisher(cache_manager: Any) -> Optional[ErrorSnapshot
|
||||
return None
|
||||
|
||||
|
||||
def apply_error_clear(request_id: str, args: Any) -> Dict[str, Any]:
|
||||
"""The display's handler for ``errors.clear`` on the control socket.
|
||||
|
||||
``args`` is the contract's ErrorsClearArgs (``cutoff``, epoch seconds).
|
||||
Runs on the socket's connection thread: the aggregator and the publisher
|
||||
have their own locks, and nothing here touches rendering. Returns
|
||||
ErrorsClearResult once the clear is applied and the snapshot rewritten.
|
||||
"""
|
||||
publisher = _snapshot_publisher
|
||||
if publisher is None:
|
||||
raise RuntimeError("the error snapshot publisher is not running")
|
||||
cutoff = float(args.cutoff)
|
||||
cleared = publisher.clear_now(request_id, cutoff)
|
||||
return {"request_id": request_id, "cutoff": cutoff, "cleared": cleared}
|
||||
|
||||
|
||||
# --- Reading side (web interface) -------------------------------------------
|
||||
|
||||
def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
"""The display service's latest snapshot and the latest clear request.
|
||||
def read_error_report(cache_manager: Any) -> Optional[Dict[str, Any]]:
|
||||
"""The display service's latest snapshot, or None before it has published.
|
||||
|
||||
memory_ttl=0: both keys are written by the other process, so only the
|
||||
file is current.
|
||||
memory_ttl=0: the display writes the key, so only the file is current.
|
||||
"""
|
||||
snapshot = cache_manager.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||
clear_request = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||
return (snapshot if isinstance(snapshot, dict) else None,
|
||||
clear_request if isinstance(clear_request, dict) else None)
|
||||
return snapshot if isinstance(snapshot, dict) else None
|
||||
|
||||
|
||||
def _epoch(iso: Any) -> Optional[float]:
|
||||
@@ -717,28 +730,6 @@ def _epoch(iso: Any) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _pending_cutoff(snapshot: Optional[Dict[str, Any]],
|
||||
clear_request: Optional[Dict[str, Any]]) -> Optional[float]:
|
||||
"""The cutoff of a clear the snapshot has not applied yet, if any."""
|
||||
if not clear_request:
|
||||
return None
|
||||
request_id = clear_request.get("request_id")
|
||||
if not request_id:
|
||||
return None
|
||||
if snapshot is not None and snapshot.get("applied_clear_id") == request_id:
|
||||
return None
|
||||
try:
|
||||
cutoff = float(clear_request.get("cutoff"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return cutoff if math.isfinite(cutoff) else None
|
||||
|
||||
|
||||
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
|
||||
when = _epoch(item.get(field_name)) if isinstance(item, dict) else None
|
||||
return when is not None and when > cutoff
|
||||
|
||||
|
||||
def _empty_summary(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
return {
|
||||
"session_start": snapshot.get("session_start") if snapshot else None,
|
||||
@@ -751,11 +742,11 @@ def _empty_summary(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def error_summary_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
clear_request: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
def error_summary_from_report(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""The /errors/summary payload: get_error_summary()'s shape plus
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending`` (always
|
||||
False now: a clear is applied before its route answers; kept for API
|
||||
compatibility)."""
|
||||
summary = _empty_summary(snapshot)
|
||||
if snapshot is not None:
|
||||
for name, default in summary.items():
|
||||
@@ -764,32 +755,17 @@ def error_summary_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
isinstance(default, float) and isinstance(value, int)) or (
|
||||
name == "session_start" and isinstance(value, str)):
|
||||
summary[name] = value
|
||||
if cutoff is not None:
|
||||
recent = summary["recent_errors"]
|
||||
newest = _epoch(recent[-1].get("timestamp")) if recent and isinstance(recent[-1], dict) else None
|
||||
if newest is None or newest <= cutoff:
|
||||
# Everything the display has reported predates the clear.
|
||||
summary = _empty_summary(snapshot)
|
||||
else:
|
||||
# Only part of it does. The lists can be filtered exactly; the
|
||||
# counts cannot, and stay as reported until the display
|
||||
# applies the clear (clear_pending says so).
|
||||
summary["recent_errors"] = [r for r in recent if _is_after(r, "timestamp", cutoff)]
|
||||
summary["active_patterns"] = {
|
||||
k: p for k, p in summary["active_patterns"].items()
|
||||
if _is_after(p, "last_seen", cutoff)
|
||||
}
|
||||
summary["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||
summary["snapshot_available"] = snapshot is not None
|
||||
summary["clear_pending"] = cutoff is not None
|
||||
summary["clear_pending"] = False
|
||||
return summary
|
||||
|
||||
|
||||
def plugin_health_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
clear_request: Optional[Dict[str, Any]],
|
||||
plugin_id: str) -> Dict[str, Any]:
|
||||
"""The /errors/plugin/<id> payload: get_plugin_health()'s shape plus
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending`` (always
|
||||
False, as in error_summary_from_report)."""
|
||||
health: Dict[str, Any] = {
|
||||
"plugin_id": plugin_id,
|
||||
"status": "healthy",
|
||||
@@ -798,19 +774,15 @@ def plugin_health_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
"recent_error_count": 0,
|
||||
"last_error": None,
|
||||
}
|
||||
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||
table = snapshot.get("plugin_health") if snapshot else None
|
||||
entry = table.get(plugin_id) if isinstance(table, dict) else None
|
||||
if isinstance(entry, dict):
|
||||
# last_error is the plugin's newest error: if even that predates a
|
||||
# pending clear, so does everything else the display reported for it.
|
||||
if cutoff is None or _is_after(entry.get("last_error"), "timestamp", cutoff):
|
||||
for name in ("status", "total_errors", "error_types", "recent_error_count", "last_error"):
|
||||
if name in entry:
|
||||
health[name] = entry[name]
|
||||
for name in ("status", "total_errors", "error_types", "recent_error_count", "last_error"):
|
||||
if name in entry:
|
||||
health[name] = entry[name]
|
||||
health["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||
health["snapshot_available"] = snapshot is not None
|
||||
health["clear_pending"] = cutoff is not None
|
||||
health["clear_pending"] = False
|
||||
return health
|
||||
|
||||
|
||||
@@ -831,35 +803,36 @@ def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
|
||||
#: ``send(request_id, cutoff)`` hands a clear to the display over the control
|
||||
#: socket and returns its ErrorsClearResult. It raises when the socket could
|
||||
#: not carry it or the display failed it (``src.ipc.client.ControlError``).
|
||||
ClearSender = Callable[[str, float], Dict[str, Any]]
|
||||
|
||||
|
||||
def request_error_clear(cache_manager: Any, cutoff: float,
|
||||
send: ClearSender) -> Dict[str, Any]:
|
||||
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
||||
|
||||
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
|
||||
(see _count_cleared) and ``clear_requested``. Raises OSError when the
|
||||
request did not reach the shared cache, since a cache without a usable
|
||||
directory accepts set() and keeps nothing.
|
||||
Over the control socket: the display applies the clear and republishes
|
||||
its snapshot before it answers, so nothing is written here. Whatever
|
||||
``send`` raises reaches the caller.
|
||||
|
||||
A request the display has not applied yet is only ever widened: a later,
|
||||
narrower one ("older than 24 hours" after "everything") overwriting it
|
||||
would otherwise bring back the errors the first one hid.
|
||||
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
|
||||
(the display's own count, else an estimate from the snapshot; see
|
||||
_count_cleared), and ``clear_requested``, ``applied`` and ``transport``,
|
||||
which are always True, True and ``"socket"`` now and are kept for API
|
||||
compatibility.
|
||||
"""
|
||||
snapshot, clear_request = read_error_report(cache_manager)
|
||||
pending = _pending_cutoff(snapshot, clear_request)
|
||||
if pending is not None:
|
||||
cutoff = max(cutoff, pending)
|
||||
before = error_summary_from_report(snapshot, clear_request)
|
||||
request = {
|
||||
"request_id": uuid.uuid4().hex,
|
||||
"cutoff": cutoff,
|
||||
"requested_at": time.time(),
|
||||
}
|
||||
cache_manager.set(ERROR_CLEAR_REQUEST_KEY, request)
|
||||
stored = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||
if not isinstance(stored, dict) or stored.get("request_id") != request["request_id"]:
|
||||
raise OSError("the clear request was not stored in the shared cache")
|
||||
before = error_summary_from_report(read_error_report(cache_manager))
|
||||
request_id = uuid.uuid4().hex
|
||||
result = send(request_id, cutoff)
|
||||
count = result.get("cleared") if isinstance(result, dict) else None
|
||||
return {
|
||||
"cleared_count": _count_cleared(before, cutoff),
|
||||
"clear_requested": True,
|
||||
"request_id": request["request_id"],
|
||||
"request_id": request_id,
|
||||
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
||||
"applied": True,
|
||||
"transport": "socket",
|
||||
"cleared_count": (count if isinstance(count, int) and not isinstance(count, bool)
|
||||
else _count_cleared(before, cutoff)),
|
||||
}
|
||||
|
||||
+37
-240
@@ -40,13 +40,7 @@ from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_assets_dir_mode,
|
||||
get_config_dir_mode,
|
||||
)
|
||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||
from src.deprecation import deprecated
|
||||
from typing import Dict, Tuple, Optional, Union, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -93,7 +87,7 @@ class FontManager:
|
||||
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
|
||||
self.temp_font_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Counters behind get_performance_stats().
|
||||
# Font-load counters, kept up by get_font().
|
||||
self.performance_stats = {
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
@@ -109,12 +103,8 @@ class FontManager:
|
||||
"tom_thumb": "assets/fonts/tom-thumb.bdf"
|
||||
}
|
||||
|
||||
# Size tokens for convenience
|
||||
self.size_tokens = {
|
||||
"xs": 6, "sm": 8, "md": 10, "lg": 12, "xl": 14, "xxl": 16
|
||||
}
|
||||
|
||||
# Font overrides storage (for manual overrides)
|
||||
# Per-element overrides read from config/font_overrides.json;
|
||||
# resolve_font applies them.
|
||||
# Under the install root's config/ (which always exists), not the
|
||||
# cwd: the file itself may not exist yet, and resolve_asset_path
|
||||
# hands back a missing path unchanged.
|
||||
@@ -187,26 +177,6 @@ class FontManager:
|
||||
if removed:
|
||||
self.manager_fonts_version += 1
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get registered fonts for a specific manager or all managers.
|
||||
|
||||
Args:
|
||||
manager_id: Optional manager ID, if None returns all
|
||||
|
||||
Returns:
|
||||
Dictionary of registered fonts
|
||||
"""
|
||||
if manager_id:
|
||||
return self.manager_fonts.get(manager_id, {})
|
||||
return self.manager_fonts.copy()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all detected font usage across managers."""
|
||||
return self.detected_fonts.copy()
|
||||
|
||||
# ==================== Plugin Font Management ====================
|
||||
|
||||
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any],
|
||||
@@ -252,6 +222,39 @@ class FontManager:
|
||||
logger.error(f"Error registering fonts for plugin {plugin_id}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def forget_plugin_fonts(self, plugin_id: str) -> bool:
|
||||
"""Drop the fonts ``plugin_id``'s manifest registered: its manifest
|
||||
and catalog, its ``plugin_id::family`` entries in font_catalog, and
|
||||
cached font objects for those families. Called by core when a plugin
|
||||
is unloaded, so a reload registers from its current manifest and a
|
||||
removed plugin's fonts stop resolving.
|
||||
|
||||
FontManager takes no locks; like forget_manager_fonts this relies on
|
||||
single dict operations being atomic and iterates snapshots, so a
|
||||
render thread calling get_font() meanwhile cannot break it. Returns
|
||||
True if the plugin had registered fonts.
|
||||
"""
|
||||
prefix = f"{plugin_id}::"
|
||||
manifest = self.plugin_fonts.pop(plugin_id, None)
|
||||
catalog = self.plugin_font_catalogs.pop(plugin_id, None)
|
||||
# Every namespaced entry, not just the families in the catalog: one
|
||||
# whose file failed to load never made it into the catalog, and a
|
||||
# caller may have added one directly.
|
||||
for family in list(self.font_catalog):
|
||||
if family.startswith(prefix):
|
||||
self.font_catalog.pop(family, None)
|
||||
# get_font() keys the cache f"{family}_{size_px}".
|
||||
dropped = [key for key in list(self.font_cache) if key.startswith(prefix)]
|
||||
for key in dropped:
|
||||
self.font_cache.pop(key, None)
|
||||
if dropped:
|
||||
# Font objects someone may hold were dropped; see cache_generation.
|
||||
self.cache_generation += 1
|
||||
if manifest is None and catalog is None:
|
||||
return False
|
||||
logger.info("Forgot fonts of plugin %s", plugin_id)
|
||||
return True
|
||||
|
||||
def _validate_font_manifest(self, font_manifest: Dict[str, Any]) -> bool:
|
||||
"""Validate the structure of a plugin's font manifest."""
|
||||
required_fields = ["fonts"]
|
||||
@@ -433,51 +436,6 @@ class FontManager:
|
||||
search_dirs = [Path(resolve_asset_path(configured)), Path(resolve_asset_path("plugins"))]
|
||||
return resolve_plugin_dir(plugin_id, search_dirs, prefix=True)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
|
||||
"""Unregister all fonts for a plugin."""
|
||||
try:
|
||||
if plugin_id in self.plugin_fonts:
|
||||
# Remove from plugin catalogs
|
||||
if plugin_id in self.plugin_font_catalogs:
|
||||
for family in self.plugin_font_catalogs[plugin_id]:
|
||||
namespaced_family = f"{plugin_id}::{family}"
|
||||
if namespaced_family in self.font_catalog:
|
||||
del self.font_catalog[namespaced_family]
|
||||
|
||||
del self.plugin_font_catalogs[plugin_id]
|
||||
|
||||
# Remove plugin manifest
|
||||
del self.plugin_fonts[plugin_id]
|
||||
|
||||
# Clear related cache entries
|
||||
self._clear_plugin_font_cache(plugin_id)
|
||||
|
||||
logger.info(f"Unregistered fonts for plugin {plugin_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error unregistering plugin fonts: {e}")
|
||||
return False
|
||||
|
||||
def _clear_plugin_font_cache(self, plugin_id: str):
|
||||
"""Clear font cache entries for a specific plugin."""
|
||||
keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")]
|
||||
for key in keys_to_remove:
|
||||
del self.font_cache[key]
|
||||
if keys_to_remove:
|
||||
# Font objects someone may hold were dropped; see cache_generation.
|
||||
self.cache_generation += 1
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
|
||||
"""Get list of font families registered by a plugin."""
|
||||
if plugin_id in self.plugin_font_catalogs:
|
||||
return list(self.plugin_font_catalogs[plugin_id].keys())
|
||||
return []
|
||||
|
||||
# ==================== Font Resolution ====================
|
||||
|
||||
def resolve_font(self, element_key: str, family: str, size_px: int,
|
||||
@@ -668,42 +626,6 @@ class FontManager:
|
||||
logger.error(f"Error getting font height: {e}", exc_info=True)
|
||||
return 12 # Default height
|
||||
|
||||
# ==================== Override Management ====================
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def set_override(self, element_key: str, family: str = None, size_px: int = None):
|
||||
"""Set font override for a specific element."""
|
||||
if element_key not in self.font_overrides:
|
||||
self.font_overrides[element_key] = {}
|
||||
|
||||
if family is not None:
|
||||
self.font_overrides[element_key]["family"] = family
|
||||
if size_px is not None:
|
||||
self.font_overrides[element_key]["size_px"] = size_px
|
||||
|
||||
# Remove empty overrides
|
||||
if not self.font_overrides[element_key]:
|
||||
del self.font_overrides[element_key]
|
||||
else:
|
||||
self._save_overrides()
|
||||
|
||||
self.clear_cache()
|
||||
logger.info(f"Font override set for {element_key}: {self.font_overrides.get(element_key, {})}")
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def remove_override(self, element_key: str):
|
||||
"""Remove font override for a specific element."""
|
||||
if element_key in self.font_overrides:
|
||||
del self.font_overrides[element_key]
|
||||
self._save_overrides()
|
||||
self.clear_cache()
|
||||
logger.info(f"Font override removed for {element_key}")
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_overrides(self) -> Dict[str, Dict[str, str]]:
|
||||
"""Get current font overrides."""
|
||||
return self.font_overrides.copy()
|
||||
|
||||
# ==================== Font Discovery ====================
|
||||
|
||||
@staticmethod
|
||||
@@ -765,17 +687,6 @@ class FontManager:
|
||||
logger.warning(f"Could not load font overrides: {e}")
|
||||
self.font_overrides = {}
|
||||
|
||||
def _save_overrides(self):
|
||||
"""Save current font overrides to file."""
|
||||
try:
|
||||
font_overrides_path = Path(self.font_overrides_file)
|
||||
ensure_directory_permissions(font_overrides_path.parent, get_config_dir_mode())
|
||||
with open(self.font_overrides_file, 'w') as f:
|
||||
json.dump(self.font_overrides, f, indent=2)
|
||||
logger.info(f"Saved {len(self.font_overrides)} font overrides")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not save font overrides: {e}")
|
||||
|
||||
# ==================== Utility Methods ====================
|
||||
|
||||
def clear_cache(self):
|
||||
@@ -786,117 +697,3 @@ class FontManager:
|
||||
# without the bump they kept serving results for the dropped fonts.
|
||||
self.cache_generation += 1
|
||||
logger.info("Font cache cleared")
|
||||
|
||||
@deprecated("3.8.0", "read font_catalog")
|
||||
def get_available_fonts(self) -> Dict[str, str]:
|
||||
"""Get dictionary of available font families and their paths."""
|
||||
return self.font_catalog.copy()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_size_tokens(self) -> Dict[str, int]:
|
||||
"""Get available size tokens."""
|
||||
return self.size_tokens.copy()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_performance_stats(self) -> Dict[str, Any]:
|
||||
"""Get performance statistics."""
|
||||
uptime = time.time() - self.performance_stats["start_time"]
|
||||
return {
|
||||
"uptime_seconds": uptime,
|
||||
"cache_hits": self.performance_stats["cache_hits"],
|
||||
"cache_misses": self.performance_stats["cache_misses"],
|
||||
"cache_hit_rate": (
|
||||
self.performance_stats["cache_hits"] /
|
||||
(self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"])
|
||||
if (self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"]) > 0 else 0
|
||||
),
|
||||
"total_fonts_cached": len(self.font_cache),
|
||||
"total_metrics_cached": len(self.metrics_cache),
|
||||
"failed_loads": self.performance_stats["failed_loads"],
|
||||
"total_fonts_available": len(self.font_catalog),
|
||||
"plugin_fonts": len(self.plugin_fonts),
|
||||
"manager_fonts": len(self.manager_fonts),
|
||||
"detected_fonts": len(self.detected_fonts)
|
||||
}
|
||||
|
||||
@deprecated("3.8.0", "read font_catalog")
|
||||
def get_font_catalog(self) -> Dict[str, str]:
|
||||
"""Get the current font catalog."""
|
||||
return self.font_catalog.copy()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def add_font(self, font_file_path: str, family_name: str) -> bool:
|
||||
"""Add ``font_file_path`` to the catalog as ``family_name``. The file
|
||||
stays where it is; only assets/fonts is created if it is missing."""
|
||||
try:
|
||||
# Validate font file
|
||||
if not os.path.exists(font_file_path):
|
||||
logger.error(f"Font file not found: {font_file_path}")
|
||||
return False
|
||||
|
||||
# Check if family name already exists
|
||||
if family_name in self.font_catalog:
|
||||
logger.warning(f"Font family '{family_name}' already exists")
|
||||
return False
|
||||
|
||||
fonts_dir = Path(resolve_asset_path("assets/fonts"))
|
||||
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
|
||||
|
||||
# Add to catalog
|
||||
self.font_catalog[family_name] = font_file_path
|
||||
self.clear_cache()
|
||||
logger.info(f"Added font {family_name}: {font_file_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding font {family_name}: {e}")
|
||||
return False
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def remove_font(self, family_name: str) -> bool:
|
||||
"""Remove a font from the catalog."""
|
||||
try:
|
||||
if family_name not in self.font_catalog:
|
||||
logger.warning(f"Font family '{family_name}' not found")
|
||||
return False
|
||||
|
||||
# Check if font is currently in use
|
||||
in_use = False
|
||||
for override in self.font_overrides.values():
|
||||
if override.get("family") == family_name:
|
||||
in_use = True
|
||||
break
|
||||
|
||||
if in_use:
|
||||
logger.error(f"Cannot remove font '{family_name}' - it is currently in use")
|
||||
return False
|
||||
|
||||
del self.font_catalog[family_name]
|
||||
self.clear_cache()
|
||||
logger.info(f"Removed font {family_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing font {family_name}: {e}")
|
||||
return False
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def validate_font(self, font_path: str) -> Dict[str, Any]:
|
||||
"""Validate a font file."""
|
||||
try:
|
||||
if not os.path.exists(font_path):
|
||||
return {"valid": False, "error": "Font file not found"}
|
||||
|
||||
if font_path.endswith('.bdf'):
|
||||
# Try to load BDF font
|
||||
freetype.Face(font_path)
|
||||
return {"valid": True, "type": "bdf", "family": "unknown"}
|
||||
elif font_path.endswith('.ttf'):
|
||||
# Try to load TTF font
|
||||
load_truetype(font_path, 12)
|
||||
return {"valid": True, "type": "ttf", "family": "unknown"}
|
||||
else:
|
||||
return {"valid": False, "error": "Unsupported font format"}
|
||||
|
||||
except Exception as e:
|
||||
return {"valid": False, "error": str(e)}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""The display process's control socket: web -> display commands with acks.
|
||||
|
||||
- :mod:`src.ipc.contract` -- the versioned messages, the framing and where
|
||||
the socket lives. Shared by both sides; standard library only.
|
||||
- :mod:`src.ipc.server` -- the display side: a threaded Unix-socket server
|
||||
whose handlers only queue work for the render thread.
|
||||
- :mod:`src.ipc.client` -- the web side: one short-timeout request.
|
||||
|
||||
See docs/IPC_CONTROL_SOCKET.md for the protocol, the security model and the
|
||||
stage plan.
|
||||
"""
|
||||
@@ -0,0 +1,552 @@
|
||||
"""The web side of the control socket: one request, a short timeout, no retries.
|
||||
|
||||
Every failure -- no socket (the display is stopped, or predates the socket),
|
||||
a refused or timed-out connection, a reply that breaks the contract, or an
|
||||
error the display returned -- raises :class:`ControlError` with a short
|
||||
``reason``. Nothing here blocks for longer than ``timeout`` in total.
|
||||
|
||||
There is no other way to reach the display: the file mailboxes the web
|
||||
interface used to fall back to are gone. :func:`display_not_listening` tells
|
||||
a caller when no display is listening yet (it is stopped, or still
|
||||
starting), the one case where sending the same request again later, once a
|
||||
display is up, can work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
|
||||
|
||||
from src.ipc.contract import (
|
||||
AWAIT_SECONDS,
|
||||
MAX_MESSAGE_BYTES,
|
||||
PROTOCOL_VERSION,
|
||||
SUBSCRIBE_KEEPALIVE_SECONDS,
|
||||
SUPPORTED_VERSIONS,
|
||||
Command,
|
||||
ErrorCode,
|
||||
FrameReader,
|
||||
ProtocolError,
|
||||
Request,
|
||||
Response,
|
||||
StateEvent,
|
||||
StateEventKind,
|
||||
client_socket_paths,
|
||||
decode_message,
|
||||
encode_message,
|
||||
parse_args,
|
||||
socket_supported,
|
||||
)
|
||||
|
||||
#: Total budget for one request: connect, send and the reply. The display
|
||||
#: answers from a thread that does no rendering, normally within a few
|
||||
#: milliseconds; this only bounds a wedged one.
|
||||
DEFAULT_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
|
||||
class ControlError(Exception):
|
||||
"""The socket could not carry the request. ``reason`` is a short code.
|
||||
|
||||
Transport reasons: ``disabled``, ``unsupported``, ``no_socket``,
|
||||
``refused``, ``timeout``, ``closed``, ``bad_response``, ``invalid_request``.
|
||||
When the display answered with an error, ``reason`` is that error's
|
||||
:class:`~src.ipc.contract.ErrorCode` (``busy``, ``unknown_command``, ...).
|
||||
|
||||
``sent`` is True once the whole request was written to a connected
|
||||
display, which may then have acted on it. A refusal the display sends
|
||||
before it reads anything (``forbidden``, too many connections) carries
|
||||
no request id and leaves ``sent`` False.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, message: str = '', *, sent: bool = False):
|
||||
super().__init__(reason, message)
|
||||
self.reason = reason
|
||||
self.message = message
|
||||
self.sent = sent
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.reason}: {self.message}' if self.message else self.reason
|
||||
|
||||
|
||||
#: Answers from a display that read the request but does not speak it: one
|
||||
#: older than the command (an upgrade in progress) or the protocol version.
|
||||
UPGRADE_REASONS = frozenset({ErrorCode.UNKNOWN_COMMAND, ErrorCode.UNSUPPORTED_VERSION})
|
||||
|
||||
#: Transport reasons that mean nothing is listening at the socket: no socket
|
||||
#: file (the display is stopped, or has not reached its run loop), or a file
|
||||
#: nobody accepts on (a stale socket) or that this user may not open.
|
||||
NOT_LISTENING_REASONS = frozenset({'no_socket', 'refused'})
|
||||
|
||||
|
||||
def display_not_listening(error: BaseException) -> bool:
|
||||
"""True when ``error`` says no display took the request because none is
|
||||
listening: it never reached one (``sent`` is False) and the reason is in
|
||||
:data:`NOT_LISTENING_REASONS`. A display that is started, or finishes
|
||||
starting, may take the same request later. False for everything else:
|
||||
a display that had the request and failed it, one too old to know the
|
||||
command, a client that cannot use the socket at all (``disabled``,
|
||||
``unsupported``), and an exception that is not a :class:`ControlError`.
|
||||
"""
|
||||
return (isinstance(error, ControlError) and not error.sent
|
||||
and error.reason in NOT_LISTENING_REASONS)
|
||||
|
||||
|
||||
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
||||
request_id: Optional[str] = None,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Send one command and return its ``result``. Raises :class:`ControlError`."""
|
||||
args = dict(args or {})
|
||||
request_id = request_id or str(uuid.uuid4())
|
||||
try:
|
||||
# Refuse locally what the display would refuse: a malformed id
|
||||
# (callers may pass their own) or arguments that break the contract.
|
||||
envelope = Request.from_dict({'v': PROTOCOL_VERSION, 'id': request_id,
|
||||
'cmd': cmd, 'args': args})
|
||||
parse_args(cmd, args)
|
||||
payload = encode_message(envelope.to_dict())
|
||||
except ProtocolError as e:
|
||||
raise ControlError('invalid_request', e.message) from None
|
||||
|
||||
if not socket_supported():
|
||||
raise ControlError('unsupported', 'no Unix sockets on this platform')
|
||||
candidates: List[str] = list(paths) if paths is not None else client_socket_paths()
|
||||
if not candidates:
|
||||
raise ControlError('disabled', 'the control socket is turned off')
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
sock = _connect(candidates, deadline)
|
||||
try:
|
||||
response = _exchange(sock, payload, deadline)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
# A refusal before the request was read (forbidden, too many
|
||||
# connections) carries no id.
|
||||
if response.id != request_id and not (response.id is None and not response.ok):
|
||||
raise ControlError('bad_response', 'the reply is for a different request', sent=True)
|
||||
if not response.ok:
|
||||
error = response.error
|
||||
# No id: refused at the door (forbidden, too many connections),
|
||||
# before the display read the request.
|
||||
raise ControlError(error.code if error else 'bad_response',
|
||||
error.message if error else '',
|
||||
sent=response.id is not None)
|
||||
return dict(response.result or {})
|
||||
|
||||
|
||||
def _remaining(deadline: float) -> float:
|
||||
left = deadline - time.monotonic()
|
||||
if left <= 0:
|
||||
raise ControlError('timeout', 'no reply in time')
|
||||
return left
|
||||
|
||||
|
||||
def _connect(paths: Sequence[str], deadline: float) -> socket.socket:
|
||||
last = ControlError('no_socket', 'the display is not serving the control socket')
|
||||
for path in paths:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(_remaining(deadline))
|
||||
sock.connect(path)
|
||||
return sock
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
sock.close()
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
sock.close()
|
||||
last = ControlError('refused', f'nothing is listening at {path}')
|
||||
except BlockingIOError:
|
||||
# EAGAIN: the listen backlog is full -- a live but swamped display.
|
||||
sock.close()
|
||||
raise ControlError('busy', 'the display is not accepting connections') from None
|
||||
except PermissionError:
|
||||
sock.close()
|
||||
last = ControlError('refused', f'no permission to connect to {path}')
|
||||
except socket.timeout:
|
||||
sock.close()
|
||||
raise ControlError('timeout', 'connect timed out') from None
|
||||
except ControlError:
|
||||
sock.close()
|
||||
raise
|
||||
except OSError as e:
|
||||
sock.close()
|
||||
last = ControlError('refused', f'{path}: {e}')
|
||||
raise last
|
||||
|
||||
|
||||
def _exchange(sock: socket.socket, payload: bytes, deadline: float) -> Response:
|
||||
"""Send ``payload`` and read the reply. A failure once the whole request
|
||||
is written raises with ``sent=True``: the display may have it."""
|
||||
sent = False
|
||||
try:
|
||||
sock.settimeout(_remaining(deadline))
|
||||
sock.sendall(payload)
|
||||
sent = True
|
||||
reader = FrameReader(MAX_MESSAGE_BYTES)
|
||||
while True:
|
||||
sock.settimeout(_remaining(deadline))
|
||||
data = sock.recv(4096)
|
||||
if not data:
|
||||
raise ControlError('closed', 'the display closed the connection', sent=sent)
|
||||
lines = reader.feed(data)
|
||||
if lines:
|
||||
return Response.from_dict(decode_message(lines[0]))
|
||||
except socket.timeout:
|
||||
raise ControlError('timeout', 'no reply in time', sent=sent) from None
|
||||
except ProtocolError as e:
|
||||
raise ControlError('bad_response', e.message, sent=sent) from None
|
||||
except ControlError as e:
|
||||
e.sent = e.sent or sent
|
||||
raise
|
||||
except OSError as e:
|
||||
raise ControlError('closed', str(e), sent=sent) from None
|
||||
|
||||
|
||||
# -- commands ---------------------------------------------------------------------------
|
||||
|
||||
def on_demand_start(request_id: str, plugin_id: Optional[str], mode: Optional[str],
|
||||
duration: Any = None, pinned: bool = False, *,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Ask the display to show a plugin now. Returns the ack; raises :class:`ControlError`.
|
||||
|
||||
``request_id`` doubles as the on-demand request id, so a request sent
|
||||
twice with the same id is processed only once.
|
||||
"""
|
||||
args = {'plugin_id': plugin_id, 'mode': mode, 'duration': duration, 'pinned': pinned}
|
||||
return request(Command.ON_DEMAND_START, args, request_id=request_id,
|
||||
timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def on_demand_stop(request_id: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Ask the display to end on-demand. Returns the ack; raises :class:`ControlError`."""
|
||||
return request(Command.ON_DEMAND_STOP, {}, request_id=request_id,
|
||||
timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def on_demand_status(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""The display's live on-demand state. Raises :class:`ControlError`."""
|
||||
return request(Command.ON_DEMAND_STATUS, {}, timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
#: Headroom over the display's own wait for an awaited command, so its
|
||||
#: ``pending`` answer arrives before the client gives up.
|
||||
_AWAIT_MARGIN_SECONDS = 1.0
|
||||
|
||||
|
||||
def _awaited_timeout(cmd: str) -> float:
|
||||
return AWAIT_SECONDS[cmd] + _AWAIT_MARGIN_SECONDS
|
||||
|
||||
|
||||
def brightness_set(brightness: int, *, timeout: Optional[float] = None,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Set the panel's normal brightness now (transient: config.json is not
|
||||
written). Returns the applied :class:`~src.ipc.contract.BrightnessResult`;
|
||||
raises :class:`ControlError`.
|
||||
"""
|
||||
return request(Command.BRIGHTNESS_SET, {'brightness': brightness},
|
||||
timeout=_awaited_timeout(Command.BRIGHTNESS_SET) if timeout is None
|
||||
else timeout, paths=paths)
|
||||
|
||||
|
||||
def plugin_reload(plugin_id: str, *, timeout: Optional[float] = None,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Have the display reload a running plugin from disk.
|
||||
|
||||
Returns :class:`~src.ipc.contract.PluginReloadResult` once the new code is
|
||||
running. Raises :class:`ControlError`: ``not_loaded`` (not running it),
|
||||
``failed`` (the new version did not load), ``pending`` (not done in
|
||||
time; it will still happen), or a transport reason.
|
||||
"""
|
||||
return request(Command.PLUGIN_RELOAD, {'plugin_id': plugin_id},
|
||||
timeout=_awaited_timeout(Command.PLUGIN_RELOAD) if timeout is None
|
||||
else timeout, paths=paths)
|
||||
|
||||
|
||||
def errors_clear(request_id: str, cutoff: float, *,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Have the display forget the plugin errors recorded at or before
|
||||
``cutoff`` (epoch seconds) and publish its error snapshot again.
|
||||
|
||||
Returns :class:`~src.ipc.contract.ErrorsClearResult` once it is done.
|
||||
Raises :class:`ControlError`: ``unknown_command`` from a display older
|
||||
than the command.
|
||||
"""
|
||||
return request(Command.ERRORS_CLEAR, {'cutoff': cutoff}, request_id=request_id,
|
||||
timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
return request(Command.PING, {}, timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def hello(client: str = 'web', *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Version negotiation: the result's ``version`` is the one both sides speak."""
|
||||
return request(Command.HELLO, {'versions': list(SUPPORTED_VERSIONS), 'client': client},
|
||||
timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
# -- the state stream (stage 3) ---------------------------------------------------------
|
||||
|
||||
def state_get(since: Optional[int] = None, epoch: Optional[str] = None, *,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""The display's state now, as a :class:`~src.ipc.contract.StateSnapshot`.
|
||||
|
||||
With ``since``/``epoch`` from an earlier answer, an unchanged state comes
|
||||
back in the short ``changed: false`` form. Raises :class:`ControlError`
|
||||
(``unknown_command`` from a display older than stage 3).
|
||||
"""
|
||||
args: Dict[str, Any] = {}
|
||||
if since is not None:
|
||||
args['since'] = since
|
||||
if epoch is not None:
|
||||
args['epoch'] = epoch
|
||||
return request(Command.STATE_GET, args, timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def snapshot_age(snapshot: Mapping[str, Any], now_mono: Optional[float] = None) -> float:
|
||||
"""Seconds since ``snapshot`` arrived: ``received_mono`` (set by
|
||||
:meth:`StateSubscription.latest`) to now; 0 for a one-shot answer."""
|
||||
received = snapshot.get('received_mono')
|
||||
if isinstance(received, (int, float)) and not isinstance(received, bool):
|
||||
now_mono = time.monotonic() if now_mono is None else now_mono
|
||||
return max(now_mono - float(received), 0.0)
|
||||
return 0.0
|
||||
|
||||
|
||||
def snapshot_loop_age(snapshot: Mapping[str, Any],
|
||||
now_mono: Optional[float] = None) -> Optional[float]:
|
||||
"""The render loop's heartbeat age now, from a state snapshot: the age the
|
||||
display measured when it answered, plus the time since the answer
|
||||
arrived. None when the display has no beat to report yet."""
|
||||
loop = snapshot.get('loop')
|
||||
if not isinstance(loop, dict):
|
||||
state = snapshot.get('state')
|
||||
loop = state.get('loop') if isinstance(state, dict) else None
|
||||
age = loop.get('heartbeat_age_seconds') if isinstance(loop, dict) else None
|
||||
if not isinstance(age, (int, float)) or isinstance(age, bool):
|
||||
return None
|
||||
return max(float(age), 0.0) + snapshot_age(snapshot, now_mono)
|
||||
|
||||
|
||||
def _merge_volatile(state: Dict[str, Any], volatile: Any) -> None:
|
||||
"""Fold a tick's ``volatile`` values (``{section: {key: value}}``) into
|
||||
``state``, copying each section it touches.
|
||||
|
||||
These are the timestamps the hub leaves out of its version --
|
||||
``display.last_updated``, ``on_demand.last_updated``/``remaining``,
|
||||
``plugins.published_at`` -- and the readers judge freshness by them, so
|
||||
a copy that only full ``state`` events updated would go stale while the
|
||||
same mode stayed on screen. Only keys the section already has are taken:
|
||||
a tick never adds a section or a key the last snapshot did not carry
|
||||
(a section left out of a truncated snapshot stays out).
|
||||
"""
|
||||
if not isinstance(volatile, dict):
|
||||
return # a display from before ticks carried them
|
||||
for name, values in volatile.items():
|
||||
section = state.get(name)
|
||||
if not isinstance(section, dict) or not isinstance(values, dict):
|
||||
continue
|
||||
fresh = {k: v for k, v in values.items() if k in section}
|
||||
if fresh:
|
||||
state[name] = dict(section, **fresh)
|
||||
|
||||
|
||||
#: A subscription that has heard nothing for this long is not trusted: the
|
||||
#: display sends a tick at least every SUBSCRIBE_KEEPALIVE_SECONDS.
|
||||
SUBSCRIPTION_SILENCE_SECONDS = 3 * SUBSCRIBE_KEEPALIVE_SECONDS
|
||||
|
||||
#: Reconnect backoff: the first retry, and the cap. A display that does not
|
||||
#: know state.subscribe (stage 2 or older) is retried at the cap.
|
||||
_RECONNECT_MIN_SECONDS = 1.0
|
||||
_RECONNECT_MAX_SECONDS = 30.0
|
||||
|
||||
#: Failures that another try soon will not fix.
|
||||
_SLOW_RETRY_REASONS = frozenset({'unknown_command', 'unsupported_version', 'disabled',
|
||||
'unsupported'})
|
||||
|
||||
|
||||
class StateSubscription:
|
||||
"""One ``state.subscribe`` connection, held on a daemon thread.
|
||||
|
||||
Keeps the latest snapshot the display pushed, so a reader answers from
|
||||
memory (:meth:`latest`). Reconnects with a backoff when the display goes
|
||||
away. Never raises into the caller: :meth:`latest` is None whenever the
|
||||
copy cannot be vouched for (not connected, or silent for longer than
|
||||
``silence``), and the caller falls back.
|
||||
"""
|
||||
|
||||
def __init__(self, paths: Optional[Sequence[str]] = None, *,
|
||||
silence: float = SUBSCRIPTION_SILENCE_SECONDS,
|
||||
connect_timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
clock: Callable[[], float] = time.monotonic):
|
||||
self._paths = list(paths) if paths is not None else None
|
||||
self._silence = silence
|
||||
self._connect_timeout = connect_timeout
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._snapshot: Optional[Dict[str, Any]] = None
|
||||
self._received: Optional[float] = None
|
||||
self._connected = False
|
||||
self._stop = threading.Event()
|
||||
self._sock: Optional[socket.socket] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
#: The reason the last connection ended (a ControlError reason).
|
||||
self.last_error: Optional[str] = None
|
||||
#: Full snapshots received: the subscribe answer and each state event.
|
||||
self.snapshots = 0
|
||||
|
||||
# -- the reader's side ---------------------------------------------------
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def latest(self) -> Optional[Dict[str, Any]]:
|
||||
"""A copy of the latest snapshot, with ``received_mono`` (this
|
||||
process's monotonic clock when it arrived); None when not trusted."""
|
||||
with self._lock:
|
||||
if not self._connected or self._snapshot is None or self._received is None:
|
||||
return None
|
||||
if self._clock() - self._received > self._silence:
|
||||
return None
|
||||
snap = dict(self._snapshot)
|
||||
snap['received_mono'] = self._received
|
||||
return snap
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
def start(self) -> 'StateSubscription':
|
||||
if self._thread is None or not self._thread.is_alive():
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._run, name='ledmatrix-state-feed',
|
||||
daemon=True)
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self, timeout: float = 2.0) -> None:
|
||||
self._stop.set()
|
||||
sock = self._sock
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
thread = self._thread
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout)
|
||||
self._thread = None
|
||||
|
||||
# -- the feed thread -----------------------------------------------------
|
||||
|
||||
def _run(self) -> None:
|
||||
backoff = _RECONNECT_MIN_SECONDS
|
||||
while not self._stop.is_set():
|
||||
snapshots = self.snapshots
|
||||
try:
|
||||
self._follow()
|
||||
except ControlError as e:
|
||||
self.last_error = e.reason
|
||||
if e.reason in _SLOW_RETRY_REASONS:
|
||||
backoff = _RECONNECT_MAX_SECONDS
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
self.last_error = type(e).__name__
|
||||
finally:
|
||||
with self._lock:
|
||||
self._connected = False
|
||||
sock, self._sock = self._sock, None
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
if self.snapshots != snapshots:
|
||||
# This connection got as far as the display's state: whatever
|
||||
# ended it (a restart, most often), it was working, so the
|
||||
# next try starts from the shortest wait again.
|
||||
backoff = _RECONNECT_MIN_SECONDS
|
||||
if self._stop.wait(backoff):
|
||||
return
|
||||
backoff = min(backoff * 2, _RECONNECT_MAX_SECONDS)
|
||||
|
||||
def _follow(self) -> None:
|
||||
"""Subscribe, then read events until the connection ends. Raises ControlError."""
|
||||
if not socket_supported():
|
||||
raise ControlError('unsupported', 'no Unix sockets on this platform')
|
||||
candidates = list(self._paths) if self._paths is not None else client_socket_paths()
|
||||
if not candidates:
|
||||
raise ControlError('disabled', 'the control socket is turned off')
|
||||
request_id = str(uuid.uuid4())
|
||||
payload = encode_message(Request(id=request_id, cmd=Command.STATE_SUBSCRIBE,
|
||||
args={}).to_dict())
|
||||
sock = _connect(candidates, time.monotonic() + self._connect_timeout)
|
||||
self._sock = sock
|
||||
try:
|
||||
sock.settimeout(self._connect_timeout)
|
||||
sock.sendall(payload)
|
||||
# A read waits for the next event; the display sends one at least
|
||||
# every keepalive, so this much silence means it is gone.
|
||||
sock.settimeout(self._silence)
|
||||
reader = FrameReader(MAX_MESSAGE_BYTES)
|
||||
first = True
|
||||
while not self._stop.is_set():
|
||||
data = sock.recv(65536)
|
||||
if not data:
|
||||
raise ControlError('closed', 'the display closed the connection')
|
||||
for line in reader.feed(data):
|
||||
obj = decode_message(line)
|
||||
if first:
|
||||
response = Response.from_dict(obj)
|
||||
if not response.ok:
|
||||
error = response.error
|
||||
raise ControlError(error.code if error else 'bad_response',
|
||||
error.message if error else '')
|
||||
self._store(dict(response.result or {}), full=True)
|
||||
first = False
|
||||
continue
|
||||
event = StateEvent.from_dict(obj)
|
||||
self._store(event.result, full=event.event == StateEventKind.STATE)
|
||||
except socket.timeout:
|
||||
raise ControlError('timeout', 'the display went quiet') from None
|
||||
except ProtocolError as e:
|
||||
raise ControlError('bad_response', e.message) from None
|
||||
except OSError as e:
|
||||
if self._stop.is_set():
|
||||
return
|
||||
raise ControlError('closed', str(e)) from None
|
||||
|
||||
def _store(self, result: Dict[str, Any], full: bool) -> None:
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
if full and isinstance(result.get('state'), dict):
|
||||
self._snapshot = result
|
||||
self.snapshots += 1
|
||||
elif (self._snapshot is not None
|
||||
and result.get('epoch') == self._snapshot.get('epoch')):
|
||||
# A tick: nothing changed but the render loop's liveness and
|
||||
# the volatile keys (timestamps) the writers keep refreshing.
|
||||
snap = dict(self._snapshot)
|
||||
state = dict(snap.get('state') or {})
|
||||
if result.get('version') == snap.get('version'):
|
||||
_merge_volatile(state, result.get('volatile'))
|
||||
loop = result.get('loop')
|
||||
if isinstance(loop, dict):
|
||||
state['loop'] = loop
|
||||
snap['loop'] = loop
|
||||
snap['state'] = state
|
||||
snap['served_at'] = result.get('served_at', snap.get('served_at'))
|
||||
self._snapshot = snap
|
||||
else:
|
||||
return # a tick before any state, or from another epoch
|
||||
self._received = now
|
||||
self._connected = True
|
||||
@@ -0,0 +1,861 @@
|
||||
"""The control socket's contract: versioned messages, framing and location.
|
||||
|
||||
Both processes import this module -- the display serves the socket
|
||||
(:mod:`src.ipc.server`) and the web interface calls it
|
||||
(:mod:`src.ipc.client`) -- so it is the one definition of what goes over the
|
||||
wire. Standard library only, and no import of the rest of ``src``.
|
||||
|
||||
Wire format (protocol version 1)
|
||||
--------------------------------
|
||||
One JSON object per line (newline-delimited JSON), UTF-8, at most
|
||||
:data:`MAX_MESSAGE_BYTES` per line including the newline. Messages are
|
||||
encoded with ``ensure_ascii``, so a newline never appears inside one.
|
||||
|
||||
Request::
|
||||
|
||||
{"v": 1, "id": "<1-128 chars>", "cmd": "on_demand.start", "args": {...}}
|
||||
|
||||
Response, always carrying the request's ``id`` (``null`` when the request
|
||||
could not be parsed far enough to have one)::
|
||||
|
||||
{"v": 1, "id": "...", "ok": true, "result": {...}}
|
||||
{"v": 1, "id": "...", "ok": false, "error": {"code": "...", "message": "..."}}
|
||||
|
||||
A connection may carry several requests; each gets exactly one response, in
|
||||
order. Commands that change what the panel shows are *acknowledged*, not
|
||||
completed: ``{"accepted": true, "request_id": ...}`` means the render thread
|
||||
has the command queued and will apply it at its next on-demand check. Its
|
||||
outcome is published the way it always was (``display_on_demand_state``,
|
||||
later the state stream). A few commands (:data:`AWAITED_COMMANDS`) are
|
||||
answered only once the render thread has applied them, or with ``pending``
|
||||
when it has not within :data:`AWAIT_SECONDS`.
|
||||
|
||||
``state.subscribe`` is the one exception to "one response per request": its
|
||||
response is followed, on the same connection, by :class:`StateEvent` lines
|
||||
the display pushes until either side hangs up. Events carry ``event``
|
||||
instead of ``ok``.
|
||||
|
||||
New commands are added within a protocol version: a display that does not
|
||||
know one answers ``unknown_command``, the client falls back, and ``hello``
|
||||
lists the commands a display knows. The version changes only when the
|
||||
envelope or the meaning of an existing command changes.
|
||||
|
||||
See docs/IPC_CONTROL_SOCKET.md for the full description.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple, TypedDict, TypeGuard, Union
|
||||
|
||||
|
||||
# -- versions and limits -------------------------------------------------------
|
||||
|
||||
#: The protocol version this code speaks by default.
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
#: Every version this code can speak; ``hello`` picks the highest common one.
|
||||
SUPPORTED_VERSIONS: Tuple[int, ...] = (1,)
|
||||
|
||||
#: The largest message either side sends or accepts, newline included. A
|
||||
#: stage-1 message is well under 1 KiB; this only bounds a broken or hostile
|
||||
#: peer, so a reader never buffers more than this per connection.
|
||||
MAX_MESSAGE_BYTES = 64 * 1024
|
||||
|
||||
#: Longest request id. Ids are also the on-demand ``request_id``, which the
|
||||
#: display logs and stores, so they are kept short.
|
||||
MAX_ID_LENGTH = 128
|
||||
|
||||
#: Longest plugin id or mode name an on-demand command may carry.
|
||||
MAX_NAME_LENGTH = 128
|
||||
|
||||
|
||||
# -- where the socket lives ------------------------------------------------------
|
||||
|
||||
#: ``RuntimeDirectory=ledmatrix`` in ledmatrix.service creates this (tmpfs,
|
||||
#: root-owned, 0755); a display under an older unit creates it itself, as it
|
||||
#: does for the heartbeat (src/display_watchdog.py).
|
||||
DEFAULT_SOCKET_DIR = '/run/ledmatrix'
|
||||
SOCKET_NAME = 'control.sock'
|
||||
DEFAULT_SOCKET_PATH = DEFAULT_SOCKET_DIR + '/' + SOCKET_NAME
|
||||
|
||||
#: Overrides the socket path for both processes (a dev checkout, a second
|
||||
#: instance, tests). One of :data:`DISABLED_VALUES` turns the socket off: the
|
||||
#: display does not serve it, and the web interface cannot send it commands
|
||||
#: (it still reads the state the display writes to the cache).
|
||||
SOCKET_PATH_ENV = 'LEDMATRIX_CONTROL_SOCKET'
|
||||
DISABLED_VALUES = frozenset({'off', '0', 'false', 'no', 'none', 'disabled'})
|
||||
|
||||
|
||||
def socket_supported() -> bool:
|
||||
"""Whether this platform has Unix sockets at all (Windows Python does not)."""
|
||||
import socket
|
||||
return os.name == 'posix' and hasattr(socket, 'AF_UNIX')
|
||||
|
||||
|
||||
def socket_disabled(environ: Optional[Mapping[str, str]] = None) -> bool:
|
||||
"""True when :data:`SOCKET_PATH_ENV` switches the socket off."""
|
||||
env = os.environ if environ is None else environ
|
||||
value = (env.get(SOCKET_PATH_ENV) or '').strip()
|
||||
return value.lower() in DISABLED_VALUES
|
||||
|
||||
|
||||
def configured_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[str]:
|
||||
"""The path :data:`SOCKET_PATH_ENV` names, or None when it is unset or 'off'."""
|
||||
env = os.environ if environ is None else environ
|
||||
value = (env.get(SOCKET_PATH_ENV) or '').strip()
|
||||
if not value or value.lower() in DISABLED_VALUES:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def dev_socket_path(uid: Optional[int] = None) -> str:
|
||||
"""Where a display that cannot use /run/ledmatrix serves the socket.
|
||||
|
||||
A per-user directory under the temp dir, so a dev checkout run as an
|
||||
ordinary user (``python3 run.py -e``) and its web interface, run by the
|
||||
same user, find each other with no configuration.
|
||||
"""
|
||||
if uid is None:
|
||||
getuid = getattr(os, 'getuid', None)
|
||||
uid = getuid() if getuid is not None else 0
|
||||
return os.path.join(tempfile.gettempdir(), f'ledmatrix-{uid}', SOCKET_NAME)
|
||||
|
||||
|
||||
def client_socket_paths(environ: Optional[Mapping[str, str]] = None) -> List[str]:
|
||||
"""The paths a client tries, in order; empty when the socket is off."""
|
||||
if socket_disabled(environ):
|
||||
return []
|
||||
configured = configured_socket_path(environ)
|
||||
if configured:
|
||||
return [configured]
|
||||
return [DEFAULT_SOCKET_PATH, dev_socket_path()]
|
||||
|
||||
|
||||
# -- commands and error codes ----------------------------------------------------
|
||||
|
||||
class Command:
|
||||
"""Command names. Dotted names group a feature's commands."""
|
||||
HELLO = 'hello'
|
||||
PING = 'ping'
|
||||
ON_DEMAND_START = 'on_demand.start'
|
||||
ON_DEMAND_STOP = 'on_demand.stop'
|
||||
ON_DEMAND_STATUS = 'on_demand.status'
|
||||
BRIGHTNESS_SET = 'brightness.set'
|
||||
PLUGIN_RELOAD = 'plugin.reload'
|
||||
STATE_GET = 'state.get'
|
||||
STATE_SUBSCRIBE = 'state.subscribe'
|
||||
ERRORS_CLEAR = 'errors.clear'
|
||||
|
||||
|
||||
#: Every command version 1 defines, in the order ``hello`` reports them.
|
||||
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, ``state.get``
|
||||
#: and ``state.subscribe`` in stage 3, and ``errors.clear`` in stage 4, all
|
||||
#: within version 1 (see the module docstring on adding commands).
|
||||
COMMANDS: Tuple[str, ...] = (
|
||||
Command.HELLO,
|
||||
Command.PING,
|
||||
Command.ON_DEMAND_START,
|
||||
Command.ON_DEMAND_STOP,
|
||||
Command.ON_DEMAND_STATUS,
|
||||
Command.BRIGHTNESS_SET,
|
||||
Command.PLUGIN_RELOAD,
|
||||
Command.STATE_GET,
|
||||
Command.STATE_SUBSCRIBE,
|
||||
Command.ERRORS_CLEAR,
|
||||
)
|
||||
|
||||
#: Commands the connection thread answers itself, through a handler the
|
||||
#: display registers (``ControlServer(handlers=...)``), because they touch
|
||||
#: nothing the render thread owns. A display that registered none answers
|
||||
#: ``unknown_command``, and the client falls back as from an older display.
|
||||
DIRECT_COMMANDS = frozenset({Command.ERRORS_CLEAR})
|
||||
|
||||
#: Commands that are queued for the render thread.
|
||||
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP,
|
||||
Command.BRIGHTNESS_SET, Command.PLUGIN_RELOAD})
|
||||
|
||||
#: Queued commands whose answer waits for the render thread's outcome
|
||||
#: instead of being an ack. The value is how long the display waits before
|
||||
#: answering ``pending``; the command stays queued and is still applied.
|
||||
#: A plugin reload first lets the current screen end (within a frame on a
|
||||
#: scrolling screen, at once on a static one) and then imports the plugin,
|
||||
#: which can take a few seconds on a slow board.
|
||||
AWAIT_SECONDS: Dict[str, float] = {
|
||||
Command.BRIGHTNESS_SET: 2.0,
|
||||
Command.PLUGIN_RELOAD: 10.0,
|
||||
}
|
||||
AWAITED_COMMANDS = frozenset(AWAIT_SECONDS)
|
||||
|
||||
#: The state stream (stage 3). ``state.subscribe`` turns its connection into
|
||||
#: a one-way stream of :class:`StateEvent` lines. Subscribers have their own
|
||||
#: bound, separate from the short request connections, so they can never
|
||||
#: take the slots a command needs.
|
||||
MAX_SUBSCRIBERS = 4
|
||||
|
||||
#: A subscriber hears from the display at least this often: a ``state``
|
||||
#: event when something changed, else a ``tick`` carrying the render loop's
|
||||
#: liveness and the latest volatile timestamps. A client that has heard nothing for a few of these treats its
|
||||
#: copy as unknown.
|
||||
SUBSCRIBE_KEEPALIVE_SECONDS = 5.0
|
||||
|
||||
#: The shape of the ``state`` object in a state snapshot. Bumped only when a
|
||||
#: field changes meaning; new fields are added within a schema.
|
||||
STATE_SCHEMA = 1
|
||||
|
||||
#: The sections of a state snapshot, in the order they are documented.
|
||||
STATE_SECTIONS: Tuple[str, ...] = ('display', 'on_demand', 'brightness', 'plugins', 'loop')
|
||||
|
||||
#: Brightness, in percent, as the display's hardware setting takes it.
|
||||
MIN_BRIGHTNESS = 0
|
||||
MAX_BRIGHTNESS = 100
|
||||
|
||||
|
||||
class ErrorCode:
|
||||
"""``error.code`` values. Clients branch on these, never on the message."""
|
||||
BAD_JSON = 'bad_json' # a line that is not a JSON object
|
||||
BAD_REQUEST = 'bad_request' # the envelope is malformed
|
||||
MESSAGE_TOO_LARGE = 'message_too_large' # over MAX_MESSAGE_BYTES
|
||||
UNSUPPORTED_VERSION = 'unsupported_version' # no version in common
|
||||
UNKNOWN_COMMAND = 'unknown_command'
|
||||
INVALID_ARGS = 'invalid_args'
|
||||
BUSY = 'busy' # queue full / too many clients
|
||||
FORBIDDEN = 'forbidden' # peer credentials refused
|
||||
INTERNAL = 'internal' # a bug on the display side
|
||||
# From the awaited commands (stage 2):
|
||||
PENDING = 'pending' # accepted, not applied within AWAIT_SECONDS; still queued
|
||||
NOT_LOADED = 'not_loaded' # plugin.reload: the display is not running that plugin
|
||||
FAILED = 'failed' # the render thread tried, and it did not work
|
||||
|
||||
|
||||
class ProtocolError(Exception):
|
||||
"""A message that breaks the contract. ``code`` is an :class:`ErrorCode`."""
|
||||
|
||||
def __init__(self, code: str, message: str, request_id: Optional[str] = None):
|
||||
super().__init__(code, message, request_id)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.request_id = request_id
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.code}: {self.message}'
|
||||
|
||||
|
||||
# -- the envelope ------------------------------------------------------------------
|
||||
|
||||
def _is_int(value: Any) -> TypeGuard[int]:
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _valid_id(value: Any) -> bool:
|
||||
return (isinstance(value, str) and 0 < len(value) <= MAX_ID_LENGTH
|
||||
and value.isprintable())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Request:
|
||||
"""``{v, id, cmd, args}``."""
|
||||
id: str
|
||||
cmd: str
|
||||
args: Dict[str, Any] = field(default_factory=dict)
|
||||
v: int = PROTOCOL_VERSION
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'v': self.v, 'id': self.id, 'cmd': self.cmd, 'args': dict(self.args)}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Any) -> 'Request':
|
||||
"""Validate an envelope. Raises :class:`ProtocolError`.
|
||||
|
||||
The version is checked by the server, not here, so that ``hello``
|
||||
can negotiate across versions.
|
||||
"""
|
||||
if not isinstance(obj, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'a request must be a JSON object')
|
||||
raw_id = obj.get('id')
|
||||
request_id = raw_id if _valid_id(raw_id) else None
|
||||
if request_id is None:
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST,
|
||||
f'id must be a printable string of 1-{MAX_ID_LENGTH} characters')
|
||||
version = obj.get('v')
|
||||
if not _is_int(version):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'v must be an integer', request_id)
|
||||
cmd = obj.get('cmd')
|
||||
if not isinstance(cmd, str) or not cmd:
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'cmd must be a non-empty string', request_id)
|
||||
args = obj.get('args', {})
|
||||
if args is None:
|
||||
args = {}
|
||||
if not isinstance(args, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'args must be a JSON object', request_id)
|
||||
return cls(id=request_id, cmd=cmd, args=args, v=version)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ErrorInfo:
|
||||
code: str
|
||||
message: str
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
return {'code': self.code, 'message': self.message}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Response:
|
||||
"""``{v, id, ok, result}`` or ``{v, id, ok: false, error: {code, message}}``."""
|
||||
id: Optional[str]
|
||||
ok: bool
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
error: Optional[ErrorInfo] = None
|
||||
v: int = PROTOCOL_VERSION
|
||||
|
||||
@classmethod
|
||||
def success(cls, request_id: Optional[str], result: Mapping[str, Any],
|
||||
v: int = PROTOCOL_VERSION) -> 'Response':
|
||||
return cls(id=request_id, ok=True, result=dict(result), v=v)
|
||||
|
||||
@classmethod
|
||||
def failure(cls, request_id: Optional[str], code: str, message: str,
|
||||
v: int = PROTOCOL_VERSION) -> 'Response':
|
||||
return cls(id=request_id, ok=False, error=ErrorInfo(code, message), v=v)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {'v': self.v, 'id': self.id, 'ok': self.ok}
|
||||
if self.ok:
|
||||
out['result'] = dict(self.result or {})
|
||||
else:
|
||||
error = self.error or ErrorInfo(ErrorCode.INTERNAL, 'unknown error')
|
||||
out['error'] = error.to_dict()
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Any) -> 'Response':
|
||||
"""Validate a response. Raises :class:`ProtocolError` (BAD_REQUEST)."""
|
||||
if not isinstance(obj, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'a response must be a JSON object')
|
||||
version = obj.get('v')
|
||||
if not _is_int(version):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'v must be an integer')
|
||||
raw_id = obj.get('id')
|
||||
if raw_id is not None and not isinstance(raw_id, str):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'id must be a string or null')
|
||||
ok = obj.get('ok')
|
||||
if not isinstance(ok, bool):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'ok must be a boolean')
|
||||
if ok:
|
||||
result = obj.get('result', {})
|
||||
if not isinstance(result, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'result must be a JSON object')
|
||||
return cls(id=raw_id, ok=True, result=result, v=version)
|
||||
error = obj.get('error')
|
||||
if (not isinstance(error, dict) or not isinstance(error.get('code'), str)
|
||||
or not isinstance(error.get('message', ''), str)):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'error must be {code, message}')
|
||||
return cls(id=raw_id, ok=False,
|
||||
error=ErrorInfo(error['code'], error.get('message', '')), v=version)
|
||||
|
||||
|
||||
# -- command arguments -------------------------------------------------------------
|
||||
|
||||
def _optional_name(args: Mapping[str, Any], key: str) -> Optional[str]:
|
||||
value = args.get(key)
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if not isinstance(value, str) or len(value) > MAX_NAME_LENGTH or not value.isprintable():
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS,
|
||||
f'{key} must be a printable string of at most '
|
||||
f'{MAX_NAME_LENGTH} characters')
|
||||
return value
|
||||
|
||||
|
||||
def _optional_duration(value: Any) -> Optional[float]:
|
||||
"""Seconds, or None for "until stopped". 0 means the same as None.
|
||||
|
||||
Numbers and numeric strings are accepted, the same as the REST route
|
||||
takes them; anything else is refused rather than guessed.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'duration must be a number of seconds')
|
||||
try:
|
||||
seconds = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS,
|
||||
'duration must be a number of seconds') from None
|
||||
if not math.isfinite(seconds) or seconds < 0:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS,
|
||||
'duration must be a finite, non-negative number of seconds')
|
||||
return seconds or None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HelloArgs:
|
||||
"""``hello``: the versions the client speaks, and a name for the logs."""
|
||||
versions: Tuple[int, ...] = (PROTOCOL_VERSION,)
|
||||
client: str = ''
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'versions': list(self.versions), 'client': self.client}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'HelloArgs':
|
||||
versions = args.get('versions', [PROTOCOL_VERSION])
|
||||
if (not isinstance(versions, list) or not versions or len(versions) > 32
|
||||
or not all(_is_int(v) for v in versions)):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'versions must be a list of integers')
|
||||
client = args.get('client', '')
|
||||
if not isinstance(client, str) or len(client) > MAX_NAME_LENGTH:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'client must be a short string')
|
||||
return cls(versions=tuple(versions), client=client)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnDemandStartArgs:
|
||||
"""``on_demand.start``: show a plugin (or one of its modes) now.
|
||||
|
||||
The same fields as the REST route's body. At least one of ``plugin_id``
|
||||
and ``mode`` is required; the display resolves the other.
|
||||
"""
|
||||
plugin_id: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
duration: Optional[float] = None
|
||||
pinned: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'plugin_id': self.plugin_id, 'mode': self.mode,
|
||||
'duration': self.duration, 'pinned': self.pinned}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStartArgs':
|
||||
plugin_id = _optional_name(args, 'plugin_id')
|
||||
mode = _optional_name(args, 'mode')
|
||||
if plugin_id is None and mode is None:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'plugin_id or mode is required')
|
||||
pinned = args.get('pinned', False)
|
||||
if pinned is None:
|
||||
pinned = False
|
||||
if not isinstance(pinned, bool):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'pinned must be a boolean')
|
||||
return cls(plugin_id=plugin_id, mode=mode,
|
||||
duration=_optional_duration(args.get('duration')), pinned=pinned)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnDemandStopArgs:
|
||||
"""``on_demand.stop``: end the on-demand session and resume rotation."""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStopArgs':
|
||||
return cls()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoArgs:
|
||||
"""``ping`` and ``on_demand.status`` take no arguments (extra ones are ignored)."""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'NoArgs':
|
||||
return cls()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrightnessSetArgs:
|
||||
"""``brightness.set``: the panel's normal brightness, in percent, now.
|
||||
|
||||
Transient: nothing is written to config.json, and the next config change
|
||||
the display picks up (or a restart) goes back to the configured value.
|
||||
The web interface sends it after saving the setting, so the two agree.
|
||||
The dim schedule still applies on top, as it does to the saved value.
|
||||
"""
|
||||
brightness: int
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'brightness': self.brightness}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'BrightnessSetArgs':
|
||||
value = args.get('brightness')
|
||||
if not _is_int(value) or not MIN_BRIGHTNESS <= value <= MAX_BRIGHTNESS:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS,
|
||||
f'brightness must be an integer from {MIN_BRIGHTNESS} '
|
||||
f'to {MAX_BRIGHTNESS}')
|
||||
return cls(brightness=value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginReloadArgs:
|
||||
"""``plugin.reload``: load a running plugin again from disk.
|
||||
|
||||
For a plugin the store has just updated. Only a plugin the display is
|
||||
running can be reloaded (``not_loaded`` otherwise), so the id never
|
||||
makes the display import anything it was not already running.
|
||||
"""
|
||||
plugin_id: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'plugin_id': self.plugin_id}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'PluginReloadArgs':
|
||||
plugin_id = _optional_name(args, 'plugin_id')
|
||||
if plugin_id is None:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'plugin_id is required')
|
||||
return cls(plugin_id=plugin_id)
|
||||
|
||||
|
||||
def _optional_version(args: Mapping[str, Any], key: str) -> Optional[int]:
|
||||
value = args.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
if not _is_int(value) or value < 0:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, f'{key} must be a non-negative integer')
|
||||
return value
|
||||
|
||||
|
||||
def _optional_epoch(args: Mapping[str, Any]) -> Optional[str]:
|
||||
value = args.get('epoch')
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if not _valid_id(value):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS,
|
||||
f'epoch must be a printable string of 1-{MAX_ID_LENGTH} characters')
|
||||
return str(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateGetArgs:
|
||||
"""``state.get``: the display's state, as a versioned snapshot.
|
||||
|
||||
With ``since`` and the ``epoch`` it came from, the answer is only
|
||||
``{changed: false, version, epoch, served_at, loop, volatile}`` while the
|
||||
state is still at that version, so a poller that already has it is sent
|
||||
no state -- only the latest values of the keys that do not count as a
|
||||
change (``volatile``, see :class:`StateSnapshot`).
|
||||
"""
|
||||
since: Optional[int] = None
|
||||
epoch: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'since': self.since, 'epoch': self.epoch}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'StateGetArgs':
|
||||
return cls(since=_optional_version(args, 'since'), epoch=_optional_epoch(args))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateSubscribeArgs:
|
||||
"""``state.subscribe``: the snapshot now, then a push stream of changes.
|
||||
|
||||
The response is the snapshot ``state.get`` returns. After it the
|
||||
connection carries only :class:`StateEvent` lines from the display: a
|
||||
``state`` event whenever the state changes (always the latest version,
|
||||
so a reader that falls behind skips versions instead of queueing them),
|
||||
and a ``tick`` at least every :data:`SUBSCRIBE_KEEPALIVE_SECONDS`.
|
||||
"""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'StateSubscribeArgs':
|
||||
return cls()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ErrorsClearArgs:
|
||||
"""``errors.clear``: forget the plugin errors recorded at or before
|
||||
``cutoff`` (seconds since the epoch), as ``POST /api/v3/errors/clear``
|
||||
asks. The request id is the clear's id, which the display's error
|
||||
snapshot then reports as ``applied_clear_id``.
|
||||
"""
|
||||
cutoff: float
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'cutoff': self.cutoff}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'ErrorsClearArgs':
|
||||
value = args.get('cutoff')
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'cutoff must be a number of seconds')
|
||||
if not math.isfinite(value) or value < 0:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS,
|
||||
'cutoff must be a finite, non-negative number of seconds')
|
||||
return cls(cutoff=float(value))
|
||||
|
||||
|
||||
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs,
|
||||
BrightnessSetArgs, PluginReloadArgs, StateGetArgs, StateSubscribeArgs,
|
||||
ErrorsClearArgs]
|
||||
|
||||
#: The arguments of a command that goes on the render thread's queue.
|
||||
QueuedArgs = Union[OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs]
|
||||
|
||||
_ARG_TYPES: Dict[str, Any] = {
|
||||
Command.HELLO: HelloArgs,
|
||||
Command.PING: NoArgs,
|
||||
Command.ON_DEMAND_START: OnDemandStartArgs,
|
||||
Command.ON_DEMAND_STOP: OnDemandStopArgs,
|
||||
Command.ON_DEMAND_STATUS: NoArgs,
|
||||
Command.BRIGHTNESS_SET: BrightnessSetArgs,
|
||||
Command.PLUGIN_RELOAD: PluginReloadArgs,
|
||||
Command.STATE_GET: StateGetArgs,
|
||||
Command.STATE_SUBSCRIBE: StateSubscribeArgs,
|
||||
Command.ERRORS_CLEAR: ErrorsClearArgs,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(cmd: str, args: Mapping[str, Any]) -> CommandArgs:
|
||||
"""Typed arguments for ``cmd``. Raises :class:`ProtocolError`."""
|
||||
arg_type = _ARG_TYPES.get(cmd)
|
||||
if arg_type is None:
|
||||
raise ProtocolError(ErrorCode.UNKNOWN_COMMAND, f'unknown command: {cmd[:64]}')
|
||||
parsed: CommandArgs = arg_type.from_dict(args)
|
||||
return parsed
|
||||
|
||||
|
||||
def on_demand_request(request_id: str, args: Union[OnDemandStartArgs, OnDemandStopArgs],
|
||||
timestamp: float) -> Dict[str, Any]:
|
||||
"""The on-demand request dict for a queued on-demand command.
|
||||
|
||||
The display hands socket commands to the same code that handles
|
||||
plugins' own requests (``DisplayController._handle_on_demand_request``),
|
||||
so a command behaves identically whichever way it arrived. (This was
|
||||
the file mailbox's payload, which the display no longer reads.)
|
||||
"""
|
||||
if isinstance(args, OnDemandStartArgs):
|
||||
return {'request_id': request_id, 'action': 'start', 'plugin_id': args.plugin_id,
|
||||
'mode': args.mode, 'duration': args.duration, 'pinned': args.pinned,
|
||||
'timestamp': timestamp, 'source': 'socket'}
|
||||
return {'request_id': request_id, 'action': 'stop', 'timestamp': timestamp,
|
||||
'source': 'socket'}
|
||||
|
||||
|
||||
# -- results -----------------------------------------------------------------------
|
||||
|
||||
class HelloResult(TypedDict):
|
||||
version: int
|
||||
versions: List[int]
|
||||
commands: List[str]
|
||||
max_message_bytes: int
|
||||
server: str
|
||||
|
||||
|
||||
class PingResult(TypedDict):
|
||||
pong: bool
|
||||
|
||||
|
||||
class AckResult(TypedDict):
|
||||
"""The answer to a queued command: the render thread will apply it."""
|
||||
accepted: bool
|
||||
request_id: str
|
||||
queued: int
|
||||
|
||||
|
||||
class BrightnessResult(TypedDict):
|
||||
"""``brightness.set``, once applied.
|
||||
|
||||
``panel_brightness`` is what the panel shows now: the dim schedule's
|
||||
level while it dims, and unchanged while the schedule has the display
|
||||
off (the new level applies when it comes back on).
|
||||
"""
|
||||
brightness: int
|
||||
panel_brightness: int
|
||||
dimmed: bool
|
||||
display_active: bool
|
||||
|
||||
|
||||
class PluginReloadResult(TypedDict):
|
||||
"""``plugin.reload``, once the plugin is running again."""
|
||||
plugin_id: str
|
||||
reloaded: bool
|
||||
version: Optional[str]
|
||||
modes: List[str]
|
||||
|
||||
|
||||
class ErrorsClearResult(TypedDict):
|
||||
"""``errors.clear``, once applied and the error snapshot republished."""
|
||||
request_id: str
|
||||
cutoff: float
|
||||
cleared: int
|
||||
|
||||
|
||||
class LoopState(TypedDict):
|
||||
"""``loop``: is the render loop still going round?
|
||||
|
||||
``heartbeat_age_seconds`` is the age of the render thread's last beat,
|
||||
measured in memory by the display when it answered -- the same beat that
|
||||
writes ``display-heartbeat.json``. None until the loop has drawn its first
|
||||
frame. At ``stale_after`` or more the loop is stalled: the threshold
|
||||
``/api/v3/health`` uses.
|
||||
"""
|
||||
heartbeat_age_seconds: Optional[float]
|
||||
armed: bool
|
||||
stale_after: float
|
||||
|
||||
|
||||
class StateSnapshot(TypedDict, total=False):
|
||||
"""The answer to ``state.get`` and ``state.subscribe``, and the
|
||||
``result`` of a ``state`` event.
|
||||
|
||||
``version`` counts changes to the state within one ``epoch`` (one run of
|
||||
the display process): a reader that sees a new epoch starts over.
|
||||
``changed`` is False only for a ``state.get`` whose ``since`` is still
|
||||
current, and then ``state`` is absent and ``volatile`` is there instead:
|
||||
``{section: {key: value}}``, the current values of the keys the version
|
||||
ignores (``display.last_updated``, ``on_demand.last_updated`` and
|
||||
``remaining``, ``plugins.published_at``). A reader merges them into the
|
||||
copy it has; they are how it can tell the writers are still publishing.
|
||||
``served_at`` is the display's wall clock when it answered. ``loop`` is
|
||||
measured at that moment, so it is also inside ``state``.
|
||||
|
||||
``state`` holds the sections in :data:`STATE_SECTIONS`:
|
||||
|
||||
* ``display``: what ``display_current_state`` holds (mode, plugin_id,
|
||||
mode_index, total_modes, on_demand_active, is_display_active,
|
||||
last_updated);
|
||||
* ``on_demand``: what ``display_on_demand_state`` holds;
|
||||
* ``brightness``: ``{brightness, panel_brightness, dimmed}``;
|
||||
* ``plugins``: the plugin runtime snapshot (``plugin_runtime_snapshot``),
|
||||
or None when there is none (or it was too large to send);
|
||||
* ``loop``: :class:`LoopState`.
|
||||
|
||||
A section the display has not published yet is None.
|
||||
"""
|
||||
schema: int
|
||||
version: int
|
||||
epoch: str
|
||||
pid: int
|
||||
served_at: float
|
||||
changed: bool
|
||||
state: Dict[str, Any]
|
||||
volatile: Dict[str, Dict[str, Any]]
|
||||
loop: LoopState
|
||||
|
||||
|
||||
class StateEventKind:
|
||||
STATE = 'state' # result: a full StateSnapshot, the latest version
|
||||
TICK = 'tick' # result: {version, epoch, pid, served_at, loop, volatile}; nothing changed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateEvent:
|
||||
"""One message the display pushes to a subscriber.
|
||||
|
||||
``{"v": 1, "id": "<the subscribe request's id>", "event": "state" | "tick",
|
||||
"result": {...}}``. It has no ``ok``, which is how a reader tells it from
|
||||
a response.
|
||||
"""
|
||||
id: str
|
||||
event: str
|
||||
result: Dict[str, Any]
|
||||
v: int = PROTOCOL_VERSION
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'v': self.v, 'id': self.id, 'event': self.event, 'result': dict(self.result)}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Any) -> 'StateEvent':
|
||||
"""Validate an event. Raises :class:`ProtocolError` (BAD_REQUEST)."""
|
||||
if not isinstance(obj, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'an event must be a JSON object')
|
||||
version = obj.get('v')
|
||||
if not _is_int(version):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'v must be an integer')
|
||||
raw_id = obj.get('id')
|
||||
if not isinstance(raw_id, str):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'id must be a string')
|
||||
event = obj.get('event')
|
||||
if event not in (StateEventKind.STATE, StateEventKind.TICK):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'event must be "state" or "tick"')
|
||||
result = obj.get('result')
|
||||
if not isinstance(result, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'result must be a JSON object')
|
||||
return cls(id=raw_id, event=event, result=result, v=version)
|
||||
|
||||
|
||||
def is_event(obj: Any) -> bool:
|
||||
"""Whether a decoded message is a pushed event rather than a response."""
|
||||
return isinstance(obj, dict) and 'event' in obj and 'ok' not in obj
|
||||
|
||||
|
||||
def negotiate_version(client_versions: Tuple[int, ...]) -> Optional[int]:
|
||||
"""The highest version both sides speak, or None."""
|
||||
common = set(client_versions) & set(SUPPORTED_VERSIONS)
|
||||
return max(common) if common else None
|
||||
|
||||
|
||||
# -- framing -----------------------------------------------------------------------
|
||||
|
||||
def encode_message(obj: Mapping[str, Any]) -> bytes:
|
||||
"""One newline-terminated JSON line. Raises :class:`ProtocolError` when too big."""
|
||||
try:
|
||||
text = json.dumps(obj, separators=(',', ':'), ensure_ascii=True, allow_nan=False)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, f'message is not JSON-serialisable: {e}') from None
|
||||
data = text.encode('ascii') + b'\n'
|
||||
if len(data) > MAX_MESSAGE_BYTES:
|
||||
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
|
||||
f'message is {len(data)} bytes; the limit is {MAX_MESSAGE_BYTES}')
|
||||
return data
|
||||
|
||||
|
||||
def decode_message(line: bytes) -> Dict[str, Any]:
|
||||
"""Parse one line (newline optional). Raises :class:`ProtocolError` (BAD_JSON)."""
|
||||
try:
|
||||
obj = json.loads(line.decode('utf-8'))
|
||||
except ValueError: # UnicodeDecodeError and JSONDecodeError are both ValueErrors
|
||||
raise ProtocolError(ErrorCode.BAD_JSON, 'not valid UTF-8 JSON') from None
|
||||
if not isinstance(obj, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_JSON, 'a message must be a JSON object')
|
||||
return obj
|
||||
|
||||
|
||||
class FrameReader:
|
||||
"""Splits a byte stream into lines, never holding more than one message.
|
||||
|
||||
``feed()`` returns the complete lines (without their newlines) the new
|
||||
bytes finished, and raises :class:`ProtocolError` (MESSAGE_TOO_LARGE) as
|
||||
soon as a line is longer than the limit, newline or not, so a peer that
|
||||
never sends one cannot make the reader buffer without bound.
|
||||
"""
|
||||
|
||||
def __init__(self, max_bytes: int = MAX_MESSAGE_BYTES):
|
||||
self._max = max_bytes
|
||||
self._buffer = bytearray()
|
||||
|
||||
@property
|
||||
def pending(self) -> int:
|
||||
"""Bytes of an unfinished message held."""
|
||||
return len(self._buffer)
|
||||
|
||||
def feed(self, data: bytes) -> List[bytes]:
|
||||
self._buffer.extend(data)
|
||||
lines: List[bytes] = []
|
||||
while True:
|
||||
newline = self._buffer.find(b'\n')
|
||||
if newline < 0:
|
||||
break
|
||||
if newline + 1 > self._max:
|
||||
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
|
||||
f'message exceeds {self._max} bytes')
|
||||
line = bytes(self._buffer[:newline])
|
||||
del self._buffer[:newline + 1]
|
||||
if line.strip():
|
||||
lines.append(line)
|
||||
if len(self._buffer) >= self._max:
|
||||
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
|
||||
f'message exceeds {self._max} bytes')
|
||||
return lines
|
||||
+1154
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ from PIL.PngImagePlugin import PngInfo
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
from src.common.json_body import response_json
|
||||
from src.common.logo_helper import MAX_LOGO_BYTES
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
@@ -481,7 +482,7 @@ class LogoDownloader:
|
||||
logger.info(f"Fetching team data for {league} from ESPN API...")
|
||||
response = self.session.get(api_url, params={'limit':1000},headers=self.headers, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
data: Dict = response.json()
|
||||
data: Dict = response_json(response)
|
||||
|
||||
logger.info(f"Successfully fetched team data for {league}")
|
||||
return data
|
||||
@@ -505,7 +506,7 @@ class LogoDownloader:
|
||||
logger.info(f"Fetching team data for team {team_id} in {league} from ESPN API...")
|
||||
response = self.session.get(f"{api_url}/{team_id}", headers=self.headers, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
data: Dict = response.json()
|
||||
data: Dict = response_json(response)
|
||||
|
||||
logger.info(f"Successfully fetched team data for {team_id} in {league}")
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Keep glibc's malloc from holding on to memory the display has freed.
|
||||
|
||||
The display process allocates and frees PIL images and numpy buffers all day
|
||||
from a dozen threads. glibc gives each allocating thread its own malloc arena
|
||||
(up to 8 x CPU count) and returns little of what is freed inside them to the
|
||||
OS, so resident memory climbs for hours while the live data stays flat. Two
|
||||
in-process remedies, both standard library only (ctypes) and both no-ops off
|
||||
Linux/glibc:
|
||||
|
||||
* :func:`cap_arenas` -- ``mallopt(M_ARENA_MAX, 2)``, the in-process twin of the
|
||||
unit's ``Environment=MALLOC_ARENA_MAX=2``. Units installed before that line
|
||||
existed never got it (systemd runs the copy in /etc/systemd/system), so the
|
||||
process applies it itself. Call it before any other thread starts: arenas
|
||||
already created stay. A ``MALLOC_ARENA_MAX`` set in the environment wins.
|
||||
* :class:`MallocTrimmer` -- ``malloc_trim(0)`` at most every few minutes,
|
||||
called from the render loop between screens, where no frame is being drawn.
|
||||
glibc 2.8+ releases free pages from the middle of every arena, not only the
|
||||
top of the main heap.
|
||||
|
||||
Without glibc (macOS, Windows, musl, the dev server on any of them) nothing is
|
||||
loaded and every call returns False.
|
||||
"""
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: glibc's mallopt() parameter number for the arena cap (malloc.h).
|
||||
M_ARENA_MAX = -8
|
||||
|
||||
#: The arena cap applied when the environment does not set one; the same value
|
||||
#: as the unit's ``MALLOC_ARENA_MAX``.
|
||||
DEFAULT_ARENA_MAX = 2
|
||||
|
||||
#: Seconds between malloc_trim() calls. A trim takes about 1-20 ms on a Pi 4,
|
||||
#: so this keeps it far from frame timing while still returning memory long
|
||||
#: before it piles up.
|
||||
TRIM_INTERVAL_SECONDS = 300.0
|
||||
|
||||
_UNLOADED = object()
|
||||
_libc: Any = _UNLOADED
|
||||
|
||||
|
||||
def _load_libc() -> Optional[Any]:
|
||||
"""The process's C library if it is glibc with malloc_trim, else None."""
|
||||
global _libc
|
||||
if _libc is _UNLOADED:
|
||||
_libc = None
|
||||
if sys.platform.startswith('linux'):
|
||||
try:
|
||||
libc = ctypes.CDLL(None)
|
||||
# gnu_get_libc_version is glibc-only, so musl (which has
|
||||
# mallopt but no malloc_trim) is left alone as a whole.
|
||||
if all(hasattr(libc, name) for name in
|
||||
('gnu_get_libc_version', 'malloc_trim', 'mallopt')):
|
||||
libc.malloc_trim.argtypes = [ctypes.c_size_t]
|
||||
libc.malloc_trim.restype = ctypes.c_int
|
||||
libc.mallopt.argtypes = [ctypes.c_int, ctypes.c_int]
|
||||
libc.mallopt.restype = ctypes.c_int
|
||||
_libc = libc
|
||||
except (OSError, AttributeError, TypeError):
|
||||
logger.debug("glibc malloc controls unavailable", exc_info=True)
|
||||
return _libc
|
||||
|
||||
|
||||
def cap_arenas(max_arenas: int = DEFAULT_ARENA_MAX) -> bool:
|
||||
"""Cap glibc's malloc arenas at ``max_arenas``. True when the cap was set.
|
||||
|
||||
Skipped when ``MALLOC_ARENA_MAX`` is in the environment: glibc has read it
|
||||
already, and an operator who set it chose that value.
|
||||
"""
|
||||
if os.environ.get('MALLOC_ARENA_MAX'):
|
||||
return False
|
||||
libc = _load_libc()
|
||||
if libc is None:
|
||||
return False
|
||||
try:
|
||||
return bool(libc.mallopt(M_ARENA_MAX, int(max_arenas)))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.debug("mallopt(M_ARENA_MAX) failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
class MallocTrimmer:
|
||||
"""Calls ``malloc_trim(0)`` at most once per ``interval`` seconds.
|
||||
|
||||
:meth:`maybe_trim` is meant for an idle point of the render loop; it costs
|
||||
one clock read when no trim is due. The first trim comes one interval
|
||||
after construction, so start-up's allocations have settled.
|
||||
"""
|
||||
|
||||
def __init__(self, interval: float = TRIM_INTERVAL_SECONDS,
|
||||
clock: Callable[[], float] = time.monotonic) -> None:
|
||||
self._interval = interval
|
||||
self._clock = clock
|
||||
self._libc = _load_libc()
|
||||
self._next = clock() + interval
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._libc is not None
|
||||
|
||||
def maybe_trim(self) -> bool:
|
||||
"""Trim if one is due. True when malloc_trim ran and released memory."""
|
||||
if self._libc is None:
|
||||
return False
|
||||
now = self._clock()
|
||||
if now < self._next:
|
||||
return False
|
||||
self._next = now + self._interval
|
||||
try:
|
||||
released = bool(self._libc.malloc_trim(0))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.debug("malloc_trim failed; not trying again", exc_info=True)
|
||||
self._libc = None
|
||||
return False
|
||||
logger.debug("malloc_trim(0) took %.1f ms, released=%s",
|
||||
(self._clock() - now) * 1000.0, released)
|
||||
return released
|
||||
@@ -3,15 +3,46 @@ LEDMatrix Plugin System
|
||||
|
||||
This module provides the core plugin infrastructure for the LEDMatrix project.
|
||||
It enables dynamic loading, management, and discovery of display plugins.
|
||||
|
||||
BasePlugin and PluginManager are imported on first use (PEP 562), not when
|
||||
the package is imported: the web interface imports several submodules
|
||||
(store_manager, schema_manager, ...) and never needs PluginManager, which
|
||||
pulls in the loader, executor and the shared helpers behind them.
|
||||
``from src.plugin_system import BasePlugin`` works as before and returns the
|
||||
same class.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Tuple
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
from .base_plugin import BasePlugin
|
||||
from .plugin_manager import PluginManager
|
||||
if TYPE_CHECKING:
|
||||
from .base_plugin import BasePlugin
|
||||
from .plugin_manager import PluginManager
|
||||
|
||||
#: Exported name -> (module it lives in, attribute name there).
|
||||
_LAZY: Dict[str, Tuple[str, str]] = {
|
||||
'BasePlugin': ('src.plugin_system.base_plugin', 'BasePlugin'),
|
||||
'PluginManager': ('src.plugin_system.plugin_manager', 'PluginManager'),
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
'BasePlugin',
|
||||
'PluginManager',
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Import an exported name on first access (PEP 562); see src.common."""
|
||||
try:
|
||||
module_name, attr = _LAZY[name]
|
||||
except KeyError:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
||||
value = getattr(importlib.import_module(module_name), attr) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import -- module_name comes from the fixed _LAZY table
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> List[str]:
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
|
||||
@@ -15,6 +15,8 @@ import os
|
||||
import sys
|
||||
from src.deprecation import deprecated, warn_deprecated
|
||||
from src.logging_config import get_logger
|
||||
# Re-exported: a plugin may import it from here beside VegasDisplayMode.
|
||||
from src.plugin_system.vegas_elements import VegasElement # noqa: F401
|
||||
|
||||
|
||||
_shared_fallback_font_manager: Optional[Any] = None
|
||||
@@ -986,6 +988,147 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_vegas_elements(self) -> Optional[List[Any]]:
|
||||
"""
|
||||
Vegas content as live elements: named, fixed-width pieces the ticker
|
||||
can swap in place while they are on screen.
|
||||
|
||||
get_vegas_content() hands the ticker pictures, and a picture already
|
||||
in the scrolling strip keeps what it showed when it was drawn. Return
|
||||
a list of ``VegasElement`` (src/plugin_system/vegas_elements.py)
|
||||
instead and the ticker records where each one is; after your update()
|
||||
it calls this again, compares each element's ``version`` (or pixels)
|
||||
with what the strip holds, and swaps the changed ones in between two
|
||||
frames -- a score changes on a card already crossing the panel, and
|
||||
nothing next to it moves.
|
||||
|
||||
The contract:
|
||||
|
||||
- Called only on the ticker's background thread, under this plugin's
|
||||
lock (never while update() runs), on a canvas of its own and told
|
||||
its width (get_vegas_render_width()), like get_vegas_content().
|
||||
- Called often -- after every update() while any of your elements is
|
||||
on or ahead of the screen -- so it must be cheap when nothing
|
||||
changed (cache images by version), idempotent, and must not fetch.
|
||||
- A live element's width must not depend on its data: a redraw at a
|
||||
different width is not swapped in (it shows the next time the
|
||||
plugin comes round).
|
||||
- Keys must be unique in the list and stable for the same logical
|
||||
item.
|
||||
|
||||
Return None (the default) to use get_vegas_content(). A core older
|
||||
than 3.8.0 never calls this, so keep get_vegas_content() working and
|
||||
floor ``ledmatrix_min_version`` at 3.8.0 only if you rely on it.
|
||||
|
||||
Example (scoreboard)::
|
||||
|
||||
def get_vegas_elements(self):
|
||||
return [VegasElement(key=f"game:{g['id']}",
|
||||
image=self._card_for(g), # cached by fingerprint
|
||||
version=self._fingerprint(g))
|
||||
for g in self.games]
|
||||
|
||||
Returns:
|
||||
A list of VegasElement, or None.
|
||||
"""
|
||||
return None
|
||||
|
||||
def redraw_vegas_element(self, key: str, width: int, height: int,
|
||||
at: float) -> Optional[Any]:
|
||||
"""
|
||||
Redraw one live element for a moment in time, without the plugin lock.
|
||||
|
||||
Only for elements returned with ``refresh_hz > 0``: content that
|
||||
changes with time rather than with data, such as an aircraft moving
|
||||
between position reports. The ticker calls it up to that often while
|
||||
the element is on or near the screen.
|
||||
|
||||
- Called WITHOUT this plugin's lock, possibly while update() runs, so
|
||||
read only state that update() replaces in a single assignment (an
|
||||
immutable snapshot), never state it mutates in place.
|
||||
- ``at`` is the time.monotonic() at which the pixels are expected to
|
||||
reach the panel; draw the element as it should look then.
|
||||
- Return an image of exactly ``width`` x ``height``, or None to skip
|
||||
this tick.
|
||||
|
||||
Returns:
|
||||
PIL Image of exactly (width, height), or None.
|
||||
"""
|
||||
return None
|
||||
|
||||
def notify_vegas_data_changed(self) -> None:
|
||||
"""
|
||||
Tell the Vegas ticker this plugin's data changed outside update().
|
||||
|
||||
The ticker redraws a plugin's live elements when its update()
|
||||
completes. Data that lands some other way -- a background thread, a
|
||||
push callback -- calls this so the change reaches the screen without
|
||||
waiting for the next update(). Cheap and safe from any thread.
|
||||
"""
|
||||
notify = getattr(getattr(self, 'plugin_manager', None),
|
||||
'notify_data_changed', None)
|
||||
if callable(notify):
|
||||
notify(self.plugin_id)
|
||||
|
||||
def request_on_demand(self, mode: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
pinned: bool = False) -> Optional[str]:
|
||||
"""
|
||||
Take the screen now: show this plugin on demand. Safe from any thread.
|
||||
|
||||
For a plugin that reacts to something outside the rotation -- an MQTT
|
||||
message, a timer, a detection -- and wants the panel for it. The
|
||||
request goes straight to the display in this process and is applied
|
||||
on its render thread within a frame or so, exactly like an on-demand
|
||||
start from the web interface.
|
||||
|
||||
Args:
|
||||
mode: One of this plugin's display modes; None for its first.
|
||||
duration: Seconds to show it before the rotation resumes; None
|
||||
(or zero) for no limit, until end_on_demand() or the user
|
||||
stops it.
|
||||
pinned: Stay on ``mode`` instead of cycling through the
|
||||
plugin's other modes.
|
||||
|
||||
Returns:
|
||||
The request id once the display has queued it, or None when
|
||||
there is no display in this process to ask (the web interface,
|
||||
scripts/check_plugin.py) or its queue is full. Nothing else can
|
||||
take the request then: the ``display_on_demand_request`` file
|
||||
mailbox older cores read is gone, and a write to it is dropped
|
||||
with a warning. See "On-demand display" in
|
||||
docs/PLUGIN_API_REFERENCE.md.
|
||||
|
||||
Example::
|
||||
|
||||
if self.request_on_demand(mode='my_alert', duration=15) is None:
|
||||
self.logger.info("No display to show the alert on")
|
||||
"""
|
||||
request = getattr(getattr(self, 'plugin_manager', None), 'request_on_demand', None)
|
||||
if not callable(request):
|
||||
return None
|
||||
request_id = request(self.plugin_id, mode=mode, duration=duration, pinned=pinned)
|
||||
# Only a real id counts: a test's MagicMock manager answers a mock,
|
||||
# which must read as "not taken" so the plugin's fallback runs.
|
||||
return request_id if isinstance(request_id, str) else None
|
||||
|
||||
def end_on_demand(self) -> Optional[str]:
|
||||
"""
|
||||
Give the screen back: end this plugin's on-demand session. Any thread.
|
||||
|
||||
Ends only a session this plugin owns. One the user started for
|
||||
another plugin, or a session that already ended, is left alone. The
|
||||
rotation resumes where it left off.
|
||||
|
||||
Returns:
|
||||
The request id once queued, or None as request_on_demand() does.
|
||||
"""
|
||||
end = getattr(getattr(self, 'plugin_manager', None), 'end_on_demand', None)
|
||||
if not callable(end):
|
||||
return None
|
||||
request_id = end(self.plugin_id)
|
||||
return request_id if isinstance(request_id, str) else None
|
||||
|
||||
def get_vegas_participation(self) -> str:
|
||||
"""
|
||||
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user