mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-12 18:16:35 +00:00
Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
185d5f0aa0 | ||
|
|
65ede54cba | ||
|
|
20bae8b609 | ||
|
|
9234332c5a | ||
|
|
1bcb524fc3 | ||
|
|
370c8fe273 | ||
|
|
358911cb20 | ||
|
|
d98b479727 | ||
|
|
87f255b2fa | ||
|
|
6c533d62af | ||
|
|
cb06124b42 | ||
|
|
7c5fa9cfdb | ||
|
|
ec117a35a1 | ||
|
|
e4f5e49ff7 | ||
|
|
4b9fc3c755 | ||
|
|
e40bc47d28 | ||
|
|
3bdb5bff3b | ||
|
|
e745ae8060 | ||
|
|
a669d781f5 | ||
|
|
c20c0beac2 | ||
|
|
6fb2dc3595 | ||
|
|
b638b91169 | ||
|
|
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 |
@@ -10,3 +10,6 @@
|
|||||||
# Generated by scripts/build_css.py; collapsed in diffs, not hand-edited.
|
# 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/tailwind.css linguist-generated=true
|
||||||
web_interface/static/v3/plugin-frame.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
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.13"
|
||||||
|
|
||||||
# No dependencies: the script reads src/__init__.py and CHANGELOG.md only.
|
# No dependencies: the script reads src/__init__.py and CHANGELOG.md only.
|
||||||
- name: Assert the tag, CHANGELOG and src.__version__ agree
|
- name: Assert the tag, CHANGELOG and src.__version__ agree
|
||||||
|
|||||||
@@ -14,8 +14,14 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
plugin-safety:
|
plugin-safety:
|
||||||
name: Plugin safety harness + unit tests
|
name: Plugin safety harness + unit tests (Python ${{ matrix.python-version }})
|
||||||
runs-on: ubuntu-latest
|
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:
|
env:
|
||||||
# The bundled fixture plugin gives the harness at least one real plugin
|
# The bundled fixture plugin gives the harness at least one real plugin
|
||||||
# to render, and REQUIRE_PLUGINS turns "discovered zero plugins" into a
|
# to render, and REQUIRE_PLUGINS turns "discovered zero plugins" into a
|
||||||
@@ -29,7 +35,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: pip
|
cache: pip
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -43,8 +49,13 @@ jobs:
|
|||||||
pytest --no-cov test/plugins/
|
pytest --no-cov test/plugins/
|
||||||
|
|
||||||
unit-tests:
|
unit-tests:
|
||||||
name: Core unit tests
|
name: Core unit tests (Python ${{ matrix.python-version }})
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
@@ -52,7 +63,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: pip
|
cache: pip
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -84,7 +95,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.13"
|
||||||
cache: pip
|
cache: pip
|
||||||
|
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
@@ -123,7 +134,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.13"
|
||||||
|
|
||||||
# Downloads the pinned standalone Tailwind CLI (SHA-256 checked; no
|
# Downloads the pinned standalone Tailwind CLI (SHA-256 checked; no
|
||||||
# Node), rebuilds static/v3/tailwind.css and plugin-frame.css from the
|
# 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
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.13"
|
||||||
cache: pip
|
cache: pip
|
||||||
|
|
||||||
# The runtime requirements are installed so mypy sees the real types of
|
# 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
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.13"
|
||||||
|
|
||||||
# Stdlib only; exits 0 whatever it finds.
|
# Stdlib only; exits 0 whatever it finds.
|
||||||
- name: Report method-family drift across the nine scoreboards
|
- name: Report method-family drift across the nine scoreboards
|
||||||
|
|||||||
+1432
-12
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).
|
- **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
|
### RGB Matrix Bonnet / HAT
|
||||||
- [Adafruit RGB Matrix Bonnet/HAT](https://www.adafruit.com/product/3211) – supports one “chain” of horizontally connected displays
|
- [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)*
|
- [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" />
|
<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" />
|
<img width="512" height="361" alt="Step 4 Trixie Lite 64" src="https://github.com/user-attachments/assets/3b8590ce-b810-4dfe-9253-26e0d4f8ed1e" />
|
||||||
|
|
||||||
|
|||||||
@@ -649,7 +649,9 @@ When nothing is running on demand, `data.state` is
|
|||||||
> on-demand machinery is internal — drive it through the REST endpoints
|
> on-demand machinery is internal — drive it through the REST endpoints
|
||||||
> above (or the web UI buttons). The API handlers
|
> above (or the web UI buttons). The API handlers
|
||||||
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||||
> `web_interface/blueprints/api_v3/display.py`) write a request into the cache
|
> `web_interface/blueprints/api_v3/display.py`) send the request over the
|
||||||
|
> display's control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)).
|
||||||
|
> Only when the socket cannot carry it do they write it into the cache
|
||||||
> manager under the `display_on_demand_request` key, which
|
> manager under the `display_on_demand_request` key, which
|
||||||
> `DisplayController._poll_on_demand_requests()`
|
> `DisplayController._poll_on_demand_requests()`
|
||||||
> (`src/display_controller.py`) picks up. A separate
|
> (`src/display_controller.py`) picks up. A separate
|
||||||
@@ -747,8 +749,14 @@ keys helps troubleshoot stuck states.
|
|||||||
"timestamp": 1234567890.123
|
"timestamp": 1234567890.123
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
**Purpose:** Communication from web interface to display controller
|
**Purpose:** Communication from web interface to display controller, as the
|
||||||
**When Set:** API endpoint receives request
|
fallback when the control socket cannot carry the request (deprecated; it
|
||||||
|
will be removed in a later release)
|
||||||
|
**When Set:** API endpoint receives a request and the display's control
|
||||||
|
socket is unavailable (display stopped, or older than the socket or the
|
||||||
|
command); some plugins also write it directly
|
||||||
|
**Read:** once a second while the display serves the control socket (0.25 s
|
||||||
|
without it), and only when the file changed since the last look
|
||||||
**Auto-Cleared:** After processing or 1 hour TTL
|
**Auto-Cleared:** After processing or 1 hour TTL
|
||||||
|
|
||||||
**2. display_on_demand_config** (No TTL)
|
**2. display_on_demand_config** (No TTL)
|
||||||
|
|||||||
+32
-19
@@ -42,27 +42,32 @@ each other. They share three things:
|
|||||||
| State | Where | Written by | Read by |
|
| State | Where | Written by | Read by |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 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 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 (fallback) | cache `display_on_demand_request` | web, when the socket fails; four plugins write it directly | display: `_poll_on_demand_requests()` |
|
| On-demand request (fallback) | cache `display_on_demand_request` | web, only when the socket could not carry the request (`should_fall_back`); four plugins write it directly | display: `_poll_on_demand_requests()`, a `stat()` every 1 s while the socket is up (0.25 s without), read only when the file changed |
|
||||||
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
|
| 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` |
|
| 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/*` |
|
| 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`; cache `plugin_error_clear_request` as the fallback | web: `POST /api/v3/errors/clear` | display: applied before the socket answers; the mailbox on the error publisher's 5 s tick, read only when the file changed |
|
||||||
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
| 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` |
|
| 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 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 |
|
| 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 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, while a preview is open | display: writes full-rate snapshots only while it is fresh |
|
| 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` |
|
| 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 |
|
| 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
|
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 routes
|
(`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; when that
|
send the command over the display's control socket and get an ack; only when
|
||||||
fails (a stopped display, one older than the socket) they write the mailbox
|
the socket could not carry it (a stopped display, one older than the socket
|
||||||
instead, which the display reads every `ON_DEMAND_POLL_INTERVAL` (0.25s), from
|
or the command) do they write the mailbox instead. A display that had the
|
||||||
its dwell sleep, its render loops and Vegas's interrupt check as well as the
|
request and refused it is answered with the error, not posted a mailbox
|
||||||
main loop. Both ways end in the same handler, `_handle_on_demand_request()`.
|
copy. The display looks at the mailbox every
|
||||||
|
`MAILBOX_POLL_INTERVAL_WITH_SOCKET` (1 s) while it serves the socket, and
|
||||||
|
every `ON_DEMAND_POLL_INTERVAL` (0.25 s) without one, from its dwell sleep,
|
||||||
|
its render loops and Vegas's interrupt check as well as the main loop; a
|
||||||
|
look is one `stat()` unless the file changed. Both ways end in the same
|
||||||
|
handler, `_handle_on_demand_request()`.
|
||||||
The socket's handlers only queue; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)
|
The socket's handlers only queue; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)
|
||||||
for the protocol, the permission model and the plan to retire the mailboxes.
|
for the protocol, the permission model and the plan to retire the mailboxes.
|
||||||
|
|
||||||
@@ -71,10 +76,10 @@ for the protocol, the permission model and the plan to retire the mailboxes.
|
|||||||
Only the display process imports plugin code, instantiates plugins and calls
|
Only the display process imports plugin code, instantiates plugins and calls
|
||||||
their lifecycle hooks (`update`, `display`, `on_config_change`, `on_enable`,
|
their lifecycle hooks (`update`, `display`, `on_config_change`, `on_enable`,
|
||||||
`on_disable`). The web process is metadata-only: it reads plugins as files
|
`on_disable`). The web process is metadata-only: it reads plugins as files
|
||||||
through `PluginCatalog`
|
-- manifests and directories through `PluginCatalog`
|
||||||
([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py))
|
([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py)),
|
||||||
-- manifests, config schemas (through `SchemaManager`), each plugin's
|
config schemas through `SchemaManager`, and each plugin's section of
|
||||||
section of `config.json`, and installed versions. The catalog keeps the
|
`config.json` through `ConfigManager`. The catalog keeps the
|
||||||
read-only method names of `PluginManager` and has nothing that can run a
|
read-only method names of `PluginManager` and has nothing that can run a
|
||||||
plugin (no `load_plugin`, `get_plugin` or `plugins`).
|
plugin (no `load_plugin`, `get_plugin` or `plugins`).
|
||||||
|
|
||||||
@@ -86,7 +91,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 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 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, 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;
|
`display_restart_required()` in `plugin_catalog.py` holds that last rule;
|
||||||
routes return it as `restart_required` (with the banner's wording in
|
routes return it as `restart_required` (with the banner's wording in
|
||||||
@@ -110,9 +116,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
|
**plugin web-entry contract** -- a declared entry point for plugin web code
|
||||||
-- replaces that function.
|
-- replaces that function.
|
||||||
|
|
||||||
Next stages: a **control socket** from the web process to the display
|
The **control socket** from the web process to the display
|
||||||
(reload one plugin, ask for its state) in place of `restart_required` and
|
([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) carries on-demand
|
||||||
the cache-key mailboxes, and the plugin web-entry contract above.
|
commands and reloads an updated plugin; its next stages stream the
|
||||||
|
display's state and retire the cache-key mailboxes. The plugin web-entry
|
||||||
|
contract above is still to come.
|
||||||
|
|
||||||
### Plugin state: desired, observed, and who owns it
|
### Plugin state: desired, observed, and who owns it
|
||||||
|
|
||||||
@@ -135,7 +143,9 @@ loaded and when. Nothing else keeps plugin state:
|
|||||||
`DisplayController` right after it creates the `PluginManager`, writes the
|
`DisplayController` right after it creates the `PluginManager`, writes the
|
||||||
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
|
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
|
||||||
(type, a redacted message of at most 200 characters, when, recoverable),
|
(type, a redacted message of at most 200 characters, when, recoverable),
|
||||||
`version` and `loaded_at`, plus `published_at`, `stale_after` and `running`.
|
`version`, `loaded_at` and `modes` (the display modes `DisplayController`
|
||||||
|
registered -- `plugin.modes` when the plugin computes them, else the
|
||||||
|
manifest's), plus `published_at`, `stale_after` and `running`.
|
||||||
The cache is on disk, usually the SD card, so it writes when something a
|
The cache is on disk, usually the SD card, so it writes when something a
|
||||||
reader sees changes -- throttled to once per 10 s -- and otherwise once a
|
reader sees changes -- throttled to once per 10 s -- and otherwise once a
|
||||||
minute as a heartbeat. RUNNING, which every `update()` passes through, is
|
minute as a heartbeat. RUNNING, which every `update()` passes through, is
|
||||||
@@ -151,6 +161,9 @@ truth cannot leak into a response. `/api/v3/plugins/installed` returns
|
|||||||
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
|
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
|
||||||
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
|
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
|
||||||
`/api/v3/plugins/state` returns the same beside the desired state.
|
`/api/v3/plugins/state` returns the same beside the desired state.
|
||||||
|
`PluginCatalog.get_plugin_display_modes` and `find_plugin_for_mode` prefer a
|
||||||
|
live view's `modes` to the manifest's `display_modes`, so `/display/modes`
|
||||||
|
and on-demand see modes a plugin generates from its config (#668).
|
||||||
|
|
||||||
**Reconciliation**
|
**Reconciliation**
|
||||||
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
|
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
|
||||||
@@ -286,7 +299,7 @@ and must not vouch for it.
|
|||||||
| Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) |
|
| Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) |
|
||||||
| Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `<id>` or `ledmatrix-<id>` |
|
| Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `<id>` or `ledmatrix-<id>` |
|
||||||
| Discovery, load, unload, scheduled updates (display process) | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
|
| Discovery, load, unload, scheduled updates (display process) | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
|
||||||
| Manifest, schema, config and version reads (web process) | [`plugin_catalog.py`](../src/plugin_system/plugin_catalog.py) (`PluginCatalog`; see [who runs plugins](#web-and-display-processes-who-runs-plugins)) |
|
| Manifest reads (web process) | [`plugin_catalog.py`](../src/plugin_system/plugin_catalog.py) (`PluginCatalog`; see [who runs plugins](#web-and-display-processes-who-runs-plugins)) |
|
||||||
| Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) |
|
| Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) |
|
||||||
| Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) |
|
| Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) |
|
||||||
| Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) |
|
| Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) |
|
||||||
|
|||||||
+273
-101
@@ -2,61 +2,73 @@
|
|||||||
|
|
||||||
Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it (see [How to re-run](#how-to-re-run)).
|
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-10-01, core 3.7.0
|
- Scanned: 2026-10-05, core 3.8.2
|
||||||
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4de1d134), 46 plugins
|
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 662fb86f), 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)
|
- 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.**
|
**45 deprecated methods: 31 unused, 3 still used, 11 need review.**
|
||||||
|
|
||||||
Counted per plugin: a *call* is `<receiver>.method` on an object named like the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot tell. *Internal* hits sit inside another deprecated core method and go with it. *Unrelated* hits are a different class's own method with the same name (a name collision), and never block removal; neither do hits in test files.
|
Counted per plugin: a *call* is `<receiver>.method` on an object named like the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot tell. *Internal* hits sit inside another deprecated core method and go with it. *Unrelated* hits are a different class's own method with the same name (a name collision), and never block removal; neither do hits in test files.
|
||||||
|
|
||||||
| Method | Removal | Core | Plugins (calls / overrides) | Name collisions & tests | Verdict |
|
| Method | Removal | Core | Plugins (calls / overrides) | Name collisions & tests | Verdict |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| `CacheManager.has_data_changed` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `BackgroundDataService.get_result` | 3.10.0 | core tests (15 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.update_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `BackgroundDataService.is_request_complete` | 3.10.0 | core tests (11 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.setup_persistent_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `BackgroundDataService.get_request_status` | 3.10.0 | core tests (2 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.get_sport_live_interval` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `BaseOddsManager.get_odds_for_games` | 3.10.0 | core tests (3 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.get_sport_key_from_cache_key` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `BaseOddsManager.format_odds_summary` | 3.10.0 | core tests (5 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.get_background_cached_data` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `CacheManager.load_cache` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.is_background_data_available` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `CacheManager.generate_sport_cache_key` | 3.10.0 | core tests (2 test calls, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.record_cache_hit` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.fetch_espn_scoreboard` | 3.10.0 | core tests (1 test call, 4 test reviews) | — | football-scoreboard (1 test review); hockey-scoreboard (1 test review); ufc-scoreboard (3 test reviews) | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.record_cache_miss` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.fetch_espn_standings` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.record_fetch_time` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.fetch_espn_rankings` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.get_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.set_cache` | 3.10.0 | core tests (1 test call, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.log_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.get_cache` | 3.10.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.get_memory_cache_stats` | 3.8.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.set_rate_limit` | 3.10.0 | core tests (10 test calls, 3 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_sun` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.get_request_stats` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_cloud` | 3.8.0 | core (2 internals) | — | ledmatrix-weather (1 unrelated) | unused — safe to remove in 3.8.0 |
|
| `ConfigManager.rollback_config` | 3.10.0 | core (1 internal); core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_rain` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `ConfigManager.list_backups` | 3.10.0 | core (1 internal); core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_snow` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `ConfigManager.validate_config_file` | 3.10.0 | core (1 internal) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_weather_icon` | 3.8.0 | core (1 internal) | — | ledmatrix-weather (5 unrelateds) | unused — safe to remove in 3.8.0 |
|
| `ConfigManager.get_secret` | 3.10.0 | core tests (5 test calls, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_text_with_icons` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `ConfigManager.cleanup_orphaned_plugin_configs` | 3.10.0 | core tests (2 test calls, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.get_scrolling_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `ConfigManager.validate_all_plugin_configs` | 3.10.0 | core tests (1 test call, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.get_manager_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `DynamicTeamResolver.get_available_dynamic_teams` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.get_detected_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `DynamicTeamResolver.is_dynamic_team` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.unregister_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `FontManager.get_native_bdf_size` | 3.10.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.get_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `FontManager.measure_text` | 3.10.0 | core tests (5 test calls) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.set_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.fetch_teams_data` | 3.10.0 | core (2 internals) | — | — | still used by core — keep or migrate first |
|
||||||
| `FontManager.remove_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.extract_teams_from_data` | 3.10.0 | core (2 internals) | — | — | still used by core — keep or migrate first |
|
||||||
| `FontManager.get_overrides` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.download_missing_logos_for_league` | 3.10.0 | core (1 call, 1 internal); core tests (2 test calls) | — | — | still used by core — keep or migrate first |
|
||||||
| `FontManager.get_available_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.download_all_ncaa_football_logos` | 3.10.0 | core tests (2 test calls) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.get_size_tokens` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.download_all_missing_logos` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.get_performance_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.convert_image_to_rgba` | 3.10.0 | core (1 internal) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.get_font_catalog` | 3.8.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.convert_all_logos_to_rgba` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.add_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `BasePlugin.get_supported_vegas_modes` | 3.9.0 | core tests (2 test reviews) | — | — | unused — safe to remove in 3.9.0 |
|
||||||
| `FontManager.remove_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
|
||||||
| `FontManager.validate_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
|
||||||
| `BasePlugin.get_supported_vegas_modes` | 3.9.0 | core tests (2 test reviews) | blackjack (1 call, 1 override); calendar (1 override); olympics (1 override) | — | still used by blackjack, calendar, olympics — keep or migrate first |
|
|
||||||
| `BasePlugin.get_vegas_segment_width` | 3.9.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.9.0 |
|
| `BasePlugin.get_vegas_segment_width` | 3.9.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.9.0 |
|
||||||
| `PluginManager.get_enabled_plugins` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `PluginManager.get_all_plugins` | 3.10.0 | — | football-scoreboard (1 review) | football-scoreboard (2 test reviews); hockey-scoreboard (1 test review) | needs review: possible use in football-scoreboard |
|
||||||
|
| `PluginManager.get_plugin_info` | 3.10.0 | core (12 reviews, 1 internal); core tests (6 test calls, 9 test reviews) | — | — | needs review: possible use in core |
|
||||||
|
| `PluginManager.get_all_plugin_info` | 3.10.0 | core (3 reviews); core tests (2 test calls, 11 test reviews) | — | — | needs review: possible use in core |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | 3.10.0 | core (4 reviews); core tests (3 test calls, 5 test reviews) | — | — | needs review: possible use in core |
|
||||||
|
| `PluginManager.find_plugin_for_mode` | 3.10.0 | core (2 reviews) | — | — | needs review: possible use in core |
|
||||||
|
| `PluginStateManager.is_loaded` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core |
|
||||||
|
| `PluginStateManager.is_running` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core |
|
||||||
|
| `PluginStateManager.is_error` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core |
|
||||||
|
| `PluginStateManager.get_error_info` | 3.10.0 | core (1 internal); core tests (4 test calls) | — | — | needs review: possible use in core |
|
||||||
|
| `PluginStateManager.get_last_update` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core |
|
||||||
|
| `PluginStateManager.get_state_info` | 3.10.0 | core (1 internal); core tests (7 test reviews) | — | — | needs review: possible use in core |
|
||||||
|
| `PluginTestCase.setUp` | 3.10.0 | — | — | basketball-scoreboard (2 test unrelateds); cricket-scoreboard (1 test unrelated); hockey-scoreboard (1 test unrelated); nrl-scoreboard (1 test unrelated) | unused — safe to remove in 3.10.0 |
|
||||||
|
|
||||||
## Unused — safe to remove (36)
|
## Unused — safe to remove (31)
|
||||||
|
|
||||||
`CacheManager.has_data_changed`, `CacheManager.update_cache`, `CacheManager.setup_persistent_cache`, `CacheManager.get_sport_live_interval`, `CacheManager.get_sport_key_from_cache_key`, `CacheManager.get_background_cached_data`, `CacheManager.is_background_data_available`, `CacheManager.record_cache_hit`, `CacheManager.record_cache_miss`, `CacheManager.record_fetch_time`, `CacheManager.get_cache_metrics`, `CacheManager.log_cache_metrics`, `CacheManager.get_memory_cache_stats`, `DisplayManager.draw_sun`, `DisplayManager.draw_cloud`, `DisplayManager.draw_rain`, `DisplayManager.draw_snow`, `DisplayManager.draw_weather_icon`, `DisplayManager.draw_text_with_icons`, `DisplayManager.get_scrolling_stats`, `FontManager.get_manager_fonts`, `FontManager.get_detected_fonts`, `FontManager.unregister_plugin_fonts`, `FontManager.get_plugin_fonts`, `FontManager.set_override`, `FontManager.remove_override`, `FontManager.get_overrides`, `FontManager.get_available_fonts`, `FontManager.get_size_tokens`, `FontManager.get_performance_stats`, `FontManager.get_font_catalog`, `FontManager.add_font`, `FontManager.remove_font`, `FontManager.validate_font`, `BasePlugin.get_vegas_segment_width`, `PluginManager.get_enabled_plugins`
|
`BackgroundDataService.get_result`, `BackgroundDataService.is_request_complete`, `BackgroundDataService.get_request_status`, `BaseOddsManager.get_odds_for_games`, `BaseOddsManager.format_odds_summary`, `CacheManager.load_cache`, `CacheManager.generate_sport_cache_key`, `APIHelper.fetch_espn_scoreboard`, `APIHelper.fetch_espn_standings`, `APIHelper.fetch_espn_rankings`, `APIHelper.set_cache`, `APIHelper.get_cache`, `APIHelper.set_rate_limit`, `APIHelper.get_request_stats`, `ConfigManager.rollback_config`, `ConfigManager.list_backups`, `ConfigManager.validate_config_file`, `ConfigManager.get_secret`, `ConfigManager.cleanup_orphaned_plugin_configs`, `ConfigManager.validate_all_plugin_configs`, `DynamicTeamResolver.get_available_dynamic_teams`, `DynamicTeamResolver.is_dynamic_team`, `FontManager.get_native_bdf_size`, `FontManager.measure_text`, `LogoDownloader.download_all_ncaa_football_logos`, `LogoDownloader.download_all_missing_logos`, `LogoDownloader.convert_image_to_rgba`, `LogoDownloader.convert_all_logos_to_rgba`, `BasePlugin.get_supported_vegas_modes`, `BasePlugin.get_vegas_segment_width`, `PluginTestCase.setUp`
|
||||||
|
|
||||||
## Still used — keep or migrate first (1)
|
## Still used — keep or migrate first (3)
|
||||||
|
|
||||||
`BasePlugin.get_supported_vegas_modes`
|
`LogoDownloader.fetch_teams_data`, `LogoDownloader.extract_teams_from_data`, `LogoDownloader.download_missing_logos_for_league`
|
||||||
|
|
||||||
|
## Needs review (11)
|
||||||
|
|
||||||
|
`PluginManager.get_all_plugins`, `PluginManager.get_plugin_info`, `PluginManager.get_all_plugin_info`, `PluginManager.get_plugin_display_modes`, `PluginManager.find_plugin_for_mode`, `PluginStateManager.is_loaded`, `PluginStateManager.is_running`, `PluginStateManager.is_error`, `PluginStateManager.get_error_info`, `PluginStateManager.get_last_update`, `PluginStateManager.get_state_info`
|
||||||
|
|
||||||
## Every hit
|
## Every hit
|
||||||
|
|
||||||
@@ -64,94 +76,254 @@ File paths are relative to the plugin's directory (core: the repo root).
|
|||||||
|
|
||||||
| Method | Where | File:line | Kind | Code |
|
| Method | Where | File:line | Kind | Code |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:28 | unrelated | `def get_sport_live_interval(self, sport_key: str) -> int:` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:85 | test review | `result = service.get_result(req_id)` |
|
||||||
| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:60 | unrelated | `live_interval = self.get_sport_live_interval(sport_key)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:107 | test review | `seen["filed"] = service.get_result(result.request_id) is result` |
|
||||||
| `CacheManager.get_sport_live_interval` | core | src/cache_manager.py:785 | unrelated | `return self._strategy_component.get_sport_live_interval(sport_key)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:148 | test review | `result = service.get_result(req_id)` |
|
||||||
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache/cache_strategy.py:214 | unrelated | `def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:165 | test review | `result = service.get_result(req_id)` |
|
||||||
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:806 | unrelated | `return self._strategy_component.get_sport_key_from_cache_key(key)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:254 | test review | `assert service.get_result("unknown") is None` |
|
||||||
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:816 | unrelated | `sport_key = self._strategy_component.get_sport_key_from_cache_key(key)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:391 | test review | `assert service.get_result(rid).cached is True` |
|
||||||
| `CacheManager.record_cache_hit` | core | src/cache_manager.py:869 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_hit('background')` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_fetch_dedupe.py:198 | test review | `assert service.get_result(req).success is True` |
|
||||||
| `CacheManager.record_cache_miss` | core | src/cache_manager.py:876 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_miss('background')` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:85 | test review | `result = service.get_result(req_id)` |
|
||||||
| `CacheManager.record_fetch_time` | core | src/cache/cache_metrics.py:67 | unrelated | `def record_fetch_time(self, duration: float) -> None:` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:117 | test review | `stored = service.get_result(req_id)` |
|
||||||
| `CacheManager.record_fetch_time` | core | src/cache_manager.py:922 | unrelated | `self._metrics_component.record_fetch_time(duration)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:144 | test review | `assert service.get_result(req_id).data == PAYLOAD` |
|
||||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:43 | test call | `stats = cm.get_memory_cache_stats()` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:154 | test review | `stored = service.get_result(req_id)` |
|
||||||
| `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` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:171 | test review | `assert service.get_result(req_id).data is None` |
|
||||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:68 | test call | `stats = cm.get_memory_cache_stats()` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:193 | test review | `assert service.get_result(req_id).data == PAYLOAD` |
|
||||||
| `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):` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:278 | test review | `stored = service.get_result(first)` |
|
||||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1359 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_fetch_service.py:703 | test review | `assert bds.get_result(request_id).success` |
|
||||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1374 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:145 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||||
| `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)):` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:162 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||||
| `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)):` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:194 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||||
| `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):` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:211 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||||
| `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):` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:246 | test review | `assert service.is_request_complete("r2") is False` |
|
||||||
| `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)` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:251 | test review | `assert service.is_request_complete("r3") is True` |
|
||||||
| `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:` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service_espn_ranges.py:91 | test review | `while not service.is_request_complete(request_id) and time.time() < deadline:` |
|
||||||
| `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)` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service_espn_ranges.py:154 | test review | `while not service.is_request_complete(request_id) and time.time() < deadline:` |
|
||||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:84 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_fetch_dedupe.py:55 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1280 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_payload_release.py:67 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1559 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_fetch_service.py:701 | test review | `while not bds.is_request_complete(request_id) and time.monotonic() < deadline:` |
|
||||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1650 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
| `BackgroundDataService.get_request_status` | core tests | test/test_background_data_service.py:223 | test review | `assert service.get_request_status("nonexistent") is None` |
|
||||||
| `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):` |
|
| `BackgroundDataService.get_request_status` | core tests | test/test_background_fetch_dedupe.py:448 | test review | `assert service.get_request_status(rid) is FetchStatus.CANCELLED, (` |
|
||||||
| `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,` |
|
| `BaseOddsManager.get_odds_for_games` | core tests | test/test_base_odds_manager.py:356 | test review | `result = manager.get_odds_for_games(games)` |
|
||||||
| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:229 | test call | `assert fm.get_font_catalog() == fm.font_catalog` |
|
| `BaseOddsManager.get_odds_for_games` | core tests | test/test_base_odds_manager.py:374 | test review | `result = manager.get_odds_for_games(games)` |
|
||||||
|
| `BaseOddsManager.get_odds_for_games` | core tests | test/test_base_odds_manager.py:385 | test review | `result = manager.get_odds_for_games([game])` |
|
||||||
|
| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:322 | test review | `result = manager.format_odds_summary({` |
|
||||||
|
| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:329 | test review | `result = manager.format_odds_summary(FULL_EXTRACTED)` |
|
||||||
|
| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:333 | test review | `assert manager.format_odds_summary(None) == 'No odds available'` |
|
||||||
|
| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:336 | test review | `assert manager.format_odds_summary({}) == 'No odds available'` |
|
||||||
|
| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:339 | test review | `assert manager.format_odds_summary(` |
|
||||||
|
| `CacheManager.load_cache` | core tests | test/conftest.py:219 | test review | `mock.load_cache = Mock(side_effect=mock_get)` |
|
||||||
|
| `CacheManager.generate_sport_cache_key` | core tests | test/test_background_data_service.py:42 | test review | `m.generate_sport_cache_key.return_value = "test_key"` |
|
||||||
|
| `CacheManager.generate_sport_cache_key` | core tests | test/test_background_data_service.py:356 | test call | `expected = CacheManager.generate_sport_cache_key(None, sport, date_str)` |
|
||||||
|
| `CacheManager.generate_sport_cache_key` | core tests | test/test_background_data_service.py:367 | test call | `theirs = cm_module.CacheManager.generate_sport_cache_key(None, "nba")` |
|
||||||
|
| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:214 | test review | `result = helper.fetch_espn_scoreboard('football', 'nfl')` |
|
||||||
|
| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:230 | test review | `helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115')` |
|
||||||
|
| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:239 | test review | `helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115', cache_key='mine')` |
|
||||||
|
| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:251 | test review | `assert helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115') == {` |
|
||||||
|
| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_espn_scoreboard_cache.py:199 | test call | `helper.fetch_espn_scoreboard("football", "nfl", date=self.DAY)` |
|
||||||
|
| `APIHelper.fetch_espn_scoreboard` | football-scoreboard | test_espn_date_ranges.py:119 | test review | `helper = sys.modules[sports.fetch_espn_scoreboard.__module__]` |
|
||||||
|
| `APIHelper.fetch_espn_scoreboard` | hockey-scoreboard | test_espn_date_ranges.py:106 | test review | `helper = sys.modules[sports.fetch_espn_scoreboard.__module__]` |
|
||||||
|
| `APIHelper.fetch_espn_scoreboard` | ufc-scoreboard | test_every_bout_is_its_own_fight.py:103 | test review | `_real_fetch = sports.fetch_espn_scoreboard` |
|
||||||
|
| `APIHelper.fetch_espn_scoreboard` | ufc-scoreboard | test_every_bout_is_its_own_fight.py:108 | test review | `sports.fetch_espn_scoreboard = lambda *a, **k: board(name)` |
|
||||||
|
| `APIHelper.fetch_espn_scoreboard` | ufc-scoreboard | test_every_bout_is_its_own_fight.py:113 | test review | `sports.fetch_espn_scoreboard = _real_fetch` |
|
||||||
|
| `APIHelper.fetch_espn_standings` | core tests | test/test_api_helper.py:258 | test review | `helper.fetch_espn_standings('football', 'nfl')` |
|
||||||
|
| `APIHelper.fetch_espn_rankings` | core tests | test/test_api_helper.py:269 | test review | `helper.fetch_espn_rankings('football', 'college-football')` |
|
||||||
|
| `APIHelper.set_cache` | core tests | test/test_api_helper.py:128 | test review | `helper.set_cache('k', {'a': 1}, ttl=42)` |
|
||||||
|
| `APIHelper.set_cache` | core tests | test/test_api_helper.py:350 | test call | `assert helper.set_cache('k', {'a': 1}) is None` |
|
||||||
|
| `APIHelper.get_cache` | core tests | test/test_api_helper.py:348 | test call | `assert helper.get_cache('k') is None` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:42 | test call | `helper.set_rate_limit(0)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:57 | test review | `helper.set_rate_limit(5)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:72 | test review | `helper.set_rate_limit(5)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:91 | test review | `helper.set_rate_limit(5)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:150 | test call | `helper.set_rate_limit(0)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:305 | test call | `helper.set_rate_limit(0)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:313 | test call | `helper.set_rate_limit(0)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:326 | test call | `helper.set_rate_limit(0)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:334 | test call | `helper.set_rate_limit(0)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:346 | test call | `helper.set_rate_limit(0)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_espn_scoreboard_cache.py:197 | test call | `helper.set_rate_limit(0)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_fetch_service.py:726 | test call | `helper.set_rate_limit(0)` |
|
||||||
|
| `APIHelper.set_rate_limit` | core tests | test/test_fetch_service.py:1143 | test call | `helper.set_rate_limit(0)` |
|
||||||
|
| `ConfigManager.rollback_config` | core | src/config_manager.py:191 | internal (in `ConfigManager.rollback_config`) | `success = atomic_mgr.rollback_config(backup_version)` |
|
||||||
|
| `ConfigManager.rollback_config` | core | src/config_manager_atomic.py:297 | unrelated | `def rollback_config(self, backup_version: Optional[str] = None) -> bool:` |
|
||||||
|
| `ConfigManager.rollback_config` | core tests | test/test_config_durable_writes.py:345 | test review | `assert manager.rollback_config()` |
|
||||||
|
| `ConfigManager.list_backups` | core | src/config_manager.py:212 | internal (in `ConfigManager.list_backups`) | `return atomic_mgr.list_backups()` |
|
||||||
|
| `ConfigManager.list_backups` | core | src/config_manager_atomic.py:334 | unrelated | `def list_backups(self) -> List[BackupInfo]:` |
|
||||||
|
| `ConfigManager.list_backups` | core | src/config_manager_atomic.py:309 | unrelated | `backups = self.list_backups()` |
|
||||||
|
| `ConfigManager.list_backups` | core tests | test/test_config_durable_writes.py:323 | test review | `assert [b.path for b in manager.list_backups()] == [` |
|
||||||
|
| `ConfigManager.validate_config_file` | core | src/config_manager.py:226 | internal (in `ConfigManager.validate_config_file`) | `return atomic_mgr.validate_config_file(config_path)` |
|
||||||
|
| `ConfigManager.validate_config_file` | core | src/config_manager_atomic.py:426 | unrelated | `def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:` |
|
||||||
|
| `ConfigManager.get_secret` | core tests | test/conftest.py:246 | test review | `mock.get_secret = Mock(side_effect=mock_get_secret)` |
|
||||||
|
| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:317 | test call | `assert manager.get_secret("api_key") == "secret123"` |
|
||||||
|
| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:318 | test call | `assert manager.get_secret("token") == "token456"` |
|
||||||
|
| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:319 | test call | `assert manager.get_secret("nonexistent") is None` |
|
||||||
|
| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:325 | test call | `assert manager.get_secret("api_key") is None` |
|
||||||
|
| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:337 | test call | `assert manager.get_secret("api_key") is None` |
|
||||||
|
| `ConfigManager.cleanup_orphaned_plugin_configs` | core tests | test/test_config_manager.py:447 | test call | `removed = manager.cleanup_orphaned_plugin_configs(["plugin1", "plugin2"])` |
|
||||||
|
| `ConfigManager.cleanup_orphaned_plugin_configs` | core tests | test/test_core_config_key_adopters.py:80 | test review | `removed = manager.cleanup_orphaned_plugin_configs(['installed'])` |
|
||||||
|
| `ConfigManager.cleanup_orphaned_plugin_configs` | core tests | test/test_web_auth.py:616 | test call | `config_manager.cleanup_orphaned_plugin_configs([])` |
|
||||||
|
| `ConfigManager.validate_all_plugin_configs` | core tests | test/test_core_config_key_adopters.py:91 | test review | `results = manager.validate_all_plugin_configs(schema_manager)` |
|
||||||
|
| `ConfigManager.validate_all_plugin_configs` | core tests | test/test_retired_plugin_config_keys.py:112 | test call | `results = config_manager.validate_all_plugin_configs(schema_manager)` |
|
||||||
|
| `FontManager.get_native_bdf_size` | core tests | test/test_font_manager.py:104 | test call | `assert fm.get_native_bdf_size("five_by_seven") == 7` |
|
||||||
|
| `FontManager.get_native_bdf_size` | core tests | test/test_font_manager.py:107 | test call | `assert fm.get_native_bdf_size("press_start") is None` |
|
||||||
|
| `FontManager.get_native_bdf_size` | core tests | test/test_font_manager.py:110 | test call | `assert fm.get_native_bdf_size("no-such-family") is None` |
|
||||||
|
| `FontManager.measure_text` | core tests | test/test_font_manager.py:116 | test call | `width, height, baseline = fm.measure_text("SCORE", font)` |
|
||||||
|
| `FontManager.measure_text` | core tests | test/test_font_manager.py:119 | test call | `assert fm.measure_text("SCORE", font) == (width, height, baseline)` |
|
||||||
|
| `FontManager.measure_text` | core tests | test/test_font_manager.py:124 | test call | `short, _, _ = fm.measure_text("AB", font)` |
|
||||||
|
| `FontManager.measure_text` | core tests | test/test_font_manager.py:125 | test call | `long, _, _ = fm.measure_text("ABCD", font)` |
|
||||||
|
| `FontManager.measure_text` | core tests | test/test_font_manager.py:132 | test call | `fm.measure_text("X", font)` |
|
||||||
|
| `LogoDownloader.fetch_teams_data` | core | src/logo_downloader.py:639 | internal (in `LogoDownloader.download_missing_logos_for_league`) | `data = self.fetch_teams_data(league)` |
|
||||||
|
| `LogoDownloader.fetch_teams_data` | core | src/logo_downloader.py:695 | internal (in `LogoDownloader.download_all_ncaa_football_logos`) | `data = self.fetch_teams_data(league)` |
|
||||||
|
| `LogoDownloader.extract_teams_from_data` | core | src/logo_downloader.py:645 | internal (in `LogoDownloader.download_missing_logos_for_league`) | `teams = self.extract_teams_from_data(data, league)` |
|
||||||
|
| `LogoDownloader.extract_teams_from_data` | core | src/logo_downloader.py:701 | internal (in `LogoDownloader.download_all_ncaa_football_logos`) | `teams = self.extract_teams_from_data(data, league)` |
|
||||||
|
| `LogoDownloader.download_missing_logos_for_league` | core | src/logo_downloader.py:786 | internal (in `LogoDownloader.download_all_missing_logos`) | `downloaded, failed = self.download_missing_logos_for_league(league, force_download)` |
|
||||||
|
| `LogoDownloader.download_missing_logos_for_league` | core | src/logo_downloader.py:1034 | call | `return downloader.download_missing_logos_for_league(league, force_download)` |
|
||||||
|
| `LogoDownloader.download_missing_logos_for_league` | core tests | test/test_logo_downloader.py:292 | test call | `downloader.download_missing_logos_for_league("nfl")` |
|
||||||
|
| `LogoDownloader.download_missing_logos_for_league` | core tests | test/test_logo_downloader.py:304 | test call | `downloader.download_missing_logos_for_league("nfl")` |
|
||||||
|
| `LogoDownloader.download_all_ncaa_football_logos` | core tests | test/test_logo_downloader.py:319 | test call | `downloader.download_all_ncaa_football_logos()` |
|
||||||
|
| `LogoDownloader.download_all_ncaa_football_logos` | core tests | test/test_logo_downloader.py:332 | test call | `downloader.download_all_ncaa_football_logos()` |
|
||||||
|
| `LogoDownloader.convert_image_to_rgba` | core | src/logo_downloader.py:897 | internal (in `LogoDownloader.convert_all_logos_to_rgba`) | `if self.convert_image_to_rgba(logo_file):` |
|
||||||
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:356 | test review | `assert plugin.get_supported_vegas_modes() == [` |
|
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:356 | test review | `assert plugin.get_supported_vegas_modes() == [` |
|
||||||
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:358 | test review | `assert plugin.get_supported_vegas_modes()` |
|
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:358 | test review | `assert plugin.get_supported_vegas_modes()` |
|
||||||
| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:732 | override | `def get_supported_vegas_modes(self):` |
|
|
||||||
| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:695 | call | `if mode in self.get_supported_vegas_modes():` |
|
|
||||||
| `BasePlugin.get_supported_vegas_modes` | calendar | manager.py:875 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` |
|
|
||||||
| `BasePlugin.get_supported_vegas_modes` | olympics | manager.py:624 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` |
|
|
||||||
| `BasePlugin.get_vegas_segment_width` | core tests | test/test_vegas_participation.py:359 | test review | `assert plugin.get_vegas_segment_width() == 2` |
|
| `BasePlugin.get_vegas_segment_width` | core tests | test/test_vegas_participation.py:359 | test review | `assert plugin.get_vegas_segment_width() == 2` |
|
||||||
|
| `PluginManager.get_all_plugins` | core | src/plugin_system/testing/mocks.py:218 | unrelated | `def get_all_plugins(self) -> Dict[str, Any]:` |
|
||||||
|
| `PluginManager.get_all_plugins` | football-scoreboard | emulator_demo.py:68 | review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` |
|
||||||
|
| `PluginManager.get_all_plugins` | football-scoreboard | test_dynamic_duration.py:64 | test review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` |
|
||||||
|
| `PluginManager.get_all_plugins` | football-scoreboard | test_football_plugin.py:74 | test review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` |
|
||||||
|
| `PluginManager.get_all_plugins` | hockey-scoreboard | test_hockey_emulator.py:99 | test review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | src/plugin_system/plugin_catalog.py:120 | unrelated | `def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | src/plugin_system/plugin_catalog.py:133 | unrelated | `return [info for info in (self.get_plugin_info(pid) for pid in ids) if info]` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | src/plugin_system/plugin_manager.py:1001 | internal (in `PluginManager.get_all_plugin_info`) | `return [info for info in [self.get_plugin_info(pid) for pid in pids] if info]` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_install.py:208 | review | `plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_registry.py:801 | unrelated | `def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True, force_refresh: bool = False) -> Optional[Dict]:` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:357 | review | `plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:362 | review | `plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:685 | review | `plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:691 | review | `plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | src/plugin_system/testing/mocks.py:223 | unrelated | `def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:216 | review | `remote_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id, fetch_latest_from_github=True)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:334 | review | `plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:545 | review | `elif not api_v3.plugin_store_manager.get_plugin_info(plugin_id):` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:602 | review | `elif not api_v3.plugin_store_manager.get_plugin_info(plugin_id):` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/pages_v3.py:224 | review | `info = pages_v3.plugin_catalog.get_plugin_info(pid) or {}` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/pages_v3.py:722 | review | `plugin_info = pages_v3.plugin_catalog.get_plugin_info(plugin_id)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/pages_v3.py:727 | review | `plugin_info = pages_v3.plugin_catalog.get_plugin_info(plugin_id)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/test_api_v3_plugin_install_endpoints.py:111 | test review | `manager.get_plugin_info.return_value = None` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/test_api_v3_plugin_install_endpoints.py:119 | test review | `manager.get_plugin_info.return_value = {"id": "clock"}` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/test_pages_v3_path_guards.py:47 | test call | `plugin_manager.get_plugin_info.return_value = {"name": "Weather", "version": "1.0.0"}` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/test_registry_id_resolution.py:81 | test review | `_ids(store.get_plugin_info("ledmatrix-weather", fetch_latest_from_github=False))` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/test_store_manager_caches.py:612 | test review | `info = self.sm.get_plugin_info("foo", fetch_latest_from_github=True, force_refresh=True)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/test_store_non_plugin_entries.py:37 | test review | `store.get_plugin_info = MagicMock(return_value=dict(SKIN))` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:86 | test review | `api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:118 | test review | `api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:178 | test call | `plugin_manager.get_plugin_info.return_value = {"id": "weather", "name": "Weather"}` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_config_form_defaults.py:119 | test call | `pm.get_plugin_info.return_value = {"name": "Demo", "version": "1.0.0"}` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_config_schema_expansion.py:88 | test call | `pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_widget_route.py:226 | test call | `pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_widget_route.py:228 | test call | `pm.get_plugin_info.return_value["version"] = version` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_update_all_plugins.py:41 | test review | `sm.get_plugin_info.return_value = None` |
|
||||||
|
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_web_process_runs_no_plugin_code.py:132 | test review | `store.get_plugin_info.return_value = None` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core | src/plugin_system/plugin_catalog.py:129 | unrelated | `def get_all_plugin_info(self) -> List[Dict[str, Any]]:` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core | web_interface/blueprints/api_v3/plugins.py:68 | review | `all_plugin_info = api_v3.plugin_catalog.get_all_plugin_info()` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core | web_interface/blueprints/pages_v3.py:209 | review | `pi.get('id') for pi in pages_v3.plugin_catalog.get_all_plugin_info()` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core | web_interface/blueprints/pages_v3.py:559 | review | `infos = sorted(pages_v3.plugin_catalog.get_all_plugin_info(),` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_api_v3_installed_display_modes.py:30 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_api_v3_installed_plugin_icon.py:24 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_installed_list_registry_offline.py:71 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_onboarding_checklist.py:68 | test review | `mock_pm.get_all_plugin_info.return_value = []` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_plugin_manager_load_failures.py:78 | test call | `infos = {i["id"]: i for i in pm.get_all_plugin_info()}` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_plugin_runtime_snapshot.py:422 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_vegas_participation.py:447 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_api.py:551 | test review | `mock_plugin_catalog.get_all_plugin_info.return_value = [` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_api.py:570 | test review | `mock_plugin_catalog.get_all_plugin_info.return_value = [` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_api.py:592 | test review | `mock_plugin_catalog.get_all_plugin_info.return_value = [` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:61 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:195 | test call | `plugin_manager.get_all_plugin_info.assert_not_called()` |
|
||||||
|
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_smoke.py:95 | test review | `mock_pm.get_all_plugin_info.return_value = [` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core | src/plugin_system/plugin_catalog.py:175 | unrelated | `def get_plugin_display_modes(self, plugin_id: str) -> List[str]:` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/api_v3/display.py:197 | review | `plugin_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id) or [plugin_id]` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/api_v3/display.py:267 | review | `modes = api_v3.plugin_catalog.get_plugin_display_modes(resolved_plugin)` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/api_v3/plugins.py:157 | review | `declared_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id)` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/pages_v3.py:565 | review | `modes = pages_v3.plugin_catalog.get_plugin_display_modes(pid) or [pid]` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_display_modes.py:40 | test call | `pm.get_plugin_display_modes = MagicMock(` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_display_modes.py:100 | test call | `pm.get_plugin_display_modes = MagicMock(return_value=[])` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_display_modes.py:122 | test call | `pm.get_plugin_display_modes = MagicMock(` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_installed_display_modes.py:31 | test review | `api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=declared_modes)` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_installed_display_modes.py:39 | test review | `api.plugin_catalog.get_plugin_display_modes.assert_any_call('football-scoreboard')` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core tests | test/test_installed_list_registry_offline.py:75 | test review | `api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=[])` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core tests | test/test_onboarding_checklist.py:69 | test review | `mock_pm.get_plugin_display_modes.side_effect = lambda pid: []` |
|
||||||
|
| `PluginManager.get_plugin_display_modes` | core tests | test/test_web_smoke.py:99 | test review | `mock_pm.get_plugin_display_modes.side_effect = (` |
|
||||||
|
| `PluginManager.find_plugin_for_mode` | core | src/plugin_system/plugin_catalog.py:186 | unrelated | `def find_plugin_for_mode(self, mode: str) -> Optional[str]:` |
|
||||||
|
| `PluginManager.find_plugin_for_mode` | core | web_interface/blueprints/api_v3/display.py:271 | review | `resolved_plugin = api_v3.plugin_catalog.find_plugin_for_mode(resolved_mode)` |
|
||||||
|
| `PluginManager.find_plugin_for_mode` | core | web_interface/blueprints/api_v3/display.py:276 | review | `resolved_plugin = api_v3.plugin_catalog.find_plugin_for_mode(resolved_mode)` |
|
||||||
|
| `PluginStateManager.is_loaded` | core | src/plugin_system/plugin_state.py:299 | internal (in `PluginStateManager.get_state_info`) | `'is_loaded': self.is_loaded(plugin_id),` |
|
||||||
|
| `PluginStateManager.is_running` | core | src/plugin_system/plugin_state.py:301 | internal (in `PluginStateManager.get_state_info`) | `'is_running': self.is_running(plugin_id),` |
|
||||||
|
| `PluginStateManager.is_error` | core | src/plugin_system/plugin_state.py:302 | internal (in `PluginStateManager.get_state_info`) | `'is_error': self.is_error(plugin_id),` |
|
||||||
|
| `PluginStateManager.get_error_info` | core | src/plugin_system/plugin_state.py:305 | internal (in `PluginStateManager.get_state_info`) | `'error_info': self.get_error_info(plugin_id),` |
|
||||||
|
| `PluginStateManager.get_error_info` | core tests | test/test_async_plugin_updates.py:236 | test call | `error = pm.state_manager.get_error_info(plugin_id)` |
|
||||||
|
| `PluginStateManager.get_error_info` | core tests | test/test_plugin_hang_containment.py:191 | test call | `error_info = pm.state_manager.get_error_info('hung')` |
|
||||||
|
| `PluginStateManager.get_error_info` | core tests | test/test_sports_sunset_matrix.py:212 | test call | `f"{manager.state_manager.get_error_info(plugin_id)}"` |
|
||||||
|
| `PluginStateManager.get_error_info` | core tests | test/test_sports_sunset_matrix.py:285 | test call | `info = manager.state_manager.get_error_info(plugin_id)` |
|
||||||
|
| `PluginStateManager.get_last_update` | core | src/plugin_system/plugin_state.py:304 | internal (in `PluginStateManager.get_state_info`) | `'last_update': self.get_last_update(plugin_id),` |
|
||||||
|
| `PluginStateManager.get_state_info` | core | src/plugin_system/plugin_manager.py:987 | internal (in `PluginManager.get_plugin_info`) | `info['state'] = self.state_manager.get_state_info(plugin_id)` |
|
||||||
|
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:39 | test review | `info = manager.get_state_info("clock")` |
|
||||||
|
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:51 | test review | `info = manager.get_state_info("clock")` |
|
||||||
|
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:62 | test review | `assert manager.get_state_info("clock")["state_history_count"] == 101` |
|
||||||
|
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:63 | test review | `assert manager.get_state_info("weather")["state_history_count"] == 1` |
|
||||||
|
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:74 | test review | `info = manager.get_state_info("clock")` |
|
||||||
|
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:95 | test review | `info = m.get_state_info("clock")` |
|
||||||
|
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:130 | test review | `info = manager.get_state_info("clock")` |
|
||||||
|
|
||||||
## Sources scanned
|
## Sources scanned
|
||||||
|
|
||||||
| Source | Group | Python files | Hits |
|
| Source | Group | Python files | Hits |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| core | core | 172 | 20 |
|
| core | core | 188 | 50 |
|
||||||
| core tests | core-tests | 347 | 17 |
|
| core tests | core-tests | 416 | 176 |
|
||||||
| 7-segment-clock | monorepo | 3 | 0 |
|
| 7-segment-clock | monorepo | 3 | 0 |
|
||||||
| afl-scoreboard | monorepo | 35 | 0 |
|
| afl-scoreboard | monorepo | 36 | 0 |
|
||||||
| baseball-scoreboard | monorepo | 61 | 0 |
|
| baseball-scoreboard | monorepo | 71 | 0 |
|
||||||
| basketball-scoreboard | monorepo | 49 | 0 |
|
| basketball-scoreboard | monorepo | 51 | 2 |
|
||||||
| birdnet-go | monorepo | 2 | 0 |
|
| birdnet-go | monorepo | 3 | 0 |
|
||||||
| blackjack | monorepo | 7 | 2 |
|
| blackjack | monorepo | 7 | 0 |
|
||||||
| calendar | monorepo | 5 | 1 |
|
| calendar | monorepo | 5 | 0 |
|
||||||
| christmas-countdown | monorepo | 3 | 0 |
|
| christmas-countdown | monorepo | 3 | 0 |
|
||||||
| clock-simple | monorepo | 2 | 0 |
|
| clock-simple | monorepo | 2 | 0 |
|
||||||
| countdown | monorepo | 5 | 0 |
|
| countdown | monorepo | 5 | 0 |
|
||||||
| cricket-scoreboard | monorepo | 8 | 0 |
|
| cricket-scoreboard | monorepo | 8 | 1 |
|
||||||
| f1-scoreboard | monorepo | 15 | 0 |
|
| f1-scoreboard | monorepo | 15 | 0 |
|
||||||
| fantasy-blitz | monorepo | 13 | 0 |
|
| fantasy-blitz | monorepo | 13 | 0 |
|
||||||
| football-scoreboard | monorepo | 74 | 0 |
|
| football-scoreboard | monorepo | 78 | 4 |
|
||||||
| geochron | monorepo | 10 | 0 |
|
| geochron | monorepo | 10 | 0 |
|
||||||
| hello-world | monorepo | 2 | 0 |
|
| hello-world | monorepo | 2 | 0 |
|
||||||
| hockey-scoreboard | monorepo | 52 | 0 |
|
| hockey-scoreboard | monorepo | 57 | 3 |
|
||||||
| incoming-packages | monorepo | 8 | 0 |
|
| incoming-packages | monorepo | 8 | 0 |
|
||||||
| jellyfin-now-playing | monorepo | 4 | 0 |
|
| jellyfin-now-playing | monorepo | 4 | 0 |
|
||||||
| lacrosse-scoreboard | monorepo | 40 | 0 |
|
| lacrosse-scoreboard | monorepo | 41 | 0 |
|
||||||
| ledmatrix-elections | monorepo | 12 | 0 |
|
| ledmatrix-elections | monorepo | 12 | 0 |
|
||||||
| ledmatrix-flights | monorepo | 48 | 0 |
|
| ledmatrix-flights | monorepo | 48 | 0 |
|
||||||
| ledmatrix-leaderboard | monorepo | 9 | 0 |
|
| ledmatrix-leaderboard | monorepo | 10 | 0 |
|
||||||
| ledmatrix-music | monorepo | 11 | 0 |
|
| ledmatrix-music | monorepo | 12 | 0 |
|
||||||
| ledmatrix-stocks | monorepo | 7 | 0 |
|
| ledmatrix-stocks | monorepo | 7 | 0 |
|
||||||
| ledmatrix-weather | monorepo | 15 | 6 |
|
| ledmatrix-weather | monorepo | 15 | 0 |
|
||||||
| march-madness | monorepo | 4 | 0 |
|
| march-madness | monorepo | 4 | 0 |
|
||||||
| masters-tournament | monorepo | 10 | 0 |
|
| masters-tournament | monorepo | 10 | 0 |
|
||||||
| mqtt-notifications | monorepo | 4 | 0 |
|
| mqtt-notifications | monorepo | 4 | 0 |
|
||||||
| news | monorepo | 6 | 0 |
|
| news | monorepo | 7 | 0 |
|
||||||
| nfl-draft | monorepo | 3 | 0 |
|
| nfl-draft | monorepo | 3 | 0 |
|
||||||
| nfl-stat-leaders | monorepo | 8 | 0 |
|
| nfl-stat-leaders | monorepo | 8 | 0 |
|
||||||
| nrl-scoreboard | monorepo | 30 | 0 |
|
| nrl-scoreboard | monorepo | 31 | 1 |
|
||||||
| odds-ticker | monorepo | 9 | 0 |
|
| odds-ticker | monorepo | 10 | 0 |
|
||||||
| of-the-day | monorepo | 14 | 0 |
|
| of-the-day | monorepo | 14 | 0 |
|
||||||
| olympics | monorepo | 16 | 1 |
|
| olympics | monorepo | 16 | 0 |
|
||||||
| on-air | monorepo | 2 | 0 |
|
| on-air | monorepo | 3 | 0 |
|
||||||
| pomodoro-timer | monorepo | 3 | 0 |
|
| pomodoro-timer | monorepo | 3 | 0 |
|
||||||
| soccer-scoreboard | monorepo | 47 | 0 |
|
| soccer-scoreboard | monorepo | 50 | 0 |
|
||||||
| static-image | monorepo | 3 | 0 |
|
| static-image | monorepo | 4 | 0 |
|
||||||
| stock-news | monorepo | 3 | 0 |
|
| stock-news | monorepo | 4 | 0 |
|
||||||
| text-display | monorepo | 4 | 0 |
|
| text-display | monorepo | 4 | 0 |
|
||||||
| tide-display | monorepo | 3 | 0 |
|
| tide-display | monorepo | 3 | 0 |
|
||||||
| ufc-scoreboard | monorepo | 38 | 0 |
|
| ufc-scoreboard | monorepo | 40 | 3 |
|
||||||
| web-ui-info | monorepo | 2 | 0 |
|
| web-ui-info | monorepo | 2 | 0 |
|
||||||
| youtube-stats | monorepo | 5 | 0 |
|
| youtube-stats | monorepo | 5 | 0 |
|
||||||
| f1-live | third-party | 10 | 0 |
|
| f1-live | third-party | 10 | 0 |
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ The LEDMatrix emulator allows you to run and test LEDMatrix displays on your com
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### System Requirements
|
### System Requirements
|
||||||
- Python 3.10 or higher
|
- Python 3.11 or higher (3.11 and 3.13 are tested)
|
||||||
- Windows, macOS, or Linux
|
- Windows, macOS, or Linux
|
||||||
- At least 2GB RAM (4GB recommended)
|
- At least 2GB RAM (4GB recommended)
|
||||||
- Internet connection for plugin downloads
|
- Internet connection for plugin downloads
|
||||||
|
|
||||||
### Required Software
|
### Required Software
|
||||||
- Python 3.10+
|
- Python 3.11+
|
||||||
- pip (Python package manager)
|
- pip (Python package manager)
|
||||||
- Git (for plugin management)
|
- Git (for plugin management)
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ Current methods:
|
|||||||
| `measure_text(text, font)` | `(width, height, baseline)` |
|
| `measure_text(text, font)` | `(width, height, baseline)` |
|
||||||
| `get_font_height(font)` | Line height |
|
| `get_font_height(font)` | Line height |
|
||||||
| `register_plugin_fonts(plugin_id, font_manifest)` | Register a plugin's fonts (core calls it at load) |
|
| `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 |
|
| `clear_cache()` | Drop cached fonts and metrics |
|
||||||
| `font_catalog` (attribute) | Family name → file path |
|
| `font_catalog` (attribute) | Family name → file path |
|
||||||
|
|
||||||
@@ -218,5 +219,6 @@ Removed in 3.8.0, after logging a deprecation warning on first call since
|
|||||||
| `get_performance_stats()` | — |
|
| `get_performance_stats()` | — |
|
||||||
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
|
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
|
||||||
| `get_manager_fonts()`, `get_detected_fonts()` | — |
|
| `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 |
|
| `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)
|
- Power supply (5V, 4A minimum recommended)
|
||||||
- MicroSD card (16GB minimum)
|
- 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:**
|
**Network:**
|
||||||
- WiFi network (or Ethernet cable)
|
- WiFi network (or Ethernet cable)
|
||||||
- Computer with web browser on same network
|
- 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
|
There is no prebuilt SD card image — you install LEDMatrix onto stock
|
||||||
Raspberry Pi OS Lite yourself:
|
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
|
2. Connect the LED matrix to your Raspberry Pi, insert the card, and
|
||||||
power on
|
power on
|
||||||
3. SSH into the Pi and run the one-shot installer:
|
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)
|
[README Installation Steps / Quick Install](../README.md#installation-steps)
|
||||||
for full details
|
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:**
|
**Expected Behavior after install:**
|
||||||
- LED matrix will light up
|
- LED matrix will light up
|
||||||
- A fresh install ships only the bundled `starlark-apps` and
|
- A fresh install ships only the bundled `starlark-apps` and
|
||||||
|
|||||||
+547
-55
@@ -2,14 +2,23 @@
|
|||||||
|
|
||||||
The display process serves a Unix socket that the web interface uses to send
|
The display process serves a Unix socket that the web interface uses to send
|
||||||
it commands and get an answer back. It replaces the cache-file "mailboxes" on
|
it commands and get an answer back. It replaces the cache-file "mailboxes" on
|
||||||
the SD card one command at a time. Stage 1, described here, carries on-demand
|
the SD card one command at a time. Stage 1 carries on-demand start, stop and
|
||||||
start/stop/status. The file mailbox stays as a fallback for one release.
|
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
|
||||||
|
wrote to the SD card. Stage 4 makes the socket the only way a command goes
|
||||||
|
while it works: the web interface writes a mailbox only when the socket
|
||||||
|
cannot carry the request, `errors.clear` replaces the last command that
|
||||||
|
always went through a mailbox, and the display looks at the mailboxes once
|
||||||
|
a second, with a `stat()`. The file mailboxes and the cache keys stay as a
|
||||||
|
fallback for one release.
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Socket | `/run/ledmatrix/control.sock` (tmpfs) |
|
| Socket | `/run/ledmatrix/control.sock` (tmpfs) |
|
||||||
| Served by | the display process ([`src/ipc/server.py`](../src/ipc/server.py)), started by `DisplayController.run()` |
|
| 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` |
|
| 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 |
|
| 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 |
|
| Override | `LEDMATRIX_CONTROL_SOCKET=/some/path.sock` for both processes, or `=off` to disable it |
|
||||||
|
|
||||||
@@ -34,6 +43,11 @@ running, the socket does not exist, and the web interface knows right away.
|
|||||||
|
|
||||||
## Protocol (version 1)
|
## 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
|
**Framing.** One JSON object per line (newline-delimited JSON), UTF-8, at
|
||||||
most 64 KiB per line (`MAX_MESSAGE_BYTES`). Senders encode with
|
most 64 KiB per line (`MAX_MESSAGE_BYTES`). Senders encode with
|
||||||
`ensure_ascii`, so a newline never appears inside a message. A connection
|
`ensure_ascii`, so a newline never appears inside a message. A connection
|
||||||
@@ -71,6 +85,21 @@ one. Clients branch on `error.code`, never on the message text.
|
|||||||
| `on_demand.start` | `{plugin_id?, mode?, duration?, pinned?}` (at least one of `plugin_id` and `mode`) | ack | queued |
|
| `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.stop` | — | ack | queued |
|
||||||
| `on_demand.status` | — | `{on_demand: {...}, current_mode, display_active}` | answered directly |
|
| `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 `""`
|
`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
|
mean "until stopped". `pinned` must be a real boolean: the REST route has
|
||||||
@@ -78,28 +107,73 @@ already converted strings like `"false"` before it sends the command. The
|
|||||||
`on_demand` object in `on_demand.status` is the same dict the display
|
`on_demand` object in `on_demand.status` is the same dict the display
|
||||||
publishes to `display_on_demand_state`.
|
publishes to `display_on_demand_state`.
|
||||||
|
|
||||||
**Acknowledgements.** A queued command is *accepted*, not *done*.
|
`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
|
`{"accepted": true, "request_id": …}` means the command is waiting in the
|
||||||
render thread's queue, and the render thread will apply it at its next
|
render thread's queue. Since stage 2 the render thread waits on that queue
|
||||||
on-demand check. That is within one frame on a scrolling screen, 0.25 s
|
instead of sleeping, so it applies the command within one frame on every
|
||||||
during a dwell, and up to 1 s on a static screen, whose frame loop sleeps a
|
kind of screen (see below). Any outcome is published as before
|
||||||
second between frames. Except on a scrolling screen, where the mailbox waits
|
|
||||||
up to 0.25 s, these are the mailbox's delays too: stage 1 adds
|
|
||||||
acknowledgements, not speed. Any outcome is published as before
|
|
||||||
(`display_on_demand_state`, and `status`/`error` for a bad plugin or mode),
|
(`display_on_demand_state`, and `status`/`error` for a bad plugin or mode),
|
||||||
and it can be read with `on_demand.status`.
|
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
|
**Versions.** Every request carries `v`. For any command except `hello`, a
|
||||||
`v` the display does not speak gets `unsupported_version`. `hello` is checked
|
`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
|
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
|
sides share, so a client can find out what a display supports before it
|
||||||
relies on anything newer. Stage 1's client sends `v: 1` and falls back to the
|
relies on anything newer. The client sends `v: 1` and falls back to the
|
||||||
mailbox when the display refuses it. It does not send `hello` first, which
|
mailbox when the display refuses it. It does not send `hello` first, which
|
||||||
saves a round trip.
|
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. 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`,
|
**Error codes:** `bad_json`, `bad_request`, `message_too_large`,
|
||||||
`unsupported_version`, `unknown_command`, `invalid_args`, `busy` (queue full,
|
`unsupported_version`, `unknown_command`, `invalid_args`, `busy` (queue full,
|
||||||
or too many connections), `forbidden` (peer credentials refused), `internal`.
|
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:
|
Try it on a device:
|
||||||
|
|
||||||
@@ -107,34 +181,346 @@ Try it on a device:
|
|||||||
python3 - <<'EOF'
|
python3 - <<'EOF'
|
||||||
from src.ipc import client # run from the project directory
|
from src.ipc import client # run from the project directory
|
||||||
print(client.on_demand_status())
|
print(client.on_demand_status())
|
||||||
|
print(client.brightness_set(60))
|
||||||
EOF
|
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, `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
|
## How the display applies a command
|
||||||
|
|
||||||
The server's threads never touch rendering. A connection thread parses the
|
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:
|
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
|
- For a command that changes the panel, it puts a `QueuedCommand` on a
|
||||||
bounded queue (16 entries) and answers with the ack.
|
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
|
- For a query, it answers from a status snapshot the display provides
|
||||||
(`DisplayController._control_status`). The snapshot only reads attributes.
|
(`DisplayController._control_status`). The snapshot only reads attributes.
|
||||||
|
|
||||||
The render thread drains the queue in `_poll_on_demand_requests()`, the same
|
The render thread drains the queue in `_poll_on_demand_requests()`, the same
|
||||||
place it reads the mailbox, and hands each command to
|
place it reads the mailbox:
|
||||||
`_handle_on_demand_request()`, which is the mailbox's own handler. The two
|
|
||||||
paths share all of their code: activation, the processed-id guard, error
|
|
||||||
publishing, and resuming the rotation afterwards. The 0.25 s floor on the
|
|
||||||
mailbox read does not apply to the queue, because draining it costs no disk
|
|
||||||
read. A queued command also lets `_service_pending_changes()` skip its own
|
|
||||||
floor, so a long scrolling screen or a Vegas iteration takes the command at
|
|
||||||
its next frame.
|
|
||||||
|
|
||||||
**Exactly once.** A command and a mailbox write for the same request share
|
- An on-demand command goes to `_handle_on_demand_request()`, which is the
|
||||||
one `request_id`. If the client times out after the display queued the
|
mailbox's own handler. The two paths share all of their code: activation,
|
||||||
command and then also writes the mailbox, the display processes the request
|
the processed-id guard, error publishing, and resuming the rotation
|
||||||
once. The existing `on_demand_request_id` and processed-id checks drop the
|
afterwards.
|
||||||
second copy.
|
- `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.
|
||||||
|
|
||||||
|
The floor on the mailbox read (0.25 s, 1 s since stage 4) does not apply to the queue, because
|
||||||
|
draining it costs no disk read. A queued command also lets
|
||||||
|
`_service_pending_changes()` skip its own 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 does. 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. The mailbox
|
||||||
|
is slower on purpose (see "The mailboxes now"). 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.** Since stage 4 the web interface writes the mailbox only
|
||||||
|
when the display never had the request (see "When the web interface falls
|
||||||
|
back"), so a request goes one way or the other, never both. A command and a
|
||||||
|
mailbox write for the same request still share one `request_id`, and the
|
||||||
|
`on_demand_request_id` and processed-id checks still drop a second copy: an
|
||||||
|
older web interface (before stage 4) wrote the mailbox after a reply timed
|
||||||
|
out, too. The display takes such a copy out of the mailbox when it drops it.
|
||||||
|
|
||||||
|
## When the web interface falls back (stage 4)
|
||||||
|
|
||||||
|
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.should_fall_back()` is the one rule every route uses:
|
||||||
|
|
||||||
|
| What happened | Example reasons | Mailbox? | The route answers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| The display never had it | `no_socket`, `refused`, `disabled`, `unsupported`, a connect or send that timed out, `forbidden` / `busy` at the door, `invalid_request` (refused by the client itself) | yes | success, `transport: "mailbox"`, `socket_error` |
|
||||||
|
| A display too old to know it (the upgrade case) | `unknown_command`, `unsupported_version` | yes | as above |
|
||||||
|
| The display had it and failed | `busy` (queue full), `invalid_args`, `internal`, a timeout or hang-up after the send, `bad_response` | no | `503` (`400` for `invalid_args`), `socket_error` |
|
||||||
|
|
||||||
|
A display that had the request may have applied it (a reply that timed out),
|
||||||
|
or would refuse the mailbox copy as well (bad arguments), or is stuck and
|
||||||
|
would not read the mailbox either (a full queue). Writing the copy anyway
|
||||||
|
only turned that into a "success". An on-demand stop with `stop_service`
|
||||||
|
still stops the service, which ends on-demand whatever happened.
|
||||||
|
|
||||||
|
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 now
|
||||||
|
|
||||||
|
| Mailbox | Written by | Read by the display | While the socket is up |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `display_on_demand_request` | the web interface, only on fallback; plugins that predate `BasePlugin.request_on_demand()`, or run on a core without it | the render thread, `_poll_on_demand_requests()` | looked at every 1 s (`MAILBOX_POLL_INTERVAL_WITH_SOCKET`), 0.25 s without a socket |
|
||||||
|
| `plugin_error_clear_request` | the web interface, only on fallback | the error publisher's thread, every 5 s tick | unchanged rate |
|
||||||
|
|
||||||
|
A look is one `stat()` of the mailbox file (`CacheManager.file_signature`):
|
||||||
|
`(inode, mtime, size)`, and every write renames a new file into place, so a
|
||||||
|
new write always looks different. `MailboxWatch` reads the file only when
|
||||||
|
that changed since the last look, so a mailbox that holds nothing new, or
|
||||||
|
nothing at all, costs no open and no parse. A socket command never reads or
|
||||||
|
deletes the on-demand mailbox. A start already processed is taken out of
|
||||||
|
the mailbox instead of being re-read until it expires.
|
||||||
|
|
||||||
|
A request that comes through the on-demand mailbox while the socket is up
|
||||||
|
is logged once per writer (`came through the file mailbox although the
|
||||||
|
control socket is up`), which names the plugins that still write it.
|
||||||
|
|
||||||
|
### 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 mailbox-shaped 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 (typically
|
||||||
|
within 0.25 s). A plugin's stop ends only a session that plugin owns. The four
|
||||||
|
plugins that wrote the mailbox (birdnet-go, mqtt-notifications, on-air,
|
||||||
|
pomodoro-timer) use it where the core has it and write the mailbox
|
||||||
|
otherwise.
|
||||||
|
|
||||||
## Robustness
|
## Robustness
|
||||||
|
|
||||||
@@ -151,9 +537,15 @@ block the render loop or crash it:
|
|||||||
and the connection is closed, because the next message boundary cannot be
|
and the connection is closed, because the next message boundary cannot be
|
||||||
found. A client that disconnects mid-message is dropped silently. No
|
found. A client that disconnects mid-message is dropped silently. No
|
||||||
exception from a handler leaves the connection thread.
|
exception from a handler leaves the connection thread.
|
||||||
- **Full queue.** When the queue is full, the client gets `busy` and falls
|
- **Full queue.** When the queue is full, the client gets `busy`, and the web
|
||||||
back to the mailbox. A full queue means the render thread is stuck, and the
|
interface answers `503` rather than write the mailbox, which the stuck
|
||||||
systemd watchdog deals with that.
|
render thread would not read either. 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
|
- **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
|
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
|
umask's permissions. It removes a stale socket (a file that nothing is
|
||||||
@@ -162,7 +554,16 @@ block the render loop or crash it:
|
|||||||
process created.
|
process created.
|
||||||
- **Never fatal.** If the server cannot start (Windows, no `AF_UNIX`, a bind
|
- **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
|
failure, `LEDMATRIX_CONTROL_SOCKET=off`), it logs that and the display runs
|
||||||
as before. The web interface then uses the mailbox.
|
as before. The web interface then uses the mailbox, 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
|
## Security model
|
||||||
|
|
||||||
@@ -191,9 +592,15 @@ anything else in the group they share:
|
|||||||
and is disconnected. This covers a socket mode that someone loosened by
|
and is disconnected. This covers a socket mode that someone loosened by
|
||||||
hand.
|
hand.
|
||||||
|
|
||||||
The commands are deliberately narrow. Stage 1 can start or stop on-demand
|
The commands are deliberately narrow. They start or stop on-demand display,
|
||||||
display and read its state, which anyone who can reach the web UI can already
|
read its state, set the brightness, and reload a plugin the display is
|
||||||
do. Nothing on the socket runs a shell, writes a file, or names a path.
|
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
|
**Development.** A display that is not root and cannot write to
|
||||||
`/run/ledmatrix`, such as `python3 run.py -e` from a checkout, serves the
|
`/run/ledmatrix`, such as `python3 run.py -e` from a checkout, serves the
|
||||||
@@ -205,34 +612,77 @@ device never touches the live display.
|
|||||||
|
|
||||||
## Stage plan
|
## Stage plan
|
||||||
|
|
||||||
1. **On-demand, with acks (this stage).** Contract, server, client.
|
1. **On-demand, with acks (done, #706).** Contract, server, client.
|
||||||
`on_demand.start`/`stop`/`status`, `hello`, `ping`. The REST routes try the
|
`on_demand.start`/`stop`/`status`, `hello`, `ping`. The REST routes try the
|
||||||
socket first and report `transport: "socket" | "mailbox"` (plus
|
socket first and report `transport: "socket" | "mailbox"` (plus
|
||||||
`socket_error` on fallback). The mailbox is unchanged, and the plugins that
|
`socket_error` on fallback). The mailbox is unchanged, and the plugins that
|
||||||
write it directly (birdnet-go, mqtt-notifications, on-air, pomodoro-timer)
|
write it directly (birdnet-go, mqtt-notifications, on-air, pomodoro-timer)
|
||||||
keep working.
|
keep working.
|
||||||
2. **Commands that are restarts or polls today.**
|
2. **Commands that were restarts or polls (done).**
|
||||||
- `brightness.set`, transient and with no `config.json` write.
|
- 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
|
- `plugin.reload`, which replaces the `restart_required` answer from #688
|
||||||
with a live reload of the updated plugin on the render thread.
|
for a store update of an enabled plugin. `POST /api/v3/plugins/update`
|
||||||
- `config.reload`, which applies a saved config without waiting for the 2 s
|
answers `restart_required: false, reloaded: true` once the new code
|
||||||
mtime poll and acks which sections changed.
|
runs, and falls back to the restart banner (with `reload_error`)
|
||||||
- The dwell sleep and the static screen's 1 s frame sleep wait on the
|
otherwise.
|
||||||
queue instead of sleeping, so a command lands within milliseconds on
|
- `config.reload` was left out. Its only gain over the config watcher
|
||||||
every kind of screen. Under WSL, with a static plugin on screen, a stop
|
would be skipping the watcher's 2 s mtime poll, and the one setting
|
||||||
takes 1.0 s by either path today.
|
where those seconds show, brightness, now has its own command. Plugin
|
||||||
3. **A state stream.** A `subscribe` command that keeps the connection open
|
settings already reach the running plugin through the watcher, and the
|
||||||
and pushes events: mode changes, on-demand state, plugin runtime state and
|
"which sections changed" ack had no reader: the web interface knows
|
||||||
the heartbeat. It replaces the polled `display_current_state`,
|
what it saved. A reload from the socket thread would also run every
|
||||||
`plugin_runtime_snapshot` (#690) and `display-heartbeat.json` (#687) for
|
config subscriber on a second thread beside the watcher's.
|
||||||
readers that hold a connection. The web interface relays it to its
|
3. **A state stream (done).** `state.get` (a versioned snapshot) and
|
||||||
existing SSE stream. The files remain for one release for older readers.
|
`state.subscribe` (the snapshot, then pushed changes and keepalive ticks)
|
||||||
4. **Retire the mailboxes.** After a release in which every device has had the
|
carry the current mode, the on-demand state (including the outcome of an
|
||||||
socket, the web interface stops writing `display_on_demand_request`, and
|
acked on-demand command), the brightness, the plugin runtime snapshot and
|
||||||
the display stops polling it, logging the plugins that still write it so
|
the render loop's liveness, all served from memory (see "The state
|
||||||
they can move to an in-process `request_display()`. The other cache keys
|
stream"). The web interface's readers use it and fall back to the cache
|
||||||
used as messages (`plugin_error_clear_request` and the remaining
|
keys and the heartbeat file. `display_current_state` and
|
||||||
`display_*` keys) move to the socket or to tmpfs.
|
`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 (see "When the web interface falls back").
|
||||||
|
- `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 (see "The mailboxes now").
|
||||||
|
- 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 (next release).** Once every device has run a
|
||||||
|
display with stage 4, the web interface stops writing both mailboxes and
|
||||||
|
the display stops reading them. The four plugins that wrote
|
||||||
|
`display_on_demand_request` now have an in-process way to ask for the
|
||||||
|
screen (`BasePlugin.request_on_demand()` / `end_on_demand()`, see
|
||||||
|
"Plugins in the display process"); they keep the mailbox write only as
|
||||||
|
their fallback on older cores. The display also stops writing `display_current_state`,
|
||||||
|
`display_on_demand_state` and `plugin_runtime_snapshot` once the web
|
||||||
|
interface no longer falls back to them.
|
||||||
|
|
||||||
## Checking it on a device
|
## Checking it on a device
|
||||||
|
|
||||||
@@ -247,4 +697,46 @@ curl -s -X POST localhost:5000/api/v3/display/on-demand/start \
|
|||||||
If the response says `"transport": "mailbox"`, `socket_error` gives the
|
If the response says `"transport": "mailbox"`, `socket_error` gives the
|
||||||
reason. `no_socket` means the display is stopped or predates the socket.
|
reason. `no_socket` means the display is stopped or predates the socket.
|
||||||
`refused` usually means the web user is not in the socket's group, which
|
`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.
|
takes effect when the web service restarts after the user is added. A `503`
|
||||||
|
with `"transport": "socket"` means the display had the request and did not
|
||||||
|
take it (`busy`, `timeout`, ...): nothing was written to the 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|file mailbox"
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ and symlink the plugin directories you are working on into LEDMatrix's
|
|||||||
### 1. The plugin monorepo
|
### 1. The plugin monorepo
|
||||||
|
|
||||||
Clone ledmatrix-plugins into the same parent directory as LEDMatrix (the
|
Clone ledmatrix-plugins into the same parent directory as LEDMatrix (the
|
||||||
workspace file and `scripts/update_plugin_repos.py` look for
|
workspace file looks for `../ledmatrix-plugins` relative to the LEDMatrix
|
||||||
`../ledmatrix-plugins` relative to the LEDMatrix root):
|
root):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/Github
|
cd ~/Github
|
||||||
@@ -86,7 +86,7 @@ the plugin from there. See the
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/Github/LEDMatrix
|
cd ~/Github/LEDMatrix
|
||||||
python3 scripts/update_plugin_repos.py # git pull in ../ledmatrix-plugins
|
git -C ../ledmatrix-plugins pull # the sibling monorepo checkout
|
||||||
# or
|
# or
|
||||||
./scripts/dev/dev_plugin_setup.sh update # git pull in every linked checkout
|
./scripts/dev/dev_plugin_setup.sh update # git pull in every linked checkout
|
||||||
```
|
```
|
||||||
|
|||||||
+20
-1
@@ -33,6 +33,9 @@ in again (services pick them up on restart).
|
|||||||
| `/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) |
|
| `/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 |
|
| `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` | |
|
| `/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:
|
What keeps it that way at runtime:
|
||||||
|
|
||||||
@@ -86,6 +89,20 @@ password:
|
|||||||
- `journalctl -u ledmatrix.service *`, `-u ledmatrix *`, `-t ledmatrix *`,
|
- `journalctl -u ledmatrix.service *`, `-u ledmatrix *`, `-t ledmatrix *`,
|
||||||
tagged `NOEXEC`: journalctl opens a pager on a terminal, and a shell
|
tagged `NOEXEC`: journalctl opens a pager on a terminal, and a shell
|
||||||
escape from that pager would be a root 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`
|
### `/etc/sudoers.d/ledmatrix_wifi`
|
||||||
|
|
||||||
@@ -136,7 +153,9 @@ directory.
|
|||||||
| `safe_plugin_rm.sh`, `safe_pip_install.sh` | — | Called by the web interface through sudo | Not for manual use |
|
| `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
|
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
|
`./scripts/install/configure_wifi_permissions.sh` (WiFi rules and polkit) as
|
||||||
the web user, not with `sudo`.
|
the web user, not with `sudo`.
|
||||||
|
|
||||||
|
|||||||
@@ -488,6 +488,78 @@ working for the plugin itself. `get_vegas_segment_width()` read the
|
|||||||
`vegas_panel_count` config value, which has never affected Vegas — a card's
|
`vegas_panel_count` config value, which has never affected Vegas — a card's
|
||||||
width comes from `get_vegas_content()` and `vegas_width_pct`.
|
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 display reads it only once a second while the control
|
||||||
|
socket is up, and it will be removed in a future release (see
|
||||||
|
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5). A plugin that
|
||||||
|
must keep working on older cores checks for the method, and writes the
|
||||||
|
mailbox only when the method is missing or answers `None`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time, uuid
|
||||||
|
|
||||||
|
def _show_alert(self):
|
||||||
|
if hasattr(self, "request_on_demand") and self.request_on_demand(
|
||||||
|
mode="my_alert", duration=15):
|
||||||
|
return
|
||||||
|
# Older core, or no display in this process: the mailbox, as before.
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _release(self):
|
||||||
|
if hasattr(self, "end_on_demand") and self.end_on_demand():
|
||||||
|
return
|
||||||
|
self.cache_manager.set("display_on_demand_request", {
|
||||||
|
"request_id": str(uuid.uuid4()), "action": "stop",
|
||||||
|
"plugin_id": self.plugin_id, "timestamp": time.time(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `ledmatrix_min_version` where it is: the fallback is what keeps the
|
||||||
|
plugin working on older cores. A mailbox stop ends any on-demand session,
|
||||||
|
whoever started it; `end_on_demand()` ends only the plugin's own.
|
||||||
|
|
||||||
|
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` and exercises the mailbox path. To test the new path, set
|
||||||
|
`plugin_manager.request_on_demand.return_value = "some-id"`.
|
||||||
|
|
||||||
> The full source for `BasePlugin` lives in
|
> The full source for `BasePlugin` lives in
|
||||||
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
> `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.
|
> source, the source wins — please open an issue or PR to fix the doc.
|
||||||
@@ -966,6 +1038,14 @@ if info:
|
|||||||
self.logger.info(f"Plugin: {info['name']}, Version: {info.get('version')}")
|
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_all_plugin_info() -> List[Dict[str, Any]]`
|
||||||
|
|
||||||
Get information for all plugins.
|
Get information for all plugins.
|
||||||
@@ -1054,8 +1134,16 @@ values, exceptions and retries are what they were.
|
|||||||
next identical request revalidates, and a `304 Not Modified` comes back to
|
next identical request revalidates, and a `304 Not Modified` comes back to
|
||||||
your code as the original `200` with its body. ESPN currently sends
|
your code as the original `200` with its body. ESPN currently sends
|
||||||
neither, so this does nothing there.
|
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
|
- **Counters.** Requests, merged requests, bytes, 304s, errors and time spent
|
||||||
waiting are counted per plugin and per host, and published for the web UI
|
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
|
at `GET /api/v3/plugins/fetch-stats` (see
|
||||||
[REST_API_REFERENCE.md](REST_API_REFERENCE.md#get-fetch-statistics)). A
|
[REST_API_REFERENCE.md](REST_API_REFERENCE.md#get-fetch-statistics)). A
|
||||||
request is counted against your plugin when it runs inside your
|
request is counted against your plugin when it runs inside your
|
||||||
@@ -1066,6 +1154,36 @@ 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
|
or `Session.get()` calls. They work as before but are invisible to the
|
||||||
budgets and counters.
|
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
|
The settings live in `config.json` under `fetch_service`, read when the
|
||||||
display starts and on a config reload:
|
display starts and on a config reload:
|
||||||
|
|
||||||
@@ -1084,7 +1202,9 @@ display starts and on a config reload:
|
|||||||
the bare domain); `"per_second": 0` removes a budget. `"enabled": false`
|
the bare domain); `"per_second": 0` removes a budget. `"enabled": false`
|
||||||
turns the whole service into a plain `session.get()`. Two further switches,
|
turns the whole service into a plain `session.get()`. Two further switches,
|
||||||
`"single_flight": false` and `"conditional_get": false`, turn off merging and
|
`"single_flight": false` and `"conditional_get": false`, turn off merging and
|
||||||
revalidation.
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+146
-42
@@ -159,11 +159,21 @@ there an unchecked checkbox — which the browser omits — is saved as
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`restart_required` is always true here: display hardware, rotation,
|
`restart_required` is true when the save changed a setting that takes
|
||||||
durations and general settings take effect when the display restarts, and
|
effect when the display restarts: display hardware, rotation order,
|
||||||
the web UI shows its restart banner on the flag. (Plugin sections saved
|
timezone, general settings and the rest. The web UI shows its restart banner
|
||||||
through this route reach the running plugin live, like
|
on the flag. It is false when the save changed only what the running display
|
||||||
`POST /plugins/config`.)
|
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
|
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
|
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
|
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**:
|
**Response**:
|
||||||
```json
|
```json
|
||||||
@@ -323,12 +336,23 @@ by the display process (stale after 120 seconds).
|
|||||||
"data": {
|
"data": {
|
||||||
"mode": "nfl_live",
|
"mode": "nfl_live",
|
||||||
"plugin_id": "football-scoreboard",
|
"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
|
### List Display Modes
|
||||||
|
|
||||||
@@ -339,9 +363,11 @@ it. This is the list the force-display dialog offers.
|
|||||||
|
|
||||||
Send the reported `plugin_id` alongside `mode` when starting an on-demand
|
Send the reported `plugin_id` alongside `mode` when starting an on-demand
|
||||||
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
|
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
|
||||||
`plugin_id` is omitted, and that lookup only sees modes declared in a static
|
`plugin_id` is omitted. While the display is running, this list and that
|
||||||
manifest — a plugin whose modes are generated (each installed Starlark app is
|
lookup use the modes the display registered, including ones a plugin generates
|
||||||
one) returns 404 there.
|
from its config (each installed Starlark app, each soccer `custom_leagues`
|
||||||
|
entry). With the display stopped, or for a plugin it has not loaded, both see
|
||||||
|
only the modes its manifest declares.
|
||||||
|
|
||||||
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
||||||
opens the dashboard still gets the full list.
|
opens the dashboard still gets the full list.
|
||||||
@@ -405,11 +431,16 @@ Get the current on-demand display state.
|
|||||||
"returncode": 0,
|
"returncode": 0,
|
||||||
"stdout": "active",
|
"stdout": "active",
|
||||||
"stderr": ""
|
"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
|
With no on-demand request, `state` is
|
||||||
`{"active": false, "status": "idle", "last_updated": null}`.
|
`{"active": false, "status": "idle", "last_updated": null}`.
|
||||||
|
|
||||||
@@ -435,7 +466,7 @@ Request a specific plugin to display on-demand.
|
|||||||
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
||||||
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
||||||
- `pinned` (boolean, optional): Pin display (pause rotation)
|
- `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 (within about a second through the mailbox fallback). When false and the service is stopped, the route returns 400.
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```json
|
```json
|
||||||
@@ -456,13 +487,23 @@ Request a specific plugin to display on-demand.
|
|||||||
`service` is `null` when `start_service` is false.
|
`service` is `null` when `start_service` is false.
|
||||||
|
|
||||||
`transport` says how the request reached the display: `"socket"` means the
|
`transport` says how the request reached the display: `"socket"` means the
|
||||||
display's control socket acknowledged it (it is queued for the render thread;
|
display's control socket acknowledged it (it is queued for the render thread,
|
||||||
see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), `"mailbox"` means it was
|
which wakes for it and applies it within a frame; see
|
||||||
|
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), `"mailbox"` means it was
|
||||||
written to the cache mailbox the display polls, as before the socket existed.
|
written to the cache mailbox the display polls, as before the socket existed.
|
||||||
With `"mailbox"`, `socket_error` gives the reason the socket was not used
|
The mailbox is used only when the socket could not carry the request. With
|
||||||
(`no_socket` when the display is stopped or predates the socket, `timeout`,
|
`"mailbox"`, `socket_error` gives the reason (`no_socket` when the display is
|
||||||
`refused`, `busy`, ...). Either way the request is applied the same way;
|
stopped or predates the socket, `refused`, a connect `timeout`,
|
||||||
`request_id` is the same id in both.
|
`unknown_command` from a display too old for the command, ...). Either way
|
||||||
|
the request is applied the same way; `request_id` is the same id in both.
|
||||||
|
|
||||||
|
When the display had the request and did not take it -- a full queue
|
||||||
|
(`busy`), bad arguments (`invalid_args`), no answer after the request was
|
||||||
|
sent (`timeout`, `closed`) -- the route answers `503` (`400` for
|
||||||
|
`invalid_args`) with `status: "error"` and `data: {request_id, transport:
|
||||||
|
"socket", socket_error}`, and writes nothing to the mailbox. The stop route
|
||||||
|
does the same, except that with `stop_service: true` it still stops the
|
||||||
|
service and answers success.
|
||||||
|
|
||||||
### Stop On-Demand Display
|
### Stop On-Demand Display
|
||||||
|
|
||||||
@@ -542,7 +583,9 @@ List all installed plugins with their status and metadata.
|
|||||||
"status": "live",
|
"status": "live",
|
||||||
"published_at": 1790000030.0,
|
"published_at": 1790000030.0,
|
||||||
"age_seconds": 12.4,
|
"age_seconds": 12.4,
|
||||||
"stale_after": 180.0
|
"stale_after": 180.0,
|
||||||
|
"heartbeat_age_seconds": 2.1,
|
||||||
|
"source": "socket"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,10 +606,18 @@ until the display restarts. A plugin a live snapshot does not list is
|
|||||||
`loaded: false`, `state: "unloaded"`.
|
`loaded: false`, `state: "unloaded"`.
|
||||||
|
|
||||||
`runtime.status` says whether to believe them: `live` (fresh snapshot from
|
`runtime.status` says whether to believe them: `live` (fresh snapshot from
|
||||||
a running display), `stale` (not refreshed within `stale_after` seconds: the
|
a running display), `stalled` (fresh snapshot, but the same process's
|
||||||
display is hung or died), `stopped` (the display shut down) or `unknown`
|
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
|
(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`.
|
and `/plugins/metrics`.
|
||||||
|
|
||||||
`vegas_participation` is what Vegas mode does with the plugin: `"scroll"`,
|
`vegas_participation` is what Vegas mode does with the plugin: `"scroll"`,
|
||||||
@@ -812,8 +863,29 @@ Update a plugin to the latest version. Runs synchronously.
|
|||||||
```
|
```
|
||||||
|
|
||||||
`update_status` is `updated`, `up_to_date` or `local_only`.
|
`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:`
|
An update this core cannot run answers `409` with `Plugin update refused:`
|
||||||
and the reason; the installed version is left as it was.
|
and the reason; the installed version is left as it was.
|
||||||
@@ -1012,7 +1084,8 @@ or `unknown` (nothing published; `data.data` is `null`).
|
|||||||
"totals": {"requests": 412, "merged": 3, "not_modified": 0,
|
"totals": {"requests": 412, "merged": 3, "not_modified": 0,
|
||||||
"errors": 1, "http_errors": 2, "retries": 0,
|
"errors": 1, "http_errors": 2, "retries": 0,
|
||||||
"throttled": 0, "overruns": 0, "bytes": 18234011,
|
"throttled": 0, "overruns": 0, "bytes": 18234011,
|
||||||
"wait_seconds": 0.0},
|
"wait_seconds": 0.0, "memo_hits": 21, "cache_hits": 40,
|
||||||
|
"legacy_cache_hits": 2},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"football-scoreboard": {"requests": 240, "merged": 2, "bytes": 9120330,
|
"football-scoreboard": {"requests": 240, "merged": 2, "bytes": 9120330,
|
||||||
"hosts": {"site.api.espn.com": 180,
|
"hosts": {"site.api.espn.com": 180,
|
||||||
@@ -1023,9 +1096,11 @@ or `unknown` (nothing published; `data.data` is `null`).
|
|||||||
"site.api.espn.com": {"requests": 301, "...": "as in totals"}
|
"site.api.espn.com": {"requests": 301, "...": "as in totals"}
|
||||||
},
|
},
|
||||||
"validators": {"entries": 0, "bytes": 0},
|
"validators": {"entries": 0, "bytes": 0},
|
||||||
|
"response_cache": {"entries": 3, "bytes": 412004},
|
||||||
"config": {"enabled": true, "single_flight": true,
|
"config": {"enabled": true, "single_flight": true,
|
||||||
"conditional_get": true, "max_wait_seconds": 2.0,
|
"conditional_get": true, "max_wait_seconds": 2.0,
|
||||||
"rate_limits": {"*.espn.com": {"per_second": 20.0, "burst": 200.0}}}
|
"rate_limits": {"*.espn.com": {"per_second": 20.0, "burst": 200.0}},
|
||||||
|
"response_cache": true, "default_max_age": 30.0}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1037,6 +1112,16 @@ flight, `not_modified` 304s served from the stored body, `errors` transport
|
|||||||
failures and `http_errors` responses with status 400 or above. `bytes` is the
|
failures and `http_errors` responses with status 400 or above. `bytes` is the
|
||||||
decoded body size. `core` is everything no plugin made.
|
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/Set Plugin Limits
|
||||||
|
|
||||||
**GET** `/api/v3/plugins/limits/<plugin_id>`
|
**GET** `/api/v3/plugins/limits/<plugin_id>`
|
||||||
@@ -1108,7 +1193,7 @@ it is neither installed nor configured).
|
|||||||
"last_updated": "2025-01-15T10:30:00"
|
"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}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2174,13 +2259,11 @@ error with `"all": true` (`max_age_hours` is then ignored).
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The clear is asynchronous. The web interface records a request
|
The clear goes to the display service over its control socket
|
||||||
(`plugin_error_clear_request` in the shared cache), and the display service
|
(`errors.clear`, see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), which
|
||||||
applies it within about 5 seconds, rebuilding its counts from the errors it
|
applies it, rebuilding its counts from the errors it keeps, and republishes
|
||||||
keeps and republishing. Reads hide the cleared errors from the moment the
|
before it answers: `applied` is `true`, `transport` is `"socket"`, and
|
||||||
request is recorded. Until the display service applies an age-based clear,
|
`cleared_count` is the display's own count.
|
||||||
`recent_errors` and `active_patterns` are already filtered but the counts
|
|
||||||
are the old ones, and `clear_pending` is `true`.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -2188,17 +2271,30 @@ are the old ones, and `clear_pending` is `true`.
|
|||||||
"data": {
|
"data": {
|
||||||
"cleared_count": 13,
|
"cleared_count": 13,
|
||||||
"clear_requested": true,
|
"clear_requested": true,
|
||||||
|
"applied": true,
|
||||||
|
"transport": "socket",
|
||||||
"request_id": "5f0c1e...",
|
"request_id": "5f0c1e...",
|
||||||
"cutoff": "2026-09-23T09:59:02.310000"
|
"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
|
When the socket cannot carry it (the display is stopped, or older than
|
||||||
|
`errors.clear`) the clear is asynchronous, as before: the web interface
|
||||||
|
records a request (`plugin_error_clear_request` in the shared cache),
|
||||||
|
`applied` is `false` and `transport` is `"mailbox"`, and the display service
|
||||||
|
applies it within about 5 seconds. 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`. Then
|
||||||
|
`cleared_count` is how many of the reported errors the clear hides, and
|
||||||
`null` when that cannot be known before the display service applies it (an
|
`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
|
age-based clear over more errors than the report lists).
|
||||||
could not be written to the shared cache answers `500`.
|
|
||||||
|
A request that could not be written to the shared cache answers `500`. A
|
||||||
|
display that had the request and failed it (`internal`, a timeout after the
|
||||||
|
request was sent) answers `503`, with `context.socket_error`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2216,7 +2312,14 @@ display snapshot. `data.status` is `healthy` or `degraded`, with
|
|||||||
(with `heartbeat_age_seconds`), `stalled` (no heartbeat for 60s: the panel is
|
(with `heartbeat_age_seconds`), `stalled` (no heartbeat for 60s: the panel is
|
||||||
frozen even if the service is active; the status turns `degraded`), or
|
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,
|
`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
|
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":
|
logged in (and has no token) then gets only `{"status": "success", "data":
|
||||||
@@ -2276,8 +2379,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
|
`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`,
|
returns, or as the web form's flat fields (`monday_enabled`,
|
||||||
`monday_start`, `monday_end`, ...). A day that is not sent counts as
|
`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 with default times `20:00`-`07:00`. As for the schedule above, an
|
||||||
enabled.
|
enabled dim schedule needs at least one day enabled and a disabled one may
|
||||||
|
have every day off.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+259
-65
@@ -33,116 +33,192 @@ on purpose.
|
|||||||
|
|
||||||
Each pass, in order:
|
Each pass, in order:
|
||||||
|
|
||||||
1. `loop_pass()` (watchdog). Apply a pending plugin enable/disable.
|
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.
|
2. With no modes: dwell 1 s, next pass.
|
||||||
3. Poll on-demand requests and expiry, release plugins loaded only for
|
3. Poll on-demand requests and expiry, release plugins loaded only for
|
||||||
on-demand, tick plugin updates, drop an expired WiFi notice, evaluate
|
on-demand, tick plugin updates, drop an expired WiFi notice, evaluate
|
||||||
the schedule (an on-demand session overrides scheduled-off), apply the
|
the schedule (an on-demand session overrides scheduled-off), apply the
|
||||||
brightness target.
|
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`
|
4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off`
|
||||||
5. **Follower:** render one frame from the leader. `_run_follower_frame`
|
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`.
|
6. **WiFi notice** (unless on-demand): draw it, dwell 0.5 s. `_show_wifi_notice`.
|
||||||
It is also polled mid-screen (`_wifi_notice_pending`): the frame loops,
|
It also ends a running screen within about a second (the runner's
|
||||||
the dwell sleep and an interrupted Vegas iteration end within about a
|
service points), and a screen cut short resumes after it.
|
||||||
second when one arrives, and a screen cut short resumes after it.
|
7. **The Sources below the notice:** read whether Vegas is on and make the
|
||||||
7. **Live priority** (unless on-demand, or Vegas keeps live content in the
|
live-priority scan (`_arbiter_inputs_below_wifi`, where run() always
|
||||||
ticker): switch to the next live mode, or resume the rotation. A game
|
read them), and call `decide()` again. It answers OnDemand (the
|
||||||
that goes live during a screen is caught sooner, by
|
session's current mode), Live (the next live mode, round-robin; a game
|
||||||
`_check_live_takeover` in the frame loops and the dwell sleep (at most
|
that goes live during a screen takes over at the next service point, at
|
||||||
once a second, and not while a live mode is showing).
|
most once a second), Vegas (`LEGACY`) or Rotation. `_take_plan` applies
|
||||||
8. **Vegas** (unless on-demand, or live content preempts it): run one
|
the answer: a live claim or the resume when live priority ends, the
|
||||||
iteration of up to `max_cycle_duration`. A completed iteration ends the
|
on-demand index.
|
||||||
pass, and so does one that yielded for a WiFi notice or the schedule.
|
8. **Vegas** (`_run_vegas_iteration`): one iteration of up to
|
||||||
Any other interrupted one falls through to step 9 in the same pass.
|
`max_cycle_duration`. A completed iteration ends the pass, and so does
|
||||||
9. **One screen:** pick the mode (`_resolve_active_mode`), the plugin
|
one that yielded for the schedule, a reload or a WiFi notice. Any other
|
||||||
(`_plugin_for_mode`), draw the first frame through the executor
|
interrupted one asks `decide()` once more (`vegas_yielded`): a game that
|
||||||
(`_dispatch_first_frame`). On no content, rotate at once
|
stopped the ticker, or an on-demand session that started, shows next.
|
||||||
(`_note_empty_pass`, `_skip_failed_plugin_modes`). Otherwise work out the
|
9. **One screen:** pick the plugin (`_plugin_for_mode`) and hand the plan
|
||||||
bounds (`_track_dynamic_cycle`, `_resolve_durations`,
|
to the `ScreenRunner` (`src/screen_runner.py`). It draws the first frame
|
||||||
`_clamp_to_on_demand`) and the frame rate (`_needs_high_fps`), run the
|
through the executor (`_dispatch_first_frame`), has the controller fill
|
||||||
125 Hz or 1 Hz frame loop, make up the minimum duration, then pick the
|
in the plugin's durations, dynamic flag and frame policy
|
||||||
next mode (`_advance_after_screen`).
|
(`_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`).
|
||||||
|
|
||||||
The helpers named above were extracted in stage 1 without changing
|
## Design
|
||||||
behaviour. The frame loops, the Vegas branch and every early exit are still
|
|
||||||
inline in `run()`.
|
|
||||||
|
|
||||||
## Target design
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def run(self):
|
def run(self):
|
||||||
while True:
|
while True:
|
||||||
inputs = self._drain_inputs() # requests, schedule, config, sync
|
inputs = self._arbiter_inputs() # schedule, follower, notice
|
||||||
plan = self.arbiter.decide(self.state, inputs, clock.now())
|
plan = Arbiter.decide(self._arbiter_state(), inputs, now)
|
||||||
outcome = self.runner.run(plan) # ExitReason + elapsed
|
... # off / follower / notice
|
||||||
self.state = self.state.after(plan, outcome) # rotation, on-demand index, live resume
|
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
|
### Sources
|
||||||
|
|
||||||
Each kind of content is a Source. A Source looks at the state and the
|
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
|
inputs and either offers a screen or passes. The Arbiter asks them in this
|
||||||
order:
|
order:
|
||||||
|
|
||||||
| Order | Source | Offers a screen when | Today |
|
| Order | Source | Offers a screen when | Code |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | step 4 |
|
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | `decide` |
|
||||||
| 1 | Follower | a sync leader is driving this panel | step 5 |
|
| 1 | Follower | a sync leader is driving this panel | `decide` |
|
||||||
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_resolve_active_mode` |
|
| 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 | step 6 |
|
| 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) | step 7 |
|
| 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 | step 8 |
|
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | `LEGACY`, run by `_run_vegas_iteration` |
|
||||||
| 6 | Rotation | always: `available_modes[current_mode_index]` | step 9 |
|
| 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
|
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
|
today: a scheduled-off panel stays blank even for a follower, and only an
|
||||||
on-demand session overrides it.
|
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
|
### Arbiter
|
||||||
|
|
||||||
```python
|
```python
|
||||||
Arbiter.decide(state, inputs, now) -> ScreenPlan
|
Arbiter.decide(state, inputs, now, running=None) -> ScreenPlan
|
||||||
```
|
```
|
||||||
|
|
||||||
`decide` is a pure function: it does no I/O, takes no locks and does not
|
`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
|
sleep. It can be tested with plain tables of (state, inputs, now) mapped to
|
||||||
an expected plan. It returns a `ScreenPlan`:
|
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 |
|
| Field | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `source` | which Source won |
|
| `source` | which Source won |
|
||||||
| `mode`, `plugin` | what to draw (None for a blank or follower plan) |
|
| `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 `_clamp_to_on_demand` |
|
| `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 |
|
| `dynamic` | run until the plugin's cycle completes, between min and max |
|
||||||
| `frame_policy` | today `_needs_high_fps` (125 Hz or 1 Hz); see stage 5 |
|
| `frame_policy` | `HIGH_FPS` or `STATIC`, today `_needs_high_fps`; see stage 5 |
|
||||||
| `preemptible_by` | the Sources allowed to interrupt this plan mid-screen |
|
| `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
|
### ScreenRunner
|
||||||
|
|
||||||
```python
|
```python
|
||||||
ScreenRunner(clock: FrameClock).run(plan) -> Outcome(exit_reason, elapsed)
|
ScreenRunner(clock: FrameClock, host: ScreenHost).run(plan, plugin) -> Outcome
|
||||||
```
|
```
|
||||||
|
|
||||||
The ScreenRunner draws the first frame (`_dispatch_first_frame`), runs the
|
The ScreenRunner draws the first frame (`_dispatch_first_frame`), runs the
|
||||||
frame loop that the plan's frame policy selects, services pending changes
|
frame loop that the plan's frame policy selects, services pending changes
|
||||||
between frames, and returns one `ExitReason`:
|
between frames, and returns one `ExitReason`:
|
||||||
|
|
||||||
| ExitReason | Today's equivalent (golden-trace exit) |
|
| ExitReason | Golden-trace exit |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `DURATION` | target duration reached (`duration`) |
|
| `DURATION` | target duration reached (`duration`) |
|
||||||
| `CYCLE_COMPLETE` | dynamic plugin finished after its minimum (`cycle-complete`) |
|
| `CYCLE_COMPLETE` | dynamic plugin finished after its minimum (`cycle-complete`) |
|
||||||
| `EMPTY` | first frame returned False (`empty`; `raised` when display() raised inside the executor) |
|
| `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`) |
|
| `ERROR` | the dispatch itself raised (`error`) |
|
||||||
| `DISPLAY_FALSE` | a later frame returned False (`display-false`) |
|
| `DISPLAY_FALSE` | a later frame returned False (`display-false`) |
|
||||||
| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `vegas-interrupt`, ...) |
|
| `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.
|
`PREEMPTED` replaces the five `current_display_mode != active_mode` checks.
|
||||||
The runner asks the Arbiter, at the throttled service points it already has,
|
The runner asks its host at named service points (`Checkpoint`): `FRAME`
|
||||||
whether a Source in `plan.preemptible_by` now wants the panel.
|
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.
|
||||||
|
|
||||||
`FrameClock` provides `now()` and `sleep()`. In production it is
|
In the 125 Hz loop the live-priority scan is made before the frame's sleep
|
||||||
`time.monotonic`/`time.sleep`. In the golden traces it is the fake clock
|
(`_screen_service`), at the moments it always was, and weighed by the
|
||||||
that the harness patches in today.
|
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
|
## Stages
|
||||||
|
|
||||||
@@ -154,7 +230,7 @@ that the harness patches in today.
|
|||||||
| 4 | Vegas as a Source driven by `run_frame()` | none intended | traces against the real coordinator; ledpi Vegas 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 |
|
| 5 | Plugins declare `frame_policy` | DEBUG instead of INFO for the FPS line | traces; soak on a static-heavy rotation |
|
||||||
|
|
||||||
### Stage 1 (this PR)
|
### Stage 1 (#704)
|
||||||
|
|
||||||
- `test/_run_loop_harness.py` builds a real `DisplayController` through
|
- `test/_run_loop_harness.py` builds a real `DisplayController` through
|
||||||
`__init__` on in-memory fakes (plugins, cache, config service, plugin
|
`__init__` on in-memory fakes (plugins, cache, config service, plugin
|
||||||
@@ -172,13 +248,14 @@ that the harness patches in today.
|
|||||||
- dynamic duration (cycle complete, plugin cap, global cap)
|
- dynamic duration (cycle complete, plugin cap, global cap)
|
||||||
- live priority taking over and handing back; live round-robin
|
- live priority taking over and handing back; live round-robin
|
||||||
- on-demand start/stop/expiry; pinned on-demand; a session resumed after
|
- on-demand start/stop/expiry; pinned on-demand; a session resumed after
|
||||||
a restart
|
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
|
- schedule off and dim, with an on-demand override during downtime
|
||||||
- WiFi notice; sync follower
|
- WiFi notice; sync follower
|
||||||
- Vegas, with and without `live_in_ticker`
|
- Vegas, with and without `live_in_ticker`
|
||||||
- Each trace row is `[start, mode, duration, exit_reason, frames,
|
- Each trace row is `[start, mode, duration, exit_reason, frames,
|
||||||
force_clear]`. The exit reason is the event that decided what came next.
|
force_clear]`. The exit reason is the event that decided what came next.
|
||||||
- All 16 tests run in under a second. The goldens were generated from
|
- All 18 tests run in under a second. The goldens were generated from
|
||||||
main's `run()` before any code moved.
|
main's `run()` before any code moved.
|
||||||
- Vegas uses `FakeVegas`, which implements only the contract the controller
|
- Vegas uses `FakeVegas`, which implements only the contract the controller
|
||||||
depends on: `run_iteration()` returns True after its duration and False
|
depends on: `run_iteration()` returns True after its duration and False
|
||||||
@@ -188,7 +265,7 @@ that the harness patches in today.
|
|||||||
- Twelve helpers were extracted from `run()` (listed under "What `run()`
|
- Twelve helpers were extracted from `run()` (listed under "What `run()`
|
||||||
does today"). Breaking any one of them fails at least one golden trace.
|
does today"). Breaking any one of them fails at least one golden trace.
|
||||||
|
|
||||||
### Stage 2: Arbiter, starting with Follower and Wifi
|
### Stage 2: Arbiter, starting with Follower and Wifi (done)
|
||||||
|
|
||||||
1. Add `ScreenPlan` and an `Arbiter` with the ScheduledOff gate, Follower
|
1. Add `ScreenPlan` and an `Arbiter` with the ScheduledOff gate, Follower
|
||||||
and Wifi. Every other case returns a `LEGACY` plan, which means "carry on
|
and Wifi. Every other case returns a `LEGACY` plan, which means "carry on
|
||||||
@@ -205,16 +282,119 @@ that the harness patches in today.
|
|||||||
Follower and Wifi go first because each is one self-contained branch that
|
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.
|
ends the pass. They prove the plumbing without touching the frame loops.
|
||||||
|
|
||||||
### Stage 3: ScreenRunner and `PREEMPTED`
|
What shipped:
|
||||||
|
|
||||||
Move the two frame loops, the make-up dwell and the dynamic-duration exit
|
- `src/display_arbiter.py` (on the mypy ratchet) holds `Source`
|
||||||
into `ScreenRunner.run(plan)` with an injected `FrameClock`. Replace the
|
(`SCHEDULED_OFF`, `FOLLOWER`, `WIFI`, `LEGACY`), `ArbiterInputs`,
|
||||||
five re-checks with `PREEMPTED`. Add the OnDemand, Live and Rotation Sources
|
`ArbiterState`, `WifiNotice`, `ScreenPlan` and `Arbiter.decide`.
|
||||||
so `LEGACY` is left meaning only Vegas.
|
`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),
|
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. Coordinate with
|
so it needs a frame soak on ledpi, A/B against main, before it merges.
|
||||||
whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
|
Coordinate with whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
|
||||||
|
|
||||||
### Stage 4: Vegas as a Source
|
### Stage 4: Vegas as a Source
|
||||||
|
|
||||||
@@ -232,7 +412,11 @@ its prefetch inline. Verify with a Vegas soak on ledpi, A/B.
|
|||||||
Plugins declare `frame_policy` (STATIC, PERIODIC(hz), ANIMATED(fps),
|
Plugins declare `frame_policy` (STATIC, PERIODIC(hz), ANIMATED(fps),
|
||||||
SCROLL). `_needs_high_fps` becomes the mapping for legacy plugins
|
SCROLL). `_needs_high_fps` becomes the mapping for legacy plugins
|
||||||
(`needs_high_fps`, the `static-image` special case, `enable_scrolling`),
|
(`needs_high_fps`, the `static-image` special case, `enable_scrolling`),
|
||||||
and its per-screen INFO line drops to DEBUG.
|
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
|
## How each stage is verified
|
||||||
|
|
||||||
@@ -275,3 +459,13 @@ PR that updates the affected trace and explains why. All six are fixed:
|
|||||||
|
|
||||||
A new one found later goes the same way: record it here with the trace that
|
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.
|
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.
|
||||||
|
|||||||
+74
-10
@@ -77,6 +77,31 @@ 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
|
line under it says what your speed will run as on this panel, and links to the
|
||||||
nearest smooth speeds.
|
nearest smooth speeds.
|
||||||
|
|
||||||
|
### A panel that cannot reach its cap
|
||||||
|
|
||||||
|
Speeds are solved against `limit_refresh_rate_hz`, the configured cap, but a
|
||||||
|
cap is only a ceiling: a long chain, a high `pwm_bits` or a big
|
||||||
|
`gpio_slowdown` can leave the panel below it. One Pi 4 driving 2×128×64 on
|
||||||
|
`adafruit-hat-pwm` with `pwm_bits 9` and `gpio_slowdown 5` measured
|
||||||
|
107.6–113.1 Hz under a 120 Hz cap. Frames still move whole pixels, but
|
||||||
|
every scroll runs that much slower than configured (60 px/s ran at 55 px/s),
|
||||||
|
and the smooth speeds are the cap's rather than the panel's.
|
||||||
|
|
||||||
|
The display measures the real rate from its own frames. About a minute
|
||||||
|
into scrolling, a panel more than 3% short of its cap is logged once:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARNING - src.common.frame_timing - The panel refreshes at about 113 Hz, below
|
||||||
|
the 120 Hz that scroll speeds are planned for ... Set Limit Refresh Rate to
|
||||||
|
100 Hz (web UI, Display tab), which this panel can hold, and restart.
|
||||||
|
```
|
||||||
|
|
||||||
|
The Display tab says the same under **Limit Refresh Rate**, with a button
|
||||||
|
that fills in the suggested cap (`GET /api/v3/config/refresh-rate`). The
|
||||||
|
suggestion is a multiple of 10 at least 5% under the measurement, because
|
||||||
|
an uncapped panel drifts and the measurement is the fast end of it. A cap the
|
||||||
|
panel holds also stops the drift.
|
||||||
|
|
||||||
### How a slow speed stays crisp
|
### How a slow speed stays crisp
|
||||||
|
|
||||||
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
|
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
|
||||||
@@ -246,8 +271,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
|
healthy 100 fps. The stats line reports the tail for that reason — read the
|
||||||
percentiles, not the fps.
|
percentiles, not the fps.
|
||||||
|
|
||||||
Every scroller emits one line every 5 seconds covering *every* frame in that
|
Every scroller summarises each 5-second window, covering *every* frame in it,
|
||||||
window, tagged with the plugin it came from:
|
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
|
```bash
|
||||||
journalctl -u ledmatrix --since "-10min" --no-pager | grep "Scroll frame stats"
|
journalctl -u ledmatrix --since "-10min" --no-pager | grep "Scroll frame stats"
|
||||||
@@ -286,6 +319,10 @@ journalctl -u ledmatrix --since "-3h" --no-pager | grep "Scroll frame stats" \
|
|||||||
| sort -k7 -rn
|
| 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.
|
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
|
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
|
frame of every scroll was timed against the end of the *previous* scroll, so
|
||||||
@@ -335,18 +372,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
|
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
|
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
|
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
|
fresh, which puts the preview's PNG encoding at the viewer rate, as an open
|
||||||
service's user.
|
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 |
|
| 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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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*. |
|
| **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). A kind whose late rate sits well above the overall one is the work making frames late. Shown only when something tagged its work. |
|
| **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
|
The refresh rate is estimated from the frames themselves (swaps that block on
|
||||||
vsync can only land on refresh boundaries). Cross-check it with
|
vsync can only land on refresh boundaries). Cross-check it with
|
||||||
@@ -360,10 +403,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
|
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
|
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
|
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
|
(C code holding the GIL) rather than one thread. A stall while the next
|
||||||
shorter hitches, run the service with `LEDMATRIX_STALL_WATCHDOG_MS=30`, which
|
screen's first `display()` is still drawing says `in a handover gap` instead of
|
||||||
dumps at three refreshes late instead: its extra polling costs a little GIL
|
`mid-scroll`; that call runs on a thread named `display-<plugin id>`. To see
|
||||||
time of its own, so do that on a diagnostic run, not a soak you are grading.
|
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.
|
`LEDMATRIX_STALL_WATCHDOG=0` turns it off.
|
||||||
|
|
||||||
### Results: hdpi, 2026-09-24
|
### Results: hdpi, 2026-09-24
|
||||||
@@ -540,6 +586,24 @@ 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
|
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.
|
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
|
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
|
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
|
edges grainy, and halving the speed halved it, so it is the scan and not a torn
|
||||||
|
|||||||
+85
-30
@@ -87,10 +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_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_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_card_wrappers.py` | 3.7.0 | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations |
|
||||||
| `sports_plugin_host.py` | next release | `SportsPluginHostMixin` — the plugin class's (`manager.py`) identical helpers: Vegas weight, off-thread switch refresh |
|
| `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` | next release | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place |
|
| `sports_live_scroll.py` | 3.8.0 | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place |
|
||||||
| `sports_display_rules.py` | next release | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell |
|
| `sports_display_rules.py` | 3.8.0 | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell |
|
||||||
| `sports_font_path.py` | next release | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
|
| `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).
|
Each is described in [src/common/README.md](../src/common/README.md).
|
||||||
|
|
||||||
@@ -102,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
|
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
|
module having gained it fails at runtime with an `AttributeError`, while a
|
||||||
missing module fails at load, where the version checks can see it.
|
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
|
Each promoted module has a parity test that compares its bodies against the
|
||||||
plugin copies when `LEDMATRIX_PLUGINS` points at a checkout
|
plugin copies when `LEDMATRIX_PLUGINS` points at a checkout
|
||||||
(`test_sports_helpers.py`, `test_sports_stage3_parity.py`), and
|
(`test_sports_helpers.py`, `test_sports_stage3_parity.py`), and
|
||||||
@@ -125,7 +128,7 @@ deprecation cycle.
|
|||||||
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
|
| `_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 |
|
| `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 |
|
| `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 |
|
| `_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 |
|
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
||||||
|
|
||||||
@@ -134,8 +137,7 @@ constants rather than behavior:
|
|||||||
|
|
||||||
| Attribute | Meaning | Default |
|
| Attribute | Meaning | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
|
| `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` |
|
||||||
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
|
|
||||||
| `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) |
|
| `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
|
### Why these are seams and not branches
|
||||||
@@ -146,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,
|
would silently select the wrong club for NRL users. The base declares the seam,
|
||||||
NRL fills it, and core never learns the string `"nrl"`.
|
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
|
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
|
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
|
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
|
goals, and folding them would swallow one. Neither default is "right" — which is
|
||||||
@@ -298,6 +303,49 @@ Left in the plugins, though identical:
|
|||||||
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
|
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
|
||||||
wants it can inherit 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
|
### Why the method changes
|
||||||
|
|
||||||
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
||||||
@@ -333,8 +381,8 @@ game-over check); the report measures each method in it. The procedure:
|
|||||||
line in each plugin.
|
line in each plugin.
|
||||||
- *A per-sport fact* (hockey ends in period 3; a soccer clock counts up).
|
- *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
|
Make it a declared class constant or override point with a default, as
|
||||||
`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`, `COALESCE_SCORING_SEQUENCE` and
|
`FINAL_PERIOD`, `COALESCE_SCORING_SEQUENCE` and `_favorite_key` are,
|
||||||
`_favorite_key` are, and add it to the tables above. Never a sport-name
|
and add it to the tables above. Never a sport-name
|
||||||
branch: core must not learn sport names.
|
branch: core must not learn sport names.
|
||||||
- *A product difference*: anything a user can see (which games show, a
|
- *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
|
colour, a date, a badge, how long a screen stays). The owner picks the
|
||||||
@@ -382,8 +430,8 @@ release.
|
|||||||
| # | Family | Methods (variants) | Why here |
|
| # | 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) 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) |
|
| 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; its seams (`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`) were designed in B1. The pilot for the procedure |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
@@ -416,21 +464,28 @@ family 9 prepares.
|
|||||||
Owner calls to make before (or while) reconciling. Items marked *verify* are
|
Owner calls to make before (or while) reconciling. Items marked *verify* are
|
||||||
suspected behaviour that needs a payload or a rig to confirm first.
|
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
|
- **5, game-over check. Decided 2026-10-05, done:** one seam,
|
||||||
game in afl, nrl and soccer (`CLOCK_COUNTS_DOWN = False`); hockey ends at
|
`FINAL_PERIOD`: hockey 3; basketball, football and lacrosse 4; `None` (the
|
||||||
0:00 from period 3, basketball, football and lacrosse from period 4.
|
clock never ends a game) for afl, nrl and soccer (clocks that count up),
|
||||||
baseball and ufc share a copy that reads a missing clock as "0:00": dormant
|
baseball (its games carry no `period`, so the old rule was dormant) and
|
||||||
in baseball (its games carry no `period`), and not triggered by ufc's round
|
ufc (a bout ends only on ESPN's final status, which also closes the ~1 s
|
||||||
breaks either. ESPN sends a break as `STATUS_END_OF_ROUND` with displayClock
|
window at the horn when the ticking clock reads `0:00`; ESPN's round-break
|
||||||
`-`, not `0:00` (verified against recorded payloads; ledmatrix-plugins#580
|
displayClock `-` was never a zero clock, ledmatrix-plugins#580). Only a
|
||||||
pins it). Whatever rule ufc gets must not read `-` as `0:00`. Decide ufc's
|
non-empty clock string counts (the baseball/ufc copy read a missing clock
|
||||||
rule: no clock rule (ESPN's `STATUS_FINAL` is the only end signal it needs;
|
as `0:00`). A score level at 0:00 is not over: the game stays live through
|
||||||
this also closes a ~1 s window at the horn when the ticking clock reads
|
the break before overtime, and one that really ends tied ends on its final
|
||||||
`0:00`), or its own final period.
|
status. Baseball keeps its postponed/suspended override in `BaseballLive`.
|
||||||
- **6, favourite matching.** NRL keeps matching favourites by team id
|
- **6, favourite matching. Decided 2026-10-05, done:** each side of a game is
|
||||||
(abbreviations collide: NEW, CAN), through `_favorite_key` rather than its
|
named by `_favorite_key` (the abbreviation; NRL overrides it with the ESPN
|
||||||
own copies of the selection methods. Six plugins log the recent-games
|
team id, and `None` for a missing id, which fixes a favourite typed "None"
|
||||||
selection at INFO; baseball, football and ufc do not.
|
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
|
- **7, other-games rotation.** football advances the rotation window under
|
||||||
`_games_lock` (update() and display() both advance it; interleaved, a
|
`_games_lock` (update() and display() both advance it; interleaved, a
|
||||||
window of games is skipped) and fixes a favourites-only pool that recomposed
|
window of games is skipped) and fixes a favourites-only pool that recomposed
|
||||||
|
|||||||
+94
-3
@@ -84,6 +84,46 @@ python3 web_interface/start.py
|
|||||||
|
|
||||||
### Installation & Build Issues
|
### 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.
|
||||||
|
- "A desktop is running": use the Lite image, not the desktop one, or boot
|
||||||
|
to the console with `sudo systemctl set-default multi-user.target` and
|
||||||
|
reboot. Desktop packages that are installed but not running only produce a
|
||||||
|
warning, and the install continues.
|
||||||
|
- "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"
|
#### Step 6 fails: "Failed building wheel for rgbmatrix"
|
||||||
|
|
||||||
**Symptoms:**
|
**Symptoms:**
|
||||||
@@ -328,6 +368,49 @@ commit, then switches to releases on its own.
|
|||||||
3. **Local changes after a channel switch:** edits that no longer fit the new
|
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`
|
version are kept in the git stash rather than lost; `git stash list`
|
||||||
shows them as "LEDMatrix autostash before update".
|
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 +447,16 @@ commit, then switches to releases on its own.
|
|||||||
|
|
||||||
5. **Check required services:**
|
5. **Check required services:**
|
||||||
```bash
|
```bash
|
||||||
|
systemctl is-active NetworkManager # must say "active"
|
||||||
sudo systemctl status hostapd
|
sudo systemctl status hostapd
|
||||||
sudo systemctl status dnsmasq
|
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:**
|
6. **Manually enable AP mode:**
|
||||||
```bash
|
```bash
|
||||||
@@ -590,9 +680,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
|
apart, so a plugin that hangs on every start does not restart the display
|
||||||
hundreds of times an hour.
|
hundreds of times an hour.
|
||||||
|
|
||||||
4. **Is the watchdog installed?** Installs from before it keep their old unit
|
4. **Is the watchdog installed?** Updates install new unit settings once the
|
||||||
until the installer is re-run (a startup warning says the unit differs
|
installer has set up `ledmatrix-refresh-units`; installs from before that
|
||||||
from its template):
|
keep their old unit until the installer is re-run (a startup warning says
|
||||||
|
the unit differs from its template):
|
||||||
```bash
|
```bash
|
||||||
systemctl show -p WatchdogUSec ledmatrix # 2min once running; 0 = not installed
|
systemctl show -p WatchdogUSec ledmatrix # 2min once running; 0 = not installed
|
||||||
sudo ./scripts/install/install_service.sh
|
sudo ./scripts/install/install_service.sh
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ static/v3/js/
|
|||||||
registry.js page lifecycle: init/destroy on htmx swaps
|
registry.js page lifecycle: init/destroy on htmx swaps
|
||||||
api.js fetch wrapper for /api/v3 (JSON envelope, login redirect)
|
api.js fetch wrapper for /api/v3 (JSON envelope, login redirect)
|
||||||
facade.js window.LEDMatrix and deprecated aliases
|
facade.js window.LEDMatrix and deprecated aliases
|
||||||
(later) escape.js, notify.js, dialog.js, streams.js, visibility.js,
|
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
|
store.js (the one installed-plugin store), form/renderer.js
|
||||||
pages/ one module per tab partial
|
pages/ one module per tab partial
|
||||||
cache.js export init(root, ctx), destroy(root, ctx)
|
cache.js export init(root, ctx), destroy(root, ctx)
|
||||||
|
durations.js, operation-history.js, raw-json.js, backup-restore.js
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,9 +59,43 @@ A converted partial has no `<script>`. Its root element names its page:
|
|||||||
<div class="..." data-page="cache"> ... </div>
|
<div class="..." data-page="cache"> ... </div>
|
||||||
```
|
```
|
||||||
|
|
||||||
`core/boot.js` registers each page with a loader:
|
`core/boot.js` lists each page with a loader,
|
||||||
`registry.register('cache', () => import('../pages/cache.js'))`. A page's
|
`'cache': page(function() { return import('../pages/cache.js'); })`, and
|
||||||
module is fetched only when its partial first appears.
|
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:
|
`core/registry.js` handles the rest:
|
||||||
|
|
||||||
@@ -82,6 +118,7 @@ Each mount gets a `ctx` object:
|
|||||||
| `ctx.state` | A per-mount object for the page's own state |
|
| `ctx.state` | A per-mount object for the page's own state |
|
||||||
| `ctx.api` | Shared service from `boot.js` |
|
| `ctx.api` | Shared service from `boot.js` |
|
||||||
| `ctx.notify` | 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
|
A page that passes `{ signal: ctx.signal }` to `addEventListener` and
|
||||||
`fetch` needs no teardown code. Its listeners and in-flight requests go
|
`fetch` needs no teardown code. Its listeners and in-flight requests go
|
||||||
@@ -90,6 +127,30 @@ example: its delete buttons use one delegated listener, rows are built with
|
|||||||
`textContent` rather than markup strings, and a newer load supersedes an
|
`textContent` rather than markup strings, and a newer load supersedes an
|
||||||
older one.
|
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
|
### One facade
|
||||||
|
|
||||||
`window.LEDMatrix` is the only global the module code adds:
|
`window.LEDMatrix` is the only global the module code adds:
|
||||||
@@ -214,13 +275,13 @@ are the inline script in each partial today.
|
|||||||
| # | Page | Inline JS | Why it is here |
|
| # | 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 |
|
| 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 | Tiny. One htmx form |
|
| 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 | No globals, read-only list |
|
| 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 | No globals. CodeMirror is set up and torn down in init/destroy |
|
| 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 | 5 globals used only by its own `onclick`s; these become delegated listeners plus deprecated aliases |
|
| 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 | 2 globals used as `hx-on` response handlers. Moves `hx-on` handlers into page listeners |
|
| 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 | 147 | `webLogin` global and the security section. The first page that touches login |
|
| 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 | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 11 | Fonts | 681 | Large, but self-contained (6 globals) |
|
||||||
@@ -237,7 +298,7 @@ the order:
|
|||||||
| `showNotification` | 4 versions | `core/notify.js` |
|
| `showNotification` | 4 versions | `core/notify.js` |
|
||||||
| The modal helper | `utils/dialog.js` | `core/dialog.js` |
|
| The modal helper | `utils/dialog.js` | `core/dialog.js` |
|
||||||
| SSE streams | `app-shell.js` | `core/streams.js` |
|
| SSE streams | `app-shell.js` | `core/streams.js` |
|
||||||
| `LEDVisibility` | `app-shell.js` | `core/visibility.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
|
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
|
gone, the script re-execution in `htmx-config.js` and the "HTMX never
|
||||||
@@ -257,10 +318,18 @@ Unit suites need only node. They import the shipped modules directly:
|
|||||||
|
|
||||||
| Suite | Kind | What it covers |
|
| 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 |
|
| `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) |
|
| `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_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 |
|
||||||
| `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/`; every registered page has its module and exactly one partial root; a converted partial has no `<script>` |
|
| `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 |
|
| `test/test_field_model_parity.py` | pytest | The model against the macro for every available schema |
|
||||||
|
|
||||||
What each future step adds:
|
What each future step adds:
|
||||||
|
|||||||
+187
-55
@@ -47,72 +47,133 @@ if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
|
|||||||
echo "Raspberry Pi 5 detected — will verify RP1 library support."
|
echo "Raspberry Pi 5 detected — will verify RP1 library support."
|
||||||
fi
|
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 ""
|
||||||
echo "Checking operating system requirements..."
|
echo "Checking operating system requirements..."
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
OS_CHECK_FAILED=0
|
OS_CHECK_FAILED=0
|
||||||
|
OS_RELEASE=""
|
||||||
|
|
||||||
if [ -f /etc/os-release ]; then
|
OS_LIB="$(cd "$(dirname "$0")" && pwd)/scripts/install/lib_os.sh"
|
||||||
. /etc/os-release
|
if [ ! -f "$OS_LIB" ]; then
|
||||||
echo "Detected OS: $PRETTY_NAME"
|
echo "✗ ERROR: $OS_LIB is missing, so the operating system cannot be checked."
|
||||||
echo "Version ID: ${VERSION_ID:-unknown}"
|
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"
|
||||||
|
|
||||||
# Check if it's Raspberry Pi OS or Debian
|
if [ -r "$LM_OS_RELEASE_FILE" ]; then
|
||||||
if [[ "$ID" != "raspbian" ]] && [[ "$ID" != "debian" ]]; then
|
echo "Detected OS: $(lm_os_field PRETTY_NAME)"
|
||||||
echo "✗ ERROR: This script requires Raspberry Pi OS (raspbian/debian)"
|
OS_VERSION_ID=$(lm_os_field VERSION_ID)
|
||||||
echo " Detected OS ID: $ID"
|
echo "Version ID: ${OS_VERSION_ID:-unknown}"
|
||||||
OS_CHECK_FAILED=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if it's Debian 13 (Trixie)
|
if OS_RELEASE=$(lm_os_release); then
|
||||||
if [ "${VERSION_ID:-0}" != "13" ]; then
|
echo "✓ $(lm_release_label "$OS_RELEASE") detected"
|
||||||
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
|
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
# Check if it's the Lite version (no desktop environment)
|
# Check for a desktop. A desktop only competes with the panel for CPU while
|
||||||
# Check for desktop packages or desktop services
|
# it runs, so a running display manager stops the install; desktop packages
|
||||||
DESKTOP_DETECTED=0
|
# or session files on a Pi that boots to the console are only a warning.
|
||||||
if dpkg -l | grep -qE "^ii.*raspberrypi-ui-mods|^ii.*lxde|^ii.*xfce|^ii.*gnome|^ii.*kde"; then
|
DESKTOP_RUNNING=0
|
||||||
DESKTOP_DETECTED=1
|
DESKTOP_INSTALLED=0
|
||||||
fi
|
# display-manager is the alias every Debian display manager registers.
|
||||||
if systemctl list-units --type=service --state=running 2>/dev/null | grep -qE "lightdm|gdm3|sddm|lxdm"; then
|
for dm in display-manager lightdm gdm gdm3 sddm lxdm; do
|
||||||
DESKTOP_DETECTED=1
|
if systemctl is-active --quiet "$dm" 2>/dev/null; then
|
||||||
|
DESKTOP_RUNNING=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# grep without -q: -q exits at the first match, dpkg then dies of SIGPIPE,
|
||||||
|
# and pipefail turns a found desktop into "not found".
|
||||||
|
# Desktop metapackages and session managers, matched as whole installed
|
||||||
|
# package names: an unanchored ".*kde" matched libblockdev-* ("bloc-kde-v"),
|
||||||
|
# and a "gnome" prefix matched standalone parts such as gnome-keyring.
|
||||||
|
# Trixie replaced raspberrypi-ui-mods with the rpd-*-core metapackages.
|
||||||
|
DESKTOP_PACKAGES='raspberrypi-ui-mods|rpd-wayland-core|rpd-x-core'
|
||||||
|
DESKTOP_PACKAGES+='|lxde|lxde-core|lxsession|xfce4|xfce4-session'
|
||||||
|
DESKTOP_PACKAGES+='|gnome-shell|gnome-session|kde-plasma-desktop|plasma-desktop'
|
||||||
|
DESKTOP_PACKAGES+='|plasma-workspace|task-desktop|task-[a-z0-9]+-desktop'
|
||||||
|
if dpkg-query -W -f='${db:Status-Abbrev} ${binary:Package}\n' 2>/dev/null \
|
||||||
|
| grep -E "^ii +(${DESKTOP_PACKAGES})(:[a-z0-9]+)?$" >/dev/null; then
|
||||||
|
DESKTOP_INSTALLED=1
|
||||||
fi
|
fi
|
||||||
if [ -d /usr/share/raspberrypi-ui-mods ] || [ -d /usr/share/xsessions ]; then
|
if [ -d /usr/share/raspberrypi-ui-mods ] || [ -d /usr/share/xsessions ]; then
|
||||||
DESKTOP_DETECTED=1
|
DESKTOP_INSTALLED=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$DESKTOP_DETECTED" -eq 1 ]; then
|
if [ "$DESKTOP_RUNNING" -eq 1 ]; then
|
||||||
echo "✗ ERROR: Desktop environment detected - this script requires Raspberry Pi OS Lite"
|
echo "✗ ERROR: A desktop is running - this script requires Raspberry Pi OS Lite"
|
||||||
echo " Please use Raspberry Pi OS Lite (not the full desktop version)"
|
echo " Please use Raspberry Pi OS Lite (not the full desktop version), or boot"
|
||||||
|
echo " to the console: sudo systemctl set-default multi-user.target && sudo reboot"
|
||||||
OS_CHECK_FAILED=1
|
OS_CHECK_FAILED=1
|
||||||
|
elif [ "$DESKTOP_INSTALLED" -eq 1 ]; then
|
||||||
|
echo "⚠ WARNING: Desktop packages are installed, but no desktop is running."
|
||||||
|
echo " Continuing. Keep the Pi booting to the console: a running desktop"
|
||||||
|
echo " competes with the LED panel for CPU and can make it flicker."
|
||||||
else
|
else
|
||||||
echo "✓ Lite version confirmed (no desktop environment)"
|
echo "✓ Lite version confirmed (no desktop environment)"
|
||||||
fi
|
fi
|
||||||
else
|
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
|
OS_CHECK_FAILED=1
|
||||||
fi
|
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
|
if [ "$OS_CHECK_FAILED" -eq 1 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "Installation cannot continue. Please install Raspberry Pi OS Lite (Trixie) and try again."
|
echo "Installation cannot continue."
|
||||||
echo ""
|
lm_print_supported_os_help
|
||||||
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"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✓ OS requirements met"
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# The user who ran the installer: SUDO_USER once we are running under sudo
|
# The user who ran the installer: SUDO_USER once we are running under sudo
|
||||||
@@ -190,6 +251,51 @@ _sync_rgb_submodule() {
|
|||||||
fi
|
fi
|
||||||
return 0
|
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 ---------------------------------
|
# --- end rpi-rgb-led-matrix checkout helpers ---------------------------------
|
||||||
|
|
||||||
# Determine the Project Root Directory (where this script is located)
|
# Determine the Project Root Directory (where this script is located)
|
||||||
@@ -223,6 +329,8 @@ SKIP_SWAP=${LEDMATRIX_SKIP_SWAP:-0}
|
|||||||
BUILD_JOBS_OVERRIDE=${LEDMATRIX_BUILD_JOBS:-}
|
BUILD_JOBS_OVERRIDE=${LEDMATRIX_BUILD_JOBS:-}
|
||||||
# Weekly automatic updates: 1 on, 0 off, empty = ask (interactive) or leave as is.
|
# Weekly automatic updates: 1 on, 0 off, empty = ask (interactive) or leave as is.
|
||||||
AUTO_UPDATE=${LEDMATRIX_AUTO_UPDATE:-}
|
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() {
|
usage() {
|
||||||
cat <<USAGE
|
cat <<USAGE
|
||||||
@@ -240,12 +348,18 @@ Options:
|
|||||||
--enable-auto-update Turn on weekly automatic updates (with health
|
--enable-auto-update Turn on weekly automatic updates (with health
|
||||||
check and automatic rollback)
|
check and automatic rollback)
|
||||||
--no-auto-update Leave weekly automatic updates off
|
--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
|
-h, --help Show this help message and exit
|
||||||
|
|
||||||
Environment variables (same effect as flags):
|
Environment variables (same effect as flags):
|
||||||
LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1,
|
LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1,
|
||||||
LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=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:
|
Low-memory devices:
|
||||||
On a Pi with under 2GB of RAM the C++ build is limited to fewer parallel
|
On a Pi with under 2GB of RAM the C++ build is limited to fewer parallel
|
||||||
@@ -265,6 +379,7 @@ while [ $# -gt 0 ]; do
|
|||||||
--skip-swap) SKIP_SWAP=1 ;;
|
--skip-swap) SKIP_SWAP=1 ;;
|
||||||
--enable-auto-update) AUTO_UPDATE=1 ;;
|
--enable-auto-update) AUTO_UPDATE=1 ;;
|
||||||
--no-auto-update) AUTO_UPDATE=0 ;;
|
--no-auto-update) AUTO_UPDATE=0 ;;
|
||||||
|
--beta) UPDATE_CHANNEL=beta ;;
|
||||||
--build-jobs)
|
--build-jobs)
|
||||||
shift
|
shift
|
||||||
if [ $# -eq 0 ]; then echo "--build-jobs requires a number"; usage; exit 1; fi
|
if [ $# -eq 0 ]; then echo "--build-jobs requires a number"; usage; exit 1; fi
|
||||||
@@ -291,10 +406,12 @@ else
|
|||||||
lm_remove_build_swap() { return 0; }
|
lm_remove_build_swap() { return 0; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove the temporary build swapfile no matter how the script ends. Step 6
|
# Remove the temporary build swapfile, and take any library patches back out
|
||||||
# tears it down itself; this is the backstop for the error path, since
|
# of the submodule, no matter how the script ends. Step 6 does both itself;
|
||||||
# on_error ends in `exit` and EXIT traps still run.
|
# this is the backstop for the error path (on_error ends in `exit` and EXIT
|
||||||
trap 'lm_remove_build_swap' 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
|
# Helpers
|
||||||
retry() {
|
retry() {
|
||||||
@@ -873,15 +990,25 @@ if [ -z "$AUTO_UPDATE" ] && [ "$ASSUME_YES" != "1" ] && [ -t 0 ]; then
|
|||||||
echo
|
echo
|
||||||
if [[ $REPLY =~ ^[Yy]$ ]]; then AUTO_UPDATE=1; else AUTO_UPDATE=0; fi
|
if [[ $REPLY =~ ^[Yy]$ ]]; then AUTO_UPDATE=1; else AUTO_UPDATE=0; fi
|
||||||
fi
|
fi
|
||||||
if [ "$AUTO_UPDATE" = "1" ] || [ "$AUTO_UPDATE" = "0" ]; then
|
case "$UPDATE_CHANNEL" in
|
||||||
if python3 - "$PROJECT_ROOT_DIR/config/config.json" "$AUTO_UPDATE" <<'PY'
|
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
|
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:
|
with open(path, encoding="utf-8") as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
if not isinstance(config.get("auto_update"), dict):
|
if not isinstance(config.get("auto_update"), dict):
|
||||||
config["auto_update"] = {}
|
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
|
# 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.
|
# config watcher may be running and must never read a half-written file.
|
||||||
original = os.stat(path)
|
original = os.stat(path)
|
||||||
@@ -902,9 +1029,11 @@ except BaseException:
|
|||||||
raise
|
raise
|
||||||
PY
|
PY
|
||||||
then
|
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
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1226,9 +1355,11 @@ else
|
|||||||
fi
|
fi
|
||||||
BUILD_OUTPUT=$(mktemp)
|
BUILD_OUTPUT=$(mktemp)
|
||||||
BUILD_SUCCESS=false
|
BUILD_SUCCESS=false
|
||||||
|
_apply_rgb_patches
|
||||||
if run_rgbmatrix_build "$BUILD_JOBS" "$BUILD_OUTPUT"; then
|
if run_rgbmatrix_build "$BUILD_JOBS" "$BUILD_OUTPUT"; then
|
||||||
BUILD_SUCCESS=true
|
BUILD_SUCCESS=true
|
||||||
fi
|
fi
|
||||||
|
_revert_rgb_patches
|
||||||
cat "$BUILD_OUTPUT" >> "$LOG_FILE"
|
cat "$BUILD_OUTPUT" >> "$LOG_FILE"
|
||||||
if [ "$BUILD_SUCCESS" != true ]; then
|
if [ "$BUILD_SUCCESS" != true ]; then
|
||||||
print_rgbmatrix_build_failure "$BUILD_OUTPUT"
|
print_rgbmatrix_build_failure "$BUILD_OUTPUT"
|
||||||
@@ -1349,15 +1480,16 @@ if ! command -v setcap >/dev/null 2>&1; then
|
|||||||
echo "⚠ setcap not found, skipping capability configuration"
|
echo "⚠ setcap not found, skipping capability configuration"
|
||||||
echo " Install libcap2-bin if you need hardware timing capabilities"
|
echo " Install libcap2-bin if you need hardware timing capabilities"
|
||||||
else
|
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_BIN=""
|
||||||
PYTHON_VER=""
|
PYTHON_VER=""
|
||||||
if [ -f "/usr/bin/python3.13" ]; then
|
if [ -f "/usr/bin/python3" ]; then
|
||||||
PYTHON_BIN=$(readlink -f /usr/bin/python3.13)
|
|
||||||
PYTHON_VER="3.13"
|
|
||||||
elif [ -f "/usr/bin/python3" ]; then
|
|
||||||
PYTHON_BIN=$(readlink -f /usr/bin/python3)
|
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
|
fi
|
||||||
|
|
||||||
if [ -n "$PYTHON_BIN" ] && [ -f "$PYTHON_BIN" ]; then
|
if [ -n "$PYTHON_BIN" ] && [ -f "$PYTHON_BIN" ]; then
|
||||||
|
|||||||
+5
-1
@@ -14,7 +14,6 @@ src/auto_update_setup.py
|
|||||||
src/backup_manager.py
|
src/backup_manager.py
|
||||||
src/base_odds_manager.py
|
src/base_odds_manager.py
|
||||||
src/cache/__init__.py
|
src/cache/__init__.py
|
||||||
src/cache/cache_metrics.py
|
|
||||||
src/cache/cache_strategy.py
|
src/cache/cache_strategy.py
|
||||||
src/cache/memory_cache.py
|
src/cache/memory_cache.py
|
||||||
src/common/__init__.py
|
src/common/__init__.py
|
||||||
@@ -36,8 +35,10 @@ src/common/sports_card.py
|
|||||||
src/common/sports_card_wrappers.py
|
src/common/sports_card_wrappers.py
|
||||||
src/common/sports_celebration.py
|
src/common/sports_celebration.py
|
||||||
src/common/sports_display_rules.py
|
src/common/sports_display_rules.py
|
||||||
|
src/common/sports_favorites.py
|
||||||
src/common/sports_fetch.py
|
src/common/sports_fetch.py
|
||||||
src/common/sports_font_path.py
|
src/common/sports_font_path.py
|
||||||
|
src/common/sports_game_over.py
|
||||||
src/common/sports_live_scroll.py
|
src/common/sports_live_scroll.py
|
||||||
src/common/sports_plugin_host.py
|
src/common/sports_plugin_host.py
|
||||||
src/common/sports_scroll.py
|
src/common/sports_scroll.py
|
||||||
@@ -47,6 +48,7 @@ src/config_service.py
|
|||||||
src/core_config_keys.py
|
src/core_config_keys.py
|
||||||
src/deprecation.py
|
src/deprecation.py
|
||||||
src/device_location.py
|
src/device_location.py
|
||||||
|
src/display_arbiter.py
|
||||||
src/display_geometry.py
|
src/display_geometry.py
|
||||||
src/dynamic_team_resolver.py
|
src/dynamic_team_resolver.py
|
||||||
src/exceptions.py
|
src/exceptions.py
|
||||||
@@ -57,6 +59,7 @@ src/ipc/contract.py
|
|||||||
src/ipc/server.py
|
src/ipc/server.py
|
||||||
src/logging_config.py
|
src/logging_config.py
|
||||||
src/logo_downloader.py
|
src/logo_downloader.py
|
||||||
|
src/malloc_tuning.py
|
||||||
src/matrix_support.py
|
src/matrix_support.py
|
||||||
src/pi5_matrix_support.py
|
src/pi5_matrix_support.py
|
||||||
src/plugin_system/__init__.py
|
src/plugin_system/__init__.py
|
||||||
@@ -87,6 +90,7 @@ src/plugin_system/testing/vegas.py
|
|||||||
src/plugin_system/vegas_elements.py
|
src/plugin_system/vegas_elements.py
|
||||||
src/redaction.py
|
src/redaction.py
|
||||||
src/scan_order.py
|
src/scan_order.py
|
||||||
|
src/screen_runner.py
|
||||||
src/startup_validator.py
|
src/startup_validator.py
|
||||||
src/vegas_mode/__init__.py
|
src/vegas_mode/__init__.py
|
||||||
src/vegas_mode/config.py
|
src/vegas_mode/config.py
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
files = src
|
files = src
|
||||||
exclude = (^|/)(test|__pycache__)/
|
exclude = (^|/)(test|__pycache__)/
|
||||||
|
|
||||||
# Python version
|
# Python version: the oldest the installer supports (Raspberry Pi OS
|
||||||
python_version = 3.10
|
# Bookworm ships 3.11; Trixie ships 3.13).
|
||||||
|
python_version = 3.11
|
||||||
|
|
||||||
# Platform (Linux/Raspberry Pi)
|
# Platform (Linux/Raspberry Pi)
|
||||||
platform = linux
|
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
|
# 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
|
# refuses to parse under python_version = 3.11 -- and 3.11 (Bookworm) is the
|
||||||
# code has to run on, so it stays. Treat numpy as Any instead: skip it, and
|
# 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.
|
# follow_imports_for_stubs makes the skip apply to its .pyi files too.
|
||||||
[mypy-numpy.*]
|
[mypy-numpy.*]
|
||||||
follow_imports = skip
|
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_;
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
||||||
pytest>=9.0.3,<10.0.0
|
pytest>=9.0.3,<10.0.0
|
||||||
pytest-cov>=4.1.0,<8.0.0
|
pytest-cov>=4.1.0,<8.0.0
|
||||||
pytest-mock>=3.11.0,<4.0.0
|
|
||||||
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
||||||
psutil>=6.0.0,<7.0.0 # optional at runtime; installed for tests so the
|
psutil>=6.0.0,<7.0.0 # optional at runtime; installed for tests so the
|
||||||
# /system/status endpoint's real path is exercised
|
# /system/status endpoint's real path is exercised
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# LEDMatrix Core Dependencies
|
# 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)
|
# Tested on Raspbian OS 12 (Bookworm) and 13 (Trixie)
|
||||||
|
|
||||||
# Image processing
|
# Image processing
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ project_dir = os.path.dirname(os.path.abspath(__file__))
|
|||||||
if project_dir not in sys.path:
|
if project_dir not in sys.path:
|
||||||
sys.path.insert(0, project_dir)
|
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
|
# 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
|
# 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
|
# it before anything slow is imported; the render loop narrows it again once
|
||||||
|
|||||||
+4
-8
@@ -15,7 +15,7 @@ display; **diagnostic** — run by hand on a Pi when something is wrong.
|
|||||||
| [`install/`](install/README.md) | keep | The installers: one-shot, services, sudoers/WiFi permissions, cache setup, and the shared `lib_*.sh` helpers `first_time_install.sh` sources |
|
| [`install/`](install/README.md) | keep | The installers: one-shot, services, sudoers/WiFi permissions, cache setup, and the shared `lib_*.sh` helpers `first_time_install.sh` sources |
|
||||||
| [`fix_perms/`](fix_perms/README.md) | keep | Permission repair scripts, plus the two root helpers the web interface runs through sudo (`safe_plugin_rm.sh`, `safe_pip_install.sh`) |
|
| [`fix_perms/`](fix_perms/README.md) | keep | Permission repair scripts, plus the two root helpers the web interface runs through sudo (`safe_plugin_rm.sh`, `safe_pip_install.sh`) |
|
||||||
| [`utils/`](utils/README.md) | keep | Scripts run by systemd units or the web interface (conditional web start, WiFi monitor, update verify, DNS fix, Pixlet config editor, cache clearing) |
|
| [`utils/`](utils/README.md) | keep | Scripts run by systemd units or the web interface (conditional web start, WiFi monitor, update verify, DNS fix, Pixlet config editor, cache clearing) |
|
||||||
| [`dev/`](dev/README.md) | dev-only | Plugin linking, emulator runner, Vegas density audit, Pillow smoke test |
|
| [`dev/`](dev/README.md) | dev-only | Plugin linking, Vegas density audit, Pillow smoke test |
|
||||||
| `templates/` | dev-only | `dev_preview.html`, the page `dev_server.py` serves |
|
| `templates/` | dev-only | `dev_preview.html`, the page `dev_server.py` serves |
|
||||||
|
|
||||||
## Top-level scripts
|
## Top-level scripts
|
||||||
@@ -43,22 +43,18 @@ display; **diagnostic** — run by hand on a Pi when something is wrong.
|
|||||||
| `scroll_speeds.py` | keep | Shows and tries the scroll speeds your panel can display cleanly |
|
| `scroll_speeds.py` | keep | Shows and tries the scroll speeds your panel can display cleanly |
|
||||||
| `sports_drift_report.py` | keep | Counts the different bodies of each method across the nine scoreboards in a `ledmatrix-plugins` checkout (report-only CI job; docs/SPORTS_UNIFICATION.md) |
|
| `sports_drift_report.py` | keep | Counts the different bodies of each method across the nine scoreboards in a `ledmatrix-plugins` checkout (report-only CI job; docs/SPORTS_UNIFICATION.md) |
|
||||||
| `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in |
|
| `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in |
|
||||||
| `update_plugin_repos.py` | dev-only | Pulls the latest `ledmatrix-plugins` monorepo |
|
|
||||||
| `verify_installation.sh` | diagnostic | Checks that an installation completed correctly |
|
| `verify_installation.sh` | diagnostic | Checks that an installation completed correctly |
|
||||||
| `verify_wifi_setup.sh` | diagnostic | Health check of the WiFi management setup |
|
| `verify_wifi_setup.sh` | diagnostic | Health check of the WiFi management setup |
|
||||||
|
|
||||||
## Candidates for removal
|
## Hand-run tools nothing else references
|
||||||
|
|
||||||
Nothing in the repo (docs, CI, tests, other scripts or code) refers to these.
|
Nothing in the repo (docs, CI, tests, other scripts or code) refers to these.
|
||||||
They are kept for now; each one needs an owner decision before it goes.
|
They are run by hand and were kept by owner decision (October 2026); the
|
||||||
|
old one-off schema fixers and WiFi test scripts listed here were removed.
|
||||||
|
|
||||||
| Script | What it does |
|
| Script | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `add_defaults_to_schemas.py` | One-off: adds missing `default` values to plugin config schemas |
|
|
||||||
| `analyze_plugin_schemas.py` | One-off: reports duplicate/inconsistent fields across plugin schemas |
|
|
||||||
| `audit_plugins.py` | AST security audit of plugin code; says it is "designed to run in CI" but no workflow runs it |
|
| `audit_plugins.py` | AST security audit of plugin code; says it is "designed to run in CI" but no workflow runs it |
|
||||||
| `audit_render_path.py` | Finds blocking calls reachable from a plugin's `display()` |
|
| `audit_render_path.py` | Finds blocking calls reachable from a plugin's `display()` |
|
||||||
| `sports_scroll_check.py` | Drives a sports scoreboard scroll on the panel and reports its pacing |
|
| `sports_scroll_check.py` | Drives a sports scoreboard scroll on the panel and reports its pacing |
|
||||||
| `test_captive_portal.sh` | Tests the captive portal from a device connected to the AP |
|
|
||||||
| `verify_wifi_before_testing.sh` | Pre-flight check before unplugging Ethernet to test WiFi |
|
|
||||||
| `dev/test_pillow_compat.py` | Pillow API smoke test to run after upgrading Pillow |
|
| `dev/test_pillow_compat.py` | Pillow API smoke test to run after upgrading Pillow |
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Script to add default values to plugin config schemas where missing.
|
|
||||||
|
|
||||||
This ensures that configs never start with None values, improving user experience
|
|
||||||
and preventing validation errors.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_for_field(prop: Dict[str, Any]) -> Any:
|
|
||||||
"""
|
|
||||||
Determine a sensible default value for a field based on its type and constraints.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prop: Field property schema
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Default value or None if no default should be added
|
|
||||||
"""
|
|
||||||
prop_type = prop.get('type')
|
|
||||||
|
|
||||||
# Handle union types (array with multiple types)
|
|
||||||
if isinstance(prop_type, list):
|
|
||||||
# Use the first non-null type
|
|
||||||
prop_type = next((t for t in prop_type if t != 'null'), prop_type[0] if prop_type else 'string')
|
|
||||||
|
|
||||||
if prop_type == 'boolean':
|
|
||||||
return False
|
|
||||||
|
|
||||||
elif prop_type == 'number':
|
|
||||||
# For numbers, use minimum if available, or a sensible default
|
|
||||||
minimum = prop.get('minimum')
|
|
||||||
maximum = prop.get('maximum')
|
|
||||||
|
|
||||||
if minimum is not None:
|
|
||||||
return minimum
|
|
||||||
elif maximum is not None:
|
|
||||||
# Use a reasonable fraction of max (like 30% or minimum 1)
|
|
||||||
return max(1, int(maximum * 0.3))
|
|
||||||
else:
|
|
||||||
# No constraints, use 0
|
|
||||||
return 0
|
|
||||||
|
|
||||||
elif prop_type == 'integer':
|
|
||||||
# Similar to number
|
|
||||||
minimum = prop.get('minimum')
|
|
||||||
maximum = prop.get('maximum')
|
|
||||||
|
|
||||||
if minimum is not None:
|
|
||||||
return minimum
|
|
||||||
elif maximum is not None:
|
|
||||||
return max(1, int(maximum * 0.3))
|
|
||||||
else:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
elif prop_type == 'string':
|
|
||||||
# Only add default for strings if it makes sense
|
|
||||||
# Check if there's an enum - use first value
|
|
||||||
enum_values = prop.get('enum')
|
|
||||||
if enum_values:
|
|
||||||
return enum_values[0]
|
|
||||||
|
|
||||||
# For optional string fields, empty string might be okay, but be cautious
|
|
||||||
# We'll skip adding defaults for strings unless explicitly needed
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif prop_type == 'array':
|
|
||||||
# Empty array as default
|
|
||||||
return []
|
|
||||||
|
|
||||||
elif prop_type == 'object':
|
|
||||||
# Empty object - but we'll handle nested objects separately
|
|
||||||
return {}
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def should_add_default(prop: Dict[str, Any], field_path: str) -> bool:
|
|
||||||
"""
|
|
||||||
Determine if we should add a default value to this field.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prop: Field property schema
|
|
||||||
field_path: Dot-separated path to the field
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if default should be added
|
|
||||||
"""
|
|
||||||
# Skip if already has a default
|
|
||||||
if 'default' in prop:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Skip secret fields (they should be user-provided)
|
|
||||||
if prop.get('x-secret', False):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Skip API keys and similar sensitive fields
|
|
||||||
field_name = field_path.split('.')[-1].lower()
|
|
||||||
sensitive_keywords = ['key', 'password', 'secret', 'token', 'auth', 'credential']
|
|
||||||
if any(keyword in field_name for keyword in sensitive_keywords):
|
|
||||||
return False
|
|
||||||
|
|
||||||
prop_type = prop.get('type')
|
|
||||||
if isinstance(prop_type, list):
|
|
||||||
prop_type = next((t for t in prop_type if t != 'null'), prop_type[0] if prop_type else None)
|
|
||||||
|
|
||||||
# Only add defaults for certain types
|
|
||||||
if prop_type in ('boolean', 'number', 'integer', 'array'):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# For strings, only if there's an enum
|
|
||||||
if prop_type == 'string' and 'enum' in prop:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def add_defaults_recursive(schema: Dict[str, Any], path: str = "", modified: List[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Recursively add default values to schema fields.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
schema: Schema dictionary to modify
|
|
||||||
path: Current path in the schema (for logging)
|
|
||||||
modified: List to track which fields were modified
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if any modifications were made
|
|
||||||
"""
|
|
||||||
if modified is None:
|
|
||||||
modified = []
|
|
||||||
|
|
||||||
if not isinstance(schema, dict) or 'properties' not in schema:
|
|
||||||
return False
|
|
||||||
|
|
||||||
changes_made = False
|
|
||||||
|
|
||||||
for key, prop in schema['properties'].items():
|
|
||||||
if not isinstance(prop, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
current_path = f"{path}.{key}" if path else key
|
|
||||||
|
|
||||||
# Check nested objects
|
|
||||||
if prop.get('type') == 'object' and 'properties' in prop:
|
|
||||||
if add_defaults_recursive(prop, current_path, modified):
|
|
||||||
changes_made = True
|
|
||||||
|
|
||||||
# Add default if appropriate
|
|
||||||
if should_add_default(prop, current_path):
|
|
||||||
default_value = get_default_for_field(prop)
|
|
||||||
if default_value is not None:
|
|
||||||
prop['default'] = default_value
|
|
||||||
modified.append(current_path)
|
|
||||||
changes_made = True
|
|
||||||
print(f" Added default to {current_path}: {default_value} (type: {prop.get('type')})")
|
|
||||||
|
|
||||||
return changes_made
|
|
||||||
|
|
||||||
|
|
||||||
def process_schema_file(schema_path: Path) -> bool:
|
|
||||||
"""
|
|
||||||
Process a single schema file to add defaults.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
schema_path: Path to the schema file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if file was modified
|
|
||||||
"""
|
|
||||||
print(f"\nProcessing: {schema_path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
|
||||||
schema = json.load(f)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Error reading schema: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
modified_fields = []
|
|
||||||
changes_made = add_defaults_recursive(schema, modified=modified_fields)
|
|
||||||
|
|
||||||
if changes_made:
|
|
||||||
# Write back with pretty formatting
|
|
||||||
with open(schema_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(schema, f, indent=2, ensure_ascii=False)
|
|
||||||
f.write('\n') # Add trailing newline
|
|
||||||
|
|
||||||
print(f" ✓ Modified {len(modified_fields)} fields")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(" ✓ No changes needed")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main entry point."""
|
|
||||||
project_root = Path(__file__).parent.parent
|
|
||||||
plugins_dir = project_root / 'plugin-repos'
|
|
||||||
|
|
||||||
if not plugins_dir.exists():
|
|
||||||
print(f"Error: Plugins directory not found: {plugins_dir}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Find all config_schema.json files
|
|
||||||
schema_files = list(plugins_dir.rglob('config_schema.json'))
|
|
||||||
|
|
||||||
if not schema_files:
|
|
||||||
print("No config_schema.json files found")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
print(f"Found {len(schema_files)} schema files")
|
|
||||||
|
|
||||||
modified_count = 0
|
|
||||||
for schema_file in sorted(schema_files):
|
|
||||||
if process_schema_file(schema_file):
|
|
||||||
modified_count += 1
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"Summary: Modified {modified_count} out of {len(schema_files)} schema files")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Analyze all plugin config schemas to identify issues:
|
|
||||||
- Duplicate fields
|
|
||||||
- Inconsistencies
|
|
||||||
- Missing common fields
|
|
||||||
- Naming variations
|
|
||||||
- Formatting issues
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Any
|
|
||||||
import jsonschema
|
|
||||||
from jsonschema import Draft7Validator
|
|
||||||
|
|
||||||
# Standard common fields that should be in all plugins
|
|
||||||
STANDARD_COMMON_FIELDS = {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": False,
|
|
||||||
"description": "Enable or disable this plugin",
|
|
||||||
"required": True,
|
|
||||||
"order": 1
|
|
||||||
},
|
|
||||||
"display_duration": {
|
|
||||||
"type": "number",
|
|
||||||
"default": 15,
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 300,
|
|
||||||
"description": "How long to display this plugin in seconds",
|
|
||||||
"order": 2
|
|
||||||
},
|
|
||||||
"live_priority": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": False,
|
|
||||||
"description": "Enable live priority takeover when plugin has live content",
|
|
||||||
"order": 3
|
|
||||||
},
|
|
||||||
"high_performance_transitions": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": False,
|
|
||||||
"description": "Use high-performance transitions (120 FPS) instead of standard (30 FPS)",
|
|
||||||
"order": 4
|
|
||||||
},
|
|
||||||
"update_interval": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 60,
|
|
||||||
"minimum": 1,
|
|
||||||
"description": "How often to refresh data in seconds",
|
|
||||||
"order": 5
|
|
||||||
},
|
|
||||||
"transition": {
|
|
||||||
"type": "object",
|
|
||||||
"order": 6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def find_duplicate_fields(schema: Dict[str, Any], path: str = "") -> List[str]:
|
|
||||||
"""Find duplicate field definitions within a schema."""
|
|
||||||
duplicates = []
|
|
||||||
seen_fields = {}
|
|
||||||
|
|
||||||
def check_properties(props: Dict[str, Any], current_path: str):
|
|
||||||
if not isinstance(props, dict):
|
|
||||||
return
|
|
||||||
|
|
||||||
for key, value in props.items():
|
|
||||||
full_path = f"{current_path}.{key}" if current_path else key
|
|
||||||
if key in seen_fields:
|
|
||||||
duplicates.append(f"Duplicate field '{key}' at {full_path} (also at {seen_fields[key]})")
|
|
||||||
else:
|
|
||||||
seen_fields[key] = full_path
|
|
||||||
|
|
||||||
# Recursively check nested objects
|
|
||||||
if isinstance(value, dict):
|
|
||||||
if "properties" in value:
|
|
||||||
check_properties(value["properties"], full_path)
|
|
||||||
elif "items" in value and isinstance(value["items"], dict):
|
|
||||||
if "properties" in value["items"]:
|
|
||||||
check_properties(value["items"]["properties"], f"{full_path}[items]")
|
|
||||||
|
|
||||||
if "properties" in schema:
|
|
||||||
check_properties(schema["properties"], "")
|
|
||||||
|
|
||||||
return duplicates
|
|
||||||
|
|
||||||
def validate_schema_syntax(schema_path: Path) -> tuple[bool, List[str]]:
|
|
||||||
"""Validate JSON Schema syntax."""
|
|
||||||
try:
|
|
||||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
|
||||||
schema = json.load(f)
|
|
||||||
|
|
||||||
# Validate schema structure
|
|
||||||
Draft7Validator.check_schema(schema)
|
|
||||||
return True, []
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
return False, [f"JSON syntax error: {str(e)}"]
|
|
||||||
except jsonschema.SchemaError as e:
|
|
||||||
return False, [f"Schema validation error: {str(e)}"]
|
|
||||||
except Exception as e:
|
|
||||||
return False, [f"Error: {str(e)}"]
|
|
||||||
|
|
||||||
def analyze_schema(schema_path: Path) -> Dict[str, Any]:
|
|
||||||
"""Analyze a single schema file."""
|
|
||||||
plugin_id = schema_path.parent.name
|
|
||||||
analysis = {
|
|
||||||
"plugin_id": plugin_id,
|
|
||||||
"path": str(schema_path),
|
|
||||||
"valid": False,
|
|
||||||
"errors": [],
|
|
||||||
"warnings": [],
|
|
||||||
"has_title": False,
|
|
||||||
"has_description": False,
|
|
||||||
"common_fields": {},
|
|
||||||
"missing_common_fields": [],
|
|
||||||
"naming_issues": [],
|
|
||||||
"duplicates": [],
|
|
||||||
"property_order": [],
|
|
||||||
"update_interval_variant": None
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
|
||||||
schema = json.load(f)
|
|
||||||
|
|
||||||
# Check for title and description
|
|
||||||
analysis["has_title"] = "title" in schema
|
|
||||||
analysis["has_description"] = "description" in schema
|
|
||||||
|
|
||||||
if not analysis["has_title"]:
|
|
||||||
analysis["warnings"].append("Missing 'title' field at root level")
|
|
||||||
if not analysis["has_description"]:
|
|
||||||
analysis["warnings"].append("Missing 'description' field at root level")
|
|
||||||
|
|
||||||
# Validate schema syntax
|
|
||||||
is_valid, errors = validate_schema_syntax(schema_path)
|
|
||||||
analysis["valid"] = is_valid
|
|
||||||
analysis["errors"].extend(errors)
|
|
||||||
|
|
||||||
if not is_valid:
|
|
||||||
return analysis
|
|
||||||
|
|
||||||
# Check for duplicate fields
|
|
||||||
duplicates = find_duplicate_fields(schema)
|
|
||||||
analysis["duplicates"] = duplicates
|
|
||||||
|
|
||||||
# Check properties
|
|
||||||
if "properties" not in schema:
|
|
||||||
analysis["errors"].append("Missing 'properties' field")
|
|
||||||
return analysis
|
|
||||||
|
|
||||||
properties = schema["properties"]
|
|
||||||
|
|
||||||
# Check common fields
|
|
||||||
for field_name, field_spec in STANDARD_COMMON_FIELDS.items():
|
|
||||||
if field_name in properties:
|
|
||||||
analysis["common_fields"][field_name] = properties[field_name]
|
|
||||||
else:
|
|
||||||
# Check for variants
|
|
||||||
if field_name == "update_interval":
|
|
||||||
# Check for update_interval_seconds variant
|
|
||||||
if "update_interval_seconds" in properties:
|
|
||||||
analysis["update_interval_variant"] = "update_interval_seconds"
|
|
||||||
analysis["naming_issues"].append(
|
|
||||||
"Uses 'update_interval_seconds' instead of 'update_interval'"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
analysis["missing_common_fields"].append(field_name)
|
|
||||||
else:
|
|
||||||
analysis["missing_common_fields"].append(field_name)
|
|
||||||
|
|
||||||
# Check property order (enabled should be first)
|
|
||||||
prop_keys = list(properties.keys())
|
|
||||||
analysis["property_order"] = prop_keys
|
|
||||||
|
|
||||||
if prop_keys and prop_keys[0] != "enabled":
|
|
||||||
analysis["warnings"].append(
|
|
||||||
f"'enabled' is not first property. First property is '{prop_keys[0]}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for required fields
|
|
||||||
required = schema.get("required", [])
|
|
||||||
if "enabled" not in required:
|
|
||||||
analysis["warnings"].append("'enabled' is not in required fields")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
analysis["errors"].append(f"Failed to analyze schema: {str(e)}")
|
|
||||||
|
|
||||||
return analysis
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main analysis function."""
|
|
||||||
project_root = Path(__file__).parent.parent
|
|
||||||
plugins_dir = project_root / "plugin-repos"
|
|
||||||
|
|
||||||
if not plugins_dir.exists():
|
|
||||||
print(f"Plugins directory not found: {plugins_dir}")
|
|
||||||
return
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
# Find all config_schema.json files
|
|
||||||
schema_files = list(plugins_dir.glob("*/config_schema.json"))
|
|
||||||
|
|
||||||
print(f"Found {len(schema_files)} plugin schemas to analyze\n")
|
|
||||||
|
|
||||||
for schema_path in sorted(schema_files):
|
|
||||||
print(f"Analyzing {schema_path.parent.name}...")
|
|
||||||
analysis = analyze_schema(schema_path)
|
|
||||||
results.append(analysis)
|
|
||||||
|
|
||||||
# Print summary
|
|
||||||
print("\n" + "="*80)
|
|
||||||
print("ANALYSIS SUMMARY")
|
|
||||||
print("="*80)
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
print(f"\n{result['plugin_id']}:")
|
|
||||||
print(f" Valid: {result['valid']}")
|
|
||||||
|
|
||||||
if result['errors']:
|
|
||||||
print(f" Errors ({len(result['errors'])}):")
|
|
||||||
for error in result['errors']:
|
|
||||||
print(f" - {error}")
|
|
||||||
|
|
||||||
if result['warnings']:
|
|
||||||
print(f" Warnings ({len(result['warnings'])}):")
|
|
||||||
for warning in result['warnings']:
|
|
||||||
print(f" - {warning}")
|
|
||||||
|
|
||||||
if result['duplicates']:
|
|
||||||
print(f" Duplicates ({len(result['duplicates'])}):")
|
|
||||||
for dup in result['duplicates']:
|
|
||||||
print(f" - {dup}")
|
|
||||||
|
|
||||||
if result['missing_common_fields']:
|
|
||||||
print(f" Missing common fields: {', '.join(result['missing_common_fields'])}")
|
|
||||||
|
|
||||||
if result['naming_issues']:
|
|
||||||
print(" Naming issues:")
|
|
||||||
for issue in result['naming_issues']:
|
|
||||||
print(f" - {issue}")
|
|
||||||
|
|
||||||
if result['property_order'] and result['property_order'][0] != 'enabled':
|
|
||||||
print(f" Property order: First is '{result['property_order'][0]}' (should be 'enabled')")
|
|
||||||
|
|
||||||
# Overall statistics
|
|
||||||
print("\n" + "="*80)
|
|
||||||
print("OVERALL STATISTICS")
|
|
||||||
print("="*80)
|
|
||||||
|
|
||||||
valid_count = sum(1 for r in results if r['valid'])
|
|
||||||
has_title_count = sum(1 for r in results if r['has_title'])
|
|
||||||
has_description_count = sum(1 for r in results if r['has_description'])
|
|
||||||
enabled_first_count = sum(1 for r in results if r['property_order'] and r['property_order'][0] == 'enabled')
|
|
||||||
total_errors = sum(len(r['errors']) for r in results)
|
|
||||||
total_warnings = sum(len(r['warnings']) for r in results)
|
|
||||||
total_duplicates = sum(len(r['duplicates']) for r in results)
|
|
||||||
|
|
||||||
print(f"Total plugins: {len(results)}")
|
|
||||||
print(f"Valid schemas: {valid_count}/{len(results)}")
|
|
||||||
print(f"Has title: {has_title_count}/{len(results)}")
|
|
||||||
print(f"Has description: {has_description_count}/{len(results)}")
|
|
||||||
print(f"'enabled' first: {enabled_first_count}/{len(results)}")
|
|
||||||
print(f"Total errors: {total_errors}")
|
|
||||||
print(f"Total warnings: {total_warnings}")
|
|
||||||
print(f"Total duplicates: {total_duplicates}")
|
|
||||||
|
|
||||||
# Save detailed report
|
|
||||||
report_path = project_root / "plugin_schema_analysis.json"
|
|
||||||
with open(report_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(results, f, indent=2)
|
|
||||||
|
|
||||||
print(f"\nDetailed report saved to: {report_path}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
||||||
@@ -53,26 +53,35 @@ else
|
|||||||
fi
|
fi
|
||||||
echo ""
|
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 "2. Checking Operating System Version..."
|
||||||
echo "---------------------------------------"
|
echo "---------------------------------------"
|
||||||
if [ -f /etc/os-release ]; then
|
OS_LIB="$(cd "$(dirname "$0")" && pwd)/install/lib_os.sh"
|
||||||
. /etc/os-release
|
OS_LIB_LOADED=0
|
||||||
echo "OS: $PRETTY_NAME"
|
OS_RELEASE=""
|
||||||
echo "Version ID: ${VERSION_ID:-unknown}"
|
if [ -f "$OS_LIB" ]; then
|
||||||
|
# shellcheck source=scripts/install/lib_os.sh
|
||||||
|
. "$OS_LIB"
|
||||||
|
OS_LIB_LOADED=1
|
||||||
|
fi
|
||||||
|
|
||||||
# first_time_install.sh refuses anything but Raspberry Pi OS / Debian 13
|
if [ "$OS_LIB_LOADED" = "0" ]; then
|
||||||
# (Trixie), so anything else is an error here too, not a warning.
|
print_error "$OS_LIB is missing - download LEDMatrix again"
|
||||||
if [[ "$ID" == "raspbian" ]] || [[ "$ID" == "debian" ]]; then
|
elif [ -r "$LM_OS_RELEASE_FILE" ]; then
|
||||||
if [ "${VERSION_ID:-0}" = "13" ]; then
|
OS_ID=$(lm_os_field ID)
|
||||||
print_success "Detected Debian 13 Trixie - supported"
|
OS_VERSION_ID=$(lm_os_field VERSION_ID)
|
||||||
elif [ "${VERSION_ID:-0}" = "12" ]; then
|
echo "OS: $(lm_os_field PRETTY_NAME)"
|
||||||
print_error "Debian 12 Bookworm is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
echo "Version ID: ${OS_VERSION_ID:-unknown}"
|
||||||
else
|
|
||||||
print_error "Debian/Raspbian ${VERSION_ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
# first_time_install.sh refuses anything else, so this is an error here
|
||||||
fi
|
# 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
|
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
|
fi
|
||||||
else
|
else
|
||||||
print_error "Could not detect OS version"
|
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)"
|
print_success "Kernel version is compatible (6.x or newer)"
|
||||||
|
|
||||||
if [ "$KERNEL_MAJOR" -eq "6" ] && [ "$KERNEL_MINOR" -ge "12" ]; then
|
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
|
fi
|
||||||
elif [ "$KERNEL_MAJOR" -eq "5" ] && [ "$KERNEL_MINOR" -ge "10" ]; then
|
elif [ "$KERNEL_MAJOR" -eq "5" ] && [ "$KERNEL_MINOR" -ge "10" ]; then
|
||||||
print_success "Kernel version is compatible (5.10+)"
|
print_success "Kernel version is compatible (5.10+)"
|
||||||
@@ -104,25 +113,34 @@ echo ""
|
|||||||
# Check Python version
|
# Check Python version
|
||||||
echo "4. Checking Python Version..."
|
echo "4. Checking Python Version..."
|
||||||
echo "-----------------------------"
|
echo "-----------------------------"
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
if [ "$OS_LIB_LOADED" = "1" ] && 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_VERSION=$(python3 -c 'import sys; print("%d.%d.%d" % sys.version_info[:3])')
|
||||||
PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)')
|
PYTHON_MINOR_VERSION=$(lm_python_version) || PYTHON_MINOR_VERSION=""
|
||||||
PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)')
|
PYTHON_RANGE="3.${LM_PYTHON_MIN_MINOR}-3.${LM_PYTHON_MAX_MINOR}"
|
||||||
|
|
||||||
echo "Python: $PYTHON_VERSION"
|
echo "Python: $PYTHON_VERSION"
|
||||||
|
|
||||||
if [ "$PYTHON_MAJOR" -eq "3" ]; then
|
case "$(lm_python_check "$PYTHON_MINOR_VERSION")" in
|
||||||
if [ "$PYTHON_MINOR" -ge "10" ] && [ "$PYTHON_MINOR" -le "13" ]; then
|
ok)
|
||||||
print_success "Python version is supported (3.10-3.13)"
|
print_success "Python version is supported ($PYTHON_RANGE)"
|
||||||
elif [ "$PYTHON_MINOR" -ge "14" ]; then
|
;;
|
||||||
print_warning "Python 3.${PYTHON_MINOR} is very new - some packages may not be compatible yet"
|
too-old)
|
||||||
else
|
# The rgbmatrix bindings declare requires-python >=3.11, so the
|
||||||
# Pillow 12 and the pinned test tools need 3.10+, so this won't install.
|
# display cannot be built on anything older.
|
||||||
print_error "Python 3.${PYTHON_MINOR} is too old - Python 3.10+ is required"
|
print_error "Python $PYTHON_MINOR_VERSION is too old - Python 3.${LM_PYTHON_MIN_MINOR}+ is required"
|
||||||
fi
|
;;
|
||||||
else
|
too-new)
|
||||||
print_error "Python 2.x detected - Python 3.10+ is required"
|
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
|
fi
|
||||||
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
|
print_warning "Cannot check the Python version without $OS_LIB"
|
||||||
else
|
else
|
||||||
print_error "Python 3 not found - installation required"
|
print_error "Python 3 not found - installation required"
|
||||||
fi
|
fi
|
||||||
@@ -268,6 +286,22 @@ if command -v ping >/dev/null 2>&1; then
|
|||||||
else
|
else
|
||||||
print_warning "Ping command not available - cannot verify network"
|
print_warning "Ping command not available - cannot verify network"
|
||||||
fi
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# Print summary
|
# Print summary
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ This directory contains scripts and utilities for development and testing.
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories
|
- **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories
|
||||||
- **`run_emulator.sh`** - Runs the LED Matrix display in emulator mode (for development without hardware)
|
|
||||||
- **`vegas_audit.py`** - Measures how much of the Vegas ticker strip actually shows content (dead-frame ratio)
|
- **`vegas_audit.py`** - Measures how much of the Vegas ticker strip actually shows content (dead-frame ratio)
|
||||||
- **`test_pillow_compat.py`** - Pillow API smoke test to run after upgrading Pillow (`python3 scripts/dev/test_pillow_compat.py`)
|
- **`test_pillow_compat.py`** - Pillow API smoke test to run after upgrading Pillow (`python3 scripts/dev/test_pillow_compat.py`)
|
||||||
|
|
||||||
@@ -28,6 +27,6 @@ links. To use a fork or another clone location, copy
|
|||||||
|
|
||||||
### Running Emulator
|
### Running Emulator
|
||||||
```bash
|
```bash
|
||||||
./scripts/dev/run_emulator.sh
|
python3 run.py -e
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# LEDMatrix Emulator Runner
|
|
||||||
# This script runs the LEDMatrix system in emulator mode for development and testing
|
|
||||||
|
|
||||||
echo "Starting LEDMatrix Emulator..."
|
|
||||||
echo "Press Ctrl+C to stop"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Set emulator mode
|
|
||||||
export EMULATOR=true
|
|
||||||
|
|
||||||
# Run the main application
|
|
||||||
python3 run.py
|
|
||||||
+51
-3
@@ -9,7 +9,10 @@ reports the difference. Nothing is stopped, restarted or drawn.
|
|||||||
python3 scripts/frame_soak.py
|
python3 scripts/frame_soak.py
|
||||||
|
|
||||||
# the same with the web preview open (the preview's PNG encodes are one of
|
# 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
|
python3 scripts/frame_soak.py --preview
|
||||||
|
|
||||||
# quick look at the totals since the service started
|
# quick look at the totals since the service started
|
||||||
@@ -27,9 +30,16 @@ What the numbers mean
|
|||||||
late frames frames that reached the panel one or more refreshes after they
|
late frames frames that reached the panel one or more refreshes after they
|
||||||
were due -- the panel showed the previous frame again, which on
|
were due -- the panel showed the previous frame again, which on
|
||||||
a moving strip is a visible hitch. This is the pass/fail number.
|
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,
|
blocking calls on the render thread. Reported, not failed on,
|
||||||
since some are handovers between plugins rather than faults.
|
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).
|
blit copying the frame into the matrix canvas (rgbmatrix SetImage).
|
||||||
Grows with width x height x pwm_bits.
|
Grows with width x height x pwm_bits.
|
||||||
wait blocked in SwapOnVSync, i.e. slack before the refresh.
|
wait blocked in SwapOnVSync, i.e. slack before the refresh.
|
||||||
@@ -58,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
|
#: 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.
|
#: DisplayManager._viewer_marker_path.
|
||||||
VIEWER_MARKER = "/tmp/led_matrix_preview_viewer" # nosec B108 - fixed path shared with the service
|
VIEWER_MARKER = "/tmp/led_matrix_preview_viewer" # nosec B108 - fixed path shared with the service
|
||||||
|
|
||||||
@@ -156,6 +167,29 @@ def op_rows(totals: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
|||||||
return rows
|
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]:
|
def build_report(before, after, preview: bool) -> Dict[str, Any]:
|
||||||
delta = diff(before, after)
|
delta = diff(before, after)
|
||||||
totals = delta["totals"]
|
totals = delta["totals"]
|
||||||
@@ -185,11 +219,15 @@ def build_report(before, after, preview: bool) -> Dict[str, Any]:
|
|||||||
"freezes": totals["freezes"],
|
"freezes": totals["freezes"],
|
||||||
"freezes_per_hour": round(totals["freezes"] / hours, 1) if hours else None,
|
"freezes_per_hour": round(totals["freezes"] / hours, 1) if hours else None,
|
||||||
"freeze_seconds": round(totals["freeze_seconds"], 2),
|
"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)
|
"worst_interval_ms": (round(totals["worst_interval_ms"], 1)
|
||||||
if totals["worst_interval_ms"] else None),
|
if totals["worst_interval_ms"] else None),
|
||||||
"timing_ms": {name: percentiles(h, bucket_ms)
|
"timing_ms": {name: percentiles(h, bucket_ms)
|
||||||
for name, h in delta["histograms"].items()},
|
for name, h in delta["histograms"].items()},
|
||||||
"ops": op_rows(totals),
|
"ops": op_rows(totals),
|
||||||
|
"gc": gc_window(before, after),
|
||||||
}
|
}
|
||||||
# The rate the panel held while rendering: the typical frame's interval
|
# 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
|
# per refresh held. A few percent under the idle rate is normal (the Pi is
|
||||||
@@ -240,6 +278,16 @@ def print_report(report: Dict[str, Any], limit: float) -> None:
|
|||||||
if report["freezes"]:
|
if report["freezes"]:
|
||||||
print(" by length: " + ", ".join(
|
print(" by length: " + ", ".join(
|
||||||
f"{k}: {v}" for k, v in report["freeze_by"].items()))
|
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()
|
||||||
print(f"{'ms':<18}{'p50':>8}{'p95':>8}{'p99':>8}{'max':>8}")
|
print(f"{'ms':<18}{'p50':>8}{'p95':>8}{'p99':>8}{'max':>8}")
|
||||||
for name in ("blit", "wait", "work", "interval_per_hold"):
|
for name in ("blit", "wait", "work", "interval_per_hold"):
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ This directory contains scripts for installing and configuring the LEDMatrix sys
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- **`one-shot-install.sh`** - Single-command installer; clones the
|
- **`one-shot-install.sh`** - Single-command installer; clones the
|
||||||
repo, checks prerequisites, then runs `first_time_install.sh`.
|
repo, checks out the newest release (or `main` with
|
||||||
Invoked via `curl ... | bash` from the project root README.
|
`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
|
- **`install_service.sh`** - Installs, enables and starts the display
|
||||||
service (`ledmatrix.service`), the web interface service
|
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
|
- **`install_web_service.sh`** - Installs only the web interface service
|
||||||
and the update-verify units (systemd)
|
and the update-verify units (systemd)
|
||||||
- **`install_wifi_monitor.sh`** - Installs the WiFi monitor daemon service
|
- **`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`
|
script that renders a unit from `systemd/*.service`
|
||||||
- **`lib_lowmem.sh`** - Build-job sizing and temporary swap for the C++
|
- **`lib_lowmem.sh`** - Build-job sizing and temporary swap for the C++
|
||||||
build on low-memory Pis (`first_time_install.sh` Step 6)
|
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
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ echo "- View system logs via journalctl"
|
|||||||
echo "- Reboot and shutdown the system"
|
echo "- Reboot and shutdown the system"
|
||||||
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
|
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 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 ""
|
echo ""
|
||||||
|
|
||||||
# Ask for confirmation
|
# Ask for confirmation
|
||||||
|
|||||||
@@ -143,6 +143,30 @@ for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path;
|
|||||||
fi
|
fi
|
||||||
done
|
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..."
|
echo "Reloading systemd daemon for web service..."
|
||||||
sudo systemctl daemon-reload
|
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.
|
# 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
|
# 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.
|
# 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
|
# 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).
|
# 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 *
|
$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
|
EOF
|
||||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
cat << EOF
|
cat << EOF
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
# LED Matrix One-Shot Installation Script
|
# LED Matrix One-Shot Installation Script
|
||||||
# This script provides a single-command installation experience
|
# 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
|
# 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
|
set -Eeuo pipefail
|
||||||
|
|
||||||
@@ -205,6 +209,114 @@ check_sudo() {
|
|||||||
print_success "Sudo access confirmed"
|
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 installation function
|
||||||
main() {
|
main() {
|
||||||
print_step "LED Matrix One-Shot Installation"
|
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)
|
# Try to safely update current branch first (fast-forward only to avoid unintended merges)
|
||||||
PULL_SUCCESS=false
|
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)"
|
print_success "Repository updated successfully (branch: $CURRENT_BRANCH)"
|
||||||
PULL_SUCCESS=true
|
PULL_SUCCESS=true
|
||||||
else
|
else
|
||||||
@@ -323,10 +438,12 @@ main() {
|
|||||||
rm -rf "$REPO_DIR"
|
rm -rf "$REPO_DIR"
|
||||||
print_success "Cloning repository..."
|
print_success "Cloning repository..."
|
||||||
retry git clone "$REPO_URL" "$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
|
fi
|
||||||
else
|
else
|
||||||
print_success "Cloning repository to $REPO_DIR..."
|
print_success "Cloning repository to $REPO_DIR..."
|
||||||
retry git clone "$REPO_URL" "$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
|
fi
|
||||||
|
|
||||||
# Verify repository is accessible
|
# Verify repository is accessible
|
||||||
@@ -397,6 +514,7 @@ main() {
|
|||||||
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 \
|
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 \
|
||||||
LEDMATRIX_APT_UPDATED="${LEDMATRIX_APT_UPDATED:-0}" \
|
LEDMATRIX_APT_UPDATED="${LEDMATRIX_APT_UPDATED:-0}" \
|
||||||
LEDMATRIX_AUTO_UPDATE="${LEDMATRIX_AUTO_UPDATE:-}" \
|
LEDMATRIX_AUTO_UPDATE="${LEDMATRIX_AUTO_UPDATE:-}" \
|
||||||
|
LEDMATRIX_CHANNEL="${LEDMATRIX_CHANNEL:-}" \
|
||||||
bash ./first_time_install.sh -y </dev/null
|
bash ./first_time_install.sh -y </dev/null
|
||||||
fi
|
fi
|
||||||
INSTALL_EXIT_CODE=$?
|
INSTALL_EXIT_CODE=$?
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ Alternative dependency installer that tries apt packages first,
|
|||||||
then falls back to pip with --break-system-packages
|
then falls back to pip with --break-system-packages
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import warnings
|
import warnings
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from pathlib import Path
|
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
|
# 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,
|
# 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).
|
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...")
|
print(f"Installing {package_name} via pip...")
|
||||||
success, output = _run([
|
success, output = _run([
|
||||||
sys.executable, '-m', 'pip', 'install',
|
sys.executable, '-m', 'pip', 'install',
|
||||||
@@ -99,26 +102,66 @@ IMPORT_NAME_MAP = {
|
|||||||
'freetype-py': 'freetype',
|
'freetype-py': 'freetype',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Minimum versions that must be met for an already-installed package to count
|
# The packages above are keyed by what main() lists; these are the ones whose
|
||||||
# as satisfied. Debian Bookworm's python3-freetype is 2.3.0, below the
|
# pip distribution name differs from that key.
|
||||||
# freetype-py>=2.5.1 pin in requirements.txt, so an import-only check would
|
DIST_NAME_MAP = {
|
||||||
# wrongly skip the pip upgrade.
|
'PIL': 'Pillow',
|
||||||
MIN_VERSIONS = {
|
|
||||||
'freetype-py': (2, 5, 1),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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:
|
def _installed_version_tuple(dist_name: str) -> tuple:
|
||||||
"""Return the installed distribution version as an int tuple, or () if unknown."""
|
"""Return the installed distribution version as an int tuple, or () if unknown."""
|
||||||
try:
|
try:
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
parts = []
|
return _version_tuple(version(dist_name))
|
||||||
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)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
@@ -134,9 +177,9 @@ def check_package_installed(package_name: str) -> bool:
|
|||||||
__import__(import_name)
|
__import__(import_name)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return False
|
return False
|
||||||
minimum = MIN_VERSIONS.get(package_name)
|
minimum = _minimum_version(package_name)
|
||||||
if minimum:
|
if minimum:
|
||||||
installed = _installed_version_tuple(package_name)
|
installed = _installed_version_tuple(_dist_name(package_name))
|
||||||
if not installed or installed < minimum:
|
if not installed or installed < minimum:
|
||||||
print(f"{package_name} is installed but below the required "
|
print(f"{package_name} is installed but below the required "
|
||||||
f"{'.'.join(map(str, minimum))}; will upgrade via pip")
|
f"{'.'.join(map(str, minimum))}; will upgrade via pip")
|
||||||
@@ -188,10 +231,11 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Try apt first, then pip. An apt install only counts if it also
|
# Try apt first, then pip. An apt install only counts if it also
|
||||||
# satisfies any minimum version (Debian's python3-freetype can be
|
# satisfies the requirements floor (the apt copies of most of these
|
||||||
# older than the freetype-py pin), otherwise fall through to pip.
|
# are older than the pins on both Bookworm and Trixie), otherwise
|
||||||
|
# fall through to pip.
|
||||||
ok, apt_output = install_via_apt(package)
|
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
|
ok = False
|
||||||
apt_output = f"apt version of {package} is below the required minimum"
|
apt_output = f"apt version of {package} is below the required minimum"
|
||||||
if not ok:
|
if not ok:
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ OWNER_RECEIVERS: Dict[str, Tuple[str, ...]] = {
|
|||||||
"DisplayManager": ("display_manager", "display_mgr", "displaymanager", "display", "dm"),
|
"DisplayManager": ("display_manager", "display_mgr", "displaymanager", "display", "dm"),
|
||||||
"FontManager": ("font_manager", "font_mgr", "fontmanager", "fonts", "fm"),
|
"FontManager": ("font_manager", "font_mgr", "fontmanager", "fonts", "fm"),
|
||||||
"PluginManager": ("plugin_manager", "plugin_mgr", "pluginmanager", "pm"),
|
"PluginManager": ("plugin_manager", "plugin_mgr", "pluginmanager", "pm"),
|
||||||
|
"PluginStateManager": ("state_manager", "plugin_state", "state_mgr"),
|
||||||
|
"ConfigManager": ("config_manager", "config_mgr", "configmanager"),
|
||||||
|
"LogoDownloader": ("logo_downloader", "downloader", "logodownloader"),
|
||||||
|
"APIHelper": ("api_helper", "apihelper", "api"),
|
||||||
|
"BackgroundDataService": ("background_service", "background_data_service", "bg_service",
|
||||||
|
"data_service"),
|
||||||
|
"BaseOddsManager": ("odds_manager", "oddsmanager", "odds"),
|
||||||
|
"DynamicTeamResolver": ("dynamic_resolver", "team_resolver", "resolver"),
|
||||||
}
|
}
|
||||||
|
|
||||||
#: Directories never scanned (vendored environments, VCS metadata, caches).
|
#: Directories never scanned (vendored environments, VCS metadata, caches).
|
||||||
@@ -330,8 +338,7 @@ class _Scanner(ast.NodeVisitor):
|
|||||||
return "call"
|
return "call"
|
||||||
definers = self.local_definers.get(m.method, ())
|
definers = self.local_definers.get(m.method, ())
|
||||||
if name in definers or self.built.get(name or "") in definers:
|
if name in definers or self.built.get(name or "") in definers:
|
||||||
# e.g. the weather plugin's WeatherIcons.draw_sun, or
|
# e.g. the weather plugin's WeatherIcons.draw_sun
|
||||||
# self._strategy_component = CacheStrategy(); ...get_sport_live_interval()
|
|
||||||
return "unrelated"
|
return "unrelated"
|
||||||
return "review"
|
return "review"
|
||||||
|
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ def main(argv=None) -> int:
|
|||||||
flush_interval=float("inf"),
|
flush_interval=float("inf"),
|
||||||
info=display._frame_timing_info(), # pylint: disable=protected-access
|
info=display._frame_timing_info(), # pylint: disable=protected-access
|
||||||
refresh_hz=idle_hz,
|
refresh_hz=idle_hz,
|
||||||
|
gc_monitor=frame_timing.install_gc_monitor(),
|
||||||
)
|
)
|
||||||
recorder.scrolling_now = display._scrolling_now # pylint: disable=protected-access
|
recorder.scrolling_now = display._scrolling_now # pylint: disable=protected-access
|
||||||
display.frame_timing = recorder
|
display.frame_timing = recorder
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Test script for captive portal functionality
|
|
||||||
# This script tests the captive portal from a device connected to the AP network
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
PI_IP="192.168.4.1"
|
|
||||||
PI_PORT="5000"
|
|
||||||
BASE_URL="http://${PI_IP}:${PI_PORT}"
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Captive Portal Functionality Test"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "Make sure you're connected to 'LEDMatrix-Setup' network"
|
|
||||||
echo "Pi IP: ${PI_IP}"
|
|
||||||
echo "Web Interface Port: ${PI_PORT}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Test counter
|
|
||||||
PASSED=0
|
|
||||||
FAILED=0
|
|
||||||
|
|
||||||
test_result() {
|
|
||||||
if [ $1 -eq 0 ]; then
|
|
||||||
echo -e "${GREEN}✓${NC} $2"
|
|
||||||
((PASSED++))
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗${NC} $2"
|
|
||||||
((FAILED++))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test 1: Check if Pi is reachable
|
|
||||||
echo "1. Testing Pi connectivity..."
|
|
||||||
if ping -c 1 -W 2 ${PI_IP} > /dev/null 2>&1; then
|
|
||||||
test_result 0 "Pi is reachable at ${PI_IP}"
|
|
||||||
else
|
|
||||||
test_result 1 "Pi is NOT reachable at ${PI_IP}"
|
|
||||||
echo " Make sure you're connected to LEDMatrix-Setup network"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 2: DNS Redirection
|
|
||||||
echo ""
|
|
||||||
echo "2. Testing DNS redirection..."
|
|
||||||
DNS_RESULT=$(nslookup google.com 2>/dev/null | grep -i "address" | tail -1 | awk '{print $2}')
|
|
||||||
if [ "$DNS_RESULT" = "${PI_IP}" ]; then
|
|
||||||
test_result 0 "DNS redirection works (google.com resolves to ${PI_IP})"
|
|
||||||
else
|
|
||||||
test_result 1 "DNS redirection failed (got ${DNS_RESULT}, expected ${PI_IP})"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 3: HTTP Redirect
|
|
||||||
echo ""
|
|
||||||
echo "3. Testing HTTP redirect..."
|
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -L --max-time 5 "${BASE_URL}/google.com" 2>/dev/null || echo "000")
|
|
||||||
if [ "$HTTP_CODE" = "200" ]; then
|
|
||||||
test_result 0 "HTTP redirect works (got 200, redirected to setup page)"
|
|
||||||
else
|
|
||||||
test_result 1 "HTTP redirect failed (got ${HTTP_CODE})"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 4: Captive Portal Detection Endpoints
|
|
||||||
echo ""
|
|
||||||
echo "4. Testing captive portal detection endpoints..."
|
|
||||||
|
|
||||||
# iOS/macOS
|
|
||||||
IOS_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/hotspot-detect.html" 2>/dev/null || echo "")
|
|
||||||
if echo "$IOS_RESPONSE" | grep -qi "success"; then
|
|
||||||
test_result 0 "iOS/macOS endpoint works"
|
|
||||||
else
|
|
||||||
test_result 1 "iOS/macOS endpoint failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Android
|
|
||||||
ANDROID_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "${BASE_URL}/generate_204" 2>/dev/null || echo "000")
|
|
||||||
if [ "$ANDROID_CODE" = "204" ]; then
|
|
||||||
test_result 0 "Android endpoint works"
|
|
||||||
else
|
|
||||||
test_result 1 "Android endpoint failed (got ${ANDROID_CODE})"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
WIN_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/connecttest.txt" 2>/dev/null || echo "")
|
|
||||||
if echo "$WIN_RESPONSE" | grep -qi "microsoft"; then
|
|
||||||
test_result 0 "Windows endpoint works"
|
|
||||||
else
|
|
||||||
test_result 1 "Windows endpoint failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Firefox
|
|
||||||
FF_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/success.txt" 2>/dev/null || echo "")
|
|
||||||
if echo "$FF_RESPONSE" | grep -qi "success"; then
|
|
||||||
test_result 0 "Firefox endpoint works"
|
|
||||||
else
|
|
||||||
test_result 1 "Firefox endpoint failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 5: API Endpoints (should NOT redirect)
|
|
||||||
echo ""
|
|
||||||
echo "5. Testing API endpoints (should work normally)..."
|
|
||||||
API_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/api/v3/wifi/status" 2>/dev/null || echo "")
|
|
||||||
if echo "$API_RESPONSE" | grep -qi "status"; then
|
|
||||||
test_result 0 "API endpoints work (not redirected)"
|
|
||||||
else
|
|
||||||
test_result 1 "API endpoints failed or were redirected"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 6: Main Interface (should be accessible)
|
|
||||||
echo ""
|
|
||||||
echo "6. Testing main interface accessibility..."
|
|
||||||
MAIN_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "${BASE_URL}/v3" 2>/dev/null || echo "000")
|
|
||||||
if [ "$MAIN_CODE" = "200" ]; then
|
|
||||||
test_result 0 "Main interface is accessible"
|
|
||||||
else
|
|
||||||
test_result 1 "Main interface failed (got ${MAIN_CODE})"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Test Summary"
|
|
||||||
echo "=========================================="
|
|
||||||
echo -e "${GREEN}Passed: ${PASSED}${NC}"
|
|
||||||
echo -e "${RED}Failed: ${FAILED}${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ $FAILED -eq 0 ]; then
|
|
||||||
echo -e "${GREEN}All tests passed! Captive portal is working correctly.${NC}"
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}Some tests failed. Check the output above for details.${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "Troubleshooting tips:"
|
|
||||||
echo "1. Verify AP mode is active: sudo systemctl status hostapd"
|
|
||||||
echo "2. Check dnsmasq config: sudo cat /etc/dnsmasq.conf"
|
|
||||||
echo "3. Check web interface logs: sudo journalctl -u ledmatrix-web -n 50"
|
|
||||||
echo "4. Verify you're connected to LEDMatrix-Setup network"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Update the ledmatrix-plugins monorepo by pulling latest changes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
MONOREPO_DIR = Path(__file__).parent.parent.parent / "ledmatrix-plugins"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if not MONOREPO_DIR.exists():
|
|
||||||
print(f"Error: Monorepo not found: {MONOREPO_DIR}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if not (MONOREPO_DIR / ".git").exists():
|
|
||||||
print(f"Error: {MONOREPO_DIR} is not a git repository")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print(f"Updating {MONOREPO_DIR}...")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "-C", str(MONOREPO_DIR), "pull"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
print(f"Error: git pull timed out after 120 seconds for {MONOREPO_DIR}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(result.stdout.strip())
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
print(f"Error: {result.stderr.strip()}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -15,7 +15,8 @@ The updater leaves data/auto_update_pending.json:
|
|||||||
|
|
||||||
{"status": "pending", "old_head": ..., "new_head": ...,
|
{"status": "pending", "old_head": ..., "new_head": ...,
|
||||||
"old_ref": "main" | "" (detached) | absent (older updaters),
|
"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",
|
This moves its status to "verifying" and then to one of "success",
|
||||||
"rolled_back" or "rollback_failed", with "reason" and "detail" saying why.
|
"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
|
#: ...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.
|
#: 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')
|
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
|
#: 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
|
#: (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
|
_WAIT_WORST_SECONDS = (HEALTH_TIMEOUT_SECONDS + STABLE_SECONDS + WEB_CHECK_TIMEOUT_SECONDS
|
||||||
+ 2 * SYSTEMCTL_QUERY_TIMEOUT_SECONDS + POLL_SECONDS)
|
+ 2 * SYSTEMCTL_QUERY_TIMEOUT_SECONDS + POLL_SECONDS)
|
||||||
WORST_CASE_SECONDS = (2 * (2 * RESTART_TIMEOUT_SECONDS + _WAIT_WORST_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
|
#: What a command that could not run at all reports: its callers only read
|
||||||
#: these three fields, the same ones a completed subprocess has.
|
#: these three fields, the same ones a completed subprocess has.
|
||||||
@@ -300,12 +306,26 @@ class Verifier:
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return False, (f'"git reset --hard {old}" failed: '
|
return False, (f'"git reset --hard {old}" failed: '
|
||||||
f'{(result.stderr or result.stdout or "").strip()}')
|
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
|
deadline = self.clock() + PIP_BUDGET_SECONDS
|
||||||
failed = [rel for rel in requirements if not self.install_requirements(rel, deadline)]
|
failed = [rel for rel in requirements if not self.install_requirements(rel, deadline)]
|
||||||
if failed:
|
if failed:
|
||||||
return True, ('reinstalling the previous dependencies from ' + ', '.join(failed)
|
notes.append('reinstalling the previous dependencies from ' + ', '.join(failed)
|
||||||
+ ' failed; run Install Base Requirements from the Tools tab')
|
+ ' failed; run Install Base Requirements from the Tools tab')
|
||||||
return True, ''
|
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 -------------------------------------------------
|
# -- the check itself -------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Pre-Testing WiFi Verification Script
|
|
||||||
# Run this BEFORE disconnecting Ethernet to ensure WiFi is ready
|
|
||||||
|
|
||||||
# Don't use set -e as it can cause premature exits with arithmetic operations
|
|
||||||
# Instead, we'll check return codes explicitly where needed
|
|
||||||
set -u # Fail on undefined variables
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo "WiFi Pre-Testing Verification"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "This script verifies WiFi is enabled and working"
|
|
||||||
echo "before you disconnect Ethernet for captive portal testing."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Colors
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
# Check counter
|
|
||||||
PASSED=0
|
|
||||||
FAILED=0
|
|
||||||
WARNINGS=0
|
|
||||||
|
|
||||||
check_result() {
|
|
||||||
local result=$1
|
|
||||||
local message=$2
|
|
||||||
if [ $result -eq 0 ]; then
|
|
||||||
echo -e "${GREEN}✓${NC} $message"
|
|
||||||
PASSED=$((PASSED + 1))
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗${NC} $message"
|
|
||||||
FAILED=$((FAILED + 1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
warn_result() {
|
|
||||||
local message=$2
|
|
||||||
echo -e "${YELLOW}⚠${NC} $message"
|
|
||||||
WARNINGS=$((WARNINGS + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check 1: WiFi interface exists
|
|
||||||
echo "1. Checking WiFi interface..."
|
|
||||||
if ip link show wlan0 > /dev/null 2>&1; then
|
|
||||||
check_result 0 "WiFi interface wlan0 exists"
|
|
||||||
else
|
|
||||||
check_result 1 "WiFi interface wlan0 NOT found"
|
|
||||||
echo " → Check if WiFi adapter is connected"
|
|
||||||
echo " → Run: lsusb (for USB WiFi) or check built-in WiFi"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check 2: WiFi radio is enabled
|
|
||||||
echo ""
|
|
||||||
echo "2. Checking WiFi radio status..."
|
|
||||||
WIFI_STATUS=$(nmcli radio wifi 2>/dev/null || echo "unknown")
|
|
||||||
if echo "$WIFI_STATUS" | grep -qi "enabled"; then
|
|
||||||
check_result 0 "WiFi radio is enabled"
|
|
||||||
elif echo "$WIFI_STATUS" | grep -qi "disabled"; then
|
|
||||||
check_result 1 "WiFi radio is DISABLED"
|
|
||||||
echo " → Enabling WiFi..."
|
|
||||||
sudo nmcli radio wifi on
|
|
||||||
sleep 2
|
|
||||||
if nmcli radio wifi | grep -qi "enabled"; then
|
|
||||||
check_result 0 "WiFi radio enabled successfully"
|
|
||||||
else
|
|
||||||
check_result 1 "Failed to enable WiFi radio"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn_result 1 "Could not determine WiFi radio status"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check 3: WiFi can scan for networks
|
|
||||||
echo ""
|
|
||||||
echo "3. Testing WiFi scanning capability..."
|
|
||||||
SCAN_RESULT=$(timeout 10 nmcli device wifi list 2>&1 | head -5)
|
|
||||||
if [ $? -eq 0 ] && [ -n "$SCAN_RESULT" ]; then
|
|
||||||
NETWORK_COUNT=$(echo "$SCAN_RESULT" | wc -l)
|
|
||||||
if [ "$NETWORK_COUNT" -gt 1 ]; then
|
|
||||||
check_result 0 "WiFi scanning works (found networks)"
|
|
||||||
echo " Sample networks found:"
|
|
||||||
echo "$SCAN_RESULT" | head -3 | sed 's/^/ /'
|
|
||||||
else
|
|
||||||
warn_result 1 "WiFi scanning works but no networks found"
|
|
||||||
echo " → This might be okay if you're in a remote location"
|
|
||||||
echo " → Make sure you can see networks when you need to connect"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
check_result 1 "WiFi scanning FAILED"
|
|
||||||
echo " → WiFi adapter may not be working properly"
|
|
||||||
echo " → Check: dmesg | grep -i wifi"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check 4: Current network connections
|
|
||||||
echo ""
|
|
||||||
echo "4. Checking current network status..."
|
|
||||||
ETH_STATUS=$(nmcli device status | grep "ethernet" | grep -v "unavailable" | head -1 || echo "")
|
|
||||||
WIFI_STATUS=$(nmcli device status | grep "wifi" | head -1 || echo "")
|
|
||||||
|
|
||||||
if echo "$ETH_STATUS" | grep -q "connected"; then
|
|
||||||
ETH_NAME=$(echo "$ETH_STATUS" | awk '{print $1}')
|
|
||||||
ETH_IP=$(ip addr show $ETH_NAME 2>/dev/null | grep "inet " | awk '{print $2}' | cut -d/ -f1 | head -1)
|
|
||||||
check_result 0 "Ethernet is connected ($ETH_NAME)"
|
|
||||||
if [ -n "$ETH_IP" ]; then
|
|
||||||
echo " Ethernet IP: $ETH_IP"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn_result 1 "Ethernet is NOT connected"
|
|
||||||
echo " → You may already be on WiFi only"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if echo "$WIFI_STATUS" | grep -q "connected"; then
|
|
||||||
WIFI_NAME=$(echo "$WIFI_STATUS" | awk '{print $1}')
|
|
||||||
WIFI_IP=$(ip addr show $WIFI_NAME 2>/dev/null | grep "inet " | awk '{print $2}' | cut -d/ -f1 | head -1)
|
|
||||||
WIFI_SSID=$(nmcli -t -f active,ssid dev wifi | grep "^yes:" | cut -d: -f2 | head -1)
|
|
||||||
check_result 0 "WiFi is connected ($WIFI_NAME)"
|
|
||||||
if [ -n "$WIFI_SSID" ]; then
|
|
||||||
echo " Connected to: $WIFI_SSID"
|
|
||||||
fi
|
|
||||||
if [ -n "$WIFI_IP" ]; then
|
|
||||||
echo " WiFi IP: $WIFI_IP"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo " ⚠ You are already connected via WiFi!"
|
|
||||||
echo " → You may want to disconnect WiFi first to test captive portal"
|
|
||||||
echo " → Or test from a different device"
|
|
||||||
else
|
|
||||||
if echo "$WIFI_STATUS" | grep -q "disconnected"; then
|
|
||||||
check_result 0 "WiFi is disconnected (ready for AP mode)"
|
|
||||||
else
|
|
||||||
warn_result 1 "WiFi status unclear"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check 5: Internet connectivity test
|
|
||||||
echo ""
|
|
||||||
echo "5. Testing internet connectivity..."
|
|
||||||
if ping -c 2 -W 3 8.8.8.8 > /dev/null 2>&1; then
|
|
||||||
check_result 0 "Internet connectivity working"
|
|
||||||
echo " → You have internet access via current connection"
|
|
||||||
else
|
|
||||||
warn_result 1 "No internet connectivity detected"
|
|
||||||
echo " → This might be okay if you're testing in isolation"
|
|
||||||
echo " → But you won't be able to download packages if needed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check 6: Saved WiFi connections
|
|
||||||
echo ""
|
|
||||||
echo "6. Checking saved WiFi connections..."
|
|
||||||
SAVED_CONNECTIONS=$(nmcli connection show | grep -i wifi | wc -l)
|
|
||||||
if [ "$SAVED_CONNECTIONS" -gt 0 ]; then
|
|
||||||
check_result 0 "Found $SAVED_CONNECTIONS saved WiFi connection(s)"
|
|
||||||
echo " Saved connections:"
|
|
||||||
nmcli connection show | grep -i wifi | awk '{print " - " $1}' | head -5
|
|
||||||
echo ""
|
|
||||||
echo " → You can reconnect using: sudo nmcli connection up <name>"
|
|
||||||
else
|
|
||||||
warn_result 1 "No saved WiFi connections found"
|
|
||||||
echo " → Make sure you know your WiFi SSID and password"
|
|
||||||
echo " → You'll need them to reconnect after testing"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check 7: Required services
|
|
||||||
echo ""
|
|
||||||
echo "7. Checking required services..."
|
|
||||||
if systemctl is-active --quiet hostapd 2>/dev/null; then
|
|
||||||
warn_result 1 "hostapd is already running (AP mode may be active)"
|
|
||||||
else
|
|
||||||
check_result 0 "hostapd service is stopped (normal)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if systemctl is-active --quiet dnsmasq 2>/dev/null; then
|
|
||||||
warn_result 1 "dnsmasq is already running (AP mode may be active)"
|
|
||||||
else
|
|
||||||
check_result 0 "dnsmasq service is stopped (normal)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check 8: WiFi monitor service
|
|
||||||
echo ""
|
|
||||||
echo "8. Checking WiFi monitor service..."
|
|
||||||
if systemctl is-active --quiet ledmatrix-wifi-monitor 2>/dev/null; then
|
|
||||||
check_result 0 "WiFi monitor service is running"
|
|
||||||
else
|
|
||||||
warn_result 1 "WiFi monitor service is NOT running"
|
|
||||||
echo " → Start with: sudo systemctl start ledmatrix-wifi-monitor"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Verification Summary"
|
|
||||||
echo "=========================================="
|
|
||||||
echo -e "${GREEN}Passed: ${PASSED}${NC}"
|
|
||||||
echo -e "${YELLOW}Warnings: ${WARNINGS}${NC}"
|
|
||||||
echo -e "${RED}Failed: ${FAILED}${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ $FAILED -eq 0 ]; then
|
|
||||||
if [ $WARNINGS -eq 0 ]; then
|
|
||||||
echo -e "${GREEN}✓ All checks passed! WiFi is ready for testing.${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo "1. You can safely disconnect Ethernet"
|
|
||||||
echo "2. Enable AP mode to test captive portal"
|
|
||||||
echo "3. Use emergency_reconnect.sh if you need to reconnect"
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}⚠ Checks passed with warnings.${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "WiFi appears ready, but review warnings above."
|
|
||||||
echo "You can proceed with testing, but be aware of the warnings."
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗ Some checks failed. Please fix issues before testing.${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "Do NOT disconnect Ethernet until all issues are resolved!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
|||||||
Core source package for the LED Matrix Display project.
|
Core source package for the LED Matrix Display project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.8.0"
|
__version__ = "3.8.3"
|
||||||
|
|
||||||
|
|||||||
+34
-12
@@ -28,6 +28,7 @@ freetype.Face, so it drops straight into DisplayManager.draw_text().
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import weakref
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
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
|
# a plugin fitting changing text (a live game clock, a ticker) on a
|
||||||
# 24/7 service would otherwise grow this without bound.
|
# 24/7 service would otherwise grow this without bound.
|
||||||
self._fit_cache: "OrderedDict[Any, FitResult]" = OrderedDict()
|
self._fit_cache: "OrderedDict[Any, FitResult]" = OrderedDict()
|
||||||
# LRU-bounded (images are big). Entries hold a strong reference to
|
# LRU-bounded (images are big). An id()-keyed entry watches its
|
||||||
# the source image when keyed by id() so the id can't be recycled
|
# source image through a weak reference and is dropped when the
|
||||||
# out from under the cache.
|
# 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()
|
self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict()
|
||||||
|
|
||||||
_IMAGE_CACHE_MAX = 64
|
_IMAGE_CACHE_MAX = 64
|
||||||
@@ -536,8 +538,15 @@ class LayoutContext:
|
|||||||
cached per (image, box size, options) for this panel size.
|
cached per (image, box size, options) for this panel size.
|
||||||
|
|
||||||
Prefer a stable ``cache_key`` (e.g. "logo:KC") for images that get
|
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
|
reloaded — the default id()-based key misses across reloads of the
|
||||||
source image) but misses across reloads of the same content.
|
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
|
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,
|
key = ("image", identity, img.size, box_w, box_h, mode,
|
||||||
crop_to_ink, anchor, resample_name, upscale)
|
crop_to_ink, anchor, resample_name, upscale)
|
||||||
|
|
||||||
cached = self._image_cache.get(key)
|
cache = self._image_cache
|
||||||
if cached is not None:
|
cached = cache.get(key)
|
||||||
self._image_cache.move_to_end(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]
|
return cached[0]
|
||||||
|
|
||||||
result = _fit_image(img, (box_w, box_h), mode=mode,
|
result = _fit_image(img, (box_w, box_h), mode=mode,
|
||||||
crop_to_ink=crop_to_ink, anchor=anchor,
|
crop_to_ink=crop_to_ink, anchor=anchor,
|
||||||
resample=resample, upscale=upscale)
|
resample=resample, upscale=upscale)
|
||||||
# Pin the source only for id()-keyed entries (see docstring).
|
source = None
|
||||||
self._image_cache[key] = (result, img if cache_key is None else None)
|
if cache_key is None:
|
||||||
while len(self._image_cache) > self._IMAGE_CACHE_MAX:
|
def _forget(ref: Any, key: Any = key) -> None:
|
||||||
self._image_cache.popitem(last=False)
|
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
|
return result
|
||||||
|
|
||||||
# ---- text utilities ------------------------------------------------
|
# ---- text utilities ------------------------------------------------
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from src.common.fetch_service import (
|
|||||||
plugin_scope,
|
plugin_scope,
|
||||||
share_connection_pool,
|
share_connection_pool,
|
||||||
)
|
)
|
||||||
|
from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload
|
||||||
from src.common.espn_dates import (
|
from src.common.espn_dates import (
|
||||||
RANGE_RETRY_SECONDS,
|
RANGE_RETRY_SECONDS,
|
||||||
_note_range_rejected,
|
_note_range_rejected,
|
||||||
@@ -42,6 +43,7 @@ from src.common.espn_dates import (
|
|||||||
fetch_espn_date_chunks,
|
fetch_espn_date_chunks,
|
||||||
parse_espn_date_range,
|
parse_espn_date_range,
|
||||||
)
|
)
|
||||||
|
from src.deprecation import deprecated
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -83,6 +85,10 @@ class FetchRequest:
|
|||||||
# the cache with the callbacks suppressed -- joiners waiting forever for a
|
# the cache with the callbacks suppressed -- joiners waiting forever for a
|
||||||
# fetch that did, in fact, succeed.
|
# fetch that did, in fact, succeed.
|
||||||
commit_claimed: bool = False
|
commit_claimed: bool = False
|
||||||
|
# Trim an ESPN scoreboard response before it is cached and delivered
|
||||||
|
# (src/common/espn_payload.py). Set by whoever created the request; a
|
||||||
|
# submitter that joins the fetch gets the same payload.
|
||||||
|
slim_payload: bool = True
|
||||||
result: Optional[Any] = None
|
result: Optional[Any] = None
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
# The plugin that submitted the request, so the fetch service counts the
|
# The plugin that submitted the request, so the fetch service counts the
|
||||||
@@ -249,7 +255,8 @@ class BackgroundDataService:
|
|||||||
timeout: Optional[int] = None,
|
timeout: Optional[int] = None,
|
||||||
max_retries: int = 3,
|
max_retries: int = 3,
|
||||||
priority: int = 1,
|
priority: int = 1,
|
||||||
callback: Optional[Callable] = None) -> str:
|
callback: Optional[Callable] = None,
|
||||||
|
slim_payload: bool = True) -> str:
|
||||||
"""
|
"""
|
||||||
Submit a background fetch request.
|
Submit a background fetch request.
|
||||||
|
|
||||||
@@ -265,6 +272,11 @@ class BackgroundDataService:
|
|||||||
priority: Accepted for compatibility and ignored; requests run in
|
priority: Accepted for compatibility and ignored; requests run in
|
||||||
submission order.
|
submission order.
|
||||||
callback: Optional callback function when request completes
|
callback: Optional callback function when request completes
|
||||||
|
slim_payload: Drop the parts of an ESPN scoreboard response no
|
||||||
|
scoreboard reads (stat leaders, athlete cards, links,
|
||||||
|
headlines, highlights) before caching it; see
|
||||||
|
src/common/espn_payload.py. Only ESPN /scoreboard URLs are
|
||||||
|
touched. Pass False to cache the response whole.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Request ID for tracking the fetch operation
|
Request ID for tracking the fetch operation
|
||||||
@@ -336,6 +348,7 @@ class BackgroundDataService:
|
|||||||
priority=priority,
|
priority=priority,
|
||||||
callback=callback,
|
callback=callback,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
slim_payload=slim_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -497,6 +510,13 @@ class BackgroundDataService:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# Most of an ESPN scoreboard response is never drawn, and the
|
||||||
|
# cached copy stays parsed in the memory tier while it is fresh.
|
||||||
|
# Trimmed before the write so the cache, request.result and the
|
||||||
|
# callbacks all see the same payload. See src/common/espn_payload.py.
|
||||||
|
if request.slim_payload and is_espn_scoreboard_url(request.url):
|
||||||
|
slim_scoreboard_payload(data)
|
||||||
|
|
||||||
# Cache the data
|
# Cache the data
|
||||||
self.cache_manager.set(request.cache_key, data)
|
self.cache_manager.set(request.cache_key, data)
|
||||||
|
|
||||||
@@ -679,6 +699,7 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
raise last_exception
|
raise last_exception
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "pass callback= to submit_fetch_request()")
|
||||||
def get_result(self, request_id: str) -> Optional[FetchResult]:
|
def get_result(self, request_id: str) -> Optional[FetchResult]:
|
||||||
"""
|
"""
|
||||||
Get the result of a fetch request.
|
Get the result of a fetch request.
|
||||||
@@ -695,6 +716,7 @@ class BackgroundDataService:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self.completed_requests.get(request_id)
|
return self.completed_requests.get(request_id)
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "pass callback= to submit_fetch_request()")
|
||||||
def is_request_complete(self, request_id: str) -> bool:
|
def is_request_complete(self, request_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a request has completed.
|
Check if a request has completed.
|
||||||
@@ -711,6 +733,7 @@ class BackgroundDataService:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return request_id in self.completed_requests
|
return request_id in self.completed_requests
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "pass callback= to submit_fetch_request()")
|
||||||
def get_request_status(self, request_id: str) -> Optional[FetchStatus]:
|
def get_request_status(self, request_id: str) -> Optional[FetchStatus]:
|
||||||
"""
|
"""
|
||||||
Get the status of a fetch request.
|
Get the status of a fetch request.
|
||||||
|
|||||||
@@ -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
|
The plugins are the ``manifest.json`` files in the configured plugin
|
||||||
directory (see :func:`_plugins_directory`), with the manifest's version;
|
directory (see :func:`_plugins_directory`), with the manifest's version;
|
||||||
``enabled`` is config.json's flag by the display's rule (a missing flag
|
``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
|
is disabled). A restore installs each listed plugin that is missing and
|
||||||
state from the restored config.json, so ``enabled`` is informational.
|
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
|
``data/plugin_state.json`` is not read: it only ever repeated config's
|
||||||
enabled flags and the manifests' versions, and is retired (nothing
|
enabled flags and the manifests' versions, and is retired (nothing
|
||||||
|
|||||||
+29
-14
@@ -20,6 +20,8 @@ from typing import Dict, Any, Optional, List, cast
|
|||||||
|
|
||||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||||
from src.common.fetch_service import fetch_get, share_connection_pool
|
from src.common.fetch_service import fetch_get, share_connection_pool
|
||||||
|
from src.common.json_body import response_json
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ class BaseOddsManager:
|
|||||||
if _is_no_odds_marker(cached_data):
|
if _is_no_odds_marker(cached_data):
|
||||||
self.logger.debug("Cached no-odds marker for %s", cache_key)
|
self.logger.debug("Cached no-odds marker for %s", cache_key)
|
||||||
return None
|
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
|
return cached_data
|
||||||
|
|
||||||
if time.monotonic() < self._skip_network_until:
|
if time.monotonic() < self._skip_network_until:
|
||||||
@@ -159,7 +161,7 @@ class BaseOddsManager:
|
|||||||
self._skip_network_until - time.monotonic())
|
self._skip_network_until - time.monotonic())
|
||||||
return None
|
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:
|
try:
|
||||||
# Map league names to ESPN API format
|
# Map league names to ESPN API format
|
||||||
@@ -173,23 +175,30 @@ class BaseOddsManager:
|
|||||||
|
|
||||||
espn_league = league_mapping.get(league, league)
|
espn_league = league_mapping.get(league, league)
|
||||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
||||||
self.logger.debug(f"Requesting odds from URL: {url}")
|
self.logger.debug("Requesting odds from URL: %s", url)
|
||||||
|
|
||||||
response = fetch_get(self.session, 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()
|
response.raise_for_status()
|
||||||
raw_data = response.json()
|
raw_data = response_json(response)
|
||||||
|
|
||||||
self._skip_network_until = 0.0 # reachable again
|
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)
|
odds_data = self._extract_espn_data(raw_data)
|
||||||
if odds_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.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:
|
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
|
# Cache the absence too, so the game is not re-requested
|
||||||
# on every update until the interval passes.
|
# on every update until the interval passes.
|
||||||
self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)
|
self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)
|
||||||
@@ -223,12 +232,12 @@ class BaseOddsManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Formatted odds data dictionary or None
|
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"]:
|
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]
|
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
|
# The ESPN API returns odds data directly in the item, not in a
|
||||||
# providers array. ESPN sends explicit JSON nulls for absent
|
# providers array. ESPN sends explicit JSON nulls for absent
|
||||||
@@ -251,13 +260,17 @@ class BaseOddsManager:
|
|||||||
.get("pointSpread") or {}).get("value")
|
.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
|
return extracted_data
|
||||||
|
|
||||||
# Check if this is a valid empty response or an unexpected structure
|
# 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"] == []:
|
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
|
# 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
|
return None
|
||||||
else:
|
else:
|
||||||
# This is an unexpected response structure
|
# This is an unexpected response structure
|
||||||
@@ -265,6 +278,7 @@ class BaseOddsManager:
|
|||||||
self.logger.warning(f"Unexpected response structure: {json.dumps(data, indent=2)}")
|
self.logger.warning(f"Unexpected response structure: {json.dumps(data, indent=2)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "call get_odds() for each game")
|
||||||
def get_odds_for_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def get_odds_for_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Fetch odds for multiple games efficiently.
|
Fetch odds for multiple games efficiently.
|
||||||
@@ -323,6 +337,7 @@ class BaseOddsManager:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def format_odds_summary(self, odds_data: Optional[Dict[str, Any]]) -> str:
|
def format_odds_summary(self, odds_data: Optional[Dict[str, Any]]) -> str:
|
||||||
"""
|
"""
|
||||||
Format odds data into a human-readable summary.
|
Format odds data into a human-readable summary.
|
||||||
|
|||||||
Vendored
-1
@@ -5,6 +5,5 @@ Provides specialized cache components:
|
|||||||
- MemoryCache: In-memory caching
|
- MemoryCache: In-memory caching
|
||||||
- DiskCache: Persistent disk caching
|
- DiskCache: Persistent disk caching
|
||||||
- CacheStrategy: Cache strategy management
|
- CacheStrategy: Cache strategy management
|
||||||
- CacheMetrics: Performance metrics tracking
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
Vendored
-134
@@ -1,134 +0,0 @@
|
|||||||
"""
|
|
||||||
Cache Metrics
|
|
||||||
|
|
||||||
Tracks cache performance metrics including hit rates, miss rates, and fetch times.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class CacheMetrics:
|
|
||||||
"""Tracks cache performance metrics."""
|
|
||||||
|
|
||||||
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
|
|
||||||
"""
|
|
||||||
Initialize cache metrics tracker.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
logger: Optional logger instance
|
|
||||||
"""
|
|
||||||
self.logger = logger or logging.getLogger(__name__)
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._metrics: Dict[str, Any] = {
|
|
||||||
'hits': 0,
|
|
||||||
'misses': 0,
|
|
||||||
'api_calls_saved': 0,
|
|
||||||
'background_hits': 0,
|
|
||||||
'background_misses': 0,
|
|
||||||
'total_fetch_time': 0.0,
|
|
||||||
'fetch_count': 0,
|
|
||||||
# Disk cleanup metrics
|
|
||||||
'last_disk_cleanup': 0.0,
|
|
||||||
'total_files_cleaned': 0,
|
|
||||||
'total_space_freed_mb': 0.0,
|
|
||||||
'last_cleanup_duration_sec': 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
def record_hit(self, cache_type: str = 'regular') -> None:
|
|
||||||
"""
|
|
||||||
Record a cache hit.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cache_type: Type of cache hit ('regular' or 'background')
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
if cache_type == 'background':
|
|
||||||
self._metrics['background_hits'] += 1
|
|
||||||
else:
|
|
||||||
self._metrics['hits'] += 1
|
|
||||||
|
|
||||||
def record_miss(self, cache_type: str = 'regular') -> None:
|
|
||||||
"""
|
|
||||||
Record a cache miss.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cache_type: Type of cache miss ('regular' or 'background')
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
if cache_type == 'background':
|
|
||||||
self._metrics['background_misses'] += 1
|
|
||||||
else:
|
|
||||||
self._metrics['misses'] += 1
|
|
||||||
self._metrics['api_calls_saved'] += 1
|
|
||||||
|
|
||||||
def record_fetch_time(self, duration: float) -> None:
|
|
||||||
"""
|
|
||||||
Record fetch operation duration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
duration: Duration in seconds
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
self._metrics['total_fetch_time'] += duration
|
|
||||||
self._metrics['fetch_count'] += 1
|
|
||||||
|
|
||||||
def record_disk_cleanup(self, files_cleaned: int, space_freed_mb: float, duration_sec: float) -> None:
|
|
||||||
"""
|
|
||||||
Record disk cleanup operation results.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
files_cleaned: Number of files deleted
|
|
||||||
space_freed_mb: Space freed in megabytes
|
|
||||||
duration_sec: Duration of cleanup operation in seconds
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
self._metrics['last_disk_cleanup'] = time.time()
|
|
||||||
self._metrics['total_files_cleaned'] += files_cleaned
|
|
||||||
self._metrics['total_space_freed_mb'] += space_freed_mb
|
|
||||||
self._metrics['last_cleanup_duration_sec'] = duration_sec
|
|
||||||
|
|
||||||
def get_metrics(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get current cache performance metrics.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with cache metrics
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
total_hits = self._metrics['hits'] + self._metrics['background_hits']
|
|
||||||
total_misses = self._metrics['misses'] + self._metrics['background_misses']
|
|
||||||
total_requests = total_hits + total_misses
|
|
||||||
|
|
||||||
avg_fetch_time = (self._metrics['total_fetch_time'] /
|
|
||||||
self._metrics['fetch_count']) if self._metrics['fetch_count'] > 0 else 0.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
'total_requests': total_requests,
|
|
||||||
'cache_hit_rate': total_hits / total_requests if total_requests > 0 else 0.0,
|
|
||||||
'background_hit_rate': (self._metrics['background_hits'] /
|
|
||||||
(self._metrics['background_hits'] + self._metrics['background_misses'])
|
|
||||||
if (self._metrics['background_hits'] + self._metrics['background_misses']) > 0 else 0.0),
|
|
||||||
'api_calls_saved': self._metrics['api_calls_saved'],
|
|
||||||
'average_fetch_time': avg_fetch_time,
|
|
||||||
'total_fetch_time': self._metrics['total_fetch_time'],
|
|
||||||
'fetch_count': self._metrics['fetch_count'],
|
|
||||||
# Disk cleanup metrics
|
|
||||||
'last_disk_cleanup': self._metrics['last_disk_cleanup'],
|
|
||||||
'total_files_cleaned': self._metrics['total_files_cleaned'],
|
|
||||||
'total_space_freed_mb': self._metrics['total_space_freed_mb'],
|
|
||||||
'last_cleanup_duration_sec': self._metrics['last_cleanup_duration_sec']
|
|
||||||
}
|
|
||||||
|
|
||||||
def log_metrics(self) -> None:
|
|
||||||
"""Log current cache performance metrics."""
|
|
||||||
metrics = self.get_metrics()
|
|
||||||
self.logger.info("Cache Performance - Hit Rate: %.2f%%, Background Hit Rate: %.2f%%, "
|
|
||||||
"API Calls Saved: %d, Avg Fetch Time: %.2fs",
|
|
||||||
metrics['cache_hit_rate'] * 100,
|
|
||||||
metrics['background_hit_rate'] * 100,
|
|
||||||
metrics['api_calls_saved'],
|
|
||||||
metrics['average_fetch_time'])
|
|
||||||
|
|
||||||
Vendored
+7
-34
@@ -4,7 +4,6 @@ Cache Strategy
|
|||||||
Manages cache strategies (TTLs) for different data types.
|
Manages cache strategies (TTLs) for different data types.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import pytz
|
import pytz
|
||||||
@@ -13,51 +12,25 @@ import pytz
|
|||||||
class CacheStrategy:
|
class CacheStrategy:
|
||||||
"""Manages cache strategies for different data types."""
|
"""Manages cache strategies for different data types."""
|
||||||
|
|
||||||
def __init__(self, config_manager: Optional[Any] = None, logger: Optional[logging.Logger] = None) -> None:
|
|
||||||
"""
|
|
||||||
Initialize cache strategy manager.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_manager: Optional ConfigManager instance. Kept for callers
|
|
||||||
that pass one; no strategy currently reads it.
|
|
||||||
logger: Optional logger instance
|
|
||||||
"""
|
|
||||||
self.config_manager = config_manager
|
|
||||||
self.logger = logger or logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def get_sport_live_interval(self, sport_key: str) -> int:
|
|
||||||
"""
|
|
||||||
Live-data cache interval, in seconds, for a sport: 60 for every sport.
|
|
||||||
|
|
||||||
This used to read ``live_update_interval`` from a ``<sport>_scoreboard``
|
|
||||||
config section. Those sections belonged to the built-in scoreboards
|
|
||||||
that the plugin system replaced; plugin config is keyed by plugin id
|
|
||||||
(``football-scoreboard``), so the lookup always fell back to 60.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sport_key: Sport identifier (e.g., 'nba', 'nfl')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Live update interval in seconds
|
|
||||||
"""
|
|
||||||
return 60
|
|
||||||
|
|
||||||
def get_cache_strategy(self, data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]:
|
def get_cache_strategy(self, data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get cache strategy for different data types.
|
Get cache strategy for different data types.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data_type: Type of data (e.g., 'live_scores', 'stocks', 'weather_current')
|
data_type: Type of data (e.g., 'live_scores', 'stocks', 'weather_current')
|
||||||
sport_key: Optional sport key; for live data it selects the
|
sport_key: Optional sport key; for live data any sport key
|
||||||
per-sport interval from :meth:`get_sport_live_interval`
|
selects a 60s interval instead of the generic live default.
|
||||||
instead of the generic live default.
|
(That used to be a per-sport ``live_update_interval`` from
|
||||||
|
``<sport>_scoreboard`` config sections, which belonged to the
|
||||||
|
built-in scoreboards the plugin system replaced, so every
|
||||||
|
lookup fell back to 60.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with cache strategy (max_age, memory_ttl, etc.)
|
Dictionary with cache strategy (max_age, memory_ttl, etc.)
|
||||||
"""
|
"""
|
||||||
live_interval = None
|
live_interval = None
|
||||||
if sport_key and data_type in ['sports_live', 'live_scores']:
|
if sport_key and data_type in ['sports_live', 'live_scores']:
|
||||||
live_interval = self.get_sport_live_interval(sport_key)
|
live_interval = 60
|
||||||
|
|
||||||
strategies = {
|
strategies = {
|
||||||
# Ultra time-sensitive data (live scores, current weather)
|
# Ultra time-sensitive data (live scores, current weather)
|
||||||
|
|||||||
Vendored
+220
-55
@@ -4,6 +4,7 @@ Disk Cache
|
|||||||
Handles persistent disk-based caching with atomic writes and error recovery.
|
Handles persistent disk-based caching with atomic writes and error recovery.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@@ -14,11 +15,14 @@ import tempfile
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import zlib
|
import zlib
|
||||||
from typing import Dict, Any, Optional, Protocol
|
from typing import TYPE_CHECKING, Dict, Any, Optional, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from src.common.path_safety import safe_path_component
|
from src.common.path_safety import safe_path_component
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.cache.cache_strategy import CacheStrategy
|
||||||
|
|
||||||
try: # optional: large speedup on the cache write path, see _dumps below
|
try: # optional: large speedup on the cache write path, see _dumps below
|
||||||
import orjson
|
import orjson
|
||||||
except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
||||||
@@ -31,22 +35,34 @@ except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
|||||||
# useful, and a half-written file was never useful.
|
# useful, and a half-written file was never useful.
|
||||||
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
|
_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
|
||||||
|
|
||||||
|
|
||||||
class CacheStrategyProtocol(Protocol):
|
def _filename_stem(key: str) -> str:
|
||||||
"""Protocol for cache strategy objects that categorize cache keys."""
|
"""The filename stem for a key that is already a safe path component.
|
||||||
|
|
||||||
def get_data_type_from_key(self, key: str) -> str:
|
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
|
||||||
Determine the data type from a cache key.
|
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
|
||||||
Args:
|
share a long prefix apart. The result is itself short, so a stem read back
|
||||||
key: Cache key
|
from a filename -- which is how the web UI names a key it deletes -- maps to
|
||||||
|
the same file.
|
||||||
Returns:
|
"""
|
||||||
Data type string for strategy lookup
|
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 DateTimeEncoder(json.JSONEncoder):
|
class DateTimeEncoder(json.JSONEncoder):
|
||||||
@@ -111,18 +127,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.
|
"""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
|
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
|
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)
|
match = _HEAD_RE.match(head)
|
||||||
if not match:
|
if not match:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
timestamp = float(match.group(1))
|
timestamp = _effective_timestamp(float(match.group(1)), mtime)
|
||||||
limit = max_age
|
limit = max_age
|
||||||
if match.group(2) is not None:
|
if match.group(2) is not None:
|
||||||
ttl = float(match.group(2))
|
ttl = float(match.group(2))
|
||||||
@@ -179,7 +268,7 @@ else:
|
|||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# The display service runs as root and the web interface as the installing
|
# The display service runs as root and the web interface as the installing
|
||||||
# user, and the web interface reads records only the display writes
|
# 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.
|
# 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
|
# The installers rely on the directory's setgid bit to set that group. That is
|
||||||
@@ -248,11 +337,14 @@ class DiskCache:
|
|||||||
self.cache_dir = cache_dir
|
self.cache_dir = cache_dir
|
||||||
self.logger = logger or logging.getLogger(__name__)
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
# key -> adler32 of the last payload successfully written to the
|
# key -> what set() last put at the primary cache path: the adler32 of
|
||||||
# primary cache path; lets set() skip rewriting identical data
|
# the payload (less a header-first timestamp), the timestamp the file
|
||||||
# (per-process only — worst case another process rewrites, never
|
# holds (None for records without one), and the file's inode and size.
|
||||||
# a missed write). Guarded by _lock.
|
# Lets set() skip rewriting identical data. Per-process only, and the
|
||||||
self._write_digests: Dict[str, int] = {}
|
# 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]:
|
def get_cache_path(self, key: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
@@ -267,6 +359,8 @@ class DiskCache:
|
|||||||
derives them), so rejecting anything with a path component turns
|
derives them), so rejecting anything with a path component turns
|
||||||
away only inputs that could never have been written here.
|
away only inputs that could never have been written here.
|
||||||
|
|
||||||
|
A key too long to be a filename is shortened by _filename_stem.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Cache key
|
key: Cache key
|
||||||
|
|
||||||
@@ -280,7 +374,7 @@ class DiskCache:
|
|||||||
if safe_key is None:
|
if safe_key is None:
|
||||||
self.logger.warning("Rejected unsafe cache key %r", key)
|
self.logger.warning("Rejected unsafe cache key %r", key)
|
||||||
return None
|
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]]:
|
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -301,31 +395,40 @@ class DiskCache:
|
|||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
with open(cache_path, 'rb') as f:
|
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
|
# Decide staleness from the header before paying for the
|
||||||
# parse. A stale read is the common case for the biggest
|
# parse. A stale read is the common case for the biggest
|
||||||
# records (a season schedule is re-fetched when its cache
|
# records (a season schedule is re-fetched when its cache
|
||||||
# expires), and parsing 53MB to throw it away held the GIL
|
# expires), and parsing 53MB to throw it away held the GIL
|
||||||
# for ~1.8s -- a visible freeze on the panel.
|
# 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
|
return None
|
||||||
f.seek(0)
|
f.seek(0)
|
||||||
record = _loads(f.read())
|
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
|
record_ts = None
|
||||||
if isinstance(record, dict):
|
if isinstance(record, dict):
|
||||||
record_ts = record.get('timestamp')
|
record_ts = record.get('timestamp')
|
||||||
if record_ts is None:
|
if record_ts is None:
|
||||||
try:
|
record_ts = mtime
|
||||||
record_ts = os.path.getmtime(cache_path)
|
else:
|
||||||
except OSError:
|
embedded_ts = _record_timestamp(record_ts)
|
||||||
record_ts = None
|
if embedded_ts is None:
|
||||||
|
try:
|
||||||
if record_ts is not None:
|
record_ts = float(record_ts)
|
||||||
try:
|
except (TypeError, ValueError):
|
||||||
record_ts = float(record_ts)
|
record_ts = None
|
||||||
except (TypeError, ValueError):
|
else:
|
||||||
record_ts = None
|
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()
|
now = time.time()
|
||||||
|
|
||||||
@@ -403,7 +506,12 @@ class DiskCache:
|
|||||||
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
digest = zlib.adler32(payload)
|
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:
|
try:
|
||||||
# Atomic write to avoid partial/corrupt files
|
# Atomic write to avoid partial/corrupt files
|
||||||
@@ -411,16 +519,10 @@ class DiskCache:
|
|||||||
# Skip the disk entirely when this exact payload was already
|
# Skip the disk entirely when this exact payload was already
|
||||||
# written for this key (plugins re-save unchanged API data
|
# written for this key (plugins re-save unchanged API data
|
||||||
# every update cycle — each write is real SD-card wear).
|
# every update cycle — each write is real SD-card wear).
|
||||||
# Refresh the file mtime so records that rely on it for TTL
|
# A metadata touch is journal-cheap compared to rewriting
|
||||||
# (no embedded 'timestamp') don't expire early; a metadata
|
# the data.
|
||||||
# touch is journal-cheap compared to rewriting the data.
|
if self._skip_unchanged(key, cache_path, digest, timestamp):
|
||||||
if self._write_digests.get(key) == digest:
|
return
|
||||||
try:
|
|
||||||
os.utime(cache_path, None)
|
|
||||||
return
|
|
||||||
except OSError:
|
|
||||||
# File vanished or perms changed — fall through and write
|
|
||||||
self._write_digests.pop(key, None)
|
|
||||||
|
|
||||||
tmp_dir = os.path.dirname(cache_path)
|
tmp_dir = os.path.dirname(cache_path)
|
||||||
# Try to create temp file in cache directory first
|
# Try to create temp file in cache directory first
|
||||||
@@ -458,7 +560,7 @@ class DiskCache:
|
|||||||
# opened it in between was refused.
|
# opened it in between was refused.
|
||||||
_share_open_file(tmp_file.fileno(), _shared_group(tmp_dir))
|
_share_open_file(tmp_file.fileno(), _shared_group(tmp_dir))
|
||||||
os.replace(tmp_path, cache_path)
|
os.replace(tmp_path, cache_path)
|
||||||
self._write_digests[key] = digest
|
self._remember_write(key, cache_path, digest, timestamp)
|
||||||
finally:
|
finally:
|
||||||
if os.path.exists(tmp_path):
|
if os.path.exists(tmp_path):
|
||||||
try:
|
try:
|
||||||
@@ -471,13 +573,13 @@ class DiskCache:
|
|||||||
with open(cache_path, 'wb') as cache_file:
|
with open(cache_path, 'wb') as cache_file:
|
||||||
cache_file.write(payload)
|
cache_file.write(payload)
|
||||||
_share_open_file(cache_file.fileno(), _shared_group(tmp_dir))
|
_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)
|
self.logger.debug("Wrote cache for %s directly (non-atomic)", key)
|
||||||
except (IOError, OSError, PermissionError) as write_error:
|
except (IOError, OSError, PermissionError) as write_error:
|
||||||
# If direct write also fails, try fallback location
|
# 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)
|
self.logger.warning("Direct write failed for key '%s' to %s: %s", key, cache_path, write_error)
|
||||||
raise # Re-raise to trigger fallback logic
|
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
|
# Attempt one-time fallback write to user's home cache directory
|
||||||
try:
|
try:
|
||||||
# Try user's home cache directory as fallback
|
# Try user's home cache directory as fallback
|
||||||
@@ -503,11 +605,14 @@ class DiskCache:
|
|||||||
self.logger.debug("Fallback cache write also failed for key '%s': %s", key, e2)
|
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
|
# 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(
|
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.",
|
"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
|
return # Exit gracefully without raising exception
|
||||||
|
|
||||||
@@ -520,6 +625,66 @@ class DiskCache:
|
|||||||
)
|
)
|
||||||
return # Exit gracefully without raising exception
|
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:
|
def clear(self, key: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Clear cache entry or all entries.
|
Clear cache entry or all entries.
|
||||||
@@ -637,12 +802,12 @@ class DiskCache:
|
|||||||
# mkstemp's random component.
|
# mkstemp's random component.
|
||||||
return bool(sep) and len(head) > 1 and bool(suffix)
|
return bool(sep) and len(head) > 1 and bool(suffix)
|
||||||
|
|
||||||
def cleanup_expired_files(self, cache_strategy: CacheStrategyProtocol, retention_policies: Dict[str, int]) -> Dict[str, Any]:
|
def cleanup_expired_files(self, cache_strategy: 'CacheStrategy', retention_policies: Dict[str, int]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Clean up expired cache files based on retention policies.
|
Clean up expired cache files based on retention policies.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cache_strategy: Object implementing CacheStrategyProtocol for categorizing files
|
cache_strategy: Categorizes files by key (get_data_type_from_key)
|
||||||
retention_policies: Dict mapping data types to retention days
|
retention_policies: Dict mapping data types to retention days
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
+139
-21
@@ -28,20 +28,86 @@ import os
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import pytz
|
import pytz
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import tempfile
|
import tempfile
|
||||||
from src.cache.memory_cache import MemoryCache, default_max_size
|
from src.cache.memory_cache import MemoryCache, default_max_size
|
||||||
from src.cache.disk_cache import DiskCache
|
from src.cache.disk_cache import DiskCache
|
||||||
from src.cache.cache_strategy import CacheStrategy
|
from src.cache.cache_strategy import CacheStrategy
|
||||||
from src.cache.cache_metrics import CacheMetrics
|
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
# Canonical implementation lives in src.cache.disk_cache; re-exported here
|
# Canonical implementation lives in src.cache.disk_cache; re-exported here
|
||||||
# because this module's docstring documents it and external code may import
|
# because this module's docstring documents it and external code may import
|
||||||
# it from either path.
|
# it from either path.
|
||||||
from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-export
|
from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-export
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
_NOT_SEEN: Any = object()
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxWatch:
|
||||||
|
"""Tells the poller of a mailbox key whether its file changed since the
|
||||||
|
last look, from one stat() (:meth:`CacheManager.file_signature`).
|
||||||
|
|
||||||
|
The display polls the mailboxes the web interface falls back to. Reading
|
||||||
|
one is an open and a JSON parse; with this a poll that finds the same file
|
||||||
|
(or none) costs a stat, and the file is read only after a new write. A
|
||||||
|
cache without ``file_signature`` (a test double) is read every time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, key: str):
|
||||||
|
self.key = key
|
||||||
|
self._seen: Any = _NOT_SEEN
|
||||||
|
|
||||||
|
def changed(self, cache_manager: Any) -> bool:
|
||||||
|
"""True when the poller should read the key now."""
|
||||||
|
signature = getattr(cache_manager, 'file_signature', None)
|
||||||
|
sig = signature(self.key) if callable(signature) else _NOT_SEEN
|
||||||
|
if sig is not None and not isinstance(sig, tuple):
|
||||||
|
return True # cannot tell: read it
|
||||||
|
if sig is None:
|
||||||
|
self._seen = None
|
||||||
|
return False # no file, nothing to read
|
||||||
|
if sig == self._seen:
|
||||||
|
return False
|
||||||
|
self._seen = sig
|
||||||
|
return True
|
||||||
|
|
||||||
|
def forget(self) -> None:
|
||||||
|
"""Read the key on the next poll even if its file has not changed
|
||||||
|
(the last read failed)."""
|
||||||
|
self._seen = _NOT_SEEN
|
||||||
|
|
||||||
|
|
||||||
class CacheManager:
|
class CacheManager:
|
||||||
"""Manages caching of API responses to reduce API calls."""
|
"""Manages caching of API responses to reduce API calls."""
|
||||||
@@ -73,22 +139,17 @@ class CacheManager:
|
|||||||
self.logger.error("Could not find or create a writable cache directory. Caching will be disabled.")
|
self.logger.error("Could not find or create a writable cache directory. Caching will be disabled.")
|
||||||
self.cache_dir = None
|
self.cache_dir = None
|
||||||
|
|
||||||
# Initialize config manager for sport-specific intervals
|
# The config manager is built on first use of self.config_manager; see
|
||||||
try:
|
# the property. Nothing in the cache reads it any more.
|
||||||
from src.config_manager import ConfigManager
|
self._config_manager: Any = _UNSET
|
||||||
self.config_manager: Optional[Any] = ConfigManager()
|
self._config_manager_lock = threading.Lock()
|
||||||
self.config_manager.load_config()
|
|
||||||
except ImportError:
|
|
||||||
self.config_manager: Optional[Any] = None
|
|
||||||
self.logger.warning("ConfigManager not available, using default cache intervals")
|
|
||||||
|
|
||||||
# Initialize cache components using composition
|
# Initialize cache components using composition
|
||||||
self._memory_cache_component = MemoryCache(
|
self._memory_cache_component = MemoryCache(
|
||||||
max_size=default_max_size(), cleanup_interval=300.0
|
max_size=default_max_size(), cleanup_interval=300.0
|
||||||
)
|
)
|
||||||
self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger)
|
self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger)
|
||||||
self._strategy_component = CacheStrategy(config_manager=self.config_manager, logger=self.logger)
|
self._strategy_component = CacheStrategy()
|
||||||
self._metrics_component = CacheMetrics(logger=self.logger)
|
|
||||||
|
|
||||||
# Disk cleanup configuration
|
# Disk cleanup configuration
|
||||||
self._disk_cleanup_interval_hours = 24 # Run cleanup every 24 hours
|
self._disk_cleanup_interval_hours = 24 # Run cleanup every 24 hours
|
||||||
@@ -115,6 +176,44 @@ class CacheManager:
|
|||||||
if self.cache_dir:
|
if self.cache_dir:
|
||||||
self.start_cleanup_thread()
|
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]:
|
def _get_writable_cache_dir(self) -> Optional[str]:
|
||||||
"""Tries to find or create a writable cache directory, preferring a system path when available."""
|
"""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)
|
# Attempt 1: System-wide persistent cache directory (preferred for services)
|
||||||
@@ -231,6 +330,24 @@ class CacheManager:
|
|||||||
"""Get the path for a cache file."""
|
"""Get the path for a cache file."""
|
||||||
return self._disk_cache_component.get_cache_path(key)
|
return self._disk_cache_component.get_cache_path(key)
|
||||||
|
|
||||||
|
def file_signature(self, key: str) -> Optional[Tuple[int, int, int]]:
|
||||||
|
"""``(st_ino, st_mtime_ns, st_size)`` of ``key``'s file, or None when
|
||||||
|
there is no file (the key is absent, or this cache has no disk tier).
|
||||||
|
|
||||||
|
One stat(), no read: a poller of a mailbox another process writes
|
||||||
|
compares it with the last one it saw and reads the file only when it
|
||||||
|
changed. Every write replaces the file (a temp file renamed into
|
||||||
|
place), so a new write always has a new inode, however fast it came.
|
||||||
|
"""
|
||||||
|
path = self._get_cache_path(key)
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return (st.st_ino, st.st_mtime_ns, st.st_size)
|
||||||
|
|
||||||
def get_cached_data(self, key: str, max_age: int = 300, memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
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.
|
"""Get data from cache (memory first, then disk) honoring TTLs.
|
||||||
|
|
||||||
@@ -245,7 +362,11 @@ class CacheManager:
|
|||||||
# 1) Memory cache
|
# 1) Memory cache
|
||||||
cached = self._memory_cache_component.get(key, max_age=in_memory_ttl)
|
cached = self._memory_cache_component.get(key, max_age=in_memory_ttl)
|
||||||
if cached is not None:
|
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
|
# 2) Disk cache
|
||||||
record = self._disk_cache_component.get(key, max_age=max_age)
|
record = self._disk_cache_component.get(key, max_age=max_age)
|
||||||
@@ -274,12 +395,15 @@ class CacheManager:
|
|||||||
# caller gets as is.
|
# caller gets as is.
|
||||||
self._disk_cache_component.set(key, data)
|
self._disk_cache_component.set(key, data)
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use get(key, max_age=3600)")
|
||||||
def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Load data from cache with memory caching."""
|
"""Load data from cache with memory caching."""
|
||||||
# Check memory cache first (1 minute TTL)
|
# Check memory cache first (1 minute TTL)
|
||||||
cached = self._memory_cache_component.get(key, max_age=60)
|
cached = self._memory_cache_component.get(key, max_age=60)
|
||||||
if cached is not None:
|
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
|
# Check disk cache
|
||||||
data = self._disk_cache_component.get(key, max_age=3600) # 1 hour for load_cache
|
data = self._disk_cache_component.get(key, max_age=3600) # 1 hour for load_cache
|
||||||
@@ -481,13 +605,6 @@ class CacheManager:
|
|||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
space_freed_mb = stats['space_freed_bytes'] / (1024 * 1024)
|
space_freed_mb = stats['space_freed_bytes'] / (1024 * 1024)
|
||||||
|
|
||||||
# Record metrics
|
|
||||||
self._metrics_component.record_disk_cleanup(
|
|
||||||
files_cleaned=stats['files_deleted'],
|
|
||||||
space_freed_mb=space_freed_mb,
|
|
||||||
duration_sec=duration
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log summary
|
# Log summary
|
||||||
if stats['files_deleted'] > 0:
|
if stats['files_deleted'] > 0:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
@@ -670,6 +787,7 @@ class CacheManager:
|
|||||||
data_type = self.get_data_type_from_key(key)
|
data_type = self.get_data_type_from_key(key)
|
||||||
return self.get_cached_data_with_strategy(key, data_type)
|
return self.get_cached_data_with_strategy(key, data_type)
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def generate_sport_cache_key(self, sport: str, date_str: Optional[str] = None) -> str:
|
def generate_sport_cache_key(self, sport: str, date_str: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Centralized cache key generation for sports data.
|
Centralized cache key generation for sports data.
|
||||||
|
|||||||
+65
-4
@@ -17,7 +17,9 @@ Rules for the package:
|
|||||||
- `from src.common import ...` re-exports `APIHelper`, `ScrollHelper`,
|
- `from src.common import ...` re-exports `APIHelper`, `ScrollHelper`,
|
||||||
`LogoHelper`, `TextHelper`, `scroll_config` (plus `ScrollSettings`,
|
`LogoHelper`, `TextHelper`, `scroll_config` (plus `ScrollSettings`,
|
||||||
`configure_scroll`, `resolve_scroll_settings`, `refresh_hz_from_config`) and
|
`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
|
## Summary
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ Rules for the package:
|
|||||||
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
||||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
|
| [`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 |
|
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||||
|
| [`espn_payload`](#espn_payload) | Drop the parts of an ESPN scoreboard payload no scoreboard reads | No, core-internal (used by `BackgroundDataService`) | n/a |
|
||||||
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.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 |
|
| [`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 |
|
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||||
@@ -42,8 +45,10 @@ Rules for the package:
|
|||||||
| [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | 3.7.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_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_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_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_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_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_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_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
|
||||||
@@ -107,12 +112,27 @@ 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
|
[`espn_dates.py`](espn_dates.py). ESPN's site API rejects `dates=` ranges
|
||||||
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
|
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
|
||||||
splits a range into month and day requests ESPN accepts and merges the
|
splits a range into month and day requests ESPN accepts and merges the
|
||||||
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
results; `espn_date_chunks()`, `espn_request_chunks()`,
|
||||||
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
`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
|
Every request goes through [`fetch_service`](#fetch_service), the chunks
|
||||||
counted against the plugin that asked. Scoreboard plugins also bundle a copy
|
counted against the plugin that asked. Scoreboard plugins also bundle a copy
|
||||||
for older cores.
|
for older cores.
|
||||||
|
|
||||||
|
### espn_payload
|
||||||
|
|
||||||
|
[`espn_payload.py`](espn_payload.py). Core-internal. ESPN scoreboard
|
||||||
|
responses carry stat leaders, athlete cards, links, headlines and highlights
|
||||||
|
that no scoreboard draws. `slim_scoreboard_payload(payload)` removes exactly
|
||||||
|
those keys, in place, and leaves everything it does not know about alone;
|
||||||
|
`is_espn_scoreboard_url(url)` says whether a URL is an ESPN site-API
|
||||||
|
scoreboard. `BackgroundDataService` slims each scoreboard window before
|
||||||
|
caching it, which cuts the five sports windows from ~40MB to ~12MB of parsed
|
||||||
|
objects. Adding a key to the drop lists means first checking that nothing
|
||||||
|
reads it.
|
||||||
|
|
||||||
### favorite_team_check
|
### favorite_team_check
|
||||||
|
|
||||||
[`favorite_team_check.py`](favorite_team_check.py).
|
[`favorite_team_check.py`](favorite_team_check.py).
|
||||||
@@ -231,7 +251,8 @@ rather than the `set_*` methods. Vegas mode reads a plugin's
|
|||||||
[`snapshot_policy.py`](snapshot_policy.py). Core-internal. `decide()`
|
[`snapshot_policy.py`](snapshot_policy.py). Core-internal. `decide()`
|
||||||
tells `DisplayManager` whether to write `/tmp/led_matrix_preview.png`, only
|
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.
|
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
|
### sports_card
|
||||||
|
|
||||||
@@ -273,6 +294,19 @@ list it before `SportsCoreSharedMixin`) and `SportsGameRulesMixin`
|
|||||||
`_effective_live_duration()`, the shorter dwell for a non-favourite live
|
`_effective_live_duration()`, the shorter dwell for a non-favourite live
|
||||||
game).
|
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
|
||||||
|
|
||||||
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
|
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
|
||||||
@@ -288,6 +322,16 @@ path as given when it exists (relative to the cwd), else
|
|||||||
`font_layout.resolve_asset_path(path)`. What the scoreboards'
|
`font_layout.resolve_asset_path(path)`. What the scoreboards'
|
||||||
`_resolve_font_path` copies return on a core that ships it.
|
`_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
|
||||||
|
|
||||||
[`sports_game_renderer.py`](sports_game_renderer.py).
|
[`sports_game_renderer.py`](sports_game_renderer.py).
|
||||||
@@ -331,6 +375,10 @@ dynamic-duration helpers. List it before `BasePlugin`.
|
|||||||
scoreboards share (Vegas items, dynamic duration, frame loop), paced through
|
scoreboards share (Vegas items, dynamic duration, frame loop), paced through
|
||||||
`scroll_config`. Subclasses supply `prepare_scroll_content()` and set
|
`scroll_config`. Subclasses supply `prepare_scroll_content()` and set
|
||||||
`SCROLL_LEAGUE_KEYS`; see the module docstring for an example.
|
`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
|
### sports_shared
|
||||||
|
|
||||||
@@ -380,6 +428,19 @@ Created by `DisplayController`; works with any plugin.
|
|||||||
`get_text_dimensions()`, `center_text()`, `wrap_text()`,
|
`get_text_dimensions()`, `center_text()`, `wrap_text()`,
|
||||||
`draw_multiline_text()`, `create_text_image()`.
|
`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
|
## Logging
|
||||||
|
|
||||||
Modules here create their logger with `logging.getLogger(__name__)`, which is
|
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
|
- Logo helpers
|
||||||
- Text/scroll helpers
|
- Text/scroll helpers
|
||||||
- Adaptive layout and image 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
|
import importlib
|
||||||
from src.common.api_helper import APIHelper
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||||
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 /
|
if TYPE_CHECKING:
|
||||||
# src.adaptive_images — re-exported here so plugin authors find them in the
|
# What mypy and editors see: the real names and their types.
|
||||||
# blessed-helpers package). See docs/ADAPTIVE_LAYOUT.md.
|
from src.common.api_helper import APIHelper
|
||||||
from src.adaptive_layout import (
|
from src.common.scroll_helper import ScrollHelper
|
||||||
Region,
|
from src.common import scroll_config
|
||||||
LayoutContext,
|
from src.common.scroll_config import (
|
||||||
FontStep,
|
ScrollSettings,
|
||||||
FontLadder,
|
configure as configure_scroll,
|
||||||
LADDER_GRID,
|
resolve as resolve_scroll_settings,
|
||||||
LADDER_ARCADE,
|
refresh_hz_from_config,
|
||||||
FitResult,
|
)
|
||||||
draw_fitted_text,
|
from src.common.logo_helper import LogoHelper
|
||||||
ScoreboardRegions,
|
from src.common.text_helper import TextHelper
|
||||||
scoreboard_regions,
|
|
||||||
MediaRow,
|
# Adaptive layout & images (canonical homes: src.adaptive_layout /
|
||||||
media_row,
|
# src.adaptive_images — re-exported here so plugin authors find them in the
|
||||||
)
|
# blessed-helpers package). See docs/ADAPTIVE_LAYOUT.md.
|
||||||
from src.adaptive_images import (
|
from src.adaptive_layout import (
|
||||||
ImageFitResult,
|
Region,
|
||||||
fit_image,
|
LayoutContext,
|
||||||
draw_fitted_image,
|
FontStep,
|
||||||
RESAMPLE_LANCZOS,
|
FontLadder,
|
||||||
RESAMPLE_NEAREST,
|
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__ = [
|
__all__ = [
|
||||||
'APIHelper',
|
'APIHelper',
|
||||||
@@ -75,3 +120,25 @@ __all__ = [
|
|||||||
'RESAMPLE_LANCZOS',
|
'RESAMPLE_LANCZOS',
|
||||||
'RESAMPLE_NEAREST',
|
'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__))
|
||||||
|
|||||||
+53
-11
@@ -10,12 +10,19 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from types import MappingProxyType
|
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.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
|
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# What Session() puts in .headers; the stubs only promise a MutableMapping.
|
# What Session() puts in .headers; the stubs only promise a MutableMapping.
|
||||||
@@ -117,6 +124,14 @@ class APIHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
Response data as dictionary or None if request fails
|
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:
|
if cache_key and self.cache_manager:
|
||||||
cached = self._get_from_cache(cache_key, cache_ttl)
|
cached = self._get_from_cache(cache_key, cache_ttl)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
@@ -138,12 +153,13 @@ class APIHelper:
|
|||||||
url,
|
url,
|
||||||
params=params,
|
params=params,
|
||||||
headers=request_headers,
|
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()
|
response.raise_for_status()
|
||||||
|
|
||||||
# Parse JSON response
|
# Parse JSON response
|
||||||
data: Dict[Any, Any] = response.json()
|
data: Dict[Any, Any] = response_json(response)
|
||||||
|
|
||||||
# Cache response if cache key provided
|
# Cache response if cache key provided
|
||||||
if cache_key and self.cache_manager:
|
if cache_key and self.cache_manager:
|
||||||
@@ -156,6 +172,7 @@ class APIHelper:
|
|||||||
self.logger.error(f"Request failed for {url}: {e}")
|
self.logger.error(f"Request failed for {url}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use src.common.espn_dates.fetch_espn_scoreboard()")
|
||||||
def fetch_espn_scoreboard(self, sport: str, league: str,
|
def fetch_espn_scoreboard(self, sport: str, league: str,
|
||||||
date: Optional[str] = None,
|
date: Optional[str] = None,
|
||||||
cache_key: Optional[str] = None,
|
cache_key: Optional[str] = None,
|
||||||
@@ -167,8 +184,13 @@ class APIHelper:
|
|||||||
sport: Sport name (e.g., 'basketball', 'football')
|
sport: Sport name (e.g., 'basketball', 'football')
|
||||||
league: League name (e.g., 'nba', 'nfl')
|
league: League name (e.g., 'nba', 'nfl')
|
||||||
date: Date in YYYYMMDD format (defaults to today)
|
date: Date in YYYYMMDD format (defaults to today)
|
||||||
cache_key: Cache key for response
|
cache_key: Cache key for response. By default the canonical
|
||||||
cache_ttl: Cache time-to-live in seconds
|
``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:
|
Returns:
|
||||||
ESPN API response data or None if request fails
|
ESPN API response data or None if request fails
|
||||||
@@ -179,10 +201,6 @@ class APIHelper:
|
|||||||
# Build URL
|
# Build URL
|
||||||
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard"
|
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
|
# Set parameters
|
||||||
# limit above 500 makes ESPN truncate instead of erroring: college
|
# limit above 500 makes ESPN truncate instead of erroring: college
|
||||||
# football came back with 25 of 68 games. See src/common/espn_dates.py.
|
# football came back with 25 of 68 games. See src/common/espn_dates.py.
|
||||||
@@ -191,8 +209,27 @@ class APIHelper:
|
|||||||
'limit': ESPN_MAX_LIMIT
|
'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
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "call get() with the ESPN URL")
|
||||||
def fetch_espn_standings(self, sport: str, league: str,
|
def fetch_espn_standings(self, sport: str, league: str,
|
||||||
cache_key: Optional[str] = None,
|
cache_key: Optional[str] = None,
|
||||||
cache_ttl: int = 3600) -> Optional[Dict]:
|
cache_ttl: int = 3600) -> Optional[Dict]:
|
||||||
@@ -215,6 +252,7 @@ class APIHelper:
|
|||||||
|
|
||||||
return self.get(url, cache_key=cache_key, cache_ttl=cache_ttl)
|
return self.get(url, cache_key=cache_key, cache_ttl=cache_ttl)
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "call get() with the ESPN URL")
|
||||||
def fetch_espn_rankings(self, sport: str, league: str,
|
def fetch_espn_rankings(self, sport: str, league: str,
|
||||||
cache_key: Optional[str] = None,
|
cache_key: Optional[str] = None,
|
||||||
cache_ttl: int = 3600) -> Optional[Dict]:
|
cache_ttl: int = 3600) -> Optional[Dict]:
|
||||||
@@ -271,12 +309,13 @@ class APIHelper:
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
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:
|
except requests.exceptions.RequestException as e:
|
||||||
self.logger.error(f"POST request failed for {url}: {e}")
|
self.logger.error(f"POST request failed for {url}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use the plugin's cache_manager")
|
||||||
def set_cache(self, key: str, data: Any, ttl: int = 3600) -> None:
|
def set_cache(self, key: str, data: Any, ttl: int = 3600) -> None:
|
||||||
"""
|
"""
|
||||||
Set cache data.
|
Set cache data.
|
||||||
@@ -289,6 +328,7 @@ class APIHelper:
|
|||||||
"""
|
"""
|
||||||
self._set_cache(key, data, ttl)
|
self._set_cache(key, data, ttl)
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use the plugin's cache_manager")
|
||||||
def get_cache(self, key: str) -> Optional[Any]:
|
def get_cache(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
Get cached data.
|
Get cached data.
|
||||||
@@ -358,6 +398,7 @@ class APIHelper:
|
|||||||
self._last_request_monotonic = time.monotonic()
|
self._last_request_monotonic = time.monotonic()
|
||||||
self._last_request_time = time.time()
|
self._last_request_time = time.time()
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def set_rate_limit(self, min_interval: float) -> None:
|
def set_rate_limit(self, min_interval: float) -> None:
|
||||||
"""
|
"""
|
||||||
Set minimum interval between requests.
|
Set minimum interval between requests.
|
||||||
@@ -368,6 +409,7 @@ class APIHelper:
|
|||||||
self._min_request_interval = min_interval
|
self._min_request_interval = min_interval
|
||||||
self.logger.debug(f"Rate limit set to {min_interval} seconds")
|
self.logger.debug(f"Rate limit set to {min_interval} seconds")
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def get_request_stats(self) -> Dict[str, Any]:
|
def get_request_stats(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get request statistics.
|
Get request statistics.
|
||||||
|
|||||||
+467
-44
@@ -26,19 +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
|
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.
|
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
|
Once a range has been rejected, later ranges skip straight to chunks for
|
||||||
``RANGE_RETRY_SECONDS`` instead of spending a doomed request first -- live
|
``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
|
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 contextvars
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import date, timedelta
|
from datetime import date, datetime, timedelta, tzinfo
|
||||||
from functools import partial
|
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:
|
try:
|
||||||
from src.common.json_body import response_json
|
from src.common.json_body import response_json
|
||||||
@@ -51,18 +90,23 @@ except ImportError:
|
|||||||
try:
|
try:
|
||||||
# The core fetch service: counts, per-host budget, merging of identical
|
# The core fetch service: counts, per-host budget, merging of identical
|
||||||
# requests. Same call, same result and errors as ``session.get``.
|
# requests. Same call, same result and errors as ``session.get``.
|
||||||
from src.common.fetch_service import fetch_get, pinned_caller
|
from src.common.fetch_service import fetch_get, get_fetch_service, pinned_caller
|
||||||
|
_COUNTS_FETCHES = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Bundled copies on cores without it call the session directly.
|
# Bundled copies on cores without it call the session directly.
|
||||||
import contextlib
|
import contextlib
|
||||||
|
|
||||||
def fetch_get(session: Any, url: str, *, share_in_flight: bool = True,
|
def fetch_get(session: Any, url: str, *, share_in_flight: bool = True,
|
||||||
**kwargs: Any) -> Any:
|
cache_max_age: Optional[float] = None, **kwargs: Any) -> Any:
|
||||||
return session.get(url, **kwargs)
|
return session.get(url, **kwargs)
|
||||||
|
|
||||||
def pinned_caller() -> Any:
|
def pinned_caller() -> Any:
|
||||||
return contextlib.nullcontext()
|
return contextlib.nullcontext()
|
||||||
|
|
||||||
|
_COUNTS_FETCHES = False
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Above this, ESPN returns a truncated list instead of an error. See module
|
# Above this, ESPN returns a truncated list instead of an error. See module
|
||||||
# docstring: 500 is the largest value measured to return complete data.
|
# docstring: 500 is the largest value measured to return complete data.
|
||||||
ESPN_MAX_LIMIT = 500
|
ESPN_MAX_LIMIT = 500
|
||||||
@@ -76,21 +120,77 @@ RANGE_RETRY_SECONDS = 6 * 60 * 60
|
|||||||
# pool_maxsize of 10 so the shared Session never has to discard connections.
|
# pool_maxsize of 10 so the shared Session never has to discard connections.
|
||||||
ESPN_CHUNK_WORKERS = 6
|
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()
|
_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__ = [
|
__all__ = [
|
||||||
"ESPN_MAX_LIMIT",
|
"ESPN_MAX_LIMIT",
|
||||||
"ESPN_CHUNK_WORKERS",
|
"ESPN_CHUNK_WORKERS",
|
||||||
|
"ESPN_MONTH_COVER_MIN_DAYS",
|
||||||
"RANGE_RETRY_SECONDS",
|
"RANGE_RETRY_SECONDS",
|
||||||
"clamp_espn_limit",
|
"clamp_espn_limit",
|
||||||
"parse_espn_date_range",
|
"parse_espn_date_range",
|
||||||
"espn_date_chunks",
|
"espn_date_chunks",
|
||||||
|
"espn_request_chunks",
|
||||||
"merge_scoreboard_payloads",
|
"merge_scoreboard_payloads",
|
||||||
"fetch_espn_date_chunks",
|
"fetch_espn_date_chunks",
|
||||||
"fetch_espn_scoreboard",
|
"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]:
|
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."""
|
"""Return a copy of ``params`` with any ``limit`` over 500 pulled back to 500."""
|
||||||
@@ -129,6 +229,12 @@ def parse_espn_date_range(dates: Any) -> Optional[Tuple[date, date]]:
|
|||||||
return start, end
|
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:
|
def _ranges_known_rejected() -> bool:
|
||||||
with _range_lock:
|
with _range_lock:
|
||||||
return time.monotonic() < _ranges_rejected_until
|
return time.monotonic() < _ranges_rejected_until
|
||||||
@@ -175,6 +281,79 @@ def espn_date_chunks(start: date, end: date) -> List[str]:
|
|||||||
return chunks
|
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]:
|
def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
|
||||||
"""Fold chunk responses into one scoreboard payload.
|
"""Fold chunk responses into one scoreboard payload.
|
||||||
|
|
||||||
@@ -204,36 +383,55 @@ def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def _fetch_one_chunk(
|
def _fetch_one_chunk(
|
||||||
session, url: str, params: Dict[str, Any], headers, timeout, logger, chunk: str,
|
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.
|
"""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
|
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.
|
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:
|
try:
|
||||||
response = fetch_get(
|
with _chunk_slots:
|
||||||
session,
|
response = fetch_get(
|
||||||
url,
|
session,
|
||||||
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
url,
|
||||||
headers=headers,
|
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
||||||
timeout=timeout,
|
headers=headers,
|
||||||
)
|
timeout=timeout,
|
||||||
response.raise_for_status()
|
**_memo_kwargs(cache_max_age),
|
||||||
return cast(Optional[Dict[str, Any]], response_json(response))
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response_json(response)
|
||||||
except Exception as exc: # noqa: BLE001 - see docstring
|
except Exception as exc: # noqa: BLE001 - see docstring
|
||||||
if logger:
|
if logger:
|
||||||
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
||||||
return None
|
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(
|
def _fetch_chunks(
|
||||||
session, url: str, params: Dict[str, Any], headers, timeout, logger,
|
session, url: str, params: Dict[str, Any], headers, timeout, logger,
|
||||||
chunks: List[str],
|
chunks: List[str], cache_max_age: Optional[float] = None,
|
||||||
) -> List[Optional[Dict[str, Any]]]:
|
trims: Optional[Dict[str, Tuple[date, date]]] = None,
|
||||||
|
) -> List[Any]:
|
||||||
"""Fetch every chunk, returning payloads positionally aligned with ``chunks``.
|
"""Fetch every chunk, returning payloads positionally aligned with ``chunks``.
|
||||||
|
|
||||||
Requests go out ``ESPN_CHUNK_WORKERS`` at a time because a cold season is
|
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
|
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
|
the session is shared across threads, which is why this only ever issues
|
||||||
GETs and never touches session state.
|
GETs and never touches session state.
|
||||||
@@ -246,6 +444,7 @@ def _fetch_chunks(
|
|||||||
return []
|
return []
|
||||||
fetch = partial(
|
fetch = partial(
|
||||||
_fetch_one_chunk, session, url, params, headers, timeout, logger,
|
_fetch_one_chunk, session, url, params, headers, timeout, logger,
|
||||||
|
cache_max_age=cache_max_age, trims=trims,
|
||||||
)
|
)
|
||||||
if len(chunks) == 1:
|
if len(chunks) == 1:
|
||||||
return [fetch(chunks[0])]
|
return [fetch(chunks[0])]
|
||||||
@@ -268,6 +467,7 @@ def fetch_espn_date_chunks(
|
|||||||
headers: Optional[Dict[str, str]] = None,
|
headers: Optional[Dict[str, str]] = None,
|
||||||
timeout: int = 15,
|
timeout: int = 15,
|
||||||
logger=None,
|
logger=None,
|
||||||
|
cache_max_age: Optional[float] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""Fetch a ``YYYYMMDD-YYYYMMDD`` window as month and day chunks.
|
"""Fetch a ``YYYYMMDD-YYYYMMDD`` window as month and day chunks.
|
||||||
|
|
||||||
@@ -291,7 +491,9 @@ def fetch_espn_date_chunks(
|
|||||||
if span is None:
|
if span is None:
|
||||||
return 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:
|
if logger:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Fetching ESPN date range %s as %d month/day chunks",
|
"Fetching ESPN date range %s as %d month/day chunks",
|
||||||
@@ -299,39 +501,38 @@ def fetch_espn_date_chunks(
|
|||||||
)
|
)
|
||||||
|
|
||||||
results = _fetch_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)
|
attempted = len(chunks)
|
||||||
|
|
||||||
# A month that came back at the cap is truncated; its days replace it in
|
# A month that came back at the cap is truncated; its days (only the
|
||||||
# place, so merged events stay in chunk order however the requests raced.
|
# 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
|
slots: List[Any] = results
|
||||||
capped: Dict[int, List[str]] = {}
|
capped: Dict[int, List[str]] = {}
|
||||||
for index, chunk in enumerate(chunks):
|
for index, chunk in enumerate(chunks):
|
||||||
payload = slots[index]
|
if slots[index] is not _CAPPED:
|
||||||
if payload is None or len(chunk) != 6:
|
|
||||||
continue
|
continue
|
||||||
events = payload.get("events") if isinstance(payload, dict) else None
|
if logger:
|
||||||
if len(events or []) >= ESPN_MAX_LIMIT:
|
logger.info(
|
||||||
if logger:
|
"ESPN month %s hit the %d-event cap; re-asking it day by day",
|
||||||
logger.info(
|
chunk, ESPN_MAX_LIMIT,
|
||||||
"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
|
||||||
capped[index] = _days_of_month(chunk)
|
else espn_date_chunks(*trim))
|
||||||
# Drop the truncated month now rather than after its days arrive:
|
slots[index] = None
|
||||||
# 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 capped:
|
if capped:
|
||||||
days = [day for index in sorted(capped) for day in capped[index]]
|
days = [day for index in sorted(capped) for day in capped[index]]
|
||||||
attempted += len(days)
|
attempted += len(days)
|
||||||
by_day = dict(zip(days, _fetch_chunks(
|
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():
|
for index, month_days in capped.items():
|
||||||
slots[index] = [by_day.get(day) for day in month_days]
|
slots[index] = [by_day.get(day) for day in month_days]
|
||||||
@@ -364,6 +565,7 @@ def fetch_espn_scoreboard(
|
|||||||
headers: Optional[Dict[str, str]] = None,
|
headers: Optional[Dict[str, str]] = None,
|
||||||
timeout: int = 15,
|
timeout: int = 15,
|
||||||
logger=None,
|
logger=None,
|
||||||
|
cache_max_age: Optional[float] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""GET an ESPN scoreboard, re-asking in month/day chunks if a range 400s.
|
"""GET an ESPN scoreboard, re-asking in month/day chunks if a range 400s.
|
||||||
|
|
||||||
@@ -373,6 +575,10 @@ def fetch_espn_scoreboard(
|
|||||||
and later ranges go straight to chunks for ``RANGE_RETRY_SECONDS``. A 400 on
|
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
|
a non-range request, any other error, and a range whose every chunk fails
|
||||||
all raise as before.
|
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)
|
params = clamp_espn_limit(params)
|
||||||
is_range = parse_espn_date_range(params.get("dates")) is not None
|
is_range = parse_espn_date_range(params.get("dates")) is not None
|
||||||
@@ -381,7 +587,7 @@ def fetch_espn_scoreboard(
|
|||||||
if is_range and _ranges_known_rejected():
|
if is_range and _ranges_known_rejected():
|
||||||
data = fetch_espn_date_chunks(
|
data = fetch_espn_date_chunks(
|
||||||
session, url, params=params, headers=headers,
|
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:
|
if data is not None:
|
||||||
return data
|
return data
|
||||||
@@ -389,7 +595,8 @@ def fetch_espn_scoreboard(
|
|||||||
# real error to log, without spending the chunks a second time.
|
# real error to log, without spending the chunks a second time.
|
||||||
chunks_tried = True
|
chunks_tried = True
|
||||||
|
|
||||||
response = fetch_get(session, 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:
|
if is_range and response.status_code == 400 and not chunks_tried:
|
||||||
_note_range_rejected()
|
_note_range_rejected()
|
||||||
if logger:
|
if logger:
|
||||||
@@ -400,9 +607,225 @@ def fetch_espn_scoreboard(
|
|||||||
)
|
)
|
||||||
data = fetch_espn_date_chunks(
|
data = fetch_espn_date_chunks(
|
||||||
session, url, params=params, headers=headers,
|
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:
|
if data is not None:
|
||||||
return data
|
return data
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return cast(Dict[str, Any], response_json(response))
|
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
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Drop the parts of an ESPN scoreboard payload no scoreboard reads.
|
||||||
|
|
||||||
|
The sports scoreboards cache their Recent/Upcoming window (14 days back, 7
|
||||||
|
ahead) as the raw ESPN response, and that record stays parsed in the memory
|
||||||
|
cache for as long as it is fresh. Most of it is never drawn. Measured on hdpi
|
||||||
|
(2026-10-02) the MLB window was 3.35MB of JSON and 13.5MB of Python objects,
|
||||||
|
and the five windows together ~40MB, mostly in:
|
||||||
|
|
||||||
|
* ``competitors[].leaders`` / ``competitions[].leaders`` -- per-team and
|
||||||
|
per-game stat leaders (28% of the MLB window)
|
||||||
|
* ``competitors[].team.links`` / ``event.links`` -- web and app URLs
|
||||||
|
* ``status.featuredAthletes`` and ``competitors[].probables`` -- athlete
|
||||||
|
cards with headshots and season stats
|
||||||
|
* ``competitions[].headlines`` / ``highlights`` -- article and video blurbs
|
||||||
|
(28% of the college-football window)
|
||||||
|
* ``competitions[].geoBroadcasts``
|
||||||
|
|
||||||
|
None of those keys is read by core or by any plugin in ledmatrix-plugins
|
||||||
|
(checked 2026-10-02 across every scoreboard, the odds ticker and the
|
||||||
|
leaderboard), while everything that is read -- odds, records, linescores,
|
||||||
|
situation, statistics, notes, broadcasts, venue -- is kept. Dropping them
|
||||||
|
takes the five windows from ~40MB to ~12MB of parsed objects and the files from
|
||||||
|
10.6MB to 3.0MB, so the reads that parse an expired window on the render
|
||||||
|
thread get 3-4x cheaper too.
|
||||||
|
|
||||||
|
:func:`slim_scoreboard_payload` changes the payload in place, and only ever
|
||||||
|
removes the keys listed here: anything it does not know about is left alone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
# Per level of the payload, the keys removed. Kept deliberately explicit:
|
||||||
|
# adding a key here means checking that nothing reads it first.
|
||||||
|
_EVENT_DROP = ("links",)
|
||||||
|
_COMPETITION_DROP = ("leaders", "headlines", "highlights", "geoBroadcasts")
|
||||||
|
_STATUS_DROP = ("featuredAthletes",)
|
||||||
|
_COMPETITOR_DROP = ("leaders", "probables")
|
||||||
|
_TEAM_DROP = ("links",)
|
||||||
|
|
||||||
|
|
||||||
|
def is_espn_scoreboard_url(url: Any) -> bool:
|
||||||
|
"""Whether ``url`` is an ESPN site-API scoreboard endpoint."""
|
||||||
|
if not isinstance(url, str):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
host = (parts.hostname or "").lower()
|
||||||
|
if host != "espn.com" and not host.endswith(".espn.com"):
|
||||||
|
return False
|
||||||
|
return parts.path.rstrip("/").endswith("/scoreboard")
|
||||||
|
|
||||||
|
|
||||||
|
def _drop(obj: Any, keys) -> None:
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for key in keys:
|
||||||
|
obj.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def slim_scoreboard_payload(payload: Any) -> Any:
|
||||||
|
"""Remove the unread parts of an ESPN scoreboard payload, in place.
|
||||||
|
|
||||||
|
Returns ``payload`` for convenience. Anything that is not shaped like a
|
||||||
|
scoreboard (not a dict, no ``events`` list, odd entries) is passed over
|
||||||
|
untouched rather than raising.
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
events = payload.get("events")
|
||||||
|
if not isinstance(events, list):
|
||||||
|
return payload
|
||||||
|
for event in events:
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
continue
|
||||||
|
_drop(event, _EVENT_DROP)
|
||||||
|
competitions = event.get("competitions")
|
||||||
|
if not isinstance(competitions, list):
|
||||||
|
continue
|
||||||
|
for competition in competitions:
|
||||||
|
if not isinstance(competition, dict):
|
||||||
|
continue
|
||||||
|
_drop(competition, _COMPETITION_DROP)
|
||||||
|
_drop(competition.get("status"), _STATUS_DROP)
|
||||||
|
competitors = competition.get("competitors")
|
||||||
|
if not isinstance(competitors, list):
|
||||||
|
continue
|
||||||
|
for competitor in competitors:
|
||||||
|
if not isinstance(competitor, dict):
|
||||||
|
continue
|
||||||
|
_drop(competitor, _COMPETITOR_DROP)
|
||||||
|
_drop(competitor.get("team"), _TEAM_DROP)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["is_espn_scoreboard_url", "slim_scoreboard_payload"]
|
||||||
+268
-13
@@ -46,9 +46,28 @@ own conditional headers gets the raw answer. (ESPN sent no validators when
|
|||||||
this was written -- see the PR that added this module -- so on ESPN the store
|
this was written -- see the PR that added this module -- so on ESPN the store
|
||||||
stays empty and costs nothing.)
|
stays empty and costs nothing.)
|
||||||
|
|
||||||
**Counters.** Requests, merged requests, bytes, 304s, errors, HTTP errors,
|
**Response cache (stage 2).** A 200 that says ``Cache-Control: max-age=N``
|
||||||
adapter retries, throttled requests and seconds waited, per plugin and per
|
is kept in memory for those N seconds (less its ``Age``), and an identical
|
||||||
host. :class:`FetchStatsPublisher` publishes them for the web interface
|
GET inside that window is answered from it without a request. ESPN sends
|
||||||
|
max-age (1-496 s measured on 2026-10-02, most of it under 10 s) and no
|
||||||
|
validators, so this is the only revalidation-free reuse ESPN allows. It
|
||||||
|
never hands a caller a response older than the caller accepts: a caller
|
||||||
|
says how old with ``cache_max_age`` (``fetch_get(..., cache_max_age=ttl)``;
|
||||||
|
0 skips the cache), and one that does not say gets at most
|
||||||
|
``response_cache.default_max_age`` (30 s). ``no-store``, ``no-cache``,
|
||||||
|
``private``, ``Vary: *`` and ``Set-Cookie`` responses are never kept.
|
||||||
|
Identical means what the validator store keys on: URL, query, effective
|
||||||
|
headers and, for a session with cookies or auth, the session.
|
||||||
|
|
||||||
|
**Counters.** Requests, merged requests, bytes (``bytes`` decoded, as the
|
||||||
|
caller reads them; ``wire_bytes`` as they crossed the network, which is
|
||||||
|
what a metered connection pays for -- ESPN gzips, so the two differ ~14x),
|
||||||
|
304s, errors, HTTP errors,
|
||||||
|
adapter retries, throttled requests and seconds waited, plus requests
|
||||||
|
answered without the network: ``memo_hits`` (the response cache) and
|
||||||
|
``cache_hits`` / ``legacy_cache_hits`` (a shared ESPN scoreboard cache entry,
|
||||||
|
counted by ``src/common/espn_dates.py``). Per plugin and per host.
|
||||||
|
:class:`FetchStatsPublisher` publishes them for the web interface
|
||||||
(``GET /api/v3/plugins/fetch-stats``).
|
(``GET /api/v3/plugins/fetch-stats``).
|
||||||
|
|
||||||
CALLER IDENTITY
|
CALLER IDENTITY
|
||||||
@@ -68,7 +87,8 @@ Counters are kept per plugin without plugins saying who they are:
|
|||||||
3. Otherwise the request is the core's own (``"core"``).
|
3. Otherwise the request is the core's own (``"core"``).
|
||||||
|
|
||||||
Nothing here raises on account of bookkeeping: a failure in counting,
|
Nothing here raises on account of bookkeeping: a failure in counting,
|
||||||
keying or the validator store falls back to a plain ``session.get``.
|
keying, the validator store or the response cache falls back to a plain
|
||||||
|
``session.get``.
|
||||||
|
|
||||||
Core-internal for now (stage 1). Plugins reach it through ``APIHelper`` and
|
Core-internal for now (stage 1). Plugins reach it through ``APIHelper`` and
|
||||||
``espn_dates``; a plugin-facing API comes with stage 3.
|
``espn_dates``; a plugin-facing API comes with stage 3.
|
||||||
@@ -143,8 +163,22 @@ DEFAULT_CONFIG: Mapping[str, Any] = {
|
|||||||
"max_bytes": 4 * 1024 * 1024,
|
"max_bytes": 4 * 1024 * 1024,
|
||||||
"max_entry_bytes": 1024 * 1024,
|
"max_entry_bytes": 1024 * 1024,
|
||||||
},
|
},
|
||||||
|
# Stage 2: responses ESPN calls fresh (Cache-Control: max-age), reused
|
||||||
|
# for identical GETs. A college-football Saturday is ~1 MB decoded, so
|
||||||
|
# one entry may be 2 MB; months (5-7 MB) are never kept.
|
||||||
|
"response_cache": {
|
||||||
|
"enabled": True,
|
||||||
|
"default_max_age": 30,
|
||||||
|
"max_entries": 64,
|
||||||
|
"max_bytes": 6 * 1024 * 1024,
|
||||||
|
"max_entry_bytes": 2 * 1024 * 1024,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#: However long a server says a response stays fresh, it is not kept longer
|
||||||
|
#: than this: the cache is for requests that coincide, not for storage.
|
||||||
|
_RESPONSE_CACHE_CEILING = 600.0
|
||||||
|
|
||||||
#: Connection pools kept per shared adapter (one per host) and connections
|
#: Connection pools kept per shared adapter (one per host) and connections
|
||||||
#: kept per pool. Larger than requests' 10 because one adapter now serves
|
#: kept per pool. Larger than requests' 10 because one adapter now serves
|
||||||
#: every core Session with its retry policy: three background workers each
|
#: every core Session with its retry policy: three background workers each
|
||||||
@@ -170,7 +204,11 @@ _COUNTER_FIELDS = (
|
|||||||
"throttled", # requests that waited for a host budget
|
"throttled", # requests that waited for a host budget
|
||||||
"overruns", # requests that went after max_wait_seconds anyway
|
"overruns", # requests that went after max_wait_seconds anyway
|
||||||
"bytes", # decoded response body bytes received
|
"bytes", # decoded response body bytes received
|
||||||
|
"wire_bytes", # body bytes as they came off the socket (still compressed)
|
||||||
"wait_seconds", # time spent waiting for host budgets
|
"wait_seconds", # time spent waiting for host budgets
|
||||||
|
"memo_hits", # answered from the response cache (max-age); nothing sent
|
||||||
|
"cache_hits", # scoreboard fetches answered from a shared ESPN cache entry
|
||||||
|
"legacy_cache_hits", # cache reads answered from a pre-stage-2 key (any helper)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -393,6 +431,117 @@ class _ValidatorStore:
|
|||||||
return {"entries": len(self._entries), "bytes": self._bytes}
|
return {"entries": len(self._entries), "bytes": self._bytes}
|
||||||
|
|
||||||
|
|
||||||
|
# --- response cache (Cache-Control: max-age) --------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Fresh:
|
||||||
|
response: requests.Response
|
||||||
|
stored_at: float
|
||||||
|
#: Seconds after stored_at the server said the response stays fresh.
|
||||||
|
lifetime: float
|
||||||
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
class _ResponseCache:
|
||||||
|
"""LRU of finished 200 responses, each kept for its server max-age.
|
||||||
|
|
||||||
|
:meth:`get` answers only while the entry is younger than both its own
|
||||||
|
lifetime and the caller's limit, so nobody is handed a response older
|
||||||
|
than they asked for. Expired entries are dropped as they are met and on
|
||||||
|
every insert, so the cache holds only what is still fresh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_entries: int, max_bytes: int, max_entry_bytes: int,
|
||||||
|
clock: Callable[[], float]) -> None:
|
||||||
|
self.max_entries = max_entries
|
||||||
|
self.max_bytes = max_bytes
|
||||||
|
self.max_entry_bytes = max_entry_bytes
|
||||||
|
self._clock = clock
|
||||||
|
self._entries: "OrderedDict[Any, _Fresh]" = OrderedDict()
|
||||||
|
self._bytes = 0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self, key: Any, max_age: float) -> Optional[requests.Response]:
|
||||||
|
with self._lock:
|
||||||
|
entry = self._entries.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
age = self._clock() - entry.stored_at
|
||||||
|
if age < 0 or age >= entry.lifetime:
|
||||||
|
self._drop_locked(key)
|
||||||
|
return None
|
||||||
|
if age > max_age:
|
||||||
|
return None # fresh for someone less strict; kept
|
||||||
|
self._entries.move_to_end(key)
|
||||||
|
clone: requests.Response = _clone_response(entry.response)
|
||||||
|
return clone
|
||||||
|
|
||||||
|
def put(self, key: Any, response: requests.Response, lifetime: float,
|
||||||
|
size: int) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._drop_locked(key)
|
||||||
|
if size > self.max_entry_bytes or self.max_entries <= 0 or lifetime <= 0:
|
||||||
|
return
|
||||||
|
now = self._clock()
|
||||||
|
for old_key in [k for k, e in self._entries.items()
|
||||||
|
if now - e.stored_at >= e.lifetime]:
|
||||||
|
self._drop_locked(old_key)
|
||||||
|
self._entries[key] = _Fresh(_clone_response(response), now, lifetime, size)
|
||||||
|
self._bytes += size
|
||||||
|
while self._entries and (len(self._entries) > self.max_entries
|
||||||
|
or self._bytes > self.max_bytes):
|
||||||
|
_, old = self._entries.popitem(last=False)
|
||||||
|
self._bytes -= old.size
|
||||||
|
|
||||||
|
def _drop_locked(self, key: Any) -> None:
|
||||||
|
old = self._entries.pop(key, None)
|
||||||
|
if old is not None:
|
||||||
|
self._bytes -= old.size
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.clear()
|
||||||
|
self._bytes = 0
|
||||||
|
|
||||||
|
def stats(self) -> Dict[str, int]:
|
||||||
|
with self._lock:
|
||||||
|
return {"entries": len(self._entries), "bytes": self._bytes}
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_directives(value: Optional[str]) -> Dict[str, Optional[str]]:
|
||||||
|
directives: Dict[str, Optional[str]] = {}
|
||||||
|
for part in (value or "").split(","):
|
||||||
|
name, _, arg = part.strip().partition("=")
|
||||||
|
if name:
|
||||||
|
directives[name.strip().lower()] = arg.strip().strip('"') if arg else None
|
||||||
|
return directives
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_for(response: Any) -> Optional[float]:
|
||||||
|
"""Seconds a finished response stays fresh by its own headers, or None
|
||||||
|
when it must not be reused: not a 200 with its body read, ``no-store``,
|
||||||
|
``no-cache``, ``private``, ``Vary: *``, ``Set-Cookie``, or no max-age."""
|
||||||
|
if _status_of(response) != 200 or _body_of(response) is None:
|
||||||
|
return None
|
||||||
|
directives = _cache_directives(_str_header(response, "Cache-Control"))
|
||||||
|
if {"no-store", "no-cache", "private"} & set(directives):
|
||||||
|
return None
|
||||||
|
if (_str_header(response, "Vary") or "").strip() == "*":
|
||||||
|
return None
|
||||||
|
if _str_header(response, "Set-Cookie"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
max_age = int(directives.get("max-age") or "")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
age = int(_str_header(response, "Age") or 0)
|
||||||
|
except ValueError:
|
||||||
|
age = 0
|
||||||
|
fresh = float(min(max_age - max(age, 0), _RESPONSE_CACHE_CEILING))
|
||||||
|
return fresh if fresh > 0 else None
|
||||||
|
|
||||||
|
|
||||||
# --- helpers ------------------------------------------------------------------------
|
# --- helpers ------------------------------------------------------------------------
|
||||||
|
|
||||||
def _host_of(url: Any) -> str:
|
def _host_of(url: Any) -> str:
|
||||||
@@ -471,6 +620,30 @@ def _body_of(response: Any) -> Optional[bytes]:
|
|||||||
return content if isinstance(content, bytes) else None
|
return content if isinstance(content, bytes) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_bytes_of(response: Any, body: Optional[bytes]) -> int:
|
||||||
|
"""How many body bytes came off the socket for ``response``: the
|
||||||
|
compressed size when the server sent gzip, which ESPN does for every
|
||||||
|
scoreboard (63 KB on the wire for an 865 KB college football Saturday).
|
||||||
|
|
||||||
|
urllib3's ``HTTPResponse.tell()`` counts the raw bytes read before
|
||||||
|
decoding. A response without one (a test double, an adapter that is not
|
||||||
|
urllib3) or one whose body was not read is counted at its decoded size,
|
||||||
|
or as 0, so the counter never claims less than it can prove.
|
||||||
|
"""
|
||||||
|
if body is None:
|
||||||
|
return 0
|
||||||
|
raw = getattr(response, "raw", None)
|
||||||
|
tell = getattr(raw, "tell", None)
|
||||||
|
if callable(tell):
|
||||||
|
try:
|
||||||
|
read = tell()
|
||||||
|
except Exception:
|
||||||
|
read = None
|
||||||
|
if isinstance(read, int) and not isinstance(read, bool) and read > 0:
|
||||||
|
return read
|
||||||
|
return len(body)
|
||||||
|
|
||||||
|
|
||||||
def _retries_of(response: Any) -> int:
|
def _retries_of(response: Any) -> int:
|
||||||
raw = getattr(response, "raw", None)
|
raw = getattr(response, "raw", None)
|
||||||
retries = getattr(raw, "retries", None)
|
retries = getattr(raw, "retries", None)
|
||||||
@@ -580,6 +753,9 @@ class FetchService:
|
|||||||
self.max_wait_seconds = 2.0
|
self.max_wait_seconds = 2.0
|
||||||
self._rate_limits: Dict[str, Tuple[float, float]] = {}
|
self._rate_limits: Dict[str, Tuple[float, float]] = {}
|
||||||
self._validators = _ValidatorStore(0, 0, 0)
|
self._validators = _ValidatorStore(0, 0, 0)
|
||||||
|
self.response_cache = True
|
||||||
|
self.default_max_age = 30.0
|
||||||
|
self._fresh = _ResponseCache(0, 0, 0, clock)
|
||||||
self._applied: Optional[str] = None
|
self._applied: Optional[str] = None
|
||||||
self.configure(config)
|
self.configure(config)
|
||||||
|
|
||||||
@@ -635,6 +811,14 @@ class FetchService:
|
|||||||
store = merged.get("validator_store")
|
store = merged.get("validator_store")
|
||||||
store = store if isinstance(store, Mapping) else {}
|
store = store if isinstance(store, Mapping) else {}
|
||||||
default_store = DEFAULT_CONFIG["validator_store"]
|
default_store = DEFAULT_CONFIG["validator_store"]
|
||||||
|
fresh = merged.get("response_cache")
|
||||||
|
if fresh is not None and not isinstance(fresh, Mapping):
|
||||||
|
logger.warning("fetch_service.response_cache is not an object; using the defaults")
|
||||||
|
fresh = fresh if isinstance(fresh, Mapping) else {}
|
||||||
|
default_fresh = DEFAULT_CONFIG["response_cache"]
|
||||||
|
self.response_cache = fresh.get("enabled") is not False
|
||||||
|
self.default_max_age = _as_float(fresh.get("default_max_age"),
|
||||||
|
float(default_fresh["default_max_age"]))
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._rate_limits = limits
|
self._rate_limits = limits
|
||||||
self._buckets.clear()
|
self._buckets.clear()
|
||||||
@@ -643,6 +827,12 @@ class FetchService:
|
|||||||
_as_int(store.get("max_bytes"), default_store["max_bytes"]),
|
_as_int(store.get("max_bytes"), default_store["max_bytes"]),
|
||||||
_as_int(store.get("max_entry_bytes"), default_store["max_entry_bytes"]),
|
_as_int(store.get("max_entry_bytes"), default_store["max_entry_bytes"]),
|
||||||
)
|
)
|
||||||
|
self._fresh = _ResponseCache(
|
||||||
|
_as_int(fresh.get("max_entries"), default_fresh["max_entries"]),
|
||||||
|
_as_int(fresh.get("max_bytes"), default_fresh["max_bytes"]),
|
||||||
|
_as_int(fresh.get("max_entry_bytes"), default_fresh["max_entry_bytes"]),
|
||||||
|
self._clock,
|
||||||
|
)
|
||||||
self.change_count += 1
|
self.change_count += 1
|
||||||
|
|
||||||
def describe_config(self) -> Dict[str, Any]:
|
def describe_config(self) -> Dict[str, Any]:
|
||||||
@@ -655,6 +845,8 @@ class FetchService:
|
|||||||
"conditional_get": self.conditional_get,
|
"conditional_get": self.conditional_get,
|
||||||
"max_wait_seconds": self.max_wait_seconds,
|
"max_wait_seconds": self.max_wait_seconds,
|
||||||
"rate_limits": limits,
|
"rate_limits": limits,
|
||||||
|
"response_cache": self.response_cache,
|
||||||
|
"default_max_age": self.default_max_age,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _limit_for(self, host: str) -> Optional[Tuple[float, float]]:
|
def _limit_for(self, host: str) -> Optional[Tuple[float, float]]:
|
||||||
@@ -723,7 +915,7 @@ class FetchService:
|
|||||||
# -- requests --
|
# -- requests --
|
||||||
|
|
||||||
def get(self, session: Any, url: str, *, share_in_flight: bool = True,
|
def get(self, session: Any, url: str, *, share_in_flight: bool = True,
|
||||||
**kwargs: Any) -> Any:
|
cache_max_age: Optional[float] = None, **kwargs: Any) -> Any:
|
||||||
"""``session.get(url, **kwargs)`` through the service.
|
"""``session.get(url, **kwargs)`` through the service.
|
||||||
|
|
||||||
Same return value, same exceptions, and ``session.get`` is called
|
Same return value, same exceptions, and ``session.get`` is called
|
||||||
@@ -734,6 +926,11 @@ class FetchService:
|
|||||||
than joining an identical one in flight -- for a caller that may
|
than joining an identical one in flight -- for a caller that may
|
||||||
retry *because* an earlier request hung (BackgroundDataService
|
retry *because* an earlier request hung (BackgroundDataService
|
||||||
cancels and replaces a fetch) and must not be handed that one.
|
cancels and replaces a fetch) and must not be handed that one.
|
||||||
|
|
||||||
|
``cache_max_age`` is the oldest response, in seconds, the caller
|
||||||
|
will take from the response cache (its own TTL); 0 always asks the
|
||||||
|
network. None means ``response_cache.default_max_age``. A response
|
||||||
|
is never reused past the max-age its server gave it either.
|
||||||
"""
|
"""
|
||||||
transport = session if session is not None else self.session_for(url)
|
transport = session if session is not None else self.session_for(url)
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
@@ -744,8 +941,24 @@ class FetchService:
|
|||||||
logger.debug("fetch_service could not key a request to %s", url, exc_info=True)
|
logger.debug("fetch_service could not key a request to %s", url, exc_info=True)
|
||||||
request = _Request(plugin=self._caller(), host=_host_of(url))
|
request = _Request(plugin=self._caller(), host=_host_of(url))
|
||||||
|
|
||||||
|
reusable = (self.response_cache and request.representation is not None
|
||||||
|
and not _has_conditional_headers(kwargs.get("headers")))
|
||||||
|
if reusable:
|
||||||
|
try:
|
||||||
|
limit = self._accepted_age(cache_max_age)
|
||||||
|
cached = self._fresh.get(request.representation, limit) if limit > 0 else None
|
||||||
|
except Exception:
|
||||||
|
logger.debug("fetch_service response cache lookup failed", exc_info=True)
|
||||||
|
cached = None
|
||||||
|
if cached is not None:
|
||||||
|
self._count(request.plugin, request.host, memo_hits=1)
|
||||||
|
return cached
|
||||||
|
|
||||||
if request.flight is None or not self.single_flight or not share_in_flight:
|
if request.flight is None or not self.single_flight or not share_in_flight:
|
||||||
return self._send_get(transport, url, kwargs, request)
|
response = self._send_get(transport, url, kwargs, request)
|
||||||
|
if reusable:
|
||||||
|
self._remember(request, response)
|
||||||
|
return response
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
flight = self._inflight.get(request.flight)
|
flight = self._inflight.get(request.flight)
|
||||||
@@ -764,6 +977,8 @@ class FetchService:
|
|||||||
try:
|
try:
|
||||||
response = self._send_get(transport, url, kwargs, request)
|
response = self._send_get(transport, url, kwargs, request)
|
||||||
flight.response = response
|
flight.response = response
|
||||||
|
if reusable:
|
||||||
|
self._remember(request, response)
|
||||||
return response
|
return response
|
||||||
except BaseException as error:
|
except BaseException as error:
|
||||||
flight.error = error
|
flight.error = error
|
||||||
@@ -790,6 +1005,40 @@ class FetchService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("fetch_service could not count a merged request", exc_info=True)
|
logger.debug("fetch_service could not count a merged request", exc_info=True)
|
||||||
|
|
||||||
|
def note_cache_hit(self, url: Any = None, *, legacy: bool = False,
|
||||||
|
avoided_request: bool = True,
|
||||||
|
plugin_id: Optional[str] = None) -> None:
|
||||||
|
"""Count a read answered from a shared cache entry instead of the
|
||||||
|
network (``espn_dates``). ``legacy`` marks a read from a key that
|
||||||
|
predates the canonical one; ``avoided_request=False`` counts only
|
||||||
|
that, for a read that was never going to fetch on a miss."""
|
||||||
|
try:
|
||||||
|
self._count(plugin_id or self._caller(), _host_of(url),
|
||||||
|
cache_hits=int(avoided_request), legacy_cache_hits=int(legacy))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("fetch_service could not count a cache hit", exc_info=True)
|
||||||
|
|
||||||
|
def _accepted_age(self, cache_max_age: Any) -> float:
|
||||||
|
"""The oldest cached response this call accepts, in seconds."""
|
||||||
|
if cache_max_age is None:
|
||||||
|
return self.default_max_age
|
||||||
|
if isinstance(cache_max_age, bool) or not isinstance(cache_max_age, (int, float)):
|
||||||
|
return self.default_max_age
|
||||||
|
value = float(cache_max_age)
|
||||||
|
return value if math.isfinite(value) and value > 0 else 0.0
|
||||||
|
|
||||||
|
def _remember(self, request: _Request, response: Any) -> None:
|
||||||
|
"""Keep a finished response for its server max-age. Never raises."""
|
||||||
|
try:
|
||||||
|
lifetime = _fresh_for(response)
|
||||||
|
if lifetime is None:
|
||||||
|
return
|
||||||
|
body = _body_of(response)
|
||||||
|
self._fresh.put(request.representation, response, lifetime,
|
||||||
|
len(body) if body is not None else 0)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("fetch_service could not keep a response", exc_info=True)
|
||||||
|
|
||||||
def _caller(self) -> str:
|
def _caller(self) -> str:
|
||||||
return current_plugin_id() or CORE
|
return current_plugin_id() or CORE
|
||||||
|
|
||||||
@@ -896,6 +1145,7 @@ class FetchService:
|
|||||||
http_errors=int(status is not None and status >= 400),
|
http_errors=int(status is not None and status >= 400),
|
||||||
retries=_retries_of(response),
|
retries=_retries_of(response),
|
||||||
bytes=len(body) if body is not None else 0,
|
bytes=len(body) if body is not None else 0,
|
||||||
|
wire_bytes=_wire_bytes_of(response, body),
|
||||||
throttled=int(waited > 0), overruns=int(overrun),
|
throttled=int(waited > 0), overruns=int(overrun),
|
||||||
wait_seconds=waited)
|
wait_seconds=waited)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -927,10 +1177,11 @@ class FetchService:
|
|||||||
per_plugin[name] += value
|
per_plugin[name] += value
|
||||||
per_host[name] += value
|
per_host[name] += value
|
||||||
self._totals[name] += value
|
self._totals[name] += value
|
||||||
if changes.get("requests") or changes.get("merged"):
|
asked = int(changes.get("requests", 0) + changes.get("merged", 0)
|
||||||
|
+ changes.get("memo_hits", 0) + changes.get("cache_hits", 0))
|
||||||
|
if asked:
|
||||||
hosts = self._plugin_hosts.setdefault(plugin, {})
|
hosts = self._plugin_hosts.setdefault(plugin, {})
|
||||||
hosts[host] = hosts.get(host, 0) + int(changes.get("requests", 0)
|
hosts[host] = hosts.get(host, 0) + asked
|
||||||
+ changes.get("merged", 0))
|
|
||||||
self.change_count += 1
|
self.change_count += 1
|
||||||
|
|
||||||
def reset_counters(self) -> None:
|
def reset_counters(self) -> None:
|
||||||
@@ -942,12 +1193,14 @@ class FetchService:
|
|||||||
self.change_count += 1
|
self.change_count += 1
|
||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
"""Counters, validators, budgets and in-flight table (tests)."""
|
"""Counters, validators, response cache, budgets and in-flight
|
||||||
|
table (tests)."""
|
||||||
self.reset_counters()
|
self.reset_counters()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._buckets.clear()
|
self._buckets.clear()
|
||||||
self._inflight.clear()
|
self._inflight.clear()
|
||||||
self._validators.clear()
|
self._validators.clear()
|
||||||
|
self._fresh.clear()
|
||||||
|
|
||||||
def snapshot(self) -> Dict[str, Any]:
|
def snapshot(self) -> Dict[str, Any]:
|
||||||
"""Counters since the service started, JSON-ready."""
|
"""Counters since the service started, JSON-ready."""
|
||||||
@@ -971,6 +1224,7 @@ class FetchService:
|
|||||||
"plugins": plugins,
|
"plugins": plugins,
|
||||||
"hosts": hosts,
|
"hosts": hosts,
|
||||||
"validators": self._validators.stats(),
|
"validators": self._validators.stats(),
|
||||||
|
"response_cache": self._fresh.stats(),
|
||||||
"config": self.describe_config(),
|
"config": self.describe_config(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,10 +1259,11 @@ def configure_fetch_service(config: Any) -> FetchService:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_get(session: Any, url: str, *, share_in_flight: bool = True,
|
def fetch_get(session: Any, url: str, *, share_in_flight: bool = True,
|
||||||
**kwargs: Any) -> Any:
|
cache_max_age: Optional[float] = None, **kwargs: Any) -> Any:
|
||||||
"""``session.get(url, **kwargs)`` through the process's FetchService."""
|
"""``session.get(url, **kwargs)`` through the process's FetchService.
|
||||||
|
``cache_max_age``: see :meth:`FetchService.get`."""
|
||||||
return get_fetch_service().get(session, url, share_in_flight=share_in_flight,
|
return get_fetch_service().get(session, url, share_in_flight=share_in_flight,
|
||||||
**kwargs)
|
cache_max_age=cache_max_age, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def fetch_post(session: Any, url: str, **kwargs: Any) -> Any:
|
def fetch_post(session: Any, url: str, **kwargs: Any) -> Any:
|
||||||
|
|||||||
+271
-15
@@ -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
|
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.
|
frame of a scroll has no predecessor worth measuring against.
|
||||||
|
|
||||||
"Scrolling" is DisplayManager's scroll state when the frame is presented, and
|
"Scrolling" is the scroll state ``DisplayManager.update_display`` acted on for
|
||||||
that state can go missing in the middle of a scroll. It expires after 2s
|
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
|
without scroll activity, which a long enough stall outlasts, and any thread can
|
||||||
clear it: plugins call ``set_scrolling_state(False)`` from their own
|
clear it: plugins call ``set_scrolling_state(False)`` from their own
|
||||||
``display()``, and Vegas captures some of those on the render thread between
|
``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.
|
a visible hitch. ``missed_refreshes`` sums how many refreshes late.
|
||||||
|
|
||||||
An interval of ``FREEZE_SECONDS`` or more is a **freeze** instead -- a
|
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
|
recompose, a plugin handover nobody tagged (see below), a blocking call on the
|
||||||
counted separately, both because they are a different fault and because
|
render thread. Those are counted separately, both because they are a different
|
||||||
folding a single 400ms handover into the late count as "40 missed refreshes"
|
fault and because folding a single 400ms handover into the late count as "40
|
||||||
would drown the jitter the late count exists to measure. ``freeze_by`` splits
|
missed refreshes" would drown the jitter the late count exists to measure.
|
||||||
them by length. Intervals of ``GAP_SECONDS`` or more are ignored as not being
|
``freeze_by`` splits them by length. Intervals of ``GAP_SECONDS`` or more are
|
||||||
frames of one scroll at all.
|
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
|
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
|
wait for the panel: the emulator, the fallback display, or a hold that was not
|
||||||
@@ -77,6 +86,24 @@ the work landed in. ``op_frames`` counts timed frames per kind,
|
|||||||
freeze instead, and ``op_bytes`` what the work moved. A kind whose late rate
|
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.
|
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
|
Stall watchdog
|
||||||
--------------
|
--------------
|
||||||
Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the
|
Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the
|
||||||
@@ -94,7 +121,9 @@ three times per threshold, so keep it to diagnostic runs, not soaks.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import atexit
|
||||||
import copy
|
import copy
|
||||||
|
import gc
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -106,6 +135,8 @@ import time
|
|||||||
import traceback
|
import traceback
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict
|
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict
|
||||||
|
|
||||||
|
from src.common import scroll_config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
#: Bumped when a field changes meaning, so a reader can refuse stale files.
|
#: Bumped when a field changes meaning, so a reader can refuse stale files.
|
||||||
@@ -133,6 +164,16 @@ RESUME_SECONDS = 1.0
|
|||||||
FREEZE_BUCKETS = ((0.5, "<0.5s"), (1.0, "0.5-1s"), (2.0, "1-2s"),
|
FREEZE_BUCKETS = ((0.5, "<0.5s"), (1.0, "0.5-1s"), (2.0, "1-2s"),
|
||||||
(float("inf"), "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.
|
#: A window may lower the refresh-period estimate by at most this fraction.
|
||||||
MAX_REFRESH_DROP = 0.2
|
MAX_REFRESH_DROP = 0.2
|
||||||
|
|
||||||
@@ -140,6 +181,12 @@ MAX_REFRESH_DROP = 0.2
|
|||||||
#: trusted -- about a second of scrolling.
|
#: trusted -- about a second of scrolling.
|
||||||
MIN_FRAMES_FOR_REFRESH = 90
|
MIN_FRAMES_FOR_REFRESH = 90
|
||||||
|
|
||||||
|
#: Consecutive windows that agree with the adopted refresh period (the one
|
||||||
|
#: that adopted it counts) before a panel slower than its cap is reported. The
|
||||||
|
#: estimate can still fall by up to MAX_REFRESH_DROP per window early on; the
|
||||||
|
#: warning should not name a rate one more window would have corrected.
|
||||||
|
REFRESH_CHECK_WINDOWS = 3
|
||||||
|
|
||||||
FLUSH_INTERVAL = 10.0
|
FLUSH_INTERVAL = 10.0
|
||||||
|
|
||||||
#: A scroll's last frame older than this is a stall worth a stack dump.
|
#: A scroll's last frame older than this is a stall worth a stack dump.
|
||||||
@@ -164,6 +211,114 @@ def default_stats_path() -> str:
|
|||||||
return os.path.join(base, STATS_FILENAME)
|
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
|
#: One presented frame's interval: (interval, blit, wait, hold, ops), where
|
||||||
#: ops is the work noted before it (kind -> bytes) or None.
|
#: ops is the work noted before it (kind -> bytes) or None.
|
||||||
_Frame = Tuple[float, float, float, int, Optional[Dict[str, int]]]
|
_Frame = Tuple[float, float, float, int, Optional[Dict[str, int]]]
|
||||||
@@ -259,11 +414,15 @@ class FrameTimingRecorder:
|
|||||||
flush_interval: float = FLUSH_INTERVAL,
|
flush_interval: float = FLUSH_INTERVAL,
|
||||||
info: Optional[Dict[str, Any]] = None,
|
info: Optional[Dict[str, Any]] = None,
|
||||||
refresh_hz: Optional[float] = None,
|
refresh_hz: Optional[float] = None,
|
||||||
|
gc_monitor: Optional[GcMonitor] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
:param refresh_hz: the panel's rate, measured independently (see the
|
:param refresh_hz: the panel's rate, measured independently (see the
|
||||||
module docstring). Omit it to estimate from the frames alone, as
|
module docstring). Omit it to estimate from the frames alone, as
|
||||||
the display service does.
|
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.path = path or default_stats_path()
|
||||||
self.flush_interval = flush_interval
|
self.flush_interval = flush_interval
|
||||||
@@ -278,6 +437,8 @@ class FrameTimingRecorder:
|
|||||||
self._unsure: Optional[_Frame] = None
|
self._unsure: Optional[_Frame] = None
|
||||||
# Work noted since the last frame (kind -> bytes), for the next one.
|
# Work noted since the last frame (kind -> bytes), for the next one.
|
||||||
self._ops: Optional[Dict[str, int]] = None
|
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._last_flush: Optional[float] = None
|
||||||
self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
|
self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
|
||||||
self._worker: Optional[threading.Thread] = None
|
self._worker: Optional[threading.Thread] = None
|
||||||
@@ -288,6 +449,12 @@ class FrameTimingRecorder:
|
|||||||
1.0 / refresh_hz if refresh_hz and refresh_hz > 0 else None)
|
1.0 / refresh_hz if refresh_hz and refresh_hz > 0 else None)
|
||||||
# The first estimate, until a second window agrees with it.
|
# The first estimate, until a second window agrees with it.
|
||||||
self._refresh_candidate: Optional[float] = None
|
self._refresh_candidate: Optional[float] = None
|
||||||
|
# The rate scroll speeds are solved against; see plan_refresh().
|
||||||
|
self.planned_refresh_hz: Optional[float] = None
|
||||||
|
# Trusted windows seen since the period was adopted, until the
|
||||||
|
# shortfall check has run.
|
||||||
|
self._refresh_windows = 0
|
||||||
|
self._shortfall_checked = True
|
||||||
self.totals: Dict[str, Any] = {
|
self.totals: Dict[str, Any] = {
|
||||||
"static_frames": 0,
|
"static_frames": 0,
|
||||||
"scroll_frames": 0,
|
"scroll_frames": 0,
|
||||||
@@ -302,6 +469,10 @@ class FrameTimingRecorder:
|
|||||||
"freezes": 0,
|
"freezes": 0,
|
||||||
"freeze_seconds": 0.0,
|
"freeze_seconds": 0.0,
|
||||||
"freeze_by": {label: 0 for _, label in FREEZE_BUCKETS},
|
"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,
|
"worst_interval_ms": 0.0,
|
||||||
# Per kind of noted render-thread work; see "Operations".
|
# Per kind of noted render-thread work; see "Operations".
|
||||||
"op_frames": {},
|
"op_frames": {},
|
||||||
@@ -337,7 +508,8 @@ class FrameTimingRecorder:
|
|||||||
|
|
||||||
Render thread only, like :meth:`record`, which consumes the tag: the
|
Render thread only, like :meth:`record`, which consumes the tag: the
|
||||||
interval the next frame ends is the one this work landed in. Several
|
interval the next frame ends is the one this work landed in. Several
|
||||||
notes before one frame accumulate, per kind. See "Operations".
|
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 kind: a short name for the work, e.g. ``"extend"``, ``"patch"``.
|
||||||
:param nbytes: how much the work moved, summed into ``op_bytes``.
|
:param nbytes: how much the work moved, summed into ``op_bytes``.
|
||||||
@@ -347,6 +519,21 @@ class FrameTimingRecorder:
|
|||||||
ops = self._ops = {}
|
ops = self._ops = {}
|
||||||
ops[kind] = ops.get(kind, 0) + int(nbytes)
|
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,
|
def record(self, blit: float, wait: float, hold: int, scrolling: bool,
|
||||||
presented_at: float) -> None:
|
presented_at: float) -> None:
|
||||||
"""One frame reached the panel.
|
"""One frame reached the panel.
|
||||||
@@ -354,13 +541,24 @@ class FrameTimingRecorder:
|
|||||||
:param blit: seconds spent copying the frame into the canvas.
|
:param blit: seconds spent copying the frame into the canvas.
|
||||||
:param wait: seconds SwapOnVSync blocked.
|
:param wait: seconds SwapOnVSync blocked.
|
||||||
:param hold: the refreshes this frame was held for.
|
: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.
|
:param presented_at: ``time.perf_counter()`` when the swap returned.
|
||||||
"""
|
"""
|
||||||
previous = self._previous
|
previous = self._previous
|
||||||
self._previous = (presented_at, scrolling, hold)
|
self._previous = (presented_at, scrolling, hold)
|
||||||
self.last_frame = (presented_at, scrolling, threading.get_ident())
|
self.last_frame = (presented_at, scrolling, threading.get_ident())
|
||||||
ops, self._ops = self._ops, None
|
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:
|
if not scrolling:
|
||||||
self._static_frames += 1
|
self._static_frames += 1
|
||||||
# The scroll ended, or its state went missing for this frame: the
|
# The scroll ended, or its state went missing for this frame: the
|
||||||
@@ -455,7 +653,20 @@ class FrameTimingRecorder:
|
|||||||
self._refresh_candidate = estimate
|
self._refresh_candidate = estimate
|
||||||
elif current * (1.0 - MAX_REFRESH_DROP) <= estimate < current:
|
elif current * (1.0 - MAX_REFRESH_DROP) <= estimate < current:
|
||||||
self.refresh_period = estimate
|
self.refresh_period = estimate
|
||||||
|
# Only a run of windows that agree with the period counts toward
|
||||||
|
# the shortfall check. One that disagrees (a faster one than the
|
||||||
|
# period may fall to, say, after a loaded start fixed a slow one)
|
||||||
|
# was rejected above, so the period does not reflect it, and a
|
||||||
|
# warning built on that period would name a rate the panel is not
|
||||||
|
# at. It resets the run; the check then waits for three that agree.
|
||||||
|
current = self.refresh_period
|
||||||
|
if current is not None:
|
||||||
|
agrees = abs(estimate - current) <= current * MAX_REFRESH_DROP
|
||||||
|
self._refresh_windows = self._refresh_windows + 1 if agrees else 0
|
||||||
period = self.refresh_period
|
period = self.refresh_period
|
||||||
|
if (period and not self._shortfall_checked
|
||||||
|
and self._refresh_windows >= REFRESH_CHECK_WINDOWS):
|
||||||
|
self._check_refresh_shortfall(1.0 / period)
|
||||||
|
|
||||||
histograms = self.histograms
|
histograms = self.histograms
|
||||||
for frame in batch:
|
for frame in batch:
|
||||||
@@ -467,13 +678,19 @@ class FrameTimingRecorder:
|
|||||||
for kind, nbytes in ops.items():
|
for kind, nbytes in ops.items():
|
||||||
_bump(totals["op_bytes"], kind, nbytes)
|
_bump(totals["op_bytes"], kind, nbytes)
|
||||||
if interval >= FREEZE_SECONDS:
|
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["freezes"] += 1
|
||||||
totals["freeze_seconds"] += interval
|
totals["freeze_seconds"] += interval
|
||||||
label = next(name for limit, name in FREEZE_BUCKETS
|
label = next(name for limit, name in FREEZE_BUCKETS
|
||||||
if interval < limit)
|
if interval < limit)
|
||||||
totals["freeze_by"][label] += 1
|
totals["freeze_by"][label] += 1
|
||||||
for kind in ops or ():
|
|
||||||
_bump(totals["op_freezes"], kind)
|
|
||||||
continue
|
continue
|
||||||
totals["scroll_frames"] += 1
|
totals["scroll_frames"] += 1
|
||||||
for name, value in (("blit", blit), ("wait", wait),
|
for name, value in (("blit", blit), ("wait", wait),
|
||||||
@@ -498,6 +715,25 @@ class FrameTimingRecorder:
|
|||||||
elif missed <= -1:
|
elif missed <= -1:
|
||||||
totals["early_frames"] += 1
|
totals["early_frames"] += 1
|
||||||
|
|
||||||
|
def plan_refresh(self, hz: Optional[float]) -> None:
|
||||||
|
"""Say what rate scroll speeds are solved against, before frames arrive.
|
||||||
|
|
||||||
|
``DisplayManager.refresh_hz``: the configured cap. Once the measured
|
||||||
|
rate has held for :data:`REFRESH_CHECK_WINDOWS` windows in a row, a panel that
|
||||||
|
falls short of it is logged once, with a cap it can hold (see
|
||||||
|
:func:`src.common.scroll_config.refresh_shortfall`). The display
|
||||||
|
manager calls this only for a real panel.
|
||||||
|
"""
|
||||||
|
self.planned_refresh_hz = hz
|
||||||
|
self._shortfall_checked = not hz
|
||||||
|
|
||||||
|
def _check_refresh_shortfall(self, measured_hz: float) -> None:
|
||||||
|
"""Log, once, a panel that cannot reach the rate speeds assume."""
|
||||||
|
self._shortfall_checked = True
|
||||||
|
shortfall = scroll_config.refresh_shortfall(measured_hz, self.planned_refresh_hz)
|
||||||
|
if shortfall:
|
||||||
|
logger.warning(scroll_config.describe_refresh_shortfall(shortfall))
|
||||||
|
|
||||||
def snapshot(self) -> Dict[str, Any]:
|
def snapshot(self) -> Dict[str, Any]:
|
||||||
"""The JSON document: cumulative since this process started."""
|
"""The JSON document: cumulative since this process started."""
|
||||||
if not self._binding_checked:
|
if not self._binding_checked:
|
||||||
@@ -514,9 +750,15 @@ class FrameTimingRecorder:
|
|||||||
"bucket_ms": BUCKET_MS,
|
"bucket_ms": BUCKET_MS,
|
||||||
"freeze_seconds": FREEZE_SECONDS,
|
"freeze_seconds": FREEZE_SECONDS,
|
||||||
"measured_refresh_hz": round(1.0 / period, 2) if period else None,
|
"measured_refresh_hz": round(1.0 / period, 2) if period else None,
|
||||||
|
# Additive: what scroll speeds were solved against, so a reader can
|
||||||
|
# tell a stale file (written under another cap) from this one.
|
||||||
|
"planned_refresh_hz": self.planned_refresh_hz,
|
||||||
"binding_releases_gil": self._binding_gil,
|
"binding_releases_gil": self._binding_gil,
|
||||||
"info": info,
|
"info": info,
|
||||||
"totals": copy.deepcopy(self.totals),
|
"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.
|
# JSON keys are strings; readers convert back.
|
||||||
"histograms": {name: {str(k): v for k, v in sorted(h.items())}
|
"histograms": {name: {str(k): v for k, v in sorted(h.items())}
|
||||||
for name, h in self.histograms.items()},
|
for name, h in self.histograms.items()},
|
||||||
@@ -647,14 +889,28 @@ class StallWatchdog:
|
|||||||
return stall_from, dumped
|
return stall_from, dumped
|
||||||
|
|
||||||
def describe(self, ident: int, age: float, late: float) -> str:
|
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()}
|
names = {t.ident: t.name for t in threading.enumerate()}
|
||||||
frames = sys._current_frames()
|
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 = [
|
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"
|
f"(watchdog woke {late * 1000.0:.0f}ms late"
|
||||||
+ ("; the interpreter itself was blocked" if late >= age / 2 else "")
|
+ ("; the interpreter itself was blocked" if late >= age / 2 else "")
|
||||||
+ ")",
|
+ gc_note + ")",
|
||||||
f"-- {names.get(ident, ident)} (presents frames):",
|
f"-- {names.get(ident, ident)} (presents frames):",
|
||||||
]
|
]
|
||||||
stalled = frames.get(ident)
|
stalled = frames.get(ident)
|
||||||
|
|||||||
@@ -538,3 +538,66 @@ def speed_advice(
|
|||||||
"smooth": smooth,
|
"smooth": smooth,
|
||||||
"alternatives": [as_dict(c) for c in alternatives],
|
"alternatives": [as_dict(c) for c in alternatives],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: A measured refresh this far below the rate speeds are planned for means
|
||||||
|
#: the panel cannot reach its cap. Smaller gaps are the cap's own slack and
|
||||||
|
#: the estimate's: one rig measured 99.95 Hz under a 100 Hz cap.
|
||||||
|
REFRESH_SHORTFALL = 0.03
|
||||||
|
|
||||||
|
#: How far under the measured rate a suggested cap sits. The measurement is
|
||||||
|
#: the fast end of the panel's refreshes (frame_timing takes the 10th
|
||||||
|
#: percentile of intervals), and an uncapped panel drifts: one read
|
||||||
|
#: 107.6-113.1 Hz over 15 seconds. A cap inside that band would not hold.
|
||||||
|
CAP_HEADROOM = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def holdable_cap(measured_hz: Any) -> Optional[int]:
|
||||||
|
"""A refresh cap the panel can hold: a multiple of 10, 5% under what it measured.
|
||||||
|
|
||||||
|
A multiple of 10 because its whole-pixel speeds are round numbers (a
|
||||||
|
100 Hz cap gives 50 and 100 px/s). None without a usable measurement, or
|
||||||
|
when the panel is too slow for any cap of 10 Hz or more.
|
||||||
|
"""
|
||||||
|
hz = _coerce(measured_hz)
|
||||||
|
if hz is None:
|
||||||
|
return None
|
||||||
|
cap = int(hz * (1.0 - CAP_HEADROOM) // 10) * 10
|
||||||
|
return cap if cap >= 10 else None
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_shortfall(measured_hz: Any, planned_hz: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""When the panel refreshes measurably slower than speeds are planned for.
|
||||||
|
|
||||||
|
``planned_hz`` is what :func:`configure` solves against -- the
|
||||||
|
``limit_refresh_rate_hz`` cap, or :data:`DEFAULT_REFRESH_HZ` when it is 0.
|
||||||
|
A panel that cannot reach it still moves whole pixels per frame, but every
|
||||||
|
speed runs slow by the shortfall and the ladder of smooth speeds is the
|
||||||
|
cap's, not the panel's. None when there is no measurement, or the panel
|
||||||
|
reaches the cap (or beats it, as some do by a few Hz).
|
||||||
|
"""
|
||||||
|
measured, planned = _coerce(measured_hz), _coerce(planned_hz)
|
||||||
|
if measured is None or planned is None:
|
||||||
|
return None
|
||||||
|
if measured >= planned * (1.0 - REFRESH_SHORTFALL):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"measured_hz": round(measured, 1),
|
||||||
|
"planned_hz": round(planned, 1),
|
||||||
|
"suggested_cap_hz": holdable_cap(measured),
|
||||||
|
"slow_percent": round((1.0 - measured / planned) * 100),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def describe_refresh_shortfall(shortfall: Dict[str, Any]) -> str:
|
||||||
|
"""One log line for :func:`refresh_shortfall`'s answer."""
|
||||||
|
text = (
|
||||||
|
f"The panel refreshes at about {shortfall['measured_hz']:.0f} Hz, below "
|
||||||
|
f"the {shortfall['planned_hz']:.0f} Hz that scroll speeds are planned "
|
||||||
|
f"for (display.hardware.limit_refresh_rate_hz), so every scroll runs "
|
||||||
|
f"about {shortfall['slow_percent']}% slower than configured and the "
|
||||||
|
f"smooth speeds are worked out for a rate this panel never reaches.")
|
||||||
|
if shortfall.get("suggested_cap_hz"):
|
||||||
|
text += (f" Set Limit Refresh Rate to {shortfall['suggested_cap_hz']} Hz "
|
||||||
|
f"(web UI, Display tab), which this panel can hold, and restart.")
|
||||||
|
return text
|
||||||
|
|||||||
+76
-31
@@ -28,6 +28,30 @@ import numpy as np
|
|||||||
# long over one frame, so a sample this large is an idle gap between scrolls.
|
# long over one frame, so a sample this large is an idle gap between scrolls.
|
||||||
FPS_LOG_INTERVAL = 5.0
|
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:
|
def _rgb_pixels(item) -> np.ndarray:
|
||||||
"""An appended item's pixels as an RGB array, as pasting it would draw them."""
|
"""An appended item's pixels as an RGB array, as pasting it would draw them."""
|
||||||
@@ -189,6 +213,11 @@ class ScrollHelper:
|
|||||||
# Every frame time since the last stats line, so the 5s summary can
|
# Every frame time since the last stats line, so the 5s summary can
|
||||||
# report the tail rather than one arbitrary sample. Cleared on log.
|
# report the tail rather than one arbitrary sample. Cleared on log.
|
||||||
self._window: list = []
|
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
|
# Scrolling state management
|
||||||
self.is_scrolling = False
|
self.is_scrolling = False
|
||||||
@@ -532,7 +561,7 @@ class ScrollHelper:
|
|||||||
width = self.display_width
|
width = self.display_width
|
||||||
strip_width = self.cached_array.shape[1]
|
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
|
# Slice the backing array directly. Going via
|
||||||
# _get_visible_portion_integer would build two PIL images only for
|
# _get_visible_portion_integer would build two PIL images only for
|
||||||
# them to be converted straight back to arrays, which measured 15x
|
# them to be converted straight back to arrays, which measured 15x
|
||||||
@@ -540,9 +569,10 @@ class ScrollHelper:
|
|||||||
near = self.cached_array[:, start_x:start_x + width]
|
near = self.cached_array[:, start_x:start_x + width]
|
||||||
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
||||||
else:
|
else:
|
||||||
# Close enough to the end that one of the slices wraps; let the
|
# One of the slices wraps (close to the end, or a strip narrower
|
||||||
# integer path handle that and pay the conversion. Continuous mode
|
# than the panel); let the integer path handle that and pay the
|
||||||
# extends the strip before reaching here, so this is the rare case.
|
# conversion. Continuous mode extends the strip before reaching
|
||||||
|
# here, so this is the rare case.
|
||||||
near = np.asarray(
|
near = np.asarray(
|
||||||
self._get_visible_portion_integer(start_x, start_x + width))
|
self._get_visible_portion_integer(start_x, start_x + width))
|
||||||
far = np.asarray(
|
far = np.asarray(
|
||||||
@@ -572,31 +602,33 @@ class ScrollHelper:
|
|||||||
_size = (self.display_width, self.display_height)
|
_size = (self.display_width, self.display_height)
|
||||||
img_w = self.cached_array.shape[1]
|
img_w = self.cached_array.shape[1]
|
||||||
|
|
||||||
if end_x <= img_w:
|
if 0 <= start_x and end_x <= img_w:
|
||||||
# Normal case: single contiguous slice (fastest path)
|
# Normal case: single contiguous slice (fastest path). tobytes()
|
||||||
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
|
# on the column-slice view already returns C-order bytes, so
|
||||||
return Image.frombytes('RGB', _size, frame_array.tobytes())
|
# 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:
|
else:
|
||||||
# Ensure frame buffer is allocated for all non-simple paths
|
# The frame runs off the strip, so it carries on from the head:
|
||||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
# frame column j is strip column (start_x + j) modulo the strip's
|
||||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
# 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
|
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||||
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())
|
|
||||||
|
|
||||||
def calculate_dynamic_duration(self) -> int:
|
def calculate_dynamic_duration(self) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -1207,10 +1239,23 @@ class ScrollHelper:
|
|||||||
# as an idle gap. There is nothing to report, and reporting the
|
# as an idle gap. There is nothing to report, and reporting the
|
||||||
# gap itself is the bug above.
|
# gap itself is the bug above.
|
||||||
if self._window:
|
if self._window:
|
||||||
self.logger.info(
|
# INFO when degraded, on the window that recovers from it, and
|
||||||
"Scroll frame stats - %s",
|
# as a slow heartbeat; DEBUG otherwise.
|
||||||
format_frame_stats(self._window),
|
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.last_fps_log_time = current_time
|
||||||
self.frame_count = 0
|
self.frame_count = 0
|
||||||
self._window = []
|
self._window = []
|
||||||
|
|||||||
@@ -26,14 +26,33 @@ Policy:
|
|||||||
- Unchanged frames are never re-encoded; the mtime is touched every
|
- Unchanged frames are never re-encoded; the mtime is touched every
|
||||||
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
|
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
|
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.
|
api_v3/misc.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
# Snapshot cadence with a browser preview open (seconds).
|
# Snapshot cadence with a browser preview open (seconds): the shortest gap
|
||||||
VIEWER_INTERVAL = 0.2
|
# 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).
|
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
||||||
IDLE_INTERVAL = 30.0
|
IDLE_INTERVAL = 30.0
|
||||||
# Max age of the last write/touch before bumping mtime for the health
|
# 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
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
@@ -338,10 +338,46 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
|||||||
if not raw:
|
if not raw:
|
||||||
return ""
|
return ""
|
||||||
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
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*.
|
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
|
||||||
|
|
||||||
The body both date formatters share. They differ in which setting names the
|
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:
|
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
|
||||||
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
||||||
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
|
*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":
|
if fmt == "numeric":
|
||||||
return raw
|
return raw
|
||||||
@@ -364,7 +403,7 @@ def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
|||||||
if fmt == "day_first":
|
if fmt == "day_first":
|
||||||
return f"{day} {name}"
|
return f"{day} {name}"
|
||||||
if fmt == "weekday":
|
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"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
||||||
return f"{name} {day}"
|
return f"{name} {day}"
|
||||||
|
|
||||||
|
|||||||
@@ -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``.
|
- ``live_games``, read with ``getattr`` -- ``_needs_previous_day``.
|
||||||
- ``background_service``, read with ``getattr`` --
|
- ``background_service``, read with ``getattr`` --
|
||||||
``_background_fetches_espn_ranges``.
|
``_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.
|
Add it as a base of the plugin's ``SportsCore``, e.g.
|
||||||
``class SportsCore(SportsFetchMixin, SportsCoreSharedMixin,
|
``class SportsCore(SportsFetchMixin, SportsCoreSharedMixin,
|
||||||
@@ -50,9 +64,31 @@ constant on the plugin's own class still wins over the mixin's.
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timedelta
|
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:
|
class SportsFetchMixin:
|
||||||
@@ -65,6 +101,8 @@ class SportsFetchMixin:
|
|||||||
cache_manager: Any
|
cache_manager: Any
|
||||||
logger: logging.Logger
|
logger: logging.Logger
|
||||||
_games_lock: threading.RLock
|
_games_lock: threading.RLock
|
||||||
|
sport: str
|
||||||
|
league: str
|
||||||
|
|
||||||
#: How many games past the one on screen keep their odds warm. One is
|
#: 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
|
#: 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)
|
service = getattr(self, "background_service", None)
|
||||||
return bool(getattr(service, "handles_espn_date_ranges", False))
|
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(
|
def _fetch_season_directly(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
datestring: str,
|
datestring: str,
|
||||||
cache_key: str,
|
cache_key: Optional[str],
|
||||||
label: str,
|
label: str,
|
||||||
ttl: Optional[int] = None,
|
ttl: Optional[int] = None,
|
||||||
) -> Optional[Dict]:
|
) -> Optional[Dict]:
|
||||||
"""Fetch a season schedule on this thread, in chunks ESPN accepts, and cache it.
|
"""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"``.
|
``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:
|
try:
|
||||||
data = fetch_espn_scoreboard(
|
data = fetch_espn_scoreboard(
|
||||||
self.session,
|
self.session,
|
||||||
|
|||||||
@@ -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
|
``_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,
|
override point from the since-removed ``src/base_classes`` sports core,
|
||||||
carried here so later phases (shared celebrations and game selection) have a
|
carried here as the hardware-free home for the seam. ``sports_favorites``
|
||||||
hardware-free home for the seam. No plugin defines it today and nothing in this module calls it.
|
calls it; nrl overrides it with the ESPN team id. Nothing in this module
|
||||||
|
calls it.
|
||||||
|
|
||||||
WHAT A HOST MUST PROVIDE
|
WHAT A HOST MUST PROVIDE
|
||||||
------------------------
|
------------------------
|
||||||
@@ -47,8 +48,8 @@ listed here.
|
|||||||
- ``mode_config`` (dict) and ``logger`` -- ``_setting_int``. ``league`` is
|
- ``mode_config`` (dict) and ``logger`` -- ``_setting_int``. ``league`` is
|
||||||
read with ``getattr`` for the warning text only.
|
read with ``getattr`` for the warning text only.
|
||||||
- ``games_list`` and ``current_game_index`` -- ``_next_switch_index``; plus
|
- ``games_list`` and ``current_game_index`` -- ``_next_switch_index``; plus
|
||||||
``_is_favorite_game`` (called with a game), which stays per-plugin and is
|
``_is_favorite_game`` (called with a game; ``sports_favorites`` has the
|
||||||
only called when
|
shared body), only called when
|
||||||
``favorite_rotation_boost`` is above 1. ``favorite_rotation_boost`` itself
|
``favorite_rotation_boost`` is above 1. ``favorite_rotation_boost`` itself
|
||||||
defaults to 1 on the mixin.
|
defaults to 1 on the mixin.
|
||||||
- ``last_game_switch`` -- ``_reset_dwell_on_reentry``, read with ``getattr``
|
- ``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"::
|
rather than a branch so core never has to learn the string "nrl"::
|
||||||
|
|
||||||
def _favorite_key(self, game, side):
|
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
|
It returns ``None`` for a missing id rather than ``str(None)``, which
|
||||||
literal ``"None"``, which would spuriously match a favorites list
|
would match a favourite typed "None". A ``None`` never matches.
|
||||||
containing that string. The default returns ``None`` for a missing
|
``sports_favorites`` compares the value stripped and upper-cased.
|
||||||
abbreviation, which never matches.
|
|
||||||
|
|
||||||
Carried from the since-removed ``src/base_classes`` sports core for
|
|
||||||
later phases; nothing in this module calls it yet.
|
|
||||||
"""
|
"""
|
||||||
return game.get(f"{side}_abbr")
|
return game.get(f"{side}_abbr")
|
||||||
|
|
||||||
|
|||||||
+372
-13
@@ -44,7 +44,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import weakref
|
||||||
|
from collections import OrderedDict
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -320,7 +323,7 @@ class SportsScrollDisplay:
|
|||||||
:returns: True if a frame was drawn; False when there is no content or
|
:returns: True if a frame was drawn; False when there is no content or
|
||||||
the frame could not be rendered.
|
the frame could not be rendered.
|
||||||
"""
|
"""
|
||||||
if not self.scroll_helper.cached_image:
|
if not self._has_strip():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -413,7 +416,21 @@ class SportsScrollDisplay:
|
|||||||
|
|
||||||
def has_cached_content(self) -> bool:
|
def has_cached_content(self) -> bool:
|
||||||
"""Whether content is prepared and ready to scroll."""
|
"""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
|
# Live Vegas cards
|
||||||
@@ -589,16 +606,80 @@ class SportsScrollDisplay:
|
|||||||
return info
|
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:
|
class SportsScrollDisplayManager:
|
||||||
"""One :class:`SportsScrollDisplay` per game type ('live'/'recent'/'upcoming').
|
"""One :class:`SportsScrollDisplay` per game type ('live'/'recent'/'upcoming').
|
||||||
|
|
||||||
Subclasses set :attr:`display_class`; everything else was near-identical
|
Subclasses set :attr:`display_class`; everything else was near-identical
|
||||||
across the eight plugin copies.
|
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.
|
#: The SportsScrollDisplay subclass to instantiate per game type.
|
||||||
display_class = SportsScrollDisplay
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
display_manager,
|
display_manager,
|
||||||
@@ -616,16 +697,34 @@ class SportsScrollDisplayManager:
|
|||||||
# either way, but two spellings of "nothing active" across two classes
|
# either way, but two spellings of "nothing active" across two classes
|
||||||
# is a trap for anyone comparing state between them.
|
# is a trap for anyone comparing state between them.
|
||||||
self._current_game_type: str = ""
|
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:
|
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:
|
if game_type not in self._scroll_displays:
|
||||||
self._scroll_displays[game_type] = self.display_class(
|
self._scroll_displays[game_type] = self._new_scroll_display()
|
||||||
self.display_manager,
|
|
||||||
self.config,
|
|
||||||
self.logger,
|
|
||||||
global_config=self.global_config,
|
|
||||||
)
|
|
||||||
return self._scroll_displays[game_type]
|
return self._scroll_displays[game_type]
|
||||||
|
|
||||||
def prepare_and_display(
|
def prepare_and_display(
|
||||||
@@ -635,8 +734,63 @@ class SportsScrollDisplayManager:
|
|||||||
leagues: List[str],
|
leagues: List[str],
|
||||||
rankings_cache: Optional[Dict[str, int]] = None,
|
rankings_cache: Optional[Dict[str, int]] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Build content for ``game_type`` and make it the active strip."""
|
"""Build content for ``game_type`` and make it the active strip.
|
||||||
scroll_display = self.get_scroll_display(game_type)
|
|
||||||
|
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:
|
try:
|
||||||
success = scroll_display.prepare_scroll_content(
|
success = scroll_display.prepare_scroll_content(
|
||||||
games, game_type, leagues, rankings_cache
|
games, game_type, leagues, rankings_cache
|
||||||
@@ -654,6 +808,200 @@ class SportsScrollDisplayManager:
|
|||||||
self._current_game_type = game_type
|
self._current_game_type = game_type
|
||||||
return success
|
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:
|
def display_frame(self, game_type: Optional[str] = None) -> bool:
|
||||||
"""Advance the active strip (or a named one) by one frame."""
|
"""Advance the active strip (or a named one) by one frame."""
|
||||||
game_type = game_type or self._current_game_type
|
game_type = game_type or self._current_game_type
|
||||||
@@ -676,8 +1024,19 @@ class SportsScrollDisplayManager:
|
|||||||
return scroll_display.is_scroll_complete()
|
return scroll_display.is_scroll_complete()
|
||||||
|
|
||||||
def clear_all(self) -> None:
|
def clear_all(self) -> None:
|
||||||
"""Clear every display and forget which one was active."""
|
"""Clear every display and forget which one was active.
|
||||||
for scroll_display in self._scroll_displays.values():
|
|
||||||
|
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()
|
scroll_display.clear()
|
||||||
self._current_game_type = ""
|
self._current_game_type = ""
|
||||||
|
|
||||||
|
|||||||
+66
-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``,
|
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
|
||||||
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
|
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
|
||||||
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
|
``_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
|
Of the fourteen shared class constants, thirteen are identical everywhere and
|
||||||
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
|
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 PIL import Image, ImageDraw
|
||||||
from src.common import sports_card as _card
|
from src.common import sports_card as _card
|
||||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -119,6 +122,32 @@ _DEFAULT_LIVE_IDLE_MAX_SECONDS = 900
|
|||||||
_KICKOFF_GRACE_SECONDS = 900
|
_KICKOFF_GRACE_SECONDS = 900
|
||||||
#: Fallback cadence around a kickoff when the manager has no update_interval.
|
#: Fallback cadence around a kickoff when the manager has no update_interval.
|
||||||
_KICKOFF_POLL_FLOOR = 30
|
_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:
|
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
|
The formatting is sports_card's. What differs from the card's
|
||||||
``format_game_date`` is passed in: the setting (``switch_date_format``,
|
``format_game_date`` is passed in: the setting (``switch_date_format``,
|
||||||
see :meth:`_switch_date_format`) and the weekday, which comes from
|
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()
|
raw = str(date_text or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return raw
|
return raw
|
||||||
return _card._format_date_as(self._switch_date_format(), raw,
|
return _card._format_date_as(self._switch_date_format(), raw,
|
||||||
lambda: self._weekday_for(game),
|
lambda: self._weekday_for(game),
|
||||||
self._MONTH_ABBR)
|
self._MONTH_ABBR, game=game)
|
||||||
|
|
||||||
def _weekday_for(self, game: Optional[Dict]) -> str:
|
def _weekday_for(self, game: Optional[Dict]) -> str:
|
||||||
"""Weekday abbreviation from the game's start time, or ''."""
|
"""Weekday abbreviation from the game's start time, or ''."""
|
||||||
@@ -851,19 +882,12 @@ class SportsCoreSharedMixin:
|
|||||||
elif fill is None:
|
elif fill is None:
|
||||||
fill = self._font_color(font)
|
fill = self._font_color(font)
|
||||||
draw.fontmode = "1"
|
draw.fontmode = "1"
|
||||||
x, y = position
|
# The eight-neighbour outline, then the text on top. Rasterized once
|
||||||
for dx, dy in [
|
# and stamped nine times rather than drawn nine times; the pixels are
|
||||||
(-1, -1),
|
# the same (draw_text_outlined falls back to the nine draws wherever
|
||||||
(-1, 0),
|
# that is not proven).
|
||||||
(-1, 1),
|
draw_text_outlined(draw, position, text, font, fill, outline_color,
|
||||||
(0, -1),
|
OUTLINE_SQUARE)
|
||||||
(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)
|
|
||||||
|
|
||||||
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
|
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
|
||||||
"""True at most once per ``cooldown`` seconds, for rate-limiting a
|
"""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
|
otherwise look like another empty check and escalate the back-off
|
||||||
again, right when the game is actually starting.
|
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:
|
if not start:
|
||||||
return interval
|
return interval
|
||||||
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
|
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
|
||||||
now = time.time()
|
|
||||||
if now < start:
|
if now < start:
|
||||||
return max(live, min(interval, int(start - now)))
|
return max(live, min(interval, int(start - now)))
|
||||||
if now - start <= _KICKOFF_GRACE_SECONDS:
|
if now - start <= _KICKOFF_GRACE_SECONDS:
|
||||||
@@ -1317,6 +1341,16 @@ class SportsLiveSharedMixin:
|
|||||||
already has. Self-correcting: a stored start that has passed is
|
already has. Self-correcting: a stored start that has passed is
|
||||||
replaced by the next one offered, so a postponed game cannot pin the
|
replaced by the next one offered, so a postponed game cannot pin the
|
||||||
cadence to a kickoff that never happens.
|
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):
|
if not isinstance(details, dict):
|
||||||
return
|
return
|
||||||
@@ -1333,7 +1367,7 @@ class SportsLiveSharedMixin:
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
if candidate <= now:
|
if candidate <= now:
|
||||||
return
|
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
|
# 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
|
# 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
|
# window in _clamp_to_scheduled_start dead code: the moment 13:00 came
|
||||||
@@ -1343,10 +1377,20 @@ class SportsLiveSharedMixin:
|
|||||||
# polled at 13:00:45, found nothing live because ESPN had not flipped
|
# 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,
|
# the status yet, and then went quiet for the next quarter of an hour,
|
||||||
# which is the behaviour this whole clamp exists to prevent.
|
# which is the behaviour this whole clamp exists to prevent.
|
||||||
if (current is None
|
#
|
||||||
or current <= now - _KICKOFF_GRACE_SECONDS
|
# Nor is it forgotten: whichever kickoff loses is queued behind the
|
||||||
or candidate < current):
|
# one honoured now, so it gets its own grace when that one's ends.
|
||||||
|
if current is None:
|
||||||
self._next_scheduled_start_ts = candidate
|
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
|
#: How long a game that finished live is still reported by
|
||||||
#: finished_games_snapshot(): long enough for the recent-games list, which
|
#: finished_games_snapshot(): long enough for the recent-games list, which
|
||||||
|
|||||||
+13
-41
@@ -29,7 +29,6 @@ import time
|
|||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
import numpy as np
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from src.config_manager_atomic import _replace
|
from src.config_manager_atomic import _replace
|
||||||
@@ -142,7 +141,6 @@ class DisplaySyncManager:
|
|||||||
self._last_leader_frame_time: float = 0.0
|
self._last_leader_frame_time: float = 0.0
|
||||||
self._frame_lock = threading.Lock()
|
self._frame_lock = threading.Lock()
|
||||||
self._leader_ip: Optional[str] = None
|
self._leader_ip: Optional[str] = None
|
||||||
self._on_new_cycle: Optional[Callable[[], None]] = None # called when leader starts new cycle
|
|
||||||
self._on_scroll_image: Optional[Callable[[Image.Image], None]] = None # called with Image when received
|
self._on_scroll_image: Optional[Callable[[Image.Image], None]] = None # called with Image when received
|
||||||
self._pending_scroll_image: Optional[Image.Image] = None # image received before callback set
|
self._pending_scroll_image: Optional[Image.Image] = None # image received before callback set
|
||||||
self._scroll_image_lock = threading.Lock() # guards _on_scroll_image / _pending_scroll_image
|
self._scroll_image_lock = threading.Lock() # guards _on_scroll_image / _pending_scroll_image
|
||||||
@@ -413,17 +411,6 @@ class DisplaySyncManager:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync: scroll_x send error: %s", exc)
|
self.logger.debug("Sync: scroll_x send error: %s", exc)
|
||||||
|
|
||||||
def send_new_cycle(self) -> None:
|
|
||||||
"""Leader: signal that a new scroll cycle has started so follower rebuilds its image."""
|
|
||||||
if self.role != SyncRole.LEADER:
|
|
||||||
return
|
|
||||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._send_sock.sendto(b'{"t":"nc"}', (self._peer_ip, self.port))
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: new_cycle send error: %s", exc)
|
|
||||||
|
|
||||||
def send_frame(self, image: Image.Image) -> None:
|
def send_frame(self, image: Image.Image) -> None:
|
||||||
"""Leader: send a rendered frame to the follower as raw RGB bytes.
|
"""Leader: send a rendered frame to the follower as raw RGB bytes.
|
||||||
Raw format is orders of magnitude faster than PNG on Pi hardware —
|
Raw format is orders of magnitude faster than PNG on Pi hardware —
|
||||||
@@ -434,6 +421,12 @@ class DisplaySyncManager:
|
|||||||
return
|
return
|
||||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
||||||
return
|
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:
|
try:
|
||||||
arr = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
arr = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||||
header = _RAW_MAGIC + _RAW_HEADER.pack(image.width, image.height)
|
header = _RAW_MAGIC + _RAW_HEADER.pack(image.width, image.height)
|
||||||
@@ -501,21 +494,19 @@ class DisplaySyncManager:
|
|||||||
self._latest_frame = img
|
self._latest_frame = img
|
||||||
self._enter_follower_mode(sender_ip)
|
self._enter_follower_mode(sender_ip)
|
||||||
|
|
||||||
def _enter_follower_mode(self, sender_ip: str) -> bool:
|
def _enter_follower_mode(self, sender_ip: str) -> None:
|
||||||
"""Note that the leader at ``sender_ip`` just sent something, and
|
"""Note that the leader at ``sender_ip`` just sent something, and
|
||||||
switch from standalone to follower mode if not already following.
|
switch from standalone to follower mode if not already following."""
|
||||||
Returns True if this call made the switch."""
|
|
||||||
self._last_leader_frame_time = time.monotonic()
|
self._last_leader_frame_time = time.monotonic()
|
||||||
self._leader_ip = sender_ip
|
self._leader_ip = sender_ip
|
||||||
if self._follower_state != FollowerState.STANDALONE:
|
if self._follower_state != FollowerState.STANDALONE:
|
||||||
return False
|
return
|
||||||
self._follower_state = FollowerState.FOLLOWER
|
self._follower_state = FollowerState.FOLLOWER
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Sync: leader active at %s — switching to follower mode",
|
"Sync: leader active at %s — switching to follower mode",
|
||||||
sender_ip,
|
sender_ip,
|
||||||
)
|
)
|
||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
return True
|
|
||||||
|
|
||||||
def _follower_recv_loop(self) -> None:
|
def _follower_recv_loop(self) -> None:
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -565,12 +556,9 @@ class DisplaySyncManager:
|
|||||||
# frame. Read and validate its fields under a guard —
|
# frame. Read and validate its fields under a guard —
|
||||||
# a UDP payload is attacker-shaped, so a non-object
|
# a UDP payload is attacker-shaped, so a non-object
|
||||||
# body makes .get() raise AttributeError and an "sx"
|
# body makes .get() raise AttributeError and an "sx"
|
||||||
# carrying a non-numeric x raises ValueError/TypeError
|
# carrying a non-numeric x raises ValueError/TypeError.
|
||||||
# — but dispatch the callback *outside* it. Running
|
# Any other "t" is ignored, including the "nc" (new
|
||||||
# the callback in here would let a fault in someone
|
# cycle) that older leaders send and no follower used.
|
||||||
# else's code read as a malformed packet and be
|
|
||||||
# logged as one.
|
|
||||||
fire_new_cycle = False
|
|
||||||
try:
|
try:
|
||||||
t = msg.get("t")
|
t = msg.get("t")
|
||||||
if t == "hello_ack":
|
if t == "hello_ack":
|
||||||
@@ -596,18 +584,11 @@ class DisplaySyncManager:
|
|||||||
# back from. Treat it as malformed.
|
# back from. Treat it as malformed.
|
||||||
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
||||||
self._latest_scroll_x = scroll_x
|
self._latest_scroll_x = scroll_x
|
||||||
if self._enter_follower_mode(sender_ip):
|
self._enter_follower_mode(sender_ip)
|
||||||
fire_new_cycle = True # build initial scroll image
|
|
||||||
elif t == "nc":
|
|
||||||
# Leader started a new scroll cycle — rebuild local image
|
|
||||||
fire_new_cycle = True
|
|
||||||
except (KeyError, AttributeError, TypeError, ValueError) as exc:
|
except (KeyError, AttributeError, TypeError, ValueError) as exc:
|
||||||
self.logger.debug("Sync: malformed control message: %s", exc)
|
self.logger.debug("Sync: malformed control message: %s", exc)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if fire_new_cycle and self._on_new_cycle:
|
|
||||||
self._on_new_cycle()
|
|
||||||
|
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -674,15 +655,6 @@ class DisplaySyncManager:
|
|||||||
"""Follower: return the most recently received Vegas scroll position, or None."""
|
"""Follower: return the most recently received Vegas scroll position, or None."""
|
||||||
return self._latest_scroll_x
|
return self._latest_scroll_x
|
||||||
|
|
||||||
def set_on_new_cycle(self, callback: Callable[[], None]) -> None:
|
|
||||||
"""Follower: register a callback fired when the leader starts a new scroll cycle.
|
|
||||||
|
|
||||||
Nothing in core registers one: display_controller follows the leader
|
|
||||||
through set_on_scroll_image() and the scroll position instead of
|
|
||||||
rebuilding locally. The hook stays for callers that want the signal.
|
|
||||||
"""
|
|
||||||
self._on_new_cycle = callback
|
|
||||||
|
|
||||||
def get_latest_frame(self) -> Optional[Image.Image]:
|
def get_latest_frame(self) -> Optional[Image.Image]:
|
||||||
"""Follower: return the most recently received pixel frame (non-Vegas fallback)."""
|
"""Follower: return the most recently received pixel frame (non-Vegas fallback)."""
|
||||||
with self._frame_lock:
|
with self._frame_lock:
|
||||||
|
|||||||
+177
-9
@@ -7,7 +7,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
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 PIL import Image, ImageDraw, ImageFont
|
||||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
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.
|
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||||
|
|
||||||
|
#: 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:
|
class TextHelper:
|
||||||
"""
|
"""
|
||||||
@@ -104,14 +272,14 @@ class TextHelper:
|
|||||||
"""
|
"""
|
||||||
x, y = position
|
x, y = position
|
||||||
|
|
||||||
# Draw outline by drawing text in outline color at offset positions
|
# Outline: every offset up to outline_width away on each axis, centre
|
||||||
for dx in range(-outline_width, outline_width + 1):
|
# skipped, in the order this has always drawn them (OUTLINE_SQUARE at
|
||||||
for dy in range(-outline_width, outline_width + 1):
|
# width 1). The main text is drawn last, on top.
|
||||||
if dx != 0 or dy != 0: # Skip center position
|
offsets = [(dx, dy)
|
||||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
for dx in range(-outline_width, outline_width + 1)
|
||||||
|
for dy in range(-outline_width, outline_width + 1)
|
||||||
# Draw main text
|
if dx != 0 or dy != 0]
|
||||||
draw.text((x, y), text, font=font, fill=fill)
|
draw_text_outlined(draw, (x, y), text, font, fill, outline_color, offsets)
|
||||||
|
|
||||||
def get_text_width(self, text: str, font: ImageFont.ImageFont) -> int:
|
def get_text_width(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+49
-9
@@ -45,6 +45,36 @@ from src.common.permission_utils import (
|
|||||||
ensure_shared_group_ownership,
|
ensure_shared_group_ownership,
|
||||||
get_config_dir_mode
|
get_config_dir_mode
|
||||||
)
|
)
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
class ConfigManager:
|
||||||
"""
|
"""
|
||||||
@@ -126,9 +156,10 @@ class ConfigManager:
|
|||||||
validate_after_write=validate_after_write
|
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:
|
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
|
# In-memory config now matches what was just written, so the
|
||||||
# load_config fast path may return it. It still carries the
|
# load_config fast path may return it. It still carries the
|
||||||
# merged secrets that were stripped on disk; that matches a full
|
# merged secrets that were stripped on disk; that matches a full
|
||||||
@@ -144,6 +175,7 @@ class ConfigManager:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "backups are handled by src.backup_manager")
|
||||||
def rollback_config(self, backup_version: Optional[str] = None) -> bool:
|
def rollback_config(self, backup_version: Optional[str] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Rollback configuration to a previous backup.
|
Rollback configuration to a previous backup.
|
||||||
@@ -168,6 +200,7 @@ class ConfigManager:
|
|||||||
|
|
||||||
return success
|
return success
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "backups are handled by src.backup_manager")
|
||||||
def list_backups(self) -> List[BackupInfo]:
|
def list_backups(self) -> List[BackupInfo]:
|
||||||
"""
|
"""
|
||||||
List all available configuration backups.
|
List all available configuration backups.
|
||||||
@@ -178,6 +211,7 @@ class ConfigManager:
|
|||||||
atomic_mgr = self._get_atomic_manager()
|
atomic_mgr = self._get_atomic_manager()
|
||||||
return atomic_mgr.list_backups()
|
return atomic_mgr.list_backups()
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:
|
def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:
|
||||||
"""
|
"""
|
||||||
Validate a configuration file.
|
Validate a configuration file.
|
||||||
@@ -208,14 +242,16 @@ class ConfigManager:
|
|||||||
|
|
||||||
Fast path: when config.json, config_secrets.json and the template
|
Fast path: when config.json, config_secrets.json and the template
|
||||||
are all unchanged since the last successful load (mtime_ns + size),
|
are all unchanged since the last successful load (mtime_ns + size),
|
||||||
the already-parsed self.config is returned without touching the
|
a copy of the already-parsed self.config is returned without
|
||||||
files — same aliasing semantics as the full path, which also
|
touching the files.
|
||||||
returns self.config.
|
|
||||||
|
Either way the caller gets its own copy (see _private_copy): editing
|
||||||
|
it changes nothing here until it is saved.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
current_sig = self._files_signature()
|
current_sig = self._files_signature()
|
||||||
if self.config and self._loaded_sig == current_sig:
|
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
|
# Check if config file exists, if not create from template
|
||||||
if not os.path.exists(self.config_path):
|
if not os.path.exists(self.config_path):
|
||||||
@@ -249,7 +285,7 @@ class ConfigManager:
|
|||||||
# Signature taken AFTER load + migration (migration may write the
|
# Signature taken AFTER load + migration (migration may write the
|
||||||
# config back), so it reflects exactly what was read/written.
|
# config back), so it reflects exactly what was read/written.
|
||||||
self._loaded_sig = self._files_signature()
|
self._loaded_sig = self._files_signature()
|
||||||
return self.config
|
return _private_copy(self.config)
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
# Only config.json can get here: a missing or unreadable secrets
|
# Only config.json can get here: a missing or unreadable secrets
|
||||||
@@ -355,8 +391,9 @@ class ConfigManager:
|
|||||||
try:
|
try:
|
||||||
atomic_write_json(self.config_path, config_to_write)
|
atomic_write_json(self.config_path, config_to_write)
|
||||||
|
|
||||||
# Update the in-memory config to the new state (which includes secrets for runtime)
|
# Update the in-memory config to the new state (which includes
|
||||||
self.config = new_config_data
|
# secrets for runtime), as a copy -- see _private_copy
|
||||||
|
self.config = _private_copy(new_config_data)
|
||||||
self._loaded_sig = self._files_signature()
|
self._loaded_sig = self._files_signature()
|
||||||
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
||||||
if secrets_content:
|
if secrets_content:
|
||||||
@@ -371,6 +408,7 @@ class ConfigManager:
|
|||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "secrets are merged into each plugin's config; read them with config.get()")
|
||||||
def get_secret(self, key: str) -> Optional[Any]:
|
def get_secret(self, key: str) -> Optional[Any]:
|
||||||
"""Get a secret value by key."""
|
"""Get a secret value by key."""
|
||||||
try:
|
try:
|
||||||
@@ -724,6 +762,7 @@ class ConfigManager:
|
|||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=self.config_path, field=plugin_id) from e
|
raise ConfigError(error_msg, config_path=self.config_path, field=plugin_id) from e
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def cleanup_orphaned_plugin_configs(self, valid_plugin_ids: List[str]) -> List[str]:
|
def cleanup_orphaned_plugin_configs(self, valid_plugin_ids: List[str]) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Remove configuration sections for plugins that are no longer installed.
|
Remove configuration sections for plugins that are no longer installed.
|
||||||
@@ -777,6 +816,7 @@ class ConfigManager:
|
|||||||
self.logger.error(f"Error cleaning up orphaned plugin configs: {e}")
|
self.logger.error(f"Error cleaning up orphaned plugin configs: {e}")
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def validate_all_plugin_configs(self, plugin_schema_manager=None) -> Dict[str, Dict[str, Any]]:
|
def validate_all_plugin_configs(self, plugin_schema_manager=None) -> Dict[str, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Validate all plugin configurations against their schemas.
|
Validate all plugin configurations against their schemas.
|
||||||
|
|||||||
+80
-30
@@ -14,7 +14,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
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
|
from collections import defaultdict
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -52,6 +52,17 @@ class ConfigService:
|
|||||||
|
|
||||||
# Thread safety
|
# Thread safety
|
||||||
self._lock: threading.RLock = threading.RLock()
|
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
|
# Current configuration
|
||||||
self._current_config: Dict[str, Any] = {}
|
self._current_config: Dict[str, Any] = {}
|
||||||
@@ -87,23 +98,24 @@ class ConfigService:
|
|||||||
True if config changed, False otherwise
|
True if config changed, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
new_config = self.config_manager.load_config()
|
with self._notify_lock:
|
||||||
new_checksum = self._calculate_checksum(new_config)
|
new_config = self.config_manager.load_config()
|
||||||
|
new_checksum = self._calculate_checksum(new_config)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Check if config actually changed
|
# Check if config actually changed
|
||||||
if new_checksum == self._current_checksum:
|
if new_checksum == self._current_checksum:
|
||||||
self.logger.debug("Configuration unchanged, skipping reload")
|
self.logger.debug("Configuration unchanged, skipping reload")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Store old config for change detection
|
# Store old config for change detection
|
||||||
old_config = self._current_config.copy()
|
old_config = self._current_config.copy()
|
||||||
|
|
||||||
# Update current config
|
# Update current config
|
||||||
self._current_config = new_config
|
self._current_config = new_config
|
||||||
self._current_checksum = new_checksum
|
self._current_checksum = new_checksum
|
||||||
|
|
||||||
# Notify subscribers
|
# Notify subscribers, outside _lock (see _notify_lock)
|
||||||
self._notify_subscribers(old_config, new_config)
|
self._notify_subscribers(old_config, new_config)
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
@@ -127,16 +139,19 @@ class ConfigService:
|
|||||||
Args:
|
Args:
|
||||||
old_config: Previous configuration
|
old_config: Previous configuration
|
||||||
new_config: New 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: '*')
|
# Notify global subscribers (key: '*')
|
||||||
for callback in self._subscribers.get('*', []):
|
for callback in subscribers.get('*', []):
|
||||||
try:
|
self._call_subscriber('*', callback, old_config, new_config)
|
||||||
callback(old_config, new_config)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error("Error in global config change callback: %s", e, exc_info=True)
|
|
||||||
|
|
||||||
# Notify plugin-specific subscribers
|
# Notify plugin-specific subscribers
|
||||||
for plugin_id in self._subscribers.keys():
|
for plugin_id, callbacks in subscribers.items():
|
||||||
if plugin_id == '*':
|
if plugin_id == '*':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -145,16 +160,42 @@ class ConfigService:
|
|||||||
|
|
||||||
# Only notify if plugin config actually changed
|
# Only notify if plugin config actually changed
|
||||||
if old_plugin_config != new_plugin_config:
|
if old_plugin_config != new_plugin_config:
|
||||||
for callback in self._subscribers[plugin_id]:
|
for callback in callbacks:
|
||||||
try:
|
self._call_subscriber(plugin_id, callback,
|
||||||
callback(old_plugin_config, new_plugin_config)
|
old_plugin_config, new_plugin_config)
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
def _call_subscriber(
|
||||||
"Error in config change callback for %s: %s",
|
self,
|
||||||
plugin_id,
|
key: str,
|
||||||
e,
|
callback: Callable[[Dict[str, Any], Dict[str, Any]], None],
|
||||||
exc_info=True
|
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:
|
def _check_file_changes(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -276,6 +317,11 @@ class ConfigService:
|
|||||||
"""
|
"""
|
||||||
Unsubscribe from configuration changes.
|
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:
|
Args:
|
||||||
callback: Callback function to remove
|
callback: Callback function to remove
|
||||||
plugin_id: Optional plugin ID (must match subscription)
|
plugin_id: Optional plugin ID (must match subscription)
|
||||||
@@ -285,6 +331,10 @@ class ConfigService:
|
|||||||
if callback in self._subscribers[key]:
|
if callback in self._subscribers[key]:
|
||||||
self._subscribers[key].remove(callback)
|
self._subscribers[key].remove(callback)
|
||||||
self.logger.debug("Unsubscribed from config changes for %s", key)
|
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:
|
def shutdown(self) -> None:
|
||||||
"""Shutdown the configuration service."""
|
"""Shutdown the configuration service."""
|
||||||
|
|||||||
@@ -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
|
||||||
+1566
-575
File diff suppressed because it is too large
Load Diff
+194
-24
@@ -57,7 +57,8 @@ import freetype
|
|||||||
|
|
||||||
from src.common import snapshot_policy
|
from src.common import snapshot_policy
|
||||||
from src import display_watchdog
|
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:
|
if TYPE_CHECKING:
|
||||||
from src.common.render_gate import RenderGate
|
from src.common.render_gate import RenderGate
|
||||||
@@ -80,6 +81,15 @@ _CALENDAR_FONT_PX = 7
|
|||||||
#: frame, so a fault that persists would otherwise log ~100 lines a second.
|
#: frame, so a fault that persists would otherwise log ~100 lines a second.
|
||||||
_UPDATE_ERROR_LOG_INTERVAL = 60.0
|
_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:
|
def _bdf_native_size(face) -> int:
|
||||||
"""The pixel height a BDF Face declares, or 0 if it does not say.
|
"""The pixel height a BDF Face declares, or 0 if it does not say.
|
||||||
@@ -296,8 +306,8 @@ class DisplayManager:
|
|||||||
self._TEXT_WIDTH_CACHE_MAX = 1024
|
self._TEXT_WIDTH_CACHE_MAX = 1024
|
||||||
# Snapshot mirror for web preview + health check (service writes, web
|
# Snapshot mirror for web preview + health check (service writes, web
|
||||||
# reads). Cadence/skip decisions live in src/common/snapshot_policy.py:
|
# reads). Cadence/skip decisions live in src/common/snapshot_policy.py:
|
||||||
# full rate only while the web SSE broadcaster keeps the viewer marker
|
# the viewer rate only while the web SSE broadcaster keeps the viewer
|
||||||
# fresh; unchanged frames are never re-encoded, only mtime-touched.
|
# 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._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._viewer_marker_path = "/tmp/led_matrix_preview_viewer" # nosec B108 - touched by web SSE broadcaster
|
||||||
self._last_snapshot_ts = 0.0
|
self._last_snapshot_ts = 0.0
|
||||||
@@ -307,6 +317,11 @@ class DisplayManager:
|
|||||||
# is handed to the writer; this only once it has been saved, so an
|
# 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.
|
# mtime touch never vouches for a frame still waiting to be written.
|
||||||
self._saved_snapshot_digest: Optional[int] = None
|
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
|
self._snapshot_dir_prepared = False
|
||||||
# Background writer used mid-scroll; see _write_snapshot_if_due.
|
# Background writer used mid-scroll; see _write_snapshot_if_due.
|
||||||
self._snapshot_cond = threading.Condition()
|
self._snapshot_cond = threading.Condition()
|
||||||
@@ -344,6 +359,10 @@ class DisplayManager:
|
|||||||
# advances a whole pixel every Nth refresh instead of every one.
|
# advances a whole pixel every Nth refresh instead of every one.
|
||||||
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
|
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
|
||||||
self._frame_hold = 1
|
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
|
# A src.common.render_gate.RenderGate while Vegas runs with
|
||||||
# vegas_scroll.prefetch_gate on: opened around each swap so the
|
# vegas_scroll.prefetch_gate on: opened around each swap so the
|
||||||
@@ -352,7 +371,8 @@ class DisplayManager:
|
|||||||
|
|
||||||
# Timing of every presented frame, whoever drew it, for
|
# Timing of every presented frame, whoever drew it, for
|
||||||
# scripts/frame_soak.py. See src/common/frame_timing.py.
|
# 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.frame_timing.scrolling_now = self._scrolling_now
|
||||||
|
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
@@ -373,6 +393,11 @@ class DisplayManager:
|
|||||||
|
|
||||||
self._setup_matrix()
|
self._setup_matrix()
|
||||||
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
|
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
|
||||||
|
# Only a real panel's swaps wait on its refresh: the emulator and the
|
||||||
|
# fallback canvas pace themselves, so "slower than the cap" would be
|
||||||
|
# noise there.
|
||||||
|
if self.matrix is not None and os.environ.get('EMULATOR', 'false') != 'true':
|
||||||
|
self.frame_timing.plan_refresh(self.refresh_hz)
|
||||||
self._setup_scan_order_compensation()
|
self._setup_scan_order_compensation()
|
||||||
|
|
||||||
font_time = time.time()
|
font_time = time.time()
|
||||||
@@ -942,16 +967,32 @@ class DisplayManager:
|
|||||||
self._write_snapshot_if_due()
|
self._write_snapshot_if_due()
|
||||||
return
|
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
|
digest = None
|
||||||
frame_checksum = 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:
|
try:
|
||||||
brightness = getattr(self.matrix, 'brightness', None)
|
brightness = getattr(self.matrix, 'brightness', None)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
brightness = None
|
brightness = None
|
||||||
frame_checksum = zlib.adler32(self.image.tobytes())
|
frame_checksum = zlib.adler32(self.image.tobytes())
|
||||||
digest = (frame_checksum, brightness)
|
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
|
# Nothing changed since the last push — the panel is
|
||||||
# already showing exactly this frame.
|
# already showing exactly this frame.
|
||||||
#
|
#
|
||||||
@@ -978,7 +1019,7 @@ class DisplayManager:
|
|||||||
if self._double_sided is not None:
|
if self._double_sided is not None:
|
||||||
segments = [(self._composite_double_sided(), self._frame_hold)]
|
segments = [(self._composite_double_sided(), self._frame_hold)]
|
||||||
else:
|
else:
|
||||||
segments = self._scan_segments(self.image)
|
segments = self._scan_segments(self.image, scrolling)
|
||||||
gate = self.render_gate
|
gate = self.render_gate
|
||||||
blit_time = swap_time = 0.0
|
blit_time = swap_time = 0.0
|
||||||
# Usually one segment: the frame, held for _frame_hold
|
# Usually one segment: the frame, held for _frame_hold
|
||||||
@@ -1004,7 +1045,7 @@ class DisplayManager:
|
|||||||
self._last_blit_seconds = blit_time / len(segments)
|
self._last_blit_seconds = blit_time / len(segments)
|
||||||
self.frame_timing.record(
|
self.frame_timing.record(
|
||||||
blit_time, swap_time,
|
blit_time, swap_time,
|
||||||
self._frame_hold, self.is_currently_scrolling(), presented_at)
|
self._frame_hold, scrolling, presented_at)
|
||||||
|
|
||||||
self._last_pushed_digest = digest
|
self._last_pushed_digest = digest
|
||||||
|
|
||||||
@@ -1051,7 +1092,9 @@ class DisplayManager:
|
|||||||
", ".join(f"rows {top}-{bottom - 1} show {lag} refresh(es) behind"
|
", ".join(f"rows {top}-{bottom - 1} show {lag} refresh(es) behind"
|
||||||
for top, bottom, lag in bands))
|
for top, bottom, lag in bands))
|
||||||
|
|
||||||
def _scan_segments(self, image: Image.Image) -> List[Tuple[Image.Image, int]]:
|
def _scan_segments(self, image: Image.Image,
|
||||||
|
scrolling: Optional[bool] = None
|
||||||
|
) -> List[Tuple[Image.Image, int]]:
|
||||||
"""What to present for this frame: ``[(image, refreshes), ...]``.
|
"""What to present for this frame: ``[(image, refreshes), ...]``.
|
||||||
|
|
||||||
Mid-scroll with compensation on, lagging rows are taken from earlier
|
Mid-scroll with compensation on, lagging rows are taken from earlier
|
||||||
@@ -1060,11 +1103,22 @@ class DisplayManager:
|
|||||||
rows catch up, so those rows step a refresh after the rest. The split
|
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
|
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
|
it is skipped when a blit is too slow to fit. A static screen goes out
|
||||||
as it is, and drops the history.
|
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
|
hold = self._frame_hold
|
||||||
bands = getattr(self, '_scan_lag_bands', None)
|
bands = getattr(self, '_scan_lag_bands', None)
|
||||||
if not bands or not self.is_currently_scrolling():
|
if (not bands
|
||||||
|
or not (scrolling if scrolling is not None
|
||||||
|
else self.is_currently_scrolling())
|
||||||
|
or self._static_handover):
|
||||||
if bands:
|
if bands:
|
||||||
self._scan_history.clear()
|
self._scan_history.clear()
|
||||||
return [(image, hold)]
|
return [(image, hold)]
|
||||||
@@ -1364,6 +1418,8 @@ class DisplayManager:
|
|||||||
# The stall watchdog would otherwise outlive this manager.
|
# The stall watchdog would otherwise outlive this manager.
|
||||||
if getattr(self, 'frame_timing', None) is not None:
|
if getattr(self, 'frame_timing', None) is not None:
|
||||||
self.frame_timing.close()
|
self.frame_timing.close()
|
||||||
|
# Installed with the recorder; stop timing collections with it.
|
||||||
|
uninstall_gc_monitor()
|
||||||
# Reset the singleton state when cleaning up
|
# Reset the singleton state when cleaning up
|
||||||
DisplayManager._instance = None
|
DisplayManager._instance = None
|
||||||
|
|
||||||
@@ -1450,7 +1506,9 @@ class DisplayManager:
|
|||||||
fractional-pixel motion. See src/common/scroll_config.py.
|
fractional-pixel motion. See src/common/scroll_config.py.
|
||||||
|
|
||||||
Note this is the configured *cap*, not necessarily what the panel
|
Note this is the configured *cap*, not necessarily what the panel
|
||||||
achieves -- scripts/scroll_speeds.py --measure reports the real rate.
|
achieves -- scripts/scroll_speeds.py --measure reports the real rate,
|
||||||
|
and the frame-timing recorder logs a warning, with a cap the panel can
|
||||||
|
hold, once it has measured a panel that falls short of this.
|
||||||
"""
|
"""
|
||||||
hardware = (self.config.get('display') or {}).get('hardware') or {}
|
hardware = (self.config.get('display') or {}).get('hardware') or {}
|
||||||
try:
|
try:
|
||||||
@@ -1524,6 +1582,9 @@ class DisplayManager:
|
|||||||
# A plugin captured for Vegas calls this from its own display();
|
# A plugin captured for Vegas calls this from its own display();
|
||||||
# it must not change the live scroll's state or frame hold.
|
# it must not change the live scroll's state or frame hold.
|
||||||
return
|
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()
|
current_time = time.time()
|
||||||
# Scrolling callers set this every frame; log transitions only.
|
# Scrolling callers set this every frame; log transitions only.
|
||||||
changed = self._scrolling_state['is_scrolling'] != is_scrolling
|
changed = self._scrolling_state['is_scrolling'] != is_scrolling
|
||||||
@@ -1536,6 +1597,47 @@ class DisplayManager:
|
|||||||
if changed:
|
if changed:
|
||||||
logger.debug("Scrolling state set to: %s", is_scrolling)
|
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:
|
def is_currently_scrolling(self) -> bool:
|
||||||
"""Check if the display is currently in a scrolling state."""
|
"""Check if the display is currently in a scrolling state."""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
@@ -1678,11 +1780,14 @@ class DisplayManager:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
frame_checksum: adler32 of the current frame, when the caller has
|
frame_checksum: adler32 of the current frame, when the caller has
|
||||||
already computed one. Dirty tracking checksums every frame a
|
already computed one. Dirty tracking checksums every static
|
||||||
few lines above the call site, and re-deriving it here meant a
|
frame a few lines above the call site, and re-deriving it here
|
||||||
second tobytes() plus a second pass over the whole framebuffer
|
meant a second tobytes() plus a second pass over the whole
|
||||||
on every single frame — ~0.17ms per frame of the two combined
|
framebuffer on every single frame — ~0.17ms per frame of the
|
||||||
at 256x64, paid 100 times a second to reach the same number.
|
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:
|
try:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1693,20 +1798,53 @@ class DisplayManager:
|
|||||||
self._last_snapshot_ts = 0.0
|
self._last_snapshot_ts = 0.0
|
||||||
self._viewer_was_fresh = viewer_fresh
|
self._viewer_was_fresh = viewer_fresh
|
||||||
|
|
||||||
digest = (frame_checksum if frame_checksum is not None
|
if frame_checksum is not None:
|
||||||
else zlib.adler32(self.image.tobytes()))
|
digest = frame_checksum
|
||||||
action = snapshot_policy.decide(
|
frame_changed = digest != self._last_snapshot_digest
|
||||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
action = snapshot_policy.decide(
|
||||||
viewer_fresh, digest != self._last_snapshot_digest)
|
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:
|
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
|
return
|
||||||
if (action is snapshot_policy.SnapshotAction.TOUCH
|
if (action is snapshot_policy.SnapshotAction.TOUCH
|
||||||
and self._saved_snapshot_digest == digest):
|
and self._saved_snapshot_digest == digest):
|
||||||
# mtime bump only: keeps the health check (snapshot age)
|
# mtime bump only: keeps the health check (snapshot age)
|
||||||
# green without paying for a PNG encode of an unchanged frame
|
# 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)
|
os.utime(self._snapshot_path, None)
|
||||||
self._last_snapshot_touch_ts = now
|
self._last_snapshot_touch_ts = now
|
||||||
return
|
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
|
# (A TOUCH for a frame that isn't on disk yet -- still queued, or
|
||||||
# its write failed -- is written instead: touching would make the
|
# its write failed -- is written instead: touching would make the
|
||||||
# older file on disk look current.)
|
# older file on disk look current.)
|
||||||
@@ -1731,9 +1869,39 @@ class DisplayManager:
|
|||||||
self._last_snapshot_ts = now
|
self._last_snapshot_ts = now
|
||||||
self._last_snapshot_touch_ts = now
|
self._last_snapshot_touch_ts = now
|
||||||
self._last_snapshot_digest = digest
|
self._last_snapshot_digest = digest
|
||||||
|
self._snapshot_owed = False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log_snapshot_failure(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:
|
def _log_snapshot_failure(self, error: Exception) -> None:
|
||||||
# Snapshot failures must never break display — but they must not
|
# Snapshot failures must never break display — but they must not
|
||||||
# be silent either: the snapshot's mtime is the web UI's display
|
# be silent either: the snapshot's mtime is the web UI's display
|
||||||
@@ -1775,7 +1943,8 @@ class DisplayManager:
|
|||||||
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
||||||
try:
|
try:
|
||||||
with os.fdopen(_fd, "wb") as _f:
|
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.chmod(tmp_path, 0o644)
|
||||||
os.replace(tmp_path, self._snapshot_path)
|
os.replace(tmp_path, self._snapshot_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1786,7 +1955,8 @@ class DisplayManager:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
# Fallback to direct save if replace not supported
|
# 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
|
# Set proper file permissions after saving
|
||||||
try:
|
try:
|
||||||
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
|
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
|
||||||
|
|||||||
@@ -216,6 +216,23 @@ class RenderWatchdog:
|
|||||||
def armed(self) -> bool:
|
def armed(self) -> bool:
|
||||||
return self._armed
|
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:
|
def _on_render_thread(self) -> bool:
|
||||||
return self._render_thread is not None and threading.get_ident() == self._render_thread
|
return self._render_thread is not None and threading.get_ident() == self._render_thread
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import requests
|
|||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||||
|
from src.common.json_body import response_json
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -157,7 +159,7 @@ class DynamicTeamResolver:
|
|||||||
response = requests.get(rankings_url, headers=dict(DEFAULT_HTTP_HEADERS),
|
response = requests.get(rankings_url, headers=dict(DEFAULT_HTTP_HEADERS),
|
||||||
timeout=self.request_timeout)
|
timeout=self.request_timeout)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response_json(response)
|
||||||
|
|
||||||
rankings = {}
|
rankings = {}
|
||||||
rankings_data = data.get('rankings', [])
|
rankings_data = data.get('rankings', [])
|
||||||
@@ -200,6 +202,7 @@ class DynamicTeamResolver:
|
|||||||
DynamicTeamResolver._failure_timestamp = current_time
|
DynamicTeamResolver._failure_timestamp = current_time
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use resolve_teams()")
|
||||||
def get_available_dynamic_teams(self) -> List[str]:
|
def get_available_dynamic_teams(self) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get list of available dynamic team names.
|
Get list of available dynamic team names.
|
||||||
@@ -209,6 +212,7 @@ class DynamicTeamResolver:
|
|||||||
"""
|
"""
|
||||||
return list(self.DYNAMIC_PATTERNS.keys())
|
return list(self.DYNAMIC_PATTERNS.keys())
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use resolve_teams()")
|
||||||
def is_dynamic_team(self, team_name: str) -> bool:
|
def is_dynamic_team(self, team_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a team name is a dynamic team.
|
Check if a team name is a dynamic team.
|
||||||
|
|||||||
+128
-72
@@ -123,8 +123,7 @@ class ErrorAggregator:
|
|||||||
self._error_counts: Dict[str, int] = defaultdict(int)
|
self._error_counts: Dict[str, int] = defaultdict(int)
|
||||||
self._plugin_error_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
self._plugin_error_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||||
self._patterns: Dict[str, ErrorPattern] = {}
|
self._patterns: Dict[str, ErrorPattern] = {}
|
||||||
self._pattern_callbacks: List[Callable[[ErrorPattern], None]] = []
|
self._lock = threading.RLock() # RLock: build_snapshot re-enters
|
||||||
self._lock = threading.RLock() # RLock: build_snapshot and pattern callbacks re-enter
|
|
||||||
|
|
||||||
# Track session start for relative timing
|
# Track session start for relative timing
|
||||||
self._session_start = datetime.now()
|
self._session_start = datetime.now()
|
||||||
@@ -238,13 +237,6 @@ class ErrorAggregator:
|
|||||||
f"{count} times in last {self.pattern_window}. "
|
f"{count} times in last {self.pattern_window}. "
|
||||||
f"Affected plugins: {set(affected_plugins) or 'unknown'}"
|
f"Affected plugins: {set(affected_plugins) or 'unknown'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify callbacks
|
|
||||||
for callback in self._pattern_callbacks:
|
|
||||||
try:
|
|
||||||
callback(pattern)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Pattern callback failed: {e}")
|
|
||||||
else:
|
else:
|
||||||
# Update existing pattern
|
# Update existing pattern
|
||||||
self._patterns[pattern_key].count = count
|
self._patterns[pattern_key].count = count
|
||||||
@@ -253,15 +245,6 @@ class ErrorAggregator:
|
|||||||
known = self._patterns[pattern_key].affected_plugins
|
known = self._patterns[pattern_key].affected_plugins
|
||||||
known.extend(p for p in affected_plugins if p not in known)
|
known.extend(p for p in affected_plugins if p not in known)
|
||||||
|
|
||||||
def on_pattern_detected(self, callback: Callable[[ErrorPattern], None]) -> None:
|
|
||||||
"""
|
|
||||||
Register a callback to be called when a new error pattern is detected.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Function that takes an ErrorPattern as argument
|
|
||||||
"""
|
|
||||||
self._pattern_callbacks.append(callback)
|
|
||||||
|
|
||||||
def get_error_summary(self) -> Dict[str, Any]:
|
def get_error_summary(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get summary of all errors for reporting.
|
Get summary of all errors for reporting.
|
||||||
@@ -325,27 +308,6 @@ class ErrorAggregator:
|
|||||||
"last_error": recent_plugin_errors[-1].to_dict() if recent_plugin_errors else None
|
"last_error": recent_plugin_errors[-1].to_dict() if recent_plugin_errors else None
|
||||||
}
|
}
|
||||||
|
|
||||||
def clear_old_records(self, max_age_hours: int = 24) -> int:
|
|
||||||
"""
|
|
||||||
Clear records older than specified age.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
max_age_hours: Maximum age in hours
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of records cleared
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
cutoff = datetime.now() - timedelta(hours=max_age_hours)
|
|
||||||
original_count = len(self._records)
|
|
||||||
self._records = [r for r in self._records if r.timestamp > cutoff]
|
|
||||||
cleared = original_count - len(self._records)
|
|
||||||
|
|
||||||
if cleared > 0:
|
|
||||||
self.logger.info(f"Cleared {cleared} old error records")
|
|
||||||
|
|
||||||
return cleared
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def version(self) -> int:
|
def version(self) -> int:
|
||||||
"""Changes whenever the recorded errors do (see ErrorSnapshotPublisher)."""
|
"""Changes whenever the recorded errors do (see ErrorSnapshotPublisher)."""
|
||||||
@@ -354,7 +316,7 @@ class ErrorAggregator:
|
|||||||
def clear_before(self, cutoff: datetime) -> int:
|
def clear_before(self, cutoff: datetime) -> int:
|
||||||
"""Forget every error recorded at or before ``cutoff``.
|
"""Forget every error recorded at or before ``cutoff``.
|
||||||
|
|
||||||
Unlike clear_old_records, this also resets what the summary reports:
|
This also resets what the summary reports:
|
||||||
the per-type and per-plugin counts are rebuilt from the records that
|
the per-type and per-plugin counts are rebuilt from the records that
|
||||||
remain, and detected patterns that began before the cutoff are dropped
|
remain, and detected patterns that began before the cutoff are dropped
|
||||||
(one that is still happening is detected again on its next
|
(one that is still happening is detected again on its next
|
||||||
@@ -485,15 +447,18 @@ def record_error(
|
|||||||
# and only the display service's ever records anything (plugin_executor runs
|
# and only the display service's ever records anything (plugin_executor runs
|
||||||
# the plugins there). The web interface therefore reads a snapshot the display
|
# the plugins there). The web interface therefore reads a snapshot the display
|
||||||
# service publishes to the shared cache directory -- the same channel, and the
|
# 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
|
# 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, and the other way round for the clear request.
|
||||||
#
|
#
|
||||||
# ERROR_SNAPSHOT_KEY written by the display service only
|
# ERROR_SNAPSHOT_KEY written by the display service only
|
||||||
# ERROR_CLEAR_REQUEST_KEY written by the web interface only
|
# ERROR_CLEAR_REQUEST_KEY written by the web interface only, as a fallback
|
||||||
#
|
#
|
||||||
# A clear is asynchronous: the web interface records a request, and the
|
# A clear goes over the control socket (``errors.clear``): the display applies
|
||||||
# display service applies it (clear_before) on its next tick and republishes.
|
# it (clear_before) and republishes the snapshot before it answers. Only when
|
||||||
|
# the socket cannot carry it (no socket, or a display older than the command)
|
||||||
|
# does the web interface record a request in the mailbox, which the display
|
||||||
|
# applies on its next tick; its tick reads that file only when it changed.
|
||||||
# Until it has, the web interface hides whatever the snapshot shows from
|
# 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
|
# before the cutoff, so a clear takes effect for readers immediately and a
|
||||||
# snapshot published just before the request cannot bring old errors back.
|
# snapshot published just before the request cannot bring old errors back.
|
||||||
@@ -598,13 +563,43 @@ class ErrorSnapshotPublisher:
|
|||||||
self._published_version: Optional[int] = None
|
self._published_version: Optional[int] = None
|
||||||
self._last_attempt: Optional[float] = None
|
self._last_attempt: Optional[float] = None
|
||||||
self._applied_clear_id: Optional[str] = None
|
self._applied_clear_id: Optional[str] = None
|
||||||
|
# The widest cutoff applied in this process: a clear request at or
|
||||||
|
# before it has nothing left to clear (see _pending_cutoff).
|
||||||
|
self._applied_clear_cutoff: Optional[float] = None
|
||||||
|
from src.cache_manager import MailboxWatch # the display's cache, loaded already
|
||||||
|
self._mailbox = MailboxWatch(ERROR_CLEAR_REQUEST_KEY)
|
||||||
self._tick_lock = threading.Lock()
|
self._tick_lock = threading.Lock()
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
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)
|
||||||
|
if self._applied_clear_cutoff is None or cutoff > self._applied_clear_cutoff:
|
||||||
|
self._applied_clear_cutoff = cutoff
|
||||||
|
# A malformed request is acknowledged too, so it is not retried forever.
|
||||||
|
self._applied_clear_id = request_id
|
||||||
|
return cleared
|
||||||
|
|
||||||
def _apply_clear_request(self) -> bool:
|
def _apply_clear_request(self) -> bool:
|
||||||
"""Honour a clear request we have not applied yet. True if one was."""
|
"""Honour a mailbox 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)
|
|
||||||
|
The mailbox is the fallback for a web interface that could not use
|
||||||
|
the control socket (``errors.clear``, :meth:`clear_now`). It is read
|
||||||
|
only when its file changed since the last tick; otherwise a tick
|
||||||
|
costs one stat().
|
||||||
|
"""
|
||||||
|
if not self._mailbox.changed(self.cache_manager):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||||
|
except Exception:
|
||||||
|
self._mailbox.forget()
|
||||||
|
raise
|
||||||
if not isinstance(request, dict):
|
if not isinstance(request, dict):
|
||||||
return False
|
return False
|
||||||
request_id = request.get("request_id")
|
request_id = request.get("request_id")
|
||||||
@@ -614,14 +609,30 @@ class ErrorSnapshotPublisher:
|
|||||||
cutoff = float(request.get("cutoff"))
|
cutoff = float(request.get("cutoff"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
cutoff = float("nan")
|
cutoff = float("nan")
|
||||||
if math.isfinite(cutoff):
|
self._clear(request_id, 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 True
|
||||||
|
|
||||||
|
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
|
||||||
|
snapshot["applied_clear_cutoff"] = self._applied_clear_cutoff
|
||||||
|
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
||||||
|
self._published_version = version
|
||||||
|
|
||||||
def tick(self) -> bool:
|
def tick(self) -> bool:
|
||||||
"""Apply a pending clear and publish if due. True if a snapshot was written."""
|
"""Apply a pending clear and publish if due. True if a snapshot was written."""
|
||||||
with self._tick_lock:
|
with self._tick_lock:
|
||||||
@@ -635,13 +646,7 @@ class ErrorSnapshotPublisher:
|
|||||||
if (self._last_attempt is not None
|
if (self._last_attempt is not None
|
||||||
and now - self._last_attempt < self.min_interval):
|
and now - self._last_attempt < self.min_interval):
|
||||||
return False
|
return False
|
||||||
# Stamp the attempt before writing: a cache that keeps failing
|
self._publish(version, now)
|
||||||
# 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
|
|
||||||
return True
|
return True
|
||||||
except Exception as err: # never let reporting break the display
|
except Exception as err: # never let reporting break the display
|
||||||
_snapshot_logger.debug("Could not publish the plugin error snapshot: %s",
|
_snapshot_logger.debug("Could not publish the plugin error snapshot: %s",
|
||||||
@@ -693,6 +698,22 @@ def start_error_snapshot_publisher(cache_manager: Any) -> Optional[ErrorSnapshot
|
|||||||
return None
|
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) -------------------------------------------
|
# --- Reading side (web interface) -------------------------------------------
|
||||||
|
|
||||||
def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
@@ -731,7 +752,15 @@ def _pending_cutoff(snapshot: Optional[Dict[str, Any]],
|
|||||||
cutoff = float(clear_request.get("cutoff"))
|
cutoff = float(clear_request.get("cutoff"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
return cutoff if math.isfinite(cutoff) else None
|
if not math.isfinite(cutoff):
|
||||||
|
return None
|
||||||
|
# A wider clear has been applied since (over the control socket): this
|
||||||
|
# older request has nothing left to hide.
|
||||||
|
applied = snapshot.get("applied_clear_cutoff") if snapshot is not None else None
|
||||||
|
if (isinstance(applied, (int, float)) and not isinstance(applied, bool)
|
||||||
|
and applied >= cutoff):
|
||||||
|
return None
|
||||||
|
return cutoff
|
||||||
|
|
||||||
|
|
||||||
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
|
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
|
||||||
@@ -831,13 +860,31 @@ def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]:
|
|||||||
return None
|
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, or None when the socket could
|
||||||
|
#: not carry it and the mailbox should be written instead. Any exception it
|
||||||
|
#: raises reaches the caller: the display had the request and failed it.
|
||||||
|
ClearSender = Callable[[str, float], Optional[Dict[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
|
def request_error_clear(cache_manager: Any, cutoff: float,
|
||||||
|
send: Optional[ClearSender] = None) -> Dict[str, Any]:
|
||||||
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
||||||
|
|
||||||
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
|
Over the control socket when ``send`` is given and carries it: the
|
||||||
(see _count_cleared) and ``clear_requested``. Raises OSError when the
|
display applies the clear and republishes its snapshot before it
|
||||||
request did not reach the shared cache, since a cache without a usable
|
answers, so nothing is written here. Otherwise (no socket, or a display
|
||||||
directory accepts set() and keeps nothing.
|
older than ``errors.clear``) a request is written to the
|
||||||
|
``plugin_error_clear_request`` mailbox, which the display applies on
|
||||||
|
its next tick, and readers hide the cleared errors until then.
|
||||||
|
|
||||||
|
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``,
|
||||||
|
``clear_requested``, ``applied`` (the display has already cleared them)
|
||||||
|
and ``transport`` (``socket`` or ``mailbox``). ``cleared_count`` is the
|
||||||
|
display's own count over the socket, else an estimate from the snapshot
|
||||||
|
(see _count_cleared). Raises OSError when a mailbox request did not reach
|
||||||
|
the shared cache, since a cache without a usable directory accepts set()
|
||||||
|
and keeps nothing.
|
||||||
|
|
||||||
A request the display has not applied yet is only ever widened: a later,
|
A request the display has not applied yet is only ever widened: a later,
|
||||||
narrower one ("older than 24 hours" after "everything") overwriting it
|
narrower one ("older than 24 hours" after "everything") overwriting it
|
||||||
@@ -848,8 +895,21 @@ def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
|
|||||||
if pending is not None:
|
if pending is not None:
|
||||||
cutoff = max(cutoff, pending)
|
cutoff = max(cutoff, pending)
|
||||||
before = error_summary_from_report(snapshot, clear_request)
|
before = error_summary_from_report(snapshot, clear_request)
|
||||||
|
request_id = uuid.uuid4().hex
|
||||||
|
answer = {
|
||||||
|
"clear_requested": True,
|
||||||
|
"request_id": request_id,
|
||||||
|
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
||||||
|
}
|
||||||
|
if send is not None:
|
||||||
|
result = send(request_id, cutoff)
|
||||||
|
if result is not None:
|
||||||
|
count = result.get("cleared")
|
||||||
|
return dict(answer, applied=True, transport="socket",
|
||||||
|
cleared_count=count if isinstance(count, int) and not isinstance(count, bool)
|
||||||
|
else _count_cleared(before, cutoff))
|
||||||
request = {
|
request = {
|
||||||
"request_id": uuid.uuid4().hex,
|
"request_id": request_id,
|
||||||
"cutoff": cutoff,
|
"cutoff": cutoff,
|
||||||
"requested_at": time.time(),
|
"requested_at": time.time(),
|
||||||
}
|
}
|
||||||
@@ -857,9 +917,5 @@ def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
|
|||||||
stored = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
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"]:
|
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")
|
raise OSError("the clear request was not stored in the shared cache")
|
||||||
return {
|
return dict(answer, applied=False, transport="mailbox",
|
||||||
"cleared_count": _count_cleared(before, cutoff),
|
cleared_count=_count_cleared(before, cutoff))
|
||||||
"clear_requested": True,
|
|
||||||
"request_id": request["request_id"],
|
|
||||||
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from PIL import ImageFont
|
|||||||
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
|
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.font_layout import load_truetype, resolve_asset_path
|
||||||
from typing import Dict, Tuple, Optional, Union, Any
|
from typing import Dict, Tuple, Optional, Union, Any
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -222,6 +223,39 @@ class FontManager:
|
|||||||
logger.error(f"Error registering fonts for plugin {plugin_id}: {e}", exc_info=True)
|
logger.error(f"Error registering fonts for plugin {plugin_id}: {e}", exc_info=True)
|
||||||
return False
|
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:
|
def _validate_font_manifest(self, font_manifest: Dict[str, Any]) -> bool:
|
||||||
"""Validate the structure of a plugin's font manifest."""
|
"""Validate the structure of a plugin's font manifest."""
|
||||||
required_fields = ["fonts"]
|
required_fields = ["fonts"]
|
||||||
@@ -500,6 +534,7 @@ class FontManager:
|
|||||||
"""
|
"""
|
||||||
return load_bdf_face(font_path, size_px)[0]
|
return load_bdf_face(font_path, size_px)[0]
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use src.common.bdf_font.read_bdf_native_size()")
|
||||||
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
||||||
"""The one true pixel size of a BDF family in the catalog, or None
|
"""The one true pixel size of a BDF family in the catalog, or None
|
||||||
for scalable (TTF) families / unknown families."""
|
for scalable (TTF) families / unknown families."""
|
||||||
@@ -520,6 +555,7 @@ class FontManager:
|
|||||||
|
|
||||||
# ==================== Font Measurement ====================
|
# ==================== Font Measurement ====================
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use src.adaptive_layout.measure_ink()")
|
||||||
def measure_text(self, text: str, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> Tuple[int, int, int]:
|
def measure_text(self, text: str, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> Tuple[int, int, int]:
|
||||||
"""
|
"""
|
||||||
Measure text dimensions and baseline.
|
Measure text dimensions and baseline.
|
||||||
|
|||||||
+366
-14
@@ -3,26 +3,37 @@
|
|||||||
Every failure -- no socket (the display is stopped, or predates the socket),
|
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
|
a refused or timed-out connection, a reply that breaks the contract, or an
|
||||||
error the display returned -- raises :class:`ControlError` with a short
|
error the display returned -- raises :class:`ControlError` with a short
|
||||||
``reason``, and the caller falls back to the file mailbox. Nothing here
|
``reason``. Nothing here blocks for longer than ``timeout`` in total.
|
||||||
blocks for longer than ``timeout`` in total.
|
|
||||||
|
Whether the caller may then write the file mailbox instead is
|
||||||
|
:func:`should_fall_back`: only when the display never took the request (it
|
||||||
|
could not be reached, or it is too old to know the command). A display that
|
||||||
|
took the request and then failed, refused or went quiet is answered as
|
||||||
|
that, not posted a second time through the mailbox.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import socket
|
import socket
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
|
||||||
|
|
||||||
from src.ipc.contract import (
|
from src.ipc.contract import (
|
||||||
|
AWAIT_SECONDS,
|
||||||
MAX_MESSAGE_BYTES,
|
MAX_MESSAGE_BYTES,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
|
SUBSCRIBE_KEEPALIVE_SECONDS,
|
||||||
SUPPORTED_VERSIONS,
|
SUPPORTED_VERSIONS,
|
||||||
Command,
|
Command,
|
||||||
|
ErrorCode,
|
||||||
FrameReader,
|
FrameReader,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
|
StateEvent,
|
||||||
|
StateEventKind,
|
||||||
client_socket_paths,
|
client_socket_paths,
|
||||||
decode_message,
|
decode_message,
|
||||||
encode_message,
|
encode_message,
|
||||||
@@ -44,17 +55,49 @@ class ControlError(Exception):
|
|||||||
``refused``, ``timeout``, ``closed``, ``bad_response``, ``invalid_request``.
|
``refused``, ``timeout``, ``closed``, ``bad_response``, ``invalid_request``.
|
||||||
When the display answered with an error, ``reason`` is that error's
|
When the display answered with an error, ``reason`` is that error's
|
||||||
:class:`~src.ipc.contract.ErrorCode` (``busy``, ``unknown_command``, ...).
|
: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 = ''):
|
def __init__(self, reason: str, message: str = '', *, sent: bool = False):
|
||||||
super().__init__(reason, message)
|
super().__init__(reason, message)
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
self.message = message
|
self.message = message
|
||||||
|
self.sent = sent
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'{self.reason}: {self.message}' if self.message else self.reason
|
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.
|
||||||
|
#: It did nothing, so the mailbox is the way to reach it.
|
||||||
|
UPGRADE_REASONS = frozenset({ErrorCode.UNKNOWN_COMMAND, ErrorCode.UNSUPPORTED_VERSION})
|
||||||
|
|
||||||
|
|
||||||
|
def should_fall_back(error: BaseException) -> bool:
|
||||||
|
"""May the caller write the file mailbox after ``error``?
|
||||||
|
|
||||||
|
Yes when the display never took the request: there is no socket (the
|
||||||
|
display is stopped, predates the socket, or it is switched off), the
|
||||||
|
connection was refused or timed out, the display turned the connection
|
||||||
|
away before reading it, or it is too old to know the command
|
||||||
|
(:data:`UPGRADE_REASONS`). Also for an error that is not a
|
||||||
|
:class:`ControlError` (a bug in the client), as before.
|
||||||
|
|
||||||
|
No once the display had the request: a ``busy`` queue, ``invalid_args``,
|
||||||
|
an ``internal`` error, or a timeout or hang-up after the request was
|
||||||
|
sent. The display may have applied it, or would refuse it from the
|
||||||
|
mailbox too, so a second copy there only hides the failure.
|
||||||
|
"""
|
||||||
|
if not isinstance(error, ControlError):
|
||||||
|
return True
|
||||||
|
return not error.sent or error.reason in UPGRADE_REASONS
|
||||||
|
|
||||||
|
|
||||||
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
||||||
request_id: Optional[str] = None,
|
request_id: Optional[str] = None,
|
||||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
@@ -88,11 +131,14 @@ def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
|||||||
# A refusal before the request was read (forbidden, too many
|
# A refusal before the request was read (forbidden, too many
|
||||||
# connections) carries no id.
|
# connections) carries no id.
|
||||||
if response.id != request_id and not (response.id is None and not response.ok):
|
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')
|
raise ControlError('bad_response', 'the reply is for a different request', sent=True)
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
error = response.error
|
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',
|
raise ControlError(error.code if error else 'bad_response',
|
||||||
error.message if error else '')
|
error.message if error else '',
|
||||||
|
sent=response.id is not None)
|
||||||
return dict(response.result or {})
|
return dict(response.result or {})
|
||||||
|
|
||||||
|
|
||||||
@@ -137,26 +183,31 @@ def _connect(paths: Sequence[str], deadline: float) -> socket.socket:
|
|||||||
|
|
||||||
|
|
||||||
def _exchange(sock: socket.socket, payload: bytes, deadline: float) -> Response:
|
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:
|
try:
|
||||||
sock.settimeout(_remaining(deadline))
|
sock.settimeout(_remaining(deadline))
|
||||||
sock.sendall(payload)
|
sock.sendall(payload)
|
||||||
|
sent = True
|
||||||
reader = FrameReader(MAX_MESSAGE_BYTES)
|
reader = FrameReader(MAX_MESSAGE_BYTES)
|
||||||
while True:
|
while True:
|
||||||
sock.settimeout(_remaining(deadline))
|
sock.settimeout(_remaining(deadline))
|
||||||
data = sock.recv(4096)
|
data = sock.recv(4096)
|
||||||
if not data:
|
if not data:
|
||||||
raise ControlError('closed', 'the display closed the connection')
|
raise ControlError('closed', 'the display closed the connection', sent=sent)
|
||||||
lines = reader.feed(data)
|
lines = reader.feed(data)
|
||||||
if lines:
|
if lines:
|
||||||
return Response.from_dict(decode_message(lines[0]))
|
return Response.from_dict(decode_message(lines[0]))
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
raise ControlError('timeout', 'no reply in time') from None
|
raise ControlError('timeout', 'no reply in time', sent=sent) from None
|
||||||
except ProtocolError as e:
|
except ProtocolError as e:
|
||||||
raise ControlError('bad_response', e.message) from None
|
raise ControlError('bad_response', e.message, sent=sent) from None
|
||||||
except ControlError:
|
except ControlError as e:
|
||||||
|
e.sent = e.sent or sent
|
||||||
raise
|
raise
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise ControlError('closed', str(e)) from None
|
raise ControlError('closed', str(e), sent=sent) from None
|
||||||
|
|
||||||
|
|
||||||
# -- commands ---------------------------------------------------------------------------
|
# -- commands ---------------------------------------------------------------------------
|
||||||
@@ -188,9 +239,53 @@ def on_demand_status(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|||||||
return request(Command.ON_DEMAND_STATUS, {}, timeout=timeout, paths=paths)
|
return request(Command.ON_DEMAND_STATUS, {}, timeout=timeout, paths=paths)
|
||||||
|
|
||||||
|
|
||||||
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
#: Headroom over the display's own wait for an awaited command, so its
|
||||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
#: ``pending`` answer arrives before the client gives up.
|
||||||
return request(Command.PING, {}, timeout=timeout, paths=paths)
|
_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, which still reads the ``plugin_error_clear_request``
|
||||||
|
mailbox.
|
||||||
|
"""
|
||||||
|
return request(Command.ERRORS_CLEAR, {'cutoff': cutoff}, request_id=request_id,
|
||||||
|
timeout=timeout, paths=paths)
|
||||||
|
|
||||||
|
|
||||||
def hello(client: str = 'web', *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
def hello(client: str = 'web', *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
@@ -198,3 +293,260 @@ def hello(client: str = 'web', *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|||||||
"""Version negotiation: the result's ``version`` is the one both sides speak."""
|
"""Version negotiation: the result's ``version`` is the one both sides speak."""
|
||||||
return request(Command.HELLO, {'versions': list(SUPPORTED_VERSIONS), 'client': client},
|
return request(Command.HELLO, {'versions': list(SUPPORTED_VERSIONS), 'client': client},
|
||||||
timeout=timeout, paths=paths)
|
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
|
||||||
|
|||||||
+324
-17
@@ -26,7 +26,19 @@ order. Commands that change what the panel shows are *acknowledged*, not
|
|||||||
completed: ``{"accepted": true, "request_id": ...}`` means the render thread
|
completed: ``{"accepted": true, "request_id": ...}`` means the render thread
|
||||||
has the command queued and will apply it at its next on-demand check. Its
|
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``,
|
outcome is published the way it always was (``display_on_demand_state``,
|
||||||
later the state stream).
|
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.
|
See docs/IPC_CONTROL_SOCKET.md for the full description.
|
||||||
"""
|
"""
|
||||||
@@ -133,19 +145,74 @@ class Command:
|
|||||||
ON_DEMAND_START = 'on_demand.start'
|
ON_DEMAND_START = 'on_demand.start'
|
||||||
ON_DEMAND_STOP = 'on_demand.stop'
|
ON_DEMAND_STOP = 'on_demand.stop'
|
||||||
ON_DEMAND_STATUS = 'on_demand.status'
|
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.
|
#: 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, ...] = (
|
COMMANDS: Tuple[str, ...] = (
|
||||||
Command.HELLO,
|
Command.HELLO,
|
||||||
Command.PING,
|
Command.PING,
|
||||||
Command.ON_DEMAND_START,
|
Command.ON_DEMAND_START,
|
||||||
Command.ON_DEMAND_STOP,
|
Command.ON_DEMAND_STOP,
|
||||||
Command.ON_DEMAND_STATUS,
|
Command.ON_DEMAND_STATUS,
|
||||||
|
Command.BRIGHTNESS_SET,
|
||||||
|
Command.PLUGIN_RELOAD,
|
||||||
|
Command.STATE_GET,
|
||||||
|
Command.STATE_SUBSCRIBE,
|
||||||
|
Command.ERRORS_CLEAR,
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Commands that are queued for the render thread and answered with an ack.
|
#: Commands the connection thread answers itself, through a handler the
|
||||||
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP})
|
#: 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:
|
class ErrorCode:
|
||||||
@@ -159,6 +226,10 @@ class ErrorCode:
|
|||||||
BUSY = 'busy' # queue full / too many clients
|
BUSY = 'busy' # queue full / too many clients
|
||||||
FORBIDDEN = 'forbidden' # peer credentials refused
|
FORBIDDEN = 'forbidden' # peer credentials refused
|
||||||
INTERNAL = 'internal' # a bug on the display side
|
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):
|
class ProtocolError(Exception):
|
||||||
@@ -328,9 +399,6 @@ class HelloArgs:
|
|||||||
versions: Tuple[int, ...] = (PROTOCOL_VERSION,)
|
versions: Tuple[int, ...] = (PROTOCOL_VERSION,)
|
||||||
client: str = ''
|
client: str = ''
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {'versions': list(self.versions), 'client': self.client}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'HelloArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'HelloArgs':
|
||||||
versions = args.get('versions', [PROTOCOL_VERSION])
|
versions = args.get('versions', [PROTOCOL_VERSION])
|
||||||
@@ -355,10 +423,6 @@ class OnDemandStartArgs:
|
|||||||
duration: Optional[float] = None
|
duration: Optional[float] = None
|
||||||
pinned: bool = False
|
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
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStartArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStartArgs':
|
||||||
plugin_id = _optional_name(args, 'plugin_id')
|
plugin_id = _optional_name(args, 'plugin_id')
|
||||||
@@ -378,9 +442,6 @@ class OnDemandStartArgs:
|
|||||||
class OnDemandStopArgs:
|
class OnDemandStopArgs:
|
||||||
"""``on_demand.stop``: end the on-demand session and resume rotation."""
|
"""``on_demand.stop``: end the on-demand session and resume rotation."""
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStopArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStopArgs':
|
||||||
return cls()
|
return cls()
|
||||||
@@ -390,15 +451,129 @@ class OnDemandStopArgs:
|
|||||||
class NoArgs:
|
class NoArgs:
|
||||||
"""``ping`` and ``on_demand.status`` take no arguments (extra ones are ignored)."""
|
"""``ping`` and ``on_demand.status`` take no arguments (extra ones are ignored)."""
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'NoArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'NoArgs':
|
||||||
return cls()
|
return cls()
|
||||||
|
|
||||||
|
|
||||||
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs]
|
@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
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
@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`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
@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] = {
|
_ARG_TYPES: Dict[str, Any] = {
|
||||||
Command.HELLO: HelloArgs,
|
Command.HELLO: HelloArgs,
|
||||||
@@ -406,6 +581,11 @@ _ARG_TYPES: Dict[str, Any] = {
|
|||||||
Command.ON_DEMAND_START: OnDemandStartArgs,
|
Command.ON_DEMAND_START: OnDemandStartArgs,
|
||||||
Command.ON_DEMAND_STOP: OnDemandStopArgs,
|
Command.ON_DEMAND_STOP: OnDemandStopArgs,
|
||||||
Command.ON_DEMAND_STATUS: NoArgs,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -457,6 +637,133 @@ class AckResult(TypedDict):
|
|||||||
queued: int
|
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]:
|
def negotiate_version(client_versions: Tuple[int, ...]) -> Optional[int]:
|
||||||
"""The highest version both sides speak, or None."""
|
"""The highest version both sides speak, or None."""
|
||||||
common = set(client_versions) & set(SUPPORTED_VERSIONS)
|
common = set(client_versions) & set(SUPPORTED_VERSIONS)
|
||||||
|
|||||||
+536
-26
@@ -6,7 +6,22 @@ rendering: a command that changes the panel is validated, put on a bounded
|
|||||||
queue and acknowledged, and the render thread drains that queue at the point
|
queue and acknowledged, and the render thread drains that queue at the point
|
||||||
where it reads the file mailbox (``DisplayController._poll_on_demand_requests``),
|
where it reads the file mailbox (``DisplayController._poll_on_demand_requests``),
|
||||||
handing each command to the same code. Queries (``on_demand.status``) are
|
handing each command to the same code. Queries (``on_demand.status``) are
|
||||||
answered from a snapshot callable the display provides.
|
answered from a snapshot callable the display provides, and the few commands
|
||||||
|
that touch nothing the render thread owns (``errors.clear``) by a handler the
|
||||||
|
display registers, on the connection thread.
|
||||||
|
|
||||||
|
The queue also wakes the render thread: :meth:`ControlServer.wait_for_command`
|
||||||
|
is what it waits on in place of a sleep, so a command lands within a frame on
|
||||||
|
every kind of screen. An awaited command (``brightness.set``,
|
||||||
|
``plugin.reload``) carries a :class:`CommandOutcome` that the render thread
|
||||||
|
fills in; its connection thread waits for that, bounded, before answering.
|
||||||
|
|
||||||
|
The state stream (stage 3): the display publishes what it is doing into a
|
||||||
|
:class:`StateHub`, in memory, and ``state.get`` / ``state.subscribe`` read
|
||||||
|
it. A subscriber's connection gives back its request slot, takes one of
|
||||||
|
:data:`~src.ipc.contract.MAX_SUBSCRIBERS`, and is pushed the latest version
|
||||||
|
on every change plus a keepalive tick, from its own thread: publishing never
|
||||||
|
waits for a reader, and a reader that stops reading is dropped.
|
||||||
|
|
||||||
Robustness rules, because this runs inside the display process:
|
Robustness rules, because this runs inside the display process:
|
||||||
|
|
||||||
@@ -31,6 +46,7 @@ server checks them again: root, its own user, or a member of that group.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
@@ -39,18 +55,27 @@ import stat
|
|||||||
import struct
|
import struct
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
import uuid
|
||||||
from typing import Any, Callable, Dict, FrozenSet, List, Mapping, Optional, Union
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Dict, FrozenSet, Iterable, List, Mapping, Optional, Tuple
|
||||||
|
|
||||||
from src.ipc.contract import (
|
from src.ipc.contract import (
|
||||||
|
AWAIT_SECONDS,
|
||||||
|
AWAITED_COMMANDS,
|
||||||
COMMANDS,
|
COMMANDS,
|
||||||
DEFAULT_SOCKET_DIR,
|
DEFAULT_SOCKET_DIR,
|
||||||
|
DIRECT_COMMANDS,
|
||||||
DEFAULT_SOCKET_PATH,
|
DEFAULT_SOCKET_PATH,
|
||||||
MAX_MESSAGE_BYTES,
|
MAX_MESSAGE_BYTES,
|
||||||
|
MAX_SUBSCRIBERS,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
QUEUED_COMMANDS,
|
QUEUED_COMMANDS,
|
||||||
|
STATE_SCHEMA,
|
||||||
|
STATE_SECTIONS,
|
||||||
|
SUBSCRIBE_KEEPALIVE_SECONDS,
|
||||||
SUPPORTED_VERSIONS,
|
SUPPORTED_VERSIONS,
|
||||||
AckResult,
|
AckResult,
|
||||||
|
BrightnessSetArgs,
|
||||||
Command,
|
Command,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
FrameReader,
|
FrameReader,
|
||||||
@@ -58,9 +83,14 @@ from src.ipc.contract import (
|
|||||||
HelloResult,
|
HelloResult,
|
||||||
OnDemandStartArgs,
|
OnDemandStartArgs,
|
||||||
OnDemandStopArgs,
|
OnDemandStopArgs,
|
||||||
|
PluginReloadArgs,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
|
QueuedArgs,
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
|
StateEvent,
|
||||||
|
StateEventKind,
|
||||||
|
StateGetArgs,
|
||||||
configured_socket_path,
|
configured_socket_path,
|
||||||
decode_message,
|
decode_message,
|
||||||
dev_socket_path,
|
dev_socket_path,
|
||||||
@@ -80,7 +110,8 @@ MAX_CLIENTS = 8
|
|||||||
|
|
||||||
#: Commands waiting for the render thread. It drains them at least every
|
#: Commands waiting for the render thread. It drains them at least every
|
||||||
#: 0.25 s, so a full queue means the render thread is stuck, and the client
|
#: 0.25 s, so a full queue means the render thread is stuck, and the client
|
||||||
#: is told ``busy`` (and falls back to the mailbox) instead of piling up work.
|
#: is told ``busy`` instead of piling up work. The mailbox would not be read
|
||||||
|
#: either, so the web interface reports the failure rather than fall back.
|
||||||
QUEUE_SIZE = 16
|
QUEUE_SIZE = 16
|
||||||
|
|
||||||
#: Timeout for one recv()/send() on a connection.
|
#: Timeout for one recv()/send() on a connection.
|
||||||
@@ -100,19 +131,310 @@ _LISTEN_BACKLOG = 64
|
|||||||
|
|
||||||
# -- queued work ---------------------------------------------------------------------
|
# -- queued work ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
class CommandOutcome:
|
||||||
|
"""How an awaited command turned out, handed from the render thread back
|
||||||
|
to the connection thread that is waiting to answer.
|
||||||
|
|
||||||
|
The render thread calls :meth:`succeed` or :meth:`fail` once; the first
|
||||||
|
call wins. The connection thread may have stopped waiting already (it
|
||||||
|
answered ``pending``), and then nobody reads it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._done = threading.Event()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.result: Optional[Dict[str, Any]] = None
|
||||||
|
self.error_code: Optional[str] = None
|
||||||
|
self.error_message = ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def done(self) -> bool:
|
||||||
|
return self._done.is_set()
|
||||||
|
|
||||||
|
def succeed(self, result: Mapping[str, Any]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._done.is_set():
|
||||||
|
return
|
||||||
|
self.result = dict(result)
|
||||||
|
self._done.set()
|
||||||
|
|
||||||
|
def fail(self, code: str, message: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._done.is_set():
|
||||||
|
return
|
||||||
|
self.error_code = code
|
||||||
|
self.error_message = message
|
||||||
|
self._done.set()
|
||||||
|
|
||||||
|
def wait(self, timeout: float) -> bool:
|
||||||
|
return self._done.wait(timeout)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class QueuedCommand:
|
class QueuedCommand:
|
||||||
"""A command waiting for the render thread."""
|
"""A command waiting for the render thread.
|
||||||
|
|
||||||
|
``outcome`` is set for an awaited command (``AWAITED_COMMANDS``): the
|
||||||
|
render thread reports through it, and the client's answer waits for it.
|
||||||
|
"""
|
||||||
request_id: str
|
request_id: str
|
||||||
cmd: str
|
cmd: str
|
||||||
args: Union[OnDemandStartArgs, OnDemandStopArgs]
|
args: QueuedArgs
|
||||||
received_at: float # time.time() when it was accepted
|
received_at: float # time.time() when it was accepted
|
||||||
peer_uid: Optional[int] = None
|
peer_uid: Optional[int] = None
|
||||||
|
outcome: Optional[CommandOutcome] = field(default=None, compare=False, repr=False)
|
||||||
|
|
||||||
def as_on_demand_request(self) -> Dict[str, Any]:
|
def as_on_demand_request(self) -> Dict[str, Any]:
|
||||||
"""The mailbox-shaped payload the display's on-demand handler takes."""
|
"""The mailbox-shaped payload the display's on-demand handler takes."""
|
||||||
|
if not isinstance(self.args, (OnDemandStartArgs, OnDemandStopArgs)):
|
||||||
|
raise TypeError(f'{self.cmd} is not an on-demand command')
|
||||||
return on_demand_request(self.request_id, self.args, self.received_at)
|
return on_demand_request(self.request_id, self.args, self.received_at)
|
||||||
|
|
||||||
|
def succeed(self, result: Mapping[str, Any]) -> None:
|
||||||
|
"""Report success to a waiting client (a no-op for an acked command)."""
|
||||||
|
if self.outcome is not None:
|
||||||
|
self.outcome.succeed(result)
|
||||||
|
|
||||||
|
def fail(self, code: str, message: str) -> None:
|
||||||
|
"""Report failure to a waiting client (a no-op for an acked command)."""
|
||||||
|
if self.outcome is not None:
|
||||||
|
self.outcome.fail(code, message)
|
||||||
|
|
||||||
|
|
||||||
|
# -- the state stream (stage 3) ----------------------------------------------------------
|
||||||
|
|
||||||
|
#: How long a ``state.get`` keeps the display counting its readers as served
|
||||||
|
#: over the socket (:meth:`StateHub.readers_active`). A subscriber counts for
|
||||||
|
#: as long as it is connected.
|
||||||
|
READER_WINDOW_SECONDS = 60.0
|
||||||
|
|
||||||
|
#: Room kept for the envelope (``v``, ``id``, ``event``) around a snapshot,
|
||||||
|
#: within MAX_MESSAGE_BYTES.
|
||||||
|
_ENVELOPE_ROOM = 512
|
||||||
|
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
LoopProbe = Callable[[], Mapping[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(value: Optional[Mapping[str, Any]], volatile: Iterable[str]) -> Any:
|
||||||
|
"""What a section's version is judged on: the value minus its volatile keys
|
||||||
|
(timestamps that move on every publish without anything changing)."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
skip = frozenset(volatile)
|
||||||
|
return {k: v for k, v in value.items() if k not in skip} if skip else dict(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _volatile_values(sections: Mapping[str, Optional[Dict[str, Any]]],
|
||||||
|
volatile: Mapping[str, FrozenSet[str]]) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""``{section: {key: value}}``: the volatile keys each published section
|
||||||
|
has now. The sections are never mutated after publish (a publish swaps
|
||||||
|
in a new dict), so reading them outside the lock is safe."""
|
||||||
|
values: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for name, keys in volatile.items():
|
||||||
|
value = sections.get(name)
|
||||||
|
if not keys or not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
present = {k: value[k] for k in keys if k in value}
|
||||||
|
if present:
|
||||||
|
values[name] = present
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _unknown_loop() -> Dict[str, Any]:
|
||||||
|
return {'heartbeat_age_seconds': None, 'armed': False, 'stale_after': None}
|
||||||
|
|
||||||
|
|
||||||
|
def fit_snapshot(snapshot: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""``snapshot``, or a copy without the plugin runtime section when the
|
||||||
|
message would be over MAX_MESSAGE_BYTES (hundreds of plugins). The
|
||||||
|
reader then falls back to the cache for that section only; ``truncated``
|
||||||
|
says which was left out."""
|
||||||
|
state = snapshot.get('state')
|
||||||
|
if not isinstance(state, dict) or state.get('plugins') is None:
|
||||||
|
return snapshot
|
||||||
|
try:
|
||||||
|
size = len(json.dumps(snapshot, separators=(',', ':'), ensure_ascii=True,
|
||||||
|
allow_nan=False))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
size = MAX_MESSAGE_BYTES
|
||||||
|
if size <= MAX_MESSAGE_BYTES - _ENVELOPE_ROOM:
|
||||||
|
return snapshot
|
||||||
|
logger.warning("State snapshot is %d bytes; sending it without the plugin runtime "
|
||||||
|
"section", size)
|
||||||
|
trimmed = dict(snapshot)
|
||||||
|
trimmed['state'] = dict(state, plugins=None)
|
||||||
|
trimmed['truncated'] = ['plugins']
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
|
class StateHub:
|
||||||
|
"""The display's live state, in memory, for ``state.get`` and ``state.subscribe``.
|
||||||
|
|
||||||
|
Writers publish whole sections (:meth:`publish`): the render thread
|
||||||
|
publishes ``display``, ``on_demand`` and ``brightness``, and the plugin
|
||||||
|
runtime publisher's thread publishes ``plugins``. Each section has one
|
||||||
|
writer. ``loop`` is not published: it is measured when a reader asks
|
||||||
|
(``loop_probe``), so it keeps ageing while the render thread is stuck.
|
||||||
|
|
||||||
|
The version goes up when a section's value changes, ignoring the keys
|
||||||
|
the publisher names as volatile (timestamps). Those keys still carry
|
||||||
|
news -- ``display.last_updated`` is the render thread's proof of life --
|
||||||
|
so the short ``changed: false`` answer, which is what a subscriber's
|
||||||
|
tick carries, has their current values in ``volatile``; a reader merges
|
||||||
|
them into its copy. Publishing never blocks on
|
||||||
|
a reader: the lock is held only to swap a dict reference and compare it,
|
||||||
|
and every socket write happens on the reader's own thread, outside it.
|
||||||
|
A reader that is slow gets the latest version when it next asks, not
|
||||||
|
every version in between.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, loop_probe: Optional[LoopProbe] = None, *,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
wall_clock: Callable[[], float] = time.time,
|
||||||
|
epoch: Optional[str] = None, pid: Optional[int] = None,
|
||||||
|
reader_window: float = READER_WINDOW_SECONDS):
|
||||||
|
self._cond = threading.Condition(threading.Lock())
|
||||||
|
self._sections: Dict[str, Optional[Dict[str, Any]]] = {}
|
||||||
|
self._fingerprints: Dict[str, Any] = {}
|
||||||
|
self._volatile: Dict[str, FrozenSet[str]] = {}
|
||||||
|
self._version = 0
|
||||||
|
self.epoch = epoch or uuid.uuid4().hex[:16]
|
||||||
|
self.pid = os.getpid() if pid is None else pid
|
||||||
|
self._loop_probe = loop_probe
|
||||||
|
self._clock = clock
|
||||||
|
self._wall_clock = wall_clock
|
||||||
|
self._reader_window = reader_window
|
||||||
|
self._last_read: Optional[float] = None
|
||||||
|
self._subscribers = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self) -> int:
|
||||||
|
return self._version
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subscribers(self) -> int:
|
||||||
|
return self._subscribers
|
||||||
|
|
||||||
|
# -- writers -------------------------------------------------------------
|
||||||
|
|
||||||
|
def publish(self, section: str, value: Optional[Mapping[str, Any]],
|
||||||
|
volatile: Iterable[str] = ()) -> bool:
|
||||||
|
"""Store a section's latest value; True when that is a new version.
|
||||||
|
|
||||||
|
The value is copied (one level), so the caller may reuse its dict.
|
||||||
|
"""
|
||||||
|
stored = None if value is None else dict(value)
|
||||||
|
skip = frozenset(volatile)
|
||||||
|
fingerprint = _fingerprint(stored, skip)
|
||||||
|
with self._cond:
|
||||||
|
self._sections[section] = stored
|
||||||
|
self._volatile[section] = skip
|
||||||
|
if self._fingerprints.get(section, _MISSING) == fingerprint:
|
||||||
|
return False
|
||||||
|
self._fingerprints[section] = fingerprint
|
||||||
|
self._version += 1
|
||||||
|
self._cond.notify_all()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def wake(self) -> None:
|
||||||
|
"""Wake every waiting reader (the server is closing)."""
|
||||||
|
with self._cond:
|
||||||
|
self._cond.notify_all()
|
||||||
|
|
||||||
|
# -- readers -------------------------------------------------------------
|
||||||
|
|
||||||
|
def loop(self) -> Dict[str, Any]:
|
||||||
|
"""The render loop's liveness now. Never raises."""
|
||||||
|
if self._loop_probe is None:
|
||||||
|
return _unknown_loop()
|
||||||
|
try:
|
||||||
|
return dict(self._loop_probe())
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
logger.debug("Render loop liveness probe failed", exc_info=True)
|
||||||
|
return _unknown_loop()
|
||||||
|
|
||||||
|
def snapshot(self, since: Optional[int] = None,
|
||||||
|
epoch: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""The :class:`~src.ipc.contract.StateSnapshot` now.
|
||||||
|
|
||||||
|
``since`` with this hub's ``epoch``, still the current version, gives
|
||||||
|
the short ``changed: false`` form, with ``volatile``: each section's
|
||||||
|
volatile keys at their latest values (the rest of the section is
|
||||||
|
what the reader already has).
|
||||||
|
"""
|
||||||
|
with self._cond:
|
||||||
|
version = self._version
|
||||||
|
sections = dict(self._sections)
|
||||||
|
volatile = dict(self._volatile)
|
||||||
|
loop = self.loop()
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
'schema': STATE_SCHEMA,
|
||||||
|
'version': version,
|
||||||
|
'epoch': self.epoch,
|
||||||
|
'pid': self.pid,
|
||||||
|
'served_at': self._wall_clock(),
|
||||||
|
'loop': loop,
|
||||||
|
}
|
||||||
|
if since is not None and epoch == self.epoch and since == version:
|
||||||
|
result['changed'] = False
|
||||||
|
result['volatile'] = _volatile_values(sections, volatile)
|
||||||
|
return result
|
||||||
|
state: Dict[str, Any] = {name: sections.get(name) for name in STATE_SECTIONS
|
||||||
|
if name != 'loop'}
|
||||||
|
state['loop'] = loop
|
||||||
|
result['changed'] = True
|
||||||
|
result['state'] = state
|
||||||
|
return result
|
||||||
|
|
||||||
|
def wait_for_change(self, version: int, timeout: float,
|
||||||
|
stop: Optional[threading.Event] = None) -> bool:
|
||||||
|
"""Block up to ``timeout`` for a version other than ``version``."""
|
||||||
|
with self._cond:
|
||||||
|
self._cond.wait_for(
|
||||||
|
lambda: self._version != version or (stop is not None and stop.is_set()),
|
||||||
|
timeout)
|
||||||
|
return self._version != version
|
||||||
|
|
||||||
|
# -- who is reading ------------------------------------------------------
|
||||||
|
|
||||||
|
def note_read(self) -> None:
|
||||||
|
self._last_read = self._clock()
|
||||||
|
|
||||||
|
def subscriber_joined(self) -> None:
|
||||||
|
with self._cond:
|
||||||
|
self._subscribers += 1
|
||||||
|
|
||||||
|
def subscriber_left(self) -> None:
|
||||||
|
with self._cond:
|
||||||
|
self._subscribers = max(0, self._subscribers - 1)
|
||||||
|
self._last_read = self._clock()
|
||||||
|
|
||||||
|
def readers_active(self) -> bool:
|
||||||
|
"""Is the socket serving state readers? A subscriber is connected, or a
|
||||||
|
``state.get`` came within the reader window. The display uses this to
|
||||||
|
write the cache copies of the same state less often."""
|
||||||
|
if self._subscribers > 0:
|
||||||
|
return True
|
||||||
|
last = self._last_read
|
||||||
|
return last is not None and self._clock() - last < self._reader_window
|
||||||
|
|
||||||
|
|
||||||
|
class _Slot:
|
||||||
|
"""A connection slot, released once (a subscriber gives its back early)."""
|
||||||
|
|
||||||
|
def __init__(self, semaphore: threading.BoundedSemaphore):
|
||||||
|
self._semaphore = semaphore
|
||||||
|
self._held = True
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._held:
|
||||||
|
self._held = False
|
||||||
|
self._semaphore.release()
|
||||||
|
|
||||||
|
|
||||||
# -- peer credentials ------------------------------------------------------------------
|
# -- peer credentials ------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -234,6 +556,12 @@ def server_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[
|
|||||||
|
|
||||||
StatusProvider = Callable[[], Dict[str, Any]]
|
StatusProvider = Callable[[], Dict[str, Any]]
|
||||||
|
|
||||||
|
#: A handler for one of DIRECT_COMMANDS, ``(request_id, args) -> result``. It
|
||||||
|
#: runs on the connection thread, so it must not touch what the render thread
|
||||||
|
#: owns. It may raise ProtocolError to answer with that error's code; any
|
||||||
|
#: other exception is answered ``internal``.
|
||||||
|
DirectHandler = Callable[[str, Any], Mapping[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
class ControlServer:
|
class ControlServer:
|
||||||
"""Serves the control socket on background threads.
|
"""Serves the control socket on background threads.
|
||||||
@@ -247,8 +575,21 @@ class ControlServer:
|
|||||||
max_clients: int = MAX_CLIENTS, io_timeout: float = IO_TIMEOUT_SECONDS,
|
max_clients: int = MAX_CLIENTS, io_timeout: float = IO_TIMEOUT_SECONDS,
|
||||||
message_timeout: float = MESSAGE_TIMEOUT_SECONDS,
|
message_timeout: float = MESSAGE_TIMEOUT_SECONDS,
|
||||||
idle_timeout: float = IDLE_TIMEOUT_SECONDS,
|
idle_timeout: float = IDLE_TIMEOUT_SECONDS,
|
||||||
check_peer: bool = True):
|
check_peer: bool = True,
|
||||||
|
await_seconds: Optional[Mapping[str, float]] = None,
|
||||||
|
state_hub: Optional[StateHub] = None,
|
||||||
|
max_subscribers: int = MAX_SUBSCRIBERS,
|
||||||
|
keepalive: float = SUBSCRIBE_KEEPALIVE_SECONDS,
|
||||||
|
handlers: Optional[Mapping[str, DirectHandler]] = None):
|
||||||
self.path = path
|
self.path = path
|
||||||
|
self.state_hub = state_hub
|
||||||
|
self._handlers: Dict[str, DirectHandler] = {
|
||||||
|
cmd: fn for cmd, fn in (handlers or {}).items() if cmd in DIRECT_COMMANDS}
|
||||||
|
self._subscriber_slots = threading.BoundedSemaphore(max_subscribers)
|
||||||
|
self._keepalive = keepalive
|
||||||
|
self._await_seconds: Dict[str, float] = dict(AWAIT_SECONDS)
|
||||||
|
if await_seconds:
|
||||||
|
self._await_seconds.update(await_seconds)
|
||||||
self._status_provider = status_provider
|
self._status_provider = status_provider
|
||||||
self._group = group
|
self._group = group
|
||||||
self._queue: 'queue.Queue[QueuedCommand]' = queue.Queue(maxsize=queue_size)
|
self._queue: 'queue.Queue[QueuedCommand]' = queue.Queue(maxsize=queue_size)
|
||||||
@@ -307,6 +648,8 @@ class ControlServer:
|
|||||||
"""Stop accepting and remove the socket file (only if it is still ours)."""
|
"""Stop accepting and remove the socket file (only if it is still ours)."""
|
||||||
self._stopping.set()
|
self._stopping.set()
|
||||||
self._close_socket()
|
self._close_socket()
|
||||||
|
if self.state_hub is not None:
|
||||||
|
self.state_hub.wake() # subscribers see _stopping and hang up
|
||||||
thread = self._thread
|
thread = self._thread
|
||||||
if thread is not None and thread is not threading.current_thread():
|
if thread is not None and thread is not threading.current_thread():
|
||||||
thread.join(timeout=2.0)
|
thread.join(timeout=2.0)
|
||||||
@@ -418,8 +761,33 @@ class ControlServer:
|
|||||||
"""Cheap check for queued commands, for the render thread's fast path."""
|
"""Cheap check for queued commands, for the render thread's fast path."""
|
||||||
return self._pending.is_set()
|
return self._pending.is_set()
|
||||||
|
|
||||||
|
def wait_for_command(self, timeout: float) -> bool:
|
||||||
|
"""Block up to ``timeout`` seconds for a queued command; True if one is.
|
||||||
|
|
||||||
|
The render thread waits here instead of sleeping, in the dwell and
|
||||||
|
on a static screen, so a command wakes it at once. It is a timed
|
||||||
|
wait on an Event: no polling, and nothing more than the sleep it
|
||||||
|
replaces when no command comes. The flag stays set until drain(),
|
||||||
|
so a caller that does not drain would return at once every time.
|
||||||
|
"""
|
||||||
|
return self._pending.wait(timeout)
|
||||||
|
|
||||||
|
def wake(self) -> None:
|
||||||
|
"""Wake the render thread as a queued command would, with nothing queued.
|
||||||
|
|
||||||
|
For work that reaches the display another way in the same process (a
|
||||||
|
plugin's on-demand request, ``DisplayController.submit_plugin_on_demand``):
|
||||||
|
the render thread returns from :meth:`wait_for_command` and drains,
|
||||||
|
and reads the caller's own queue there. Safe from any thread.
|
||||||
|
"""
|
||||||
|
self._pending.set()
|
||||||
|
|
||||||
def drain(self) -> List[QueuedCommand]:
|
def drain(self) -> List[QueuedCommand]:
|
||||||
"""Every queued command, oldest first. Called from the render thread."""
|
"""Every queued command, oldest first. Called from the render thread.
|
||||||
|
|
||||||
|
Clears the wake flag first, so anything queued (or woken for) while
|
||||||
|
this runs wakes the next wait again.
|
||||||
|
"""
|
||||||
commands: List[QueuedCommand] = []
|
commands: List[QueuedCommand] = []
|
||||||
self._pending.clear()
|
self._pending.clear()
|
||||||
while True:
|
while True:
|
||||||
@@ -467,6 +835,7 @@ class ControlServer:
|
|||||||
|
|
||||||
def _serve(self, conn: socket.socket) -> None:
|
def _serve(self, conn: socket.socket) -> None:
|
||||||
"""One connection: authenticate, then answer requests until it ends."""
|
"""One connection: authenticate, then answer requests until it ends."""
|
||||||
|
slot = _Slot(self._slots)
|
||||||
try:
|
try:
|
||||||
conn.settimeout(self._io_timeout)
|
conn.settimeout(self._io_timeout)
|
||||||
peer = peer_credentials(conn)
|
peer = peer_credentials(conn)
|
||||||
@@ -475,7 +844,7 @@ class ControlServer:
|
|||||||
"this user or group %s", peer.pid, peer.uid, peer.gid, self._group)
|
"this user or group %s", peer.pid, peer.uid, peer.gid, self._group)
|
||||||
self._send(conn, Response.failure(None, ErrorCode.FORBIDDEN, 'not permitted'))
|
self._send(conn, Response.failure(None, ErrorCode.FORBIDDEN, 'not permitted'))
|
||||||
return
|
return
|
||||||
self._read_requests(conn, peer)
|
self._read_requests(conn, peer, slot)
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
logger.exception("Control socket connection failed")
|
logger.exception("Control socket connection failed")
|
||||||
finally:
|
finally:
|
||||||
@@ -483,7 +852,7 @@ class ControlServer:
|
|||||||
conn.close()
|
conn.close()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
self._slots.release()
|
slot.release()
|
||||||
|
|
||||||
def _peer_ok(self, peer: PeerCredentials) -> bool:
|
def _peer_ok(self, peer: PeerCredentials) -> bool:
|
||||||
groups = None
|
groups = None
|
||||||
@@ -491,7 +860,8 @@ class ControlServer:
|
|||||||
groups = process_groups(peer.pid)
|
groups = process_groups(peer.pid)
|
||||||
return peer_allowed(peer, self._own_uid, self._group, groups)
|
return peer_allowed(peer, self._own_uid, self._group, groups)
|
||||||
|
|
||||||
def _read_requests(self, conn: socket.socket, peer: Optional[PeerCredentials]) -> None:
|
def _read_requests(self, conn: socket.socket, peer: Optional[PeerCredentials],
|
||||||
|
slot: Optional[_Slot] = None) -> None:
|
||||||
reader = FrameReader(MAX_MESSAGE_BYTES)
|
reader = FrameReader(MAX_MESSAGE_BYTES)
|
||||||
idle_since = time.monotonic()
|
idle_since = time.monotonic()
|
||||||
message_started: Optional[float] = None
|
message_started: Optional[float] = None
|
||||||
@@ -516,7 +886,13 @@ class ControlServer:
|
|||||||
self._send(conn, Response.failure(None, e.code, e.message))
|
self._send(conn, Response.failure(None, e.code, e.message))
|
||||||
return # can't find the next message boundary: hang up
|
return # can't find the next message boundary: hang up
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if not self._send(conn, self.handle_line(line, peer)):
|
response, cmd = self._handle(line, peer)
|
||||||
|
if cmd == Command.STATE_SUBSCRIBE and response.ok:
|
||||||
|
# The connection becomes a one-way stream; anything the
|
||||||
|
# client sent after the subscribe is ignored.
|
||||||
|
self._subscribe(conn, response, slot)
|
||||||
|
return
|
||||||
|
if not self._send(conn, response):
|
||||||
return
|
return
|
||||||
if reader.pending:
|
if reader.pending:
|
||||||
if message_started is None or lines:
|
if message_started is None or lines:
|
||||||
@@ -542,20 +918,91 @@ class ControlServer:
|
|||||||
# -- requests --------------------------------------------------------------------
|
# -- requests --------------------------------------------------------------------
|
||||||
|
|
||||||
def handle_line(self, line: bytes, peer: Optional[PeerCredentials] = None) -> Response:
|
def handle_line(self, line: bytes, peer: Optional[PeerCredentials] = None) -> Response:
|
||||||
"""Answer one request line. Never raises."""
|
"""Answer one request line. Never raises.
|
||||||
|
|
||||||
|
A ``state.subscribe`` answered here gets its snapshot only; the
|
||||||
|
stream that follows needs a connection (``_read_requests``).
|
||||||
|
"""
|
||||||
|
return self._handle(line, peer)[0]
|
||||||
|
|
||||||
|
def _handle(self, line: bytes,
|
||||||
|
peer: Optional[PeerCredentials]) -> Tuple[Response, Optional[str]]:
|
||||||
|
"""The response to one line, and the command it answered (when known)."""
|
||||||
request_id: Optional[str] = None
|
request_id: Optional[str] = None
|
||||||
|
cmd: Optional[str] = None
|
||||||
try:
|
try:
|
||||||
obj = decode_message(line)
|
obj = decode_message(line)
|
||||||
raw_id = obj.get('id')
|
raw_id = obj.get('id')
|
||||||
request_id = raw_id if isinstance(raw_id, str) and len(raw_id) <= 128 else None
|
request_id = raw_id if isinstance(raw_id, str) and len(raw_id) <= 128 else None
|
||||||
request = Request.from_dict(obj)
|
request = Request.from_dict(obj)
|
||||||
request_id = request.id
|
request_id = request.id
|
||||||
return self._dispatch(request, peer)
|
cmd = request.cmd
|
||||||
|
return self._dispatch(request, peer), cmd
|
||||||
except ProtocolError as e:
|
except ProtocolError as e:
|
||||||
return Response.failure(e.request_id or request_id, e.code, e.message)
|
return Response.failure(e.request_id or request_id, e.code, e.message), cmd
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
logger.exception("Control socket handler failed")
|
logger.exception("Control socket handler failed")
|
||||||
return Response.failure(request_id, ErrorCode.INTERNAL, 'internal error')
|
return Response.failure(request_id, ErrorCode.INTERNAL, 'internal error'), cmd
|
||||||
|
|
||||||
|
# -- the state stream ----------------------------------------------------------
|
||||||
|
|
||||||
|
def _subscribe(self, conn: socket.socket, response: Response,
|
||||||
|
slot: Optional[_Slot]) -> None:
|
||||||
|
"""Answer a ``state.subscribe`` and push state events until it ends.
|
||||||
|
|
||||||
|
Subscribers have their own bound (MAX_SUBSCRIBERS) and give their
|
||||||
|
request slot back, so a few browsers watching never use up the slots
|
||||||
|
commands need. Everything here runs on this connection's thread: a
|
||||||
|
reader that does not keep up only stalls its own sends, and one that
|
||||||
|
stops reading for a whole IO timeout is dropped. The render thread
|
||||||
|
only ever publishes into the hub.
|
||||||
|
"""
|
||||||
|
hub = self.state_hub
|
||||||
|
if hub is None or not self._subscriber_slots.acquire(blocking=False):
|
||||||
|
self._send(conn, Response.failure(response.id, ErrorCode.BUSY,
|
||||||
|
'too many state subscribers', v=response.v))
|
||||||
|
return
|
||||||
|
if slot is not None:
|
||||||
|
slot.release()
|
||||||
|
hub.subscriber_joined()
|
||||||
|
try:
|
||||||
|
if not self._send(conn, response):
|
||||||
|
return
|
||||||
|
result = response.result or {}
|
||||||
|
version = result.get('version', -1)
|
||||||
|
sub_id = response.id or ''
|
||||||
|
logger.debug("Control socket: state subscriber joined at version %s", version)
|
||||||
|
while not self._stopping.is_set():
|
||||||
|
hub.wait_for_change(version, self._keepalive, self._stopping)
|
||||||
|
if self._stopping.is_set():
|
||||||
|
return
|
||||||
|
snap = hub.snapshot(since=version, epoch=hub.epoch)
|
||||||
|
if snap.get('changed'):
|
||||||
|
version = snap['version']
|
||||||
|
event = StateEvent(sub_id, StateEventKind.STATE, fit_snapshot(snap),
|
||||||
|
v=response.v)
|
||||||
|
else:
|
||||||
|
event = StateEvent(sub_id, StateEventKind.TICK, snap, v=response.v)
|
||||||
|
if not self._send_event(conn, event):
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
hub.subscriber_left()
|
||||||
|
self._subscriber_slots.release()
|
||||||
|
|
||||||
|
def _send_event(self, conn: socket.socket, event: StateEvent) -> bool:
|
||||||
|
try:
|
||||||
|
data = encode_message(event.to_dict())
|
||||||
|
except ProtocolError as e:
|
||||||
|
logger.error("Control socket state event not sent: %s", e.message)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
conn.sendall(data)
|
||||||
|
return True
|
||||||
|
except socket.timeout:
|
||||||
|
logger.info("Control socket: dropping a state subscriber that stopped reading")
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
def _dispatch(self, request: Request, peer: Optional[PeerCredentials]) -> Response:
|
def _dispatch(self, request: Request, peer: Optional[PeerCredentials]) -> Response:
|
||||||
if request.cmd == Command.HELLO:
|
if request.cmd == Command.HELLO:
|
||||||
@@ -596,11 +1043,28 @@ class ControlServer:
|
|||||||
v=request.v)
|
v=request.v)
|
||||||
return Response.success(request.id, self._status_provider(), v=request.v)
|
return Response.success(request.id, self._status_provider(), v=request.v)
|
||||||
|
|
||||||
if request.cmd in QUEUED_COMMANDS and isinstance(args, (OnDemandStartArgs,
|
if request.cmd in (Command.STATE_GET, Command.STATE_SUBSCRIBE):
|
||||||
OnDemandStopArgs)):
|
hub = self.state_hub
|
||||||
|
if hub is None:
|
||||||
|
return Response.failure(request.id, ErrorCode.INTERNAL, 'no state available',
|
||||||
|
v=request.v)
|
||||||
|
if isinstance(args, StateGetArgs):
|
||||||
|
hub.note_read()
|
||||||
|
snap = hub.snapshot(since=args.since, epoch=args.epoch)
|
||||||
|
else:
|
||||||
|
snap = hub.snapshot()
|
||||||
|
return Response.success(request.id, fit_snapshot(snap), v=request.v)
|
||||||
|
|
||||||
|
if request.cmd in DIRECT_COMMANDS:
|
||||||
|
return self._direct(request, args)
|
||||||
|
|
||||||
|
if request.cmd in QUEUED_COMMANDS and isinstance(args, (
|
||||||
|
OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs)):
|
||||||
|
awaited = request.cmd in AWAITED_COMMANDS
|
||||||
command = QueuedCommand(request_id=request.id, cmd=request.cmd, args=args,
|
command = QueuedCommand(request_id=request.id, cmd=request.cmd, args=args,
|
||||||
received_at=time.time(),
|
received_at=time.time(),
|
||||||
peer_uid=peer.uid if peer is not None else None)
|
peer_uid=peer.uid if peer is not None else None,
|
||||||
|
outcome=CommandOutcome() if awaited else None)
|
||||||
try:
|
try:
|
||||||
self._queue.put_nowait(command)
|
self._queue.put_nowait(command)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
@@ -610,34 +1074,80 @@ class ControlServer:
|
|||||||
'the display is not taking commands right now',
|
'the display is not taking commands right now',
|
||||||
v=request.v)
|
v=request.v)
|
||||||
self._pending.set()
|
self._pending.set()
|
||||||
|
logger.info("Control socket accepted %s %s", request.cmd, request.id)
|
||||||
|
if command.outcome is not None:
|
||||||
|
return self._await_outcome(request, command.outcome)
|
||||||
ack: AckResult = {'accepted': True, 'request_id': request.id,
|
ack: AckResult = {'accepted': True, 'request_id': request.id,
|
||||||
'queued': self._queue.qsize()}
|
'queued': self._queue.qsize()}
|
||||||
logger.info("Control socket accepted %s %s", request.cmd, request.id)
|
|
||||||
return Response.success(request.id, dict(ack), v=request.v)
|
return Response.success(request.id, dict(ack), v=request.v)
|
||||||
|
|
||||||
# A command in COMMANDS with no handler here is a bug in this module.
|
# A command in COMMANDS with no handler here is a bug in this module.
|
||||||
return Response.failure(request.id, ErrorCode.INTERNAL,
|
return Response.failure(request.id, ErrorCode.INTERNAL,
|
||||||
f'{request.cmd} is not implemented', v=request.v)
|
f'{request.cmd} is not implemented', v=request.v)
|
||||||
|
|
||||||
|
def _direct(self, request: Request, args: Any) -> Response:
|
||||||
|
"""A command the display answers on this thread (DIRECT_COMMANDS)."""
|
||||||
|
handler = self._handlers.get(request.cmd)
|
||||||
|
if handler is None:
|
||||||
|
# Answered as an older display would, so the client falls back.
|
||||||
|
return Response.failure(request.id, ErrorCode.UNKNOWN_COMMAND,
|
||||||
|
f'{request.cmd} is not served by this display',
|
||||||
|
v=request.v)
|
||||||
|
try:
|
||||||
|
result = handler(request.id, args)
|
||||||
|
except ProtocolError as e:
|
||||||
|
return Response.failure(request.id, e.code, e.message, v=request.v)
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
logger.exception("Control socket: %s %s failed", request.cmd, request.id)
|
||||||
|
return Response.failure(request.id, ErrorCode.INTERNAL,
|
||||||
|
'the display failed to apply it', v=request.v)
|
||||||
|
logger.info("Control socket applied %s %s", request.cmd, request.id)
|
||||||
|
return Response.success(request.id, dict(result), v=request.v)
|
||||||
|
|
||||||
|
def _await_outcome(self, request: Request, outcome: CommandOutcome) -> Response:
|
||||||
|
"""Answer an awaited command once the render thread has applied it.
|
||||||
|
|
||||||
|
Waits on this connection's thread, never the render thread's. If the
|
||||||
|
render thread does not get to it in time, the answer is ``pending``:
|
||||||
|
the command stays queued and is still applied, so a client treats
|
||||||
|
that as "not known to be done" rather than as a refusal.
|
||||||
|
"""
|
||||||
|
timeout = self._await_seconds.get(request.cmd, 0.0)
|
||||||
|
if not outcome.wait(timeout):
|
||||||
|
logger.warning("Control socket: %s %s not applied within %.1fs; answering pending",
|
||||||
|
request.cmd, request.id, timeout)
|
||||||
|
return Response.failure(request.id, ErrorCode.PENDING,
|
||||||
|
f'accepted, but not applied within {timeout:g}s; '
|
||||||
|
'the display will still apply it', v=request.v)
|
||||||
|
if outcome.error_code is not None:
|
||||||
|
return Response.failure(request.id, outcome.error_code, outcome.error_message,
|
||||||
|
v=request.v)
|
||||||
|
return Response.success(request.id, outcome.result or {}, v=request.v)
|
||||||
|
|
||||||
|
|
||||||
def start_control_server(status_provider: Optional[StatusProvider] = None,
|
def start_control_server(status_provider: Optional[StatusProvider] = None,
|
||||||
cache_dir: Optional[str] = None,
|
cache_dir: Optional[str] = None,
|
||||||
environ: Optional[Mapping[str, str]] = None) -> Optional[ControlServer]:
|
environ: Optional[Mapping[str, str]] = None,
|
||||||
|
state_hub: Optional[StateHub] = None,
|
||||||
|
handlers: Optional[Mapping[str, DirectHandler]] = None,
|
||||||
|
) -> Optional[ControlServer]:
|
||||||
"""Start the display's control socket, or return None when it can't run.
|
"""Start the display's control socket, or return None when it can't run.
|
||||||
|
|
||||||
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
|
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
|
||||||
bind; in every case the web interface falls back to the file mailbox.
|
bind; in every case the web interface falls back to the file mailbox
|
||||||
|
and to the cache keys the display still writes.
|
||||||
"""
|
"""
|
||||||
path = server_socket_path(environ)
|
path = server_socket_path(environ)
|
||||||
if path is None:
|
if path is None:
|
||||||
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
|
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
|
||||||
return None
|
return None
|
||||||
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir))
|
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir),
|
||||||
|
state_hub=state_hub, handlers=handlers)
|
||||||
return server if server.start() else None
|
return server if server.start() else None
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'ControlServer', 'PeerCredentials', 'QueuedCommand', 'StatusProvider',
|
'CommandOutcome', 'ControlServer', 'PeerCredentials', 'QueuedCommand', 'StateHub',
|
||||||
'peer_allowed', 'peer_credentials', 'process_groups', 'resolve_socket_group',
|
'StatusProvider', 'fit_snapshot', 'peer_allowed', 'peer_credentials', 'process_groups',
|
||||||
'server_socket_path', 'start_control_server', 'PROTOCOL_VERSION',
|
'resolve_socket_group', 'server_socket_path', 'start_control_server', 'PROTOCOL_VERSION',
|
||||||
]
|
]
|
||||||
|
|||||||
+11
-2
@@ -21,6 +21,7 @@ from PIL.PngImagePlugin import PngInfo
|
|||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
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.logo_helper import MAX_LOGO_BYTES
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions,
|
ensure_directory_permissions,
|
||||||
@@ -28,6 +29,7 @@ from src.common.permission_utils import (
|
|||||||
get_assets_dir_mode,
|
get_assets_dir_mode,
|
||||||
get_assets_file_mode
|
get_assets_file_mode
|
||||||
)
|
)
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -470,6 +472,7 @@ class LogoDownloader:
|
|||||||
logger.info(f"Using dynamic ESPN endpoint for custom soccer league: {league}")
|
logger.info(f"Using dynamic ESPN endpoint for custom soccer league: {league}")
|
||||||
return api_url
|
return api_url
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "download logos one at a time with download_missing_logo()")
|
||||||
def fetch_teams_data(self, league: str) -> Optional[Dict]:
|
def fetch_teams_data(self, league: str) -> Optional[Dict]:
|
||||||
"""Fetch team data from ESPN API for a specific league."""
|
"""Fetch team data from ESPN API for a specific league."""
|
||||||
api_url = self._resolve_api_url(league)
|
api_url = self._resolve_api_url(league)
|
||||||
@@ -481,7 +484,7 @@ class LogoDownloader:
|
|||||||
logger.info(f"Fetching team data for {league} from ESPN API...")
|
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 = self.session.get(api_url, params={'limit':1000},headers=self.headers, timeout=self.request_timeout)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data: Dict = response.json()
|
data: Dict = response_json(response)
|
||||||
|
|
||||||
logger.info(f"Successfully fetched team data for {league}")
|
logger.info(f"Successfully fetched team data for {league}")
|
||||||
return data
|
return data
|
||||||
@@ -505,7 +508,7 @@ class LogoDownloader:
|
|||||||
logger.info(f"Fetching team data for team {team_id} in {league} from ESPN API...")
|
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 = self.session.get(f"{api_url}/{team_id}", headers=self.headers, timeout=self.request_timeout)
|
||||||
response.raise_for_status()
|
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}")
|
logger.info(f"Successfully fetched team data for {team_id} in {league}")
|
||||||
return data
|
return data
|
||||||
@@ -517,6 +520,7 @@ class LogoDownloader:
|
|||||||
logger.error(f"Error parsing JSON response for {team_id} in {league}: {e}")
|
logger.error(f"Error parsing JSON response for {team_id} in {league}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "download logos one at a time with download_missing_logo()")
|
||||||
def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]:
|
def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]:
|
||||||
"""Extract team information from ESPN API response."""
|
"""Extract team information from ESPN API response."""
|
||||||
teams = []
|
teams = []
|
||||||
@@ -620,6 +624,7 @@ class LogoDownloader:
|
|||||||
# Default to FBS for unknown conferences
|
# Default to FBS for unknown conferences
|
||||||
return 'FBS'
|
return 'FBS'
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "download logos one at a time with download_missing_logo()")
|
||||||
def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]:
|
def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]:
|
||||||
"""Download missing logos for a specific league."""
|
"""Download missing logos for a specific league."""
|
||||||
logger.info(f"Starting logo download for league: {league}")
|
logger.info(f"Starting logo download for league: {league}")
|
||||||
@@ -674,6 +679,7 @@ class LogoDownloader:
|
|||||||
logger.info(f"Logo download complete for {league}: {downloaded_count} downloaded, {failed_count} failed")
|
logger.info(f"Logo download complete for {league}: {downloaded_count} downloaded, {failed_count} failed")
|
||||||
return downloaded_count, failed_count
|
return downloaded_count, failed_count
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "download logos one at a time with download_missing_logo()")
|
||||||
def download_all_ncaa_football_logos(self, include_fcs: bool = True, force_download: bool = False) -> Tuple[int, int]:
|
def download_all_ncaa_football_logos(self, include_fcs: bool = True, force_download: bool = False) -> Tuple[int, int]:
|
||||||
"""Download all NCAA football team logos including FCS teams."""
|
"""Download all NCAA football team logos including FCS teams."""
|
||||||
logger.info(f"Starting comprehensive NCAA football logo download (FCS: {include_fcs})")
|
logger.info(f"Starting comprehensive NCAA football logo download (FCS: {include_fcs})")
|
||||||
@@ -762,6 +768,7 @@ class LogoDownloader:
|
|||||||
time.sleep(0.1) # Small delay
|
time.sleep(0.1) # Small delay
|
||||||
return success
|
return success
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "download logos one at a time with download_missing_logo()")
|
||||||
def download_all_missing_logos(self, leagues: List[str] | None = None, force_download: bool = False) -> Dict[str, Tuple[int, int]]:
|
def download_all_missing_logos(self, leagues: List[str] | None = None, force_download: bool = False) -> Dict[str, Tuple[int, int]]:
|
||||||
"""Download missing logos for all specified leagues."""
|
"""Download missing logos for all specified leagues."""
|
||||||
if leagues is None:
|
if leagues is None:
|
||||||
@@ -857,6 +864,7 @@ class LogoDownloader:
|
|||||||
logger.error(f"Failed to create placeholder logo for {team_abbreviation}: {e}")
|
logger.error(f"Failed to create placeholder logo for {team_abbreviation}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def convert_image_to_rgba(self, filepath: Path) -> bool:
|
def convert_image_to_rgba(self, filepath: Path) -> bool:
|
||||||
"""Convert an image file to RGBA format to avoid PIL warnings."""
|
"""Convert an image file to RGBA format to avoid PIL warnings."""
|
||||||
try:
|
try:
|
||||||
@@ -874,6 +882,7 @@ class LogoDownloader:
|
|||||||
logger.error(f"Failed to convert {filepath.name} to RGBA: {e}")
|
logger.error(f"Failed to convert {filepath.name} to RGBA: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def convert_all_logos_to_rgba(self, league: str) -> Tuple[int, int]:
|
def convert_all_logos_to_rgba(self, league: str) -> Tuple[int, int]:
|
||||||
"""Convert all logos in a league directory to RGBA format."""
|
"""Convert all logos in a league directory to RGBA format."""
|
||||||
logo_dir = Path(self.get_logo_directory(league))
|
logo_dir = Path(self.get_logo_directory(league))
|
||||||
|
|||||||
@@ -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.
|
This module provides the core plugin infrastructure for the LEDMatrix project.
|
||||||
It enables dynamic loading, management, and discovery of display plugins.
|
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"
|
__version__ = "1.0.0"
|
||||||
|
|
||||||
from .base_plugin import BasePlugin
|
if TYPE_CHECKING:
|
||||||
from .plugin_manager import PluginManager
|
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__ = [
|
__all__ = [
|
||||||
'BasePlugin',
|
'BasePlugin',
|
||||||
'PluginManager',
|
'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__))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Stability: Stable - maintains backward compatibility
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from src.deprecation import deprecated, warn_deprecated
|
from src.deprecation import deprecated, warn_deprecated
|
||||||
@@ -240,6 +241,26 @@ def resolve_vegas_participation(plugin: Any, plugin_id: Optional[str] = None) ->
|
|||||||
return legacy_vegas_participation(plugin)
|
return legacy_vegas_participation(plugin)
|
||||||
|
|
||||||
|
|
||||||
|
def finite_seconds(value: Any) -> Optional[float]:
|
||||||
|
"""``value`` as seconds when it is a finite number or a numeric string,
|
||||||
|
else None. A bool is not a number here, though it is an int: True would
|
||||||
|
read as a one-second screen.
|
||||||
|
|
||||||
|
How the core reads a plugin's get_display_duration() -- the rotation
|
||||||
|
(DisplayController._get_display_duration) and the Vegas static pause --
|
||||||
|
which several plugins answer straight from config.json, so a value saved
|
||||||
|
as "20" or null arrives as a string or None. A number at or below zero is
|
||||||
|
returned as it is; each caller has its own rule for that.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
seconds = float(value)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
return seconds if math.isfinite(seconds) else None
|
||||||
|
|
||||||
|
|
||||||
class BasePlugin(ABC):
|
class BasePlugin(ABC):
|
||||||
"""
|
"""
|
||||||
Base class that all plugins must inherit from.
|
Base class that all plugins must inherit from.
|
||||||
@@ -1070,6 +1091,65 @@ class BasePlugin(ABC):
|
|||||||
if callable(notify):
|
if callable(notify):
|
||||||
notify(self.plugin_id)
|
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. A plugin that
|
||||||
|
also runs on cores without this method writes the
|
||||||
|
``display_on_demand_request`` mailbox on None, as before; see
|
||||||
|
"On-demand display" in docs/PLUGIN_API_REFERENCE.md.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
if not (hasattr(self, 'request_on_demand')
|
||||||
|
and self.request_on_demand(mode='my_alert', duration=15)):
|
||||||
|
self._write_on_demand_mailbox(...) # older cores
|
||||||
|
"""
|
||||||
|
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:
|
def get_vegas_participation(self) -> str:
|
||||||
"""
|
"""
|
||||||
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
|
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
|
||||||
|
|||||||
@@ -51,34 +51,27 @@ class OperationHistory:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
history_file: Optional[str] = None,
|
history_file: Optional[str] = None,
|
||||||
max_records: int = 1000,
|
max_records: int = 1000
|
||||||
lazy_load: bool = False
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize operation history.
|
Initialize operation history. The history file is read on first use,
|
||||||
|
not here, so constructing this costs the web app's startup nothing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
history_file: Path to file for persisting history
|
history_file: Path to file for persisting history
|
||||||
max_records: Maximum number of records to keep
|
max_records: Maximum number of records to keep
|
||||||
lazy_load: If True, defer loading history file until first access
|
|
||||||
"""
|
"""
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
self.history_file = Path(history_file) if history_file else None
|
self.history_file = Path(history_file) if history_file else None
|
||||||
self.max_records = max_records
|
self.max_records = max_records
|
||||||
self._lazy_load = lazy_load
|
|
||||||
self._history_loaded = False
|
self._history_loaded = False
|
||||||
|
|
||||||
# In-memory history
|
# In-memory history
|
||||||
self._history: List[OperationRecord] = []
|
self._history: List[OperationRecord] = []
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
# Load history from file if it exists (unless lazy loading)
|
|
||||||
if not self._lazy_load and self.history_file and self.history_file.exists():
|
|
||||||
self._load_history()
|
|
||||||
self._history_loaded = True
|
|
||||||
|
|
||||||
def _ensure_loaded(self) -> None:
|
def _ensure_loaded(self) -> None:
|
||||||
"""Ensure history is loaded (for lazy loading)."""
|
"""Load the history file on first use."""
|
||||||
if not self._history_loaded and self.history_file and self.history_file.exists():
|
if not self._history_loaded and self.history_file and self.history_file.exists():
|
||||||
self._load_history()
|
self._load_history()
|
||||||
self._history_loaded = True
|
self._history_loaded = True
|
||||||
@@ -88,7 +81,6 @@ class OperationHistory:
|
|||||||
operation_type: str,
|
operation_type: str,
|
||||||
plugin_id: Optional[str] = None,
|
plugin_id: Optional[str] = None,
|
||||||
status: str = "completed",
|
status: str = "completed",
|
||||||
user: Optional[str] = None,
|
|
||||||
details: Optional[Dict[str, Any]] = None,
|
details: Optional[Dict[str, Any]] = None,
|
||||||
error: Optional[str] = None,
|
error: Optional[str] = None,
|
||||||
operation_id: Optional[str] = None
|
operation_id: Optional[str] = None
|
||||||
@@ -100,7 +92,6 @@ class OperationHistory:
|
|||||||
operation_type: Type of operation (install, update, uninstall, etc.)
|
operation_type: Type of operation (install, update, uninstall, etc.)
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
status: Operation status
|
status: Operation status
|
||||||
user: User who performed operation
|
|
||||||
details: Optional operation details
|
details: Optional operation details
|
||||||
error: Optional error message
|
error: Optional error message
|
||||||
operation_id: Optional operation ID
|
operation_id: Optional operation ID
|
||||||
@@ -118,7 +109,6 @@ class OperationHistory:
|
|||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
timestamp=datetime.now(),
|
timestamp=datetime.now(),
|
||||||
status=status,
|
status=status,
|
||||||
user=user,
|
|
||||||
details=details,
|
details=details,
|
||||||
error=error
|
error=error
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Plugin operation queue manager.
|
Plugin operation queue manager.
|
||||||
|
|
||||||
Serializes plugin operations to prevent conflicts and provides
|
Serializes plugin operations to prevent conflicts and provides
|
||||||
status tracking and cancellation support.
|
status tracking.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
@@ -25,8 +25,8 @@ class PluginOperationQueue:
|
|||||||
- Serialized execution (one operation at a time)
|
- Serialized execution (one operation at a time)
|
||||||
- Prevents concurrent operations on same plugin
|
- Prevents concurrent operations on same plugin
|
||||||
- Operation status tracking
|
- Operation status tracking
|
||||||
- Operation cancellation
|
- A bounded in-memory history of finished operations, which also caps
|
||||||
- In-memory history of finished operations
|
how many finished operations get_operation_status() remembers
|
||||||
|
|
||||||
The history is not persisted. The web UI's operation history comes from
|
The history is not persisted. The web UI's operation history comes from
|
||||||
OperationHistory (operation_history.py), which has its own file; a copy
|
OperationHistory (operation_history.py), which has its own file; a copy
|
||||||
@@ -133,56 +133,6 @@ class PluginOperationQueue:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._operations.get(operation_id)
|
return self._operations.get(operation_id)
|
||||||
|
|
||||||
def cancel_operation(self, operation_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Cancel a pending operation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
operation_id: Operation identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if operation was cancelled, False if not found or already running
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
operation = self._operations.get(operation_id)
|
|
||||||
if not operation:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if operation.status == OperationStatus.RUNNING:
|
|
||||||
self.logger.warning(
|
|
||||||
f"Cannot cancel running operation {operation_id}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if operation.status == OperationStatus.PENDING:
|
|
||||||
operation.status = OperationStatus.CANCELLED
|
|
||||||
operation.completed_at = datetime.now()
|
|
||||||
operation.message = "Operation cancelled by user"
|
|
||||||
self._add_to_history(operation)
|
|
||||||
self.logger.info(f"Cancelled operation {operation_id}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_operation_history(self, limit: int = 50) -> List[PluginOperation]:
|
|
||||||
"""
|
|
||||||
Get operation history.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
limit: Maximum number of operations to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of operations, sorted by creation time (newest first)
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
# Sort by creation time (newest first)
|
|
||||||
history = sorted(
|
|
||||||
self._operation_history,
|
|
||||||
key=lambda op: op.created_at,
|
|
||||||
reverse=True
|
|
||||||
)
|
|
||||||
return history[:limit]
|
|
||||||
|
|
||||||
def _start_worker(self) -> None:
|
def _start_worker(self) -> None:
|
||||||
"""Start the worker thread that processes operations."""
|
"""Start the worker thread that processes operations."""
|
||||||
if self._worker_thread and self._worker_thread.is_alive():
|
if self._worker_thread and self._worker_thread.is_alive():
|
||||||
@@ -207,11 +157,6 @@ class PluginOperationQueue:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if operation was cancelled
|
|
||||||
if operation.status == OperationStatus.CANCELLED:
|
|
||||||
self._operation_queue.task_done()
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Execute operation
|
# Execute operation
|
||||||
self._execute_operation(operation)
|
self._execute_operation(operation)
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ import uuid
|
|||||||
class OperationType(Enum):
|
class OperationType(Enum):
|
||||||
"""Types of plugin operations."""
|
"""Types of plugin operations."""
|
||||||
INSTALL = "install"
|
INSTALL = "install"
|
||||||
UPDATE = "update"
|
|
||||||
UNINSTALL = "uninstall"
|
UNINSTALL = "uninstall"
|
||||||
ENABLE = "enable"
|
|
||||||
DISABLE = "disable"
|
|
||||||
CONFIGURE = "configure"
|
|
||||||
|
|
||||||
|
|
||||||
class OperationStatus(Enum):
|
class OperationStatus(Enum):
|
||||||
@@ -28,7 +24,6 @@ class OperationStatus(Enum):
|
|||||||
RUNNING = "running"
|
RUNNING = "running"
|
||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -48,12 +43,19 @@ class PluginOperation:
|
|||||||
completed_at: Optional[datetime] = None
|
completed_at: Optional[datetime] = None
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert operation to dictionary for serialization."""
|
"""Convert operation to dictionary for serialization.
|
||||||
|
|
||||||
|
Parameters whose name starts with ``_`` are internal and left out:
|
||||||
|
PluginOperationQueue keeps the operation's callback there as
|
||||||
|
``_callback`` until its worker runs it, and a pending operation's
|
||||||
|
status answered 500 because that function cannot be serialized.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
'operation_id': self.operation_id,
|
'operation_id': self.operation_id,
|
||||||
'operation_type': self.operation_type.value,
|
'operation_type': self.operation_type.value,
|
||||||
'plugin_id': self.plugin_id,
|
'plugin_id': self.plugin_id,
|
||||||
'parameters': self.parameters,
|
'parameters': {key: value for key, value in self.parameters.items()
|
||||||
|
if not str(key).startswith('_')},
|
||||||
'status': self.status.value,
|
'status': self.status.value,
|
||||||
'progress': self.progress,
|
'progress': self.progress,
|
||||||
'message': self.message,
|
'message': self.message,
|
||||||
@@ -63,29 +65,3 @@ class PluginOperation:
|
|||||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||||
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> 'PluginOperation':
|
|
||||||
"""Create operation from dictionary."""
|
|
||||||
op = cls(
|
|
||||||
operation_type=OperationType(data['operation_type']),
|
|
||||||
plugin_id=data['plugin_id'],
|
|
||||||
operation_id=data.get('operation_id', str(uuid.uuid4())),
|
|
||||||
parameters=data.get('parameters', {}),
|
|
||||||
status=OperationStatus(data.get('status', 'pending')),
|
|
||||||
progress=data.get('progress', 0.0),
|
|
||||||
message=data.get('message', ''),
|
|
||||||
error=data.get('error'),
|
|
||||||
result=data.get('result'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse datetime fields
|
|
||||||
if data.get('created_at'):
|
|
||||||
op.created_at = datetime.fromisoformat(data['created_at'])
|
|
||||||
if data.get('started_at'):
|
|
||||||
op.started_at = datetime.fromisoformat(data['started_at'])
|
|
||||||
if data.get('completed_at'):
|
|
||||||
op.completed_at = datetime.fromisoformat(data['completed_at'])
|
|
||||||
|
|
||||||
return op
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ Plugin catalog: what the web process knows about installed plugins.
|
|||||||
|
|
||||||
The web interface and the display run as two processes. Only the display
|
The web interface and the display run as two processes. Only the display
|
||||||
imports plugin code and runs it; the web process reads plugins as files --
|
imports plugin code and runs it; the web process reads plugins as files --
|
||||||
manifest, config schema, the plugin's section of config.json, the installed
|
manifest, config schema, the plugin's section of config.json -- and never
|
||||||
version -- and never imports a plugin module, instantiates a plugin class or
|
imports a plugin module, instantiates a plugin class or calls a plugin
|
||||||
calls a plugin lifecycle hook. This class is that read side.
|
lifecycle hook. This class is the manifest side of that; schemas come from
|
||||||
|
SchemaManager and config from ConfigManager.
|
||||||
|
|
||||||
It keeps the method names of the read-only part of :class:`PluginManager`
|
It keeps the method names of the read-only part of :class:`PluginManager`
|
||||||
(``discover_plugins``, ``plugin_manifests``, ``get_plugin_info``,
|
(``discover_plugins``, ``plugin_manifests``, ``get_plugin_info``,
|
||||||
@@ -15,7 +16,8 @@ reads through a catalog unchanged. It has nothing that runs a plugin: no
|
|||||||
``load_plugin``, ``get_plugin`` or ``plugins``.
|
``load_plugin``, ``get_plugin`` or ``plugins``.
|
||||||
|
|
||||||
Runtime state -- whether the display has a plugin loaded, its health, its
|
Runtime state -- whether the display has a plugin loaded, its health, its
|
||||||
errors -- is not here either. The display process publishes what it knows to
|
errors -- is not here either, with one exception: given a ``runtime_source``,
|
||||||
|
the mode lookups prefer the modes the running display registered. The display process publishes what it knows to
|
||||||
the shared cache (health and resource metrics, the current mode, the error
|
the shared cache (health and resource metrics, the current mode, the error
|
||||||
aggregator snapshot), and the web routes read those publications. What the
|
aggregator snapshot), and the web routes read those publications. What the
|
||||||
display does not publish (which plugins it has loaded, its plugin state
|
display does not publish (which plugins it has loaded, its plugin state
|
||||||
@@ -24,10 +26,10 @@ machine) the web cannot know, and reports as unknown.
|
|||||||
See docs/ARCHITECTURE.md ("Web and display processes").
|
See docs/ARCHITECTURE.md ("Web and display processes").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Union, cast
|
from typing import Any, Callable, Dict, List, Optional, Union
|
||||||
|
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions, get_plugin_dir_mode,
|
ensure_directory_permissions, get_plugin_dir_mode,
|
||||||
@@ -39,20 +41,28 @@ from src.plugin_system.plugin_dirs import (
|
|||||||
|
|
||||||
PathLike = Union[str, Path]
|
PathLike = Union[str, Path]
|
||||||
|
|
||||||
|
#: How long one read of the display's runtime view answers mode lookups. A
|
||||||
|
#: listing asks once per plugin; the cache copy is a file read each time.
|
||||||
|
_RUNTIME_VIEW_TTL_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
class PluginCatalog:
|
class PluginCatalog:
|
||||||
"""Manifests, schemas, config and versions of the installed plugins.
|
"""Manifests and directories of the installed plugins.
|
||||||
|
|
||||||
Discovery is explicit and cheap to repeat: :meth:`discover_plugins`
|
Discovery is explicit and cheap to repeat: :meth:`discover_plugins`
|
||||||
rescans the plugins directory and replaces the manifest map, so an
|
rescans the plugins directory and replaces the manifest map, so an
|
||||||
uninstalled plugin disappears and a new one appears.
|
uninstalled plugin disappears and a new one appears.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
|
def __init__(self, plugins_dir: PathLike,
|
||||||
schema_manager: Optional[Any] = None) -> None:
|
runtime_source: Optional[Callable[[], Any]] = None) -> None:
|
||||||
self.plugins_dir: Path = Path(plugins_dir)
|
self.plugins_dir: Path = Path(plugins_dir)
|
||||||
self.config_manager = config_manager
|
# Returns the display's PluginRuntimeView
|
||||||
self.schema_manager = schema_manager
|
# (src/plugin_system/plugin_runtime.py). Its live view carries the
|
||||||
|
# modes the display registered, which the mode lookups below prefer
|
||||||
|
# to the manifest's. None: manifests only.
|
||||||
|
self.runtime_source = runtime_source
|
||||||
|
self._runtime_view_memo: Optional[tuple] = None
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
|
|
||||||
# Guards plugin_manifests/plugin_directories: request threads read
|
# Guards plugin_manifests/plugin_directories: request threads read
|
||||||
@@ -132,30 +142,6 @@ class PluginCatalog:
|
|||||||
ids = list(self.plugin_manifests)
|
ids = list(self.plugin_manifests)
|
||||||
return [info for info in (self.get_plugin_info(pid) for pid in ids) if info]
|
return [info for info in (self.get_plugin_info(pid) for pid in ids) if info]
|
||||||
|
|
||||||
def read_manifest(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""The manifest as it is on disk now, not as discovery last saw it.
|
|
||||||
|
|
||||||
For reads that must reflect a change made since the last scan -- the
|
|
||||||
version just after an update, say. None when the plugin has no
|
|
||||||
directory or its manifest is missing, unreadable or not an object.
|
|
||||||
"""
|
|
||||||
plugin_dir = self.get_plugin_directory(plugin_id)
|
|
||||||
if plugin_dir is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with open(Path(plugin_dir) / 'manifest.json', 'r', encoding='utf-8') as f:
|
|
||||||
manifest = json.load(f)
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
self.logger.debug("Could not read manifest for %s: %s", plugin_id, exc)
|
|
||||||
return None
|
|
||||||
return manifest if isinstance(manifest, dict) else None
|
|
||||||
|
|
||||||
def get_installed_version(self, plugin_id: str) -> str:
|
|
||||||
"""The installed version from the on-disk manifest, or ''."""
|
|
||||||
manifest = self.read_manifest(plugin_id) or {}
|
|
||||||
version = manifest.get('version', '')
|
|
||||||
return version if isinstance(version, str) else str(version)
|
|
||||||
|
|
||||||
def get_plugin_directory(self, plugin_id: str) -> Optional[str]:
|
def get_plugin_directory(self, plugin_id: str) -> Optional[str]:
|
||||||
"""Where ``plugin_id`` is installed, or None.
|
"""Where ``plugin_id`` is installed, or None.
|
||||||
|
|
||||||
@@ -172,54 +158,73 @@ class PluginCatalog:
|
|||||||
by_manifest=False)
|
by_manifest=False)
|
||||||
return str(plugin_dir) if plugin_dir is not None else None
|
return str(plugin_dir) if plugin_dir is not None else None
|
||||||
|
|
||||||
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
def _runtime_view(self) -> Any:
|
||||||
"""The manifest's ``display_modes``, or [].
|
"""The display's runtime view, read at most once a second; None
|
||||||
|
without a source or when reading it fails."""
|
||||||
|
if self.runtime_source is None:
|
||||||
|
return None
|
||||||
|
now = time.monotonic()
|
||||||
|
memo = self._runtime_view_memo
|
||||||
|
if memo is not None and now - memo[0] < _RUNTIME_VIEW_TTL_SECONDS:
|
||||||
|
return memo[1]
|
||||||
|
try:
|
||||||
|
view = self.runtime_source()
|
||||||
|
except Exception as exc: # a lookup must still answer from manifests
|
||||||
|
self.logger.debug("Could not read the display's runtime view: %s", exc)
|
||||||
|
view = None
|
||||||
|
self._runtime_view_memo = (now, view)
|
||||||
|
return view
|
||||||
|
|
||||||
What the display actually rotates can differ: a plugin may compute
|
def _live_display_modes(self, plugin_id: str) -> Optional[List[str]]:
|
||||||
its modes at run time (``plugin.modes``). This is the declared list.
|
"""The modes the running display registered for ``plugin_id``, or None."""
|
||||||
|
view = self._runtime_view()
|
||||||
|
if view is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
modes = view.display_modes(plugin_id)
|
||||||
|
except Exception as exc: # includes a source returning something else
|
||||||
|
self.logger.debug("Could not read display modes for %s: %s", plugin_id, exc)
|
||||||
|
return None
|
||||||
|
return list(modes) if isinstance(modes, list) and modes else None
|
||||||
|
|
||||||
|
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
||||||
|
"""The modes the display registered for the plugin, else the
|
||||||
|
manifest's ``display_modes``, else [].
|
||||||
|
|
||||||
|
A plugin may compute its modes at run time (``plugin.modes``): each
|
||||||
|
league soccer-scoreboard's ``custom_leagues`` adds is a mode no
|
||||||
|
manifest can list ahead of time (#668). The running display
|
||||||
|
publishes what it registered, and that wins while the display is
|
||||||
|
live and has the plugin loaded. Otherwise -- display stopped, plugin
|
||||||
|
disabled -- the declared list is the best answer there is.
|
||||||
"""
|
"""
|
||||||
|
live = self._live_display_modes(plugin_id)
|
||||||
|
if live is not None:
|
||||||
|
return live
|
||||||
with self._lock:
|
with self._lock:
|
||||||
manifest = self.plugin_manifests.get(plugin_id)
|
manifest = self.plugin_manifests.get(plugin_id)
|
||||||
modes = (manifest or {}).get('display_modes', [])
|
modes = (manifest or {}).get('display_modes', [])
|
||||||
return list(modes) if isinstance(modes, list) else []
|
return list(modes) if isinstance(modes, list) else []
|
||||||
|
|
||||||
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
||||||
"""The plugin whose manifest declares ``mode`` (case-insensitive)."""
|
"""The plugin that registered ``mode`` on the running display, else
|
||||||
|
the one whose manifest declares it (case-insensitive both ways)."""
|
||||||
wanted = mode.strip().lower()
|
wanted = mode.strip().lower()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
manifests = dict(self.plugin_manifests)
|
manifests = dict(self.plugin_manifests)
|
||||||
|
for plugin_id in manifests:
|
||||||
|
live = self._live_display_modes(plugin_id)
|
||||||
|
if live and any(m.lower() == wanted for m in live):
|
||||||
|
return plugin_id
|
||||||
for plugin_id, manifest in manifests.items():
|
for plugin_id, manifest in manifests.items():
|
||||||
|
if self._live_display_modes(plugin_id):
|
||||||
|
continue # the display's list is the truth for this plugin
|
||||||
modes = manifest.get('display_modes')
|
modes = manifest.get('display_modes')
|
||||||
if isinstance(modes, list) and any(
|
if isinstance(modes, list) and any(
|
||||||
isinstance(m, str) and m.lower() == wanted for m in modes):
|
isinstance(m, str) and m.lower() == wanted for m in modes):
|
||||||
return plugin_id
|
return plugin_id
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# -- schema and config ------------------------------------------------
|
|
||||||
|
|
||||||
def get_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[str, Any]]:
|
|
||||||
"""The plugin's config schema through SchemaManager, or None."""
|
|
||||||
if self.schema_manager is None:
|
|
||||||
return None
|
|
||||||
schema = self.schema_manager.load_schema(plugin_id, use_cache=use_cache)
|
|
||||||
return cast(Optional[Dict[str, Any]], schema)
|
|
||||||
|
|
||||||
def get_config(self, plugin_id: str) -> Dict[str, Any]:
|
|
||||||
"""The plugin's section of config.json (secrets merged), or {}."""
|
|
||||||
if self.config_manager is None:
|
|
||||||
return {}
|
|
||||||
section = (self.config_manager.load_config() or {}).get(plugin_id)
|
|
||||||
return section if isinstance(section, dict) else {}
|
|
||||||
|
|
||||||
def is_enabled(self, plugin_id: str) -> bool:
|
|
||||||
"""Whether config.json enables the plugin, by the display's rule.
|
|
||||||
|
|
||||||
The display loads a plugin only when its section says
|
|
||||||
``"enabled": true``; a missing flag or section means disabled
|
|
||||||
(``DisplayController._reconcile_enabled_plugins``).
|
|
||||||
"""
|
|
||||||
return bool(self.get_config(plugin_id).get('enabled', False))
|
|
||||||
|
|
||||||
|
|
||||||
def display_restart_required(action: str, plugin_enabled: bool, *,
|
def display_restart_required(action: str, plugin_enabled: bool, *,
|
||||||
changed: bool = True,
|
changed: bool = True,
|
||||||
@@ -238,7 +243,9 @@ def display_restart_required(action: str, plugin_enabled: bool, *,
|
|||||||
config carried over) is not picked up until a restart.
|
config carried over) is not picked up until a restart.
|
||||||
- ``update``: the display keeps running the code it loaded until it
|
- ``update``: the display keeps running the code it loaded until it
|
||||||
restarts, if it runs the plugin at all -- only when it is enabled.
|
restarts, if it runs the plugin at all -- only when it is enabled.
|
||||||
``changed=False`` (already up to date) needs nothing.
|
``changed=False`` (already up to date) needs nothing. The update route
|
||||||
|
first asks the display to reload it over the control socket
|
||||||
|
(``_reload_after_store_update``); this answer stands when it cannot.
|
||||||
- ``uninstall``: removing the plugin's config section flips its enabled
|
- ``uninstall``: removing the plugin's config section flips its enabled
|
||||||
flag, and the reconcile unloads it. With ``preserve_config`` the flag
|
flag, and the reconcile unloads it. With ``preserve_config`` the flag
|
||||||
stays, and an enabled plugin keeps running until a restart.
|
stays, and an enabled plugin keeps running until a restart.
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ class PluginExecutor:
|
|||||||
self,
|
self,
|
||||||
operation: Callable[[], Any],
|
operation: Callable[[], Any],
|
||||||
timeout: Optional[float] = None,
|
timeout: Optional[float] = None,
|
||||||
plugin_id: Optional[str] = None
|
plugin_id: Optional[str] = None,
|
||||||
|
thread_name: Optional[str] = None
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Execute a plugin operation with timeout.
|
Execute a plugin operation with timeout.
|
||||||
@@ -68,6 +69,8 @@ class PluginExecutor:
|
|||||||
operation: Function to execute
|
operation: Function to execute
|
||||||
timeout: Timeout in seconds (None = use default)
|
timeout: Timeout in seconds (None = use default)
|
||||||
plugin_id: Optional plugin ID for logging
|
plugin_id: Optional plugin ID for logging
|
||||||
|
thread_name: Name for the thread the operation runs on (None
|
||||||
|
keeps Python's default). Stack dumps list threads by name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Result of operation
|
Result of operation
|
||||||
@@ -89,11 +92,14 @@ class PluginExecutor:
|
|||||||
with plugin_scope(plugin_id):
|
with plugin_scope(plugin_id):
|
||||||
result_container['value'] = operation()
|
result_container['value'] = operation()
|
||||||
result_container['completed'] = True
|
result_container['completed'] = True
|
||||||
except Exception as e:
|
except BaseException as e: # pylint: disable=broad-except
|
||||||
|
# asyncio.CancelledError and SystemExit too: uncaught, one
|
||||||
|
# ended this thread with 'completed' unset, and an operation
|
||||||
|
# that failed at once was reported as timing out.
|
||||||
result_container['exception'] = e
|
result_container['exception'] = e
|
||||||
result_container['completed'] = True
|
result_container['completed'] = True
|
||||||
|
|
||||||
thread = Thread(target=target, daemon=True)
|
thread = Thread(target=target, daemon=True, name=thread_name)
|
||||||
thread.start()
|
thread.start()
|
||||||
thread.join(timeout=timeout)
|
thread.join(timeout=timeout)
|
||||||
|
|
||||||
@@ -223,18 +229,24 @@ class PluginExecutor:
|
|||||||
'display_mode' in inspect.signature(plugin.display).parameters)
|
'display_mode' in inspect.signature(plugin.display).parameters)
|
||||||
has_display_mode = accepts_display_mode
|
has_display_mode = accepts_display_mode
|
||||||
|
|
||||||
|
# Named for the plugin: this thread presents a screen's first
|
||||||
|
# frame, so the frame-timing stall watchdog's stack dumps name it.
|
||||||
|
thread_name = f"display-{plugin_id}"
|
||||||
|
|
||||||
# Capture the return value from the plugin's display() method
|
# Capture the return value from the plugin's display() method
|
||||||
if has_display_mode and display_mode:
|
if has_display_mode and display_mode:
|
||||||
result = self.execute_with_timeout(
|
result = self.execute_with_timeout(
|
||||||
lambda: plugin.display(display_mode=display_mode, force_clear=force_clear),
|
lambda: plugin.display(display_mode=display_mode, force_clear=force_clear),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
plugin_id=plugin_id
|
plugin_id=plugin_id,
|
||||||
|
thread_name=thread_name
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = self.execute_with_timeout(
|
result = self.execute_with_timeout(
|
||||||
lambda: plugin.display(force_clear=force_clear),
|
lambda: plugin.display(force_clear=force_clear),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
plugin_id=plugin_id
|
plugin_id=plugin_id,
|
||||||
|
thread_name=thread_name
|
||||||
)
|
)
|
||||||
|
|
||||||
duration = time.monotonic() - start_time
|
duration = time.monotonic() - start_time
|
||||||
|
|||||||
@@ -199,9 +199,22 @@ def contained_plugin_dir(plugin_dir: Path, plugins_dir: Path) -> Optional[str]:
|
|||||||
name that came out of ``os.scandir()`` on the trusted root carries no
|
name that came out of ``os.scandir()`` on the trusted root carries no
|
||||||
taint, which is a real containment guarantee (and one CodeQL's
|
taint, which is a real containment guarantee (and one CodeQL's
|
||||||
path-injection query can follow), not a string sanitiser.
|
path-injection query can follow), not a string sanitiser.
|
||||||
|
|
||||||
|
The entry looked for is the one ``plugin_dir`` itself names when it sits
|
||||||
|
directly in ``plugins_dir``: for a dev plugin symlinked in under its id,
|
||||||
|
the link's name. Resolving the link first and looking for the target's
|
||||||
|
folder name refused ``plugins/foo -> ~/.ledmatrix-dev-plugins/ledmatrix-foo``
|
||||||
|
(what ``dev_plugin_setup.sh link-github foo <url>`` makes), so the plugin
|
||||||
|
never loaded. Any other path is resolved and matched by its final name,
|
||||||
|
as before.
|
||||||
"""
|
"""
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
|
||||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||||
|
plugin_dir_abs = os.path.abspath(str(plugin_dir))
|
||||||
|
if os.path.realpath(os.path.dirname(plugin_dir_abs)) == plugins_dir_real:
|
||||||
|
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_abs))
|
||||||
|
if matched_name is not None:
|
||||||
|
return os.path.join(plugins_dir_real, matched_name)
|
||||||
|
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||||
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_real))
|
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_real))
|
||||||
if matched_name is None:
|
if matched_name is None:
|
||||||
return None
|
return None
|
||||||
@@ -243,6 +256,10 @@ class PluginLoader:
|
|||||||
self.logger = logger or get_logger(__name__)
|
self.logger = logger or get_logger(__name__)
|
||||||
self._loaded_modules: Dict[str, Any] = {}
|
self._loaded_modules: Dict[str, Any] = {}
|
||||||
self._plugin_module_registry: Dict[str, set] = {} # Maps plugin_id to set of module names
|
self._plugin_module_registry: Dict[str, set] = {} # Maps plugin_id to set of module names
|
||||||
|
# plugin_id -> {dotted name: module} for the modules of the plugin's
|
||||||
|
# own packages (``providers.feed``). They keep their names while the
|
||||||
|
# plugin runs and are dropped with it; see _iter_plugin_submodules.
|
||||||
|
self._plugin_submodules: Dict[str, Dict[str, Any]] = {}
|
||||||
# Lock to serialize module loading when plugins share module names
|
# Lock to serialize module loading when plugins share module names
|
||||||
# (e.g., scroll_display.py, game_renderer.py across sport plugins).
|
# (e.g., scroll_display.py, game_renderer.py across sport plugins).
|
||||||
# During exec_module, bare-name sub-modules temporarily appear in
|
# During exec_module, bare-name sub-modules temporarily appear in
|
||||||
@@ -449,6 +466,45 @@ class PluginLoader:
|
|||||||
continue
|
continue
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _iter_plugin_submodules(
|
||||||
|
plugin_dir: Path, before_keys: set
|
||||||
|
) -> list:
|
||||||
|
"""Return dotted-name modules from plugin_dir added after before_keys.
|
||||||
|
|
||||||
|
The modules of a package the plugin ships (``providers.feed`` from
|
||||||
|
``providers/feed.py``). _iter_plugin_bare_modules skips them, so the
|
||||||
|
bare ``providers`` was namespaced and dropped on unload while
|
||||||
|
``providers.feed`` stayed in sys.modules: a reload after a store update
|
||||||
|
imported a fresh ``providers`` and then got the old ``feed`` back from
|
||||||
|
the cache, running the new manager.py against the old helpers until the
|
||||||
|
display restarted.
|
||||||
|
|
||||||
|
A module counts when its ``__file__`` -- or, for a namespace package,
|
||||||
|
which has none, every ``__path__`` entry -- is inside plugin_dir, so a
|
||||||
|
library the plugin imports (``requests.adapters``) never does.
|
||||||
|
|
||||||
|
Returns a list of (mod_name, module) tuples.
|
||||||
|
"""
|
||||||
|
resolved_dir = plugin_dir.resolve()
|
||||||
|
result = []
|
||||||
|
for key in set(sys.modules.keys()) - before_keys:
|
||||||
|
if "." not in key:
|
||||||
|
continue
|
||||||
|
mod = sys.modules.get(key)
|
||||||
|
if mod is None:
|
||||||
|
continue
|
||||||
|
mod_file = getattr(mod, "__file__", None)
|
||||||
|
locations = [mod_file] if mod_file else list(getattr(mod, "__path__", None) or [])
|
||||||
|
if not locations:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if all(Path(loc).resolve().is_relative_to(resolved_dir) for loc in locations):
|
||||||
|
result.append((key, mod))
|
||||||
|
except (ValueError, TypeError, OSError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
def _evict_stale_bare_modules(self, plugin_dir: Path) -> dict:
|
def _evict_stale_bare_modules(self, plugin_dir: Path) -> dict:
|
||||||
"""Temporarily remove bare-name sys.modules entries from other plugins.
|
"""Temporarily remove bare-name sys.modules entries from other plugins.
|
||||||
|
|
||||||
@@ -527,6 +583,13 @@ class PluginLoader:
|
|||||||
# Track for cleanup during unload
|
# Track for cleanup during unload
|
||||||
self._plugin_module_registry[plugin_id] = namespaced_names
|
self._plugin_module_registry[plugin_id] = namespaced_names
|
||||||
|
|
||||||
|
# The modules of the plugin's own packages keep their dotted names
|
||||||
|
# while it runs -- as they always have, so the package and its
|
||||||
|
# children stay a matching set in sys.modules -- and are dropped
|
||||||
|
# with the plugin by unregister_plugin_modules().
|
||||||
|
self._plugin_submodules[plugin_id] = dict(
|
||||||
|
self._iter_plugin_submodules(plugin_dir, before_keys))
|
||||||
|
|
||||||
if namespaced_names:
|
if namespaced_names:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Namespace-isolated %d module(s) for plugin %s",
|
"Namespace-isolated %d module(s) for plugin %s",
|
||||||
@@ -537,10 +600,16 @@ class PluginLoader:
|
|||||||
"""Remove namespaced sub-modules and cached module for a plugin from sys.modules.
|
"""Remove namespaced sub-modules and cached module for a plugin from sys.modules.
|
||||||
|
|
||||||
Called by PluginManager during unload to clean up all module entries
|
Called by PluginManager during unload to clean up all module entries
|
||||||
that were created when the plugin was loaded.
|
that were created when the plugin was loaded, including the dotted
|
||||||
|
modules of its packages. A dotted name is dropped only while it still
|
||||||
|
holds this plugin's module: the name is not namespaced, so another
|
||||||
|
plugin may have put its own there since.
|
||||||
"""
|
"""
|
||||||
for ns_name in self._plugin_module_registry.pop(plugin_id, set()):
|
for ns_name in self._plugin_module_registry.pop(plugin_id, set()):
|
||||||
sys.modules.pop(ns_name, None)
|
sys.modules.pop(ns_name, None)
|
||||||
|
for name, mod in self._plugin_submodules.pop(plugin_id, {}).items():
|
||||||
|
if sys.modules.get(name) is mod:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
self._loaded_modules.pop(plugin_id, None)
|
self._loaded_modules.pop(plugin_id, None)
|
||||||
|
|
||||||
def load_module(
|
def load_module(
|
||||||
@@ -646,11 +715,13 @@ class PluginLoader:
|
|||||||
if evicted_name not in sys.modules:
|
if evicted_name not in sys.modules:
|
||||||
sys.modules[evicted_name] = evicted_mod
|
sys.modules[evicted_name] = evicted_mod
|
||||||
# Clean up the partially-initialized main module and any
|
# Clean up the partially-initialized main module and any
|
||||||
# bare-name sub-modules that were added during exec_module
|
# bare-name or package sub-modules that were added during
|
||||||
# so they don't leak into subsequent plugin loads.
|
# exec_module so they don't leak into subsequent plugin loads.
|
||||||
sys.modules.pop(module_name, None)
|
sys.modules.pop(module_name, None)
|
||||||
for key, _ in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
for key, _ in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
||||||
sys.modules.pop(key, None)
|
sys.modules.pop(key, None)
|
||||||
|
for key, _ in self._iter_plugin_submodules(plugin_dir, before_keys):
|
||||||
|
sys.modules.pop(key, None)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
self._loaded_modules[plugin_id] = module
|
self._loaded_modules[plugin_id] = module
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user