mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 23:35:08 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffbf7b7067 | ||
|
|
c3a7a110c4 | ||
|
|
6047eb5e4e | ||
|
|
8363983f1c | ||
|
|
c4c46d3ba7 | ||
|
|
0f39e9a2f3 | ||
|
|
da9a999102 | ||
|
|
1e4c890d59 | ||
|
|
7f96075076 | ||
|
|
439013b18c | ||
|
|
e5bbfa2ae3 | ||
|
|
db49275075 | ||
|
|
a11412dabb | ||
|
|
fe5bed2886 | ||
|
|
5b30052b59 | ||
|
|
8557eff88a | ||
|
|
b8c01c69fb | ||
|
|
e6e0a16140 | ||
|
|
989eae9405 | ||
|
|
d469fe39d2 | ||
|
|
09103a8a7d | ||
|
|
724673ba0b | ||
|
|
6cfcf2e384 | ||
|
|
c00bf5e8e6 | ||
|
|
0e9e2cabba | ||
|
|
65d82580bc | ||
|
|
f6c0fe55d9 | ||
|
|
6f45ff5e63 | ||
|
|
1e62677257 | ||
|
|
224847cebc | ||
|
|
aeaeaa4e94 | ||
|
|
76f5d8a336 | ||
|
|
7eb7a58d0c | ||
|
|
b518c51679 | ||
|
|
6bc13a8934 | ||
|
|
bcef1957a9 |
@@ -4,4 +4,3 @@ exclude_paths:
|
||||
- "plugins/**"
|
||||
- "assets/**"
|
||||
- "test/**"
|
||||
- "scripts/debug/**"
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Files the Pi executes must stay LF even in a Windows checkout with
|
||||
# core.autocrlf=true: a CRLF shebang line fails with "bad interpreter",
|
||||
# and systemd rejects CRLF unit files.
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
|
||||
@@ -6,6 +6,10 @@ on:
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Pull requests from forks get no repository secrets, so without this
|
||||
# guard every outside contributor's PR showed this check red for a reason
|
||||
# they can't fix. Skipped checks don't block merging.
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -21,7 +25,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
# Review PRs opened by the Claude GitHub App. Without this the action
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
# needs a re-run or didn't get created.
|
||||
workflow_dispatch:
|
||||
|
||||
# Both jobs only check out the repo and run pytest.
|
||||
# The jobs only check out the repo and run the tests.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install -r requirements.txt -r web_interface/requirements.txt -r requirements-test.txt
|
||||
pip install RGBMatrixEmulator
|
||||
|
||||
- name: Run plugin safety harness
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install -r requirements.txt -r web_interface/requirements.txt -r requirements-test.txt
|
||||
pip install RGBMatrixEmulator
|
||||
|
||||
# Run the ENTIRE test tree (except test/plugins, which the
|
||||
@@ -73,3 +73,70 @@ jobs:
|
||||
--cov=src --cov=web_interface \
|
||||
--cov-report=term \
|
||||
--cov-fail-under=52
|
||||
|
||||
js-tests:
|
||||
name: Web UI JS tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r web_interface/requirements.txt
|
||||
npm install --no-audit --no-fund --prefix test/js
|
||||
|
||||
# The DOM suites test the real server-rendered pages and API, so they
|
||||
# need the web interface running. REQUIRE_DOM turns "couldn't reach it"
|
||||
# into a failure instead of a silent skip.
|
||||
- name: Start the web interface
|
||||
run: |
|
||||
EMULATOR=true python -c "from web_interface.app import app; app.run(host='127.0.0.1', port=5000, threaded=True)" > web.log 2>&1 &
|
||||
for i in $(seq 60); do curl -sf -o /dev/null http://127.0.0.1:5000/ && exit 0; sleep 1; done
|
||||
cat web.log
|
||||
exit 1
|
||||
|
||||
- name: Run JS suites
|
||||
env:
|
||||
BASE: http://127.0.0.1:5000
|
||||
REQUIRE_DOM: "1"
|
||||
run: node test/js/run_all.js
|
||||
|
||||
type-check:
|
||||
name: Type check (mypy ratchet)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
# The runtime requirements are installed so mypy sees the real types of
|
||||
# PIL, requests, psutil and friends -- missing, they'd be Any and the
|
||||
# result would differ from a developer's machine. mypy and the stubs are
|
||||
# pinned so a new release can't turn this red without a code change.
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r web_interface/requirements.txt
|
||||
pip install mypy==1.20.2 types-requests==2.33.0.20260906 types-pytz==2026.4.0.20260926
|
||||
|
||||
# mypy on exactly the modules in mypy-clean.txt; fails on any error in
|
||||
# them, or if a listed file is missing. See CONTRIBUTING.md.
|
||||
- name: Run mypy on the ratchet list
|
||||
run: python scripts/check_types.py
|
||||
|
||||
+7
-9
@@ -3,15 +3,13 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Secrets
|
||||
config/config_secrets.json
|
||||
# Atomic writes leave these behind when a save or a test is interrupted;
|
||||
# the suite drops several per run.
|
||||
config/.config_secrets.json.tmp.*
|
||||
config/config.json
|
||||
config/config.json.backup
|
||||
config/wifi_config.json
|
||||
config/uninstalled_plugins.json
|
||||
# Secrets and per-device state. Everything the software writes into config/
|
||||
# is local to one device -- config.json, config_secrets.json, wifi_config.json,
|
||||
# ytm_auth.json (a login session), saved_repositories.json, font_overrides.json,
|
||||
# and the temp files atomic writes leave behind when interrupted -- so only
|
||||
# the templates are tracked. Listing files one by one missed several.
|
||||
config/*
|
||||
!config/*.template.json
|
||||
credentials.json
|
||||
token.pickle
|
||||
|
||||
|
||||
+13
-5
@@ -37,14 +37,22 @@ repos:
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.8.0
|
||||
# The mypy ratchet -- the same check as CI's "Type check (mypy ratchet)"
|
||||
# job: mypy on exactly the modules listed in mypy-clean.txt. Run it with
|
||||
# pre-commit run mypy --hook-stage manual
|
||||
# A local hook rather than mirrors-mypy so mypy sees the packages installed
|
||||
# from requirements.txt, as CI does; an isolated hook env without them types
|
||||
# PIL, requests and friends as Any and reports different errors. Needs
|
||||
# mypy==1.20.2 (the version CI pins) in the environment you commit from.
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-requests, types-pytz]
|
||||
args: [--ignore-missing-imports, --no-error-summary]
|
||||
name: mypy (ratchet, mypy-clean.txt)
|
||||
entry: python scripts/check_types.py
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: ^src/
|
||||
always_run: true
|
||||
stages: [manual]
|
||||
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.8.3
|
||||
|
||||
+598
-249
@@ -19,266 +19,122 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fixes found testing on a Pi:
|
||||
- Stopping `ledmatrix.service` runs the controller's cleanup (SIGTERM now takes the Ctrl-C path).
|
||||
- The Logs tab's "Now showing" no longer reads "unknown" when one screen stays up longer than 2 minutes.
|
||||
- Turning Vegas on in the web UI works without a restart when it was off at startup.
|
||||
- `configure_web_sudo.sh` run as the web user keeps the reboot/poweroff rules.
|
||||
- `check_system_compatibility.sh` no longer reports installed packages as missing.
|
||||
- A network failure fetching GitHub repo info logs a warning, not an error.
|
||||
### Fixes
|
||||
|
||||
- Scripts and installer:
|
||||
- `fix_web_permissions.sh` makes `safe_plugin_rm.sh` and `safe_pip_install.sh` root-owned again after resetting ownership. A web-user-owned copy of either is a root shell, since sudo lets the web user run them as root. It also restores `config_secrets.json` to mode 640.
|
||||
- `configure_wifi_permissions.sh` checks its rules with `visudo -c` before installing them, and grants the NetworkManager captive-portal `cp` and `rm` commands `wifi_manager` runs.
|
||||
- `configure_web_sudo.sh` uses a random temp file and installs its rules with mode 440.
|
||||
- The installer prints its completion summary before the `-y` reboot, and describes the setup access point as an open network (it was shown with a password it doesn't have).
|
||||
- `fix_cache_permissions.sh` applies `setup_cache.sh`'s `ledmatrix`-group model instead of setting 777.
|
||||
- `check_system_compatibility.sh` reports anything but Debian 13 (Trixie) as unsupported, and reaches its summary.
|
||||
- New `scripts/README.md` lists every script.
|
||||
- Docs:
|
||||
- New `docs/ARCHITECTURE.md` (processes, shared state, display loop, plugin system, web UI) and `docs/PERMISSIONS.md` (owners, modes, both sudoers files, repair scripts).
|
||||
- Deprecated plugin APIs are marked in the plugin docs.
|
||||
- `src/common/README.md` covers every module.
|
||||
- Stale setup, service and troubleshooting claims are corrected.
|
||||
- On-demand no longer restarts a running display. `POST
|
||||
/display/on-demand/start` treated `start_service` (on by default, and what
|
||||
"Preview on display", the on-demand dialog and the MQTT bridge all send) as
|
||||
"restart": it stopped the service, waited 1.5s and started it again, so
|
||||
every request reloaded every plugin and left the panel blank for seconds.
|
||||
The running display already reads the request within a quarter of a second,
|
||||
mid-screen and mid-Vegas included, so the route now only starts the service
|
||||
when it is not running. `POST /display/on-demand/stop` reads
|
||||
`stop_service` as a boolean, so `"false"` no longer stops the service.
|
||||
- On-demand works for a disabled plugin. The display only loads enabled
|
||||
plugins, so "Preview on display" on a disabled plugin's config page (which
|
||||
says the plugin will be enabled for the preview) failed with
|
||||
`invalid-mode`. The display now loads the plugin live for the session,
|
||||
without writing `enabled` to `config.json`, and unloads it when on-demand
|
||||
is stopped, expires or moves to another plugin. A plugin that fails to
|
||||
load reports on-demand status `error` with `load-failed`. A session
|
||||
restored after a restart unloads its disabled plugin the same way; it used
|
||||
to stay loaded until the next restart.
|
||||
- A stop request now clears an on-demand error. After a failed request,
|
||||
`/display/on-demand/status` kept reporting `status: error` for up to two
|
||||
minutes even after a stop.
|
||||
- Re-saving unchanged data through `CacheManager.set` no longer rewrites its
|
||||
cache file. The disk cache already skipped a payload identical to the last
|
||||
one written, but `set()` stamps every record with the current time, so the
|
||||
skip never fired and every plugin rewrote its unchanged API data to the SD
|
||||
card on every update cycle. Records are now compared without that
|
||||
timestamp, and a skipped write moves the file's mtime to it instead; reads
|
||||
treat a record as fresh from the later of the two, so it expires exactly
|
||||
when the rewrite would have. Changed data or a changed `ttl` still writes,
|
||||
and so does a file another process has replaced since.
|
||||
|
||||
- Plugin store and plugin manager fixes:
|
||||
- Updating a plugin that was installed from a ZIP no longer tries to reinstall it from the LEDMatrix repository's own URL.
|
||||
- Repository URLs with `.git` in the middle are no longer mangled. The URL helpers now live in `src/plugin_system/repo_urls.py`.
|
||||
- Installing from a URL works when the repository's only branch isn't `main` or `master`.
|
||||
- A missing required config field is reported once, by name.
|
||||
- A plugin that went over `max_memory_mb` once is no longer refused on every call after that.
|
||||
- `reload_plugin` reads the manifest from the plugin's discovered directory.
|
||||
- Removed: `last_display` from plugin state info and `get_last_display()` (nothing recorded them); `PluginOperationQueue`'s `history_file` and `lazy_load` arguments; and `data/plugin_operations.json`, which nothing read.
|
||||
## 3.7.0
|
||||
|
||||
- Core service fixes:
|
||||
- `/api/v3/errors` shows each exception's real stack trace instead of `NoneType: None`.
|
||||
- Wi-Fi disconnect takes the saved connection profile down.
|
||||
- `wifi_config.json` is written atomically, and a save that fails now gets a 500.
|
||||
- `plugin://` fonts load from the plugin's own install directory. `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`.
|
||||
- `APIHelper` keeps cached responses for the `cache_ttl` it was given, instead of always 300 s.
|
||||
- Logo scales from 0.1 to 10 are honoured everywhere; values outside that range are clamped.
|
||||
- `LogoHelper` and `logo_downloader`: an empty ESPN logo list counts as a failed download, and the placeholder is written at the requested path.
|
||||
- Bundled font paths no longer depend on the directory the process was started from.
|
||||
- Backups record `src.__version__`.
|
||||
- Removed: `BackgroundDataService`'s `queue_size` stat and `clear_completed_requests()`.
|
||||
Sports consolidation stage 3 (#672). No behaviour change: nothing in core
|
||||
uses these yet, and the scoreboards adopt them when they floor on 3.7.0.
|
||||
|
||||
- Web API fixes:
|
||||
- A plugin save drops repeated entries in lists whose schema says `uniqueItems`, instead of failing validation.
|
||||
- `/api/v3/health` reports the real plugin count.
|
||||
- A malformed `vegas_plugin_order` or `vegas_excluded_plugins` is refused with a 400 and nothing is saved. It used to wipe the saved list.
|
||||
- The per-plugin health and metrics routes return the display service's latest state.
|
||||
- Resetting a plugin's config takes a backup first and reports a failed save.
|
||||
- System metrics that can't be read are `null` everywhere: `cpu_temp` off a Pi, and every metric without psutil, where `/system/status` now answers 200 instead of 503.
|
||||
- `/plugins/store/refresh` no longer claims a commit-metadata refresh it doesn't do.
|
||||
- The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`.
|
||||
### New modules
|
||||
|
||||
- Web UI:
|
||||
- Cache tab errors no longer show up in the Logs tab.
|
||||
- A tab that fails to load shows "Try again" instead of a skeleton that never goes away.
|
||||
- Plugin Store search and registry errors appear as a notification, and the Plugin Manager stays on screen.
|
||||
- The image schedule button works on uploaded images, and the editor stays open while you edit.
|
||||
- A failed plugin toggle moves the switch back.
|
||||
- Each save shows one notification; a failed Durations save says it failed.
|
||||
- Stats the server can't read show `--`.
|
||||
- New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages.
|
||||
A plugin may import these via `src.*` (floor on 3.7.0). All three hold code
|
||||
the scoreboard plugins carry as identical copies, moved without behaviour
|
||||
change under the plugins' own method names; each docstring lists what the
|
||||
host class must provide. The plugins delete their copies when they floor on
|
||||
3.7.0.
|
||||
|
||||
- Display and Vegas:
|
||||
- Vegas `max_cycle_duration` defaults to 240 s when unset, as documented (it was 600 s). The Vegas defaults are now defined once.
|
||||
- The display controller stops Vegas mode on shutdown.
|
||||
- Startup validation warnings are logged once, not twice.
|
||||
- Vegas logs one INFO line per plugin-list refresh.
|
||||
- `run.py -d` shows `display_manager` debug output.
|
||||
- Removed: the Vegas staging buffer that was never filled (`swap_buffers()`, and `staging_count` / `current_index` in `get_buffer_status()`), unread `ContentSegment` fields, and `geometry.find_blank_cut()`.
|
||||
- `src/common/sports_celebration.py` — `SportsCelebrationMixin`, the
|
||||
score/win celebration takeover drawn by afl, football, hockey, nrl and
|
||||
soccer (`_draw_celebration_layout` and the palette, backdrop, scenery,
|
||||
confetti and crest steps behind it), plus its colour helpers as free
|
||||
functions: `logo_palette`, `lift_color`, `cap_luminance`, `mix_color`,
|
||||
`scale_color`, `dim_rgba`, `rgb_luminance`, `rgb_saturation`,
|
||||
`color_distance`. Only the drawing: when to celebrate, the phrase and the
|
||||
scenery stay in each plugin.
|
||||
- `src/common/sports_fetch.py` — `SportsFetchMixin`, four `SportsCore`
|
||||
methods identical in all nine scoreboards: `_fetch_season_directly`,
|
||||
`_background_fetches_espn_ranges`, `_needs_previous_day` and
|
||||
`_wants_live_odds` (with `_LOOKBACK_CUTOFF_HOUR` and
|
||||
`_LIVE_ODDS_LOOKAHEAD`).
|
||||
- `src/common/sports_card_wrappers.py` — `SportsCardWrappersMixin`, the
|
||||
seventeen `sports_card` delegations the eight scoreboard game renderers
|
||||
carry (`_vs_text`, `_element_color`, `_format_game_date`, ...): the methods
|
||||
`SportsGameRendererMixin` expects its host to provide.
|
||||
|
||||
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
|
||||
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
|
||||
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
|
||||
INFO; 4xx at WARNING, 5xx at ERROR. `LEDMATRIX_DEBUG=true` shows them again.
|
||||
`web_interface/logging_config.py` is removed. The web cache
|
||||
(`web_interface/cache.py`) now honours the TTL a value was stored with and is
|
||||
thread-safe.
|
||||
## 3.6.2
|
||||
|
||||
- One plugin-directory resolver, `src/plugin_system/plugin_dirs.py`, behind
|
||||
discovery, `PluginManager.get_plugin_directory`, `PluginLoader`, the store and
|
||||
state reconciliation. A manifest's `id` wins over a directory merely named for
|
||||
the id; hidden and `.standalone-backup-` directories are never treated as
|
||||
plugins (auto-update could previously try to update a backup); ids like
|
||||
`a/b` or `..` resolve to nothing everywhere. Installs where each directory is
|
||||
named for its manifest id, the installer's layout, behave as before.
|
||||
A fix to `src.common.favorite_team_check` (#670).
|
||||
|
||||
- `/api/v3` routes answer an exception they don't handle themselves from one
|
||||
blueprint error handler, with the same `{status, message, details}` body the
|
||||
53 removed per-route catch-alls returned. `ErrorCategory` and the
|
||||
`error_category` key are removed from `src.web_interface.errors` (nothing read
|
||||
them); `exception_error_response()` replaces the `from_exception` +
|
||||
`error_response` pairs. A failing plugin action script's error now names the
|
||||
real failure instead of `UnboundLocalError`.
|
||||
### Fixes
|
||||
|
||||
- `FontManager.get_font()` returns a BDF font at its native size when asked for
|
||||
a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to
|
||||
return PIL's default font, a different typeface, so a plugin that relied on
|
||||
that will now render the font it asked for.
|
||||
- `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for
|
||||
the display are written (`config/wifi_status.json`).
|
||||
- `src.device_location` — a blank `Location` field on a Starlark (Tidbyt) app
|
||||
now renders at the device's City / State / Country (geocoded once via
|
||||
Open-Meteo and cached) instead of the app author's hard-coded default,
|
||||
usually San Francisco. A location saved on the app still wins. With no
|
||||
device city set, or when the lookup fails or finds no match, the app keeps
|
||||
its own default (a failed lookup is retried after 30 minutes). Clearing an
|
||||
app's location in the web UI now actually clears it; the save used to drop
|
||||
the blank field, so the old value stayed.
|
||||
- `src.common.bdf_font` — `load_bdf_face(path, size)` (a cached
|
||||
`freetype.Face` plus the pixel size it really renders at, falling back to
|
||||
the file's native strike) and `draw_bdf_text(draw, text, x, y, face, color)`.
|
||||
`DisplayManager`, `FontManager`, `element_style` and the plugin test harness
|
||||
now all load and draw BDF text through it; the panel's pixels are unchanged
|
||||
and BDF text draws 10-250x faster. The plugin test harness's
|
||||
`calendar_font` / `bdf_5x7_font` now has the panel's 7px size set: it used
|
||||
to be an unsized face, so in golden images and `check_plugin` /
|
||||
`dev_server` previews its text sat 6px above where the panel draws it (off
|
||||
the canvas entirely near the top) and `get_font_height()` returned 0.
|
||||
- The favourite-team check no longer says the Europa League season has
|
||||
finished between matchdays. Its scoreboard keeps showing the last matchday,
|
||||
and its calendar is a "list" of rounds rather than match days, so neither
|
||||
3.6.1 rule applied. When every event is past, a round in a list calendar
|
||||
that has not started yet (outside an offseason phase) now draws no
|
||||
conclusion. PLL, the World Cup and AFL, whose seasons are over, are still
|
||||
reported as finished: no round of theirs is still to start. (#670)
|
||||
|
||||
- The web UI's Fonts tab has a **Used by** column: the loaded plugins that
|
||||
registered each font with `FontManager.register_manager_font()`, published
|
||||
by the display service to the shared cache (`src/font_usage.py`) and merged
|
||||
into `GET /api/v3/fonts/catalog` as `used_by`. Deleting a font a plugin
|
||||
uses now names those plugins in the confirmation (it is not blocked).
|
||||
`FontManager.forget_manager_fonts()` is new; unloading a plugin calls it.
|
||||
## 3.6.1
|
||||
|
||||
Deprecated, removed in 3.7.0 (each logs a warning on first use; see
|
||||
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
|
||||
core, the monorepo or the registry's third-party plugins calls them:
|
||||
A fix to `src.common.favorite_team_check` (#667). Plugins that drop their
|
||||
bundled copy of it should floor on 3.6.1, not 3.6.0.
|
||||
|
||||
- `CacheManager`: `has_data_changed`, `update_cache`, `setup_persistent_cache`,
|
||||
`get_sport_live_interval`, `get_sport_key_from_cache_key`,
|
||||
`get_background_cached_data`, `is_background_data_available`,
|
||||
`record_cache_hit`, `record_cache_miss`, `record_fetch_time`,
|
||||
`get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats`.
|
||||
- `DisplayManager`: `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`,
|
||||
`draw_snow`, `draw_text_with_icons`, `get_scrolling_stats`.
|
||||
- `FontManager`: `set_override`, `remove_override`, `get_overrides`,
|
||||
`add_font`, `remove_font`, `validate_font`, `get_font_catalog`,
|
||||
`get_available_fonts`, `get_size_tokens`, `get_performance_stats`,
|
||||
`get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`,
|
||||
`unregister_plugin_fonts`.
|
||||
- `PluginManager.get_enabled_plugins`.
|
||||
### Fixes
|
||||
|
||||
### Config writes
|
||||
- The favourite-team check no longer logs "the season has finished" for a
|
||||
league that is still playing. ESPN's default scoreboard keeps showing the
|
||||
last slate after it: MLB's regular-season games two days into the
|
||||
postseason, a soccer league's previous matchday between rounds. When every
|
||||
event is in the past, the check now looks first at the league's phase (a
|
||||
regular season or postseason that has moved past the events shown draws no
|
||||
conclusion) and at a match-day calendar (`calendarType` "day" with
|
||||
`calendarIsWhitelist`, as soccer, the NHL and the NBA use), whose next date
|
||||
becomes "nothing on until <date>". An offseason, or a payload without these
|
||||
fields, is reported as before.
|
||||
|
||||
- A power cut or crash mid-save can no longer leave `config/config.json`
|
||||
truncated. `ConfigManager.save_config()` wrote the file in place; it,
|
||||
`save_config_atomic()`, `save_raw_file_content()` and backup rollback now
|
||||
share one writer (`atomic_write_text` in `src/config_manager_atomic.py`)
|
||||
that fsyncs a temp file, renames it into place and fsyncs the directory.
|
||||
- `save_config_atomic()` no longer rewrites `config_secrets.json` on every
|
||||
save, only when its content changes, and rotating backups no longer re-reads
|
||||
every backup. The backups themselves are unchanged:
|
||||
`config/backups/config.json.backup.<version>` plus its paired secrets
|
||||
backup, five newest kept.
|
||||
- A save by the root-run display service keeps the file's previous owner
|
||||
instead of handing `config.json` to root, and an install path with
|
||||
"secrets" in a directory name no longer makes `config.json` mode 0640.
|
||||
## 3.6.0
|
||||
|
||||
New names in existing modules (no new modules; a plugin importing these must
|
||||
floor on the release that ships them):
|
||||
New modules a plugin may import via `src.*` (floor on 3.6.0). Both are
|
||||
promoted from files the scoreboard plugins carry as copies; the plugins keep
|
||||
their copies as a fallback until they floor on 3.6.0. No other change since
|
||||
3.5.0.
|
||||
|
||||
- `src.common.api_helper`: `USER_AGENT`, `DEFAULT_HTTP_HEADERS` (read-only).
|
||||
- `src.logo_downloader`: `fetch_logo`, `save_png_atomically`,
|
||||
`shared_downloader`.
|
||||
- `src.common.sports_card.unshare_element_fonts` takes an optional third
|
||||
argument, `element_for_font` (default: the module's `ELEMENT_FOR_FONT`, so
|
||||
existing calls are unchanged).
|
||||
|
||||
### Sports twins
|
||||
|
||||
- The `SportsCoreSharedMixin` helpers that behave identically to their
|
||||
`sports_card` twins (`_card_option`, `_vs_text`, `_format_game_time`,
|
||||
`_coerce_rgb`, `_crisp_size`, `_unshare_element_fonts`, the colour/month/
|
||||
weekday/font-grid tables) are now thin wrappers over the `sports_card`
|
||||
functions, and `_format_game_date` / `_schema_font_size` share its
|
||||
formatting body and schema parser. No method was removed or renamed and
|
||||
nothing renders differently: `test/test_sports_twins.py` checks each pair
|
||||
against the same inputs, and the old and new mixin agree on every input
|
||||
there. The pairs that do differ -- favourite-result colours on nested
|
||||
payloads, the weekday's timezone, the element-name map, per-mode colours --
|
||||
are left as they are and pinned in that test.
|
||||
|
||||
### Logo downloads
|
||||
|
||||
- `download_missing_logo` / `LogoDownloader.download_logo` (the path the
|
||||
scoreboard plugins use) now stream the logo with a 10 MB cap, accept only an
|
||||
`image/*` response that Pillow can decode, and move the finished RGBA PNG
|
||||
into place atomically. A failed, oversized or non-image download no longer
|
||||
leaves a partial file behind, and no longer replaces a logo already on disk.
|
||||
`LogoHelper._download_logo` goes through the same code. Signatures and return
|
||||
values are unchanged; saved files are pixel-identical to before.
|
||||
- `download_missing_logo` reuses one downloader (one `requests.Session`) per
|
||||
thread instead of building a new one for every logo.
|
||||
- Placeholder logos are written atomically, without the `test_write.tmp`
|
||||
probe file.
|
||||
|
||||
### HTTP headers
|
||||
|
||||
- The logo downloader and the background data service send the real
|
||||
`LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)` User-Agent
|
||||
instead of a `yourusername` / `contact@example.com` placeholder, and no
|
||||
longer set `Accept-Encoding: ... br` by hand (brotli is not installed, so a
|
||||
`br` response could not be decoded); requests picks the encodings.
|
||||
|
||||
### Plugin error reporting
|
||||
|
||||
- `/api/v3/errors/summary` and `/api/v3/errors/plugin/<id>` report the errors
|
||||
the display service recorded. They used to read the web process's own error
|
||||
aggregator, which never records anything, so they always answered "no
|
||||
errors". The display service now publishes a bounded snapshot to the shared
|
||||
cache (`plugin_error_snapshot`, at most every 10 seconds and only on change;
|
||||
`src/error_aggregator.py`, started from `DisplayController.__init__`).
|
||||
Responses keep their shape and add `snapshot_available`, `generated_at` and
|
||||
`clear_pending`; exception text has credentials redacted.
|
||||
- `POST /api/v3/errors/clear` records a request (`plugin_error_clear_request`)
|
||||
the display service applies within about 5 seconds; reads hide the cleared
|
||||
errors at once. It accepts `"all": true`, and `cleared_count` can be `null`
|
||||
when the count is only known to the display service.
|
||||
- The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating
|
||||
errors and a Clear button.
|
||||
- Credential redaction in exception text (`src/redaction.py`) takes time
|
||||
proportional to the text, not its square. Two patterns were quadratic: URL
|
||||
`user:password@`, on a long unbroken run of letters or digits (a hex digest,
|
||||
an ID), and `Authorization:` followed by a long run of whitespace. Either
|
||||
used to stall every thread of the display service for up to seconds each
|
||||
time the snapshot was published: about 0.5s for 20k characters of hex, 8s
|
||||
for 20k spaces. What gets redacted is unchanged.
|
||||
|
||||
### Removed
|
||||
|
||||
- **The skin system.** Skins never rendered with the current scoreboard
|
||||
plugins, so they are gone rather than "not supported yet": `src/skin_system/`,
|
||||
`skins/`, `scripts/validate_skin.py`, `GET /api/v3/skins`, the store's
|
||||
`"type": "skin"` handling and `docs/SKIN_SYSTEM.md` / `docs/CREATING_SKINS.md`.
|
||||
A `skin` or `skin_options` key left in a plugin's saved config still loads
|
||||
and saves without a validation error; it is ignored, and the next save of
|
||||
that plugin's settings removes it (unless the plugin's own schema declares
|
||||
the key).
|
||||
- **`src/base_classes/`** (`SportsCore`, the sport and mode classes,
|
||||
`CelebrationMixin`, the rotation strategies, `data_sources`,
|
||||
`api_extractors`). No known plugin imports it. A plugin that does must use
|
||||
`src.common` or its own copy of the code.
|
||||
|
||||
- `src.common.frame_timing` -- times every frame the display presents, whoever
|
||||
drew it, and writes cumulative counters to `/dev/shm`. Two tools read it:
|
||||
`scripts/frame_soak.py` judges a running service (late frames, freezes,
|
||||
where the time goes), and `scripts/render_bench.py` judges the hardware and
|
||||
render path alone on a synthetic strip. Both fail a run above 0.1% late
|
||||
frames, and both call a loop that never waited for the panel NOT LOCKED. A
|
||||
stall watchdog logs the stack of whatever holds a scroll up for 250 ms or
|
||||
more. See `docs/SCROLL_PERFORMANCE.md`, "Soaking a rig".
|
||||
|
||||
- `display.scan_order_compensation` (`"auto"` by default): while something
|
||||
scrolls at one pixel per refresh, one half of each panel is shown a refresh
|
||||
behind the other, which removes the 1px step a 1:N-scan panel shows across
|
||||
its middle. Only for layouts whose row order is known; `"off"` disables it.
|
||||
See `docs/SCROLL_PERFORMANCE.md`, "A tear across the middle on fast scrolls".
|
||||
- `src/common/favorite_team_check.py` — `FavoriteTeamCheck(logger, leagues)`:
|
||||
checks configured favourite team codes against ESPN once per league, on a
|
||||
daemon thread, and logs why a league shows nothing (a wrong code, with the
|
||||
nearest real one, or a season that has not started). The seven copies
|
||||
(`<sport>_favorite_check.py`) were byte-identical; this is the same code,
|
||||
with type annotations added.
|
||||
- `src/common/sports_timezone.py` — `resolve_timezone_name()` /
|
||||
`resolve_timezone()` (plus `system_timezone_name()`): the timezone a
|
||||
scoreboard draws start times in. The ten copies (`<sport>_timezone.py`)
|
||||
differed only in two values, which are keyword-only arguments here:
|
||||
`plugin_label` (named in the warning logged when nothing resolves) and
|
||||
`writeback_fixed_in` (for a plugin that once wrote `"UTC"` back into the
|
||||
saved config; `None` otherwise). Same resolution order and log messages.
|
||||
|
||||
## 3.5.0
|
||||
|
||||
@@ -297,13 +153,69 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
deletes a copy and leans on an older module having grown the method fails at
|
||||
runtime with `AttributeError`, which no load-time check sees, while a missing
|
||||
module fails at load. Nothing in core uses it yet.
|
||||
- `test/test_common_is_hardware_free.py` — `src/common` must import without
|
||||
`rgbmatrix` and never import `src.base_classes`, `src.display_manager` or
|
||||
`src.plugin_system` at module level.
|
||||
- `src/common/espn_dates.py` — `fetch_espn_scoreboard`,
|
||||
`fetch_espn_date_chunks`, `espn_date_chunks`, `clamp_espn_limit`,
|
||||
`ESPN_MAX_LIMIT`: fetch an ESPN scoreboard date range now that ESPN rejects
|
||||
ranges (see Sports data below). Plugins bundle a copy of it.
|
||||
- `src/common/json_body.py` — `response_json(response)`: `response.json()`,
|
||||
parsed by orjson when it is installed (an optional dependency) and by the
|
||||
stdlib otherwise; an orjson parse error falls back to `response.json()` so
|
||||
requests raises its usual error. Same Python objects either way; a season
|
||||
schedule parses about 1.7x faster on a Pi 4, and the parse holds the GIL (so
|
||||
freezes the display) for that much less time. `espn_dates` and
|
||||
`BackgroundDataService` use it; `espn_dates` falls back to `response.json()`
|
||||
when it is missing, so the plugins' bundled copies of `espn_dates` still load
|
||||
on an older core.
|
||||
- `src/common/bdf_font.py` — `load_bdf_face(path, size)` (a cached
|
||||
`freetype.Face` plus the pixel size it really renders at, falling back to
|
||||
the file's native strike) and `draw_bdf_text(draw, text, x, y, face, color)`.
|
||||
`DisplayManager`, `FontManager`, `element_style` and the plugin test harness
|
||||
now all load and draw BDF text through it; the panel's pixels are unchanged
|
||||
and BDF text draws 10-250x faster. The plugin test harness's
|
||||
`calendar_font` / `bdf_5x7_font` now has the panel's 7px size set: it used
|
||||
to be an unsized face, so in golden images and `check_plugin` /
|
||||
`dev_server` previews its text sat 6px above where the panel draws it (off
|
||||
the canvas entirely near the top) and `get_font_height()` returned 0.
|
||||
|
||||
Also new under `src/` since 3.4.0, but internal to core rather than for plugins:
|
||||
`src/common/frame_timing.py` and `src/common/render_gate.py` (see Scrolling),
|
||||
`src/core_config_keys.py`, `src/deprecation.py`, `src/device_location.py`,
|
||||
`src/font_usage.py`, `src/matrix_support.py`, `src/pi5_matrix_support.py`,
|
||||
`src/redaction.py`, `src/scan_order.py`, `src/web_interface/config_arrays.py`,
|
||||
and in `src/plugin_system/`: `plugin_dirs.py`, `repo_urls.py`,
|
||||
`store_install.py`, `store_registry.py` and `store_update.py`.
|
||||
|
||||
New names in existing modules (a plugin using these must floor on 3.5.0):
|
||||
|
||||
- `src.common.api_helper`: `USER_AGENT`, `DEFAULT_HTTP_HEADERS` (read-only).
|
||||
- `src.logo_downloader`: `fetch_logo`, `save_png_atomically`,
|
||||
`shared_downloader`.
|
||||
- `src.common.sports_card.unshare_element_fonts` takes an optional third
|
||||
argument, `element_for_font` (default: the module's `ELEMENT_FOR_FONT`, so
|
||||
existing calls are unchanged).
|
||||
- `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for
|
||||
the display are written (`config/wifi_status.json`).
|
||||
- `BackgroundDataService.handles_espn_date_ranges` (see Sports data).
|
||||
- `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`, and
|
||||
`FontManager.forget_manager_fonts()` is new (see Fonts).
|
||||
|
||||
Deprecated, removed in 3.7.0 (each logs a warning on first use; see
|
||||
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
|
||||
core, the monorepo or the registry's third-party plugins calls them:
|
||||
|
||||
- `CacheManager`: `has_data_changed`, `update_cache`, `setup_persistent_cache`,
|
||||
`get_sport_live_interval`, `get_sport_key_from_cache_key`,
|
||||
`get_background_cached_data`, `is_background_data_available`,
|
||||
`record_cache_hit`, `record_cache_miss`, `record_fetch_time`,
|
||||
`get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats`.
|
||||
- `DisplayManager`: `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`,
|
||||
`draw_snow`, `draw_text_with_icons`, `get_scrolling_stats`.
|
||||
- `FontManager`: `set_override`, `remove_override`, `get_overrides`,
|
||||
`add_font`, `remove_font`, `validate_font`, `get_font_catalog`,
|
||||
`get_available_fonts`, `get_size_tokens`, `get_performance_stats`,
|
||||
`get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`,
|
||||
`unregister_plugin_fonts`.
|
||||
- `PluginManager.get_enabled_plugins`.
|
||||
|
||||
### Config saves and plugin config preparation
|
||||
|
||||
@@ -350,8 +262,23 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
"enabled but not found in plugins directory", and plugin ids that collide
|
||||
with any core config section are flagged: the last private copies of the
|
||||
core-key list now use `src/core_config_keys.py`.
|
||||
- Plugin config saves recombine position-keyed inputs for nullable array fields (`"type": ["array", "null"]`).
|
||||
- A blank Max Dynamic Duration keeps the stored value instead of failing the Display save with a 500; other values must be whole seconds from 30 to 1800.
|
||||
- A power cut or crash mid-save can no longer leave `config/config.json`
|
||||
truncated. `ConfigManager.save_config()` wrote the file in place; it,
|
||||
`save_config_atomic()`, `save_raw_file_content()` and backup rollback now
|
||||
share one writer (`atomic_write_text` in `src/config_manager_atomic.py`)
|
||||
that fsyncs a temp file, renames it into place and fsyncs the directory.
|
||||
- `save_config_atomic()` no longer rewrites `config_secrets.json` on every
|
||||
save, only when its content changes, and rotating backups no longer re-reads
|
||||
every backup. The backups themselves are unchanged:
|
||||
`config/backups/config.json.backup.<version>` plus its paired secrets
|
||||
backup, five newest kept.
|
||||
- A save by the root-run display service keeps the file's previous owner
|
||||
instead of handing `config.json` to root, and an install path with
|
||||
"secrets" in a directory name no longer makes `config.json` mode 0640.
|
||||
|
||||
### Sports data
|
||||
### Sports data, logos and odds
|
||||
|
||||
- Since 2026-09-15 ESPN answers `dates=YYYYMMDD-YYYYMMDD` scoreboard queries
|
||||
with `400 Bad Request` for every sport, so season schedules, the weeks window
|
||||
@@ -399,6 +326,44 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
longer swallowed as a missing poll. This is the implementation the football,
|
||||
baseball and hockey boards already ship; core was the last copy on the old
|
||||
one.
|
||||
- `BaseOddsManager.get_odds()` no longer returns the cached "no odds" marker (`{"no_odds": True}`) as if it were odds. A game ESPN had no odds for is cached that way so it isn't re-requested every update; on the next update the cache hit handed the marker back, and callers saw a truthy dict. It now returns `None` for it, on the cache hit and in the stale-cache fallback after a failed fetch, as the plugins' bundled copies already did.
|
||||
- Background data fetches retry at one level instead of two. The session adapter retried a connection error three times inside every attempt of the service's own retry loop, so a dead network cost up to 16 connection attempts per request and held one of the few worker threads throughout; now it is the loop's `max_retries + 1` attempts. ESPN date-range chunks, which don't go through that loop and skip a chunk that fails, keep a small connection retry of their own so a brief blip doesn't drop a month from a cached season.
|
||||
- `LogoHelper.load_logo_with_download()` sizes its placeholder to the scaled logo box, like a real logo (only differs when `scale` isn't 1).
|
||||
- The AP Top 25 resolver remembers a failed or empty rankings fetch for 5 minutes, so an ESPN outage no longer costs every scoreboard update a 30s timeout. Its duplicate INFO log line is gone.
|
||||
- `BackgroundDataService` runs a cache-hit callback outside its lock, as the fetch path does.
|
||||
- `LogoHelper.load_logo_with_download()` waits an hour before retrying a download that failed for a missing logo, instead of retrying (with a 30 s timeout) on every call.
|
||||
- Restamping a placeholder logo writes the file atomically.
|
||||
- The odds manager logs cache hits, misses and fetches at DEBUG, and a bad JSON body is logged as a parse error rather than a failed fetch.
|
||||
- `APIHelper` keeps cached responses for the `cache_ttl` it was given, instead of always 300 s.
|
||||
- Logo scales from 0.1 to 10 are honoured everywhere; values outside that range are clamped.
|
||||
- `LogoHelper` and `logo_downloader`: an empty ESPN logo list counts as a failed download, and the placeholder is written at the requested path.
|
||||
- The `SportsCoreSharedMixin` helpers that behave identically to their
|
||||
`sports_card` twins (`_card_option`, `_vs_text`, `_format_game_time`,
|
||||
`_coerce_rgb`, `_crisp_size`, `_unshare_element_fonts`, the colour/month/
|
||||
weekday/font-grid tables) are now thin wrappers over the `sports_card`
|
||||
functions, and `_format_game_date` / `_schema_font_size` share its
|
||||
formatting body and schema parser. No method was removed or renamed and
|
||||
nothing renders differently: `test/test_sports_twins.py` checks each pair
|
||||
against the same inputs, and the old and new mixin agree on every input
|
||||
there. The pairs that do differ -- favourite-result colours on nested
|
||||
payloads, the weekday's timezone, the element-name map, per-mode colours --
|
||||
are left as they are and pinned in that test.
|
||||
- `download_missing_logo` / `LogoDownloader.download_logo` (the path the
|
||||
scoreboard plugins use) now stream the logo with a 10 MB cap, accept only an
|
||||
`image/*` response that Pillow can decode, and move the finished RGBA PNG
|
||||
into place atomically. A failed, oversized or non-image download no longer
|
||||
leaves a partial file behind, and no longer replaces a logo already on disk.
|
||||
`LogoHelper._download_logo` goes through the same code. Signatures and return
|
||||
values are unchanged; saved files are pixel-identical to before.
|
||||
- `download_missing_logo` reuses one downloader (one `requests.Session`) per
|
||||
thread instead of building a new one for every logo.
|
||||
- Placeholder logos are written atomically, without the `test_write.tmp`
|
||||
probe file.
|
||||
- The logo downloader and the background data service send the real
|
||||
`LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)` User-Agent
|
||||
instead of a `yourusername` / `contact@example.com` placeholder, and no
|
||||
longer set `Accept-Encoding: ... br` by hand (brotli is not installed, so a
|
||||
`br` response could not be decoded); requests picks the encodings.
|
||||
|
||||
### Scrolling
|
||||
|
||||
@@ -429,6 +394,92 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
held 20 ms frame as missed refreshes, and Vegas `frame_based_scrolling` /
|
||||
`scroll_delay` are described as the speed clamp they are rather than frame
|
||||
stepping. Scoreboard `scroll_delay` is documented as ignored for pacing.
|
||||
- `ScrollHelper.set_scrolling_image()` accepts RGBA, L and palette images (transparent pixels become black), and a new scrolling image no longer jumps ahead by the time the helper sat idle.
|
||||
- `src.common.frame_timing` -- times every frame the display presents, whoever
|
||||
drew it, and writes cumulative counters to `/dev/shm`. Two tools read it:
|
||||
`scripts/frame_soak.py` judges a running service (late frames, freezes,
|
||||
where the time goes), and `scripts/render_bench.py` judges the hardware and
|
||||
render path alone on a synthetic strip. Both fail a run above 0.1% late
|
||||
frames, and both call a loop that never waited for the panel NOT LOCKED. A
|
||||
stall watchdog logs the stack of whatever holds a scroll up for 250 ms or
|
||||
more. See `docs/SCROLL_PERFORMANCE.md`, "Soaking a rig".
|
||||
- `display.scan_order_compensation` (`"auto"` by default): while something
|
||||
scrolls at one pixel per refresh, one half of each panel is shown a refresh
|
||||
behind the other, which removes the 1px step a 1:N-scan panel shows across
|
||||
its middle. Only for layouts whose row order is known; `"off"` disables it.
|
||||
See `docs/SCROLL_PERFORMANCE.md`, "A tear across the middle on fast scrolls".
|
||||
|
||||
### Display and Vegas
|
||||
|
||||
- Vegas scrolls in step with the panel's refresh (#628). With `smooth_scroll`
|
||||
(on by default) the strip moves a whole number of pixels per presented
|
||||
frame, each held for `frame_hold` refreshes and timed by `SwapOnVSync`,
|
||||
the same pacing as the plugin tickers; it used to advance by elapsed time
|
||||
and sleep to `target_fps`, missing a vsync every few frames. The speed is
|
||||
solved against the panel's measured refresh when that is below its
|
||||
`limit_refresh_rate_hz` cap. The old sub-pixel blend, which the panel shows
|
||||
as shimmer, is kept as `vegas_scroll.sub_pixel_blend` (default off). While
|
||||
scrolling, the web preview's PNG is encoded on a background writer instead
|
||||
of the render thread. On a Pi 4 driving 512x64, late frames went from about
|
||||
6.4% to 0.7%.
|
||||
- Vegas prepares plugin content off the render thread (#630). A plugin that
|
||||
needed the shared canvas used to be fetched on the render thread, stalling
|
||||
the scroll for as long as it took (320 ms for news, 660 ms for a hockey
|
||||
scoreboard, measured). `DisplayManager.offscreen(width, height)` gives the
|
||||
calling thread a canvas of its own: `image`, `draw` and `matrix` are now
|
||||
properties that resolve to it inside the block, where `update_display()`,
|
||||
the hardware half of `clear()` and `set_scrolling_state()` /
|
||||
`set_frame_hold()` do nothing. Background fetches take the plugin's lock,
|
||||
waiting up to 2 s for a running `update()` and otherwise skipping the plugin
|
||||
that round. A GIL gate (`src/common/render_gate.py`) pauses the prefetch
|
||||
thread outside a window around each vsync swap, so the render thread finds
|
||||
the GIL free; it needs the rebuilt binding that releases the GIL in
|
||||
`SwapOnVSync` and stays off (one INFO line per Vegas run) on a stock one.
|
||||
New `vegas_scroll` keys: `offscreen_prefetch` and `prefetch_gate` (both on by
|
||||
default) and `switch_interval_ms` (experimental, default 0, off). Design in
|
||||
`docs/OFFSCREEN_RENDERING.md`.
|
||||
- On-demand requests, the display on/off schedule and brightness take effect
|
||||
within about a quarter of a second instead of at the next screen (#618). A
|
||||
screen can stay up for a minute and a Vegas iteration for 240 s, so an
|
||||
on-demand request during Vegas waited for the iteration to end and a
|
||||
brightness save mid-screen could be lost. Vegas now stops for an on-demand
|
||||
request and for the display being scheduled off, and a brightness change
|
||||
re-sends the current frame. Plugin enable/disable, screen durations and Vegas
|
||||
settings still apply at the next screen.
|
||||
- The Rotation & Durations page takes effect (#605). A saved
|
||||
`display.display_durations` value now wins over the plugin's own duration;
|
||||
the plugin was asked first, and every plugin inherits
|
||||
`get_display_duration()`, so saved values did nothing. The page shows an
|
||||
unsaved screen blank with the plugin's own duration as the placeholder (it
|
||||
showed 30 where the real default is 15), and saving a blank removes the
|
||||
override. Durations saved before this now apply.
|
||||
- Vegas settings reach a running scroll (#605): they are queued when
|
||||
`display.vegas_scroll` changes (unrelated saves don't rebuild the strip) and
|
||||
also applied while Vegas is stopped. The sync follower's scroll-speed
|
||||
default (75) now matches `VegasModeConfig`'s (50). The Vegas live-priority
|
||||
scan is throttled to 4 Hz.
|
||||
- `DisplayManager.defer_update()` from a plugin's update thread no longer loses queued updates while the render thread processes the queue; the queue is locked, and the queued callables still run outside the lock.
|
||||
- **Behaviour change:** when `display.hardware.limit_refresh_rate_hz` is missing from config, the panel is now capped at 100 Hz (the config template's value) instead of 90 Hz. Scroll pacing already assumed 100 Hz in that case, so it now matches what the panel does. Configs that set the key (every config migrated from the template) are unaffected.
|
||||
- A sync follower adopts the leader's scroll image between frames on the render thread, instead of the TCP thread swapping the image, array and width while a frame is being drawn.
|
||||
- `update_display()` errors are logged once with a traceback, then at most once a minute with a count, instead of an untraced line every frame. Several swallowed exceptions in `DisplayController` now log at DEBUG.
|
||||
- The repo-root `display_controller.py` now runs `run.py` (the real entry point), so it gets run.py's `-e`/`-d` flags, logging setup and `sys.dont_write_bytecode`.
|
||||
- Vegas: a plugin set to `vegas_mode: "static"` pauses the scroll for its turn again. The pause was triggered by peeking at the front of a segment buffer that continuous scrolling (the default) never advances, so a static plugin paused only if it happened to be first, once, at startup, and otherwise scrolled past as ordinary content; swap mode had the same problem for any static plugin not first in its cycle. The render pipeline now marks where each static plugin's turn falls in the strip and the scroll pauses when it gets there. The pause runs the plugin's `display()` under its plugin lock, and a static plugin's content is no longer rendered for the strip.
|
||||
- The display loop no longer spins at 100% CPU when no enabled mode has anything to show (for example, only a sports plugin enabled in its off-season). After one full rotation of empty modes it checks one mode per second until something shows; live content still takes over at once.
|
||||
- Stopping `ledmatrix.service` runs the controller's cleanup (SIGTERM now takes the Ctrl-C path).
|
||||
- Turning Vegas on in the web UI works without a restart when it was off at startup.
|
||||
- Vegas comes back after live content interrupts it. It stayed paused, and the display fell back to normal rotation until a restart.
|
||||
- A day with dimming turned off in a per-day dim schedule stays at normal brightness. Before, brightness went back to dim for most of each minute.
|
||||
- Stopping on-demand after a second request resumes rotation where it was first interrupted, not at the first request's screen.
|
||||
- Turning Vegas off and on no longer shows content prepared for the previous run, including plugins disabled in between.
|
||||
- How long a Vegas iteration runs is timed with the monotonic clock, so an NTP clock step on a Pi without an RTC doesn't cut it short or stretch it.
|
||||
- The sync status file is removed when the display service stops, and at startup in standalone mode, so the web UI no longer reports a peer from an earlier run. Concurrent writes each use their own temp file.
|
||||
- `render_gate.swap_releases_gil()` delegates to `frame_timing.binding_releases_gil()` instead of duplicating it.
|
||||
- Vegas `max_cycle_duration` defaults to 240 s when unset, as documented (it was 600 s). The Vegas defaults are now defined once.
|
||||
- The display controller stops Vegas mode on shutdown.
|
||||
- Startup validation warnings are logged once, not twice.
|
||||
- Vegas logs one INFO line per plugin-list refresh.
|
||||
- `run.py -d` shows `display_manager` debug output.
|
||||
- Removed: the Vegas staging buffer that was never filled (`swap_buffers()`, and `staging_count` / `current_index` in `get_buffer_status()`), unread `ContentSegment` fields, and `geometry.find_blank_cut()`.
|
||||
|
||||
### Web interface
|
||||
|
||||
@@ -493,8 +544,142 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
discover when nothing has been discovered yet, and rescan once when a
|
||||
specific plugin id (or, for on-demand by mode, a mode) is not found, so a
|
||||
plugin installed since the last scan is found too.
|
||||
- `web_interface/blueprints/api_v3/plugins.py` (3,285 lines) is split by area into `plugins.py` (installed list, enable/disable, plugin actions), `plugin_store.py`, `plugin_config.py`, `plugin_assets.py`, `plugin_health.py`, `plugin_operations.py` and `plugin_calendar.py`. Pure move: every function body and route decorator is byte-identical, and URLs and endpoint names are unchanged.
|
||||
- Plugins installed as `ledmatrix-<id>` (or in a directory not named after their id) work in the installed list, the update button, recorded versions, the plugin config form and plugin web UI pages. Those routes built `plugins_dir/<id>` themselves instead of asking the plugin manager.
|
||||
- Uploading several plugin images checks every file before saving any, so a rejected file no longer leaves the others saved; the images' `.metadata.json` and the calendar plugin's `credentials.json` are written atomically, and the credentials upload no longer returns the server's absolute path.
|
||||
- `"false"` sent as a string no longer counts as true when toggling a plugin (including Starlark apps) or starting on-demand mode (`pinned`, `start_service`); `force` on the AP-enable route is parsed like every other WiFi boolean (`"yes"` and `1` now force).
|
||||
- The live-preview stream starts a new broadcast thread for a client that connects while the previous one is shutting down; that client got no updates.
|
||||
- The web server's log filter no longer raises when werkzeug logs with `exc_info=True`.
|
||||
- The raw secrets editor's save errors carry `error_code` like the main config's; the asset delete route answers 400 for a missing body instead of 415/500. Dead code removed: an unused manifest scan on each Plugins-tab load, backup routes' duplicate catch-alls, redundant imports.
|
||||
- A plugin's own config widget (`/static/plugin-widgets/<id>/<widget>.js`) is requested with `?v=<plugin version>`, so an updated plugin's widget reaches browsers instead of the copy cached as immutable for a year.
|
||||
- A failed installed-plugins reload after a toggle, install or uninstall shows one error, not a second generic "unexpected error" toast.
|
||||
- The timezone picker on the General tab renders again when the tab is reloaded in the same page session.
|
||||
- Removed dead code: the plugin-action button's six plugin-id fallbacks (the button always passes its id) and its `[DEBUG]` logging, `window.currentPluginConfig` (never set to anything but `null`), the file-upload widget's JSON delete branch (its endpoint never existed), unused `PluginAPI` / `PluginInstallManager` / `PluginStateManager` helpers, `loadPluginWidgetsFromManifest`, no-longer-reachable fallbacks for a stale `install_manager.js` and a missing `LEDVisibility`, and 13 unused CSS utility rules.
|
||||
- The Logs tab's "Now showing" no longer reads "unknown" when one screen stays up longer than 2 minutes.
|
||||
- A network failure fetching GitHub repo info logs a warning, not an error.
|
||||
- The Operation History plugin filter lists installed plugins (it showed one option, "plugins").
|
||||
- Ctrl/Cmd+S submits the active tab's visible form (with its validation) instead of the first form in the page; it does nothing inside a dialog or on a tab without a form. The Ctrl/Cmd+R override (the browser's own reload) and the textarea auto-resize (no textarea exists at load) are removed.
|
||||
- Tools tab actions and diagnostics show the server's error message; only a non-JSON error falls back to `HTTP <status>`.
|
||||
- An uninstalled plugin no longer reappears in the installed list: writes through `PluginAPI` clear its 5s GET cache, and Refresh and the post-uninstall reload bypass both list caches.
|
||||
- Plugin widgets load from `/static/plugin-widgets/` only; the two other paths it tried have no route.
|
||||
- The raw JSON editor escapes the parse error, and the slider widget escapes its value, min, max and step.
|
||||
- Removed unused array-of-objects and key-value helpers from `plugins_manager.js` (about 640 lines, no callers) and a redundant `?v=` on its script tag.
|
||||
- Plugin tabs show the manifest's `icon`: `/api/v3/plugins/installed` now includes it.
|
||||
- `POST /api/v3/starlark/apps/<id>/toggle` goes through the same code as `/plugins/toggle`: `"false"` disables, a failed save no longer leaves the running app out of step with disk, and a loaded app with no manifest entry no longer answers 500.
|
||||
- `/api/v3/` JSON responses are sent `Cache-Control: no-store`, so a reload right after an install, toggle or Wi-Fi connect shows the new state. Non-JSON files served through the API keep the 5 s cache.
|
||||
- Startup plugin validation no longer gives up on a `null` plugin block, and plugins are discovered once at startup instead of twice.
|
||||
- A plugin save drops repeated entries in lists whose schema says `uniqueItems`, instead of failing validation.
|
||||
- `/api/v3/health` reports the real plugin count.
|
||||
- A malformed `vegas_plugin_order` or `vegas_excluded_plugins` is refused with a 400 and nothing is saved. It used to wipe the saved list.
|
||||
- The per-plugin health and metrics routes return the display service's latest state.
|
||||
- Resetting a plugin's config takes a backup first and reports a failed save.
|
||||
- System metrics that can't be read are `null` everywhere: `cpu_temp` off a Pi, and every metric without psutil, where `/system/status` now answers 200 instead of 503.
|
||||
- `/plugins/store/refresh` no longer claims a commit-metadata refresh it doesn't do.
|
||||
- The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`.
|
||||
- Cache tab errors no longer show up in the Logs tab.
|
||||
- A tab that fails to load shows "Try again" instead of a skeleton that never goes away.
|
||||
- Plugin Store search and registry errors appear as a notification, and the Plugin Manager stays on screen.
|
||||
- The image schedule button works on uploaded images, and the editor stays open while you edit.
|
||||
- A failed plugin toggle moves the switch back.
|
||||
- Each save shows one notification; a failed Durations save says it failed.
|
||||
- Stats the server can't read show `--`.
|
||||
- New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages.
|
||||
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
|
||||
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
|
||||
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
|
||||
INFO; 4xx at WARNING, 5xx at ERROR. `LEDMATRIX_DEBUG=true` shows them again.
|
||||
`web_interface/logging_config.py` is removed. The web cache
|
||||
(`web_interface/cache.py`) now honours the TTL a value was stored with and is
|
||||
thread-safe.
|
||||
- `/api/v3` routes answer an exception they don't handle themselves from one
|
||||
blueprint error handler, with the same `{status, message, details}` body the
|
||||
53 removed per-route catch-alls returned. `ErrorCategory` and the
|
||||
`error_category` key are removed from `src.web_interface.errors` (nothing read
|
||||
them); `exception_error_response()` replaces the `from_exception` +
|
||||
`error_response` pairs. A failing plugin action script's error now names the
|
||||
real failure instead of `UnboundLocalError`.
|
||||
- Installing a Starlark app works on a fresh install (#604). `starlark-apps/`
|
||||
is created by whichever service reaches it first, and on a fresh install
|
||||
that was usually the root display service, so the web interface could not
|
||||
write to it and every install path answered "Failed to install from
|
||||
repository". The display service now hands the directory and its contents
|
||||
to the checkout's owner on every start (a no-op when not root or when the
|
||||
checkout belongs to root), which also repairs devices already affected; a
|
||||
permission error from the install routes names the directory and the fix.
|
||||
- Clicks on plugin cards reach `handlePluginAction` (#605). Every click took
|
||||
a copied fallback that asked twice before uninstalling and sent Starlark app
|
||||
uninstalls to `POST /plugins/uninstall` instead of
|
||||
`DELETE /starlark/apps/<id>`. A failed plugin toggle no longer always says
|
||||
"A plugin operation is already in progress".
|
||||
- The web interface starts with an absolute `plugin_system.plugins_directory`
|
||||
(#616); it crashed at import with `NameError: project_root`.
|
||||
- Stopping a Pixlet editor that ignores SIGTERM restarts the display instead
|
||||
of answering 500 and leaving the panel dark (#625).
|
||||
- Removed dead routes and files (#609): `POST /plugins/authenticate/spotify`
|
||||
and `/ytm` (the music plugin runs its auth scripts through `web_ui_actions`),
|
||||
`POST /plugins/of-the-day/json/upload` and `/json/delete` (they used the
|
||||
wrong plugin id), `js/plugins/store_manager.js`, `js/config/diff_viewer.js`
|
||||
and `js/htmx-sse.js`, and `web_interface/run.sh`. `htmx-config.js` no longer
|
||||
replaces `console.error` / `console.warn`, which hid some real errors.
|
||||
|
||||
### Security (request paths and inline handlers, siblings of #561)
|
||||
### Plugin error reporting
|
||||
|
||||
- `/api/v3/errors/summary` and `/api/v3/errors/plugin/<id>` report the errors
|
||||
the display service recorded. They used to read the web process's own error
|
||||
aggregator, which never records anything, so they always answered "no
|
||||
errors". The display service now publishes a bounded snapshot to the shared
|
||||
cache (`plugin_error_snapshot`, at most every 10 seconds and only on change;
|
||||
`src/error_aggregator.py`, started from `DisplayController.__init__`).
|
||||
Responses keep their shape and add `snapshot_available`, `generated_at` and
|
||||
`clear_pending`; exception text has credentials redacted.
|
||||
- `POST /api/v3/errors/clear` records a request (`plugin_error_clear_request`)
|
||||
the display service applies within about 5 seconds; reads hide the cleared
|
||||
errors at once. It accepts `"all": true`, and `cleared_count` can be `null`
|
||||
when the count is only known to the display service.
|
||||
- The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating
|
||||
errors and a Clear button.
|
||||
- Credential redaction in exception text (`src/redaction.py`) takes time
|
||||
proportional to the text, not its square. Two patterns were quadratic: URL
|
||||
`user:password@`, on a long unbroken run of letters or digits (a hex digest,
|
||||
an ID), and `Authorization:` followed by a long run of whitespace. Either
|
||||
used to stall every thread of the display service for up to seconds each
|
||||
time the snapshot was published: about 0.5s for 20k characters of hex, 8s
|
||||
for 20k spaces. What gets redacted is unchanged.
|
||||
|
||||
### Wi-Fi
|
||||
|
||||
- WiFi status messages reach the panel (#605). The display controller looked
|
||||
for `wifi_status.json` one directory above the repo; both sides now use
|
||||
`wifi_manager.get_wifi_status_path()`, the file is written atomically, and
|
||||
the plugin that resumes afterwards redraws the whole panel.
|
||||
- The captive-portal checks (`/generate_204` and friends) also detect an access point brought up through NetworkManager, the fallback `enable_ap_mode` uses without hostapd; only hostapd was checked, so phones on that AP were told the internet worked.
|
||||
- The WiFi monitor daemon re-reads `wifi_config.json` when it changes, so the "auto-enable AP mode" toggle takes effect without restarting the daemon.
|
||||
- Disconnecting from WiFi in the web UI no longer runs an AP-mode check that could never enable the AP; it only added seconds of waiting. The daemon still enables the AP after its grace period.
|
||||
- The WiFi status message file follows each WiFi manager's own config directory, and the config path falls back to this checkout rather than `/home/ledpi/LEDMatrix`.
|
||||
- A wrong Wi-Fi password is reported as one again ("Incorrect password for ..."); the fallback that restores the old network or brings up the setup AP was replacing the signal.
|
||||
- Wi-Fi disconnect takes the saved connection profile down.
|
||||
- `wifi_config.json` is written atomically, and a save that fails now gets a 500.
|
||||
|
||||
### Fonts
|
||||
|
||||
- Fonts tab: the preview endpoint renders BDF fonts with the panel's own rasterizer instead of refusing them. (The Fonts page still skips the request for `.bdf`; enabling it there is a separate template change.)
|
||||
- A plugin font declared as a `.zip` URL is served as the font extracted from it after a restart, instead of registering the archive itself. Font downloads time out after 30s and land in the cache only once complete, so an interrupted download is retried rather than served forever.
|
||||
- BDF fonts: `FontManager.get_font()` and `element_style.load_font()` no longer hand one `freetype.Face` to every thread. BDF faces come from `load_bdf_face`, which already caches them per thread; TrueType fonts are cached as before. `element_style`'s font cache is locked (a concurrent eviction could raise `KeyError`).
|
||||
- `FontManager.clear_cache()` and unregistering a plugin's fonts bump `cache_generation`, so cached layouts are rebuilt.
|
||||
- `plugin://` fonts load from the plugin's own install directory. `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`.
|
||||
- Bundled font paths no longer depend on the directory the process was started from.
|
||||
- `FontManager.get_font()` returns a BDF font at its native size when asked for
|
||||
a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to
|
||||
return PIL's default font, a different typeface, so a plugin that relied on
|
||||
that will now render the font it asked for.
|
||||
- The web UI's Fonts tab has a **Used by** column: the loaded plugins that
|
||||
registered each font with `FontManager.register_manager_font()`, published
|
||||
by the display service to the shared cache (`src/font_usage.py`) and merged
|
||||
into `GET /api/v3/fonts/catalog` as `used_by`. Deleting a font a plugin
|
||||
uses now names those plugins in the confirmation (it is not blocked).
|
||||
`FontManager.forget_manager_fonts()` is new; unloading a plugin calls it.
|
||||
|
||||
### Security
|
||||
|
||||
- `POST /api/v3/plugins/assets/upload`, `GET .../assets/list` and
|
||||
`POST .../assets/delete` validate `plugin_id` with `src/common/path_safety`
|
||||
@@ -513,6 +698,24 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
and add its own script. The store's View button opens only `http(s)` links.
|
||||
- The uploaded-images list escapes each file's original name, path and ids; a
|
||||
name like `<img src=x onerror=...>.png` was inserted as markup.
|
||||
- Installing from a URL (and a registry install whose manifest renames the plugin) refuses a plugin id that isn't a single safe name, so `../x` can no longer delete and replace a directory outside the plugins directory.
|
||||
- Plugin uninstall and config reset refuse core config sections (`display`, `schedule`, ...) and ids with path parts. Uninstall still cleans the config of a plugin whose directory is already gone.
|
||||
- A config field marked `x-secret` whose value is an object or array is saved to `config_secrets.json`, not to `config.json` in plain text.
|
||||
- Restoring a backup onto a device without `config_secrets.json`, `wifi_config.json` or `ytm_auth.json` creates them with mode 640 instead of world-readable 644.
|
||||
- Backup export skips a plugin `manifest.json` that isn't a JSON object instead of failing, and two exports in the same second no longer share a temp file or overwrite each other (the second gets a `-2` suffix).
|
||||
- Every font that ships in `assets/fonts/` is protected from deletion; `MatrixChunky8X`, `MatrixLight6X`, `MatrixLight8X` and `ic8x8u` could be deleted from the Fonts tab.
|
||||
- The raw config and secrets editors, and endpoints using `validate_request_json`, answer 400 for a JSON body that isn't an object.
|
||||
- `fix_web_permissions.sh` makes `safe_plugin_rm.sh` and `safe_pip_install.sh` root-owned again after resetting ownership. A web-user-owned copy of either is a root shell, since sudo lets the web user run them as root. It also restores `config_secrets.json` to mode 640.
|
||||
- Wi-Fi passwords are no longer stored in `config/wifi_config.json` (#608).
|
||||
`WiFiManager` appended every joined network's SSID and password, in plain
|
||||
text, to `saved_networks`, and nothing read them back (NetworkManager keeps
|
||||
its own credentials). Loading the config now drops a `saved_networks` key and
|
||||
rewrites the file, so passwords already on disk are removed.
|
||||
- The installers no longer grant the web user passwordless root on
|
||||
`display_controller.py`, `start_display.sh` and `stop_display.sh` (#606).
|
||||
Those files are owned by the user, so the web user could rewrite them and
|
||||
run them as root; nothing ran them through sudo. Existing devices keep the
|
||||
old rules until the installer or `configure_web_sudo.sh` is run again.
|
||||
|
||||
### Display hardware settings the library refuses
|
||||
|
||||
@@ -555,6 +758,38 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
(`legacy_bool_as_object` in `src/plugin_system/schema_manager.py`). Nothing
|
||||
is written at load; the next save of that plugin's settings stores the object.
|
||||
Other type mismatches still warn.
|
||||
- A plugin that is reloaded (switched off and on again from the web UI) imports its own modules again, not another plugin's. Plugins import their own files by bare name (`from sports import ...`), which resolves to the first plugin directory on `sys.path` that has the file; the loader only added a directory that was missing, so a reloaded plugin's directory stayed behind any loaded since. On a Pi, re-enabling UFC with hockey running failed with "cannot import name '_status_is_final' from 'sports'". A loading plugin's directory is now always moved to the front.
|
||||
- `src/plugin_system/store_manager.py` (2,977 lines) is split into mixins: `store_registry.py` (registry, GitHub metadata, search, manifest validation), `store_install.py` (install paths and dependencies) and `store_update.py` (updates, rollback, local git state). `PluginStoreManager` is still imported from `store_manager.py` and has exactly the same methods and attributes; every method body is byte-identical.
|
||||
- Unloading a plugin waits (up to 5s) for an in-flight `update()` before running `cleanup()`/`on_disable()`, and an update that finishes after the unload no longer puts the plugin back to ENABLED.
|
||||
- A plugin whose load fails after its module was imported (constructor, `validate_config()` or `on_enable()` raising) no longer leaves that module cached: fixing the plugin and reloading it runs the new code without a restart. Its font registrations are dropped too.
|
||||
- `POST /api/v3/plugins/limits/<id>` answers 400 for a limit that isn't a non-negative number (a string limit used to make every later update of that plugin raise). A bad cached limits record is ignored with a warning instead of raising.
|
||||
- The config schema is found for a plugin installed as `ledmatrix-<id>` or in a directory named differently from its manifest id, resolved the way the loader resolves it (plugins/ is still searched before plugin-repos/). A plugin with no schema is logged once at DEBUG instead of a warning on every lookup.
|
||||
- Installing from a URL over an existing install sets the old copy aside and restores it if the move fails, under the same per-plugin lock as a registry install.
|
||||
- The operation queue refuses a second operation for a plugin whose first is still waiting (a double-clicked Install ran twice), and no longer keeps every finished operation in memory.
|
||||
- `get_vegas_render_width()` reads `display_manager.width` first, as plugins are told to.
|
||||
- Store and state files are read as UTF-8 regardless of the system locale.
|
||||
- Docs: `update_interval` in `config.json` sets the scheduler's cadence only for a plugin whose manifest has none (TROUBLESHOOTING, PLUGIN_CONFIGURATION_GUIDE). The health/metrics reset and limits routes note that they only change the web process's view.
|
||||
- A plugin whose `on_enable()` raises is no longer left registered: the next load retries it instead of reporting "already loaded" for a plugin that never ran.
|
||||
- One plugin's `get_info()` raising no longer breaks the installed-plugins list; it is logged and shown with empty runtime info.
|
||||
- `plugin_state.json` and the operation history are written atomically (temp file + rename) under their lock, so concurrent saves or a failed save can't leave a truncated file.
|
||||
- Plugin dependency installs run one `pip` at a time during parallel startup loading.
|
||||
- A failed store download no longer leaves its extraction directory in the temp dir.
|
||||
- Test doubles: `draw_image()` on `MockDisplayManager`, `VisualTestDisplayManager` and `BoundsCheckingDisplayManager` now emits a `DeprecationWarning` — the real `DisplayManager` has no such method; use `display_manager.image.paste(img, (x, y))`. `MockDisplayManager.draw_text` accepts the real signature's `small_font`/`centered` and default `x`/`y`, and `VisualTestDisplayManager` logs draw errors at WARNING.
|
||||
- Removed the unused `PluginOperationQueue.get_active_operations()`.
|
||||
- Updating a plugin that was installed from a ZIP no longer tries to reinstall it from the LEDMatrix repository's own URL.
|
||||
- Repository URLs with `.git` in the middle are no longer mangled. The URL helpers now live in `src/plugin_system/repo_urls.py`.
|
||||
- Installing from a URL works when the repository's only branch isn't `main` or `master`.
|
||||
- A missing required config field is reported once, by name.
|
||||
- A plugin that went over `max_memory_mb` once is no longer refused on every call after that.
|
||||
- `reload_plugin` reads the manifest from the plugin's discovered directory.
|
||||
- Removed: `last_display` from plugin state info and `get_last_display()` (nothing recorded them); `PluginOperationQueue`'s `history_file` and `lazy_load` arguments; and `data/plugin_operations.json`, which nothing read.
|
||||
- One plugin-directory resolver, `src/plugin_system/plugin_dirs.py`, behind
|
||||
discovery, `PluginManager.get_plugin_directory`, `PluginLoader`, the store and
|
||||
state reconciliation. A manifest's `id` wins over a directory merely named for
|
||||
the id; hidden and `.standalone-backup-` directories are never treated as
|
||||
plugins (auto-update could previously try to update a backup); ids like
|
||||
`a/b` or `..` resolve to nothing everywhere. Installs where each directory is
|
||||
named for its manifest id, the installer's layout, behave as before.
|
||||
|
||||
### Core
|
||||
|
||||
@@ -573,6 +808,39 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
handling, so the restore stopped at `config.json` with nothing restored. The
|
||||
ownership step is now skipped where `os.chown` is missing. No behaviour
|
||||
change on the Pi.
|
||||
- `APIHelper`'s rate limit and the display-sync heartbeat/leader timeouts measure elapsed time with `time.monotonic()`. A wall-clock step (NTP correcting a Pi with no RTC) could stall API requests for as long as the step or fake a sync timeout. `get_request_stats()['last_request_time']` is still wall-clock time.
|
||||
- `sudo_remove_directory()` tries each bash path the sudoers rule might name, as `install_requirements_file()` already did.
|
||||
- An element's saved layout `scale` equal to its schema default is no longer treated as a user choice when the default is declared under an alias (`score` for `score_text`).
|
||||
- `CacheError`/`ConfigError`/`PluginError`/`DisplayError` no longer write their key into the caller's `context` dict; the JSON log formatter stringifies values it can't encode instead of dropping the record.
|
||||
- Removed `ErrorAggregator`'s unused JSON export (`export_path`, `export_to_file()`); nothing called it. Docstring fixes in `validate_file_upload`, `StartupValidator.raise_on_errors`, `DisplaySyncManager.set_on_new_cycle`, `dynamic_team_resolver` and `config_arrays`.
|
||||
- `/api/v3/errors` shows each exception's real stack trace instead of `NoneType: None`.
|
||||
- Backups record `src.__version__`.
|
||||
- Removed: `BackgroundDataService`'s `queue_size` stat and `clear_completed_requests()`.
|
||||
- `src.device_location` — a blank `Location` field on a Starlark (Tidbyt) app
|
||||
now renders at the device's City / State / Country (geocoded once via
|
||||
Open-Meteo and cached) instead of the app author's hard-coded default,
|
||||
usually San Francisco. A location saved on the app still wins. With no
|
||||
device city set, or when the lookup fails or finds no match, the app keeps
|
||||
its own default (a failed lookup is retried after 30 minutes). Clearing an
|
||||
app's location in the web UI now actually clears it; the save used to drop
|
||||
the blank field, so the old value stayed.
|
||||
- Fixed a memory leak in the display service (#605): `ErrorAggregator`
|
||||
appended every plugin in the time window to a pattern's `affected_plugins`
|
||||
on each repeat (3,000 errors from three plugins reached 2.5 million
|
||||
entries).
|
||||
- An expired cache record is refused without being parsed (#633).
|
||||
`CacheManager.set` writes `timestamp` and `ttl` ahead of `data`, and
|
||||
`DiskCache.get` reads the first 256 bytes to decide staleness, with the same
|
||||
rules as before. A 53 MB MLB season file used to be parsed in full (about
|
||||
1.8 s holding the GIL on a Pi 4, freezing the display) only to be thrown
|
||||
away. Files in the old layout are parsed as before and convert when
|
||||
rewritten.
|
||||
- Cache internals (#613): `CacheManager` delegates memory-tier cleanup and
|
||||
stats to `MemoryCache`; `list_cache_files` no longer holds the memory lock
|
||||
during directory I/O; `BackgroundDataService.get_sport_cache_key()` formats
|
||||
the key instead of building a whole `CacheManager` (and probing the cache
|
||||
directory) on every call; the unused request queue is gone, and `priority=`
|
||||
is accepted and documented as ignored.
|
||||
|
||||
### Cache permissions
|
||||
|
||||
@@ -627,6 +895,8 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
timeouts with a second bash path, and all reinstalls share a 10-minute
|
||||
budget, so a rollback finishes inside the unit's 30-minute limit instead of
|
||||
being killed mid-way.
|
||||
- A hand-edited non-object `auto_update` value reads as off instead of raising at startup, and a failed result write no longer leaves a temp file behind.
|
||||
- Overview "Check Updates" asks for the same confirmation as "Update Code" and shows the server's message. Both, and the Tools tab's git pull, show the restart-pending banner when the update needs a restart.
|
||||
|
||||
### Installers
|
||||
|
||||
@@ -640,6 +910,22 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
`configure_web_sudo.sh` does the same before offering the rules for
|
||||
confirmation. `first_time_install.sh` also built that file at a fixed `/tmp`
|
||||
path as root; `mktemp` now picks the name.
|
||||
- `check_system_compatibility.sh` treats Python 3.13 (what Trixie ships) as supported and anything below 3.10 as an error.
|
||||
- `configure_web_sudo.sh` run as the web user keeps the reboot/poweroff rules.
|
||||
- `check_system_compatibility.sh` no longer reports installed packages as missing.
|
||||
- `configure_wifi_permissions.sh` checks its rules with `visudo -c` before installing them, and grants the NetworkManager captive-portal `cp` and `rm` commands `wifi_manager` runs.
|
||||
- `configure_web_sudo.sh` uses a random temp file and installs its rules with mode 440.
|
||||
- The installer prints its completion summary before the `-y` reboot, and describes the setup access point as an open network (it was shown with a password it doesn't have).
|
||||
- `fix_cache_permissions.sh` applies `setup_cache.sh`'s `ledmatrix`-group model instead of setting 777.
|
||||
- `check_system_compatibility.sh` reports anything but Debian 13 (Trixie) as unsupported, and reaches its summary.
|
||||
- `one-shot-install.sh`'s `retry()` retries (#606). It read `$?` after `!`,
|
||||
which is always 0, so a failed command ran once and was reported as a
|
||||
success. It now tries three times and returns the command's status; both
|
||||
apt steps still warn and continue after their retries, and a clone that
|
||||
keeps failing stops the install sooner, with its own message.
|
||||
- One generator for the web sudoers rules, `scripts/install/lib_sudoers.sh`,
|
||||
used by `first_time_install.sh` and `configure_web_sudo.sh` (#622); the two
|
||||
copies had drifted.
|
||||
|
||||
### Small fixes (update-all, plugin system settings, scripts)
|
||||
|
||||
@@ -677,7 +963,9 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
(only an explicit `web_display_autostart: false` keeps the web interface
|
||||
down), so a missing key no longer shows as disabled. The shell scripts also
|
||||
check `web_interface/blueprints/api_v3/`, which became a package, instead of
|
||||
reporting `api_v3.py` as missing.
|
||||
reporting `api_v3.py` as missing. (`scripts/verify_web_ui.sh`,
|
||||
`scripts/diagnose_web_ui.sh` and `scripts/debug/debug_web_manual.py` were
|
||||
later deleted as unreferenced; see Docs and developer tools.)
|
||||
|
||||
### Docs and developer tools
|
||||
|
||||
@@ -706,6 +994,67 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
`app.py` line numbers, `api_v3.py` paths, StreamManager method names,
|
||||
nonexistent version-bump scripts and `ledmatrix` service user references
|
||||
removed.
|
||||
- A mypy ratchet in CI. `mypy-clean.txt` lists the 71 modules under `src/` that type-check clean, and the new "Type check (mypy ratchet)" job runs `python scripts/check_types.py` (mypy 1.20.2 on exactly those files) so they stay clean; add a module when you make it clean (see CONTRIBUTING.md). The manual pre-commit `mypy` hook runs the same script. 35 modules were made clean for it with annotation-only fixes, no behaviour change. Their public signatures only widened (`declared_min_version()` now says it returns the manifest's value as-is, `Any`); `DynamicTeamResolver._rankings_cache` is annotated as the abbreviation-to-rank dict it holds. `mypy.ini` treats numpy and orjson as `Any`, so it parses with `python_version = 3.10` against numpy 2.3+ stubs and gives the same result whether orjson is installed or not.
|
||||
- CI runs the web UI's DOM test suites (jsdom against the real server-rendered pages and API) in a new **Web UI JS tests** job, with the web interface started in emulator mode; `REQUIRE_DOM=1` makes a suite that can't run fail instead of being skipped. Two suites that had gone stale were fixed: the Tools suite now installs `LEDEscape` the way `base.html` does and supplies sample Starlark apps when the server has none, and the Store suite no longer assumes the registry has 48 plugins or fewer.
|
||||
- CI installs `web_interface/requirements.txt` too, so flask-limiter, flask-compress and the web floors are tested. `test_api_helper_does_not_hand_set_brotli` now checks what it meant: core doesn't add `br` itself, and `requests` may advertise it when a brotli decoder is installed.
|
||||
- All Discord links point to the LEDMatrix server's invite.
|
||||
- `pytz` may be any release before 2027, so current timezone data installs; `requirements-test.txt` caps `psutil` below 7 like the runtime requirements and allows `pytest-cov` up to 7.x (checked against pytest 9 with the CI coverage run).
|
||||
- The Claude GitHub Actions workflows pin `anthropics/claude-code-action` to a commit SHA like the other actions.
|
||||
- `mypy.ini` parses again. A multi-line `exclude` and trailing comments on values made mypy refuse the whole file, so none of its settings applied and the pre-commit hook failed with "Missing target". The mypy hook is now manual (`pre-commit run mypy --hook-stage manual`) while the ~500 existing type errors in `src/` are paid down.
|
||||
- `.gitignore` ignores everything in `config/` except the templates; `ytm_auth.json`, `saved_repositories.json`, `wifi_status.json` and `font_overrides.json` weren't ignored.
|
||||
- `.sh` and `.service` files are always checked out with LF line endings.
|
||||
- The Claude code-review check is skipped on pull requests from forks, which get no secrets and always failed it.
|
||||
- Doc fixes: emulator guide (Python 3.10+, `emulator_config.json` isn't in the repo), README's nonexistent "API Metrics" feature, a stale route count, and missing index entries for the scroll-performance and offscreen-rendering docs and the frame-soak and render-bench scripts.
|
||||
- `src/common/README.md` lists `frame_timing`, `json_body` and `render_gate`.
|
||||
- New `scripts/README.md` lists every script.
|
||||
- New `docs/ARCHITECTURE.md` (processes, shared state, display loop, plugin system, web UI) and `docs/PERMISSIONS.md` (owners, modes, both sudoers files, repair scripts).
|
||||
- Deprecated plugin APIs are marked in the plugin docs.
|
||||
- `src/common/README.md` covers every module.
|
||||
- Stale setup, service and troubleshooting claims are corrected.
|
||||
- Deleted 13 scripts nothing referenced (#607): `utils/cleanup_venv.sh`,
|
||||
`utils/clear_python_cache.sh`, `install/migrate_config.sh`,
|
||||
`install/debug_install.sh`, `debug/debug_web_manual.py`,
|
||||
`diagnose_web_ui.sh`, `verify_web_ui.sh`, `fix_internet_connectivity.sh`,
|
||||
`diagnose_plugin_permissions.sh`, `dev/validate_python.py`,
|
||||
`download_nba_logos.py` (with `README_NBA_LOGOS.md`) and
|
||||
`setup_plugin_repos.py`, all under `scripts/`; also `docs/archive/` and
|
||||
`PLUGIN_IMPLEMENTATION_SUMMARY.md`. `config.template.json` no longer carries
|
||||
`plugin_system.auto_discover`, `auto_load_enabled` or `development_mode`,
|
||||
which nothing reads (existing configs keep them). About 20 docs had stale
|
||||
claims corrected against the code.
|
||||
- `test/test_js_unit_suites.py` runs every `test/js/unit/*.js` suite under
|
||||
pytest; CI used to run one of the eight (#605).
|
||||
- New test `test/test_common_is_hardware_free.py`: `src/common` must import
|
||||
without `rgbmatrix`, and never import `src.base_classes`,
|
||||
`src.display_manager` or `src.plugin_system` at module level, so plugins can
|
||||
use it on machines with no panel library.
|
||||
|
||||
### Removed
|
||||
|
||||
- **The skin system.** Skins never rendered with the current scoreboard
|
||||
plugins, so they are gone rather than "not supported yet": `src/skin_system/`,
|
||||
`skins/`, `scripts/validate_skin.py`, `GET /api/v3/skins`, the store's
|
||||
`"type": "skin"` handling and `docs/SKIN_SYSTEM.md` / `docs/CREATING_SKINS.md`.
|
||||
A `skin` or `skin_options` key left in a plugin's saved config still loads
|
||||
and saves without a validation error; it is ignored, and the next save of
|
||||
that plugin's settings removes it (unless the plugin's own schema declares
|
||||
the key).
|
||||
- **`src/base_classes/`** (`SportsCore`, the sport and mode classes,
|
||||
`CelebrationMixin`, the rotation strategies, `data_sources`,
|
||||
`api_extractors`). No known plugin imports it. A plugin that does must use
|
||||
`src.common` or its own copy of the code.
|
||||
- **Unused `src.common` modules and plugin-system helpers** (#608):
|
||||
`src/common/config_helper.py`, `display_helper.py`, `game_helper.py`,
|
||||
`utils.py` and `error_handler.py` (its re-exports leave `src.common`'s
|
||||
`__all__`), `src/plugin_system/health_monitor.py` (`PluginHealthMonitor`,
|
||||
whose loop did nothing; `PluginHealthTracker` is unchanged), and
|
||||
`src.plugin_system.get_store_manager` / `__api_version__`. Nothing in core,
|
||||
the scripts or the plugin monorepo imported them. `APIHelper`, `TextHelper`,
|
||||
`ScrollHelper`, `LogoHelper` and the adaptive-layout exports of `src.common`
|
||||
are unchanged. The same change removed unused methods from `ConfigService`,
|
||||
`PluginStateManager`, `PluginManager`, `PluginExecutor`, `PluginLoader`,
|
||||
`PluginStoreManager`, `VegasModeConfig` and `DisplayController`; none had
|
||||
callers in core, the scripts or the monorepo.
|
||||
|
||||
## 3.4.0
|
||||
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ ChuckBuilds, and any other forums hosted by or affiliated with the project.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement on the
|
||||
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT) (DM a moderator or
|
||||
[LEDMatrix Discord](https://discord.gg/RdrC37rEag) (DM a moderator or
|
||||
ChuckBuilds directly) or by opening a private GitHub Security Advisory if
|
||||
the issue involves account safety. All complaints will be reviewed and
|
||||
investigated promptly and fairly.
|
||||
|
||||
+12
-4
@@ -9,7 +9,7 @@ improvements, and code changes.
|
||||
- **Bugs / feature requests**: open an issue using one of the templates
|
||||
in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/).
|
||||
- **Real-time discussion**: the
|
||||
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT).
|
||||
[LEDMatrix Discord](https://discord.gg/RdrC37rEag).
|
||||
- **Plugin development**:
|
||||
[`docs/PLUGIN_DEVELOPMENT_GUIDE.md`](docs/PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||
and the [`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||
@@ -58,10 +58,18 @@ integration tests.
|
||||
3. **Keep PRs focused.** One conceptual change per PR. If you find
|
||||
adjacent bugs while working, fix them in a separate PR.
|
||||
4. **Follow the existing code style.** The pre-commit hooks run
|
||||
`flake8` (E9, F63, F7, F82 plus bugbear `B` checks), `mypy` on
|
||||
`src/`, `bandit`, and `gitleaks` — install the CLI with
|
||||
`flake8` (E9, F63, F7, F82 plus bugbear `B` checks), `bandit`,
|
||||
and `gitleaks` — install the CLI with
|
||||
`python -m pip install pre-commit`, then run
|
||||
`pre-commit install` so they run on every commit; HTML/JS in
|
||||
`pre-commit install` so they run on every commit. Type checking
|
||||
is a ratchet while the existing mypy errors in `src/` are paid
|
||||
down: `mypy-clean.txt` lists the modules that type-check clean, and
|
||||
CI runs `python scripts/check_types.py` (also the manual hook
|
||||
`pre-commit run mypy --hook-stage manual`) to keep every listed
|
||||
module clean. When you make another module clean, add it to the
|
||||
list (sorted); don't take one off to get CI green. Keep type fixes
|
||||
annotation-only where you can -- widen a hint rather than delete a
|
||||
defensive runtime check mypy calls unreachable. HTML/JS in
|
||||
`web_interface/` follows the patterns already in `templates/v3/`
|
||||
and `static/v3/`.
|
||||
5. **Update documentation** alongside code changes. If you add a
|
||||
|
||||
@@ -33,7 +33,7 @@ I'm trying to be open to constructive criticism and support, as long as it's a r
|
||||
- Show support on Youtube: https://www.youtube.com/@ChuckBuilds
|
||||
- Check out the write-up on my website: https://www.chuck-builds.com/led-matrix/
|
||||
- Stay in touch on Instagram: https://www.instagram.com/ChuckBuilds/
|
||||
- Want to chat? Reach out on the LEDMatrix Discord: [https://discord.com/invite/uW36dVAtcT](https://discord.gg/dfFwsasa6W)
|
||||
- Want to chat? Reach out on the LEDMatrix Discord: [https://discord.gg/RdrC37rEag](https://discord.gg/RdrC37rEag)
|
||||
- Feeling Generous? Consider sponsoring this project or sending a donation (these AI credits aren't cheap!)
|
||||
|
||||
-----------------------------------------------------------------------------------
|
||||
@@ -948,7 +948,7 @@ sudo systemctl enable ledmatrix-web.service
|
||||
- **On-Demand Controls**: Start specific displays (weather, stocks, sports) on demand
|
||||
- **Service Management**: Start/stop the main display service
|
||||
- **System Controls**: Restart, update code, and manage the system
|
||||
- **API Metrics**: Monitor API usage and system performance
|
||||
- **System Stats**: CPU, memory and temperature on the Overview tab
|
||||
- **Logs**: View system logs in real-time
|
||||
|
||||
### Troubleshooting Web Interface
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Use one of these channels, in order of preference:
|
||||
maintainer.
|
||||
- Direct link: <https://github.com/ChuckBuilds/LEDMatrix/security/advisories/new>
|
||||
2. **Discord DM**. Send a direct message to a moderator on the
|
||||
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT). Don't post in
|
||||
[LEDMatrix Discord](https://discord.gg/RdrC37rEag). Don't post in
|
||||
public channels.
|
||||
|
||||
Please include:
|
||||
|
||||
+15
-7
@@ -1,12 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Legacy entry point: runs ``run.py``, which is the one to use.
|
||||
|
||||
``python3 run.py`` (``-e`` for the emulator, ``-d`` for debug logging) is how
|
||||
the display service and the docs start LEDMatrix. This file used to import
|
||||
``src.display_controller.main`` directly, which skipped what run.py sets up
|
||||
first -- ``sys.dont_write_bytecode`` (root-owned ``__pycache__`` in plugin
|
||||
directories blocks the web service from updating them), the ``-e``/``-d``
|
||||
flags, and the logging configuration. It now runs run.py exactly as
|
||||
``python3 run.py`` would, with the same arguments.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the project root directory to Python path
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from src.display_controller import main
|
||||
import runpy
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
runpy.run_path(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "run.py"),
|
||||
run_name="__main__",
|
||||
)
|
||||
|
||||
+17
-6
@@ -52,8 +52,10 @@ each other. They share three things:
|
||||
| Preview viewer marker | `/tmp/led_matrix_preview_viewer` | web, while a preview is open | display: writes full-rate snapshots only while it is fresh |
|
||||
| Hardware init status | `/tmp/led_matrix_hw_status.json` | display | web: `/api/v3/hardware/status` |
|
||||
|
||||
The on-demand start route also restarts `ledmatrix.service` by default so the
|
||||
request takes effect straight away.
|
||||
The on-demand start route starts `ledmatrix.service` when it is not running
|
||||
(`start_service`, on by default) but never restarts a running one: the display
|
||||
reads the mailbox every `ON_DEMAND_POLL_INTERVAL` (0.25s), from its dwell
|
||||
sleep, its render loops and Vegas's interrupt check as well as the main loop.
|
||||
|
||||
## Display loop
|
||||
|
||||
@@ -82,7 +84,11 @@ then normal rotation.
|
||||
- **On-demand.** A request from the web interface pins one plugin (or mode)
|
||||
for a duration. `_activate_on_demand()` / `_clear_on_demand()`; the
|
||||
session is saved under `display_on_demand_config` so it survives a
|
||||
restart. It also keeps the display on during scheduled off hours.
|
||||
restart. It also keeps the display on during scheduled off hours. A
|
||||
request for a plugin that is disabled in config loads it live
|
||||
(`_load_plugin_for_on_demand()`, `load_plugin(force_enabled=True)`)
|
||||
without writing `config.json`; the main loop unloads it once on-demand
|
||||
moves off it (`_release_on_demand_plugins()`).
|
||||
- **Live priority.** `_check_live_priority()` looks for a plugin whose
|
||||
`has_live_priority()` and `has_live_content()` are both true and switches
|
||||
to it, rotating between several live games.
|
||||
@@ -127,7 +133,7 @@ then normal rotation.
|
||||
| Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) |
|
||||
| Resource metrics | [`resource_monitor.py`](../src/plugin_system/resource_monitor.py) |
|
||||
| Config schemas and defaults | [`schema_manager.py`](../src/plugin_system/schema_manager.py) |
|
||||
| Install, update, uninstall | [`store_manager.py`](../src/plugin_system/store_manager.py) (`PluginStoreManager`) |
|
||||
| Install, update, uninstall | [`store_manager.py`](../src/plugin_system/store_manager.py) (`PluginStoreManager`), with its methods split across [`store_registry.py`](../src/plugin_system/store_registry.py) (registry, GitHub), [`store_install.py`](../src/plugin_system/store_install.py) and [`store_update.py`](../src/plugin_system/store_update.py) |
|
||||
| Core-version gate | [`compatibility.py`](../src/plugin_system/compatibility.py) |
|
||||
|
||||
Discovery scans only `plugin_system.plugins_directory` (default
|
||||
@@ -158,8 +164,13 @@ everything else through `_reinstall_with_rollback()`.
|
||||
- **API.** [`blueprints/api_v3/`](../web_interface/blueprints/api_v3/) is one
|
||||
blueprint at `/api/v3`, split by area: `backup.py`, `config.py`,
|
||||
`display.py`, `fonts.py`, `misc.py` (health, logs, errors, cache, sync),
|
||||
`plugins.py`, `starlark.py`, `system.py` (service actions, updates, git),
|
||||
`wifi.py`. `__init__.py` defines the blueprint and shared helpers and
|
||||
`starlark.py`, `system.py` (service actions, updates, git), `wifi.py`, and
|
||||
the plugin routes: `plugins.py` (installed list, enable/disable, plugin
|
||||
actions), `plugin_store.py` (install, update, uninstall, store),
|
||||
`plugin_config.py` (config, schema, reset), `plugin_assets.py` (uploads,
|
||||
plugin static files), `plugin_health.py` (health, metrics, limits),
|
||||
`plugin_operations.py` (operation history, state reconciliation) and
|
||||
`plugin_calendar.py`. `__init__.py` defines the blueprint and shared helpers and
|
||||
imports the modules so their routes register. Endpoints are listed in
|
||||
[REST_API_REFERENCE.md](REST_API_REFERENCE.md).
|
||||
- **Front end.** HTMX loads each tab's partial on first open
|
||||
|
||||
@@ -180,5 +180,5 @@ See [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md).
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `github.api_token` | Optional GitHub token the Plugin Store uses to avoid API rate limits (`src/plugin_system/store_manager.py`) |
|
||||
| `github.api_token` | Optional GitHub token the Plugin Store uses to avoid API rate limits (`src/plugin_system/store_registry.py`) |
|
||||
| `<plugin-id>.*` | Secrets a plugin declares with `"x-secret": true` in its config schema; merged into that plugin's config at load time |
|
||||
|
||||
@@ -17,13 +17,13 @@ The LEDMatrix emulator allows you to run and test LEDMatrix displays on your com
|
||||
## Prerequisites
|
||||
|
||||
### System Requirements
|
||||
- Python 3.7 or higher
|
||||
- Python 3.10 or higher
|
||||
- Windows, macOS, or Linux
|
||||
- At least 2GB RAM (4GB recommended)
|
||||
- Internet connection for plugin downloads
|
||||
|
||||
### Required Software
|
||||
- Python 3.7+
|
||||
- Python 3.10+
|
||||
- pip (Python package manager)
|
||||
- Git (for plugin management)
|
||||
|
||||
@@ -50,8 +50,7 @@ pip install -r requirements-emulator.txt
|
||||
```
|
||||
|
||||
This installs:
|
||||
- `RGBMatrixEmulator` - The core emulation library
|
||||
- Additional dependencies for display adapters
|
||||
- `RGBMatrixEmulator` - the emulation library (and whatever it depends on)
|
||||
|
||||
### 3. Install Standard Dependencies
|
||||
|
||||
@@ -63,8 +62,9 @@ pip install -r requirements.txt
|
||||
|
||||
### 1. Emulator Configuration File
|
||||
|
||||
The emulator uses `emulator_config.json` for configuration. Here's the
|
||||
default configuration as it ships in the repo:
|
||||
The emulator uses `emulator_config.json` for configuration. It isn't in
|
||||
the repo (it's gitignored): RGBMatrixEmulator writes it on first run.
|
||||
A typical file looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -26,7 +26,7 @@ fields:
|
||||
| Check | Fields | What happens when one is missing |
|
||||
|---|---|---|
|
||||
| JSON schema, [`schema/manifest_schema.json`](../schema/manifest_schema.json) | `id`, `name`, `version`, `author`, `entry_point`, `class_name`, `compatible_versions` | Install from URL logs a warning (`PluginStoreManager._validate_manifest_schema()`); nothing is refused |
|
||||
| Plugin Store install, [`src/plugin_system/store_manager.py`](../src/plugin_system/store_manager.py) | `id`, `name`, `class_name`, `display_modes` | Install is refused. A registry install first tries to detect a missing `class_name` from the entry-point file |
|
||||
| Plugin Store install, [`src/plugin_system/store_install.py`](../src/plugin_system/store_install.py) | `id`, `name`, `class_name`, `display_modes` | Install is refused. A registry install first tries to detect a missing `class_name` from the entry-point file |
|
||||
| Plugin loader, [`src/plugin_system/plugin_loader.py`](../src/plugin_system/plugin_loader.py) | `class_name` | The plugin fails to load |
|
||||
|
||||
Defaults and other uses:
|
||||
|
||||
@@ -124,6 +124,14 @@ Plugins are configured by adding their plugin ID as a top-level key in the confi
|
||||
}
|
||||
```
|
||||
|
||||
How often the core calls a plugin's `update()`: the plugin's
|
||||
`get_update_interval()` if it returns a number, else `update_interval` in the
|
||||
plugin's `manifest.json`, else `update_interval` in its `config.json` section
|
||||
as above, else 60 seconds. A config `update_interval` therefore only sets the
|
||||
scheduler's cadence for a plugin whose manifest does not; plugins that expose
|
||||
it in their config schema typically also honour it themselves inside
|
||||
`update()`. See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#get_update_interval---optionalfloat).
|
||||
|
||||
### Plugin Display Durations
|
||||
|
||||
Add plugin display modes to the `display_durations` section:
|
||||
@@ -194,7 +202,7 @@ plugin-repos/
|
||||
```
|
||||
|
||||
The Plugin Store refuses a manifest that lacks any of `id`, `name`,
|
||||
`class_name` or `display_modes` (`store_manager.py`); the loader itself
|
||||
`class_name` or `display_modes` (`store_install.py`); the loader itself
|
||||
needs `class_name`. `version` is not required, but the store compares it
|
||||
with the registry's `latest_version` to offer updates, so set it.
|
||||
`entry_point` defaults to `manager.py` if omitted. The config schema is not
|
||||
|
||||
@@ -203,7 +203,7 @@ Forms are rendered on the server, not generated in the browser:
|
||||
from the schema (widgets named by `x-widget` are rendered by the scripts in
|
||||
`web_interface/static/v3/js/widgets/`)
|
||||
4. **Save Configuration** posts the form to `/api/v3/plugins/config`
|
||||
(`web_interface/blueprints/api_v3/plugins.py`), which validates it against
|
||||
(`web_interface/blueprints/api_v3/plugin_config.py`), which validates it against
|
||||
the schema, writes `config.json` (secret fields go to
|
||||
`config_secrets.json`) and shows a notification
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
│ • masks x-secret fields │
|
||||
│ • renders partials/plugin_config.html (render_field macros) │
|
||||
│ │
|
||||
│ api_v3 blueprint (blueprints/api_v3/plugins.py) │
|
||||
│ api_v3 blueprint (blueprints/api_v3/plugin_config.py) │
|
||||
│ save_plugin_config() POST /api/v3/plugins/config │
|
||||
│ get_plugin_config() GET /api/v3/plugins/config │
|
||||
│ get_plugin_schema() GET /api/v3/plugins/schema │
|
||||
@@ -91,7 +91,7 @@ validatePluginConfigForm() (client-side checks)
|
||||
POST /api/v3/plugins/config?plugin_id=<id> (form data, all fields of the form)
|
||||
│
|
||||
▼
|
||||
save_plugin_config() (api_v3/plugins.py)
|
||||
save_plugin_config() (api_v3/plugin_config.py)
|
||||
├─→ Start from the stored config.json[<id>]
|
||||
├─→ Apply form fields: dotted names → nested keys, "[]" checkbox
|
||||
│ groups → lists, values coerced to the schema's types
|
||||
@@ -175,7 +175,7 @@ Implement `on_config_change(new_config)` in the plugin (see
|
||||
|---------|------|
|
||||
| Tab partial loader | `web_interface/blueprints/pages_v3.py` (`_load_plugin_config_partial`) |
|
||||
| Form template and field macros | `web_interface/templates/v3/partials/plugin_config.html` |
|
||||
| Save / get / schema / reset handlers | `web_interface/blueprints/api_v3/plugins.py` |
|
||||
| Save / get / schema / reset handlers | `web_interface/blueprints/api_v3/plugin_config.py` |
|
||||
| Schema loading, defaults, validation | `src/plugin_system/schema_manager.py` |
|
||||
| Secret masking and splitting | `src/web_interface/secret_helpers.py` |
|
||||
| Widgets | `web_interface/static/v3/js/widgets/` |
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
A plugin can name an icon for its tab in the web interface's second nav row
|
||||
(next to **Plugin Manager**) with the `icon` field in `manifest.json`.
|
||||
|
||||
> **Status:** the tab code honors `icon`, but `GET /api/v3/plugins/installed`
|
||||
> (`web_interface/blueprints/api_v3/plugins.py`) does not currently include
|
||||
> the manifest's `icon` in its response, so every tab shows the default
|
||||
> puzzle piece. Setting `icon` is harmless and will take effect once the API
|
||||
> passes it through again.
|
||||
`GET /api/v3/plugins/installed` passes the manifest's `icon` through (a
|
||||
non-string value comes back as `null`), and a plugin without one gets the
|
||||
default puzzle piece.
|
||||
|
||||
## Font Awesome classes only
|
||||
|
||||
@@ -55,8 +53,8 @@ With no `icon` (or an empty one) the tab shows `fas fa-puzzle-piece`.
|
||||
or misspelled class renders as a blank space.
|
||||
2. Include the style prefix (`fas`, `far` or `fab`) as well as the icon
|
||||
class.
|
||||
3. See the status note above: the icon is currently not passed through by
|
||||
the API.
|
||||
3. The manifest is re-read on each plugin list load; reload the page after
|
||||
editing `icon`.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ which runs as root.** Anything installed only into another user's
|
||||
The web interface is not root, so it installs through a narrow sudo helper:
|
||||
|
||||
1. `PluginStoreManager._install_dependencies()`
|
||||
(`src/plugin_system/store_manager.py`) calls
|
||||
(`src/plugin_system/store_install.py`) calls
|
||||
`install_requirements_file()` (`src/common/permission_utils.py`).
|
||||
2. That runs `sudo -n bash scripts/fix_perms/safe_pip_install.sh <plugin>/requirements.txt`.
|
||||
The helper checks the path is the project's own `requirements.txt` or a
|
||||
@@ -154,7 +154,7 @@ For more, see the [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TR
|
||||
## Files to Reference
|
||||
|
||||
- Service units: `systemd/ledmatrix.service`, `systemd/ledmatrix-web.service`
|
||||
- Store installs: `src/plugin_system/store_manager.py` (`_install_dependencies`)
|
||||
- Store installs: `src/plugin_system/store_install.py` (`_install_dependencies`)
|
||||
- Root install helper: `src/common/permission_utils.py` (`install_requirements_file`), `scripts/fix_perms/safe_pip_install.sh`
|
||||
- Load-time installs: `src/plugin_system/plugin_loader.py` (`install_dependencies`)
|
||||
- Sudo rules: `scripts/install/lib_sudoers.sh` (written by `first_time_install.sh`
|
||||
|
||||
@@ -645,7 +645,7 @@ To have your plugin added to the official plugin store:
|
||||
|
||||
3. **Contact maintainers** (own-repository plugins):
|
||||
- Open a GitHub issue in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) repository
|
||||
- Or reach out on Discord: https://discord.gg/uW36dVAtcT
|
||||
- Or reach out on Discord: https://discord.gg/RdrC37rEag
|
||||
- Include: Repository URL, plugin description, why it's useful
|
||||
|
||||
4. **Review process**:
|
||||
|
||||
@@ -56,6 +56,8 @@ Going deeper:
|
||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
||||
cache management, background services, permissions
|
||||
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
||||
- [SCROLL_PERFORMANCE.md](SCROLL_PERFORMANCE.md) — how scrolling is paced, and how to make a plugin's marquee smooth
|
||||
- [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) — rendering plugin content off the render thread
|
||||
- [PERMISSIONS.md](PERMISSIONS.md) — file ownership, sudo rules, repair scripts
|
||||
- [MQTT bridge](../integrations/mqtt_bridge/README.md) — control the display from Home Assistant over MQTT
|
||||
|
||||
|
||||
@@ -40,13 +40,14 @@ the entry below says so.
|
||||
|
||||
> The API blueprint is the `api_v3` package in
|
||||
> `web_interface/blueprints/api_v3/` (one module per area: `config.py`,
|
||||
> `display.py`, `plugins.py`, `system.py`, `backup.py`, `fonts.py`,
|
||||
> `misc.py`, `wifi.py`, `starlark.py`). `web_interface/app.py` registers it
|
||||
> `display.py`, `system.py`, `backup.py`, `fonts.py`, `misc.py`, `wifi.py`,
|
||||
> `starlark.py`, and `plugins.py` plus the `plugin_*.py` modules for the
|
||||
> plugin routes). `web_interface/app.py` registers it
|
||||
> at `/api/v3` (`app.register_blueprint(api_v3, url_prefix='/api/v3')`).
|
||||
> The three SSE endpoints (`/api/v3/stream/*`) are defined directly on the
|
||||
> Flask app in `app.py` (`stream_stats`, `stream_display`, `stream_logs`).
|
||||
> `test/fixtures/api_v3_url_map.json` is the canonical list of blueprint
|
||||
> routes (116 URL rules); a test fails if the code and that fixture differ.
|
||||
> routes; a test fails if the code and that fixture differ.
|
||||
|
||||
---
|
||||
|
||||
@@ -389,7 +390,7 @@ Request a specific plugin to display on-demand.
|
||||
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
||||
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
||||
- `pinned` (boolean, optional): Pin display (pause rotation)
|
||||
- `start_service` (boolean, optional): (Re)start the display service so it picks the request up (default: true)
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within about a quarter of a second. When false and the service is stopped, the route returns 400.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -869,7 +870,13 @@ Get a plugin's resource limits. `data` is `null` when none are configured.
|
||||
**POST** `/api/v3/plugins/limits/<plugin_id>`
|
||||
|
||||
Set a plugin's resource limits. The body replaces all four limits: a key you
|
||||
omit is stored as no limit (`warning_threshold` defaults to `0.8`).
|
||||
omit is stored as no limit (`warning_threshold` defaults to `0.8`). Each value
|
||||
must be a non-negative number or `null`; anything else is a 400.
|
||||
|
||||
The limits are stored in the shared cache. A display service that has already
|
||||
read limits for the plugin keeps using those until it restarts; likewise the
|
||||
health and metrics reset routes clear the stored record and the web process's
|
||||
copy, not the display service's in-memory state.
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
|
||||
@@ -81,6 +81,11 @@ more. Shared sports code lives in `src/common`:
|
||||
| `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods |
|
||||
| `sports_helpers.py` | 3.5.0 | clamp/logo/rotation free functions and `SportsHelpersMixin`, plus the `_favorite_key` seam |
|
||||
| `espn_dates.py` | 3.5.0 | ESPN date-range and `limit` workarounds |
|
||||
| `favorite_team_check.py` | 3.6.0 | `FavoriteTeamCheck` — logs why a favourite team code shows nothing |
|
||||
| `sports_timezone.py` | 3.6.0 | Which timezone start times are drawn in (`resolve_timezone_name`) |
|
||||
| `sports_celebration.py` | 3.7.0 | `SportsCelebrationMixin` — draws the score/win takeover; colour helpers |
|
||||
| `sports_fetch.py` | 3.7.0 | `SportsFetchMixin` — season fetch, live lookback and live-odds decisions |
|
||||
| `sports_card_wrappers.py` | 3.7.0 | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations |
|
||||
|
||||
Each is described in [src/common/README.md](../src/common/README.md).
|
||||
|
||||
@@ -166,6 +171,15 @@ Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin,
|
||||
SportsLive)` — so the celebration `display()` runs first and falls through to
|
||||
the scorebug via `super()`.
|
||||
|
||||
What shipped is narrower. `src/common/sports_celebration.py`
|
||||
(`SportsCelebrationMixin`) holds only the drawing, which is identical in the
|
||||
five scoreboards that celebrate (afl, football, hockey, nrl, soccer — hockey
|
||||
grew celebrations after this was written). Arming a celebration stays in each
|
||||
plugin: the trigger bodies differ (nrl matches favourites by team id, football
|
||||
folds a touchdown's extra point into one celebration and picks scenery by
|
||||
points), and so does `display()`. The seams above were not needed to move the
|
||||
drawing, so none was added.
|
||||
|
||||
**Rotation strategies.** The three "dialects" turned out to be one algorithm
|
||||
(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state
|
||||
across calls (afl/nrl/soccer) and a precomputed per-cycle list
|
||||
|
||||
@@ -616,6 +616,15 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
```
|
||||
**Note:** Minimum recommended: 300 seconds (5 minutes)
|
||||
|
||||
How often the core calls the plugin's `update()` comes from the plugin
|
||||
itself first: its `get_update_interval()` if it has one, then
|
||||
`update_interval` in its `manifest.json`. The `update_interval` in
|
||||
`config.json` is used by the scheduler only when the manifest sets none.
|
||||
Many plugins also read their own config `update_interval` and skip the
|
||||
API call inside `update()` until it has elapsed, which is what makes the
|
||||
setting above effective; check the plugin's settings form or
|
||||
`config_schema.json` for the option it actually honours.
|
||||
|
||||
2. **Check current rate limit usage:**
|
||||
- OpenWeatherMap free tier: 1,000 calls/day, 60 calls/minute
|
||||
- With 300s interval: 288 calls/day (well within limits)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# The mypy ratchet: modules that type-check clean, one path per line, sorted.
|
||||
#
|
||||
# CI ("Type check (mypy ratchet)") runs `python scripts/check_types.py`, which
|
||||
# runs mypy on exactly these files (imports followed silently, so errors in an
|
||||
# unlisted module they import don't count) and fails on any error, so a listed
|
||||
# module stays clean. Most of src/ isn't clean yet. When you make a module
|
||||
# clean, add it here. Don't take a module off to get CI green -- fix the error
|
||||
# (annotation-only where you can: hints, typing.cast, TYPE_CHECKING imports;
|
||||
# widen an annotation rather than delete a defensive runtime check).
|
||||
|
||||
src/__init__.py
|
||||
src/adaptive_images.py
|
||||
src/auto_update_setup.py
|
||||
src/backup_manager.py
|
||||
src/base_odds_manager.py
|
||||
src/cache/__init__.py
|
||||
src/cache/cache_metrics.py
|
||||
src/cache/cache_strategy.py
|
||||
src/cache/memory_cache.py
|
||||
src/common/__init__.py
|
||||
src/common/api_helper.py
|
||||
src/common/bdf_font.py
|
||||
src/common/espn_dates.py
|
||||
src/common/favorite_team_check.py
|
||||
src/common/font_layout.py
|
||||
src/common/frame_timing.py
|
||||
src/common/json_body.py
|
||||
src/common/logo_helper.py
|
||||
src/common/path_safety.py
|
||||
src/common/permission_utils.py
|
||||
src/common/render_gate.py
|
||||
src/common/scroll_config.py
|
||||
src/common/snapshot_policy.py
|
||||
src/common/sports_card.py
|
||||
src/common/sports_card_wrappers.py
|
||||
src/common/sports_celebration.py
|
||||
src/common/sports_fetch.py
|
||||
src/common/sports_scroll.py
|
||||
src/common/sports_timezone.py
|
||||
src/config_service.py
|
||||
src/core_config_keys.py
|
||||
src/deprecation.py
|
||||
src/device_location.py
|
||||
src/display_geometry.py
|
||||
src/dynamic_team_resolver.py
|
||||
src/exceptions.py
|
||||
src/font_usage.py
|
||||
src/logging_config.py
|
||||
src/logo_downloader.py
|
||||
src/matrix_support.py
|
||||
src/pi5_matrix_support.py
|
||||
src/plugin_system/__init__.py
|
||||
src/plugin_system/compatibility.py
|
||||
src/plugin_system/operation_history.py
|
||||
src/plugin_system/operation_queue.py
|
||||
src/plugin_system/operation_types.py
|
||||
src/plugin_system/plugin_dirs.py
|
||||
src/plugin_system/plugin_executor.py
|
||||
src/plugin_system/plugin_health.py
|
||||
src/plugin_system/plugin_loader.py
|
||||
src/plugin_system/plugin_state.py
|
||||
src/plugin_system/repo_urls.py
|
||||
src/plugin_system/resource_monitor.py
|
||||
src/plugin_system/saved_repositories.py
|
||||
src/plugin_system/schema_manager.py
|
||||
src/plugin_system/state_reconciliation.py
|
||||
src/plugin_system/testing/__init__.py
|
||||
src/plugin_system/testing/bounds_display_manager.py
|
||||
src/plugin_system/testing/loading.py
|
||||
src/plugin_system/testing/mocks.py
|
||||
src/plugin_system/testing/plugin_test_base.py
|
||||
src/plugin_system/testing/sizes.py
|
||||
src/redaction.py
|
||||
src/scan_order.py
|
||||
src/startup_validator.py
|
||||
src/vegas_mode/__init__.py
|
||||
src/vegas_mode/config.py
|
||||
src/vegas_mode/coordinator.py
|
||||
src/vegas_mode/geometry.py
|
||||
src/vegas_mode/stream_manager.py
|
||||
src/web_interface/api_helpers.py
|
||||
src/web_interface/config_arrays.py
|
||||
src/web_interface/error_handler.py
|
||||
src/web_interface/errors.py
|
||||
src/web_interface/secret_helpers.py
|
||||
src/web_interface/validators.py
|
||||
@@ -1,6 +1,11 @@
|
||||
[mypy]
|
||||
# Mypy configuration for LEDMatrix
|
||||
|
||||
# What a bare `mypy` checks. ini values can't span lines or carry trailing
|
||||
# comments -- this file used to have both, so mypy refused to read it at all.
|
||||
files = src
|
||||
exclude = (^|/)(test|__pycache__)/
|
||||
|
||||
# Python version
|
||||
python_version = 3.10
|
||||
|
||||
@@ -25,11 +30,11 @@ warn_unreachable = True
|
||||
# Strict optional checking
|
||||
strict_optional = True
|
||||
|
||||
# Disallow untyped definitions
|
||||
disallow_untyped_defs = False # Set to True once all code is typed
|
||||
# Disallow untyped definitions (set to True once all code is typed)
|
||||
disallow_untyped_defs = False
|
||||
|
||||
# Disallow untyped calls
|
||||
disallow_untyped_calls = False # Set to True once all code is typed
|
||||
# Disallow untyped calls (set to True once all code is typed)
|
||||
disallow_untyped_calls = False
|
||||
|
||||
# Check untyped definitions
|
||||
check_untyped_defs = True
|
||||
@@ -96,10 +101,20 @@ ignore_missing_imports = True
|
||||
[mypy-spotipy.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
# Exclude test files and generated files
|
||||
exclude = (?x)(
|
||||
^test/.*|
|
||||
^.*/__pycache__/.*|
|
||||
^.*\.pyc$
|
||||
)
|
||||
|
||||
# numpy's own stubs (numpy>=2.3) use Python 3.12 `type` statements, which mypy
|
||||
# refuses to parse under python_version = 3.10 -- and 3.10 is the floor this
|
||||
# code has to run on, so it stays. Treat numpy as Any instead: skip it, and
|
||||
# follow_imports_for_stubs makes the skip apply to its .pyi files too.
|
||||
[mypy-numpy.*]
|
||||
follow_imports = skip
|
||||
follow_imports_for_stubs = True
|
||||
|
||||
# orjson is optional (see requirements.txt): the modules that use it fall back
|
||||
# to the stdlib when `import orjson` fails. Whether mypy sees its stubs would
|
||||
# otherwise depend on whether it happens to be installed -- installed, the
|
||||
# `orjson = None` fallback is a type error and the stdlib branch "unreachable";
|
||||
# not installed, silencing either is an unused ignore. Treat it as Any always.
|
||||
[mypy-orjson.*]
|
||||
follow_imports = skip
|
||||
follow_imports_for_stubs = True
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Test/dev-only dependencies (not needed on a running display).
|
||||
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
||||
pytest>=9.0.3,<10.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
pytest-cov>=4.1.0,<8.0.0
|
||||
pytest-mock>=3.11.0,<4.0.0
|
||||
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
||||
psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the
|
||||
psutil>=6.0.0,<7.0.0 # optional at runtime; installed for tests so the
|
||||
# /system/status endpoint's real path is exercised
|
||||
mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml)
|
||||
PyYAML>=6.0.2,<7.0.0 # not a core dependency: test_starlark_pixlet_routes loads
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Pillow>=12.2.0,<13.0.0
|
||||
numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
|
||||
|
||||
# Timezone handling
|
||||
pytz>=2024.2,<2025.0 # Updated for latest timezone data
|
||||
pytz>=2024.2,<2027.0 # Updated for latest timezone data
|
||||
|
||||
# HTTP requests
|
||||
requests>=2.33.0,<3.0.0
|
||||
|
||||
@@ -31,9 +31,11 @@ display; **diagnostic** — run by hand on a Pi when something is wrong.
|
||||
| `diagnose_web_interface.sh` | diagnostic | Checks why the web interface is not reachable |
|
||||
| `download_pixlet.sh` | keep | Downloads the bundled Pixlet binaries for Starlark apps (also run from the web UI) |
|
||||
| `emergency_reconnect.sh` | diagnostic | Reconnects to your WiFi network if captive-portal testing leaves the Pi offline |
|
||||
| `frame_soak.py` | diagnostic | Soaks a running display and reports how often frames reached the panel late (docs/SCROLL_PERFORMANCE.md) |
|
||||
| `install_dependencies_apt.py` | keep | Dependency installer that tries apt packages first, then pip (installer Step 7, plugin loader) |
|
||||
| `install_plugin_dependencies.sh` | diagnostic | Installs plugin requirements by hand when the automatic install fails |
|
||||
| `prove_security.py` | keep | Security property checks run by pre-commit |
|
||||
| `render_bench.py` | diagnostic | Benchmarks the render loop against the panel's real refresh rate on a synthetic strip |
|
||||
| `render_plugin.py` | dev-only | Runs a plugin's `update()` + `display()` and saves the frame as a PNG |
|
||||
| `run_plugin_tests.py` | dev-only | Discovers and runs plugin test suites |
|
||||
| `scroll_speeds.py` | keep | Shows and tries the scroll speeds your panel can display cleanly |
|
||||
|
||||
@@ -194,7 +194,7 @@ def process_schema_file(schema_path: Path) -> bool:
|
||||
print(f" ✓ Modified {len(modified_fields)} fields")
|
||||
return True
|
||||
else:
|
||||
print(f" ✓ No changes needed")
|
||||
print(" ✓ No changes needed")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ def find_duplicate_fields(schema: Dict[str, Any], path: str = "") -> List[str]:
|
||||
|
||||
def validate_schema_syntax(schema_path: Path) -> tuple[bool, List[str]]:
|
||||
"""Validate JSON Schema syntax."""
|
||||
errors = []
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
@@ -164,7 +163,7 @@ def analyze_schema(schema_path: Path) -> Dict[str, Any]:
|
||||
if "update_interval_seconds" in properties:
|
||||
analysis["update_interval_variant"] = "update_interval_seconds"
|
||||
analysis["naming_issues"].append(
|
||||
f"Uses 'update_interval_seconds' instead of 'update_interval'"
|
||||
"Uses 'update_interval_seconds' instead of 'update_interval'"
|
||||
)
|
||||
else:
|
||||
analysis["missing_common_fields"].append(field_name)
|
||||
@@ -239,7 +238,7 @@ def main():
|
||||
print(f" Missing common fields: {', '.join(result['missing_common_fields'])}")
|
||||
|
||||
if result['naming_issues']:
|
||||
print(f" Naming issues:")
|
||||
print(" Naming issues:")
|
||||
for issue in result['naming_issues']:
|
||||
print(f" - {issue}")
|
||||
|
||||
|
||||
@@ -112,15 +112,13 @@ if command -v python3 >/dev/null 2>&1; then
|
||||
echo "Python: $PYTHON_VERSION"
|
||||
|
||||
if [ "$PYTHON_MAJOR" -eq "3" ]; then
|
||||
if [ "$PYTHON_MINOR" -ge "10" ] && [ "$PYTHON_MINOR" -le "12" ]; then
|
||||
print_success "Python version is fully supported (3.10-3.12)"
|
||||
elif [ "$PYTHON_MINOR" -eq "13" ]; then
|
||||
print_warning "Python 3.13 detected - most packages compatible, but some may have limited testing"
|
||||
print_warning "Please report any compatibility issues you encounter"
|
||||
if [ "$PYTHON_MINOR" -ge "10" ] && [ "$PYTHON_MINOR" -le "13" ]; then
|
||||
print_success "Python version is supported (3.10-3.13)"
|
||||
elif [ "$PYTHON_MINOR" -ge "14" ]; then
|
||||
print_warning "Python 3.${PYTHON_MINOR} is very new - some packages may not be compatible yet"
|
||||
else
|
||||
print_warning "Python 3.${PYTHON_MINOR} is outdated - upgrade to 3.10+ recommended"
|
||||
# Pillow 12 and the pinned test tools need 3.10+, so this won't install.
|
||||
print_error "Python 3.${PYTHON_MINOR} is too old - Python 3.10+ is required"
|
||||
fi
|
||||
else
|
||||
print_error "Python 2.x detected - Python 3.10+ is required"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Type-check the modules listed in mypy-clean.txt (the mypy ratchet).
|
||||
|
||||
Most of src/ still has mypy errors, so CI can't require a clean `mypy src`.
|
||||
Instead mypy-clean.txt lists the modules that *are* clean, and this script
|
||||
fails if any of them regresses. When you make another module clean, add it to
|
||||
the list; nothing ever comes off it.
|
||||
|
||||
Imports are followed silently: a listed module is checked against the types of
|
||||
everything it imports, but errors inside those imported modules are not
|
||||
reported, so a clean file isn't failed by an unlisted neighbour.
|
||||
|
||||
Usage:
|
||||
python scripts/check_types.py # check the listed modules
|
||||
python scripts/check_types.py --list # print the list and exit
|
||||
|
||||
Extra arguments after ``--`` are passed to mypy.
|
||||
Exit status: 0 clean, 1 mypy errors, 2 a bad list (missing file, duplicate,
|
||||
unsorted, or empty).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
LIST_FILE = REPO_ROOT / "mypy-clean.txt"
|
||||
|
||||
|
||||
def read_list(path: Path = LIST_FILE) -> list:
|
||||
"""The listed paths, in file order, with comments and blank lines dropped."""
|
||||
entries = []
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if line:
|
||||
entries.append(line)
|
||||
return entries
|
||||
|
||||
|
||||
def list_problems(entries: list, root: Path = REPO_ROOT) -> list:
|
||||
"""Why the list can't be used as-is; empty when it is fine."""
|
||||
problems = []
|
||||
if not entries:
|
||||
problems.append(f"{LIST_FILE.name} lists no modules")
|
||||
seen = set()
|
||||
for entry in entries:
|
||||
if entry in seen:
|
||||
problems.append(f"listed twice: {entry}")
|
||||
seen.add(entry)
|
||||
if "\\" in entry:
|
||||
problems.append(f"use forward slashes: {entry}")
|
||||
elif not (root / entry).is_file():
|
||||
problems.append(f"listed but not found (renamed or deleted? update the list): {entry}")
|
||||
if entries != sorted(entries):
|
||||
problems.append(f"{LIST_FILE.name} is not sorted")
|
||||
return problems
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("--list", action="store_true", help="print the listed modules and exit")
|
||||
parser.add_argument("mypy_args", nargs="*", help="extra mypy arguments (after --)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
entries = read_list()
|
||||
problems = list_problems(entries)
|
||||
if problems:
|
||||
for problem in problems:
|
||||
print(f"check_types: {problem}", file=sys.stderr)
|
||||
return 2
|
||||
if args.list:
|
||||
print("\n".join(entries))
|
||||
return 0
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "mypy",
|
||||
"--config-file", str(REPO_ROOT / "mypy.ini"),
|
||||
"--follow-imports=silent",
|
||||
*args.mypy_args,
|
||||
*entries,
|
||||
]
|
||||
print(f"check_types: mypy on {len(entries)} modules from {LIST_FILE.name}", flush=True)
|
||||
# This interpreter's mypy, fixed flags, and paths from the checked-in list.
|
||||
result = subprocess.run(cmd, cwd=REPO_ROOT) # nosec B603 - list-form argv, no shell # nosemgrep
|
||||
if result.returncode > 1: # mypy itself failed (bad config, crash)
|
||||
return result.returncode
|
||||
if result.returncode != 0:
|
||||
print(
|
||||
"check_types: a module on the mypy ratchet has type errors. Fix them "
|
||||
f"(annotation-only where possible) rather than taking it off {LIST_FILE.name}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -423,7 +423,7 @@ def main():
|
||||
global _extra_dirs
|
||||
_extra_dirs = args.extra_dir
|
||||
|
||||
print(f"LEDMatrix Dev Preview Server")
|
||||
print("LEDMatrix Dev Preview Server")
|
||||
print(f"Open http://{args.host}:{args.port} in your browser")
|
||||
print(f"Plugin search dirs: {[str(d) for d in get_search_dirs()]}")
|
||||
print()
|
||||
|
||||
@@ -299,6 +299,5 @@ def main():
|
||||
|
||||
if __name__ == '__main__':
|
||||
import importlib.util
|
||||
from typing import Optional
|
||||
sys.exit(main())
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ class WiFiMonitorDaemon:
|
||||
"""
|
||||
self.check_interval = check_interval
|
||||
self.wifi_manager = WiFiManager()
|
||||
# mtime of wifi_config.json as last loaded; see _reload_config_if_changed.
|
||||
self._config_mtime = self._config_file_mtime()
|
||||
self.running = True
|
||||
self.last_state = None
|
||||
# Counts consecutive checks where nmcli says "connected" but internet is unreachable.
|
||||
@@ -57,7 +59,32 @@ class WiFiMonitorDaemon:
|
||||
"""Handle shutdown signals"""
|
||||
logger.info(f"Received signal {signum}, shutting down...")
|
||||
self.running = False
|
||||
|
||||
|
||||
def _config_file_mtime(self):
|
||||
try:
|
||||
return self.wifi_manager.config_path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def _reload_config_if_changed(self):
|
||||
"""Re-read wifi_config.json when it has changed on disk.
|
||||
|
||||
The web UI's auto-enable toggle (POST /api/v3/wifi/ap/auto-enable)
|
||||
only writes the file; this process read it once at startup, so the
|
||||
toggle did nothing until the daemon restarted. One stat per check.
|
||||
"""
|
||||
mtime = self._config_file_mtime()
|
||||
if mtime is None or mtime == self._config_mtime:
|
||||
return
|
||||
before = self.wifi_manager.config.get("auto_enable_ap_mode", True)
|
||||
self.wifi_manager._load_config()
|
||||
# _load_config can itself save (it fills in missing keys), so take
|
||||
# the mtime after it, or that save would trigger another reload.
|
||||
self._config_mtime = self._config_file_mtime()
|
||||
after = self.wifi_manager.config.get("auto_enable_ap_mode", True)
|
||||
if after != before:
|
||||
logger.info(f"wifi_config.json changed: auto_enable_ap_mode={after}")
|
||||
|
||||
def run(self):
|
||||
"""Main daemon loop"""
|
||||
logger.info("WiFi Monitor Daemon started")
|
||||
@@ -78,6 +105,8 @@ class WiFiMonitorDaemon:
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
self._reload_config_if_changed()
|
||||
|
||||
# One combined check that also returns the state it observed —
|
||||
# the previous flow fetched status before AND after the check
|
||||
# on top of the check's own internal fetch, each one several
|
||||
@@ -219,7 +248,7 @@ def main():
|
||||
parser.add_argument(
|
||||
'--foreground',
|
||||
action='store_true',
|
||||
help='Run in foreground (for debugging)'
|
||||
help='Accepted for compatibility; the daemon always runs in the foreground'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.5.0"
|
||||
__version__ = "3.7.0"
|
||||
|
||||
|
||||
@@ -70,7 +70,14 @@ def _read(path):
|
||||
|
||||
|
||||
def is_enabled(config):
|
||||
return bool((config.get('auto_update') or {}).get('enabled', False))
|
||||
# Only the {"enabled": true} object turns this on. A hand-edited
|
||||
# non-dict (e.g. "auto_update": true) raised AttributeError here and
|
||||
# aborted startup setup; the web UI's save replaces such a value with {}
|
||||
# (disabled), so read it the same way.
|
||||
section = config.get('auto_update')
|
||||
if not isinstance(section, dict):
|
||||
return False
|
||||
return bool(section.get('enabled', False))
|
||||
|
||||
|
||||
class UpdateHelperSetup:
|
||||
@@ -201,17 +208,26 @@ class UpdateHelperSetup:
|
||||
try:
|
||||
self.result_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(self.result_file.parent), prefix='.auto_update_setup_')
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, indent=2)
|
||||
os.chmod(tmp, 0o644)
|
||||
if self._web_ids and hasattr(os, 'chown'):
|
||||
# Only root can give the file away; the result is readable
|
||||
# (0644) either way, so a failed chown must not lose it.
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, indent=2)
|
||||
os.chmod(tmp, 0o644)
|
||||
if self._web_ids and hasattr(os, 'chown'):
|
||||
# Only root can give the file away; the result is readable
|
||||
# (0644) either way, so a failed chown must not lose it.
|
||||
try:
|
||||
os.chown(tmp, *self._web_ids)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, self.result_file)
|
||||
except BaseException:
|
||||
# Don't leave a .auto_update_setup_* file behind in the
|
||||
# project dir every time the write fails.
|
||||
try:
|
||||
os.chown(tmp, *self._web_ids)
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, self.result_file)
|
||||
raise
|
||||
except OSError as e:
|
||||
logger.warning("Could not record automatic update setup result: %s", e)
|
||||
return result
|
||||
|
||||
@@ -103,6 +103,32 @@ class FetchResult:
|
||||
# FAILED, which turns "you cancelled this" into "this errored".
|
||||
final_status: Optional[FetchStatus] = None
|
||||
|
||||
class _ConnectionRetryingSession:
|
||||
"""``session.get`` that retries a connection error a few times.
|
||||
|
||||
For ESPN date chunks, which bypass _make_request_with_retry: a failed
|
||||
chunk is logged and skipped, so a brief network blip would otherwise drop
|
||||
a month from a cached season. That protection used to come from the
|
||||
session adapter's own retries, which every other request stacked with the
|
||||
retry loop.
|
||||
"""
|
||||
|
||||
ATTEMPTS = 3
|
||||
DELAY = 0.5
|
||||
|
||||
def __init__(self, session):
|
||||
self._session = session
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
for attempt in range(self.ATTEMPTS):
|
||||
try:
|
||||
return self._session.get(*args, **kwargs)
|
||||
except requests.ConnectionError:
|
||||
if attempt == self.ATTEMPTS - 1:
|
||||
raise
|
||||
time.sleep(self.DELAY * (attempt + 1))
|
||||
|
||||
|
||||
class BackgroundDataService:
|
||||
"""
|
||||
Background data service for fetching season data without blocking the main thread.
|
||||
@@ -163,10 +189,16 @@ class BackgroundDataService:
|
||||
'average_fetch_time': 0.0
|
||||
}
|
||||
|
||||
# Session for HTTP requests
|
||||
# Session for HTTP requests. No retries at the adapter: a fetch goes
|
||||
# through _make_request_with_retry (max_retries + 1 attempts with
|
||||
# exponential backoff, logged), and date-range chunks through
|
||||
# _ConnectionRetryingSession. With the adapter also retrying
|
||||
# connection errors three times, a dead network cost up to 16
|
||||
# connection attempts per request and held one of the few worker
|
||||
# threads for all of them.
|
||||
self.session = requests.Session()
|
||||
self.session.mount('http://', requests.adapters.HTTPAdapter(max_retries=3))
|
||||
self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=3))
|
||||
self.session.mount('http://', requests.adapters.HTTPAdapter(max_retries=0))
|
||||
self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=0))
|
||||
|
||||
# Default headers: core's shared set (real User-Agent, no hand-set
|
||||
# Accept-Encoding) -- see src/common/api_helper.py.
|
||||
@@ -247,15 +279,19 @@ class BackgroundDataService:
|
||||
# same object the dict holds.
|
||||
self.completed_requests[request_id] = result
|
||||
|
||||
if callback:
|
||||
try:
|
||||
callback(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in callback for request {request_id}: {e}")
|
||||
self._release_payload(result)
|
||||
# The callback runs outside the lock, as on the worker path: it is
|
||||
# plugin code, and holding the service lock through it blocked
|
||||
# every worker's result bookkeeping (and any other thread's
|
||||
# submit) for as long as the callback took.
|
||||
if callback:
|
||||
try:
|
||||
callback(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in callback for request {request_id}: {e}")
|
||||
self._release_payload(result)
|
||||
|
||||
logger.debug(f"Cache hit for {sport} {year} data")
|
||||
return request_id
|
||||
logger.debug(f"Cache hit for {sport} {year} data")
|
||||
return request_id
|
||||
|
||||
# limit above 500 makes an ESPN *scoreboard* return a truncated list
|
||||
# (src/common/espn_dates.py). Other endpoints need more: /teams has 762
|
||||
@@ -560,7 +596,7 @@ class BackgroundDataService:
|
||||
"""
|
||||
logger.info("Recovering %s %s from a rejected date range", request.sport, request.year)
|
||||
return fetch_espn_date_chunks(
|
||||
self.session,
|
||||
_ConnectionRetryingSession(self.session),
|
||||
request.url,
|
||||
params=request.params,
|
||||
headers=request.headers,
|
||||
|
||||
+41
-5
@@ -102,6 +102,12 @@ _SINGLE_FILE_SECTIONS: Tuple[Tuple[str, Path, str], ...] = (
|
||||
("ytm_auth", _YTM_REL, "restore_wifi"),
|
||||
)
|
||||
|
||||
#: Sections holding credentials. Restored onto a device that has no copy yet,
|
||||
#: they would otherwise take the extracted temp file's umask mode (0o644,
|
||||
#: world-readable); 0o640 matches what config_manager_atomic gives secrets.
|
||||
_PRIVATE_SECTION_RELS = frozenset({_SECRETS_REL, _WIFI_REL, _YTM_REL})
|
||||
_PRIVATE_FILE_MODE = 0o640
|
||||
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
PLUGINS_MANIFEST_NAME = "plugins.json"
|
||||
|
||||
@@ -235,6 +241,10 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
# Valid JSON that is not an object (a list, a bare string) would
|
||||
# raise AttributeError on .get() and abort the whole export.
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
plugin_id = data.get("id") or entry.name
|
||||
if plugin_id not in plugins:
|
||||
plugins[plugin_id] = {
|
||||
@@ -310,7 +320,11 @@ def create_backup(
|
||||
contents: List[str] = []
|
||||
|
||||
# Stream directly to a temp file so we never hold the whole ZIP in memory.
|
||||
tmp_path = zip_path.with_suffix(".zip.tmp")
|
||||
# The name is unique per call: a fixed "<zip>.tmp" was shared by two
|
||||
# exports started in the same second, which then wrote the same file.
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(output_dir), prefix=f".{zip_name}.", suffix=".tmp")
|
||||
os.close(fd)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for section, rel, _flag in _SINGLE_FILE_SECTIONS:
|
||||
@@ -347,7 +361,24 @@ def create_backup(
|
||||
manifest = _build_manifest(contents)
|
||||
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
||||
|
||||
os.replace(tmp_path, zip_path)
|
||||
# Same-second exports share a timestamp; number the later one rather
|
||||
# than replacing the backup the first one just returned. The name is
|
||||
# claimed with an exclusive create (O_EXCL fails if it exists), so two
|
||||
# exports finishing together can't both pick the same free name; the
|
||||
# replace then swaps the finished archive in over our own placeholder.
|
||||
suffix = 2
|
||||
while True:
|
||||
try:
|
||||
os.close(os.open(zip_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
|
||||
break
|
||||
except FileExistsError:
|
||||
zip_path = output_dir / f"{Path(zip_name).stem}-{suffix}.zip"
|
||||
suffix += 1
|
||||
try:
|
||||
os.replace(tmp_path, zip_path)
|
||||
except BaseException:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
raise
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -450,7 +481,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
):
|
||||
detected.append("plugin_uploads")
|
||||
|
||||
plugins: List[Dict[str, Any]] = []
|
||||
# Whatever the archive's manifest holds; checked below.
|
||||
plugins: Any = []
|
||||
if PLUGINS_MANIFEST_NAME in names:
|
||||
try:
|
||||
plugins = json.loads(zf.read(PLUGINS_MANIFEST_NAME).decode("utf-8"))
|
||||
@@ -493,7 +525,7 @@ def _extract_zip_safe(zip_path: Path, dest_dir: Path) -> None:
|
||||
shutil.copyfileobj(src, dst, length=64 * 1024)
|
||||
|
||||
|
||||
def _copy_file(src: Path, dst: Path) -> None:
|
||||
def _copy_file(src: Path, dst: Path, new_mode: Optional[int] = None) -> None:
|
||||
"""Replace ``dst`` with ``src``, atomically, without needing to own ``dst``.
|
||||
|
||||
``shutil.copy2`` opens the destination for writing, so it needs write
|
||||
@@ -509,6 +541,7 @@ def _copy_file(src: Path, dst: Path) -> None:
|
||||
|
||||
The destination's existing mode is preserved when there is one, so
|
||||
restoring secrets does not silently widen them to the umask default.
|
||||
When there is none, ``new_mode`` (if given) is used instead of ``src``'s.
|
||||
"""
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -530,6 +563,8 @@ def _copy_file(src: Path, dst: Path) -> None:
|
||||
shutil.copyfile(src, tmp_path)
|
||||
if existing_mode is not None:
|
||||
os.chmod(tmp_path, existing_mode)
|
||||
elif new_mode is not None:
|
||||
os.chmod(tmp_path, new_mode)
|
||||
else:
|
||||
shutil.copymode(src, tmp_path)
|
||||
if existing_owner is not None and hasattr(os, 'chown'):
|
||||
@@ -593,7 +628,8 @@ def restore_backup(
|
||||
result.skipped.append(section)
|
||||
continue
|
||||
try:
|
||||
_copy_file(tmp_dir / rel, project_root / rel)
|
||||
_copy_file(tmp_dir / rel, project_root / rel,
|
||||
new_mode=_PRIVATE_FILE_MODE if rel in _PRIVATE_SECTION_RELS else None)
|
||||
result.restored.append(section)
|
||||
except OSError as e:
|
||||
logger.error("[Backup] Failed to restore %s: %s", rel.name, e, exc_info=True)
|
||||
|
||||
+33
-12
@@ -16,11 +16,16 @@ import time
|
||||
|
||||
import requests
|
||||
import json
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional, List, cast
|
||||
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
|
||||
|
||||
|
||||
def _is_no_odds_marker(data: Any) -> bool:
|
||||
"""Whether a cached odds entry is the "ESPN had none" marker, not odds."""
|
||||
return isinstance(data, dict) and bool(data.get("no_odds"))
|
||||
|
||||
class BaseOddsManager:
|
||||
"""
|
||||
Base class for odds data fetching and management.
|
||||
@@ -100,7 +105,7 @@ class BaseOddsManager:
|
||||
_FAILURE_COOLDOWN = 60.0
|
||||
|
||||
def get_odds(self, sport: str | None, league: str | None, event_id: str,
|
||||
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]:
|
||||
update_interval_seconds: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch odds data for a specific game.
|
||||
|
||||
@@ -121,10 +126,20 @@ class BaseOddsManager:
|
||||
cache_key = f"odds_espn_{sport}_{league}_{event_id}"
|
||||
|
||||
# Check cache first
|
||||
cached_data = self.cache_manager.get_with_auto_strategy(cache_key)
|
||||
cached_data: Optional[Dict[str, Any]] = self.cache_manager.get_with_auto_strategy(cache_key)
|
||||
|
||||
# Per-game chatter, logged on every update of every game on the
|
||||
# slate: debug, not the journal.
|
||||
if cached_data:
|
||||
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
|
||||
# A game ESPN had no odds for is cached as {"no_odds": True} so it
|
||||
# isn't re-requested every update. That marker is a cache hit --
|
||||
# its ttl decides when to ask again -- but it is not odds: returned
|
||||
# as-is, a caller saw a truthy dict and treated the game as having
|
||||
# odds. The plugins' bundled copies already did this.
|
||||
if _is_no_odds_marker(cached_data):
|
||||
self.logger.debug("Cached no-odds marker for %s", cache_key)
|
||||
return None
|
||||
self.logger.debug(f"Using cached odds from ESPN for {cache_key}")
|
||||
return cached_data
|
||||
|
||||
if time.monotonic() < self._skip_network_until:
|
||||
@@ -137,7 +152,7 @@ class BaseOddsManager:
|
||||
self._skip_network_until - time.monotonic())
|
||||
return None
|
||||
|
||||
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
|
||||
self.logger.debug(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
|
||||
|
||||
try:
|
||||
# Map league names to ESPN API format
|
||||
@@ -151,7 +166,7 @@ class BaseOddsManager:
|
||||
|
||||
espn_league = league_mapping.get(league, league)
|
||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
||||
self.logger.info(f"Requesting odds from URL: {url}")
|
||||
self.logger.debug(f"Requesting odds from URL: {url}")
|
||||
|
||||
response = self.session.get(url, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
@@ -163,9 +178,9 @@ class BaseOddsManager:
|
||||
|
||||
odds_data = self._extract_espn_data(raw_data)
|
||||
if odds_data:
|
||||
self.logger.info(f"Successfully extracted odds data: {odds_data}")
|
||||
self.logger.debug(f"Successfully extracted odds data: {odds_data}")
|
||||
self.cache_manager.set(cache_key, odds_data, ttl=interval)
|
||||
self.logger.info(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
|
||||
self.logger.debug(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
|
||||
else:
|
||||
self.logger.debug(f"No odds data available for {cache_key}")
|
||||
# Cache the absence too, so the game is not re-requested
|
||||
@@ -174,16 +189,22 @@ class BaseOddsManager:
|
||||
|
||||
return odds_data
|
||||
|
||||
# Before RequestException: requests' JSONDecodeError subclasses it, so
|
||||
# listed second this branch never ran and a bad body was reported as a
|
||||
# failed fetch. It holds off like a failed fetch did, so only the
|
||||
# message changes.
|
||||
except (json.JSONDecodeError, requests.exceptions.JSONDecodeError):
|
||||
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
|
||||
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
||||
except requests.exceptions.RequestException as e:
|
||||
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
|
||||
self.logger.error(
|
||||
"Error fetching odds from ESPN API for %s: %s. Holding off on odds "
|
||||
"for %.0fs so a slate of games does not pay this timeout each.",
|
||||
cache_key, e, self._FAILURE_COOLDOWN)
|
||||
except json.JSONDecodeError:
|
||||
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
||||
|
||||
return self.cache_manager.get_with_auto_strategy(cache_key)
|
||||
|
||||
cached = self.cache_manager.get_with_auto_strategy(cache_key)
|
||||
return None if _is_no_odds_marker(cached) else cast(Optional[Dict[str, Any]], cached)
|
||||
|
||||
def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
|
||||
Vendored
+113
-20
@@ -14,7 +14,7 @@ import tempfile
|
||||
import logging
|
||||
import threading
|
||||
import zlib
|
||||
from typing import Dict, Any, Optional, Protocol
|
||||
from typing import Dict, Any, Optional, Protocol, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
from src.common.path_safety import safe_path_component
|
||||
@@ -111,18 +111,66 @@ _HEAD_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _stale_from_head(head: bytes, max_age: Optional[int], now: float) -> bool:
|
||||
def _head_timestamp(head: bytes) -> Optional[Tuple[float, int]]:
|
||||
"""A header-first record's timestamp and the offset just past it.
|
||||
|
||||
None when the record does not start with a finite numeric timestamp.
|
||||
"""
|
||||
match = _HEAD_RE.match(head)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
timestamp = float(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
if not math.isfinite(timestamp):
|
||||
return None
|
||||
return timestamp, match.end(1)
|
||||
|
||||
|
||||
# FRESHNESS OF A SKIPPED WRITE
|
||||
# ----------------------------
|
||||
# CacheManager.set stamps every record with time.time(), so re-saving
|
||||
# unchanged data produced a different payload every time and DiskCache.set's
|
||||
# identical-payload skip never fired: every plugin rewrote its unchanged API
|
||||
# data to the SD card every update cycle. set() now compares header-first
|
||||
# records without their timestamp, and on a skip moves the file's mtime to
|
||||
# the timestamp the skipped record carried instead of rewriting it. So the
|
||||
# file's mtime is when its content was last saved, and a header-first
|
||||
# record is as fresh as the later of its embedded timestamp and its mtime.
|
||||
#
|
||||
# A real write sets the mtime to the embedded timestamp too, so mtime is
|
||||
# never later than the timestamp for a record written with an old one on
|
||||
# purpose -- only a skip can move it forward.
|
||||
|
||||
|
||||
def _refreshed_at(timestamp: float, mtime: float) -> float:
|
||||
"""When a header-first record was last saved, embedded time or mtime.
|
||||
|
||||
An mtime within a second of the timestamp is the write that carried it
|
||||
(float rounding, or an older file whose mtime was not set to match),
|
||||
not a skipped rewrite, and leaves the record as written.
|
||||
"""
|
||||
return mtime if mtime > timestamp + 1.0 else timestamp
|
||||
|
||||
|
||||
def _stale_from_head(head: bytes, max_age: Optional[int], now: float,
|
||||
refreshed: Optional[float] = None) -> bool:
|
||||
"""True when a record's header alone shows it has expired.
|
||||
|
||||
Mirrors the expiry rule in DiskCache.get: a per-entry ttl wins over the
|
||||
caller's max_age, and no limit at all means never stale. False whenever the
|
||||
header cannot be read, so the full parse decides as it always did.
|
||||
``refreshed`` is the file's mtime: a skipped rewrite advances it rather
|
||||
than the embedded timestamp (see "FRESHNESS OF A SKIPPED WRITE").
|
||||
"""
|
||||
match = _HEAD_RE.match(head)
|
||||
if not match:
|
||||
return False
|
||||
try:
|
||||
timestamp = float(match.group(1))
|
||||
if refreshed is not None:
|
||||
timestamp = max(timestamp, refreshed)
|
||||
limit = max_age
|
||||
if match.group(2) is not None:
|
||||
ttl = float(match.group(2))
|
||||
@@ -248,11 +296,13 @@ class DiskCache:
|
||||
self.cache_dir = cache_dir
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self._lock = threading.Lock()
|
||||
# key -> adler32 of the last payload successfully written to the
|
||||
# primary cache path; lets set() skip rewriting identical data
|
||||
# (per-process only — worst case another process rewrites, never
|
||||
# a missed write). Guarded by _lock.
|
||||
self._write_digests: Dict[str, int] = {}
|
||||
# key -> ((length, adler32) of the last content written to the
|
||||
# primary cache path, (st_ino, st_size) of the file it left); lets
|
||||
# set() skip rewriting identical data. The file identity catches
|
||||
# another process -- the web interface writes and clears keys too --
|
||||
# having replaced the file since, which would otherwise make the skip
|
||||
# a missed write. Per-process only. Guarded by _lock.
|
||||
self._write_digests: Dict[str, Tuple[Tuple[int, int], Tuple[int, int]]] = {}
|
||||
|
||||
def get_cache_path(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -306,7 +356,12 @@ class DiskCache:
|
||||
# records (a season schedule is re-fetched when its cache
|
||||
# expires), and parsing 53MB to throw it away held the GIL
|
||||
# for ~1.8s -- a visible freeze on the panel.
|
||||
if _stale_from_head(f.read(_HEAD_BYTES), max_age, time.time()):
|
||||
head = f.read(_HEAD_BYTES)
|
||||
stamp = _head_timestamp(head)
|
||||
fresh_at = None
|
||||
if stamp is not None:
|
||||
fresh_at = _refreshed_at(stamp[0], os.fstat(f.fileno()).st_mtime)
|
||||
if _stale_from_head(head, max_age, time.time(), fresh_at):
|
||||
return None
|
||||
f.seek(0)
|
||||
record = _loads(f.read())
|
||||
@@ -315,6 +370,11 @@ class DiskCache:
|
||||
record_ts = None
|
||||
if isinstance(record, dict):
|
||||
record_ts = record.get('timestamp')
|
||||
if fresh_at is not None and fresh_at > stamp[0]:
|
||||
# A skipped rewrite refreshed this record (see "FRESHNESS
|
||||
# OF A SKIPPED WRITE"); hand callers the time it was last
|
||||
# saved, as the rewrite would have.
|
||||
record['timestamp'] = record_ts = fresh_at
|
||||
if record_ts is None:
|
||||
try:
|
||||
record_ts = os.path.getmtime(cache_path)
|
||||
@@ -403,24 +463,37 @@ class DiskCache:
|
||||
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
||||
return
|
||||
|
||||
digest = zlib.adler32(payload)
|
||||
# A header-first record is compared without its timestamp, which
|
||||
# CacheManager.set changes on every call (see "FRESHNESS OF A SKIPPED
|
||||
# WRITE"). The length rides along with adler32, which is weak on its
|
||||
# own for short payloads, and a collision here is a missed write.
|
||||
stamp = _head_timestamp(payload[:_HEAD_BYTES])
|
||||
stamped_at = stamp[0] if stamp is not None else None
|
||||
content = memoryview(payload)[stamp[1]:] if stamp is not None else payload
|
||||
digest = (len(content), zlib.adler32(content))
|
||||
|
||||
try:
|
||||
# Atomic write to avoid partial/corrupt files
|
||||
with self._lock:
|
||||
# Skip the disk entirely when this exact payload was already
|
||||
# Skip the disk entirely when this content was already
|
||||
# written for this key (plugins re-save unchanged API data
|
||||
# every update cycle — each write is real SD-card wear).
|
||||
# Refresh the file mtime so records that rely on it for TTL
|
||||
# (no embedded 'timestamp') don't expire early; a metadata
|
||||
# touch is journal-cheap compared to rewriting the data.
|
||||
if self._write_digests.get(key) == digest:
|
||||
# Move the file mtime instead, so the record stays as fresh as
|
||||
# the rewrite would have left it; a metadata touch is
|
||||
# journal-cheap compared to rewriting the data.
|
||||
known = self._write_digests.get(key)
|
||||
if known is not None and known[0] == digest:
|
||||
try:
|
||||
os.utime(cache_path, None)
|
||||
return
|
||||
st = os.stat(cache_path)
|
||||
if (st.st_ino, st.st_size) == known[1]:
|
||||
os.utime(cache_path, None if stamped_at is None
|
||||
else (stamped_at, stamped_at))
|
||||
return
|
||||
except OSError:
|
||||
# File vanished or perms changed — fall through and write
|
||||
self._write_digests.pop(key, None)
|
||||
pass
|
||||
# File vanished, was replaced by another process, or its
|
||||
# times cannot be set — fall through and write
|
||||
self._write_digests.pop(key, None)
|
||||
|
||||
tmp_dir = os.path.dirname(cache_path)
|
||||
# Try to create temp file in cache directory first
|
||||
@@ -458,7 +531,7 @@ class DiskCache:
|
||||
# opened it in between was refused.
|
||||
_share_open_file(tmp_file.fileno(), _shared_group(tmp_dir))
|
||||
os.replace(tmp_path, cache_path)
|
||||
self._write_digests[key] = digest
|
||||
self._remember_write(key, cache_path, digest, stamped_at)
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
@@ -471,7 +544,7 @@ class DiskCache:
|
||||
with open(cache_path, 'wb') as cache_file:
|
||||
cache_file.write(payload)
|
||||
_share_open_file(cache_file.fileno(), _shared_group(tmp_dir))
|
||||
self._write_digests[key] = digest
|
||||
self._remember_write(key, cache_path, digest, stamped_at)
|
||||
self.logger.debug("Wrote cache for %s directly (non-atomic)", key)
|
||||
except (IOError, OSError, PermissionError) as write_error:
|
||||
# If direct write also fails, try fallback location
|
||||
@@ -520,6 +593,26 @@ class DiskCache:
|
||||
)
|
||||
return # Exit gracefully without raising exception
|
||||
|
||||
def _remember_write(self, key: str, cache_path: str,
|
||||
digest: Tuple[int, int], stamped_at: Optional[float]) -> None:
|
||||
"""Record a completed write so an identical set() can skip the disk.
|
||||
|
||||
Caller holds _lock. A header-first record's mtime is set to its
|
||||
timestamp, so only a skipped rewrite ever moves it later (see
|
||||
"FRESHNESS OF A SKIPPED WRITE").
|
||||
"""
|
||||
try:
|
||||
if stamped_at is not None:
|
||||
os.utime(cache_path, (stamped_at, stamped_at))
|
||||
st = os.stat(cache_path)
|
||||
except OSError:
|
||||
# Written but not stamped (another user's file, on the direct
|
||||
# write path): mtime is the write time, which _refreshed_at reads
|
||||
# as the write itself. Remember nothing; the next set() writes.
|
||||
self._write_digests.pop(key, None)
|
||||
return
|
||||
self._write_digests[key] = (digest, (st.st_ino, st.st_size))
|
||||
|
||||
def clear(self, key: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clear cache entry or all entries.
|
||||
|
||||
Vendored
+6
-3
@@ -8,7 +8,7 @@ import os
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any, Optional, Union
|
||||
|
||||
# Historical fixed ceiling, kept as the fallback when RAM cannot be read.
|
||||
DEFAULT_MAX_SIZE = 1000
|
||||
@@ -70,13 +70,15 @@ class MemoryCache:
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._cache: Dict[str, Dict[str, Any]] = {}
|
||||
self._timestamps: Dict[str, float] = {}
|
||||
# Values are time.time() floats; get()/cleanup also accept a numeric
|
||||
# string, as a timestamp may have been restored from serialized data.
|
||||
self._timestamps: Dict[str, Union[float, str]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_size = max_size
|
||||
self._cleanup_interval = cleanup_interval
|
||||
self._last_cleanup = time.time()
|
||||
|
||||
def get(self, key: str, max_age: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get value from memory cache.
|
||||
|
||||
@@ -200,6 +202,7 @@ class MemoryCache:
|
||||
max_age_for_cleanup = 3600 # 1 hour
|
||||
|
||||
expired_keys = []
|
||||
timestamp: Optional[Union[float, str]]
|
||||
for key, timestamp in list(self._timestamps.items()):
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
|
||||
+86
-2
@@ -24,24 +24,32 @@ Rules for the package:
|
||||
| Module | For | Plugins import it? | Since |
|
||||
|---|---|---|---|
|
||||
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased |
|
||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
|
||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
|
||||
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
|
||||
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | 3.5.0 |
|
||||
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
|
||||
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
|
||||
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
|
||||
| [`render_gate`](#render_gate) | Keep background Python off the GIL while the panel swaps | No, core-internal | n/a |
|
||||
| [`scroll_config`](#scroll_config) | Plugin scroll config → configured `ScrollHelper` | Yes (scrollers) | 3.4.0 |
|
||||
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
|
||||
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
|
||||
| [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | 3.7.0 |
|
||||
| [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | 3.7.0 |
|
||||
| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 |
|
||||
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
|
||||
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
||||
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
||||
|
||||
The four `sports_*` mixin and card modules hold code the scoreboard plugins
|
||||
The `sports_*` mixin and card modules hold code the scoreboard plugins
|
||||
used to carry as identical copies. Each module docstring lists what a host
|
||||
class must provide. The plan behind them is in
|
||||
[docs/SPORTS_UNIFICATION.md](../../docs/SPORTS_UNIFICATION.md).
|
||||
@@ -97,6 +105,17 @@ results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
||||
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
||||
Scoreboard plugins also bundle a copy for older cores.
|
||||
|
||||
### favorite_team_check
|
||||
|
||||
[`favorite_team_check.py`](favorite_team_check.py).
|
||||
`FavoriteTeamCheck(logger, leagues)`, where `leagues` maps a league key to
|
||||
`(display name, ESPN sport/league path)`. `schedule(league_key, favorites)`
|
||||
checks the configured favourite team codes against ESPN's team list once per
|
||||
league, on a daemon thread, and logs a bad code with the nearest real one, or
|
||||
says the league has nothing on yet; `reset()` re-arms it after a config edit.
|
||||
Diagnostics only: every failure is swallowed. Scoreboard plugins also bundle
|
||||
a copy for older cores.
|
||||
|
||||
### font_layout
|
||||
|
||||
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
|
||||
@@ -107,6 +126,24 @@ the size a bundled face renders on whole pixels at. `resolve_asset_path()`
|
||||
resolves `assets/fonts/...` against the install root rather than the
|
||||
working directory.
|
||||
|
||||
### frame_timing
|
||||
|
||||
[`frame_timing.py`](frame_timing.py). Core-internal. `DisplayManager`
|
||||
records every presented frame in a `FrameTimingRecorder`, which writes
|
||||
cumulative late-frame counters and histograms to `/dev/shm` for
|
||||
`scripts/frame_soak.py` and `scripts/render_bench.py`. `StallWatchdog` logs
|
||||
the stack of whatever holds up a scroll. See
|
||||
[docs/SCROLL_PERFORMANCE.md](../../docs/SCROLL_PERFORMANCE.md).
|
||||
|
||||
### json_body
|
||||
|
||||
[`json_body.py`](json_body.py). `response_json(response)` is
|
||||
`response.json()` parsed by orjson when it is installed, falling back to the
|
||||
stdlib parser (and requests' own error) otherwise. For multi-MB payloads such
|
||||
as a season schedule, where the parse holds the GIL and freezes the display.
|
||||
A plugin that also runs on older cores should guard the import, as
|
||||
`espn_dates` does.
|
||||
|
||||
### logo_helper
|
||||
|
||||
[`logo_helper.py`](logo_helper.py). `LogoHelper(display_width,
|
||||
@@ -134,6 +171,14 @@ let the root display service and the web user share files:
|
||||
already call these; a plugin needs them only when it creates its own files
|
||||
outside the cache. See [docs/PERMISSIONS.md](../../docs/PERMISSIONS.md).
|
||||
|
||||
### render_gate
|
||||
|
||||
[`render_gate.py`](render_gate.py). Core-internal. `RenderGate` is opened by
|
||||
the render thread around each vsync swap; a background thread inside
|
||||
`gate.yielding()` (Vegas's prefetch) parks while the gate is closed, so the
|
||||
render thread finds the GIL free when its refresh arrives. It never parks a
|
||||
thread holding a guarded lock or inside logging, threading or import code.
|
||||
|
||||
### scroll_config
|
||||
|
||||
[`scroll_config.py`](scroll_config.py). `configure(scroll_helper,
|
||||
@@ -174,6 +219,33 @@ dates (`format_game_date()`, `format_game_time()`, `card_tzinfo()`) and font
|
||||
sizes (`schema_font_size()`, `resolve_font_size()`). A plugin keeps its own
|
||||
method and delegates the body.
|
||||
|
||||
### sports_card_wrappers
|
||||
|
||||
[`sports_card_wrappers.py`](sports_card_wrappers.py).
|
||||
`SportsCardWrappersMixin`: the one-line methods a scoreboard's game renderer
|
||||
uses to call `sports_card` with its own `config` and `logger`
|
||||
(`_vs_text()`, `_element_color()`, `_format_game_date()`, ... seventeen in
|
||||
all), under their existing names. They are what `sports_game_renderer`'s
|
||||
mixin expects its host to provide. No `__init__` and no state.
|
||||
|
||||
### sports_celebration
|
||||
|
||||
[`sports_celebration.py`](sports_celebration.py). `SportsCelebrationMixin`
|
||||
draws the full-screen takeover a scoreboard shows when a team scores or wins
|
||||
(`_draw_celebration_layout(celebration)`): a backdrop in the scoring team's
|
||||
colours read off its crest, scenery, confetti, the headline and the score.
|
||||
The colour helpers are free functions (`logo_palette()`, `lift_color()`,
|
||||
`mix_color()`, ...). Deciding *when* to celebrate stays in the plugin, which
|
||||
builds the celebration dict the docstring describes.
|
||||
|
||||
### sports_fetch
|
||||
|
||||
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
|
||||
methods that decide which requests a scoreboard makes --
|
||||
`_fetch_season_directly()` (a season, in chunks ESPN accepts),
|
||||
`_background_fetches_espn_ranges()`, `_needs_previous_day()` (the live
|
||||
lookback) and `_wants_live_odds()` (odds only for games near the screen).
|
||||
|
||||
### sports_game_renderer
|
||||
|
||||
[`sports_game_renderer.py`](sports_game_renderer.py).
|
||||
@@ -208,6 +280,18 @@ fonts, colours, dates, the switch-mode upcoming card). The docstring lists
|
||||
the attributes the host class must have and the three methods deliberately
|
||||
left out.
|
||||
|
||||
### sports_timezone
|
||||
|
||||
[`sports_timezone.py`](sports_timezone.py).
|
||||
`resolve_timezone_name(config, plugin_manager, cache_manager, log, *,
|
||||
plugin_label, writeback_fixed_in=None)` and `resolve_timezone(...)` (the same
|
||||
as a pytz zone): the plugin's own `timezone`, then the global one via either
|
||||
manager's `config_manager`, then the host's zone (`system_timezone_name()`),
|
||||
then UTC. `plugin_label` names the plugin in the warning logged when nothing
|
||||
resolves; `writeback_fixed_in` is for a plugin that once wrote `"UTC"` into
|
||||
the saved config (a bare plugin-level `"UTC"` is then ignored when another
|
||||
source disagrees). Scoreboard plugins also bundle a copy for older cores.
|
||||
|
||||
### sync_manager
|
||||
|
||||
[`sync_manager.py`](sync_manager.py). Core-internal. `DisplaySyncManager`
|
||||
|
||||
+27
-15
@@ -11,12 +11,16 @@ import time
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT
|
||||
from typing import Any, Dict, Mapping, Optional
|
||||
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# What Session() puts in .headers; the stubs only promise a MutableMapping.
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
|
||||
#: The User-Agent core sends to ESPN and other data APIs. It names the client
|
||||
#: and links to it: around 2026-08-04 ESPN began 403ing bare custom tokens
|
||||
@@ -84,7 +88,11 @@ class APIHelper:
|
||||
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
|
||||
|
||||
# Rate limiting
|
||||
self._last_request_time = 0
|
||||
self._last_request_time: float = 0 # wall clock, reported by get_request_stats()
|
||||
# The interval is measured on time.monotonic(): a wall-clock step
|
||||
# back (NTP correcting a Pi with no RTC) made time_since_last
|
||||
# negative and the "remaining interval" sleep as long as the step.
|
||||
self._last_request_monotonic: Optional[float] = None
|
||||
self._min_request_interval = 1.0 # Minimum seconds between requests
|
||||
|
||||
def get(self, url: str, params: Optional[Dict] = None,
|
||||
@@ -108,14 +116,14 @@ class APIHelper:
|
||||
cached = self._get_from_cache(cache_key, cache_ttl)
|
||||
if cached is not None:
|
||||
self.logger.debug(f"Using cached response for {cache_key}")
|
||||
return cached
|
||||
return cast(Dict[Any, Any], cached)
|
||||
|
||||
# Rate limiting
|
||||
self._enforce_rate_limit()
|
||||
|
||||
try:
|
||||
# Prepare request
|
||||
request_headers = self.session.headers.copy()
|
||||
request_headers = cast('CaseInsensitiveDict[Any]', self.session.headers).copy()
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
@@ -129,7 +137,7 @@ class APIHelper:
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse JSON response
|
||||
data = response.json()
|
||||
data: Dict[Any, Any] = response.json()
|
||||
|
||||
# Cache response if cache key provided
|
||||
if cache_key and self.cache_manager:
|
||||
@@ -243,7 +251,7 @@ class APIHelper:
|
||||
self._enforce_rate_limit()
|
||||
|
||||
try:
|
||||
request_headers = self.session.headers.copy()
|
||||
request_headers = cast('CaseInsensitiveDict[Any]', self.session.headers).copy()
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
@@ -256,7 +264,7 @@ class APIHelper:
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
return cast(Optional[Dict[Any, Any]], response.json())
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"POST request failed for {url}: {e}")
|
||||
@@ -333,13 +341,14 @@ class APIHelper:
|
||||
|
||||
def _enforce_rate_limit(self) -> None:
|
||||
"""Enforce rate limiting between requests."""
|
||||
current_time = time.time()
|
||||
time_since_last = current_time - self._last_request_time
|
||||
|
||||
if time_since_last < self._min_request_interval:
|
||||
sleep_time = self._min_request_interval - time_since_last
|
||||
time.sleep(sleep_time)
|
||||
|
||||
if self._last_request_monotonic is not None:
|
||||
time_since_last = time.monotonic() - self._last_request_monotonic
|
||||
|
||||
if time_since_last < self._min_request_interval:
|
||||
sleep_time = self._min_request_interval - time_since_last
|
||||
time.sleep(sleep_time)
|
||||
|
||||
self._last_request_monotonic = time.monotonic()
|
||||
self._last_request_time = time.time()
|
||||
|
||||
def set_rate_limit(self, min_interval: float) -> None:
|
||||
@@ -362,5 +371,8 @@ class APIHelper:
|
||||
return {
|
||||
'min_request_interval': self._min_request_interval,
|
||||
'last_request_time': self._last_request_time,
|
||||
'time_since_last_request': time.time() - self._last_request_time
|
||||
'time_since_last_request': (
|
||||
time.monotonic() - self._last_request_monotonic
|
||||
if self._last_request_monotonic is not None
|
||||
else time.time() - self._last_request_time),
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, timedelta
|
||||
from functools import partial
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple, cast
|
||||
|
||||
try:
|
||||
from src.common.json_body import response_json
|
||||
@@ -159,7 +159,7 @@ def espn_date_chunks(start: date, end: date) -> List[str]:
|
||||
return chunks
|
||||
|
||||
|
||||
def merge_scoreboard_payloads(payloads: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
|
||||
"""Fold chunk responses into one scoreboard payload.
|
||||
|
||||
Events are de-duplicated by id and keep first-seen order. Non-event keys
|
||||
@@ -202,7 +202,7 @@ def _fetch_one_chunk(
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response_json(response)
|
||||
return cast(Optional[Dict[str, Any]], response_json(response))
|
||||
except Exception as exc: # noqa: BLE001 - see docstring
|
||||
if logger:
|
||||
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
||||
@@ -379,4 +379,4 @@ def fetch_espn_scoreboard(
|
||||
if data is not None:
|
||||
return data
|
||||
response.raise_for_status()
|
||||
return response_json(response)
|
||||
return cast(Dict[str, Any], response_json(response))
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Explain an empty screen: a wrong team code, or a season that has not started.
|
||||
|
||||
Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking
|
||||
code silently matches nothing and the plugin shows an empty screen with no hint
|
||||
that the code is at fault. The codes are not always guessable — ESPN calls
|
||||
Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than
|
||||
``GSW``. Between seasons a perfectly correct code produces the same empty
|
||||
screen for a completely different reason, and the two were indistinguishable
|
||||
from the logs.
|
||||
|
||||
This module is diagnostics only. It runs on a daemon thread, once per league per
|
||||
process, and every failure is swallowed: it must never delay a frame or change
|
||||
what is displayed.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000"
|
||||
SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard"
|
||||
REQUEST_TIMEOUT = 15
|
||||
|
||||
|
||||
class FavoriteTeamCheck:
|
||||
"""
|
||||
Validates configured favourite team codes against ESPN, and says so in the log.
|
||||
|
||||
``leagues`` maps the plugin's own league key to a
|
||||
``(human readable name, ESPN sport/league path)`` pair, e.g.
|
||||
``{'nhl': ('NHL', 'hockey/nhl')}``.
|
||||
"""
|
||||
|
||||
# How far out the next fixture has to be before it is worth mentioning.
|
||||
# An off day or two is normal mid-season and saying so would just be noise.
|
||||
GAP_DAYS = 3
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger],
|
||||
leagues: Dict[str, Tuple[str, str]]) -> None:
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.leagues = leagues
|
||||
self._checked: Set[str] = set()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Re-check on the next call, e.g. after the user edits the config."""
|
||||
with self._lock:
|
||||
self._checked.clear()
|
||||
|
||||
def schedule(self, league_key: str, favorites: Iterable[str]) -> None:
|
||||
"""Check one league in the background, at most once per process."""
|
||||
try:
|
||||
favorites = [str(f) for f in (favorites or []) if str(f).strip()]
|
||||
if not favorites or league_key not in self.leagues:
|
||||
return
|
||||
with self._lock:
|
||||
if league_key in self._checked:
|
||||
return
|
||||
self._checked.add(league_key)
|
||||
threading.Thread(
|
||||
target=self._run, args=(league_key, favorites),
|
||||
name="favorite-team-check", daemon=True,
|
||||
).start()
|
||||
except Exception:
|
||||
pass # nosec B110 - a diagnostic must never be the reason an update fails # nosemgrep
|
||||
|
||||
def _run(self, league_key: str, favorites) -> None:
|
||||
try:
|
||||
self._check(league_key, favorites)
|
||||
except Exception as exc:
|
||||
self.logger.debug("Favorite team check failed for %s: %s",
|
||||
league_key, exc)
|
||||
|
||||
def _check(self, league_key: str, favorites) -> None:
|
||||
name, path = self.leagues[league_key]
|
||||
|
||||
try:
|
||||
teams = self._fetch_teams(path)
|
||||
except Exception as exc:
|
||||
self.logger.debug("Could not verify %s favorite teams: %s", name, exc)
|
||||
return
|
||||
if not teams:
|
||||
# Some ESPN endpoints (college lacrosse) return no teams at all.
|
||||
# Nothing can be concluded, so say nothing.
|
||||
return
|
||||
|
||||
# Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not
|
||||
# team codes and must not be reported as bad ones.
|
||||
codes = [f for f in favorites if not self._is_dynamic(f)]
|
||||
recognised = [f for f in codes if f in teams]
|
||||
unknown = [f for f in codes if f not in teams]
|
||||
|
||||
for code in unknown:
|
||||
self.logger.warning(
|
||||
"%s favorite team %r is not a %s team code.%s "
|
||||
"Every code this league accepts is listed at %s.",
|
||||
name, code, name, self._suggest(code, teams),
|
||||
TEAMS_URL.format(path=path),
|
||||
)
|
||||
|
||||
if codes and not recognised:
|
||||
self.logger.warning(
|
||||
"%s has no recognised favorite teams, so nothing will be shown "
|
||||
"for it. Codes must be ESPN abbreviations, e.g. %s.",
|
||||
name, ", ".join("{} ({})".format(a, n)
|
||||
for a, n in list(sorted(teams.items()))[:3]),
|
||||
)
|
||||
return
|
||||
|
||||
if not recognised:
|
||||
return
|
||||
|
||||
# Codes are fine, so check the other cause of an empty screen.
|
||||
try:
|
||||
note = self._schedule_note(path)
|
||||
except Exception as exc:
|
||||
self.logger.debug("Could not check the %s schedule: %s", name, exc)
|
||||
return
|
||||
|
||||
if note:
|
||||
self.logger.info(
|
||||
"%s favorite teams %s look correct, but %s. An empty display "
|
||||
"until then is expected, not a configuration problem.",
|
||||
name, ", ".join(recognised), note,
|
||||
)
|
||||
else:
|
||||
self.logger.info("%s favorite teams recognised: %s",
|
||||
name, ", ".join(recognised))
|
||||
|
||||
@staticmethod
|
||||
def _is_dynamic(code: str) -> bool:
|
||||
upper = (code or "").strip().upper()
|
||||
return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper
|
||||
|
||||
@staticmethod
|
||||
def _fetch_teams(path: str) -> Dict[str, str]:
|
||||
"""ESPN's {abbreviation: display name} for a league.
|
||||
|
||||
``limit=1000`` is required: the default page size truncates the NCAA
|
||||
responses to roughly half their teams, which makes valid codes look wrong.
|
||||
"""
|
||||
import requests
|
||||
|
||||
payload = requests.get(TEAMS_URL.format(path=path),
|
||||
timeout=REQUEST_TIMEOUT).json()
|
||||
entries = payload['sports'][0]['leagues'][0]['teams']
|
||||
return {
|
||||
t['team']['abbreviation']: t['team']['displayName']
|
||||
for t in entries if t.get('team', {}).get('abbreviation')
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _schedule_note(cls, path: str) -> Optional[str]:
|
||||
"""
|
||||
Why the league has nothing to show, as a clause, or ``None`` if it does.
|
||||
|
||||
Two things make this harder than reading ``events``:
|
||||
|
||||
* An out-of-season league does not come back empty. ESPN rolls the
|
||||
scoreboard forward to the next day that has fixtures, so in July the
|
||||
NHL endpoint returns seven September games. Emptiness cannot be the
|
||||
signal; the date of those games is, and it is more useful anyway.
|
||||
* A *finished* season rolls nowhere and returns its last game instead,
|
||||
months in the past — so dates have to be filtered to the future
|
||||
before the soonest one means anything.
|
||||
"""
|
||||
import requests
|
||||
|
||||
payload = requests.get(SCOREBOARD_URL.format(path=path),
|
||||
timeout=REQUEST_TIMEOUT).json()
|
||||
|
||||
event_dates = [cls._parse_date(e.get('date'))
|
||||
for e in payload.get('events') or []]
|
||||
calendar_dates = []
|
||||
for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []:
|
||||
calendar_dates.append(cls._parse_date(
|
||||
entry if isinstance(entry, str) else entry.get('startDate')))
|
||||
|
||||
# Count the last day as current, rather than filtering on "later than
|
||||
# right now": a game that began a few hours ago still means the league
|
||||
# has something on, and dropping it would report a live slate as a
|
||||
# finished season. A day's grace also keeps this correct whatever the
|
||||
# user's timezone, since these timestamps are UTC.
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
def future(candidates):
|
||||
return sorted(d for d in candidates if d and (now - d).days < 1)
|
||||
|
||||
# Events are fixtures; the calendar is week and phase boundaries,
|
||||
# which routinely open days before their first game (an NFL week 1
|
||||
# calendar entry starts the weekend before the Thursday opener).
|
||||
# Reading the two together reported the earliest boundary as a game
|
||||
# date -- "nothing on until 06 September" for a league whose first
|
||||
# snap is the 10th. The calendar only gets a say when the scoreboard
|
||||
# has no events at all to roll forward to: events that exist but are
|
||||
# all in the past mean the season is over, and an offseason calendar
|
||||
# phase must not be dressed up as its next game.
|
||||
#
|
||||
# The exception is a calendar of match days. With calendarType "day"
|
||||
# and calendarIsWhitelist true, every entry is a day that has games,
|
||||
# so a future entry is a real next fixture. Soccer needs it: between
|
||||
# matchdays the scoreboard keeps showing the last one, so on
|
||||
# 2026-09-29 every Premier League event was from 20 September and the
|
||||
# next games (10 October) were only in the calendar. A day calendar
|
||||
# that is not a whitelist (MLB's) lists days *without* games.
|
||||
if any(event_dates):
|
||||
upcoming = future(event_dates)
|
||||
if not upcoming and cls._calendar_is_match_days(payload):
|
||||
upcoming = future(calendar_dates)
|
||||
else:
|
||||
upcoming = future(calendar_dates)
|
||||
if not upcoming:
|
||||
if not any(event_dates) and not any(calendar_dates):
|
||||
return None # Nothing published either way; draw no conclusion.
|
||||
if cls._moved_to_later_phase(payload):
|
||||
return None # e.g. postseason under way; see the method.
|
||||
if cls._later_round_scheduled(payload, now):
|
||||
return None # e.g. Europa League between matchdays.
|
||||
return ("the season has finished and the next one's fixtures are "
|
||||
"not published yet")
|
||||
|
||||
# A day or two out is just an off day, and saying so would be noise.
|
||||
if (upcoming[0] - now).days < cls.GAP_DAYS:
|
||||
return None
|
||||
return "the league has nothing on until {}".format(
|
||||
upcoming[0].strftime('%d %B %Y'))
|
||||
|
||||
@staticmethod
|
||||
def _calendar_is_match_days(payload) -> bool:
|
||||
"""Whether the league calendar lists the days that have games."""
|
||||
league = (payload.get('leagues') or [{}])[0] or {}
|
||||
return (league.get('calendarType') == 'day'
|
||||
and league.get('calendarIsWhitelist') is True)
|
||||
|
||||
@staticmethod
|
||||
def _moved_to_later_phase(payload) -> bool:
|
||||
"""
|
||||
Whether the league is in a later in-season phase than its events.
|
||||
|
||||
ESPN does not roll the scoreboard forward into a postseason. The day
|
||||
after MLB's regular season ended, the default scoreboard still returned
|
||||
that last regular-season day, while ``leagues[0].season`` already said
|
||||
Postseason and the wild-card games were two days out. Past events alone
|
||||
then read as a finished season while the same process's upcoming
|
||||
manager was showing the favourite's playoff games.
|
||||
|
||||
Only regular season (2) and postseason (3) count as "later". The
|
||||
offseason (4) follows the postseason too, and there past events really
|
||||
do mean the season is over.
|
||||
"""
|
||||
season = ((payload.get('leagues') or [{}])[0] or {}).get('season') or {}
|
||||
league_type = (season.get('type') or {}).get('type')
|
||||
if league_type not in (2, 3):
|
||||
return False
|
||||
event_types = [(e.get('season') or {}).get('type')
|
||||
for e in payload.get('events') or []]
|
||||
known = [t for t in event_types if isinstance(t, int)]
|
||||
return bool(known) and all(t < league_type for t in known)
|
||||
|
||||
@classmethod
|
||||
def _later_round_scheduled(cls, payload, now: datetime) -> bool:
|
||||
"""
|
||||
Whether a "list" calendar has a round that has not started yet.
|
||||
|
||||
Competitions with a list calendar (the UEFA club competitions, the
|
||||
World Cup, AFL, NFL) give each phase its rounds as ``entries`` with
|
||||
start and end dates. Between matchdays the Europa League scoreboard
|
||||
keeps showing the last one: on 2026-09-29 every event was from 17
|
||||
September, the next matchday was only days away, and the rounds from
|
||||
the knockout play-offs to the final were all still to come. A round
|
||||
that starts later means the season is not over, even though the
|
||||
date of the next fixture is not known.
|
||||
|
||||
Only a round's *start* counts. End dates are padded well past the
|
||||
last game -- the World Cup's final round ran to 1 August for a 19 July
|
||||
final -- so a future end date is also true of a finished season.
|
||||
Rounds in an offseason phase (the college football All-Star week)
|
||||
are not games for the favourites and do not count either.
|
||||
"""
|
||||
league = (payload.get('leagues') or [{}])[0] or {}
|
||||
for phase in league.get('calendar') or []:
|
||||
if not isinstance(phase, dict) or cls._is_offseason(phase.get('label')):
|
||||
continue
|
||||
for entry in phase.get('entries') or []:
|
||||
if not isinstance(entry, dict) or cls._is_offseason(entry.get('label')):
|
||||
continue
|
||||
start = cls._parse_date(entry.get('startDate'))
|
||||
if start and start > now:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_offseason(label) -> bool:
|
||||
"""'Off Season', 'Offseason', 'Off-season' ..."""
|
||||
return isinstance(label, str) and 'offseason' in re.sub(
|
||||
r'[^a-z]', '', label.lower())
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(raw) -> Optional[datetime]:
|
||||
if not raw or not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _suggest(cls, code: str, teams: Dict[str, str]) -> str:
|
||||
"""Nearest matching code for a typo, as a ready-to-log clause."""
|
||||
upper = (code or "").strip().upper()
|
||||
if not upper or code in teams:
|
||||
return ""
|
||||
|
||||
# Right code, wrong case — matching is case-sensitive. Guard on the case
|
||||
# actually differing, so a valid code never draws this message.
|
||||
for abbr in teams:
|
||||
if abbr.upper() == upper:
|
||||
return " Codes are case-sensitive; use {!r} ({}).".format(
|
||||
abbr, teams[abbr])
|
||||
|
||||
ranked = cls._rank(upper, (a for a, n in teams.items()
|
||||
if cls._abbreviates(upper, n)), teams)
|
||||
if not ranked:
|
||||
# Nicknames are often a fragment of a word rather than its initials:
|
||||
# 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require
|
||||
# three characters, since shorter fragments match far too much.
|
||||
if len(upper) >= 3:
|
||||
ranked = cls._rank(
|
||||
upper,
|
||||
(a for a, n in teams.items()
|
||||
if any(upper in w for w in cls._words(n))),
|
||||
teams)
|
||||
|
||||
if len(ranked) == 1:
|
||||
return " Closest match is {!r} ({}).".format(
|
||||
ranked[0], teams[ranked[0]])
|
||||
if ranked:
|
||||
return " Did you mean {}?".format(", ".join(
|
||||
"{!r} ({})".format(a, teams[a]) for a in ranked[:3]))
|
||||
|
||||
# Otherwise fall back to similarity, against names before codes: a name
|
||||
# gives more characters to compare and so produces fewer ties.
|
||||
names = {n.upper(): a for a, n in teams.items()}
|
||||
hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6)
|
||||
if hits:
|
||||
abbr = names[hits[0]]
|
||||
return " Closest match is {!r} ({}).".format(abbr, teams[abbr])
|
||||
|
||||
code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6)
|
||||
if code_hits:
|
||||
return " Closest match is {!r} ({}).".format(
|
||||
code_hits[0], teams[code_hits[0]])
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _words(name: str):
|
||||
return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w]
|
||||
|
||||
@classmethod
|
||||
def _rank(cls, code: str, candidates, teams: Dict[str, str]):
|
||||
"""
|
||||
Order candidate codes best-first.
|
||||
|
||||
A code that picks up the *first* word of the name wins, because that is
|
||||
how people shorten team names: 'SCAR' for South Carolina starts at
|
||||
'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without
|
||||
this the tie is broken alphabetically and the obvious answer can land
|
||||
third in the list.
|
||||
"""
|
||||
def key(abbr):
|
||||
words = cls._words(teams.get(abbr, ''))
|
||||
first_word_hit = bool(words) and words[0].startswith(code[:1])
|
||||
return (not first_word_hit, len(abbr), abbr)
|
||||
|
||||
return sorted(set(candidates), key=key)
|
||||
|
||||
@staticmethod
|
||||
def _abbreviates(code: str, name: str) -> bool:
|
||||
"""
|
||||
Whether ``code`` reads as an abbreviation of ``name``.
|
||||
|
||||
Each part of the code must be a prefix of one of the name's words, taken
|
||||
in order — which is how people actually shorten team names. Plain string
|
||||
similarity is no use for three-letter codes: 'MUN' scores identically
|
||||
against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the
|
||||
suggestion is a coin flip. This rule separates them, because 'MUN'
|
||||
splits as M-anchester UN-ited while Sunderland has no word starting M.
|
||||
"""
|
||||
words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w]
|
||||
|
||||
def consume(rest: str, remaining: List[str]) -> bool:
|
||||
if not rest:
|
||||
return True
|
||||
if not remaining:
|
||||
return False
|
||||
head, tail = remaining[0], remaining[1:]
|
||||
# Skip this word entirely, as in "Manchester United" -> "UTD".
|
||||
if consume(rest, tail):
|
||||
return True
|
||||
for size in range(1, min(len(rest), len(head)) + 1):
|
||||
if head.startswith(rest[:size]) and consume(rest[size:], tail):
|
||||
return True
|
||||
return False
|
||||
|
||||
return consume((code or "").strip().upper(), words)
|
||||
@@ -93,7 +93,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -483,7 +483,13 @@ class FrameTimingRecorder:
|
||||
raise
|
||||
|
||||
|
||||
def watchdog_settings() -> Dict[str, float]:
|
||||
class _WatchdogSettings(TypedDict, total=False):
|
||||
"""The StallWatchdog keyword arguments watchdog_settings() may set."""
|
||||
threshold: float
|
||||
poll: float
|
||||
|
||||
|
||||
def watchdog_settings() -> _WatchdogSettings:
|
||||
"""StallWatchdog arguments from ``LEDMATRIX_STALL_WATCHDOG_MS``, if set.
|
||||
|
||||
The poll comes down with the threshold, or a stall shorter than one poll
|
||||
|
||||
+42
-17
@@ -8,7 +8,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageDraw
|
||||
@@ -80,6 +80,11 @@ class LogoHelper:
|
||||
# Time-bounded rather than permanent so a logo that appears later (the
|
||||
# downloader writes them at runtime) is still picked up.
|
||||
self._missing_logos: Dict[str, float] = {}
|
||||
|
||||
# Failed downloads by logo path. A logo that is absent (not a stale
|
||||
# placeholder) has no on-disk timestamp to back off on, so without this
|
||||
# every call retried the download -- up to a 30s timeout each time.
|
||||
self._download_failures: Dict[str, float] = {}
|
||||
|
||||
# Session for HTTP requests
|
||||
self.session = requests.Session()
|
||||
@@ -120,17 +125,7 @@ class LogoHelper:
|
||||
# Resolve the effective target size BEFORE the cache lookup so the
|
||||
# key is size-qualified — a panel-size change must not return a
|
||||
# logo resized for the old dimensions.
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||
# Imported here: src.element_style imports src.common (for bdf_font),
|
||||
# whose __init__ imports this module.
|
||||
from src.element_style import coerce_scale
|
||||
scale = coerce_scale(scale, 1.0)
|
||||
if scale != 1.0:
|
||||
max_width = max(1, int(round(max_width * scale)))
|
||||
max_height = max(1, int(round(max_height * scale)))
|
||||
max_width, max_height, scale = self._scaled_box(max_width, max_height, scale)
|
||||
# The key carries the scaled box, so two elements scaled differently
|
||||
# cannot be served each other's image.
|
||||
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
|
||||
@@ -159,7 +154,7 @@ class LogoHelper:
|
||||
return None
|
||||
|
||||
# Load image
|
||||
logo = Image.open(logo_path)
|
||||
logo: Image.Image = Image.open(logo_path)
|
||||
if logo.mode != 'RGBA':
|
||||
logo = logo.convert('RGBA')
|
||||
|
||||
@@ -204,7 +199,12 @@ class LogoHelper:
|
||||
return self.load_logo(team_abbr, logo_path, max_width, max_height,
|
||||
scale)
|
||||
|
||||
# Download if URL provided and file doesn't exist
|
||||
# Download if URL provided and file doesn't exist, unless the last
|
||||
# attempt for this path failed recently.
|
||||
failed_at = self._download_failures.get(str(logo_path))
|
||||
if (logo_url and failed_at is not None
|
||||
and time.time() - failed_at < MISSING_LOGO_RECHECK_SECONDS):
|
||||
logo_url = None
|
||||
if logo_url:
|
||||
try:
|
||||
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
||||
@@ -218,6 +218,7 @@ class LogoHelper:
|
||||
scale)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
||||
self._download_failures[str(logo_path)] = time.time()
|
||||
# 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 --
|
||||
@@ -225,8 +226,30 @@ class LogoHelper:
|
||||
# exists to prevent.
|
||||
self._refresh_stale_placeholder(logo_path)
|
||||
|
||||
# Create placeholder if all else fails
|
||||
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
||||
# Create placeholder if all else fails. Sized to the same scaled box
|
||||
# a real logo gets, so a scaled element doesn't jump in size while
|
||||
# its logo is missing.
|
||||
box_width, box_height, _ = self._scaled_box(max_width, max_height, scale)
|
||||
return self._create_placeholder_logo(team_abbr, box_width, box_height)
|
||||
|
||||
def _scaled_box(self, max_width: Optional[int], max_height: Optional[int],
|
||||
scale: float) -> Tuple[int, int, float]:
|
||||
"""The logo box after defaults and the user's scale are applied.
|
||||
|
||||
Returns ``(width, height, coerced_scale)``.
|
||||
"""
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||
# Imported here: src.element_style imports src.common (for bdf_font),
|
||||
# whose __init__ imports this module.
|
||||
from src.element_style import coerce_scale
|
||||
scale = coerce_scale(scale, 1.0)
|
||||
if scale != 1.0:
|
||||
max_width = max(1, int(round(max_width * scale)))
|
||||
max_height = max(1, int(round(max_height * scale)))
|
||||
return max_width, max_height, scale
|
||||
|
||||
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."""
|
||||
@@ -240,6 +263,7 @@ class LogoHelper:
|
||||
# leaving it would hide a logo we just downloaded.
|
||||
for key in [k for k in self._missing_logos if k.startswith(prefix)]:
|
||||
del self._missing_logos[key]
|
||||
self._download_failures.pop(str(logo_path), None)
|
||||
|
||||
@staticmethod
|
||||
def _refresh_stale_placeholder(logo_path: Path) -> None:
|
||||
@@ -332,9 +356,10 @@ class LogoHelper:
|
||||
self._logo_cache.clear()
|
||||
self._cache_order.clear()
|
||||
self._missing_logos.clear()
|
||||
self._download_failures.clear()
|
||||
self.logger.debug("Logo cache cleared")
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, int]:
|
||||
def get_cache_stats(self) -> Dict[str, float]:
|
||||
"""
|
||||
Get cache statistics.
|
||||
|
||||
|
||||
@@ -290,6 +290,26 @@ def get_cache_dir_mode() -> int:
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
|
||||
def _sudo_bash_candidates() -> list:
|
||||
"""Bash paths to try, in order, when running a vetted helper via sudo.
|
||||
|
||||
sudoers matches the exact argv, so ``sudo -n <bash> <helper> ...`` only
|
||||
works if <bash> is the same path configure_web_sudo.sh wrote into the
|
||||
rule -- whatever ``command -v bash`` said on the machine that ran it.
|
||||
On merged-/usr systems /usr/bin/bash and /bin/bash are the same file but
|
||||
different strings to sudo, and the web user's PATH can differ from the
|
||||
installer's, so no single guess is reliable. Callers try each in turn and
|
||||
move on only when sudo refused the command line (SUDO_REFUSAL_PHRASES).
|
||||
The helper is invoked through bash rather than its shebang for the same
|
||||
reason: the rule names bash, not the script.
|
||||
"""
|
||||
candidates = []
|
||||
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
|
||||
if candidate and candidate not in candidates:
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
|
||||
def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> bool:
|
||||
"""
|
||||
Remove a directory using sudo as a last resort.
|
||||
@@ -350,22 +370,25 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
|
||||
logger.error(f"Safe removal helper not found: {helper_script}")
|
||||
return False
|
||||
|
||||
bash_path = _shutil.which('bash') or '/bin/bash'
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sudo', '-n', bash_path, str(helper_script), str(resolved)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
if result.returncode == 0 and not resolved.exists():
|
||||
logger.info(f"Successfully removed {path} via sudo helper")
|
||||
return True
|
||||
else:
|
||||
stderr = result.stderr.strip()
|
||||
logger.error(f"sudo helper failed for {path}: {stderr}")
|
||||
return False
|
||||
for bash_path in _sudo_bash_candidates():
|
||||
result = subprocess.run(
|
||||
['sudo', '-n', bash_path, str(helper_script), str(resolved)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
if result.returncode == 0 and not resolved.exists():
|
||||
logger.info(f"Successfully removed {path} via sudo helper")
|
||||
return True
|
||||
# Only a refused command line is worth another bash path; if the
|
||||
# helper itself ran and failed, a retry would just repeat it.
|
||||
if result.returncode == 0 or not any(
|
||||
phrase in (result.stderr or '') for phrase in SUDO_REFUSAL_PHRASES):
|
||||
break
|
||||
stderr = (result.stderr or '').strip()
|
||||
logger.error(f"sudo helper failed for {path}: {stderr}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"sudo helper timed out for {path}")
|
||||
return False
|
||||
@@ -417,16 +440,10 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
||||
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
|
||||
|
||||
if wrapper.exists():
|
||||
# See sudo_remove_directory / configure_web_sudo.sh for why bash must
|
||||
# be invoked with an explicit, known path rather than relying on the
|
||||
# wrapper's shebang: sudoers matches the exact command line.
|
||||
bash_candidates = []
|
||||
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
|
||||
if candidate and candidate not in bash_candidates:
|
||||
bash_candidates.append(candidate)
|
||||
|
||||
# See _sudo_bash_candidates for why bash is invoked by explicit path
|
||||
# and why there is more than one to try.
|
||||
result = None
|
||||
for bash_path in bash_candidates:
|
||||
for bash_path in _sudo_bash_candidates():
|
||||
# bash_path and wrapper are fixed, known-good paths, and
|
||||
# safe_pip_install.sh independently re-validates req_file is an
|
||||
# allowed requirements.txt before installing anything as root.
|
||||
|
||||
@@ -46,7 +46,9 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any, Callable, Deque, List, Optional
|
||||
from typing import Any, Callable, Deque, List, Optional, cast
|
||||
|
||||
from src.common.frame_timing import binding_releases_gil
|
||||
|
||||
#: Park background threads this long before the refresh a swap will return on,
|
||||
#: so a short C call already under way has finished by then.
|
||||
@@ -90,27 +92,19 @@ def _unsafe(frame: Any, base: Any) -> bool:
|
||||
def swap_releases_gil() -> Optional[bool]:
|
||||
"""Whether the loaded rgbmatrix binding releases the GIL, or None if none is loaded.
|
||||
|
||||
The rebuilt binding links PyEval_SaveThread and the stock one never does.
|
||||
The same test as src.common.frame_timing.binding_releases_gil (#629); one
|
||||
of the two goes once both have landed.
|
||||
A thin delegate to src.common.frame_timing.binding_releases_gil (#629),
|
||||
which this used to duplicate line for line. The name stays because the
|
||||
coordinator calls it here and tests replace it here.
|
||||
"""
|
||||
module = sys.modules.get("rgbmatrix.core")
|
||||
path = getattr(module, "__file__", None)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
return b"PyEval_SaveThread" in handle.read()
|
||||
except OSError:
|
||||
return None
|
||||
return binding_releases_gil()
|
||||
|
||||
|
||||
def _held(lock: Any) -> bool:
|
||||
"""Is ``lock`` held? RLocks report this thread's ownership; plain locks, anyone's."""
|
||||
is_owned = getattr(lock, "_is_owned", None)
|
||||
if is_owned is not None:
|
||||
return is_owned()
|
||||
return lock.locked()
|
||||
return cast(bool, is_owned())
|
||||
return cast(bool, lock.locked())
|
||||
|
||||
|
||||
class RenderGate:
|
||||
|
||||
@@ -49,7 +49,9 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
from src.matrix_support import DEFAULT_REFRESH_LIMIT_HZ
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,8 +65,9 @@ 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
|
||||
#: ``display.hardware.limit_refresh_rate_hz``, and is the cap DisplayManager
|
||||
#: applies when that key is missing.
|
||||
DEFAULT_REFRESH_HZ = float(DEFAULT_REFRESH_LIMIT_HZ)
|
||||
|
||||
#: 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.
|
||||
@@ -129,14 +132,14 @@ 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,
|
||||
):
|
||||
) -> List[CrispSpeed]:
|
||||
"""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 = {}
|
||||
best: Dict[float, CrispSpeed] = {}
|
||||
for hold in range(1, max_frame_hold + 1):
|
||||
for ppf in range(1, max_pixels_per_frame + 1):
|
||||
pps = refresh_hz / hold * ppf
|
||||
@@ -431,7 +434,8 @@ def configure(
|
||||
# they start scrolling. configure() only reports what is needed.
|
||||
|
||||
if choice:
|
||||
requested = settings.requested_pixels_per_second
|
||||
# Set whenever there is a crisp choice (see the replace() above).
|
||||
requested = cast(float, settings.requested_pixels_per_second)
|
||||
if abs(requested - applied) > 0.05:
|
||||
log.info(
|
||||
"Scroll configured: %s (asked for %.1f px/s from %s; "
|
||||
|
||||
@@ -254,6 +254,9 @@ class ScrollHelper:
|
||||
now = time.time()
|
||||
self.scroll_start_time = now
|
||||
self.last_progress_log_time = now
|
||||
# The position just went back to 0; the first update must not advance
|
||||
# it by however long the helper sat idle (off-screen) before this.
|
||||
self.last_update_time = now
|
||||
self.logger.info(
|
||||
"Dynamic duration target set to %ds (min=%ds, max=%ds, buffer=%.2f)",
|
||||
self.calculated_duration,
|
||||
@@ -776,6 +779,18 @@ class ScrollHelper:
|
||||
self.clear_cache()
|
||||
return
|
||||
|
||||
# Every frame is cut from cached_array with Image.frombytes('RGB', ...),
|
||||
# which reads a 4-channel (RGBA) array as garbage and raises on a
|
||||
# 1-channel (L) one. Transparent pixels go to black, the panel's
|
||||
# background, rather than to whatever colour hides under the alpha.
|
||||
if image.mode != 'RGB':
|
||||
if 'A' in image.mode or 'transparency' in image.info:
|
||||
rgba = image.convert('RGBA')
|
||||
image = Image.new('RGB', rgba.size, (0, 0, 0))
|
||||
image.paste(rgba, (0, 0), rgba)
|
||||
else:
|
||||
image = image.convert('RGB')
|
||||
|
||||
# Set the cached image
|
||||
self.cached_image = image
|
||||
|
||||
@@ -802,6 +817,9 @@ class ScrollHelper:
|
||||
self.scroll_start_time = now
|
||||
self.last_progress_log_time = now
|
||||
self.last_step_time = now # Initialize step timer for frame-based scrolling
|
||||
# The position just went back to 0; the first update must not advance
|
||||
# it by however long the helper sat idle before this image arrived.
|
||||
self.last_update_time = now
|
||||
|
||||
self.logger.debug("Set scrolling image: %dx%d, total_scroll_width=%d",
|
||||
image.width, image.height, self.total_scroll_width)
|
||||
|
||||
@@ -144,7 +144,8 @@ def resolve_font_color(config: Optional[Dict[str, Any]],
|
||||
if len(matches) > 1:
|
||||
configured = []
|
||||
for element in matches:
|
||||
colour = element_color(config, element, None, mode)
|
||||
# None as the default makes it come back when unconfigured.
|
||||
colour = element_color(config, element, None, mode) # type: ignore[arg-type]
|
||||
if colour is not None and colour not in configured:
|
||||
configured.append(colour)
|
||||
if len(configured) == 1:
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""The ``sports_card`` delegations every scoreboard's game renderer carries.
|
||||
|
||||
After the card helpers moved to ``sports_card`` (3.3.0), each of the eight
|
||||
scoreboards with a ``game_renderer.py`` -- afl, baseball, basketball,
|
||||
football, hockey, lacrosse, nrl and soccer -- kept one-line methods that
|
||||
forward to them with its own ``config`` and ``logger``. Seventeen are
|
||||
identical in all eight (executable AST, docstrings stripped) or in all but
|
||||
football, and were copied here from ledmatrix-plugins ``30455671``
|
||||
(origin/main, 2026-09-29) under their existing names. Football's own
|
||||
``_format_game_date`` and ``_upcoming_center_mode`` (they follow the
|
||||
switch-mode settings when it draws the full-screen scorebug) stay in football
|
||||
and override these.
|
||||
|
||||
``_schema_font_size`` and ``_resolve_font_size`` look the same in every copy
|
||||
but are not moved: they read ``_SCHEMA_PATH``, a module global that is each
|
||||
plugin's own ``config_schema.json``.
|
||||
|
||||
These are the methods ``SportsGameRendererMixin`` (``sports_game_renderer``)
|
||||
lists among what its host must provide, so a renderer that inherits both no
|
||||
longer has to write them. Like that mixin this has no ``__init__`` and no
|
||||
state. It is a separate module rather than more methods there for the reason
|
||||
``sports_helpers`` gives: a missing module fails at load, where the version
|
||||
checks see it; a missing method fails mid-render.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||
test in ``test/test_sports_card_wrappers.py`` fails if a read is added
|
||||
without being listed here.
|
||||
|
||||
- ``config`` and ``logger``.
|
||||
- ``fonts``, read with ``getattr`` -- ``_font_color``.
|
||||
- ``_FONT_NAME_ALIASES`` and ``_FONT_PIXEL_GRID`` class attributes --
|
||||
``_crisp_size``, which passes them to ``sports_card.crisp_size`` so a
|
||||
renderer that declares extra faces keeps them.
|
||||
|
||||
Add it as a base of the plugin's renderer, e.g.
|
||||
``class GameRenderer(SportsCardWrappersMixin, SportsGameRendererMixin)``.
|
||||
The two define no name in common; a method on the plugin's own class still
|
||||
wins over either.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, ClassVar, Dict, Optional, Tuple
|
||||
|
||||
from src.common import sports_card as _card
|
||||
|
||||
|
||||
class SportsCardWrappersMixin:
|
||||
"""The game renderer's ``sports_card`` delegations. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only: these create no
|
||||
# attributes, so the host's own values are what the methods read.
|
||||
config: Dict[str, Any]
|
||||
logger: logging.Logger
|
||||
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]]
|
||||
_FONT_PIXEL_GRID: ClassVar[Dict[str, Any]]
|
||||
|
||||
# ---- fonts ---------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def _crisp_size(cls, font_file, desired):
|
||||
"""``sports_card.crisp_size`` with this renderer's font tables."""
|
||||
return _card.crisp_size(font_file, desired,
|
||||
cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID)
|
||||
|
||||
def _unshare_element_fonts(self, fonts):
|
||||
"""``sports_card.unshare_element_fonts``."""
|
||||
return _card.unshare_element_fonts(self.logger, fonts)
|
||||
|
||||
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||
"""``sports_card.font_color`` for one of ``self.fonts``."""
|
||||
return _card.font_color(self.config, getattr(self, "fonts", None), font, default)
|
||||
|
||||
# ---- colours and favourites ---------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _coerce_rgb(value, fallback):
|
||||
"""``sports_card.coerce_rgb``."""
|
||||
return _card.coerce_rgb(value, fallback)
|
||||
|
||||
@staticmethod
|
||||
def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
|
||||
"""``sports_card.side_is_favorite``."""
|
||||
return _card.side_is_favorite(game, side, favorites)
|
||||
|
||||
@staticmethod
|
||||
def _side_score(game: Dict[str, Any], side: str) -> Optional[int]:
|
||||
"""``sports_card.side_score``."""
|
||||
return _card.side_score(game, side)
|
||||
|
||||
def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]:
|
||||
"""``sports_card.favorite_result``."""
|
||||
return _card.favorite_result(self.config, game)
|
||||
|
||||
def _score_color_for(self, game: Dict[str, Any], game_type: str, default=None):
|
||||
"""``sports_card.score_color_for``."""
|
||||
return _card.score_color_for(self.config, self.logger, game, game_type, default)
|
||||
|
||||
def _recent_score_color(self, game: Dict[str, Any], default):
|
||||
"""``sports_card.recent_score_color``."""
|
||||
return _card.recent_score_color(self.config, self.logger, game, default)
|
||||
|
||||
def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||
"""``sports_card.element_color``."""
|
||||
return _card.element_color(self.config, element, default)
|
||||
|
||||
# ---- card options, dates and times --------------------------------
|
||||
|
||||
def _scroll_card_option(self, key: str, default: Any = None) -> Any:
|
||||
"""``sports_card.scroll_card_option``."""
|
||||
return _card.scroll_card_option(self.config, key, default)
|
||||
|
||||
def _upcoming_center_mode(self) -> str:
|
||||
"""``sports_card.upcoming_center_mode``."""
|
||||
return _card.upcoming_center_mode(self.config)
|
||||
|
||||
def _vs_text(self) -> str:
|
||||
"""``sports_card.vs_text``."""
|
||||
return _card.vs_text(self.config)
|
||||
|
||||
def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str:
|
||||
"""``sports_card.format_game_date``."""
|
||||
return _card.format_game_date(self.config, self.logger, date_text, game)
|
||||
|
||||
def _weekday_for(self, game: Optional[Dict]) -> str:
|
||||
"""``sports_card.weekday_for``."""
|
||||
return _card.weekday_for(self.config, self.logger, game)
|
||||
|
||||
def _card_tzinfo(self):
|
||||
"""``sports_card.card_tzinfo``."""
|
||||
return _card.card_tzinfo(self.config, self.logger)
|
||||
|
||||
def _format_game_time(self, time_text: str) -> str:
|
||||
"""``sports_card.format_game_time``."""
|
||||
return _card.format_game_time(self.config, time_text)
|
||||
@@ -0,0 +1,780 @@
|
||||
"""How the scoreboards draw a score or win celebration.
|
||||
|
||||
Five scoreboards -- afl, football, hockey, nrl and soccer -- take over the
|
||||
panel when a team scores or wins: a backdrop in the scoring team's colours
|
||||
read off its crest, scenery for the kind of score, confetti, the headline and
|
||||
the score with the scoring side's digits breathing. The drawing is identical
|
||||
in all five ``sports.py`` copies (executable AST, docstrings stripped), and
|
||||
so are the colour helpers it uses; they were copied here from
|
||||
ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29).
|
||||
|
||||
Only the drawing moved. What *arms* a celebration stays in each plugin,
|
||||
because it differs: which scores count (``_check_for_goal`` /
|
||||
``_check_for_score``, and nrl matches favourites by team id), the phrase and
|
||||
the scenery (``_start_celebration``), and when a win fires
|
||||
(``_check_for_win``). So does ``display()``, which decides whether the
|
||||
takeover or the scorebug is on screen. A plugin hands this mixin a
|
||||
celebration dict and it draws it.
|
||||
|
||||
The colour helpers are public free functions here (``logo_palette``,
|
||||
``lift_color``, ``mix_color``, ...); in the plugins they were the same
|
||||
functions with a leading underscore.
|
||||
|
||||
THE CELEBRATION DICT
|
||||
--------------------
|
||||
Built by the plugin's ``_start_celebration``. Read here: ``game`` (a
|
||||
view-model dict; ``<side>_id``, ``<side>_abbr``, ``<side>_logo_path`` and
|
||||
``<side>_logo_url`` for the crests, ``id`` for the confetti seed),
|
||||
``scored_side`` (``"away"`` or ``"home"``), ``away_score``, ``home_score``,
|
||||
``phrase``, ``started_at`` (a ``time.time()`` value) and ``motif``
|
||||
(``"score"``, ``"kick"``, ``"touchdown"``, ``"net"`` or ``"win"``; anything
|
||||
else draws the ``"score"`` diagonals). The drawing caches what it derives in
|
||||
the same dict, under ``_palette``, ``_backdrop``, ``_confetti`` and
|
||||
``_crests``, so each is worked out once per celebration.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||
test in ``test/test_sports_celebration.py`` fails if a read is added without
|
||||
being listed here. All five scoreboards' ``SportsLive`` provide them.
|
||||
|
||||
- ``display_manager`` -- ``image`` is replaced with the frame, then
|
||||
``update_display()``; ``clear()`` on ``force_clear``. Its ``matrix``
|
||||
width and height are used when it has a matrix, else ``display_width`` /
|
||||
``display_height``.
|
||||
- ``fonts`` -- ``"time"`` and ``"status"`` for the headline (the first that
|
||||
fits), ``"score"`` for the score.
|
||||
- ``logger``.
|
||||
- ``_load_and_resize_logo(team_id, abbr, logo_path, logo_url)`` -- a crest
|
||||
as an RGBA image, or ``None``.
|
||||
- ``_draw_text_with_outline(draw, text, position, font, fill=...)`` -- on
|
||||
``SportsCoreSharedMixin``.
|
||||
- ``celebration_duration``, ``celebration_team_colors`` and
|
||||
``celebration_confetti``, read with ``getattr`` (defaults 8, on, on).
|
||||
|
||||
Mix it in ahead of the mode classes, e.g.
|
||||
``class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin,
|
||||
SportsCore)``. It defines nothing any of them define, so the order only
|
||||
matters for a plugin that keeps its own copy of one of these methods: a
|
||||
method on the plugin's class always wins over the mixin's.
|
||||
"""
|
||||
|
||||
import colorsys
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Callable, ClassVar, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
#: A colour as the helpers return it: three 0-255 channels.
|
||||
Color = Tuple[int, ...]
|
||||
#: ``deep``, ``glow``, ``headline`` and ``accent``; see ``logo_palette``.
|
||||
Palette = Dict[str, Color]
|
||||
#: One confetti flake: column, start height, fall speed, sway phase, size
|
||||
#: in pixels, colour.
|
||||
Flake = Tuple[float, float, float, float, int, Color]
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Colour helpers for the score/win celebration
|
||||
#
|
||||
# Module level rather than methods: they are pure, which is what makes the
|
||||
# palette testable without standing up a live manager, and they are shared by
|
||||
# the takeover's backdrop, confetti and text.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
#: The crest is sampled at this resolution. Big enough that a secondary
|
||||
#: colour survives (a helmet stripe, a trim), small enough that the whole
|
||||
#: sample is ~1600 pixels of pure-Python work, once per team.
|
||||
_PALETTE_SAMPLE_PX = 40
|
||||
#: Above this, a colour carries team identity; below it, it is a grey.
|
||||
_PALETTE_VIVID_SATURATION = 0.22
|
||||
#: Ignore pixels this dark -- crest outlines, drop shadows, anti-aliasing.
|
||||
_PALETTE_MIN_CHANNEL = 24
|
||||
#: How far apart two bins must be to count as a second, different colour.
|
||||
_PALETTE_DISTINCT_DISTANCE = 90.0
|
||||
#: Never bleed a lifted colour below this saturation; past it a hue stops
|
||||
#: being the team's colour and starts being a pastel.
|
||||
_PALETTE_MIN_SATURATION = 0.42
|
||||
#: Lift a headline colour until it is at least this luminous. Chosen so
|
||||
#: midnight navy reaches a blue that reads at 6px on a panel without
|
||||
#: becoming a different colour.
|
||||
_PALETTE_HEADLINE_LUMINANCE = 112.0
|
||||
#: A crest colour this luminous already reads on a panel, so it is preferred
|
||||
#: over a darker one that would have to be lifted to get there. Lifting is a
|
||||
#: compromise -- Green Bay's dark green only reaches legibility as a teal --
|
||||
#: and most teams whose primary is dark carry a bright second colour that is
|
||||
#: just as much theirs. This is what picks the Packers' gold over that teal.
|
||||
_PALETTE_LEGIBLE_LUMINANCE = 90.0
|
||||
#: ...but only from a colour the crest actually means. The pixels where a
|
||||
#: bright edge is anti-aliased into a dark fill are luminous too, and there is
|
||||
#: always a band of them: Kansas City's white-on-red outline leaves a pink at
|
||||
#: luminance 90 that would otherwise be preferred over the red itself. A blend
|
||||
#: is a mix, so it is markedly less saturated than either colour it sits
|
||||
#: between -- that pink is 0.48 where the red is 0.96 and the Packers' gold,
|
||||
#: which this must keep, is 0.89.
|
||||
_PALETTE_LEGIBLE_SATURATION = 0.65
|
||||
#: And it has to be a band of the crest, not a speck of one.
|
||||
_PALETTE_LEGIBLE_AREA = 0.02
|
||||
#: Cap the backdrop's luminance so the headline stays legible over it,
|
||||
#: and the scenery's so it stays behind the headline. Both are luminance and
|
||||
#: not HSV value on purpose: a silver crest -- the Raiders, or the grey
|
||||
#: placeholder a failed logo download leaves behind -- has a value of ~0.95,
|
||||
#: and capping that at 0.34 still yields a light grey card that white text
|
||||
#: then vanishes into. Scaling the channels down is also hue-exact, which is
|
||||
#: what lets this be the plain arithmetic that lifting a colour cannot be.
|
||||
_PALETTE_BACKDROP_LUMINANCE = 34.0
|
||||
_PALETTE_SCENERY_LUMINANCE = 70.0
|
||||
|
||||
|
||||
def rgb_luminance(color: Sequence[float]) -> float:
|
||||
"""Rec. 709 relative luminance, 0-255."""
|
||||
return 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2]
|
||||
|
||||
|
||||
def rgb_saturation(color: Sequence[float]) -> float:
|
||||
"""HSV saturation, 0-1."""
|
||||
high = max(color)
|
||||
return (high - min(color)) / high if high else 0.0
|
||||
|
||||
|
||||
def color_distance(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
"""Euclidean distance between two colours in RGB."""
|
||||
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
|
||||
|
||||
|
||||
def mix_color(a: Sequence[float], b: Sequence[float], t: float) -> Color:
|
||||
"""Blend ``a`` towards ``b``; t=0 is all a, t=1 is all b."""
|
||||
t = min(max(t, 0.0), 1.0)
|
||||
return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))
|
||||
|
||||
|
||||
def scale_color(color: Sequence[float], factor: float) -> Color:
|
||||
"""Scale a colour's brightness, clamped to the panel's range."""
|
||||
return tuple(min(255, max(0, int(round(c * factor)))) for c in color)
|
||||
|
||||
|
||||
def lift_color(color: Sequence[float], min_luminance: float = _PALETTE_HEADLINE_LUMINANCE,
|
||||
cap_saturation: float = 0.92) -> Color:
|
||||
"""Raise a colour's brightness until it reads on a panel, keeping its hue.
|
||||
|
||||
Scaling the channels directly is what the obvious version of this does,
|
||||
and it shifts hue badly on exactly the colours that need lifting: it turns
|
||||
Baltimore's navy-purple into magenta. Working in HSV and raising only the
|
||||
value leaves the hue where the team put it.
|
||||
"""
|
||||
if rgb_luminance(color) >= min_luminance:
|
||||
return tuple(int(c) for c in color)
|
||||
hue, saturation, value = colorsys.rgb_to_hsv(*[c / 255.0 for c in color])
|
||||
if saturation < 0.12:
|
||||
# A grey or a silver has no hue to preserve; just make it bright.
|
||||
lifted = colorsys.hsv_to_rgb(hue, saturation, max(value, 0.85))
|
||||
return tuple(int(round(c * 255)) for c in lifted)
|
||||
saturation = min(saturation, cap_saturation)
|
||||
|
||||
def _rgb(s: float, v: float) -> Color:
|
||||
return tuple(int(round(c * 255)) for c in colorsys.hsv_to_rgb(hue, s, v))
|
||||
|
||||
out = _rgb(saturation, value)
|
||||
while value < 1.0 and rgb_luminance(out) < min_luminance:
|
||||
value = min(1.0, value + 0.05)
|
||||
out = _rgb(saturation, value)
|
||||
# Blue carries almost no luminance -- pure blue sits at 18 of 255 -- so a
|
||||
# navy or a deep purple runs out of value long before it is legible.
|
||||
# Bleeding saturation out of it is the only way up, and it keeps the hue
|
||||
# (Baltimore stays purple, just a lighter one) where giving up would
|
||||
# leave the headline unreadable. Floored so it never washes out to white.
|
||||
while saturation > _PALETTE_MIN_SATURATION and rgb_luminance(out) < min_luminance:
|
||||
saturation = max(_PALETTE_MIN_SATURATION, saturation - 0.05)
|
||||
out = _rgb(saturation, value)
|
||||
return out
|
||||
|
||||
|
||||
def cap_luminance(color: Sequence[float], max_luminance: float) -> Color:
|
||||
"""Darken a colour until it is no brighter than ``max_luminance``.
|
||||
|
||||
A straight channel scale, which is exactly hue-preserving on the way down
|
||||
-- unlike lifting, where clamping at 255 is what bends the hue.
|
||||
"""
|
||||
luminance = rgb_luminance(color)
|
||||
if luminance <= max_luminance or luminance <= 0:
|
||||
return tuple(int(c) for c in color)
|
||||
return scale_color(color, max_luminance / luminance)
|
||||
|
||||
|
||||
def dim_rgba(image: Image.Image, factor: float) -> Image.Image:
|
||||
"""Scale an RGBA image's colour channels, leaving its alpha alone.
|
||||
|
||||
ImageEnhance.Brightness would scale the alpha band too, which fades the
|
||||
crest out instead of dimming it and leaves its anti-aliased edge looking
|
||||
chewed against the backdrop.
|
||||
"""
|
||||
red, green, blue, alpha = image.split()
|
||||
lut = [min(255, int(i * factor)) for i in range(256)]
|
||||
return Image.merge(
|
||||
"RGBA", (red.point(lut), green.point(lut), blue.point(lut), alpha)
|
||||
)
|
||||
|
||||
|
||||
_Buckets = Dict[Tuple[int, int, int], List[int]]
|
||||
|
||||
|
||||
def _palette_buckets(logo: Image.Image) -> Tuple[_Buckets, _Buckets]:
|
||||
"""Bucket a crest's opaque pixels into coarse colour bins.
|
||||
|
||||
Returns ``(vivid, neutral)``; each maps a 3-bit-per-channel key to
|
||||
``[r_sum, g_sum, b_sum, count]``. Neutral holds the greys, silvers and
|
||||
whites that carry no identity on their own but are all a monochrome crest
|
||||
-- the Raiders' silver on black -- has to offer.
|
||||
"""
|
||||
sample = logo.convert("RGBA")
|
||||
sample.thumbnail((_PALETTE_SAMPLE_PX, _PALETTE_SAMPLE_PX), Image.Resampling.BOX)
|
||||
vivid: _Buckets = {}
|
||||
neutral: _Buckets = {}
|
||||
# tobytes() rather than getdata(): same pixels, no per-pixel Python
|
||||
# object, and getdata() is deprecated from Pillow 14.
|
||||
raw = sample.tobytes()
|
||||
for i in range(0, len(raw) - 3, 4):
|
||||
red, green, blue, alpha = raw[i], raw[i + 1], raw[i + 2], raw[i + 3]
|
||||
if alpha < 160:
|
||||
continue
|
||||
high, low = max(red, green, blue), min(red, green, blue)
|
||||
if high < _PALETTE_MIN_CHANNEL:
|
||||
continue
|
||||
target = vivid if (high - low) / high >= _PALETTE_VIVID_SATURATION else neutral
|
||||
acc = target.setdefault((red >> 5, green >> 5, blue >> 5), [0, 0, 0, 0])
|
||||
acc[0] += red
|
||||
acc[1] += green
|
||||
acc[2] += blue
|
||||
acc[3] += 1
|
||||
return vivid, neutral
|
||||
|
||||
|
||||
def _bucket_mean(acc: List[int]) -> Color:
|
||||
count = acc[3]
|
||||
return (acc[0] // count, acc[1] // count, acc[2] // count)
|
||||
|
||||
|
||||
def _bucket_headline_score(acc: List[int]) -> float:
|
||||
"""How well a colour bin would serve as 6px of text on a panel.
|
||||
|
||||
Area alone picks the biggest block of colour, which on a lot of crests is
|
||||
a dark navy fill -- correct as a backdrop, invisible as text. Weighting
|
||||
area by saturation and by luminance picks the colour the team is loud in:
|
||||
Chicago's orange over its navy, Baltimore's gold over its purple.
|
||||
"""
|
||||
color = _bucket_mean(acc)
|
||||
return (
|
||||
acc[3]
|
||||
* (0.30 + 0.70 * rgb_saturation(color))
|
||||
* (0.20 + 0.80 * min(1.0, rgb_luminance(color) / 120.0))
|
||||
)
|
||||
|
||||
|
||||
def logo_palette(logo: Image.Image) -> Optional[Palette]:
|
||||
"""Pick a celebration palette out of a team crest, or None.
|
||||
|
||||
Two rankings, because a crest's largest colour and its most legible one
|
||||
are usually not the same and the takeover needs both:
|
||||
|
||||
* ``deep`` -- the largest vivid area, darkened into the background wash.
|
||||
This is what the team reads as at a glance: Chicago navy, Dallas navy,
|
||||
Baltimore purple.
|
||||
* ``headline`` -- the vivid area that best survives being shrunk to text,
|
||||
then lifted until it is legible: Chicago orange, Baltimore gold.
|
||||
* ``accent`` -- the next vivid colour far enough away from the headline to
|
||||
be told apart, for confetti. Falls back to the headline.
|
||||
|
||||
A crest with no vivid pixels at all falls back to its brightest neutral,
|
||||
which for the Raiders' silver-on-black is exactly the right answer.
|
||||
"""
|
||||
try:
|
||||
vivid, neutral = _palette_buckets(logo)
|
||||
except Exception: # noqa: BLE001 - a crest is never worth the takeover
|
||||
return None
|
||||
|
||||
pool = list(vivid.values())
|
||||
if not pool and neutral:
|
||||
pool = [
|
||||
max(
|
||||
neutral.values(),
|
||||
key=lambda acc: acc[3]
|
||||
* (0.2 + 0.8 * min(1.0, rgb_luminance(_bucket_mean(acc)) / 160.0)),
|
||||
)
|
||||
]
|
||||
if not pool:
|
||||
return None
|
||||
|
||||
deep_base = _bucket_mean(max(pool, key=lambda acc: acc[3]))
|
||||
ranked = sorted(pool, key=_bucket_headline_score, reverse=True)
|
||||
headline_base = _bucket_mean(ranked[0])
|
||||
vivid_pixels = sum(acc[3] for acc in pool)
|
||||
for acc in ranked:
|
||||
candidate = _bucket_mean(acc)
|
||||
if (
|
||||
rgb_luminance(candidate) >= _PALETTE_LEGIBLE_LUMINANCE
|
||||
and rgb_saturation(candidate) >= _PALETTE_LEGIBLE_SATURATION
|
||||
and acc[3] >= max(3, vivid_pixels * _PALETTE_LEGIBLE_AREA)
|
||||
):
|
||||
headline_base = candidate
|
||||
break
|
||||
headline = lift_color(headline_base)
|
||||
|
||||
accent = headline
|
||||
for acc in ranked[1:]:
|
||||
candidate = _bucket_mean(acc)
|
||||
if color_distance(candidate, headline_base) > _PALETTE_DISTINCT_DISTANCE:
|
||||
accent = lift_color(candidate)
|
||||
break
|
||||
|
||||
deep = cap_luminance(deep_base, _PALETTE_BACKDROP_LUMINANCE)
|
||||
return {
|
||||
"deep": deep,
|
||||
# Scenery is the backdrop carried a little way towards the headline:
|
||||
# tied to the team's colours, and guaranteed to be visible even when
|
||||
# the backdrop is nearly black.
|
||||
"glow": cap_luminance(
|
||||
mix_color(deep, headline, 0.22), _PALETTE_SCENERY_LUMINANCE
|
||||
),
|
||||
"headline": headline,
|
||||
"accent": accent,
|
||||
}
|
||||
|
||||
|
||||
class SportsCelebrationMixin:
|
||||
"""Draws a score/win celebration takeover. See the module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only: these create no
|
||||
# attributes, so the host's own values are what the methods read.
|
||||
display_manager: Any
|
||||
display_width: int
|
||||
display_height: int
|
||||
fonts: Dict[str, Any]
|
||||
logger: logging.Logger
|
||||
_load_and_resize_logo: Callable[..., Optional[Image.Image]]
|
||||
_draw_text_with_outline: Callable[..., None]
|
||||
|
||||
def _fit_font(self, draw, text: str, max_width: int, fonts: list):
|
||||
"""Return the first font whose rendered ``text`` fits ``max_width``,
|
||||
falling back to the last (smallest) font."""
|
||||
for font in fonts:
|
||||
if draw.textlength(text, font=font) <= max_width - 2:
|
||||
return font
|
||||
return fonts[-1]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Celebration palette
|
||||
#
|
||||
# The takeover is drawn in the scoring team's own colours, taken from the
|
||||
# pixels of its crest.
|
||||
#
|
||||
# ESPN does serve team.color / team.alternateColor, but only inside
|
||||
# _extract_game_details_common -- a function each scoreboard lineage
|
||||
# keeps its own copy of -- so reading it there would drag every one of
|
||||
# them into a celebration change. The crest is already downloaded,
|
||||
# decoded and sitting in the logo cache by the time a celebration draws,
|
||||
# so the colours come from it instead: no extra request, no per-league
|
||||
# colour table to maintain, and it works for any team ESPN can name --
|
||||
# including the FCS opponents no table would list.
|
||||
#
|
||||
# Where a crest's colour differs from the club's published one it
|
||||
# tends to differ usefully: a published primary is often a near-black
|
||||
# navy, or an actual #000000, where what the crest carries is the colour
|
||||
# that reads on an LED panel. Measured across all 32 clubs in
|
||||
# football-scoreboard.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
#: Used when the crest yields nothing (no logo on disk yet, or the grey
|
||||
#: placeholder a failed download leaves) or team colours are switched
|
||||
#: off -- the navy and amber the celebration wore before it had a palette.
|
||||
_DEFAULT_CELEBRATION_PALETTE: ClassVar[Palette] = {
|
||||
"deep": (10, 10, 40),
|
||||
"glow": (30, 30, 86),
|
||||
"headline": (255, 208, 56),
|
||||
"accent": (255, 255, 255),
|
||||
}
|
||||
|
||||
def _celebration_palette(self, celebration: Dict) -> Palette:
|
||||
"""The scoring team's colours, derived once per celebration."""
|
||||
cached: Optional[Palette] = celebration.get("_palette")
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
palette = dict(self._DEFAULT_CELEBRATION_PALETTE)
|
||||
if getattr(self, "celebration_team_colors", True):
|
||||
try:
|
||||
game = celebration["game"]
|
||||
side = celebration.get("scored_side") or "home"
|
||||
logo = self._load_and_resize_logo(
|
||||
game.get("%s_id" % side),
|
||||
game.get("%s_abbr" % side),
|
||||
game.get("%s_logo_path" % side),
|
||||
game.get("%s_logo_url" % side),
|
||||
)
|
||||
derived = logo_palette(logo) if logo is not None else None
|
||||
if derived:
|
||||
palette = derived
|
||||
except Exception as e: # noqa: BLE001 - never lose a takeover to a crest
|
||||
self.logger.debug(f"Celebration palette fell back to the default: {e}")
|
||||
|
||||
celebration["_palette"] = palette
|
||||
return palette
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Celebration choreography
|
||||
#
|
||||
# Every frame is a finished card. The beats below shift the emphasis --
|
||||
# an opening colour hit, confetti, a breathing score -- but none of them
|
||||
# leaves the panel mid-wipe, because on a switch-mode board the core
|
||||
# drives this plugin at 1 FPS (display_controller reserves its high-FPS
|
||||
# loop for plugins that scroll or declare needs_high_fps), so any single
|
||||
# frame may be the only one a viewer ever sees of it.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
#: Fraction of the celebration spent on the opening colour hit.
|
||||
_CELEBRATION_IMPACT: ClassVar[float] = 0.11
|
||||
#: Fraction of it after which the takeover eases back down.
|
||||
_CELEBRATION_SETTLE: ClassVar[float] = 0.80
|
||||
#: Seconds per breath of the scoring side's digits. Deliberately a
|
||||
#: continuous sine rather than an on/off toggle: the 4 Hz flash this
|
||||
#: replaced was sampled once a second on a switch-mode board, which
|
||||
#: aliases into a colour that changes at random. A ramp degrades into a
|
||||
#: slow glow instead, and still reads as a pulse at 125 FPS.
|
||||
_CELEBRATION_BREATH_SECONDS: ClassVar[float] = 1.7
|
||||
|
||||
def _celebration_backdrop(
|
||||
self,
|
||||
celebration: Dict,
|
||||
width: int,
|
||||
height: int,
|
||||
palette: Palette,
|
||||
) -> Image.Image:
|
||||
"""The static half of the takeover: a team-colour gradient with the
|
||||
scenery for this kind of score painted into it.
|
||||
|
||||
Built once per celebration per panel size and copied per frame, so the
|
||||
per-pixel work never lands on the render path.
|
||||
"""
|
||||
cached: Optional[Tuple[Tuple[int, int], Image.Image]] = celebration.get("_backdrop")
|
||||
if cached is not None and cached[0] == (width, height):
|
||||
return cached[1]
|
||||
|
||||
# One column, then stretched: filling the panel pixel by pixel would
|
||||
# be `width` times the work for the same image.
|
||||
column = Image.new("RGB", (1, max(height, 1)))
|
||||
pixels: Any = column.load()
|
||||
for y in range(height):
|
||||
k = y / max(height - 1, 1)
|
||||
pixels[0, y] = mix_color(palette["deep"], (0, 0, 0), 0.18 + 0.82 * k)
|
||||
backdrop = column.resize((width, height)).convert("RGBA")
|
||||
|
||||
try:
|
||||
self._draw_celebration_motif(
|
||||
ImageDraw.Draw(backdrop),
|
||||
celebration.get("motif") or "score",
|
||||
width,
|
||||
height,
|
||||
palette,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - scenery is never worth a blank panel
|
||||
self.logger.debug(f"Celebration motif skipped: {e}")
|
||||
|
||||
celebration["_backdrop"] = ((width, height), backdrop)
|
||||
return backdrop
|
||||
|
||||
def _draw_celebration_motif(
|
||||
self,
|
||||
draw,
|
||||
motif: str,
|
||||
width: int,
|
||||
height: int,
|
||||
palette: Palette,
|
||||
) -> None:
|
||||
"""Paint the scenery for one kind of score, dim enough to stay behind
|
||||
the headline and the score instead of competing with them."""
|
||||
glow = palette["glow"]
|
||||
if motif == "kick":
|
||||
# The uprights a field goal or an extra point went through,
|
||||
# spread wide enough to frame the score rather than sit beside it.
|
||||
half = max(8, min(width // 3, height))
|
||||
mid = width // 2
|
||||
crossbar = int(height * 0.60)
|
||||
draw.line([(mid - half, int(height * 0.08)), (mid - half, crossbar)], fill=glow)
|
||||
draw.line([(mid + half, int(height * 0.08)), (mid + half, crossbar)], fill=glow)
|
||||
draw.line([(mid - half, crossbar), (mid + half, crossbar)], fill=glow)
|
||||
draw.line([(mid, crossbar), (mid, height - 1)], fill=glow)
|
||||
elif motif == "touchdown":
|
||||
# The goal line, with its hash marks.
|
||||
line_y = int(height * 0.36)
|
||||
draw.line([(0, line_y), (width, line_y)], fill=glow)
|
||||
for x in range(3, width, 9):
|
||||
draw.line([(x, line_y - 2), (x, line_y + 2)], fill=glow)
|
||||
elif motif == "net":
|
||||
# The goal a puck just went into: frame, posts and mesh, sized to
|
||||
# frame the score the way the uprights do.
|
||||
half = max(7, min(width // 4, height))
|
||||
mid = width // 2
|
||||
top = int(height * 0.34)
|
||||
draw.rectangle([(mid - half, top), (mid + half, height - 1)], outline=glow)
|
||||
step = max(3, (half * 2) // 6)
|
||||
for x in range(mid - half + step, mid + half, step):
|
||||
draw.line([(x, top + 1), (x, height - 2)], fill=glow)
|
||||
for y in range(top + step, height - 1, step):
|
||||
draw.line([(mid - half + 1, y), (mid + half - 1, y)], fill=glow)
|
||||
elif motif == "win":
|
||||
# A sunburst behind the winner.
|
||||
cx, cy = width // 2, height // 2
|
||||
reach = max(width, height)
|
||||
for i in range(10):
|
||||
angle = (math.pi * 2 * i / 10) + math.pi / 20
|
||||
draw.line(
|
||||
[
|
||||
(cx, cy),
|
||||
(cx + math.cos(angle) * reach, cy + math.sin(angle) * reach),
|
||||
],
|
||||
fill=glow,
|
||||
)
|
||||
else:
|
||||
for x in range(-height, width + height, 11):
|
||||
draw.line([(x, height), (x + height, 0)], fill=glow)
|
||||
|
||||
def _celebration_confetti(
|
||||
self,
|
||||
celebration: Dict,
|
||||
width: int,
|
||||
height: int,
|
||||
palette: Palette,
|
||||
) -> List[Flake]:
|
||||
"""Seed the confetti once per celebration.
|
||||
|
||||
Seeded from the game rather than the clock, so the same score always
|
||||
produces the same fall -- which is what lets a golden screen lock the
|
||||
effect down instead of having to tolerate it.
|
||||
"""
|
||||
cached: Optional[Tuple[Tuple[int, int], List[Flake]]] = celebration.get("_confetti")
|
||||
if cached is not None and cached[0] == (width, height):
|
||||
return cached[1]
|
||||
|
||||
# Sparse on purpose. At one flake per 170 square pixels a 128x32
|
||||
# panel carried 24 single-pixel specks over the headline and the
|
||||
# score, which reads as a dead-pixel problem rather than as confetti.
|
||||
count = max(6, min(22, (width * height) // 260))
|
||||
seed = "%s/%s" % (
|
||||
(celebration.get("game") or {}).get("id", "?"),
|
||||
celebration.get("phrase", ""),
|
||||
)
|
||||
rng = random.Random(seed) # nosec B311 - confetti, not security
|
||||
# Team colours, plus a pale tint of the headline rather than a flat
|
||||
# white, so the fall still belongs to the team that scored.
|
||||
colors = [
|
||||
palette["headline"],
|
||||
palette["accent"],
|
||||
mix_color(palette["headline"], (255, 255, 255), 0.55),
|
||||
]
|
||||
flakes = [
|
||||
(
|
||||
float(rng.randrange(max(width, 1))), # column
|
||||
rng.uniform(0.0, float(height)), # start height
|
||||
rng.uniform(0.40, 1.15), # fall speed
|
||||
rng.uniform(0.0, math.pi * 2), # sway phase
|
||||
2 if rng.random() < 0.6 else 1, # size in pixels
|
||||
colors[rng.randrange(len(colors))],
|
||||
)
|
||||
for _ in range(count)
|
||||
]
|
||||
celebration["_confetti"] = ((width, height), flakes)
|
||||
return flakes
|
||||
|
||||
def _draw_celebration_confetti(
|
||||
self,
|
||||
draw,
|
||||
celebration: Dict,
|
||||
width: int,
|
||||
height: int,
|
||||
palette: Palette,
|
||||
elapsed: float,
|
||||
progress: float,
|
||||
) -> None:
|
||||
"""Draw the confetti for this instant, thinning it out as the
|
||||
celebration eases back towards the scorebug."""
|
||||
flakes = self._celebration_confetti(celebration, width, height, palette)
|
||||
fade = 1.0
|
||||
if progress > self._CELEBRATION_SETTLE:
|
||||
fade = max(
|
||||
0.0,
|
||||
1.0
|
||||
- (progress - self._CELEBRATION_SETTLE)
|
||||
/ (1.0 - self._CELEBRATION_SETTLE),
|
||||
)
|
||||
if fade <= 0.02:
|
||||
return
|
||||
alpha = int(235 * fade)
|
||||
for column, start, speed, phase, size, color in flakes:
|
||||
y = (start + speed * elapsed * height * 0.42) % (height + 4) - 2
|
||||
x = column + math.sin(elapsed * 2.1 + phase) * 2.4
|
||||
draw.rectangle(
|
||||
[(int(x), int(y)), (int(x) + size - 1, int(y) + size - 1)],
|
||||
fill=tuple(color) + (alpha,),
|
||||
)
|
||||
|
||||
def _celebration_crests(
|
||||
self, celebration: Dict, height: int
|
||||
) -> Dict[str, Optional[Image.Image]]:
|
||||
"""The two crests for the takeover, with the side that did not score
|
||||
dimmed so the scoring team reads at a glance."""
|
||||
cached: Optional[Tuple[int, Dict[str, Optional[Image.Image]]]] = celebration.get("_crests")
|
||||
if cached is not None and cached[0] == height:
|
||||
return cached[1]
|
||||
|
||||
game = celebration["game"]
|
||||
scored = celebration.get("scored_side")
|
||||
crests: Dict[str, Optional[Image.Image]] = {}
|
||||
for side in ("away", "home"):
|
||||
logo = None
|
||||
try:
|
||||
logo = self._load_and_resize_logo(
|
||||
game.get("%s_id" % side),
|
||||
game.get("%s_abbr" % side),
|
||||
game.get("%s_logo_path" % side),
|
||||
game.get("%s_logo_url" % side),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - a crest is never worth the panel
|
||||
self.logger.debug(f"Celebration logo load failed: {e}")
|
||||
if logo is not None and side != scored:
|
||||
logo = dim_rgba(logo, 0.40)
|
||||
crests[side] = logo
|
||||
|
||||
celebration["_crests"] = (height, crests)
|
||||
return crests
|
||||
|
||||
def _draw_celebration_layout(self, celebration: Dict, force_clear: bool = False) -> None:
|
||||
"""Render the full-screen goal/win takeover."""
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
display_width = (
|
||||
self.display_manager.matrix.width
|
||||
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
||||
else self.display_width
|
||||
)
|
||||
display_height = (
|
||||
self.display_manager.matrix.height
|
||||
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
||||
else self.display_height
|
||||
)
|
||||
|
||||
elapsed = max(0.0, time.time() - celebration["started_at"])
|
||||
# getattr throughout the render path: the golden-screen tests build a
|
||||
# live manager through __new__ and set only what they draw with, and a
|
||||
# celebration must never be lost to a missing knob.
|
||||
duration = max(float(getattr(self, "celebration_duration", 8) or 8), 0.5)
|
||||
progress = min(elapsed / duration, 1.0)
|
||||
palette = self._celebration_palette(celebration)
|
||||
|
||||
main_img = self._celebration_backdrop(
|
||||
celebration, display_width, display_height, palette
|
||||
).copy()
|
||||
|
||||
# Crests at the edges, bleeding off as the scorebug's do.
|
||||
crests = self._celebration_crests(celebration, display_height)
|
||||
center_y = display_height // 2
|
||||
home_logo, away_logo = crests.get("home"), crests.get("away")
|
||||
if home_logo is not None:
|
||||
main_img.paste(
|
||||
home_logo,
|
||||
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
|
||||
home_logo,
|
||||
)
|
||||
if away_logo is not None:
|
||||
main_img.paste(away_logo, (-2, center_y - away_logo.height // 2), away_logo)
|
||||
|
||||
# The opening hit: the team's headline colour washes the panel and
|
||||
# decays out of it. Held below opaque so the outlined text drawn on
|
||||
# top still reads in whichever frame happens to catch it.
|
||||
impact = max(0.0, 1.0 - progress / self._CELEBRATION_IMPACT)
|
||||
if impact > 0.0:
|
||||
# Scaled by how colourful the team is. A saturated crest gets the
|
||||
# full hit; a silver one -- the Raiders, or the grey placeholder a
|
||||
# failed logo download leaves -- would otherwise wash the whole
|
||||
# panel out to the same flat grey as its own headline colour.
|
||||
punch = 0.45 + 0.55 * rgb_saturation(palette["headline"])
|
||||
alpha = int(140 * punch * (impact ** 1.5))
|
||||
if alpha > 0:
|
||||
main_img = Image.alpha_composite(
|
||||
main_img,
|
||||
Image.new(
|
||||
"RGBA",
|
||||
(display_width, display_height),
|
||||
tuple(palette["headline"]) + (alpha,),
|
||||
),
|
||||
)
|
||||
|
||||
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
|
||||
if getattr(self, "celebration_confetti", True):
|
||||
try:
|
||||
self._draw_celebration_confetti(
|
||||
draw,
|
||||
celebration,
|
||||
display_width,
|
||||
display_height,
|
||||
palette,
|
||||
elapsed,
|
||||
progress,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.logger.debug(f"Celebration confetti skipped: {e}")
|
||||
|
||||
# Headline across the top, shrunk to fit the panel width, struck
|
||||
# white on the opening hit and settling into the team's colour.
|
||||
phrase = celebration["phrase"]
|
||||
phrase_font = self._fit_font(
|
||||
draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]]
|
||||
)
|
||||
phrase_width = draw.textlength(phrase, font=phrase_font)
|
||||
# Eased in by colour rather than by position. Sliding it down into
|
||||
# place put the first frame at y=-3 with its top row cut off, and on a
|
||||
# 1 FPS board that clipped frame can be the only one anyone sees.
|
||||
self._draw_text_with_outline(
|
||||
draw,
|
||||
phrase,
|
||||
((display_width - phrase_width) // 2, 1),
|
||||
phrase_font,
|
||||
fill=mix_color(palette["headline"], (255, 255, 255), impact),
|
||||
)
|
||||
|
||||
# Score centred low, the scoring side's digits breathing in the team's
|
||||
# headline colour so the change reads at a glance.
|
||||
away_text = str(celebration["away_score"])
|
||||
home_text = str(celebration["home_score"])
|
||||
score_font = self.fonts["score"]
|
||||
segments = [
|
||||
(away_text, celebration["scored_side"] == "away"),
|
||||
("-", False),
|
||||
(home_text, celebration["scored_side"] == "home"),
|
||||
]
|
||||
total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments)
|
||||
breath = 0.72 + 0.28 * (
|
||||
0.5
|
||||
+ 0.5 * math.sin(2 * math.pi * elapsed / self._CELEBRATION_BREATH_SECONDS)
|
||||
)
|
||||
highlight = scale_color(palette["headline"], breath)
|
||||
x = (display_width - total_width) // 2
|
||||
# display_height - 14 was sized for the old fixed 8px score. #338
|
||||
# scales the score with the panel (16px at 48 and 64 tall), which put
|
||||
# the bottom of the digits off the panel. Lift it by the measured ink
|
||||
# (+1 for the outline stroke) only when it would clip, so panels where
|
||||
# it always fitted render exactly as before.
|
||||
score_text = "".join(seg for seg, _ in segments)
|
||||
ink_bottom = draw.textbbox((0, 0), score_text, font=score_font)[3]
|
||||
y = min(display_height - 14, display_height - ink_bottom - 2)
|
||||
for seg, is_highlight in segments:
|
||||
color = highlight if is_highlight else (216, 216, 216)
|
||||
self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color)
|
||||
x += draw.textlength(seg, font=score_font)
|
||||
|
||||
main_img = Image.alpha_composite(main_img, overlay).convert("RGB")
|
||||
self.display_manager.image = main_img
|
||||
self.display_manager.update_display()
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Which requests a scoreboard makes: season fetches, the lookback, live odds.
|
||||
|
||||
Four ``SportsCore`` methods are identical (executable AST, docstrings
|
||||
stripped) in all nine scoreboards' ``sports.py`` -- afl, baseball,
|
||||
basketball, football, hockey, lacrosse, nrl, soccer and ufc -- and were
|
||||
copied here from ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29)
|
||||
under their existing names:
|
||||
|
||||
- ``_background_fetches_espn_ranges`` -- whether the core's background
|
||||
service can fetch an ESPN date range, or the plugin must;
|
||||
- ``_fetch_season_directly`` -- fetch and cache a season in chunks ESPN
|
||||
accepts, on the calling thread;
|
||||
- ``_needs_previous_day`` (with ``_LOOKBACK_CUTOFF_HOUR``) -- whether the
|
||||
live fetch still has to ask for yesterday;
|
||||
- ``_wants_live_odds`` (with ``_LIVE_ODDS_LOOKAHEAD``) -- whether a live
|
||||
game is close enough to the screen to be worth an odds request.
|
||||
|
||||
Three other ``SportsCore`` methods are as identical and stay in the plugins,
|
||||
for the reasons ``sports_shared`` gives: ``_get_timezone`` binds each
|
||||
plugin's own ``resolve_timezone`` shim, and ``_extract_game_details`` /
|
||||
``_fetch_data`` are the abstract sport-specific contract. So does
|
||||
``SportsUpcoming.__init__``: the mixins in ``src/common`` hold no
|
||||
constructor, so the plugins' constructor signature stays theirs.
|
||||
|
||||
A new module rather than more methods on ``sports_shared``, for the reason
|
||||
``sports_helpers`` gives: a missing module fails at load, where the version
|
||||
checks see it; a missing method fails mid-update.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||
test in ``test/test_sports_fetch.py`` fails if a read is added without being
|
||||
listed here.
|
||||
|
||||
- ``session``, ``headers``, ``cache_manager`` and ``logger`` --
|
||||
``_fetch_season_directly``.
|
||||
- ``_games_lock`` -- ``_wants_live_odds``, which also reads ``live_games``,
|
||||
``current_game_index`` and ``_rotation_schedule`` with ``getattr``
|
||||
(only ``SportsLive`` has them).
|
||||
- ``live_games``, read with ``getattr`` -- ``_needs_previous_day``.
|
||||
- ``background_service``, read with ``getattr`` --
|
||||
``_background_fetches_espn_ranges``.
|
||||
|
||||
Add it as a base of the plugin's ``SportsCore``, e.g.
|
||||
``class SportsCore(SportsFetchMixin, SportsCoreSharedMixin,
|
||||
SportsHelpersMixin, ABC)``. It defines nothing those define; a method or
|
||||
constant on the plugin's own class still wins over the mixin's.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, ClassVar, Dict, Optional
|
||||
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard
|
||||
|
||||
|
||||
class SportsFetchMixin:
|
||||
"""Season fetch, lookback and live-odds decisions. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only: these create no
|
||||
# attributes, so the host's own values are what the methods read.
|
||||
session: Any
|
||||
headers: Dict[str, str]
|
||||
cache_manager: Any
|
||||
logger: logging.Logger
|
||||
_games_lock: threading.RLock
|
||||
|
||||
#: How many games past the one on screen keep their odds warm. One is
|
||||
#: enough for the line to be ready when the rotation advances; more just
|
||||
#: re-creates the whole-slate fetch this replaced.
|
||||
_LIVE_ODDS_LOOKAHEAD: ClassVar[int] = 1
|
||||
|
||||
def _wants_live_odds(self, game: Dict) -> bool:
|
||||
"""Whether a live game is near enough the front of the rotation to be
|
||||
worth an odds request.
|
||||
|
||||
Odds used to be fetched for *every* live game in the league on every
|
||||
update. The renderer only ever draws ``current_game``, and a full
|
||||
rotation of a big slate takes minutes while ``live_odds_update_interval``
|
||||
is 60s -- so all but one of those requests expired before the game they
|
||||
belonged to came round.
|
||||
|
||||
Measured 2026-09-19 over a full college-football slate: 11,978 odds
|
||||
requests in 13h on one rig, 54% of all its ESPN traffic, across only
|
||||
~140 distinct games. The eager loop also cost up to 2s of ``update()``
|
||||
per live game, because ``_fetch_odds`` waits on its worker thread.
|
||||
|
||||
Mirrors the narrowing already applied to the upcoming path and to
|
||||
``_attach_odds_to_rotated_games``: only games about to be on screen are
|
||||
asked about. ``get_odds`` still caches per game, so a game re-entering
|
||||
the window inside its TTL costs a cache lookup, not a request.
|
||||
|
||||
The rotation state read here is the previous cycle's -- the new list is
|
||||
still being built -- which is exactly the question being asked: is this
|
||||
game at or near the position currently on the panel?
|
||||
"""
|
||||
# Read defensively: this predicate lives on SportsCore so it sits
|
||||
# beside _fetch_odds, but live_games/_rotation_schedule belong to
|
||||
# SportsLive, which is the only caller.
|
||||
with self._games_lock:
|
||||
games = list(getattr(self, "live_games", ()) or ())
|
||||
index = getattr(self, "current_game_index", 0)
|
||||
schedule = list(getattr(self, "_rotation_schedule", ()) or ())
|
||||
if not games:
|
||||
# Cold start: nothing is on screen yet, so let the games seen on
|
||||
# this first pass through rather than render a blank line for a
|
||||
# whole cycle. Bounded -- the next pass has a rotation to narrow by.
|
||||
return True
|
||||
order = schedule or [g.get("id") for g in games]
|
||||
if not order:
|
||||
return True
|
||||
start = index if 0 <= index < len(order) else 0
|
||||
wanted = {
|
||||
order[(start + offset) % len(order)]
|
||||
for offset in range(self._LIVE_ODDS_LOOKAHEAD + 1)
|
||||
}
|
||||
return game.get("id") in wanted
|
||||
|
||||
#: Hour of the Eastern day past which last night's games are assumed over.
|
||||
#:
|
||||
#: The live fetch asks ESPN for a two-day window so a game that started
|
||||
#: yesterday and is still running is not lost. ESPN rejects date *ranges*,
|
||||
#: so that window is split into one request per day -- doubling every live
|
||||
#: poll. Measured 2026-09-19: 1,858 requests per rig spent on yesterday's
|
||||
#: date, which after breakfast holds nothing but final games.
|
||||
#:
|
||||
#: No sport on these boards runs six hours past midnight, and one that
|
||||
#: somehow did is still covered: a game already being tracked keeps its own
|
||||
#: day in the window regardless of the hour.
|
||||
_LOOKBACK_CUTOFF_HOUR: ClassVar[int] = 6
|
||||
|
||||
def _needs_previous_day(self, now: datetime) -> bool:
|
||||
"""Whether the previous Eastern day can still hold a live game."""
|
||||
if now.hour < self._LOOKBACK_CUTOFF_HOUR:
|
||||
return True
|
||||
previous = (now - timedelta(days=1)).strftime("%Y%m%d")
|
||||
for game in (getattr(self, "live_games", None) or []):
|
||||
start: Any = game.get("start_time_utc") if hasattr(game, "get") else None
|
||||
try:
|
||||
if start.astimezone(now.tzinfo).strftime("%Y%m%d") == previous:
|
||||
return True
|
||||
except (AttributeError, ValueError, OSError, OverflowError):
|
||||
continue
|
||||
return False
|
||||
|
||||
def _background_fetches_espn_ranges(self) -> bool:
|
||||
"""Can the core's background service fetch an ESPN date range?
|
||||
|
||||
Cores from before the 2026-09-15 fix send a season range to ESPN as-is,
|
||||
which now answers 400 for every sport. On those cores the managers fetch
|
||||
the season themselves with _fetch_season_directly instead.
|
||||
"""
|
||||
service = getattr(self, "background_service", None)
|
||||
return bool(getattr(service, "handles_espn_date_ranges", False))
|
||||
|
||||
def _fetch_season_directly(
|
||||
self,
|
||||
url: str,
|
||||
datestring: str,
|
||||
cache_key: str,
|
||||
label: str,
|
||||
ttl: Optional[int] = None,
|
||||
) -> Optional[Dict]:
|
||||
"""Fetch a season schedule on this thread, in chunks ESPN accepts, and cache it.
|
||||
|
||||
``label`` names the schedule in log lines, e.g. ``"2026 season"``.
|
||||
"""
|
||||
try:
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session,
|
||||
url,
|
||||
params={"dates": datestring, "limit": ESPN_MAX_LIMIT},
|
||||
headers=self.headers,
|
||||
timeout=30,
|
||||
logger=self.logger,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to fetch {label} schedule: {e}")
|
||||
return None
|
||||
if ttl is None:
|
||||
self.cache_manager.set(cache_key, data)
|
||||
else:
|
||||
self.cache_manager.set(cache_key, data, ttl=ttl)
|
||||
self.logger.info(
|
||||
f"Fetched {label} schedule: {len(data.get('events', []))} events"
|
||||
)
|
||||
return data
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Timezone resolution for the scoreboard plugins.
|
||||
|
||||
Game start times arrive from ESPN in UTC and have to be converted to the
|
||||
user's local zone before they are drawn. This module owns the "which zone?"
|
||||
decision so every part of a scoreboard (its scorebug, its scroll-mode game
|
||||
card and its plugin manager) agrees.
|
||||
|
||||
The scoreboards each carried a copy of this module as ``<sport>_timezone.py``.
|
||||
The copies were identical apart from two per-plugin values, which are
|
||||
keyword-only arguments here: ``plugin_label``, the name the final warning
|
||||
tells the user to open, and ``writeback_fixed_in`` (see below).
|
||||
|
||||
Resolution order, first valid wins:
|
||||
|
||||
1. ``timezone`` in the plugin's own config (explicit per-plugin override)
|
||||
2. The LEDMatrix global timezone via ``plugin_manager.config_manager``
|
||||
3. The LEDMatrix global timezone via ``cache_manager.config_manager``
|
||||
4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``)
|
||||
5. UTC
|
||||
|
||||
Steps 2 and 3 matter because the core does not consistently hang
|
||||
``config_manager`` off both objects -- reading only one of them is what made
|
||||
a scoreboard fall through to UTC while the clock plugin (which checks
|
||||
``plugin_manager`` first) showed the right time on the same device. Step 4 is
|
||||
the backstop for cores that expose no ``config_manager`` at all: a Pi with its
|
||||
system clock set correctly should never end up rendering UTC.
|
||||
|
||||
Two traps this module exists to avoid, both of which render every start time
|
||||
in UTC on a correctly-configured device:
|
||||
|
||||
* **The stale ``"UTC"`` artifact.** Some plugins once wrote
|
||||
``"timezone": "UTC"`` into the *saved* config whenever resolution failed, and
|
||||
that write-back stuck -- thereafter shadowing the real global timezone. Such
|
||||
a plugin passes ``writeback_fixed_in`` (the release that fixed it), and step 1
|
||||
then treats a bare ``"UTC"`` as suspect: it is honored only when nothing
|
||||
downstream disagrees. ``Etc/UTC`` is the unambiguous spelling for "I really
|
||||
do want UTC"; the bug never produced it, so it is always honored. A plugin
|
||||
that never had the bug leaves ``writeback_fixed_in`` as ``None``, and its
|
||||
plugin-level ``"UTC"`` is honored verbatim.
|
||||
* **``get_timezone()``'s own default.** The core's
|
||||
``ConfigManager.get_timezone()`` is ``self.config.get('timezone', 'UTC')``, so
|
||||
it hands back ``"UTC"`` for a config that simply has no ``timezone`` key.
|
||||
Steps 2 and 3 read the raw config dict instead, so an absent key falls
|
||||
through to the system zone rather than latching onto that default.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Iterable, Optional, Tuple
|
||||
|
||||
import pytz
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]:
|
||||
"""Pull the global timezone out of a core ConfigManager, if it has one.
|
||||
|
||||
Reads the raw config dict in preference to ``get_timezone()``. The core's
|
||||
``ConfigManager.get_timezone()`` is ``self.config.get('timezone', 'UTC')`` --
|
||||
it substitutes its own ``"UTC"`` when the key is absent, which is
|
||||
indistinguishable from the user deliberately choosing UTC. Taking that at
|
||||
face value would mask a missing global setting and stop resolution ever
|
||||
reaching the host system zone. So: if the raw config is readable and has no
|
||||
``timezone`` key, report "nothing here" and let the caller fall through.
|
||||
``get_timezone()`` is only consulted for cores that expose no raw config.
|
||||
"""
|
||||
if config_manager is None:
|
||||
return None
|
||||
|
||||
raw_readable = False
|
||||
for loader_name in ("get_config", "load_config"):
|
||||
loader = getattr(config_manager, loader_name, None)
|
||||
if not callable(loader):
|
||||
continue
|
||||
try:
|
||||
main_config = loader()
|
||||
except Exception:
|
||||
log.debug("config_manager.%s() failed", loader_name, exc_info=True)
|
||||
continue
|
||||
if isinstance(main_config, dict):
|
||||
raw_readable = True
|
||||
name: Optional[str] = main_config.get("timezone")
|
||||
if name:
|
||||
return name
|
||||
|
||||
if raw_readable:
|
||||
return None
|
||||
|
||||
getter = getattr(config_manager, "get_timezone", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
name = getter()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
log.debug("config_manager.get_timezone() failed", exc_info=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def system_timezone_name() -> Optional[str]:
|
||||
"""Best-effort IANA name for the host's configured timezone."""
|
||||
name = os.environ.get("TZ")
|
||||
if name:
|
||||
return name
|
||||
|
||||
# Debian / Raspberry Pi OS record the zone name here.
|
||||
try:
|
||||
with open("/etc/timezone", "r", encoding="utf-8") as handle:
|
||||
name = handle.read().strip()
|
||||
if name:
|
||||
return name
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Otherwise /etc/localtime is a symlink into the zoneinfo tree.
|
||||
try:
|
||||
path = os.path.realpath("/etc/localtime")
|
||||
marker = "zoneinfo" + os.sep
|
||||
if marker in path:
|
||||
return path.split(marker, 1)[1]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validated(name: object, source: str, log: logging.Logger) -> Optional[str]:
|
||||
"""Return a usable IANA name, or None if blank/absent/not a real zone."""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
pytz.timezone(name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
log.warning("Ignoring invalid timezone %r from %s", name, source)
|
||||
return None
|
||||
except Exception:
|
||||
# Not an unknown-zone error, so something else went wrong inside pytz.
|
||||
# Log it loudly rather than silently reclassifying it as "invalid" --
|
||||
# but still don't propagate: this runs in the render path, and a
|
||||
# mislabelled zone beats taking the whole display down.
|
||||
log.warning(
|
||||
"Unexpected error validating timezone %r from %s; ignoring it",
|
||||
name, source, exc_info=True,
|
||||
)
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def resolve_timezone_name(
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
plugin_manager: Any = None,
|
||||
cache_manager: Any = None,
|
||||
log: Optional[logging.Logger] = None,
|
||||
*,
|
||||
plugin_label: str,
|
||||
writeback_fixed_in: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return the IANA timezone name to render game times in.
|
||||
|
||||
Never raises and never returns an empty string; falls back to ``"UTC"``
|
||||
only when every source is missing or invalid.
|
||||
|
||||
``plugin_label`` names the plugin in the warning logged when nothing
|
||||
resolves (e.g. ``"hockey scoreboard"``). ``writeback_fixed_in`` is the
|
||||
plugin release that stopped writing ``"UTC"`` back into the saved config,
|
||||
for a plugin that ever did; ``None`` (the default) otherwise.
|
||||
"""
|
||||
log = log or logger
|
||||
|
||||
def downstream():
|
||||
"""Yield (source, name) for everything except the plugin's own config.
|
||||
|
||||
Lazy: ``SportsCore._get_timezone()`` runs this once per game and the
|
||||
answer is almost always already in the plugin config, so evaluating on
|
||||
demand keeps the common case from calling into both config managers and
|
||||
stat-ing the host timezone files every time.
|
||||
"""
|
||||
yield (
|
||||
"plugin_manager.config_manager",
|
||||
_from_config_manager(getattr(plugin_manager, "config_manager", None), log),
|
||||
)
|
||||
yield (
|
||||
"cache_manager.config_manager",
|
||||
_from_config_manager(getattr(cache_manager, "config_manager", None), log),
|
||||
)
|
||||
yield "system timezone", system_timezone_name()
|
||||
|
||||
def first_valid(
|
||||
sources: Iterable[Tuple[str, Any]],
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
for source, name in sources:
|
||||
name = _validated(name, source, log)
|
||||
if name:
|
||||
return source, name
|
||||
return None, None
|
||||
|
||||
plugin_value = _validated((config or {}).get("timezone"), "plugin config", log)
|
||||
|
||||
if writeback_fixed_in is not None and plugin_value and plugin_value.lower() == "utc":
|
||||
# Before writeback_fixed_in this plugin wrote "timezone": "UTC" into
|
||||
# the saved config whenever it failed to resolve a global timezone, and
|
||||
# that write-back persisted. A bare "UTC" is therefore far more likely
|
||||
# to be that artifact than a deliberate choice -- it only ever appeared
|
||||
# on failure. Honor it only when nothing downstream disagrees; a user
|
||||
# who genuinely wants UTC writes the unambiguous "Etc/UTC", which the
|
||||
# bug never produced and which falls through to the normal path below.
|
||||
source, downstream_name = first_valid(downstream())
|
||||
if downstream_name and downstream_name.lower() not in ("utc", "etc/utc"):
|
||||
log.warning(
|
||||
"Ignoring the plugin-level timezone 'UTC': it is almost "
|
||||
"certainly left over from the write-back bug fixed in %s, and "
|
||||
"%s says %s. Using %s. If you really do want UTC here, set "
|
||||
"this plugin's timezone to 'Etc/UTC' instead.",
|
||||
writeback_fixed_in, source, downstream_name, downstream_name,
|
||||
)
|
||||
return downstream_name
|
||||
log.debug("Plugin-level timezone 'UTC' agrees with %s; using UTC", source or "no other source")
|
||||
return "UTC"
|
||||
|
||||
if plugin_value:
|
||||
log.debug("Resolved timezone %s from plugin config", plugin_value)
|
||||
return plugin_value
|
||||
|
||||
source, name = first_valid(downstream())
|
||||
if name:
|
||||
log.debug("Resolved timezone %s from %s", name, source)
|
||||
return name
|
||||
|
||||
log.warning(
|
||||
"Could not determine a timezone from the plugin config, the LEDMatrix "
|
||||
"config or the system; game times will be shown in UTC. Set a timezone "
|
||||
"in the %s's Advanced Settings to override.",
|
||||
plugin_label,
|
||||
)
|
||||
return "UTC"
|
||||
|
||||
|
||||
def resolve_timezone(
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
plugin_manager: Any = None,
|
||||
cache_manager: Any = None,
|
||||
log: Optional[logging.Logger] = None,
|
||||
*,
|
||||
plugin_label: str,
|
||||
writeback_fixed_in: Optional[str] = None,
|
||||
):
|
||||
"""``resolve_timezone_name`` as a ready-to-use tzinfo object."""
|
||||
return pytz.timezone(
|
||||
resolve_timezone_name(
|
||||
config=config,
|
||||
plugin_manager=plugin_manager,
|
||||
cache_manager=cache_manager,
|
||||
log=log,
|
||||
plugin_label=plugin_label,
|
||||
writeback_fixed_in=writeback_fixed_in,
|
||||
)
|
||||
)
|
||||
+76
-14
@@ -32,6 +32,7 @@ from typing import Callable, Optional
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from src.config_manager_atomic import _replace
|
||||
from src.display_geometry import DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_ROWS
|
||||
|
||||
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
|
||||
@@ -53,6 +54,17 @@ HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
||||
PEER_TIMEOUT = 6.0 # leader: no heartbeat → follower gone
|
||||
LEADER_TIMEOUT = 6.0 # follower: no frame → leader gone
|
||||
STATUS_FILE = os.path.join(tempfile.gettempdir(), "led_matrix_sync_status.json")
|
||||
# Serialises writes to STATUS_FILE (several threads report status) against
|
||||
# its removal in stop(), so a write already under way cannot put the file back
|
||||
# after the display process has shut down.
|
||||
_STATUS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _remove_status_file() -> None:
|
||||
try:
|
||||
os.remove(STATUS_FILE)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
class SyncRole(Enum):
|
||||
@@ -83,6 +95,10 @@ class DisplaySyncManager:
|
||||
back to its own plugins when the leader stops sending.
|
||||
"""
|
||||
|
||||
# Set by stop(); status writes after that are dropped. Class-level so
|
||||
# instances built without __init__ (tests) have it too.
|
||||
_status_closed = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
role_str: str,
|
||||
@@ -112,6 +128,9 @@ class DisplaySyncManager:
|
||||
self._peer_ip: Optional[str] = None
|
||||
self._peer_compatible: bool = False
|
||||
self._peer_chain: int = 0
|
||||
# time.monotonic() readings, like _last_leader_frame_time: these only
|
||||
# feed the timeout watchdogs, and a wall-clock step (NTP correcting a
|
||||
# Pi with no RTC) would otherwise fake or mask a timeout.
|
||||
self._last_heartbeat_time: float = 0.0
|
||||
self._leader_width: int = 0 # set by display_controller after init
|
||||
self._oversized_frame_warned: bool = False
|
||||
@@ -138,6 +157,14 @@ class DisplaySyncManager:
|
||||
self._send_sock: Optional[socket.socket] = None
|
||||
|
||||
if self.role == SyncRole.STANDALONE:
|
||||
# Standalone never writes a status file, so one still here is
|
||||
# from an earlier run as leader or follower. The web UI would
|
||||
# keep reporting that run's peer as if it were live.
|
||||
try:
|
||||
with _STATUS_LOCK:
|
||||
_remove_status_file()
|
||||
except OSError as exc:
|
||||
logger.debug("Sync: could not remove stale status file: %s", exc)
|
||||
return
|
||||
|
||||
if self.role == SyncRole.LEADER:
|
||||
@@ -183,7 +210,7 @@ class DisplaySyncManager:
|
||||
self._handle_hello(msg, sender_ip)
|
||||
elif t == "hb":
|
||||
if self._peer_ip == sender_ip:
|
||||
self._last_heartbeat_time = time.time()
|
||||
self._last_heartbeat_time = time.monotonic()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as exc:
|
||||
@@ -206,7 +233,7 @@ class DisplaySyncManager:
|
||||
self._peer_ip = sender_ip
|
||||
self._peer_compatible = compatible
|
||||
self._peer_chain = peer_chain
|
||||
self._last_heartbeat_time = time.time()
|
||||
self._last_heartbeat_time = time.monotonic()
|
||||
|
||||
prev_state = self._leader_state
|
||||
if compatible:
|
||||
@@ -250,7 +277,7 @@ class DisplaySyncManager:
|
||||
while self._running:
|
||||
time.sleep(1.0)
|
||||
if self._leader_state == LeaderState.CONNECTED:
|
||||
if time.time() - self._last_heartbeat_time > PEER_TIMEOUT:
|
||||
if time.monotonic() - self._last_heartbeat_time > PEER_TIMEOUT:
|
||||
self.logger.info(
|
||||
"Sync: follower heartbeat timeout — peer disconnected"
|
||||
)
|
||||
@@ -478,7 +505,7 @@ class DisplaySyncManager:
|
||||
"""Note that the leader at ``sender_ip`` just sent something, and
|
||||
switch from standalone to follower mode if not already following.
|
||||
Returns True if this call made the switch."""
|
||||
self._last_leader_frame_time = time.time()
|
||||
self._last_leader_frame_time = time.monotonic()
|
||||
self._leader_ip = sender_ip
|
||||
if self._follower_state != FollowerState.STANDALONE:
|
||||
return False
|
||||
@@ -598,11 +625,13 @@ class DisplaySyncManager:
|
||||
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
||||
dest = ("<broadcast>", self.port)
|
||||
|
||||
last_hello = 0.0
|
||||
last_hb = 0.0
|
||||
# -inf, not 0.0: monotonic time starts near boot, so "now - 0.0" can
|
||||
# be under the interval and would delay the first announcement.
|
||||
last_hello = float("-inf")
|
||||
last_hb = float("-inf")
|
||||
|
||||
while self._running:
|
||||
now = time.time()
|
||||
now = time.monotonic()
|
||||
if now - last_hello >= HELLO_INTERVAL:
|
||||
try:
|
||||
self._send_sock.sendto(hello, dest)
|
||||
@@ -621,7 +650,7 @@ class DisplaySyncManager:
|
||||
while self._running:
|
||||
time.sleep(1.0)
|
||||
if self._follower_state == FollowerState.FOLLOWER:
|
||||
if time.time() - self._last_leader_frame_time > LEADER_TIMEOUT:
|
||||
if time.monotonic() - self._last_leader_frame_time > LEADER_TIMEOUT:
|
||||
self.logger.info(
|
||||
"Sync: leader frame timeout — returning to standalone mode"
|
||||
)
|
||||
@@ -647,7 +676,10 @@ class DisplaySyncManager:
|
||||
|
||||
def set_on_new_cycle(self, callback: Callable[[], None]) -> None:
|
||||
"""Follower: register a callback fired when the leader starts a new scroll cycle.
|
||||
Used to trigger a local start_new_cycle() so both Pis rebuild from same fresh data.
|
||||
|
||||
Nothing in core registers one: display_controller follows the leader
|
||||
through set_on_scroll_image() and the scroll position instead of
|
||||
rebuilding locally. The hook stays for callers that want the signal.
|
||||
"""
|
||||
self._on_new_cycle = callback
|
||||
|
||||
@@ -692,18 +724,40 @@ class DisplaySyncManager:
|
||||
|
||||
def write_status_file(self) -> None:
|
||||
"""Write current sync status to STATUS_FILE for the web UI to read."""
|
||||
tmp = None
|
||||
try:
|
||||
status = self.get_status()
|
||||
status["ts"] = time.time()
|
||||
tmp = STATUS_FILE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(status, f)
|
||||
os.replace(tmp, STATUS_FILE)
|
||||
with _STATUS_LOCK:
|
||||
if self._status_closed:
|
||||
return
|
||||
# A unique temp name per write, like frame_timing's stats
|
||||
# file: the receive loop, watchdog and hello handler all
|
||||
# write, and with one fixed ".tmp" name one thread's
|
||||
# os.replace() could move the other's half-written file.
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=os.path.dirname(STATUS_FILE) or ".",
|
||||
prefix=".led_matrix_sync_status.", suffix=".tmp")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(status, f)
|
||||
# mkstemp makes it owner-only; the web UI may run as a
|
||||
# different user from the display service.
|
||||
os.chmod(tmp, 0o644)
|
||||
# _replace: on Windows a rename can briefly fail with
|
||||
# "Access is denied" while a scanner holds the target open.
|
||||
_replace(tmp, STATUS_FILE)
|
||||
tmp = None
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: status file write error: %s", exc)
|
||||
finally:
|
||||
if tmp is not None:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Shut down threads and close sockets."""
|
||||
"""Shut down threads, close sockets and withdraw the status file."""
|
||||
self._running = False
|
||||
for sock in (self._recv_sock, self._send_sock, self._img_server_sock):
|
||||
if sock:
|
||||
@@ -711,3 +765,11 @@ class DisplaySyncManager:
|
||||
sock.close()
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: error closing socket: %s", exc)
|
||||
# The web UI reads this file as live status. Left behind, it went on
|
||||
# reporting a connected peer after the display service had stopped.
|
||||
try:
|
||||
with _STATUS_LOCK:
|
||||
self._status_closed = True
|
||||
_remove_status_file()
|
||||
except OSError as exc:
|
||||
self.logger.debug("Sync: could not remove status file: %s", exc)
|
||||
|
||||
@@ -206,7 +206,8 @@ class ConfigService:
|
||||
# Sleep with periodic checks for stop signal
|
||||
for _ in range(int(self._watch_interval)):
|
||||
if self._stop_watching:
|
||||
break
|
||||
# Set from another thread; mypy keeps the while's narrowing.
|
||||
break # type: ignore[unreachable]
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ def deprecated(removal: str, alternative: Optional[str] = None) -> Callable[[F],
|
||||
warnings.warn(message, DeprecationWarning, stacklevel=2)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper.__deprecated__ = message
|
||||
wrapper.__deprecated__ = message # type: ignore[attr-defined] # functools' _Wrapped doesn't declare it
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorate
|
||||
|
||||
@@ -24,7 +24,7 @@ next render. A location saved on the app itself always wins.
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, cast
|
||||
|
||||
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
GEOCODE_TIMEOUT = 10
|
||||
@@ -112,6 +112,7 @@ def parse_location(value: Any) -> Optional[Dict[str, Any]]:
|
||||
``{"timezone": ...}`` when only the timezone box is filled) all mean the
|
||||
user has not given the app a place.
|
||||
"""
|
||||
loc: Any
|
||||
if isinstance(value, dict):
|
||||
loc = value
|
||||
elif isinstance(value, str) and value.strip():
|
||||
@@ -162,10 +163,10 @@ def geocode(city: str, state: Any = None, country: Any = None,
|
||||
"""Look the city up on Open-Meteo. Raises on a network/HTTP failure."""
|
||||
import requests
|
||||
|
||||
response = requests.get(GEOCODE_URL, params={
|
||||
response = requests.get(GEOCODE_URL, params=cast(Dict[str, Any], {
|
||||
"name": city, "count": GEOCODE_RESULT_COUNT,
|
||||
"language": "en", "format": "json",
|
||||
}, timeout=timeout)
|
||||
}), timeout=timeout)
|
||||
response.raise_for_status()
|
||||
best = pick_geocode_result(response.json().get("results") or [], state, country)
|
||||
if best is None:
|
||||
|
||||
+330
-44
@@ -27,8 +27,9 @@ import signal
|
||||
import json
|
||||
import threading
|
||||
import types
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
from typing import Dict, Any, List, Optional, Callable, Set, Tuple
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
||||
import pytz
|
||||
@@ -101,6 +102,16 @@ class DisplayController:
|
||||
it and start the run loop.
|
||||
"""
|
||||
|
||||
#: How long the run loop pauses per pass once a whole rotation has had
|
||||
#: nothing to show. See _note_empty_pass.
|
||||
EMPTY_ROTATION_PAUSE = 1.0
|
||||
|
||||
#: Consecutive passes whose mode had nothing to show, and the rotation
|
||||
#: (on-demand or not, and its modes) they were counted in. Class-level so
|
||||
#: controllers built without __init__ (tests) have them too.
|
||||
_empty_pass_streak = 0
|
||||
_empty_pass_rotation: Optional[Tuple[bool, Tuple[str, ...]]] = None
|
||||
|
||||
def __init__(self):
|
||||
start_time = time.time()
|
||||
logger.info("Starting DisplayController initialization")
|
||||
@@ -186,6 +197,12 @@ class DisplayController:
|
||||
# scroll image arrives.
|
||||
self._follower_pending_new_image = False
|
||||
self._follower_last_frame = None
|
||||
# (image, array) from the leader, handed from the sync TCP thread to
|
||||
# the render thread, which adopts it at the start of a follower frame
|
||||
# (_adopt_follower_scroll_image). One append / one popleft, each
|
||||
# atomic, so the render thread never draws from a half-swapped
|
||||
# cached_image / cached_array / total_scroll_width.
|
||||
self._follower_incoming_image: deque = deque(maxlen=1)
|
||||
self._follower_deadline: Optional[float] = None
|
||||
# Leader: time.time() of the last follower frame sent.
|
||||
self._last_follower_send = 0.0
|
||||
@@ -249,6 +266,10 @@ class DisplayController:
|
||||
self.on_demand_last_error: Optional[str] = None
|
||||
self.on_demand_last_event: Optional[str] = None
|
||||
self.on_demand_schedule_override = False
|
||||
# Plugins that are disabled in config and loaded only because an
|
||||
# on-demand request named them. The main loop unloads each one once
|
||||
# on-demand has moved off it (_release_on_demand_plugins).
|
||||
self._on_demand_loaded_plugins: Set[str] = set()
|
||||
self.rotation_resume_index: Optional[int] = None
|
||||
# Saved rotation position when a live-priority plugin preempts the
|
||||
# rotation, so it resumes where it left off (not after the live plugin)
|
||||
@@ -284,7 +305,10 @@ class DisplayController:
|
||||
if os.path.isabs(plugins_dir_name):
|
||||
plugins_dir = plugins_dir_name
|
||||
else:
|
||||
# If relative, resolve relative to the project root (LEDMatrix directory)
|
||||
# If relative, resolve against the current working directory.
|
||||
# That is the project root only because ledmatrix.service
|
||||
# sets WorkingDirectory to it; run from anywhere else, a
|
||||
# relative path resolves against wherever that is.
|
||||
project_root = os.getcwd()
|
||||
plugins_dir = os.path.join(project_root, plugins_dir_name)
|
||||
|
||||
@@ -315,13 +339,19 @@ class DisplayController:
|
||||
except Exception as e:
|
||||
logger.warning("Could not enable plugin health/resource monitoring: %s", e)
|
||||
|
||||
# Discover plugins. Before the plugin checks so they can reuse the
|
||||
# list: each discover_plugins() call rescans the plugins directory
|
||||
# and logs every plugin again.
|
||||
discovered_plugins = self.plugin_manager.discover_plugins()
|
||||
logger.info("Discovered %d plugin(s)", len(discovered_plugins))
|
||||
|
||||
# Only the plugin checks: validate_all() above has run the rest,
|
||||
# and running it again logged every config warning twice.
|
||||
try:
|
||||
from src.startup_validator import StartupValidator
|
||||
validator = StartupValidator(self.config_manager, self.plugin_manager,
|
||||
cache_manager=self.cache_manager)
|
||||
validator._validate_plugins()
|
||||
validator._validate_plugins(discovered_plugins=discovered_plugins)
|
||||
for warning in validator.warnings:
|
||||
logger.warning("Plugin validation warning: %s", warning)
|
||||
if validator.errors:
|
||||
@@ -330,10 +360,6 @@ class DisplayController:
|
||||
except Exception as e:
|
||||
logger.warning("Plugin validation could not be completed: %s", e)
|
||||
|
||||
# Discover plugins
|
||||
discovered_plugins = self.plugin_manager.discover_plugins()
|
||||
logger.info("Discovered %d plugin(s)", len(discovered_plugins))
|
||||
|
||||
# Check for on-demand plugin filter from cache
|
||||
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
|
||||
enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config)
|
||||
@@ -347,7 +373,11 @@ class DisplayController:
|
||||
"""Load a single plugin and return result."""
|
||||
plugin_load_start = time.time()
|
||||
try:
|
||||
if self.plugin_manager.load_plugin(plugin_id):
|
||||
if plugin_id in self._on_demand_loaded_plugins:
|
||||
loaded = self.plugin_manager.load_plugin(plugin_id, force_enabled=True)
|
||||
else:
|
||||
loaded = self.plugin_manager.load_plugin(plugin_id)
|
||||
if loaded:
|
||||
plugin_load_time = time.time() - plugin_load_start
|
||||
return {
|
||||
'success': True,
|
||||
@@ -544,21 +574,15 @@ class DisplayController:
|
||||
# temporarily replace the leader's correct one.
|
||||
|
||||
# When the leader sends its scroll image (TCP), update our
|
||||
# cached_array so both Pis have pixel-identical images.
|
||||
# cached_array so both Pis have pixel-identical images. This runs
|
||||
# on the sync TCP thread, so it only converts and queues the
|
||||
# image; the render thread swaps it in between frames.
|
||||
import numpy as _np
|
||||
def _on_leader_scroll_image(image):
|
||||
vc = self.vegas_coordinator
|
||||
if vc and vc.render_pipeline:
|
||||
rp = vc.render_pipeline
|
||||
arr = _np.asarray(image.convert("RGB"), dtype=_np.uint8)
|
||||
rp.scroll_helper.cached_image = image
|
||||
rp.scroll_helper.cached_array = arr
|
||||
rp.scroll_helper.total_scroll_width = image.width
|
||||
self._follower_pending_new_image = False
|
||||
logger.info(
|
||||
"Sync: follower adopted leader scroll image %dx%d",
|
||||
image.width, image.height,
|
||||
)
|
||||
self._follower_incoming_image.append((image, arr))
|
||||
self.sync_manager.set_on_scroll_image(_on_leader_scroll_image)
|
||||
|
||||
if self.sync_manager.role == SyncRole.LEADER:
|
||||
@@ -581,6 +605,29 @@ class DisplayController:
|
||||
logger.error("Failed to initialize Vegas mode: %s", e, exc_info=True)
|
||||
self.vegas_coordinator = None
|
||||
|
||||
def _adopt_follower_scroll_image(self, rp) -> None:
|
||||
"""Swap in the leader's latest scroll image, on the render thread.
|
||||
|
||||
The sync TCP thread used to set cached_image, cached_array and
|
||||
total_scroll_width one after another while this thread read them, so
|
||||
a frame could slice the new array with the old width. It now queues
|
||||
the image and this applies it between frames.
|
||||
"""
|
||||
try:
|
||||
image, arr = self._follower_incoming_image.popleft()
|
||||
except IndexError:
|
||||
return
|
||||
if rp is None:
|
||||
return
|
||||
rp.scroll_helper.cached_image = image
|
||||
rp.scroll_helper.cached_array = arr
|
||||
rp.scroll_helper.total_scroll_width = image.width
|
||||
self._follower_pending_new_image = False
|
||||
logger.info(
|
||||
"Sync: follower adopted leader scroll image %dx%d",
|
||||
image.width, image.height,
|
||||
)
|
||||
|
||||
def _is_vegas_mode_active(self) -> bool:
|
||||
"""Check if Vegas mode should be running."""
|
||||
self._apply_pending_vegas_init()
|
||||
@@ -801,7 +848,15 @@ class DisplayController:
|
||||
if use_per_day:
|
||||
day_config = days_config[current_day]
|
||||
if not day_config.get('enabled', True):
|
||||
# Past the minute gate, so the cache must say the same thing:
|
||||
# returning here without it left the previous minute's dim
|
||||
# value to be served for the rest of this one, and the
|
||||
# brightness flipped between dim and normal every minute.
|
||||
if self._was_dimmed:
|
||||
logger.info(f"Dim schedule deactivated: brightness restored to {normal_brightness}%")
|
||||
self.is_dimmed = False
|
||||
self._was_dimmed = False
|
||||
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||
return normal_brightness
|
||||
start_time_str = day_config.get('start_time', '20:00')
|
||||
end_time_str = day_config.get('end_time', '07:00')
|
||||
@@ -1075,6 +1130,38 @@ class DisplayController:
|
||||
or self.on_demand_active != on_demand):
|
||||
break
|
||||
|
||||
def _note_empty_pass(self) -> None:
|
||||
"""Record a pass whose mode had nothing to show; pause once a whole
|
||||
rotation has been empty.
|
||||
|
||||
A mode with no content rotates to the next one at once, with no dwell.
|
||||
When every mode is empty -- say, only a sports plugin enabled in its
|
||||
off-season -- the loop went round with no sleep at all: 100% of a core,
|
||||
a plugin-executor thread per pass and several log lines each time,
|
||||
indefinitely. After one full rotation of empty passes, each further
|
||||
one pauses EMPTY_ROTATION_PAUSE seconds. Live content is still picked
|
||||
up within that second (the live-priority check runs at the top of
|
||||
every pass), the pause services plugin updates, and it returns early
|
||||
on an on-demand request or a schedule change. The streak resets as
|
||||
soon as any mode shows something, and when the rotation itself
|
||||
changes (on-demand starting or stopping, a plugin enabled or
|
||||
disabled): a streak counted in one rotation says nothing about the
|
||||
modes of another, which haven't been tried yet.
|
||||
"""
|
||||
modes = self.on_demand_modes if self.on_demand_active else self.available_modes
|
||||
rotation_key = (bool(self.on_demand_active), tuple(modes))
|
||||
if rotation_key != self._empty_pass_rotation:
|
||||
self._empty_pass_rotation = rotation_key
|
||||
self._empty_pass_streak = 0
|
||||
self._empty_pass_streak += 1
|
||||
rotation = max(1, len(modes))
|
||||
if self._empty_pass_streak < rotation:
|
||||
return
|
||||
if self._empty_pass_streak == rotation:
|
||||
logger.info("No mode has anything to show; checking one mode every %.0fs "
|
||||
"until one does", self.EMPTY_ROTATION_PAUSE)
|
||||
self._sleep_with_plugin_updates(self.EMPTY_ROTATION_PAUSE)
|
||||
|
||||
def _get_display_duration(self, mode_key):
|
||||
"""Seconds to show a mode: the Rotation & Durations page's value for it
|
||||
(display.display_durations), else the plugin's own duration.
|
||||
@@ -1396,8 +1483,13 @@ class DisplayController:
|
||||
On-demand still resumes on its saved mode; this only widens what gets
|
||||
loaded, so normal rotation has somewhere to return to when it ends.
|
||||
A plugin that is disabled in config but named by the on-demand request
|
||||
is still enabled and added, since otherwise the mode being resumed
|
||||
would have nothing behind it.
|
||||
is still loaded, since otherwise the mode being resumed would have
|
||||
nothing behind it. It is tracked as loaded for on-demand only, the
|
||||
same as one loaded live by _activate_on_demand, so it is unloaded
|
||||
when the session ends instead of staying loaded until the next
|
||||
restart. Its config section is not touched: setting ``enabled`` in
|
||||
self.config wrote into the dict config_manager caches and returns to
|
||||
every later load_config() in this process.
|
||||
"""
|
||||
enabled_plugins = [p for p in discovered_plugins
|
||||
if self.config.get(p, {}).get('enabled', False)]
|
||||
@@ -1412,11 +1504,10 @@ class DisplayController:
|
||||
logger.warning("Falling back to normal mode (all enabled plugins)")
|
||||
return enabled_plugins
|
||||
|
||||
if not self.config.get(on_demand_plugin_id, {}).get('enabled', False):
|
||||
logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id)
|
||||
self.config.setdefault(on_demand_plugin_id, {})['enabled'] = True
|
||||
if on_demand_plugin_id not in enabled_plugins:
|
||||
enabled_plugins.append(on_demand_plugin_id)
|
||||
if on_demand_plugin_id not in enabled_plugins:
|
||||
logger.info("Loading disabled plugin '%s' for on-demand mode only", on_demand_plugin_id)
|
||||
self._on_demand_loaded_plugins.add(on_demand_plugin_id)
|
||||
enabled_plugins.append(on_demand_plugin_id)
|
||||
|
||||
# Restore on-demand state from the cached request so it resumes.
|
||||
self.on_demand_active = True
|
||||
@@ -1512,6 +1603,11 @@ class DisplayController:
|
||||
logger.debug("Stop request %s received but on-demand is not active", request_id)
|
||||
# Still update request_id to acknowledge the request
|
||||
self.on_demand_request_id = request_id
|
||||
if self.on_demand_status == 'error':
|
||||
# A failed request left status 'error' published, and
|
||||
# without this the status route kept reporting it until
|
||||
# the state aged out (120s) or another request came in.
|
||||
self._clear_on_demand(reason='requested-stop')
|
||||
# Stop requests are deliberately exempt from the request_id/
|
||||
# processed_id guards above, so that a second click stops a mode
|
||||
# that a race left running. Consuming the mailbox is therefore the
|
||||
@@ -1612,7 +1708,10 @@ class DisplayController:
|
||||
if plugin_instance.has_live_content():
|
||||
live_with_content.append(live_mode)
|
||||
except Exception:
|
||||
pass
|
||||
# Treated as no live content; logged so a plugin whose
|
||||
# check always raises is findable.
|
||||
logger.debug("has_live_content() failed for %s", live_mode,
|
||||
exc_info=True)
|
||||
|
||||
# Build mode list: live modes with content first, then other modes, then live modes without content
|
||||
if live_with_content:
|
||||
@@ -1675,10 +1774,136 @@ class DisplayController:
|
||||
plugin_id, ordered_modes, self.on_demand_mode_index,
|
||||
ordered_modes[self.on_demand_mode_index] if ordered_modes else 'N/A')
|
||||
|
||||
def _load_plugin_for_on_demand(self, plugin_id: str) -> bool:
|
||||
"""Load an installed plugin that isn't running so on-demand can show it.
|
||||
|
||||
This process only loads the plugins enabled in config, so a request
|
||||
for a disabled one -- the config page's "Preview on display" button
|
||||
offers it on every plugin -- failed with "invalid-mode" while the UI
|
||||
said the plugin would be enabled for the session. Nothing did that
|
||||
short of a restart, and restarts no longer happen on a request.
|
||||
|
||||
Loads through the same path as a live enable (load_plugin, then
|
||||
_register_loaded_plugin), with force_enabled so the instance runs
|
||||
enabled while config.json keeps saying disabled. The plugin is
|
||||
recorded in _on_demand_loaded_plugins, and the main loop unloads it
|
||||
once on-demand moves off it (_release_on_demand_plugins).
|
||||
|
||||
Returns False after publishing an error when the load fails. A
|
||||
plugin that isn't installed returns True without loading anything:
|
||||
the mode checks that follow report it as they always have.
|
||||
"""
|
||||
if self.plugin_manager is None:
|
||||
return True
|
||||
try:
|
||||
known = self.plugin_manager.discovered_plugin_ids()
|
||||
except AttributeError:
|
||||
known = set(getattr(self.plugin_manager, 'plugin_manifests', ()) or ())
|
||||
if plugin_id not in known:
|
||||
# Installed after this process scanned: the web process checked
|
||||
# its own, fresher list before posting the request.
|
||||
try:
|
||||
known = set(self.plugin_manager.discover_plugins())
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("On-demand: plugin discovery failed")
|
||||
known = set()
|
||||
if plugin_id not in known:
|
||||
return True
|
||||
|
||||
logger.info("On-demand: loading disabled plugin '%s' for this session only", plugin_id)
|
||||
self._on_demand_loaded_plugins.add(plugin_id)
|
||||
try:
|
||||
loaded = self.plugin_manager.load_plugin(plugin_id, force_enabled=True)
|
||||
if loaded:
|
||||
modes = self._register_loaded_plugin(plugin_id)
|
||||
logger.info("On-demand: loaded plugin '%s' (modes: %s)", plugin_id, modes)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("On-demand: error loading plugin '%s'", plugin_id)
|
||||
loaded = False
|
||||
if not loaded:
|
||||
# Stays in _on_demand_loaded_plugins so the main loop removes
|
||||
# whatever part of it did get registered.
|
||||
logger.error("On-demand: could not load plugin '%s'", plugin_id)
|
||||
self._set_on_demand_error("load-failed")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _release_on_demand_plugins(self) -> None:
|
||||
"""Unload plugins loaded only for on-demand that it has moved off.
|
||||
|
||||
Runs from the main loop, right after its own on-demand poll, not
|
||||
where on-demand ends: a stop, an expiry or the next request is often
|
||||
read from inside a render loop or a dwell sleep, where the plugin
|
||||
being released may still be on the stack mid-display(). Unloading
|
||||
goes through _unregister_plugin, as a live disable does, and nothing
|
||||
is written to config.json.
|
||||
|
||||
A plugin the user enabled in the meantime stays loaded and takes its
|
||||
place in the rotation, which is what the reconcile that the enable
|
||||
queued would have done.
|
||||
"""
|
||||
if self.plugin_manager is None: # plugin system failed after startup restore
|
||||
self._on_demand_loaded_plugins.clear()
|
||||
return
|
||||
keep = self.on_demand_plugin_id if self.on_demand_active else None
|
||||
releasable = [p for p in self._on_demand_loaded_plugins if p != keep]
|
||||
if not releasable:
|
||||
return
|
||||
try:
|
||||
config = self.config_service.get_config()
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.warning("On-demand release: falling back to cached config: %s", e)
|
||||
config = self.config
|
||||
previous_mode = self.current_display_mode
|
||||
for plugin_id in releasable:
|
||||
self._on_demand_loaded_plugins.discard(plugin_id)
|
||||
section = config.get(plugin_id)
|
||||
if isinstance(section, dict) and section.get('enabled', False):
|
||||
logger.info("On-demand: keeping plugin '%s' loaded; it was enabled "
|
||||
"while on-demand showed it", plugin_id)
|
||||
continue
|
||||
if (plugin_id in self.plugin_display_modes
|
||||
or self.plugin_manager.get_plugin(plugin_id) is not None):
|
||||
logger.info("On-demand: unloading plugin '%s'; it is disabled in config",
|
||||
plugin_id)
|
||||
self._unregister_plugin(plugin_id)
|
||||
if not self.on_demand_active:
|
||||
# Only outside a session: rotation_resume_index points into
|
||||
# available_modes until the session ends.
|
||||
self._apply_plugin_rotation_order()
|
||||
self._resync_mode_index_after_change(previous_mode)
|
||||
if self.current_display_mode != previous_mode:
|
||||
self.force_change = True
|
||||
|
||||
def _rotation_index_outside_on_demand(self, start: int) -> Optional[int]:
|
||||
"""First index from `start` (wrapping) whose mode is not owned by a
|
||||
plugin loaded only for on-demand, or None if every mode is.
|
||||
|
||||
Ending a session must not resume the rotation onto the plugin that
|
||||
is about to be unloaded. A live load appends that plugin's modes
|
||||
after the saved resume index, but a session restored after a
|
||||
restart has no saved index and its plugin was ordered in with the
|
||||
rest -- the rotation resumed onto it, and a stop read during its own
|
||||
screen changed nothing on the panel until that screen ended.
|
||||
"""
|
||||
if not self._on_demand_loaded_plugins:
|
||||
return start
|
||||
on_demand_only = {mode for plugin_id in self._on_demand_loaded_plugins
|
||||
for mode in self.plugin_display_modes.get(plugin_id, [])}
|
||||
count = len(self.available_modes)
|
||||
for step in range(count):
|
||||
index = (start + step) % count
|
||||
if self.available_modes[index] not in on_demand_only:
|
||||
return index
|
||||
return None
|
||||
|
||||
def _activate_on_demand(self, request: Dict[str, Any]) -> None:
|
||||
"""Activate on-demand mode for a specific plugin display."""
|
||||
plugin_id = request.get('plugin_id')
|
||||
mode = request.get('mode')
|
||||
if (plugin_id and plugin_id not in self.plugin_display_modes
|
||||
and not self._load_plugin_for_on_demand(plugin_id)):
|
||||
return
|
||||
resolved_mode = self._resolve_mode_for_plugin(plugin_id, mode)
|
||||
|
||||
if not resolved_mode:
|
||||
@@ -1710,10 +1935,16 @@ class DisplayController:
|
||||
pinned = bool(request.get('pinned', False))
|
||||
now = time.time()
|
||||
|
||||
if self.available_modes:
|
||||
self.rotation_resume_index = self.current_mode_index
|
||||
else:
|
||||
self.rotation_resume_index = None
|
||||
# Only a request that starts a session records where rotation was.
|
||||
# A request made while on-demand is already showing would otherwise
|
||||
# save the previous request's mode (current_mode_index points at it
|
||||
# by now), and clearing would resume there instead of where the
|
||||
# normal rotation was interrupted.
|
||||
if not self.on_demand_active:
|
||||
if self.available_modes:
|
||||
self.rotation_resume_index = self.current_mode_index
|
||||
else:
|
||||
self.rotation_resume_index = None
|
||||
|
||||
if resolved_mode in self.available_modes:
|
||||
self.current_mode_index = self.available_modes.index(resolved_mode)
|
||||
@@ -1778,6 +2009,15 @@ class DisplayController:
|
||||
self.on_demand_last_event = 'stop-request-ignored' # Already idle
|
||||
self._publish_on_demand_state()
|
||||
return
|
||||
if not self.on_demand_active and self.on_demand_status == 'error':
|
||||
# _set_on_demand_error already ended any session and dropped
|
||||
# rotation_resume_index; the full clear below would only move
|
||||
# the rotation and force a redraw. Just drop the error.
|
||||
self.on_demand_status = 'idle'
|
||||
self.on_demand_last_error = None
|
||||
self.on_demand_last_event = reason or 'cleared'
|
||||
self._publish_on_demand_state()
|
||||
return
|
||||
|
||||
self._reset_on_demand_fields()
|
||||
self.on_demand_status = 'idle'
|
||||
@@ -1787,17 +2027,27 @@ class DisplayController:
|
||||
# Clear on-demand configuration from cache
|
||||
self.cache_manager.clear_cache('display_on_demand_config')
|
||||
|
||||
if self.rotation_resume_index is not None and self.available_modes:
|
||||
self.current_mode_index = self.rotation_resume_index % len(self.available_modes)
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||
logger.info("Resuming rotation from saved index %d: mode '%s'",
|
||||
self.rotation_resume_index, self.current_display_mode)
|
||||
elif self.available_modes:
|
||||
# Default to first mode if no resume index
|
||||
self.current_mode_index = self.current_mode_index % len(self.available_modes)
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||
logger.info("Resuming rotation to mode '%s' (index %d)",
|
||||
self.current_display_mode, self.current_mode_index)
|
||||
if self.available_modes:
|
||||
saved = self.rotation_resume_index
|
||||
# Default to the current index if no resume index
|
||||
start = saved if saved is not None else self.current_mode_index
|
||||
index = self._rotation_index_outside_on_demand(start % len(self.available_modes))
|
||||
if index is None:
|
||||
# Every mode belongs to a plugin loaded only for on-demand,
|
||||
# which the main loop is about to unload; it then idles.
|
||||
self.current_mode_index = 0
|
||||
self.current_display_mode = None
|
||||
logger.info("No enabled mode to resume rotation to")
|
||||
elif saved is not None:
|
||||
self.current_mode_index = index
|
||||
self.current_display_mode = self.available_modes[index]
|
||||
logger.info("Resuming rotation from saved index %d: mode '%s'",
|
||||
saved, self.current_display_mode)
|
||||
else:
|
||||
self.current_mode_index = index
|
||||
self.current_display_mode = self.available_modes[index]
|
||||
logger.info("Resuming rotation to mode '%s' (index %d)",
|
||||
self.current_display_mode, self.current_mode_index)
|
||||
else:
|
||||
logger.warning("No available modes to resume rotation to")
|
||||
|
||||
@@ -1843,7 +2093,9 @@ class DisplayController:
|
||||
if bg_service and hasattr(bg_service, 'log_memory_stats'):
|
||||
bg_service.log_memory_stats()
|
||||
except Exception:
|
||||
pass # Background service may not be initialized
|
||||
# Background service may not be initialized
|
||||
logger.debug("Background service memory stats unavailable",
|
||||
exc_info=True)
|
||||
|
||||
# Log deferred updates stats
|
||||
if hasattr(self.display_manager, '_scrolling_state'):
|
||||
@@ -1992,6 +2244,14 @@ class DisplayController:
|
||||
# Handle on-demand commands before rendering
|
||||
self._poll_on_demand_requests()
|
||||
self._check_on_demand_expiration()
|
||||
# Unload plugins loaded only to show them on-demand once it
|
||||
# has moved off them. Here, where no display() is on the
|
||||
# stack; one ended from inside a screen is caught here on
|
||||
# the next pass.
|
||||
if self._on_demand_loaded_plugins:
|
||||
self._release_on_demand_plugins()
|
||||
if not self.available_modes:
|
||||
continue # it was all there was; idle as above
|
||||
self._tick_plugin_updates()
|
||||
|
||||
# Clean up expired WiFi status messages
|
||||
@@ -2045,6 +2305,7 @@ class DisplayController:
|
||||
vc = self.vegas_coordinator
|
||||
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
||||
width = self.display_manager.width
|
||||
self._adopt_follower_scroll_image(rp)
|
||||
|
||||
local_x = self._follower_local_x
|
||||
if local_x is None:
|
||||
@@ -2328,6 +2589,16 @@ class DisplayController:
|
||||
|
||||
# If display() returned False, skip to next mode immediately
|
||||
if not display_result:
|
||||
was_on_demand = self.on_demand_active
|
||||
self._note_empty_pass()
|
||||
# The pause returns early when an on-demand request, its
|
||||
# end, or the schedule decides what comes next. Rotating
|
||||
# past this empty mode now would skip that: an on-demand
|
||||
# start would advance past the mode just requested.
|
||||
if (self.current_display_mode != active_mode
|
||||
or self.on_demand_active != was_on_demand
|
||||
or not self.is_display_active):
|
||||
continue
|
||||
if self.on_demand_active:
|
||||
logger.info("No content for on-demand mode %s, skipping to next mode", active_mode)
|
||||
if not self.on_demand_modes:
|
||||
@@ -2376,6 +2647,7 @@ class DisplayController:
|
||||
# If no exception (just no content), fall through to normal rotation logic
|
||||
# This allows trying other modes (recent, upcoming) from the same plugin
|
||||
else:
|
||||
self._empty_pass_streak = 0
|
||||
# Get base duration for current mode
|
||||
base_duration = self._get_display_duration(active_mode)
|
||||
dynamic_enabled = self._plugin_supports_dynamic(manager_to_display)
|
||||
@@ -2804,7 +3076,8 @@ class DisplayController:
|
||||
try:
|
||||
self.wifi_status_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Could not remove WiFi status file %s",
|
||||
self.wifi_status_file, exc_info=True)
|
||||
return None
|
||||
|
||||
# Validate required fields
|
||||
@@ -2837,7 +3110,8 @@ class DisplayController:
|
||||
try:
|
||||
self.wifi_status_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Could not remove WiFi status file %s",
|
||||
self.wifi_status_file, exc_info=True)
|
||||
return None
|
||||
|
||||
# Message is valid and not expired — cache for the throttle window
|
||||
@@ -2995,6 +3269,11 @@ class DisplayController:
|
||||
prepared = prepare(_pid, new_config) if callable(prepare) else None
|
||||
if isinstance(prepared, dict):
|
||||
new_config = prepared
|
||||
if _pid in self._on_demand_loaded_plugins:
|
||||
# Saved while on-demand shows it: config.json still
|
||||
# says disabled, and on_config_change would switch
|
||||
# the instance off mid-session.
|
||||
new_config = {**new_config, 'enabled': True}
|
||||
_plugin.on_config_change(new_config)
|
||||
logger.debug("Plugin %s notified of config change", _pid)
|
||||
except Exception as e:
|
||||
@@ -3310,6 +3589,13 @@ class DisplayController:
|
||||
self.vegas_coordinator.cleanup()
|
||||
except Exception as e:
|
||||
logger.warning("Error cleaning up Vegas mode: %s", e)
|
||||
# After Vegas, which sends through it. Stopping also withdraws the
|
||||
# sync status file, which the web UI otherwise kept showing as live.
|
||||
if getattr(self, 'sync_manager', None) is not None:
|
||||
try:
|
||||
self.sync_manager.stop()
|
||||
except Exception as e:
|
||||
logger.warning("Error stopping display sync: %s", e)
|
||||
# Shutdown config service if it exists
|
||||
if hasattr(self, 'config_service'):
|
||||
try:
|
||||
|
||||
@@ -132,6 +132,7 @@ def apply_pixel_mappers(width: int, height: int, mapper_config: str,
|
||||
``multiplexing`` isn't modelled: its mappers give back the configured
|
||||
size for the panel sizes they are made for.
|
||||
"""
|
||||
param: Optional[str]
|
||||
for entry in (mapper_config or '').split(';'):
|
||||
name, colon, param = entry.partition(':')
|
||||
name = name.lower()
|
||||
|
||||
+88
-38
@@ -20,7 +20,10 @@ Key responsibilities
|
||||
|
||||
Singleton: only one ``DisplayManager`` instance exists per process. The
|
||||
first call to ``DisplayManager(config)`` creates it; subsequent calls return
|
||||
the same object.
|
||||
the same object, but ``__init__`` runs again on it each time, so it is
|
||||
re-initialised (matrix included) with the new arguments rather than handed
|
||||
back as it was. Construct it once and pass that instance around;
|
||||
:meth:`DisplayManager.cleanup` clears the singleton.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -41,18 +44,23 @@ from src.display_geometry import (
|
||||
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||
compose_pixel_mapper_config, physical_size, resolve_double_sided,
|
||||
)
|
||||
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
|
||||
from src.matrix_support import (
|
||||
DEFAULT_REFRESH_LIMIT_HZ, MatrixSettingsRefused, library_refusals, refusal_message,
|
||||
)
|
||||
from src.pi5_matrix_support import is_raspberry_pi_5
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict, deque
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from typing import Dict, Any, List, Optional, Tuple, TYPE_CHECKING
|
||||
import math
|
||||
import zlib
|
||||
import freetype
|
||||
|
||||
from src.common import snapshot_policy
|
||||
from src.common.frame_timing import FrameTimingRecorder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.common.render_gate import RenderGate
|
||||
from src.deprecation import deprecated
|
||||
from src.logging_config import get_logger
|
||||
from src.common.permission_utils import (
|
||||
@@ -69,6 +77,10 @@ logger = get_logger(__name__)
|
||||
#: and therefore get_font_height() -- report anything but 0.
|
||||
_CALENDAR_FONT_PX = 7
|
||||
|
||||
#: Seconds between repeats of update_display()'s error log. It runs every
|
||||
#: frame, so a fault that persists would otherwise log ~100 lines a second.
|
||||
_UPDATE_ERROR_LOG_INTERVAL = 60.0
|
||||
|
||||
|
||||
def _bdf_native_size(face) -> int:
|
||||
"""The pixel height a BDF Face declares, or 0 if it does not say.
|
||||
@@ -233,6 +245,11 @@ class DisplayManager:
|
||||
|
||||
_instance = None
|
||||
|
||||
# update_display()'s error-log throttle. Class defaults so instances built
|
||||
# without __init__ (tests, doubles) have them too.
|
||||
_update_error_logged_at: Optional[float] = None
|
||||
_update_errors_suppressed = 0
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(DisplayManager, cls).__new__(cls)
|
||||
@@ -327,7 +344,7 @@ class DisplayManager:
|
||||
# A src.common.render_gate.RenderGate while Vegas runs with
|
||||
# vegas_scroll.prefetch_gate on: opened around each swap so the
|
||||
# prefetch thread only runs Python while this thread waits on vsync.
|
||||
self.render_gate = None
|
||||
self.render_gate: Optional['RenderGate'] = None
|
||||
|
||||
# Timing of every presented frame, whoever drew it, for
|
||||
# scripts/frame_soak.py. See src/common/frame_timing.py.
|
||||
@@ -342,6 +359,13 @@ class DisplayManager:
|
||||
'max_deferred_updates': 50, # Limit queue size to prevent memory issues
|
||||
'deferred_update_ttl': 300.0 # 5 minutes TTL for deferred updates
|
||||
}
|
||||
# Guards _scrolling_state['deferred_updates']. defer_update() is called
|
||||
# from plugin update() on the update worker thread while
|
||||
# process_deferred_updates() runs on the render thread, and both
|
||||
# rebuild the list (TTL filter, [n:] slice) and assign it back -- an
|
||||
# append landing between one side's read and its assignment was lost.
|
||||
# Never held while a queued callable runs: those may defer again.
|
||||
self._deferred_lock = threading.Lock()
|
||||
|
||||
self._setup_matrix()
|
||||
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
|
||||
@@ -973,7 +997,21 @@ class DisplayManager:
|
||||
# Write a snapshot for the web preview (throttled)
|
||||
self._write_snapshot_if_due(frame_checksum)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating display: {e}")
|
||||
# Once with the traceback, then at most every
|
||||
# _UPDATE_ERROR_LOG_INTERVAL with a count of what was skipped.
|
||||
now = time.monotonic()
|
||||
last = self._update_error_logged_at
|
||||
if last is None:
|
||||
self._update_error_logged_at = now
|
||||
logger.error("Error updating display: %s", e, exc_info=True)
|
||||
elif now - last >= _UPDATE_ERROR_LOG_INTERVAL:
|
||||
skipped = self._update_errors_suppressed
|
||||
self._update_error_logged_at = now
|
||||
self._update_errors_suppressed = 0
|
||||
logger.error("Error updating display: %s (%d more since the "
|
||||
"last report)", e, skipped)
|
||||
else:
|
||||
self._update_errors_suppressed += 1
|
||||
|
||||
def _setup_scan_order_compensation(self) -> None:
|
||||
"""Work out which rows to show a refresh behind while scrolling.
|
||||
@@ -1544,7 +1582,7 @@ class DisplayManager:
|
||||
options.panel_type = hardware_config.get('panel_type', '')
|
||||
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
|
||||
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
|
||||
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
|
||||
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', DEFAULT_REFRESH_LIMIT_HZ)
|
||||
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
|
||||
|
||||
# Disable internal privilege dropping - we manage this via systemd or remain root
|
||||
@@ -1590,7 +1628,7 @@ class DisplayManager:
|
||||
value = float(hardware.get('limit_refresh_rate_hz') or 0)
|
||||
except (TypeError, ValueError):
|
||||
value = 0.0
|
||||
return value if value > 0 else 100.0
|
||||
return value if value > 0 else float(DEFAULT_REFRESH_LIMIT_HZ)
|
||||
|
||||
def _scrolling_now(self) -> bool:
|
||||
"""Whether a scroll is running, without is_currently_scrolling()'s
|
||||
@@ -1701,26 +1739,28 @@ class DisplayManager:
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# Clean up expired updates before adding new ones
|
||||
self._cleanup_expired_deferred_updates(current_time)
|
||||
|
||||
# Limit queue size to prevent memory issues
|
||||
if len(self._scrolling_state['deferred_updates']) >= self._scrolling_state['max_deferred_updates']:
|
||||
# Remove oldest update to make room
|
||||
self._scrolling_state['deferred_updates'].pop(0)
|
||||
logger.debug("Removed oldest deferred update due to queue size limit")
|
||||
|
||||
self._scrolling_state['deferred_updates'].append({
|
||||
'func': update_func,
|
||||
'priority': priority,
|
||||
'timestamp': current_time
|
||||
})
|
||||
|
||||
# Only sort if we have a reasonable number of updates to avoid excessive sorting
|
||||
if len(self._scrolling_state['deferred_updates']) <= 20:
|
||||
self._scrolling_state['deferred_updates'].sort(key=lambda x: x['priority'])
|
||||
|
||||
logger.debug(f"Deferred update added. Total deferred: {len(self._scrolling_state['deferred_updates'])}")
|
||||
with self._deferred_lock:
|
||||
# Clean up expired updates before adding new ones
|
||||
self._cleanup_expired_deferred_updates(current_time)
|
||||
|
||||
# Limit queue size to prevent memory issues
|
||||
if len(self._scrolling_state['deferred_updates']) >= self._scrolling_state['max_deferred_updates']:
|
||||
# Remove oldest update to make room
|
||||
self._scrolling_state['deferred_updates'].pop(0)
|
||||
logger.debug("Removed oldest deferred update due to queue size limit")
|
||||
|
||||
self._scrolling_state['deferred_updates'].append({
|
||||
'func': update_func,
|
||||
'priority': priority,
|
||||
'timestamp': current_time
|
||||
})
|
||||
|
||||
# Only sort if we have a reasonable number of updates to avoid excessive sorting
|
||||
if len(self._scrolling_state['deferred_updates']) <= 20:
|
||||
self._scrolling_state['deferred_updates'].sort(key=lambda x: x['priority'])
|
||||
|
||||
queued = len(self._scrolling_state['deferred_updates'])
|
||||
logger.debug(f"Deferred update added. Total deferred: {queued}")
|
||||
|
||||
def process_deferred_updates(self):
|
||||
"""Process any deferred updates if not currently scrolling."""
|
||||
@@ -1728,21 +1768,26 @@ class DisplayManager:
|
||||
|
||||
# Always clean up expired updates, even if scrolling
|
||||
# This prevents memory leaks from accumulated expired updates
|
||||
self._cleanup_expired_deferred_updates(current_time)
|
||||
with self._deferred_lock:
|
||||
self._cleanup_expired_deferred_updates(current_time)
|
||||
|
||||
if self.is_currently_scrolling():
|
||||
return
|
||||
|
||||
if not self._scrolling_state['deferred_updates']:
|
||||
return
|
||||
|
||||
# Process only a limited number of updates per call to avoid blocking
|
||||
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
|
||||
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
|
||||
self._scrolling_state['deferred_updates'] = self._scrolling_state['deferred_updates'][max_updates_per_call:]
|
||||
with self._deferred_lock:
|
||||
if not self._scrolling_state['deferred_updates']:
|
||||
return
|
||||
|
||||
# Process only a limited number of updates per call to avoid blocking
|
||||
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
|
||||
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
|
||||
self._scrolling_state['deferred_updates'] = self._scrolling_state['deferred_updates'][max_updates_per_call:]
|
||||
queued = len(self._scrolling_state['deferred_updates'])
|
||||
|
||||
logger.debug(f"Processing {len(updates_to_process)} deferred updates (queue size: {len(self._scrolling_state['deferred_updates'])})")
|
||||
logger.debug(f"Processing {len(updates_to_process)} deferred updates (queue size: {queued})")
|
||||
|
||||
# The callables run outside the lock: they are plugin code of any
|
||||
# length, and one that defers again would deadlock on it.
|
||||
failed_updates = []
|
||||
for update_info in updates_to_process:
|
||||
try:
|
||||
@@ -1761,10 +1806,15 @@ class DisplayManager:
|
||||
|
||||
# Re-add failed updates to the end of the queue (not the beginning)
|
||||
if failed_updates:
|
||||
self._scrolling_state['deferred_updates'].extend(failed_updates)
|
||||
with self._deferred_lock:
|
||||
self._scrolling_state['deferred_updates'].extend(failed_updates)
|
||||
|
||||
def _cleanup_expired_deferred_updates(self, current_time: float):
|
||||
"""Remove expired deferred updates to prevent memory leaks."""
|
||||
"""Remove expired deferred updates to prevent memory leaks.
|
||||
|
||||
Callers hold ``_deferred_lock``: this reads the list and assigns a
|
||||
filtered copy back.
|
||||
"""
|
||||
ttl = self._scrolling_state['deferred_update_ttl']
|
||||
initial_count = len(self._scrolling_state['deferred_updates'])
|
||||
|
||||
|
||||
@@ -13,13 +13,14 @@ Supported dynamic teams:
|
||||
Usage:
|
||||
resolver = DynamicTeamResolver()
|
||||
resolved_teams = resolver.resolve_teams(["UGA", "AP_TOP_25", "AUB"])
|
||||
# Returns: ["UGA", "UGA", "AUB", "MICH", "OSU", ...] (AP_TOP_25 teams)
|
||||
# Returns: ["UGA", "MICH", "OSU", ..., "AUB"] -- AP_TOP_25 expanded in
|
||||
# place, and UGA (also ranked) kept once, at its first position
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, List
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
|
||||
@@ -34,12 +35,17 @@ class DynamicTeamResolver:
|
||||
"""
|
||||
|
||||
# Cache for rankings data
|
||||
_rankings_cache: Dict[str, List[str]] = {}
|
||||
_rankings_cache: Dict[str, int] = {} # team abbreviation -> AP rank
|
||||
_cache_timestamp: float = 0
|
||||
_cache_duration: int = 3600 # 1 hour cache
|
||||
# A failed or empty fetch is remembered briefly too: during an ESPN
|
||||
# outage every resolve would otherwise wait out request_timeout (30s)
|
||||
# again, on each scoreboard's update.
|
||||
_failure_timestamp: float = 0
|
||||
_failure_backoff: int = 300 # 5 minutes
|
||||
|
||||
# Supported dynamic team patterns
|
||||
DYNAMIC_PATTERNS = {
|
||||
DYNAMIC_PATTERNS: Dict[str, Dict[str, Any]] = {
|
||||
'AP_TOP_25': {'sport': 'ncaa_fb', 'limit': 25},
|
||||
'AP_TOP_10': {'sport': 'ncaa_fb', 'limit': 10},
|
||||
'AP_TOP_5': {'sport': 'ncaa_fb', 'limit': 5},
|
||||
@@ -70,8 +76,8 @@ class DynamicTeamResolver:
|
||||
if team in self.DYNAMIC_PATTERNS:
|
||||
# Resolve dynamic team
|
||||
dynamic_teams = self._resolve_dynamic_team(team, sport)
|
||||
# _resolve_dynamic_team already logs the result.
|
||||
resolved_teams.extend(dynamic_teams)
|
||||
self.logger.info(f"Resolved {team} to {len(dynamic_teams)} teams: {dynamic_teams[:5]}{'...' if len(dynamic_teams) > 5 else ''}")
|
||||
elif self._is_potential_dynamic_team(team):
|
||||
# Unknown dynamic team, skip it
|
||||
self.logger.warning(f"Unknown dynamic team '{team}' - skipping")
|
||||
@@ -138,7 +144,11 @@ class DynamicTeamResolver:
|
||||
if (self._rankings_cache and
|
||||
current_time - self._cache_timestamp < self._cache_duration):
|
||||
return self._rankings_cache
|
||||
|
||||
|
||||
# A recent attempt failed: don't pay the request timeout again yet.
|
||||
if current_time - self._failure_timestamp < self._failure_backoff:
|
||||
return {}
|
||||
|
||||
try:
|
||||
self.logger.info("Fetching fresh NCAA Football rankings from ESPN API")
|
||||
rankings_url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings"
|
||||
@@ -185,7 +195,9 @@ class DynamicTeamResolver:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching NCAA Football rankings: {e}")
|
||||
|
||||
|
||||
# On the class, for the same reason as the rankings cache above.
|
||||
DynamicTeamResolver._failure_timestamp = current_time
|
||||
return {}
|
||||
|
||||
def get_available_dynamic_teams(self) -> List[str]:
|
||||
@@ -229,6 +241,7 @@ class DynamicTeamResolver:
|
||||
shadow the shared cache for this instance."""
|
||||
DynamicTeamResolver._rankings_cache = {}
|
||||
DynamicTeamResolver._cache_timestamp = 0
|
||||
DynamicTeamResolver._failure_timestamp = 0
|
||||
self.logger.info("Cleared dynamic team rankings cache")
|
||||
|
||||
|
||||
|
||||
+39
-19
@@ -48,6 +48,7 @@ import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
@@ -68,8 +69,10 @@ _FONTS_SUBDIR = os.path.join('assets', 'fonts')
|
||||
_FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
|
||||
|
||||
# (resolved absolute path, requested size) -> (font face, realised size).
|
||||
# BDF faces are stateful in principle, but the core's own FontManager shares
|
||||
# faces the same way.
|
||||
# TTF only: a BDF ``freetype.Face`` must never be shared between threads
|
||||
# (FreeType does not allow it, and ``load_char`` rewrites the face's glyph
|
||||
# slot), and this cache is process-wide. BDF faces come from
|
||||
# ``load_bdf_face`` every time, which already caches them per thread.
|
||||
#
|
||||
# Bounded LRU rather than the unbounded dict this started as: the display
|
||||
# process runs for weeks, and every config save can introduce a new
|
||||
@@ -78,14 +81,28 @@ _FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
|
||||
# every other hot cache (display_manager, font_manager, adaptive_layout).
|
||||
_FONT_CACHE_MAX = 256
|
||||
_font_cache: 'OrderedDict[Tuple[str, int], Tuple[Any, int]]' = OrderedDict()
|
||||
# load_font is called from the display thread and from plugin update threads.
|
||||
# A get() then move_to_end() pair on an unguarded OrderedDict raises KeyError
|
||||
# when another thread evicts the key in between.
|
||||
_font_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _cache_get(key: Tuple[str, int]) -> Optional[Tuple[Any, int]]:
|
||||
"""The cached entry for ``key`` (marked most recently used), or None."""
|
||||
with _font_cache_lock:
|
||||
cached = _font_cache.get(key)
|
||||
if cached is not None:
|
||||
_font_cache.move_to_end(key)
|
||||
return cached
|
||||
|
||||
|
||||
def _cache_put(key: Tuple[str, int], value: Tuple[Any, int]) -> None:
|
||||
"""Insert, evicting the least recently used entry past the bound."""
|
||||
_font_cache[key] = value
|
||||
_font_cache.move_to_end(key)
|
||||
while len(_font_cache) > _FONT_CACHE_MAX:
|
||||
_font_cache.popitem(last=False)
|
||||
with _font_cache_lock:
|
||||
_font_cache[key] = value
|
||||
_font_cache.move_to_end(key)
|
||||
while len(_font_cache) > _FONT_CACHE_MAX:
|
||||
_font_cache.popitem(last=False)
|
||||
|
||||
# Config keys a style element block carries, in schema/UI order.
|
||||
_STYLE_KEYS = ('font', 'font_size', 'text_color', 'visible', 'align')
|
||||
@@ -222,14 +239,15 @@ def _load_font_sized(font_name: str, size: int) -> Tuple[Any, int]:
|
||||
logger.warning("Font file not found: %s, using fallback", font_name)
|
||||
return _load_fallback_font(size)
|
||||
|
||||
is_bdf = path.lower().endswith('.bdf')
|
||||
cache_key = (path, size)
|
||||
cached = _font_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
_font_cache.move_to_end(cache_key)
|
||||
return cached
|
||||
if not is_bdf:
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
if path.lower().endswith('.bdf'):
|
||||
if is_bdf:
|
||||
font, effective = _load_bdf(path, size)
|
||||
else:
|
||||
font, effective = load_truetype(path, size), size
|
||||
@@ -238,7 +256,9 @@ def _load_font_sized(font_name: str, size: int) -> Tuple[Any, int]:
|
||||
path, size, e)
|
||||
return _load_fallback_font(size)
|
||||
|
||||
_cache_put(cache_key, (font, effective))
|
||||
# Not BDF: load_bdf_face caches those per thread (see _font_cache).
|
||||
if not is_bdf:
|
||||
_cache_put(cache_key, (font, effective))
|
||||
return font, effective
|
||||
|
||||
|
||||
@@ -247,9 +267,8 @@ def _load_fallback_font(size: int) -> Tuple[Any, int]:
|
||||
path = resolve_font_path(_FALLBACK_FONT_NAME)
|
||||
if path is not None:
|
||||
cache_key = (path, size)
|
||||
cached = _font_cache.get(cache_key)
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
_font_cache.move_to_end(cache_key)
|
||||
return cached
|
||||
try:
|
||||
entry = (load_truetype(path, size), size)
|
||||
@@ -840,7 +859,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
defaults['align'] = align_spec['default']
|
||||
scale_spec = spec.get('scale')
|
||||
if isinstance(scale_spec, dict) and 'default' in scale_spec:
|
||||
layout.setdefault(element_key, {})['scale'] = scale_spec['default']
|
||||
layout.setdefault(element_key, {})['scale'] = scale_spec['default']
|
||||
elif scale_spec is True:
|
||||
layout.setdefault(element_key, {})['scale'] = 1.0
|
||||
if defaults:
|
||||
@@ -856,7 +875,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
continue
|
||||
scale_prop = (block.get('properties') or {}).get('scale')
|
||||
if isinstance(scale_prop, dict) and 'default' in scale_prop:
|
||||
layout.setdefault(element_key, {})['scale'] = scale_prop['default']
|
||||
layout.setdefault(element_key, {})['scale'] = scale_prop['default']
|
||||
for element_key, block in properties.items():
|
||||
if element_key in ('layout', 'modes') or element_key in elements:
|
||||
continue
|
||||
@@ -1480,11 +1499,12 @@ class ElementStyleResolver:
|
||||
mode_config, 'align')
|
||||
# scale is geometry, so it lives with the offsets rather than in the
|
||||
# element block -- a logo has a scale and no font.
|
||||
layout_defaults = self._defaults.get('layout', {})
|
||||
# The default is looked up through the aliases too, like the value:
|
||||
# an exact-key lookup missed a default filed under another name, so
|
||||
# a configured value equal to it counted as a user choice.
|
||||
scale = self._forced(
|
||||
self._layout_element(self._customization(), element_key),
|
||||
layout_defaults.get(element_key, {})
|
||||
if isinstance(layout_defaults, dict) else {},
|
||||
_lookup_element(self._defaults.get('layout', {}), element_key),
|
||||
self._layout_element(self._mode_block(mode), element_key),
|
||||
'scale')
|
||||
|
||||
|
||||
+3
-40
@@ -6,7 +6,8 @@ for the LEDMatrix system. Enables automatic bug detection by tracking
|
||||
error frequency, patterns, and context.
|
||||
|
||||
This is a local-only implementation with no external dependencies.
|
||||
Errors are stored in memory with optional JSON export.
|
||||
Errors are stored in memory; ErrorSnapshotPublisher shares a summary with the
|
||||
web process through the cache.
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -18,7 +19,6 @@ import uuid
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any, Callable, Tuple
|
||||
import logging
|
||||
|
||||
@@ -105,7 +105,6 @@ class ErrorAggregator:
|
||||
max_records: int = 1000,
|
||||
pattern_threshold: int = 5,
|
||||
pattern_window_minutes: int = 60,
|
||||
export_path: Optional[Path] = None
|
||||
):
|
||||
"""
|
||||
Initialize the error aggregator.
|
||||
@@ -114,20 +113,18 @@ class ErrorAggregator:
|
||||
max_records: Maximum number of error records to keep in memory
|
||||
pattern_threshold: Number of occurrences to detect a pattern
|
||||
pattern_window_minutes: Time window for pattern detection
|
||||
export_path: Optional path for JSON export (auto-export on pattern detection)
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.max_records = max_records
|
||||
self.pattern_threshold = pattern_threshold
|
||||
self.pattern_window = timedelta(minutes=pattern_window_minutes)
|
||||
self.export_path = export_path
|
||||
|
||||
self._records: List[ErrorRecord] = []
|
||||
self._error_counts: Dict[str, int] = defaultdict(int)
|
||||
self._plugin_error_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
self._patterns: Dict[str, ErrorPattern] = {}
|
||||
self._pattern_callbacks: List[Callable[[ErrorPattern], None]] = []
|
||||
self._lock = threading.RLock() # RLock allows nested acquisition for export_to_file
|
||||
self._lock = threading.RLock() # RLock: build_snapshot and pattern callbacks re-enter
|
||||
|
||||
# Track session start for relative timing
|
||||
self._session_start = datetime.now()
|
||||
@@ -248,10 +245,6 @@ class ErrorAggregator:
|
||||
callback(pattern)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Pattern callback failed: {e}")
|
||||
|
||||
# Auto-export if path configured
|
||||
if self.export_path:
|
||||
self._auto_export()
|
||||
else:
|
||||
# Update existing pattern
|
||||
self._patterns[pattern_key].count = count
|
||||
@@ -424,33 +417,6 @@ class ErrorAggregator:
|
||||
# turned into a string here rather than failing the write.
|
||||
return json.loads(json.dumps(summary, default=str))
|
||||
|
||||
def export_to_file(self, filepath: Path) -> None:
|
||||
"""
|
||||
Export error data to JSON file.
|
||||
|
||||
Args:
|
||||
filepath: Path to export file
|
||||
"""
|
||||
with self._lock:
|
||||
data = {
|
||||
"exported_at": datetime.now().isoformat(),
|
||||
"summary": self.get_error_summary(),
|
||||
"all_records": [r.to_dict() for r in self._records]
|
||||
}
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
filepath.write_text(json.dumps(data, indent=2))
|
||||
self.logger.info(f"Exported error data to {filepath}")
|
||||
|
||||
def _auto_export(self) -> None:
|
||||
"""Auto-export on pattern detection (if export_path configured)."""
|
||||
if self.export_path:
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filepath = self.export_path / f"errors_{timestamp}.json"
|
||||
self.export_to_file(filepath)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Auto-export failed: {e}")
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_error_aggregator: Optional[ErrorAggregator] = None
|
||||
@@ -461,7 +427,6 @@ def get_error_aggregator(
|
||||
max_records: int = 1000,
|
||||
pattern_threshold: int = 5,
|
||||
pattern_window_minutes: int = 60,
|
||||
export_path: Optional[Path] = None
|
||||
) -> ErrorAggregator:
|
||||
"""
|
||||
Get or create the global error aggregator instance.
|
||||
@@ -470,7 +435,6 @@ def get_error_aggregator(
|
||||
max_records: Maximum records to keep (only used on first call)
|
||||
pattern_threshold: Pattern detection threshold (only used on first call)
|
||||
pattern_window_minutes: Pattern detection window (only used on first call)
|
||||
export_path: Export path for auto-export (only used on first call)
|
||||
|
||||
Returns:
|
||||
The global ErrorAggregator instance
|
||||
@@ -483,7 +447,6 @@ def get_error_aggregator(
|
||||
max_records=max_records,
|
||||
pattern_threshold=pattern_threshold,
|
||||
pattern_window_minutes=pattern_window_minutes,
|
||||
export_path=export_path
|
||||
)
|
||||
return _error_aggregator
|
||||
|
||||
|
||||
+19
-9
@@ -5,11 +5,13 @@ Provides specific exception types for different error categories,
|
||||
enabling better error handling and debugging.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class LEDMatrixError(Exception):
|
||||
"""Base exception for all LEDMatrix errors."""
|
||||
|
||||
def __init__(self, message: str, context: dict = None):
|
||||
def __init__(self, message: str, context: Optional[dict] = None):
|
||||
"""
|
||||
Initialize the exception.
|
||||
|
||||
@@ -32,7 +34,7 @@ class LEDMatrixError(Exception):
|
||||
class CacheError(LEDMatrixError):
|
||||
"""Exception raised for cache-related errors."""
|
||||
|
||||
def __init__(self, message: str, cache_key: str = None, context: dict = None):
|
||||
def __init__(self, message: str, cache_key: Optional[str] = None, context: Optional[dict] = None):
|
||||
"""
|
||||
Initialize cache error.
|
||||
|
||||
@@ -42,7 +44,9 @@ class CacheError(LEDMatrixError):
|
||||
context: Optional context dictionary
|
||||
"""
|
||||
if cache_key:
|
||||
context = context or {}
|
||||
# Copy so the caller's dict isn't mutated (a reused context
|
||||
# dict would otherwise collect every error's keys).
|
||||
context = dict(context or {})
|
||||
context['cache_key'] = cache_key
|
||||
super().__init__(message, context)
|
||||
self.cache_key = cache_key
|
||||
@@ -51,7 +55,7 @@ class CacheError(LEDMatrixError):
|
||||
class ConfigError(LEDMatrixError):
|
||||
"""Exception raised for configuration-related errors."""
|
||||
|
||||
def __init__(self, message: str, config_path: str = None, field: str = None, context: dict = None):
|
||||
def __init__(self, message: str, config_path: Optional[str] = None, field: Optional[str] = None, context: Optional[dict] = None):
|
||||
"""
|
||||
Initialize config error.
|
||||
|
||||
@@ -62,7 +66,9 @@ class ConfigError(LEDMatrixError):
|
||||
context: Optional context dictionary
|
||||
"""
|
||||
if config_path or field:
|
||||
context = context or {}
|
||||
# Copy so the caller's dict isn't mutated (a reused context
|
||||
# dict would otherwise collect every error's keys).
|
||||
context = dict(context or {})
|
||||
if config_path:
|
||||
context['config_path'] = config_path
|
||||
if field:
|
||||
@@ -75,7 +81,7 @@ class ConfigError(LEDMatrixError):
|
||||
class PluginError(LEDMatrixError):
|
||||
"""Exception raised for plugin-related errors."""
|
||||
|
||||
def __init__(self, message: str, plugin_id: str = None, context: dict = None):
|
||||
def __init__(self, message: str, plugin_id: Optional[str] = None, context: Optional[dict] = None):
|
||||
"""
|
||||
Initialize plugin error.
|
||||
|
||||
@@ -85,7 +91,9 @@ class PluginError(LEDMatrixError):
|
||||
context: Optional context dictionary
|
||||
"""
|
||||
if plugin_id:
|
||||
context = context or {}
|
||||
# Copy so the caller's dict isn't mutated (a reused context
|
||||
# dict would otherwise collect every error's keys).
|
||||
context = dict(context or {})
|
||||
context['plugin_id'] = plugin_id
|
||||
super().__init__(message, context)
|
||||
self.plugin_id = plugin_id
|
||||
@@ -94,7 +102,7 @@ class PluginError(LEDMatrixError):
|
||||
class DisplayError(LEDMatrixError):
|
||||
"""Exception raised for display-related errors."""
|
||||
|
||||
def __init__(self, message: str, display_mode: str = None, context: dict = None):
|
||||
def __init__(self, message: str, display_mode: Optional[str] = None, context: Optional[dict] = None):
|
||||
"""
|
||||
Initialize display error.
|
||||
|
||||
@@ -104,7 +112,9 @@ class DisplayError(LEDMatrixError):
|
||||
context: Optional context dictionary
|
||||
"""
|
||||
if display_mode:
|
||||
context = context or {}
|
||||
# Copy so the caller's dict isn't mutated (a reused context
|
||||
# dict would otherwise collect every error's keys).
|
||||
context = dict(context or {})
|
||||
context['display_mode'] = display_mode
|
||||
super().__init__(message, context)
|
||||
self.display_mode = display_mode
|
||||
|
||||
+70
-17
@@ -28,10 +28,10 @@ for accurate width/height calculations.
|
||||
import os
|
||||
import logging
|
||||
import freetype
|
||||
import requests
|
||||
import json
|
||||
import hashlib
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import tempfile
|
||||
import time
|
||||
@@ -50,6 +50,9 @@ from src.deprecation import deprecated
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Seconds before a stalled font download gives up (connect and per-read).
|
||||
_FONT_DOWNLOAD_TIMEOUT = 30
|
||||
|
||||
class FontManager:
|
||||
"""
|
||||
Comprehensive font management supporting TTF and BDF fonts with caching,
|
||||
@@ -320,31 +323,59 @@ class FontManager:
|
||||
extension = self._get_font_extension(url)
|
||||
cache_filename = f"{family}_{url_hash}{extension}"
|
||||
cache_path = self.temp_font_dir / cache_filename
|
||||
is_zip = url.endswith('.zip')
|
||||
extract_dir = self.temp_font_dir / f"{family}_{url_hash}"
|
||||
|
||||
# Check if already downloaded
|
||||
if cache_path.exists():
|
||||
# Check if already downloaded. For a zip the font is the file
|
||||
# extracted from it, so look there first -- returning the cached
|
||||
# .zip itself would register the archive as the font after a
|
||||
# restart.
|
||||
if is_zip:
|
||||
extracted = self._find_extracted_font(extract_dir)
|
||||
if extracted:
|
||||
logger.info(f"Using cached font: {extracted}")
|
||||
return extracted
|
||||
elif cache_path.exists():
|
||||
logger.info(f"Using cached font: {cache_path}")
|
||||
return str(cache_path)
|
||||
|
||||
# Download font — restrict to http/https to prevent file:// reads
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
raise ValueError(f"Font URL must use http or https, got: {parsed.scheme!r}")
|
||||
logger.info(f"Downloading font from {url}")
|
||||
urllib.request.urlretrieve(url, cache_path) # nosec B310 - scheme validated above
|
||||
if not cache_path.exists():
|
||||
# Download font — restrict to http/https to prevent file:// reads
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
raise ValueError(f"Font URL must use http or https, got: {parsed.scheme!r}")
|
||||
logger.info(f"Downloading font from {url}")
|
||||
# Download to a temp file and rename into place, with a
|
||||
# timeout: writing straight to cache_path left a truncated
|
||||
# file after a stalled/interrupted download, and the exists()
|
||||
# check above then served it forever.
|
||||
fd, tmp_name = tempfile.mkstemp(dir=self.temp_font_dir, suffix='.part')
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as tmp_file:
|
||||
response = requests.get(url, timeout=_FONT_DOWNLOAD_TIMEOUT, stream=True)
|
||||
response.raise_for_status()
|
||||
for chunk in response.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
tmp_file.write(chunk)
|
||||
os.replace(tmp_name, cache_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
# Handle zip files
|
||||
if url.endswith('.zip'):
|
||||
extract_dir = self.temp_font_dir / f"{family}_{url_hash}"
|
||||
if is_zip:
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
with zipfile.ZipFile(cache_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(extract_dir)
|
||||
|
||||
|
||||
# Find the actual font file
|
||||
for file in extract_dir.iterdir():
|
||||
if file.suffix.lower() in ['.ttf', '.otf', '.bdf']:
|
||||
return str(file)
|
||||
extracted = self._find_extracted_font(extract_dir)
|
||||
if extracted:
|
||||
return extracted
|
||||
|
||||
return str(cache_path)
|
||||
|
||||
@@ -352,6 +383,16 @@ class FontManager:
|
||||
logger.error(f"Error downloading font from {url}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _find_extracted_font(extract_dir: Path) -> Optional[str]:
|
||||
"""Return the first font file in a zip's extract dir, if any."""
|
||||
if not extract_dir.is_dir():
|
||||
return None
|
||||
for file in extract_dir.iterdir():
|
||||
if file.suffix.lower() in ['.ttf', '.otf', '.bdf']:
|
||||
return str(file)
|
||||
return None
|
||||
|
||||
def _get_font_extension(self, url: str) -> str:
|
||||
"""Extract font file extension from URL."""
|
||||
if '.ttf' in url.lower():
|
||||
@@ -426,6 +467,9 @@ class FontManager:
|
||||
keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")]
|
||||
for key in keys_to_remove:
|
||||
del self.font_cache[key]
|
||||
if keys_to_remove:
|
||||
# Font objects someone may hold were dropped; see cache_generation.
|
||||
self.cache_generation += 1
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
|
||||
@@ -495,6 +539,7 @@ class FontManager:
|
||||
self.performance_stats["cache_misses"] += 1
|
||||
|
||||
# Load font
|
||||
shareable = True
|
||||
font_path = self.font_catalog.get(family)
|
||||
if not font_path:
|
||||
logger.warning(f"Font family '{family}' not found")
|
||||
@@ -504,6 +549,7 @@ class FontManager:
|
||||
try:
|
||||
if font_path.endswith('.bdf'):
|
||||
font = self._load_bdf_font(font_path, size_px)
|
||||
shareable = False
|
||||
else:
|
||||
font = load_truetype(font_path, size_px)
|
||||
except Exception as e:
|
||||
@@ -513,7 +559,11 @@ class FontManager:
|
||||
self.performance_stats["failed_loads"] += 1
|
||||
font = ImageFont.load_default()
|
||||
|
||||
self.font_cache[cache_key] = font
|
||||
# A BDF face is not cached here: font_cache is shared by every
|
||||
# thread, and a freetype.Face must never be (see load_bdf_face, which
|
||||
# already caches BDF faces per thread).
|
||||
if shareable:
|
||||
self.font_cache[cache_key] = font
|
||||
return font
|
||||
|
||||
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
||||
@@ -732,6 +782,9 @@ class FontManager:
|
||||
"""Clear font and metrics cache."""
|
||||
self.font_cache.clear()
|
||||
self.metrics_cache.clear()
|
||||
# Holders of derived caches (layout fits, font usage) key off this;
|
||||
# without the bump they kept serving results for the dropped fonts.
|
||||
self.cache_generation += 1
|
||||
logger.info("Font cache cleared")
|
||||
|
||||
@deprecated("3.7.0", "read font_catalog")
|
||||
|
||||
@@ -43,7 +43,10 @@ class StructuredFormatter(logging.Formatter):
|
||||
if hasattr(record, 'operation_id'):
|
||||
log_data['operation_id'] = record.operation_id
|
||||
|
||||
return json.dumps(log_data)
|
||||
# default=str: record.context / extras can hold datetimes, Paths,
|
||||
# exceptions etc.; without it one such value raised TypeError and
|
||||
# the whole record was dropped by the handler's error path.
|
||||
return json.dumps(log_data, default=str)
|
||||
|
||||
|
||||
class ContextualFormatter(logging.Formatter):
|
||||
@@ -122,6 +125,7 @@ def setup_logging(
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Create formatter based on type
|
||||
formatter: logging.Formatter
|
||||
if format_type == 'json':
|
||||
formatter = StructuredFormatter()
|
||||
else:
|
||||
@@ -241,7 +245,7 @@ class PluginLoggerAdapter(logging.LoggerAdapter):
|
||||
|
||||
def process(self, msg, kwargs):
|
||||
extra = dict(kwargs.get('extra') or {})
|
||||
extra.setdefault('plugin_id', self.extra.get('plugin_id'))
|
||||
extra.setdefault('plugin_id', self.extra.get('plugin_id')) # type: ignore[union-attr] # get_logger always passes a dict
|
||||
kwargs['extra'] = extra
|
||||
return msg, kwargs
|
||||
|
||||
@@ -287,7 +291,7 @@ def log_with_context(
|
||||
operation_id: Optional operation ID for request tracking
|
||||
exc_info: Optional exception info for error logging
|
||||
"""
|
||||
extra = {}
|
||||
extra: Dict[str, Any] = {}
|
||||
|
||||
if context:
|
||||
extra['context'] = context
|
||||
|
||||
@@ -13,7 +13,7 @@ import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
@@ -235,7 +235,10 @@ def refresh_placeholder_timestamp(filepath: Path) -> bool:
|
||||
metadata = PngInfo()
|
||||
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
|
||||
with Image.open(filepath) as img:
|
||||
img.copy().save(filepath, "PNG", pnginfo=metadata)
|
||||
image = img.copy()
|
||||
# Atomically, like every other logo write: a renderer can open this
|
||||
# file at any moment, and an in-place save exposes a truncated PNG.
|
||||
save_png_atomically(image, filepath, pnginfo=metadata)
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("Could not refresh placeholder timestamp for %s", filepath,
|
||||
@@ -478,7 +481,7 @@ class LogoDownloader:
|
||||
logger.info(f"Fetching team data for {league} from ESPN API...")
|
||||
response = self.session.get(api_url, params={'limit':1000},headers=self.headers, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
data: Dict = response.json()
|
||||
|
||||
logger.info(f"Successfully fetched team data for {league}")
|
||||
return data
|
||||
@@ -502,7 +505,7 @@ class LogoDownloader:
|
||||
logger.info(f"Fetching team data for team {team_id} in {league} from ESPN API...")
|
||||
response = self.session.get(f"{api_url}/{team_id}", headers=self.headers, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
data: Dict = response.json()
|
||||
|
||||
logger.info(f"Successfully fetched team data for {team_id} in {league}")
|
||||
return data
|
||||
@@ -812,6 +815,7 @@ class LogoDownloader:
|
||||
draw = ImageDraw.Draw(logo)
|
||||
|
||||
# Try to load a font, fallback to default
|
||||
font: Optional[Union[ImageFont.FreeTypeFont, ImageFont.ImageFont]]
|
||||
try:
|
||||
font = load_truetype(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"), 12)
|
||||
except (OSError, IOError):
|
||||
|
||||
@@ -74,6 +74,13 @@ MAPPING_OUTPUTS: Dict[str, int] = {
|
||||
'classic-pi1': 1,
|
||||
}
|
||||
|
||||
#: The refresh cap (``display.hardware.limit_refresh_rate_hz``) when config
|
||||
#: omits it -- config/config.template.json's value. DisplayManager passes it to
|
||||
#: the library and reports it as ``refresh_hz`` for scroll pacing, so the two
|
||||
#: must be the same number: they were 90 and 100, and pacing solved against a
|
||||
#: rate the panel was capped below.
|
||||
DEFAULT_REFRESH_LIMIT_HZ = 100
|
||||
|
||||
#: What DisplayManager passes when a key is missing from display.hardware /
|
||||
#: display.runtime. Config migration normally fills these from
|
||||
#: config/config.template.json first, so they rarely apply.
|
||||
@@ -81,7 +88,7 @@ DISPLAY_MANAGER_DEFAULTS: Dict[str, Any] = {
|
||||
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
|
||||
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90, 'pwm_bits': 10,
|
||||
'pwm_lsb_nanoseconds': 150, 'led_rgb_sequence': 'RGB',
|
||||
'row_address_type': 0, 'multiplexing': 0, 'limit_refresh_rate_hz': 90,
|
||||
'row_address_type': 0, 'multiplexing': 0, 'limit_refresh_rate_hz': DEFAULT_REFRESH_LIMIT_HZ,
|
||||
'gpio_slowdown': 3,
|
||||
}
|
||||
|
||||
|
||||
@@ -776,28 +776,35 @@ class BasePlugin(ABC):
|
||||
tighter arrangement instead of being cropped afterwards.
|
||||
|
||||
Vegas also narrows ``display_manager`` for the duration of the call, so
|
||||
a plugin that already sizes itself from ``matrix.width`` needs no
|
||||
changes. Read this only when you size content some other way.
|
||||
a plugin that already sizes itself from ``display_manager.width`` needs
|
||||
no changes. Read this only when you size content some other way.
|
||||
|
||||
Controlled by the plugin's own ``vegas_width_pct`` config value, else
|
||||
the global ``display.vegas_scroll.render_width_pct``.
|
||||
|
||||
Returns:
|
||||
Target width in pixels. Outside a Vegas content request, the full
|
||||
display width.
|
||||
display width: ``display_manager.width``, which falls back to the
|
||||
canvas size when ``matrix`` is None (hardware init failed).
|
||||
"""
|
||||
requested = getattr(self, '_vegas_render_width', None)
|
||||
if isinstance(requested, int) and requested > 0:
|
||||
return requested
|
||||
|
||||
# display_manager.width first, as CLAUDE.md asks of every plugin: it
|
||||
# already reads matrix.width when there is a matrix. matrix.width is
|
||||
# only the fallback for a display_manager without a width (a test
|
||||
# double, an older wrapper).
|
||||
display_manager = getattr(self, 'display_manager', None)
|
||||
matrix = getattr(display_manager, 'matrix', None)
|
||||
if matrix is not None and getattr(matrix, 'width', None):
|
||||
return int(matrix.width)
|
||||
width = getattr(display_manager, 'width', None)
|
||||
if callable(width):
|
||||
width = width()
|
||||
return int(width) if width else 128
|
||||
if width:
|
||||
return int(width)
|
||||
matrix = getattr(display_manager, 'matrix', None)
|
||||
if matrix is not None and getattr(matrix, 'width', None):
|
||||
return int(matrix.width)
|
||||
return 128
|
||||
|
||||
def get_vegas_content(self) -> Optional[Any]:
|
||||
"""
|
||||
|
||||
@@ -97,6 +97,8 @@ def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||
try:
|
||||
nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]]
|
||||
except ValueError:
|
||||
# Reachable: str.isdigit() accepts characters int() rejects, such as
|
||||
# a superscript "\u00b2" -- "1.\u00b2.0" lands here.
|
||||
return None
|
||||
while len(nums) < 3:
|
||||
nums.append(0)
|
||||
@@ -189,11 +191,11 @@ def satisfies_compatible_versions(
|
||||
return any(parsed)
|
||||
|
||||
|
||||
def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
|
||||
def declared_min_version(manifest: Dict[str, Any]) -> Any:
|
||||
"""The core version this plugin says it needs, or ``None`` if it doesn't say.
|
||||
|
||||
Checked in order of specificity. `ledmatrix_min` is the deprecated spelling
|
||||
of `ledmatrix_min_version` (`store_manager._validate_manifest_fields` flags
|
||||
of `ledmatrix_min_version` (`store_manager._validate_manifest_version_fields` flags
|
||||
it); both are read because a large share of published manifests still carry
|
||||
the old one.
|
||||
|
||||
@@ -204,6 +206,10 @@ def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
|
||||
untrustworthy-core branch of :func:`check` calls this for *every* manifest,
|
||||
so one malformed file would take down the install path rather than just
|
||||
itself. A shape we do not recognise means "no declared floor".
|
||||
|
||||
The value is returned as the manifest holds it -- normally a version
|
||||
string, but nothing here checks that; callers hand it to
|
||||
:func:`parse_semver`, which accepts anything.
|
||||
"""
|
||||
declared = manifest.get('min_ledmatrix_version')
|
||||
if not declared:
|
||||
@@ -220,7 +226,7 @@ def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def is_update_available(installed_version: str, latest_version: str) -> bool:
|
||||
def is_update_available(installed_version: Any, latest_version: Any) -> bool:
|
||||
"""Return True when the registry's ``latest_version`` is strictly newer
|
||||
than the installed version.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from src.config_manager_atomic import atomic_write_text
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
@@ -180,15 +181,16 @@ class OperationHistory:
|
||||
return
|
||||
|
||||
try:
|
||||
# Held across the write, and written via a temp file, so two
|
||||
# threads saving at once can't interleave or truncate the file.
|
||||
with self._lock:
|
||||
history_data = [record.to_dict() for record in self._history]
|
||||
|
||||
# Ensure directory exists
|
||||
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to file
|
||||
with open(self.history_file, 'w') as f:
|
||||
json.dump(history_data, f, indent=2)
|
||||
# Ensure directory exists
|
||||
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to file
|
||||
atomic_write_text(self.history_file, json.dumps(history_data, indent=2))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving operation history: {e}", exc_info=True)
|
||||
|
||||
@@ -85,6 +85,17 @@ class PluginOperationQueue:
|
||||
f"Plugin {plugin_id} already has an active operation: "
|
||||
f"{active_op.operation_id} ({active_op.operation_type.value})"
|
||||
)
|
||||
|
||||
# _active_operations only holds the *running* one, so a second
|
||||
# request while the first still waits in the queue (a double-
|
||||
# clicked Install) used to be queued too, and both ran back to
|
||||
# back. Refuse it the same way.
|
||||
for queued_op in self._operations.values():
|
||||
if queued_op.plugin_id == plugin_id and queued_op.status == OperationStatus.PENDING:
|
||||
raise ValueError(
|
||||
f"Plugin {plugin_id} already has an active operation: "
|
||||
f"{queued_op.operation_id} ({queued_op.operation_type.value})"
|
||||
)
|
||||
|
||||
# Create operation
|
||||
operation = PluginOperation(
|
||||
@@ -172,20 +183,6 @@ class PluginOperationQueue:
|
||||
)
|
||||
return history[:limit]
|
||||
|
||||
def get_active_operations(self) -> List[PluginOperation]:
|
||||
"""
|
||||
Get all currently active operations (pending or running).
|
||||
|
||||
Returns:
|
||||
List of active operations
|
||||
"""
|
||||
with self._lock:
|
||||
active = []
|
||||
for operation in self._operations.values():
|
||||
if operation.status in [OperationStatus.PENDING, OperationStatus.RUNNING]:
|
||||
active.append(operation)
|
||||
return active
|
||||
|
||||
def _start_worker(self) -> None:
|
||||
"""Start the worker thread that processes operations."""
|
||||
if self._worker_thread and self._worker_thread.is_alive():
|
||||
@@ -302,7 +299,14 @@ class PluginOperationQueue:
|
||||
if len(self._operation_history) > self.max_history:
|
||||
# Remove oldest operations
|
||||
self._operation_history.sort(key=lambda op: op.created_at)
|
||||
dropped = self._operation_history[:-self.max_history]
|
||||
self._operation_history = self._operation_history[-self.max_history:]
|
||||
# ...and forget them in the status map too, which otherwise kept
|
||||
# every operation ever enqueued for the life of the process. A
|
||||
# still-pending or running one is never dropped from lookups.
|
||||
for op in dropped:
|
||||
if op.status not in (OperationStatus.PENDING, OperationStatus.RUNNING):
|
||||
self._operations.pop(op.operation_id, None)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shutdown the operation queue and worker thread."""
|
||||
|
||||
@@ -45,7 +45,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Union
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Union, cast
|
||||
|
||||
from src.common.path_safety import safe_path_component
|
||||
|
||||
@@ -91,7 +91,7 @@ class PluginDirEntry:
|
||||
"""One candidate directory and its manifest, read once."""
|
||||
path: Path
|
||||
status: str
|
||||
manifest: Optional[Any] = None
|
||||
manifest: Any = None
|
||||
error: Optional[BaseException] = None
|
||||
|
||||
@property
|
||||
@@ -103,7 +103,8 @@ class PluginDirEntry:
|
||||
"""The manifest's ``id`` when the manifest is usable, else None."""
|
||||
if self.status != ManifestStatus.OK:
|
||||
return None
|
||||
return self.manifest['id']
|
||||
# OK means a dict whose "id" is a non-empty string (_read_entry).
|
||||
return cast(str, self.manifest['id'])
|
||||
|
||||
@property
|
||||
def manifest_parses(self) -> bool:
|
||||
@@ -240,7 +241,7 @@ class PluginDirectoryIndex:
|
||||
|
||||
# -- lookup -----------------------------------------------------------
|
||||
|
||||
def find(self, plugin_id: str, *, prefix: bool, case_insensitive: bool,
|
||||
def find(self, plugin_id: Any, *, prefix: bool, case_insensitive: bool,
|
||||
by_manifest: bool = True) -> Optional[Path]:
|
||||
"""Resolve ``plugin_id`` within this directory (rules in the module doc)."""
|
||||
plugin_id = _lookup_id(plugin_id)
|
||||
@@ -270,7 +271,7 @@ def _lookup_id(plugin_id: Any) -> Optional[str]:
|
||||
plugin_id = safe_path_component(plugin_id)
|
||||
if plugin_id is None or is_ignored_dir_name(plugin_id):
|
||||
return None
|
||||
return plugin_id
|
||||
return cast(str, plugin_id) # safe_path_component returned a str
|
||||
|
||||
|
||||
def _candidate_names(plugin_id: str, prefix: bool) -> List[str]:
|
||||
|
||||
@@ -6,7 +6,7 @@ error isolation, and performance monitoring.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Optional, Callable
|
||||
from typing import Any, Dict, Optional, Callable
|
||||
from threading import Thread
|
||||
import logging
|
||||
|
||||
@@ -62,7 +62,7 @@ class PluginExecutor:
|
||||
plugin_context = f"plugin {plugin_id}" if plugin_id else "plugin"
|
||||
|
||||
# Use threading-based timeout (more reliable than signal-based)
|
||||
result_container = {'value': None, 'exception': None, 'completed': False}
|
||||
result_container: Dict[str, Any] = {'value': None, 'exception': None, 'completed': False}
|
||||
|
||||
def target():
|
||||
try:
|
||||
|
||||
@@ -6,10 +6,11 @@ and circuit breaker state. Provides automatic recovery mechanisms.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Optional, Any, Tuple
|
||||
from enum import Enum
|
||||
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class CircuitState(Enum):
|
||||
"""Circuit breaker states."""
|
||||
@@ -43,7 +44,7 @@ class PluginHealthTracker:
|
||||
self.failure_threshold = failure_threshold
|
||||
self.cooldown_period = cooldown_period
|
||||
self.half_open_timeout = half_open_timeout
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
# In-memory health state (also persisted to cache)
|
||||
self._health_state: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@@ -23,6 +23,11 @@ from src.exceptions import PluginError
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_dirs import resolve_plugin_dir
|
||||
|
||||
#: Serialises pip runs across threads. Startup loads plugins on a small
|
||||
#: thread pool, and two concurrent ``pip install`` processes writing the same
|
||||
#: site-packages can corrupt it or fail on each other's partial installs.
|
||||
_PIP_INSTALL_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def requirements_has_real_deps(requirements_file: str) -> bool:
|
||||
"""
|
||||
@@ -272,6 +277,7 @@ class PluginLoader:
|
||||
Path to plugin directory or None if not found. An id that is not
|
||||
one plain path segment finds nothing.
|
||||
"""
|
||||
plugin_dir: Optional[Path]
|
||||
# Strategy 1: Use mapping from discovery
|
||||
if plugin_directories and plugin_id in plugin_directories:
|
||||
plugin_dir = plugin_directories[plugin_id]
|
||||
@@ -325,93 +331,94 @@ class PluginLoader:
|
||||
if requirements_file is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
with _PIP_INSTALL_LOCK:
|
||||
try:
|
||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||
return True
|
||||
else:
|
||||
stderr = result.stderr or ""
|
||||
# uninstall-no-record-file means a system-managed copy of a package
|
||||
# (e.g. apt's python3-requests, which ships no pip RECORD file) is in
|
||||
# the way of the version this requirements.txt pins. Retry with
|
||||
# --ignore-installed so pip lays the pinned version down alongside
|
||||
# the system copy instead of trying to replace it — matching the
|
||||
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
|
||||
# Without this retry, the plugin would silently keep running against
|
||||
# whatever version the system happened to ship.
|
||||
if "uninstall-no-record-file" in stderr:
|
||||
self.logger.warning(
|
||||
"Dependencies for %s conflict with a system-managed package "
|
||||
"(no pip RECORD); retrying with --ignore-installed: %s",
|
||||
plugin_id, stderr.strip()
|
||||
)
|
||||
# Wrapped in its own try/except so a retry timeout is
|
||||
# tolerated the same way as a retry failure, instead of
|
||||
# propagating to the outer handler and returning False
|
||||
# (which would contradict the "assume satisfied" fallback
|
||||
# below).
|
||||
try:
|
||||
# sys.executable is this process's own interpreter (not
|
||||
# attacker-influenced), and requirements_file is rebuilt
|
||||
# by contained_plugin_dir() from a trusted listing, never raw
|
||||
# external input.
|
||||
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
||||
"--ignore-installed", "-r", requirements_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
if retry_result.returncode != 0:
|
||||
self.logger.warning(
|
||||
"Retry with --ignore-installed also failed for %s; assuming the "
|
||||
"system-managed version satisfies the requirement: %s",
|
||||
plugin_id, (retry_result.stderr or "").strip()
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.warning(
|
||||
"Retry with --ignore-installed timed out for %s; assuming the "
|
||||
"system-managed version satisfies the requirement",
|
||||
plugin_id
|
||||
)
|
||||
if result.returncode == 0:
|
||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||
return True
|
||||
self.logger.warning(
|
||||
"Dependency installation returned non-zero exit code for %s: %s",
|
||||
plugin_id,
|
||||
stderr
|
||||
)
|
||||
else:
|
||||
stderr = result.stderr or ""
|
||||
# uninstall-no-record-file means a system-managed copy of a package
|
||||
# (e.g. apt's python3-requests, which ships no pip RECORD file) is in
|
||||
# the way of the version this requirements.txt pins. Retry with
|
||||
# --ignore-installed so pip lays the pinned version down alongside
|
||||
# the system copy instead of trying to replace it — matching the
|
||||
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
|
||||
# Without this retry, the plugin would silently keep running against
|
||||
# whatever version the system happened to ship.
|
||||
if "uninstall-no-record-file" in stderr:
|
||||
self.logger.warning(
|
||||
"Dependencies for %s conflict with a system-managed package "
|
||||
"(no pip RECORD); retrying with --ignore-installed: %s",
|
||||
plugin_id, stderr.strip()
|
||||
)
|
||||
# Wrapped in its own try/except so a retry timeout is
|
||||
# tolerated the same way as a retry failure, instead of
|
||||
# propagating to the outer handler and returning False
|
||||
# (which would contradict the "assume satisfied" fallback
|
||||
# below).
|
||||
try:
|
||||
# sys.executable is this process's own interpreter (not
|
||||
# attacker-influenced), and requirements_file is rebuilt
|
||||
# by contained_plugin_dir() from a trusted listing, never raw
|
||||
# external input.
|
||||
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
||||
"--ignore-installed", "-r", requirements_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
if retry_result.returncode != 0:
|
||||
self.logger.warning(
|
||||
"Retry with --ignore-installed also failed for %s; assuming the "
|
||||
"system-managed version satisfies the requirement: %s",
|
||||
plugin_id, (retry_result.stderr or "").strip()
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.warning(
|
||||
"Retry with --ignore-installed timed out for %s; assuming the "
|
||||
"system-managed version satisfies the requirement",
|
||||
plugin_id
|
||||
)
|
||||
return True
|
||||
self.logger.warning(
|
||||
"Dependency installation returned non-zero exit code for %s: %s",
|
||||
plugin_id,
|
||||
stderr
|
||||
)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("Dependency installation timed out for %s", plugin_id)
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
|
||||
return True
|
||||
except OSError as e:
|
||||
# A broken pipe (EPIPE) happens when pip's output pipe closes
|
||||
# mid-download, usually a network interruption.
|
||||
if e.errno == errno.EPIPE:
|
||||
self.logger.error(
|
||||
"Broken pipe error during dependency installation for %s. "
|
||||
"This usually indicates a network interruption or pip output buffer issue. "
|
||||
"Try installing again or check your network connection.", plugin_id
|
||||
)
|
||||
else:
|
||||
self.logger.error("OS error during dependency installation for %s: %s", plugin_id, e)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("Dependency installation timed out for %s", plugin_id)
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
|
||||
return True
|
||||
except OSError as e:
|
||||
# A broken pipe (EPIPE) happens when pip's output pipe closes
|
||||
# mid-download, usually a network interruption.
|
||||
if e.errno == errno.EPIPE:
|
||||
self.logger.error(
|
||||
"Broken pipe error during dependency installation for %s. "
|
||||
"This usually indicates a network interruption or pip output buffer issue. "
|
||||
"Try installing again or check your network connection.", plugin_id
|
||||
)
|
||||
else:
|
||||
self.logger.error("OS error during dependency installation for %s: %s", plugin_id, e)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _iter_plugin_bare_modules(
|
||||
@@ -585,11 +592,21 @@ class PluginLoader:
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
with self._module_load_lock:
|
||||
# Add plugin directory to sys.path if not already there
|
||||
# Put this plugin's directory first on sys.path -- moving it there
|
||||
# if it is already present. Plugins import their own modules by
|
||||
# bare name (``from sports import ...``), and those resolve to the
|
||||
# first directory that has the file. A directory added on an
|
||||
# earlier load stays where it was, so reloading a plugin (a live
|
||||
# re-enable from the web UI) after another scoreboard had loaded
|
||||
# found that one's sports.py first and failed on a name only its
|
||||
# own copy has.
|
||||
plugin_dir_str = str(plugin_dir)
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
self.logger.debug("Added plugin %s's directory to sys.path", plugin_id)
|
||||
try:
|
||||
sys.path.remove(plugin_dir_str)
|
||||
except ValueError:
|
||||
pass
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
self.logger.debug("Put plugin %s's directory first on sys.path", plugin_id)
|
||||
|
||||
# Import the plugin module
|
||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||
|
||||
@@ -52,6 +52,10 @@ class PluginManager:
|
||||
- PluginExecutor: Handles plugin execution with timeout and error isolation
|
||||
- PluginStateManager: Manages plugin state machine
|
||||
"""
|
||||
|
||||
# How long unload_plugin() waits for an in-flight update() to finish
|
||||
# before tearing the instance down anyway.
|
||||
UNLOAD_LOCK_TIMEOUT = 5.0
|
||||
|
||||
def __init__(self, plugins_dir: str = "plugins",
|
||||
config_manager: Optional[Any] = None,
|
||||
@@ -183,6 +187,8 @@ class PluginManager:
|
||||
someone opened a page -- the same log-volume problem this is meant to
|
||||
help diagnose.
|
||||
"""
|
||||
# setdefault rather than self._skip_reported: tests build a bare
|
||||
# scanner with PluginManager.__new__ and skip __init__.
|
||||
reported = self.__dict__.setdefault('_skip_reported', set())
|
||||
if key in reported:
|
||||
return
|
||||
@@ -290,7 +296,7 @@ class PluginManager:
|
||||
|
||||
return plugin_ids
|
||||
|
||||
def load_plugin(self, plugin_id: str) -> bool:
|
||||
def load_plugin(self, plugin_id: str, force_enabled: bool = False) -> bool:
|
||||
"""
|
||||
Load a plugin by ID.
|
||||
|
||||
@@ -304,6 +310,10 @@ class PluginManager:
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
force_enabled: Run the plugin enabled even though config.json has
|
||||
it disabled. On-demand uses this to show a disabled plugin
|
||||
(DisplayController._load_plugin_for_on_demand). Only the
|
||||
instance's config says enabled; config.json is not written.
|
||||
|
||||
Returns:
|
||||
True if loaded successfully, False otherwise
|
||||
@@ -370,6 +380,12 @@ class PluginManager:
|
||||
# (prepare_plugin_config). In memory only: config.json is written
|
||||
# by saves, never by loading a plugin.
|
||||
config = self.prepare_plugin_config(plugin_id, config, schema=schema)
|
||||
if force_enabled:
|
||||
# A copy: prepare_plugin_config can hand back the section from
|
||||
# config_manager's cached config, and setting the flag there
|
||||
# would read as enabled to everything else in this process.
|
||||
config = dict(config)
|
||||
config['enabled'] = True
|
||||
|
||||
# Use PluginLoader to load plugin
|
||||
plugin_instance, _module = self.plugin_loader.load_plugin(
|
||||
@@ -406,10 +422,12 @@ class PluginManager:
|
||||
try:
|
||||
if not plugin_instance.validate_config():
|
||||
self.logger.error("Plugin %s configuration validation failed", plugin_id)
|
||||
self._discard_failed_load(plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error("Error validating plugin %s config: %s", plugin_id, e, exc_info=True)
|
||||
self._discard_failed_load(plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
return False
|
||||
|
||||
@@ -433,7 +451,18 @@ class PluginManager:
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
# Call on_enable if plugin is enabled
|
||||
if hasattr(plugin_instance, 'on_enable'):
|
||||
plugin_instance.on_enable()
|
||||
try:
|
||||
plugin_instance.on_enable()
|
||||
except Exception:
|
||||
# Undo the registration above before the outer
|
||||
# handler marks it ERROR: left in self.plugins, the
|
||||
# next load_plugin() would return True as "already
|
||||
# loaded" for a plugin that never enabled.
|
||||
self.plugins.pop(plugin_id, None)
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update.pop(plugin_id, None)
|
||||
self._update_interval_cache.pop(plugin_id, None)
|
||||
raise
|
||||
else:
|
||||
self.state_manager.set_state(plugin_id, PluginState.DISABLED)
|
||||
|
||||
@@ -443,16 +472,38 @@ class PluginManager:
|
||||
|
||||
except PluginError as e:
|
||||
self.logger.error("Plugin error loading %s: %s", plugin_id, e, exc_info=True)
|
||||
self._discard_failed_load(plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error("Unexpected error loading plugin %s: %s", plugin_id, e, exc_info=True)
|
||||
self._discard_failed_load(plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
return False
|
||||
|
||||
def _discard_failed_load(self, plugin_id: str) -> None:
|
||||
"""Forget a plugin's imported module and font registrations after a
|
||||
failed load.
|
||||
|
||||
load_module() reuses ``plugin_<id>`` from sys.modules, so a module
|
||||
left behind by a load that failed after import (instantiation,
|
||||
validate_config, on_enable) would keep serving the old code even
|
||||
after the user fixes the plugin and reloads it. Never raises.
|
||||
"""
|
||||
try:
|
||||
sys.modules.pop(f"plugin_{plugin_id.replace('-', '_')}", None)
|
||||
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
self.logger.debug("Could not drop modules of %s: %s", plugin_id, e)
|
||||
try:
|
||||
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
|
||||
self.font_manager.forget_manager_fonts(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
|
||||
|
||||
#: Config keys the **core** reads out of a plugin's own config block. The
|
||||
#: plugin never declares them, so a schema with
|
||||
#: ``"additionalProperties": false`` — 37 of the 42 published ones — reports
|
||||
#: ``"additionalProperties": false`` — most published ones do — reports
|
||||
#: them as violations and the plugin gets flagged degraded in the web UI for
|
||||
#: using a documented core feature.
|
||||
#:
|
||||
@@ -594,6 +645,28 @@ class PluginManager:
|
||||
self.logger.warning("Plugin %s not loaded", plugin_id)
|
||||
return False
|
||||
|
||||
# Take the plugin's lock so cleanup()/on_disable() can't run while
|
||||
# the update worker is mid-update() on this instance. Bounded: an
|
||||
# update() that hangs past PluginExecutor's timeout keeps holding the
|
||||
# lock from its lingering thread, and unload must still go through.
|
||||
lock = self.get_plugin_lock(plugin_id)
|
||||
lock_acquired = lock.acquire(timeout=self.UNLOAD_LOCK_TIMEOUT)
|
||||
if not lock_acquired:
|
||||
self.logger.warning(
|
||||
"Plugin %s still busy after %.1fs; unloading without its lock",
|
||||
plugin_id, self.UNLOAD_LOCK_TIMEOUT)
|
||||
try:
|
||||
return self._unload_plugin_locked(plugin_id)
|
||||
finally:
|
||||
if lock_acquired:
|
||||
lock.release()
|
||||
|
||||
def _unload_plugin_locked(self, plugin_id: str) -> bool:
|
||||
"""Body of unload_plugin(); caller holds (or gave up on) the plugin lock."""
|
||||
if plugin_id not in self.plugins: # unloaded while we waited
|
||||
self.logger.warning("Plugin %s not loaded", plugin_id)
|
||||
return False
|
||||
|
||||
try:
|
||||
plugin = self.plugins[plugin_id]
|
||||
|
||||
@@ -744,7 +817,13 @@ class PluginManager:
|
||||
if plugin:
|
||||
info['loaded'] = True
|
||||
if hasattr(plugin, 'get_info'):
|
||||
info['runtime_info'] = plugin.get_info()
|
||||
# One plugin's get_info() raising must not take down the
|
||||
# whole installed-plugins listing (/api/v3/plugins/installed).
|
||||
try:
|
||||
info['runtime_info'] = plugin.get_info()
|
||||
except Exception as e:
|
||||
self.logger.warning("Plugin %s get_info() failed: %s", plugin_id, e)
|
||||
info['runtime_info'] = {}
|
||||
else:
|
||||
info['loaded'] = False
|
||||
|
||||
@@ -886,6 +965,14 @@ class PluginManager:
|
||||
updating, since a scheduler that propagates a plugin bug stops every
|
||||
other plugin too.
|
||||
|
||||
Precedence, first match wins: the ``get_update_interval()`` hook, then
|
||||
``update_interval`` in the plugin's **manifest**, then
|
||||
``update_interval`` in the plugin's section of config.json, then 60s.
|
||||
So a config value only drives the scheduler for a plugin whose
|
||||
manifest sets none; when the manifest sets one, the config value is
|
||||
ignored here (a plugin may still read it itself, e.g. to skip fetches
|
||||
inside update()).
|
||||
|
||||
The static result is cached per plugin_id after the first lookup, so
|
||||
the manifest/config resolution is not repeated on every scheduling
|
||||
tick of the display loop. A change to ``update_interval`` in
|
||||
@@ -1187,6 +1274,15 @@ class PluginManager:
|
||||
return
|
||||
finished['done'] = True
|
||||
try:
|
||||
# The plugin was unloaded (or reloaded as a new instance)
|
||||
# while this update() ran: unload_plugin() already cleared its
|
||||
# lifecycle state, so recording success/failure here would
|
||||
# resurrect a torn-down plugin as ENABLED. Only release.
|
||||
if self.plugins.get(plugin_id) is not plugin_instance:
|
||||
if lock is not None:
|
||||
with self._pending_lock:
|
||||
self._pending_updates.discard(plugin_id)
|
||||
return
|
||||
# Drop the queue reservation *before* the state goes back to
|
||||
# ENABLED. The other order leaves a window where a scheduler
|
||||
# sees ENABLED, reserves the plugin, then finds it still in
|
||||
|
||||
@@ -17,7 +17,7 @@ from src.logging_config import get_logger
|
||||
class PluginState(Enum):
|
||||
"""Plugin state enumeration."""
|
||||
UNLOADED = "unloaded" # Plugin not loaded
|
||||
LOADED = "loaded" # Plugin module loaded but not instantiated
|
||||
LOADED = "loaded" # load_plugin() in progress: set before the module is imported
|
||||
ENABLED = "enabled" # Plugin instantiated and enabled
|
||||
RUNNING = "running" # Plugin is currently executing
|
||||
ERROR = "error" # Plugin encountered an error
|
||||
|
||||
@@ -5,12 +5,14 @@ Tracks resource usage (memory, CPU, execution time) for plugins.
|
||||
Provides resource limits and performance monitoring.
|
||||
"""
|
||||
|
||||
import math
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
from typing import Dict, Optional, Any, Callable, cast
|
||||
from dataclasses import dataclass, field, fields
|
||||
|
||||
from src.logging_config import get_logger
|
||||
|
||||
try:
|
||||
import psutil
|
||||
PSUTIL_AVAILABLE = True
|
||||
@@ -31,6 +33,52 @@ class ResourceLimits:
|
||||
warning_threshold: float = 0.8 # Warning at 80% of limit
|
||||
|
||||
|
||||
_LIMIT_FIELDS = ('max_memory_mb', 'max_cpu_percent', 'max_execution_time',
|
||||
'warning_threshold')
|
||||
|
||||
|
||||
def invalid_limit_field(data: Any) -> Optional[str]:
|
||||
"""The first field of a limits mapping that isn't a valid limit, or None.
|
||||
|
||||
``"limits"`` when ``data`` isn't a mapping at all. Separate from
|
||||
limits_from_dict so a caller can report the problem without passing an
|
||||
exception's text back to a client.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return 'limits'
|
||||
for name in _LIMIT_FIELDS:
|
||||
value = data.get(name)
|
||||
if value is None:
|
||||
continue
|
||||
# bool is an int subclass; True is not a limit anyone meant.
|
||||
if (isinstance(value, bool) or not isinstance(value, (int, float))
|
||||
or not math.isfinite(value) or value < 0):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def limits_from_dict(data: Any) -> ResourceLimits:
|
||||
"""Build ResourceLimits from a JSON-shaped mapping, validating each value.
|
||||
|
||||
A dataclass does not enforce its annotations, so ResourceLimits built from
|
||||
raw request JSON or a cached record happily stores ``"50"`` -- and then
|
||||
every monitored update() raises TypeError comparing a float with it. Each
|
||||
``max_*`` value must be absent/None (no limit) or a non-negative number;
|
||||
``warning_threshold`` defaults to 0.8. Unknown keys are ignored.
|
||||
|
||||
Raises:
|
||||
ValueError: naming the first offending field.
|
||||
"""
|
||||
bad = invalid_limit_field(data)
|
||||
if bad == 'limits':
|
||||
raise ValueError(f"limits must be an object, got {type(data).__name__}")
|
||||
if bad:
|
||||
raise ValueError(
|
||||
f"{bad} must be a non-negative number or null, got {data.get(bad)!r}")
|
||||
return ResourceLimits(**{name: data[name] for name in _LIMIT_FIELDS
|
||||
if data.get(name) is not None})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceMetrics:
|
||||
"""Resource usage metrics for a plugin.
|
||||
@@ -86,11 +134,12 @@ class PluginResourceMonitor:
|
||||
"""
|
||||
self.cache_manager = cache_manager
|
||||
self.enable_monitoring = enable_monitoring and PSUTIL_AVAILABLE
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
# Resource metrics per plugin
|
||||
self._metrics: Dict[str, ResourceMetrics] = {}
|
||||
self._limits: Dict[str, ResourceLimits] = {}
|
||||
self._bad_limits_warned: set = set()
|
||||
# When each plugin's metrics last reached the cache. Metrics change on
|
||||
# every call, so they cannot be de-duplicated the way health state can;
|
||||
# they are rate-limited instead. See _METRICS_PERSIST_INTERVAL.
|
||||
@@ -160,7 +209,7 @@ class PluginResourceMonitor:
|
||||
# (not \"int\") to str"). Coerce here, where there is still a cache
|
||||
# key to name in the warning.
|
||||
declared = {f.name: f.type for f in fields(ResourceMetrics)}
|
||||
usable = {}
|
||||
usable: Dict[str, Any] = {}
|
||||
for key, value in cached.items():
|
||||
if key not in known:
|
||||
continue
|
||||
@@ -230,7 +279,17 @@ class PluginResourceMonitor:
|
||||
cache_key = self._get_limits_key(plugin_id)
|
||||
cached = self.cache_manager.get(cache_key, max_age=None)
|
||||
if cached:
|
||||
self._limits[plugin_id] = ResourceLimits(**cached)
|
||||
try:
|
||||
self._limits[plugin_id] = limits_from_dict(cached)
|
||||
except ValueError as e:
|
||||
# Treat as no limits rather than letting every update
|
||||
# of this plugin raise; warn once, not on every call.
|
||||
if plugin_id not in self._bad_limits_warned:
|
||||
self._bad_limits_warned.add(plugin_id)
|
||||
self.logger.warning(
|
||||
"Ignoring cached resource limits for %s: %s",
|
||||
plugin_id, e)
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
return self._limits[plugin_id]
|
||||
@@ -240,7 +299,7 @@ class PluginResourceMonitor:
|
||||
if not self.enable_monitoring or self._process is None:
|
||||
return 0.0
|
||||
try:
|
||||
return self._process.memory_info().rss / 1024 / 1024
|
||||
return cast(float, self._process.memory_info().rss / 1024 / 1024)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@@ -254,7 +313,7 @@ class PluginResourceMonitor:
|
||||
if not self.enable_monitoring or self._process is None:
|
||||
return 0.0
|
||||
try:
|
||||
return self._process.cpu_percent(interval=None)
|
||||
return cast(float, self._process.cpu_percent(interval=None))
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ Manages saved GitHub repository URLs for easy plugin discovery and installation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from typing import List, Dict, Optional, cast
|
||||
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.repo_urls import normalize_repo_url
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class SavedRepositoriesManager:
|
||||
config_path: Path to JSON file storing saved repositories
|
||||
"""
|
||||
self.config_path = Path(config_path)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger = get_logger(__name__)
|
||||
self.repositories = self._load_repositories()
|
||||
|
||||
def _load_repositories(self) -> List[Dict[str, str]]:
|
||||
@@ -37,7 +37,7 @@ class SavedRepositoriesManager:
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
elif isinstance(data, dict) and 'repositories' in data:
|
||||
return data['repositories']
|
||||
return cast(List[Dict[str, str]], data['repositories'])
|
||||
else:
|
||||
return []
|
||||
return []
|
||||
|
||||
@@ -8,6 +8,7 @@ Provides utilities for extracting defaults, validating configurations, and manag
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import jsonschema
|
||||
@@ -15,6 +16,8 @@ from jsonschema import Draft7Validator, ValidationError
|
||||
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||
from src.element_style import expand_style_elements
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_dirs import resolve_plugin_dir
|
||||
|
||||
|
||||
def _renders_as_object(prop: Dict[str, Any]) -> bool:
|
||||
@@ -367,7 +370,7 @@ class SchemaManager:
|
||||
device-wide ``location`` that seeds plugin location defaults.
|
||||
Omitting it simply leaves schema defaults untouched.
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.logger = logger or get_logger(__name__)
|
||||
self.plugins_dir = plugins_dir
|
||||
self.project_root = project_root or Path.cwd()
|
||||
self.config_manager = config_manager
|
||||
@@ -377,23 +380,67 @@ class SchemaManager:
|
||||
|
||||
# Default config cache: plugin_id -> default config dict
|
||||
self._defaults_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
# Schema-path misses: plugin_id -> monotonic time of the miss. A
|
||||
# lookup now scans each search directory's manifests, and plugins
|
||||
# without a schema are asked about on every page render.
|
||||
self._schema_path_misses: Dict[str, float] = {}
|
||||
self._schema_miss_logged: set = set()
|
||||
|
||||
#: How long a "no schema" answer is reused before the directories are
|
||||
#: searched again -- short, so a plugin installed by a path that doesn't
|
||||
#: call invalidate_cache() (a dev symlink, a manual copy) still shows up.
|
||||
SCHEMA_MISS_TTL = 30.0
|
||||
|
||||
def get_schema_path(self, plugin_id: str) -> Optional[Path]:
|
||||
"""
|
||||
Get the path to a plugin's config_schema.json file.
|
||||
|
||||
Tries multiple locations in order:
|
||||
|
||||
Each search directory -- plugins_dir, then PROJECT_ROOT/plugins, then
|
||||
PROJECT_ROOT/plugin-repos -- is first resolved the way the plugin
|
||||
loader resolves it (``plugin_dirs.resolve_plugin_dir``: the directory
|
||||
whose manifest declares the id, else ``<id>`` / ``ledmatrix-<id>``,
|
||||
case-insensitively). Only if none of those holds a schema are the
|
||||
literal locations tried:
|
||||
1. plugins_dir / plugin_id / config_schema.json
|
||||
2. PROJECT_ROOT / plugins / plugin_id / config_schema.json
|
||||
3. PROJECT_ROOT / plugin-repos / plugin_id / config_schema.json
|
||||
|
||||
4. a case-insensitive match of plugin_id in plugins/ and plugin-repos/
|
||||
|
||||
A miss is remembered for SCHEMA_MISS_TTL seconds (or until
|
||||
invalidate_cache()) and logged once, at DEBUG: PluginManager already
|
||||
warns at load time about a plugin that ships no schema.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
|
||||
Returns:
|
||||
Path to schema file or None if not found
|
||||
"""
|
||||
missed_at = self._schema_path_misses.get(plugin_id)
|
||||
if missed_at is not None and time.monotonic() - missed_at < self.SCHEMA_MISS_TTL:
|
||||
return None
|
||||
|
||||
search_dirs = []
|
||||
if self.plugins_dir:
|
||||
search_dirs.append(Path(self.plugins_dir))
|
||||
search_dirs.extend([self.project_root / 'plugins',
|
||||
self.project_root / 'plugin-repos'])
|
||||
|
||||
# Resolved the way the loader does, so a plugin installed as
|
||||
# ``ledmatrix-<id>`` or under a directory named differently from its
|
||||
# manifest id still gets its schema. One directory at a time keeps
|
||||
# the documented plugins/-before-plugin-repos/ order.
|
||||
possible_paths = []
|
||||
for search_dir in search_dirs:
|
||||
try:
|
||||
resolved = resolve_plugin_dir(
|
||||
plugin_id, [search_dir], prefix=True, case_insensitive=True)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
self.logger.debug(f"Could not resolve {plugin_id} in {search_dir}: {e}")
|
||||
resolved = None
|
||||
if resolved is not None:
|
||||
possible_paths.append(resolved / 'config_schema.json')
|
||||
|
||||
# Try plugins_dir if set
|
||||
if self.plugins_dir:
|
||||
@@ -416,9 +463,14 @@ class SchemaManager:
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
self.logger.debug(f"Found schema for {plugin_id} at {path}")
|
||||
self._schema_path_misses.pop(plugin_id, None)
|
||||
self._schema_miss_logged.discard(plugin_id)
|
||||
return path
|
||||
|
||||
self.logger.warning(f"Schema file not found for plugin {plugin_id}")
|
||||
|
||||
self._schema_path_misses[plugin_id] = time.monotonic()
|
||||
if plugin_id not in self._schema_miss_logged:
|
||||
self._schema_miss_logged.add(plugin_id)
|
||||
self.logger.debug(f"Schema file not found for plugin {plugin_id}")
|
||||
return None
|
||||
|
||||
def load_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[str, Any]]:
|
||||
@@ -481,10 +533,12 @@ class SchemaManager:
|
||||
if plugin_id:
|
||||
self._schema_cache.pop(plugin_id, None)
|
||||
self._defaults_cache.pop(plugin_id, None)
|
||||
self._schema_path_misses.pop(plugin_id, None)
|
||||
self.logger.debug(f"Invalidated cache for plugin {plugin_id}")
|
||||
else:
|
||||
self._schema_cache.clear()
|
||||
self._defaults_cache.clear()
|
||||
self._schema_path_misses.clear()
|
||||
self.logger.debug("Invalidated all schema caches")
|
||||
|
||||
def extract_defaults_from_schema(self, schema: Dict[str, Any], prefix: str = '') -> Dict[str, Any]:
|
||||
|
||||
@@ -13,6 +13,7 @@ from datetime import datetime
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
|
||||
from src.config_manager_atomic import atomic_write_text
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
@@ -283,6 +284,10 @@ class PluginStateManager:
|
||||
return
|
||||
|
||||
try:
|
||||
# The write stays under the lock and goes through a temp file:
|
||||
# Flask serves requests on threads, and two saves racing on a
|
||||
# plain open('w') could interleave or leave a truncated file
|
||||
# that _load_state then drops wholesale.
|
||||
with self._lock:
|
||||
# Convert states to dicts
|
||||
states_data = {
|
||||
@@ -296,16 +301,15 @@ class PluginStateManager:
|
||||
'last_updated': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Ensure directory exists with proper permissions
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_config_dir_mode
|
||||
)
|
||||
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
|
||||
|
||||
# Write to file
|
||||
with open(self.state_file, 'w') as f:
|
||||
json.dump(state_data, f, indent=2)
|
||||
# Ensure directory exists with proper permissions
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_config_dir_mode
|
||||
)
|
||||
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
|
||||
|
||||
# Write to file
|
||||
atomic_write_text(self.state_file, json.dumps(state_data, indent=2))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving plugin state: {e}", exc_info=True)
|
||||
@@ -316,7 +320,7 @@ class PluginStateManager:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.state_file, 'r') as f:
|
||||
with open(self.state_file, 'r', encoding='utf-8') as f:
|
||||
state_data = json.load(f)
|
||||
|
||||
with self._lock:
|
||||
|
||||
@@ -9,7 +9,7 @@ Detects and fixes inconsistencies between:
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List, Set
|
||||
from typing import Dict, Any, List, Set, cast
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
@@ -237,7 +237,7 @@ class StateReconciliation:
|
||||
state_manager_state = self._get_state_manager_state()
|
||||
|
||||
# Find all unique plugin IDs
|
||||
all_plugin_ids = set()
|
||||
all_plugin_ids: Set[str] = set()
|
||||
all_plugin_ids.update(config_state.keys())
|
||||
all_plugin_ids.update(disk_state.keys())
|
||||
all_plugin_ids.update(manager_state.keys())
|
||||
@@ -380,7 +380,7 @@ class StateReconciliation:
|
||||
state_manager_state: Dict[str, Dict[str, Any]]
|
||||
) -> List[Inconsistency]:
|
||||
"""Check consistency for a single plugin."""
|
||||
inconsistencies = []
|
||||
inconsistencies: List[Inconsistency] = []
|
||||
|
||||
if plugin_id in CORE_CONFIG_KEYS:
|
||||
# A plugin whose id is a core setting's key ('display', 'sync',
|
||||
@@ -496,7 +496,8 @@ class StateReconciliation:
|
||||
# Bring the state manager in sync with config rather than the reverse,
|
||||
# so that manual config edits (or the state left behind after an
|
||||
# uninstall+reinstall cycle) don't silently override the user's intent.
|
||||
config_enabled = inconsistency.expected_state.get('enabled')
|
||||
# Always set for this type (see _check_plugin_consistency).
|
||||
config_enabled = cast(bool, inconsistency.expected_state.get('enabled'))
|
||||
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
|
||||
if success:
|
||||
self.logger.info(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+19
-2478
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,852 @@
|
||||
"""Plugin store: the plugin registry, GitHub metadata, search and manifest
|
||||
validation.
|
||||
|
||||
Part of PluginStoreManager (store_manager.py), which mixes this class in;
|
||||
methods reach shared state and helpers through ``self``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import requests
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
from src.plugin_system.repo_urls import (
|
||||
github_api_headers, github_owner_repo, normalize_repo_url,
|
||||
)
|
||||
|
||||
|
||||
class _RegistryMixin:
|
||||
"""PluginStoreManager methods: see the module docstring."""
|
||||
|
||||
def _load_github_token(self) -> Optional[str]:
|
||||
"""
|
||||
Load GitHub API token from config_secrets.json if available.
|
||||
|
||||
Returns:
|
||||
GitHub token or None if not configured
|
||||
"""
|
||||
try:
|
||||
config_path = Path(__file__).parent.parent.parent / "config" / "config_secrets.json"
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
token = config.get('github', {}).get('api_token', '').strip()
|
||||
# The config template's placeholder, not a credential.
|
||||
if token and token != "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN": # nosec B105 # nosemgrep
|
||||
return token
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not load GitHub token: {e}")
|
||||
return None
|
||||
|
||||
def _validate_github_token(self, token: str) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate a GitHub token by making a lightweight API call.
|
||||
|
||||
Args:
|
||||
token: GitHub personal access token to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
- is_valid: True if token is valid, False otherwise
|
||||
- error_message: None if valid, error description if invalid
|
||||
"""
|
||||
if not token:
|
||||
return (False, "No token provided")
|
||||
|
||||
# Check cache first
|
||||
cache_key = token[:10] # Use first 10 chars as cache key for privacy
|
||||
if cache_key in self._token_validation_cache:
|
||||
cached_valid, cached_time, cached_error = self._token_validation_cache[cache_key]
|
||||
if time.time() - cached_time < self._token_validation_cache_timeout:
|
||||
return (cached_valid, cached_error)
|
||||
|
||||
# Validate token by making a lightweight API call to /user endpoint
|
||||
try:
|
||||
api_url = "https://api.github.com/user"
|
||||
response = requests.get(api_url, headers=github_api_headers(token), timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Token is valid
|
||||
result = (True, None)
|
||||
self._token_validation_cache[cache_key] = (True, time.time(), None)
|
||||
return result
|
||||
elif response.status_code == 401:
|
||||
# Token is invalid or expired
|
||||
error_msg = "Token is invalid or expired"
|
||||
result = (False, error_msg)
|
||||
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
|
||||
return result
|
||||
elif response.status_code == 403:
|
||||
# Rate limit or forbidden (but token might be valid)
|
||||
# Check if it's a rate limit issue
|
||||
if 'rate limit' in response.text.lower():
|
||||
# Rate limit: return error but don't cache (rate limits are temporary)
|
||||
error_msg = "Rate limit exceeded"
|
||||
result = (False, error_msg)
|
||||
return result
|
||||
else:
|
||||
# Token lacks permissions: cache the result (permissions don't change)
|
||||
error_msg = "Token lacks required permissions"
|
||||
result = (False, error_msg)
|
||||
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
|
||||
return result
|
||||
else:
|
||||
# Other error
|
||||
error_msg = f"GitHub API error: {response.status_code}"
|
||||
result = (False, error_msg)
|
||||
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
|
||||
return result
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
error_msg = "GitHub API request timed out"
|
||||
result = (False, error_msg)
|
||||
# Don't cache timeout errors
|
||||
return result
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f"Network error: {str(e)}"
|
||||
result = (False, error_msg)
|
||||
# Don't cache network errors
|
||||
return result
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error: {str(e)}"
|
||||
result = (False, error_msg)
|
||||
# Don't cache unexpected errors
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _iso_to_date(iso_timestamp: str) -> str:
|
||||
"""Convert an ISO timestamp to YYYY-MM-DD string."""
|
||||
if not iso_timestamp:
|
||||
return ""
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
|
||||
return dt.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _distinct_sequence(values: List[str]) -> List[str]:
|
||||
"""Return list preserving order while removing duplicates and falsey entries."""
|
||||
seen = set()
|
||||
ordered = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
if value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
ordered.append(value)
|
||||
return ordered
|
||||
|
||||
def _validate_manifest_version_fields(self, manifest: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Validate version-related fields in manifest for consistency.
|
||||
|
||||
Checks:
|
||||
- compatible_versions is present and is an array
|
||||
- Standardized field names are used (min_ledmatrix_version, max_ledmatrix_version)
|
||||
- Deprecated fields are not used (ledmatrix_version)
|
||||
- versions array entries use ledmatrix_min_version instead of ledmatrix_min
|
||||
|
||||
Args:
|
||||
manifest: Manifest dictionary to validate
|
||||
|
||||
Returns:
|
||||
List of validation error/warning messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check compatible_versions is an array
|
||||
if 'compatible_versions' in manifest:
|
||||
if not isinstance(manifest['compatible_versions'], list):
|
||||
errors.append("compatible_versions must be an array")
|
||||
elif len(manifest['compatible_versions']) == 0:
|
||||
errors.append("compatible_versions array cannot be empty")
|
||||
|
||||
# Warn about deprecated ledmatrix_version field
|
||||
if 'ledmatrix_version' in manifest:
|
||||
errors.append("ledmatrix_version is deprecated, use compatible_versions instead")
|
||||
|
||||
# Check versions array entries use standardized field names
|
||||
if 'versions' in manifest and isinstance(manifest['versions'], list):
|
||||
for i, version_entry in enumerate(manifest['versions']):
|
||||
if not isinstance(version_entry, dict):
|
||||
continue
|
||||
|
||||
# Check for old ledmatrix_min field
|
||||
if 'ledmatrix_min' in version_entry and 'ledmatrix_min_version' not in version_entry:
|
||||
errors.append(f"versions[{i}] uses deprecated 'ledmatrix_min', should use 'ledmatrix_min_version'")
|
||||
|
||||
return errors
|
||||
|
||||
def _validate_manifest_schema(self, manifest: Dict[str, Any], plugin_id: str) -> List[str]:
|
||||
"""
|
||||
Validate manifest against JSON schema if available.
|
||||
|
||||
Args:
|
||||
manifest: Manifest dictionary to validate
|
||||
plugin_id: Plugin ID for error messages
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid or schema unavailable)
|
||||
"""
|
||||
try:
|
||||
# Load manifest schema
|
||||
schema_path = Path(__file__).parent.parent.parent / "schema" / "manifest_schema.json"
|
||||
if not schema_path.exists():
|
||||
return [] # Schema not available, skip validation
|
||||
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
|
||||
# Validate schema itself
|
||||
Draft7Validator.check_schema(schema)
|
||||
|
||||
# Validate manifest against schema
|
||||
validator = Draft7Validator(schema)
|
||||
errors = []
|
||||
for error in validator.iter_errors(manifest):
|
||||
error_path = '.'.join(str(p) for p in error.path)
|
||||
errors.append(f"{error_path}: {error.message}")
|
||||
|
||||
return errors
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.warning(f"Could not parse manifest schema: {e}")
|
||||
return []
|
||||
except ValidationError as e:
|
||||
self.logger.warning(f"Manifest schema is invalid: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error validating manifest schema for {plugin_id}: {e}")
|
||||
return []
|
||||
|
||||
_EMPTY_REPO_INFO: Dict[str, Any] = {
|
||||
'stars': 0,
|
||||
'forks': 0,
|
||||
'open_issues': 0,
|
||||
'updated_at_iso': '',
|
||||
'last_commit_iso': '',
|
||||
'last_commit_date': '',
|
||||
'language': '',
|
||||
'license': '',
|
||||
'default_branch': 'main',
|
||||
}
|
||||
|
||||
def _get_github_repo_info(self, repo_url: str) -> Dict[str, Any]:
|
||||
"""GitHub metadata for a repository (stars, default branch, last push).
|
||||
|
||||
Returns zeroed defaults (``_EMPTY_REPO_INFO``) for a non-GitHub URL or
|
||||
when GitHub cannot be asked and nothing is cached.
|
||||
"""
|
||||
try:
|
||||
owner_repo = github_owner_repo(repo_url)
|
||||
if owner_repo is None:
|
||||
return dict(self._EMPTY_REPO_INFO)
|
||||
owner, repo = owner_repo
|
||||
cache_key = f"{owner}/{repo}"
|
||||
|
||||
if cache_key in self.github_cache:
|
||||
cached_time, cached_data = self.github_cache[cache_key]
|
||||
if time.time() - cached_time < self.cache_timeout:
|
||||
return cached_data
|
||||
|
||||
api_url = f"https://api.github.com/repos/{owner}/{repo}"
|
||||
try:
|
||||
response = requests.get(
|
||||
api_url, headers=github_api_headers(self.github_token), timeout=10)
|
||||
except requests.RequestException as req_err:
|
||||
# Network error: prefer a stale cache hit over an empty
|
||||
# default so the UI keeps working on a flaky Pi WiFi link.
|
||||
# Bump the cached entry's timestamp into a short backoff
|
||||
# window so subsequent requests serve the stale payload
|
||||
# cheaply instead of re-hitting the network on every request.
|
||||
if cache_key in self.github_cache:
|
||||
_, stale = self.github_cache[cache_key]
|
||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||
self.logger.warning(
|
||||
"GitHub repo info fetch failed for %s (%s); serving stale cache.",
|
||||
cache_key, req_err,
|
||||
)
|
||||
return stale
|
||||
raise
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
pushed_at = data.get('pushed_at', '') or data.get('updated_at', '')
|
||||
repo_info = {
|
||||
'stars': data.get('stargazers_count', 0),
|
||||
'forks': data.get('forks_count', 0),
|
||||
'open_issues': data.get('open_issues_count', 0),
|
||||
'updated_at_iso': data.get('updated_at', ''),
|
||||
'last_commit_iso': pushed_at,
|
||||
'last_commit_date': self._iso_to_date(pushed_at),
|
||||
'language': data.get('language', ''),
|
||||
'license': data.get('license', {}).get('name', '') if data.get('license') else '',
|
||||
'default_branch': data.get('default_branch', 'main')
|
||||
}
|
||||
self.github_cache[cache_key] = (time.time(), repo_info)
|
||||
return repo_info
|
||||
|
||||
if response.status_code == 403:
|
||||
# Rate limit or authentication issue. A stale star count is
|
||||
# better than a reset to zero, and the backoff bump stops the
|
||||
# store hammering the API while rate-limited.
|
||||
if cache_key in self.github_cache:
|
||||
_, stale = self.github_cache[cache_key]
|
||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||
self.logger.warning(
|
||||
"GitHub API 403 for %s; serving stale cache.", cache_key,
|
||||
)
|
||||
return stale
|
||||
if not self.github_token:
|
||||
self.logger.warning(
|
||||
"GitHub API rate limit likely exceeded (403). "
|
||||
"Add a GitHub personal access token to config/config_secrets.json "
|
||||
"under 'github.api_token' to increase rate limits from 60 to 5000/hour."
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"GitHub API request failed: 403 for {api_url}. "
|
||||
f"Your token may have insufficient permissions or rate limit exceeded."
|
||||
)
|
||||
else:
|
||||
self.logger.warning(f"GitHub API request failed: {response.status_code} for {api_url}")
|
||||
if cache_key in self.github_cache:
|
||||
_, stale = self.github_cache[cache_key]
|
||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||
return stale
|
||||
|
||||
return dict(self._EMPTY_REPO_INFO)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Offline, DNS or a timeout reaching GitHub: the listing still
|
||||
# works without the extra repo info, so this is not an error.
|
||||
self.logger.warning("GitHub repo info unavailable for %s: %s", repo_url, e)
|
||||
return dict(self._EMPTY_REPO_INFO)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching GitHub repo info for {repo_url}: {e}")
|
||||
return dict(self._EMPTY_REPO_INFO)
|
||||
|
||||
def _http_get_with_retries(self, url: str, *, timeout: int = 10, stream: bool = False, headers: Dict[str, str] = None, max_retries: int = 3, backoff_sec: float = 0.75):
|
||||
"""
|
||||
HTTP GET with simple retry strategy and exponential backoff.
|
||||
|
||||
Returns a requests.Response or raises the last exception.
|
||||
"""
|
||||
last_exc = None
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
resp = requests.get(url, timeout=timeout, stream=stream, headers=headers)
|
||||
return resp
|
||||
except requests.RequestException as e:
|
||||
last_exc = e
|
||||
self.logger.warning(f"HTTP GET failed (attempt {attempt}/{max_retries}) for {url}: {e}")
|
||||
if attempt < max_retries:
|
||||
time.sleep(backoff_sec * attempt)
|
||||
# Exhausted retries
|
||||
raise last_exc
|
||||
|
||||
def fetch_registry_from_url(self, repo_url: str) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch a registry-style plugins.json from a custom GitHub repository URL.
|
||||
|
||||
This allows users to point to a registry-style monorepo (like the official
|
||||
ledmatrix-plugins repo) and browse/install plugins from it.
|
||||
|
||||
Args:
|
||||
repo_url: GitHub repository URL (e.g., https://github.com/user/ledmatrix-plugins)
|
||||
|
||||
Returns:
|
||||
Registry dict with plugins list, or None if not found/invalid
|
||||
"""
|
||||
try:
|
||||
repo_url = normalize_repo_url(repo_url)
|
||||
|
||||
# plugins.json or registry.json at the root of main, then master.
|
||||
registry_urls = []
|
||||
owner_repo = github_owner_repo(repo_url)
|
||||
if owner_repo is not None:
|
||||
owner, repo = owner_repo
|
||||
for branch in ['main', 'master']:
|
||||
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/plugins.json")
|
||||
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/registry.json")
|
||||
|
||||
for url in registry_urls:
|
||||
try:
|
||||
response = self._http_get_with_retries(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
registry = response.json()
|
||||
# Validate it looks like a registry
|
||||
if isinstance(registry, dict) and 'plugins' in registry:
|
||||
self.logger.info(f"Successfully fetched registry from {url}")
|
||||
return registry
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to fetch from {url}: {e}")
|
||||
continue
|
||||
|
||||
self.logger.warning(f"No valid registry found at {repo_url}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching registry from URL: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def fetch_registry(self, force_refresh: bool = False, raise_on_failure: bool = False) -> Dict:
|
||||
"""
|
||||
Fetch the plugin registry from GitHub.
|
||||
|
||||
Args:
|
||||
force_refresh: Force refresh even if cached
|
||||
raise_on_failure: If True, re-raise network / JSON errors instead
|
||||
of silently falling back to stale cache / empty dict. UI
|
||||
callers prefer the stale-fallback default so the plugin
|
||||
list keeps working on flaky WiFi; the state reconciler
|
||||
needs the explicit failure signal so it can distinguish
|
||||
"plugin genuinely not in registry" from "I couldn't reach
|
||||
the registry at all" and not mark everything unrecoverable.
|
||||
|
||||
Returns:
|
||||
Registry data with list of available plugins
|
||||
|
||||
Raises:
|
||||
requests.RequestException / json.JSONDecodeError when
|
||||
``raise_on_failure`` is True and the fetch fails.
|
||||
"""
|
||||
# Check if cache is still valid (within timeout)
|
||||
current_time = time.time()
|
||||
if (self.registry_cache and self.registry_cache_time and
|
||||
not force_refresh and
|
||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||
return self.registry_cache
|
||||
|
||||
with self._registry_fetch_lock:
|
||||
# Re-check inside the lock — a concurrent caller that was waiting
|
||||
# may have already populated the cache while we blocked.
|
||||
current_time = time.time()
|
||||
if (self.registry_cache and self.registry_cache_time and
|
||||
not force_refresh and
|
||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||
return self.registry_cache
|
||||
|
||||
try:
|
||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
self.registry_cache = response.json()
|
||||
self.registry_cache_time = current_time
|
||||
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
||||
return self.registry_cache
|
||||
except requests.RequestException as e:
|
||||
self.logger.error(f"Error fetching registry: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
# Prefer stale cache over an empty list so the plugin list UI
|
||||
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
||||
# registry_cache_time into a short backoff window so the next
|
||||
# request serves the stale payload cheaply instead of
|
||||
# re-hitting the network on every request (matches the
|
||||
# pattern used by github_cache / commit_info_cache).
|
||||
if self.registry_cache:
|
||||
self.logger.warning("Falling back to stale registry cache")
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Error parsing registry JSON: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
if self.registry_cache:
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
|
||||
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
|
||||
"""
|
||||
Search for plugins in the registry with enhanced metadata.
|
||||
|
||||
GitHub supplies live metadata such as stars and last commit
|
||||
timestamps; the registry supplies descriptive information (name,
|
||||
description, repo URL, etc.).
|
||||
|
||||
Args:
|
||||
query: Search query string (searches name, description, id, author)
|
||||
category: Filter by category (e.g., 'sports', 'weather', 'time')
|
||||
tags: Filter by tags (matches any tag in list)
|
||||
fetch_commit_info: If True (default), fetch commit metadata from GitHub.
|
||||
include_saved_repos: If True (default), also search the
|
||||
registry-style repositories the user saved.
|
||||
saved_repositories_manager: The SavedRepositoriesManager holding
|
||||
those repositories; without it only the official registry is
|
||||
searched.
|
||||
|
||||
Returns:
|
||||
List of matching plugin metadata enriched with GitHub information
|
||||
"""
|
||||
if tags is None:
|
||||
tags = []
|
||||
|
||||
# Fetch from official registry
|
||||
registry = self.fetch_registry()
|
||||
plugins = registry.get('plugins', []) or []
|
||||
|
||||
# Also fetch from saved repositories if enabled
|
||||
if include_saved_repos and saved_repositories_manager:
|
||||
saved_repos = saved_repositories_manager.get_registry_repositories()
|
||||
for repo_info in saved_repos:
|
||||
repo_url = repo_info.get('url')
|
||||
if repo_url:
|
||||
try:
|
||||
custom_registry = self.fetch_registry_from_url(repo_url)
|
||||
if custom_registry:
|
||||
custom_plugins = custom_registry.get('plugins', []) or []
|
||||
# Mark these as from custom repository
|
||||
for plugin in custom_plugins:
|
||||
plugin['_source'] = 'custom_repository'
|
||||
plugin['_repository_url'] = repo_url
|
||||
plugin['_repository_name'] = repo_info.get('name', repo_url)
|
||||
plugins.extend(custom_plugins)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to fetch plugins from saved repository {repo_url}: {e}")
|
||||
|
||||
# First pass: apply cheap filters (category/tags/query) so we only
|
||||
# fetch GitHub metadata for plugins that will actually be returned.
|
||||
filtered: List[Dict] = []
|
||||
for plugin in plugins:
|
||||
if category and plugin.get('category') != category:
|
||||
continue
|
||||
if tags and not any(tag in plugin.get('tags', []) for tag in tags):
|
||||
continue
|
||||
if query:
|
||||
query_lower = query.lower()
|
||||
searchable_text = ' '.join([
|
||||
plugin.get('name', ''),
|
||||
plugin.get('description', ''),
|
||||
plugin.get('id', ''),
|
||||
plugin.get('author', ''),
|
||||
]).lower()
|
||||
if query_lower not in searchable_text:
|
||||
continue
|
||||
filtered.append(plugin)
|
||||
|
||||
def _enrich(plugin: Dict) -> Dict:
|
||||
"""Enrich a single plugin with GitHub metadata.
|
||||
|
||||
Called concurrently from a ThreadPoolExecutor. Both HTTP helpers
|
||||
(``_get_github_repo_info`` / ``_get_latest_commit_info``) are
|
||||
thread-safe -- they use ``requests`` and write their own cache
|
||||
keys on Python dicts, which is atomic under the GIL for
|
||||
single-key assignments.
|
||||
"""
|
||||
enhanced_plugin = plugin.copy()
|
||||
repo_url = plugin.get('repo', '')
|
||||
if not repo_url:
|
||||
return enhanced_plugin
|
||||
|
||||
github_info = self._get_github_repo_info(repo_url)
|
||||
enhanced_plugin['stars'] = github_info.get('stars', plugin.get('stars', 0))
|
||||
enhanced_plugin['default_branch'] = github_info.get('default_branch', plugin.get('branch', 'main'))
|
||||
enhanced_plugin['last_updated_iso'] = github_info.get('last_commit_iso')
|
||||
enhanced_plugin['last_updated'] = github_info.get('last_commit_date')
|
||||
|
||||
if fetch_commit_info:
|
||||
branch = plugin.get('branch') or github_info.get('default_branch', 'main')
|
||||
|
||||
commit_info = self._get_latest_commit_info(repo_url, branch)
|
||||
if commit_info:
|
||||
enhanced_plugin['last_commit'] = commit_info.get('short_sha')
|
||||
enhanced_plugin['last_commit_sha'] = commit_info.get('sha')
|
||||
enhanced_plugin['last_updated'] = commit_info.get('date') or enhanced_plugin.get('last_updated')
|
||||
enhanced_plugin['last_updated_iso'] = commit_info.get('date_iso') or enhanced_plugin.get('last_updated_iso')
|
||||
enhanced_plugin['last_commit_message'] = commit_info.get('message')
|
||||
enhanced_plugin['last_commit_author'] = commit_info.get('author')
|
||||
enhanced_plugin['branch'] = commit_info.get('branch', branch)
|
||||
enhanced_plugin['last_commit_branch'] = commit_info.get('branch')
|
||||
|
||||
# Intentionally NO per-plugin manifest.json fetch here.
|
||||
# The registry's plugins.json already carries ``description``
|
||||
# (it is generated from each plugin's manifest by
|
||||
# ``update_registry.py``), and ``last_updated`` is filled in
|
||||
# from the commit info above. Fetching manifest.json per
|
||||
# plugin costs one extra HTTPS round trip per result; on a Pi4
|
||||
# with a flaky WiFi link the tail retries of that one call
|
||||
# (_http_get_with_retries does 3 attempts with exponential
|
||||
# backoff) dominate wall time even with the thread pool.
|
||||
|
||||
return enhanced_plugin
|
||||
|
||||
# Fan out the per-plugin GitHub enrichment. Serially, a Pi4 with ~15
|
||||
# plugins and a cold cache makes 30+ HTTP requests in strict sequence
|
||||
# (the "connecting to display" hang users reported). With a thread
|
||||
# pool, latency is dominated by the slowest request rather than
|
||||
# their sum. Workers capped at 10 to stay well under the
|
||||
# unauthenticated GitHub rate limit burst and avoid overwhelming a
|
||||
# Pi's WiFi link.
|
||||
if not filtered:
|
||||
return []
|
||||
|
||||
# Not worth the pool overhead for tiny workloads. Parenthesized to
|
||||
# make Python's default ``and`` > ``or`` precedence explicit: a
|
||||
# single plugin, OR a small batch where we don't need commit info.
|
||||
if (len(filtered) == 1) or ((not fetch_commit_info) and (len(filtered) < 4)):
|
||||
return [_enrich(p) for p in filtered]
|
||||
|
||||
max_workers = min(10, len(filtered))
|
||||
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='plugin-search') as executor:
|
||||
# executor.map preserves input order, which the UI relies on.
|
||||
return list(executor.map(_enrich, filtered))
|
||||
|
||||
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json", force_refresh: bool = False) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch manifest.json directly from a GitHub repository.
|
||||
|
||||
Args:
|
||||
repo_url: GitHub repository URL
|
||||
branch: Branch name (default: master)
|
||||
manifest_path: Path to manifest within the repo (default: manifest.json).
|
||||
For monorepo plugins this will be e.g. "plugins/football-scoreboard/manifest.json".
|
||||
force_refresh: If True, bypass the cache.
|
||||
|
||||
Returns:
|
||||
Manifest data or None if not found
|
||||
"""
|
||||
try:
|
||||
owner_repo = github_owner_repo(repo_url)
|
||||
if owner_repo is None:
|
||||
return None
|
||||
owner, repo = owner_repo
|
||||
|
||||
cache_key = f"{owner}/{repo}:{branch}:{manifest_path}"
|
||||
if not force_refresh and cache_key in self.manifest_cache:
|
||||
cached_time, cached_data = self.manifest_cache[cache_key]
|
||||
if time.time() - cached_time < self.manifest_cache_timeout:
|
||||
return cached_data
|
||||
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}"
|
||||
response = self._http_get_with_retries(raw_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
self.manifest_cache[cache_key] = (time.time(), result)
|
||||
return result
|
||||
if response.status_code == 404 and branch != "main":
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}"
|
||||
response = self._http_get_with_retries(raw_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
self.manifest_cache[cache_key] = (time.time(), result)
|
||||
return result
|
||||
|
||||
# Cache the miss too, so a plugin without a manifest at this path
|
||||
# is not re-fetched on every browse.
|
||||
self.manifest_cache[cache_key] = (time.time(), None)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _get_latest_commit_info(self, repo_url: str, branch: str = "main", force_refresh: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""Return metadata about the latest commit on the given branch."""
|
||||
try:
|
||||
owner_repo = github_owner_repo(repo_url)
|
||||
if owner_repo is None:
|
||||
return None
|
||||
owner, repo = owner_repo
|
||||
|
||||
cache_key = f"{owner}/{repo}:{branch}"
|
||||
if not force_refresh and cache_key in self.commit_info_cache:
|
||||
cached_time, cached_data = self.commit_info_cache[cache_key]
|
||||
if time.time() - cached_time < self.commit_cache_timeout:
|
||||
return cached_data
|
||||
|
||||
branches_to_try = self._distinct_sequence([branch, 'main', 'master'])
|
||||
headers = github_api_headers(self.github_token)
|
||||
|
||||
last_error = None
|
||||
for branch_name in branches_to_try:
|
||||
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{branch_name}"
|
||||
try:
|
||||
response = requests.get(api_url, headers=headers, timeout=10)
|
||||
except requests.RequestException as req_err:
|
||||
# Network failure: fall back to a stale cache hit if
|
||||
# available so the plugin store UI keeps populating
|
||||
# commit info on a flaky WiFi link. Bump the cached
|
||||
# timestamp into the backoff window so we don't
|
||||
# re-retry on every request.
|
||||
if cache_key in self.commit_info_cache:
|
||||
_, stale = self.commit_info_cache[cache_key]
|
||||
if stale is not None:
|
||||
self._record_cache_backoff(
|
||||
self.commit_info_cache, cache_key,
|
||||
self.commit_cache_timeout, stale,
|
||||
)
|
||||
self.logger.warning(
|
||||
"GitHub commit fetch failed for %s (%s); serving stale cache.",
|
||||
cache_key, req_err,
|
||||
)
|
||||
return stale
|
||||
last_error = str(req_err)
|
||||
continue
|
||||
if response.status_code == 200:
|
||||
commit_data = response.json()
|
||||
commit_sha_full = commit_data.get('sha', '')
|
||||
commit_sha_short = commit_sha_full[:7] if commit_sha_full else ''
|
||||
commit_meta = commit_data.get('commit', {})
|
||||
commit_author = commit_meta.get('author', {})
|
||||
commit_date_iso = commit_author.get('date', '')
|
||||
|
||||
result = {
|
||||
'branch': branch_name,
|
||||
'sha': commit_sha_full,
|
||||
'short_sha': commit_sha_short,
|
||||
'date_iso': commit_date_iso,
|
||||
'date': self._iso_to_date(commit_date_iso),
|
||||
'author': commit_author.get('name', ''),
|
||||
'message': commit_meta.get('message', ''),
|
||||
}
|
||||
self.commit_info_cache[cache_key] = (time.time(), result)
|
||||
return result
|
||||
|
||||
if response.status_code == 403 and not self.github_token:
|
||||
self.logger.debug("GitHub commit API rate limited (403). Consider adding a token.")
|
||||
last_error = response.text
|
||||
else:
|
||||
last_error = response.text
|
||||
|
||||
if last_error:
|
||||
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
|
||||
|
||||
# All branches returned a non-200 response (e.g. 404 on every
|
||||
# candidate, or a transient 5xx). If we already had a good
|
||||
# cached value, prefer serving that — overwriting it with
|
||||
# None here would wipe out commit info the UI just showed
|
||||
# on the previous request. Bump the timestamp into the
|
||||
# backoff window so subsequent lookups hit the cache.
|
||||
if cache_key in self.commit_info_cache:
|
||||
_, prior = self.commit_info_cache[cache_key]
|
||||
if prior is not None:
|
||||
self._record_cache_backoff(
|
||||
self.commit_info_cache, cache_key,
|
||||
self.commit_cache_timeout, prior,
|
||||
)
|
||||
return prior
|
||||
|
||||
# No prior good value — cache the negative result so we don't
|
||||
# hammer a plugin that genuinely has no reachable commits.
|
||||
self.commit_info_cache[cache_key] = (time.time(), None)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error fetching latest commit metadata for {repo_url}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True, force_refresh: bool = False) -> Optional[Dict]:
|
||||
"""
|
||||
Get detailed information about a plugin from the registry.
|
||||
|
||||
GitHub provides authoritative metadata such as stars and the latest
|
||||
commit. The registry supplies descriptive information (name, id, repo URL).
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
fetch_latest_from_github: If True (default), augment with GitHub commit metadata.
|
||||
force_refresh: If True, bypass caches for commit/manifest data.
|
||||
|
||||
Returns:
|
||||
Plugin metadata or None if not found
|
||||
"""
|
||||
registry = self.fetch_registry()
|
||||
plugins = registry.get('plugins', []) or []
|
||||
plugin_info = self._match_registry_entry(plugins, plugin_id)
|
||||
|
||||
if not plugin_info:
|
||||
return None
|
||||
|
||||
if fetch_latest_from_github:
|
||||
repo_url = plugin_info.get('repo')
|
||||
if repo_url:
|
||||
plugin_info = plugin_info.copy()
|
||||
|
||||
github_info = self._get_github_repo_info(repo_url)
|
||||
branch = plugin_info.get('branch') or github_info.get('default_branch', 'main')
|
||||
|
||||
plugin_info['default_branch'] = github_info.get('default_branch', branch)
|
||||
plugin_info['stars'] = github_info.get('stars', plugin_info.get('stars', 0))
|
||||
plugin_info['last_updated'] = github_info.get('last_commit_date', plugin_info.get('last_updated'))
|
||||
plugin_info['last_updated_iso'] = github_info.get('last_commit_iso', plugin_info.get('last_updated_iso'))
|
||||
|
||||
commit_info = self._get_latest_commit_info(repo_url, branch, force_refresh=force_refresh)
|
||||
if commit_info:
|
||||
plugin_info['last_commit'] = commit_info.get('short_sha')
|
||||
plugin_info['last_commit_sha'] = commit_info.get('sha')
|
||||
plugin_info['last_commit_message'] = commit_info.get('message')
|
||||
plugin_info['last_commit_author'] = commit_info.get('author')
|
||||
plugin_info['last_updated'] = commit_info.get('date') or plugin_info.get('last_updated')
|
||||
plugin_info['last_updated_iso'] = commit_info.get('date_iso') or plugin_info.get('last_updated_iso')
|
||||
plugin_info['branch'] = commit_info.get('branch', branch)
|
||||
plugin_info['last_commit_branch'] = commit_info.get('branch')
|
||||
|
||||
plugin_subpath = plugin_info.get('plugin_path', '')
|
||||
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
|
||||
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel, force_refresh=force_refresh)
|
||||
if github_manifest:
|
||||
if 'last_updated' in github_manifest and not plugin_info.get('last_updated'):
|
||||
plugin_info['last_updated'] = github_manifest['last_updated']
|
||||
if 'description' in github_manifest:
|
||||
plugin_info['description'] = github_manifest['description']
|
||||
|
||||
return plugin_info
|
||||
|
||||
@staticmethod
|
||||
def _match_registry_entry(plugins: List[Dict], plugin_id: str) -> Optional[Dict]:
|
||||
"""Find a registry entry by its id, or by the directory it installs to.
|
||||
|
||||
Four shipped plugins have a registry ``id`` that differs from the ``id``
|
||||
in their own manifest: ``weather`` installs to ``plugins/ledmatrix-weather``,
|
||||
and likewise stocks, music and leaderboard. Installation already prefers
|
||||
the manifest id for the directory name, so on disk, in ``config.json``
|
||||
and in a backup manifest those plugins are called ``ledmatrix-weather``.
|
||||
|
||||
Only the registry calls them ``weather``, and nothing resolved that in
|
||||
reverse: restoring a backup asked the store for ``ledmatrix-weather``
|
||||
and got "Plugin not found in registry", silently dropping four enabled
|
||||
plugins from a restored device.
|
||||
|
||||
Matching ``plugin_path`` fixes it without renaming any published id,
|
||||
which would orphan ``plugin_state.json`` entries keyed on the old ones.
|
||||
Exact id always wins, so an entry whose *path* happens to collide with
|
||||
another entry's id cannot shadow it.
|
||||
"""
|
||||
if not plugin_id:
|
||||
return None
|
||||
exact = next((p for p in plugins if p.get('id') == plugin_id), None)
|
||||
if exact is not None:
|
||||
return exact
|
||||
for entry in plugins:
|
||||
path = (entry.get('plugin_path') or '').rstrip('/')
|
||||
if path and path.rsplit('/', 1)[-1] == plugin_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get plugin information from the registry cache only (no GitHub API calls).
|
||||
|
||||
Use this for lightweight lookups where only registry fields are needed
|
||||
(e.g., verified status, latest_version).
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Plugin metadata from registry or None if not found
|
||||
"""
|
||||
registry = self.fetch_registry()
|
||||
plugins = registry.get('plugins', []) or []
|
||||
return self._match_registry_entry(plugins, plugin_id)
|
||||
@@ -0,0 +1,732 @@
|
||||
"""Plugin store: updating installed plugins, with rollback, and reading their
|
||||
local git state.
|
||||
|
||||
Part of PluginStoreManager (store_manager.py), which mixes this class in;
|
||||
methods reach shared state and helpers through ``self``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
from src.plugin_system.plugin_dirs import BACKUP_MARKER
|
||||
from src.plugin_system.repo_urls import same_repo
|
||||
|
||||
|
||||
class _UpdateMixin:
|
||||
"""PluginStoreManager methods: see the module docstring."""
|
||||
|
||||
def _git_cache_signature(self, git_dir: Path) -> Optional[Tuple]:
|
||||
"""Build a cache signature that invalidates on the kind of updates
|
||||
a plugin user actually cares about.
|
||||
|
||||
Caching on ``.git/HEAD`` mtime alone is not enough: a ``git pull``
|
||||
that fast-forwards the current branch updates
|
||||
``.git/refs/heads/<branch>`` (or ``.git/packed-refs``) but leaves
|
||||
HEAD's contents and mtime untouched. And the cached ``result``
|
||||
dict includes ``remote_url`` — a value read from ``.git/config`` —
|
||||
so a config-only change (e.g. a monorepo-migration re-pointing
|
||||
``remote.origin.url``) must also invalidate the cache.
|
||||
|
||||
Signature components:
|
||||
- HEAD contents (catches detach / branch switch)
|
||||
- HEAD mtime
|
||||
- if HEAD points at a ref, that ref file's mtime (catches
|
||||
fast-forward / reset on the current branch)
|
||||
- packed-refs mtime as a coarse fallback for repos using packed refs
|
||||
- .git/config contents + mtime (catches remote URL changes and
|
||||
any other config-only edit that affects what the cached
|
||||
``remote_url`` field should contain)
|
||||
|
||||
Returns ``None`` if HEAD cannot be read at all (caller will skip
|
||||
the cache and take the slow path).
|
||||
"""
|
||||
head_file = git_dir / 'HEAD'
|
||||
try:
|
||||
head_mtime = head_file.stat().st_mtime
|
||||
head_contents = head_file.read_text(encoding='utf-8', errors='replace').strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
ref_mtime = None
|
||||
if head_contents.startswith('ref: '):
|
||||
ref_path = head_contents[len('ref: '):].strip()
|
||||
# ``ref_path`` looks like ``refs/heads/main``. It lives either
|
||||
# as a loose file under .git/ or inside .git/packed-refs.
|
||||
loose_ref = git_dir / ref_path
|
||||
try:
|
||||
ref_mtime = loose_ref.stat().st_mtime
|
||||
except OSError:
|
||||
ref_mtime = None
|
||||
|
||||
packed_refs_mtime = None
|
||||
if ref_mtime is None:
|
||||
try:
|
||||
packed_refs_mtime = (git_dir / 'packed-refs').stat().st_mtime
|
||||
except OSError:
|
||||
packed_refs_mtime = None
|
||||
|
||||
config_mtime = None
|
||||
config_contents = None
|
||||
config_file = git_dir / 'config'
|
||||
try:
|
||||
config_mtime = config_file.stat().st_mtime
|
||||
config_contents = config_file.read_text(encoding='utf-8', errors='replace').strip()
|
||||
except OSError:
|
||||
config_mtime = None
|
||||
config_contents = None
|
||||
|
||||
return (
|
||||
head_contents, head_mtime,
|
||||
ref_mtime, packed_refs_mtime,
|
||||
config_contents, config_mtime,
|
||||
)
|
||||
|
||||
def _get_local_git_info(self, plugin_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Return local git branch, commit hash, and commit date if the plugin is a git checkout.
|
||||
|
||||
Results are cached keyed on a signature that includes HEAD
|
||||
contents plus the mtime of HEAD AND the resolved ref (or
|
||||
packed-refs). Repeated calls skip the ``git log`` subprocess when
|
||||
nothing has changed, and a ``git pull`` that fast-forwards the
|
||||
branch correctly invalidates the cache.
|
||||
"""
|
||||
git_dir = plugin_path / '.git'
|
||||
if not git_dir.exists():
|
||||
return None
|
||||
|
||||
cache_key = str(plugin_path)
|
||||
signature = self._git_cache_signature(git_dir)
|
||||
|
||||
if signature is not None:
|
||||
cached = self._git_info_cache.get(cache_key)
|
||||
if cached is not None and cached[0] == signature:
|
||||
return cached[1]
|
||||
|
||||
try:
|
||||
# .git may be a file (worktree / submodule) containing "gitdir: <path>".
|
||||
# Resolve it to the actual git directory before reading any files.
|
||||
try:
|
||||
if git_dir.is_file():
|
||||
pointer = git_dir.read_text(encoding='utf-8', errors='replace').strip()
|
||||
if pointer.startswith('gitdir:'):
|
||||
resolved = (plugin_path / pointer[len('gitdir:'):].strip()).resolve()
|
||||
if resolved.is_dir():
|
||||
git_dir = resolved
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
except (OSError, NotADirectoryError):
|
||||
return None
|
||||
|
||||
# Read branch directly from .git/HEAD (no subprocess).
|
||||
branch = ''
|
||||
try:
|
||||
head_text = (git_dir / 'HEAD').read_text(encoding='utf-8', errors='replace').strip()
|
||||
if head_text.startswith('ref: refs/heads/'):
|
||||
branch = head_text[len('ref: refs/heads/'):]
|
||||
elif head_text.startswith('ref: '):
|
||||
branch = head_text[len('ref: '):]
|
||||
# else: detached HEAD — branch stays ''
|
||||
except (OSError, NotADirectoryError):
|
||||
pass
|
||||
|
||||
# Remote URL from .git/config — parse [remote "origin"] url line.
|
||||
remote_url = None
|
||||
try:
|
||||
config_text = (git_dir / 'config').read_text(encoding='utf-8', errors='replace')
|
||||
in_origin = False
|
||||
for line in config_text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped == '[remote "origin"]':
|
||||
in_origin = True
|
||||
elif stripped.startswith('['):
|
||||
in_origin = False
|
||||
elif in_origin and stripped.startswith('url') and '=' in stripped:
|
||||
remote_url = stripped.split('=', 1)[1].strip()
|
||||
break
|
||||
except (OSError, NotADirectoryError):
|
||||
pass
|
||||
|
||||
# Single subprocess: SHA + commit date in one call.
|
||||
log_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'log', '-1', '--format=%H%n%cI', 'HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=True
|
||||
)
|
||||
lines = log_result.stdout.strip().splitlines()
|
||||
sha = lines[0] if lines else ''
|
||||
commit_date_iso = lines[1] if len(lines) > 1 else ''
|
||||
|
||||
result = {
|
||||
'sha': sha,
|
||||
'short_sha': sha[:7] if sha else '',
|
||||
'branch': branch,
|
||||
}
|
||||
|
||||
if remote_url:
|
||||
result['remote_url'] = remote_url
|
||||
|
||||
if commit_date_iso:
|
||||
result['date_iso'] = commit_date_iso
|
||||
result['date'] = self._iso_to_date(commit_date_iso)
|
||||
|
||||
if signature is not None:
|
||||
self._git_info_cache[cache_key] = (signature, result)
|
||||
return result
|
||||
except subprocess.CalledProcessError as err:
|
||||
self.logger.debug(f"Failed to read git info for {plugin_path.name}: {err}")
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.debug(f"Timed out reading git info for {plugin_path.name}")
|
||||
|
||||
return None
|
||||
|
||||
def _gate_pulled_commit(self, plugin_id: str, plugin_path: Path,
|
||||
previous_sha: Optional[str]) -> bool:
|
||||
"""Apply the compatibility gate to a commit that arrived via git pull.
|
||||
|
||||
Every other route into an installed plugin goes through
|
||||
``install_plugin``, which gates in ``_install_plugin_impl``. This one
|
||||
did not: a ``git pull`` could deliver a manifest flooring above this
|
||||
core and nothing would notice until the plugin failed to load, which
|
||||
surfaces as one line in the journal and a scoreboard that silently
|
||||
stopped appearing.
|
||||
|
||||
Checked after the pull rather than before it, for the same reason
|
||||
``_install_plugin_impl`` checks after the download: the registry
|
||||
carries no compatibility field, so the incoming floor is only knowable
|
||||
once the new commit is on disk.
|
||||
|
||||
Undone with ``git reset --hard`` rather than by removing the directory.
|
||||
This is a live checkout, the previous commit is still in the object
|
||||
store, and the reset leaves the user on the exact version they were
|
||||
already running -- the same promise ``_reinstall_with_rollback`` makes,
|
||||
reached by the means this path actually has. It is also the gentler
|
||||
option: no window in which the plugin directory does not exist, and no
|
||||
``.standalone-backup-`` debris if the process dies mid-way.
|
||||
|
||||
A manifest that cannot be read is not evidence of incompatibility, so
|
||||
it allows. ``compatibility.check`` refuses only on evidence for the
|
||||
same reason: a wrong refusal breaks a working install, while a wrong
|
||||
allowance degrades to exactly the behaviour this path had before the
|
||||
gate existed.
|
||||
"""
|
||||
manifest_path = plugin_path / "manifest.json"
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as mf:
|
||||
manifest = json.load(mf)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(
|
||||
"Could not read %s after updating %s (%s); allowing the "
|
||||
"update, as an unreadable manifest declares no floor",
|
||||
manifest_path, plugin_id, e)
|
||||
return True
|
||||
|
||||
from src.plugin_system import compatibility
|
||||
core_version = compatibility.current_core_version()
|
||||
|
||||
compatible, reason = compatibility.check(manifest, core_version)
|
||||
if compatible:
|
||||
return True
|
||||
|
||||
self.logger.error("Refusing the update to %s: %s", plugin_id, reason)
|
||||
|
||||
if not previous_sha:
|
||||
self.logger.error(
|
||||
"Cannot roll %s back: the commit it was on before the pull is "
|
||||
"unknown. It is now on a version this core cannot run — "
|
||||
"reinstall it from the plugin store.", plugin_id)
|
||||
return False
|
||||
|
||||
# Safe by construction: update_plugin returns before pulling unless the
|
||||
# tree was clean or successfully stashed, so there are no uncommitted
|
||||
# tracked edits for --hard to discard. The stash is not popped on the
|
||||
# success path either, so the reset leaves the working tree exactly
|
||||
# where a successful pull would have. Say "commit", not "changes".
|
||||
reset = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'reset', '--hard', previous_sha],
|
||||
capture_output=True, text=True, timeout=60, check=False)
|
||||
if reset.returncode != 0:
|
||||
self.logger.error(
|
||||
"CRITICAL: could not roll %s back to commit %s: %s. It is left "
|
||||
"on a version this core cannot run; "
|
||||
"`git -C %s reset --hard %s` restores it.",
|
||||
plugin_id, previous_sha[:7],
|
||||
(reset.stderr or reset.stdout or '').strip(),
|
||||
plugin_path, previous_sha)
|
||||
else:
|
||||
self.logger.info(
|
||||
"Rolled %s back to commit %s; it stays on the version it was "
|
||||
"already running.", plugin_id, previous_sha[:7])
|
||||
return False
|
||||
|
||||
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
||||
"""Replace an installed plugin with a fresh install, atomically.
|
||||
|
||||
The old install is renamed aside (not deleted) until the new install
|
||||
succeeds, then removed; on ANY install failure the old directory is
|
||||
restored. Deleting first turns a failed download into a destroyed
|
||||
plugin: during the monorepo migration a Pi with broken DNS lost every
|
||||
old-remote plugin that way, with none able to be re-downloaded.
|
||||
|
||||
The aside name embeds BACKUP_MARKER ('.standalone-backup-') so every
|
||||
plugin directory lookup (src/plugin_system/plugin_dirs.py) ignores it
|
||||
even though it still contains a manifest.json.
|
||||
|
||||
Held for the whole operation under a per-plugin_id lock: two
|
||||
overlapping requests for the same plugin (double-click, two
|
||||
browser tabs — the web UI runs Flask with threaded=True) must not
|
||||
interleave their renames, or the second could steal the first's
|
||||
rollback safety net mid-install. Other plugin_ids are unaffected.
|
||||
"""
|
||||
with self._get_reinstall_lock(plugin_id):
|
||||
backup_path = plugin_path.with_name(
|
||||
f"{plugin_path.name}{BACKUP_MARKER}migrating")
|
||||
problem = self._set_aside(plugin_path, backup_path)
|
||||
if problem:
|
||||
self.logger.error(
|
||||
"Not updating %s: %s; the installed version is left in place",
|
||||
plugin_id, problem)
|
||||
return False
|
||||
|
||||
try:
|
||||
installed = self.install_plugin(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Reinstall of {plugin_id} raised: {e}")
|
||||
installed = False
|
||||
|
||||
if installed:
|
||||
self._discard_backup(plugin_id, backup_path, "update")
|
||||
return True
|
||||
|
||||
# Bad network, registry error...: the user keeps a working plugin.
|
||||
self._restore_backup(plugin_id, plugin_path, backup_path, "Reinstall")
|
||||
return False
|
||||
|
||||
def update_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Update a plugin to the latest commit on its upstream branch.
|
||||
"""
|
||||
plugin_path = self._find_plugin_path(plugin_id)
|
||||
|
||||
if plugin_path is None or not plugin_path.exists():
|
||||
self.logger.error(f"Plugin not installed: {plugin_id}")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"Checking for updates to plugin {plugin_id}")
|
||||
|
||||
# Check if this is a bundled/unmanaged plugin (no registry entry, no git remote)
|
||||
# These are plugins shipped with LEDMatrix itself and updated via LEDMatrix updates.
|
||||
metadata_path = plugin_path / ".plugin_metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
metadata = json.load(f)
|
||||
if metadata.get('install_type') == 'bundled':
|
||||
self.logger.info(f"Plugin {plugin_id} is a bundled plugin; updates are delivered via LEDMatrix itself")
|
||||
return True
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.debug(f"[PluginStore] Could not read metadata for {plugin_id} at {metadata_path}: {e}")
|
||||
|
||||
# First check if it's a git repository - if so, we can update directly
|
||||
git_info = self._get_local_git_info(plugin_path)
|
||||
|
||||
if git_info:
|
||||
# Plugin is a git repository - try to update via git
|
||||
local_branch = git_info.get('branch') or 'main'
|
||||
local_sha = git_info.get('sha')
|
||||
|
||||
# Try to get remote info from registry (optional)
|
||||
self.fetch_registry(force_refresh=True)
|
||||
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
# Try without 'ledmatrix-' prefix (monorepo migration)
|
||||
resolved_id = plugin_id
|
||||
if not plugin_info_remote and plugin_id.startswith('ledmatrix-'):
|
||||
alt_id = plugin_id[len('ledmatrix-'):]
|
||||
plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
if plugin_info_remote:
|
||||
resolved_id = alt_id
|
||||
self.logger.info(f"Plugin {plugin_id} found in registry as {resolved_id}")
|
||||
remote_branch = None
|
||||
remote_sha = None
|
||||
|
||||
if plugin_info_remote:
|
||||
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
|
||||
remote_sha = plugin_info_remote.get('last_commit_sha')
|
||||
|
||||
# Check if the local git remote still matches the registry repo URL.
|
||||
# After monorepo migration, old clones point to archived individual repos
|
||||
# while the registry now points to the monorepo. Detect this and reinstall.
|
||||
registry_repo = plugin_info_remote.get('repo', '')
|
||||
local_remote = git_info.get('remote_url', '')
|
||||
if local_remote and registry_repo and not same_repo(local_remote, registry_repo):
|
||||
self.logger.info(
|
||||
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
||||
f"Reinstalling from registry to migrate to new source."
|
||||
)
|
||||
return self._reinstall_with_rollback(resolved_id, plugin_path)
|
||||
|
||||
# Check if already up to date
|
||||
if remote_sha and local_sha and remote_sha.startswith(local_sha):
|
||||
self.logger.info(f"Plugin {plugin_id} already matches remote commit {remote_sha[:7]}")
|
||||
return True
|
||||
|
||||
# Update via git pull
|
||||
self.logger.info(f"Updating {plugin_id} via git pull (local branch: {local_branch})...")
|
||||
try:
|
||||
# Fetch latest changes first to get all remote branch info
|
||||
# If fetch fails, we'll still try to pull (might work with existing remote refs)
|
||||
fetch_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'fetch', 'origin'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False
|
||||
)
|
||||
if fetch_result.returncode != 0:
|
||||
self.logger.warning(f"Git fetch failed for {plugin_id}: {fetch_result.stderr or fetch_result.stdout}. Will still attempt pull.")
|
||||
else:
|
||||
self.logger.debug(f"Successfully fetched remote changes for {plugin_id}")
|
||||
|
||||
# Determine which remote branch to pull from
|
||||
# Strategy: Use what the local branch is tracking, or find the best match
|
||||
remote_pull_branch = None
|
||||
|
||||
# First, check what the local branch is tracking
|
||||
tracking_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'rev-parse', '--abbrev-ref', '--symbolic-full-name', f'{local_branch}@{{upstream}}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
|
||||
if tracking_result.returncode == 0 and tracking_result.stdout.strip():
|
||||
# Local branch is tracking a remote branch
|
||||
tracking_ref = tracking_result.stdout.strip()
|
||||
# Extract branch name from refs/remotes/origin/branch-name or origin/branch-name
|
||||
if tracking_ref.startswith('refs/remotes/origin/'):
|
||||
remote_pull_branch = tracking_ref.replace('refs/remotes/origin/', '')
|
||||
self.logger.info(f"Local branch {local_branch} is tracking origin/{remote_pull_branch}")
|
||||
elif tracking_ref.startswith('origin/'):
|
||||
remote_pull_branch = tracking_ref.replace('origin/', '')
|
||||
self.logger.info(f"Local branch {local_branch} is tracking origin/{remote_pull_branch}")
|
||||
|
||||
# If not tracking anything, try to find the best remote branch match
|
||||
if not remote_pull_branch:
|
||||
# Check if remote branch from registry exists
|
||||
if remote_branch:
|
||||
remote_check = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'ls-remote', '--heads', 'origin', remote_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
if remote_check.returncode == 0 and remote_check.stdout.strip():
|
||||
remote_pull_branch = remote_branch
|
||||
self.logger.info(f"Using remote branch {remote_branch} from registry")
|
||||
|
||||
# If registry branch doesn't exist, check if local branch name exists on remote
|
||||
if not remote_pull_branch:
|
||||
local_as_remote_check = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'ls-remote', '--heads', 'origin', local_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
if local_as_remote_check.returncode == 0 and local_as_remote_check.stdout.strip():
|
||||
remote_pull_branch = local_branch
|
||||
self.logger.info(f"Using local branch name {local_branch} as remote branch")
|
||||
|
||||
# Last resort: try to get remote's default branch
|
||||
if not remote_pull_branch:
|
||||
default_branch_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'symbolic-ref', 'refs/remotes/origin/HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
if default_branch_result.returncode == 0:
|
||||
default_ref = default_branch_result.stdout.strip()
|
||||
if default_ref.startswith('refs/remotes/origin/'):
|
||||
remote_pull_branch = default_ref.replace('refs/remotes/origin/', '')
|
||||
self.logger.info(f"Using remote default branch {remote_pull_branch}")
|
||||
|
||||
# If we still don't have a remote branch, use local branch name (git will handle it)
|
||||
if not remote_pull_branch:
|
||||
remote_pull_branch = local_branch
|
||||
self.logger.info(f"Falling back to local branch name {local_branch} for pull")
|
||||
|
||||
# Ensure we're on the local branch
|
||||
checkout_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'checkout', local_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False
|
||||
)
|
||||
if checkout_result.returncode != 0:
|
||||
self.logger.warning(f"Git checkout to {local_branch} failed for {plugin_id}: {checkout_result.stderr or checkout_result.stdout}. Will still attempt pull.")
|
||||
|
||||
# Check for local changes and untracked files that might conflict
|
||||
# First, check for untracked files that would be overwritten
|
||||
try:
|
||||
# Check for untracked files
|
||||
untracked_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=all'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False
|
||||
)
|
||||
untracked_files = []
|
||||
if untracked_result.returncode == 0:
|
||||
for line in untracked_result.stdout.strip().split('\n'):
|
||||
if line.startswith('??'):
|
||||
# Untracked file
|
||||
file_path = line[3:].strip()
|
||||
untracked_files.append(file_path)
|
||||
|
||||
# Check for tracked file changes
|
||||
status_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False
|
||||
)
|
||||
has_changes = bool(status_result.stdout.strip())
|
||||
|
||||
# If there are untracked files, stash them
|
||||
if untracked_files:
|
||||
self.logger.info(f"Found {len(untracked_files)} untracked files in {plugin_id}, will stash them")
|
||||
has_changes = True
|
||||
except subprocess.TimeoutExpired:
|
||||
# If status check times out, assume there might be changes and proceed
|
||||
self.logger.warning(f"Git status check timed out for {plugin_id}, proceeding with update")
|
||||
has_changes = True
|
||||
|
||||
stash_info = ""
|
||||
# Whether the pull can be undone without destroying work.
|
||||
tree_is_recoverable = not has_changes
|
||||
if has_changes:
|
||||
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
||||
try:
|
||||
# Use -u to include untracked files in stash
|
||||
stash_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'stash', 'push', '-u', '-m', f'LEDMatrix auto-stash before update {plugin_id}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False
|
||||
)
|
||||
if stash_result.returncode == 0:
|
||||
stash_info = " (local changes were stashed)"
|
||||
tree_is_recoverable = True
|
||||
self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}")
|
||||
else:
|
||||
self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}")
|
||||
except subprocess.TimeoutExpired:
|
||||
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
|
||||
self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...")
|
||||
pull_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'pull', 'origin', remote_pull_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=True
|
||||
)
|
||||
|
||||
pull_message = pull_result.stdout.strip() or f"Pulled latest changes for {plugin_id}"
|
||||
if stash_info:
|
||||
pull_message += stash_info
|
||||
self.logger.info(pull_message)
|
||||
|
||||
updated_git_info = self._get_local_git_info(plugin_path) or {}
|
||||
updated_sha = updated_git_info.get('sha', '')
|
||||
if remote_sha and updated_sha and remote_sha.startswith(updated_sha):
|
||||
self.logger.info(f"Plugin {plugin_id} now at remote commit {remote_sha[:7]}{stash_info}")
|
||||
elif updated_sha:
|
||||
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)
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as git_error:
|
||||
error_output = git_error.stderr or git_error.stdout or "Unknown error"
|
||||
cmd_str = ' '.join(git_error.cmd)
|
||||
self.logger.error(f"Git update failed for {plugin_id}")
|
||||
self.logger.error(f"Command: {cmd_str}")
|
||||
self.logger.error(f"Return code: {git_error.returncode}")
|
||||
self.logger.error(f"Error output: {error_output}")
|
||||
|
||||
# Check for specific error conditions
|
||||
error_lower = error_output.lower()
|
||||
if "would be overwritten" in error_output or "local changes" in error_lower:
|
||||
self.logger.warning(f"Plugin {plugin_id} has local changes that prevent update. Consider committing or stashing changes manually.")
|
||||
elif "refusing to merge unrelated histories" in error_lower:
|
||||
self.logger.error(f"Plugin {plugin_id} has unrelated git histories. Plugin may need to be reinstalled.")
|
||||
elif "authentication" in error_lower or "permission denied" in error_lower:
|
||||
self.logger.error(f"Authentication failed for {plugin_id}. Check git credentials or repository permissions.")
|
||||
elif "not found" in error_lower or "does not exist" in error_lower:
|
||||
self.logger.error(f"Remote branch or repository not found for {plugin_id}. Check repository URL and branch name.")
|
||||
elif "conflict" in error_lower:
|
||||
self.logger.error(f"Merge conflict detected for {plugin_id}. Resolve conflicts manually or reinstall plugin.")
|
||||
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.warning(f"Git update timed out for {plugin_id}")
|
||||
return False
|
||||
|
||||
# A plugin with its own .git that _get_local_git_info could not
|
||||
# read (e.g. no commits yet) may still name a remote to reinstall
|
||||
# from. Without its own .git, `git -C <plugin>` walks up and finds
|
||||
# the enclosing LEDMatrix checkout when plugins live in
|
||||
# plugin-repos/ -- `--local` does not prevent that -- and the
|
||||
# "plugin's" remote would be LEDMatrix itself.
|
||||
repo_url = None
|
||||
if (plugin_path / '.git').exists():
|
||||
try:
|
||||
remote_url_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'config', '--local', '--get', 'remote.origin.url'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
if remote_url_result.returncode == 0:
|
||||
repo_url = remote_url_result.stdout.strip() or None
|
||||
if repo_url:
|
||||
self.logger.info(f"Found git remote URL for {plugin_id}: {repo_url}")
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
self.logger.debug(f"Could not get git remote URL: {e}")
|
||||
|
||||
# Try registry-based update
|
||||
self.logger.info(f"Plugin {plugin_id} is not a git repository, checking registry...")
|
||||
self.fetch_registry(force_refresh=True)
|
||||
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
|
||||
# If not found, try without 'ledmatrix-' prefix (monorepo migration)
|
||||
registry_id = plugin_id
|
||||
if not plugin_info_remote and plugin_id.startswith('ledmatrix-'):
|
||||
alt_id = plugin_id[len('ledmatrix-'):]
|
||||
plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
if plugin_info_remote:
|
||||
registry_id = alt_id
|
||||
self.logger.info(f"Plugin {plugin_id} found in registry as {alt_id}")
|
||||
|
||||
# If not in registry but we have a repo URL, try reinstalling from that URL
|
||||
if not plugin_info_remote and repo_url:
|
||||
self.logger.info(f"Plugin {plugin_id} not in registry but has git remote URL. Reinstalling from {repo_url} to enable updates...")
|
||||
try:
|
||||
# Get current branch if possible
|
||||
branch_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
branch = branch_result.stdout.strip() if branch_result.returncode == 0 else None
|
||||
if branch == 'HEAD' or not branch:
|
||||
branch = 'main'
|
||||
|
||||
# Reinstall from URL
|
||||
result = self.install_from_url(repo_url, plugin_id=plugin_id, branch=branch)
|
||||
if result.get('success'):
|
||||
self.logger.info(f"Successfully reinstalled {plugin_id} from {repo_url} as git repository")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"Failed to reinstall {plugin_id} from {repo_url}: {result.get('error')}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reinstalling {plugin_id} from URL: {e}")
|
||||
|
||||
if not plugin_info_remote:
|
||||
self.logger.warning(f"Plugin {plugin_id} not found in registry and not a git repository; cannot update automatically")
|
||||
if not repo_url:
|
||||
self.logger.warning("Plugin may have been installed via ZIP download. Try reinstalling from GitHub URL to enable updates.")
|
||||
return False
|
||||
|
||||
repo_url = plugin_info_remote.get('repo')
|
||||
remote_sha = plugin_info_remote.get('last_commit_sha')
|
||||
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
|
||||
|
||||
# Compare local manifest version against registry latest_version
|
||||
# to avoid unnecessary reinstalls for monorepo plugins. Uses the
|
||||
# same semantic comparator as the web UI's update badge, so
|
||||
# equivalent spellings ("v1.2.0" vs "1.2.0") never trigger a
|
||||
# reinstall and a locally-ahead version is never downgraded.
|
||||
try:
|
||||
local_manifest_path = plugin_path / "manifest.json"
|
||||
if local_manifest_path.exists():
|
||||
with open(local_manifest_path, 'r', encoding='utf-8') as f:
|
||||
local_manifest = json.load(f)
|
||||
local_version = local_manifest.get('version', '')
|
||||
remote_version = plugin_info_remote.get('latest_version', '')
|
||||
from src.plugin_system.compatibility import is_update_available
|
||||
# No truthiness gate: the shared comparator already treats
|
||||
# a missing version on either side as "no update", and the
|
||||
# store must agree with the UI badge in that case too. A
|
||||
# missing manifest (not just a missing version field)
|
||||
# still falls through to the reinstall recovery path.
|
||||
if not is_update_available(local_version, remote_version):
|
||||
self.logger.info(
|
||||
f"Plugin {plugin_id} already at latest version "
|
||||
f"(installed {local_version}, registry {remote_version})")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
|
||||
|
||||
# Plugin is not a git repo but is in registry and has a newer version - reinstall
|
||||
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
|
||||
|
||||
# Reinstall with the old version kept aside until the new
|
||||
# download succeeds — this is the path every routine store
|
||||
# update takes, and a mid-update network failure must not
|
||||
# destroy the user's plugin.
|
||||
return self._reinstall_with_rollback(registry_id, plugin_path)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating plugin {plugin_id}: {e}", exc_info=True)
|
||||
return False
|
||||
@@ -7,7 +7,7 @@ plugin discovery / manifest / config-default logic lives in exactly one place.
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence, Union
|
||||
from typing import Any, Dict, Optional, Sequence, Union, cast
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
||||
@@ -39,7 +39,7 @@ def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
if not manifest_path.exists():
|
||||
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return cast(Dict[str, Any], json.load(f))
|
||||
|
||||
|
||||
def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -64,7 +64,7 @@ def load_schema(plugin_dir: Union[str, Path]) -> Optional[Dict[str, Any]]:
|
||||
if not schema_path.exists():
|
||||
return None
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return cast(Optional[Dict[str, Any]], json.load(f))
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
@@ -124,7 +124,7 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
if not spec_path.exists():
|
||||
return {}
|
||||
with open(spec_path, 'r', encoding='utf-8') as f:
|
||||
spec = json.load(f)
|
||||
spec: Dict[str, Any] = json.load(f)
|
||||
|
||||
# Resolve mock_data path and inline its contents for convenience.
|
||||
mock_rel = spec.get('mock_data')
|
||||
|
||||
@@ -5,9 +5,21 @@ Provides mock implementations of display_manager, cache_manager, config_manager,
|
||||
and plugin_manager for use in plugin unit tests.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
import warnings
|
||||
from typing import Dict, Any, List, Optional
|
||||
from PIL import Image
|
||||
|
||||
#: Why draw_image() warns. Kept (rather than removed) so existing plugin test
|
||||
#: suites that call it keep passing, but a plugin that calls it passes its
|
||||
#: tests and then crashes on the Pi.
|
||||
DRAW_IMAGE_DEPRECATION = (
|
||||
"display_manager.draw_image() exists only on the test doubles; the real "
|
||||
"DisplayManager has no such method, so this raises AttributeError on a "
|
||||
"device. Paste onto the canvas instead: "
|
||||
"display_manager.image.paste(img, (x, y)) (with the image as mask, "
|
||||
"image.paste(rgba, (x, y), rgba), for transparency)."
|
||||
)
|
||||
|
||||
|
||||
class MockDisplayManager:
|
||||
"""Mock display manager for testing."""
|
||||
@@ -20,7 +32,7 @@ class MockDisplayManager:
|
||||
self.image = Image.new('RGB', (width, height), color=(0, 0, 0))
|
||||
self.clear_called = False
|
||||
self.update_called = False
|
||||
self.draw_calls = []
|
||||
self.draw_calls: List[Dict[str, Any]] = []
|
||||
|
||||
def clear(self):
|
||||
"""Clear the display."""
|
||||
@@ -31,8 +43,16 @@ class MockDisplayManager:
|
||||
"""Update the display."""
|
||||
self.update_called = True
|
||||
|
||||
def draw_text(self, text: str, x: int, y: int, color: tuple = (255, 255, 255), font=None):
|
||||
"""Draw text on the display."""
|
||||
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None, color: tuple = (255, 255, 255),
|
||||
font=None, small_font: bool = False, centered: bool = False):
|
||||
"""Draw text on the display.
|
||||
|
||||
Accepts every argument the real ``DisplayManager.draw_text`` does, so
|
||||
a plugin passing ``small_font``/``centered`` (or leaving x/y to
|
||||
default) doesn't fail here while working on the device. ``font``
|
||||
stays fifth for callers of the old mock signature; pass the rest by
|
||||
keyword, as the real method's positional order differs.
|
||||
"""
|
||||
self.draw_calls.append({
|
||||
'type': 'text',
|
||||
'text': text,
|
||||
@@ -43,7 +63,8 @@ class MockDisplayManager:
|
||||
})
|
||||
|
||||
def draw_image(self, image: Image.Image, x: int, y: int):
|
||||
"""Draw an image on the display."""
|
||||
"""Draw an image on the display. Deprecated: see DRAW_IMAGE_DEPRECATION."""
|
||||
warnings.warn(DRAW_IMAGE_DEPRECATION, DeprecationWarning, stacklevel=2)
|
||||
self.draw_calls.append({
|
||||
'type': 'image',
|
||||
'image': image,
|
||||
@@ -145,8 +166,8 @@ class MockConfigManager:
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
self._config = config or {}
|
||||
self.load_config_calls = []
|
||||
self.save_config_calls = []
|
||||
self.load_config_calls: List[Dict[str, Any]] = []
|
||||
self.save_config_calls: List[Dict[str, Any]] = []
|
||||
|
||||
def load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration."""
|
||||
|
||||
@@ -29,6 +29,7 @@ through src/common/bdf_font.py, so those pixels cannot drift.
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import warnings
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
@@ -38,9 +39,12 @@ from src.common.bdf_font import draw_bdf_text, load_bdf_face
|
||||
from src.common.font_layout import crisp_size, load_truetype
|
||||
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.testing.mocks import DRAW_IMAGE_DEPRECATION
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_draw_image_warning_logged = False
|
||||
|
||||
|
||||
class _MatrixProxy:
|
||||
"""Lightweight proxy so plugins can access display_manager.matrix.width/height."""
|
||||
@@ -321,17 +325,26 @@ class VisualTestDisplayManager:
|
||||
else:
|
||||
self.draw.text((x, y), text, font=current_font, fill=color)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error drawing text: {e}")
|
||||
# WARNING, not DEBUG: the real DisplayManager logs this at ERROR,
|
||||
# and a test double that hides it lets a broken draw pass.
|
||||
logger.warning(f"Error drawing text: {e}")
|
||||
|
||||
def draw_image(self, image: Image.Image, x: int, y: int):
|
||||
"""Draw an image on the display."""
|
||||
"""Draw an image on the display. Deprecated: see DRAW_IMAGE_DEPRECATION."""
|
||||
warnings.warn(DRAW_IMAGE_DEPRECATION, DeprecationWarning, stacklevel=2)
|
||||
global _draw_image_warning_logged
|
||||
if not _draw_image_warning_logged:
|
||||
# Also logged once: the dev preview server drives this class
|
||||
# outside pytest, where DeprecationWarning is hidden by default.
|
||||
_draw_image_warning_logged = True
|
||||
logger.warning(DRAW_IMAGE_DEPRECATION)
|
||||
self.draw_calls.append({
|
||||
'type': 'image', 'image': image, 'x': x, 'y': y,
|
||||
})
|
||||
try:
|
||||
self.image.paste(image, (x, y))
|
||||
except Exception as e:
|
||||
logger.debug(f"Error drawing image: {e}")
|
||||
logger.warning(f"Error drawing image: {e}")
|
||||
|
||||
def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None):
|
||||
"""Draw text in a BDF ``freetype.Face`` with (x, y) as its top-left.
|
||||
|
||||
@@ -268,15 +268,21 @@ class StartupValidator:
|
||||
except Exception as e:
|
||||
self.warnings.append(f"Could not validate display configuration: {e}")
|
||||
|
||||
def _validate_plugins(self) -> None:
|
||||
"""Validate plugin configurations and dependencies."""
|
||||
def _validate_plugins(self, discovered_plugins=None) -> None:
|
||||
"""Validate plugin configurations and dependencies.
|
||||
|
||||
``discovered_plugins`` is a list the caller already got from
|
||||
``discover_plugins()``; passing it skips a second directory scan (and
|
||||
its duplicate log lines) at startup.
|
||||
"""
|
||||
if not self.plugin_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get enabled plugins from config
|
||||
config = self.config_manager.get_config()
|
||||
discovered_plugins = self.plugin_manager.discover_plugins()
|
||||
if discovered_plugins is None:
|
||||
discovered_plugins = self.plugin_manager.discover_plugins()
|
||||
|
||||
# Check for enabled plugins that don't exist
|
||||
for plugin_id, plugin_config in config.items():
|
||||
@@ -294,7 +300,11 @@ class StartupValidator:
|
||||
|
||||
# Validate plugin configurations
|
||||
for plugin_id in discovered_plugins:
|
||||
plugin_config = config.get(plugin_id, {})
|
||||
plugin_config = config.get(plugin_id)
|
||||
# A null block ("my-plugin": null) is not an enabled plugin;
|
||||
# .get() on it raised and abandoned every remaining check.
|
||||
if not isinstance(plugin_config, dict):
|
||||
continue
|
||||
if plugin_config.get('enabled', False):
|
||||
# Check if plugin can be loaded (without actually loading it)
|
||||
plugin_dir = self.plugin_manager.get_plugin_directory(plugin_id)
|
||||
@@ -308,12 +318,21 @@ class StartupValidator:
|
||||
|
||||
def raise_on_errors(self) -> None:
|
||||
"""
|
||||
Raise exceptions if validation errors exist.
|
||||
|
||||
Raise one exception if validation errors exist; return None if not.
|
||||
|
||||
Nothing in core calls this (see the module docstring). Errors are
|
||||
grouped by a keyword in their message, not by which check produced
|
||||
them, and only the first non-empty group is raised, in the order
|
||||
config > cache > plugin: a "plugin ... config" message counts as a
|
||||
config error, and cache/plugin errors are not reported while a
|
||||
config error exists. The raised exception's ``context['errors']``
|
||||
holds that group's messages only.
|
||||
|
||||
Raises:
|
||||
ConfigError: If configuration validation fails
|
||||
CacheError: If cache validation fails
|
||||
PluginError: If plugin validation fails
|
||||
ConfigError: If any message mentions config/configuration, or if
|
||||
none matches any group
|
||||
CacheError: If a message mentions cache (and none config)
|
||||
PluginError: If a message mentions plugin (and none of the above)
|
||||
"""
|
||||
if not self.errors:
|
||||
return
|
||||
|
||||
@@ -23,7 +23,6 @@ from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
@@ -78,8 +77,14 @@ class VegasModeCoordinator:
|
||||
- Provide status and control interface
|
||||
"""
|
||||
|
||||
#: How long a STATIC pause waits for the plugin's lock (held while its
|
||||
#: update() runs) before skipping that turn.
|
||||
STATIC_LOCK_TIMEOUT = 1.0
|
||||
|
||||
# Class-level so coordinators built without __init__ (tests) have it.
|
||||
_last_live_check: float = float('-inf')
|
||||
# Set only while Vegas has changed the GIL switch interval; read with getattr.
|
||||
_saved_switch_interval: Optional[float]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -273,6 +278,10 @@ class VegasModeCoordinator:
|
||||
|
||||
self._is_active = True
|
||||
self._should_stop = False
|
||||
# A pause belongs to the run it happened in; carrying it into a
|
||||
# new run would have run_frame() refuse every frame.
|
||||
self._is_paused = False
|
||||
self._live_priority_active = False
|
||||
self._start_time = time.time()
|
||||
# A fresh run starts with a clean health slate: no stale
|
||||
# "was degraded" from the previous run, and a heartbeat that is
|
||||
@@ -298,6 +307,8 @@ class VegasModeCoordinator:
|
||||
|
||||
self._should_stop = True
|
||||
self._is_active = False
|
||||
self._is_paused = False
|
||||
self._live_priority_active = False
|
||||
|
||||
if self._start_time:
|
||||
self.stats['total_runtime_seconds'] += time.time() - self._start_time
|
||||
@@ -322,7 +333,7 @@ class VegasModeCoordinator:
|
||||
self._saved_switch_interval = sys.getswitchinterval()
|
||||
sys.setswitchinterval(ms / 1000.0)
|
||||
logger.info("Vegas: GIL switch interval %.1fms (was %.1fms)",
|
||||
ms, self._saved_switch_interval * 1000.0)
|
||||
ms, self._saved_switch_interval * 1000.0) # type: ignore[operator] # set just above; getattr hides it
|
||||
|
||||
def _restore_switch_interval(self) -> None:
|
||||
saved = getattr(self, '_saved_switch_interval', None)
|
||||
@@ -473,6 +484,20 @@ class VegasModeCoordinator:
|
||||
if not self.start():
|
||||
return False
|
||||
|
||||
# A live-priority pause is only ever lifted by _check_live_priority(),
|
||||
# and run_frame() returns before reaching it while paused -- so once
|
||||
# paused, every later iteration returned False at its first frame and
|
||||
# the ticker never came back until a restart. The display controller
|
||||
# only calls run_iteration() when nothing preempts Vegas (no live mode,
|
||||
# or live content is kept in the ticker), so being called at all means
|
||||
# the live content that paused us has ended.
|
||||
with self._state_lock:
|
||||
paused_for_live = self._is_paused and self._live_priority_active
|
||||
if paused_for_live:
|
||||
self._live_priority_active = False
|
||||
self.resume()
|
||||
logger.info("Live priority ended - resuming Vegas")
|
||||
|
||||
if self.vegas_config.continuous_scroll:
|
||||
# The strip is continuously extended and trimmed, so its width says
|
||||
# nothing about how long to run. This is only how often control
|
||||
@@ -481,7 +506,11 @@ class VegasModeCoordinator:
|
||||
duration = float(self.vegas_config.max_cycle_duration)
|
||||
else:
|
||||
duration = self.render_pipeline.get_dynamic_duration()
|
||||
start_time = time.time()
|
||||
# Monotonic for the same reason as the per-frame clock below: this
|
||||
# bounds how long the iteration runs, and an NTP step on an RTC-less
|
||||
# Pi would otherwise end it at once (forward) or stretch it by the
|
||||
# size of the correction (backward).
|
||||
start_time = time.monotonic()
|
||||
frame_count = 0
|
||||
fps_log_interval = 5.0 # Sample FPS every 5 seconds
|
||||
# Health state lives on the coordinator, not here: run_iteration() is
|
||||
@@ -490,10 +519,8 @@ class VegasModeCoordinator:
|
||||
# of every iteration rather than once per interval, and a recovery
|
||||
# that crossed an iteration boundary was never reported at all --
|
||||
# was_degraded had already gone back to False.
|
||||
# Monotonic, and deliberately not start_time: start_time is wall
|
||||
# clock and is used below to report the iteration's duration. Mixing
|
||||
# the two here would make every delta hugely negative and silence the
|
||||
# frame-rate reporting altogether.
|
||||
# Monotonic. Never mix it with a wall-clock value: every delta would
|
||||
# be hugely negative and silence the frame-rate reporting altogether.
|
||||
last_fps_log_time = time.monotonic()
|
||||
fps_frame_count = 0
|
||||
# A mean hides stutter completely. At 120fps a five-second window is
|
||||
@@ -520,8 +547,7 @@ class VegasModeCoordinator:
|
||||
if not self._handle_static_pause(static_plugin):
|
||||
# Static pause was interrupted
|
||||
return False
|
||||
# After static pause, skip this segment and continue
|
||||
self.stream_manager.get_next_segment() # Consume the segment
|
||||
# The trigger consumed the plugin's marker; carry on scrolling.
|
||||
continue
|
||||
|
||||
# Run frame
|
||||
@@ -633,7 +659,7 @@ class VegasModeCoordinator:
|
||||
).start()
|
||||
|
||||
# Check elapsed time
|
||||
elapsed = time.time() - start_time
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= duration:
|
||||
break
|
||||
|
||||
@@ -652,7 +678,7 @@ class VegasModeCoordinator:
|
||||
# cycle content multiple times within one iteration — acceptable for
|
||||
# a continuous ticker.
|
||||
|
||||
logger.info("Vegas iteration completed after %.1fs", time.time() - start_time)
|
||||
logger.info("Vegas iteration completed after %.1fs", time.monotonic() - start_time)
|
||||
return True
|
||||
|
||||
def _check_live_priority(self) -> bool:
|
||||
@@ -800,32 +826,30 @@ class VegasModeCoordinator:
|
||||
"""
|
||||
Check if a STATIC mode plugin should take over display.
|
||||
|
||||
Called during iteration to detect when scroll should pause
|
||||
for a static plugin display.
|
||||
Called every frame. The render pipeline marks where each STATIC
|
||||
plugin's turn falls in the strip, and this reports the one the scroll
|
||||
has just reached.
|
||||
|
||||
This used to peek at the front of the stream manager's segment
|
||||
buffer, which continuous scrolling (the default) never advances: it
|
||||
extends the strip with take_next_group() instead. The same first
|
||||
segment was examined on every frame, so a STATIC plugin paused the
|
||||
scroll only if it happened to be first, once, at startup -- and
|
||||
otherwise just scrolled past as ordinary content. Swap mode fared no
|
||||
better: nothing advanced the buffer mid-cycle either.
|
||||
|
||||
Returns:
|
||||
Plugin instance if static pause should begin, None otherwise
|
||||
"""
|
||||
# Get the next plugin that would be displayed
|
||||
next_segment = self.stream_manager.peek_next_segment()
|
||||
if not next_segment:
|
||||
plugin_id = self.render_pipeline.next_static_trigger()
|
||||
if not plugin_id:
|
||||
return None
|
||||
|
||||
plugin_id = next_segment.plugin_id
|
||||
plugin = self.plugin_manager.get_plugin(plugin_id)
|
||||
|
||||
plugin: Optional['BasePlugin'] = self.plugin_manager.get_plugin(plugin_id)
|
||||
if not plugin:
|
||||
logger.debug("[%s] STATIC turn reached, but the plugin is no longer loaded",
|
||||
plugin_id)
|
||||
return None
|
||||
|
||||
# Check if this plugin is configured for STATIC mode
|
||||
try:
|
||||
display_mode = plugin.get_vegas_display_mode()
|
||||
if display_mode == VegasDisplayMode.STATIC:
|
||||
return plugin
|
||||
except (AttributeError, TypeError):
|
||||
logger.exception("Error checking vegas mode for %s", plugin_id)
|
||||
|
||||
return None
|
||||
return plugin
|
||||
|
||||
def _handle_static_pause(self, plugin: 'BasePlugin') -> bool:
|
||||
"""
|
||||
@@ -855,15 +879,32 @@ class VegasModeCoordinator:
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
|
||||
try:
|
||||
# Display the plugin using its standard display() method
|
||||
plugin.display(force_clear=True)
|
||||
# Display the plugin using its standard display() method, under
|
||||
# its plugin lock like every other display() call: without it this
|
||||
# could draw while the update worker is inside the plugin's
|
||||
# update(). If update() holds the lock past the wait, skip this
|
||||
# turn rather than stall the marquee.
|
||||
get_lock = getattr(self.plugin_manager, 'get_plugin_lock', None)
|
||||
plugin_lock = get_lock(plugin_id) if get_lock else None
|
||||
if plugin_lock is not None and not plugin_lock.acquire(
|
||||
timeout=self.STATIC_LOCK_TIMEOUT):
|
||||
logger.info("Static pause skipped for %s: its update() is still running",
|
||||
plugin_id)
|
||||
return True
|
||||
try:
|
||||
plugin.display(force_clear=True)
|
||||
finally:
|
||||
if plugin_lock is not None:
|
||||
plugin_lock.release()
|
||||
self.display_manager.update_display()
|
||||
|
||||
# Wait for the plugin's display duration
|
||||
# Wait for the plugin's display duration. Monotonic, like the
|
||||
# iteration clock: an NTP step on an RTC-less Pi would otherwise
|
||||
# end the pause at once or stretch it by the correction.
|
||||
duration = plugin.get_display_duration()
|
||||
start = time.time()
|
||||
start = time.monotonic()
|
||||
|
||||
while time.time() - start < duration:
|
||||
while time.monotonic() - start < duration:
|
||||
# Check for interruptions
|
||||
if self._should_stop:
|
||||
logger.info("Static pause interrupted by stop request")
|
||||
@@ -883,7 +924,7 @@ class VegasModeCoordinator:
|
||||
|
||||
logger.info(
|
||||
"Static pause completed for %s after %.1fs",
|
||||
plugin_id, time.time() - start
|
||||
plugin_id, time.monotonic() - start
|
||||
)
|
||||
|
||||
except Exception:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user