mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 12:59:12 +00:00
Compare commits
11
Commits
2622c15265
..
v3.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc2dbf3824 | ||
|
|
cb0545ecb3 | ||
|
|
300cdaa250 | ||
|
|
10da2f97fd | ||
|
|
92f9d06af9 | ||
|
|
6e361e05cc | ||
|
|
a686932c7e | ||
|
|
154525beb8 | ||
|
|
9b522d412c | ||
|
|
cbc540a679 | ||
|
|
4aeb0033e0 |
@@ -17,6 +17,81 @@ release that ships it.
|
|||||||
accepts both, but the store flags the old spelling as deprecated
|
accepts both, but the store flags the old spelling as deprecated
|
||||||
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||||
|
|
||||||
|
## 3.3.0
|
||||||
|
|
||||||
|
**The release the sports scoreboards floor on to delete their bundled copies.**
|
||||||
|
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
|
||||||
|
enforceable; this ships the last three shared modules and completes the store
|
||||||
|
gate, so a scoreboard can now floor here and carry no fallback at all.
|
||||||
|
|
||||||
|
New modules a plugin may import via `src.*` and floor on 3.3.0 for:
|
||||||
|
|
||||||
|
- `src/common/sports_card.py` — settings, colour, font and date helpers for a
|
||||||
|
scoreboard's `game_renderer.py`. Free functions taking `config`/`fonts`
|
||||||
|
explicitly, so nothing about the caller's class is assumed.
|
||||||
|
- `src/common/sports_game_renderer.py` — `SportsGameRendererMixin`: scroll/Vegas
|
||||||
|
card geometry (centre gap, logo slot and cache key, layout offsets, the
|
||||||
|
upcoming-card date and time layout). No `__init__` and no state, so adoption
|
||||||
|
is one line on the class statement.
|
||||||
|
- `src/common/sports_shared.py` — `SportsCoreSharedMixin`,
|
||||||
|
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` bodies
|
||||||
|
byte-identical in all eight lineage-sharing scoreboards.
|
||||||
|
|
||||||
|
All three sit under `src/common/` rather than `src/base_classes/sports/`,
|
||||||
|
deliberately: importing that package pulls `core.py` → `DisplayManager` →
|
||||||
|
`rgbmatrix`, and these are pure logic. Plugins importing them must not acquire a
|
||||||
|
hardware dependency.
|
||||||
|
|
||||||
|
Three notes for anyone adopting `sports_shared`:
|
||||||
|
|
||||||
|
- `SportsRecentSharedMixin` defines `__init__`. Its bare `super()` binds to the
|
||||||
|
mixin, so it reaches the host only when the mixin is listed **first** in the
|
||||||
|
bases. Reversing that order silently skips the host constructor.
|
||||||
|
- Methods that resolve the plugin's `config_schema.json` use `_plugin_dir()`,
|
||||||
|
which walks the MRO rather than reading `__file__` — `__file__` is now
|
||||||
|
`src/common/`. It walks because `SportsCore` is an ABC: a subclass built with
|
||||||
|
`type(name, bases, ns)` reports `__module__` as `"abc"`.
|
||||||
|
- `_get_timezone`, `_extract_game_details` and `_fetch_data` are byte-identical
|
||||||
|
across the eight but stay in the plugins. The first binds a per-plugin
|
||||||
|
timezone module whose contents differ; the other two are the abstract stubs
|
||||||
|
that define the sport.
|
||||||
|
|
||||||
|
**The store's compatibility gate is now on every registry-managed route.** 3.2.0
|
||||||
|
gated `install_plugin`. This release gates the git-pull update path and
|
||||||
|
`install_from_url`, so a plugin whose floor the core cannot meet is refused
|
||||||
|
after download with no partial directory left behind.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ESPN 403s.** `site.api` began rejecting the User-Agent strings this repo
|
||||||
|
sent on 2026-08-04; every shared-data-source scoreboard returned
|
||||||
|
`403 Forbidden`. Requests now send an identifying token with a project URL —
|
||||||
|
browser-style strings and bare custom tokens are both refused.
|
||||||
|
- **Low-memory boards becoming unreachable under load** while still answering
|
||||||
|
pings and serving the web UI. Fetched payloads are released after delivery
|
||||||
|
rather than pinned on the completed request for up to an hour, malloc arenas
|
||||||
|
are capped, and log volume and SD writes are reduced. Available memory is now
|
||||||
|
reported in Tools diagnostics.
|
||||||
|
- **A failed logo download pinning a team to a grey box**: the placeholder was
|
||||||
|
written under the real logo's filename, so later attempts found a file and
|
||||||
|
reported success without retrying.
|
||||||
|
- **A plugin enabled but never loaded is retried** rather than staying absent
|
||||||
|
with `error = null`.
|
||||||
|
- `ttl` now controls cache expiry; abandoned cache writes no longer leave temp
|
||||||
|
files; one cache-cleanup thread per directory rather than per manager.
|
||||||
|
- Odds are fetched for the games displayed, not the whole schedule window, and a
|
||||||
|
stalled ESPN no longer stalls the whole plugin update.
|
||||||
|
- Array-item secrets are no longer wiped or logged.
|
||||||
|
- A restored backup matches the device it was taken from.
|
||||||
|
- The web UI reports the real error instead of "unknown", rejects non-finite
|
||||||
|
JSON numbers, and stops checkbox groups posting back hidden options.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `display.hardware.orientation` for panels mounted upside down.
|
||||||
|
- Vegas keeps live content in the ticker rather than being preempted by it.
|
||||||
|
- Schemas can label enum dropdown options.
|
||||||
|
|
||||||
## 3.2.0
|
## 3.2.0
|
||||||
|
|
||||||
**The first release shipping the unified sports library.** This is the version
|
**The first release shipping the unified sports library.** This is the version
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB |
@@ -1,258 +0,0 @@
|
|||||||
# Scroll Performance
|
|
||||||
|
|
||||||
How scrolling is paced on this hardware, what was wrong with it, and how to
|
|
||||||
configure a plugin so its marquee is smooth.
|
|
||||||
|
|
||||||
Measured on a Raspberry Pi 4 driving a 2×128×64 chain (256×64 logical) at
|
|
||||||
`limit_refresh_rate_hz: 100`. Numbers below come from that panel.
|
|
||||||
|
|
||||||
| | before | after |
|
|
||||||
|---|---|---|
|
|
||||||
| scroll frame rate | 44–46 fps | **100 fps, locked** |
|
|
||||||
| frames ≥ 45 ms | 14–17% | none observed |
|
|
||||||
| dominant frame time | 20 ms | **10 ms** |
|
|
||||||
| disk cache write (~1 MB) | 14.8 ms | **5.4 ms** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The one rule that matters
|
|
||||||
|
|
||||||
**Motion is smooth when the strip advances a whole number of pixels per panel
|
|
||||||
refresh.**
|
|
||||||
|
|
||||||
Advancing one pixel per refresh on a 100 Hz panel gives 100 px/s. Slower crisp
|
|
||||||
speeds come from holding each frame for several refreshes -- 50 px/s is one
|
|
||||||
pixel every second refresh -- which is covered under *Choosing a speed* below.
|
|
||||||
A speed that lands on no such combination has to do one of two bad things:
|
|
||||||
|
|
||||||
- **blend** two adjacent columns to render a half-step — on pixel-font text
|
|
||||||
this alternates crisp and smeared frames and reads as shimmer, or as the
|
|
||||||
text jumping a pixel ahead of itself;
|
|
||||||
- **repeat** a frame — the strip stands still, then jumps, which reads as
|
|
||||||
judder.
|
|
||||||
|
|
||||||
Neither is tunable away. Pick a speed that divides evenly.
|
|
||||||
|
|
||||||
`src.common.scroll_config` solves this for you: `configure()` snaps a requested
|
|
||||||
speed to the nearest one the panel can actually show in whole pixels, and
|
|
||||||
`scripts/scroll_speeds.py` prints the full ladder for your hardware.
|
|
||||||
|
|
||||||
## Choosing a speed
|
|
||||||
|
|
||||||
The crisp speeds are not a fixed list -- they depend on how fast *your* panel
|
|
||||||
refreshes, which depends on its size, `pwm_bits`, `gpio_slowdown` and the Pi
|
|
||||||
model. A Pi Zero driving a long chain has a completely different set of good
|
|
||||||
speeds from a Pi 4 driving a short one.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# what can this panel do? (reads your configured refresh rate)
|
|
||||||
python3 scripts/scroll_speeds.py
|
|
||||||
|
|
||||||
# what does it ACTUALLY manage, rather than what is configured?
|
|
||||||
sudo systemctl stop ledmatrix
|
|
||||||
sudo python3 scripts/scroll_speeds.py --measure
|
|
||||||
sudo systemctl start ledmatrix
|
|
||||||
|
|
||||||
# highlight the closest option to the speed you want
|
|
||||||
python3 scripts/scroll_speeds.py --want 45
|
|
||||||
|
|
||||||
# try one on the panel
|
|
||||||
sudo systemctl stop ledmatrix
|
|
||||||
sudo python3 scripts/scroll_speeds.py --demo 50
|
|
||||||
sudo systemctl start ledmatrix
|
|
||||||
```
|
|
||||||
|
|
||||||
Sample ladder for a 100 Hz panel:
|
|
||||||
|
|
||||||
```
|
|
||||||
20.0 px/s (1px every 5 refreshes = 20.0 fps, slightly stepped)
|
|
||||||
25.0 px/s (1px every 4 refreshes = 25.0 fps, slightly stepped)
|
|
||||||
33.3 px/s (1px every 3 refreshes = 33.3 fps, smooth)
|
|
||||||
50.0 px/s (1px every 2 refreshes = 50.0 fps, smooth)
|
|
||||||
66.7 px/s (2px every 3 refreshes = 33.3 fps, smooth)
|
|
||||||
100.0 px/s (1px every 1 refresh = 100.0 fps, smooth)
|
|
||||||
```
|
|
||||||
|
|
||||||
### How a slow speed stays crisp
|
|
||||||
|
|
||||||
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
|
|
||||||
refreshes. **The panel keeps refreshing at its full rate either way**, so
|
|
||||||
holding a frame costs nothing in flicker -- it only changes how often a *new*
|
|
||||||
image is presented. That is what allows 50 px/s to be one whole pixel every
|
|
||||||
second refresh, instead of half a pixel every refresh (which has no good
|
|
||||||
rendering, only a choice between blur and judder).
|
|
||||||
|
|
||||||
`scroll_config.configure()` snaps the requested speed to the nearest entry on
|
|
||||||
the ladder and applies the hold, provided it is given the display manager:
|
|
||||||
|
|
||||||
```python
|
|
||||||
scroll_config.configure(
|
|
||||||
self.scroll_helper,
|
|
||||||
plugin_config=self.config,
|
|
||||||
global_config=self.global_config,
|
|
||||||
display_manager=self.display_manager, # required for the hold to apply
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Without `display_manager` a sub-refresh speed still resolves, but the hold is
|
|
||||||
never applied and the motion falls back to fractional pixels -- so `configure`
|
|
||||||
logs a warning rather than failing quietly. Pass `snap_to_crisp=False` to keep
|
|
||||||
an exact requested speed and accept the artefacts.
|
|
||||||
|
|
||||||
Speeds slower than about 20 px/s are stepped no matter what, because a 1-pixel
|
|
||||||
advance at 20 fps is simply a coarse increment. That is the pixel pitch, not a
|
|
||||||
software limit; the only way to move in smaller increments is sub-pixel
|
|
||||||
blending, which this display does not tolerate (see above).
|
|
||||||
|
|
||||||
## Configuring a plugin
|
|
||||||
|
|
||||||
Use the shared resolver rather than reading config keys yourself:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from src.common import scroll_config
|
|
||||||
|
|
||||||
settings = scroll_config.configure(
|
|
||||||
self.scroll_helper,
|
|
||||||
plugin_config=self.config,
|
|
||||||
global_config=self.global_config,
|
|
||||||
refresh_hz=scroll_config.refresh_hz_from_config(self.global_config),
|
|
||||||
plugin_logger=self.logger,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
It resolves every config shape in one place, applies the speed, and returns
|
|
||||||
what it did. Precedence, highest first:
|
|
||||||
|
|
||||||
1. `display_options.scroll_speed` + `scroll_delay` — **the recommended form**
|
|
||||||
2. `display.scroll_speed` + `scroll_delay` — deprecated shape
|
|
||||||
3. `scroll_speed` + `scroll_delay` at the root — legacy flat
|
|
||||||
4. `scroll_pixels_per_second` — deprecated
|
|
||||||
5. the global `display` block
|
|
||||||
6. the built-in default (100 px/s)
|
|
||||||
|
|
||||||
`scroll_speed` is pixels per frame and `scroll_delay` is the frame period in
|
|
||||||
seconds, so the pair means `scroll_speed / scroll_delay` px/s. The recommended
|
|
||||||
config for a 100 Hz panel:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"display_options": { "scroll_speed": 1.0, "scroll_delay": 0.01 }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why the deprecated key ranks below the explicit pair
|
|
||||||
|
|
||||||
Because some plugins give `scroll_pixels_per_second` a **schema default**, and
|
|
||||||
schema defaults are merged into plugin config. Ranking it above the pair means
|
|
||||||
it is always present and always wins, so the documented settings become
|
|
||||||
unreachable. That is a real, shipped bug — see
|
|
||||||
[ledmatrix-plugins#408](https://github.com/ChuckBuilds/ledmatrix-plugins/issues/408).
|
|
||||||
|
|
||||||
If you are writing a plugin: do not give a deprecated key a schema default.
|
|
||||||
|
|
||||||
## What was actually wrong
|
|
||||||
|
|
||||||
Four independent faults, each found by measurement.
|
|
||||||
|
|
||||||
### 1. The frame loop slept on top of a wait it had already done
|
|
||||||
|
|
||||||
`display_controller.py` ran the high-FPS loop as `render → SwapOnVSync (blocks
|
|
||||||
to the panel's refresh) → time.sleep(0.008) → plugin ticks`. The sleep was
|
|
||||||
unconditional and added to a wait that had already happened. Render work
|
|
||||||
measured ~4 ms, so each iteration cost ~12 ms against a 10 ms refresh grid —
|
|
||||||
every swap missed a refresh and landed on the next one. The loop settled at
|
|
||||||
exactly 50 fps while asking for 125, with no headroom, so ~14% of frames
|
|
||||||
slipped a further refresh.
|
|
||||||
|
|
||||||
Now the loop sleeps only the remainder of the frame budget, with a 1 ms floor
|
|
||||||
so plugin threads still get the GIL.
|
|
||||||
|
|
||||||
### 2. `SwapOnVSync` held the GIL while blocking
|
|
||||||
|
|
||||||
The rgbmatrix binding declares it without `nogil` (unlike `SetPixel`, `Clear`
|
|
||||||
and `Fill` immediately above it in `cppinc.pxd`), so the render thread held the
|
|
||||||
GIL for the entire vsync wait — most of every frame. Background threads were
|
|
||||||
starved into long uninterruptible bursts; a 1.5 MB API response costs ~17 ms to
|
|
||||||
parse and ~18 ms to re-encode for the cache, and `json.raw_decode` cannot be
|
|
||||||
preempted mid-document. Those bursts are what the render loop then waited on.
|
|
||||||
|
|
||||||
Fixed by rebuilding the binding: `scripts/build_rgbmatrix_nogil.sh`.
|
|
||||||
|
|
||||||
### 3. Sub-pixel blending was wrong for this display
|
|
||||||
|
|
||||||
Enabling it made things worse, not better — see the rule at the top. It is off
|
|
||||||
by default and only Vegas mode opts in via `set_sub_pixel_scrolling(True)`.
|
|
||||||
|
|
||||||
### 4. Frame-based stepping raced the vsync clock
|
|
||||||
|
|
||||||
Frame-based mode gated motion on a wall clock at `1/scroll_delay` steps per
|
|
||||||
second. Plugins set `scroll_delay` to the frame period, which puts that
|
|
||||||
comparison exactly on its own threshold: a frame arriving a hair early moved
|
|
||||||
zero pixels and rendered an identical frame, which dirty-tracking skipped, so
|
|
||||||
it returned in ~2 ms and the beat repeated. No `scroll_delay` value tunes this
|
|
||||||
out — a shorter delay just trades stalled frames for periodic double-steps.
|
|
||||||
|
|
||||||
`ScrollHelper` now accumulates elapsed time in both modes at the same
|
|
||||||
configured speed, so position stays proportional to real time.
|
|
||||||
|
|
||||||
## Diagnosing a juddery scroller
|
|
||||||
|
|
||||||
**`Avg FPS` will lie to you.** It is a 100-frame moving average, and a 2 ms
|
|
||||||
duplicate frame plus a 21 ms double-wait average to exactly 10 ms. A ticker
|
|
||||||
that is stalling on half its frames still reports a healthy `100.0`.
|
|
||||||
|
|
||||||
Look at the **distribution** instead:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
journalctl -u ledmatrix --since "-10min" --no-pager \
|
|
||||||
| grep -oE "Frame time: [0-9.]+ms" | awk '{print $3}' | sed 's/ms//' \
|
|
||||||
| awk '{printf "%.0f\n", $1}' | sort -n | uniq -c
|
|
||||||
```
|
|
||||||
|
|
||||||
Reading it, on a 100 Hz panel:
|
|
||||||
|
|
||||||
| you see | it means |
|
|
||||||
|---|---|
|
|
||||||
| everything at 10 ms | healthy |
|
|
||||||
| a mode at ~2 ms | **duplicate frames** — the swap was skipped because the image did not change. The scroller is advancing less than one pixel per frame. |
|
|
||||||
| a mode at 20/30/50 ms | frames missing refreshes — per-frame work is overrunning, or a background thread is holding the GIL |
|
|
||||||
| `Avg FPS` above 100 | duplicates present, unless the scroll cycle has completed and is idling |
|
|
||||||
|
|
||||||
Then confirm what the plugin actually loaded — config edits do not always reach
|
|
||||||
the running code:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
journalctl -u ledmatrix --since "-5min" --no-pager | grep -iE "px/s|px/frame"
|
|
||||||
```
|
|
||||||
|
|
||||||
If a plugin logs its scroll config **twice** with different modes, the second
|
|
||||||
line is what is running.
|
|
||||||
|
|
||||||
## Rebuilding the binding
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/build_rgbmatrix_nogil.sh # build into a scratch dir
|
|
||||||
sudo bash scripts/build_rgbmatrix_nogil.sh --install
|
|
||||||
sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
|
||||||
```
|
|
||||||
|
|
||||||
The build never touches the installed module. `--install` backs up the original
|
|
||||||
to `~/rgbmatrix-core.so.ORIGINAL` first, and rolls back automatically if the
|
|
||||||
service does not come back healthy. Requires `build-essential`; Cython is
|
|
||||||
installed into a cached venv under `~/.cache/ledmatrix-cython`.
|
|
||||||
|
|
||||||
Re-run it after upgrading `rpi-rgb-led-matrix`, since a library upgrade
|
|
||||||
replaces the patched binding.
|
|
||||||
|
|
||||||
## Faster JSON
|
|
||||||
|
|
||||||
`src/cache/disk_cache.py` uses `orjson` when it is importable and falls back to
|
|
||||||
the stdlib otherwise, so it is optional:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pip3 install --break-system-packages orjson
|
|
||||||
```
|
|
||||||
|
|
||||||
Encoding is where it pays — about 7× on this hardware. Decoding gains far less
|
|
||||||
(~1.3× on large payloads) because the cost there is building Python objects,
|
|
||||||
not scanning text. That is also why moving parsing to a subprocess does not
|
|
||||||
help: `pickle.loads` of the same payload costs 8.1 ms against `json.loads` at
|
|
||||||
10.9 ms, so the work just moves rather than disappearing.
|
|
||||||
+169
-37
@@ -27,7 +27,7 @@ These are independent concerns. Conflating them is what produces god classes.
|
|||||||
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
||||||
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
||||||
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
||||||
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. Nothing enforces that floor today, so the copy also waits for the B6 gate below. |
|
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. The store enforces that floor on every registry-managed install and on both supported update paths (sideloading via `install_from_url` is not gated), but a floor cannot reach a user who never updates, so the copy also waits for the B6 gate below. |
|
||||||
|
|
||||||
The core API is **additive-only**. A method the plugins call is never removed or
|
The core API is **additive-only**. A method the plugins call is never removed or
|
||||||
given a new required parameter; new behavior arrives as new methods with
|
given a new required parameter; new behavior arrives as new methods with
|
||||||
@@ -204,7 +204,7 @@ legacy compatibility rather than the mechanism.
|
|||||||
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
||||||
**rollout**, and it splits into three phases with very different risk profiles.
|
**rollout**, and it splits into three phases with very different risk profiles.
|
||||||
The original plan folded the last two together; they are separated here because
|
The original plan folded the last two together; they are separated here because
|
||||||
one of them is safe by construction and the other is not.
|
one of them cannot break a user on an old core and the other can.
|
||||||
|
|
||||||
| Phase | Scope | Status | Gate |
|
| Phase | Scope | Status | Gate |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -212,9 +212,9 @@ one of them is safe by construction and the other is not.
|
|||||||
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
|
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
|
||||||
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
||||||
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
|
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
|
||||||
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ⏳ **next** | Tag, release, and `src.__version__` agree; compatibility gate merged |
|
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ✅ | Released 2026-08-03; tag, release and `src.__version__` agree; compatibility gate merged (#428, #431, #433) |
|
||||||
| **B5** | Adoption — guarded core imports: three pilots, then the remaining six. **Bundled copies stay.** | after B4 | Per plugin: harness + goldens byte-identical, then a device soak |
|
| **B5** | Adoption — guarded core imports, all eight. **Bundled copies stay.** | ✅ | All eight adopted; harness byte-identical; see the B5 retrospective below — four shipped broken and were repaired in plugins #251 |
|
||||||
| **B6** | Sunset — delete the bundled copies | **blocked** | B4's gate shipped *and* in users' hands (see below) |
|
| **B6** | Sunset — delete the bundled copies | ✅ | Ran 2026-09-01, all eight. Floors at 3.2.0; the store refuses on all three routes in (#431/#433, #508, #510). See "B6 — what actually happened" |
|
||||||
|
|
||||||
### B4 — what "ship 3.2.0" actually requires
|
### B4 — what "ship 3.2.0" actually requires
|
||||||
|
|
||||||
@@ -234,6 +234,20 @@ a floor can be trusted against, and today it is not:
|
|||||||
compares the plugin's manifest version against the registry's
|
compares the plugin's manifest version against the registry's
|
||||||
`latest_version` and nothing else.
|
`latest_version` and nothing else.
|
||||||
|
|
||||||
|
*Fixed, in two parts.* `install_plugin` gained the gate in #431/#433, which
|
||||||
|
covers every registry-managed install and, through `_reinstall_with_rollback`,
|
||||||
|
the update path that re-downloads.
|
||||||
|
`update_plugin`'s git branch pulls in place and re-downloads nothing, so it
|
||||||
|
stayed ungated until `_gate_pulled_commit` closed it — checked after the pull
|
||||||
|
(the registry carries no floor field, so the incoming floor is unknowable
|
||||||
|
before it) and undone with `git reset --hard` to the pre-pull commit. That
|
||||||
|
route is rare in practice, since monorepo plugins install as archives; it was
|
||||||
|
closed because the sunset rule in the plugins repo's
|
||||||
|
`08-shared-sports-code.md` states as **condition 3** that the core enforces
|
||||||
|
the floor "at install/update time", and B6 rests on that being true rather
|
||||||
|
than merely written down. `install_from_url` — sideloading a plugin from a
|
||||||
|
URL — is still ungated.
|
||||||
|
|
||||||
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
|
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
|
||||||
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
|
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
|
||||||
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
|
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
|
||||||
@@ -262,13 +276,21 @@ default is the more restrictive.
|
|||||||
`compatible_versions`. No manifest still carries it, so there is nothing to
|
`compatible_versions`. No manifest still carries it, so there is nothing to
|
||||||
migrate there.)
|
migrate there.)
|
||||||
|
|
||||||
### B5 — adoption is safe by construction
|
### B5 — the *fallback* is safe by construction; the modern path is not
|
||||||
|
|
||||||
|
The heading matters, because the unqualified version of this claim is false and
|
||||||
|
this document proves it two sections down: four of the eight adopted plugins
|
||||||
|
shipped with scroll mode broken on a 3.2.0 core. What is safe by construction is
|
||||||
|
narrower than "adoption".
|
||||||
|
|
||||||
A plugin adopting core imports keeps its bundled copy and reaches it through the
|
A plugin adopting core imports keeps its bundled copy and reaches it through the
|
||||||
guarded import (see the Upgradability table above). On a core that ships the
|
guarded import (see the Upgradability table above). On a core that doesn't ship
|
||||||
module the plugin uses core code; on one that doesn't it falls back and behaves
|
the module the plugin falls back and behaves exactly as it does today. That
|
||||||
exactly as it does today. There is no version of this step that breaks a user,
|
fallback compatibility — and only that — is safe by construction. On a core that
|
||||||
which is why it does not wait for B6's gate.
|
*does* ship the module, correctness is not automatic — object-level and scroll-mode validation
|
||||||
|
(building both classes and comparing, per the retrospective below) is required
|
||||||
|
to prove full behavior. There is no version of this step that breaks a user *on
|
||||||
|
an old core*, which is why it does not wait for B6's gate.
|
||||||
|
|
||||||
The hockey scroll-display pilot is **already validated**: adopted against a core
|
The hockey scroll-display pilot is **already validated**: adopted against a core
|
||||||
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
|
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
|
||||||
@@ -297,7 +319,8 @@ been in users' hands long enough that the population running a core without it
|
|||||||
is small.** The bundled copies cost disk space; deleting them early costs
|
is small.** The bundled copies cost disk space; deleting them early costs
|
||||||
scoreboards, silently. That trade is not close.
|
scoreboards, silently. That trade is not close.
|
||||||
|
|
||||||
Before the first sunset, add a **compatibility regression test**. It has to
|
Before the first sunset, add a **compatibility regression test**. **Built:**
|
||||||
|
core `test/test_sports_sunset_matrix.py` (#505). It has to
|
||||||
cover four cases, not one — B5's safety claim and B6's failure mode are
|
cover four cases, not one — B5's safety claim and B6's failure mode are
|
||||||
different propositions and only the second is obvious:
|
different propositions and only the second is obvious:
|
||||||
|
|
||||||
@@ -321,35 +344,144 @@ gate rather than trusting the failure to be noticed.
|
|||||||
The same suite should exercise the install/update gate, since it is the other
|
The same suite should exercise the install/update gate, since it is the other
|
||||||
half of the guarantee.
|
half of the guarantee.
|
||||||
|
|
||||||
|
### B6 — what actually happened
|
||||||
|
|
||||||
|
**Ran 2026-09-01, across all eight scoreboards.** Held from 2026-08-05 to
|
||||||
|
2026-09-01 on the argument below, which is kept because the reasoning applies to
|
||||||
|
the next module, not because it is still in force.
|
||||||
|
|
||||||
|
**The hold, and why it lifted.** The stated gate was evidence of 3.2.0 uptake —
|
||||||
|
"a few months of it being the default download, or store-side install data".
|
||||||
|
That evidence never arrived and could not: the core updates by
|
||||||
|
`git pull --rebase`, so release-asset counts cannot measure uptake, and no
|
||||||
|
store-side telemetry exists. What changed instead is that the *risk* the gate
|
||||||
|
protected against was closed directly. The store now refuses a plugin whose
|
||||||
|
floor exceeds the running core on **all three** routes in:
|
||||||
|
|
||||||
|
| route | gated by |
|
||||||
|
|---|---|
|
||||||
|
| `install_plugin` — every path that re-downloads, `_reinstall_with_rollback` included | #431, #433 |
|
||||||
|
| `update_plugin`'s git branch — pulls in place, re-downloads nothing | #508 |
|
||||||
|
| `install_from_url` — sideloading | #510 |
|
||||||
|
|
||||||
|
With all three closed a pre-3.2.0 user cannot receive a sunset plugin at all;
|
||||||
|
they keep the version they already run. The population the hold existed to
|
||||||
|
protect is protected by refusal rather than by a bundled copy — which is what
|
||||||
|
the copy was standing in for.
|
||||||
|
|
||||||
|
**What shipped.** Eight plugins, ~5,800 lines of frozen fallback deleted. Each:
|
||||||
|
copy removed, guarded import collapsed to a plain one, floor raised to 3.2.0,
|
||||||
|
`test_core_fallback.py` rewritten as `test_core_scroll.py` asserting the sunset
|
||||||
|
rather than the fallback. `scripts/check_scroll_adoption.py` gained
|
||||||
|
`sunset_violations` and a `SUNSET_PLUGINS` set naming all eight, so a
|
||||||
|
resurrected copy or a returned guard fails CI.
|
||||||
|
|
||||||
|
Delivered as plugins #346 (hockey, later folded into #351), #349 (football),
|
||||||
|
#350 (baseball), #351 (the remaining six).
|
||||||
|
|
||||||
|
**Two things found by doing it, both worth carrying forward:**
|
||||||
|
|
||||||
|
- **Only one fallback held orchestration logic the core lacked.** baseball's
|
||||||
|
`_configure_scroll_helper` reinterpreted `scroll_speed` as pixels-per-*frame*
|
||||||
|
when `speed × delay` fell outside the 0.1–5.0 window — measured, 10–20×
|
||||||
|
faster than configured for a speed between 1.0 and 5.0. Standardised onto the
|
||||||
|
core's behaviour (honour the documented px/sec, clamp) rather than preserved.
|
||||||
|
Every other difference across the eight was a docstring, an unreachable
|
||||||
|
`scroll_helper is None` guard, or an equivalent diagnostic.
|
||||||
|
- **Two tests had been leaning on the guard without anyone knowing.**
|
||||||
|
`soccer/test_live_screens.py` stubbed `src` in a way that shadowed the core,
|
||||||
|
so its guarded import fell back and the test had been exercising the *frozen
|
||||||
|
copy* rather than the shipping class since B5. Before sunsetting anything else
|
||||||
|
that carries a guarded core import, grep for tests that stub `src`.
|
||||||
|
|
||||||
|
**The floor-raising traps still apply** to any future sunset: four plugins
|
||||||
|
declare their floor top-level, where editing `versions[0]` is a silent no-op,
|
||||||
|
and the floor has three live spellings (`min_ledmatrix_version`,
|
||||||
|
`requires.min_ledmatrix_version`, `versions[].ledmatrix_min_version`, plus
|
||||||
|
deprecated `ledmatrix_min`). See
|
||||||
|
`src/plugin_system/compatibility.py:declared_min_version` for the resolution
|
||||||
|
order any floor-raising tool must reproduce — and note the name is **inverted**
|
||||||
|
between the top level and `versions[]`.
|
||||||
|
|
||||||
|
**Still not adopted, deliberately:** `data_sources.py`, `game_renderer.py` and
|
||||||
|
`base_odds_manager.py`. The standing decision held them until B6 closed; it now
|
||||||
|
has, so they can be reconsidered — with B5's lesson applied, which is to build
|
||||||
|
the object and diff rendered output rather than trust a static check.
|
||||||
|
|
||||||
|
### B5 retrospective — what the adoption actually cost
|
||||||
|
|
||||||
|
Recorded because it is the evidence behind the two decisions above, and because
|
||||||
|
"the adoption went fine" is not what happened.
|
||||||
|
|
||||||
|
**Four of the eight shipped with scroll mode broken** on a 3.2.0 core, and were
|
||||||
|
repaired in plugins-repo #251. The restructure lifted the content methods
|
||||||
|
verbatim but left the state they read off `self` behind: separator-icon
|
||||||
|
constants (hockey, basketball, lacrosse) and the game-renderer cache (afl).
|
||||||
|
hockey/basketball/lacrosse could not construct the scroll display at all; afl
|
||||||
|
raised inside `prepare_scroll_content`, which the core base *catches*, so its
|
||||||
|
only symptom was scroll mode silently drawing nothing.
|
||||||
|
|
||||||
|
Three things are worth carrying forward:
|
||||||
|
|
||||||
|
- **The bundled fallback did not protect anyone from this.** The break was on
|
||||||
|
the modern path, which the fallback never touches. Carrying the second copy
|
||||||
|
bought nothing against the actual defect while creating the divergence that
|
||||||
|
produced it. That is an argument *for* B6, not against it.
|
||||||
|
- **Every gate was green.** The safety harness renders the scoreboard screens,
|
||||||
|
not scroll mode; `test_core_fallback.py` checked that methods existed and that
|
||||||
|
their *globals* resolved, and `self.NHL_SEPARATOR_ICON` is an attribute read,
|
||||||
|
invisible to an AST scan for `Name` loads. The fix was to stop reasoning about
|
||||||
|
source and **build the object**: construct both classes on both paths, compare
|
||||||
|
the separator icons they end up with, and assert the adopted class ends up
|
||||||
|
with every instance attribute the bundled one sets.
|
||||||
|
- **Test what the change touches, not what is convenient to render.** Scroll
|
||||||
|
mode had no coverage because the harness could not reach it. A comparison
|
||||||
|
harness that renders the same games through both paths and diffs the pixels
|
||||||
|
needs no per-sport knowledge of the right answer, only that adopting core code
|
||||||
|
did not change it.
|
||||||
|
|
||||||
|
**The ledger.** Before adoption, eight duplicated copies totalled 5,685 lines.
|
||||||
|
After adoption plus the frozen legacy copies it was 10,610; removing the dead
|
||||||
|
inline duplication (plugins #252) brought it to roughly 8,620. B6 would take it
|
||||||
|
to about 3,300 including the shared core module — some 2,400 fewer than before
|
||||||
|
this project started. **Until B6 runs, the adoption is net negative on disk**,
|
||||||
|
and its one delivered user-visible gain is that adopted plugins honour the
|
||||||
|
global `target_fps` instead of hardcoding ~100 FPS.
|
||||||
|
|
||||||
|
### Decision: stop adopting further modules until B6 closes
|
||||||
|
|
||||||
|
`data_sources.py` (9 copies), `game_renderer.py` (8) and `base_odds_manager.py`
|
||||||
|
are the obvious next candidates. **Do not adopt them yet.** Each adoption adds
|
||||||
|
carrying cost — a second copy to keep in step — against a payoff that is
|
||||||
|
contingent on B6, and B6 is gated on an installed base we cannot currently
|
||||||
|
measure. Consolidate what is already committed; revisit when B6 does.
|
||||||
|
|
||||||
## What's next
|
## What's next
|
||||||
|
|
||||||
In order. Each step is independently useful and independently revertible.
|
Steps 1–5 of the original plan are **done**: 3.2.0 is tagged and published with
|
||||||
|
a version number CI now asserts (#428), the compatibility gate is in
|
||||||
|
`install_plugin` and reads `compatible_versions` as well as the floor
|
||||||
|
(#431, #433), the newest manifest entry is required to use `ledmatrix_min_version`
|
||||||
|
(plugins #244), and all eight plugins have adopted the scroll orchestration
|
||||||
|
(plugins #245–#249, repaired in #251, tidied in #252).
|
||||||
|
|
||||||
1. **Tag and publish v3.2.0.** The code is already on `main` (`21825cbf`).
|
What actually remains, smallest first:
|
||||||
Nothing else blocks this, and it is what makes `ledmatrix_min_version:
|
|
||||||
"3.2.0"` refer to something real.
|
1. **Soak the adoptions on hardware.** football and hockey have been run on a
|
||||||
2. **Make the version number honest.** Have the release process assert that the
|
live rig through real games; baseball was watched through one earlier. The
|
||||||
tag, the GitHub release, and `src.__version__` agree — a check in CI is
|
rest are proven by harness, unit tests and pixel comparison. Out-of-season
|
||||||
cheaper than the confusion of the last two releases. Then revisit the
|
sports cannot be soaked until their season starts. When you do, **check the
|
||||||
`< 2.0.0` skip in `_warn_if_incompatible`, which currently silences the
|
rig's `*_display_mode` first** — a board in `switch` mode will happily load a
|
||||||
warning for the users who most need it.
|
sunset plugin and tell you nothing about the scroll code the sunset changed.
|
||||||
3. **Add the compatibility gate** to `StoreManager.install_plugin` and
|
2. **Cut 3.3.0.** Not required by B6 — its floors are 3.2.0, which is released —
|
||||||
`.update_plugin`: refuse a plugin whose declared floor exceeds
|
but `calendar` 1.2.3 floors at 3.3.0 for the device-authorization endpoints
|
||||||
`src.__version__`, and surface the reason in the store UI rather than only
|
that landed after 3.2.0, so it is un-installable until the release exists.
|
||||||
the log. This is the single change that turns the floor from documentation
|
3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`,
|
||||||
into a guarantee, and B6 depends on it.
|
`base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is
|
||||||
4. **Migrate the manifests** to `ledmatrix_min_version`, and reconcile them with
|
the largest single duplication left: ~11,500 lines across eight plugins, with
|
||||||
`compatible_versions` (see above — that field is the required, canonical one,
|
~36,500 more in the eight `sports.py`. Note that core already ships
|
||||||
and the gate does not read it yet). Currently 28 plugins spell the floor both
|
`src/base_classes/sports/` (~143KB, promoted in B1/B2) that **no plugin
|
||||||
ways across their `versions[]` entries, 12 use only the old spelling, and 2
|
imports** — check whether it has drifted before treating it as the target.
|
||||||
only the new. Scope the sweep to the nine sports plugins if a 42-plugin
|
|
||||||
version-bump wave isn't worth it — but the `compatible_versions` half has to
|
|
||||||
cover every manifest the gate can refuse, or define explicit legacy handling,
|
|
||||||
before the gate is allowed to block anything.
|
|
||||||
5. **Run B5 adoption** — hockey, soccer, football, then the remaining six.
|
|
||||||
Bundled copies stay. Byte-identical harness output per plugin, then a soak.
|
|
||||||
6. **Only then plan B6**, with the compatibility regression test described above
|
|
||||||
in CI first.
|
|
||||||
|
|
||||||
## How to keep this project healthy
|
## How to keep this project healthy
|
||||||
|
|
||||||
|
|||||||
@@ -1740,12 +1740,15 @@ journald_effective() {
|
|||||||
systemd-analyze cat-config systemd/journald.conf >/dev/null 2>&1; then
|
systemd-analyze cat-config systemd/journald.conf >/dev/null 2>&1; then
|
||||||
systemd-analyze cat-config systemd/journald.conf 2>/dev/null
|
systemd-analyze cat-config systemd/journald.conf 2>/dev/null
|
||||||
else
|
else
|
||||||
cat /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null
|
cat /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
journald_conf="$(journald_effective)"
|
journald_conf="$(journald_effective)"
|
||||||
journald_storage="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
# Both settings are optional, and stock images ship them commented out. grep
|
||||||
journald_cap="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*SystemMaxUse=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
# exits 1 on no match, which pipefail turns fatal under set -e — an absent
|
||||||
|
# setting must read as empty, not abort the install.
|
||||||
|
journald_storage="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||||
|
journald_cap="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*SystemMaxUse=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||||
|
|
||||||
if [ "$journald_storage" = "persistent" ] && [ -n "$journald_cap" ]; then
|
if [ "$journald_storage" = "persistent" ] && [ -n "$journald_cap" ]; then
|
||||||
echo "Persistent journald storage already configured (SystemMaxUse=$journald_cap)"
|
echo "Persistent journald storage already configured (SystemMaxUse=$journald_cap)"
|
||||||
@@ -1771,7 +1774,7 @@ else
|
|||||||
# after ledmatrix-persistent.conf (zz-local.conf and friends) still wins.
|
# after ledmatrix-persistent.conf (zz-local.conf and friends) still wins.
|
||||||
# Writing the file is not evidence it took effect -- re-read and say so
|
# Writing the file is not evidence it took effect -- re-read and say so
|
||||||
# plainly rather than reporting success we cannot confirm.
|
# plainly rather than reporting success we cannot confirm.
|
||||||
journald_now="$(journald_effective | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
journald_now="$(journald_effective | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||||
if [ "$journald_now" = "persistent" ]; then
|
if [ "$journald_now" = "persistent" ]; then
|
||||||
echo " Persistent journald storage active"
|
echo " Persistent journald storage active"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -55,14 +55,6 @@ packaging>=23.0,<27.0
|
|||||||
# range as a hard dependency — keep the two in sync.
|
# range as a hard dependency — keep the two in sync.
|
||||||
# pip install 'psutil>=6.0.0,<7.0.0'
|
# pip install 'psutil>=6.0.0,<7.0.0'
|
||||||
#
|
#
|
||||||
# orjson — faster JSON for the disk cache
|
|
||||||
# (src/cache/disk_cache.py). Encoding a ~1MB cache
|
|
||||||
# record drops from ~12ms to ~1.6ms on a Pi 4, which
|
|
||||||
# matters because that work holds the GIL and stalls
|
|
||||||
# the render thread mid-scroll. Falls back to the
|
|
||||||
# stdlib json when missing — see docs/SCROLL_PERFORMANCE.md.
|
|
||||||
# pip install 'orjson>=3.9,<4.0'
|
|
||||||
#
|
|
||||||
# Flask-Limiter — request rate limiting in web_interface/app.py
|
# Flask-Limiter — request rate limiting in web_interface/app.py
|
||||||
# (accidental-abuse protection, not security). The
|
# (accidental-abuse protection, not security). The
|
||||||
# web interface starts without rate limiting when
|
# web interface starts without rate limiting when
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Rebuild the rgbmatrix Python binding so it releases the GIL.
|
|
||||||
#
|
|
||||||
# WHY THIS EXISTS
|
|
||||||
# ---------------
|
|
||||||
# The upstream binding declares FrameCanvas::SwapOnVSync WITHOUT `nogil`
|
|
||||||
# (cppinc.pxd), unlike SetPixel/Clear/Fill on the lines just above it.
|
|
||||||
# SwapOnVSync blocks until the panel's next vertical sync -- up to a full
|
|
||||||
# refresh period on every frame -- so the render thread was holding the GIL
|
|
||||||
# for most of every frame. Background threads (API fetches, JSON parsing,
|
|
||||||
# image decode) were starved into long uninterruptible bursts, which in turn
|
|
||||||
# made the render loop miss refreshes.
|
|
||||||
#
|
|
||||||
# Measured on a Pi 4 driving a 2x128x64 chain at limit_refresh_rate_hz=100:
|
|
||||||
#
|
|
||||||
# before ~44 fps average, 14-17% of frames 41-53ms
|
|
||||||
# after 100 fps, median 10.00ms, p95 10.05ms, 0% stalls
|
|
||||||
#
|
|
||||||
# The per-pixel blit (SetPixelsPillow) can also release the GIL and walk the
|
|
||||||
# Pillow buffer row-major, but that is OFF by default and you almost certainly
|
|
||||||
# want to leave it that way. Row-major changes what a partially-written frame
|
|
||||||
# looks like: column-major tearing shows as a vertical seam, row-major tearing
|
|
||||||
# shows as a horizontal split between the panel's upper and lower halves. On a
|
|
||||||
# 1/32 scan panel that reads as a one-pixel "fold" across the middle of every
|
|
||||||
# panel -- reported on hardware, and it went away when the blit was reverted.
|
|
||||||
# Enable with RGB_PATCH_BLIT=1 only if you have measured that you need it;
|
|
||||||
# essentially all of the gain above comes from the SwapOnVSync change alone.
|
|
||||||
#
|
|
||||||
# SAFETY
|
|
||||||
# ------
|
|
||||||
# Builds into a scratch directory; touches the installed module only in the
|
|
||||||
# --install step, and backs up the original first. Roll back at any time with:
|
|
||||||
#
|
|
||||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
|
||||||
#
|
|
||||||
# USAGE
|
|
||||||
# bash scripts/build_rgbmatrix_nogil.sh # build only
|
|
||||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --install
|
|
||||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
|
||||||
#
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
# Resolve the invoking user's home, not root's. --install runs under sudo,
|
|
||||||
# where $HOME is /root, so every default path below pointed somewhere the
|
|
||||||
# build had never written and the install died with "no built module found".
|
|
||||||
if [ -n "${SUDO_USER:-}" ]; then
|
|
||||||
OWNER_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)"
|
|
||||||
fi
|
|
||||||
OWNER_HOME="${OWNER_HOME:-$HOME}"
|
|
||||||
|
|
||||||
SRC_TREE="${RGB_SRC_TREE:-$OWNER_HOME/LEDMatrix/rpi-rgb-led-matrix-master}"
|
|
||||||
BUILD_DIR="${RGB_BUILD_DIR:-$OWNER_HOME/rgbmatrix-nogil-build}"
|
|
||||||
VENV="${RGB_CYTHON_VENV:-$OWNER_HOME/.cache/ledmatrix-cython}"
|
|
||||||
BACKUP="${RGB_BACKUP:-$OWNER_HOME/rgbmatrix-core.so.ORIGINAL}"
|
|
||||||
PATCH_BLIT="${RGB_PATCH_BLIT:-0}"
|
|
||||||
|
|
||||||
die() { echo "FATAL: $*" >&2; exit 1; }
|
|
||||||
|
|
||||||
py_site() {
|
|
||||||
python3 -c 'import rgbmatrix, os; print(os.path.dirname(rgbmatrix.__file__))' 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
abi_so() {
|
|
||||||
ls "$BUILD_DIR"/bindings/python/rgbmatrix/core.cpython-*.so 2>/dev/null | head -1
|
|
||||||
}
|
|
||||||
|
|
||||||
do_rollback() {
|
|
||||||
local dst; dst="$(py_site)"
|
|
||||||
[ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
|
||||||
[ -f "$BACKUP" ] || die "no backup at $BACKUP"
|
|
||||||
systemctl stop ledmatrix 2>/dev/null
|
|
||||||
cp -a "$BACKUP" "$dst/core.so" || die "restore failed"
|
|
||||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
|
||||||
systemctl start ledmatrix 2>/dev/null
|
|
||||||
echo "rolled back to the original core.so"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
do_install() {
|
|
||||||
local so dst
|
|
||||||
so="$(abi_so)"; [ -n "$so" ] || die "no built module found - run the build first"
|
|
||||||
dst="$(py_site)"; [ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
|
||||||
|
|
||||||
if [ ! -f "$BACKUP" ]; then
|
|
||||||
cp -a "$dst/core.so" "$BACKUP" || die "could not back up the original"
|
|
||||||
echo "backed up original core.so -> $BACKUP"
|
|
||||||
else
|
|
||||||
echo "backup already present at $BACKUP (keeping the true original)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
systemctl stop ledmatrix 2>/dev/null
|
|
||||||
cp "$so" "$dst/core.so" || die "install failed"
|
|
||||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
|
||||||
systemctl start ledmatrix 2>/dev/null
|
|
||||||
|
|
||||||
echo "waiting 25s for the display to come back..."
|
|
||||||
sleep 25
|
|
||||||
local healthy=1
|
|
||||||
systemctl is-active --quiet ledmatrix || healthy=0
|
|
||||||
if journalctl -u ledmatrix --since "40 sec ago" --no-pager \
|
|
||||||
| grep -qiE "Traceback|ImportError|Segmentation fault|undefined symbol"; then
|
|
||||||
healthy=0
|
|
||||||
fi
|
|
||||||
if [ "$healthy" = "1" ]; then
|
|
||||||
echo "SUCCESS - running on the rebuilt binding"
|
|
||||||
else
|
|
||||||
echo "UNHEALTHY - rolling back"
|
|
||||||
cp -a "$BACKUP" "$dst/core.so"
|
|
||||||
systemctl restart ledmatrix
|
|
||||||
journalctl -u ledmatrix --since "90 sec ago" --no-pager | tail -25
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
--rollback) do_rollback ;;
|
|
||||||
--install) do_install ;;
|
|
||||||
"" ) ;;
|
|
||||||
*) die "unknown option: $1" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- build ----
|
|
||||||
[ -d "$SRC_TREE" ] || die "matrix source tree not found at $SRC_TREE (set RGB_SRC_TREE)"
|
|
||||||
command -v g++ >/dev/null || die "g++ not installed (apt install build-essential)"
|
|
||||||
|
|
||||||
echo "==> staging a scratch copy at $BUILD_DIR"
|
|
||||||
rm -rf "$BUILD_DIR"
|
|
||||||
cp -r "$SRC_TREE" "$BUILD_DIR" || die "copy failed"
|
|
||||||
|
|
||||||
echo "==> patching the bindings to release the GIL"
|
|
||||||
python3 - "$BUILD_DIR" "$PATCH_BLIT" <<'PYEOF' || die "patch failed"
|
|
||||||
import io
|
|
||||||
import sys
|
|
||||||
|
|
||||||
base = sys.argv[1] + "/bindings/python/rgbmatrix/"
|
|
||||||
patch_blit = len(sys.argv) > 2 and sys.argv[2] == "1"
|
|
||||||
|
|
||||||
# --- declare SwapOnVSync as nogil ---------------------------------------
|
|
||||||
p = base + "cppinc.pxd"
|
|
||||||
s = io.open(p, encoding="utf-8").read()
|
|
||||||
OLD_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t)\n"
|
|
||||||
NEW_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t) nogil\n"
|
|
||||||
if OLD_DECL in s:
|
|
||||||
io.open(p, "w", encoding="utf-8", newline="\n").write(s.replace(OLD_DECL, NEW_DECL, 1))
|
|
||||||
print(" cppinc.pxd: SwapOnVSync declared nogil")
|
|
||||||
elif NEW_DECL in s:
|
|
||||||
print(" cppinc.pxd: already nogil")
|
|
||||||
else:
|
|
||||||
sys.exit("could not find the SwapOnVSync declaration")
|
|
||||||
|
|
||||||
# --- release the GIL across the vsync wait ------------------------------
|
|
||||||
p = base + "core.pyx"
|
|
||||||
s = io.open(p, encoding="utf-8").read()
|
|
||||||
|
|
||||||
OLD_SWAP = (
|
|
||||||
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
|
|
||||||
" return __createFrameCanvas("
|
|
||||||
"self.__matrix.SwapOnVSync(newFrame.__canvas, framerate_fraction))\n"
|
|
||||||
)
|
|
||||||
NEW_SWAP = (
|
|
||||||
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
|
|
||||||
" # Blocks until the panel's next vertical sync. Holding the GIL\n"
|
|
||||||
" # across that wait starves every other Python thread for most of\n"
|
|
||||||
" # each frame. Pointers are hoisted into C locals so the blocking\n"
|
|
||||||
" # call itself needs no Python state.\n"
|
|
||||||
" cdef cppinc.RGBMatrix* matrix = self.__matrix\n"
|
|
||||||
" cdef cppinc.FrameCanvas* frame = newFrame.__canvas\n"
|
|
||||||
" cdef uint8_t fraction = framerate_fraction\n"
|
|
||||||
" cdef cppinc.FrameCanvas* swapped\n"
|
|
||||||
" with nogil:\n"
|
|
||||||
" swapped = matrix.SwapOnVSync(frame, fraction)\n"
|
|
||||||
" return __createFrameCanvas(swapped)\n"
|
|
||||||
)
|
|
||||||
if OLD_SWAP in s:
|
|
||||||
s = s.replace(OLD_SWAP, NEW_SWAP, 1)
|
|
||||||
print(" core.pyx: SwapOnVSync releases the GIL")
|
|
||||||
elif "swapped = matrix.SwapOnVSync(frame, fraction)" in s:
|
|
||||||
print(" core.pyx: SwapOnVSync already patched")
|
|
||||||
else:
|
|
||||||
sys.exit("could not find the SwapOnVSync body")
|
|
||||||
|
|
||||||
# --- optional: release the GIL across the blit --------------------------
|
|
||||||
OLD_BLIT = (
|
|
||||||
" buffer = get_pillow_buffer(image_capsule)\n"
|
|
||||||
"\n"
|
|
||||||
" for col in range(max(0, -xstart), min(width, frame_width - xstart)):\n"
|
|
||||||
" for row in range(max(0, -ystart), min(height, frame_height - ystart)):\n"
|
|
||||||
" pixel = buffer[row][col]\n"
|
|
||||||
" r = (pixel ) & 0xFF\n"
|
|
||||||
" g = (pixel >> 8) & 0xFF\n"
|
|
||||||
" b = (pixel >> 16) & 0xFF\n"
|
|
||||||
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
|
|
||||||
)
|
|
||||||
NEW_BLIT = (
|
|
||||||
" buffer = get_pillow_buffer(image_capsule)\n"
|
|
||||||
"\n"
|
|
||||||
" # Bounds hoisted so the blit needs no Python state and can run\n"
|
|
||||||
" # without the GIL: it touches only a C buffer and a C++ canvas.\n"
|
|
||||||
" # NOTE: row-major order makes a torn frame show as a horizontal\n"
|
|
||||||
" # split across the panel's halves. See the header before enabling.\n"
|
|
||||||
" cdef int col_start = max(0, -xstart)\n"
|
|
||||||
" cdef int col_end = min(width, frame_width - xstart)\n"
|
|
||||||
" cdef int row_start = max(0, -ystart)\n"
|
|
||||||
" cdef int row_end = min(height, frame_height - ystart)\n"
|
|
||||||
"\n"
|
|
||||||
" with nogil:\n"
|
|
||||||
" for row in range(row_start, row_end):\n"
|
|
||||||
" for col in range(col_start, col_end):\n"
|
|
||||||
" pixel = buffer[row][col]\n"
|
|
||||||
" r = (pixel ) & 0xFF\n"
|
|
||||||
" g = (pixel >> 8) & 0xFF\n"
|
|
||||||
" b = (pixel >> 16) & 0xFF\n"
|
|
||||||
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
|
|
||||||
)
|
|
||||||
if patch_blit:
|
|
||||||
if OLD_BLIT in s:
|
|
||||||
s = s.replace(OLD_BLIT, NEW_BLIT, 1)
|
|
||||||
print(" core.pyx: pixel blit releases the GIL, row-major")
|
|
||||||
elif "for row in range(row_start, row_end):" in s:
|
|
||||||
print(" core.pyx: blit already patched")
|
|
||||||
else:
|
|
||||||
sys.exit("could not find the SetPixelsPillow loop")
|
|
||||||
else:
|
|
||||||
print(" core.pyx: blit left unpatched (RGB_PATCH_BLIT=1 to enable)")
|
|
||||||
|
|
||||||
io.open(p, "w", encoding="utf-8", newline="\n").write(s)
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
echo "==> building librgbmatrix.a (this takes a few minutes)"
|
|
||||||
nice -n 10 make -C "$BUILD_DIR/lib" -j2 >/dev/null 2>&1 \
|
|
||||||
|| die "library build failed - rerun 'make -C $BUILD_DIR/lib' to see why"
|
|
||||||
[ -f "$BUILD_DIR/lib/librgbmatrix.a" ] || die "librgbmatrix.a was not produced"
|
|
||||||
|
|
||||||
echo "==> preparing Cython"
|
|
||||||
[ -d "$VENV" ] || python3 -m venv --system-site-packages "$VENV" || die "venv failed"
|
|
||||||
"$VENV/bin/pip" install --quiet cython || die "cython install failed"
|
|
||||||
|
|
||||||
cat > "$BUILD_DIR/bindings/python/setup.py" <<'EOF'
|
|
||||||
from setuptools import setup, Extension
|
|
||||||
from Cython.Build import cythonize
|
|
||||||
|
|
||||||
core = Extension(
|
|
||||||
"rgbmatrix.core",
|
|
||||||
sources=["rgbmatrix/core.pyx", "rgbmatrix/shims/pillow.c"],
|
|
||||||
include_dirs=["../../include", "rgbmatrix/shims"],
|
|
||||||
extra_objects=["../../lib/librgbmatrix.a"],
|
|
||||||
language="c++",
|
|
||||||
extra_compile_args=["-O3", "-Wall", "-fno-exceptions", "-std=c++11"],
|
|
||||||
extra_link_args=["-lrt", "-lm", "-lpthread"],
|
|
||||||
)
|
|
||||||
|
|
||||||
setup(name="rgbmatrix",
|
|
||||||
ext_modules=cythonize([core], language_level="3str",
|
|
||||||
compiler_directives={"binding": False}))
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "==> compiling the extension"
|
|
||||||
( cd "$BUILD_DIR/bindings/python" && "$VENV/bin/python" setup.py build_ext --inplace ) \
|
|
||||||
>/dev/null 2>&1 || die "extension build failed"
|
|
||||||
|
|
||||||
SO="$(abi_so)"; [ -n "$SO" ] || die "no .so produced"
|
|
||||||
|
|
||||||
# Verify the GIL really is released before anyone installs this.
|
|
||||||
EXPECTED=1; [ "$PATCH_BLIT" = "1" ] && EXPECTED=2
|
|
||||||
PAIRS=$(grep -c "PyEval_SaveThread\|Py_UNBLOCK_THREADS" \
|
|
||||||
"$BUILD_DIR/bindings/python/rgbmatrix/core.cpp")
|
|
||||||
[ "$PAIRS" -ge "$EXPECTED" ] \
|
|
||||||
|| die "generated C++ has $PAIRS GIL-release sites, expected >= $EXPECTED"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "BUILT: $SO"
|
|
||||||
echo " ($PAIRS GIL-release site(s) in the generated C++)"
|
|
||||||
echo
|
|
||||||
echo "Install with: sudo bash $0 --install"
|
|
||||||
echo "Roll back with: sudo bash $0 --rollback"
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Show and try the scroll speeds your panel can display cleanly.
|
|
||||||
|
|
||||||
Motion looks smooth when the strip advances a WHOLE number of pixels per panel
|
|
||||||
refresh. Anything else has to blend two columns (which on pixel-font text reads
|
|
||||||
as shimmer) or repeat frames unevenly (which reads as judder). So the speeds
|
|
||||||
worth using are not arbitrary -- they are
|
|
||||||
|
|
||||||
refresh_hz / frame_hold * pixels_per_frame
|
|
||||||
|
|
||||||
for whole numbers of frame_hold and pixels_per_frame, and that ladder depends
|
|
||||||
on how fast YOUR panel actually refreshes. A Pi Zero driving a big chain will
|
|
||||||
have a completely different set of good speeds from a Pi 4 driving a small one.
|
|
||||||
|
|
||||||
# what can this panel do? (no hardware needed, uses your configured rate)
|
|
||||||
python3 scripts/scroll_speeds.py
|
|
||||||
|
|
||||||
# measure what the panel ACTUALLY manages, rather than what is configured
|
|
||||||
sudo systemctl stop ledmatrix
|
|
||||||
sudo python3 scripts/scroll_speeds.py --measure
|
|
||||||
sudo systemctl start ledmatrix
|
|
||||||
|
|
||||||
# what would a 60Hz panel offer?
|
|
||||||
python3 scripts/scroll_speeds.py --hz 60
|
|
||||||
|
|
||||||
# try one on the panel
|
|
||||||
sudo systemctl stop ledmatrix
|
|
||||||
sudo python3 scripts/scroll_speeds.py --demo 50
|
|
||||||
sudo systemctl start ledmatrix
|
|
||||||
|
|
||||||
This script never starts or stops the display service itself -- that is left to
|
|
||||||
you, so a crash here can never leave the panel dark.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from src.common import scroll_config # noqa: E402
|
|
||||||
|
|
||||||
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
|
|
||||||
|
|
||||||
|
|
||||||
def load_hardware():
|
|
||||||
try:
|
|
||||||
with open(CONFIG, encoding="utf-8") as handle:
|
|
||||||
return (json.load(handle).get("display") or {}).get("hardware") or {}
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def build_options(hardware, refresh_override=None):
|
|
||||||
from rgbmatrix import RGBMatrixOptions
|
|
||||||
|
|
||||||
o = RGBMatrixOptions()
|
|
||||||
o.rows = int(hardware.get("rows", 32))
|
|
||||||
o.cols = int(hardware.get("cols", 64))
|
|
||||||
o.chain_length = int(hardware.get("chain_length", 1))
|
|
||||||
o.parallel = int(hardware.get("parallel", 1))
|
|
||||||
o.brightness = int(hardware.get("brightness", 80))
|
|
||||||
o.hardware_mapping = hardware.get("hardware_mapping", "regular")
|
|
||||||
o.pwm_bits = int(hardware.get("pwm_bits", 11))
|
|
||||||
o.pwm_dither_bits = int(hardware.get("pwm_dither_bits", 0))
|
|
||||||
o.pwm_lsb_nanoseconds = int(hardware.get("pwm_lsb_nanoseconds", 130))
|
|
||||||
o.led_rgb_sequence = hardware.get("led_rgb_sequence", "RGB")
|
|
||||||
o.scan_mode = int(hardware.get("scan_mode", 0))
|
|
||||||
o.row_address_type = int(hardware.get("row_address_type", 0))
|
|
||||||
o.multiplexing = int(hardware.get("multiplexing", 0))
|
|
||||||
o.gpio_slowdown = int(hardware.get("gpio_slowdown", 2))
|
|
||||||
o.limit_refresh_rate_hz = (
|
|
||||||
int(refresh_override) if refresh_override is not None
|
|
||||||
else int(hardware.get("limit_refresh_rate_hz", 0))
|
|
||||||
)
|
|
||||||
return o
|
|
||||||
|
|
||||||
|
|
||||||
def open_matrix(hardware, refresh_override=None):
|
|
||||||
"""Construct the matrix, or explain why it will not open."""
|
|
||||||
if os.geteuid() != 0:
|
|
||||||
sys.exit("this needs root for GPIO access - rerun with sudo")
|
|
||||||
try:
|
|
||||||
from rgbmatrix import RGBMatrix
|
|
||||||
except ImportError:
|
|
||||||
sys.exit("rgbmatrix is not installed on this machine")
|
|
||||||
try:
|
|
||||||
return RGBMatrix(options=build_options(hardware, refresh_override))
|
|
||||||
except Exception as exc: # pragma: no cover - hardware dependent
|
|
||||||
sys.exit(
|
|
||||||
"could not open the panel ({}).\n"
|
|
||||||
"If the display service is running it owns the GPIO - stop it first:\n"
|
|
||||||
" sudo systemctl stop ledmatrix".format(exc)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def measure_refresh(hardware, seconds=6.0):
|
|
||||||
"""Actual refresh rate, by running uncapped and timing the swaps.
|
|
||||||
|
|
||||||
SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop
|
|
||||||
runs at exactly the panel's rate. This is what an older Pi or a longer
|
|
||||||
chain will really give you, as opposed to whatever limit_refresh_rate_hz
|
|
||||||
optimistically asks for.
|
|
||||||
"""
|
|
||||||
matrix = open_matrix(hardware, refresh_override=0)
|
|
||||||
canvas = matrix.CreateFrameCanvas()
|
|
||||||
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
|
|
||||||
frames = 0
|
|
||||||
started = time.perf_counter()
|
|
||||||
while time.perf_counter() - started < seconds:
|
|
||||||
canvas = matrix.SwapOnVSync(canvas)
|
|
||||||
frames += 1
|
|
||||||
measured = frames / (time.perf_counter() - started)
|
|
||||||
matrix.Clear()
|
|
||||||
return measured
|
|
||||||
|
|
||||||
|
|
||||||
def demo(hardware, target, seconds):
|
|
||||||
"""Scroll text at the crisp speed nearest `target`."""
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
hz = float(hardware.get("limit_refresh_rate_hz") or scroll_config.DEFAULT_REFRESH_HZ)
|
|
||||||
choice = scroll_config.solve_crisp(target, hz)
|
|
||||||
print("asked for {:.0f} px/s -> {}".format(target, choice.describe()))
|
|
||||||
|
|
||||||
matrix = open_matrix(hardware)
|
|
||||||
canvas = matrix.CreateFrameCanvas()
|
|
||||||
W, H = canvas.width, canvas.height
|
|
||||||
|
|
||||||
font = None
|
|
||||||
for path, size in (
|
|
||||||
(str(Path(__file__).resolve().parent.parent / "assets/fonts/PressStart2P-Regular.ttf"), 16),
|
|
||||||
("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 26),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype(path, size)
|
|
||||||
break
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
if font is None:
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
|
|
||||||
text = " {:.0f} px/s *** THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG ***".format(
|
|
||||||
choice.pixels_per_second)
|
|
||||||
box = ImageDraw.Draw(Image.new("RGB", (8, 8))).textbbox((0, 0), text, font=font)
|
|
||||||
tw, th = box[2] - box[0], box[3] - box[1]
|
|
||||||
reps = max(2, (W * 3) // max(tw, 1) + 1)
|
|
||||||
strip = Image.new("RGB", (tw * reps, H), (0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(strip)
|
|
||||||
for i in range(reps):
|
|
||||||
draw.text((i * tw, (H - th) // 2 - box[1]), text, font=font, fill=(255, 210, 60))
|
|
||||||
|
|
||||||
offset = 0
|
|
||||||
frames = 0
|
|
||||||
started = time.time()
|
|
||||||
while time.time() - started < seconds:
|
|
||||||
window = strip.crop((offset, 0, offset + W, H))
|
|
||||||
if window.width < W:
|
|
||||||
whole = Image.new("RGB", (W, H), (0, 0, 0))
|
|
||||||
head = strip.crop((offset, 0, strip.width, H))
|
|
||||||
whole.paste(head, (0, 0))
|
|
||||||
whole.paste(strip.crop((0, 0, W - head.width, H)), (head.width, 0))
|
|
||||||
window = whole
|
|
||||||
canvas.SetImage(window)
|
|
||||||
canvas = matrix.SwapOnVSync(canvas, choice.frame_hold)
|
|
||||||
offset = (offset + choice.pixels_per_frame) % strip.width
|
|
||||||
frames += 1
|
|
||||||
elapsed = time.time() - started
|
|
||||||
print(" {} frames in {:.1f}s = {:.1f} fps = {:.1f} px/s actual".format(
|
|
||||||
frames, elapsed, frames / elapsed, frames * choice.pixels_per_frame / elapsed))
|
|
||||||
matrix.Clear()
|
|
||||||
|
|
||||||
|
|
||||||
def print_ladder(hz, highlight=None):
|
|
||||||
print("")
|
|
||||||
print("Whole-pixel scroll speeds at {:.1f}Hz refresh".format(hz))
|
|
||||||
print("(the panel refreshes at {:.0f}Hz for every one of these - holding a "
|
|
||||||
"frame costs no flicker)".format(hz))
|
|
||||||
print("")
|
|
||||||
for entry in scroll_config.crisp_ladder(hz):
|
|
||||||
if entry.pixels_per_second > hz * 3:
|
|
||||||
break
|
|
||||||
mark = " <-- nearest to {:.0f}".format(highlight) if (
|
|
||||||
highlight is not None
|
|
||||||
and entry.pixels_per_second == scroll_config.solve_crisp(highlight, hz).pixels_per_second
|
|
||||||
) else ""
|
|
||||||
print(" " + entry.describe() + mark)
|
|
||||||
print("")
|
|
||||||
print("Set one in config.json as pixels per second, e.g.")
|
|
||||||
print(' "display_options": {{"scroll_pixels_per_second": {:.0f}}}'.format(
|
|
||||||
scroll_config.solve_crisp(highlight if highlight else hz / 2, hz).pixels_per_second))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__,
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
||||||
ap.add_argument("--hz", type=float,
|
|
||||||
help="refresh rate to compute the ladder for (default: your config)")
|
|
||||||
ap.add_argument("--measure", action="store_true",
|
|
||||||
help="measure the panel's real refresh rate (needs root, service stopped)")
|
|
||||||
ap.add_argument("--demo", type=float, metavar="PXPS",
|
|
||||||
help="scroll text at the crisp speed nearest this (needs root)")
|
|
||||||
ap.add_argument("--seconds", type=float, default=15.0, help="demo duration")
|
|
||||||
ap.add_argument("--want", type=float, metavar="PXPS",
|
|
||||||
help="highlight the entry nearest this speed")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
hardware = load_hardware()
|
|
||||||
configured = float(hardware.get("limit_refresh_rate_hz") or 0)
|
|
||||||
|
|
||||||
if args.demo is not None:
|
|
||||||
demo(hardware, args.demo, args.seconds)
|
|
||||||
return
|
|
||||||
|
|
||||||
if args.measure:
|
|
||||||
measured = measure_refresh(hardware)
|
|
||||||
print("measured panel refresh: {:.1f}Hz".format(measured))
|
|
||||||
if configured:
|
|
||||||
print("configured limit_refresh_rate_hz: {:.0f}".format(configured))
|
|
||||||
if measured < configured * 0.95:
|
|
||||||
print(" -> the panel cannot reach the configured rate; the ladder")
|
|
||||||
print(" below uses what it actually manages")
|
|
||||||
print_ladder(measured, args.want)
|
|
||||||
return
|
|
||||||
|
|
||||||
hz = args.hz or configured or scroll_config.DEFAULT_REFRESH_HZ
|
|
||||||
if not args.hz and not configured:
|
|
||||||
print("no limit_refresh_rate_hz in config; assuming {:.0f}Hz".format(hz))
|
|
||||||
print("run with --measure to find your panel's real rate")
|
|
||||||
print_ladder(hz, args.want)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+1
-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.2.0"
|
__version__ = "3.3.0"
|
||||||
|
|
||||||
|
|||||||
@@ -478,8 +478,22 @@ class BackgroundDataService:
|
|||||||
cb(result)
|
cb(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in callback for request {request.id}: {e}")
|
logger.error(f"Error in callback for request {request.id}: {e}")
|
||||||
# Delivered. Drop both references -- they point at the same
|
|
||||||
# object, so one survivor keeps the whole payload resident.
|
# Released AFTER the loop, not inside it. Every callback here holds
|
||||||
|
# the same FetchResult, so releasing per-delivery handed the first
|
||||||
|
# one the data and every joiner `result.data is None` -- which is
|
||||||
|
# not a quiet degradation: they read `result.data.get('events')` and
|
||||||
|
# raise AttributeError, which this very loop catches and logs, so
|
||||||
|
# the symptom was one ERROR line and a manager that silently never
|
||||||
|
# got its schedule. Deduplication is the normal case, not a corner:
|
||||||
|
# a sport's recent, upcoming and live managers all ride one season
|
||||||
|
# fetch.
|
||||||
|
#
|
||||||
|
# Guarded on `callbacks`, because a request submitted without one
|
||||||
|
# has no other way to collect its payload than polling get_result().
|
||||||
|
# The old per-delivery release got that right by accident: an empty
|
||||||
|
# list never entered the loop body.
|
||||||
|
if callbacks:
|
||||||
self._release_payload(result)
|
self._release_payload(result)
|
||||||
request.result = None
|
request.result = None
|
||||||
|
|
||||||
@@ -489,8 +503,11 @@ class BackgroundDataService:
|
|||||||
def _release_payload(result: FetchResult) -> None:
|
def _release_payload(result: FetchResult) -> None:
|
||||||
"""Drop a delivered payload, keeping the result's status and timings.
|
"""Drop a delivered payload, keeping the result's status and timings.
|
||||||
|
|
||||||
Only called once a callback has been handed the data. Consumers read
|
Only called once EVERY callback has been handed the data -- callers
|
||||||
fetched data back from the cache under ``cache_key``; the copy carried
|
that joined an in-flight fetch share this object, so releasing between
|
||||||
|
deliveries strips the payload out from under the ones still queued.
|
||||||
|
Consumers read fetched data back from the cache under ``cache_key``;
|
||||||
|
the copy carried
|
||||||
here was pinning a parsed season schedule -- 946 games for NCAA
|
here was pinning a parsed season schedule -- 946 games for NCAA
|
||||||
football, roughly a tenth of total RAM on a 1GB Pi -- in memory until
|
football, roughly a tenth of total RAM on a 1GB Pi -- in memory until
|
||||||
the hourly sweep.
|
the hourly sweep.
|
||||||
|
|||||||
Vendored
+8
-52
@@ -14,11 +14,6 @@ import zlib
|
|||||||
from typing import Dict, Any, Optional, Protocol
|
from typing import Dict, Any, Optional, Protocol
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
try: # optional: large speedup on the cache write path, see _dumps below
|
|
||||||
import orjson
|
|
||||||
except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
|
||||||
orjson = None
|
|
||||||
|
|
||||||
# How old an abandoned write's temp file must be before the sweep removes it.
|
# How old an abandoned write's temp file must be before the sweep removes it.
|
||||||
# A real write holds its temp file for milliseconds, so an hour is far beyond
|
# A real write holds its temp file for milliseconds, so an hour is far beyond
|
||||||
# any in-flight write while still clearing the same day's debris. Deliberately
|
# any in-flight write while still clearing the same day's debris. Deliberately
|
||||||
@@ -45,52 +40,13 @@ class CacheStrategyProtocol(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class DateTimeEncoder(json.JSONEncoder):
|
class DateTimeEncoder(json.JSONEncoder):
|
||||||
"""JSON encoder that handles datetime objects.
|
"""JSON encoder that handles datetime objects."""
|
||||||
|
|
||||||
Retained for the stdlib fallback path and for any caller importing it.
|
|
||||||
"""
|
|
||||||
def default(self, obj: Any) -> Any:
|
def default(self, obj: Any) -> Any:
|
||||||
if isinstance(obj, datetime):
|
if isinstance(obj, datetime):
|
||||||
return obj.isoformat()
|
return obj.isoformat()
|
||||||
return super().default(obj)
|
return super().default(obj)
|
||||||
|
|
||||||
|
|
||||||
def _datetime_default(obj: Any) -> Any:
|
|
||||||
"""Serialise datetimes exactly as DateTimeEncoder did."""
|
|
||||||
if isinstance(obj, datetime):
|
|
||||||
return obj.isoformat()
|
|
||||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
||||||
|
|
||||||
|
|
||||||
if orjson is not None:
|
|
||||||
# Encoding the cache record dominated the background fetch worker: on a
|
|
||||||
# Pi 4, stdlib json.dumps runs ~12ms per MB and holds the GIL for all of
|
|
||||||
# it, which stalls the render thread mid-scroll. orjson measures ~7x
|
|
||||||
# faster on the same payloads (11.9ms -> 1.6ms for 985KB). Decoding gains
|
|
||||||
# far less (~1.3x on large payloads) because the cost there is building
|
|
||||||
# the Python objects, not scanning the text, but it is still free to take.
|
|
||||||
#
|
|
||||||
# OPT_NON_STR_KEYS: stdlib json coerces int/float dict keys to strings;
|
|
||||||
# orjson raises without this, and cache records do carry numeric keys.
|
|
||||||
# OPT_PASSTHROUGH_DATETIME: orjson would otherwise emit its own RFC 3339
|
|
||||||
# form for datetimes instead of calling default(). Routing them through
|
|
||||||
# _datetime_default keeps byte-for-byte parity with the records already
|
|
||||||
# on disk.
|
|
||||||
_DUMPS_OPTS = orjson.OPT_NON_STR_KEYS | orjson.OPT_PASSTHROUGH_DATETIME
|
|
||||||
|
|
||||||
def _dumps(data: Any) -> bytes:
|
|
||||||
return orjson.dumps(data, default=_datetime_default, option=_DUMPS_OPTS)
|
|
||||||
|
|
||||||
def _loads(raw: bytes) -> Any:
|
|
||||||
return orjson.loads(raw)
|
|
||||||
else:
|
|
||||||
def _dumps(data: Any) -> bytes:
|
|
||||||
return json.dumps(data, cls=DateTimeEncoder).encode("utf-8")
|
|
||||||
|
|
||||||
def _loads(raw: bytes) -> Any:
|
|
||||||
return json.loads(raw)
|
|
||||||
|
|
||||||
|
|
||||||
class DiskCache:
|
class DiskCache:
|
||||||
"""Manages persistent disk-based cache."""
|
"""Manages persistent disk-based cache."""
|
||||||
|
|
||||||
@@ -143,8 +99,8 @@ class DiskCache:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
with open(cache_path, 'rb') as f:
|
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||||
record = _loads(f.read())
|
record = json.load(f)
|
||||||
|
|
||||||
# Determine record timestamp (prefer embedded, else file mtime)
|
# Determine record timestamp (prefer embedded, else file mtime)
|
||||||
record_ts = None
|
record_ts = None
|
||||||
@@ -233,12 +189,12 @@ class DiskCache:
|
|||||||
# write path below, and cache files are machine-read only — indenting
|
# write path below, and cache files are machine-read only — indenting
|
||||||
# them just multiplied the bytes written to the SD card.
|
# them just multiplied the bytes written to the SD card.
|
||||||
try:
|
try:
|
||||||
payload = _dumps(data)
|
payload = json.dumps(data, cls=DateTimeEncoder)
|
||||||
except (TypeError, ValueError) as e:
|
except (TypeError, ValueError) as e:
|
||||||
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
digest = zlib.adler32(payload)
|
digest = zlib.adler32(payload.encode('utf-8'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Atomic write to avoid partial/corrupt files
|
# Atomic write to avoid partial/corrupt files
|
||||||
@@ -286,7 +242,7 @@ class DiskCache:
|
|||||||
# wear source (dozens of fsyncs/min on API-heavy
|
# wear source (dozens of fsyncs/min on API-heavy
|
||||||
# installs) for data that can be re-downloaded.
|
# installs) for data that can be re-downloaded.
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, 'wb') as tmp_file:
|
with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
|
||||||
tmp_file.write(payload)
|
tmp_file.write(payload)
|
||||||
os.replace(tmp_path, cache_path)
|
os.replace(tmp_path, cache_path)
|
||||||
self._write_digests[key] = digest
|
self._write_digests[key] = digest
|
||||||
@@ -304,7 +260,7 @@ class DiskCache:
|
|||||||
else:
|
else:
|
||||||
# Fallback: direct write (not atomic, but better than failing)
|
# Fallback: direct write (not atomic, but better than failing)
|
||||||
try:
|
try:
|
||||||
with open(cache_path, 'wb') as cache_file:
|
with open(cache_path, 'w', encoding='utf-8') as cache_file:
|
||||||
cache_file.write(payload)
|
cache_file.write(payload)
|
||||||
self._write_digests[key] = digest
|
self._write_digests[key] = digest
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
@@ -334,7 +290,7 @@ class DiskCache:
|
|||||||
# is a different path, so future sets must keep
|
# is a different path, so future sets must keep
|
||||||
# retrying the primary location.
|
# retrying the primary location.
|
||||||
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
||||||
with open(fallback_path, 'wb') as tmp_file:
|
with open(fallback_path, 'w', encoding='utf-8') as tmp_file:
|
||||||
tmp_file.write(payload)
|
tmp_file.write(payload)
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -23,13 +23,6 @@ from src.common.error_handler import (
|
|||||||
)
|
)
|
||||||
from src.common.api_helper import APIHelper
|
from src.common.api_helper import APIHelper
|
||||||
from src.common.scroll_helper import ScrollHelper
|
from src.common.scroll_helper import ScrollHelper
|
||||||
from src.common import scroll_config
|
|
||||||
from src.common.scroll_config import (
|
|
||||||
ScrollSettings,
|
|
||||||
configure as configure_scroll,
|
|
||||||
resolve as resolve_scroll_settings,
|
|
||||||
refresh_hz_from_config,
|
|
||||||
)
|
|
||||||
from src.common.logo_helper import LogoHelper
|
from src.common.logo_helper import LogoHelper
|
||||||
from src.common.text_helper import TextHelper
|
from src.common.text_helper import TextHelper
|
||||||
|
|
||||||
@@ -67,11 +60,6 @@ __all__ = [
|
|||||||
'log_and_raise',
|
'log_and_raise',
|
||||||
'APIHelper',
|
'APIHelper',
|
||||||
'ScrollHelper',
|
'ScrollHelper',
|
||||||
'scroll_config',
|
|
||||||
'ScrollSettings',
|
|
||||||
'configure_scroll',
|
|
||||||
'resolve_scroll_settings',
|
|
||||||
'refresh_hz_from_config',
|
|
||||||
'LogoHelper',
|
'LogoHelper',
|
||||||
'TextHelper',
|
'TextHelper',
|
||||||
# adaptive layout & images
|
# adaptive layout & images
|
||||||
|
|||||||
@@ -143,8 +143,10 @@ class LogoHelper:
|
|||||||
"""
|
"""
|
||||||
logo_path = Path(logo_path)
|
logo_path = Path(logo_path)
|
||||||
|
|
||||||
# Try to load existing logo first
|
# Try to load existing logo first. A placeholder written by a previous
|
||||||
if logo_path.exists():
|
# failed download does not count: it wears the real logo's filename, so
|
||||||
|
# trusting the file's existence is what left teams as grey boxes.
|
||||||
|
if logo_path.exists() and not self._is_stale_placeholder(logo_path):
|
||||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
||||||
|
|
||||||
# Download if URL provided and file doesn't exist
|
# Download if URL provided and file doesn't exist
|
||||||
@@ -152,13 +154,61 @@ class LogoHelper:
|
|||||||
try:
|
try:
|
||||||
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
||||||
self._download_logo(logo_url, logo_path)
|
self._download_logo(logo_url, logo_path)
|
||||||
|
# The file on disk just changed. Any cached image for it is the
|
||||||
|
# placeholder we came here to replace, and load_logo() answers
|
||||||
|
# from the cache before touching the disk -- so without this the
|
||||||
|
# real logo would not appear until the process restarted.
|
||||||
|
self._invalidate_cached_logo(team_abbr, logo_path)
|
||||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
||||||
|
# The retry failed, so restart the back-off. The stale
|
||||||
|
# placeholder is still on disk with its old timestamp, and
|
||||||
|
# leaving it there means the next call retries immediately --
|
||||||
|
# a download attempt per call, which is what the back-off
|
||||||
|
# exists to prevent.
|
||||||
|
self._refresh_stale_placeholder(logo_path)
|
||||||
|
|
||||||
# Create placeholder if all else fails
|
# Create placeholder if all else fails
|
||||||
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
||||||
|
|
||||||
|
def _invalidate_cached_logo(self, team_abbr: str, logo_path: Path) -> None:
|
||||||
|
"""Drop every cached size of one logo after its file changed on disk."""
|
||||||
|
prefix = f"{team_abbr}_{logo_path}_"
|
||||||
|
for key in [k for k in self._logo_cache if k.startswith(prefix)]:
|
||||||
|
self._logo_cache.pop(key, None)
|
||||||
|
if key in self._cache_order:
|
||||||
|
self._cache_order.remove(key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _refresh_stale_placeholder(logo_path: Path) -> None:
|
||||||
|
"""Restart the retry back-off after a failed download attempt."""
|
||||||
|
try:
|
||||||
|
from src.logo_downloader import refresh_placeholder_timestamp
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
refresh_placeholder_timestamp(logo_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_stale_placeholder(logo_path: Path) -> bool:
|
||||||
|
"""True if the file is a placeholder old enough to be worth retrying.
|
||||||
|
|
||||||
|
Imported lazily so this module keeps working against a core build whose
|
||||||
|
logo_downloader predates placeholder marking.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.logo_downloader import (
|
||||||
|
PLACEHOLDER_RETRY_SECONDS,
|
||||||
|
is_placeholder_logo,
|
||||||
|
placeholder_age_seconds,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
if not is_placeholder_logo(logo_path):
|
||||||
|
return False
|
||||||
|
age = placeholder_age_seconds(logo_path)
|
||||||
|
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
|
||||||
|
|
||||||
def get_logo_variations(self, team_abbr: str) -> List[str]:
|
def get_logo_variations(self, team_abbr: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get possible filename variations for a team abbreviation.
|
Get possible filename variations for a team abbreviation.
|
||||||
|
|||||||
@@ -1,421 +0,0 @@
|
|||||||
"""One place that turns plugin config into a configured ScrollHelper.
|
|
||||||
|
|
||||||
Five ticker plugins each hand-rolled this resolution (odds-ticker, news and
|
|
||||||
ledmatrix-leaderboard reference the deprecated ``scroll_pixels_per_second``
|
|
||||||
key 16-18 times apiece), and they disagreed in ways that were invisible until
|
|
||||||
someone watched the panel:
|
|
||||||
|
|
||||||
* odds-ticker read ``scroll_pixels_per_second`` on the *recommended* config
|
|
||||||
path and let it override ``scroll_speed``/``scroll_delay``. Because that key
|
|
||||||
carries a schema default, the documented settings were dead for every user
|
|
||||||
-- see ChuckBuilds/ledmatrix-plugins#408.
|
|
||||||
* ledmatrix-leaderboard read the same key only as a fallback, so identical
|
|
||||||
config produced different speeds in the two plugins.
|
|
||||||
* stock-news derived px/frame from it via its own arithmetic.
|
|
||||||
|
|
||||||
What matters on the hardware
|
|
||||||
----------------------------
|
|
||||||
Motion is smooth when the strip advances a **whole number of pixels per panel
|
|
||||||
refresh**. On a 100Hz panel that means 100 px/s, 200 px/s, and so on. Anything
|
|
||||||
else has to either blend adjacent columns (which on pixel-font text reads as
|
|
||||||
shimmer) or repeat frames (which reads as judder). :func:`resolve` warns when
|
|
||||||
the requested speed will not divide evenly, because that is a real display
|
|
||||||
artefact and not a rounding detail.
|
|
||||||
|
|
||||||
Speed is always expressed to the helper as pixels per second and applied in
|
|
||||||
time-based mode. Frame-based stepping gates motion on a wall clock at
|
|
||||||
``1/scroll_delay`` steps per second; plugins set ``scroll_delay`` to the frame
|
|
||||||
period, which puts that comparison exactly on its own threshold and makes the
|
|
||||||
step count flip on sub-millisecond jitter. Accumulating elapsed time keeps
|
|
||||||
position proportional to real time instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass, replace
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
#: Speed used when a plugin supplies nothing usable. One pixel per refresh on a
|
|
||||||
#: 100Hz panel, which is the slowest crisp scroll that hardware can show.
|
|
||||||
DEFAULT_PIXELS_PER_SECOND = 100.0
|
|
||||||
|
|
||||||
#: Bounds accepted from config. Below the floor a marquee appears frozen;
|
|
||||||
#: above the ceiling it outruns any panel's refresh and tears.
|
|
||||||
MIN_PIXELS_PER_SECOND = 1.0
|
|
||||||
MAX_PIXELS_PER_SECOND = 500.0
|
|
||||||
|
|
||||||
#: Assumed refresh when the caller does not say. Matches the usual
|
|
||||||
#: ``display.hardware.limit_refresh_rate_hz``.
|
|
||||||
DEFAULT_REFRESH_HZ = 100.0
|
|
||||||
|
|
||||||
#: How far px/s may sit from a whole number of pixels per refresh before it is
|
|
||||||
#: worth warning about. 0.05px per frame is invisible; a third of a pixel is not.
|
|
||||||
_WHOLE_PIXEL_TOLERANCE = 0.05
|
|
||||||
|
|
||||||
|
|
||||||
#: Longest a frame may be held before motion reads as a slideshow rather than
|
|
||||||
#: a scroll. 6 refreshes at 100Hz is ~17px/s, already visibly stepped.
|
|
||||||
MAX_FRAME_HOLD = 8
|
|
||||||
|
|
||||||
#: Largest whole-pixel jump per presented frame before motion looks like it is
|
|
||||||
#: teleporting rather than sliding.
|
|
||||||
MAX_PIXELS_PER_FRAME = 6
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CrispSpeed:
|
|
||||||
"""A speed the panel can show with whole-pixel motion.
|
|
||||||
|
|
||||||
``pixels_per_second`` is always ``refresh_hz / frame_hold * pixels_per_frame``
|
|
||||||
exactly -- no rounding, no fractional pixel positions, so nothing has to be
|
|
||||||
blended or repeated unevenly.
|
|
||||||
|
|
||||||
:param frame_hold: refreshes each frame is held for. This is rgbmatrix's
|
|
||||||
``SwapOnVSync(canvas, framerate_fraction)``. The panel keeps refreshing
|
|
||||||
at full rate either way, so holding a frame costs nothing in flicker.
|
|
||||||
:param pixels_per_frame: whole pixels advanced per presented frame.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pixels_per_second: float
|
|
||||||
frame_hold: int
|
|
||||||
pixels_per_frame: int
|
|
||||||
refresh_hz: float
|
|
||||||
|
|
||||||
@property
|
|
||||||
def frames_per_second(self) -> float:
|
|
||||||
return self.refresh_hz / self.frame_hold
|
|
||||||
|
|
||||||
@property
|
|
||||||
def steppiness(self) -> str:
|
|
||||||
"""Rough readability hint for this combination."""
|
|
||||||
if self.pixels_per_frame > 2:
|
|
||||||
return "jumpy"
|
|
||||||
if self.frames_per_second < 20:
|
|
||||||
return "stepped"
|
|
||||||
if self.frames_per_second < 30:
|
|
||||||
return "slightly stepped"
|
|
||||||
return "smooth"
|
|
||||||
|
|
||||||
def describe(self) -> str:
|
|
||||||
return (
|
|
||||||
f"{self.pixels_per_second:6.1f} px/s "
|
|
||||||
f"({self.pixels_per_frame}px every {self.frame_hold} refresh"
|
|
||||||
f"{'es' if self.frame_hold != 1 else ' '} = "
|
|
||||||
f"{self.frames_per_second:5.1f} fps, {self.steppiness})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def crisp_ladder(
|
|
||||||
refresh_hz: float = DEFAULT_REFRESH_HZ,
|
|
||||||
max_frame_hold: int = MAX_FRAME_HOLD,
|
|
||||||
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
|
|
||||||
):
|
|
||||||
"""Every whole-pixel speed this panel can show, slowest first.
|
|
||||||
|
|
||||||
Duplicates are collapsed keeping the gentlest option: 100 px/s is reachable
|
|
||||||
as 1px every refresh or 2px every 2nd refresh, and the former moves in
|
|
||||||
smaller increments, so that is the one worth offering.
|
|
||||||
"""
|
|
||||||
best = {}
|
|
||||||
for hold in range(1, max_frame_hold + 1):
|
|
||||||
for ppf in range(1, max_pixels_per_frame + 1):
|
|
||||||
pps = refresh_hz / hold * ppf
|
|
||||||
key = round(pps, 3)
|
|
||||||
candidate = CrispSpeed(pps, hold, ppf, refresh_hz)
|
|
||||||
incumbent = best.get(key)
|
|
||||||
if incumbent is None or ppf < incumbent.pixels_per_frame:
|
|
||||||
best[key] = candidate
|
|
||||||
return [best[k] for k in sorted(best)]
|
|
||||||
|
|
||||||
|
|
||||||
#: How much a bigger pixel step costs, as a fraction of the target speed.
|
|
||||||
#: Tuned so 66.7px/s (2px at 33fps) beats 50px/s (1px at 50fps) when 60 was
|
|
||||||
#: asked for, but 33.3px/s (1px, smooth) still beats 28.6px/s (2px at 14fps)
|
|
||||||
#: when 30 was asked for -- being 11% slow is worth far less than looking bad.
|
|
||||||
_STEP_PENALTY = 0.05
|
|
||||||
_SLOW_FPS_PENALTY = 0.25 # below 20fps
|
|
||||||
_LOWISH_FPS_PENALTY = 0.10 # below 25fps
|
|
||||||
|
|
||||||
|
|
||||||
def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
|
|
||||||
"""Lower is better. Numeric closeness alone picks bad-looking speeds.
|
|
||||||
|
|
||||||
Nearest-by-value would answer "30 px/s" with 28.6 px/s -- which is 2px
|
|
||||||
jumps at 14fps -- over 33.3 px/s, which is single-pixel motion at 33fps and
|
|
||||||
obviously better on the panel. Proximity has to be traded against how the
|
|
||||||
motion actually reads.
|
|
||||||
"""
|
|
||||||
error = abs(candidate.pixels_per_second - target) / max(target, 1e-6)
|
|
||||||
cost = error + _STEP_PENALTY * (candidate.pixels_per_frame - 1)
|
|
||||||
fps = candidate.frames_per_second
|
|
||||||
if fps < 20:
|
|
||||||
cost += _SLOW_FPS_PENALTY
|
|
||||||
elif fps < 25:
|
|
||||||
cost += _LOWISH_FPS_PENALTY
|
|
||||||
return cost
|
|
||||||
|
|
||||||
|
|
||||||
def solve_crisp(
|
|
||||||
target_pixels_per_second: float,
|
|
||||||
refresh_hz: float = DEFAULT_REFRESH_HZ,
|
|
||||||
max_frame_hold: int = MAX_FRAME_HOLD,
|
|
||||||
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
|
|
||||||
) -> CrispSpeed:
|
|
||||||
"""The whole-pixel speed that will look best for what was asked for.
|
|
||||||
|
|
||||||
Not simply the nearest -- see :func:`_quality_cost`. Ties break toward the
|
|
||||||
smaller pixel step and the shorter hold.
|
|
||||||
"""
|
|
||||||
ladder = crisp_ladder(refresh_hz, max_frame_hold, max_pixels_per_frame)
|
|
||||||
# Clamp into the ladder's range first. Relative error saturates near 1.0
|
|
||||||
# for a target far outside it, so the quality penalty would dominate and
|
|
||||||
# answer "10000 px/s" with the *slowest* entry -- smooth, and useless.
|
|
||||||
target = min(max(target_pixels_per_second, ladder[0].pixels_per_second),
|
|
||||||
ladder[-1].pixels_per_second)
|
|
||||||
return min(
|
|
||||||
ladder,
|
|
||||||
key=lambda c: (round(_quality_cost(c, target), 6),
|
|
||||||
c.pixels_per_frame, c.frame_hold),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ScrollSettings:
|
|
||||||
"""The resolved outcome, and which config key produced it."""
|
|
||||||
|
|
||||||
pixels_per_second: float
|
|
||||||
source: str
|
|
||||||
target_fps: Optional[float] = None
|
|
||||||
pixels_per_frame: Optional[float] = None
|
|
||||||
warning: Optional[str] = None
|
|
||||||
#: The whole-pixel speed actually applied, when snapping was enabled.
|
|
||||||
crisp: Optional[CrispSpeed] = None
|
|
||||||
#: What the config asked for, before snapping.
|
|
||||||
requested_pixels_per_second: Optional[float] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def frame_hold(self) -> int:
|
|
||||||
"""Refreshes to hold each frame for; pass to set_scrolling_state()."""
|
|
||||||
return self.crisp.frame_hold if self.crisp else 1
|
|
||||||
|
|
||||||
def describe(self) -> str:
|
|
||||||
text = f"{self.pixels_per_second:.1f} px/s (from {self.source})"
|
|
||||||
if self.pixels_per_frame is not None:
|
|
||||||
text += f" = {self.pixels_per_frame:.2f} px/frame"
|
|
||||||
if self.target_fps:
|
|
||||||
text += f" at {self.target_fps:.0f} fps"
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce(value: Any) -> Optional[float]:
|
|
||||||
"""A positive float, or None. Config reaches us with nulls and strings."""
|
|
||||||
if value is None or isinstance(value, bool):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
number = float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
return number if number > 0 else None
|
|
||||||
|
|
||||||
|
|
||||||
def _from_speed_and_delay(block: Any) -> Optional[float]:
|
|
||||||
"""px/s from a ``scroll_speed`` (px/frame) + ``scroll_delay`` (s) pair."""
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
return None
|
|
||||||
speed = _coerce(block.get("scroll_speed"))
|
|
||||||
delay = _coerce(block.get("scroll_delay"))
|
|
||||||
if speed is None or delay is None:
|
|
||||||
return None
|
|
||||||
return speed / delay
|
|
||||||
|
|
||||||
|
|
||||||
def resolve(
|
|
||||||
plugin_config: Optional[Dict[str, Any]] = None,
|
|
||||||
global_config: Optional[Dict[str, Any]] = None,
|
|
||||||
default_pixels_per_second: float = DEFAULT_PIXELS_PER_SECOND,
|
|
||||||
refresh_hz: Optional[float] = None,
|
|
||||||
) -> ScrollSettings:
|
|
||||||
"""Resolve one scroll speed from the several shapes plugins accept.
|
|
||||||
|
|
||||||
Precedence, highest first. The deprecated flat key sits *below* the
|
|
||||||
explicit pairs deliberately: it carries schema defaults in some plugins, so
|
|
||||||
ranking it above them silently disables the documented settings.
|
|
||||||
|
|
||||||
1. ``display_options.scroll_speed`` + ``scroll_delay`` (current)
|
|
||||||
2. ``display.scroll_speed`` + ``scroll_delay`` (deprecated shape)
|
|
||||||
3. ``scroll_speed`` + ``scroll_delay`` at the root (legacy flat)
|
|
||||||
4. ``scroll_pixels_per_second``, nested or flat (deprecated)
|
|
||||||
5. the global ``display`` block
|
|
||||||
6. ``default_pixels_per_second``
|
|
||||||
|
|
||||||
:param refresh_hz: panel refresh, used only to check whether the resolved
|
|
||||||
speed lands on whole pixels per frame and to fill in ``target_fps``.
|
|
||||||
"""
|
|
||||||
plugin_config = plugin_config or {}
|
|
||||||
global_config = global_config or {}
|
|
||||||
refresh = _coerce(refresh_hz) or DEFAULT_REFRESH_HZ
|
|
||||||
|
|
||||||
display_options = plugin_config.get("display_options")
|
|
||||||
display_block = plugin_config.get("display")
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
(_from_speed_and_delay(display_options), "display_options.scroll_speed/delay"),
|
|
||||||
(_from_speed_and_delay(display_block), "display.scroll_speed/delay"),
|
|
||||||
(_from_speed_and_delay(plugin_config), "scroll_speed/delay (root)"),
|
|
||||||
]
|
|
||||||
for block, label in (
|
|
||||||
(display_options, "display_options.scroll_pixels_per_second"),
|
|
||||||
(display_block, "display.scroll_pixels_per_second"),
|
|
||||||
(plugin_config, "scroll_pixels_per_second"),
|
|
||||||
):
|
|
||||||
if isinstance(block, dict):
|
|
||||||
candidates.append((_coerce(block.get("scroll_pixels_per_second")), label))
|
|
||||||
|
|
||||||
global_display = global_config.get("display")
|
|
||||||
candidates.append((_from_speed_and_delay(global_display), "global display.scroll_speed/delay"))
|
|
||||||
|
|
||||||
pixels_per_second = None
|
|
||||||
source = "default"
|
|
||||||
for value, label in candidates:
|
|
||||||
if value is not None:
|
|
||||||
pixels_per_second, source = value, label
|
|
||||||
break
|
|
||||||
if pixels_per_second is None:
|
|
||||||
pixels_per_second = default_pixels_per_second
|
|
||||||
|
|
||||||
clamped = max(MIN_PIXELS_PER_SECOND, min(MAX_PIXELS_PER_SECOND, pixels_per_second))
|
|
||||||
warning = None
|
|
||||||
if clamped != pixels_per_second:
|
|
||||||
warning = (
|
|
||||||
f"scroll speed {pixels_per_second:.1f} px/s out of range, "
|
|
||||||
f"clamped to {clamped:.1f}"
|
|
||||||
)
|
|
||||||
pixels_per_second = clamped
|
|
||||||
|
|
||||||
pixels_per_frame = pixels_per_second / refresh if refresh > 0 else None
|
|
||||||
if warning is None and pixels_per_frame is not None:
|
|
||||||
offset = abs(pixels_per_frame - round(pixels_per_frame))
|
|
||||||
if pixels_per_frame < 1.0 - _WHOLE_PIXEL_TOLERANCE or offset > _WHOLE_PIXEL_TOLERANCE:
|
|
||||||
suggestion = max(1.0, round(pixels_per_frame)) * refresh
|
|
||||||
warning = (
|
|
||||||
f"{pixels_per_second:.1f} px/s is {pixels_per_frame:.2f} px per "
|
|
||||||
f"refresh at {refresh:.0f}Hz, so some frames repeat and the "
|
|
||||||
f"scroll will judder; {suggestion:.0f} px/s divides evenly"
|
|
||||||
)
|
|
||||||
|
|
||||||
return ScrollSettings(
|
|
||||||
pixels_per_second=pixels_per_second,
|
|
||||||
source=source,
|
|
||||||
target_fps=refresh,
|
|
||||||
pixels_per_frame=pixels_per_frame,
|
|
||||||
warning=warning,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def configure(
|
|
||||||
scroll_helper: Any,
|
|
||||||
plugin_config: Optional[Dict[str, Any]] = None,
|
|
||||||
global_config: Optional[Dict[str, Any]] = None,
|
|
||||||
default_pixels_per_second: float = DEFAULT_PIXELS_PER_SECOND,
|
|
||||||
refresh_hz: Optional[float] = None,
|
|
||||||
plugin_logger: Optional[logging.Logger] = None,
|
|
||||||
display_manager: Any = None,
|
|
||||||
snap_to_crisp: bool = True,
|
|
||||||
) -> ScrollSettings:
|
|
||||||
"""Resolve the config and apply it to ``scroll_helper``.
|
|
||||||
|
|
||||||
Applied in time-based mode: see the module docstring for why frame-based
|
|
||||||
stepping is not used. ``hasattr`` guards keep this usable against older
|
|
||||||
ScrollHelper builds that a plugin may be running on.
|
|
||||||
|
|
||||||
:param display_manager: when given, the frame hold for the chosen speed is
|
|
||||||
applied to it. Without this a sub-refresh speed still resolves, but the
|
|
||||||
panel keeps presenting a new frame every refresh, so the motion falls
|
|
||||||
back to fractional pixels and judders -- the hold is what makes slow
|
|
||||||
speeds crisp.
|
|
||||||
:param snap_to_crisp: move the requested speed to the nearest speed the
|
|
||||||
panel can show in whole pixels. On by default because a speed that does
|
|
||||||
not divide evenly has no good rendering, only a choice of artefacts.
|
|
||||||
|
|
||||||
:returns: the settings applied, so the caller can log or assert on them.
|
|
||||||
"""
|
|
||||||
log = plugin_logger or logger
|
|
||||||
settings = resolve(
|
|
||||||
plugin_config,
|
|
||||||
global_config,
|
|
||||||
default_pixels_per_second=default_pixels_per_second,
|
|
||||||
refresh_hz=refresh_hz,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Refresh rate, most authoritative first: what the caller passed, then the
|
|
||||||
# display manager (which can see display.hardware; a plugin cannot), then
|
|
||||||
# whatever resolve() inferred, then the default.
|
|
||||||
hz = _coerce(refresh_hz)
|
|
||||||
if hz is None and display_manager is not None:
|
|
||||||
hz = _coerce(getattr(display_manager, "refresh_hz", None))
|
|
||||||
hz = hz or settings.target_fps or DEFAULT_REFRESH_HZ
|
|
||||||
applied = settings.pixels_per_second
|
|
||||||
choice = None
|
|
||||||
|
|
||||||
if snap_to_crisp:
|
|
||||||
choice = solve_crisp(settings.pixels_per_second, hz)
|
|
||||||
applied = choice.pixels_per_second
|
|
||||||
settings = replace(
|
|
||||||
settings,
|
|
||||||
pixels_per_second=applied,
|
|
||||||
requested_pixels_per_second=settings.pixels_per_second,
|
|
||||||
crisp=choice,
|
|
||||||
pixels_per_frame=float(choice.pixels_per_frame),
|
|
||||||
# Snapping resolves the whole-pixel problem the warning describes.
|
|
||||||
warning=None if settings.warning and "judder" in settings.warning
|
|
||||||
else settings.warning,
|
|
||||||
)
|
|
||||||
|
|
||||||
if hasattr(scroll_helper, "set_frame_based_scrolling"):
|
|
||||||
scroll_helper.set_frame_based_scrolling(False)
|
|
||||||
scroll_helper.set_scroll_speed(applied)
|
|
||||||
if choice and hasattr(scroll_helper, "set_target_fps"):
|
|
||||||
scroll_helper.set_target_fps(choice.frames_per_second)
|
|
||||||
elif settings.target_fps and hasattr(scroll_helper, "set_target_fps"):
|
|
||||||
scroll_helper.set_target_fps(settings.target_fps)
|
|
||||||
|
|
||||||
# Deliberately NOT applied here. The hold belongs to a scroll, not to a
|
|
||||||
# plugin's lifetime: plugins share one display manager, and one left set at
|
|
||||||
# construction is reset the moment any other plugin finishes scrolling.
|
|
||||||
# Callers pass settings.frame_hold to set_scrolling_state(True, ...) when
|
|
||||||
# they start scrolling. configure() only reports what is needed.
|
|
||||||
|
|
||||||
if choice:
|
|
||||||
requested = settings.requested_pixels_per_second
|
|
||||||
if abs(requested - applied) > 0.05:
|
|
||||||
log.info(
|
|
||||||
"Scroll configured: %s (asked for %.1f px/s from %s; "
|
|
||||||
"nearest whole-pixel speed on a %.0fHz panel)",
|
|
||||||
choice.describe(), requested, settings.source, hz,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.info("Scroll configured: %s (from %s)",
|
|
||||||
choice.describe(), settings.source)
|
|
||||||
if choice.frame_hold > 1:
|
|
||||||
log.debug(
|
|
||||||
"Scroll needs a frame hold of %d - pass settings.frame_hold to "
|
|
||||||
"display_manager.set_scrolling_state(True, ...) each scroll",
|
|
||||||
choice.frame_hold,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.info("Scroll configured: %s", settings.describe())
|
|
||||||
|
|
||||||
if settings.warning:
|
|
||||||
log.warning("Scroll speed: %s", settings.warning)
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_hz_from_config(global_config: Optional[Dict[str, Any]]) -> float:
|
|
||||||
"""The panel's refresh cap from the global config, or the default."""
|
|
||||||
if not isinstance(global_config, dict):
|
|
||||||
return DEFAULT_REFRESH_HZ
|
|
||||||
hardware = (global_config.get("display") or {}).get("hardware") or {}
|
|
||||||
return _coerce(hardware.get("limit_refresh_rate_hz")) or DEFAULT_REFRESH_HZ
|
|
||||||
+21
-71
@@ -75,19 +75,8 @@ class ScrollHelper:
|
|||||||
# Pre-allocated buffer for output frame (reused to avoid allocations)
|
# Pre-allocated buffer for output frame (reused to avoid allocations)
|
||||||
self._frame_buffer: Optional[np.ndarray] = None
|
self._frame_buffer: Optional[np.ndarray] = None
|
||||||
|
|
||||||
# Sub-pixel scrolling: OFF by default, and that is deliberate.
|
# Sub-pixel scrolling settings (disabled - using high FPS integer scrolling instead)
|
||||||
# Blending renders a half-step by mixing two adjacent columns 50/50.
|
self.sub_pixel_scrolling = False # Disabled - use high frame rate for smoothness
|
||||||
# On a high-resolution screen that reads as smooth motion; on a coarse
|
|
||||||
# LED matrix showing pixel-font text it does not. A one-pixel stroke
|
|
||||||
# becomes two half-brightness pixels, so frames alternate between crisp
|
|
||||||
# and smeared and the text appears to shimmer and jump a pixel ahead --
|
|
||||||
# tested on a 2x128x64 panel and clearly worse than integer stepping.
|
|
||||||
#
|
|
||||||
# The rule this display obeys: motion is smooth when it advances a
|
|
||||||
# whole number of pixels per refresh. Anything slower must either
|
|
||||||
# blend (blur) or repeat frames (judder); blending is the worse of the
|
|
||||||
# two here. Vegas mode still opts in via set_sub_pixel_scrolling().
|
|
||||||
self.sub_pixel_scrolling = False
|
|
||||||
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
|
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
|
||||||
|
|
||||||
# Frame-based scrolling settings
|
# Frame-based scrolling settings
|
||||||
@@ -116,9 +105,6 @@ class ScrollHelper:
|
|||||||
self.last_frame_time = time.time()
|
self.last_frame_time = time.time()
|
||||||
self.last_fps_log_time = time.time()
|
self.last_fps_log_time = time.time()
|
||||||
self.frame_times = []
|
self.frame_times = []
|
||||||
# Every frame time since the last stats line, so the 5s summary can
|
|
||||||
# report the tail rather than one arbitrary sample. Cleared on log.
|
|
||||||
self._window: list = []
|
|
||||||
|
|
||||||
# Scrolling state management
|
# Scrolling state management
|
||||||
self.is_scrolling = False
|
self.is_scrolling = False
|
||||||
@@ -258,31 +244,19 @@ class ScrollHelper:
|
|||||||
if self.last_step_time == 0.0:
|
if self.last_step_time == 0.0:
|
||||||
self.last_step_time = current_time
|
self.last_step_time = current_time
|
||||||
|
|
||||||
# Frame-based mode advances by elapsed time, exactly like the
|
# Check if scroll_delay has passed
|
||||||
# time-based branch below, at the same configured speed
|
time_since_last_step = current_time - self.last_step_time
|
||||||
# (scroll_speed px per scroll_delay seconds).
|
if time_since_last_step >= self.scroll_delay:
|
||||||
#
|
# Move pixels (can move multiple steps if lag occurred, but cap to prevent huge jumps)
|
||||||
# It used to step discretely: 0, 1 or 2 whole pixels depending on
|
steps = int(time_since_last_step / self.scroll_delay)
|
||||||
# whether a wall clock had passed scroll_delay. Plugins set
|
# Cap at reasonable number to prevent huge jumps from lag
|
||||||
# scroll_delay to the target frame period, so that comparison sits
|
max_steps = max(1, int(0.04 / self.scroll_delay)) # Limit to 0.04s (2 steps at 50 FPS) for smoother scrolling
|
||||||
# exactly on its own threshold and the decision flips on sub-
|
steps = min(steps, max_steps)
|
||||||
# millisecond jitter -- a frame a hair early moved nothing and
|
pixels_to_move = self.scroll_speed * steps
|
||||||
# rendered an identical frame, a frame a hair late moved two
|
# Update last_step_time, preserving fractional delay for smooth timing
|
||||||
# pixels. Rounding the step count fixed the stalls but still
|
self.last_step_time = current_time - (time_since_last_step % self.scroll_delay)
|
||||||
# discarded the remainder, so the error never corrected.
|
|
||||||
#
|
|
||||||
# Accumulating elapsed time keeps position exactly proportional to
|
|
||||||
# real time: jitter shifts a pixel boundary by a fraction of a
|
|
||||||
# frame instead of flipping a whole step, and nothing is lost or
|
|
||||||
# gained. This is what the one visibly smooth scroller on the
|
|
||||||
# hardware (the stock ticker) was already doing by virtue of never
|
|
||||||
# enabling frame-based mode.
|
|
||||||
if self.scroll_delay > 0:
|
|
||||||
pixels_per_second = self.scroll_speed / self.scroll_delay
|
|
||||||
else:
|
else:
|
||||||
pixels_per_second = self.scroll_speed * 100.0
|
pixels_to_move = 0.0
|
||||||
pixels_to_move = pixels_per_second * delta_time
|
|
||||||
self.last_step_time = current_time
|
|
||||||
else:
|
else:
|
||||||
# Time-based: move based on time delta (correct speed over time)
|
# Time-based: move based on time delta (correct speed over time)
|
||||||
# scroll_speed is pixels per second
|
# scroll_speed is pixels per second
|
||||||
@@ -1043,42 +1017,18 @@ class ScrollHelper:
|
|||||||
# Keep only last 100 frames for average
|
# Keep only last 100 frames for average
|
||||||
if len(self.frame_times) > 100:
|
if len(self.frame_times) > 100:
|
||||||
self.frame_times.pop(0)
|
self.frame_times.pop(0)
|
||||||
|
|
||||||
# Every frame since the last log, not just the last 100 and not just
|
|
||||||
# the one that happens to land on the 5s boundary. The old line
|
|
||||||
# reported a single instantaneous sample -- roughly 1 frame in 500 --
|
|
||||||
# which cannot see a stall that hits 1% of frames, and reported it
|
|
||||||
# next to an average that hides the same stall by construction (a 2ms
|
|
||||||
# duplicate and a 21ms double-wait mean exactly 10ms). Chasing scroll
|
|
||||||
# judder needs the tail, so keep the window and report percentiles.
|
|
||||||
self._window.append(frame_time)
|
|
||||||
|
|
||||||
# Log FPS every 5 seconds to avoid spam
|
# Log FPS every 5 seconds to avoid spam
|
||||||
if current_time - self.last_fps_log_time >= 5.0:
|
if current_time - self.last_fps_log_time >= 5.0:
|
||||||
window = sorted(self._window) if self._window else [frame_time]
|
avg_frame_time = sum(self.frame_times) / len(self.frame_times)
|
||||||
n = len(window)
|
avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0
|
||||||
median = window[n // 2]
|
instant_fps = 1.0 / frame_time if frame_time > 0 else 0
|
||||||
p95 = window[min(n - 1, int(n * 0.95))]
|
|
||||||
worst = window[-1]
|
self.logger.info(f"Scroll frame stats - Avg FPS: {avg_fps:.1f}, "
|
||||||
best = window[0]
|
f"Current FPS: {instant_fps:.1f}, "
|
||||||
mean = sum(window) / n
|
f"Frame time: {frame_time*1000:.2f}ms")
|
||||||
# Anything past 1.5x the median missed a panel refresh; anything
|
|
||||||
# under half of it never reached the panel at all (dirty tracking
|
|
||||||
# skipped the swap, so the frame did not wait for vsync).
|
|
||||||
stalls = sum(1 for f in window if f > median * 1.5)
|
|
||||||
skips = sum(1 for f in window if f < median * 0.5)
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
"Scroll frame stats - %.1f fps over %d frames | "
|
|
||||||
"median %.2fms p95 %.2fms max %.2fms min %.2fms | "
|
|
||||||
"stalls %d (%.1f%%) skips %d (%.1f%%)",
|
|
||||||
(1.0 / mean) if mean > 0 else 0.0, n,
|
|
||||||
median * 1000, p95 * 1000, worst * 1000, best * 1000,
|
|
||||||
stalls, 100.0 * stalls / n, skips, 100.0 * skips / n,
|
|
||||||
)
|
|
||||||
self.last_fps_log_time = current_time
|
self.last_fps_log_time = current_time
|
||||||
self.frame_count = 0
|
self.frame_count = 0
|
||||||
self._window = []
|
|
||||||
|
|
||||||
self.last_frame_time = current_time
|
self.last_frame_time = current_time
|
||||||
self.frame_count += 1
|
self.frame_count += 1
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"""Card-drawing helpers shared by every sports scoreboard plugin.
|
||||||
|
|
||||||
|
The eight scoreboards each carried byte-identical copies of the functions
|
||||||
|
below: the colour pickers, the settings lookup, the date and time formatting,
|
||||||
|
the favourite-team rules and the font-size grid snapping. One fix had to be
|
||||||
|
made eight times, and a new scoreboard started by copying them a ninth.
|
||||||
|
|
||||||
|
Everything here is a **free function taking explicit arguments**, not a base
|
||||||
|
class. Adoption is therefore per-function and reversible: a plugin keeps its
|
||||||
|
method and delegates the body, so the call sites and the override points are
|
||||||
|
untouched. That is also why `config`, `logger` and `fonts` are parameters
|
||||||
|
rather than attributes -- the helper never reaches back into the caller.
|
||||||
|
|
||||||
|
The bodies are the plugins' own code, moved rather than rewritten. The one
|
||||||
|
deliberate difference is `crisp_size`, which takes the seven-plugin guard
|
||||||
|
(`not desired`) instead of football's: they agree on every real input, and
|
||||||
|
the extra guard only stops a None size raising TypeError.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
|
||||||
|
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
|
||||||
|
"scroll_card_option", "element_color", "font_color", "coerce_rgb",
|
||||||
|
"score_color_for", "recent_score_color", "favorite_teams_for",
|
||||||
|
"side_is_favorite", "side_score", "favorite_result",
|
||||||
|
"card_tzinfo", "weekday_for", "format_game_date", "format_game_time",
|
||||||
|
"vs_text", "upcoming_center_mode", "crisp_size", "schema_font_size",
|
||||||
|
"resolve_font_size", "unshare_element_fonts",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Which customization element owns each font key, for colour resolution.
|
||||||
|
ELEMENT_FOR_FONT: Dict[str, str] = {
|
||||||
|
"score": "score_text",
|
||||||
|
"time": "period_text",
|
||||||
|
"team": "team_name",
|
||||||
|
"status": "status_text",
|
||||||
|
"detail": "detail_text",
|
||||||
|
"rank": "rank_text",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Fallback colours when favourite_result_colors is on but a slot is unset.
|
||||||
|
FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = {
|
||||||
|
"win": (0, 255, 0),
|
||||||
|
"loss": (255, 0, 0),
|
||||||
|
"tie": (255, 200, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Family aliases the web UI may write, mapped to the shipped filename.
|
||||||
|
FONT_NAME_ALIASES: Dict[str, str] = {
|
||||||
|
"press_start": "PressStart2P-Regular.ttf",
|
||||||
|
"four_by_six": "4x6-font.ttf",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which
|
||||||
|
#: on an LED matrix is a dim lamp rather than a soft edge.
|
||||||
|
FONT_PIXEL_GRID: Dict[str, int] = {
|
||||||
|
"PressStart2P-Regular.ttf": 8,
|
||||||
|
"4x6-font.ttf": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings lookup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def scroll_card_option(config: Optional[Dict[str, Any]], key: str,
|
||||||
|
default: Any = None) -> Any:
|
||||||
|
"""Read one key from the scroll_card config block."""
|
||||||
|
block = (config or {}).get("scroll_card")
|
||||||
|
if isinstance(block, dict) and block.get(key) is not None:
|
||||||
|
return block.get(key)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def vs_text(config: Optional[Dict[str, Any]]) -> str:
|
||||||
|
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
||||||
|
return str(scroll_card_option(config, "vs_text", "VS"))
|
||||||
|
|
||||||
|
|
||||||
|
def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
|
||||||
|
"""Middle of an upcoming card: 'vs', 'date_time' or 'none'."""
|
||||||
|
mode = str(scroll_card_option(config, "upcoming_center", "vs") or "vs").lower()
|
||||||
|
return mode if mode in ("vs", "date_time", "none") else "vs"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colour
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def element_color(config: Optional[Dict[str, Any]], element: str,
|
||||||
|
default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
|
"""Per-element text colour from customization.<element>.text_color."""
|
||||||
|
try:
|
||||||
|
cfg = (config or {}).get("customization", {}).get(element, {})
|
||||||
|
value = cfg.get("text_color")
|
||||||
|
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||||
|
return tuple(max(0, min(255, int(c))) for c in value)
|
||||||
|
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
|
||||||
|
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
||||||
|
font, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
|
"""Colour for whichever element owns this face.
|
||||||
|
|
||||||
|
Matched on identity, and deliberately gives up when one object is
|
||||||
|
shared: the last-resort font path can hand the same face to several
|
||||||
|
keys, and there is no right answer for which element's colour that is.
|
||||||
|
White is what those draws used before, so ambiguity costs nothing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fonts = fonts or {}
|
||||||
|
matches = [element for key, element in ELEMENT_FOR_FONT.items()
|
||||||
|
if fonts.get(key) is font]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return element_color(config, matches[0], default)
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_rgb(value, fallback):
|
||||||
|
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
||||||
|
# Checked before unpacking: a 3-character string ("123") would otherwise
|
||||||
|
# iterate into three digits and yield a colour rather than the fallback.
|
||||||
|
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
r, g, b = (max(0, min(255, int(channel))) for channel in value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
return (r, g, b)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Favourite teams
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def favorite_teams_for(config: Dict[str, Any], game: Dict[str, Any]) -> list:
|
||||||
|
"""Favorite teams that apply to this game.
|
||||||
|
|
||||||
|
Both sources are used. Games carry the league manager's *resolved*
|
||||||
|
favorites, which is the only place dynamic groups such as AP_TOP_25
|
||||||
|
appear expanded; the config is read as well so an edit takes effect on
|
||||||
|
already-fetched games, and so hand-built game dicts (tests, other
|
||||||
|
callers) still work.
|
||||||
|
"""
|
||||||
|
favorites = list(game.get("favorite_teams") or [])
|
||||||
|
league_config = config.get(str(game.get("league", "") or ""))
|
||||||
|
if isinstance(league_config, dict):
|
||||||
|
favorites += list(league_config.get("favorite_teams") or [])
|
||||||
|
else:
|
||||||
|
favorites += list(config.get("favorite_teams") or [])
|
||||||
|
return favorites
|
||||||
|
|
||||||
|
|
||||||
|
def side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
|
||||||
|
"""Is the home/away side of this game a favorite team?
|
||||||
|
|
||||||
|
Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``)
|
||||||
|
payload shapes, and matches on the ESPN id too, because a couple of
|
||||||
|
leagues (NRL) key favorites by id where abbreviations collide.
|
||||||
|
"""
|
||||||
|
candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")]
|
||||||
|
team = game.get(f"{side}_team")
|
||||||
|
if isinstance(team, dict):
|
||||||
|
candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")]
|
||||||
|
for value in candidates:
|
||||||
|
if value is not None and str(value).strip().upper() in favorites:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def side_score(game: Dict[str, Any], side: str) -> Optional[int]:
|
||||||
|
"""Numeric score for one side, from either payload shape."""
|
||||||
|
raw = None
|
||||||
|
team = game.get(f"{side}_team")
|
||||||
|
if isinstance(team, dict) and team.get("score") is not None:
|
||||||
|
raw = team.get("score")
|
||||||
|
if raw is None:
|
||||||
|
raw = game.get(f"{side}_score")
|
||||||
|
try:
|
||||||
|
return int(float(str(raw).strip()))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def favorite_result(config: Dict[str, Any], game: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""Say how the favorite team did in a finished game.
|
||||||
|
|
||||||
|
Returns 'win', 'loss' or 'tie', or None when there is no single team
|
||||||
|
to root for: no favorites configured, neither side is a favorite, or
|
||||||
|
*both* are -- a favorite-vs-favorite game has no losing side worth
|
||||||
|
flagging in red. Also None when the scores are not usable numbers.
|
||||||
|
"""
|
||||||
|
favorites = {
|
||||||
|
str(team).strip().upper()
|
||||||
|
for team in favorite_teams_for(config, game)
|
||||||
|
if str(team).strip()
|
||||||
|
}
|
||||||
|
if not favorites:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_fav = side_is_favorite(game, "home", favorites)
|
||||||
|
away_fav = side_is_favorite(game, "away", favorites)
|
||||||
|
if home_fav == away_fav:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_score = side_score(game, "home")
|
||||||
|
away_score = side_score(game, "away")
|
||||||
|
if home_score is None or away_score is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if home_score == away_score:
|
||||||
|
return "tie"
|
||||||
|
favorite_score, other_score = (
|
||||||
|
(home_score, away_score) if home_fav else (away_score, home_score)
|
||||||
|
)
|
||||||
|
return "win" if favorite_score > other_score else "loss"
|
||||||
|
|
||||||
|
|
||||||
|
def recent_score_color(config: Dict[str, Any], logger, game: Dict[str, Any], default):
|
||||||
|
"""Fill color for a finished game's score, per favorite_result_colors."""
|
||||||
|
try:
|
||||||
|
settings = (config.get("customization") or {}).get(
|
||||||
|
"favorite_result_colors"
|
||||||
|
) or {}
|
||||||
|
if not settings.get("enabled", False):
|
||||||
|
return default
|
||||||
|
result = favorite_result(config, game)
|
||||||
|
if result is None:
|
||||||
|
return default
|
||||||
|
return coerce_rgb(
|
||||||
|
settings.get(f"{result}_color"),
|
||||||
|
FAVORITE_RESULT_COLOR_DEFAULTS[result],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not resolve favorite result color", exc_info=True)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def score_color_for(config: Dict[str, Any], logger, game: Dict[str, Any],
|
||||||
|
game_type: str, default=None):
|
||||||
|
"""Fill color for a game card's score. Only finished games are tinted.
|
||||||
|
|
||||||
|
The default is the configured score colour rather than a flat white,
|
||||||
|
so customization.score_text.text_color shows on games the favourite
|
||||||
|
tint does not apply to. The tint still wins where it applies.
|
||||||
|
"""
|
||||||
|
if default is None:
|
||||||
|
default = element_color(config, 'score_text')
|
||||||
|
if game_type != "recent":
|
||||||
|
return default
|
||||||
|
return recent_score_color(config, logger, game, default)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Date and time
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def card_tzinfo(config: Optional[Dict[str, Any]], logger):
|
||||||
|
"""Timezone for weekday/24h conversions; falls back to UTC."""
|
||||||
|
configured = (config or {}).get("timezone")
|
||||||
|
if configured:
|
||||||
|
try:
|
||||||
|
return ZoneInfo(configured)
|
||||||
|
except (KeyError, ValueError, TypeError, OSError) as exc:
|
||||||
|
# KeyError covers ZoneInfoNotFoundError. A bad zone name in
|
||||||
|
# config should fall back to UTC, not blank the card.
|
||||||
|
logger.debug("Unusable timezone %r: %s", configured, exc)
|
||||||
|
return timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def weekday_for(config: Optional[Dict[str, Any]], logger,
|
||||||
|
game: Optional[Dict]) -> str:
|
||||||
|
"""Weekday abbreviation from the game's start time, or ''."""
|
||||||
|
if not game:
|
||||||
|
return ""
|
||||||
|
raw = game.get("start_time_utc") or game.get("start_time")
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
||||||
|
str(raw).replace("Z", "+00:00"))
|
||||||
|
return WEEKDAY_ABBR[start.astimezone(card_tzinfo(config, logger)).weekday()]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||||
|
game: Optional[Dict] = None) -> str:
|
||||||
|
"""Format an upcoming card's date per scroll_card.date_format."""
|
||||||
|
raw = str(date_text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||||
|
if fmt == "numeric":
|
||||||
|
return raw
|
||||||
|
parts = raw.replace("-", "/").split("/")
|
||||||
|
if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()):
|
||||||
|
return raw
|
||||||
|
month, day = int(parts[0]), int(parts[1])
|
||||||
|
if not 1 <= month <= 12:
|
||||||
|
return raw
|
||||||
|
name = MONTH_ABBR[month - 1]
|
||||||
|
if fmt == "numeric_day_first":
|
||||||
|
return f"{day}/{month}"
|
||||||
|
if fmt == "day_first":
|
||||||
|
return f"{day} {name}"
|
||||||
|
if fmt == "weekday":
|
||||||
|
weekday = weekday_for(config, logger, game)
|
||||||
|
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
|
||||||
|
return f"{name} {day}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
|
||||||
|
"""Return the time as-is (12h) or converted to 24h."""
|
||||||
|
raw = str(time_text or "").strip()
|
||||||
|
if not raw or str(scroll_card_option(config, "time_format", "12h")) != "24h":
|
||||||
|
return raw
|
||||||
|
cleaned = raw.upper().replace(" ", "")
|
||||||
|
meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else ""
|
||||||
|
if not meridiem:
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
hh, _, mm = cleaned[:-2].partition(":")
|
||||||
|
hour, minute = int(hh), int(mm or 0)
|
||||||
|
except ValueError:
|
||||||
|
return raw
|
||||||
|
if not (0 <= hour <= 12 and 0 <= minute <= 59):
|
||||||
|
return raw
|
||||||
|
hour = hour % 12 + (12 if meridiem == "PM" else 0)
|
||||||
|
return f"{hour:02d}:{minute:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Font sizing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Per-schema caches, keyed by the schema's absolute path. Keyed rather than
|
||||||
|
#: global because each plugin declares its own defaults; keyed rather than
|
||||||
|
#: per-class because the helper has no class to hang it on.
|
||||||
|
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def crisp_size(font_file, desired, aliases=None, grid_table=None):
|
||||||
|
"""Snap *desired* to the nearest size *font_file* renders crisply at.
|
||||||
|
|
||||||
|
A face with no known grid is returned unchanged, so a user-supplied
|
||||||
|
font is never second-guessed.
|
||||||
|
|
||||||
|
``aliases`` and ``grid_table`` default to the shared tables; a plugin
|
||||||
|
that ships an extra face can pass its own without forking this.
|
||||||
|
"""
|
||||||
|
aliases = FONT_NAME_ALIASES if aliases is None else aliases
|
||||||
|
grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table
|
||||||
|
font_file = aliases.get(font_file, font_file)
|
||||||
|
grid = grid_table.get(font_file)
|
||||||
|
if not grid or not desired or desired <= 0:
|
||||||
|
return desired
|
||||||
|
return max(grid, int(round(float(desired) / grid)) * grid)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||||
|
"""The font_size this plugin's config_schema.json declares, or None.
|
||||||
|
|
||||||
|
Cached per schema path. The plugins cached this on their own class; the
|
||||||
|
path is the same distinction expressed without one, so two plugins never
|
||||||
|
share an entry.
|
||||||
|
"""
|
||||||
|
if not element_key:
|
||||||
|
return None
|
||||||
|
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
|
||||||
|
if cache is None:
|
||||||
|
cache = {}
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
with open(schema_path) as fh:
|
||||||
|
schema = json.load(fh)
|
||||||
|
props = (schema.get('properties', {})
|
||||||
|
.get('customization', {})
|
||||||
|
.get('properties', {}))
|
||||||
|
for key, spec in props.items():
|
||||||
|
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||||
|
if size is not None:
|
||||||
|
cache[key] = int(size)
|
||||||
|
except Exception:
|
||||||
|
cache = {}
|
||||||
|
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
|
||||||
|
return cache.get(element_key)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_font_size(schema_path: str, element_config, element_key,
|
||||||
|
default_size, font_name, aliases=None, grid_table=None):
|
||||||
|
"""Size to render at: the user's choice, or a grid-snapped default.
|
||||||
|
|
||||||
|
A configured size counts as a real choice only when it differs from
|
||||||
|
the schema default. The web UI writes the whole schema default block
|
||||||
|
on every save, so "font_size == schema default" carries no intent and
|
||||||
|
would otherwise pin every install to an anti-aliased size forever.
|
||||||
|
"""
|
||||||
|
configured = (element_config or {}).get('font_size')
|
||||||
|
if configured is not None:
|
||||||
|
try:
|
||||||
|
configured = int(configured)
|
||||||
|
if configured != schema_font_size(schema_path, element_key):
|
||||||
|
return configured
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return crisp_size(font_name, default_size, aliases, grid_table)
|
||||||
|
|
||||||
|
|
||||||
|
def unshare_element_fonts(logger, fonts):
|
||||||
|
"""Give each colourable element its own face object.
|
||||||
|
|
||||||
|
The colour a draw gets is resolved from the face it was handed, and
|
||||||
|
several of these loaders legitimately hand one object to more than one
|
||||||
|
element -- a size resolver that lands two elements on the same face, a
|
||||||
|
fallback that fills every key from one default, football's narrowing
|
||||||
|
step that deliberately shrinks the clock along with the score. Sharing
|
||||||
|
the object makes the element ambiguous and the colour unresolvable.
|
||||||
|
|
||||||
|
Re-instantiating from the same path and size gives a distinct object
|
||||||
|
with identical metrics, so nothing about the rendering changes; only
|
||||||
|
the ability to tell two elements apart does. Faces that cannot be
|
||||||
|
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
||||||
|
path) are left shared, and their draws stay white as before.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import ImageFont as _IF
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
return fonts
|
||||||
|
seen = {}
|
||||||
|
for key in ELEMENT_FOR_FONT:
|
||||||
|
font = fonts.get(key)
|
||||||
|
if font is None:
|
||||||
|
continue
|
||||||
|
if id(font) not in seen:
|
||||||
|
seen[id(font)] = key
|
||||||
|
continue
|
||||||
|
path, size = getattr(font, "path", None), getattr(font, "size", None)
|
||||||
|
if not path or not size:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fonts[key] = _IF.truetype(path, size)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
logger.debug(
|
||||||
|
"Could not un-share the %s face; it keeps the default colour", key)
|
||||||
|
return fonts
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""The scroll/Vegas card geometry the sports scoreboards all share.
|
||||||
|
|
||||||
|
Eight scoreboards -- afl, baseball, basketball, football, hockey, lacrosse,
|
||||||
|
nrl and soccer -- each carried their own ``game_renderer.py``. After the card
|
||||||
|
helpers moved to ``src/common/sports_card.py`` the settings lookups were
|
||||||
|
shared, but the *geometry* was not: nine methods that decide how wide the
|
||||||
|
centre gap is, how much room a logo gets, and where an upcoming card's date
|
||||||
|
and time land were still eight separate copies. Five were byte-identical
|
||||||
|
across all eight plugins; the other four were identical in seven, with a
|
||||||
|
different single outlier each time.
|
||||||
|
|
||||||
|
That last detail is why this is a mixin rather than free functions. There is
|
||||||
|
no per-sport branching to write -- baseball needs its own
|
||||||
|
``_draw_upcoming_game_status`` and ``_logo_slot_width``, hockey its own
|
||||||
|
``_upcoming_date_and_time``, football its own ``_score_reserve_width``, and
|
||||||
|
every one of those is an ordinary override. The seven that agree inherit and
|
||||||
|
say nothing.
|
||||||
|
|
||||||
|
It is deliberately *only* a mixin: no ``__init__``, no state of its own. The
|
||||||
|
plugins' constructors differ in six ways and none of that difference is worth
|
||||||
|
unifying, so adoption is one line on the class statement plus deleting the
|
||||||
|
methods that now come from here.
|
||||||
|
|
||||||
|
What a host class must provide
|
||||||
|
------------------------------
|
||||||
|
Attributes: ``display_width``, ``display_height``, ``config``, ``fonts``,
|
||||||
|
``logger``, ``_team_rankings_cache``.
|
||||||
|
|
||||||
|
Methods: ``_draw_text_with_outline(draw, text, position, font, fill=None,
|
||||||
|
outline_color=(0, 0, 0))`` -- the one hook whose body genuinely varies -- plus
|
||||||
|
the ``sports_card`` delegations ``_scroll_card_option``,
|
||||||
|
``_upcoming_center_mode``, ``_vs_text``, ``_element_color``,
|
||||||
|
``_format_game_date`` and ``_format_game_time``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import ClassVar, Dict, Tuple
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
|
||||||
|
class SportsGameRendererMixin:
|
||||||
|
"""Shared card geometry for the sports scoreboards. See module docstring."""
|
||||||
|
|
||||||
|
#: Centre strip as a fraction of card width, before clamping.
|
||||||
|
CENTER_GAP_RATIO: ClassVar[float] = 0.28
|
||||||
|
#: Clamp floor for the derived centre gap.
|
||||||
|
CENTER_GAP_MIN_PX: ClassVar[int] = 22
|
||||||
|
#: Clamp ceiling for the derived centre gap.
|
||||||
|
CENTER_GAP_MAX_PX: ClassVar[int] = 40
|
||||||
|
#: Breathing room kept between the score and each logo.
|
||||||
|
_SCORE_LOGO_GUTTER_PX: ClassVar[int] = 4
|
||||||
|
#: Widest score the centre strip must fit. Leagues that can reach three
|
||||||
|
#: digits a side override this with "000-000".
|
||||||
|
_SCORE_PROBE: ClassVar[str] = "00-00"
|
||||||
|
|
||||||
|
# ---- geometry ------------------------------------------------------
|
||||||
|
|
||||||
|
# Non-finite settings are rejected before any int()/round(): "inf" reaches
|
||||||
|
# these from config as a float or a string, passes an `isinstance` plus
|
||||||
|
# `>= 0` check unharmed, and then raises OverflowError out of int() --
|
||||||
|
# which the old `except (TypeError, ValueError)` did not catch, so it
|
||||||
|
# aborted the whole card render. Present in all eight plugins before this
|
||||||
|
# moved to the core; fixing it here fixes it in all eight.
|
||||||
|
|
||||||
|
def _score_reserve_width(self) -> int:
|
||||||
|
"""Centre strip the score actually needs, measured rather than assumed.
|
||||||
|
|
||||||
|
The gap was derived from the card width alone (width x
|
||||||
|
CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size
|
||||||
|
comes from config and the element-style resolver. Nothing compared the
|
||||||
|
two, so any score wider than the clamp was drawn over the logos.
|
||||||
|
Measuring it keeps the strip wide enough for whatever font is in play.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
|
||||||
|
width = probe.textlength(self._SCORE_PROBE, font=self.fonts['score'])
|
||||||
|
return int(width) + 2 * self._SCORE_LOGO_GUTTER_PX
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("Score reserve measurement failed", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _center_gap_width(self) -> int:
|
||||||
|
"""Width of the middle strip kept clear of logos.
|
||||||
|
|
||||||
|
``scroll_card.center_gap`` pins it outright; otherwise it scales with
|
||||||
|
the card width between the configurable min and max. 0 restores
|
||||||
|
edge-to-edge logos.
|
||||||
|
"""
|
||||||
|
configured = self._scroll_card_option("center_gap")
|
||||||
|
if (isinstance(configured, (int, float))
|
||||||
|
and math.isfinite(configured) and configured >= 0):
|
||||||
|
return int(configured)
|
||||||
|
ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO)
|
||||||
|
low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX)
|
||||||
|
high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX)
|
||||||
|
try:
|
||||||
|
ratio, low, high = float(ratio), float(low), float(high)
|
||||||
|
if not (math.isfinite(ratio) and math.isfinite(low)
|
||||||
|
and math.isfinite(high)):
|
||||||
|
return self.CENTER_GAP_MIN_PX
|
||||||
|
scaled = round(self.display_width * ratio)
|
||||||
|
derived = int(max(int(low), min(int(high), scaled)))
|
||||||
|
# A strip narrower than the score is the bug, not a style choice.
|
||||||
|
# An explicit ``center_gap`` is still honoured above, including 0.
|
||||||
|
return max(derived, self._score_reserve_width())
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return self.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
def _logo_slot_width(self) -> int:
|
||||||
|
"""Per-side logo slot, leaving the center gap clear.
|
||||||
|
|
||||||
|
No longer capped at display_height: the card is sized as two
|
||||||
|
full-height logos plus the measured gap, so what is left after the gap
|
||||||
|
is exactly the logo's share. The cap was what froze the logos at 46px
|
||||||
|
on the old flat 128px card.
|
||||||
|
"""
|
||||||
|
available = (self.display_width - self._center_gap_width()) // 2
|
||||||
|
return max(8, available)
|
||||||
|
|
||||||
|
def _logo_cache_key(self, name: str) -> str:
|
||||||
|
"""Cache key scoped to the logo slot.
|
||||||
|
|
||||||
|
One cache dict is shared by renderers built for different card widths,
|
||||||
|
so a logo sized for a wide slot must not be handed to a narrow one.
|
||||||
|
"""
|
||||||
|
return f"{name}@{self._logo_slot_width()}x{self.display_height}"
|
||||||
|
|
||||||
|
def _layout_offset(self, element: str, axis: str, default: int = 0) -> int:
|
||||||
|
"""X/Y nudge for one element, from customization.layout.
|
||||||
|
|
||||||
|
Same block the full-screen scorebug reads (sports.py
|
||||||
|
_get_layout_offset), so a nudge configured in the web UI now moves
|
||||||
|
the element on the scroll/Vegas card too -- previously the schema
|
||||||
|
advertised these offsets but this renderer ignored them.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
layout = (self.config or {}).get("customization", {}).get("layout", {})
|
||||||
|
value = (layout.get(element) or {}).get(axis, default)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return int(value) if math.isfinite(value) else default
|
||||||
|
if isinstance(value, str):
|
||||||
|
parsed = float(value)
|
||||||
|
return int(parsed) if math.isfinite(parsed) else default
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
# ---- upcoming cards ------------------------------------------------
|
||||||
|
|
||||||
|
def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]:
|
||||||
|
"""(date, time) for an upcoming card, from the extractor's flat keys."""
|
||||||
|
return (
|
||||||
|
str(game.get("game_date", "") or ""),
|
||||||
|
str(game.get("game_time", "") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
|
||||||
|
"""Draw the middle of an upcoming card.
|
||||||
|
|
||||||
|
Never a score: an upcoming game has not started, so the extractor's
|
||||||
|
0-0 is noise. Either the VS text (default), the date and time stacked,
|
||||||
|
or nothing at all.
|
||||||
|
"""
|
||||||
|
mode = self._upcoming_center_mode()
|
||||||
|
if mode == "none":
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode == "vs":
|
||||||
|
vs_text = self._vs_text()
|
||||||
|
if not vs_text:
|
||||||
|
return
|
||||||
|
vs_width = draw.textlength(vs_text, font=self.fonts['score'])
|
||||||
|
vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset')
|
||||||
|
vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, vs_text, (vs_x, vs_y), self.fonts['score'],
|
||||||
|
fill=self._element_color('score_text')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
date_text, time_text = self._upcoming_date_and_time(game)
|
||||||
|
lines = []
|
||||||
|
if self._scroll_card_option("show_date", True):
|
||||||
|
lines.append(self._format_game_date(date_text, game))
|
||||||
|
if self._scroll_card_option("show_time", True):
|
||||||
|
lines.append(self._format_game_time(time_text))
|
||||||
|
lines = [t for t in lines if t]
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
line_h = 7
|
||||||
|
top = (self.display_height // 2) - (len(lines) * line_h) // 2
|
||||||
|
top += self._layout_offset('score', 'y_offset')
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
width = draw.textlength(line, font=font)
|
||||||
|
x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, line, (x, top + i * line_h), font,
|
||||||
|
fill=self._element_color('detail_text')
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_upcoming_game_status(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
|
||||||
|
"""Draw the date and time around an upcoming card.
|
||||||
|
|
||||||
|
Time top and date bottom by default; scroll_card.swap_date_time puts
|
||||||
|
the date on top instead. Skipped when the pair is stacked in the
|
||||||
|
middle, which would otherwise print them twice.
|
||||||
|
"""
|
||||||
|
if self._upcoming_center_mode() == "date_time":
|
||||||
|
return
|
||||||
|
|
||||||
|
date_raw, time_raw = self._upcoming_date_and_time(game)
|
||||||
|
date_text = (self._format_game_date(date_raw, game)
|
||||||
|
if self._scroll_card_option("show_date", True) else "")
|
||||||
|
time_text = (self._format_game_time(time_raw)
|
||||||
|
if self._scroll_card_option("show_time", True) else "")
|
||||||
|
|
||||||
|
if self._scroll_card_option("swap_date_time", False):
|
||||||
|
top_text, top_el, bottom_text, bottom_el = (
|
||||||
|
date_text, 'date', time_text, 'time')
|
||||||
|
top_font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
bottom_font = self.fonts['time']
|
||||||
|
top_color, bottom_color = 'detail_text', 'period_text'
|
||||||
|
else:
|
||||||
|
top_text, top_el, bottom_text, bottom_el = (
|
||||||
|
time_text, 'time', date_text, 'date')
|
||||||
|
top_font = self.fonts['time']
|
||||||
|
bottom_font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
top_color, bottom_color = 'period_text', 'detail_text'
|
||||||
|
|
||||||
|
if top_text:
|
||||||
|
top_width = draw.textlength(top_text, font=top_font)
|
||||||
|
top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset')
|
||||||
|
top_y = 1 + self._layout_offset(top_el, 'y_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, top_text, (top_x, top_y), top_font,
|
||||||
|
fill=self._element_color(top_color)
|
||||||
|
)
|
||||||
|
|
||||||
|
if bottom_text:
|
||||||
|
bottom_width = draw.textlength(bottom_text, font=bottom_font)
|
||||||
|
bottom_x = ((self.display_width - bottom_width) // 2
|
||||||
|
+ self._layout_offset(bottom_el, 'x_offset'))
|
||||||
|
# Measured, not a fixed -7: the detail font is 6px in most plugins
|
||||||
|
# but 10px in soccer and nrl, where "Sep 19" ran past the card.
|
||||||
|
ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3]
|
||||||
|
bottom_y = (max(0, self.display_height - ink_bottom - 1)
|
||||||
|
+ self._layout_offset(bottom_el, 'y_offset'))
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, bottom_text, (bottom_x, bottom_y), bottom_font,
|
||||||
|
fill=self._element_color(bottom_color)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- rankings ------------------------------------------------------
|
||||||
|
|
||||||
|
def set_rankings_cache(self, rankings: Dict[str, int]) -> None:
|
||||||
|
"""Set the team rankings cache for display."""
|
||||||
|
self._team_rankings_cache = rankings
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2405,7 +2405,6 @@ class DisplayController:
|
|||||||
)
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
_frame_start = time.perf_counter()
|
|
||||||
try:
|
try:
|
||||||
with self._display_lock_or_skip(plugin_id) as can_display:
|
with self._display_lock_or_skip(plugin_id) as can_display:
|
||||||
if can_display:
|
if can_display:
|
||||||
@@ -2426,26 +2425,11 @@ class DisplayController:
|
|||||||
# Multi-display sync: send follower frame after each render
|
# Multi-display sync: send follower frame after each render
|
||||||
self._send_follower_frame(manager_to_display)
|
self._send_follower_frame(manager_to_display)
|
||||||
|
|
||||||
|
time.sleep(display_interval)
|
||||||
self._tick_plugin_updates()
|
self._tick_plugin_updates()
|
||||||
self._poll_on_demand_requests()
|
self._poll_on_demand_requests()
|
||||||
self._check_on_demand_expiration()
|
self._check_on_demand_expiration()
|
||||||
|
|
||||||
# Pace to the frame deadline rather than sleeping a flat
|
|
||||||
# interval on top of the work. display() has already
|
|
||||||
# blocked on the panel's vsync by this point, so an
|
|
||||||
# unconditional sleep is added to a wait that already
|
|
||||||
# happened. Measured on a 2x128x64 chain at
|
|
||||||
# limit_refresh_rate_hz=100: ~4ms of render plus a flat
|
|
||||||
# 8ms put each iteration at ~12ms against a 10ms refresh
|
|
||||||
# grid, so every swap missed a refresh and the loop
|
|
||||||
# settled at 50fps where display_interval asks for 125 --
|
|
||||||
# and with zero headroom, ~14% of frames slipped a
|
|
||||||
# further refresh, which is what reads as scroll stutter.
|
|
||||||
_remaining = display_interval - (time.perf_counter() - _frame_start)
|
|
||||||
# Yield even when the frame overran its budget, so plugin
|
|
||||||
# update threads and the web UI are not starved of the GIL.
|
|
||||||
time.sleep(_remaining if _remaining > 0 else 0.001)
|
|
||||||
|
|
||||||
if self.current_display_mode != active_mode:
|
if self.current_display_mode != active_mode:
|
||||||
logger.debug("Mode changed during high-FPS loop, breaking early")
|
logger.debug("Mode changed during high-FPS loop, breaking early")
|
||||||
break
|
break
|
||||||
|
|||||||
+11
-101
@@ -240,14 +240,6 @@ class DisplayManager:
|
|||||||
self._update_lock = threading.RLock()
|
self._update_lock = threading.RLock()
|
||||||
|
|
||||||
# Scrolling state tracking for graceful updates
|
# Scrolling state tracking for graceful updates
|
||||||
# How many panel refreshes each pushed frame is held for. 1 means a new
|
|
||||||
# frame every refresh. Higher values are how a scroll runs slower than
|
|
||||||
# one pixel per refresh WITHOUT fractional pixel positions: the panel
|
|
||||||
# keeps refreshing at full rate (so flicker is unchanged) but motion
|
|
||||||
# advances a whole pixel every Nth refresh instead of every one.
|
|
||||||
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
|
|
||||||
self._frame_hold = 1
|
|
||||||
|
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
'is_scrolling': False,
|
'is_scrolling': False,
|
||||||
'last_scroll_activity': 0,
|
'last_scroll_activity': 0,
|
||||||
@@ -776,33 +768,16 @@ class DisplayManager:
|
|||||||
return # Skip hardware write — content is being captured off-screen
|
return # Skip hardware write — content is being captured off-screen
|
||||||
|
|
||||||
digest = None
|
digest = None
|
||||||
frame_checksum = None
|
|
||||||
if self._dirty_tracking_enabled:
|
if self._dirty_tracking_enabled:
|
||||||
try:
|
try:
|
||||||
brightness = getattr(self.matrix, 'brightness', None)
|
brightness = getattr(self.matrix, 'brightness', None)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
brightness = None
|
brightness = None
|
||||||
frame_checksum = zlib.adler32(self.image.tobytes())
|
digest = (zlib.adler32(self.image.tobytes()), brightness)
|
||||||
digest = (frame_checksum, brightness)
|
if digest == self._last_pushed_digest:
|
||||||
if digest == self._last_pushed_digest and not self.is_currently_scrolling():
|
|
||||||
# Nothing changed since the last push — the panel is
|
# Nothing changed since the last push — the panel is
|
||||||
# already showing exactly this frame.
|
# already showing exactly this frame.
|
||||||
#
|
self._write_snapshot_if_due()
|
||||||
# Never taken mid-scroll, and that exception is the
|
|
||||||
# point. SwapOnVSync is what paces the render loop, so
|
|
||||||
# skipping it also skips the wait: a duplicate frame
|
|
||||||
# returns in ~8ms instead of ~10ms on a 100Hz panel,
|
|
||||||
# advances only 0.8px instead of 1.0px, and so makes
|
|
||||||
# the *next* frame more likely to repeat as well. That
|
|
||||||
# is self-sustaining -- measured at ~20% duplicate
|
|
||||||
# frames mid-scroll on the odds ticker, against
|
|
||||||
# essentially zero on a lighter plugin with identical
|
|
||||||
# scroll settings. Swapping an identical frame costs
|
|
||||||
# one canvas copy and keeps the loop locked to the
|
|
||||||
# panel; falling out of that lock costs smooth motion.
|
|
||||||
# Static content is unaffected: is_currently_scrolling()
|
|
||||||
# expires on its own inactivity threshold.
|
|
||||||
self._write_snapshot_if_due(frame_checksum)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Copy the current image to the offscreen canvas. In double-sided
|
# Copy the current image to the offscreen canvas. In double-sided
|
||||||
@@ -812,10 +787,8 @@ class DisplayManager:
|
|||||||
else:
|
else:
|
||||||
self.offscreen_canvas.SetImage(self.image)
|
self.offscreen_canvas.SetImage(self.image)
|
||||||
|
|
||||||
# Swap buffers immediately. framerate_fraction holds the frame
|
# Swap buffers immediately
|
||||||
# for N refreshes; SwapOnVSync blocks for all of them, which is
|
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
||||||
# what paces the render loop to the chosen frame rate.
|
|
||||||
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
|
|
||||||
|
|
||||||
# Swap our canvas references
|
# Swap our canvas references
|
||||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||||
@@ -823,7 +796,7 @@ class DisplayManager:
|
|||||||
self._last_pushed_digest = digest
|
self._last_pushed_digest = digest
|
||||||
|
|
||||||
# Write a snapshot for the web preview (throttled)
|
# Write a snapshot for the web preview (throttled)
|
||||||
self._write_snapshot_if_due(frame_checksum)
|
self._write_snapshot_if_due()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating display: {e}")
|
logger.error(f"Error updating display: {e}")
|
||||||
|
|
||||||
@@ -1301,65 +1274,12 @@ class DisplayManager:
|
|||||||
|
|
||||||
return dt.strftime(f"%b %-d{suffix}")
|
return dt.strftime(f"%b %-d{suffix}")
|
||||||
|
|
||||||
@property
|
def set_scrolling_state(self, is_scrolling: bool):
|
||||||
def refresh_hz(self) -> float:
|
"""Set the current scrolling state. Call this when a display starts/stops scrolling."""
|
||||||
"""The panel's refresh rate in Hz, from the hardware config.
|
|
||||||
|
|
||||||
The authoritative place to ask, because a plugin only receives its own
|
|
||||||
config section and cannot see display.hardware. Scroll pacing needs
|
|
||||||
this: the speeds a panel can show in whole pixels are refresh_hz
|
|
||||||
divided by the frame hold, so getting it wrong silently produces
|
|
||||||
fractional-pixel motion. See src/common/scroll_config.py.
|
|
||||||
|
|
||||||
Note this is the configured *cap*, not necessarily what the panel
|
|
||||||
achieves -- scripts/scroll_speeds.py --measure reports the real rate.
|
|
||||||
"""
|
|
||||||
hardware = (self.config.get('display') or {}).get('hardware') or {}
|
|
||||||
try:
|
|
||||||
value = float(hardware.get('limit_refresh_rate_hz') or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
value = 0.0
|
|
||||||
return value if value > 0 else 100.0
|
|
||||||
|
|
||||||
def set_frame_hold(self, refreshes: int) -> None:
|
|
||||||
"""Hold each pushed frame for this many panel refreshes (>=1).
|
|
||||||
|
|
||||||
Set by the scroll configuration so a plugin can run at, say, 50px/s on
|
|
||||||
a 100Hz panel as one whole pixel every second refresh, rather than half
|
|
||||||
a pixel every refresh (which has to be blended or repeated unevenly).
|
|
||||||
|
|
||||||
Reset to 1 whenever scrolling stops, so one plugin's pacing cannot
|
|
||||||
leak into the next thing on screen.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
value = int(refreshes)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
logger.warning("Ignoring unusable frame hold: %r", refreshes)
|
|
||||||
return
|
|
||||||
self._frame_hold = max(1, min(255, value))
|
|
||||||
|
|
||||||
def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1):
|
|
||||||
"""Set the current scrolling state, and this scroll's frame pacing.
|
|
||||||
|
|
||||||
Call this when a display starts or stops scrolling. ``frame_hold`` is
|
|
||||||
how many panel refreshes each frame is held for -- 2 gives one whole
|
|
||||||
pixel every second refresh, which is how a scroll runs at half the
|
|
||||||
refresh rate without fractional pixel positions.
|
|
||||||
|
|
||||||
The hold is set here rather than once at plugin construction because
|
|
||||||
it must not outlive the scroll that asked for it: plugins share one
|
|
||||||
display manager, so a hold left set by whoever scrolled last would
|
|
||||||
silently re-pace the next plugin. Passing it alongside the state makes
|
|
||||||
the lifetime exactly the scroll, and the default of 1 means any caller
|
|
||||||
that does not care gets a new frame every refresh.
|
|
||||||
"""
|
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
self._scrolling_state['is_scrolling'] = is_scrolling
|
self._scrolling_state['is_scrolling'] = is_scrolling
|
||||||
if is_scrolling:
|
if is_scrolling:
|
||||||
self._scrolling_state['last_scroll_activity'] = current_time
|
self._scrolling_state['last_scroll_activity'] = current_time
|
||||||
self.set_frame_hold(frame_hold)
|
|
||||||
else:
|
|
||||||
self._frame_hold = 1
|
|
||||||
logger.debug(f"Scrolling state set to: {is_scrolling}")
|
logger.debug(f"Scrolling state set to: {is_scrolling}")
|
||||||
|
|
||||||
def is_currently_scrolling(self) -> bool:
|
def is_currently_scrolling(self) -> bool:
|
||||||
@@ -1490,20 +1410,11 @@ class DisplayManager:
|
|||||||
self._viewer_fresh = False
|
self._viewer_fresh = False
|
||||||
return self._viewer_fresh
|
return self._viewer_fresh
|
||||||
|
|
||||||
def _write_snapshot_if_due(self, frame_checksum: Optional[int] = None) -> None:
|
def _write_snapshot_if_due(self) -> None:
|
||||||
"""Mirror the current frame to the preview snapshot when the policy
|
"""Mirror the current frame to the preview snapshot when the policy
|
||||||
says it's worth it — see src/common/snapshot_policy.py. Unchanged
|
says it's worth it — see src/common/snapshot_policy.py. Unchanged
|
||||||
frames are never re-encoded; without viewers the cadence drops to
|
frames are never re-encoded; without viewers the cadence drops to
|
||||||
the idle keepalive.
|
the idle keepalive."""
|
||||||
|
|
||||||
Args:
|
|
||||||
frame_checksum: adler32 of the current frame, when the caller has
|
|
||||||
already computed one. Dirty tracking checksums every frame a
|
|
||||||
few lines above the call site, and re-deriving it here meant a
|
|
||||||
second tobytes() plus a second pass over the whole framebuffer
|
|
||||||
on every single frame — ~0.17ms per frame of the two combined
|
|
||||||
at 256x64, paid 100 times a second to reach the same number.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
viewer_fresh = self._viewer_is_fresh(now)
|
viewer_fresh = self._viewer_is_fresh(now)
|
||||||
@@ -1513,8 +1424,7 @@ class DisplayManager:
|
|||||||
self._last_snapshot_ts = 0.0
|
self._last_snapshot_ts = 0.0
|
||||||
self._viewer_was_fresh = viewer_fresh
|
self._viewer_was_fresh = viewer_fresh
|
||||||
|
|
||||||
digest = (frame_checksum if frame_checksum is not None
|
digest = zlib.adler32(self.image.tobytes())
|
||||||
else zlib.adler32(self.image.tobytes()))
|
|
||||||
action = snapshot_policy.decide(
|
action = snapshot_policy.decide(
|
||||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||||
viewer_fresh, digest != self._last_snapshot_digest)
|
viewer_fresh, digest != self._last_snapshot_digest)
|
||||||
|
|||||||
+116
-8
@@ -14,6 +14,7 @@ import json
|
|||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from PIL.PngImagePlugin import PngInfo
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
@@ -25,6 +26,96 @@ from src.common.permission_utils import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: PNG text key stamped into a generated placeholder so a later run can tell it
|
||||||
|
#: apart from a real logo that happens to be small.
|
||||||
|
PLACEHOLDER_MARKER = "ledmatrix_placeholder"
|
||||||
|
|
||||||
|
#: Geometry of a generated placeholder, used to recognise ones written before
|
||||||
|
#: the marker existed. Those are already on users' disks and would otherwise
|
||||||
|
#: never be retried.
|
||||||
|
PLACEHOLDER_SIZE = (64, 64)
|
||||||
|
PLACEHOLDER_BG = (100, 100, 100, 255)
|
||||||
|
|
||||||
|
#: How long a placeholder is trusted before the real logo is attempted again.
|
||||||
|
#: A placeholder means the download failed, and download failures are usually
|
||||||
|
#: transient (no network at boot, ESPN blipping). Retrying every frame would
|
||||||
|
#: hammer the API from a Pi that is also driving a panel; never retrying leaves
|
||||||
|
#: the team a grey box forever, which is the bug this exists to avoid.
|
||||||
|
PLACEHOLDER_RETRY_SECONDS = 6 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def is_placeholder_logo(filepath: Path) -> bool:
|
||||||
|
"""True if the file at ``filepath`` is a generated placeholder, not a logo.
|
||||||
|
|
||||||
|
Checks the marker first, then falls back to matching the placeholder's
|
||||||
|
exact geometry and background colour so files written before the marker was
|
||||||
|
introduced are still recognised.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Image.open(filepath) as img:
|
||||||
|
if img.info.get(PLACEHOLDER_MARKER):
|
||||||
|
return True
|
||||||
|
if img.size != PLACEHOLDER_SIZE:
|
||||||
|
return False
|
||||||
|
return img.convert("RGBA").getpixel((0, 0)) == PLACEHOLDER_BG
|
||||||
|
except Exception:
|
||||||
|
# Unreadable file: not provably a placeholder, and the caller's own
|
||||||
|
# error handling is better placed to deal with it.
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def should_attempt_download(filepath: Path, force_download: bool = False) -> bool:
|
||||||
|
"""Whether a real logo is worth (re)fetching for ``filepath``.
|
||||||
|
|
||||||
|
True when nothing is there, when the caller forced it, or when what is
|
||||||
|
there is a placeholder old enough to retry. A *fresh* placeholder says a
|
||||||
|
download just failed, so retrying it immediately would hammer the API for
|
||||||
|
a result that is very unlikely to have changed.
|
||||||
|
"""
|
||||||
|
if force_download or not filepath.exists():
|
||||||
|
return True
|
||||||
|
if not is_placeholder_logo(filepath):
|
||||||
|
return False
|
||||||
|
age = placeholder_age_seconds(filepath)
|
||||||
|
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_placeholder_timestamp(filepath: Path) -> bool:
|
||||||
|
"""Restamp a placeholder so a failed retry restarts the back-off clock.
|
||||||
|
|
||||||
|
Without this a stale placeholder stays stale: every later call sees an
|
||||||
|
expired timestamp, retries, fails, and leaves the timestamp untouched --
|
||||||
|
which is a download attempt per call, the opposite of what the back-off is
|
||||||
|
for.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not is_placeholder_logo(filepath):
|
||||||
|
return False
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
|
||||||
|
with Image.open(filepath) as img:
|
||||||
|
img.copy().save(filepath, "PNG", pnginfo=metadata)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not refresh placeholder timestamp for %s", filepath,
|
||||||
|
exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def placeholder_age_seconds(filepath: Path) -> Optional[float]:
|
||||||
|
"""Seconds since a placeholder was written, or None if unknown."""
|
||||||
|
try:
|
||||||
|
with Image.open(filepath) as img:
|
||||||
|
stamped = img.info.get(PLACEHOLDER_MARKER)
|
||||||
|
if stamped and stamped != "1":
|
||||||
|
return max(0.0, time.time() - float(stamped))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return max(0.0, time.time() - filepath.stat().st_mtime)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
class LogoDownloader:
|
class LogoDownloader:
|
||||||
"""Centralized logo downloader for team logos from ESPN API."""
|
"""Centralized logo downloader for team logos from ESPN API."""
|
||||||
|
|
||||||
@@ -499,8 +590,10 @@ class LogoDownloader:
|
|||||||
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
||||||
filepath = Path(logo_dir) / filename
|
filepath = Path(logo_dir) / filename
|
||||||
|
|
||||||
# Skip if already exists and not forcing download
|
# A placeholder does not count as existing -- it is a previous
|
||||||
if filepath.exists() and not force_download:
|
# failure, and a bulk pass is exactly where it should get another
|
||||||
|
# chance, subject to the same back-off as everywhere else.
|
||||||
|
if not should_attempt_download(filepath, force_download):
|
||||||
logger.debug(f"Skipping {display_name}: {filename} already exists")
|
logger.debug(f"Skipping {display_name}: {filename} already exists")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -559,8 +652,9 @@ class LogoDownloader:
|
|||||||
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
||||||
filepath = Path(logo_dir) / filename
|
filepath = Path(logo_dir) / filename
|
||||||
|
|
||||||
# Skip if already exists and not forcing download
|
# Same eligibility rule as every other download site: a stale
|
||||||
if filepath.exists() and not force_download:
|
# placeholder is a failed download, not a logo.
|
||||||
|
if not should_attempt_download(filepath, force_download):
|
||||||
logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists")
|
logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -674,11 +768,16 @@ class LogoDownloader:
|
|||||||
# Fallback without font
|
# Fallback without font
|
||||||
draw.text((16, 24), text, fill=(255, 255, 255, 255))
|
draw.text((16, 24), text, fill=(255, 255, 255, 255))
|
||||||
|
|
||||||
logo.save(filepath)
|
# Stamp it so a later run can tell this apart from a real logo and
|
||||||
|
# retry the download, instead of treating the file's existence as
|
||||||
|
# proof the logo was fetched.
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
|
||||||
|
logo.save(filepath, "PNG", pnginfo=metadata)
|
||||||
|
|
||||||
# Set proper file permissions after saving
|
# Set proper file permissions after saving
|
||||||
ensure_file_permissions(filepath, get_assets_file_mode())
|
ensure_file_permissions(filepath, get_assets_file_mode())
|
||||||
|
|
||||||
logger.info(f"Created placeholder logo for {team_abbreviation} at {filepath}")
|
logger.info(f"Created placeholder logo for {team_abbreviation} at {filepath}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -770,9 +869,18 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
|||||||
# Use the exact filepath that was passed in (respects config settings)
|
# Use the exact filepath that was passed in (respects config settings)
|
||||||
filepath = logo_path
|
filepath = logo_path
|
||||||
|
|
||||||
if filepath.exists():
|
if filepath.exists() and not should_attempt_download(filepath):
|
||||||
|
# Either a real logo, or a placeholder too fresh to be worth retrying.
|
||||||
logger.debug(f"Logo already exists for {team_abbreviation} ({league})")
|
logger.debug(f"Logo already exists for {team_abbreviation} ({league})")
|
||||||
return True
|
return True
|
||||||
|
if filepath.exists():
|
||||||
|
# A placeholder is a *failed* download wearing the real logo's
|
||||||
|
# filename. Treating it as "already exists" is what pinned a team to a
|
||||||
|
# grey box permanently after one transient failure.
|
||||||
|
logger.info(
|
||||||
|
"Logo for %s (%s) is a placeholder from a failed download; "
|
||||||
|
"retrying the real logo", team_abbreviation, league,
|
||||||
|
)
|
||||||
|
|
||||||
# Try to download the real logo first
|
# Try to download the real logo first
|
||||||
logger.info(f"Attempting to download logo for {team_abbreviation} from {league}")
|
logger.info(f"Attempting to download logo for {team_abbreviation} from {league}")
|
||||||
|
|||||||
@@ -1602,6 +1602,26 @@ class PluginStoreManager:
|
|||||||
'error': f'Manifest missing required fields: {", ".join(missing_fields)}'
|
'error': f'Manifest missing required fields: {", ".join(missing_fields)}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Refuse a plugin that needs a newer core than this one, exactly as
|
||||||
|
# _install_plugin_impl does after its download. Sideloading is an
|
||||||
|
# explicit act rather than an automatic store update, but the floor
|
||||||
|
# is not advice about intent -- it is a statement that the plugin
|
||||||
|
# cannot run here, and letting it through produces the same silent
|
||||||
|
# PluginState.ERROR at load. This was the last of the three routes
|
||||||
|
# in that skipped the check.
|
||||||
|
#
|
||||||
|
# Before the move, so the `finally` below removes the temp tree and
|
||||||
|
# nothing half-installed is left behind.
|
||||||
|
from src import __version__ as core_version
|
||||||
|
from src.plugin_system import compatibility
|
||||||
|
|
||||||
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
|
if not compatible:
|
||||||
|
self.logger.error(
|
||||||
|
"Refusing to install %s from %s: %s",
|
||||||
|
plugin_id, repo_url, reason)
|
||||||
|
return {'success': False, 'error': reason}
|
||||||
|
|
||||||
# Validate version fields consistency (warnings only, not required)
|
# Validate version fields consistency (warnings only, not required)
|
||||||
validation_errors = self._validate_manifest_version_fields(manifest)
|
validation_errors = self._validate_manifest_version_fields(manifest)
|
||||||
if validation_errors:
|
if validation_errors:
|
||||||
@@ -2583,6 +2603,85 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _gate_pulled_commit(self, plugin_id: str, plugin_path: Path,
|
||||||
|
previous_sha: Optional[str]) -> bool:
|
||||||
|
"""Apply the compatibility gate to a commit that arrived via git pull.
|
||||||
|
|
||||||
|
Every other route into an installed plugin goes through
|
||||||
|
``install_plugin``, which gates in ``_install_plugin_impl``. This one
|
||||||
|
did not: a ``git pull`` could deliver a manifest flooring above this
|
||||||
|
core and nothing would notice until the plugin failed to load, which
|
||||||
|
surfaces as one line in the journal and a scoreboard that silently
|
||||||
|
stopped appearing.
|
||||||
|
|
||||||
|
Checked after the pull rather than before it, for the same reason
|
||||||
|
``_install_plugin_impl`` checks after the download: the registry
|
||||||
|
carries no compatibility field, so the incoming floor is only knowable
|
||||||
|
once the new commit is on disk.
|
||||||
|
|
||||||
|
Undone with ``git reset --hard`` rather than by removing the directory.
|
||||||
|
This is a live checkout, the previous commit is still in the object
|
||||||
|
store, and the reset leaves the user on the exact version they were
|
||||||
|
already running -- the same promise ``_reinstall_with_rollback`` makes,
|
||||||
|
reached by the means this path actually has. It is also the gentler
|
||||||
|
option: no window in which the plugin directory does not exist, and no
|
||||||
|
``.standalone-backup-`` debris if the process dies mid-way.
|
||||||
|
|
||||||
|
A manifest that cannot be read is not evidence of incompatibility, so
|
||||||
|
it allows. ``compatibility.check`` refuses only on evidence for the
|
||||||
|
same reason: a wrong refusal breaks a working install, while a wrong
|
||||||
|
allowance degrades to exactly the behaviour this path had before the
|
||||||
|
gate existed.
|
||||||
|
"""
|
||||||
|
manifest_path = plugin_path / "manifest.json"
|
||||||
|
try:
|
||||||
|
with open(manifest_path, 'r', encoding='utf-8') as mf:
|
||||||
|
manifest = json.load(mf)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
self.logger.warning(
|
||||||
|
"Could not read %s after updating %s (%s); allowing the "
|
||||||
|
"update, as an unreadable manifest declares no floor",
|
||||||
|
manifest_path, plugin_id, e)
|
||||||
|
return True
|
||||||
|
|
||||||
|
from src import __version__ as core_version
|
||||||
|
from src.plugin_system import compatibility
|
||||||
|
|
||||||
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
|
if compatible:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.logger.error("Refusing the update to %s: %s", plugin_id, reason)
|
||||||
|
|
||||||
|
if not previous_sha:
|
||||||
|
self.logger.error(
|
||||||
|
"Cannot roll %s back: the commit it was on before the pull is "
|
||||||
|
"unknown. It is now on a version this core cannot run — "
|
||||||
|
"reinstall it from the plugin store.", plugin_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Safe by construction: update_plugin returns before pulling unless the
|
||||||
|
# tree was clean or successfully stashed, so there are no uncommitted
|
||||||
|
# tracked edits for --hard to discard. The stash is not popped on the
|
||||||
|
# success path either, so the reset leaves the working tree exactly
|
||||||
|
# where a successful pull would have. Say "commit", not "changes".
|
||||||
|
reset = subprocess.run(
|
||||||
|
['git', '-C', str(plugin_path), 'reset', '--hard', previous_sha],
|
||||||
|
capture_output=True, text=True, timeout=60, check=False)
|
||||||
|
if reset.returncode != 0:
|
||||||
|
self.logger.error(
|
||||||
|
"CRITICAL: could not roll %s back to commit %s: %s. It is left "
|
||||||
|
"on a version this core cannot run; "
|
||||||
|
"`git -C %s reset --hard %s` restores it.",
|
||||||
|
plugin_id, previous_sha[:7],
|
||||||
|
(reset.stderr or reset.stdout or '').strip(),
|
||||||
|
plugin_path, previous_sha)
|
||||||
|
else:
|
||||||
|
self.logger.info(
|
||||||
|
"Rolled %s back to commit %s; it stays on the version it was "
|
||||||
|
"already running.", plugin_id, previous_sha[:7])
|
||||||
|
return False
|
||||||
|
|
||||||
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
||||||
"""Replace an installed plugin with a fresh install, atomically.
|
"""Replace an installed plugin with a fresh install, atomically.
|
||||||
|
|
||||||
@@ -2860,6 +2959,8 @@ class PluginStoreManager:
|
|||||||
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
||||||
|
|
||||||
stash_info = ""
|
stash_info = ""
|
||||||
|
# Whether the pull can be undone without destroying work.
|
||||||
|
tree_is_recoverable = not has_changes
|
||||||
if has_changes:
|
if has_changes:
|
||||||
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
||||||
try:
|
try:
|
||||||
@@ -2873,12 +2974,37 @@ class PluginStoreManager:
|
|||||||
)
|
)
|
||||||
if stash_result.returncode == 0:
|
if stash_result.returncode == 0:
|
||||||
stash_info = " (local changes were stashed)"
|
stash_info = " (local changes were stashed)"
|
||||||
|
tree_is_recoverable = True
|
||||||
self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}")
|
self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}")
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}")
|
self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}")
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self.logger.warning(f"Stash operation timed out for {plugin_id}, proceeding with pull")
|
self.logger.warning(f"Stash operation timed out for {plugin_id}, proceeding with pull")
|
||||||
|
|
||||||
|
# Do not pull what cannot be un-pulled.
|
||||||
|
#
|
||||||
|
# The compatibility gate below can refuse the commit this
|
||||||
|
# pull brings down, and its only way back is `git reset
|
||||||
|
# --hard`, which discards uncommitted tracked edits. Those
|
||||||
|
# edits are exactly what the stash above exists to protect,
|
||||||
|
# so a stash that failed or timed out leaves the rollback
|
||||||
|
# unable to run without destroying them.
|
||||||
|
#
|
||||||
|
# A pull does not necessarily refuse on a dirty tree -- git
|
||||||
|
# merges happily as long as the incoming commit touches
|
||||||
|
# different files -- so without this the update would
|
||||||
|
# succeed, the gate would refuse, and the reset would take
|
||||||
|
# the user's work with it. Refusing here costs an update in
|
||||||
|
# a case that already went wrong; the alternative costs
|
||||||
|
# data.
|
||||||
|
if not tree_is_recoverable:
|
||||||
|
self.logger.error(
|
||||||
|
"Refusing to update %s: it has local changes that could "
|
||||||
|
"not be stashed, and an incompatible update could then "
|
||||||
|
"only be rolled back by discarding them. Commit or stash "
|
||||||
|
"them by hand, then update.", plugin_id)
|
||||||
|
return False
|
||||||
|
|
||||||
# Pull from the determined remote branch
|
# Pull from the determined remote branch
|
||||||
self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...")
|
self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...")
|
||||||
pull_result = subprocess.run(
|
pull_result = subprocess.run(
|
||||||
@@ -2901,6 +3027,14 @@ class PluginStoreManager:
|
|||||||
elif updated_sha:
|
elif updated_sha:
|
||||||
self.logger.info(f"Plugin {plugin_id} updated to commit {updated_sha[:7]}{stash_info}")
|
self.logger.info(f"Plugin {plugin_id} updated to commit {updated_sha[:7]}{stash_info}")
|
||||||
|
|
||||||
|
# The install gate, at the only point on this path where
|
||||||
|
# it can be answered. Every other route in goes through
|
||||||
|
# install_plugin, which gates in _install_plugin_impl; this
|
||||||
|
# one did not, so a pull could deliver a manifest flooring
|
||||||
|
# above this core and nothing would notice.
|
||||||
|
if not self._gate_pulled_commit(plugin_id, plugin_path, local_sha):
|
||||||
|
return False
|
||||||
|
|
||||||
self._install_dependencies(plugin_path)
|
self._install_dependencies(plugin_path)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,14 @@ it only in passing before reading the cache back.
|
|||||||
Requests submitted *without* a callback keep their payload: polling
|
Requests submitted *without* a callback keep their payload: polling
|
||||||
get_result() is then the only way to collect it, so releasing would break that
|
get_result() is then the only way to collect it, so releasing would break that
|
||||||
contract.
|
contract.
|
||||||
|
|
||||||
|
And the release must happen after EVERY callback, not after each one. Callers
|
||||||
|
that joined an in-flight fetch share a single FetchResult, so releasing per
|
||||||
|
delivery strips the payload out from under everyone still queued -- see
|
||||||
|
TestJoinersAllGetTheData.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
@@ -185,3 +191,90 @@ class TestCacheHitPath:
|
|||||||
cache_key="nfl_2026",
|
cache_key="nfl_2026",
|
||||||
)
|
)
|
||||||
assert service.get_result(req_id).data == PAYLOAD
|
assert service.get_result(req_id).data == PAYLOAD
|
||||||
|
|
||||||
|
|
||||||
|
class TestJoinersAllGetTheData:
|
||||||
|
"""Deduplicated callers share one FetchResult; releasing between them
|
||||||
|
empties it for the rest.
|
||||||
|
|
||||||
|
Not a corner case. A sport's recent, upcoming and live managers all ask for
|
||||||
|
the same season schedule, so the second and third are joiners on almost
|
||||||
|
every cycle. Releasing inside the delivery loop handed the payload to
|
||||||
|
whichever ran first and gave the others `result.data is None`.
|
||||||
|
|
||||||
|
The consequence was worse than a quiet degradation, because consumers do
|
||||||
|
`result.data.get('events')`: they raised AttributeError, the delivery loop
|
||||||
|
caught it, and the whole failure surfaced as a single
|
||||||
|
"Error in callback for request ..." line while that manager silently never
|
||||||
|
received its schedule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class _BlockingSession:
|
||||||
|
"""Holds the fetch open so a second submit lands while in flight."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.release = threading.Event()
|
||||||
|
self.started = threading.Event()
|
||||||
|
|
||||||
|
def get(self, *a, **k):
|
||||||
|
self.started.set()
|
||||||
|
self.release.wait(timeout=5)
|
||||||
|
return _resp()
|
||||||
|
|
||||||
|
def test_every_joiner_is_handed_the_payload(self, service):
|
||||||
|
session = self._BlockingSession()
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def record(name):
|
||||||
|
# Read it the way the sport managers do. `result.data['events']`
|
||||||
|
# would raise TypeError on None; `.get` raises AttributeError,
|
||||||
|
# which is the error actually seen in the field.
|
||||||
|
def cb(result):
|
||||||
|
seen[name] = result.data.get('events') if result.data else None
|
||||||
|
return cb
|
||||||
|
|
||||||
|
with patch.object(service, "session", session):
|
||||||
|
first = service.submit_fetch_request(
|
||||||
|
sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026",
|
||||||
|
callback=record("first"), max_retries=0)
|
||||||
|
assert session.started.wait(timeout=5)
|
||||||
|
|
||||||
|
joined = service.submit_fetch_request(
|
||||||
|
sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026",
|
||||||
|
callback=record("second"), max_retries=0)
|
||||||
|
# Without coalescing these are two independent fetches that each
|
||||||
|
# own their result, and the test proves nothing about sharing.
|
||||||
|
assert joined == first, "the joiner should share the in-flight id"
|
||||||
|
|
||||||
|
session.release.set()
|
||||||
|
_wait(service, first)
|
||||||
|
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while len(seen) < 2 and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
assert set(seen) == {"first", "second"}, f"both must be called, got {seen}"
|
||||||
|
for name, events in seen.items():
|
||||||
|
assert events is not None, (
|
||||||
|
f"{name!r} was handed a released payload: the result was "
|
||||||
|
f"emptied before every callback had been delivered")
|
||||||
|
assert len(events) == 50, f"{name!r} got {events!r}"
|
||||||
|
|
||||||
|
def test_the_payload_is_still_released_once_they_have_all_had_it(self, service):
|
||||||
|
"""The memory fix must survive the ordering fix."""
|
||||||
|
session = self._BlockingSession()
|
||||||
|
|
||||||
|
with patch.object(service, "session", session):
|
||||||
|
first = service.submit_fetch_request(
|
||||||
|
sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026",
|
||||||
|
callback=lambda r: None, max_retries=0)
|
||||||
|
assert session.started.wait(timeout=5)
|
||||||
|
service.submit_fetch_request(
|
||||||
|
sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026",
|
||||||
|
callback=lambda r: None, max_retries=0)
|
||||||
|
session.release.set()
|
||||||
|
_wait_for_release(service, first)
|
||||||
|
|
||||||
|
stored = service.get_result(first)
|
||||||
|
assert stored is not None and stored.data is None, (
|
||||||
|
"the payload must still be dropped once every callback has run")
|
||||||
|
|||||||
@@ -47,17 +47,12 @@ class _SwapSpy:
|
|||||||
def __init__(self, matrix):
|
def __init__(self, matrix):
|
||||||
self.matrix = matrix
|
self.matrix = matrix
|
||||||
self.count = 0
|
self.count = 0
|
||||||
self.last_frame_hold = None
|
|
||||||
self._orig = matrix.SwapOnVSync
|
self._orig = matrix.SwapOnVSync
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
def counting(canvas, *args):
|
def counting(canvas):
|
||||||
# *args carries framerate_fraction, which display_manager passes so
|
|
||||||
# a frame can be held for several refreshes. Signature must match
|
|
||||||
# the real binding or the spy hides a TypeError as a failed push.
|
|
||||||
self.count += 1
|
self.count += 1
|
||||||
self.last_frame_hold = args[0] if args else 1
|
return self._orig(canvas)
|
||||||
return self._orig(canvas, *args)
|
|
||||||
self.matrix.SwapOnVSync = counting
|
self.matrix.SwapOnVSync = counting
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -114,11 +109,6 @@ class TestDirtyTracking:
|
|||||||
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
|
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
|
||||||
dm.update_display() # push + snapshot write (first frame)
|
dm.update_display() # push + snapshot write (first frame)
|
||||||
assert os.path.exists(dm._snapshot_path)
|
assert os.path.exists(dm._snapshot_path)
|
||||||
# Backdate the file so the "was it bumped?" check below cannot be
|
|
||||||
# defeated by filesystem mtime granularity -- on Windows two writes in
|
|
||||||
# the same tick get identical timestamps, which made this test fail
|
|
||||||
# roughly two runs in three regardless of the code under test.
|
|
||||||
os.utime(dm._snapshot_path, (time.time() - 60, time.time() - 60))
|
|
||||||
first_mtime = os.path.getmtime(dm._snapshot_path)
|
first_mtime = os.path.getmtime(dm._snapshot_path)
|
||||||
|
|
||||||
# Age the write/touch bookkeeping past TOUCH_INTERVAL so the next
|
# Age the write/touch bookkeeping past TOUCH_INTERVAL so the next
|
||||||
@@ -135,62 +125,6 @@ class TestDirtyTracking:
|
|||||||
assert os.path.getmtime(dm._snapshot_path) > first_mtime
|
assert os.path.getmtime(dm._snapshot_path) > first_mtime
|
||||||
|
|
||||||
|
|
||||||
class TestScrollLock:
|
|
||||||
"""Dirty tracking must not skip the panel push while a scroll is running.
|
|
||||||
|
|
||||||
SwapOnVSync is what paces the render loop, so skipping it also skips the
|
|
||||||
wait for the panel. A duplicate frame therefore returns early -- ~8ms
|
|
||||||
instead of ~10ms on a 100Hz panel -- which advances the strip only 0.8px
|
|
||||||
instead of 1.0px, which makes the NEXT frame more likely to be a duplicate
|
|
||||||
too. That is self-sustaining: measured at ~20% duplicate frames mid-scroll
|
|
||||||
on the odds ticker against essentially zero on a lighter plugin with
|
|
||||||
identical scroll settings. Pushing an identical frame costs one canvas
|
|
||||||
copy; falling out of vsync lock costs smooth motion.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_identical_frames_still_push_while_scrolling(self, dm):
|
|
||||||
dm.draw.rectangle([0, 0, 12, 12], fill=(0, 0, 255))
|
|
||||||
dm.update_display()
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
try:
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.count == 3, "scrolling must stay locked to the panel"
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
def test_identical_frames_are_skipped_when_not_scrolling(self, dm):
|
|
||||||
"""The optimisation still applies to static content."""
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
dm.draw.rectangle([0, 0, 14, 14], fill=(255, 0, 255))
|
|
||||||
dm.update_display()
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.count == 0
|
|
||||||
|
|
||||||
def test_stale_scrolling_state_stops_forcing_pushes(self, dm):
|
|
||||||
"""A plugin that stops scrolling without saying so must not pin the
|
|
||||||
panel into always-push forever. is_currently_scrolling() expires on
|
|
||||||
its own inactivity threshold, and the skip has to come back with it."""
|
|
||||||
dm.draw.rectangle([0, 0, 16, 16], fill=(0, 255, 255))
|
|
||||||
dm.update_display()
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
try:
|
|
||||||
# Backdate the activity marker past the inactivity threshold.
|
|
||||||
dm._scrolling_state['last_scroll_activity'] = (
|
|
||||||
time.time() - dm._scrolling_state['scroll_inactivity_threshold'] - 1.0)
|
|
||||||
assert dm.is_currently_scrolling() is False
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.count == 0
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
|
|
||||||
class TestKillSwitch:
|
class TestKillSwitch:
|
||||||
def test_dirty_tracking_can_be_disabled(self, dm):
|
def test_dirty_tracking_can_be_disabled(self, dm):
|
||||||
dm._dirty_tracking_enabled = False
|
dm._dirty_tracking_enabled = False
|
||||||
@@ -226,89 +160,3 @@ class TestKillSwitch:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(pytest.main([__file__, "-v"]))
|
sys.exit(pytest.main([__file__, "-v"]))
|
||||||
|
|
||||||
|
|
||||||
class TestFrameHold:
|
|
||||||
"""Holding a frame for N refreshes is how a scroll runs slower than one
|
|
||||||
pixel per refresh without fractional pixel positions."""
|
|
||||||
|
|
||||||
def test_hold_reaches_swap_on_vsync(self, dm):
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
dm.set_frame_hold(3)
|
|
||||||
try:
|
|
||||||
dm.draw.rectangle([0, 0, 9, 9], fill=(120, 0, 200))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.count == 1
|
|
||||||
assert spy.last_frame_hold == 3
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
def test_default_is_every_refresh(self, dm):
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
try:
|
|
||||||
dm.draw.rectangle([0, 0, 11, 11], fill=(0, 120, 200))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.last_frame_hold == 1
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
def test_hold_resets_when_scrolling_stops(self, dm):
|
|
||||||
"""One plugin's pacing must not leak into whatever is on screen next."""
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
dm.set_frame_hold(5)
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
try:
|
|
||||||
dm.draw.rectangle([0, 0, 13, 13], fill=(200, 120, 0))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.last_frame_hold == 1
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad,expected", [(0, 1), (-4, 1), (None, 1), ("x", 1)])
|
|
||||||
def test_unusable_holds_are_ignored_or_floored(self, dm, bad, expected):
|
|
||||||
dm.set_frame_hold(bad)
|
|
||||||
assert dm._frame_hold == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestFrameHoldLifetime:
|
|
||||||
"""The hold must last exactly as long as the scroll that asked for it.
|
|
||||||
|
|
||||||
Plugins share one display manager. A hold applied at plugin construction is
|
|
||||||
wiped the moment any *other* plugin finishes scrolling, so by the time the
|
|
||||||
first plugin renders it is back to one pixel per refresh -- the speed reads
|
|
||||||
correct in the log and is wrong on the panel.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_scrolling_state_carries_the_hold(self, dm):
|
|
||||||
dm.set_scrolling_state(True, frame_hold=4)
|
|
||||||
try:
|
|
||||||
dm.draw.rectangle([0, 0, 7, 7], fill=(10, 200, 10))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.last_frame_hold == 4
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
def test_another_plugin_stopping_does_not_strand_a_hold(self, dm):
|
|
||||||
dm.set_scrolling_state(True, frame_hold=3)
|
|
||||||
dm.set_scrolling_state(False) # some other plugin finishes
|
|
||||||
dm.set_scrolling_state(True) # a plugin that wants no hold
|
|
||||||
try:
|
|
||||||
dm.draw.rectangle([0, 0, 6, 6], fill=(200, 10, 10))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.last_frame_hold == 1
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|
||||||
def test_default_keeps_previous_behaviour(self, dm):
|
|
||||||
"""Callers that never heard of frame holds get one frame per refresh."""
|
|
||||||
dm.set_scrolling_state(True)
|
|
||||||
try:
|
|
||||||
assert dm._frame_hold == 1
|
|
||||||
finally:
|
|
||||||
dm.set_scrolling_state(False)
|
|
||||||
|
|||||||
@@ -8,11 +8,27 @@ ensure_logo_directory, and the download_missing_logo function path
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, Mock, MagicMock
|
from unittest.mock import patch, Mock, MagicMock
|
||||||
|
|
||||||
from src.logo_downloader import LogoDownloader
|
from PIL import Image
|
||||||
|
from PIL.PngImagePlugin import PngInfo
|
||||||
|
|
||||||
|
from src.logo_downloader import (
|
||||||
|
PLACEHOLDER_BG,
|
||||||
|
PLACEHOLDER_MARKER,
|
||||||
|
PLACEHOLDER_RETRY_SECONDS,
|
||||||
|
PLACEHOLDER_SIZE,
|
||||||
|
LogoDownloader,
|
||||||
|
download_missing_logo,
|
||||||
|
is_placeholder_logo,
|
||||||
|
placeholder_age_seconds,
|
||||||
|
refresh_placeholder_timestamp,
|
||||||
|
should_attempt_download,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -127,3 +143,211 @@ class TestEnsureLogoDirectory:
|
|||||||
with patch("builtins.open", side_effect=mock_open):
|
with patch("builtins.open", side_effect=mock_open):
|
||||||
result = downloader.ensure_logo_directory(test_dir)
|
result = downloader.ensure_logo_directory(test_dir)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Placeholder detection and retry
|
||||||
|
#
|
||||||
|
# A failed download used to be cached as a placeholder wearing the real logo's
|
||||||
|
# filename, and download_missing_logo returned early on "the file exists". One
|
||||||
|
# transient failure therefore pinned a team to a grey box permanently.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPlaceholderLogos:
|
||||||
|
def _placeholder(self, tmp_path, abbrev="COLL"):
|
||||||
|
downloader = LogoDownloader()
|
||||||
|
assert downloader.create_placeholder_logo(abbrev, str(tmp_path)) is True
|
||||||
|
return tmp_path / f"{abbrev}.png"
|
||||||
|
|
||||||
|
def test_generated_placeholder_is_recognised(self, tmp_path):
|
||||||
|
assert is_placeholder_logo(self._placeholder(tmp_path)) is True
|
||||||
|
|
||||||
|
def test_real_logo_is_not_a_placeholder(self, tmp_path):
|
||||||
|
real = tmp_path / "REAL.png"
|
||||||
|
Image.new("RGBA", (500, 500), (12, 34, 56, 255)).save(real)
|
||||||
|
assert is_placeholder_logo(real) is False
|
||||||
|
|
||||||
|
def test_legacy_unmarked_placeholder_is_recognised(self, tmp_path):
|
||||||
|
"""Placeholders written before the marker existed must still be caught.
|
||||||
|
|
||||||
|
They are already sitting on users' disks; if they were not recognised
|
||||||
|
those teams would stay grey boxes forever even after this fix.
|
||||||
|
"""
|
||||||
|
legacy = tmp_path / "LEGACY.png"
|
||||||
|
Image.new("RGBA", PLACEHOLDER_SIZE, PLACEHOLDER_BG).save(legacy)
|
||||||
|
assert is_placeholder_logo(legacy) is True
|
||||||
|
|
||||||
|
def test_same_size_but_different_colour_is_not_a_placeholder(self, tmp_path):
|
||||||
|
real = tmp_path / "SMALL.png"
|
||||||
|
Image.new("RGBA", PLACEHOLDER_SIZE, (10, 200, 10, 255)).save(real)
|
||||||
|
assert is_placeholder_logo(real) is False
|
||||||
|
|
||||||
|
def test_missing_file_is_not_a_placeholder(self, tmp_path):
|
||||||
|
assert is_placeholder_logo(tmp_path / "nope.png") is False
|
||||||
|
|
||||||
|
def test_existing_real_logo_short_circuits_without_downloading(self, tmp_path):
|
||||||
|
real = tmp_path / "REAL.png"
|
||||||
|
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||||
|
with patch.object(LogoDownloader, "download_logo") as download:
|
||||||
|
assert download_missing_logo(
|
||||||
|
"afl", "1", "REAL", real, logo_url="http://example/x.png") is True
|
||||||
|
download.assert_not_called()
|
||||||
|
|
||||||
|
def _age_placeholder(self, path, seconds):
|
||||||
|
"""Rewrite a placeholder's marker so it reads as `seconds` old."""
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - seconds))
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||||
|
|
||||||
|
def test_stale_placeholder_triggers_a_retry(self, tmp_path):
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
self._age_placeholder(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||||
|
assert placeholder_age_seconds(path) > PLACEHOLDER_RETRY_SECONDS
|
||||||
|
|
||||||
|
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
|
||||||
|
assert download_missing_logo(
|
||||||
|
"afl", "1", "COLL", path,
|
||||||
|
logo_url="http://example/coll.png") is True
|
||||||
|
download.assert_called_once()
|
||||||
|
|
||||||
|
def test_placeholder_age_survives_an_mtime_touch(self, tmp_path):
|
||||||
|
"""The age comes from the stamp, not the filesystem.
|
||||||
|
|
||||||
|
Anything that rewrites file times -- a backup restore, an rsync, a
|
||||||
|
permissions fix script -- would otherwise reset the retry clock.
|
||||||
|
"""
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
self._age_placeholder(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||||
|
now = time.time()
|
||||||
|
os.utime(path, (now, now))
|
||||||
|
assert placeholder_age_seconds(path) > PLACEHOLDER_RETRY_SECONDS
|
||||||
|
|
||||||
|
def test_fresh_placeholder_does_not_retry(self, tmp_path):
|
||||||
|
"""Rate limiting: a placeholder written seconds ago must not re-download.
|
||||||
|
|
||||||
|
Without this the fix would trade a permanent grey box for an ESPN
|
||||||
|
request on every frame.
|
||||||
|
"""
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
with patch.object(LogoDownloader, "download_logo") as download:
|
||||||
|
assert download_missing_logo(
|
||||||
|
"afl", "1", "COLL", path,
|
||||||
|
logo_url="http://example/coll.png") is True
|
||||||
|
download.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadEligibility:
|
||||||
|
"""One rule, shared by every download site.
|
||||||
|
|
||||||
|
The two bulk loops and the single-logo path each had their own idea of what
|
||||||
|
counted as "already have it", which is how one of them ended up retrying
|
||||||
|
fresh placeholders and the other skipping stale ones forever.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _placeholder(self, tmp_path, abbrev="COLL"):
|
||||||
|
assert LogoDownloader().create_placeholder_logo(abbrev, str(tmp_path))
|
||||||
|
return tmp_path / f"{abbrev}.png"
|
||||||
|
|
||||||
|
def _age(self, path, seconds):
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - seconds))
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||||
|
|
||||||
|
def test_missing_file_is_eligible(self, tmp_path):
|
||||||
|
assert should_attempt_download(tmp_path / "nope.png") is True
|
||||||
|
|
||||||
|
def test_real_logo_is_not_eligible(self, tmp_path):
|
||||||
|
real = tmp_path / "REAL.png"
|
||||||
|
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||||
|
assert should_attempt_download(real) is False
|
||||||
|
|
||||||
|
def test_force_download_beats_a_real_logo(self, tmp_path):
|
||||||
|
real = tmp_path / "REAL.png"
|
||||||
|
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||||
|
assert should_attempt_download(real, force_download=True) is True
|
||||||
|
|
||||||
|
def test_fresh_placeholder_is_not_eligible(self, tmp_path):
|
||||||
|
assert should_attempt_download(self._placeholder(tmp_path)) is False
|
||||||
|
|
||||||
|
def test_stale_placeholder_is_eligible(self, tmp_path):
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||||
|
assert should_attempt_download(path) is True
|
||||||
|
|
||||||
|
def test_league_bulk_loop_skips_a_fresh_placeholder(self, tmp_path):
|
||||||
|
"""A bulk pass honours the same back-off as everything else."""
|
||||||
|
self._placeholder(tmp_path, "AAA")
|
||||||
|
downloader = LogoDownloader()
|
||||||
|
teams = [{"abbreviation": "AAA", "display_name": "A", "logo_url": "http://x/a.png"}]
|
||||||
|
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||||
|
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||||
|
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||||
|
with patch.object(LogoDownloader, "download_logo") as download:
|
||||||
|
downloader.download_missing_logos_for_league("nfl")
|
||||||
|
download.assert_not_called()
|
||||||
|
|
||||||
|
def test_league_bulk_loop_retries_a_stale_placeholder(self, tmp_path):
|
||||||
|
path = self._placeholder(tmp_path, "AAA")
|
||||||
|
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||||
|
downloader = LogoDownloader()
|
||||||
|
teams = [{"abbreviation": "AAA", "display_name": "A", "logo_url": "http://x/a.png"}]
|
||||||
|
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||||
|
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||||
|
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||||
|
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
|
||||||
|
downloader.download_missing_logos_for_league("nfl")
|
||||||
|
download.assert_called_once()
|
||||||
|
|
||||||
|
def test_ncaa_bulk_loop_retries_a_stale_placeholder(self, tmp_path):
|
||||||
|
"""This loop skipped placeholders forever; it now shares the rule."""
|
||||||
|
path = self._placeholder(tmp_path, "AAA")
|
||||||
|
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||||
|
downloader = LogoDownloader()
|
||||||
|
teams = [{"abbreviation": "AAA", "display_name": "A",
|
||||||
|
"logo_url": "http://x/a.png", "category": "FBS",
|
||||||
|
"conference": "SEC"}]
|
||||||
|
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||||
|
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||||
|
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||||
|
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
|
||||||
|
downloader.download_all_ncaa_football_logos()
|
||||||
|
download.assert_called_once()
|
||||||
|
|
||||||
|
def test_ncaa_bulk_loop_skips_a_fresh_placeholder(self, tmp_path):
|
||||||
|
self._placeholder(tmp_path, "AAA")
|
||||||
|
downloader = LogoDownloader()
|
||||||
|
teams = [{"abbreviation": "AAA", "display_name": "A",
|
||||||
|
"logo_url": "http://x/a.png", "category": "FBS",
|
||||||
|
"conference": "SEC"}]
|
||||||
|
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||||
|
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||||
|
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||||
|
with patch.object(LogoDownloader, "download_logo") as download:
|
||||||
|
downloader.download_all_ncaa_football_logos()
|
||||||
|
download.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshPlaceholderTimestamp:
|
||||||
|
def test_restarts_the_back_off(self, tmp_path):
|
||||||
|
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path))
|
||||||
|
path = tmp_path / "COLL.png"
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - (PLACEHOLDER_RETRY_SECONDS + 60)))
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||||
|
assert should_attempt_download(path) is True
|
||||||
|
|
||||||
|
assert refresh_placeholder_timestamp(path) is True
|
||||||
|
assert should_attempt_download(path) is False
|
||||||
|
|
||||||
|
def test_refuses_to_touch_a_real_logo(self, tmp_path):
|
||||||
|
real = tmp_path / "REAL.png"
|
||||||
|
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||||
|
before = real.read_bytes()
|
||||||
|
assert refresh_placeholder_timestamp(real) is False
|
||||||
|
assert real.read_bytes() == before
|
||||||
|
|
||||||
|
def test_missing_file_is_not_an_error(self, tmp_path):
|
||||||
|
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
|
||||||
|
|||||||
@@ -421,3 +421,76 @@ class TestSessionConfiguration:
|
|||||||
def test_user_agent_and_accept_headers(self, helper):
|
def test_user_agent_and_accept_headers(self, helper):
|
||||||
assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0"
|
assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0"
|
||||||
assert helper.session.headers["Accept"] == "image/*"
|
assert helper.session.headers["Accept"] == "image/*"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStalePlaceholderHandling:
|
||||||
|
"""load_logo_with_download must not be fooled by a cached placeholder.
|
||||||
|
|
||||||
|
A placeholder wears the real logo's filename, so both the file cache and
|
||||||
|
the in-memory cache can hold one and look like a hit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _placeholder(self, tmp_path, abbrev="COLL"):
|
||||||
|
from src.logo_downloader import LogoDownloader
|
||||||
|
assert LogoDownloader().create_placeholder_logo(abbrev, str(tmp_path))
|
||||||
|
return tmp_path / f"{abbrev}.png"
|
||||||
|
|
||||||
|
def _make_stale(self, path):
|
||||||
|
import time
|
||||||
|
from PIL.PngImagePlugin import PngInfo
|
||||||
|
from src.logo_downloader import PLACEHOLDER_MARKER, PLACEHOLDER_RETRY_SECONDS
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - (PLACEHOLDER_RETRY_SECONDS + 60)))
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||||
|
|
||||||
|
def test_fresh_placeholder_is_served_without_a_download(self, helper, tmp_path):
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
with patch.object(LogoHelper, "_download_logo") as download:
|
||||||
|
assert helper.load_logo_with_download("COLL", path, "http://x/c.png") is not None
|
||||||
|
download.assert_not_called()
|
||||||
|
|
||||||
|
def test_stale_placeholder_triggers_a_download(self, helper, tmp_path):
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
self._make_stale(path)
|
||||||
|
with patch.object(LogoHelper, "_download_logo") as download:
|
||||||
|
helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||||
|
download.assert_called_once()
|
||||||
|
|
||||||
|
def test_replacement_logo_is_not_masked_by_the_cached_placeholder(self, helper, tmp_path):
|
||||||
|
"""The bug this guards: load_logo answers from cache before the disk.
|
||||||
|
|
||||||
|
Without invalidation the freshly downloaded logo would not appear until
|
||||||
|
the process restarted.
|
||||||
|
"""
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
first = helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||||
|
assert first is not None
|
||||||
|
|
||||||
|
self._make_stale(path)
|
||||||
|
|
||||||
|
def fake_download(_self, _url, file_path):
|
||||||
|
Image.new("RGB", (500, 500), (7, 8, 9)).save(file_path, format="PNG")
|
||||||
|
|
||||||
|
with patch.object(LogoHelper, "_download_logo", fake_download):
|
||||||
|
second = helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||||
|
|
||||||
|
assert second is not None
|
||||||
|
from src.logo_downloader import is_placeholder_logo
|
||||||
|
assert is_placeholder_logo(path) is False
|
||||||
|
assert second.getpixel((0, 0))[:3] == (7, 8, 9)
|
||||||
|
|
||||||
|
def test_failed_retry_restarts_the_back_off(self, helper, tmp_path):
|
||||||
|
"""Otherwise a stale placeholder means a download attempt per call."""
|
||||||
|
from src.logo_downloader import should_attempt_download
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
self._make_stale(path)
|
||||||
|
assert should_attempt_download(path) is True
|
||||||
|
|
||||||
|
def boom(_self, _url, _file_path):
|
||||||
|
raise OSError("network down")
|
||||||
|
|
||||||
|
with patch.object(LogoHelper, "_download_logo", boom):
|
||||||
|
helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||||
|
|
||||||
|
assert should_attempt_download(path) is False
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ The rules being pinned here, in priority order:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
@@ -146,9 +147,18 @@ def store(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
class TestInstallGate:
|
class TestInstallGate:
|
||||||
"""`install_plugin` is the chokepoint: `_reinstall_with_rollback` calls it,
|
"""`install_plugin` is the chokepoint for every route that re-downloads:
|
||||||
so gating there covers updates too, and a refused update restores the
|
`_reinstall_with_rollback` calls it, so a refused update restores the
|
||||||
version the user already had."""
|
version the user already had.
|
||||||
|
|
||||||
|
It is not the *only* route in, and saying so here once is cheaper than
|
||||||
|
rediscovering it. `update_plugin` also has a git branch that pulls in
|
||||||
|
place and never re-downloads; that one is gated separately by
|
||||||
|
`_gate_pulled_commit` and pinned in `TestGitPullGate` below. A third
|
||||||
|
route, `install_from_url` (sideloading from a URL), is gated in that
|
||||||
|
function and pinned in `TestSideloadGate`. All three refuse on the same
|
||||||
|
rule.
|
||||||
|
"""
|
||||||
|
|
||||||
def _install_with_manifest(self, store, manifest, core_version, monkeypatch):
|
def _install_with_manifest(self, store, manifest, core_version, monkeypatch):
|
||||||
mgr, plugins_dir = store
|
mgr, plugins_dir = store
|
||||||
@@ -208,6 +218,291 @@ class TestInstallGate:
|
|||||||
assert (path / "manifest.json").exists()
|
assert (path / "manifest.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSideloadGate:
|
||||||
|
"""`install_from_url` never looked at the core version.
|
||||||
|
|
||||||
|
Sideloading is an explicit act rather than an automatic store update, so
|
||||||
|
the argument for gating it is different: not "the user did not choose
|
||||||
|
this", but that the floor states the plugin *cannot run here*. Letting it
|
||||||
|
through produces the same silent PluginState.ERROR at load that the store
|
||||||
|
gate exists to prevent, and the user who typed the URL is no better placed
|
||||||
|
to diagnose it than one who pressed Update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _sideload(self, store, manifest, core_version, monkeypatch):
|
||||||
|
mgr, plugins_dir = store
|
||||||
|
plugin_id = manifest["id"]
|
||||||
|
|
||||||
|
def fake_clone(repo_url, target_path, branches=None):
|
||||||
|
target_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(target_path / "manifest.json").write_text(
|
||||||
|
json.dumps(manifest), encoding="utf-8")
|
||||||
|
(target_path / "manager.py").write_text(
|
||||||
|
"class P: pass\n", encoding="utf-8")
|
||||||
|
return "main"
|
||||||
|
|
||||||
|
monkeypatch.setattr(mgr, "_install_via_git", fake_clone)
|
||||||
|
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, "__version__", core_version)
|
||||||
|
return mgr.install_from_url("https://example.invalid/plugin"), \
|
||||||
|
plugins_dir / plugin_id
|
||||||
|
|
||||||
|
def test_refuses_a_plugin_that_needs_a_newer_core(self, store, monkeypatch):
|
||||||
|
manifest = {
|
||||||
|
"id": "sideload-newer", "name": "Sideload", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
||||||
|
}
|
||||||
|
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "9.9.9" in result["error"], result["error"]
|
||||||
|
assert not path.exists(), (
|
||||||
|
"a refused sideload must not leave the plugin installed")
|
||||||
|
|
||||||
|
def test_allows_a_compatible_plugin(self, store, monkeypatch):
|
||||||
|
"""The guard against over-refusing: a gate that blocks everything
|
||||||
|
passes the test above and breaks sideloading entirely."""
|
||||||
|
manifest = {
|
||||||
|
"id": "sideload-fine", "name": "Sideload", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
|
||||||
|
}
|
||||||
|
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)
|
||||||
|
|
||||||
|
assert result["success"] is True, result.get("error")
|
||||||
|
assert (path / "manifest.json").exists()
|
||||||
|
|
||||||
|
def test_untrustworthy_core_does_not_block_a_2_0_0_floor(
|
||||||
|
self, store, monkeypatch):
|
||||||
|
"""Same rule as the other two routes: a v3.1.0 release reports 1.0.0,
|
||||||
|
and nearly every published manifest floors at 2.0.0."""
|
||||||
|
manifest = {
|
||||||
|
"id": "sideload-floored", "name": "Sideload", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "versions": [{"ledmatrix_min": "2.0.0"}],
|
||||||
|
}
|
||||||
|
result, _ = self._sideload(store, manifest, "1.0.0", monkeypatch)
|
||||||
|
|
||||||
|
assert result["success"] is True, result.get("error")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# The git-pull update path — the one route that does not re-download
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _git_available() -> bool:
|
||||||
|
# OSError, not just a non-zero exit: with no git on PATH subprocess raises
|
||||||
|
# FileNotFoundError, and this runs at import time -- before skipif can act,
|
||||||
|
# so the whole module would error out instead of skipping.
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
['git', '--version'], capture_output=True).returncode == 0
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_HAS_GIT = _git_available()
|
||||||
|
|
||||||
|
|
||||||
|
def _git(*args, cwd):
|
||||||
|
return subprocess.run(['git', *args], cwd=str(cwd),
|
||||||
|
capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_GIT, reason='git not available')
|
||||||
|
class TestGitPullGate:
|
||||||
|
"""A plugin installed as a git checkout updates by pulling in place, so it
|
||||||
|
never passes through `install_plugin` and was never gated.
|
||||||
|
|
||||||
|
Real git repositories rather than mocks, because the claim under test is
|
||||||
|
that `git reset --hard` puts the checkout back — a mock of git would only
|
||||||
|
prove the call was made, which is the easy half.
|
||||||
|
|
||||||
|
Only the handful of registry entries with no `plugin_path` reach this path
|
||||||
|
in practice; monorepo plugins install as archives and update through
|
||||||
|
`_reinstall_with_rollback`. It is gated anyway because the sunset rule in
|
||||||
|
`docs/plugin-development/08-shared-sports-code.md` states the core enforces
|
||||||
|
the floor "at install/update time", and a precondition that is documented
|
||||||
|
but not true is worse than one that is merely missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def checkout(self, tmp_path, monkeypatch):
|
||||||
|
"""An origin repo holding a plugin, and a clone of it installed as
|
||||||
|
`plugin-repos/gitplug`, with the store pointed at it."""
|
||||||
|
from src.plugin_system.store_manager import PluginStoreManager
|
||||||
|
|
||||||
|
origin = tmp_path / 'origin'
|
||||||
|
origin.mkdir()
|
||||||
|
_git('init', '--initial-branch=main', '--bare', cwd=origin)
|
||||||
|
|
||||||
|
seed = tmp_path / 'seed'
|
||||||
|
_git('clone', str(origin), str(seed), cwd=tmp_path)
|
||||||
|
_git('config', 'user.email', 'test@example.com', cwd=seed)
|
||||||
|
_git('config', 'user.name', 'Test', cwd=seed)
|
||||||
|
(seed / 'manifest.json').write_text(json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "2.0.0",
|
||||||
|
}), encoding='utf-8')
|
||||||
|
(seed / 'manager.py').write_text("class P: pass\n", encoding='utf-8')
|
||||||
|
_git('add', '-A', cwd=seed)
|
||||||
|
_git('commit', '-m', 'initial', cwd=seed)
|
||||||
|
_git('push', '-u', 'origin', 'main', cwd=seed)
|
||||||
|
|
||||||
|
plugins_dir = tmp_path / 'plugin-repos'
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
work = plugins_dir / 'gitplug'
|
||||||
|
_git('clone', str(origin), str(work), cwd=tmp_path)
|
||||||
|
_git('config', 'user.email', 'test@example.com', cwd=work)
|
||||||
|
_git('config', 'user.name', 'Test', cwd=work)
|
||||||
|
|
||||||
|
mgr = PluginStoreManager(plugins_dir=str(plugins_dir))
|
||||||
|
mgr.logger = MagicMock()
|
||||||
|
# No registry entry, so update_plugin takes the plain-pull branch
|
||||||
|
# rather than the remote-mismatch or already-current shortcuts.
|
||||||
|
monkeypatch.setattr(mgr, 'fetch_registry', lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(mgr, 'get_plugin_info', lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(mgr, '_install_dependencies', lambda *a, **k: True)
|
||||||
|
return mgr, seed, work
|
||||||
|
|
||||||
|
def _push(self, seed, manifest_text):
|
||||||
|
(seed / 'manifest.json').write_text(manifest_text, encoding='utf-8')
|
||||||
|
_git('add', '-A', cwd=seed)
|
||||||
|
_git('commit', '-m', 'update', cwd=seed)
|
||||||
|
_git('push', 'origin', 'main', cwd=seed)
|
||||||
|
|
||||||
|
def _head(self, work):
|
||||||
|
return _git('rev-parse', 'HEAD', cwd=work).stdout.strip()
|
||||||
|
|
||||||
|
def test_refuses_a_pulled_commit_that_needs_a_newer_core(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
before = self._head(work)
|
||||||
|
self._push(seed, json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
||||||
|
}))
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
||||||
|
assert mgr.update_plugin('gitplug') is False
|
||||||
|
|
||||||
|
assert self._head(work) == before, (
|
||||||
|
"a refused update must leave the checkout on the commit it was "
|
||||||
|
"already running, not on the one it cannot load")
|
||||||
|
floor = json.loads((work / 'manifest.json').read_text())
|
||||||
|
assert floor['min_ledmatrix_version'] == '2.0.0', (
|
||||||
|
"the reset must restore the working tree, not just the ref")
|
||||||
|
|
||||||
|
def test_allows_a_pulled_commit_the_core_can_run(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
"""The guard against over-refusing. A gate that refuses everything
|
||||||
|
passes the test above and breaks every update."""
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
before = self._head(work)
|
||||||
|
self._push(seed, json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
|
||||||
|
}))
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
||||||
|
assert mgr.update_plugin('gitplug') is True
|
||||||
|
assert self._head(work) != before
|
||||||
|
|
||||||
|
def test_an_unreadable_manifest_after_pull_is_not_a_refusal(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
"""Rule 1 of this file — refuse only on evidence. A manifest that
|
||||||
|
will not parse declares no floor, so it is not evidence of anything."""
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
self._push(seed, '{ this is not json')
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
||||||
|
assert mgr.update_plugin('gitplug') is True
|
||||||
|
|
||||||
|
def test_an_untrustworthy_core_does_not_block_a_2_0_0_floor_on_pull(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
"""The same regression guard as
|
||||||
|
`test_untrustworthy_core_does_not_block_installs`, because this path
|
||||||
|
now shares that rule: a v3.1.0 release reports 1.0.0, and nearly every
|
||||||
|
published manifest floors at 2.0.0."""
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
before = self._head(work)
|
||||||
|
self._push(seed, json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "2.0.0",
|
||||||
|
"description": "changed",
|
||||||
|
}))
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '1.0.0')
|
||||||
|
assert mgr.update_plugin('gitplug') is True
|
||||||
|
assert self._head(work) != before
|
||||||
|
|
||||||
|
def test_a_failed_stash_stops_the_update_before_pulling(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
"""Uncommitted work is not collateral for the gate.
|
||||||
|
|
||||||
|
The gate's only rollback is `git reset --hard`, which discards
|
||||||
|
uncommitted tracked edits. update_plugin stashes them first -- but when
|
||||||
|
that stash fails it used to pull anyway, and a pull touching different
|
||||||
|
files succeeds on a dirty tree. Refuse, refuse the rollback's rollback,
|
||||||
|
and the user's edits go with it.
|
||||||
|
"""
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
before = self._head(work)
|
||||||
|
(work / 'manager.py').write_text(
|
||||||
|
"class P:\n MINE = 'do not lose this'\n", encoding='utf-8')
|
||||||
|
self._push(seed, json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
||||||
|
}))
|
||||||
|
|
||||||
|
real_run = subprocess.run
|
||||||
|
|
||||||
|
def fail_stash(cmd, *a, **k):
|
||||||
|
if isinstance(cmd, list) and 'stash' in cmd:
|
||||||
|
return subprocess.CompletedProcess(cmd, 1, '', 'stash boom')
|
||||||
|
return real_run(cmd, *a, **k)
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
||||||
|
monkeypatch.setattr(
|
||||||
|
'src.plugin_system.store_manager.subprocess.run', fail_stash)
|
||||||
|
assert mgr.update_plugin('gitplug') is False
|
||||||
|
|
||||||
|
assert self._head(work) == before, "must not pull what it cannot undo"
|
||||||
|
assert 'do not lose this' in (work / 'manager.py').read_text(), (
|
||||||
|
"the local edit the stash failed to save must still be there")
|
||||||
|
|
||||||
|
def test_rollback_reports_the_recovery_command_when_git_fails(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
"""If the reset itself fails the user is left on a version that cannot
|
||||||
|
load, so the log line has to carry the command that fixes it —
|
||||||
|
it is the only thing standing between them and a manual reinstall."""
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
self._push(seed, json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
||||||
|
}))
|
||||||
|
|
||||||
|
real_run = subprocess.run
|
||||||
|
|
||||||
|
def fail_reset(cmd, *a, **k):
|
||||||
|
if isinstance(cmd, list) and 'reset' in cmd:
|
||||||
|
return subprocess.CompletedProcess(cmd, 1, '', 'reset boom')
|
||||||
|
return real_run(cmd, *a, **k)
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
||||||
|
monkeypatch.setattr(
|
||||||
|
'src.plugin_system.store_manager.subprocess.run', fail_reset)
|
||||||
|
assert mgr.update_plugin('gitplug') is False
|
||||||
|
|
||||||
|
logged = ' '.join(str(c) for c in mgr.logger.error.call_args_list)
|
||||||
|
assert 'reset --hard' in logged, logged
|
||||||
|
|
||||||
|
|
||||||
class TestLoaderAndStoreAgree:
|
class TestLoaderAndStoreAgree:
|
||||||
"""Both read the same manifests; a disagreement means one of them is
|
"""Both read the same manifests; a disagreement means one of them is
|
||||||
lying to the user."""
|
lying to the user."""
|
||||||
|
|||||||
@@ -1,390 +0,0 @@
|
|||||||
"""Tests for the shared scroll configuration resolver."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from src.common.scroll_config import ( # noqa: E402
|
|
||||||
DEFAULT_PIXELS_PER_SECOND,
|
|
||||||
MAX_PIXELS_PER_FRAME,
|
|
||||||
crisp_ladder,
|
|
||||||
solve_crisp,
|
|
||||||
MAX_PIXELS_PER_SECOND,
|
|
||||||
MIN_PIXELS_PER_SECOND,
|
|
||||||
ScrollSettings,
|
|
||||||
configure,
|
|
||||||
refresh_hz_from_config,
|
|
||||||
resolve,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeHelper:
|
|
||||||
"""Records what configure() applied."""
|
|
||||||
|
|
||||||
def __init__(self, with_optional=True):
|
|
||||||
self.speed = None
|
|
||||||
self.frame_based = None
|
|
||||||
self.target_fps = None
|
|
||||||
if not with_optional:
|
|
||||||
del FakeHelper.set_frame_based_scrolling
|
|
||||||
del FakeHelper.set_target_fps
|
|
||||||
|
|
||||||
def set_scroll_speed(self, speed):
|
|
||||||
self.speed = speed
|
|
||||||
|
|
||||||
def set_frame_based_scrolling(self, enabled):
|
|
||||||
self.frame_based = enabled
|
|
||||||
|
|
||||||
def set_target_fps(self, fps):
|
|
||||||
self.target_fps = fps
|
|
||||||
|
|
||||||
|
|
||||||
class FakeDisplayManager:
|
|
||||||
"""Records the frame hold configure() applies."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.hold = None
|
|
||||||
|
|
||||||
def set_frame_hold(self, refreshes):
|
|
||||||
self.hold = refreshes
|
|
||||||
|
|
||||||
|
|
||||||
class MinimalHelper:
|
|
||||||
"""An older helper exposing only set_scroll_speed."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.speed = None
|
|
||||||
|
|
||||||
def set_scroll_speed(self, speed):
|
|
||||||
self.speed = speed
|
|
||||||
|
|
||||||
|
|
||||||
class TestPrecedence:
|
|
||||||
def test_display_options_pair_wins(self):
|
|
||||||
s = resolve({"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01}})
|
|
||||||
assert s.pixels_per_second == 100.0
|
|
||||||
assert s.source == "display_options.scroll_speed/delay"
|
|
||||||
|
|
||||||
def test_display_block_used_when_options_absent(self):
|
|
||||||
s = resolve({"display": {"scroll_speed": 2.0, "scroll_delay": 0.01}})
|
|
||||||
assert s.pixels_per_second == 200.0
|
|
||||||
assert s.source == "display.scroll_speed/delay"
|
|
||||||
|
|
||||||
def test_root_pair_used_when_both_blocks_absent(self):
|
|
||||||
s = resolve({"scroll_speed": 1.0, "scroll_delay": 0.02})
|
|
||||||
assert s.pixels_per_second == 50.0
|
|
||||||
|
|
||||||
def test_pixels_per_second_used_when_no_pair_given(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 120.0}})
|
|
||||||
assert s.pixels_per_second == 120.0
|
|
||||||
assert s.source == "display_options.scroll_pixels_per_second"
|
|
||||||
|
|
||||||
def test_global_display_is_the_last_resort_before_default(self):
|
|
||||||
s = resolve({}, {"display": {"scroll_speed": 1.0, "scroll_delay": 0.005}})
|
|
||||||
assert s.pixels_per_second == 200.0
|
|
||||||
|
|
||||||
def test_default_when_nothing_configured(self):
|
|
||||||
s = resolve({}, {})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
assert s.source == "default"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeprecatedKeyCannotOverrideExplicitPair:
|
|
||||||
"""Regression for ChuckBuilds/ledmatrix-plugins#408.
|
|
||||||
|
|
||||||
odds-ticker ranked scroll_pixels_per_second above the documented
|
|
||||||
scroll_speed/scroll_delay pair. Because that key carries a schema default,
|
|
||||||
the documented settings became unreachable for every user and the ticker
|
|
||||||
silently ran at the default speed. The pair must win.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_pair_beats_pixels_per_second_in_the_same_block(self):
|
|
||||||
s = resolve(
|
|
||||||
{
|
|
||||||
"display_options": {
|
|
||||||
"scroll_speed": 1.0,
|
|
||||||
"scroll_delay": 0.01,
|
|
||||||
"scroll_pixels_per_second": 50.0, # schema default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert s.pixels_per_second == 100.0
|
|
||||||
assert "scroll_speed/delay" in s.source
|
|
||||||
|
|
||||||
def test_pair_beats_pixels_per_second_in_an_outer_block(self):
|
|
||||||
s = resolve(
|
|
||||||
{
|
|
||||||
"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01},
|
|
||||||
"scroll_pixels_per_second": 50.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert s.pixels_per_second == 100.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestMalformedValues:
|
|
||||||
@pytest.mark.parametrize("bad", [None, "fast", "", {}, [], float("nan")])
|
|
||||||
def test_unusable_speed_falls_through(self, bad):
|
|
||||||
s = resolve({"display_options": {"scroll_speed": bad, "scroll_delay": 0.01}})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad", [0, -5, 0.0])
|
|
||||||
def test_non_positive_values_fall_through(self, bad):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": bad}})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
def test_booleans_are_not_treated_as_numbers(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": True}})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
def test_zero_delay_does_not_divide_by_zero(self):
|
|
||||||
s = resolve({"display_options": {"scroll_speed": 1.0, "scroll_delay": 0}})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
def test_non_dict_blocks_are_ignored(self):
|
|
||||||
s = resolve({"display_options": "nonsense", "display": 5})
|
|
||||||
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
|
|
||||||
class TestClamping:
|
|
||||||
def test_absurdly_fast_is_clamped(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 100000.0}})
|
|
||||||
assert s.pixels_per_second == MAX_PIXELS_PER_SECOND
|
|
||||||
assert s.warning and "clamped" in s.warning
|
|
||||||
|
|
||||||
def test_absurdly_slow_is_clamped(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 0.01}})
|
|
||||||
assert s.pixels_per_second == MIN_PIXELS_PER_SECOND
|
|
||||||
|
|
||||||
|
|
||||||
class TestWholePixelWarning:
|
|
||||||
"""The display rule: whole pixels per refresh, or it judders."""
|
|
||||||
|
|
||||||
def test_no_warning_when_speed_divides_evenly(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 100.0}}, refresh_hz=100)
|
|
||||||
assert s.warning is None
|
|
||||||
assert s.pixels_per_frame == pytest.approx(1.0)
|
|
||||||
|
|
||||||
def test_no_warning_at_an_integer_multiple(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 200.0}}, refresh_hz=100)
|
|
||||||
assert s.warning is None
|
|
||||||
|
|
||||||
def test_warns_on_a_half_pixel_per_frame(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 50.0}}, refresh_hz=100)
|
|
||||||
assert s.warning is not None
|
|
||||||
assert "judder" in s.warning
|
|
||||||
assert "100 px/s" in s.warning
|
|
||||||
|
|
||||||
def test_warns_on_a_fractional_multiple(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 130.0}}, refresh_hz=100)
|
|
||||||
assert s.warning is not None
|
|
||||||
|
|
||||||
def test_respects_a_non_default_refresh_rate(self):
|
|
||||||
s = resolve({"display_options": {"scroll_pixels_per_second": 150.0}}, refresh_hz=150)
|
|
||||||
assert s.warning is None
|
|
||||||
assert s.pixels_per_frame == pytest.approx(1.0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigure:
|
|
||||||
def test_applies_time_based_mode_and_speed(self):
|
|
||||||
helper = FakeHelper()
|
|
||||||
s = configure(helper, {"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01}})
|
|
||||||
assert helper.speed == 100.0
|
|
||||||
assert helper.frame_based is False, "must not use the wall-clock step gate"
|
|
||||||
assert helper.target_fps == 100.0
|
|
||||||
assert s.pixels_per_second == 100.0
|
|
||||||
|
|
||||||
def test_works_against_a_helper_without_optional_methods(self):
|
|
||||||
helper = MinimalHelper()
|
|
||||||
configure(helper, {"display_options": {"scroll_pixels_per_second": 100.0}})
|
|
||||||
assert helper.speed == 100.0
|
|
||||||
|
|
||||||
def test_snapping_removes_the_judder_warning(self, caplog):
|
|
||||||
"""50px/s cannot be shown in whole pixels at 100Hz *per refresh*, but it
|
|
||||||
can as 1px every 2nd refresh -- so once snapped there is nothing to warn
|
|
||||||
about. resolve() alone still warns; configure() resolves it."""
|
|
||||||
assert "judder" in resolve(
|
|
||||||
{"display_options": {"scroll_pixels_per_second": 50.0}},
|
|
||||||
refresh_hz=100).warning
|
|
||||||
helper, dm = FakeHelper(), FakeDisplayManager()
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
settings = configure(
|
|
||||||
helper, {"display_options": {"scroll_pixels_per_second": 50.0}},
|
|
||||||
display_manager=dm)
|
|
||||||
assert settings.warning is None
|
|
||||||
assert not [r for r in caplog.records if "judder" in r.getMessage()]
|
|
||||||
|
|
||||||
def test_reports_the_hold_without_applying_it(self):
|
|
||||||
"""The hold belongs to a scroll, not a plugin's lifetime -- one left set
|
|
||||||
at construction is wiped as soon as any other plugin stops scrolling.
|
|
||||||
configure() reports it; the caller passes it to set_scrolling_state."""
|
|
||||||
dm = FakeDisplayManager()
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 25.0}},
|
|
||||||
display_manager=dm)
|
|
||||||
assert s.frame_hold == 4, "25px/s at 100Hz is 1px every 4th refresh"
|
|
||||||
assert dm.hold is None, "must not apply the hold behind the caller's back"
|
|
||||||
|
|
||||||
def test_full_speed_needs_no_hold(self):
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 100.0}},
|
|
||||||
display_manager=FakeDisplayManager())
|
|
||||||
assert s.frame_hold == 1
|
|
||||||
|
|
||||||
def test_frame_hold_is_one_when_snapping_is_off(self):
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 50.0}},
|
|
||||||
snap_to_crisp=False)
|
|
||||||
assert s.frame_hold == 1
|
|
||||||
|
|
||||||
def test_snaps_to_the_nearest_crisp_speed_and_reports_both(self):
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 45.0}},
|
|
||||||
display_manager=FakeDisplayManager())
|
|
||||||
assert s.requested_pixels_per_second == 45.0
|
|
||||||
assert s.pixels_per_second == 50.0
|
|
||||||
assert s.crisp is not None and s.crisp.pixels_per_frame == 1
|
|
||||||
|
|
||||||
def test_snapping_can_be_turned_off(self):
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 45.0}},
|
|
||||||
snap_to_crisp=False)
|
|
||||||
assert s.pixels_per_second == 45.0
|
|
||||||
assert s.crisp is None
|
|
||||||
|
|
||||||
def test_helper_target_fps_matches_the_presentation_rate(self):
|
|
||||||
"""At a hold of 4 the panel still refreshes at 100Hz, but frames are
|
|
||||||
presented at 25/s -- that is the rate the helper should pace to."""
|
|
||||||
helper = FakeHelper()
|
|
||||||
configure(helper, {"display_options": {"scroll_pixels_per_second": 25.0}},
|
|
||||||
display_manager=FakeDisplayManager())
|
|
||||||
assert helper.target_fps == pytest.approx(25.0)
|
|
||||||
|
|
||||||
def test_returns_settings_describing_the_source(self):
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_speed": 2.0, "scroll_delay": 0.01}})
|
|
||||||
assert isinstance(s, ScrollSettings)
|
|
||||||
assert "200.0 px/s" in s.describe()
|
|
||||||
|
|
||||||
|
|
||||||
class TestRefreshFromConfig:
|
|
||||||
def test_reads_the_hardware_limit(self):
|
|
||||||
assert refresh_hz_from_config(
|
|
||||||
{"display": {"hardware": {"limit_refresh_rate_hz": 150}}}
|
|
||||||
) == 150.0
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("cfg", [None, {}, {"display": {}}, {"display": {"hardware": {}}},
|
|
||||||
"nonsense", {"display": {"hardware": {"limit_refresh_rate_hz": None}}}])
|
|
||||||
def test_falls_back_to_the_default(self, cfg):
|
|
||||||
assert refresh_hz_from_config(cfg) == 100.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestCrispLadder:
|
|
||||||
"""Whole-pixel speeds available on a given panel."""
|
|
||||||
|
|
||||||
def test_every_entry_is_exactly_reachable(self):
|
|
||||||
for c in crisp_ladder(100):
|
|
||||||
assert c.pixels_per_second == pytest.approx(
|
|
||||||
c.refresh_hz / c.frame_hold * c.pixels_per_frame)
|
|
||||||
|
|
||||||
def test_sorted_slowest_first(self):
|
|
||||||
speeds = [c.pixels_per_second for c in crisp_ladder(100)]
|
|
||||||
assert speeds == sorted(speeds)
|
|
||||||
|
|
||||||
def test_no_duplicate_speeds(self):
|
|
||||||
speeds = [round(c.pixels_per_second, 3) for c in crisp_ladder(100)]
|
|
||||||
assert len(speeds) == len(set(speeds))
|
|
||||||
|
|
||||||
def test_duplicates_resolve_to_the_smaller_step(self):
|
|
||||||
"""100px/s is 1px every refresh or 2px every 2nd; prefer the former."""
|
|
||||||
entry = next(c for c in crisp_ladder(100)
|
|
||||||
if c.pixels_per_second == pytest.approx(100.0))
|
|
||||||
assert entry.pixels_per_frame == 1
|
|
||||||
assert entry.frame_hold == 1
|
|
||||||
|
|
||||||
def test_ladder_scales_with_the_panel(self):
|
|
||||||
assert any(c.pixels_per_second == pytest.approx(60.0)
|
|
||||||
for c in crisp_ladder(60))
|
|
||||||
assert any(c.pixels_per_second == pytest.approx(30.0)
|
|
||||||
for c in crisp_ladder(60))
|
|
||||||
|
|
||||||
def test_full_refresh_speed_is_present(self):
|
|
||||||
for hz in (60, 75, 100, 120):
|
|
||||||
assert any(c.pixels_per_second == pytest.approx(float(hz))
|
|
||||||
for c in crisp_ladder(hz))
|
|
||||||
|
|
||||||
|
|
||||||
class TestSolveCrisp:
|
|
||||||
def test_exact_targets_are_matched_exactly(self):
|
|
||||||
for target in (100, 50, 25, 20):
|
|
||||||
assert solve_crisp(target, 100).pixels_per_second == pytest.approx(target)
|
|
||||||
|
|
||||||
def test_prefers_smooth_motion_over_raw_proximity(self):
|
|
||||||
"""30 -> 33.3 (1px, 33fps), not 28.6 (2px at 14fps) which is nearer."""
|
|
||||||
got = solve_crisp(30, 100)
|
|
||||||
assert got.pixels_per_second == pytest.approx(33.333, abs=0.01)
|
|
||||||
assert got.pixels_per_frame == 1
|
|
||||||
|
|
||||||
def test_does_not_chase_a_jumpy_exact_match(self):
|
|
||||||
"""45 -> 50 (1px, smooth) beats 42.9 (3px at 14fps)."""
|
|
||||||
assert solve_crisp(45, 100).pixels_per_frame == 1
|
|
||||||
|
|
||||||
def test_allows_a_two_pixel_step_when_it_is_clearly_closer(self):
|
|
||||||
"""60 -> 66.7 (2px at 33fps) rather than 50 (1px) which is 17% slow."""
|
|
||||||
got = solve_crisp(60, 100)
|
|
||||||
assert got.pixels_per_second == pytest.approx(66.667, abs=0.01)
|
|
||||||
assert got.pixels_per_frame == 2
|
|
||||||
|
|
||||||
def test_result_is_always_on_the_ladder(self):
|
|
||||||
ladder = {round(c.pixels_per_second, 3) for c in crisp_ladder(100)}
|
|
||||||
for target in range(5, 205, 5):
|
|
||||||
assert round(solve_crisp(target, 100).pixels_per_second, 3) in ladder
|
|
||||||
|
|
||||||
def test_adapts_to_a_slower_panel(self):
|
|
||||||
got = solve_crisp(30, 60)
|
|
||||||
assert got.pixels_per_second == pytest.approx(30.0)
|
|
||||||
assert got.pixels_per_frame == 1
|
|
||||||
assert got.frame_hold == 2
|
|
||||||
|
|
||||||
def test_speeds_beyond_the_panel_clamp_to_the_fastest_available(self):
|
|
||||||
got = solve_crisp(10_000, 100)
|
|
||||||
assert got.frame_hold == 1
|
|
||||||
assert got.pixels_per_frame == MAX_PIXELS_PER_FRAME
|
|
||||||
|
|
||||||
def test_steppiness_labels_are_sane(self):
|
|
||||||
assert solve_crisp(100, 100).steppiness == "smooth"
|
|
||||||
assert crisp_ladder(100)[0].steppiness == "stepped"
|
|
||||||
|
|
||||||
|
|
||||||
class TestRefreshFromDisplayManager:
|
|
||||||
"""A plugin sees only its own config section, so the display manager is the
|
|
||||||
authoritative source for the panel's refresh rate."""
|
|
||||||
|
|
||||||
class DM:
|
|
||||||
def __init__(self, hz):
|
|
||||||
self.refresh_hz = hz
|
|
||||||
self.hold = None
|
|
||||||
|
|
||||||
def set_frame_hold(self, refreshes):
|
|
||||||
self.hold = refreshes
|
|
||||||
|
|
||||||
def test_uses_the_display_manager_rate(self):
|
|
||||||
dm = self.DM(60.0)
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 30.0}},
|
|
||||||
display_manager=dm)
|
|
||||||
# 30px/s on a 60Hz panel is 1px every 2nd refresh, exactly.
|
|
||||||
assert s.pixels_per_second == pytest.approx(30.0)
|
|
||||||
assert s.frame_hold == 2
|
|
||||||
|
|
||||||
def test_explicit_refresh_hz_wins_over_the_display_manager(self):
|
|
||||||
dm = self.DM(60.0)
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 25.0}},
|
|
||||||
display_manager=dm, refresh_hz=100)
|
|
||||||
assert s.frame_hold == 4, "25px/s at 100Hz is 1px every 4th refresh"
|
|
||||||
|
|
||||||
def test_missing_attribute_falls_back_to_the_default(self):
|
|
||||||
class Bare:
|
|
||||||
def set_frame_hold(self, refreshes):
|
|
||||||
self.hold = refreshes
|
|
||||||
|
|
||||||
bare = Bare()
|
|
||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 50.0}},
|
|
||||||
display_manager=bare)
|
|
||||||
assert s.frame_hold == 2, "assumed 100Hz"
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""The card helpers the eight scoreboards now share.
|
||||||
|
|
||||||
|
These bodies lived in eight byte-identical copies. Moving them here means one
|
||||||
|
fix reaches every scoreboard — and that a mistake does too, which is what this
|
||||||
|
file guards. Each case below is one the plugins' own code already handled; the
|
||||||
|
point is that it keeps handling it.
|
||||||
|
|
||||||
|
The functions take ``config``/``logger``/``fonts`` as arguments rather than
|
||||||
|
reading them off an instance, so a plugin keeps its method and delegates the
|
||||||
|
body. That is what let all eight adopt this with byte-identical renders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.common import sports_card as C
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def log():
|
||||||
|
return logging.getLogger("test_sports_card")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsLookup:
|
||||||
|
def test_reads_the_scroll_card_block(self):
|
||||||
|
cfg = {"scroll_card": {"vs_text": "@"}}
|
||||||
|
assert C.scroll_card_option(cfg, "vs_text", "VS") == "@"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cfg", [None, {}, {"scroll_card": None},
|
||||||
|
{"scroll_card": {"vs_text": None}}])
|
||||||
|
def test_missing_or_null_falls_back(self, cfg):
|
||||||
|
"""A null in config means "unset", not "empty string"."""
|
||||||
|
assert C.scroll_card_option(cfg, "vs_text", "VS") == "VS"
|
||||||
|
|
||||||
|
def test_upcoming_center_rejects_unknown_modes(self):
|
||||||
|
for bad in ("sideways", "", None, 7):
|
||||||
|
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": bad}}) == "vs"
|
||||||
|
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": "DATE_TIME"}}) == "date_time"
|
||||||
|
|
||||||
|
|
||||||
|
class TestColour:
|
||||||
|
def test_rgb_list_and_hex_both_work(self):
|
||||||
|
assert C.element_color({"customization": {"score_text": {"text_color": [1, 2, 3]}}},
|
||||||
|
"score_text") == (1, 2, 3)
|
||||||
|
assert C.element_color({"customization": {"score_text": {"text_color": "#ff8000"}}},
|
||||||
|
"score_text") == (255, 128, 0)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["nope", "#fff", [1, 2], None, ["a", "b", "c"]])
|
||||||
|
def test_unusable_colour_falls_back(self, value):
|
||||||
|
cfg = {"customization": {"score_text": {"text_color": value}}}
|
||||||
|
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
|
||||||
|
|
||||||
|
def test_coerce_rgb_clamps_rather_than_rejecting(self):
|
||||||
|
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)
|
||||||
|
|
||||||
|
def test_coerce_rgb_refuses_a_three_character_string(self):
|
||||||
|
""""123" would otherwise iterate into three digits and yield a colour."""
|
||||||
|
assert C.coerce_rgb("123", (7, 7, 7)) == (7, 7, 7)
|
||||||
|
|
||||||
|
def test_font_colour_is_resolved_by_identity(self):
|
||||||
|
a, b = object(), object()
|
||||||
|
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
||||||
|
assert C.font_color(cfg, {"score": a, "team": b}, a) == (4, 5, 6)
|
||||||
|
|
||||||
|
def test_a_shared_face_gives_up_rather_than_guessing(self):
|
||||||
|
"""One object used for two elements has no single right colour."""
|
||||||
|
shared = object()
|
||||||
|
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
||||||
|
assert C.font_color(cfg, {"score": shared, "team": shared}, shared) == (255, 255, 255)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFavourites:
|
||||||
|
GAME = {"home_abbr": "TB", "away_abbr": "NO", "home_score": "21", "away_score": "17"}
|
||||||
|
|
||||||
|
def test_win_loss_and_tie(self):
|
||||||
|
cfg = {"favorite_teams": ["TB"]}
|
||||||
|
assert C.favorite_result(cfg, self.GAME) == "win"
|
||||||
|
assert C.favorite_result({"favorite_teams": ["NO"]}, self.GAME) == "loss"
|
||||||
|
tied = dict(self.GAME, home_score="3", away_score="3")
|
||||||
|
assert C.favorite_result(cfg, tied) == "tie"
|
||||||
|
|
||||||
|
def test_no_verdict_without_exactly_one_favourite_side(self):
|
||||||
|
assert C.favorite_result({}, self.GAME) is None
|
||||||
|
assert C.favorite_result({"favorite_teams": ["TB", "NO"]}, self.GAME) is None
|
||||||
|
assert C.favorite_result({"favorite_teams": ["SEA"]}, self.GAME) is None
|
||||||
|
|
||||||
|
def test_unusable_scores_give_no_verdict(self):
|
||||||
|
bad = dict(self.GAME, home_score="x")
|
||||||
|
assert C.favorite_result({"favorite_teams": ["TB"]}, bad) is None
|
||||||
|
|
||||||
|
def test_nested_payload_shape_is_read_too(self):
|
||||||
|
game = {"home_team": {"abbrev": "TB", "score": 9},
|
||||||
|
"away_team": {"abbrev": "NO", "score": 2}}
|
||||||
|
assert C.side_score(game, "home") == 9
|
||||||
|
assert C.side_is_favorite(game, "home", {"TB"}) is True
|
||||||
|
|
||||||
|
def test_matches_on_id_where_abbreviations_collide(self):
|
||||||
|
"""NRL keys favourites by ESPN id; abbreviations are not unique there."""
|
||||||
|
game = {"home_abbr": "SYD", "home_id": "4321"}
|
||||||
|
assert C.side_is_favorite(game, "home", {"4321"}) is True
|
||||||
|
|
||||||
|
def test_game_and_config_favourites_are_both_used(self):
|
||||||
|
"""Games carry resolved dynamic groups; config catches later edits."""
|
||||||
|
game = dict(self.GAME, favorite_teams=["NO"], league="nfl")
|
||||||
|
assert set(C.favorite_teams_for({"nfl": {"favorite_teams": ["TB"]}}, game)) == {"NO", "TB"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateAndTime:
|
||||||
|
def test_date_formats(self, log):
|
||||||
|
for fmt, want in [("abbrev", "Sep 5"), ("numeric", "9/5"),
|
||||||
|
("day_first", "5 Sep"), ("numeric_day_first", "5/9")]:
|
||||||
|
cfg = {"scroll_card": {"date_format": fmt}}
|
||||||
|
assert C.format_game_date(cfg, log, "9/5") == want
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["", "garbage", "13/40", "no/slash/here"])
|
||||||
|
def test_unparseable_dates_pass_through(self, log, raw):
|
||||||
|
assert C.format_game_date({}, log, raw) == raw.strip()
|
||||||
|
|
||||||
|
def test_24h_conversion(self):
|
||||||
|
cfg = {"scroll_card": {"time_format": "24h"}}
|
||||||
|
assert C.format_game_time(cfg, "7:30 PM") == "19:30"
|
||||||
|
assert C.format_game_time(cfg, "12:00 AM") == "00:00"
|
||||||
|
assert C.format_game_time(cfg, "12:15 PM") == "12:15"
|
||||||
|
|
||||||
|
def test_12h_is_left_alone_and_junk_survives(self):
|
||||||
|
assert C.format_game_time({}, "7:30 PM") == "7:30 PM"
|
||||||
|
assert C.format_game_time({"scroll_card": {"time_format": "24h"}}, "soon") == "soon"
|
||||||
|
|
||||||
|
def test_a_bad_timezone_falls_back_to_utc(self, log):
|
||||||
|
"""A typo in config should not blank the card."""
|
||||||
|
from datetime import timezone
|
||||||
|
assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
class TestFontSizing:
|
||||||
|
def test_snaps_to_the_faces_pixel_grid(self):
|
||||||
|
assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid
|
||||||
|
assert C.crisp_size("PressStart2P-Regular.ttf", 10) == 8
|
||||||
|
assert C.crisp_size("PressStart2P-Regular.ttf", 13) == 16
|
||||||
|
|
||||||
|
def test_an_unknown_face_is_never_second_guessed(self):
|
||||||
|
assert C.crisp_size("SomeUserFont.ttf", 11) == 11
|
||||||
|
|
||||||
|
def test_aliases_resolve_before_the_grid_lookup(self):
|
||||||
|
assert C.crisp_size("four_by_six", 6) == C.crisp_size("4x6-font.ttf", 6)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("desired", [0, -3, None])
|
||||||
|
def test_unusable_sizes_pass_through_without_raising(self, desired):
|
||||||
|
"""None reached this in the field; football's variant raised TypeError."""
|
||||||
|
assert C.crisp_size("4x6-font.ttf", desired) == desired
|
||||||
|
|
||||||
|
def test_schema_cache_is_keyed_per_schema_not_globally(self, tmp_path):
|
||||||
|
"""Two plugins declaring different defaults must not share an answer."""
|
||||||
|
a, b = tmp_path / "a.json", tmp_path / "b.json"
|
||||||
|
for path, size in ((a, 11), (b, 22)):
|
||||||
|
path.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||||
|
"score_text": {"properties": {"font_size": {"default": size}}}}}}}))
|
||||||
|
assert C.schema_font_size(str(a), "score_text") == 11
|
||||||
|
assert C.schema_font_size(str(b), "score_text") == 22
|
||||||
|
|
||||||
|
def test_a_missing_schema_is_not_an_error(self, tmp_path):
|
||||||
|
assert C.schema_font_size(str(tmp_path / "nope.json"), "score_text") is None
|
||||||
|
|
||||||
|
def test_a_configured_size_matching_the_schema_default_is_not_a_choice(self, tmp_path):
|
||||||
|
"""The web UI writes the whole default block on every save, so
|
||||||
|
font_size == schema default carries no intent and must not pin the
|
||||||
|
install to an off-grid size forever."""
|
||||||
|
schema = tmp_path / "s.json"
|
||||||
|
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||||
|
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
|
||||||
|
got = C.resolve_font_size(str(schema), {"font_size": 10}, "score_text", 10,
|
||||||
|
"PressStart2P-Regular.ttf")
|
||||||
|
assert got == 8, "a default-valued size should snap to the grid"
|
||||||
|
|
||||||
|
def test_a_real_choice_wins(self, tmp_path):
|
||||||
|
schema = tmp_path / "s.json"
|
||||||
|
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||||
|
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
|
||||||
|
got = C.resolve_font_size(str(schema), {"font_size": 13}, "score_text", 10,
|
||||||
|
"PressStart2P-Regular.ttf")
|
||||||
|
assert got == 13, "an explicit size the user chose is not second-guessed"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTables:
|
||||||
|
def test_every_font_key_maps_to_an_element(self):
|
||||||
|
assert set(C.ELEMENT_FOR_FONT) == {"score", "time", "team", "status", "detail", "rank"}
|
||||||
|
|
||||||
|
def test_result_colours_cover_every_verdict(self):
|
||||||
|
assert set(C.FAVORITE_RESULT_COLOR_DEFAULTS) == {"win", "loss", "tie"}
|
||||||
|
|
||||||
|
def test_month_and_weekday_tables_are_complete(self):
|
||||||
|
assert len(C.MONTH_ABBR) == 12 and len(C.WEEKDAY_ABBR) == 7
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""The shared card geometry, exercised against a host that supplies only what
|
||||||
|
the mixin's contract names.
|
||||||
|
|
||||||
|
The point of these is the contract, not the arithmetic. The mixin reaches for
|
||||||
|
``display_width``, ``fonts``, ``config`` and six ``sports_card`` delegations
|
||||||
|
through ``self``, and the eight plugins are what actually provide them. A
|
||||||
|
stub host that provides exactly the documented surface and nothing else is
|
||||||
|
what catches the mixin quietly growing a dependency the plugins do not have.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from src.common.sports_game_renderer import SportsGameRendererMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Host(SportsGameRendererMixin):
|
||||||
|
"""The documented contract, and not one attribute more."""
|
||||||
|
|
||||||
|
def __init__(self, width=128, height=32, config=None, scroll=None):
|
||||||
|
self.display_width = width
|
||||||
|
self.display_height = height
|
||||||
|
self.config = config or {}
|
||||||
|
self.logger = _Logger()
|
||||||
|
self._team_rankings_cache = {}
|
||||||
|
self._scroll = scroll or {}
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
self.fonts = {'score': font, 'time': font, 'detail': font}
|
||||||
|
self.drawn = []
|
||||||
|
|
||||||
|
# -- the six sports_card delegations the mixin calls --
|
||||||
|
def _scroll_card_option(self, key, default=None):
|
||||||
|
return self._scroll.get(key, default)
|
||||||
|
|
||||||
|
def _upcoming_center_mode(self):
|
||||||
|
return self._scroll.get('upcoming_center', 'vs')
|
||||||
|
|
||||||
|
def _vs_text(self):
|
||||||
|
return self._scroll.get('vs_text', 'VS')
|
||||||
|
|
||||||
|
def _element_color(self, element):
|
||||||
|
return (255, 255, 255)
|
||||||
|
|
||||||
|
def _format_game_date(self, raw, game):
|
||||||
|
return raw
|
||||||
|
|
||||||
|
def _format_game_time(self, raw):
|
||||||
|
return raw
|
||||||
|
|
||||||
|
# -- the one hook whose body genuinely varies per plugin --
|
||||||
|
def _draw_text_with_outline(self, draw, text, position, font,
|
||||||
|
fill=None, outline_color=(0, 0, 0)):
|
||||||
|
self.drawn.append((text, position))
|
||||||
|
|
||||||
|
|
||||||
|
class _Logger:
|
||||||
|
def debug(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _draw():
|
||||||
|
return ImageDraw.Draw(Image.new("RGB", (256, 64)))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCenterGap:
|
||||||
|
def test_explicit_center_gap_wins_outright(self):
|
||||||
|
assert Host(scroll={'center_gap': 31})._center_gap_width() == 31
|
||||||
|
|
||||||
|
def test_explicit_zero_restores_edge_to_edge_logos(self):
|
||||||
|
# 0 is a real setting, not a falsy miss -- the guard is `>= 0`.
|
||||||
|
assert Host(scroll={'center_gap': 0})._center_gap_width() == 0
|
||||||
|
|
||||||
|
def test_otherwise_it_scales_with_card_width_within_the_clamp(self):
|
||||||
|
h = Host(width=512)
|
||||||
|
# 512 * 0.28 = 143, clamped to the 40px ceiling.
|
||||||
|
assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
def test_the_gap_never_ends_up_narrower_than_the_score(self):
|
||||||
|
# This is the bug the measurement exists to prevent: a derived gap
|
||||||
|
# smaller than the rendered score drew the score over the logos.
|
||||||
|
h = Host(width=64)
|
||||||
|
assert h._center_gap_width() >= h._score_reserve_width()
|
||||||
|
|
||||||
|
def test_a_junk_ratio_falls_back_to_the_floor(self):
|
||||||
|
h = Host(scroll={'center_gap_ratio': 'wide'})
|
||||||
|
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
|
||||||
|
class TestNonFiniteSettings:
|
||||||
|
"""inf reaches int() and raises OverflowError, which the old
|
||||||
|
`except (TypeError, ValueError)` did not catch -- so one bad config value
|
||||||
|
aborted the entire card render rather than falling back."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [float("inf"), float("-inf")])
|
||||||
|
def test_a_non_finite_center_gap_falls_back(self, bad):
|
||||||
|
h = Host(scroll={'center_gap': bad})
|
||||||
|
assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [float("inf"), float("-inf"), float("nan")])
|
||||||
|
def test_a_non_finite_ratio_falls_back_to_the_floor(self, bad):
|
||||||
|
h = Host(scroll={'center_gap_ratio': bad})
|
||||||
|
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [float("inf"), float("-inf")])
|
||||||
|
def test_non_finite_clamp_bounds_fall_back(self, bad):
|
||||||
|
h = Host(scroll={'center_gap_min': bad, 'center_gap_max': bad})
|
||||||
|
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [float("inf"), float("-inf"), "inf", "-inf", "nan"])
|
||||||
|
def test_a_non_finite_layout_offset_gives_the_default(self, bad):
|
||||||
|
cfg = {'customization': {'layout': {'score': {'x_offset': bad}}}}
|
||||||
|
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7
|
||||||
|
|
||||||
|
def test_a_finite_value_is_still_honoured(self):
|
||||||
|
# The guard must not swallow ordinary settings.
|
||||||
|
assert Host(scroll={'center_gap': 31})._center_gap_width() == 31
|
||||||
|
cfg = {'customization': {'layout': {'score': {'x_offset': -3}}}}
|
||||||
|
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == -3
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreReserve:
|
||||||
|
def test_it_measures_the_probe_plus_both_gutters(self):
|
||||||
|
h = Host()
|
||||||
|
assert h._score_reserve_width() > 2 * h._SCORE_LOGO_GUTTER_PX
|
||||||
|
|
||||||
|
def test_a_wider_probe_reserves_more(self):
|
||||||
|
class Wide(Host):
|
||||||
|
_SCORE_PROBE = "000-000"
|
||||||
|
assert Wide()._score_reserve_width() > Host()._score_reserve_width()
|
||||||
|
|
||||||
|
def test_an_unmeasurable_font_reserves_nothing_rather_than_raising(self):
|
||||||
|
h = Host()
|
||||||
|
h.fonts = {'score': object()}
|
||||||
|
assert h._score_reserve_width() == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogoSlot:
|
||||||
|
def test_the_slot_is_what_is_left_after_the_gap(self):
|
||||||
|
h = Host(width=128, scroll={'center_gap': 40})
|
||||||
|
assert h._logo_slot_width() == 44
|
||||||
|
|
||||||
|
def test_it_is_not_capped_at_the_card_height(self):
|
||||||
|
# The height cap is what froze logos at 46px on a 128px card.
|
||||||
|
h = Host(width=512, height=32, scroll={'center_gap': 40})
|
||||||
|
assert h._logo_slot_width() > h.display_height
|
||||||
|
|
||||||
|
def test_a_gap_wider_than_the_card_still_leaves_a_usable_slot(self):
|
||||||
|
assert Host(width=64, scroll={'center_gap': 200})._logo_slot_width() == 8
|
||||||
|
|
||||||
|
def test_the_cache_key_is_scoped_to_the_slot_not_just_the_name(self):
|
||||||
|
# One cache dict is shared by renderers of different card widths.
|
||||||
|
narrow = Host(width=64, scroll={'center_gap': 20})._logo_cache_key("NYY")
|
||||||
|
wide = Host(width=256, scroll={'center_gap': 20})._logo_cache_key("NYY")
|
||||||
|
assert narrow != wide
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayoutOffset:
|
||||||
|
def _host(self, value):
|
||||||
|
return Host(config={'customization': {'layout': {'score': {'x_offset': value}}}})
|
||||||
|
|
||||||
|
def test_it_reads_the_same_block_as_the_full_screen_scorebug(self):
|
||||||
|
assert self._host(5)._layout_offset('score', 'x_offset') == 5
|
||||||
|
|
||||||
|
def test_a_string_offset_from_the_web_ui_is_coerced(self):
|
||||||
|
assert self._host("-3")._layout_offset('score', 'x_offset') == -3
|
||||||
|
|
||||||
|
def test_a_bool_is_not_silently_an_offset_of_one(self):
|
||||||
|
assert self._host(True)._layout_offset('score', 'x_offset', 9) == 9
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cfg", [{}, {'customization': {}},
|
||||||
|
{'customization': {'layout': {}}}])
|
||||||
|
def test_a_missing_block_gives_the_default(self, cfg):
|
||||||
|
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7
|
||||||
|
|
||||||
|
def test_an_unparseable_offset_gives_the_default(self):
|
||||||
|
assert self._host("left")._layout_offset('score', 'x_offset', 4) == 4
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpcomingCenter:
|
||||||
|
def test_none_draws_nothing(self):
|
||||||
|
h = Host(scroll={'upcoming_center': 'none'})
|
||||||
|
h._draw_upcoming_center(_draw(), {})
|
||||||
|
assert h.drawn == []
|
||||||
|
|
||||||
|
def test_vs_is_the_default_and_never_a_score(self):
|
||||||
|
# An upcoming game has not started; the extractor's 0-0 is noise.
|
||||||
|
h = Host()
|
||||||
|
h._draw_upcoming_center(_draw(), {'home_score': 0, 'away_score': 0})
|
||||||
|
assert [t for t, _ in h.drawn] == ['VS']
|
||||||
|
|
||||||
|
def test_an_empty_vs_string_draws_nothing(self):
|
||||||
|
h = Host(scroll={'vs_text': ''})
|
||||||
|
h._draw_upcoming_center(_draw(), {})
|
||||||
|
assert h.drawn == []
|
||||||
|
|
||||||
|
def test_date_time_stacks_both_lines(self):
|
||||||
|
h = Host(scroll={'upcoming_center': 'date_time'})
|
||||||
|
h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM']
|
||||||
|
|
||||||
|
def test_hiding_both_lines_draws_nothing(self):
|
||||||
|
h = Host(scroll={'upcoming_center': 'date_time',
|
||||||
|
'show_date': False, 'show_time': False})
|
||||||
|
h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
assert h.drawn == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpcomingStatus:
|
||||||
|
def test_time_on_top_and_date_below_by_default(self):
|
||||||
|
h = Host()
|
||||||
|
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
assert [t for t, _ in h.drawn] == ['7:00 PM', 'Sep 19']
|
||||||
|
|
||||||
|
def test_swap_date_time_reverses_them(self):
|
||||||
|
h = Host(scroll={'swap_date_time': True})
|
||||||
|
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM']
|
||||||
|
|
||||||
|
def test_it_stays_out_of_the_way_when_the_centre_already_has_them(self):
|
||||||
|
# Otherwise the date and time print twice on the same card.
|
||||||
|
h = Host(scroll={'upcoming_center': 'date_time'})
|
||||||
|
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
assert h.drawn == []
|
||||||
|
|
||||||
|
def test_the_bottom_line_is_measured_not_a_fixed_offset(self):
|
||||||
|
# A fixed -7 ran "Sep 19" past the card wherever the detail font is
|
||||||
|
# 10px rather than 6px.
|
||||||
|
h = Host(height=64)
|
||||||
|
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
bottom_y = h.drawn[1][1][1]
|
||||||
|
assert 0 <= bottom_y < h.display_height
|
||||||
|
|
||||||
|
|
||||||
|
class TestRankings:
|
||||||
|
def test_set_rankings_cache_replaces_the_cache(self):
|
||||||
|
h = Host()
|
||||||
|
h.set_rankings_cache({'UGA': 1})
|
||||||
|
assert h._team_rankings_cache == {'UGA': 1}
|
||||||
|
|
||||||
|
|
||||||
|
class TestContract:
|
||||||
|
def test_the_mixin_carries_no_state_of_its_own(self):
|
||||||
|
# Adoption must be one line on the class statement; a mixin with an
|
||||||
|
# __init__ would force eight constructors to cooperate.
|
||||||
|
assert '__init__' not in SportsGameRendererMixin.__dict__
|
||||||
|
|
||||||
|
def test_a_host_providing_the_documented_surface_needs_nothing_more(self):
|
||||||
|
# Host defines exactly what the module docstring names. If the mixin
|
||||||
|
# grows a new self.* dependency, this is what fails.
|
||||||
|
h = Host()
|
||||||
|
h._center_gap_width()
|
||||||
|
h._logo_slot_width()
|
||||||
|
h._logo_cache_key("X")
|
||||||
|
h._layout_offset('score', 'x_offset')
|
||||||
|
h._upcoming_date_and_time({})
|
||||||
|
h._draw_upcoming_center(_draw(), {})
|
||||||
|
h._draw_upcoming_game_status(_draw(), {})
|
||||||
|
h.set_rankings_cache({})
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""The shared sports.py mixins: their host contract, and _plugin_dir.
|
||||||
|
|
||||||
|
Two things are worth testing here and the rest is not. The 45 method bodies
|
||||||
|
moved verbatim from the plugins, so they are covered by the plugins' own tests
|
||||||
|
and by 176 byte-identical safety-harness renders. What is genuinely new is:
|
||||||
|
|
||||||
|
1. The contract. Every ``self.<CONSTANT>`` a mixin reads must be defined on the
|
||||||
|
mixin, or a host that does not happen to declare it raises AttributeError at
|
||||||
|
runtime. Two were missed on the first pass (_QUALITY_CHOICES and
|
||||||
|
_RANKING_COVERAGE_SECONDS); the eight plugins all declare them, so nothing
|
||||||
|
failed -- it would only have bitten a ninth. The test derives the list rather
|
||||||
|
than restating it, so the next omission fails here instead of in the field.
|
||||||
|
|
||||||
|
2. ``_plugin_dir``. This is the only line of genuinely new logic in the move. In
|
||||||
|
sports.py these methods found config_schema.json with ``__file__``; here that
|
||||||
|
is src/common/, so the plugin directory has to be recovered from the
|
||||||
|
instance -- and getting it wrong is silent, costing grid-snapped font sizes
|
||||||
|
(measured at 81% anti-aliased edges) rather than raising.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from abc import ABC
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.common import sports_shared
|
||||||
|
from src.common.sports_shared import (
|
||||||
|
SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
|
||||||
|
|
||||||
|
MIXINS = (SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
|
||||||
|
|
||||||
|
|
||||||
|
def _constants_read_by_mixins():
|
||||||
|
"""Every ALL-CAPS ``self.X`` the mixin bodies read, found by parsing them."""
|
||||||
|
tree = ast.parse(open(sports_shared.__file__).read())
|
||||||
|
names = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if (isinstance(node, ast.Attribute)
|
||||||
|
and isinstance(node.value, ast.Name)
|
||||||
|
and node.value.id == "self"
|
||||||
|
and node.attr.upper() == node.attr):
|
||||||
|
names.add(node.attr)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostContract:
|
||||||
|
def test_every_constant_read_is_also_defined(self):
|
||||||
|
# Otherwise a host that does not declare it raises AttributeError the
|
||||||
|
# first time the code path runs -- which for these is mid-render.
|
||||||
|
missing = sorted(
|
||||||
|
name for name in _constants_read_by_mixins()
|
||||||
|
if not any(hasattr(m, name) for m in MIXINS))
|
||||||
|
assert missing == [], (
|
||||||
|
f"read but never defined on a mixin: {missing}. Give each a default "
|
||||||
|
f"on SportsCoreSharedMixin and document it in the module docstring.")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,expected", [
|
||||||
|
("_QUALITY_CHOICES", frozenset({"any", "ranked"})),
|
||||||
|
("_RANKING_COVERAGE_SECONDS", 3600),
|
||||||
|
("_SCORE_PROBE_TEXT", "00-00"),
|
||||||
|
("_FONT_DESIGN_HEIGHT", 32),
|
||||||
|
])
|
||||||
|
def test_defaults_match_what_the_plugins_ship(self, name, expected):
|
||||||
|
# The eight plugins declare their own copies, which shadow these. The
|
||||||
|
# values must still agree, or a ninth plugin inheriting the default
|
||||||
|
# behaves differently from the eight.
|
||||||
|
assert getattr(SportsCoreSharedMixin, name) == expected
|
||||||
|
|
||||||
|
def test_only_the_recent_mixin_carries_a_constructor(self):
|
||||||
|
# SportsCore and SportsLive keep their own __init__ -- those differ per
|
||||||
|
# plugin. SportsRecent.__init__ was one of the 48 byte-identical bodies,
|
||||||
|
# so it moved with the rest; that is deliberate, not an oversight.
|
||||||
|
assert "__init__" not in SportsCoreSharedMixin.__dict__
|
||||||
|
assert "__init__" not in SportsLiveSharedMixin.__dict__
|
||||||
|
assert "__init__" in SportsRecentSharedMixin.__dict__
|
||||||
|
|
||||||
|
def test_the_recent_constructor_still_chains_to_the_host(self):
|
||||||
|
"""Its zero-arg super() binds to where it is DEFINED, not where it is used.
|
||||||
|
|
||||||
|
Moving a body containing bare ``super()`` is the one move that can
|
||||||
|
change meaning: the compiler closes over __class__ = the defining class,
|
||||||
|
so after the move that is SportsRecentSharedMixin rather than the
|
||||||
|
plugin's SportsRecent. It still works only because the mixin is listed
|
||||||
|
first, leaving the host class next in the MRO -- adopt it in the other
|
||||||
|
order and the chain silently skips the host's __init__.
|
||||||
|
"""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Host:
|
||||||
|
def __init__(self, config, display_manager, cache_manager, logger, sport_key):
|
||||||
|
calls.append(sport_key)
|
||||||
|
self.mode_config = {}
|
||||||
|
|
||||||
|
class Recent(SportsRecentSharedMixin, Host):
|
||||||
|
pass
|
||||||
|
|
||||||
|
inst = Recent({}, None, None, None, "nhl")
|
||||||
|
assert calls == ["nhl"], "the host constructor must still run"
|
||||||
|
assert inst.current_game_index == 0
|
||||||
|
assert inst.update_interval == 3600
|
||||||
|
assert inst._zero_clock_timestamps == {}
|
||||||
|
|
||||||
|
def test_adopting_the_recent_mixin_second_would_skip_the_host(self):
|
||||||
|
# The failure mode the ordering above prevents, pinned so nobody
|
||||||
|
# "tidies" the base list.
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Host:
|
||||||
|
def __init__(self, *a):
|
||||||
|
calls.append(a)
|
||||||
|
self.mode_config = {}
|
||||||
|
|
||||||
|
class Wrong(Host, SportsRecentSharedMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
Wrong({}, None, None, None, "nhl")
|
||||||
|
# Host.__init__ wins and the mixin's setup never runs at all.
|
||||||
|
assert not hasattr(Wrong({}, None, None, None, "nhl"), "current_game_index")
|
||||||
|
|
||||||
|
|
||||||
|
class _Host(SportsCoreSharedMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _write_plugin(tmp_path, name="fakeplug", schema=True):
|
||||||
|
"""A throwaway package on sys.path, with or without a config_schema.json."""
|
||||||
|
d = tmp_path / name
|
||||||
|
d.mkdir()
|
||||||
|
(d / "__init__.py").write_text("")
|
||||||
|
(d / "mod.py").write_text("class Leaf:\n pass\n")
|
||||||
|
if schema:
|
||||||
|
(d / "config_schema.json").write_text(
|
||||||
|
'{"properties": {"customization": {"properties": '
|
||||||
|
'{"score": {"properties": {"font_size": {"default": 16}}}}}}}')
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginDir:
|
||||||
|
def test_it_finds_the_directory_holding_config_schema_json(self, tmp_path, monkeypatch):
|
||||||
|
d = _write_plugin(tmp_path)
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("fakeplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._plugin_dir() == str(d)
|
||||||
|
|
||||||
|
def test_a_class_built_by_type_still_resolves(self, tmp_path, monkeypatch):
|
||||||
|
# SportsCore is an ABC, so type(name, bases, ns) reports __module__ as
|
||||||
|
# "abc" rather than the plugin -- which is exactly what the plugins'
|
||||||
|
# own tests build. Walking the MRO is what steps past it.
|
||||||
|
d = _write_plugin(tmp_path, "abcplug")
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("abcplug.mod", fromlist=["Leaf"])
|
||||||
|
|
||||||
|
class Base(SportsCoreSharedMixin, mod.Leaf, ABC):
|
||||||
|
pass
|
||||||
|
|
||||||
|
synthetic = type("Probe", (Base,), {})
|
||||||
|
assert synthetic.__module__ == "abc", "precondition: the trap this guards"
|
||||||
|
assert synthetic.__new__(synthetic)._plugin_dir() == str(d)
|
||||||
|
|
||||||
|
def test_it_returns_none_when_no_schema_is_anywhere_on_the_mro(self, tmp_path, monkeypatch):
|
||||||
|
d = _write_plugin(tmp_path, "noschema", schema=False)
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("noschema.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
# None rather than a wrong guess: _schema_font_size then caches empty
|
||||||
|
# and every element keeps its own default.
|
||||||
|
assert host._plugin_dir() is None
|
||||||
|
|
||||||
|
def test_it_never_returns_the_core_module_directory(self):
|
||||||
|
# The bug this replaced: __file__ pointed at src/common/, so the schema
|
||||||
|
# was never found and font sizes silently stopped snapping to the grid.
|
||||||
|
host = _Host()
|
||||||
|
core_common = os.path.dirname(os.path.abspath(sports_shared.__file__))
|
||||||
|
assert host._plugin_dir() != core_common
|
||||||
|
|
||||||
|
def test_a_module_with_no_file_is_skipped_not_crashed_on(self, monkeypatch):
|
||||||
|
# Namespace packages and some frozen/dynamic modules have no __file__.
|
||||||
|
ghost = types.ModuleType("ghost_no_file")
|
||||||
|
if hasattr(ghost, "__file__"):
|
||||||
|
del ghost.__file__
|
||||||
|
monkeypatch.setitem(sys.modules, "ghost_no_file", ghost)
|
||||||
|
cls = type("H", (SportsCoreSharedMixin,), {"__module__": "ghost_no_file"})
|
||||||
|
assert cls.__new__(cls)._plugin_dir() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaFontSize:
|
||||||
|
def test_it_reads_the_plugin_schema_not_the_cores(self, tmp_path, monkeypatch):
|
||||||
|
d = _write_plugin(tmp_path, "sizeplug")
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("sizeplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._schema_font_size("score") == 16
|
||||||
|
|
||||||
|
def test_an_unknown_element_is_none(self, tmp_path, monkeypatch):
|
||||||
|
_write_plugin(tmp_path, "unkplug")
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("unkplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._schema_font_size("nonesuch") is None
|
||||||
|
|
||||||
|
def test_an_empty_key_is_none_without_touching_the_disk(self):
|
||||||
|
assert _Host()._schema_font_size("") is None
|
||||||
|
|
||||||
|
def test_a_missing_schema_degrades_to_none_rather_than_raising(self, tmp_path, monkeypatch):
|
||||||
|
_write_plugin(tmp_path, "bareplug", schema=False)
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("bareplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._schema_font_size("score") is None
|
||||||
|
|
||||||
|
|
||||||
|
class _LiveHost(SportsLiveSharedMixin):
|
||||||
|
"""The documented contract for the live mixin, and nothing else."""
|
||||||
|
|
||||||
|
def __init__(self, no_data_interval=300, stale_game_timeout=600, over=()):
|
||||||
|
self.no_data_interval = no_data_interval
|
||||||
|
self.stale_game_timeout = stale_game_timeout
|
||||||
|
self.game_update_timestamps = {}
|
||||||
|
self._over = set(over)
|
||||||
|
|
||||||
|
class _L:
|
||||||
|
def __getattr__(self, _n):
|
||||||
|
return lambda *a, **k: None
|
||||||
|
self.logger = _L()
|
||||||
|
|
||||||
|
def _is_game_really_over(self, game):
|
||||||
|
return game.get("id") in self._over
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveMixin:
|
||||||
|
"""These three moved to the core, so they are tested here.
|
||||||
|
|
||||||
|
They were already covered by hockey's and lacrosse's own tests, but those
|
||||||
|
two plugins disable live mode in their safety-harness fixtures, so the 176
|
||||||
|
renders never exercise this path. Testing the mixin directly means the
|
||||||
|
coverage no longer depends on which plugin happens to have a unit test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_a_stale_game_is_dropped_and_forgotten(self):
|
||||||
|
h = _LiveHost(stale_game_timeout=600)
|
||||||
|
import time as _t
|
||||||
|
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5000}
|
||||||
|
games = [{"id": "g1", "home_abbr": "H", "away_abbr": "A"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert games == []
|
||||||
|
assert "g1" not in h.game_update_timestamps, "its timestamp must go too"
|
||||||
|
|
||||||
|
def test_a_fresh_game_survives(self):
|
||||||
|
h = _LiveHost(stale_game_timeout=600)
|
||||||
|
import time as _t
|
||||||
|
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5}
|
||||||
|
games = [{"id": "g1"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert len(games) == 1
|
||||||
|
|
||||||
|
def test_a_game_never_seen_is_not_treated_as_stale(self):
|
||||||
|
# last_seen 0 means "no reading", not "seen at the epoch".
|
||||||
|
h = _LiveHost()
|
||||||
|
games = [{"id": "g1"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert len(games) == 1
|
||||||
|
|
||||||
|
def test_a_game_with_no_id_is_left_alone(self):
|
||||||
|
h = _LiveHost()
|
||||||
|
games = [{"home_abbr": "H"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert len(games) == 1
|
||||||
|
|
||||||
|
def test_a_finished_game_is_dropped_even_when_fresh(self):
|
||||||
|
h = _LiveHost(over=("g2",))
|
||||||
|
games = [{"id": "g1"}, {"id": "g2"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert [g["id"] for g in games] == ["g1"]
|
||||||
|
|
||||||
|
def test_removing_several_does_not_skip_any(self):
|
||||||
|
# It iterates a copy for exactly this reason; mutating the live list
|
||||||
|
# while looping would step over the element after each removal.
|
||||||
|
h = _LiveHost(over=("g1", "g2", "g3"))
|
||||||
|
games = [{"id": "g1"}, {"id": "g2"}, {"id": "g3"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert games == []
|
||||||
|
|
||||||
|
def test_the_idle_interval_escalates_with_the_empty_streak(self):
|
||||||
|
h = _LiveHost(no_data_interval=60)
|
||||||
|
h.live_idle_max_interval = 100000
|
||||||
|
base = h._idle_live_interval()
|
||||||
|
h._empty_live_streak = 6
|
||||||
|
short = h._idle_live_interval()
|
||||||
|
h._empty_live_streak = 24
|
||||||
|
long = h._idle_live_interval()
|
||||||
|
assert base < short < long
|
||||||
|
|
||||||
|
def test_the_ceiling_bounds_even_the_unescalated_interval(self):
|
||||||
|
# base > ceiling is a reachable config: the two settings are
|
||||||
|
# independent integers with no cross-validation. Returning base
|
||||||
|
# unclamped made the wait SHRINK as the streak grew.
|
||||||
|
h = _LiveHost(no_data_interval=3600)
|
||||||
|
h.live_idle_max_interval = 900
|
||||||
|
h._empty_live_streak = 0
|
||||||
|
assert h._idle_live_interval() == 900
|
||||||
|
h._empty_live_streak = 24
|
||||||
|
assert h._idle_live_interval() == 900
|
||||||
|
|
||||||
|
def test_finding_a_live_game_resets_the_streak(self):
|
||||||
|
h = _LiveHost()
|
||||||
|
h._note_live_fetch(False)
|
||||||
|
h._note_live_fetch(False)
|
||||||
|
assert h._empty_live_streak == 2
|
||||||
|
h._note_live_fetch(True)
|
||||||
|
assert h._empty_live_streak == 0
|
||||||
|
|
||||||
|
def test_the_streak_starts_from_absent_state(self):
|
||||||
|
# The host is not required to pre-declare _empty_live_streak.
|
||||||
|
h = _LiveHost()
|
||||||
|
assert not hasattr(h, "_empty_live_streak")
|
||||||
|
h._note_live_fetch(False)
|
||||||
|
assert h._empty_live_streak == 1
|
||||||
Reference in New Issue
Block a user