mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 23:35:08 +00:00
Compare commits
21
Commits
da5937da3d
...
8557eff88a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
+118
@@ -19,6 +19,86 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
- Web backend and WiFi fixes:
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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.)
|
||||
- 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.
|
||||
- Plugin system fixes:
|
||||
- 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.
|
||||
- Web UI cleanup and dependency pins:
|
||||
- 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.
|
||||
- `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.
|
||||
- Core services and `src.common` fixes:
|
||||
- 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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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`).
|
||||
- `BackgroundDataService` runs a cache-hit callback outside its lock, as the fetch path does.
|
||||
- Plugin config saves recombine position-keyed inputs for nullable array fields (`"type": ["array", "null"]`).
|
||||
- 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.
|
||||
- `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`.
|
||||
- Display thread-safety and consistency fixes:
|
||||
- `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.
|
||||
- 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`).
|
||||
- **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.
|
||||
|
||||
- Contributor tooling and docs:
|
||||
- `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.
|
||||
- `check_system_compatibility.sh` treats Python 3.13 (what Trixie ships) as supported and anything below 3.10 as an error.
|
||||
- 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.
|
||||
- Security and input-validation fixes:
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
@@ -27,6 +107,44 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
- `check_system_compatibility.sh` no longer reports installed packages as missing.
|
||||
- A network failure fetching GitHub repo info logs a warning, not an error.
|
||||
|
||||
- Web UI fixes:
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Web UI and `src.common` fixes:
|
||||
- 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.
|
||||
- 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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `FontManager.clear_cache()` and unregistering a plugin's fonts bump `cache_generation`, so cached layouts are rebuilt.
|
||||
- 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.
|
||||
- Startup plugin validation no longer gives up on a `null` plugin block, and plugins are discovered once at startup instead of twice.
|
||||
- `src/common/README.md` lists `frame_timing`, `json_body` and `render_gate`.
|
||||
- Plugin system:
|
||||
- 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()`.
|
||||
- Display runtime:
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
|
||||
+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__",
|
||||
)
|
||||
|
||||
@@ -127,7 +127,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 +158,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,81 @@
|
||||
# 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/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_scroll.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()
|
||||
|
||||
@@ -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
+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:
|
||||
|
||||
@@ -27,9 +27,12 @@ Rules for the package:
|
||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased |
|
||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.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) | Unreleased |
|
||||
| [`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 |
|
||||
@@ -107,6 +110,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 +155,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,
|
||||
|
||||
+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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
+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:
|
||||
|
||||
+136
-25
@@ -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, 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
|
||||
@@ -284,7 +301,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 +335,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 +356,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)
|
||||
@@ -544,21 +566,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 +597,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 +840,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 +1122,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.
|
||||
@@ -1612,7 +1691,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:
|
||||
@@ -1710,10 +1792,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)
|
||||
@@ -1843,7 +1931,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'):
|
||||
@@ -2045,6 +2135,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 +2419,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 +2477,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 +2906,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 +2940,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
|
||||
@@ -3310,6 +3414,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
|
||||
@@ -406,10 +412,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 +441,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 +462,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 +635,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 +807,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 +955,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 +1264,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:
|
||||
|
||||
@@ -11,11 +11,12 @@ import time
|
||||
import threading
|
||||
from collections import deque
|
||||
from contextlib import nullcontext
|
||||
from typing import Optional, List, Any, Dict, Deque
|
||||
from typing import Optional, List, Any, Dict, Deque, Tuple
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_config import solve_crisp
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.matrix_support import DEFAULT_REFRESH_LIMIT_HZ
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.geometry import separation_gap
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
@@ -52,6 +53,20 @@ class RenderPipeline:
|
||||
# A panel measured within this fraction of its cap is keeping up with it.
|
||||
REFRESH_TOLERANCE = 0.03
|
||||
|
||||
# Bumped by reset(). A prefetch thread records the value it started under
|
||||
# and drops its group if a reset happened meanwhile, so a fetch still in
|
||||
# flight when Vegas stops cannot land in the next run. Class-level so
|
||||
# pipelines built without __init__ (tests) still have it.
|
||||
_prefetch_generation = 0
|
||||
|
||||
# Where each STATIC plugin's turn falls in the strip, as (x, plugin_id)
|
||||
# in ascending strip columns: x is the end of the content before it. When
|
||||
# that column reaches the right edge of the viewport, the coordinator
|
||||
# pauses the scroll for the plugin (next_static_trigger). Always replaced,
|
||||
# never mutated, so the class-level default is safe for pipelines built
|
||||
# without __init__ (tests).
|
||||
_static_markers: Tuple[Tuple[int, str], ...] = ()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: VegasModeConfig,
|
||||
@@ -185,7 +200,7 @@ class RenderPipeline:
|
||||
hz = float(getattr(self.display_manager, 'refresh_hz', 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
hz = 0.0
|
||||
return hz if hz > 0 else 100.0
|
||||
return hz if hz > 0 else float(DEFAULT_REFRESH_LIMIT_HZ)
|
||||
|
||||
def _refresh_hz(self) -> float:
|
||||
"""The refresh to solve the crisp speed against: measured, else the cap."""
|
||||
@@ -276,6 +291,7 @@ class RenderPipeline:
|
||||
# Content grouped by plugin, so a separator can be placed at the
|
||||
# plugin boundaries only.
|
||||
grouped = self.stream_manager.get_grouped_content_for_composition()
|
||||
self._static_markers = ()
|
||||
|
||||
if not grouped:
|
||||
logger.warning("No content available for composition")
|
||||
@@ -311,6 +327,8 @@ class RenderPipeline:
|
||||
logger.error("ScrollHelper failed to create cached image")
|
||||
return False
|
||||
|
||||
self._static_markers = self._markers_for_composition(blocks)
|
||||
|
||||
# Track which plugins are in this scroll (get safely via buffer status)
|
||||
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
|
||||
|
||||
@@ -338,6 +356,56 @@ class RenderPipeline:
|
||||
logger.exception("Error composing scroll content")
|
||||
return False
|
||||
|
||||
def _markers_for_composition(self, blocks: List[Image.Image]) -> Tuple[Tuple[int, str], ...]:
|
||||
"""Static markers for a strip just built by create_scrolling_image.
|
||||
|
||||
Mirrors its layout: lead_in_width, then each block followed by
|
||||
separator_width except the last. A STATIC plugin's marker is the end
|
||||
of whatever content precedes it (the lead-in, if nothing does).
|
||||
"""
|
||||
layout_fn = getattr(self.stream_manager, 'get_static_layout', None)
|
||||
if layout_fn is None:
|
||||
return ()
|
||||
layout = layout_fn()
|
||||
if not any(is_static for _pid, is_static in layout):
|
||||
return ()
|
||||
markers = []
|
||||
end = max(0, int(self.config.lead_in_width))
|
||||
x = end
|
||||
block_index = 0
|
||||
for plugin_id, is_static in layout:
|
||||
if is_static:
|
||||
markers.append((end, plugin_id))
|
||||
continue
|
||||
if block_index >= len(blocks):
|
||||
break
|
||||
end = x + blocks[block_index].width
|
||||
x = end + self.config.separator_width
|
||||
block_index += 1
|
||||
return tuple(markers)
|
||||
|
||||
def _add_static_markers(self, markers: List[Tuple[int, str]]) -> None:
|
||||
if markers:
|
||||
self._static_markers = tuple(
|
||||
sorted(self._static_markers + tuple(markers), key=lambda m: m[0]))
|
||||
|
||||
def next_static_trigger(self) -> Optional[str]:
|
||||
"""
|
||||
The STATIC plugin whose turn the scroll has just reached, if any.
|
||||
|
||||
Called every frame by the coordinator, so it is a comparison against
|
||||
the first marker and nothing more. The marker is consumed: a plugin
|
||||
pauses the scroll once per place it holds in the strip.
|
||||
"""
|
||||
markers = self._static_markers
|
||||
if not markers:
|
||||
return None
|
||||
x, plugin_id = markers[0]
|
||||
if x > self.scroll_helper.scroll_position + self.display_width:
|
||||
return None
|
||||
self._static_markers = markers[1:]
|
||||
return plugin_id
|
||||
|
||||
def needs_extension(self) -> bool:
|
||||
"""
|
||||
Whether the strip should be extended with the next group of plugins.
|
||||
@@ -373,6 +441,7 @@ class RenderPipeline:
|
||||
return
|
||||
if self._prepared_group is not None:
|
||||
return # already have one waiting
|
||||
generation = self._prefetch_generation
|
||||
|
||||
def _work():
|
||||
# Deprioritise against the render loop. Linux applies nice
|
||||
@@ -394,6 +463,8 @@ class RenderPipeline:
|
||||
logger.exception("Background prefetch failed")
|
||||
group = []
|
||||
with self._prefetch_lock:
|
||||
if generation != self._prefetch_generation:
|
||||
return # Vegas was reset while this was fetching
|
||||
self._prepared_group = group
|
||||
|
||||
self._prefetch_thread = threading.Thread(
|
||||
@@ -498,6 +569,21 @@ class RenderPipeline:
|
||||
logger.warning("No content available to extend the scroll strip")
|
||||
return False
|
||||
|
||||
# STATIC plugins pause the scroll instead of joining the strip.
|
||||
# Note each one's place -- how many of this group's blocks come
|
||||
# before it -- and take it out before anything else sees it.
|
||||
is_static = getattr(self.stream_manager, 'is_static_plugin', None)
|
||||
statics: List[Tuple[int, str]] = []
|
||||
content: List[Tuple[str, Optional[List[Image.Image]]]] = []
|
||||
for pid, images in grouped:
|
||||
if is_static is not None and is_static(pid):
|
||||
statics.append((sum(1 for _p, imgs in content if imgs), pid))
|
||||
else:
|
||||
content.append((pid, images))
|
||||
grouped = content
|
||||
strip_end = (self.scroll_helper.cached_image.width
|
||||
if self.scroll_helper.cached_image is not None else 0)
|
||||
|
||||
# Plugins the background thread had to defer need the shared canvas,
|
||||
# so they can only be fetched here. Queue them rather than doing all
|
||||
# of them now: measured, six in one go held the render thread for
|
||||
@@ -514,6 +600,9 @@ class RenderPipeline:
|
||||
grouped = [(pid, imgs) for pid, imgs in grouped if imgs]
|
||||
|
||||
if not grouped:
|
||||
# Nothing is appended, so each STATIC turn falls at the end
|
||||
# of the strip as it stands.
|
||||
self._add_static_markers([(strip_end, pid) for _n, pid in statics])
|
||||
if deferred:
|
||||
# Everything in this group is queued; the queue will extend
|
||||
# the strip as it drains, so this is not a failure.
|
||||
@@ -529,6 +618,7 @@ class RenderPipeline:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
|
||||
had_strip = self.scroll_helper.cached_image is not None
|
||||
appended = self.scroll_helper.append_content(
|
||||
content_items=blocks,
|
||||
item_gap=self.config.separator_width,
|
||||
@@ -537,8 +627,25 @@ class RenderPipeline:
|
||||
if not appended:
|
||||
return False
|
||||
|
||||
if statics:
|
||||
# Where each block ends, laid out as append_content does: a
|
||||
# separator before every block, or -- when there was no strip
|
||||
# to extend -- as create_scrolling_image does with no lead-in.
|
||||
gap = max(0, self.config.separator_width)
|
||||
ends = []
|
||||
x = strip_end if had_strip else -gap
|
||||
for block in blocks:
|
||||
x += gap + block.width
|
||||
ends.append(x)
|
||||
self._add_static_markers([
|
||||
(ends[n - 1] if n > 0 else strip_end, pid) for n, pid in statics
|
||||
])
|
||||
|
||||
# Keep a screen's worth behind the viewport as a safety margin.
|
||||
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
|
||||
cut = self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
|
||||
if cut and self._static_markers:
|
||||
self._static_markers = tuple(
|
||||
(max(0, x - cut), pid) for x, pid in self._static_markers)
|
||||
|
||||
self._segments_in_scroll = [pid for pid, _ in grouped]
|
||||
self.stats['composition_count'] += 1
|
||||
@@ -602,7 +709,8 @@ class RenderPipeline:
|
||||
"""
|
||||
Render a single frame to the display.
|
||||
|
||||
Should be called at ~125 FPS (8ms intervals).
|
||||
Called once per frame by the coordinator, which paces the calls by
|
||||
frame_interval (see that property) rather than a fixed rate.
|
||||
|
||||
Returns:
|
||||
True if frame was rendered, False if no content
|
||||
@@ -917,6 +1025,16 @@ class RenderPipeline:
|
||||
self._segments_in_scroll = []
|
||||
self._frame_times = deque(maxlen=100)
|
||||
|
||||
# Content lined up for the old run belongs to it. Left in place, the
|
||||
# first extension after Vegas is switched back on appended that stale
|
||||
# group -- including plugins disabled in the meantime -- and the
|
||||
# deferred queue went on fetching the old run's plugins.
|
||||
with self._prefetch_lock:
|
||||
self._prefetch_generation += 1
|
||||
self._prepared_group = None
|
||||
self._deferred_queue = []
|
||||
self._static_markers = ()
|
||||
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
|
||||
logger.info("RenderPipeline reset")
|
||||
|
||||
@@ -14,7 +14,7 @@ Supports three display modes:
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
|
||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING, cast
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from PIL import Image
|
||||
@@ -74,8 +74,11 @@ class StreamManager:
|
||||
|
||||
# Segments composed into the current cycle (swap mode only).
|
||||
self._active_buffer: Deque[ContentSegment] = deque()
|
||||
# Reentrant: _prefetch_content releases and re-acquires it around the
|
||||
# slow fetch while a caller may already hold it.
|
||||
# Reentrant: get_next_segment holds it while calling
|
||||
# _prefetch_content, which acquires it again. _prefetch_content's
|
||||
# release() around the slow fetch only frees the lock when its caller
|
||||
# did not already hold it (initialize); from get_next_segment the
|
||||
# count only drops to 1, so the fetch runs with the lock held.
|
||||
self._buffer_lock = threading.RLock()
|
||||
|
||||
# Plugin rotation, and the position of the next plugin to fetch in it.
|
||||
@@ -536,7 +539,10 @@ class StreamManager:
|
||||
|
||||
plugin_id = self._ordered_plugins[self._prefetch_index]
|
||||
|
||||
# Release lock for potentially slow content fetch
|
||||
# Release for the potentially slow content fetch. This frees
|
||||
# the lock only when the caller did not hold it already
|
||||
# (initialize); under get_next_segment's hold the RLock count
|
||||
# just drops to 1 and other threads still wait.
|
||||
self._buffer_lock.release()
|
||||
try:
|
||||
segment = self._fetch_plugin_content(plugin_id)
|
||||
@@ -667,6 +673,37 @@ class StreamManager:
|
||||
grouped.append((segment.plugin_id, list(segment.images)))
|
||||
return grouped
|
||||
|
||||
def get_static_layout(self) -> List[Tuple[str, bool]]:
|
||||
"""
|
||||
The buffer's composition order, with STATIC segments kept in place.
|
||||
|
||||
get_grouped_content_for_composition() drops STATIC segments because
|
||||
they contribute no columns; this says where they sat. Each entry is
|
||||
(plugin_id, is_static), and only segments that composition keeps or
|
||||
that are STATIC are listed, so the non-static entries line up one to
|
||||
one with get_grouped_content_for_composition()'s groups.
|
||||
"""
|
||||
layout: List[Tuple[str, bool]] = []
|
||||
with self._buffer_lock:
|
||||
for segment in self._active_buffer:
|
||||
if segment.display_mode == VegasDisplayMode.STATIC:
|
||||
layout.append((segment.plugin_id, True))
|
||||
elif segment.images:
|
||||
layout.append((segment.plugin_id, False))
|
||||
return layout
|
||||
|
||||
def is_static_plugin(self, plugin_id: str) -> bool:
|
||||
"""Whether a loaded plugin asks Vegas to pause for it (STATIC mode)."""
|
||||
plugin = getattr(self.plugin_manager, 'plugins', {}).get(plugin_id)
|
||||
if plugin is None:
|
||||
return False
|
||||
try:
|
||||
return cast(bool, plugin.get_vegas_display_mode() == VegasDisplayMode.STATIC)
|
||||
except Exception:
|
||||
logger.debug("[%s] get_vegas_display_mode() failed; treating as not STATIC",
|
||||
plugin_id, exc_info=True)
|
||||
return False
|
||||
|
||||
def take_next_group(
|
||||
self, count: Optional[int] = None, offscreen_only: bool = False
|
||||
) -> List[Tuple[str, Optional[List[Image.Image]]]]:
|
||||
@@ -692,6 +729,8 @@ class StreamManager:
|
||||
content path now draws on a canvas of its own, so a background
|
||||
fetch that comes back empty had nothing to show, and ``images``
|
||||
is an empty list rather than a request for the render thread.
|
||||
A STATIC plugin is also returned with an empty list, unfetched: it
|
||||
pauses the scroll instead of adding to it (see is_static_plugin).
|
||||
"""
|
||||
if count is None:
|
||||
count = self.config.plugins_per_cycle
|
||||
@@ -717,6 +756,12 @@ class StreamManager:
|
||||
plugin = plugins.get(plugin_id)
|
||||
if not plugin:
|
||||
continue
|
||||
if self.is_static_plugin(plugin_id):
|
||||
# A STATIC plugin pauses the scroll rather than scrolling by,
|
||||
# so it contributes no columns. It keeps its place in the
|
||||
# group (empty) so the pipeline can mark where its turn falls.
|
||||
group.append((plugin_id, []))
|
||||
continue
|
||||
try:
|
||||
images = self.plugin_adapter.get_content(
|
||||
plugin, plugin_id, offscreen_only=offscreen_only)
|
||||
@@ -726,7 +771,6 @@ class StreamManager:
|
||||
continue
|
||||
if images:
|
||||
self.stats['segments_fetched'] += 1
|
||||
if images:
|
||||
group.append((plugin_id, images))
|
||||
else:
|
||||
group.append((plugin_id, None if defer_empty else []))
|
||||
|
||||
@@ -109,7 +109,7 @@ def exception_error_response(
|
||||
)
|
||||
|
||||
|
||||
def validate_request_json(required_fields: list, data: Optional[Dict] = None) -> Tuple[Optional[Dict], Optional[Any]]:
|
||||
def validate_request_json(required_fields: list, data: Any = None) -> Tuple[Optional[Dict], Optional[Any]]:
|
||||
"""
|
||||
Validate request JSON has required fields.
|
||||
|
||||
@@ -129,7 +129,17 @@ def validate_request_json(required_fields: list, data: Optional[Dict] = None) ->
|
||||
"Request body must be valid JSON",
|
||||
status_code=400
|
||||
)
|
||||
|
||||
|
||||
# A JSON array passes the check above, and ``field in data`` then tests
|
||||
# list membership: ["plugin_id"] "had" every required field and the
|
||||
# handler's data['plugin_id'] raised TypeError -- a 500, not a 400.
|
||||
if not isinstance(data, dict):
|
||||
return None, error_response(
|
||||
ErrorCode.INVALID_INPUT,
|
||||
"Request body must be a JSON object",
|
||||
status_code=400
|
||||
)
|
||||
|
||||
missing_fields = [field for field in required_fields if field not in data]
|
||||
if missing_fields:
|
||||
return None, error_response(
|
||||
|
||||
@@ -2,19 +2,35 @@
|
||||
|
||||
A list reaches a plugin-config save keyed by position more often than as a
|
||||
list. The settings form posts one field per element (``feeds.custom_feeds.0.name``),
|
||||
which ``_set_nested_value`` stores as ``{"0": {"name": ...}}``, and the JSON
|
||||
path's dotToNested() in the browser builds the same dict. Validation expects
|
||||
an array there, so the save converts them first.
|
||||
which ``_set_nested_value`` stores as ``{"0": {"name": ...}}``, and a JSON
|
||||
save built by flattening then re-nesting dotted keys carries the same dict.
|
||||
Validation expects an array there, so the save converts them first.
|
||||
"""
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def _schema_type_is(prop: Any, wanted: str) -> bool:
|
||||
"""Whether a schema property is of ``wanted`` type, unions included.
|
||||
|
||||
Mirrors ``_schema_type_is`` in ``web_interface/blueprints/api_v3`` (kept
|
||||
here so src/ doesn't import the Flask blueprint). A union such as
|
||||
``["array", "null"]`` -- the per-element style overrides, where null means
|
||||
"inherit" -- is still an array for recombining position-keyed inputs.
|
||||
"""
|
||||
if not isinstance(prop, dict):
|
||||
return False
|
||||
declared = prop.get('type')
|
||||
if isinstance(declared, list):
|
||||
return wanted in declared
|
||||
return declared == wanted
|
||||
|
||||
|
||||
def _is_index_dict(value: Any) -> bool:
|
||||
"""True for a dict keyed only by list positions ("0", "1", ...), or empty."""
|
||||
return isinstance(value, dict) and all(str(k).isdigit() for k in value)
|
||||
|
||||
|
||||
def coerce_array_shapes(config: Dict[str, Any], schema_props: Dict[str, Any],
|
||||
def coerce_array_shapes(config: Any, schema_props: Dict[str, Any],
|
||||
short_lists_take_default: bool = False) -> None:
|
||||
"""Turn position-keyed dicts into lists wherever the schema has an array.
|
||||
|
||||
@@ -35,10 +51,9 @@ def coerce_array_shapes(config: Dict[str, Any], schema_props: Dict[str, Any],
|
||||
for key, prop_schema in schema_props.items():
|
||||
if key not in config or not isinstance(prop_schema, dict):
|
||||
continue
|
||||
prop_type = prop_schema.get('type')
|
||||
value = config[key]
|
||||
|
||||
if prop_type == 'array':
|
||||
if _schema_type_is(prop_schema, 'array'):
|
||||
if _is_index_dict(value):
|
||||
value = config[key] = [value[k] for k in sorted(value, key=lambda k: int(str(k)))]
|
||||
if not isinstance(value, list):
|
||||
@@ -49,12 +64,12 @@ def coerce_array_shapes(config: Dict[str, Any], schema_props: Dict[str, Any],
|
||||
and len(value) < min_items
|
||||
and isinstance(default, list) and len(default) >= min_items):
|
||||
value = config[key] = list(default)
|
||||
items_schema = prop_schema.get('items')
|
||||
if (isinstance(items_schema, dict) and items_schema.get('type') == 'object'
|
||||
items_schema: Any = prop_schema.get('items')
|
||||
if (_schema_type_is(items_schema, 'object')
|
||||
and 'properties' in items_schema):
|
||||
for element in value:
|
||||
coerce_array_shapes(element, items_schema['properties'],
|
||||
short_lists_take_default)
|
||||
|
||||
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||
elif _schema_type_is(prop_schema, 'object') and 'properties' in prop_schema:
|
||||
coerce_array_shapes(value, prop_schema['properties'], short_lists_take_default)
|
||||
|
||||
@@ -152,7 +152,7 @@ def create_success_response(
|
||||
Returns:
|
||||
Dictionary for jsonify
|
||||
"""
|
||||
response = {
|
||||
response: dict[str, Any] = {
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ class WebInterfaceError:
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert error to dictionary for JSON response."""
|
||||
result = {
|
||||
result: Dict[str, Any] = {
|
||||
"status": "error",
|
||||
"error_code": self.error_code.value,
|
||||
"message": self.message,
|
||||
|
||||
@@ -5,10 +5,10 @@ Provides functions for identifying, masking, separating, and filtering
|
||||
secret fields in plugin configurations based on JSON Schema x-secret markers.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Set, Tuple
|
||||
from typing import Any, Dict, Optional, Set, Tuple, cast
|
||||
|
||||
|
||||
def find_secret_fields(properties: Dict[str, Any], prefix: str = '') -> Set[str]:
|
||||
def find_secret_fields(properties: Any, prefix: str = '') -> Set[str]:
|
||||
"""Find all fields marked with ``x-secret: true`` in a JSON Schema properties dict.
|
||||
|
||||
Recurses into nested objects and array items to discover secrets at any
|
||||
@@ -64,7 +64,14 @@ def separate_secrets(
|
||||
secrets: Dict[str, Any] = {}
|
||||
for key, value in config.items():
|
||||
full_path = f"{prefix}.{key}" if prefix else key
|
||||
if isinstance(value, dict):
|
||||
# The field's own x-secret marker is checked before its type. A secret
|
||||
# whose value is an object or array used to fall into the recursion
|
||||
# below, where none of its children are marked, and was written to
|
||||
# config.json in plain text. mask_secret_fields already checks the
|
||||
# marker first, so this also matches what the API masks.
|
||||
if full_path in secret_paths:
|
||||
secrets[key] = value
|
||||
elif isinstance(value, dict):
|
||||
nested_regular, nested_secrets = separate_secrets(value, secret_paths, full_path)
|
||||
if nested_regular:
|
||||
regular[key] = nested_regular
|
||||
@@ -94,8 +101,6 @@ def separate_secrets(
|
||||
secrets[key] = sec_items
|
||||
else:
|
||||
regular[key] = value
|
||||
elif full_path in secret_paths:
|
||||
secrets[key] = value
|
||||
else:
|
||||
regular[key] = value
|
||||
return regular, secrets
|
||||
@@ -331,4 +336,4 @@ def _contains_mask(value: Any) -> bool:
|
||||
return any(_contains_mask(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_mask(item) for item in value)
|
||||
return value == SECRET_MASK
|
||||
return cast(bool, value == SECRET_MASK)
|
||||
|
||||
@@ -8,11 +8,13 @@ from pathlib import Path
|
||||
def validate_file_upload(filename: str, max_size_mb: int = 10,
|
||||
allowed_extensions: Optional[List[str]] = None) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate file upload parameters.
|
||||
Validate an upload's filename (not its contents or size).
|
||||
|
||||
Args:
|
||||
filename: Name of the file
|
||||
max_size_mb: Maximum file size in MB
|
||||
max_size_mb: Unused. Kept so existing callers keep working; this
|
||||
function only sees the filename, so callers must enforce the
|
||||
size limit themselves on the uploaded stream.
|
||||
allowed_extensions: List of allowed file extensions (e.g., ['.ttf', '.otf'])
|
||||
|
||||
Returns:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user