mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
Merge remote-tracking branch 'origin/main' into claude/scan-order-compensation
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -3,21 +3,9 @@ name: Claude Code Review
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, ready_for_review, reopened]
|
types: [opened, synchronize, ready_for_review, reopened]
|
||||||
# Optional: Only run on specific file changes
|
|
||||||
# paths:
|
|
||||||
# - "src/**/*.ts"
|
|
||||||
# - "src/**/*.tsx"
|
|
||||||
# - "src/**/*.js"
|
|
||||||
# - "src/**/*.jsx"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
claude-review:
|
claude-review:
|
||||||
# Optional: Filter by PR author
|
|
||||||
# if: |
|
|
||||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
|
||||||
# github.event.pull_request.user.login == 'new-developer' ||
|
|
||||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -27,7 +15,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -45,6 +33,4 @@ jobs:
|
|||||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||||
plugins: 'code-review@claude-code-plugins'
|
plugins: 'code-review@claude-code-plugins'
|
||||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
actions: read # Required for Claude to read CI results on PRs
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -40,11 +40,3 @@ jobs:
|
|||||||
additional_permissions: |
|
additional_permissions: |
|
||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -81,10 +81,10 @@ assets/stocks/crypto_icons/
|
|||||||
|
|
||||||
# Plugin operation state written at runtime.
|
# Plugin operation state written at runtime.
|
||||||
#
|
#
|
||||||
# web_interface/app.py writes data/plugin_operations.json, data/plugin_state.json
|
# web_interface/app.py writes data/plugin_state.json and data/operation_history.json
|
||||||
# and data/operation_history.json as the web interface runs, into a directory that
|
# (older releases also data/plugin_operations.json) as the web interface runs, into
|
||||||
# ships tracked (data/.gitkeep) and was otherwise unignored. So every rig that ever
|
# a directory that ships tracked (data/.gitkeep). Unignored, every rig that ever
|
||||||
# opened the web UI -- and every test run that constructs the app -- left three
|
# opened the web UI -- and every test run that constructs the app -- would leave
|
||||||
# untracked files behind and a permanently dirty `git status`. Same reasoning as
|
# untracked files behind and a permanently dirty `git status`. Same reasoning as
|
||||||
# the logo rule above: a checkout that is always dirty is a checkout nobody reads.
|
# the logo rule above: a checkout that is always dirty is a checkout nobody reads.
|
||||||
data/*
|
data/*
|
||||||
|
|||||||
+53
-8
@@ -19,14 +19,50 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
- `src.common.frame_timing` -- times every frame the display presents, whoever
|
- Scripts and installer:
|
||||||
drew it, and writes cumulative counters to `/dev/shm`. Two tools read it:
|
- `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.
|
||||||
`scripts/frame_soak.py` judges a running service (late frames, freezes,
|
- `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.
|
||||||
where the time goes), and `scripts/render_bench.py` judges the hardware and
|
- `configure_web_sudo.sh` uses a random temp file and installs its rules with mode 440.
|
||||||
render path alone on a synthetic strip. Both fail a run above 0.1% late
|
- The installer prints its completion summary before the `-y` reboot, and describes the setup access point as an open network (it was shown with a password it doesn't have).
|
||||||
frames, and both call a loop that never waited for the panel NOT LOCKED. A
|
- `fix_cache_permissions.sh` applies `setup_cache.sh`'s `ledmatrix`-group model instead of setting 777.
|
||||||
stall watchdog logs the stack of whatever holds a scroll up for 250 ms or
|
- `check_system_compatibility.sh` reports anything but Debian 13 (Trixie) as unsupported, and reaches its summary.
|
||||||
more. See `docs/SCROLL_PERFORMANCE.md`, "Soaking a rig".
|
- New `scripts/README.md` lists every script.
|
||||||
|
- Docs:
|
||||||
|
- New `docs/ARCHITECTURE.md` (processes, shared state, display loop, plugin system, web UI) and `docs/PERMISSIONS.md` (owners, modes, both sudoers files, repair scripts).
|
||||||
|
- Deprecated plugin APIs are marked in the plugin docs.
|
||||||
|
- `src/common/README.md` covers every module.
|
||||||
|
- Stale setup, service and troubleshooting claims are corrected.
|
||||||
|
|
||||||
|
- Plugin store and plugin manager fixes:
|
||||||
|
- Updating a plugin that was installed from a ZIP no longer tries to reinstall it from the LEDMatrix repository's own URL.
|
||||||
|
- Repository URLs with `.git` in the middle are no longer mangled. The URL helpers now live in `src/plugin_system/repo_urls.py`.
|
||||||
|
- Installing from a URL works when the repository's only branch isn't `main` or `master`.
|
||||||
|
- A missing required config field is reported once, by name.
|
||||||
|
- A plugin that went over `max_memory_mb` once is no longer refused on every call after that.
|
||||||
|
- `reload_plugin` reads the manifest from the plugin's discovered directory.
|
||||||
|
- Removed: `last_display` from plugin state info and `get_last_display()` (nothing recorded them); `PluginOperationQueue`'s `history_file` and `lazy_load` arguments; and `data/plugin_operations.json`, which nothing read.
|
||||||
|
|
||||||
|
- Core service fixes:
|
||||||
|
- `/api/v3/errors` shows each exception's real stack trace instead of `NoneType: None`.
|
||||||
|
- Wi-Fi disconnect takes the saved connection profile down.
|
||||||
|
- `wifi_config.json` is written atomically, and a save that fails now gets a 500.
|
||||||
|
- `plugin://` fonts load from the plugin's own install directory. `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`.
|
||||||
|
- `APIHelper` keeps cached responses for the `cache_ttl` it was given, instead of always 300 s.
|
||||||
|
- Logo scales from 0.1 to 10 are honoured everywhere; values outside that range are clamped.
|
||||||
|
- `LogoHelper` and `logo_downloader`: an empty ESPN logo list counts as a failed download, and the placeholder is written at the requested path.
|
||||||
|
- Bundled font paths no longer depend on the directory the process was started from.
|
||||||
|
- Backups record `src.__version__`.
|
||||||
|
- Removed: `BackgroundDataService`'s `queue_size` stat and `clear_completed_requests()`.
|
||||||
|
|
||||||
|
- Web API fixes:
|
||||||
|
- A plugin save drops repeated entries in lists whose schema says `uniqueItems`, instead of failing validation.
|
||||||
|
- `/api/v3/health` reports the real plugin count.
|
||||||
|
- A malformed `vegas_plugin_order` or `vegas_excluded_plugins` is refused with a 400 and nothing is saved. It used to wipe the saved list.
|
||||||
|
- The per-plugin health and metrics routes return the display service's latest state.
|
||||||
|
- Resetting a plugin's config takes a backup first and reports a failed save.
|
||||||
|
- System metrics that can't be read are `null` everywhere: `cpu_temp` off a Pi, and every metric without psutil, where `/system/status` now answers 200 instead of 503.
|
||||||
|
- `/plugins/store/refresh` no longer claims a commit-metadata refresh it doesn't do.
|
||||||
|
- The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`.
|
||||||
|
|
||||||
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
|
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
|
||||||
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
|
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
|
||||||
@@ -203,6 +239,15 @@ floor on the release that ships them):
|
|||||||
`api_extractors`). No known plugin imports it. A plugin that does must use
|
`api_extractors`). No known plugin imports it. A plugin that does must use
|
||||||
`src.common` or its own copy of the code.
|
`src.common` or its own copy of the code.
|
||||||
|
|
||||||
|
- `src.common.frame_timing` -- times every frame the display presents, whoever
|
||||||
|
drew it, and writes cumulative counters to `/dev/shm`. Two tools read it:
|
||||||
|
`scripts/frame_soak.py` judges a running service (late frames, freezes,
|
||||||
|
where the time goes), and `scripts/render_bench.py` judges the hardware and
|
||||||
|
render path alone on a synthetic strip. Both fail a run above 0.1% late
|
||||||
|
frames, and both call a loop that never waited for the panel NOT LOCKED. A
|
||||||
|
stall watchdog logs the stack of whatever holds a scroll up for 250 ms or
|
||||||
|
more. See `docs/SCROLL_PERFORMANCE.md`, "Soaking a rig".
|
||||||
|
|
||||||
- `display.scan_order_compensation` (`"auto"` by default): while something
|
- `display.scan_order_compensation` (`"auto"` by default): while something
|
||||||
scrolls at one pixel per refresh, one half of each panel is shown a refresh
|
scrolls at one pixel per refresh, one half of each panel is shown a refresh
|
||||||
behind the other, which removes the 1px step a 1:N-scan panel shows across
|
behind the other, which removes the 1px step a 1:N-scan panel shows across
|
||||||
|
|||||||
@@ -13,14 +13,17 @@
|
|||||||
loader does NOT fall back to it — `PluginManager.discover_plugins()`
|
loader does NOT fall back to it — `PluginManager.discover_plugins()`
|
||||||
(`src/plugin_system/plugin_manager.py`) scans only the configured
|
(`src/plugin_system/plugin_manager.py`) scans only the configured
|
||||||
directory. Fallbacks exist in two narrower places: store operations
|
directory. Fallbacks exist in two narrower places: store operations
|
||||||
(`StoreManager._find_plugin_path()` in `store_manager.py`) and schema
|
(`PluginStoreManager._find_plugin_path()` in `store_manager.py`, which
|
||||||
lookup (`SchemaManager.get_schema_path()` in `schema_manager.py`,
|
searches `store_search_dirs()` from `plugin_dirs.py`) and schema lookup
|
||||||
which probes `plugins/` *before* `plugin-repos/`).
|
(`SchemaManager.get_schema_path()` in `schema_manager.py`, which probes
|
||||||
|
`plugins/` *before* `plugin-repos/`).
|
||||||
|
- `src/plugin_system/plugin_dirs.py` — the one resolver for "which directory
|
||||||
|
holds plugin X" (manifest `id` first, then `<id>` / `ledmatrix-<id>`)
|
||||||
|
|
||||||
## Plugin System
|
## Plugin System
|
||||||
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
|
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
|
||||||
- Required abstract methods: `update()`, `display(force_clear=False)`
|
- Required abstract methods: `update()`, `display(force_clear=False)`
|
||||||
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
|
- Each plugin needs: `manifest.json`, `config_schema.json`, and the entry point (`manager.py` by default); `requirements.txt` if it has dependencies. Required manifest fields: `docs/PLUGIN_API_REFERENCE.md#manifest-required-fields`
|
||||||
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
||||||
- Config schemas use JSON Schema Draft-7
|
- Config schemas use JSON Schema Draft-7
|
||||||
- Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
|
- Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
|
||||||
@@ -40,14 +43,14 @@
|
|||||||
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
|
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
|
||||||
- Plugin repo naming convention: `ledmatrix-<plugin-id>` (e.g., `ledmatrix-football-scoreboard`)
|
- Plugin repo naming convention: `ledmatrix-<plugin-id>` (e.g., `ledmatrix-football-scoreboard`)
|
||||||
- `plugins.json` registry at `https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json`
|
- `plugins.json` registry at `https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json`
|
||||||
- Store manager (`src/plugin_system/store_manager.py`) handles install/update/uninstall
|
- Store manager (`PluginStoreManager` in `src/plugin_system/store_manager.py`) handles install/update/uninstall
|
||||||
- Monorepo plugins are installed via ZIP extraction (no `.git` directory)
|
- Monorepo plugins are installed without a `.git` directory: GitHub Trees API + raw downloads, falling back to ZIP extraction
|
||||||
- Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version)
|
- Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version)
|
||||||
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
||||||
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||||
|
|
||||||
## Common Pitfalls
|
## Common Pitfalls
|
||||||
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
- paho-mqtt 2.x requires a `CallbackAPIVersion` argument: `VERSION1` for code written against v1 callback signatures (the MQTT bridge uses `VERSION2`)
|
||||||
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
||||||
- `DisplayManager` has no `draw_image()` — paste onto the PIL image directly:
|
- `DisplayManager` has no `draw_image()` — paste onto the PIL image directly:
|
||||||
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
|
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ This one-shot installer will automatically:
|
|||||||
- Install required system packages (git, python3, build tools, etc.)
|
- Install required system packages (git, python3, build tools, etc.)
|
||||||
- Clone or update the LEDMatrix repository
|
- Clone or update the LEDMatrix repository
|
||||||
- Run the complete first-time installation script
|
- Run the complete first-time installation script
|
||||||
|
- Print the web interface address, then **reboot the Pi automatically** (your SSH session will disconnect; give it a few minutes to come back)
|
||||||
|
|
||||||
The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. Pi 3B/3B+ and other 1GB boards land at the top of that range, because the C++ library is compiled serially to stay within available memory. All errors are reported explicitly with actionable fixes.
|
The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. Pi 3B/3B+ and other 1GB boards land at the top of that range, because the C++ library is compiled serially to stay within available memory. All errors are reported explicitly with actionable fixes.
|
||||||
|
|
||||||
@@ -689,9 +690,10 @@ Controls how long each installed plugin stays visible in seconds before switchin
|
|||||||
### Display Format Settings
|
### Display Format Settings
|
||||||
|
|
||||||
- **`use_short_date_format`** (boolean, default: true)
|
- **`use_short_date_format`** (boolean, default: true)
|
||||||
- Use short date format (e.g., "Jan 15") instead of long format (e.g., "January 15th")
|
- Currently has no effect. The web UI still saves it, but no core code
|
||||||
- Set to `false` for longer, more readable dates
|
reads it. Scoreboard plugins that offer a short date format read the
|
||||||
- Set to `true` to save space and show more information
|
setting from their own plugin config instead. See
|
||||||
|
[CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md#display--other-keys).
|
||||||
|
|
||||||
### Dynamic Duration Settings (`display.dynamic_duration`)
|
### Dynamic Duration Settings (`display.dynamic_duration`)
|
||||||
|
|
||||||
@@ -779,15 +781,21 @@ Controls how long each installed plugin stays visible in seconds before switchin
|
|||||||
<details>
|
<details>
|
||||||
<summary>Manual SSH Commands (for reference)</summary>
|
<summary>Manual SSH Commands (for reference)</summary>
|
||||||
|
|
||||||
The quick actions essentially just execute the following commands on the Pi.
|
The web interface's quick actions (Start/Stop/Restart Display) call
|
||||||
|
`sudo systemctl start|stop|restart ledmatrix.service` — see
|
||||||
|
`execute_system_action()` in
|
||||||
|
[`web_interface/blueprints/api_v3/system.py`](web_interface/blueprints/api_v3/system.py).
|
||||||
|
The service runs [`run.py`](run.py) as root.
|
||||||
|
|
||||||
From the project root directory (ex: /home/ledpi/LEDMatrix):
|
To run the display in the foreground instead (for debugging), stop the service
|
||||||
|
first, then from the project root (e.g. `/home/ledpi/LEDMatrix`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo python3 display_controller.py
|
sudo systemctl stop ledmatrix.service
|
||||||
|
sudo python3 run.py # add -d for debug logging
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start the display cycle but only stays active as long as your ssh session is active.
|
This only runs as long as your SSH session stays open.
|
||||||
|
|
||||||
### Convenience Scripts
|
### Convenience Scripts
|
||||||
|
|
||||||
@@ -957,9 +965,10 @@ sudo systemctl enable ledmatrix-web.service
|
|||||||
3. Check if another service is using port 5000
|
3. Check if another service is using port 5000
|
||||||
|
|
||||||
**Service Fails to Start:**
|
**Service Fails to Start:**
|
||||||
1. Check Python dependencies are installed
|
1. Check Python dependencies are installed. The installer puts them in the
|
||||||
2. Verify the virtual environment is set up correctly
|
system Python with `pip install --break-system-packages` (there is no
|
||||||
3. Check file permissions and ownership
|
virtual environment), so `python3 -c "import flask"` should succeed.
|
||||||
|
2. Check file permissions and ownership
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
+55
-74
@@ -185,63 +185,53 @@ their config section to control how oversized content is handled (see
|
|||||||
|
|
||||||
### Plugin Integration (Developer Guide)
|
### Plugin Integration (Developer Guide)
|
||||||
|
|
||||||
|
All of these have defaults in
|
||||||
|
[`BasePlugin`](../src/plugin_system/base_plugin.py); override only what you
|
||||||
|
need.
|
||||||
|
|
||||||
**1. Implement Content Method:**
|
**1. Implement Content Method:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def get_vegas_content(self):
|
def get_vegas_content(self):
|
||||||
"""
|
# Return a PIL Image, a list of Images, or None.
|
||||||
Return PIL Image or list of Images for Vegas mode.
|
# A single image is one block; a list becomes one item per image.
|
||||||
|
return [self._render_game(game) for game in self.games]
|
||||||
Returns:
|
|
||||||
PIL.Image or list[PIL.Image]: Content to display
|
|
||||||
- Single image: fixed-width content
|
|
||||||
- List of images: multiple segments
|
|
||||||
- None: skip this cycle
|
|
||||||
"""
|
|
||||||
# Example: Return single wide image
|
|
||||||
img = Image.new('RGB', (256, 32))
|
|
||||||
# ... render your content ...
|
|
||||||
return img
|
|
||||||
|
|
||||||
# Example: Return multiple segments
|
|
||||||
return [image1, image2, image3]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If it returns `None` (the default), Vegas falls back to the plugin's
|
||||||
|
`scroll_helper` image, then to capturing `display()` output
|
||||||
|
(`PluginAdapter.get_content()` in
|
||||||
|
[`src/vegas_mode/plugin_adapter.py`](../src/vegas_mode/plugin_adapter.py)).
|
||||||
|
|
||||||
**2. Specify Content Type:**
|
**2. Specify Content Type:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def get_vegas_content_type(self):
|
def get_vegas_content_type(self):
|
||||||
"""
|
# 'multi' | 'static' | 'none' -- default is 'static'
|
||||||
Specify how content should be handled.
|
return 'multi'
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 'multi' | 'static' | 'none'
|
|
||||||
"""
|
|
||||||
return 'multi' # Default for most plugins
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`'none'` excludes the plugin from Vegas mode.
|
||||||
|
|
||||||
**3. Optionally Specify Display Mode:**
|
**3. Optionally Specify Display Mode:**
|
||||||
|
|
||||||
```python
|
These return `VegasDisplayMode` members, not strings:
|
||||||
def get_vegas_display_mode(self):
|
|
||||||
"""
|
|
||||||
Preferred display mode for this plugin.
|
|
||||||
|
|
||||||
Returns:
|
```python
|
||||||
str: 'scroll' | 'fixed' | 'static'
|
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||||
"""
|
|
||||||
return 'scroll'
|
def get_vegas_display_mode(self):
|
||||||
|
return VegasDisplayMode.SCROLL
|
||||||
|
|
||||||
def get_supported_vegas_modes(self):
|
def get_supported_vegas_modes(self):
|
||||||
"""
|
return [VegasDisplayMode.SCROLL, VegasDisplayMode.STATIC]
|
||||||
List of supported modes.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: ['scroll', 'fixed', 'static']
|
|
||||||
"""
|
|
||||||
return ['scroll', 'static']
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`VegasDisplayMode` has `SCROLL` (`"scroll"`), `FIXED_SEGMENT` (`"fixed"`) and
|
||||||
|
`STATIC` (`"static"`). The default `get_vegas_display_mode()` uses the
|
||||||
|
plugin's `vegas_mode` config value if set, otherwise maps the content type
|
||||||
|
(`multi` to `SCROLL`, anything else to `FIXED_SEGMENT`).
|
||||||
|
|
||||||
### Content Rendering Guidelines
|
### Content Rendering Guidelines
|
||||||
|
|
||||||
**Image Dimensions:**
|
**Image Dimensions:**
|
||||||
@@ -966,11 +956,16 @@ from src.cache_manager import CacheManager
|
|||||||
|
|
||||||
service = get_background_service(CacheManager())
|
service = get_background_service(CacheManager())
|
||||||
stats = service.get_statistics()
|
stats = service.get_statistics()
|
||||||
print(f"Active tasks: {stats['active_tasks']}")
|
print(f"Active: {stats['active_requests']}")
|
||||||
print(f"Completed: {stats['completed']}")
|
print(f"Completed: {stats['completed_requests']}")
|
||||||
print(f"Failed: {stats['failed']}")
|
print(f"Failed: {stats['failed_requests']}")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Other keys: `total_requests`, `cached_hits`, `cache_misses`,
|
||||||
|
`average_fetch_time`, `completed_requests_count` (results currently held in
|
||||||
|
memory) — see `BackgroundDataService.get_statistics()` in
|
||||||
|
[`src/background_data_service.py`](../src/background_data_service.py).
|
||||||
|
|
||||||
**Enable Debug Logging:**
|
**Enable Debug Logging:**
|
||||||
```python
|
```python
|
||||||
import logging
|
import logging
|
||||||
@@ -981,6 +976,10 @@ logging.getLogger('src.background_data_service').setLevel(logging.DEBUG)
|
|||||||
|
|
||||||
## 5. Permission Management
|
## 5. Permission Management
|
||||||
|
|
||||||
|
Ownership, modes, sudo rules and the repair scripts are listed in
|
||||||
|
[PERMISSIONS.md](PERMISSIONS.md). This section covers the helpers code uses
|
||||||
|
to keep files shareable.
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|
||||||
LEDMatrix uses a dual-user architecture: the display service runs as root (hardware access), while the web interface runs as a non-privileged user. Centralized permission management ensures both can access necessary files.
|
LEDMatrix uses a dual-user architecture: the display service runs as root (hardware access), while the web interface runs as a non-privileged user. Centralized permission management ensures both can access necessary files.
|
||||||
@@ -1044,7 +1043,7 @@ ensure_file_permissions(config_path, get_config_file_mode(config_path))
|
|||||||
| Config (secrets) | `rw-r-----` | `0o640` | Owner write, group read |
|
| Config (secrets) | `rw-r-----` | `0o640` | Owner write, group read |
|
||||||
| Assets | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
| Assets | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
||||||
| Plugins | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
| Plugins | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
||||||
| Cache files | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
| Cache files | `rw-rw----` | `0o660` | Owner/group write, no world access (`_CACHE_FILE_MODE` in `src/cache/disk_cache.py`) |
|
||||||
|
|
||||||
**Directory Permissions:**
|
**Directory Permissions:**
|
||||||
|
|
||||||
@@ -1115,40 +1114,22 @@ These core utilities **already handle permissions** - you don't need to call per
|
|||||||
|
|
||||||
### Manual Fixes
|
### Manual Fixes
|
||||||
|
|
||||||
If you encounter permission issues:
|
[PERMISSIONS.md](PERMISSIONS.md) lists who owns what on an installed system,
|
||||||
|
the expected modes, and which `scripts/fix_perms/` script to run as which
|
||||||
|
user. In short:
|
||||||
|
|
||||||
```bash
|
- `fix_assets_permissions.sh`, `fix_cache_permissions.sh` and
|
||||||
# Targeted permission fixes (see scripts/fix_perms/README.md)
|
`fix_plugin_permissions.sh` are run with `sudo`.
|
||||||
sudo ./scripts/fix_perms/fix_assets_permissions.sh # assets/ tree (logos, fonts)
|
- `fix_web_permissions.sh` is run as the web interface user, without
|
||||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh # all cache directories
|
`sudo` (it refuses to run as root and calls `sudo` itself where needed).
|
||||||
sudo ./scripts/fix_perms/fix_plugin_permissions.sh # plugin directories
|
It resets project file ownership for that user, then makes the two
|
||||||
sudo ./scripts/fix_perms/fix_web_permissions.sh # web interface files
|
helper scripts the web user may run as root (`safe_plugin_rm.sh`,
|
||||||
|
`safe_pip_install.sh`) root-owned again and restores `config_secrets.json`
|
||||||
|
to its owner, the `ledmatrix` group and mode `640`. It does not write
|
||||||
|
sudoers rules; `scripts/install/configure_web_sudo.sh` does that.
|
||||||
|
|
||||||
# Fix specific directory
|
Do not `chmod` the whole `config/` directory: `config_secrets.json` must stay
|
||||||
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/config
|
`640`.
|
||||||
sudo chmod -R 2775 /home/ledpi/LEDMatrix/config
|
|
||||||
sudo find /home/ledpi/LEDMatrix/config -type f -exec chmod 664 {} \;
|
|
||||||
|
|
||||||
# Verify permissions
|
|
||||||
ls -la config/
|
|
||||||
ls -la assets/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check directory has setgid bit
|
|
||||||
ls -ld assets/
|
|
||||||
# Should show: drwxrwsr-x (note the 's')
|
|
||||||
|
|
||||||
# Check file has correct group
|
|
||||||
ls -l assets/logo.png
|
|
||||||
# Should show group 'ledpi'
|
|
||||||
|
|
||||||
# Check file permissions
|
|
||||||
stat -c "%a %n" config/config.json
|
|
||||||
# Should show: 644 config/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
|
|||||||
- [Using Weather Icons](#using-weather-icons)
|
- [Using Weather Icons](#using-weather-icons)
|
||||||
- [Implementing Scrolling with Deferred Updates](#implementing-scrolling-with-deferred-updates)
|
- [Implementing Scrolling with Deferred Updates](#implementing-scrolling-with-deferred-updates)
|
||||||
- [Cache Strategy Patterns](#cache-strategy-patterns)
|
- [Cache Strategy Patterns](#cache-strategy-patterns)
|
||||||
- [Font Management and Overrides](#font-management-and-overrides)
|
- [Font Management](#font-management)
|
||||||
- [Error Handling Best Practices](#error-handling-best-practices)
|
- [Error Handling Best Practices](#error-handling-best-practices)
|
||||||
- [Performance Optimization](#performance-optimization)
|
- [Performance Optimization](#performance-optimization)
|
||||||
- [Testing Plugins with Mocks](#testing-plugins-with-mocks)
|
- [Testing Plugins with Mocks](#testing-plugins-with-mocks)
|
||||||
@@ -25,69 +25,12 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
|
|||||||
|
|
||||||
## Using Weather Icons
|
## Using Weather Icons
|
||||||
|
|
||||||
The Display Manager provides built-in weather icon drawing methods for easy visual representation of weather conditions.
|
The Display Manager's icon methods — `draw_weather_icon()`, `draw_sun()`,
|
||||||
|
`draw_cloud()`, `draw_rain()`, `draw_snow()` and `draw_text_with_icons()` —
|
||||||
### Basic Weather Icon Usage
|
are deprecated, removed in 3.7.0. Draw your own icons instead: render them
|
||||||
|
onto a PIL image and paste it onto `self.display_manager.image`, or ship
|
||||||
```python
|
icon images with the plugin. The weather plugin's `WeatherIcons` class is an
|
||||||
def display(self, force_clear=False):
|
example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||||
if force_clear:
|
|
||||||
self.display_manager.clear()
|
|
||||||
|
|
||||||
# Draw weather icon based on condition
|
|
||||||
condition = self.data.get('condition', 'clear')
|
|
||||||
self.display_manager.draw_weather_icon(condition, x=5, y=5, size=16)
|
|
||||||
|
|
||||||
# Draw temperature next to icon
|
|
||||||
temp = self.data.get('temp', 72)
|
|
||||||
self.display_manager.draw_text(
|
|
||||||
f"{temp}°F",
|
|
||||||
x=25, y=10,
|
|
||||||
color=(255, 255, 255)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.display_manager.update_display()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Supported Weather Conditions
|
|
||||||
|
|
||||||
The `draw_weather_icon()` method automatically maps condition strings to appropriate icons:
|
|
||||||
|
|
||||||
- `"clear"`, `"sunny"` → Sun icon
|
|
||||||
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
|
|
||||||
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
|
|
||||||
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
|
|
||||||
- `"thunderstorm"`, `"storm"` → Storm icon
|
|
||||||
|
|
||||||
### Custom Weather Icons
|
|
||||||
|
|
||||||
For more control, use individual icon methods:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Draw specific icons
|
|
||||||
self.display_manager.draw_sun(x=10, y=10, size=16)
|
|
||||||
self.display_manager.draw_cloud(x=10, y=10, size=16, color=(150, 150, 150))
|
|
||||||
self.display_manager.draw_rain(x=10, y=10, size=16)
|
|
||||||
self.display_manager.draw_snow(x=10, y=10, size=16)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Text with Weather Icons
|
|
||||||
|
|
||||||
Use `draw_text_with_icons()` to combine text and icons:
|
|
||||||
|
|
||||||
```python
|
|
||||||
icons = [
|
|
||||||
("sun", 5, 5), # Sun icon at (5, 5)
|
|
||||||
("cloud", 100, 5) # Cloud icon at (100, 5)
|
|
||||||
]
|
|
||||||
|
|
||||||
self.display_manager.draw_text_with_icons(
|
|
||||||
"Weather: Sunny, Cloudy",
|
|
||||||
icons=icons,
|
|
||||||
x=10, y=20,
|
|
||||||
color=(255, 255, 255)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -251,11 +194,8 @@ def update(self):
|
|||||||
sport_key = "nhl"
|
sport_key = "nhl"
|
||||||
cache_key = f"{self.plugin_id}_{sport_key}_games"
|
cache_key = f"{self.plugin_id}_{sport_key}_games"
|
||||||
|
|
||||||
# Uses sport-specific live_update_interval from config
|
# get_background_cached_data() is deprecated, removed in 3.7.0 — use get()
|
||||||
cached = self.cache_manager.get_background_cached_data(
|
cached = self.cache_manager.get(cache_key, max_age=60)
|
||||||
cache_key,
|
|
||||||
sport_key=sport_key
|
|
||||||
)
|
|
||||||
|
|
||||||
if cached:
|
if cached:
|
||||||
self.games = cached
|
self.games = cached
|
||||||
@@ -282,9 +222,9 @@ def on_config_change(self, new_config):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Font Management and Overrides
|
## Font Management
|
||||||
|
|
||||||
Use the Font Manager for advanced font handling and user customization.
|
The display manager's built-in fonts and text measurement. For fonts shipped with a plugin, see [FONT_MANAGER.md](FONT_MANAGER.md).
|
||||||
|
|
||||||
### Using Different Fonts
|
### Using Different Fonts
|
||||||
|
|
||||||
@@ -656,12 +596,10 @@ def update(self):
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
def update(self):
|
def update(self):
|
||||||
# Check if another plugin is enabled
|
# get_enabled_plugins() is deprecated, removed in 3.7.0 — check the
|
||||||
enabled_plugins = self.plugin_manager.get_enabled_plugins()
|
# instance's `enabled` flag instead
|
||||||
if "weather" in enabled_plugins:
|
|
||||||
# Weather plugin is available
|
|
||||||
weather_plugin = self.plugin_manager.get_plugin("weather")
|
weather_plugin = self.plugin_manager.get_plugin("weather")
|
||||||
if weather_plugin:
|
if weather_plugin is not None and weather_plugin.enabled:
|
||||||
# Use weather data
|
# Use weather data
|
||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
A map of the codebase for a new contributor: which process does what, how
|
||||||
|
they talk to each other, and where to start reading for common changes.
|
||||||
|
|
||||||
|
## Processes
|
||||||
|
|
||||||
|
| systemd unit | Runs as | Runs | Installed by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ledmatrix.service` | root | [`run.py`](../run.py) → `DisplayController` | [`install_service.sh`](../scripts/install/install_service.sh) |
|
||||||
|
| `ledmatrix-web.service` | the installing user | [`start_web_conditionally.py`](../scripts/utils/start_web_conditionally.py) → [`web_interface/start.py`](../web_interface/start.py) (Flask, port 5000) | `install_service.sh`, [`install_web_service.sh`](../scripts/install/install_web_service.sh) |
|
||||||
|
| `ledmatrix-update-verify.path` / `.service` | the web user | Health check after an automatic update | the same installers, or [`src/auto_update_setup.py`](../src/auto_update_setup.py) at runtime |
|
||||||
|
| `ledmatrix-wifi-monitor.service` | root | [`wifi_monitor_daemon.py`](../scripts/utils/wifi_monitor_daemon.py) | [`install_wifi_monitor.sh`](../scripts/install/install_wifi_monitor.sh) |
|
||||||
|
| `ledmatrix-mqtt-bridge.service` | root | [MQTT bridge](../integrations/mqtt_bridge/README.md) (optional) | [`install_mqtt_bridge.sh`](../scripts/install/install_mqtt_bridge.sh) |
|
||||||
|
| `ledmatrix-dns-fix.service` | root | DNS workaround (optional) | [`install_dns_fix.sh`](../scripts/install/install_dns_fix.sh) |
|
||||||
|
|
||||||
|
Unit templates are in [`systemd/`](../systemd/README.md). The display runs as
|
||||||
|
root because the LED matrix library needs direct GPIO access. The web
|
||||||
|
interface runs unprivileged and uses a fixed list of `sudo` rules for the
|
||||||
|
few privileged things it does; see [PERMISSIONS.md](PERMISSIONS.md).
|
||||||
|
|
||||||
|
`start_web_conditionally.py` exits without starting Flask when
|
||||||
|
`web_display_autostart` is explicitly false in `config.json`.
|
||||||
|
|
||||||
|
## How the two main processes share state
|
||||||
|
|
||||||
|
The display and the web interface are separate processes that never call
|
||||||
|
each other. They share three things:
|
||||||
|
|
||||||
|
1. **`config/config.json` and `config/config_secrets.json`.** The web
|
||||||
|
interface writes them through `ConfigManager`
|
||||||
|
([`src/config_manager.py`](../src/config_manager.py)); the display
|
||||||
|
notices through `ConfigService` (below).
|
||||||
|
2. **The disk cache**, `/var/cache/ledmatrix` (owned `root:ledmatrix`,
|
||||||
|
setgid, files `0660`), read and written through `CacheManager`
|
||||||
|
([`src/cache_manager.py`](../src/cache_manager.py),
|
||||||
|
[`src/cache/disk_cache.py`](../src/cache/disk_cache.py)). Readers in the
|
||||||
|
other process pass `memory_ttl=0` so they do not serve a stale in-memory
|
||||||
|
copy.
|
||||||
|
3. **A few files in `/tmp`.**
|
||||||
|
|
||||||
|
| State | Where | Written by | Read by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| On-demand request | cache `display_on_demand_request` | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py) | display: `_poll_on_demand_requests()` |
|
||||||
|
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
|
||||||
|
| Current screen | cache `display_current_state` | display | web: `/api/v3/display/current-status` |
|
||||||
|
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
|
||||||
|
| Error clear | cache `plugin_error_clear_request` | web | display |
|
||||||
|
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
||||||
|
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
||||||
|
| Preview frame | `/tmp/led_matrix_preview.png` | display: `DisplayManager`, gated by [`snapshot_policy`](../src/common/snapshot_policy.py) | web: display SSE stream, `/api/v3/health` (file age) |
|
||||||
|
| Preview viewer marker | `/tmp/led_matrix_preview_viewer` | web, while a preview is open | display: writes full-rate snapshots only while it is fresh |
|
||||||
|
| Hardware init status | `/tmp/led_matrix_hw_status.json` | display | web: `/api/v3/hardware/status` |
|
||||||
|
|
||||||
|
The on-demand start route also restarts `ledmatrix.service` by default so the
|
||||||
|
request takes effect straight away.
|
||||||
|
|
||||||
|
## Display loop
|
||||||
|
|
||||||
|
[`src/display_controller.py`](../src/display_controller.py), class
|
||||||
|
`DisplayController`. `__init__` loads config, starts the cache and the
|
||||||
|
error-snapshot publisher, runs the startup validator, creates the
|
||||||
|
`DisplayManager` ([`src/display_manager.py`](../src/display_manager.py)),
|
||||||
|
`FontManager` and `PluginManager`, loads the enabled plugins in parallel,
|
||||||
|
runs an initial `update()` pass within a 20-second budget
|
||||||
|
(`_INITIAL_UPDATE_BUDGET_SECONDS`; a plugin that misses it is deferred to
|
||||||
|
the scheduler), and sets up Vegas mode.
|
||||||
|
|
||||||
|
`run()` is the main loop. Each pass, in order: apply a pending plugin
|
||||||
|
enable/disable, poll on-demand requests, run scheduled plugin updates, check
|
||||||
|
the on/off schedule and brightness, then show one screen. Priority is
|
||||||
|
on-demand, then WiFi status messages, then live priority, then Vegas mode,
|
||||||
|
then normal rotation.
|
||||||
|
|
||||||
|
- **Rotation.** `available_modes` is the ordered list of display modes;
|
||||||
|
`current_mode_index` advances after each screen.
|
||||||
|
`_apply_plugin_rotation_order()` applies `display.plugin_rotation_order`.
|
||||||
|
- **Durations.** `_get_display_duration()`: `display.display_durations[mode]`,
|
||||||
|
else the plugin's `get_display_duration()`, else 30 s. Plugins that
|
||||||
|
support dynamic duration run until `is_cycle_complete()`, capped by
|
||||||
|
`display.dynamic_duration.max_duration_seconds` (default 180 s).
|
||||||
|
- **On-demand.** A request from the web interface pins one plugin (or mode)
|
||||||
|
for a duration. `_activate_on_demand()` / `_clear_on_demand()`; the
|
||||||
|
session is saved under `display_on_demand_config` so it survives a
|
||||||
|
restart. It also keeps the display on during scheduled off hours.
|
||||||
|
- **Live priority.** `_check_live_priority()` looks for a plugin whose
|
||||||
|
`has_live_priority()` and `has_live_content()` are both true and switches
|
||||||
|
to it, rotating between several live games.
|
||||||
|
- **Schedule and dim schedule.** `_check_schedule()` reads `schedule`;
|
||||||
|
`_check_dim_schedule()` reads `dim_schedule` and
|
||||||
|
`display.hardware.brightness`. Both are re-evaluated once a minute.
|
||||||
|
- **Long screens.** While a screen is showing (a dwell, a scroll, a Vegas
|
||||||
|
iteration), `_service_pending_changes()` repeats the on-demand, schedule
|
||||||
|
and brightness checks every 0.25 s, so a change does not wait for the
|
||||||
|
screen to end.
|
||||||
|
- **Config hot reload.** `ConfigService`
|
||||||
|
([`src/config_service.py`](../src/config_service.py)) polls the config and
|
||||||
|
secrets files' mtimes every 2 s and notifies subscribers when the content
|
||||||
|
changes. The controller refreshes its cached settings; enabling or
|
||||||
|
disabling a plugin queues `_reconcile_enabled_plugins()`, which loads or
|
||||||
|
unloads it on the display thread; each plugin gets `on_config_change()`
|
||||||
|
for its own section. Set `LEDMATRIX_HOT_RELOAD=false` to turn this off.
|
||||||
|
Matrix hardware settings are only read at start-up.
|
||||||
|
- **Vegas mode.** [`src/vegas_mode/`](../src/vegas_mode/): the display loop
|
||||||
|
calls `VegasModeCoordinator.run_iteration()`
|
||||||
|
([`coordinator.py`](../src/vegas_mode/coordinator.py)) when
|
||||||
|
`display.vegas_scroll.enabled` is set. `PluginAdapter` gets each plugin's
|
||||||
|
content (`get_vegas_content()`, else its `scroll_helper` image, else a
|
||||||
|
capture of `display()`), `StreamManager` orders it and `RenderPipeline`
|
||||||
|
scrolls it. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md).
|
||||||
|
- **Multi-display sync.** `DisplaySyncManager`
|
||||||
|
([`src/common/sync_manager.py`](../src/common/sync_manager.py)), enabled by
|
||||||
|
`sync.role`: a leader sends a follower its share of each frame over UDP
|
||||||
|
(port 5765).
|
||||||
|
|
||||||
|
## Plugin system
|
||||||
|
|
||||||
|
[`src/plugin_system/`](../src/plugin_system/):
|
||||||
|
|
||||||
|
| Area | Where |
|
||||||
|
|---|---|
|
||||||
|
| Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) |
|
||||||
|
| Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `<id>` or `ledmatrix-<id>` |
|
||||||
|
| Discovery, load, unload, scheduled updates | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
|
||||||
|
| Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) |
|
||||||
|
| Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) |
|
||||||
|
| 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`) |
|
||||||
|
| Core-version gate | [`compatibility.py`](../src/plugin_system/compatibility.py) |
|
||||||
|
|
||||||
|
Discovery scans only `plugin_system.plugins_directory` (default
|
||||||
|
`plugin-repos/`). Scheduled `update()` calls run on one background worker
|
||||||
|
thread; a per-plugin lock keeps `display()` from running during an update.
|
||||||
|
|
||||||
|
**Store flow.** `install_plugin()` renames any existing copy aside
|
||||||
|
(`<id>.standalone-backup-preinstall`), installs the new one, and puts the old
|
||||||
|
copy back if the install fails. Monorepo plugins come from the GitHub Trees
|
||||||
|
API, falling back to the repository ZIP; other plugins by `git clone` or
|
||||||
|
download. The manifest is checked (see
|
||||||
|
[required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)), the core
|
||||||
|
version gate runs, then dependencies are installed as root through
|
||||||
|
`scripts/fix_perms/safe_pip_install.sh`. `update_plugin()` pulls git
|
||||||
|
installs, undoing a pull whose new version is incompatible, and reinstalls
|
||||||
|
everything else through `_reinstall_with_rollback()`.
|
||||||
|
|
||||||
|
## Web interface
|
||||||
|
|
||||||
|
- **App.** [`web_interface/app.py`](../web_interface/app.py) builds the
|
||||||
|
Flask `app` at import time, creates the managers, and registers two
|
||||||
|
blueprints. `web_interface/start.py` runs it on port 5000.
|
||||||
|
- **Pages.** [`blueprints/pages_v3.py`](../web_interface/blueprints/pages_v3.py)
|
||||||
|
serves the shell `templates/v3/base.html` at `/` and each tab as a
|
||||||
|
partial at `/partials/<name>` (templates in
|
||||||
|
`web_interface/templates/v3/partials/`). Plugin configuration tabs are
|
||||||
|
rendered from the plugin's schema by `plugin_config.html`.
|
||||||
|
- **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
|
||||||
|
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
|
||||||
|
(`hx-trigger="loadtab"`); Alpine.js holds page state. Scripts are in
|
||||||
|
`web_interface/static/v3/js/`; form widgets are bundled from
|
||||||
|
[`js/widgets/`](../web_interface/static/v3/js/widgets/README.md).
|
||||||
|
- **Server-sent events** (`app.py`): `/api/v3/stream/stats` (CPU, memory,
|
||||||
|
temperature, service state, every 10 s), `/api/v3/stream/display` (preview
|
||||||
|
frames when the PNG changes) and `/api/v3/stream/logs` (journal of both
|
||||||
|
services). One generator thread per stream is shared by all clients.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
- **Update Code** on the Overview tab and the automatic updater both call
|
||||||
|
`perform_core_update()` in
|
||||||
|
[`api_v3/system.py`](../web_interface/blueprints/api_v3/system.py):
|
||||||
|
`git pull --rebase`, reinstall changed requirement files, report whether a
|
||||||
|
restart is needed.
|
||||||
|
- **Automatic updates** (`auto_update.enabled`, off by default):
|
||||||
|
`AutoUpdater` in [`web_interface/auto_update.py`](../web_interface/auto_update.py)
|
||||||
|
runs in the web process, checks every 30 minutes, and updates at most
|
||||||
|
weekly between 02:00 and 05:00. Before pulling it copies
|
||||||
|
[`scripts/utils/auto_update_verify.py`](../scripts/utils/auto_update_verify.py)
|
||||||
|
to `data/auto_update_verifier.py`, then writes
|
||||||
|
`data/auto_update_verify.request`. That file triggers
|
||||||
|
`ledmatrix-update-verify.path`, which runs the verifier as a separate unit
|
||||||
|
(so restarting the web service does not kill it). The verifier restarts
|
||||||
|
both services, waits for the web API to answer and the display service to
|
||||||
|
stay up, and on failure resets to the previous commit and restarts again.
|
||||||
|
Plugin updates run only after a verified core update. State is in
|
||||||
|
`data/auto_update_state.json` and `data/auto_update_pending.json`.
|
||||||
|
- **Startup validator.** `StartupValidator`
|
||||||
|
([`src/startup_validator.py`](../src/startup_validator.py)) runs twice in
|
||||||
|
`DisplayController.__init__`: config and cache directory first, then
|
||||||
|
enabled plugins once the plugin manager exists. It also warns when an
|
||||||
|
installed systemd unit differs from its template in `systemd/`. Results
|
||||||
|
are logged; startup continues either way.
|
||||||
|
|
||||||
|
## Where to start reading
|
||||||
|
|
||||||
|
| Task | Start with |
|
||||||
|
|---|---|
|
||||||
|
| Change rotation, durations or priorities | `DisplayController.run()` and `_get_display_duration()` in [`display_controller.py`](../src/display_controller.py) |
|
||||||
|
| Add a config key | [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md), [`config/config.template.json`](../config/config.template.json), the tab's partial and `api_v3/config.py` |
|
||||||
|
| Change drawing or fonts | [`display_manager.py`](../src/display_manager.py), [`font_manager.py`](../src/font_manager.py), [`src/common/bdf_font.py`](../src/common/bdf_font.py) |
|
||||||
|
| Add a plugin-facing API | [`base_plugin.py`](../src/plugin_system/base_plugin.py) or [`src/common/`](../src/common/README.md); document it in [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) |
|
||||||
|
| Plugin install/update bugs | `PluginStoreManager` in [`store_manager.py`](../src/plugin_system/store_manager.py) |
|
||||||
|
| A plugin that won't load | `PluginManager.load_plugin()` and `PluginLoader.load_plugin()`; `python3 scripts/check_plugin.py --plugin <id>` |
|
||||||
|
| Add an API endpoint | the matching module in [`api_v3/`](../web_interface/blueprints/api_v3/) |
|
||||||
|
| Add a web UI tab or control | `templates/v3/base.html`, the tab's partial, `pages_v3.py` |
|
||||||
|
| Vegas scroll | [`src/vegas_mode/coordinator.py`](../src/vegas_mode/coordinator.py) |
|
||||||
|
| Installer or permissions | [`first_time_install.sh`](../first_time_install.sh), [`scripts/install/`](../scripts/install/), [PERMISSIONS.md](PERMISSIONS.md) |
|
||||||
|
| Work without a Pi | [DEV_PREVIEW.md](DEV_PREVIEW.md), [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md), [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) |
|
||||||
@@ -172,10 +172,14 @@ ERROR - Plugin football-scoreboard configuration validation failed: 'api_key' is
|
|||||||
|
|
||||||
### Enable Debug Logging
|
### Enable Debug Logging
|
||||||
|
|
||||||
Set environment variable:
|
Run the display in the foreground with `-d`, or set `LEDMATRIX_DEBUG=true`
|
||||||
|
(the value must be `true`; `1` is ignored — see `setup_logging()` in
|
||||||
|
[`src/logging_config.py`](../src/logging_config.py)):
|
||||||
```bash
|
```bash
|
||||||
export LEDMATRIX_DEBUG=1
|
sudo systemctl stop ledmatrix.service
|
||||||
python run.py
|
sudo python3 run.py -d
|
||||||
|
# or
|
||||||
|
sudo LEDMATRIX_DEBUG=true python3 run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Merged Configuration
|
### Check Merged Configuration
|
||||||
@@ -321,8 +325,10 @@ cp config/backups/config.json.backup.20240115_120000_000000 config/config.json
|
|||||||
|
|
||||||
## Getting Help
|
## Getting Help
|
||||||
|
|
||||||
1. Check logs: `tail -f logs/ledmatrix.log`
|
1. Check logs. Both services log to journald, not to a file:
|
||||||
2. Enable debug: `LEDMATRIX_DEBUG=1`
|
`sudo journalctl -u ledmatrix.service -f` (display) and
|
||||||
|
`sudo journalctl -u ledmatrix-web.service -f` (web interface)
|
||||||
|
2. Enable debug: `LEDMATRIX_DEBUG=true` or `python3 run.py -d`
|
||||||
3. Check error dashboard: `/api/v3/errors/summary`
|
3. Check error dashboard: `/api/v3/errors/summary`
|
||||||
4. Validate JSON: https://jsonlint.com/
|
4. Validate JSON: https://jsonlint.com/
|
||||||
5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues
|
5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
|||||||
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||||
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||||
|
|
||||||
# Weather icons
|
# Weather icons: draw_weather_icon() is deprecated, removed in 3.7.0 —
|
||||||
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
# draw your own icons (the weather plugin ships WeatherIcons)
|
||||||
|
|
||||||
# Scrolling state
|
# Scrolling state
|
||||||
display_manager.set_scrolling_state(True)
|
display_manager.set_scrolling_state(True)
|
||||||
@@ -72,20 +72,23 @@ cache_manager.delete("key") # alias for clear_cache(key)
|
|||||||
|
|
||||||
# Advanced caching
|
# Advanced caching
|
||||||
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
|
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
|
||||||
data = cache_manager.get_background_cached_data("key", sport_key="nhl")
|
|
||||||
|
|
||||||
# Strategy
|
# Strategy
|
||||||
strategy = cache_manager.get_cache_strategy("weather")
|
strategy = cache_manager.get_cache_strategy("weather")
|
||||||
interval = cache_manager.get_sport_live_interval("nhl")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`get_background_cached_data()` (use `get()`) and `get_sport_live_interval()`
|
||||||
|
are deprecated, removed in 3.7.0. See
|
||||||
|
[Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||||
|
|
||||||
## Plugin Manager Quick Methods
|
## Plugin Manager Quick Methods
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Get plugins
|
# Get plugins
|
||||||
plugin = plugin_manager.get_plugin("plugin-id")
|
plugin = plugin_manager.get_plugin("plugin-id")
|
||||||
all_plugins = plugin_manager.get_all_plugins()
|
all_plugins = plugin_manager.get_all_plugins()
|
||||||
enabled = plugin_manager.get_enabled_plugins()
|
# get_enabled_plugins() is deprecated, removed in 3.7.0 — check `enabled`
|
||||||
|
# on the entries in plugin_manager.plugins
|
||||||
|
|
||||||
# Get info
|
# Get info
|
||||||
info = plugin_manager.get_plugin_info("plugin-id")
|
info = plugin_manager.get_plugin_info("plugin-id")
|
||||||
@@ -168,7 +171,7 @@ def display(self, force_clear=False):
|
|||||||
|
|
||||||
- [ ] Plugin inherits from `BasePlugin`
|
- [ ] Plugin inherits from `BasePlugin`
|
||||||
- [ ] Implements `update()` and `display()` methods
|
- [ ] Implements `update()` and `display()` methods
|
||||||
- [ ] `manifest.json` with required fields
|
- [ ] `manifest.json` with the [required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)
|
||||||
- [ ] `config_schema.json` for web UI (recommended)
|
- [ ] `config_schema.json` for web UI (recommended)
|
||||||
- [ ] `README.md` with documentation
|
- [ ] `README.md` with documentation
|
||||||
- [ ] Error handling implemented
|
- [ ] Error handling implemented
|
||||||
|
|||||||
+120
-323
@@ -9,12 +9,14 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
[`src/font_manager.py`](../src/font_manager.py) loads and caches the TTF and
|
||||||
- Manager font registration and detection
|
BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records
|
||||||
- Plugin font management
|
which plugin uses which font so the web UI can show it.
|
||||||
- Programmatic per-element font overrides
|
|
||||||
- Performance monitoring and caching
|
Several methods are deprecated and will be removed in LEDMatrix 3.7.0; they
|
||||||
- Dynamic font discovery
|
log a warning on first call. They are listed in
|
||||||
|
[Deprecated methods](#deprecated-methods) below, and the full set is pinned in
|
||||||
|
[`test/test_deprecation.py`](../test/test_deprecation.py).
|
||||||
|
|
||||||
## Getting the FontManager
|
## Getting the FontManager
|
||||||
|
|
||||||
@@ -34,157 +36,60 @@ standalone FontManager when none is available (test harnesses, mocks).
|
|||||||
`DisplayManager` has **no** `font_manager` attribute —
|
`DisplayManager` has **no** `font_manager` attribute —
|
||||||
`display_manager.font_manager` raises `AttributeError`.
|
`display_manager.font_manager` raises `AttributeError`.
|
||||||
|
|
||||||
## Architecture
|
## Resolving a font
|
||||||
|
|
||||||
### Manager-Centric Design
|
|
||||||
|
|
||||||
Managers define their own fonts, but the FontManager:
|
|
||||||
1. **Loads and caches fonts** for performance
|
|
||||||
2. **Detects font usage** for visibility
|
|
||||||
3. **Allows manual overrides** when needed
|
|
||||||
4. **Supports plugin fonts** with namespacing
|
|
||||||
|
|
||||||
### Font Resolution Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Manager requests font → Check manual overrides → Apply manager choice → Cache & return
|
|
||||||
```
|
|
||||||
|
|
||||||
## For Manager Developers
|
|
||||||
|
|
||||||
### Basic Font Usage
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from src.font_manager import FontManager
|
element_key = f"{self.plugin_id}.title"
|
||||||
|
|
||||||
class MyManager:
|
# Register the choice so the web UI's Fonts tab can list it.
|
||||||
def __init__(self, config, display_manager, cache_manager, plugin_manager):
|
self.font_manager.register_manager_font(
|
||||||
self.display_manager = display_manager
|
manager_id=self.plugin_id,
|
||||||
self.font_manager = plugin_manager.font_manager # Shared FontManager
|
|
||||||
self.manager_id = "my_manager"
|
|
||||||
|
|
||||||
def display(self):
|
|
||||||
# Define your font choices
|
|
||||||
element_key = "my_manager.title"
|
|
||||||
font_family = "press_start"
|
|
||||||
font_size_px = 10
|
|
||||||
color = (255, 255, 255) # RGB white
|
|
||||||
|
|
||||||
# Register your font choice (for detection and future overrides)
|
|
||||||
self.font_manager.register_manager_font(
|
|
||||||
manager_id=self.manager_id,
|
|
||||||
element_key=element_key,
|
element_key=element_key,
|
||||||
family=font_family,
|
|
||||||
size_px=font_size_px,
|
|
||||||
color=color
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get the font (checks for manual overrides automatically)
|
|
||||||
font = self.font_manager.resolve_font(
|
|
||||||
element_key=element_key,
|
|
||||||
family=font_family,
|
|
||||||
size_px=font_size_px
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use the font for rendering
|
|
||||||
self.display_manager.draw_text(
|
|
||||||
"Hello World",
|
|
||||||
x=10, y=10,
|
|
||||||
color=color,
|
|
||||||
font=font
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Font Usage
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AdvancedManager:
|
|
||||||
def __init__(self, config, display_manager, cache_manager, plugin_manager):
|
|
||||||
self.display_manager = display_manager
|
|
||||||
self.font_manager = plugin_manager.font_manager
|
|
||||||
self.manager_id = "advanced_manager"
|
|
||||||
|
|
||||||
# Define your font specifications
|
|
||||||
self.font_specs = {
|
|
||||||
"title": {"family": "press_start", "size_px": 12, "color": (255, 255, 0)},
|
|
||||||
"body": {"family": "four_by_six", "size_px": 8, "color": (255, 255, 255)},
|
|
||||||
"footer": {"family": "five_by_seven", "size_px": 7, "color": (128, 128, 128)}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Register all font specs
|
|
||||||
for element_type, spec in self.font_specs.items():
|
|
||||||
element_key = f"{self.manager_id}.{element_type}"
|
|
||||||
self.font_manager.register_manager_font(
|
|
||||||
manager_id=self.manager_id,
|
|
||||||
element_key=element_key,
|
|
||||||
family=spec["family"],
|
|
||||||
size_px=spec["size_px"],
|
|
||||||
color=spec["color"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_font(self, element_type: str):
|
|
||||||
"""Helper method to get fonts with override support."""
|
|
||||||
spec = self.font_specs[element_type]
|
|
||||||
element_key = f"{self.manager_id}.{element_type}"
|
|
||||||
|
|
||||||
return self.font_manager.resolve_font(
|
|
||||||
element_key=element_key,
|
|
||||||
family=spec["family"],
|
|
||||||
size_px=spec["size_px"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def display(self):
|
|
||||||
# Get fonts (automatically checks for overrides)
|
|
||||||
title_font = self.get_font("title")
|
|
||||||
body_font = self.get_font("body")
|
|
||||||
footer_font = self.get_font("footer")
|
|
||||||
|
|
||||||
# Render with fonts
|
|
||||||
self.display_manager.draw_text("Title", font=title_font, color=self.font_specs["title"]["color"])
|
|
||||||
self.display_manager.draw_text("Body Text", font=body_font, color=self.font_specs["body"]["color"])
|
|
||||||
self.display_manager.draw_text("Footer", font=footer_font, color=self.font_specs["footer"]["color"])
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Size Tokens
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Get available size tokens
|
|
||||||
tokens = self.font_manager.get_size_tokens()
|
|
||||||
# Returns: {'xs': 6, 'sm': 8, 'md': 10, 'lg': 12, 'xl': 14, 'xxl': 16}
|
|
||||||
|
|
||||||
# Use token to get size
|
|
||||||
size_px = tokens.get('md', 10) # 10px
|
|
||||||
|
|
||||||
# Then use in font resolution
|
|
||||||
font = self.font_manager.resolve_font(
|
|
||||||
element_key="my_manager.text",
|
|
||||||
family="press_start",
|
family="press_start",
|
||||||
size_px=size_px
|
size_px=10,
|
||||||
|
color=(255, 255, 255),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
font = self.font_manager.resolve_font(
|
||||||
|
element_key=element_key,
|
||||||
|
family="press_start",
|
||||||
|
size_px=10,
|
||||||
|
)
|
||||||
|
self.display_manager.draw_text("Hello", x=10, y=10, font=font)
|
||||||
```
|
```
|
||||||
|
|
||||||
## For Plugin Developers
|
`resolve_font()` applies any entry for `element_key` in
|
||||||
|
`config/font_overrides.json`, maps a plugin-local family to its namespaced
|
||||||
|
name when `plugin_id` is passed, and then calls `get_font(family, size_px)`.
|
||||||
|
On error it returns a fallback font rather than raising.
|
||||||
|
|
||||||
> **Note**: plugins that ship their own fonts via a `"fonts"` block
|
`get_font(family, size_px)` looks the family up in `font_catalog` and loads
|
||||||
> in `manifest.json` are registered automatically during plugin load
|
it (cached per family and size).
|
||||||
> (`src/plugin_system/plugin_manager.py` calls
|
|
||||||
> `FontManager.register_plugin_fonts()`). The `plugin://…` source
|
|
||||||
> URIs documented below are resolved relative to the plugin's
|
|
||||||
> install directory.
|
|
||||||
>
|
|
||||||
> The web UI's **Fonts** tab lists, uploads, previews and deletes the
|
|
||||||
> font files in `assets/fonts/`. Its **Used by** column shows which
|
|
||||||
> loaded plugins registered each file through `register_manager_font()`
|
|
||||||
> (see [Font usage in the web UI](#font-usage-in-the-web-ui)), and it
|
|
||||||
> warns before deleting one of them. It has no override editor (the
|
|
||||||
> override panels and `/api/v3/fonts/overrides` endpoints were removed).
|
|
||||||
> The programmatic override workflow in
|
|
||||||
> [Manual Font Overrides](#manual-font-overrides) below still works.
|
|
||||||
> Let users pick fonts through your plugin's own config schema.
|
|
||||||
|
|
||||||
### Plugin Font Registration
|
## Font families
|
||||||
|
|
||||||
In your plugin's `manifest.json`:
|
At start-up the FontManager scans `assets/fonts/` for `.ttf` and `.bdf`
|
||||||
|
files. Each becomes a family named after the file, lower-cased and without
|
||||||
|
the extension (`PressStart2P-Regular.ttf` → `pressstart2p-regular`). Four
|
||||||
|
aliases are added on top:
|
||||||
|
|
||||||
|
| Alias | File |
|
||||||
|
|---|---|
|
||||||
|
| `press_start` | `assets/fonts/PressStart2P-Regular.ttf` |
|
||||||
|
| `four_by_six` | `assets/fonts/4x6-font.ttf` |
|
||||||
|
| `five_by_seven` | `assets/fonts/5x7.bdf` |
|
||||||
|
| `tom_thumb` | `assets/fonts/tom-thumb.bdf` |
|
||||||
|
|
||||||
|
Read the catalog directly: `font_manager.font_catalog` is a dict of family
|
||||||
|
name to file path. Files added later are picked up on the next start of the
|
||||||
|
display service.
|
||||||
|
|
||||||
|
## Plugin fonts
|
||||||
|
|
||||||
|
Plugins that ship their own fonts declare them in a `"fonts"` block in
|
||||||
|
`manifest.json`. The plugin manager calls
|
||||||
|
`FontManager.register_plugin_fonts()` during plugin load. `plugin://…`
|
||||||
|
sources are resolved relative to the plugin's install directory.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -195,231 +100,123 @@ In your plugin's `manifest.json`:
|
|||||||
{
|
{
|
||||||
"family": "custom_font",
|
"family": "custom_font",
|
||||||
"source": "plugin://fonts/custom.ttf",
|
"source": "plugin://fonts/custom.ttf",
|
||||||
"metadata": {
|
"metadata": {"description": "Custom plugin font", "license": "MIT"}
|
||||||
"description": "Custom plugin font",
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"family": "web_font",
|
"family": "web_font",
|
||||||
"source": "https://example.com/fonts/font.ttf",
|
"source": "https://example.com/fonts/font.ttf",
|
||||||
"metadata": {
|
"metadata": {"checksum": "sha256:abc123..."}
|
||||||
"description": "Downloaded font",
|
|
||||||
"checksum": "sha256:abc123..."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Plugin Fonts
|
Registered families are namespaced as `<plugin_id>::<family>`. Pass
|
||||||
|
`plugin_id` to `resolve_font()` to use the short name:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class MyPlugin(BasePlugin):
|
font = self.font_manager.resolve_font(
|
||||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
|
||||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
|
||||||
self.font_manager = self._get_font_manager()
|
|
||||||
|
|
||||||
def display(self):
|
|
||||||
# Use plugin font (automatically namespaced)
|
|
||||||
font = self.font_manager.resolve_font(
|
|
||||||
element_key=f"{self.plugin_id}.text",
|
element_key=f"{self.plugin_id}.text",
|
||||||
family="custom_font", # Will be resolved as "my-plugin::custom_font"
|
family="custom_font", # resolved as "my-plugin::custom_font"
|
||||||
size_px=10,
|
size_px=10,
|
||||||
plugin_id=self.plugin_id
|
plugin_id=self.plugin_id,
|
||||||
)
|
|
||||||
|
|
||||||
self.display_manager.draw_text("Plugin Text", font=font)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manual Font Overrides
|
|
||||||
|
|
||||||
Overrides are set in code (there is no web UI or REST endpoint for them).
|
|
||||||
They are stored in `config/font_overrides.json` and persist across restarts.
|
|
||||||
|
|
||||||
### Programmatic Overrides
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Set override
|
|
||||||
font_manager.set_override(
|
|
||||||
element_key="nfl.live.score",
|
|
||||||
family="four_by_six",
|
|
||||||
size_px=8
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove override
|
|
||||||
font_manager.remove_override("nfl.live.score")
|
|
||||||
|
|
||||||
# Get all overrides
|
|
||||||
overrides = font_manager.get_overrides()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Font Discovery
|
## Overrides
|
||||||
|
|
||||||
### Available Fonts
|
`resolve_font()` still honours `config/font_overrides.json` (a map of
|
||||||
|
element key to `family` and/or `size_px`), which is read once at start-up.
|
||||||
The FontManager automatically scans `assets/fonts/` for TTF and BDF fonts:
|
The methods that edit it — `set_override()`, `remove_override()`,
|
||||||
|
`get_overrides()` — are deprecated, and there is no web UI or REST endpoint
|
||||||
```python
|
for overrides (the override editor and `/api/v3/fonts/overrides` were
|
||||||
# Get all available fonts
|
removed). To let users choose a font, add a field to your plugin's config
|
||||||
fonts = font_manager.get_available_fonts()
|
schema.
|
||||||
# Returns: {'press_start': 'assets/fonts/PressStart2P-Regular.ttf', ...}
|
|
||||||
|
|
||||||
# Check if font exists
|
|
||||||
if "my_font" in fonts:
|
|
||||||
font = font_manager.get_font("my_font", 10)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding Custom Fonts
|
|
||||||
|
|
||||||
Place font files in `assets/fonts/` directory:
|
|
||||||
- Supported formats: `.ttf`, `.bdf`
|
|
||||||
- Font family name is derived from filename (without extension)
|
|
||||||
- Will be automatically discovered on next initialization
|
|
||||||
|
|
||||||
## Font usage in the web UI
|
## Font usage in the web UI
|
||||||
|
|
||||||
The web interface runs in its own process and has no FontManager, so the
|
The web UI's **Fonts** tab lists, uploads, previews and deletes the font
|
||||||
display service publishes which plugin uses which font
|
files in `assets/fonts/`. The web interface runs in its own process and has
|
||||||
(`src/font_usage.py`), and the Fonts tab's **Used by** column reads it:
|
no FontManager, so the display service publishes which plugin uses which
|
||||||
|
font ([`src/font_usage.py`](../src/font_usage.py)), and the tab's **Used by**
|
||||||
|
column reads it:
|
||||||
|
|
||||||
- **Source**: `register_manager_font()` registrations of the loaded
|
- **Source**: `register_manager_font()` registrations of the loaded
|
||||||
plugins. `get_font()` and `resolve_font()` do not know the calling plugin
|
plugins. `get_font()` and `resolve_font()` do not know the calling plugin
|
||||||
and are not counted, and neither is a plugin that opens a font file
|
and are not counted, and neither is a plugin that opens a font file
|
||||||
directly with PIL — register the fonts your plugin draws with if you want
|
directly with PIL — register the fonts your plugin draws with if you want
|
||||||
them listed.
|
them listed.
|
||||||
- **Names**: a family, alias (`press_start`, `four_by_six`,
|
- **Names**: a family, alias or path is resolved through `font_catalog` to
|
||||||
`five_by_seven`, `tom_thumb`) or path is resolved through
|
the file it loads and reported under that file's name without extension
|
||||||
`font_catalog` to the file it loads and reported under that file's name
|
(`PressStart2P-Regular`, `4x6-font`, `5x7`, `tom-thumb`), which is how the
|
||||||
without extension (`PressStart2P-Regular`, `4x6-font`, `5x7`,
|
Fonts tab keys its rows. Fonts outside `assets/fonts/` (a plugin's own
|
||||||
`tom-thumb`), which is how the Fonts tab keys its rows. Fonts outside
|
`plugin_id::family` fonts) and families that resolve to nothing are left
|
||||||
`assets/fonts/` (a plugin's own `plugin_id::family` fonts) and families
|
out.
|
||||||
that resolve to nothing are left out.
|
|
||||||
- **When**: a daemon thread started once plugins have loaded checks every
|
- **When**: a daemon thread started once plugins have loaded checks every
|
||||||
10 seconds and writes the `font_usage_snapshot` cache key only when the
|
10 seconds and writes the `font_usage_snapshot` cache key only when the
|
||||||
usage changed (and once a day, so the cache's cleanup never expires it).
|
usage changed (and once a day, so the cache's cleanup never expires it).
|
||||||
Unloading a plugin drops its registrations (`forget_manager_fonts`).
|
Unloading a plugin drops its registrations (`forget_manager_fonts`).
|
||||||
- **Unknown**: until the display service has published, the column reads
|
- **Unknown**: until the display service has published, the column reads
|
||||||
"unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`.
|
"unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`.
|
||||||
|
- The tab warns before deleting a font that a loaded plugin registered.
|
||||||
|
|
||||||
## Performance Monitoring
|
## Text measurement
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Get performance stats
|
|
||||||
stats = font_manager.get_performance_stats()
|
|
||||||
|
|
||||||
print(f"Cache hit rate: {stats['cache_hit_rate']*100:.1f}%")
|
|
||||||
print(f"Total fonts cached: {stats['total_fonts_cached']}")
|
|
||||||
print(f"Failed loads: {stats['failed_loads']}")
|
|
||||||
print(f"Manager fonts: {stats['manager_fonts']}")
|
|
||||||
print(f"Plugin fonts: {stats['plugin_fonts']}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Text Measurement
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Measure text dimensions
|
|
||||||
width, height, baseline = font_manager.measure_text("Hello", font)
|
width, height, baseline = font_manager.measure_text("Hello", font)
|
||||||
|
|
||||||
# Get font height
|
|
||||||
font_height = font_manager.get_font_height(font)
|
font_height = font_manager.get_font_height(font)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Best Practices
|
## Tips
|
||||||
|
|
||||||
### For Managers
|
- BDF fonts usually look better than TTF at small sizes on LED panels.
|
||||||
|
- Use `{plugin_id}.{element}` element keys.
|
||||||
1. **Register all fonts** you use for visibility
|
- Register the fonts you draw with, so the Fonts tab can warn before one is
|
||||||
2. **Use consistent element keys** (e.g., `{manager_id}.{element_type}`)
|
deleted.
|
||||||
3. **Cache font references** if using same font multiple times
|
- Replace direct `ImageFont.truetype("assets/fonts/...", 8)` calls with
|
||||||
4. **Use `resolve_font()`** not `get_font()` directly to support overrides
|
`resolve_font()`: it caches, resolves paths against the install directory,
|
||||||
5. **Define sensible defaults** that work well on LED matrix
|
and handles BDF files.
|
||||||
|
|
||||||
### For Plugins
|
|
||||||
|
|
||||||
1. **Use plugin-relative paths** (`plugin://fonts/...`)
|
|
||||||
2. **Include font metadata** (license, description)
|
|
||||||
3. **Provide fallback** fonts if custom fonts fail to load
|
|
||||||
4. **Test with different display sizes**
|
|
||||||
|
|
||||||
### General
|
|
||||||
|
|
||||||
1. **BDF fonts** are often better for small sizes on LED matrices
|
|
||||||
2. **TTF fonts** work well for larger sizes
|
|
||||||
3. **Monospace fonts** are easier to align
|
|
||||||
4. **Test on actual hardware** - what looks good on screen may not work on LED matrix
|
|
||||||
|
|
||||||
## Migration from Old System
|
|
||||||
|
|
||||||
### Old Way (Direct Font Loading)
|
|
||||||
```python
|
|
||||||
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
|
||||||
```
|
|
||||||
|
|
||||||
### New Way (FontManager)
|
|
||||||
```python
|
|
||||||
element_key = f"{self.manager_id}.text"
|
|
||||||
self.font_manager.register_manager_font(
|
|
||||||
manager_id=self.manager_id,
|
|
||||||
element_key=element_key,
|
|
||||||
family="pressstart2p-regular",
|
|
||||||
size_px=8
|
|
||||||
)
|
|
||||||
self.font = self.font_manager.resolve_font(
|
|
||||||
element_key=element_key,
|
|
||||||
family="pressstart2p-regular",
|
|
||||||
size_px=8
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Font Not Found
|
**Font not found**
|
||||||
- Check font file exists in `assets/fonts/`
|
- Check the file exists in `assets/fonts/`.
|
||||||
- Verify font family name matches filename (without extension, lowercase)
|
- The family name is the filename without extension, lower-cased.
|
||||||
- Check logs for font discovery errors
|
- Check the display service log for font discovery errors.
|
||||||
|
|
||||||
### Override Not Working
|
**Plugin fonts not loading**
|
||||||
- Verify element key matches exactly what manager registered
|
- Check the manifest's `"fonts"` block.
|
||||||
- Check `config/font_overrides.json` for correct syntax
|
- Check the log for download or registration errors, and that font URLs are
|
||||||
- Restart application to ensure overrides are loaded
|
reachable.
|
||||||
|
|
||||||
### Performance Issues
|
## API reference
|
||||||
- Check cache hit rate in performance stats
|
|
||||||
- Reduce number of unique font/size combinations
|
|
||||||
- Clear cache if it grows too large: `font_manager.clear_cache()`
|
|
||||||
|
|
||||||
### Plugin Fonts Not Loading
|
Current methods:
|
||||||
- Verify plugin manifest syntax
|
|
||||||
- Check plugin directory structure
|
|
||||||
- Review logs for download/registration errors
|
|
||||||
- Ensure font URLs are accessible
|
|
||||||
|
|
||||||
## API Reference
|
| Method | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `register_manager_font(manager_id, element_key, family, size_px, color=None)` | Record a font choice (feeds the Fonts tab) |
|
||||||
|
| `forget_manager_fonts(manager_id)` | Drop a manager's registrations (core calls it when a plugin unloads) |
|
||||||
|
| `resolve_font(element_key, family, size_px, plugin_id=None)` | Get a font, applying overrides and plugin namespacing |
|
||||||
|
| `get_font(family, size_px)` | Get a font directly |
|
||||||
|
| `get_native_bdf_size(family)` | Native pixel size of a BDF family, or `None` |
|
||||||
|
| `measure_text(text, font)` | `(width, height, baseline)` |
|
||||||
|
| `get_font_height(font)` | Line height |
|
||||||
|
| `register_plugin_fonts(plugin_id, font_manifest)` | Register a plugin's fonts (core calls it at load) |
|
||||||
|
| `clear_cache()` | Drop cached fonts and metrics |
|
||||||
|
| `font_catalog` (attribute) | Family name → file path |
|
||||||
|
|
||||||
### FontManager Methods
|
### Deprecated methods
|
||||||
|
|
||||||
- `register_manager_font(manager_id, element_key, family, size_px, color=None)` - Register font usage
|
Removed in 3.7.0. Each logs a warning on first call.
|
||||||
- `forget_manager_fonts(manager_id)` - Drop a manager's registrations (core calls it when a plugin unloads)
|
|
||||||
- `resolve_font(element_key, family, size_px, plugin_id=None)` - Get font with override support
|
|
||||||
- `get_font(family, size_px)` - Get font directly (bypasses overrides)
|
|
||||||
- `measure_text(text, font)` - Measure text dimensions
|
|
||||||
- `get_font_height(font)` - Get font height
|
|
||||||
- `set_override(element_key, family=None, size_px=None)` - Set manual override
|
|
||||||
- `remove_override(element_key)` - Remove override
|
|
||||||
- `get_overrides()` - Get all overrides
|
|
||||||
- `get_detected_fonts()` - Get all detected font usage
|
|
||||||
- `get_manager_fonts(manager_id=None)` - Get fonts by manager
|
|
||||||
- `get_available_fonts()` - Get font catalog
|
|
||||||
- `get_size_tokens()` - Get size token definitions
|
|
||||||
- `get_performance_stats()` - Get performance metrics
|
|
||||||
- `clear_cache()` - Clear font cache
|
|
||||||
- `register_plugin_fonts(plugin_id, font_manifest)` - Register plugin fonts
|
|
||||||
- `unregister_plugin_fonts(plugin_id)` - Unregister plugin fonts
|
|
||||||
|
|
||||||
## Example: Complete Manager Implementation
|
|
||||||
|
|
||||||
For a working example of the font manager API in use, see
|
|
||||||
`src/font_manager.py` itself.
|
|
||||||
|
|
||||||
|
| Method | Use instead |
|
||||||
|
|---|---|
|
||||||
|
| `get_available_fonts()`, `get_font_catalog()` | read `font_catalog` |
|
||||||
|
| `get_size_tokens()` | pass a pixel size |
|
||||||
|
| `get_performance_stats()` | — |
|
||||||
|
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
|
||||||
|
| `get_manager_fonts()`, `get_detected_fonts()` | — |
|
||||||
|
| `get_plugin_fonts()`, `unregister_plugin_fonts()` | — |
|
||||||
|
| `add_font()`, `remove_font()`, `validate_font()` | the web UI's Fonts tab |
|
||||||
|
|||||||
+11
-10
@@ -116,8 +116,8 @@ weather and other location-aware plugins.
|
|||||||
4. Wait for installation to finish — installed plugins appear in the
|
4. Wait for installation to finish — installed plugins appear in the
|
||||||
**Installed Plugins** section above and get their own tab in the second
|
**Installed Plugins** section above and get their own tab in the second
|
||||||
nav row
|
nav row
|
||||||
5. Toggle the plugin to enabled
|
5. Toggle the plugin to enabled. The running display loads it within a
|
||||||
6. From **Overview**, click **Restart Display Service**
|
few seconds; no restart is needed
|
||||||
|
|
||||||
You can also install community plugins straight from a GitHub URL using the
|
You can also install community plugins straight from a GitHub URL using the
|
||||||
**Install from GitHub** section further down the same tab — see
|
**Install from GitHub** section further down the same tab — see
|
||||||
@@ -128,9 +128,9 @@ You can also install community plugins straight from a GitHub URL using the
|
|||||||
1. Each installed plugin gets its own tab in the second navigation row
|
1. Each installed plugin gets its own tab in the second navigation row
|
||||||
2. Open that plugin's tab to edit its settings (favorite teams, API keys,
|
2. Open that plugin's tab to edit its settings (favorite teams, API keys,
|
||||||
update intervals, etc.)
|
update intervals, etc.)
|
||||||
3. Click **Save**
|
3. Click **Save**. The display service watches `config.json` and hands the
|
||||||
4. Restart the display service from **Overview** so the new settings take
|
new settings to the running plugin, so no restart is needed. If a plugin
|
||||||
effect
|
still shows old settings, restart the display service from **Overview**
|
||||||
|
|
||||||
**Note:** how long each plugin stays on screen is not set in the
|
**Note:** how long each plugin stays on screen is not set in the
|
||||||
plugin's own tab — use the **Rotation** tab's **Screen Durations**
|
plugin's own tab — use the **Rotation** tab's **Screen Durations**
|
||||||
@@ -197,14 +197,15 @@ The fastest way to verify a plugin works without waiting for the rotation:
|
|||||||
|
|
||||||
**Check:**
|
**Check:**
|
||||||
1. Plugin is enabled (toggle on the **Plugin Manager** tab)
|
1. Plugin is enabled (toggle on the **Plugin Manager** tab)
|
||||||
2. Display service was restarted after enabling
|
2. Plugin's display duration is non-zero
|
||||||
3. Plugin's display duration is non-zero
|
3. No errors in the **Logs** tab for that plugin. A plugin whose
|
||||||
4. No errors in the **Logs** tab for that plugin
|
`validate_config()` fails is not loaded until its settings are fixed
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
1. Enable the plugin from **Plugin Manager**
|
1. Enable the plugin from **Plugin Manager**
|
||||||
2. Click **Restart Display Service** on **Overview**
|
2. Check the **Logs** tab for plugin-specific errors
|
||||||
3. Check the **Logs** tab for plugin-specific errors
|
3. If it still does not appear, click **Restart Display Service** on
|
||||||
|
**Overview**
|
||||||
|
|
||||||
### Weather Plugin Shows "No Data"
|
### Weather Plugin Shows "No Data"
|
||||||
|
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ pytest test/test_display_controller.py test/test_plugin_system.py
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run a specific test class
|
# Run a specific test class
|
||||||
pytest test/test_display_controller.py::TestDisplayControllerModeRotation
|
pytest test/test_display_controller.py::TestDisplayControllerLivePriority
|
||||||
|
|
||||||
# Run a specific test function
|
# Run a specific test function
|
||||||
pytest test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
|
pytest test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run Tests by Marker
|
### Run Tests by Marker
|
||||||
@@ -98,7 +98,7 @@ When you run `pytest`, you'll see:
|
|||||||
|
|
||||||
```
|
```
|
||||||
test/test_display_controller.py::TestDisplayControllerInitialization::test_init_success PASSED
|
test/test_display_controller.py::TestDisplayControllerInitialization::test_init_success PASSED
|
||||||
test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation PASSED
|
test/test_display_controller.py::TestDisplayControllerOnDemand::test_activate_on_demand PASSED
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -174,10 +174,10 @@ pytest
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run with maximum verbosity and show print statements
|
# Run with maximum verbosity and show print statements
|
||||||
pytest -vv -s test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
|
pytest -vv -s test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
|
||||||
|
|
||||||
# Run with Python debugger (pdb)
|
# Run with Python debugger (pdb)
|
||||||
pytest --pdb test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
|
pytest --pdb test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run Tests in Parallel (Faster)
|
### Run Tests in Parallel (Faster)
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Permissions
|
||||||
|
|
||||||
|
Who owns what on an installed system, which privileged commands the web
|
||||||
|
interface may run, and how to repair ownership when it goes wrong. The
|
||||||
|
installer, [`first_time_install.sh`](../first_time_install.sh), sets all of
|
||||||
|
this up; this page describes the result.
|
||||||
|
|
||||||
|
## Users and groups
|
||||||
|
|
||||||
|
| Account | Used by | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `root` | `ledmatrix.service` (the display) | The LED matrix library needs direct GPIO access |
|
||||||
|
| The installing user (e.g. `ledpi`) | `ledmatrix-web.service`, `ledmatrix-update-verify.service` | A web server should not run as root |
|
||||||
|
| `ledmatrix` group | shared files | Members: the installing user, `root`, and `daemon` if it exists. Created by [`setup_cache.sh`](../scripts/install/setup_cache.sh) and the installer |
|
||||||
|
|
||||||
|
The installer also adds the web user to `systemd-journal` and `adm` so the
|
||||||
|
**Logs** tab can read the journal. Group changes apply after the user logs
|
||||||
|
in again (services pick them up on restart).
|
||||||
|
|
||||||
|
## Files and directories
|
||||||
|
|
||||||
|
| Path | Owner | Mode | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Project directory | web user | dirs `755`, files `644`, `*.sh` `755` | Set in the installer's "Normalize project file permissions" step |
|
||||||
|
| `config/` | web user | `2775` | |
|
||||||
|
| `config/config.json` | web user | `644` | Written by the web interface |
|
||||||
|
| `config/config_secrets.json` | web user : `ledmatrix` | `640` | Owned by the web user because the web interface writes it; root reads it regardless of mode |
|
||||||
|
| `plugin-repos/`, `plugins/` | web user | dirs `2775`, files `664` | The web interface installs and removes plugins |
|
||||||
|
| `assets/` | web user | dirs `755`, files `644` | Root writes downloaded logos regardless |
|
||||||
|
| `/var/cache/ledmatrix/` | `root:ledmatrix` | `2775` (setgid) | Shared cache: see below |
|
||||||
|
| Cache files | creator : `ledmatrix` | `660` | |
|
||||||
|
| `scripts/fix_perms/safe_plugin_rm.sh`, `safe_pip_install.sh` | `root:root` | `755` | Run as root through sudo, so the web user must not be able to edit them |
|
||||||
|
| `/etc/sudoers.d/ledmatrix_web`, `ledmatrix_wifi` | `root` | `440` | |
|
||||||
|
|
||||||
|
What keeps it that way at runtime:
|
||||||
|
|
||||||
|
- **Config files.** Saves go through
|
||||||
|
[`src/config_manager_atomic.py`](../src/config_manager_atomic.py), which
|
||||||
|
applies `get_config_file_mode()` (`640` for secrets, `644` otherwise) and,
|
||||||
|
when running as root, moves the file's group to the project directory's
|
||||||
|
group (`ensure_shared_group_ownership()` in
|
||||||
|
[`src/common/permission_utils.py`](../src/common/permission_utils.py)).
|
||||||
|
- **Cache files.** [`src/cache/disk_cache.py`](../src/cache/disk_cache.py)
|
||||||
|
sets every file it writes to `0660` and gives it the cache directory's
|
||||||
|
group, without relying on the setgid bit. So a file root writes stays
|
||||||
|
readable by the web user.
|
||||||
|
- **Plugin directories.** [`run.py`](../run.py) sets
|
||||||
|
`sys.dont_write_bytecode`, because root-owned `__pycache__` directories
|
||||||
|
inside a plugin stop the web user updating or removing it.
|
||||||
|
|
||||||
|
`ledmatrix-web.service` deliberately has no `CacheDirectory=`: systemd would
|
||||||
|
re-own `/var/cache/ledmatrix` to the web user and its primary group, and the
|
||||||
|
web interface could no longer read what the display writes (see the comment
|
||||||
|
in [`systemd/ledmatrix-web.service`](../systemd/ledmatrix-web.service)).
|
||||||
|
|
||||||
|
If `/var/cache/ledmatrix` is not usable, `CacheManager` falls back to
|
||||||
|
`~/.ledmatrix_cache`, `/opt/ledmatrix/cache` or a temp directory
|
||||||
|
([`src/cache_manager.py`](../src/cache_manager.py)). The two services then
|
||||||
|
may not share a cache, and the web UI shows stale or empty display status,
|
||||||
|
on-demand state and plugin health. Fix the directory rather than living
|
||||||
|
with the fallback.
|
||||||
|
|
||||||
|
## sudo rules
|
||||||
|
|
||||||
|
### `/etc/sudoers.d/ledmatrix_web`
|
||||||
|
|
||||||
|
Generated by `web_sudoers_rules()` in
|
||||||
|
[`scripts/install/lib_sudoers.sh`](../scripts/install/lib_sudoers.sh), the
|
||||||
|
only place these rules are defined. Installed by the installer and by
|
||||||
|
[`configure_web_sudo.sh`](../scripts/install/configure_web_sudo.sh), both of
|
||||||
|
which check them with `visudo -c` first. The web user may run, without a
|
||||||
|
password:
|
||||||
|
|
||||||
|
- `reboot`, `poweroff`
|
||||||
|
- `systemctl start|stop|restart|enable|disable|status ledmatrix.service`,
|
||||||
|
`systemctl is-active ledmatrix[.service]`
|
||||||
|
- `systemctl start|stop|restart ledmatrix-web.service`
|
||||||
|
- `bash <project>/scripts/fix_perms/safe_plugin_rm.sh *` — removes a
|
||||||
|
directory only if it resolves to a child of `plugin-repos/` or `plugins/`
|
||||||
|
- `bash <project>/scripts/fix_perms/safe_pip_install.sh *` — installs a
|
||||||
|
`requirements.txt` only if it is the project's own or one under
|
||||||
|
`plugin-repos/` or `plugins/`, so the root display service can import the
|
||||||
|
packages
|
||||||
|
- `journalctl -u ledmatrix.service *`, `-u ledmatrix *`, `-t ledmatrix *`,
|
||||||
|
tagged `NOEXEC`: journalctl opens a pager on a terminal, and a shell
|
||||||
|
escape from that pager would be a root shell
|
||||||
|
|
||||||
|
### `/etc/sudoers.d/ledmatrix_wifi`
|
||||||
|
|
||||||
|
Written by
|
||||||
|
[`scripts/install/configure_wifi_permissions.sh`](../scripts/install/configure_wifi_permissions.sh)
|
||||||
|
(run as the web user; the installer calls it). It refuses to grant a binary
|
||||||
|
that is not root-owned or is group/world-writable. The rules cover:
|
||||||
|
|
||||||
|
- `nmcli device wifi connect|disconnect *`, `nmcli device connect|disconnect *`,
|
||||||
|
`nmcli radio wifi on|off`
|
||||||
|
- `systemctl start|stop|restart hostapd`, `... dnsmasq`,
|
||||||
|
`systemctl restart NetworkManager`
|
||||||
|
- `sysctl -w net.ipv4.ip_forward=0|1`
|
||||||
|
- `nft add|delete table ip ledmatrix`
|
||||||
|
- `rfkill unblock wifi`
|
||||||
|
- `mkdir -p /etc/NetworkManager/dnsmasq-shared.d`
|
||||||
|
- `cp` of `/tmp/hostapd.conf` and `/tmp/dnsmasq.conf` to their fixed
|
||||||
|
destinations, and `rm -f /etc/dnsmasq.d/ledmatrix-captive.conf`
|
||||||
|
- `cp /tmp/ledmatrix-nm-dnsmasq.conf` to
|
||||||
|
`/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf`, and
|
||||||
|
`rm -f` of that file
|
||||||
|
|
||||||
|
**`iptables` is deliberately not granted.** The captive portal's rules are
|
||||||
|
built from the interface name and port, so a rule covering them would need a
|
||||||
|
trailing wildcard, and `iptables --modprobe=<path>` runs `<path>` as root: a
|
||||||
|
wildcard grant is a root shell for the web user. Doing it safely needs a
|
||||||
|
wrapper script that builds the rules itself, like `safe_plugin_rm.sh`. On a
|
||||||
|
stock Raspberry Pi OS image the default user's blanket `NOPASSWD` rule
|
||||||
|
(`/etc/sudoers.d/010_pi-nopasswd`) hides this gap.
|
||||||
|
|
||||||
|
### polkit
|
||||||
|
|
||||||
|
The same script installs `/etc/polkit-1/rules.d/10-ledmatrix-wifi.rules`,
|
||||||
|
which lets the web user perform any `org.freedesktop.NetworkManager.*`
|
||||||
|
action without authentication.
|
||||||
|
|
||||||
|
## Repair scripts
|
||||||
|
|
||||||
|
In [`scripts/fix_perms/`](../scripts/fix_perms/). Run them from the project
|
||||||
|
directory.
|
||||||
|
|
||||||
|
| Script | Run as | What it does | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `fix_plugin_permissions.sh` | `sudo` | `plugins/` and `plugin-repos/` to `root:<user>`, dirs `2775`, files `664`; makes a `700` home directory `755` so root can traverse it | Safe. Group-writable, so the web user keeps write access |
|
||||||
|
| `fix_assets_permissions.sh` | `sudo` | `assets/` to `<user>:<group>`, mode `777` recursively | Works, but looser than the installer's `755`/`644` |
|
||||||
|
| `fix_cache_permissions.sh` | `sudo` | Runs [`setup_cache.sh`](../scripts/install/setup_cache.sh) for `/var/cache/ledmatrix` (`root:ledmatrix`, `2775`, files `660`), then makes `~/.ledmatrix_cache` (the fallback cache) `<user>:<group>` mode `777` | Safe. The `~/.ledmatrix_cache` mode is still `777` |
|
||||||
|
| `fix_web_permissions.sh` | the web user, **without** `sudo` | Resets project file ownership for the web user (it calls `sudo` itself), then makes `safe_plugin_rm.sh` and `safe_pip_install.sh` `root:root` `755` again and restores `config_secrets.json` to its owner, group `ledmatrix`, mode `640` | Refuses to run as root. It does not write sudoers rules |
|
||||||
|
| `safe_plugin_rm.sh`, `safe_pip_install.sh` | — | Called by the web interface through sudo | Not for manual use |
|
||||||
|
|
||||||
|
To reinstall the sudoers rules, run
|
||||||
|
`./scripts/install/configure_web_sudo.sh` (web rules) or
|
||||||
|
`./scripts/install/configure_wifi_permissions.sh` (WiFi rules and polkit) as
|
||||||
|
the web user, not with `sudo`.
|
||||||
|
|
||||||
|
After any of these, restart both services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart ledmatrix.service ledmatrix-web.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checking
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -ld /var/cache/ledmatrix # drwxrwsr-x root ledmatrix
|
||||||
|
stat -c '%U:%G %a %n' config/config.json config/config_secrets.json
|
||||||
|
id # web user should list ledmatrix
|
||||||
|
sudo -l # lists the NOPASSWD rules
|
||||||
|
```
|
||||||
@@ -9,6 +9,7 @@ Complete API reference for plugin developers. This document describes all method
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Manifest Required Fields](#manifest-required-fields)
|
||||||
- [BasePlugin](#baseplugin)
|
- [BasePlugin](#baseplugin)
|
||||||
- [Display Manager](#display-manager)
|
- [Display Manager](#display-manager)
|
||||||
- [Cache Manager](#cache-manager)
|
- [Cache Manager](#cache-manager)
|
||||||
@@ -17,6 +18,50 @@ Complete API reference for plugin developers. This document describes all method
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Manifest Required Fields
|
||||||
|
|
||||||
|
Three parts of core check `manifest.json`, each for a different set of
|
||||||
|
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 loader, [`src/plugin_system/plugin_loader.py`](../src/plugin_system/plugin_loader.py) | `class_name` | The plugin fails to load |
|
||||||
|
|
||||||
|
Defaults and other uses:
|
||||||
|
|
||||||
|
- `entry_point` defaults to `manager.py`; the store writes the default back
|
||||||
|
into the manifest on install.
|
||||||
|
- `compatible_versions` (a list of semver ranges such as `">=2.0.0"`) is how
|
||||||
|
the store decides whether a plugin can run on this core. An install is
|
||||||
|
refused only when the field excludes the running version
|
||||||
|
(`compatibility.check()` in
|
||||||
|
[`src/plugin_system/compatibility.py`](../src/plugin_system/compatibility.py)).
|
||||||
|
- `version` is compared with the registry's `latest_version` to decide
|
||||||
|
whether an update is available.
|
||||||
|
- If `display_modes` is empty at load time, the display controller uses the
|
||||||
|
plugin id as the only mode.
|
||||||
|
|
||||||
|
**Set all eight:** `id`, `name`, `version`, `author`, `entry_point`,
|
||||||
|
`class_name`, `display_modes`, `compatible_versions`. That satisfies every
|
||||||
|
check. The schema lists the optional fields.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-plugin",
|
||||||
|
"name": "My Plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "YourName",
|
||||||
|
"entry_point": "manager.py",
|
||||||
|
"class_name": "MyPlugin",
|
||||||
|
"display_modes": ["my-plugin"],
|
||||||
|
"compatible_versions": [">=2.0.0"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## BasePlugin
|
## BasePlugin
|
||||||
|
|
||||||
All plugins must inherit from `BasePlugin` and implement the required methods. The base class provides access to managers and common functionality.
|
All plugins must inherit from `BasePlugin` and implement the required methods. The base class provides access to managers and common functionality.
|
||||||
@@ -432,82 +477,17 @@ self.display_manager.update_display()
|
|||||||
|
|
||||||
This is the canonical way to render arbitrary images.
|
This is the canonical way to render arbitrary images.
|
||||||
|
|
||||||
### Weather Icons
|
### Weather Icons (deprecated)
|
||||||
|
|
||||||
#### `draw_weather_icon(condition: str, x: int, y: int, size: int = 16) -> None`
|
> Deprecated, removed in 3.7.0 — draw your own icons (the weather plugin
|
||||||
|
> ships `WeatherIcons`). See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Draw a weather icon based on the condition string.
|
- `draw_weather_icon(condition, x, y, size=16)` — icon for a condition
|
||||||
|
string such as `"clear"`, `"clouds"`, `"rain"`, `"snow"`, `"storm"`
|
||||||
**Parameters**:
|
- `draw_sun(x, y, size=16)`, `draw_cloud(x, y, size=16, color=(200, 200, 200))`,
|
||||||
- `condition` (str): Weather condition (e.g., "clear", "cloudy", "rain", "snow", "storm")
|
`draw_rain(x, y, size=16)`, `draw_snow(x, y, size=16)`
|
||||||
- `x` (int): X position
|
- `draw_text_with_icons(text, icons=None, x=None, y=None, color=(255, 255, 255))`
|
||||||
- `y` (int): Y position
|
— text plus a list of `(icon_type, x, y)` icons; calls `update_display()`
|
||||||
- `size` (int): Icon size in pixels (default: 16)
|
|
||||||
|
|
||||||
**Supported Conditions**:
|
|
||||||
- `"clear"`, `"sunny"` → Sun icon
|
|
||||||
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
|
|
||||||
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
|
|
||||||
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
|
|
||||||
- `"thunderstorm"`, `"storm"` → Storm icon
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```python
|
|
||||||
self.display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `draw_sun(x: int, y: int, size: int = 16) -> None`
|
|
||||||
|
|
||||||
Draw a sun icon with rays.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `x` (int): X position
|
|
||||||
- `y` (int): Y position
|
|
||||||
- `size` (int): Icon size (default: 16)
|
|
||||||
|
|
||||||
#### `draw_cloud(x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)) -> None`
|
|
||||||
|
|
||||||
Draw a cloud icon.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `x` (int): X position
|
|
||||||
- `y` (int): Y position
|
|
||||||
- `size` (int): Icon size (default: 16)
|
|
||||||
- `color` (tuple): RGB color (default: light gray)
|
|
||||||
|
|
||||||
#### `draw_rain(x: int, y: int, size: int = 16) -> None`
|
|
||||||
|
|
||||||
Draw rain icon with cloud and droplets.
|
|
||||||
|
|
||||||
#### `draw_snow(x: int, y: int, size: int = 16) -> None`
|
|
||||||
|
|
||||||
Draw snow icon with cloud and snowflakes.
|
|
||||||
|
|
||||||
#### `draw_text_with_icons(text: str, icons: List[tuple] = None, x: int = None, y: int = None, color: tuple = (255, 255, 255)) -> None`
|
|
||||||
|
|
||||||
Draw text with weather icons at specified positions.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `text` (str): Text to display
|
|
||||||
- `icons` (List[tuple], optional): List of (icon_type, x, y) tuples
|
|
||||||
- `x` (int, optional): X position for text
|
|
||||||
- `y` (int, optional): Y position for text
|
|
||||||
- `color` (tuple): Text color
|
|
||||||
|
|
||||||
**Note**: Automatically calls `update_display()` after drawing.
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```python
|
|
||||||
icons = [
|
|
||||||
("sun", 5, 5),
|
|
||||||
("cloud", 100, 5)
|
|
||||||
]
|
|
||||||
self.display_manager.draw_text_with_icons(
|
|
||||||
"Weather: Sunny, Cloudy",
|
|
||||||
icons=icons,
|
|
||||||
x=10, y=20
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scrolling State Management
|
### Scrolling State Management
|
||||||
|
|
||||||
@@ -601,6 +581,8 @@ Process any deferred updates if not currently scrolling. Called automatically by
|
|||||||
|
|
||||||
#### `get_scrolling_stats() -> dict`
|
#### `get_scrolling_stats() -> dict`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get current scrolling statistics for debugging.
|
Get current scrolling statistics for debugging.
|
||||||
|
|
||||||
**Returns**: Dictionary with scrolling state information
|
**Returns**: Dictionary with scrolling state information
|
||||||
@@ -742,6 +724,8 @@ data = self.cache_manager.get_with_auto_strategy("nhl_live_scores")
|
|||||||
|
|
||||||
#### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]`
|
#### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0 — use `get()`. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get background service cached data with sport-specific intervals.
|
Get background service cached data with sport-specific intervals.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -779,6 +763,8 @@ max_age = strategy['max_age'] # Get configured max age
|
|||||||
|
|
||||||
#### `get_sport_live_interval(sport_key: str) -> int`
|
#### `get_sport_live_interval(sport_key: str) -> int`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get the live_update_interval for a specific sport from config.
|
Get the live_update_interval for a specific sport from config.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -803,6 +789,8 @@ Extract data type from cache key to determine appropriate cache strategy.
|
|||||||
|
|
||||||
#### `get_sport_key_from_cache_key(key: str) -> Optional[str]`
|
#### `get_sport_key_from_cache_key(key: str) -> Optional[str]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Extract sport key from cache key for sport-specific strategies.
|
Extract sport key from cache key for sport-specific strategies.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -847,10 +835,12 @@ for file_info in files:
|
|||||||
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
|
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Metrics Methods
|
### Metrics Methods (deprecated)
|
||||||
|
|
||||||
#### `get_cache_metrics() -> Dict[str, Any]`
|
#### `get_cache_metrics() -> Dict[str, Any]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get cache performance metrics.
|
Get cache performance metrics.
|
||||||
|
|
||||||
**Returns**: Dictionary with cache statistics (`total_requests`, `cache_hit_rate`, `background_hit_rate`, `api_calls_saved`, `average_fetch_time`, etc.)
|
**Returns**: Dictionary with cache statistics (`total_requests`, `cache_hit_rate`, `background_hit_rate`, `api_calls_saved`, `average_fetch_time`, etc.)
|
||||||
@@ -863,6 +853,8 @@ self.logger.info(f"Cache hit rate: {metrics['cache_hit_rate']:.2%}")
|
|||||||
|
|
||||||
#### `get_memory_cache_stats() -> Dict[str, Any]`
|
#### `get_memory_cache_stats() -> Dict[str, Any]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get memory cache statistics.
|
Get memory cache statistics.
|
||||||
|
|
||||||
**Returns**: Dictionary with memory cache stats (size, max_size, etc.)
|
**Returns**: Dictionary with memory cache stats (size, max_size, etc.)
|
||||||
@@ -907,6 +899,8 @@ for plugin_id, plugin in all_plugins.items():
|
|||||||
|
|
||||||
#### `get_enabled_plugins() -> List[str]`
|
#### `get_enabled_plugins() -> List[str]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0 — check `enabled` on the instances in `plugin_manager.plugins`. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get list of enabled plugin IDs.
|
Get list of enabled plugin IDs.
|
||||||
|
|
||||||
**Returns**: List of plugin identifier strings
|
**Returns**: List of plugin identifier strings
|
||||||
@@ -985,9 +979,8 @@ def update(self):
|
|||||||
|
|
||||||
**Example - Checking if another plugin is enabled**:
|
**Example - Checking if another plugin is enabled**:
|
||||||
```python
|
```python
|
||||||
enabled_plugins = self.plugin_manager.get_enabled_plugins()
|
weather = self.plugin_manager.plugins.get("weather")
|
||||||
if "weather" in enabled_plugins:
|
if weather is not None and weather.enabled:
|
||||||
# Weather plugin is enabled
|
|
||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -155,9 +155,9 @@ deep-merged back into the plugin's config at load time
|
|||||||
### Custom input widgets
|
### Custom input widgets
|
||||||
|
|
||||||
Set `"x-widget": "<name>"` on a property. Core widgets are in
|
Set `"x-widget": "<name>"` on a property. Core widgets are in
|
||||||
`web_interface/static/v3/js/widgets/` (see its README); a plugin can ship its
|
`web_interface/static/v3/js/widgets/`; a plugin can ship its own widget
|
||||||
own widget script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`.
|
script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`. See the
|
||||||
See [widget-guide.md](widget-guide.md).
|
[widget guide](../web_interface/static/v3/js/widgets/README.md).
|
||||||
|
|
||||||
### Custom actions
|
### Custom actions
|
||||||
|
|
||||||
|
|||||||
@@ -520,19 +520,22 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
|
|||||||
`display_manager.image` (a PIL Image) and call `update_display()`;
|
`display_manager.image` (a PIL Image) and call `update_display()`;
|
||||||
there is no `draw_image()` helper method.
|
there is no `draw_image()` helper method.
|
||||||
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons
|
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons
|
||||||
|
(deprecated, removed in 3.7.0 — draw your own icons)
|
||||||
- `get_text_width()`, `get_font_height()` - Text utilities
|
- `get_text_width()`, `get_font_height()` - Text utilities
|
||||||
- `set_scrolling_state()`, `defer_update()` - Scrolling state management
|
- `set_scrolling_state()`, `defer_update()` - Scrolling state management
|
||||||
|
|
||||||
**Cache Manager** (`self.cache_manager`):
|
**Cache Manager** (`self.cache_manager`):
|
||||||
- `get()`, `set()`, `delete()` - Basic caching
|
- `get()`, `set()`, `delete()` - Basic caching
|
||||||
- `get_cached_data_with_strategy()` - Advanced caching with strategies
|
- `get_cached_data_with_strategy()` - Advanced caching with strategies
|
||||||
- `get_background_cached_data()` - Background service caching
|
- `get_background_cached_data()` - deprecated, removed in 3.7.0 — use `get()`
|
||||||
|
|
||||||
**Plugin Manager** (`self.plugin_manager`):
|
**Plugin Manager** (`self.plugin_manager`):
|
||||||
- `get_plugin()`, `get_all_plugins()` - Access other plugins
|
- `get_plugin()`, `get_all_plugins()` - Access other plugins
|
||||||
- `get_plugin_info()` - Get plugin information
|
- `get_plugin_info()` - Get plugin information
|
||||||
|
|
||||||
See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for complete documentation.
|
See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for complete
|
||||||
|
documentation, and its [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis)
|
||||||
|
table for everything removed in 3.7.0.
|
||||||
|
|
||||||
## 3rd Party Plugin Development
|
## 3rd Party Plugin Development
|
||||||
|
|
||||||
@@ -577,12 +580,14 @@ Your plugin must:
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Include manifest.json** with required fields:
|
2. **Include manifest.json** with the required fields listed in
|
||||||
|
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "my-plugin",
|
"id": "my-plugin",
|
||||||
"name": "My Plugin",
|
"name": "My Plugin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"author": "YourName",
|
||||||
"class_name": "MyPlugin",
|
"class_name": "MyPlugin",
|
||||||
"entry_point": "manager.py",
|
"entry_point": "manager.py",
|
||||||
"display_modes": ["my_plugin"],
|
"display_modes": ["my_plugin"],
|
||||||
@@ -658,7 +663,7 @@ For your plugin to work well in the plugin store:
|
|||||||
with the registry's `latest_version`; releases and tags are not read
|
with the registry's `latest_version`; releases and tags are not read
|
||||||
- **README.md**: Clear installation and configuration instructions
|
- **README.md**: Clear installation and configuration instructions
|
||||||
- **config_schema.json**: Recommended for web UI configuration
|
- **config_schema.json**: Recommended for web UI configuration
|
||||||
- **manifest.json**: Required with all required fields
|
- **manifest.json**: Required, with the [required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)
|
||||||
- **requirements.txt**: If your plugin has Python dependencies
|
- **requirements.txt**: If your plugin has Python dependencies
|
||||||
|
|
||||||
### Distribution Options
|
### Distribution Options
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ LEDMatrix/
|
|||||||
|
|
||||||
### 1. Minimal Plugin Structure
|
### 1. Minimal Plugin Structure
|
||||||
|
|
||||||
**manifest.json**:
|
**manifest.json** (the required fields are explained in
|
||||||
|
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields)):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "my-plugin",
|
"id": "my-plugin",
|
||||||
@@ -54,6 +55,8 @@ LEDMatrix/
|
|||||||
"author": "YourName",
|
"author": "YourName",
|
||||||
"entry_point": "manager.py",
|
"entry_point": "manager.py",
|
||||||
"class_name": "MyPlugin",
|
"class_name": "MyPlugin",
|
||||||
|
"display_modes": ["my-plugin"],
|
||||||
|
"compatible_versions": [">=2.0.0"],
|
||||||
"category": "custom"
|
"category": "custom"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ Don't edit `latest_version` or `last_updated` by hand for monorepo plugins:
|
|||||||
|
|
||||||
## Adding or changing an official plugin
|
## Adding or changing an official plugin
|
||||||
|
|
||||||
1. Add or edit `plugins/<your-plugin-id>/` in the monorepo. The store refuses
|
1. Add or edit `plugins/<your-plugin-id>/` in the monorepo, with the
|
||||||
a manifest without `id`, `name`, `class_name` and `display_modes`; also
|
manifest fields listed in
|
||||||
set `version`.
|
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields).
|
||||||
2. Bump `version` in the plugin's `manifest.json` for every change, or users
|
2. Bump `version` in the plugin's `manifest.json` for every change, or users
|
||||||
won't be offered the update.
|
won't be offered the update.
|
||||||
3. Run `python update_registry.py` in ledmatrix-plugins and commit the
|
3. Run `python update_registry.py` in ledmatrix-plugins and commit the
|
||||||
|
|||||||
+7
-3
@@ -11,7 +11,7 @@ the one-shot installer. The pages here go deeper.
|
|||||||
2. [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) — using the web UI
|
2. [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) — using the web UI
|
||||||
3. [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) — installing and managing plugins
|
3. [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) — installing and managing plugins
|
||||||
4. [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) — WiFi and AP-mode setup
|
4. [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) — WiFi and AP-mode setup
|
||||||
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes
|
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes ([PERMISSIONS.md](PERMISSIONS.md) for "Permission denied")
|
||||||
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
|
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
|
||||||
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
|
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
|
||||||
8. [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md) — Pi Zero 2 W / 3B+ / 1GB Pi 4 memory limits
|
8. [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md) — Pi Zero 2 W / 3B+ / 1GB Pi 4 memory limits
|
||||||
@@ -37,7 +37,7 @@ Going deeper:
|
|||||||
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md)
|
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md)
|
||||||
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json))
|
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json))
|
||||||
- [STARLARK_APPS_GUIDE.md](STARLARK_APPS_GUIDE.md) — Starlark-based mini-apps
|
- [STARLARK_APPS_GUIDE.md](STARLARK_APPS_GUIDE.md) — Starlark-based mini-apps
|
||||||
- [widget-guide.md](widget-guide.md) — widget development
|
- [Widget guide](../web_interface/static/v3/js/widgets/README.md) — built-in `x-widget`s and custom widgets
|
||||||
- [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md) — render legibly on any panel size (opt-in font/layout scaling)
|
- [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md) — render legibly on any panel size (opt-in font/layout scaling)
|
||||||
- [plugin-safety-harness.md](plugin-safety-harness.md) — test a plugin across every screen and matrix size
|
- [plugin-safety-harness.md](plugin-safety-harness.md) — test a plugin across every screen and matrix size
|
||||||
|
|
||||||
@@ -56,21 +56,25 @@ Going deeper:
|
|||||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
||||||
cache management, background services, permissions
|
cache management, background services, permissions
|
||||||
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
||||||
|
- [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
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
- [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md) — every key in config.json and config_secrets.json
|
- [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md) — every key in config.json and config_secrets.json
|
||||||
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) — all web-interface HTTP endpoints
|
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) — all web-interface HTTP endpoints
|
||||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — Python APIs available to plugins
|
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — Python APIs available to plugins
|
||||||
|
- [src/common/README.md](../src/common/README.md) — shared helper modules plugins can import
|
||||||
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks
|
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks
|
||||||
|
|
||||||
## Contributing to LEDMatrix itself
|
## Contributing to LEDMatrix itself
|
||||||
|
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — processes, display loop, plugin system, web UI; where to start reading
|
||||||
- [DEVELOPMENT.md](DEVELOPMENT.md) — environment setup
|
- [DEVELOPMENT.md](DEVELOPMENT.md) — environment setup
|
||||||
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite
|
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite
|
||||||
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace
|
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace
|
||||||
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases
|
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases
|
||||||
- [SPORTS_UNIFICATION.md](SPORTS_UNIFICATION.md) — how the sports scoreboard base classes are organized
|
- [SPORTS_UNIFICATION.md](SPORTS_UNIFICATION.md) — how shared sports scoreboard code moves into `src/common`
|
||||||
|
|
||||||
## Audits
|
## Audits
|
||||||
|
|
||||||
|
|||||||
@@ -73,14 +73,16 @@ B2 below promoted code into it (`SportsCore`, the mode classes,
|
|||||||
capabilities sections record that design, but none of it ships in core any
|
capabilities sections record that design, but none of it ships in core any
|
||||||
more. Shared sports code lives in `src/common`:
|
more. Shared sports code lives in `src/common`:
|
||||||
|
|
||||||
```
|
| Module | Since | Holds |
|
||||||
src/common/
|
|---|---|---|
|
||||||
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
|
| `sports_scroll.py` | 3.2.0 | `SportsScrollDisplay` / `SportsScrollDisplayManager` — scroll orchestration (content building stays in the plugins) |
|
||||||
(content building stays in the plugins)
|
| `sports_card.py` | 3.3.0 | Free functions for card settings, colours, favourite-team rules, dates and font sizes |
|
||||||
sports_helpers.py clamp/logo/rotation free functions + SportsHelpersMixin
|
| `sports_game_renderer.py` | 3.3.0 | `SportsGameRendererMixin` — scroll/Vegas card geometry |
|
||||||
(3.5.0) — the helpers byte-identical in the
|
| `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods |
|
||||||
plugins' sports.py, and the _favorite_key seam
|
| `sports_helpers.py` | 3.5.0 | clamp/logo/rotation free functions and `SportsHelpersMixin`, plus the `_favorite_key` seam |
|
||||||
```
|
| `espn_dates.py` | 3.5.0 | ESPN date-range and `limit` workarounds |
|
||||||
|
|
||||||
|
Each is described in [src/common/README.md](../src/common/README.md).
|
||||||
|
|
||||||
### Converging on `src/common`
|
### Converging on `src/common`
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ modules taken from the plugin copies, each a **new module** rather than growth
|
|||||||
on an existing one: a plugin that deletes a method copy and relies on an older
|
on an existing one: a plugin that deletes a method copy and relies on an older
|
||||||
module having gained it fails at runtime with an `AttributeError`, while a
|
module having gained it fails at runtime with an `AttributeError`, while a
|
||||||
missing module fails at load, where the version checks can see it.
|
missing module fails at load, where the version checks can see it.
|
||||||
`sports_helpers.py` is the first (it holds `_favorite_key`, the override point
|
`sports_helpers.py` is the newest (it holds `_favorite_key`, the override point
|
||||||
listed below, for later phases); its parity test compares every body against
|
listed below, for later phases); its parity test compares every body against
|
||||||
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
|
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
|
||||||
`test/test_common_is_hardware_free.py` keeps `src/common` free of
|
`test/test_common_is_hardware_free.py` keeps `src/common` free of
|
||||||
|
|||||||
@@ -102,9 +102,16 @@ cd /path/to/LEDMatrix
|
|||||||
bash scripts/download_pixlet.sh
|
bash scripts/download_pixlet.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The script downloads only the Linux ARM64 build (Raspberry Pi OS 64-bit),
|
||||||
|
from the `tronbyt/pixlet` releases, to `bin/pixlet/pixlet-linux-arm64`. On
|
||||||
|
any other platform (32-bit Pi OS, x86_64, macOS), put a `pixlet` binary on
|
||||||
|
your `PATH`, or set the plugin's `pixlet_path`, or place it in `bin/pixlet/`
|
||||||
|
under the name `_find_pixlet_binary()` looks for
|
||||||
|
([`web_interface/blueprints/api_v3/__init__.py`](../web_interface/blueprints/api_v3/__init__.py)).
|
||||||
|
|
||||||
Verify installation:
|
Verify installation:
|
||||||
```bash
|
```bash
|
||||||
./bin/pixlet/pixlet-linux-amd64 version
|
./bin/pixlet/pixlet-linux-arm64 version
|
||||||
# Pixlet 0.50.2 (or later)
|
# Pixlet 0.50.2 (or later)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -276,10 +283,8 @@ LEDMatrix/
|
|||||||
│ ├── hour_hand.png
|
│ ├── hour_hand.png
|
||||||
│ └── minute_hand.png
|
│ └── minute_hand.png
|
||||||
│
|
│
|
||||||
├── bin/pixlet/ # Pixlet binaries
|
├── bin/pixlet/ # Pixlet binary
|
||||||
│ ├── pixlet-linux-amd64
|
│ └── pixlet-linux-arm64 # the only one download_pixlet.sh fetches
|
||||||
│ ├── pixlet-linux-arm64
|
|
||||||
│ └── pixlet-darwin-arm64
|
|
||||||
│
|
│
|
||||||
└── scripts/
|
└── scripts/
|
||||||
└── download_pixlet.sh # Pixlet installer
|
└── download_pixlet.sh # Pixlet installer
|
||||||
@@ -324,7 +329,7 @@ Many apps require API keys for external services:
|
|||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. Check logs: `journalctl -u ledmatrix | grep -i pixlet`
|
1. Check logs: `journalctl -u ledmatrix | grep -i pixlet`
|
||||||
2. Verify config: Ensure all required fields are filled
|
2. Verify config: Ensure all required fields are filled
|
||||||
3. Test manually: `./bin/pixlet/pixlet-linux-amd64 render starlark-apps/{app-id}/{app-id}.star`
|
3. Test manually: `./bin/pixlet/pixlet-linux-arm64 render starlark-apps/{app-id}/{app-id}.star`
|
||||||
4. Missing assets: Some apps need images/fonts that may fail to download
|
4. Missing assets: Some apps need images/fonts that may fail to download
|
||||||
5. API issues: Check API keys and rate limits
|
5. API issues: Check API keys and rate limits
|
||||||
|
|
||||||
|
|||||||
+32
-27
@@ -201,10 +201,11 @@ sudo systemctl restart ledmatrix-web
|
|||||||
|
|
||||||
**Solutions:**
|
**Solutions:**
|
||||||
|
|
||||||
1. **Install dependencies:**
|
1. **Install dependencies** as root, so the root display service can import
|
||||||
|
them:
|
||||||
```bash
|
```bash
|
||||||
pip3 install --break-system-packages -r requirements.txt
|
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||||
pip3 install --break-system-packages -r web_interface/requirements.txt
|
sudo python3 -m pip install --break-system-packages --no-cache-dir -r web_interface/requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Test imports step-by-step:**
|
2. **Test imports step-by-step:**
|
||||||
@@ -250,15 +251,18 @@ sudo systemctl restart ledmatrix-web
|
|||||||
|
|
||||||
**Solutions:**
|
**Solutions:**
|
||||||
|
|
||||||
```bash
|
[PERMISSIONS.md](PERMISSIONS.md) lists the expected owner and mode of every
|
||||||
# Fix ownership of LEDMatrix directory
|
file and directory, and which `scripts/fix_perms/` script to run as which
|
||||||
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix
|
user. Don't `chown -R` the whole project: the two sudo helper scripts in
|
||||||
|
`scripts/fix_perms/` must stay owned by root.
|
||||||
|
|
||||||
# Fix config file permissions
|
```bash
|
||||||
|
# Config files: web user owns both; secrets must stay 640
|
||||||
|
stat -c '%U:%G %a %n' config/config.json config/config_secrets.json
|
||||||
sudo chmod 644 config/config.json
|
sudo chmod 644 config/config.json
|
||||||
sudo chmod 640 config/config_secrets.json
|
sudo chmod 640 config/config_secrets.json
|
||||||
|
|
||||||
# Verify service runs as correct user
|
# Which user the web interface runs as
|
||||||
sudo systemctl cat ledmatrix-web | grep User
|
sudo systemctl cat ledmatrix-web | grep User
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -462,15 +466,17 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Restart display:**
|
Or toggle the plugin on in the **Plugin Manager** tab, which writes the
|
||||||
```bash
|
same flag.
|
||||||
sudo systemctl restart ledmatrix
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Verify in web interface:**
|
2. **Wait a few seconds.** The display service watches `config.json` and
|
||||||
- Open the **Plugin Manager** tab
|
loads a newly enabled plugin without a restart
|
||||||
- Toggle the plugin switch to enable
|
(`DisplayController._reconcile_enabled_plugins()` in
|
||||||
- From **Overview**, click **Restart Display Service**
|
[`src/display_controller.py`](../src/display_controller.py)). This
|
||||||
|
needs hot reload, which is on unless `LEDMATRIX_HOT_RELOAD=false` is set.
|
||||||
|
|
||||||
|
3. **If it still does not appear**, check the logs for a config validation
|
||||||
|
error, then restart: `sudo systemctl restart ledmatrix`
|
||||||
|
|
||||||
#### Plugin Not Loading
|
#### Plugin Not Loading
|
||||||
|
|
||||||
@@ -491,10 +497,12 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
# Verify all required fields present
|
# Verify all required fields present
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Check dependencies installed:**
|
3. **Check dependencies installed.** Install them with `sudo`: the display
|
||||||
|
service runs as root and does not see packages pip put in your user's
|
||||||
|
`~/.local` (see [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md)):
|
||||||
```bash
|
```bash
|
||||||
if [ -f plugin-repos/plugin-id/requirements.txt ]; then
|
if [ -f plugin-repos/plugin-id/requirements.txt ]; then
|
||||||
pip3 install --break-system-packages -r plugin-repos/plugin-id/requirements.txt
|
sudo python3 -m pip install --break-system-packages --no-cache-dir -r plugin-repos/plugin-id/requirements.txt
|
||||||
fi
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -503,14 +511,9 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
sudo journalctl -u ledmatrix -f | grep plugin-id
|
sudo journalctl -u ledmatrix -f | grep plugin-id
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Test plugin import:**
|
5. **Load and render the plugin headlessly:**
|
||||||
```bash
|
```bash
|
||||||
python3 -c "
|
python3 scripts/check_plugin.py --plugin plugin-id
|
||||||
import sys
|
|
||||||
sys.path.insert(0, 'plugin-repos/plugin-id')
|
|
||||||
from manager import PluginClass
|
|
||||||
print('Plugin imports successfully')
|
|
||||||
"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Stale Cache Data
|
#### Stale Cache Data
|
||||||
@@ -540,10 +543,12 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
sudo systemctl restart ledmatrix
|
sudo systemctl restart ledmatrix
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Check cache permissions:**
|
2. **Check cache permissions.** Expected: `root:ledmatrix`, `drwxrwsr-x`.
|
||||||
|
`setup_cache.sh` restores that layout (see
|
||||||
|
[PERMISSIONS.md](PERMISSIONS.md#repair-scripts)):
|
||||||
```bash
|
```bash
|
||||||
ls -ld /var/cache/ledmatrix
|
ls -ld /var/cache/ledmatrix
|
||||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh
|
sudo bash scripts/install/setup_cache.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -161,7 +161,11 @@ duration, and related settings — so you can configure Vegas mode
|
|||||||
entirely from the web UI without hand-editing JSON. See
|
entirely from the web UI without hand-editing JSON. See
|
||||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for what the options do.
|
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for what the options do.
|
||||||
|
|
||||||
Changes require **Restart Display Service** from the Overview tab.
|
Brightness and the Vegas Scroll settings apply to the running display
|
||||||
|
within a few seconds. Matrix hardware settings (rows, columns, chain length,
|
||||||
|
mapping, GPIO slowdown, PWM and refresh settings) are only read when the
|
||||||
|
display starts, so those need **Restart Display Service** from the Overview
|
||||||
|
tab.
|
||||||
|
|
||||||
### Plugin Manager Tab
|
### Plugin Manager Tab
|
||||||
|
|
||||||
@@ -248,16 +252,16 @@ View real-time system logs:
|
|||||||
|
|
||||||
1. Open the **Display** tab
|
1. Open the **Display** tab
|
||||||
2. Adjust the **Brightness** slider (1–100)
|
2. Adjust the **Brightness** slider (1–100)
|
||||||
3. Click **Save**
|
3. Click **Save**. The panel picks up the new brightness within a few
|
||||||
4. Click **Restart Display Service** on the **Overview** tab
|
seconds; no restart is needed
|
||||||
|
|
||||||
### Installing a New Plugin
|
### Installing a New Plugin
|
||||||
|
|
||||||
1. Open the **Plugin Manager** tab
|
1. Open the **Plugin Manager** tab
|
||||||
2. Scroll to the **Plugin Store** section and browse or search
|
2. Scroll to the **Plugin Store** section and browse or search
|
||||||
3. Click **Install** next to the plugin
|
3. Click **Install** next to the plugin
|
||||||
4. Toggle the plugin on in **Installed Plugins**
|
4. Toggle the plugin on in **Installed Plugins**. The running display
|
||||||
5. Click **Restart Display Service** on **Overview**
|
loads it within a few seconds; no restart is needed
|
||||||
|
|
||||||
### Configuring a Plugin
|
### Configuring a Plugin
|
||||||
|
|
||||||
|
|||||||
+5
-588
@@ -1,590 +1,7 @@
|
|||||||
# Widget Development Guide
|
# Widget Development Guide
|
||||||
|
|
||||||
## Overview
|
The widget guide lives next to the widgets, in
|
||||||
|
[web_interface/static/v3/js/widgets/README.md](../web_interface/static/v3/js/widgets/README.md).
|
||||||
The LEDMatrix Widget Registry system allows plugins to use reusable UI components for configuration forms. This enables:
|
It lists every built-in `x-widget`, the schema keywords the config form
|
||||||
|
understands (`x-options.labels`, `x-advanced`, `x-display: "hidden"`), and how
|
||||||
- **Reusable Components**: Use existing widgets (file upload, checkboxes, etc.) without custom code
|
to ship a custom widget with a plugin.
|
||||||
- **Custom Widgets**: Create plugin-specific widgets without modifying the LEDMatrix codebase
|
|
||||||
- **Backwards Compatibility**: Existing plugins continue to work without changes
|
|
||||||
|
|
||||||
## Available Core Widgets
|
|
||||||
|
|
||||||
### Plugin File Manager Widget (`plugin-file-manager`)
|
|
||||||
|
|
||||||
Full inline file management UI for plugins that manage files via the `web_ui_actions` system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe.
|
|
||||||
|
|
||||||
`plugin_id` is **automatically injected** from template context. File operations call `/api/v3/plugins/action` immediately on user action; no Save Configuration needed.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"file_manager": {
|
|
||||||
"type": "null",
|
|
||||||
"title": "Data Files",
|
|
||||||
"x-widget": "plugin-file-manager",
|
|
||||||
"x-widget-config": {
|
|
||||||
"actions": {
|
|
||||||
"list": "list-files",
|
|
||||||
"get": "get-file",
|
|
||||||
"save": "save-file",
|
|
||||||
"upload": "upload-file",
|
|
||||||
"delete": "delete-file",
|
|
||||||
"create": "create-file",
|
|
||||||
"toggle": "toggle-category"
|
|
||||||
},
|
|
||||||
"upload_hint": "JSON files with day numbers 1–365 as keys",
|
|
||||||
"directory_label": "my_data/",
|
|
||||||
"create_fields": [
|
|
||||||
{ "key": "category_name", "label": "Category Name",
|
|
||||||
"placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
|
|
||||||
"hint": "Lowercase letters, numbers, underscores" },
|
|
||||||
{ "key": "display_name", "label": "Display Name",
|
|
||||||
"placeholder": "e.g., My Words", "hint": "Optional" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
|
||||||
|
|
||||||
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
|
|
||||||
|
|
||||||
**Used by:** of-the-day
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Time Picker Widget (`time-picker`)
|
|
||||||
|
|
||||||
Single time selection using the browser's native time input. Returns a string in `HH:MM` (24-hour) format. Generic — works in any plugin without configuration.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"target_time": {
|
|
||||||
"type": "string",
|
|
||||||
"x-widget": "time-picker",
|
|
||||||
"default": "00:00",
|
|
||||||
"x-options": {
|
|
||||||
"placeholder": "Select time",
|
|
||||||
"clearable": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Used by:** countdown
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### File Upload Single Widget (`file-upload-single`)
|
|
||||||
|
|
||||||
Single-image upload for string fields. Uploads to the plugin's asset folder (`assets/plugins/<plugin_id>/uploads/`) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The `plugin_id` is **automatically injected** from the template context — no need to specify it in the schema.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"image_path": {
|
|
||||||
"type": "string",
|
|
||||||
"x-widget": "file-upload-single",
|
|
||||||
"x-upload-config": {
|
|
||||||
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
|
|
||||||
"max_size_mb": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: Unlike `file-upload` (array-level), this widget is for a single `string` field. It is ideal for per-item images inside `array-table` rows.
|
|
||||||
|
|
||||||
**Used by:** countdown
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### File Upload Widget (`file-upload`)
|
|
||||||
|
|
||||||
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "array",
|
|
||||||
"x-widget": "file-upload",
|
|
||||||
"x-upload-config": {
|
|
||||||
"plugin_id": "my-plugin",
|
|
||||||
"max_files": 10,
|
|
||||||
"max_size_mb": 5,
|
|
||||||
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Used by:** static-image, news plugins
|
|
||||||
|
|
||||||
### Checkbox Group Widget (`checkbox-group`)
|
|
||||||
|
|
||||||
Multi-select checkboxes for array fields with enum items.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "array",
|
|
||||||
"x-widget": "checkbox-group",
|
|
||||||
"items": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["option1", "option2", "option3"]
|
|
||||||
},
|
|
||||||
"x-options": {
|
|
||||||
"labels": {
|
|
||||||
"option1": "Option 1 Label",
|
|
||||||
"option2": "Option 2 Label"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Used by:** odds-ticker, news plugins
|
|
||||||
|
|
||||||
### Custom Feeds Widget (`custom-feeds`)
|
|
||||||
|
|
||||||
Table-based RSS feed editor with logo uploads.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "array",
|
|
||||||
"x-widget": "custom-feeds",
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"name": { "type": "string" },
|
|
||||||
"url": { "type": "string", "format": "uri" },
|
|
||||||
"enabled": { "type": "boolean" },
|
|
||||||
"logo": { "type": "object" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"maxItems": 50
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Used by:** news plugin (for custom RSS feeds)
|
|
||||||
|
|
||||||
## Using Existing Widgets
|
|
||||||
|
|
||||||
To use an existing widget in your plugin's `config_schema.json`, simply add the `x-widget` property:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"my_images": {
|
|
||||||
"type": "array",
|
|
||||||
"x-widget": "file-upload",
|
|
||||||
"x-upload-config": {
|
|
||||||
"plugin_id": "my-plugin",
|
|
||||||
"max_files": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enabled_leagues": {
|
|
||||||
"type": "array",
|
|
||||||
"x-widget": "checkbox-group",
|
|
||||||
"items": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["nfl", "nba", "mlb"]
|
|
||||||
},
|
|
||||||
"x-options": {
|
|
||||||
"labels": {
|
|
||||||
"nfl": "NFL",
|
|
||||||
"nba": "NBA",
|
|
||||||
"mlb": "MLB"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The widget will be automatically rendered when the plugin configuration form is loaded.
|
|
||||||
|
|
||||||
## Labelling Enum Options (`x-options.labels`)
|
|
||||||
|
|
||||||
A plain `enum` renders as a dropdown whose option text is the value with
|
|
||||||
underscores replaced and title case applied — `day_first` becomes "Day First".
|
|
||||||
That is fine for values that read as their own label, and wrong for values that
|
|
||||||
do not: `vs` becomes "Vs", and `abbrev` says nothing about the `Sep 19` it
|
|
||||||
actually produces.
|
|
||||||
|
|
||||||
Supply `x-options.labels` to set the visible text. This is the same convention
|
|
||||||
the `checkbox-group` widget uses:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"date_format": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["abbrev", "numeric", "day_first"],
|
|
||||||
"default": "abbrev",
|
|
||||||
"x-options": {
|
|
||||||
"labels": {
|
|
||||||
"abbrev": "Sep 19",
|
|
||||||
"numeric": "9/19",
|
|
||||||
"day_first": "19 Sep"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Labels are **display only** — the stored value is still the enum value, so
|
|
||||||
adding them never changes a saved config. The map may be partial: any value
|
|
||||||
without a label keeps the humanised fallback. Older cores that predate this
|
|
||||||
support ignore `x-options` and render the fallback for every option, so a
|
|
||||||
plugin can ship labels without requiring a core upgrade.
|
|
||||||
|
|
||||||
Array-table columns (`x-widget: array-table`) accept the same
|
|
||||||
`x-options.labels` on a column definition, but their fallback is the **raw
|
|
||||||
value** rather than the humanised one, because those columns hold values such
|
|
||||||
as ticker symbols where `aapl` → "Aapl" would be wrong. Rows added in the
|
|
||||||
browser use the labels too (`array-table.js`), so a column reads the same
|
|
||||||
before and after a page reload.
|
|
||||||
|
|
||||||
## Marking Fields as Advanced (`x-advanced`)
|
|
||||||
|
|
||||||
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
|
||||||
of the main form and into a single collapsed **Advanced Settings** section at
|
|
||||||
the bottom of the plugin's configuration page:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"city": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "City"
|
|
||||||
},
|
|
||||||
"request_timeout": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 10,
|
|
||||||
"description": "HTTP timeout in seconds",
|
|
||||||
"x-advanced": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Guidelines:
|
|
||||||
|
|
||||||
- Use it for fine-tuning knobs most users never touch (timeouts, retry
|
|
||||||
behavior, cache TTLs, styling overrides). Anything a first-time user must
|
|
||||||
set to get the plugin working should stay basic.
|
|
||||||
- Nothing is hidden permanently — the section expands on click, and the
|
|
||||||
settings search finds and auto-expands advanced fields like any others.
|
|
||||||
- The flag is ignored on `object`-type properties (they already render as
|
|
||||||
their own collapsible sections) and is safely ignored by older cores, so
|
|
||||||
adding it never breaks compatibility.
|
|
||||||
|
|
||||||
## Hiding Fields From the Form (`x-display: "hidden"`)
|
|
||||||
|
|
||||||
Add `"x-display": "hidden"` to a property that must stay in the schema but
|
|
||||||
should not appear as a control: a deprecated key kept so existing configs keep
|
|
||||||
validating, or an internal value such as an auto-generated row id.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"radar_zoom": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 6,
|
|
||||||
"title": "Radar Zoom Level (deprecated)",
|
|
||||||
"x-display": "hidden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
What the core does with it:
|
|
||||||
|
|
||||||
- **Not rendered** at any depth: top-level fields, children of an object
|
|
||||||
section, and properties of array-of-object items (never a table column, even
|
|
||||||
if `x-columns` names it, and never in the row editor). A hidden field flagged
|
|
||||||
`x-advanced` is not listed or counted in Advanced Settings, and an object
|
|
||||||
whose children are all hidden draws no empty section. Hidden fields don't
|
|
||||||
show up in the settings search either, since it indexes the rendered form.
|
|
||||||
- **Stored value preserved on save.** Saving the form never changes a hidden
|
|
||||||
value. The unchecked-checkbox rule ignores a hidden boolean. Array rows carry
|
|
||||||
a hidden property's stored value through the form, so the value survives the
|
|
||||||
row being posted back; a new row gets no value (the plugin fills it in).
|
|
||||||
- **The API is unaffected.** A JSON save to `POST /api/v3/plugins/config` can
|
|
||||||
still set a hidden field.
|
|
||||||
|
|
||||||
Older cores ignore the flag and render the field as a normal control.
|
|
||||||
|
|
||||||
## Creating Custom Widgets
|
|
||||||
|
|
||||||
### Step 1: Create Widget File
|
|
||||||
|
|
||||||
Create a JavaScript file in your plugin's `widgets/` directory, named
|
|
||||||
`widgets/[widget-name].js`. The directory is not optional: it is the only
|
|
||||||
place the core will serve a widget from.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Ensure LEDMatrixWidgets registry is available
|
|
||||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
|
||||||
console.error('LEDMatrixWidgets registry not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register your widget
|
|
||||||
window.LEDMatrixWidgets.register('my-custom-widget', {
|
|
||||||
name: 'My Custom Widget',
|
|
||||||
version: '1.0.0',
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the widget HTML
|
|
||||||
* @param {HTMLElement} container - Container element to render into
|
|
||||||
* @param {Object} config - Widget configuration from schema
|
|
||||||
* @param {*} value - Current value
|
|
||||||
* @param {Object} options - Additional options (fieldId, pluginId, etc.)
|
|
||||||
*/
|
|
||||||
render: function(container, config, value, options) {
|
|
||||||
const fieldId = options.fieldId || container.id;
|
|
||||||
|
|
||||||
// Always escape HTML to prevent XSS
|
|
||||||
const escapeHtml = (text) => {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
};
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="my-custom-widget">
|
|
||||||
<input type="text"
|
|
||||||
id="${fieldId}_input"
|
|
||||||
value="${escapeHtml(value || '')}"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded">
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Attach event listeners
|
|
||||||
const input = container.querySelector('input');
|
|
||||||
input.addEventListener('change', (e) => {
|
|
||||||
this.handlers.onChange(fieldId, e.target.value);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current value from widget
|
|
||||||
*/
|
|
||||||
getValue: function(fieldId) {
|
|
||||||
const input = document.querySelector(`#${fieldId}_input`);
|
|
||||||
return input ? input.value : null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set value programmatically
|
|
||||||
*/
|
|
||||||
setValue: function(fieldId, value) {
|
|
||||||
const input = document.querySelector(`#${fieldId}_input`);
|
|
||||||
if (input) {
|
|
||||||
input.value = value || '';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event handlers
|
|
||||||
*/
|
|
||||||
handlers: {
|
|
||||||
onChange: function(fieldId, value) {
|
|
||||||
// Trigger form change event
|
|
||||||
const event = new CustomEvent('widget-change', {
|
|
||||||
detail: { fieldId, value },
|
|
||||||
bubbles: true
|
|
||||||
});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Declare the Widget in `manifest.json`
|
|
||||||
|
|
||||||
The manifest is the allowlist. A widget is served only if the plugin declares
|
|
||||||
it, so shipping a file under `widgets/` does not by itself publish it:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"widgets": [
|
|
||||||
{
|
|
||||||
"name": "my-custom-widget",
|
|
||||||
"script": "my-custom-widget.js",
|
|
||||||
"description": "What this widget is for"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`name` is what you use in `x-widget` and in the URL. `script` is optional and
|
|
||||||
defaults to `[name].js`; it must be a plain filename directly inside
|
|
||||||
`widgets/` (no paths). Both are validated against
|
|
||||||
`schema/manifest_schema.json`.
|
|
||||||
|
|
||||||
### Step 3: Reference Widget in Schema
|
|
||||||
|
|
||||||
In your plugin's `config_schema.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"my_field": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "My custom field",
|
|
||||||
"x-widget": "my-custom-widget",
|
|
||||||
"default": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Widget Loading
|
|
||||||
|
|
||||||
The widget is loaded on demand when the plugin's configuration form renders a
|
|
||||||
field that references it. The system will:
|
|
||||||
|
|
||||||
1. Check whether the widget is already registered in the core registry.
|
|
||||||
2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`.
|
|
||||||
That route serves the declared `script` from your plugin's `widgets/`
|
|
||||||
directory, as `text/javascript`.
|
|
||||||
3. Render it by calling the `render` function your script registered.
|
|
||||||
|
|
||||||
The fetch uses a dynamic `import()`, so the file must parse as an ES module.
|
|
||||||
A plain IIFE does — modules are strict mode, so avoid sloppy-mode constructs.
|
|
||||||
|
|
||||||
**If the widget fails to load** (not declared, file missing, script throws, or
|
|
||||||
it never calls `register`), the field falls back to a plain text input holding
|
|
||||||
the current value. This is deliberate: a broken widget costs the user an
|
|
||||||
editor, not their configured value.
|
|
||||||
|
|
||||||
**Limitation:** the on-demand path applies to `string`-typed fields (the
|
|
||||||
default branch of the config-form renderer). Fields typed `object`, `array`,
|
|
||||||
`boolean`, `integer` or `number`, and fields whose `enum` is set, are
|
|
||||||
dispatched by the server-side template to its own built-in renderers, so a
|
|
||||||
plugin-supplied `x-widget` on one of those is ignored today.
|
|
||||||
|
|
||||||
## Widget API Reference
|
|
||||||
|
|
||||||
### Widget Definition Object
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
name: string, // Human-readable widget name
|
|
||||||
version: string, // Widget version
|
|
||||||
render: function, // Required: Render function
|
|
||||||
getValue: function, // Optional: Get current value
|
|
||||||
setValue: function, // Optional: Set value programmatically
|
|
||||||
handlers: object // Optional: Event handlers
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Render Function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
render(container, config, value, options)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `container` (HTMLElement): Container element to render into
|
|
||||||
- `config` (Object): Widget configuration from schema
|
|
||||||
- `value` (*): Current field value
|
|
||||||
- `options` (Object): Additional options
|
|
||||||
- `fieldId` (string): Field ID
|
|
||||||
- `pluginId` (string): Plugin ID
|
|
||||||
- `fullKey` (string): Full field key path
|
|
||||||
|
|
||||||
### Get Value Function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
getValue(fieldId)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Returns:** Current widget value
|
|
||||||
|
|
||||||
### Set Value Function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
setValue(fieldId, value)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `fieldId` (string): Field ID
|
|
||||||
- `value` (*): Value to set
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interface/static/v3/js/widgets/example-color-picker.js) for a complete example of a custom color picker widget.
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
1. **Always escape HTML**: Use `escapeHtml()` or `textContent` to prevent XSS
|
|
||||||
2. **Validate inputs**: Validate user input before processing
|
|
||||||
3. **Sanitize values**: Clean values before storing
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
1. **Lazy loading**: Load widget scripts only when needed
|
|
||||||
2. **Event delegation**: Use event delegation for dynamic content
|
|
||||||
3. **Debounce**: Debounce frequent events (e.g., input changes)
|
|
||||||
|
|
||||||
### Accessibility
|
|
||||||
|
|
||||||
1. **Labels**: Always associate labels with inputs
|
|
||||||
2. **ARIA attributes**: Use appropriate ARIA attributes
|
|
||||||
3. **Keyboard navigation**: Ensure keyboard accessibility
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Widget Not Loading
|
|
||||||
|
|
||||||
1. Check browser console for errors
|
|
||||||
2. Verify widget file path is correct
|
|
||||||
3. Ensure `LEDMatrixWidgets.register()` is called
|
|
||||||
4. Check that widget name matches schema `x-widget` value
|
|
||||||
|
|
||||||
### Widget Not Rendering
|
|
||||||
|
|
||||||
1. Verify `render` function is defined
|
|
||||||
2. Check container element exists
|
|
||||||
3. Ensure widget is registered before form loads
|
|
||||||
4. Check for JavaScript errors in console
|
|
||||||
|
|
||||||
### Value Not Saving
|
|
||||||
|
|
||||||
1. Ensure widget triggers `widget-change` event
|
|
||||||
2. Verify form submission includes widget value
|
|
||||||
3. Check `getValue` function returns correct type
|
|
||||||
4. Verify field name matches schema property
|
|
||||||
|
|
||||||
## Current Implementation Status
|
|
||||||
|
|
||||||
**Phase 1 Complete:**
|
|
||||||
- ✅ Widget registry system created
|
|
||||||
- ✅ Core widgets extracted to separate files
|
|
||||||
- ✅ Widget handlers available globally (backwards compatible)
|
|
||||||
- ✅ Plugin widget loading system implemented
|
|
||||||
|
|
||||||
**Current Behavior:**
|
|
||||||
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
|
|
||||||
- Widget handlers are registered and available globally
|
|
||||||
- Custom widgets can be created, declared in `manifest.json`, and are served
|
|
||||||
and rendered on demand for `string`-typed fields
|
|
||||||
- Plugin widgets on non-string fields are not dispatched yet (see Step 4)
|
|
||||||
|
|
||||||
**Backwards Compatibility:**
|
|
||||||
- All existing plugins using widgets continue to work without changes
|
|
||||||
- Server-side rendering remains the primary method
|
|
||||||
- Widget registry provides foundation for future enhancements
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- [Widget README](../web_interface/static/v3/js/widgets/README.md) - Complete widget development guide with examples
|
|
||||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - General plugin development
|
|
||||||
- [Plugin Configuration Guide](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup
|
|
||||||
|
|||||||
+52
-36
@@ -18,7 +18,7 @@ on_error() {
|
|||||||
echo "-- Last 100 lines from log --" >&2
|
echo "-- Last 100 lines from log --" >&2
|
||||||
tail -n 100 "$LOG_FILE" >&2 || true
|
tail -n 100 "$LOG_FILE" >&2 || true
|
||||||
fi
|
fi
|
||||||
echo "\nCommon fixes:" >&2
|
printf '\nCommon fixes:\n' >&2
|
||||||
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
||||||
echo "- If you saw an APT lock error: wait a minute, close other installers, then run: sudo dpkg --configure -a" >&2
|
echo "- If you saw an APT lock error: wait a minute, close other installers, then run: sudo dpkg --configure -a" >&2
|
||||||
echo "- Re-run this script. It is safe to run multiple times." >&2
|
echo "- Re-run this script. It is safe to run multiple times." >&2
|
||||||
@@ -115,7 +115,8 @@ fi
|
|||||||
echo "✓ OS requirements met"
|
echo "✓ OS requirements met"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Get the actual user who invoked sudo (set after we ensure sudo below)
|
# The user who ran the installer: SUDO_USER once we are running under sudo
|
||||||
|
# (the re-exec below guarantees that), otherwise whoever we are now.
|
||||||
if [ -n "${SUDO_USER:-}" ]; then
|
if [ -n "${SUDO_USER:-}" ]; then
|
||||||
ACTUAL_USER="$SUDO_USER"
|
ACTUAL_USER="$SUDO_USER"
|
||||||
else
|
else
|
||||||
@@ -202,7 +203,7 @@ echo ""
|
|||||||
# Check if running as root; if not, try to elevate automatically for novices
|
# Check if running as root; if not, try to elevate automatically for novices
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$EUID" -ne 0 ]; then
|
||||||
echo "This script needs administrator privileges. Attempting to re-run with sudo..."
|
echo "This script needs administrator privileges. Attempting to re-run with sudo..."
|
||||||
exec sudo -E env LEDMATRIX_ELEVATED=1 bash "$0" "$@"
|
exec sudo -E bash "$0" "$@"
|
||||||
fi
|
fi
|
||||||
echo "✓ Running as root (required for installation)"
|
echo "✓ Running as root (required for installation)"
|
||||||
|
|
||||||
@@ -507,8 +508,11 @@ print_rgbmatrix_build_failure() {
|
|||||||
# it. The logic was pasted three times, identically, and is kept verbatim here.
|
# it. The logic was pasted three times, identically, and is kept verbatim here.
|
||||||
# Note: install_web_service.sh and install_service.sh no longer contain the
|
# Note: install_web_service.sh and install_service.sh no longer contain the
|
||||||
# "User=root" / "User=${ACTUAL_USER}" strings grepped for below (the units come
|
# "User=root" / "User=${ACTUAL_USER}" strings grepped for below (the units come
|
||||||
# from systemd/*.service templates with User=__USER__), so until Step 8 has
|
# from systemd/*.service templates with User=__USER__). So once the unit is
|
||||||
# installed the unit this yields "root".
|
# installed (Step 7.5, by install_service.sh) the first branch reads its real
|
||||||
|
# User=; before that the second branch is taken whenever
|
||||||
|
# install_web_service.sh exists, matches neither string, and yields "root" --
|
||||||
|
# the later branches are reached only if that script is missing.
|
||||||
detect_web_service_user() {
|
detect_web_service_user() {
|
||||||
WEB_SERVICE_USER="root"
|
WEB_SERVICE_USER="root"
|
||||||
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
||||||
@@ -669,8 +673,9 @@ else
|
|||||||
echo "Setting ownership of assets directory..."
|
echo "Setting ownership of assets directory..."
|
||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PROJECT_ROOT_DIR/assets"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PROJECT_ROOT_DIR/assets"
|
||||||
|
|
||||||
# Set permissions to allow read/write for owner, group, and others (for root service user)
|
# 777: read/write for owner, group and every other account. Root (the
|
||||||
# Note: 777 allows root (service user) to write, which is necessary when service runs as root
|
# display service) does not need it -- root ignores mode bits -- so the
|
||||||
|
# "other" bits only matter to accounts that are neither the owner nor root.
|
||||||
echo "Setting permissions for assets directory..."
|
echo "Setting permissions for assets directory..."
|
||||||
chmod -R 777 "$PROJECT_ROOT_DIR/assets"
|
chmod -R 777 "$PROJECT_ROOT_DIR/assets"
|
||||||
|
|
||||||
@@ -782,8 +787,8 @@ else
|
|||||||
chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
|
chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set directory permissions (775: rwxrwxr-x)
|
# Set directory permissions (2775: rwxrwsr-x, setgid so new entries inherit the group)
|
||||||
echo "Setting plugin-repos directory permissions to 2775 (sticky bit)..."
|
echo "Setting plugin-repos directory permissions to 2775 (setgid)..."
|
||||||
find "$PLUGIN_REPOS_DIR" -type d -exec chmod 2775 {} \;
|
find "$PLUGIN_REPOS_DIR" -type d -exec chmod 2775 {} \;
|
||||||
|
|
||||||
# Set file permissions (664: rw-rw-r--)
|
# Set file permissions (664: rw-rw-r--)
|
||||||
@@ -990,9 +995,9 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
PACKAGE_NUM=$((PACKAGE_NUM + 1))
|
PACKAGE_NUM=$((PACKAGE_NUM + 1))
|
||||||
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
|
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
|
||||||
|
|
||||||
# Check if package is already installed (basic check - may not catch all cases)
|
# Install with a timeout where available. --verbose output goes to
|
||||||
# Try installing with verbose output and timeout (if available)
|
# $INSTALL_OUTPUT (filtered below, full copy in the log); --no-cache-dir
|
||||||
# Use --no-cache-dir to avoid cache issues, --verbose for diagnostics
|
# avoids pip cache issues.
|
||||||
INSTALL_OUTPUT=$(mktemp)
|
INSTALL_OUTPUT=$(mktemp)
|
||||||
INSTALL_SUCCESS=false
|
INSTALL_SUCCESS=false
|
||||||
|
|
||||||
@@ -1297,7 +1302,11 @@ else
|
|||||||
WEB_DEPS_OK=false
|
WEB_DEPS_OK=false
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Web dependencies already installed from web_interface/requirements.txt in Step 5"
|
# No marker means Step 5 did not install web_interface/requirements.txt,
|
||||||
|
# and without the smart installer there is nothing else to try here.
|
||||||
|
echo "⚠ scripts/install_dependencies_apt.py not found, and Step 5 did not install"
|
||||||
|
echo " web_interface/requirements.txt, so web interface dependencies may be missing."
|
||||||
|
WEB_DEPS_OK=false
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create the marker only when installation actually succeeded, so a
|
# Create the marker only when installation actually succeeded, so a
|
||||||
@@ -1562,11 +1571,12 @@ echo "-----------------------------------------------------"
|
|||||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh" ]; then
|
||||||
echo "Configuring WiFi management permissions..."
|
echo "Configuring WiFi management permissions..."
|
||||||
# Run as the actual user (not root) since the script checks for that
|
# Run as the actual user (not root) since the script checks for that
|
||||||
sudo -u "$ACTUAL_USER" bash "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh" || {
|
if sudo -u "$ACTUAL_USER" bash "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh"; then
|
||||||
|
echo "✓ WiFi management permissions configured"
|
||||||
|
else
|
||||||
echo "⚠ WiFi permissions configuration failed, but continuing installation"
|
echo "⚠ WiFi permissions configuration failed, but continuing installation"
|
||||||
echo " You can run it manually later: ./scripts/install/configure_wifi_permissions.sh"
|
echo " You can run it manually later: ./scripts/install/configure_wifi_permissions.sh"
|
||||||
}
|
fi
|
||||||
echo "✓ WiFi management permissions configured"
|
|
||||||
else
|
else
|
||||||
echo "⚠ configure_wifi_permissions.sh not found; skipping WiFi permissions configuration"
|
echo "⚠ configure_wifi_permissions.sh not found; skipping WiFi permissions configuration"
|
||||||
echo " You can configure WiFi permissions later by running:"
|
echo " You can configure WiFi permissions later by running:"
|
||||||
@@ -1736,10 +1746,11 @@ echo "-------------------------------------"
|
|||||||
echo "Removing potential conflicting services (bluetooth and others)..."
|
echo "Removing potential conflicting services (bluetooth and others)..."
|
||||||
if [ "$SKIP_SOUND" = "1" ]; then
|
if [ "$SKIP_SOUND" = "1" ]; then
|
||||||
echo "Skipping sound module configuration as requested (--skip-sound)."
|
echo "Skipping sound module configuration as requested (--skip-sound)."
|
||||||
elif apt_remove bluez bluez-firmware pi-bluetooth triggerhappy pigpio; then
|
|
||||||
echo "✓ Unnecessary services removed (or not present)"
|
|
||||||
else
|
else
|
||||||
echo "⚠ Some packages could not be removed; continuing"
|
# apt_remove never fails (it ends in `|| true`); apt itself reports any
|
||||||
|
# package it could not remove.
|
||||||
|
apt_remove bluez bluez-firmware pi-bluetooth triggerhappy pigpio
|
||||||
|
echo "✓ Unnecessary services removed (or not present)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Blacklist onboard sound module (idempotent)
|
# Blacklist onboard sound module (idempotent)
|
||||||
@@ -1954,20 +1965,6 @@ if systemctl list-unit-files | grep -q "ledmatrix-wifi-monitor.service"; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$SKIP_REBOOT_PROMPT" = "1" ]; then
|
|
||||||
echo "Skipping reboot prompt as requested (--no-reboot-prompt)."
|
|
||||||
elif [ "$ASSUME_YES" = "1" ]; then
|
|
||||||
echo "Non-interactive mode: rebooting now to apply changes..."
|
|
||||||
reboot
|
|
||||||
else
|
|
||||||
read -p "A reboot is recommended to apply kernel and audio changes. Reboot now? (y/N): " -n 1 -r
|
|
||||||
echo
|
|
||||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
echo "Rebooting now..."
|
|
||||||
reboot
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Installation Complete!"
|
echo "Installation Complete!"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
@@ -2013,7 +2010,7 @@ if command -v nmcli >/dev/null 2>&1; then
|
|||||||
if [ -n "$WIFI_STATUS" ]; then
|
if [ -n "$WIFI_STATUS" ]; then
|
||||||
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do
|
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do
|
||||||
if [ "$state" = "connected" ]; then
|
if [ "$state" = "connected" ]; then
|
||||||
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1)
|
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1 || true)
|
||||||
if [ -n "$SSID" ]; then
|
if [ -n "$SSID" ]; then
|
||||||
echo " ✓ Connected to: $SSID"
|
echo " ✓ Connected to: $SSID"
|
||||||
else
|
else
|
||||||
@@ -2037,7 +2034,7 @@ echo "AP Mode Status:"
|
|||||||
if systemctl is-active --quiet hostapd 2>/dev/null; then
|
if systemctl is-active --quiet hostapd 2>/dev/null; then
|
||||||
echo " ✓ AP Mode is ACTIVE"
|
echo " ✓ AP Mode is ACTIVE"
|
||||||
echo " → Connect to WiFi network: LEDMatrix-Setup"
|
echo " → Connect to WiFi network: LEDMatrix-Setup"
|
||||||
echo " → Password: ledmatrix123"
|
echo " → Open network, no password"
|
||||||
echo " → Access web UI at: http://192.168.4.1:5000"
|
echo " → Access web UI at: http://192.168.4.1:5000"
|
||||||
AP_MODE_ACTIVE=true
|
AP_MODE_ACTIVE=true
|
||||||
else
|
else
|
||||||
@@ -2045,7 +2042,7 @@ else
|
|||||||
if ip addr show wlan0 2>/dev/null | grep -q "192.168.4.1"; then
|
if ip addr show wlan0 2>/dev/null | grep -q "192.168.4.1"; then
|
||||||
echo " ✓ AP Mode is ACTIVE (IP detected)"
|
echo " ✓ AP Mode is ACTIVE (IP detected)"
|
||||||
echo " → Connect to WiFi network: LEDMatrix-Setup"
|
echo " → Connect to WiFi network: LEDMatrix-Setup"
|
||||||
echo " → Password: ledmatrix123"
|
echo " → Open network, no password"
|
||||||
echo " → Access web UI at: http://192.168.4.1:5000"
|
echo " → Access web UI at: http://192.168.4.1:5000"
|
||||||
AP_MODE_ACTIVE=true
|
AP_MODE_ACTIVE=true
|
||||||
else
|
else
|
||||||
@@ -2147,3 +2144,22 @@ echo " - Main config: $PROJECT_ROOT_DIR/config/config.json"
|
|||||||
echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json"
|
echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Enjoy your LED Matrix display!"
|
echo "Enjoy your LED Matrix display!"
|
||||||
|
|
||||||
|
# Reboot last. It used to come before the summary above, so with -y (and
|
||||||
|
# the one-shot installer, which always passes -y) the reboot was already
|
||||||
|
# under way while the summary printed, and the SSH session usually dropped
|
||||||
|
# before any of it -- the web UI address included -- could be read.
|
||||||
|
echo ""
|
||||||
|
if [ "$SKIP_REBOOT_PROMPT" = "1" ]; then
|
||||||
|
echo "Skipping reboot prompt as requested (--no-reboot-prompt)."
|
||||||
|
elif [ "$ASSUME_YES" = "1" ]; then
|
||||||
|
echo "Non-interactive mode: rebooting now to apply changes..."
|
||||||
|
reboot
|
||||||
|
else
|
||||||
|
read -p "A reboot is recommended to apply kernel and audio changes. Reboot now? (y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Rebooting now..."
|
||||||
|
reboot
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Scripts
|
||||||
|
|
||||||
|
Helper scripts for installing, repairing, diagnosing and developing
|
||||||
|
LEDMatrix. Most users only ever run the one-shot installer (see the project
|
||||||
|
README); everything else here is for troubleshooting or development.
|
||||||
|
|
||||||
|
Status key: **keep** — part of install/runtime or referenced by docs, CI,
|
||||||
|
tests or code; **dev-only** — for plugin/core development, not needed on a
|
||||||
|
display; **diagnostic** — run by hand on a Pi when something is wrong.
|
||||||
|
|
||||||
|
## Directories
|
||||||
|
|
||||||
|
| Directory | Status | What it holds |
|
||||||
|
|---|---|---|
|
||||||
|
| [`install/`](install/README.md) | keep | The installers: one-shot, services, sudoers/WiFi permissions, cache setup, and the shared `lib_*.sh` helpers `first_time_install.sh` sources |
|
||||||
|
| [`fix_perms/`](fix_perms/README.md) | keep | Permission repair scripts, plus the two root helpers the web interface runs through sudo (`safe_plugin_rm.sh`, `safe_pip_install.sh`) |
|
||||||
|
| [`utils/`](utils/README.md) | keep | Scripts run by systemd units or the web interface (conditional web start, WiFi monitor, update verify, DNS fix, Pixlet config editor, cache clearing) |
|
||||||
|
| [`dev/`](dev/README.md) | dev-only | Plugin linking, emulator runner, Vegas density audit, Pillow smoke test |
|
||||||
|
| `templates/` | dev-only | `dev_preview.html`, the page `dev_server.py` serves |
|
||||||
|
|
||||||
|
## Top-level scripts
|
||||||
|
|
||||||
|
| Script | Status | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `build_rgbmatrix_nogil.sh` | keep | Rebuilds the rgbmatrix Python binding so `SwapOnVSync` releases the GIL (docs/SCROLL_PERFORMANCE.md) |
|
||||||
|
| `check_plugin.py` | dev-only | Renders a plugin across every mode and matrix size and fails on crashes, overflow or golden-image drift |
|
||||||
|
| `check_release_version.py` | keep | Checks a release tag, CHANGELOG and `src.__version__` agree (release-version-check workflow) |
|
||||||
|
| `check_system_compatibility.sh` | diagnostic | Pre-install check of hardware, OS (Trixie only), kernel, Python, packages, disk and network |
|
||||||
|
| `dev_server.py` | dev-only | Browser preview server for plugins without the display loop (http://localhost:5001) |
|
||||||
|
| `diagnose_dependencies.sh` | diagnostic | Investigates pip installs stuck on "Preparing metadata" |
|
||||||
|
| `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 |
|
||||||
|
| `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_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 |
|
||||||
|
| `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in |
|
||||||
|
| `update_plugin_repos.py` | dev-only | Pulls the latest `ledmatrix-plugins` monorepo |
|
||||||
|
| `verify_installation.sh` | diagnostic | Checks that an installation completed correctly |
|
||||||
|
| `verify_wifi_setup.sh` | diagnostic | Health check of the WiFi management setup |
|
||||||
|
|
||||||
|
## Candidates for removal
|
||||||
|
|
||||||
|
Nothing in the repo (docs, CI, tests, other scripts or code) refers to these.
|
||||||
|
They are kept for now; each one needs an owner decision before it goes.
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `add_defaults_to_schemas.py` | One-off: adds missing `default` values to plugin config schemas |
|
||||||
|
| `analyze_plugin_schemas.py` | One-off: reports duplicate/inconsistent fields across plugin schemas |
|
||||||
|
| `audit_plugins.py` | AST security audit of plugin code; says it is "designed to run in CI" but no workflow runs it |
|
||||||
|
| `audit_render_path.py` | Finds blocking calls reachable from a plugin's `display()` |
|
||||||
|
| `sports_scroll_check.py` | Drives a sports scoreboard scroll on the panel and reports its pacing |
|
||||||
|
| `test_captive_portal.sh` | Tests the captive portal from a device connected to the AP |
|
||||||
|
| `verify_wifi_before_testing.sh` | Pre-flight check before unplugging Ethernet to test WiFi |
|
||||||
|
| `dev/test_pillow_compat.py` | Pillow API smoke test to run after upgrading Pillow |
|
||||||
@@ -28,12 +28,12 @@ print_success() {
|
|||||||
|
|
||||||
print_warning() {
|
print_warning() {
|
||||||
echo -e "${YELLOW}⚠${NC} $1"
|
echo -e "${YELLOW}⚠${NC} $1"
|
||||||
((WARNINGS++))
|
WARNINGS=$((WARNINGS + 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
print_error() {
|
print_error() {
|
||||||
echo -e "${RED}✗${NC} $1"
|
echo -e "${RED}✗${NC} $1"
|
||||||
((COMPATIBILITY_ISSUES++))
|
COMPATIBILITY_ISSUES=$((COMPATIBILITY_ISSUES + 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if running on Raspberry Pi
|
# Check if running on Raspberry Pi
|
||||||
@@ -61,20 +61,18 @@ if [ -f /etc/os-release ]; then
|
|||||||
echo "OS: $PRETTY_NAME"
|
echo "OS: $PRETTY_NAME"
|
||||||
echo "Version ID: ${VERSION_ID:-unknown}"
|
echo "Version ID: ${VERSION_ID:-unknown}"
|
||||||
|
|
||||||
|
# first_time_install.sh refuses anything but Raspberry Pi OS / Debian 13
|
||||||
|
# (Trixie), so anything else is an error here too, not a warning.
|
||||||
if [[ "$ID" == "raspbian" ]] || [[ "$ID" == "debian" ]]; then
|
if [[ "$ID" == "raspbian" ]] || [[ "$ID" == "debian" ]]; then
|
||||||
if [ "${VERSION_ID:-0}" -ge "12" ]; then
|
if [ "${VERSION_ID:-0}" = "13" ]; then
|
||||||
print_success "Running compatible Debian/Raspbian version (${VERSION_ID})"
|
print_success "Detected Debian 13 Trixie - supported"
|
||||||
|
elif [ "${VERSION_ID:-0}" = "12" ]; then
|
||||||
if [ "${VERSION_ID:-0}" -eq "13" ]; then
|
print_error "Debian 12 Bookworm is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
||||||
print_success "Detected Debian 13 Trixie - full compatibility expected"
|
else
|
||||||
elif [ "${VERSION_ID:-0}" -eq "12" ]; then
|
print_error "Debian/Raspbian ${VERSION_ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
||||||
print_success "Detected Debian 12 Bookworm - full compatibility confirmed"
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
print_warning "Old Debian/Raspbian version (${VERSION_ID}) - upgrade recommended"
|
print_error "${ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
||||||
fi
|
|
||||||
else
|
|
||||||
print_warning "Not running Debian/Raspbian - compatibility not guaranteed"
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
print_error "Could not detect OS version"
|
print_error "Could not detect OS version"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ This directory contains scripts and utilities for development and testing.
|
|||||||
|
|
||||||
- **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories
|
- **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories
|
||||||
- **`run_emulator.sh`** - Runs the LED Matrix display in emulator mode (for development without hardware)
|
- **`run_emulator.sh`** - Runs the LED Matrix display in emulator mode (for development without hardware)
|
||||||
|
- **`vegas_audit.py`** - Measures how much of the Vegas ticker strip actually shows content (dead-frame ratio)
|
||||||
|
- **`test_pillow_compat.py`** - Pillow API smoke test to run after upgrading Pillow (`python3 scripts/dev/test_pillow_compat.py`)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
+17
-10
@@ -18,21 +18,26 @@ system user.
|
|||||||
permissions on the `assets/` tree so plugins can download and cache
|
permissions on the `assets/` tree so plugins can download and cache
|
||||||
team logos, fonts, and other static content.
|
team logos, fonts, and other static content.
|
||||||
|
|
||||||
- **`fix_cache_permissions.sh`** — Creates (if missing) and fixes
|
- **`fix_cache_permissions.sh`** — Restores `/var/cache/ledmatrix/` to the
|
||||||
permissions on `/var/cache/ledmatrix/` and `~/.ledmatrix_cache/` of the
|
shared `ledmatrix`-group setup by running
|
||||||
user running `sudo`, and creates
|
`scripts/install/setup_cache.sh` (the same script the installer uses),
|
||||||
`/var/cache/ledmatrix/placeholder_logos/` for the sports plugins. It does
|
and creates/fixes `~/.ledmatrix_cache/` of the user running `sudo`. It
|
||||||
not touch the cache manager's other fallbacks (`/opt/ledmatrix/cache`,
|
does not touch the cache manager's other fallbacks
|
||||||
`$TMPDIR/ledmatrix_cache`).
|
(`/opt/ledmatrix/cache`, `$TMPDIR/ledmatrix_cache`).
|
||||||
|
|
||||||
- **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins
|
- **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins
|
||||||
directory so both the root display service and the web service user
|
directory so both the root display service and the web service user
|
||||||
can read and write plugin files (manifests, configs, requirements
|
can read and write plugin files (manifests, configs, requirements
|
||||||
installs).
|
installs).
|
||||||
|
|
||||||
- **`fix_web_permissions.sh`** — Fixes permissions on log files,
|
- **`fix_web_permissions.sh`** — Adds you to the `systemd-journal` and
|
||||||
systemd journal access, and the sudoers entries the web interface
|
`adm` groups so the web UI can read logs, and makes the project
|
||||||
needs to control the display service.
|
directory yours again, keeping the root-owned sudo helpers
|
||||||
|
(`safe_plugin_rm.sh`, `safe_pip_install.sh`) and `config_secrets.json`
|
||||||
|
the way the installer leaves them. Run it as the web interface's user,
|
||||||
|
**without** `sudo` (it refuses to run as root and calls `sudo` itself).
|
||||||
|
It does not write sudoers rules; that is
|
||||||
|
`scripts/install/configure_web_sudo.sh`.
|
||||||
|
|
||||||
- **`safe_pip_install.sh`** — Installs a `requirements.txt` as root
|
- **`safe_pip_install.sh`** — Installs a `requirements.txt` as root
|
||||||
after checking it is the project's own or one under `plugin-repos/` or
|
after checking it is the project's own or one under `plugin-repos/` or
|
||||||
@@ -67,7 +72,9 @@ Run these scripts only when:
|
|||||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh
|
sudo ./scripts/fix_perms/fix_cache_permissions.sh
|
||||||
sudo ./scripts/fix_perms/fix_assets_permissions.sh
|
sudo ./scripts/fix_perms/fix_assets_permissions.sh
|
||||||
sudo ./scripts/fix_perms/fix_plugin_permissions.sh
|
sudo ./scripts/fix_perms/fix_plugin_permissions.sh
|
||||||
sudo ./scripts/fix_perms/fix_web_permissions.sh
|
|
||||||
|
# Run as the web interface's user, without sudo (it asks for sudo itself)
|
||||||
|
./scripts/fix_perms/fix_web_permissions.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
If you're not sure which one you need, run `fix_cache_permissions.sh`
|
If you're not sure which one you need, run `fix_cache_permissions.sh`
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set permissions to allow read/write for owner, group, and others (for root service user)
|
# 777: read/write for owner, group and every other account. Root (the display
|
||||||
# Note: 777 allows root (service user) to write, which is necessary when service runs as root
|
# service) does not need it -- root ignores mode bits -- so the "other" bits
|
||||||
|
# only matter to accounts that are neither $REAL_USER nor root.
|
||||||
echo "Setting permissions for assets directory..."
|
echo "Setting permissions for assets directory..."
|
||||||
if sudo chmod -R 777 "$ASSETS_DIR"; then
|
if sudo chmod -R 777 "$ASSETS_DIR"; then
|
||||||
echo "✓ Set assets directory permissions to 777 (writable by root service user)"
|
echo "✓ Set assets directory permissions to 777"
|
||||||
else
|
else
|
||||||
echo "✗ Failed to set assets directory permissions"
|
echo "✗ Failed to set assets directory permissions"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -70,8 +71,7 @@ for SPORTS_DIR in "${SPORTS_DIRS[@]}"; do
|
|||||||
echo " - Current permissions:"
|
echo " - Current permissions:"
|
||||||
ls -ld "$FULL_PATH"
|
ls -ld "$FULL_PATH"
|
||||||
|
|
||||||
# Ensure the directory is writable by both the real user and root (service user)
|
# Owned by the real user; 777 as above (root can write here regardless)
|
||||||
# Use 777 permissions to allow root (service) to write, or set group ownership
|
|
||||||
sudo chmod 777 "$FULL_PATH"
|
sudo chmod 777 "$FULL_PATH"
|
||||||
sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH"
|
sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH"
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# LEDMatrix Cache Permissions Fix Script
|
# LEDMatrix Cache Permissions Fix Script
|
||||||
# This script fixes permissions on all known cache directories so they're writable by the daemon or current user
|
#
|
||||||
# Also sets up placeholder logo directories for sports managers
|
# /var/cache/ledmatrix is shared by the display service (root) and the web
|
||||||
|
# interface (your user) through the ledmatrix group: root:ledmatrix, 2775,
|
||||||
|
# files 660. scripts/install/setup_cache.sh is what sets that up (the
|
||||||
|
# installer's Step 2 runs it, and install_web_service.sh keeps the group), so
|
||||||
|
# this script runs it rather than applying a model of its own. It used to set
|
||||||
|
# the directory 777 and re-group it to your own group, replacing the ledmatrix
|
||||||
|
# group everything else relies on.
|
||||||
|
#
|
||||||
|
# It also repairs ~/.ledmatrix_cache, the cache manager's fallback when
|
||||||
|
# /var/cache/ledmatrix is unusable.
|
||||||
|
|
||||||
echo "Fixing LEDMatrix cache directory permissions..."
|
echo "Fixing LEDMatrix cache directory permissions..."
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SETUP_CACHE="$SCRIPT_DIR/../install/setup_cache.sh"
|
||||||
|
|
||||||
# Get the real user (not root when running with sudo)
|
# Get the real user (not root when running with sudo)
|
||||||
REAL_USER=${SUDO_USER:-$USER}
|
REAL_USER=${SUDO_USER:-$USER}
|
||||||
# Resolve the home directory of the real user robustly
|
# Resolve the home directory of the real user robustly
|
||||||
@@ -16,72 +28,36 @@ else
|
|||||||
fi
|
fi
|
||||||
REAL_GROUP=$(id -gn "$REAL_USER")
|
REAL_GROUP=$(id -gn "$REAL_USER")
|
||||||
|
|
||||||
# Known cache directories for LEDMatrix. Use the actual user's home instead of a hard-coded path.
|
echo ""
|
||||||
CACHE_DIRS=(
|
echo "Checking cache directory: /var/cache/ledmatrix"
|
||||||
"/var/cache/ledmatrix"
|
if [ -f "$SETUP_CACHE" ]; then
|
||||||
"$REAL_HOME/.ledmatrix_cache"
|
bash "$SETUP_CACHE"
|
||||||
)
|
else
|
||||||
|
echo " ✗ $SETUP_CACHE not found; /var/cache/ledmatrix left unchanged."
|
||||||
|
fi
|
||||||
|
|
||||||
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
|
CACHE_DIR="$REAL_HOME/.ledmatrix_cache"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Checking cache directory: $CACHE_DIR"
|
echo "Checking cache directory: $CACHE_DIR"
|
||||||
if [ ! -d "$CACHE_DIR" ]; then
|
if [ ! -d "$CACHE_DIR" ]; then
|
||||||
echo " - Directory does not exist. Creating it..."
|
echo " - Directory does not exist. Creating it..."
|
||||||
sudo mkdir -p "$CACHE_DIR"
|
sudo mkdir -p "$CACHE_DIR"
|
||||||
fi
|
|
||||||
echo " - Current permissions:"
|
|
||||||
ls -ld "$CACHE_DIR"
|
|
||||||
echo " - Fixing permissions..."
|
|
||||||
# Make directory writable by services regardless of user context
|
|
||||||
sudo chmod 777 "$CACHE_DIR"
|
|
||||||
sudo chown "$REAL_USER":"$REAL_GROUP" "$CACHE_DIR"
|
|
||||||
echo " - Updated permissions:"
|
|
||||||
ls -ld "$CACHE_DIR"
|
|
||||||
echo " - Testing write access as $REAL_USER..."
|
|
||||||
if sudo -u "$REAL_USER" test -w "$CACHE_DIR"; then
|
|
||||||
echo " ✓ $CACHE_DIR is now writable by $REAL_USER"
|
|
||||||
else
|
|
||||||
echo " ✗ $CACHE_DIR is still not writable by $REAL_USER"
|
|
||||||
fi
|
|
||||||
echo " - Permissions fix complete for $CACHE_DIR."
|
|
||||||
done
|
|
||||||
|
|
||||||
# Set up placeholder logos directory for sports managers
|
|
||||||
echo ""
|
|
||||||
echo "Setting up placeholder logos directory for sports managers..."
|
|
||||||
|
|
||||||
PLACEHOLDER_DIR="/var/cache/ledmatrix/placeholder_logos"
|
|
||||||
if [ ! -d "$PLACEHOLDER_DIR" ]; then
|
|
||||||
echo "Creating placeholder logos directory: $PLACEHOLDER_DIR"
|
|
||||||
sudo mkdir -p "$PLACEHOLDER_DIR"
|
|
||||||
sudo chown "$REAL_USER":"$REAL_GROUP" "$PLACEHOLDER_DIR"
|
|
||||||
sudo chmod 777 "$PLACEHOLDER_DIR"
|
|
||||||
else
|
|
||||||
echo "Placeholder logos directory already exists: $PLACEHOLDER_DIR"
|
|
||||||
sudo chmod 777 "$PLACEHOLDER_DIR"
|
|
||||||
sudo chown "$REAL_USER":"$REAL_GROUP" "$PLACEHOLDER_DIR"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " - Current permissions:"
|
echo " - Current permissions:"
|
||||||
ls -ld "$PLACEHOLDER_DIR"
|
ls -ld "$CACHE_DIR"
|
||||||
|
echo " - Fixing permissions..."
|
||||||
|
sudo chmod 777 "$CACHE_DIR"
|
||||||
|
sudo chown "$REAL_USER":"$REAL_GROUP" "$CACHE_DIR"
|
||||||
|
echo " - Updated permissions:"
|
||||||
|
ls -ld "$CACHE_DIR"
|
||||||
echo " - Testing write access as $REAL_USER..."
|
echo " - Testing write access as $REAL_USER..."
|
||||||
if sudo -u "$REAL_USER" test -w "$PLACEHOLDER_DIR"; then
|
if sudo -u "$REAL_USER" test -w "$CACHE_DIR"; then
|
||||||
echo " ✓ Placeholder logos directory is writable by $REAL_USER"
|
echo " ✓ $CACHE_DIR is now writable by $REAL_USER"
|
||||||
else
|
else
|
||||||
echo " ✗ Placeholder logos directory is not writable by $REAL_USER"
|
echo " ✗ $CACHE_DIR is still not writable by $REAL_USER"
|
||||||
fi
|
|
||||||
|
|
||||||
# Test with daemon user (which the system might run as)
|
|
||||||
if sudo -u daemon test -w "$PLACEHOLDER_DIR" 2>/dev/null; then
|
|
||||||
echo " ✓ Placeholder logos directory is writable by daemon user"
|
|
||||||
else
|
|
||||||
echo " ✗ Placeholder logos directory is not writable by daemon user"
|
|
||||||
fi
|
fi
|
||||||
|
echo " - Permissions fix complete for $CACHE_DIR."
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "All cache directory permission fixes attempted."
|
echo "All cache directory permission fixes attempted."
|
||||||
echo "If you still see errors, check which user is running the LEDMatrix service and ensure it matches the owner above."
|
echo "If you still see errors, check which user is running the LEDMatrix service and ensure it matches the owner above."
|
||||||
echo ""
|
|
||||||
echo "The system will now create placeholder logos in:"
|
|
||||||
echo " $PLACEHOLDER_DIR"
|
|
||||||
echo "This should eliminate the permission denied warnings for sports logos."
|
|
||||||
@@ -51,9 +51,10 @@ fi
|
|||||||
echo "Setting ownership to root:$ACTUAL_USER..."
|
echo "Setting ownership to root:$ACTUAL_USER..."
|
||||||
sudo chown -R root:"$ACTUAL_USER" "$PLUGINS_DIR"
|
sudo chown -R root:"$ACTUAL_USER" "$PLUGINS_DIR"
|
||||||
|
|
||||||
# Set directory permissions (775: rwxrwxr-x)
|
# Set directory permissions (2775: rwxrwsr-x)
|
||||||
# Root: read/write/execute, Group (ACTUAL_USER): read/write/execute, Others: read/execute
|
# Owner (root) and group (ACTUAL_USER): read/write/execute, others: read/execute.
|
||||||
echo "Setting directory permissions to 2775 (rwxrwxr-x + sticky bit)..."
|
# The setgid bit makes new entries inherit the ACTUAL_USER group.
|
||||||
|
echo "Setting directory permissions to 2775 (rwxrwsr-x, setgid)..."
|
||||||
find "$PLUGINS_DIR" -type d -exec sudo chmod 2775 {} \;
|
find "$PLUGINS_DIR" -type d -exec sudo chmod 2775 {} \;
|
||||||
|
|
||||||
# Set file permissions (664: rw-rw-r--)
|
# Set file permissions (664: rw-rw-r--)
|
||||||
@@ -71,7 +72,7 @@ fi
|
|||||||
echo "Setting ownership of plugin-repos to root:$ACTUAL_USER..."
|
echo "Setting ownership of plugin-repos to root:$ACTUAL_USER..."
|
||||||
sudo chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
|
sudo chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
|
||||||
|
|
||||||
echo "Setting plugin-repos directory permissions to 2775 (rwxrwxr-x + sticky bit)..."
|
echo "Setting plugin-repos directory permissions to 2775 (rwxrwsr-x, setgid)..."
|
||||||
find "$PLUGIN_REPOS_DIR" -type d -exec sudo chmod 2775 {} \;
|
find "$PLUGIN_REPOS_DIR" -type d -exec sudo chmod 2775 {} \;
|
||||||
|
|
||||||
echo "Setting plugin-repos file permissions to 664..."
|
echo "Setting plugin-repos file permissions to 664..."
|
||||||
@@ -87,7 +88,7 @@ echo "plugin-repos/:"
|
|||||||
ls -la "$PLUGIN_REPOS_DIR" 2>/dev/null || echo " (empty or not accessible)"
|
ls -la "$PLUGIN_REPOS_DIR" 2>/dev/null || echo " (empty or not accessible)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Permissions summary:"
|
echo "Permissions summary:"
|
||||||
echo "- Root service: Can read/write plugins (for PWM hardware access)"
|
echo "- Root service: Can read/write plugins (as root it needs no permission bits)"
|
||||||
echo "- Web service ($ACTUAL_USER): Can read/write plugins (for installation)"
|
echo "- Web service ($ACTUAL_USER): Can read/write plugins (for installation)"
|
||||||
echo "- Others: Can read plugins"
|
echo "- Others: Can read plugins"
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ echo ""
|
|||||||
echo "This script will:"
|
echo "This script will:"
|
||||||
echo "1. Add the web user to the 'systemd-journal' group for log access"
|
echo "1. Add the web user to the 'systemd-journal' group for log access"
|
||||||
echo "2. Add the web user to the 'adm' group for additional system access"
|
echo "2. Add the web user to the 'adm' group for additional system access"
|
||||||
echo "3. Configure sudoers for passwordless access to system commands"
|
echo "3. Make the project directory yours again, keeping the root-owned sudo"
|
||||||
echo "4. Set proper file permissions"
|
echo " helpers and config_secrets.json as the installer leaves them"
|
||||||
|
echo " (sudoers rules are configure_web_sudo.sh's job, not this script's)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Ask for confirmation
|
# Ask for confirmation
|
||||||
@@ -62,6 +63,51 @@ else
|
|||||||
echo "✗ Failed to set project ownership"
|
echo "✗ Failed to set project ownership"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The chown above also takes back two kinds of file that first_time_install.sh
|
||||||
|
# deliberately keeps from the web user. Put them back the way the installer
|
||||||
|
# leaves them (its Steps 11 and 11.1), whether or not the chown succeeded.
|
||||||
|
#
|
||||||
|
# 1. The helpers /etc/sudoers.d/ledmatrix_web lets the web user run as root
|
||||||
|
# (scripts/install/lib_sudoers.sh). A copy the web user owns is a root shell
|
||||||
|
# for whoever can edit it, so they stay root-owned and writable by root only.
|
||||||
|
# Keep this list in step with the installer's Step 11.1 loop;
|
||||||
|
# test/test_web_sudoers_installers_agree.py checks both against the grants.
|
||||||
|
for helper in safe_plugin_rm.sh safe_pip_install.sh; do
|
||||||
|
HELPER_PATH="$PROJECT_DIR/scripts/fix_perms/$helper"
|
||||||
|
if [ -f "$HELPER_PATH" ]; then
|
||||||
|
if sudo chown root:root "$HELPER_PATH" && sudo chmod 755 "$HELPER_PATH"; then
|
||||||
|
echo "✓ $helper is root-owned again (sudo runs it as root)"
|
||||||
|
else
|
||||||
|
echo "⚠ Could not make $HELPER_PATH root-owned, mode 755."
|
||||||
|
echo " Fix it by hand: sudo chown root:root $HELPER_PATH && sudo chmod 755 $HELPER_PATH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. config_secrets.json: owned by the account ledmatrix-web.service runs as,
|
||||||
|
# group ledmatrix, mode 640 -- the same owner, group and mode as the
|
||||||
|
# installer's Step 11 gives it.
|
||||||
|
SECRETS_FILE="$PROJECT_DIR/config/config_secrets.json"
|
||||||
|
if [ -f "$SECRETS_FILE" ]; then
|
||||||
|
SECRETS_OWNER=""
|
||||||
|
if [ -f /etc/systemd/system/ledmatrix-web.service ]; then
|
||||||
|
SECRETS_OWNER=$(grep -m1 "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || true)
|
||||||
|
fi
|
||||||
|
SECRETS_OWNER="${SECRETS_OWNER:-$WEB_USER}"
|
||||||
|
if getent group ledmatrix >/dev/null 2>&1; then
|
||||||
|
SECRETS_OWNERSHIP="$SECRETS_OWNER:ledmatrix"
|
||||||
|
else
|
||||||
|
# No ledmatrix group means the installer never ran; keep the chown's group.
|
||||||
|
SECRETS_OWNERSHIP="$SECRETS_OWNER"
|
||||||
|
fi
|
||||||
|
if sudo chown "$SECRETS_OWNERSHIP" "$SECRETS_FILE" && sudo chmod 640 "$SECRETS_FILE"; then
|
||||||
|
echo "✓ config_secrets.json restored to $SECRETS_OWNERSHIP, mode 640"
|
||||||
|
else
|
||||||
|
echo "⚠ Could not restore $SECRETS_FILE to $SECRETS_OWNERSHIP, mode 640."
|
||||||
|
echo " Fix it by hand: sudo chown $SECRETS_OWNERSHIP $SECRETS_FILE && sudo chmod 640 $SECRETS_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Set proper permissions for config files
|
# Set proper permissions for config files
|
||||||
if sudo chmod 644 "$PROJECT_DIR/config/config.json" 2>/dev/null; then
|
if sudo chmod 644 "$PROJECT_DIR/config/config.json" 2>/dev/null; then
|
||||||
echo "✓ Set config file permissions"
|
echo "✓ Set config file permissions"
|
||||||
@@ -86,7 +132,7 @@ echo "Step 5: Testing sudo access..."
|
|||||||
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
|
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
|
||||||
echo "✓ Sudo access test passed"
|
echo "✓ Sudo access test passed"
|
||||||
else
|
else
|
||||||
echo "⚠ Sudo access test failed - you may need to run configure_web_sudo.sh"
|
echo "⚠ Sudo access test failed - you may need to run scripts/install/configure_web_sudo.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -101,5 +147,5 @@ echo ""
|
|||||||
echo "After logging back in, test journal access with:"
|
echo "After logging back in, test journal access with:"
|
||||||
echo " journalctl --no-pager --lines=5"
|
echo " journalctl --no-pager --lines=5"
|
||||||
echo ""
|
echo ""
|
||||||
echo "If you still have sudo issues, run:"
|
echo "If you still have sudo issues, run (as this user, without sudo):"
|
||||||
echo " ./configure_web_sudo.sh"
|
echo " $PROJECT_DIR/scripts/install/configure_web_sudo.sh"
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ This directory contains scripts for installing and configuring the LEDMatrix sys
|
|||||||
(the user who runs the script, i.e. the one you installed LEDMatrix as;
|
(the user who runs the script, i.e. the one you installed LEDMatrix as;
|
||||||
there is no `ledmatrix` system user) the passwordless `nmcli` and related
|
there is no `ledmatrix` system user) the passwordless `nmcli` and related
|
||||||
WiFi permissions the web interface needs
|
WiFi permissions the web interface needs
|
||||||
|
- **`install_dns_fix.sh`** - Optional. Installs `ledmatrix-dns-fix.service`,
|
||||||
|
which adds `options single-request` to the resolver when API calls time
|
||||||
|
out (see `systemd/README.md`)
|
||||||
|
- **`install_mqtt_bridge.sh`** - Optional. Installs the Home Assistant MQTT
|
||||||
|
bridge service (see `integrations/mqtt_bridge/README.md`)
|
||||||
|
|
||||||
|
Libraries (sourced, not run):
|
||||||
|
|
||||||
|
- **`lib_sudoers.sh`** - The web interface's sudo allow-list
|
||||||
|
(`/etc/sudoers.d/ledmatrix_web`), shared by `first_time_install.sh` and
|
||||||
|
`configure_web_sudo.sh`
|
||||||
|
- **`lib_systemd_render.sh`** - `sed_escape_replacement`, used by every
|
||||||
|
script that renders a unit from `systemd/*.service`
|
||||||
|
- **`lib_lowmem.sh`** - Build-job sizing and temporary swap for the C++
|
||||||
|
build on low-memory Pis (`first_time_install.sh` Step 6)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ fi
|
|||||||
# Get the full paths to commands and validate each one
|
# Get the full paths to commands and validate each one
|
||||||
MISSING_CMDS=()
|
MISSING_CMDS=()
|
||||||
|
|
||||||
PYTHON_PATH=$(command -v python3) || true
|
|
||||||
SYSTEMCTL_PATH=$(command -v systemctl) || true
|
SYSTEMCTL_PATH=$(command -v systemctl) || true
|
||||||
REBOOT_PATH=$(command -v reboot) || true
|
REBOOT_PATH=$(command -v reboot) || true
|
||||||
POWEROFF_PATH=$(command -v poweroff) || true
|
POWEROFF_PATH=$(command -v poweroff) || true
|
||||||
@@ -35,8 +34,8 @@ JOURNALCTL_PATH=$(command -v journalctl) || true
|
|||||||
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
|
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
|
||||||
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
|
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
|
||||||
|
|
||||||
# Validate required commands (systemctl, bash, python3 are essential)
|
# Validate required commands (systemctl and bash are essential)
|
||||||
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH; do
|
||||||
CMD_VAL="${!CMD_NAME}"
|
CMD_VAL="${!CMD_NAME}"
|
||||||
if [ -z "$CMD_VAL" ]; then
|
if [ -z "$CMD_VAL" ]; then
|
||||||
MISSING_CMDS+=("$CMD_NAME")
|
MISSING_CMDS+=("$CMD_NAME")
|
||||||
@@ -70,7 +69,6 @@ fi
|
|||||||
. "$SUDOERS_LIB"
|
. "$SUDOERS_LIB"
|
||||||
|
|
||||||
echo "Command paths:"
|
echo "Command paths:"
|
||||||
echo " Python: $PYTHON_PATH"
|
|
||||||
echo " Systemctl: $SYSTEMCTL_PATH"
|
echo " Systemctl: $SYSTEMCTL_PATH"
|
||||||
echo " Reboot: ${REBOOT_PATH:-(not found, skipping)}"
|
echo " Reboot: ${REBOOT_PATH:-(not found, skipping)}"
|
||||||
echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
|
echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
|
||||||
@@ -79,14 +77,24 @@ echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
|
|||||||
echo " Safe plugin rm: $SAFE_RM_PATH"
|
echo " Safe plugin rm: $SAFE_RM_PATH"
|
||||||
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
|
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
|
||||||
|
|
||||||
# Create a temporary sudoers file
|
# Create a temporary sudoers file. A predictable name in a world-writable
|
||||||
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
# directory is a symlink target, and these rules end up in /etc/sudoers.d, so
|
||||||
|
# let mktemp pick the name; the trap removes it however the script ends.
|
||||||
|
TEMP_SUDOERS=$(mktemp "${TMPDIR:-/tmp}/ledmatrix_web_sudoers.XXXXXX") || {
|
||||||
|
echo "Error: could not create a temporary file" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
trap 'rm -f "$TEMP_SUDOERS"' EXIT
|
||||||
|
|
||||||
web_sudoers_rules "$WEB_USER" "$PROJECT_ROOT" "$SYSTEMCTL_PATH" "$BASH_PATH" \
|
web_sudoers_rules "$WEB_USER" "$PROJECT_ROOT" "$SYSTEMCTL_PATH" "$BASH_PATH" \
|
||||||
"$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$TEMP_SUDOERS"
|
"$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$TEMP_SUDOERS"
|
||||||
|
|
||||||
# Never offer to install rules we have not parsed. A malformed drop-in in
|
# Never offer to install rules we have not parsed. A malformed drop-in in
|
||||||
# /etc/sudoers.d makes sudo refuse every command for every user.
|
# /etc/sudoers.d makes sudo refuse every command for every user.
|
||||||
|
# visudo lives in /usr/sbin, which is not on every user's PATH.
|
||||||
|
if ! command -v visudo >/dev/null 2>&1 && [ -x /usr/sbin/visudo ]; then
|
||||||
|
PATH="$PATH:/usr/sbin"
|
||||||
|
fi
|
||||||
if command -v visudo >/dev/null 2>&1; then
|
if command -v visudo >/dev/null 2>&1; then
|
||||||
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
|
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
|
||||||
echo ""
|
echo ""
|
||||||
@@ -96,6 +104,8 @@ if command -v visudo >/dev/null 2>&1; then
|
|||||||
rm -f "$TEMP_SUDOERS"
|
rm -f "$TEMP_SUDOERS"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠ visudo not found; the rules below have not been validated"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -124,7 +134,7 @@ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Apply the configuration using visudo
|
# Apply the configuration
|
||||||
echo "Applying sudoers configuration..."
|
echo "Applying sudoers configuration..."
|
||||||
# Harden the helper script: root-owned, not writable by web user
|
# Harden the helper script: root-owned, not writable by web user
|
||||||
echo "Hardening safe_plugin_rm.sh ownership..."
|
echo "Hardening safe_plugin_rm.sh ownership..."
|
||||||
@@ -143,21 +153,29 @@ if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
||||||
|
# sudo reads /etc/sudoers.d files that are root-owned and not writable by
|
||||||
|
# group or other; 440 is the mode visudo and first_time_install.sh use.
|
||||||
|
if ! sudo chmod 440 /etc/sudoers.d/ledmatrix_web; then
|
||||||
|
echo "Warning: could not set mode 440 on /etc/sudoers.d/ledmatrix_web"
|
||||||
|
fi
|
||||||
echo "Configuration applied successfully!"
|
echo "Configuration applied successfully!"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Testing sudo access..."
|
echo "Testing sudo access..."
|
||||||
|
|
||||||
# Test a few commands
|
# Ask sudo whether two of the new rules let this user in without a
|
||||||
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
|
# password. `sudo -l CMD` answers from the rules without running CMD, so
|
||||||
|
# this does not depend on whether ledmatrix.service is running, and it
|
||||||
|
# tests commands the rules actually grant.
|
||||||
|
if sudo -n -l "$SYSTEMCTL_PATH" status ledmatrix.service > /dev/null 2>&1; then
|
||||||
echo "✓ systemctl status ledmatrix.service - OK"
|
echo "✓ systemctl status ledmatrix.service - OK"
|
||||||
else
|
else
|
||||||
echo "✗ systemctl status ledmatrix.service - Failed"
|
echo "✗ systemctl status ledmatrix.service - not allowed without a password"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then
|
if sudo -n -l "$BASH_PATH" "$SAFE_RM_PATH" "$PROJECT_ROOT/plugin-repos/example" > /dev/null 2>&1; then
|
||||||
echo "✓ File access test - OK"
|
echo "✓ safe_plugin_rm.sh helper - OK"
|
||||||
else
|
else
|
||||||
echo "✗ File access test - Failed"
|
echo "✗ safe_plugin_rm.sh helper - not allowed without a password"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ $WEB_USER ALL=(ALL) NOPASSWD: $MKDIR_PATH -p /etc/NetworkManager/dnsmasq-shared.
|
|||||||
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
|
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
|
||||||
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf
|
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf
|
||||||
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf
|
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf
|
||||||
|
# The same captive-portal DNS drop-in for NetworkManager's shared-mode dnsmasq
|
||||||
|
# (wifi_manager._write_nm_dnsmasq_captive_conf / _remove_nm_dnsmasq_captive_conf),
|
||||||
|
# exact paths.
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/ledmatrix-nm-dnsmasq.conf /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "Generated sudoers configuration:"
|
echo "Generated sudoers configuration:"
|
||||||
@@ -151,6 +156,21 @@ echo "--------------------------------"
|
|||||||
cat "$TEMP_SUDOERS"
|
cat "$TEMP_SUDOERS"
|
||||||
echo "--------------------------------"
|
echo "--------------------------------"
|
||||||
|
|
||||||
|
# Never install rules we have not parsed. A malformed drop-in in
|
||||||
|
# /etc/sudoers.d makes sudo refuse every command for every user, which on a
|
||||||
|
# headless Pi leaves no way in at all. first_time_install.sh and
|
||||||
|
# configure_web_sudo.sh check their rules the same way.
|
||||||
|
if command -v visudo >/dev/null 2>&1; then
|
||||||
|
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
|
||||||
|
echo "✗ The generated sudoers rules did not parse:" >&2
|
||||||
|
visudo -c -f "$TEMP_SUDOERS" >&2 || true
|
||||||
|
echo " Leaving $SUDOERS_FILE unchanged." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠ visudo not found; installing the sudoers rules unvalidated"
|
||||||
|
fi
|
||||||
|
|
||||||
# Apply the sudoers configuration
|
# Apply the sudoers configuration
|
||||||
echo ""
|
echo ""
|
||||||
echo "Applying sudoers configuration..."
|
echo "Applying sudoers configuration..."
|
||||||
@@ -213,11 +233,14 @@ rm -f "$TEMP_POLKIT"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Step 3: Testing permissions..."
|
echo "Step 3: Testing permissions..."
|
||||||
|
|
||||||
# Test sudo access
|
# Ask sudo whether one of the new rules lets this user in without a password.
|
||||||
if sudo -n "$NMCLI_PATH" device status > /dev/null 2>&1; then
|
# `sudo -l CMD` answers from the rules without running CMD, so the radio is
|
||||||
echo "✓ nmcli device status - OK"
|
# left alone. (This used to run `nmcli device status`, which is not granted,
|
||||||
|
# so it could only ever report a failure.)
|
||||||
|
if sudo -n -l "$NMCLI_PATH" radio wifi on > /dev/null 2>&1; then
|
||||||
|
echo "✓ nmcli radio wifi on - OK"
|
||||||
else
|
else
|
||||||
echo "✗ nmcli device status - Failed (this is expected if not connected)"
|
echo "✗ nmcli radio wifi on - not allowed without a password"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
SERVICE_NAME="ledmatrix-dns-fix"
|
SERVICE_NAME="ledmatrix-dns-fix"
|
||||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
||||||
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
|
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
|
||||||
@@ -34,7 +38,8 @@ fi
|
|||||||
chmod +x "$PROJECT_ROOT_DIR/scripts/utils/apply_dns_single_request.sh"
|
chmod +x "$PROJECT_ROOT_DIR/scripts/utils/apply_dns_single_request.sh"
|
||||||
|
|
||||||
echo "Installing $UNIT_DEST..."
|
echo "Installing $UNIT_DEST..."
|
||||||
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||||
|
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
||||||
| $SUDO tee "$UNIT_DEST" > /dev/null
|
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||||
|
|
||||||
# Order ledmatrix.service after the fix. `Before=` in the unit itself only
|
# Order ledmatrix.service after the fix. `Before=` in the unit itself only
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
|
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
|
||||||
SERVICE_NAME="ledmatrix-mqtt-bridge"
|
SERVICE_NAME="ledmatrix-mqtt-bridge"
|
||||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
||||||
@@ -40,7 +44,8 @@ python3 -m pip install -r "$BRIDGE_DIR/requirements.txt" 2>/dev/null \
|
|||||||
|| python3 -m pip install --break-system-packages -r "$BRIDGE_DIR/requirements.txt"
|
|| python3 -m pip install --break-system-packages -r "$BRIDGE_DIR/requirements.txt"
|
||||||
|
|
||||||
echo "Installing $UNIT_DEST..."
|
echo "Installing $UNIT_DEST..."
|
||||||
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||||
|
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
||||||
| $SUDO tee "$UNIT_DEST" > /dev/null
|
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||||
|
|
||||||
$SYSTEMCTL_CMD daemon-reload
|
$SYSTEMCTL_CMD daemon-reload
|
||||||
|
|||||||
@@ -51,20 +51,25 @@ if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
|
|||||||
|
|
||||||
# Install packages automatically (no prompt)
|
# Install packages automatically (no prompt)
|
||||||
# Use apt directly if running as root, otherwise use sudo
|
# Use apt directly if running as root, otherwise use sudo
|
||||||
|
PACKAGES_OK=true
|
||||||
if [ "$EUID" -eq 0 ]; then
|
if [ "$EUID" -eq 0 ]; then
|
||||||
apt update || echo "⚠ apt update failed, continuing anyway..."
|
apt update || echo "⚠ apt update failed, continuing anyway..."
|
||||||
apt install -y "${MISSING_PACKAGES[@]}" || {
|
apt install -y "${MISSING_PACKAGES[@]}" || {
|
||||||
|
PACKAGES_OK=false
|
||||||
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
|
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
|
||||||
echo " You may need to install packages manually: apt install -y ${MISSING_PACKAGES[*]}"
|
echo " You may need to install packages manually: apt install -y ${MISSING_PACKAGES[*]}"
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
sudo apt update || echo "⚠ apt update failed, continuing anyway..."
|
sudo apt update || echo "⚠ apt update failed, continuing anyway..."
|
||||||
sudo apt install -y "${MISSING_PACKAGES[@]}" || {
|
sudo apt install -y "${MISSING_PACKAGES[@]}" || {
|
||||||
|
PACKAGES_OK=false
|
||||||
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
|
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
|
||||||
echo " You may need to install packages manually: sudo apt install -y ${MISSING_PACKAGES[*]}"
|
echo " You may need to install packages manually: sudo apt install -y ${MISSING_PACKAGES[*]}"
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
if [ "$PACKAGES_OK" = true ]; then
|
||||||
echo "✓ Package installation completed"
|
echo "✓ Package installation completed"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Render the unit from systemd/ledmatrix-wifi-monitor.service rather than
|
# Render the unit from systemd/ledmatrix-wifi-monitor.service rather than
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
#
|
#
|
||||||
# Shared helper for rendering systemd unit templates via sed.
|
# Shared helper for rendering systemd unit templates via sed.
|
||||||
#
|
#
|
||||||
# Sourced by install_service.sh, install_web_service.sh and
|
# Sourced by install_service.sh, install_web_service.sh,
|
||||||
# install_wifi_monitor.sh so all three escape sed replacement text the same
|
# install_wifi_monitor.sh, install_dns_fix.sh and install_mqtt_bridge.sh so
|
||||||
# way instead of carrying three copies of the same fix.
|
# every unit renderer escapes sed replacement text the same way instead of
|
||||||
|
# carrying its own copy of the fix.
|
||||||
|
|
||||||
# sed_escape_replacement VALUE
|
# sed_escape_replacement VALUE
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -205,27 +205,6 @@ check_sudo() {
|
|||||||
print_success "Sudo access confirmed"
|
print_success "Sudo access confirmed"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fix /tmp permissions if needed (common issue when running via curl | bash)
|
|
||||||
# Note: /tmp permission fixing is now done inline before running first_time_install.sh
|
|
||||||
# This function is kept for backward compatibility but not actively used
|
|
||||||
fix_tmp_permissions() {
|
|
||||||
CURRENT_STEP="TMP directory check"
|
|
||||||
# Only fix if /tmp is actually not writable (don't preemptively fix)
|
|
||||||
if [ ! -w /tmp ]; then
|
|
||||||
print_warning "/tmp is not writable, attempting to fix..."
|
|
||||||
if [ "$EUID" -eq 0 ]; then
|
|
||||||
chmod 1777 /tmp 2>/dev/null || true
|
|
||||||
else
|
|
||||||
sudo chmod 1777 /tmp 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure TMPDIR is set correctly
|
|
||||||
if [ -z "${TMPDIR:-}" ] || [ ! -w "${TMPDIR:-/tmp}" ]; then
|
|
||||||
export TMPDIR=/tmp
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main installation function
|
# Main installation function
|
||||||
main() {
|
main() {
|
||||||
print_step "LED Matrix One-Shot Installation"
|
print_step "LED Matrix One-Shot Installation"
|
||||||
@@ -429,6 +408,13 @@ main() {
|
|||||||
print_step "Installation Complete!"
|
print_step "Installation Complete!"
|
||||||
print_success "LED Matrix has been successfully installed!"
|
print_success "LED Matrix has been successfully installed!"
|
||||||
echo ""
|
echo ""
|
||||||
|
# first_time_install.sh -y reboots as its last action, so by now the
|
||||||
|
# reboot is under way (unless LEDMATRIX_SKIP_REBOOT_PROMPT=1 was set).
|
||||||
|
if [ "${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}" != "1" ]; then
|
||||||
|
echo "The installer has just started a reboot to finish setup, so this"
|
||||||
|
echo "session may disconnect now. Give the Pi a few minutes to come back, then:"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
echo " 1. Configure your settings: sudo nano $REPO_DIR/config/config.json"
|
echo " 1. Configure your settings: sudo nano $REPO_DIR/config/config.json"
|
||||||
if command -v hostname >/dev/null 2>&1; then
|
if command -v hostname >/dev/null 2>&1; then
|
||||||
@@ -449,7 +435,7 @@ main() {
|
|||||||
else
|
else
|
||||||
echo " 2. Or use the web interface: http://<your-pi-ip>:5000"
|
echo " 2. Or use the web interface: http://<your-pi-ip>:5000"
|
||||||
fi
|
fi
|
||||||
echo " 3. Start the service: sudo systemctl start ledmatrix.service"
|
echo " 3. The display service starts on boot; to start it by hand: sudo systemctl start ledmatrix.service"
|
||||||
echo ""
|
echo ""
|
||||||
else
|
else
|
||||||
print_error "Main installation script exited with code $INSTALL_EXIT_CODE"
|
print_error "Main installation script exited with code $INSTALL_EXIT_CODE"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ This directory contains utility scripts for maintenance and system operations.
|
|||||||
- **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode
|
- **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode
|
||||||
- **`pixlet_config_editor.sh`** - Opens Pixlet's own config UI for one installed Starlark app
|
- **`pixlet_config_editor.sh`** - Opens Pixlet's own config UI for one installed Starlark app
|
||||||
- **`apply_dns_single_request.sh`** - Adds `options single-request` to the resolver (run by `ledmatrix-dns-fix.service`)
|
- **`apply_dns_single_request.sh`** - Adds `options single-request` to the resolver (run by `ledmatrix-dns-fix.service`)
|
||||||
|
- **`auto_update_verify.py`** - Health check after an automatic update, rolling back if it fails (the updater copies it to `data/` before pulling and `ledmatrix-update-verify.service` runs that copy)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -29,13 +29,9 @@ from typing import Any, Optional, Tuple
|
|||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
# The one Pillow >= 9.1 compat shim (replaces the per-plugin copies).
|
# Re-exported by src.common for plugins, which import them from there.
|
||||||
try:
|
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
|
||||||
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
|
RESAMPLE_NEAREST = Image.Resampling.NEAREST
|
||||||
RESAMPLE_NEAREST = Image.Resampling.NEAREST
|
|
||||||
except AttributeError: # Pillow < 9.1
|
|
||||||
RESAMPLE_LANCZOS = Image.LANCZOS
|
|
||||||
RESAMPLE_NEAREST = Image.NEAREST
|
|
||||||
|
|
||||||
FIT_MODES = ("contain", "cover", "fill_height", "stretch")
|
FIT_MODES = ("contain", "cover", "fill_height", "stretch")
|
||||||
|
|
||||||
|
|||||||
@@ -131,19 +131,14 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
# Thread management
|
# Thread management
|
||||||
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData")
|
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData")
|
||||||
# cache_key -> request_id for fetches currently in flight. Submitting
|
# cache_key -> request_id for fetches currently in flight, so a second
|
||||||
# the same key twice used to start two identical fetches: request_id
|
# submit for the same key joins the running fetch instead of starting
|
||||||
# carries a millisecond timestamp, so every submit looked new, and
|
# another. It is the normal case: a sport's Recent and Upcoming
|
||||||
# active_requests is keyed by it rather than by what is being fetched.
|
# managers miss the cache for the same season schedule together.
|
||||||
# On a real board the season-schedule key is requested by both the
|
|
||||||
# Recent and the Upcoming manager, which miss the cache in the same
|
|
||||||
# millisecond and each download and parse the same payload.
|
|
||||||
self._inflight_by_cache_key: Dict[str, str] = {}
|
self._inflight_by_cache_key: Dict[str, str] = {}
|
||||||
# request_id was sport_year_milliseconds, which is not unique: two
|
# Makes every request_id unique. The id also carries a millisecond
|
||||||
# submits inside the same millisecond produced the SAME id, so one
|
# timestamp, but two submits can share a millisecond, and a joiner
|
||||||
# silently replaced the other in active_requests and completed_requests.
|
# uses the id as its handle for get_result().
|
||||||
# Rare before, but dedupe hands this id back to every joiner as their
|
|
||||||
# handle for get_result(), so it has to be unique. A counter is enough.
|
|
||||||
self._request_seq = itertools.count()
|
self._request_seq = itertools.count()
|
||||||
self.active_requests: Dict[str, FetchRequest] = {}
|
self.active_requests: Dict[str, FetchRequest] = {}
|
||||||
self.completed_requests: Dict[str, FetchResult] = {}
|
self.completed_requests: Dict[str, FetchResult] = {}
|
||||||
@@ -186,9 +181,9 @@ class BackgroundDataService:
|
|||||||
This ensures Recent/Upcoming managers and background service
|
This ensures Recent/Upcoming managers and background service
|
||||||
use the same cache keys.
|
use the same cache keys.
|
||||||
"""
|
"""
|
||||||
# Same format as CacheManager.generate_sport_cache_key(). This used to
|
# Same format as CacheManager.generate_sport_cache_key(), built here
|
||||||
# build a whole CacheManager to call it -- config load, cache-dir
|
# rather than by constructing a CacheManager (config load, cache-dir
|
||||||
# probing with test writes -- on every submit without a cache_key.
|
# probing) on every submit without a cache_key.
|
||||||
if date_str is None:
|
if date_str is None:
|
||||||
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
|
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
|
||||||
return f"{sport}_{date_str}"
|
return f"{sport}_{date_str}"
|
||||||
@@ -331,10 +326,8 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# A request cancelled while it sat in the executor queue must
|
# A request cancelled while it sat in the executor queue stays
|
||||||
# stay cancelled. Overwriting the status here undid the cancel
|
# cancelled: no download, no cache write, no callback.
|
||||||
# outright: the worker went on to download, cache and call back
|
|
||||||
# for work the caller had already withdrawn.
|
|
||||||
if request.status == FetchStatus.CANCELLED:
|
if request.status == FetchStatus.CANCELLED:
|
||||||
cancelled_before_start = True
|
cancelled_before_start = True
|
||||||
else:
|
else:
|
||||||
@@ -463,10 +456,9 @@ class BackgroundDataService:
|
|||||||
logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}")
|
logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}")
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Don't relabel a cancelled request. The callback gate in the
|
# A cancelled request stays CANCELLED even when its fetch
|
||||||
# finally block only suppresses CANCELLED, so promoting it to
|
# failed: the finally block skips callbacks only for
|
||||||
# FAILED here delivered an error callback for a fetch nobody
|
# CANCELLED, and nobody is waiting on this fetch any more.
|
||||||
# was waiting on any more.
|
|
||||||
if request.status != FetchStatus.CANCELLED:
|
if request.status != FetchStatus.CANCELLED:
|
||||||
request.status = FetchStatus.FAILED
|
request.status = FetchStatus.FAILED
|
||||||
request.error = error_msg
|
request.error = error_msg
|
||||||
@@ -526,20 +518,13 @@ class BackgroundDataService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in callback for request {request.id}: {e}")
|
logger.error(f"Error in callback for request {request.id}: {e}")
|
||||||
|
|
||||||
# Released AFTER the loop, not inside it. Every callback here holds
|
# Released after the loop, never inside it: every callback holds
|
||||||
# the same FetchResult, so releasing per-delivery handed the first
|
# the same FetchResult (a sport's recent, upcoming and live
|
||||||
# one the data and every joiner `result.data is None` -- which is
|
# managers usually share one fetch), so a release between
|
||||||
# not a quiet degradation: they read `result.data.get('events')` and
|
# deliveries would hand the later ones `result.data is None`.
|
||||||
# raise AttributeError, which this very loop catches and logs, so
|
|
||||||
# the symptom was one ERROR line and a manager that silently never
|
|
||||||
# got its schedule. Deduplication is the normal case, not a corner:
|
|
||||||
# a sport's recent, upcoming and live managers all ride one season
|
|
||||||
# fetch.
|
|
||||||
#
|
#
|
||||||
# Guarded on `callbacks`, because a request submitted without one
|
# Only when there were callbacks: a request submitted without one
|
||||||
# has no other way to collect its payload than polling get_result().
|
# collects its payload by polling get_result().
|
||||||
# The old per-delivery release got that right by accident: an empty
|
|
||||||
# list never entered the loop body.
|
|
||||||
if callbacks:
|
if callbacks:
|
||||||
self._release_payload(result)
|
self._release_payload(result)
|
||||||
request.result = None
|
request.result = None
|
||||||
@@ -721,9 +706,6 @@ class BackgroundDataService:
|
|||||||
'completed_requests_count': len(self.completed_requests),
|
'completed_requests_count': len(self.completed_requests),
|
||||||
'max_completed_requests': self._max_completed_requests,
|
'max_completed_requests': self._max_completed_requests,
|
||||||
'completed_requests_usage_percent': (len(self.completed_requests) / self._max_completed_requests * 100) if self._max_completed_requests > 0 else 0,
|
'completed_requests_usage_percent': (len(self.completed_requests) / self._max_completed_requests * 100) if self._max_completed_requests > 0 else 0,
|
||||||
# Nothing is queued outside the executor; kept for callers
|
|
||||||
# that read the key.
|
|
||||||
'queue_size': 0,
|
|
||||||
'last_cleanup': self._last_completed_requests_cleanup,
|
'last_cleanup': self._last_completed_requests_cleanup,
|
||||||
'cleanup_interval': self._completed_requests_cleanup_interval
|
'cleanup_interval': self._completed_requests_cleanup_interval
|
||||||
}
|
}
|
||||||
@@ -793,27 +775,6 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
return removed_count
|
return removed_count
|
||||||
|
|
||||||
def clear_completed_requests(self, older_than_hours: int = 24):
|
|
||||||
"""
|
|
||||||
Clear completed requests older than specified time.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
older_than_hours: Clear requests older than this many hours
|
|
||||||
"""
|
|
||||||
cutoff_time = time.time() - (older_than_hours * 3600)
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
to_remove = []
|
|
||||||
for request_id, result in self.completed_requests.items():
|
|
||||||
if result.completed_at < cutoff_time:
|
|
||||||
to_remove.append(request_id)
|
|
||||||
|
|
||||||
for request_id in to_remove:
|
|
||||||
del self.completed_requests[request_id]
|
|
||||||
|
|
||||||
if to_remove:
|
|
||||||
logger.info(f"Cleared {len(to_remove)} old completed requests")
|
|
||||||
|
|
||||||
def shutdown(self, wait: bool = True):
|
def shutdown(self, wait: bool = True):
|
||||||
"""
|
"""
|
||||||
Shutdown the background data service.
|
Shutdown the background data service.
|
||||||
|
|||||||
+71
-106
@@ -83,14 +83,25 @@ BUNDLED_FONTS: frozenset[str] = frozenset({
|
|||||||
_CONFIG_REL = Path("config/config.json")
|
_CONFIG_REL = Path("config/config.json")
|
||||||
_SECRETS_REL = Path("config/config_secrets.json")
|
_SECRETS_REL = Path("config/config_secrets.json")
|
||||||
_WIFI_REL = Path("config/wifi_config.json")
|
_WIFI_REL = Path("config/wifi_config.json")
|
||||||
# Sits in config/ next to the three above and is pure user state — a
|
# A YouTube Music session: pure user state that has to be re-authenticated by
|
||||||
# YouTube Music session that has to be re-authenticated by hand if lost.
|
# hand if lost, so a restore must bring it back.
|
||||||
# It was omitted from backups, so a restore silently signed the user out.
|
|
||||||
_YTM_REL = Path("config/ytm_auth.json")
|
_YTM_REL = Path("config/ytm_auth.json")
|
||||||
_FONTS_REL = Path("assets/fonts")
|
_FONTS_REL = Path("assets/fonts")
|
||||||
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
||||||
_STATE_REL = Path("data/plugin_state.json")
|
_STATE_REL = Path("data/plugin_state.json")
|
||||||
|
|
||||||
|
#: The sections that are one file each: (section name, path, the
|
||||||
|
#: RestoreOptions flag that restores it). create, preview, validate and
|
||||||
|
#: restore all walk this table. ytm_auth follows restore_wifi: it is
|
||||||
|
#: device-local auth like the Wi-Fi settings, and a toggle of its own for one
|
||||||
|
#: file would be noise in the restore dialog.
|
||||||
|
_SINGLE_FILE_SECTIONS: Tuple[Tuple[str, Path, str], ...] = (
|
||||||
|
("config", _CONFIG_REL, "restore_config"),
|
||||||
|
("secrets", _SECRETS_REL, "restore_secrets"),
|
||||||
|
("wifi", _WIFI_REL, "restore_wifi"),
|
||||||
|
("ytm_auth", _YTM_REL, "restore_wifi"),
|
||||||
|
)
|
||||||
|
|
||||||
MANIFEST_NAME = "manifest.json"
|
MANIFEST_NAME = "manifest.json"
|
||||||
PLUGINS_MANIFEST_NAME = "plugins.json"
|
PLUGINS_MANIFEST_NAME = "plugins.json"
|
||||||
|
|
||||||
@@ -140,34 +151,18 @@ class RestoreResult:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _ledmatrix_version(project_root: Path) -> str:
|
def _ledmatrix_version() -> str:
|
||||||
"""Best-effort version string for the current install."""
|
"""The release of the running core (``src.__version__``), recorded in the
|
||||||
version_file = project_root / "VERSION"
|
manifest so a restore can tell which release wrote the backup."""
|
||||||
if version_file.exists():
|
from src import __version__
|
||||||
try:
|
return __version__
|
||||||
return version_file.read_text(encoding="utf-8").strip() or "unknown"
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
head_file = project_root / ".git" / "HEAD"
|
|
||||||
if head_file.exists():
|
|
||||||
try:
|
|
||||||
head = head_file.read_text(encoding="utf-8").strip()
|
|
||||||
if head.startswith("ref: "):
|
|
||||||
ref = head[5:]
|
|
||||||
ref_path = project_root / ".git" / ref
|
|
||||||
if ref_path.exists():
|
|
||||||
return ref_path.read_text(encoding="utf-8").strip()[:12] or "unknown"
|
|
||||||
return head[:12] or "unknown"
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
|
def _build_manifest(contents: List[str]) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"schema_version": SCHEMA_VERSION,
|
"schema_version": SCHEMA_VERSION,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
"ledmatrix_version": _ledmatrix_version(project_root),
|
"ledmatrix_version": _ledmatrix_version(),
|
||||||
"hostname": socket.gethostname(),
|
"hostname": socket.gethostname(),
|
||||||
"contents": contents,
|
"contents": contents,
|
||||||
}
|
}
|
||||||
@@ -178,13 +173,34 @@ def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _plugins_directory(project_root: Path) -> Path:
|
||||||
|
"""The plugin install directory: ``plugin_system.plugins_directory`` from
|
||||||
|
config/config.json (relative to ``project_root`` unless absolute), or
|
||||||
|
``plugin-repos`` when the config does not say or cannot be read."""
|
||||||
|
configured: Any = None
|
||||||
|
try:
|
||||||
|
with (project_root / _CONFIG_REL).open("r", encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
if isinstance(config, dict):
|
||||||
|
plugin_system = config.get("plugin_system")
|
||||||
|
if isinstance(plugin_system, dict):
|
||||||
|
configured = plugin_system.get("plugins_directory")
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
if not isinstance(configured, str) or not configured.strip():
|
||||||
|
configured = "plugin-repos"
|
||||||
|
path = Path(configured)
|
||||||
|
return path if path.is_absolute() else project_root / path
|
||||||
|
|
||||||
|
|
||||||
def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Return a list of currently-installed plugins suitable for the backup
|
Return a list of currently-installed plugins suitable for the backup
|
||||||
manifest. Each entry has ``plugin_id`` and ``version``.
|
manifest. Each entry has ``plugin_id`` and ``version``.
|
||||||
|
|
||||||
Reads ``data/plugin_state.json`` if present; otherwise walks the plugin
|
Reads ``data/plugin_state.json`` if present, then adds any plugin it
|
||||||
directory and reads each ``manifest.json``.
|
does not list from the ``manifest.json`` files in the configured plugin
|
||||||
|
directory (see :func:`_plugins_directory`).
|
||||||
"""
|
"""
|
||||||
plugins: Dict[str, Dict[str, Any]] = {}
|
plugins: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
@@ -206,8 +222,7 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
|||||||
except (OSError, json.JSONDecodeError) as e:
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
logger.warning("Could not read plugin_state.json: %s", e)
|
logger.warning("Could not read plugin_state.json: %s", e)
|
||||||
|
|
||||||
# Fall back to scanning plugin-repos/ for manifests.
|
plugins_root = _plugins_directory(project_root)
|
||||||
plugins_root = project_root / "plugin-repos"
|
|
||||||
if plugins_root.exists():
|
if plugins_root.exists():
|
||||||
for entry in sorted(plugins_root.iterdir()):
|
for entry in sorted(plugins_root.iterdir()):
|
||||||
if not entry.is_dir():
|
if not entry.is_dir():
|
||||||
@@ -298,19 +313,10 @@ def create_backup(
|
|||||||
tmp_path = zip_path.with_suffix(".zip.tmp")
|
tmp_path = zip_path.with_suffix(".zip.tmp")
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
# Config files.
|
for section, rel, _flag in _SINGLE_FILE_SECTIONS:
|
||||||
if (project_root / _CONFIG_REL).exists():
|
if (project_root / rel).exists():
|
||||||
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix())
|
zf.write(project_root / rel, rel.as_posix())
|
||||||
contents.append("config")
|
contents.append(section)
|
||||||
if (project_root / _SECRETS_REL).exists():
|
|
||||||
zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix())
|
|
||||||
contents.append("secrets")
|
|
||||||
if (project_root / _WIFI_REL).exists():
|
|
||||||
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
|
|
||||||
contents.append("wifi")
|
|
||||||
if (project_root / _YTM_REL).exists():
|
|
||||||
zf.write(project_root / _YTM_REL, _YTM_REL.as_posix())
|
|
||||||
contents.append("ytm_auth")
|
|
||||||
|
|
||||||
# User-uploaded fonts.
|
# User-uploaded fonts.
|
||||||
user_fonts = iter_user_fonts(project_root)
|
user_fonts = iter_user_fonts(project_root)
|
||||||
@@ -338,7 +344,7 @@ def create_backup(
|
|||||||
contents.append("plugins")
|
contents.append("plugins")
|
||||||
|
|
||||||
# Manifest goes last so that `contents` reflects what we actually wrote.
|
# Manifest goes last so that `contents` reflects what we actually wrote.
|
||||||
manifest = _build_manifest(contents, project_root)
|
manifest = _build_manifest(contents)
|
||||||
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
os.replace(tmp_path, zip_path)
|
os.replace(tmp_path, zip_path)
|
||||||
@@ -352,15 +358,16 @@ def create_backup(
|
|||||||
def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
|
def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
|
||||||
"""Return a summary of what ``create_backup`` would include."""
|
"""Return a summary of what ``create_backup`` would include."""
|
||||||
project_root = Path(project_root).resolve()
|
project_root = Path(project_root).resolve()
|
||||||
return {
|
preview: Dict[str, Any] = {
|
||||||
"has_config": (project_root / _CONFIG_REL).exists(),
|
f"has_{section}": (project_root / rel).exists()
|
||||||
"has_secrets": (project_root / _SECRETS_REL).exists(),
|
for section, rel, _flag in _SINGLE_FILE_SECTIONS
|
||||||
"has_wifi": (project_root / _WIFI_REL).exists(),
|
}
|
||||||
"has_ytm_auth": (project_root / _YTM_REL).exists(),
|
preview.update({
|
||||||
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
|
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
|
||||||
"plugin_uploads": len(iter_plugin_uploads(project_root)),
|
"plugin_uploads": len(iter_plugin_uploads(project_root)),
|
||||||
"plugins": list_installed_plugins(project_root),
|
"plugins": list_installed_plugins(project_root),
|
||||||
}
|
})
|
||||||
|
return preview
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -431,15 +438,10 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
|||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
|
||||||
detected: List[str] = []
|
detected: List[str] = [
|
||||||
if _CONFIG_REL.as_posix() in names:
|
section for section, rel, _flag in _SINGLE_FILE_SECTIONS
|
||||||
detected.append("config")
|
if rel.as_posix() in names
|
||||||
if _SECRETS_REL.as_posix() in names:
|
]
|
||||||
detected.append("secrets")
|
|
||||||
if _WIFI_REL.as_posix() in names:
|
|
||||||
detected.append("wifi")
|
|
||||||
if _YTM_REL.as_posix() in names:
|
|
||||||
detected.append("ytm_auth")
|
|
||||||
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
||||||
detected.append("fonts")
|
detected.append("fonts")
|
||||||
if any(
|
if any(
|
||||||
@@ -584,55 +586,18 @@ def restore_backup(
|
|||||||
result.errors.append("Failed to extract backup")
|
result.errors.append("Failed to extract backup")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Main config.
|
for section, rel, flag in _SINGLE_FILE_SECTIONS:
|
||||||
if options.restore_config and (tmp_dir / _CONFIG_REL).exists():
|
if not (tmp_dir / rel).exists():
|
||||||
|
continue
|
||||||
|
if not getattr(options, flag):
|
||||||
|
result.skipped.append(section)
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL)
|
_copy_file(tmp_dir / rel, project_root / rel)
|
||||||
result.restored.append("config")
|
result.restored.append(section)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error("[Backup] Failed to restore config.json: %s", e, exc_info=True)
|
logger.error("[Backup] Failed to restore %s: %s", rel.name, e, exc_info=True)
|
||||||
result.errors.append("Failed to restore config.json")
|
result.errors.append(f"Failed to restore {rel.name}")
|
||||||
elif (tmp_dir / _CONFIG_REL).exists():
|
|
||||||
result.skipped.append("config")
|
|
||||||
|
|
||||||
# Secrets.
|
|
||||||
if options.restore_secrets and (tmp_dir / _SECRETS_REL).exists():
|
|
||||||
try:
|
|
||||||
_copy_file(tmp_dir / _SECRETS_REL, project_root / _SECRETS_REL)
|
|
||||||
result.restored.append("secrets")
|
|
||||||
except OSError as e:
|
|
||||||
logger.error(
|
|
||||||
"[Backup] Failed to restore config_secrets.json: %s", e, exc_info=True
|
|
||||||
)
|
|
||||||
result.errors.append("Failed to restore config_secrets.json")
|
|
||||||
elif (tmp_dir / _SECRETS_REL).exists():
|
|
||||||
result.skipped.append("secrets")
|
|
||||||
|
|
||||||
# WiFi.
|
|
||||||
if options.restore_wifi and (tmp_dir / _WIFI_REL).exists():
|
|
||||||
try:
|
|
||||||
_copy_file(tmp_dir / _WIFI_REL, project_root / _WIFI_REL)
|
|
||||||
result.restored.append("wifi")
|
|
||||||
except OSError as e:
|
|
||||||
logger.error(
|
|
||||||
"[Backup] Failed to restore wifi_config.json: %s", e, exc_info=True
|
|
||||||
)
|
|
||||||
result.errors.append("Failed to restore wifi_config.json")
|
|
||||||
elif (tmp_dir / _WIFI_REL).exists():
|
|
||||||
result.skipped.append("wifi")
|
|
||||||
|
|
||||||
# YouTube Music session. Follows restore_wifi rather than getting its
|
|
||||||
# own flag: it is device-local auth in the same sense, and a separate
|
|
||||||
# toggle for one file would be noise in the restore dialog.
|
|
||||||
if options.restore_wifi and (tmp_dir / _YTM_REL).exists():
|
|
||||||
try:
|
|
||||||
_copy_file(tmp_dir / _YTM_REL, project_root / _YTM_REL)
|
|
||||||
result.restored.append("ytm_auth")
|
|
||||||
except OSError as e:
|
|
||||||
logger.error("[Backup] Failed to restore ytm_auth.json: %s", e, exc_info=True)
|
|
||||||
result.errors.append("Failed to restore ytm_auth.json")
|
|
||||||
elif (tmp_dir / _YTM_REL).exists():
|
|
||||||
result.skipped.append("ytm_auth")
|
|
||||||
|
|
||||||
# User fonts — skip anything that collides with a bundled font.
|
# User fonts — skip anything that collides with a bundled font.
|
||||||
tmp_fonts = tmp_dir / _FONTS_REL
|
tmp_fonts = tmp_dir / _FONTS_REL
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import requests
|
|||||||
import json
|
import json
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
|
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||||
|
|
||||||
|
|
||||||
class BaseOddsManager:
|
class BaseOddsManager:
|
||||||
"""
|
"""
|
||||||
@@ -45,22 +47,15 @@ class BaseOddsManager:
|
|||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
||||||
|
|
||||||
# This path used a bare requests.get, so it identified itself as
|
# Core's shared headers: ESPN rejects requests' default User-Agent
|
||||||
# python-requests/x.y -- the one thing ESPN is known to reject. Around
|
# (see api_helper.USER_AGENT), and a rejected odds request costs the
|
||||||
# 2026-08-04 it began 403ing browser strings and bare custom tokens
|
# calling plugin its update budget.
|
||||||
# alike; what it accepts is a token with a URL that says who is
|
|
||||||
# calling. Every other ESPN caller in the tree already sends this
|
|
||||||
# (src/common/api_helper.py); the odds path was simply missed, and it is the one whose failures cost
|
|
||||||
# the caller its whole update budget.
|
|
||||||
#
|
#
|
||||||
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
||||||
# request_timeout, which is set to 5s precisely to stay inside that
|
# request_timeout, which is set to 5s precisely to stay inside that
|
||||||
# budget. One try, then the cooldown below.
|
# budget. One try, then the cooldown below.
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update({
|
self.session.headers.update(DEFAULT_HTTP_HEADERS)
|
||||||
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
})
|
|
||||||
|
|
||||||
# Configuration with defaults
|
# Configuration with defaults
|
||||||
self.update_interval = 3600 # 1 hour default
|
self.update_interval = 3600 # 1 hour default
|
||||||
@@ -72,7 +67,6 @@ class BaseOddsManager:
|
|||||||
self.request_timeout = 5
|
self.request_timeout = 5
|
||||||
# Set when a request fails; until then, skip the network entirely.
|
# Set when a request fails; until then, skip the network entirely.
|
||||||
self._skip_network_until = 0.0
|
self._skip_network_until = 0.0
|
||||||
self.cache_ttl = 1800 # 30 minutes default
|
|
||||||
|
|
||||||
# Load configuration if available
|
# Load configuration if available
|
||||||
if config_manager:
|
if config_manager:
|
||||||
@@ -89,12 +83,10 @@ class BaseOddsManager:
|
|||||||
|
|
||||||
self.update_interval = odds_config.get('update_interval', self.update_interval)
|
self.update_interval = odds_config.get('update_interval', self.update_interval)
|
||||||
self.request_timeout = odds_config.get('timeout', self.request_timeout)
|
self.request_timeout = odds_config.get('timeout', self.request_timeout)
|
||||||
self.cache_ttl = odds_config.get('cache_ttl', self.cache_ttl)
|
|
||||||
|
|
||||||
self.logger.debug(f"BaseOddsManager configuration loaded: "
|
self.logger.debug(f"BaseOddsManager configuration loaded: "
|
||||||
f"update_interval={self.update_interval}s, "
|
f"update_interval={self.update_interval}s, "
|
||||||
f"timeout={self.request_timeout}s, "
|
f"timeout={self.request_timeout}s")
|
||||||
f"cache_ttl={self.cache_ttl}s")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}")
|
self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}")
|
||||||
@@ -172,15 +164,12 @@ class BaseOddsManager:
|
|||||||
odds_data = self._extract_espn_data(raw_data)
|
odds_data = self._extract_espn_data(raw_data)
|
||||||
if odds_data:
|
if odds_data:
|
||||||
self.logger.info(f"Successfully extracted odds data: {odds_data}")
|
self.logger.info(f"Successfully extracted odds data: {odds_data}")
|
||||||
else:
|
|
||||||
self.logger.debug("No odds data available for this game")
|
|
||||||
|
|
||||||
if odds_data:
|
|
||||||
self.cache_manager.set(cache_key, odds_data, ttl=interval)
|
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.info(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
|
||||||
else:
|
else:
|
||||||
self.logger.debug(f"No odds data available for {cache_key}")
|
self.logger.debug(f"No odds data available for {cache_key}")
|
||||||
# Cache the fact that no odds are available to avoid repeated API calls
|
# Cache the absence too, so the game is not re-requested
|
||||||
|
# on every update until the interval passes.
|
||||||
self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)
|
self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)
|
||||||
|
|
||||||
return odds_data
|
return odds_data
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ from typing import Any, Dict, List, Optional
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import tempfile
|
import tempfile
|
||||||
from src.exceptions import CacheError
|
|
||||||
from src.cache.memory_cache import MemoryCache, default_max_size
|
from src.cache.memory_cache import MemoryCache, default_max_size
|
||||||
from src.cache.disk_cache import DiskCache
|
from src.cache.disk_cache import DiskCache
|
||||||
from src.cache.cache_strategy import CacheStrategy
|
from src.cache.cache_strategy import CacheStrategy
|
||||||
@@ -272,12 +271,9 @@ class CacheManager:
|
|||||||
# Update memory cache first
|
# Update memory cache first
|
||||||
self._memory_cache_component.set(key, data)
|
self._memory_cache_component.set(key, data)
|
||||||
|
|
||||||
# Save to disk cache
|
# DiskCache logs a failed write and raises CacheError, which the
|
||||||
try:
|
# caller gets as is.
|
||||||
self._disk_cache_component.set(key, data)
|
self._disk_cache_component.set(key, data)
|
||||||
except CacheError:
|
|
||||||
# Disk cache errors are already logged and raised by DiskCache
|
|
||||||
raise
|
|
||||||
|
|
||||||
def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Load data from cache with memory caching."""
|
"""Load data from cache with memory caching."""
|
||||||
|
|||||||
+217
-37
@@ -1,62 +1,242 @@
|
|||||||
# Common Utilities
|
# src/common
|
||||||
|
|
||||||
This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules.
|
Helpers shared by core and plugins. This page lists every module, what it is
|
||||||
|
for, and whether plugins are expected to import it.
|
||||||
|
|
||||||
## Adaptive Layout & Images (`src/adaptive_layout.py`, `src/adaptive_images.py`)
|
Rules for the package:
|
||||||
|
|
||||||
The recommended way to lay out plugins that render legibly on **any** panel
|
- Every module must import without display hardware: nothing here may import
|
||||||
size (64x32 through 256x128+) without hand-tuned coordinates. Re-exported
|
`src.display_manager` or `src.plugin_system` at module level
|
||||||
from `src.common` for convenience; canonical import paths are
|
([`test/test_common_is_hardware_free.py`](../../test/test_common_is_hardware_free.py)).
|
||||||
`src.adaptive_layout` / `src.adaptive_images`.
|
That keeps plugins that use it loadable by the web preview,
|
||||||
|
`scripts/check_plugin.py` and tests on a laptop.
|
||||||
|
- A plugin that imports a module added in a given core release must declare
|
||||||
|
that release as its minimum (`ledmatrix_min_version` in the manifest's
|
||||||
|
`versions` entry). The "Since" column gives the release; "—" means it
|
||||||
|
predates 3.1.0, "n/a" that plugins should not import it.
|
||||||
|
- `from src.common import ...` re-exports `APIHelper`, `ScrollHelper`,
|
||||||
|
`LogoHelper`, `TextHelper`, `scroll_config` (plus `ScrollSettings`,
|
||||||
|
`configure_scroll`, `resolve_scroll_settings`, `refresh_hz_from_config`) and
|
||||||
|
the adaptive layout names below ([`__init__.py`](__init__.py)).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Module | For | Plugins import it? | Since |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
||||||
|
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased |
|
||||||
|
| [`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 |
|
||||||
|
| [`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 | — |
|
||||||
|
| [`scroll_config`](#scroll_config) | Plugin scroll config → configured `ScrollHelper` | Yes (scrollers) | 3.4.0 |
|
||||||
|
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
|
||||||
|
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
|
||||||
|
| [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 |
|
||||||
|
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
|
||||||
|
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||||
|
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||||
|
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||||
|
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
||||||
|
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
||||||
|
|
||||||
|
The four `sports_*` mixin and card modules hold code the scoreboard plugins
|
||||||
|
used to carry as identical copies. Each module docstring lists what a host
|
||||||
|
class must provide. The plan behind them is in
|
||||||
|
[docs/SPORTS_UNIFICATION.md](../../docs/SPORTS_UNIFICATION.md).
|
||||||
|
|
||||||
|
## Adaptive layout and images
|
||||||
|
|
||||||
|
`src/adaptive_layout.py` and `src/adaptive_images.py` live outside this
|
||||||
|
package but are re-exported from `src.common`. They are the recommended way
|
||||||
|
to lay out a plugin that renders legibly on any panel size. Every
|
||||||
|
`BasePlugin` already has `self.layout`, `self.draw_fit()` and
|
||||||
|
`self.draw_image()`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Every BasePlugin already has self.layout and the draw helpers:
|
|
||||||
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||||
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
|
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
|
||||||
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||||
self.draw_fit(score_text, regs.score_area) # largest crisp font that fits
|
self.draw_fit(score_text, regs.score_area) # largest crisp font that fits
|
||||||
self.draw_fit(status, regs.status_band)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Key pieces: `Region` (rect algebra: bands/columns/splits/offset),
|
Key pieces: `Region`, the font ladders `LADDER_GRID` / `LADDER_ARCADE`,
|
||||||
font ladders (`LADDER_GRID`, `LADDER_ARCADE` — discrete crisp sizes, never
|
`LayoutContext` (`fit_text`, `fit_image`, `by_tier`, `px`), and
|
||||||
fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`,
|
`scoreboard_regions()` / `media_row()`. Guide:
|
||||||
`px`), and composite carvers `scoreboard_regions()` / `media_row()`.
|
[docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||||
Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
|
||||||
|
|
||||||
## API Helpers (`api_helper.py`)
|
## Modules
|
||||||
|
|
||||||
Utilities for making HTTP requests and handling API responses.
|
### api_helper
|
||||||
|
|
||||||
## Logo Helpers (`logo_helper.py`)
|
[`api_helper.py`](api_helper.py). `APIHelper(cache_manager=None, ...)`:
|
||||||
|
`get()` and `post()` with retries, optional caching through the cache
|
||||||
|
manager, and a minimum interval between requests (`set_rate_limit()`). Also has
|
||||||
|
`fetch_espn_scoreboard()`, `fetch_espn_standings()` and
|
||||||
|
`fetch_espn_rankings()`.
|
||||||
|
|
||||||
Utilities for loading and managing team logos.
|
### bdf_font
|
||||||
|
|
||||||
## Text Helpers (`text_helper.py`)
|
[`bdf_font.py`](bdf_font.py). The one BDF loader and rasterizer.
|
||||||
|
`load_bdf_face(path, size)` returns `(face, realised_px)`, falling back to
|
||||||
|
the file's native strike when it has none at `size`;
|
||||||
|
`draw_bdf_text(draw, text, x, y, face, color)` draws top-left anchored onto a
|
||||||
|
PIL `ImageDraw` the same way the panel does. `read_bdf_native_size(path)`
|
||||||
|
and `clear_face_cache()` round it out. Faces are cached per thread (FreeType
|
||||||
|
faces are not thread-safe). `DisplayManager`, `FontManager`, `element_style`
|
||||||
|
and the plugin test harness all use it. Most plugins get BDF text through
|
||||||
|
`display_manager.draw_text()` or `FontManager` and never import this.
|
||||||
|
|
||||||
Utilities for text processing and formatting.
|
### espn_dates
|
||||||
|
|
||||||
## BDF Fonts (`bdf_font.py`)
|
[`espn_dates.py`](espn_dates.py). ESPN's site API rejects `dates=` ranges
|
||||||
|
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
|
||||||
|
splits a range into month and day requests ESPN accepts and merges the
|
||||||
|
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
||||||
|
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
||||||
|
Scoreboard plugins also bundle a copy for older cores.
|
||||||
|
|
||||||
The one way to load and draw BDF bitmap fonts. `load_bdf_face(path, size)`
|
### font_layout
|
||||||
returns `(face, realised_px)`, falling back to the file's native strike when
|
|
||||||
it has none at `size`; `draw_bdf_text(draw, text, x, y, face, color)` draws
|
|
||||||
top-left anchored onto a PIL `ImageDraw` exactly as the panel does.
|
|
||||||
`DisplayManager`, `FontManager`, `element_style` and the plugin test harness
|
|
||||||
all go through it.
|
|
||||||
|
|
||||||
## Scroll Helpers (`scroll_helper.py`)
|
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
|
||||||
|
`ImageFont.truetype` with PIL's Basic layout engine pinned, so text lays out
|
||||||
|
the same whether or not the host Pillow has libraqm; use it for anything
|
||||||
|
drawn to the panel or compared against a golden image. `crisp_size()` gives
|
||||||
|
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.
|
||||||
|
|
||||||
Utilities for scrolling text on the display.
|
### logo_helper
|
||||||
|
|
||||||
## Permission Utilities (`permission_utils.py`)
|
[`logo_helper.py`](logo_helper.py). `LogoHelper(display_width,
|
||||||
|
display_height, ...)`: `load_logo()`, `load_logo_with_download()`,
|
||||||
|
`get_logo_variations()`, `normalize_abbreviation()`, with an in-memory cache.
|
||||||
|
|
||||||
Helpers for ensuring directory permissions and ownership are correct
|
### path_safety
|
||||||
when running as a service (used by `CacheManager` to set up its
|
|
||||||
persistent cache directory).
|
|
||||||
|
|
||||||
## Best Practices
|
[`path_safety.py`](path_safety.py). Core-internal, used by web handlers that
|
||||||
|
open files named in a request. `safe_path_component(value)` returns the
|
||||||
|
value if it is one harmless path segment, else `None`;
|
||||||
|
`resolve_under(base, *parts)` returns the resolved path, or `None` if a part
|
||||||
|
is unsafe or the result would leave `base`; `safe_relative_parts()` splits a
|
||||||
|
relative path the same way. Both return the sanitised value rather than a
|
||||||
|
boolean, so a caller cannot check one string and open another.
|
||||||
|
|
||||||
1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly
|
### permission_utils
|
||||||
2. **Reuse utilities**: Check existing utilities before creating new ones
|
|
||||||
3. **Document additions**: Add documentation when adding new utilities
|
[`permission_utils.py`](permission_utils.py). The modes and ownership that
|
||||||
|
let the root display service and the web user share files:
|
||||||
|
`ensure_directory_permissions()`, `ensure_file_permissions()`, the
|
||||||
|
`get_*_mode()` functions, `ensure_shared_group_ownership()`,
|
||||||
|
`sudo_remove_directory()` and `install_requirements_file()` (the sudo
|
||||||
|
`safe_pip_install.sh` path). `ConfigManager`, `CacheManager` and the store
|
||||||
|
already call these; a plugin needs them only when it creates its own files
|
||||||
|
outside the cache. See [docs/PERMISSIONS.md](../../docs/PERMISSIONS.md).
|
||||||
|
|
||||||
|
### scroll_config
|
||||||
|
|
||||||
|
[`scroll_config.py`](scroll_config.py). `configure(scroll_helper,
|
||||||
|
plugin_config=, global_config=, display_manager=, plugin_logger=)` reads a
|
||||||
|
plugin's scroll settings, snaps the speed to a whole number of pixels per
|
||||||
|
panel refresh, puts the helper in fixed-step mode and returns
|
||||||
|
`ScrollSettings`. Pass `settings.frame_hold` to
|
||||||
|
`display_manager.set_scrolling_state(True, frame_hold=...)` or the scroll
|
||||||
|
runs too fast. `resolve()` does the calculation without touching a helper.
|
||||||
|
See [docs/SCROLL_PERFORMANCE.md](../../docs/SCROLL_PERFORMANCE.md).
|
||||||
|
|
||||||
|
### scroll_helper
|
||||||
|
|
||||||
|
[`scroll_helper.py`](scroll_helper.py). `ScrollHelper(display_width,
|
||||||
|
display_height, logger=None)`: build a wide image once
|
||||||
|
(`create_scrolling_image()` or `set_scrolling_image()`), then per frame
|
||||||
|
`update_scroll_position()` and `get_visible_portion()`;
|
||||||
|
`is_scroll_complete()`, `calculate_dynamic_duration()` and
|
||||||
|
`get_dynamic_duration()` for timing. Configure it with `scroll_config`
|
||||||
|
rather than the `set_*` methods. Vegas mode reads a plugin's
|
||||||
|
`scroll_helper` image when the plugin has no `get_vegas_content()`.
|
||||||
|
|
||||||
|
### snapshot_policy
|
||||||
|
|
||||||
|
[`snapshot_policy.py`](snapshot_policy.py). Core-internal. `decide()`
|
||||||
|
tells `DisplayManager` whether to write `/tmp/led_matrix_preview.png`, only
|
||||||
|
touch its mtime, or skip, based on whether a browser is watching the preview.
|
||||||
|
The web health check reads the file's age.
|
||||||
|
|
||||||
|
### sports_card
|
||||||
|
|
||||||
|
[`sports_card.py`](sports_card.py). Free functions taking `config`, `fonts`
|
||||||
|
and `logger` explicitly: card options (`scroll_card_option()`,
|
||||||
|
`vs_text()`, `upcoming_center_mode()`), colours (`element_color()`,
|
||||||
|
`font_color()`, `score_color_for()`, `recent_score_color()`), favourite-team
|
||||||
|
rules (`favorite_teams_for()`, `side_is_favorite()`, `favorite_result()`),
|
||||||
|
dates (`format_game_date()`, `format_game_time()`, `card_tzinfo()`) and font
|
||||||
|
sizes (`schema_font_size()`, `resolve_font_size()`). A plugin keeps its own
|
||||||
|
method and delegates the body.
|
||||||
|
|
||||||
|
### sports_game_renderer
|
||||||
|
|
||||||
|
[`sports_game_renderer.py`](sports_game_renderer.py).
|
||||||
|
`SportsGameRendererMixin`: the scroll/Vegas card geometry (centre gap, logo
|
||||||
|
slot, layout offsets, upcoming-card date and time). No `__init__` and no
|
||||||
|
state; add it as a base class of the plugin's game renderer and override
|
||||||
|
what differs.
|
||||||
|
|
||||||
|
### sports_helpers
|
||||||
|
|
||||||
|
[`sports_helpers.py`](sports_helpers.py). Free functions `clamp_window()`,
|
||||||
|
`clamp_seconds()`, `logo_needs_refresh()`, `spread_weighted_order()`, and
|
||||||
|
`SportsHelpersMixin` with the scoreboards' `_mode_customization`,
|
||||||
|
`_setting_int`, `_reset_dwell_on_reentry`, `_next_switch_index`,
|
||||||
|
`_odds_color` and `_upcoming_date_and_time_text` under their existing names.
|
||||||
|
Nothing in core uses it.
|
||||||
|
|
||||||
|
### sports_scroll
|
||||||
|
|
||||||
|
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
|
||||||
|
`SportsScrollDisplayManager`: the scroll-display orchestration the
|
||||||
|
scoreboards share (Vegas items, dynamic duration, frame loop), paced through
|
||||||
|
`scroll_config`. Subclasses supply `prepare_scroll_content()` and set
|
||||||
|
`SCROLL_LEAGUE_KEYS`; see the module docstring for an example.
|
||||||
|
|
||||||
|
### sports_shared
|
||||||
|
|
||||||
|
[`sports_shared.py`](sports_shared.py). `SportsCoreSharedMixin`,
|
||||||
|
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` methods
|
||||||
|
that were identical in every scoreboard (game selection and rotation,
|
||||||
|
fonts, colours, dates, the switch-mode upcoming card). The docstring lists
|
||||||
|
the attributes the host class must have and the three methods deliberately
|
||||||
|
left out.
|
||||||
|
|
||||||
|
### sync_manager
|
||||||
|
|
||||||
|
[`sync_manager.py`](sync_manager.py). Core-internal. `DisplaySyncManager`
|
||||||
|
links two displays as leader and follower (`sync.role` in config) over UDP
|
||||||
|
port 5765, plus TCP on the next port for scroll images. The leader drives the
|
||||||
|
scroll and sends the follower its part of each frame; a follower falls back
|
||||||
|
to its own plugins when the leader goes quiet. Rows and columns must match.
|
||||||
|
Created by `DisplayController`; works with any plugin.
|
||||||
|
|
||||||
|
### text_helper
|
||||||
|
|
||||||
|
[`text_helper.py`](text_helper.py). `TextHelper(font_dir=None, ...)`:
|
||||||
|
`load_fonts()`, `draw_text_with_outline()`, `get_text_width()`,
|
||||||
|
`get_text_dimensions()`, `center_text()`, `wrap_text()`,
|
||||||
|
`draw_multiline_text()`, `create_text_image()`.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Modules here create their logger with `logging.getLogger(__name__)`, which is
|
||||||
|
the same logger `src.logging_config.get_logger(__name__)` returns. The helper
|
||||||
|
classes (`APIHelper`, `LogoHelper`, `ScrollHelper`, `TextHelper`) and
|
||||||
|
`espn_dates` take an optional `logger`. In a plugin, pass `self.logger`: it is
|
||||||
|
created by `get_logger(..., plugin_id=...)` in `BasePlugin`, so messages carry
|
||||||
|
the plugin id.
|
||||||
|
|
||||||
|
## Adding a module
|
||||||
|
|
||||||
|
- Keep it importable without hardware (see the test above).
|
||||||
|
- Give it a module docstring that says what it is for and, if it is a mixin,
|
||||||
|
what the host class must provide.
|
||||||
|
- Add it to the table on this page and, if plugins may import it, to the
|
||||||
|
CHANGELOG with the release to floor on.
|
||||||
|
|||||||
+37
-37
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
API Helper
|
API Helper
|
||||||
|
|
||||||
Handles HTTP requests, caching, and ESPN API integration for LED matrix plugins.
|
HTTP requests, response caching and ESPN fetch helpers for plugins
|
||||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
(``from src.common import APIHelper``), plus the headers every core request
|
||||||
|
sends (:data:`USER_AGENT`, :data:`DEFAULT_HTTP_HEADERS`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -36,13 +37,20 @@ DEFAULT_HTTP_HEADERS: Mapping[str, str] = MappingProxyType({
|
|||||||
|
|
||||||
class APIHelper:
|
class APIHelper:
|
||||||
"""
|
"""
|
||||||
Helper class for HTTP requests, caching, and ESPN API integration.
|
HTTP requests with retries, response caching and ESPN helpers.
|
||||||
|
|
||||||
Provides functionality for:
|
- Requests go through one ``requests.Session`` that retries GET, HEAD
|
||||||
- HTTP requests with retry logic and timeouts
|
and OPTIONS on 429 and 5xx with exponential backoff, and sends
|
||||||
- Response caching with TTL support
|
:data:`DEFAULT_HTTP_HEADERS`.
|
||||||
- ESPN API integration for sports data
|
- Consecutive requests from one helper are spaced at least
|
||||||
- Request rate limiting and throttling
|
``set_rate_limit()`` seconds apart (1 second by default). A cache hit
|
||||||
|
does not count.
|
||||||
|
- With a ``cache_manager``, :meth:`get` caches the parsed JSON under
|
||||||
|
``cache_key`` for ``cache_ttl`` seconds. The lifetime is stored with
|
||||||
|
the entry, so CacheManager honours it on every later read, whatever
|
||||||
|
max_age that read asks for.
|
||||||
|
- Failed requests are logged and return None; nothing here raises for a
|
||||||
|
network or HTTP error.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, cache_manager=None, default_timeout: int = 30,
|
def __init__(self, cache_manager=None, default_timeout: int = 30,
|
||||||
@@ -73,13 +81,7 @@ class APIHelper:
|
|||||||
self.session.mount("https://", adapter)
|
self.session.mount("https://", adapter)
|
||||||
self.session.mount("http://", adapter)
|
self.session.mount("http://", adapter)
|
||||||
|
|
||||||
# Default headers
|
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
|
||||||
self.session.headers.update({
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
|
||||||
'Connection': 'keep-alive'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting
|
||||||
self._last_request_time = 0
|
self._last_request_time = 0
|
||||||
@@ -102,9 +104,8 @@ class APIHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
Response data as dictionary or None if request fails
|
Response data as dictionary or None if request fails
|
||||||
"""
|
"""
|
||||||
# Check cache first
|
|
||||||
if cache_key and self.cache_manager:
|
if cache_key and self.cache_manager:
|
||||||
cached = self._get_from_cache(cache_key)
|
cached = self._get_from_cache(cache_key, cache_ttl)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
self.logger.debug(f"Using cached response for {cache_key}")
|
self.logger.debug(f"Using cached response for {cache_key}")
|
||||||
return cached
|
return cached
|
||||||
@@ -268,10 +269,10 @@ class APIHelper:
|
|||||||
Args:
|
Args:
|
||||||
key: Cache key
|
key: Cache key
|
||||||
data: Data to cache
|
data: Data to cache
|
||||||
ttl: Time-to-live in seconds (ignored - CacheManager doesn't support TTL)
|
ttl: Seconds the entry stays valid. Stored with the entry, so
|
||||||
|
it applies to every later read of ``key``.
|
||||||
"""
|
"""
|
||||||
if self.cache_manager:
|
self._set_cache(key, data, ttl)
|
||||||
self.cache_manager.set(key, data)
|
|
||||||
|
|
||||||
def get_cache(self, key: str) -> Optional[Any]:
|
def get_cache(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
@@ -281,20 +282,18 @@ class APIHelper:
|
|||||||
key: Cache key
|
key: Cache key
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Cached data or None if not found
|
Cached data, or None if there is none or it has expired. An
|
||||||
|
entry written with a ttl (set_cache, get) expires after that ttl;
|
||||||
|
one written without expires after CacheManager's default max_age.
|
||||||
"""
|
"""
|
||||||
if self.cache_manager:
|
return self._get_from_cache(key)
|
||||||
return self.cache_manager.get(key)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def clear_cache(self, pattern: Optional[str] = None) -> None:
|
def clear_cache(self, pattern: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Clear cache data.
|
Clear cache data.
|
||||||
|
|
||||||
Uses CacheManager's real surface (clear_cache / delete /
|
Uses CacheManager's clear_cache(), or list_cache_files() and delete()
|
||||||
list_cache_files); safely no-ops on managers without it. The old
|
for a pattern. A cache manager without those methods is left alone.
|
||||||
implementation guarded on a nonexistent ``clear`` method, so it
|
|
||||||
silently never cleared anything.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pattern: Optional substring to match cache keys; only matching
|
pattern: Optional substring to match cache keys; only matching
|
||||||
@@ -315,21 +314,22 @@ class APIHelper:
|
|||||||
"cannot clear by pattern")
|
"cannot clear by pattern")
|
||||||
elif hasattr(self.cache_manager, 'clear_cache'):
|
elif hasattr(self.cache_manager, 'clear_cache'):
|
||||||
self.cache_manager.clear_cache()
|
self.cache_manager.clear_cache()
|
||||||
elif hasattr(self.cache_manager, 'clear'):
|
|
||||||
self.cache_manager.clear()
|
|
||||||
else:
|
else:
|
||||||
self.logger.debug("Cache manager exposes no clear method; no-op")
|
self.logger.debug("Cache manager exposes no clear method; no-op")
|
||||||
|
|
||||||
def _get_from_cache(self, key: str) -> Optional[Any]:
|
def _get_from_cache(self, key: str, max_age: Optional[int] = None) -> Optional[Any]:
|
||||||
"""Get data from cache."""
|
"""Cached data for ``key``, or None. ``max_age`` only matters for an
|
||||||
if self.cache_manager:
|
entry stored without a ttl; one stored with a ttl uses that."""
|
||||||
return self.cache_manager.get(key)
|
if not self.cache_manager:
|
||||||
return None
|
return None
|
||||||
|
if max_age is None:
|
||||||
|
return self.cache_manager.get(key)
|
||||||
|
return self.cache_manager.get(key, max_age=max_age)
|
||||||
|
|
||||||
def _set_cache(self, key: str, data: Any, ttl: int) -> None:
|
def _set_cache(self, key: str, data: Any, ttl: Optional[int]) -> None:
|
||||||
"""Set data in cache."""
|
"""Store ``data`` under ``key`` for ``ttl`` seconds."""
|
||||||
if self.cache_manager:
|
if self.cache_manager:
|
||||||
self.cache_manager.set(key, data)
|
self.cache_manager.set(key, data, ttl=ttl)
|
||||||
|
|
||||||
def _enforce_rate_limit(self) -> None:
|
def _enforce_rate_limit(self) -> None:
|
||||||
"""Enforce rate limiting between requests."""
|
"""Enforce rate limiting between requests."""
|
||||||
|
|||||||
@@ -67,10 +67,11 @@ _INSTALL_ROOT = Path(__file__).resolve().parents[2]
|
|||||||
def resolve_asset_path(relative_path: str) -> str:
|
def resolve_asset_path(relative_path: str) -> str:
|
||||||
"""Resolve a repo-relative asset path independently of the process cwd.
|
"""Resolve a repo-relative asset path independently of the process cwd.
|
||||||
|
|
||||||
Prefers the path as given — so an absolute path is returned untouched and
|
In order: an absolute path that exists is returned untouched; otherwise
|
||||||
behaviour is unchanged wherever the cwd already happened to be the install
|
``relative_path`` under the install root derived above, if that exists;
|
||||||
root — then the install root derived above, then the original string so a
|
otherwise ``relative_path`` unchanged, so a caller that wants to raise
|
||||||
caller that wants to raise and fall back still can.
|
and fall back still can. The cwd is never consulted, so a relative path
|
||||||
|
means the same file whichever directory the process started in.
|
||||||
|
|
||||||
Without the fallback, any process started outside the install root (the
|
Without the fallback, any process started outside the install root (the
|
||||||
plugin safety harness, a manual ``python run.py`` from ``$HOME``, a unit
|
plugin safety harness, a manual ``python run.py`` from ``$HOME``, a unit
|
||||||
|
|||||||
+31
-50
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
from typing import Dict, List, Optional, Union
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image, ImageDraw
|
||||||
from src.common.api_helper import USER_AGENT
|
from src.common.api_helper import USER_AGENT
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions,
|
ensure_directory_permissions,
|
||||||
@@ -32,35 +32,14 @@ from src.common.permission_utils import (
|
|||||||
# trade for not re-warning about a file nobody is going to add.
|
# trade for not re-warning about a file nobody is going to add.
|
||||||
MISSING_LOGO_RECHECK_SECONDS = 3600.0
|
MISSING_LOGO_RECHECK_SECONDS = 3600.0
|
||||||
|
|
||||||
#: Bounds on a user-supplied logo scale. Wide enough to be useful, closed
|
|
||||||
#: enough that a typo cannot ask for a 4000px image on a 64px panel.
|
|
||||||
MIN_LOGO_SCALE = 0.05
|
|
||||||
MAX_LOGO_SCALE = 8.0
|
|
||||||
|
|
||||||
|
|
||||||
def _usable_scale(scale) -> float:
|
|
||||||
"""A scale that can be applied, or 1.0.
|
|
||||||
|
|
||||||
Anything unusable -- None, a string, zero, a negative, NaN, infinity --
|
|
||||||
means "as shipped", because the alternative is a blank panel from a
|
|
||||||
mistyped number.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
value = float(scale)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return 1.0
|
|
||||||
if value != value or value in (float('inf'), float('-inf')):
|
|
||||||
return 1.0
|
|
||||||
if value < MIN_LOGO_SCALE or value > MAX_LOGO_SCALE:
|
|
||||||
return 1.0
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Well above any real team logo; bounds what a remote URL can write to disk.
|
# Well above any real team logo; bounds what a remote URL can write to disk.
|
||||||
# The cap for every logo download: src.logo_downloader.fetch_logo uses it too.
|
# The cap for every logo download: src.logo_downloader.fetch_logo uses it too.
|
||||||
MAX_LOGO_BYTES = 10 * 1024 * 1024
|
MAX_LOGO_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
#: A logo's default bounding box, as a multiple of the panel's width and
|
||||||
|
#: height, when the caller gives no max_width / max_height.
|
||||||
|
DEFAULT_LOGO_BOX_FACTOR = 1.5
|
||||||
|
|
||||||
|
|
||||||
class LogoHelper:
|
class LogoHelper:
|
||||||
"""
|
"""
|
||||||
@@ -119,12 +98,16 @@ class LogoHelper:
|
|||||||
Args:
|
Args:
|
||||||
team_abbr: Team abbreviation for caching
|
team_abbr: Team abbreviation for caching
|
||||||
logo_path: Path to the logo file
|
logo_path: Path to the logo file
|
||||||
max_width: Maximum width (defaults to display_width * 1.5)
|
max_width: Maximum width (default display_width *
|
||||||
max_height: Maximum height (defaults to display_height * 1.5)
|
DEFAULT_LOGO_BOX_FACTOR)
|
||||||
|
max_height: Maximum height (default display_height *
|
||||||
|
DEFAULT_LOGO_BOX_FACTOR)
|
||||||
scale: User's size multiplier for this image, from
|
scale: User's size multiplier for this image, from
|
||||||
``customization.layout.<element>.scale``. 1.0 is untouched and
|
``customization.layout.<element>.scale``; 1.0 leaves the box
|
||||||
takes exactly the path it always did. Callers hold the config,
|
as is. Callers hold the config, so they resolve the element
|
||||||
so they resolve the element name; this only applies the number.
|
name; this only applies the number, clamped to
|
||||||
|
src.element_style's MIN_ELEMENT_SCALE..MAX_ELEMENT_SCALE.
|
||||||
|
A value that is not a finite positive number means 1.0.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL Image object or None if loading fails
|
PIL Image object or None if loading fails
|
||||||
@@ -138,10 +121,13 @@ class LogoHelper:
|
|||||||
# key is size-qualified — a panel-size change must not return a
|
# key is size-qualified — a panel-size change must not return a
|
||||||
# logo resized for the old dimensions.
|
# logo resized for the old dimensions.
|
||||||
if max_width is None:
|
if max_width is None:
|
||||||
max_width = int(self.display_width * 1.5)
|
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
if max_height is None:
|
if max_height is None:
|
||||||
max_height = int(self.display_height * 1.5)
|
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
scale = _usable_scale(scale)
|
# 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:
|
if scale != 1.0:
|
||||||
max_width = max(1, int(round(max_width * scale)))
|
max_width = max(1, int(round(max_width * scale)))
|
||||||
max_height = max(1, int(round(max_height * scale)))
|
max_height = max(1, int(round(max_height * scale)))
|
||||||
@@ -374,9 +360,9 @@ class LogoHelper:
|
|||||||
nobody asked to grow would change every existing render.
|
nobody asked to grow would change every existing render.
|
||||||
"""
|
"""
|
||||||
if max_width is None:
|
if max_width is None:
|
||||||
max_width = int(self.display_width * 1.5)
|
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
if max_height is None:
|
if max_height is None:
|
||||||
max_height = int(self.display_height * 1.5)
|
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
|
|
||||||
# Only resize if necessary
|
# Only resize if necessary
|
||||||
if logo.width <= max_width and logo.height <= max_height:
|
if logo.width <= max_width and logo.height <= max_height:
|
||||||
@@ -429,31 +415,26 @@ class LogoHelper:
|
|||||||
max_width: Optional[int] = None,
|
max_width: Optional[int] = None,
|
||||||
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
||||||
"""
|
"""
|
||||||
Create a placeholder logo with team abbreviation.
|
A stand-in for a logo that could not be loaded or downloaded: a
|
||||||
|
translucent grey box with a light outline, filling the logo box.
|
||||||
|
No text is drawn; ``team_abbr`` is only used in log messages.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
team_abbr: Team abbreviation to display
|
team_abbr: Team the placeholder stands in for
|
||||||
max_width: Maximum width
|
max_width: Width (default display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
max_height: Maximum height
|
max_height: Height (default display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL Image with placeholder logo
|
The RGBA placeholder, or None if it could not be created
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if max_width is None:
|
if max_width is None:
|
||||||
max_width = int(self.display_width * 1.5)
|
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
if max_height is None:
|
if max_height is None:
|
||||||
max_height = int(self.display_height * 1.5)
|
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
|
|
||||||
# Create placeholder image
|
|
||||||
placeholder = Image.new('RGBA', (max_width, max_height), (0, 0, 0, 0))
|
placeholder = Image.new('RGBA', (max_width, max_height), (0, 0, 0, 0))
|
||||||
|
|
||||||
# This would require a font, so we'll create a simple colored rectangle
|
|
||||||
# In a real implementation, you'd want to add text rendering here
|
|
||||||
from PIL import ImageDraw
|
|
||||||
draw = ImageDraw.Draw(placeholder)
|
draw = ImageDraw.Draw(placeholder)
|
||||||
|
|
||||||
# Draw a simple rectangle with team abbreviation
|
|
||||||
draw.rectangle([0, 0, max_width-1, max_height-1],
|
draw.rectangle([0, 0, max_width-1, max_height-1],
|
||||||
fill=(100, 100, 100, 200), outline=(200, 200, 200, 255))
|
fill=(100, 100, 100, 200), outline=(200, 200, 200, 255))
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,8 @@ def get_assets_dir_mode() -> int:
|
|||||||
Return permission mode for asset directories.
|
Return permission mode for asset directories.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||||
|
entries created in it take the directory's group
|
||||||
"""
|
"""
|
||||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||||
|
|
||||||
@@ -251,7 +252,8 @@ def get_config_dir_mode() -> int:
|
|||||||
Return permission mode for config directory.
|
Return permission mode for config directory.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||||
|
entries created in it take the directory's group
|
||||||
"""
|
"""
|
||||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||||
|
|
||||||
@@ -271,7 +273,8 @@ def get_plugin_dir_mode() -> int:
|
|||||||
Return permission mode for plugin directories.
|
Return permission mode for plugin directories.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||||
|
entries created in it take the directory's group
|
||||||
"""
|
"""
|
||||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||||
|
|
||||||
@@ -281,7 +284,8 @@ def get_cache_dir_mode() -> int:
|
|||||||
Return permission mode for cache directories.
|
Return permission mode for cache directories.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable cache directories
|
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||||
|
entries created in it take the directory's group
|
||||||
"""
|
"""
|
||||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ serves two consumers with different needs:
|
|||||||
|
|
||||||
- The web UI's live preview (SSE reader in web_interface/app.py) wants
|
- The web UI's live preview (SSE reader in web_interface/app.py) wants
|
||||||
fresh frames — but only while a browser is actually watching.
|
fresh frames — but only while a browser is actually watching.
|
||||||
- The health check (web_interface/blueprints/api_v3.py, hardware status)
|
- The health check (web_interface/blueprints/api_v3/misc.py, hardware status)
|
||||||
uses the file's AGE as a liveness proxy: age >= 60s reads as degraded.
|
uses the file's AGE as a liveness proxy: age >= 60s reads as degraded.
|
||||||
|
|
||||||
PNG-encoding every frame at 5 fps forever — identical frames, no viewers —
|
PNG-encoding every frame at 5 fps forever — identical frames, no viewers —
|
||||||
@@ -27,7 +27,7 @@ Policy:
|
|||||||
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
|
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
|
||||||
|
|
||||||
If any constant here changes, re-check the health threshold in
|
If any constant here changes, re-check the health threshold in
|
||||||
api_v3.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
api_v3/misc.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -37,7 +37,7 @@ VIEWER_INTERVAL = 0.2
|
|||||||
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
||||||
IDLE_INTERVAL = 30.0
|
IDLE_INTERVAL = 30.0
|
||||||
# Max age of the last write/touch before bumping mtime for the health
|
# Max age of the last write/touch before bumping mtime for the health
|
||||||
# check. MUST stay well under api_v3's 60s degraded threshold.
|
# check. MUST stay well under get_hardware_status's 60s degraded threshold.
|
||||||
TOUCH_INTERVAL = 20.0
|
TOUCH_INTERVAL = 20.0
|
||||||
# A viewer marker older than this no longer counts as a live viewer.
|
# A viewer marker older than this no longer counts as a live viewer.
|
||||||
VIEWER_MARKER_FRESH_SEC = 5.0
|
VIEWER_MARKER_FRESH_SEC = 5.0
|
||||||
|
|||||||
+11
-15
@@ -101,11 +101,10 @@ def element_color(config: Optional[Dict[str, Any]], element: str,
|
|||||||
mode: Optional[str] = None):
|
mode: Optional[str] = None):
|
||||||
"""Per-element text colour from customization.<element>.text_color.
|
"""Per-element text colour from customization.<element>.text_color.
|
||||||
|
|
||||||
Delegated rather than reimplemented: there were two copies of this
|
Delegates to src.element_style.element_color, which also resolves the
|
||||||
read and three of the offset read, and the shared one also resolves
|
element under the names plugins actually use (the layout block says
|
||||||
the element under the names plugins actually use (the layout block
|
`score` where the style block says `score_text`) and honours a per-mode
|
||||||
says `score` where the style block says `score_text`) and honours a
|
override. Hex strings are accepted.
|
||||||
per-mode override. Hex strings are still accepted.
|
|
||||||
"""
|
"""
|
||||||
from src.element_style import element_color as _shared
|
from src.element_style import element_color as _shared
|
||||||
return _shared(config, element, default, mode)
|
return _shared(config, element, default, mode)
|
||||||
@@ -125,12 +124,11 @@ def resolve_font_color(config: Optional[Dict[str, Any]],
|
|||||||
One object can legitimately belong to several elements -- a size resolver
|
One object can legitimately belong to several elements -- a size resolver
|
||||||
can land two of them on the same face, and a BDF face cannot be un-shared
|
can land two of them on the same face, and a BDF face cannot be un-shared
|
||||||
at all because ``freetype.Face`` objects cannot be rebuilt from a path.
|
at all because ``freetype.Face`` objects cannot be rebuilt from a path.
|
||||||
Those draws used to go out white, which is how an element rendered in any
|
Ambiguity is therefore narrowed before it is given up on: among the
|
||||||
of the 32 shipped bitmap fonts could silently lose a colour the user had
|
|
||||||
set. So ambiguity is now narrowed before it is given up on: among the
|
|
||||||
elements sharing a face, a single configured colour is the only thing the
|
elements sharing a face, a single configured colour is the only thing the
|
||||||
user can have meant, and several that agree mean the same thing. Only a
|
user can have meant, and several that agree mean the same thing. Only a
|
||||||
genuine disagreement falls back to *default*.
|
genuine disagreement falls back to *default* -- otherwise an element
|
||||||
|
drawn in any of the shipped bitmap fonts could lose a colour the user set.
|
||||||
|
|
||||||
The element vocabulary is a parameter because the two callers disagree
|
The element vocabulary is a parameter because the two callers disagree
|
||||||
about it -- the mixin's map says ``team_text`` where this module's says
|
about it -- the mixin's map says ``team_text`` where this module's says
|
||||||
@@ -483,7 +481,7 @@ def unshare_element_fonts(logger, fonts, element_for_font=None):
|
|||||||
with identical metrics, so nothing about the rendering changes; only
|
with identical metrics, so nothing about the rendering changes; only
|
||||||
the ability to tell two elements apart does. Faces that cannot be
|
the ability to tell two elements apart does. Faces that cannot be
|
||||||
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
||||||
path) are left shared, and their draws stay white as before.
|
path) are left shared; resolve_font_color then picks their colour.
|
||||||
|
|
||||||
*element_for_font* names the font keys to consider, in order (the first
|
*element_for_font* names the font keys to consider, in order (the first
|
||||||
holder of a face keeps it); it defaults to this module's
|
holder of a face keeps it); it defaults to this module's
|
||||||
@@ -491,10 +489,8 @@ def unshare_element_fonts(logger, fonts, element_for_font=None):
|
|||||||
which names different keys -- see ``resolve_font_color`` for why the two
|
which names different keys -- see ``resolve_font_color`` for why the two
|
||||||
vocabularies are kept apart.
|
vocabularies are kept apart.
|
||||||
"""
|
"""
|
||||||
try:
|
# Looked up at call time so tests can spy on the pinned loader.
|
||||||
from src.common.font_layout import load_truetype as _load
|
from src.common.font_layout import load_truetype
|
||||||
except ImportError: # pragma: no cover
|
|
||||||
return fonts
|
|
||||||
if element_for_font is None:
|
if element_for_font is None:
|
||||||
element_for_font = ELEMENT_FOR_FONT
|
element_for_font = ELEMENT_FOR_FONT
|
||||||
seen = {}
|
seen = {}
|
||||||
@@ -509,7 +505,7 @@ def unshare_element_fonts(logger, fonts, element_for_font=None):
|
|||||||
if not path or not size:
|
if not path or not size:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
fonts[key] = _load(path, size)
|
fonts[key] = load_truetype(path, size)
|
||||||
except (OSError, ValueError, TypeError):
|
except (OSError, ValueError, TypeError):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Could not un-share the %s face; it keeps the default colour", key)
|
"Could not un-share the %s face; it keeps the default colour", key)
|
||||||
|
|||||||
@@ -56,21 +56,13 @@ class SportsGameRendererMixin:
|
|||||||
|
|
||||||
# ---- geometry ------------------------------------------------------
|
# ---- geometry ------------------------------------------------------
|
||||||
|
|
||||||
# Non-finite settings are rejected before any int()/round(): "inf" reaches
|
|
||||||
# these from config as a float or a string, passes an `isinstance` plus
|
|
||||||
# `>= 0` check unharmed, and then raises OverflowError out of int() --
|
|
||||||
# which the old `except (TypeError, ValueError)` did not catch, so it
|
|
||||||
# aborted the whole card render. Present in all eight plugins before this
|
|
||||||
# moved to the core; fixing it here fixes it in all eight.
|
|
||||||
|
|
||||||
def _score_reserve_width(self) -> int:
|
def _score_reserve_width(self) -> int:
|
||||||
"""Centre strip the score actually needs, measured rather than assumed.
|
"""Centre strip the score needs: the width of _SCORE_PROBE in the
|
||||||
|
score font plus a gutter each side, or 0 if it cannot be measured.
|
||||||
|
|
||||||
The gap was derived from the card width alone (width x
|
Measured rather than derived from the card width, because the score's
|
||||||
CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size
|
size comes from config and the element-style resolver: a strip sized
|
||||||
comes from config and the element-style resolver. Nothing compared the
|
from the width alone lets a large score run over the logos.
|
||||||
two, so any score wider than the clamp was drawn over the logos.
|
|
||||||
Measuring it keeps the strip wide enough for whatever font is in play.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
|
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
|
||||||
@@ -87,6 +79,10 @@ class SportsGameRendererMixin:
|
|||||||
the card width between the configurable min and max. 0 restores
|
the card width between the configurable min and max. 0 restores
|
||||||
edge-to-edge logos.
|
edge-to-edge logos.
|
||||||
"""
|
"""
|
||||||
|
# Non-finite settings are rejected before any int()/round(): "inf"
|
||||||
|
# arrives from config as a float or a string, passes the isinstance
|
||||||
|
# and >= 0 checks, and int() then raises OverflowError, which would
|
||||||
|
# abort the whole card render.
|
||||||
configured = self._scroll_card_option("center_gap")
|
configured = self._scroll_card_option("center_gap")
|
||||||
if (isinstance(configured, (int, float))
|
if (isinstance(configured, (int, float))
|
||||||
and math.isfinite(configured) and configured >= 0):
|
and math.isfinite(configured) and configured >= 0):
|
||||||
@@ -110,10 +106,9 @@ class SportsGameRendererMixin:
|
|||||||
def _logo_slot_width(self) -> int:
|
def _logo_slot_width(self) -> int:
|
||||||
"""Per-side logo slot, leaving the center gap clear.
|
"""Per-side logo slot, leaving the center gap clear.
|
||||||
|
|
||||||
No longer capped at display_height: the card is sized as two
|
Not capped at display_height: the card is sized as two full-height
|
||||||
full-height logos plus the measured gap, so what is left after the gap
|
logos plus the measured gap, so what is left after the gap is exactly
|
||||||
is exactly the logo's share. The cap was what froze the logos at 46px
|
the logo's share. At least 8 px.
|
||||||
on the old flat 128px card.
|
|
||||||
"""
|
"""
|
||||||
available = (self.display_width - self._center_gap_width()) // 2
|
available = (self.display_width - self._center_gap_width()) // 2
|
||||||
return max(8, available)
|
return max(8, available)
|
||||||
@@ -130,9 +125,8 @@ class SportsGameRendererMixin:
|
|||||||
"""X/Y nudge for one element, from customization.layout.
|
"""X/Y nudge for one element, from customization.layout.
|
||||||
|
|
||||||
Same block the full-screen scorebug reads (sports.py
|
Same block the full-screen scorebug reads (sports.py
|
||||||
_get_layout_offset), so a nudge configured in the web UI now moves
|
_get_layout_offset), so a nudge configured in the web UI moves the
|
||||||
the element on the scroll/Vegas card too -- previously the schema
|
element on the scroll/Vegas card as well as on the scorebug.
|
||||||
advertised these offsets but this renderer ignored them.
|
|
||||||
"""
|
"""
|
||||||
from src.element_style import layout_offset
|
from src.element_style import layout_offset
|
||||||
return layout_offset(self.config, element, axis, default,
|
return layout_offset(self.config, element, axis, default,
|
||||||
|
|||||||
@@ -487,9 +487,9 @@ class SportsScrollDisplayManager:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# prepare_scroll_content is subclass-implemented and builds cards
|
# prepare_scroll_content is subclass-implemented and builds cards
|
||||||
# straight from feed data, which is exactly where this PR's other
|
# straight from feed data, so it can raise on a malformed payload.
|
||||||
# crashes came from. One sport's bad payload must not take down the
|
# One sport's bad payload must not take down the shared
|
||||||
# shared orchestration for the others.
|
# orchestration for the others.
|
||||||
self.logger.exception(
|
self.logger.exception(
|
||||||
"Error preparing scroll content for game_type=%s", game_type
|
"Error preparing scroll content for game_type=%s", game_type
|
||||||
)
|
)
|
||||||
|
|||||||
+15
-37
@@ -97,11 +97,11 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from src.common.espn_dates import fetch_espn_scoreboard
|
from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw
|
||||||
from src.common import sports_card as _card
|
from src.common import sports_card as _card
|
||||||
from src.common.font_layout import load_truetype
|
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -132,36 +132,16 @@ def _resolve_font_path(path: str) -> str:
|
|||||||
load raises, the caller falls back, and the scoreboard renders in PIL's
|
load raises, the caller falls back, and the scoreboard renders in PIL's
|
||||||
default face instead of the pixel font it was laid out for.
|
default face instead of the pixel font it was laid out for.
|
||||||
|
|
||||||
Resolution order matches the core's own resolver: the path as given
|
Resolution order: the path as given, relative to the cwd, when it
|
||||||
first, so behaviour is unchanged wherever it already worked and a
|
exists -- the order the scoreboards' own sports.py copies used, so a
|
||||||
configured absolute path is returned untouched, then the core install
|
process running from another checkout keeps that checkout's fonts --
|
||||||
root, then the original string so callers still raise and fall back
|
then :func:`src.common.font_layout.resolve_asset_path` (the install
|
||||||
exactly as they do today.
|
root), which returns the original string when neither exists so callers
|
||||||
|
still raise and fall back.
|
||||||
"""
|
"""
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
return path
|
return path
|
||||||
try:
|
return resolve_asset_path(path)
|
||||||
import src.font_manager as _core_fonts
|
|
||||||
|
|
||||||
# The core grew this resolver in ChuckBuilds/LEDMatrix#425. Use it
|
|
||||||
# when it is there so both repos stay on one definition of "install
|
|
||||||
# root"; older cores fall through to the equivalent derivation below.
|
|
||||||
manager = getattr(_core_fonts, "FontManager", None)
|
|
||||||
resolver = getattr(manager, "_resolve_asset_path", None)
|
|
||||||
if resolver is not None:
|
|
||||||
resolved = resolver(path)
|
|
||||||
if resolved and os.path.exists(resolved):
|
|
||||||
return resolved
|
|
||||||
root = os.path.dirname(os.path.dirname(os.path.abspath(_core_fonts.__file__)))
|
|
||||||
candidate = os.path.join(root, path)
|
|
||||||
if os.path.exists(candidate):
|
|
||||||
return candidate
|
|
||||||
except (ImportError, AttributeError, OSError):
|
|
||||||
# No core on the path (standalone tooling), a core laid out
|
|
||||||
# differently, or an unreadable install. Returning the original keeps
|
|
||||||
# the caller's existing fallback intact.
|
|
||||||
return path
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
class SportsCoreSharedMixin:
|
class SportsCoreSharedMixin:
|
||||||
@@ -201,9 +181,6 @@ class SportsCoreSharedMixin:
|
|||||||
#: How long to stay quiet between ranking-coverage warnings.
|
#: How long to stay quiet between ranking-coverage warnings.
|
||||||
_RANKING_COVERAGE_SECONDS: ClassVar[int] = 60 * 60
|
_RANKING_COVERAGE_SECONDS: ClassVar[int] = 60 * 60
|
||||||
|
|
||||||
def _get_season_schedule_dates(self) -> tuple[str, str]:
|
|
||||||
return "", ""
|
|
||||||
|
|
||||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||||
"""Placeholder draw method - subclasses should override."""
|
"""Placeholder draw method - subclasses should override."""
|
||||||
# This base method will be simple, subclasses provide specifics
|
# This base method will be simple, subclasses provide specifics
|
||||||
@@ -889,7 +866,10 @@ class SportsCoreSharedMixin:
|
|||||||
draw.text((x, y), text, font=font, fill=fill)
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
|
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
|
||||||
"""Check if we should log a warning based on cooldown period."""
|
"""True at most once per ``cooldown`` seconds, for rate-limiting a
|
||||||
|
warning. The cooldown is shared by every warning on this manager:
|
||||||
|
``warning_type`` is part of the signature scoreboards inherit, but
|
||||||
|
does not give each type its own cooldown."""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if current_time - self._last_warning_time > cooldown:
|
if current_time - self._last_warning_time > cooldown:
|
||||||
self._last_warning_time = current_time
|
self._last_warning_time = current_time
|
||||||
@@ -904,8 +884,6 @@ class SportsCoreSharedMixin:
|
|||||||
try:
|
try:
|
||||||
# Fetch current week and next few days for immediate display
|
# Fetch current week and next few days for immediate display
|
||||||
now = datetime.now(pytz.utc)
|
now = datetime.now(pytz.utc)
|
||||||
immediate_events = []
|
|
||||||
|
|
||||||
start_date = now - timedelta(days=self.schedule_lookback_days)
|
start_date = now - timedelta(days=self.schedule_lookback_days)
|
||||||
end_date = now + timedelta(days=self.schedule_lookahead_days)
|
end_date = now + timedelta(days=self.schedule_lookahead_days)
|
||||||
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
|
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
|
||||||
@@ -913,7 +891,7 @@ class SportsCoreSharedMixin:
|
|||||||
data = fetch_espn_scoreboard(
|
data = fetch_espn_scoreboard(
|
||||||
self.session,
|
self.session,
|
||||||
url,
|
url,
|
||||||
params={"dates": date_str, "limit": 1000},
|
params={"dates": date_str, "limit": ESPN_MAX_LIMIT},
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
logger=self.logger,
|
logger=self.logger,
|
||||||
|
|||||||
+23
-21
@@ -32,7 +32,7 @@ from typing import Callable, Optional
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from src.display_geometry import DEFAULT_CHAIN_LENGTH
|
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
|
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
|
||||||
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
|
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
|
||||||
@@ -75,9 +75,12 @@ class FollowerState(Enum):
|
|||||||
class DisplaySyncManager:
|
class DisplaySyncManager:
|
||||||
"""
|
"""
|
||||||
Core sync manager. Instantiated by DisplayController based on config['sync'].
|
Core sync manager. Instantiated by DisplayController based on config['sync'].
|
||||||
Leader sends compressed PNG frames to the follower after each render cycle.
|
|
||||||
Follower renders received frames; returns to own plugin stack when leader
|
The leader sends each rendered frame to the follower over UDP as raw RGB
|
||||||
goes offline.
|
bytes (send_frame), and for Vegas scrolling sends the whole scroll image
|
||||||
|
once per cycle as a PNG over TCP on port + 1 (send_scroll_image), then
|
||||||
|
only the scroll position. The follower draws what it receives and goes
|
||||||
|
back to its own plugins when the leader stops sending.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -192,8 +195,8 @@ class DisplaySyncManager:
|
|||||||
|
|
||||||
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
||||||
hw = self._hw_config
|
hw = self._hw_config
|
||||||
local_rows = hw.get("rows", 32)
|
local_rows = hw.get("rows", DEFAULT_ROWS)
|
||||||
local_cols = hw.get("cols", 64)
|
local_cols = hw.get("cols", DEFAULT_COLS)
|
||||||
peer_rows = int(msg.get("rows", 0))
|
peer_rows = int(msg.get("rows", 0))
|
||||||
peer_cols = int(msg.get("cols", 0))
|
peer_cols = int(msg.get("cols", 0))
|
||||||
peer_chain = int(msg.get("chain", DEFAULT_CHAIN_LENGTH))
|
peer_chain = int(msg.get("chain", DEFAULT_CHAIN_LENGTH))
|
||||||
@@ -469,16 +472,23 @@ class DisplaySyncManager:
|
|||||||
"""Record a decoded leader frame and enter follower mode if needed."""
|
"""Record a decoded leader frame and enter follower mode if needed."""
|
||||||
with self._frame_lock:
|
with self._frame_lock:
|
||||||
self._latest_frame = img
|
self._latest_frame = img
|
||||||
|
self._enter_follower_mode(sender_ip)
|
||||||
|
|
||||||
|
def _enter_follower_mode(self, sender_ip: str) -> bool:
|
||||||
|
"""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.time()
|
||||||
self._leader_ip = sender_ip
|
self._leader_ip = sender_ip
|
||||||
|
if self._follower_state != FollowerState.STANDALONE:
|
||||||
if self._follower_state == FollowerState.STANDALONE:
|
return False
|
||||||
self._follower_state = FollowerState.FOLLOWER
|
self._follower_state = FollowerState.FOLLOWER
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Sync: leader active at %s — switching to follower mode",
|
"Sync: leader active at %s — switching to follower mode",
|
||||||
sender_ip,
|
sender_ip,
|
||||||
)
|
)
|
||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
|
return True
|
||||||
|
|
||||||
def _follower_recv_loop(self) -> None:
|
def _follower_recv_loop(self) -> None:
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -559,15 +569,7 @@ class DisplaySyncManager:
|
|||||||
# back from. Treat it as malformed.
|
# back from. Treat it as malformed.
|
||||||
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
||||||
self._latest_scroll_x = scroll_x
|
self._latest_scroll_x = scroll_x
|
||||||
self._last_leader_frame_time = time.time()
|
if self._enter_follower_mode(sender_ip):
|
||||||
self._leader_ip = sender_ip
|
|
||||||
if self._follower_state == FollowerState.STANDALONE:
|
|
||||||
self._follower_state = FollowerState.FOLLOWER
|
|
||||||
self.logger.info(
|
|
||||||
"Sync: leader active at %s — switching to follower mode",
|
|
||||||
sender_ip,
|
|
||||||
)
|
|
||||||
self.write_status_file()
|
|
||||||
fire_new_cycle = True # build initial scroll image
|
fire_new_cycle = True # build initial scroll image
|
||||||
elif t == "nc":
|
elif t == "nc":
|
||||||
# Leader started a new scroll cycle — rebuild local image
|
# Leader started a new scroll cycle — rebuild local image
|
||||||
@@ -589,8 +591,8 @@ class DisplaySyncManager:
|
|||||||
hw = self._hw_config
|
hw = self._hw_config
|
||||||
hello = json.dumps({
|
hello = json.dumps({
|
||||||
"t": "hello",
|
"t": "hello",
|
||||||
"rows": hw.get("rows", 32),
|
"rows": hw.get("rows", DEFAULT_ROWS),
|
||||||
"cols": hw.get("cols", 64),
|
"cols": hw.get("cols", DEFAULT_COLS),
|
||||||
"chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
"chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||||
}).encode("utf-8")
|
}).encode("utf-8")
|
||||||
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
||||||
@@ -660,8 +662,8 @@ class DisplaySyncManager:
|
|||||||
base = {
|
base = {
|
||||||
"role": self.role.value,
|
"role": self.role.value,
|
||||||
"port": self.port,
|
"port": self.port,
|
||||||
"local_rows": hw.get("rows", 32),
|
"local_rows": hw.get("rows", DEFAULT_ROWS),
|
||||||
"local_cols": hw.get("cols", 64),
|
"local_cols": hw.get("cols", DEFAULT_COLS),
|
||||||
"local_chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
"local_chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-17
@@ -10,7 +10,7 @@ from pathlib import Path
|
|||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from src.common.font_layout import load_truetype
|
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||||
|
|
||||||
# Shared throwaway draw surface for measuring text without a target canvas.
|
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||||
@@ -18,13 +18,14 @@ _measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
|||||||
|
|
||||||
class TextHelper:
|
class TextHelper:
|
||||||
"""
|
"""
|
||||||
Helper class for text rendering with outlines and font management.
|
Font loading, outlined text and text measurement for plugins.
|
||||||
|
|
||||||
Provides functionality for:
|
- :meth:`load_fonts` loads TrueType fonts from ``font_dir`` (the install's
|
||||||
- Loading and managing fonts
|
assets/fonts by default) with the layout engine pinned
|
||||||
- Drawing text with outlines for better readability
|
(font_layout.load_truetype). Each (file, size) is loaded once per helper
|
||||||
- Calculating text dimensions and positioning
|
and reused; a missing or unloadable file becomes PIL's default font.
|
||||||
- Managing font resources
|
- :meth:`draw_text_with_outline` and friends draw onto a caller's
|
||||||
|
``ImageDraw``; the measuring methods need no canvas.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, font_dir: Optional[Union[str, Path]] = None,
|
def __init__(self, font_dir: Optional[Union[str, Path]] = None,
|
||||||
@@ -33,11 +34,13 @@ class TextHelper:
|
|||||||
Initialize the TextHelper.
|
Initialize the TextHelper.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
font_dir: Directory containing font files (defaults to assets/fonts)
|
font_dir: Directory containing font files. Defaults to the
|
||||||
|
install's assets/fonts, whatever the process cwd is.
|
||||||
logger: Optional logger instance
|
logger: Optional logger instance
|
||||||
"""
|
"""
|
||||||
self.logger = logger or logging.getLogger(__name__)
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
self.font_dir = Path(font_dir) if font_dir else Path("assets/fonts")
|
self.font_dir = Path(font_dir) if font_dir else Path(resolve_asset_path("assets/fonts"))
|
||||||
|
# "<path>:<size>" -> loaded font; see load_fonts.
|
||||||
self._font_cache: Dict[str, ImageFont.ImageFont] = {}
|
self._font_cache: Dict[str, ImageFont.ImageFont] = {}
|
||||||
|
|
||||||
def load_fonts(self, font_config: Optional[Dict[str, Dict]] = None) -> Dict[str, ImageFont.ImageFont]:
|
def load_fonts(self, font_config: Optional[Dict[str, Dict]] = None) -> Dict[str, ImageFont.ImageFont]:
|
||||||
@@ -45,10 +48,12 @@ class TextHelper:
|
|||||||
Load fonts for different text elements.
|
Load fonts for different text elements.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
font_config: Custom font configuration dictionary
|
font_config: ``{name: {"file": <file in font_dir>, "size": <px>}}``;
|
||||||
|
defaults to the scoreboard set in _get_default_font_config.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping font names to PIL ImageFont objects
|
Dictionary mapping font names to PIL ImageFont objects. A font
|
||||||
|
already loaded by this helper at the same size is reused.
|
||||||
"""
|
"""
|
||||||
if font_config is None:
|
if font_config is None:
|
||||||
font_config = self._get_default_font_config()
|
font_config = self._get_default_font_config()
|
||||||
@@ -61,9 +66,13 @@ class TextHelper:
|
|||||||
size = config['size']
|
size = config['size']
|
||||||
|
|
||||||
if font_path.exists():
|
if font_path.exists():
|
||||||
|
cache_key = f"{font_path}:{size}"
|
||||||
|
font = self._font_cache.get(cache_key)
|
||||||
|
if font is None:
|
||||||
font = load_truetype(str(font_path), size)
|
font = load_truetype(str(font_path), size)
|
||||||
fonts[font_name] = font
|
self._font_cache[cache_key] = font
|
||||||
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
||||||
|
fonts[font_name] = font
|
||||||
else:
|
else:
|
||||||
# Fallback to default font
|
# Fallback to default font
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
@@ -115,12 +124,7 @@ class TextHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
Width in pixels
|
Width in pixels
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
return int(_measure_draw.textlength(text, font=font))
|
return int(_measure_draw.textlength(text, font=font))
|
||||||
except AttributeError:
|
|
||||||
# Fallback for older PIL versions
|
|
||||||
bbox = _measure_draw.textbbox((0, 0), text, font=font)
|
|
||||||
return bbox[2] - bbox[0]
|
|
||||||
|
|
||||||
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+20
-27
@@ -16,8 +16,10 @@ additionally keeps rotating backups in ``config/backups/``.
|
|||||||
Plugin configuration
|
Plugin configuration
|
||||||
--------------------
|
--------------------
|
||||||
Plugin configs are stored inside ``config.json`` under the plugin's ID key
|
Plugin configs are stored inside ``config.json`` under the plugin's ID key
|
||||||
and survive plugin reinstalls. Use :meth:`ConfigManager.update_plugin_config`
|
and survive plugin reinstalls. Write them by saving the whole config with
|
||||||
to write plugin settings; never write directly to the plugin directory.
|
:meth:`ConfigManager.save_config_atomic` (or
|
||||||
|
:meth:`ConfigManager.save_raw_file_content`); never write settings into the
|
||||||
|
plugin directory, which a reinstall deletes.
|
||||||
|
|
||||||
Hot-reload
|
Hot-reload
|
||||||
----------
|
----------
|
||||||
@@ -127,13 +129,10 @@ class ConfigManager:
|
|||||||
# Update in-memory config if save was successful
|
# Update in-memory config if save was successful
|
||||||
if result.status == SaveResultStatus.SUCCESS:
|
if result.status == SaveResultStatus.SUCCESS:
|
||||||
self.config = new_config_data
|
self.config = new_config_data
|
||||||
# In-memory config now matches what was just written; refresh
|
# In-memory config now matches what was just written, so the
|
||||||
# the load signature so the fast path stays valid. NOTE: the
|
# load_config fast path may return it. It still carries the
|
||||||
# in-memory copy includes merged secrets; the on-disk file has
|
# merged secrets that were stripped on disk; that matches a full
|
||||||
# them stripped — the fast path returning self.config preserves
|
# reload, because the secrets file was not changed by the save.
|
||||||
# exactly the pre-cache behavior (load-after-save also returned
|
|
||||||
# the secret-merged self.config only after re-reading secrets;
|
|
||||||
# here secrets file is unchanged, so contents are equivalent).
|
|
||||||
self._loaded_sig = self._files_signature()
|
self._loaded_sig = self._files_signature()
|
||||||
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
||||||
elif result.status == SaveResultStatus.ROLLED_BACK:
|
elif result.status == SaveResultStatus.ROLLED_BACK:
|
||||||
@@ -253,11 +252,11 @@ class ConfigManager:
|
|||||||
return self.config
|
return self.config
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
if str(e).find('config_secrets.json') == -1: # Only raise if main config is missing
|
# Only config.json can get here: a missing or unreadable secrets
|
||||||
|
# file is handled where it is read.
|
||||||
error_msg = f"Configuration file not found at {os.path.abspath(self.config_path)}"
|
error_msg = f"Configuration file not found at {os.path.abspath(self.config_path)}"
|
||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||||
return self.config
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
error_msg = f"Error parsing configuration file {os.path.abspath(self.config_path)}"
|
error_msg = f"Error parsing configuration file {os.path.abspath(self.config_path)}"
|
||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
@@ -320,10 +319,9 @@ class ConfigManager:
|
|||||||
A missing secrets file is fine (nothing to strip). But a file that
|
A missing secrets file is fine (nothing to strip). But a file that
|
||||||
EXISTS and cannot be read or parsed means stripping is impossible —
|
EXISTS and cannot be read or parsed means stripping is impossible —
|
||||||
and the in-memory config being saved has secrets deep-merged into it,
|
and the in-memory config being saved has secrets deep-merged into it,
|
||||||
so proceeding would write them into config.json in plaintext. That
|
so proceeding would write them into config.json in plaintext. The
|
||||||
was the historical behavior; it is now a hard refusal. The save
|
save raises instead, so the caller (and user) fixes the secrets file
|
||||||
raises so the caller (and user) fixes the secrets file instead of
|
rather than leaking its contents into the world-readable main config.
|
||||||
silently leaking its contents into the world-readable main config.
|
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(self.secrets_path):
|
if not os.path.exists(self.secrets_path):
|
||||||
return {}
|
return {}
|
||||||
@@ -465,9 +463,8 @@ class ConfigManager:
|
|||||||
# Merge template defaults into current config
|
# Merge template defaults into current config
|
||||||
self._merge_template_defaults(self.config, template_config)
|
self._merge_template_defaults(self.config, template_config)
|
||||||
|
|
||||||
# Save migrated config using atomic save to preserve permissions
|
# save_config_atomic strips the merged secrets back out and
|
||||||
# Use atomic save to preserve file permissions
|
# keeps the file's owner and mode.
|
||||||
# Note: save_config_atomic handles secrets internally
|
|
||||||
result = self.save_config_atomic(
|
result = self.save_config_atomic(
|
||||||
new_config_data=self.config,
|
new_config_data=self.config,
|
||||||
create_backup=False, # Already created backup above
|
create_backup=False, # Already created backup above
|
||||||
@@ -600,16 +597,12 @@ class ConfigManager:
|
|||||||
|
|
||||||
self.logger.info(f"{file_type.capitalize()} configuration successfully saved to {os.path.abspath(path_to_save)}")
|
self.logger.info(f"{file_type.capitalize()} configuration successfully saved to {os.path.abspath(path_to_save)}")
|
||||||
|
|
||||||
# If we just saved the main config or secrets, the merged self.config might be stale.
|
# The merged self.config is now stale; reload it. A reload failure
|
||||||
# Reload it to reflect the new state.
|
# (a migration error, say) is logged, not raised: the file itself
|
||||||
# Note: We wrap this in try-except because reload failures (e.g., migration errors)
|
# was saved.
|
||||||
# should not cause the save operation to fail - the file was saved successfully.
|
|
||||||
if file_type == "main" or file_type == "secrets":
|
|
||||||
try:
|
try:
|
||||||
self.load_config()
|
self.load_config()
|
||||||
except Exception as reload_error:
|
except Exception as reload_error:
|
||||||
# Log the reload error but don't fail the save operation
|
|
||||||
# The file was saved successfully, reload is just for in-memory consistency
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
f"Configuration file saved successfully, but reload failed: {reload_error}. "
|
f"Configuration file saved successfully, but reload failed: {reload_error}. "
|
||||||
f"The file on disk is valid, but in-memory config may be stale."
|
f"The file on disk is valid, but in-memory config may be stale."
|
||||||
@@ -670,7 +663,7 @@ class ConfigManager:
|
|||||||
try:
|
try:
|
||||||
# Load current configs
|
# Load current configs
|
||||||
main_config = self.get_raw_file_content('main')
|
main_config = self.get_raw_file_content('main')
|
||||||
secrets_config = self.get_raw_file_content('secrets') if os.path.exists(self.secrets_path) else {}
|
secrets_config = self.get_raw_file_content('secrets') # {} when there is no file
|
||||||
|
|
||||||
# Remove plugin from main config
|
# Remove plugin from main config
|
||||||
if plugin_id in main_config:
|
if plugin_id in main_config:
|
||||||
@@ -703,7 +696,7 @@ class ConfigManager:
|
|||||||
try:
|
try:
|
||||||
# Load current configs
|
# Load current configs
|
||||||
main_config = self.get_raw_file_content('main')
|
main_config = self.get_raw_file_content('main')
|
||||||
secrets_config = self.get_raw_file_content('secrets') if os.path.exists(self.secrets_path) else {}
|
secrets_config = self.get_raw_file_content('secrets') # {} when there is no file
|
||||||
|
|
||||||
valid_set = set(valid_plugin_ids)
|
valid_set = set(valid_plugin_ids)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import time
|
|||||||
import requests
|
import requests
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class DynamicTeamResolver:
|
class DynamicTeamResolver:
|
||||||
@@ -141,7 +143,9 @@ class DynamicTeamResolver:
|
|||||||
self.logger.info("Fetching fresh NCAA Football rankings from ESPN API")
|
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"
|
rankings_url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings"
|
||||||
|
|
||||||
response = requests.get(rankings_url, timeout=self.request_timeout)
|
# ESPN rejects requests' default User-Agent; see api_helper.USER_AGENT.
|
||||||
|
response = requests.get(rankings_url, headers=dict(DEFAULT_HTTP_HEADERS),
|
||||||
|
timeout=self.request_timeout)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|||||||
+31
-25
@@ -90,6 +90,17 @@ def _cache_put(key: Tuple[str, int], value: Tuple[Any, int]) -> None:
|
|||||||
# Config keys a style element block carries, in schema/UI order.
|
# Config keys a style element block carries, in schema/UI order.
|
||||||
_STYLE_KEYS = ('font', 'font_size', 'text_color', 'visible', 'align')
|
_STYLE_KEYS = ('font', 'font_size', 'text_color', 'visible', 'align')
|
||||||
|
|
||||||
|
# Title of every generated `layout` (x/y offset) group in the config form.
|
||||||
|
_LAYOUT_TITLE = 'Layout Offsets'
|
||||||
|
|
||||||
|
#: Bounds on a user-set ``customization.layout.<element>.scale``. They are the
|
||||||
|
#: Scale field's minimum and maximum in the generated schema, and every reader
|
||||||
|
#: (coerce_scale, element_scale, LogoHelper.load_logo) clamps to them, so the
|
||||||
|
#: web form and the renderer agree. Below 0.1 a logo is a dot; ten times a
|
||||||
|
#: panel-sized box is already far off the panel.
|
||||||
|
MIN_ELEMENT_SCALE = 0.1
|
||||||
|
MAX_ELEMENT_SCALE = 10.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ElementStyle:
|
class ElementStyle:
|
||||||
@@ -301,7 +312,7 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
if layout_props:
|
if layout_props:
|
||||||
layout = props.setdefault('layout', {
|
layout = props.setdefault('layout', {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'title': 'Layout Offsets',
|
'title': _LAYOUT_TITLE,
|
||||||
'description': 'Pixel offsets applied to each element '
|
'description': 'Pixel offsets applied to each element '
|
||||||
'(positive x moves right, positive y moves down)',
|
'(positive x moves right, positive y moves down)',
|
||||||
'x-advanced': True,
|
'x-advanced': True,
|
||||||
@@ -345,16 +356,14 @@ def _element_block_from_spec(element_key: str,
|
|||||||
'type': 'string',
|
'type': 'string',
|
||||||
'title': 'Font Family',
|
'title': 'Font Family',
|
||||||
'x-advanced': True,
|
'x-advanced': True,
|
||||||
# The core already ships this widget and the config form already
|
# The core's font picker; without the hint the form renders a
|
||||||
# allowlists it; without the hint the field rendered as a bare
|
# bare text box the user has to type a filename into.
|
||||||
# text box the user had to type a filename into.
|
|
||||||
'x-widget': 'font-selector',
|
'x-widget': 'font-selector',
|
||||||
}
|
}
|
||||||
# A bitmap font ignores font_size and renders at its own baked-in
|
# A bitmap font ignores font_size and renders at its own baked-in
|
||||||
# size, so the size ceiling has to be enforced when picking the
|
# size, so the size ceiling has to be enforced when picking the
|
||||||
# font, not when setting the size.
|
# font, not when setting the size.
|
||||||
max_size = (size_spec or {}).get('max') if isinstance(
|
max_size = size_spec.get('max') if size_spec else None
|
||||||
spec.get('size'), dict) else None
|
|
||||||
if isinstance(max_size, (int, float)):
|
if isinstance(max_size, (int, float)):
|
||||||
font_prop['x-options'] = {'maxFixedSize': max_size}
|
font_prop['x-options'] = {'maxFixedSize': max_size}
|
||||||
if 'default' in font_spec:
|
if 'default' in font_spec:
|
||||||
@@ -457,8 +466,8 @@ def _offset_block_from_spec(element_key: str,
|
|||||||
'title': 'Scale',
|
'title': 'Scale',
|
||||||
'description': 'Size multiplier; 1 is the shipped size.',
|
'description': 'Size multiplier; 1 is the shipped size.',
|
||||||
'default': 1.0,
|
'default': 1.0,
|
||||||
'minimum': 0.1,
|
'minimum': MIN_ELEMENT_SCALE,
|
||||||
'maximum': 10.0,
|
'maximum': MAX_ELEMENT_SCALE,
|
||||||
'x-advanced': True,
|
'x-advanced': True,
|
||||||
}
|
}
|
||||||
if isinstance(scale_spec, dict):
|
if isinstance(scale_spec, dict):
|
||||||
@@ -539,7 +548,7 @@ def _modes_block(declaration: Dict[str, Any],
|
|||||||
if layout_props:
|
if layout_props:
|
||||||
element_props['layout'] = {
|
element_props['layout'] = {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'title': 'Layout Offsets',
|
'title': _LAYOUT_TITLE,
|
||||||
'x-advanced': True,
|
'x-advanced': True,
|
||||||
'additionalProperties': False,
|
'additionalProperties': False,
|
||||||
'properties': layout_props,
|
'properties': layout_props,
|
||||||
@@ -680,7 +689,7 @@ def _modes_block_from_properties(props: Dict[str, Any], element_keys: list,
|
|||||||
if layout_props:
|
if layout_props:
|
||||||
element_props['layout'] = {
|
element_props['layout'] = {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'title': 'Layout Offsets',
|
'title': _LAYOUT_TITLE,
|
||||||
'x-advanced': True,
|
'x-advanced': True,
|
||||||
'additionalProperties': False,
|
'additionalProperties': False,
|
||||||
'properties': layout_props,
|
'properties': layout_props,
|
||||||
@@ -1010,12 +1019,15 @@ def _coerce_align(value: Any) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _coerce_scale(value: Any, default: float) -> float:
|
def coerce_scale(value: Any, default: float = 1.0) -> float:
|
||||||
"""A positive size multiplier, or ``default``.
|
"""A usable size multiplier: ``value`` clamped to
|
||||||
|
[MIN_ELEMENT_SCALE, MAX_ELEMENT_SCALE], or ``default``.
|
||||||
|
|
||||||
Clamped rather than merely validated: a scale of 0 or a negative one is
|
``default`` is returned for anything that is not a finite positive number
|
||||||
a zero-or-inverted image, and the panel is 32 pixels tall -- a typo
|
(None, a bool, a string, 0, a negative, NaN, infinity): those are typos,
|
||||||
should cost a wrong size, not a crash inside PIL.
|
and a typo should cost the shipped size, not a blank or inverted image or
|
||||||
|
a crash inside PIL. A positive number outside the range is a real request
|
||||||
|
for "smaller" or "bigger", so it is clamped rather than ignored.
|
||||||
"""
|
"""
|
||||||
if isinstance(value, bool) or value is None:
|
if isinstance(value, bool) or value is None:
|
||||||
return default
|
return default
|
||||||
@@ -1023,9 +1035,9 @@ def _coerce_scale(value: Any, default: float) -> float:
|
|||||||
scale = float(value)
|
scale = float(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
return default
|
||||||
if scale <= 0:
|
if not math.isfinite(scale) or scale <= 0:
|
||||||
return default
|
return default
|
||||||
return min(scale, 10.0)
|
return min(max(scale, MIN_ELEMENT_SCALE), MAX_ELEMENT_SCALE)
|
||||||
|
|
||||||
|
|
||||||
def _coerce_offset(value: Any, default: int, element_key: str,
|
def _coerce_offset(value: Any, default: int, element_key: str,
|
||||||
@@ -1194,7 +1206,7 @@ def element_scale(config: Any, element_key: str, default: float = 1.0,
|
|||||||
try:
|
try:
|
||||||
value = _element_field(config, element_key, 'scale', mode,
|
value = _element_field(config, element_key, 'scale', mode,
|
||||||
in_layout=True)
|
in_layout=True)
|
||||||
return default if value is None else _coerce_scale(value, default)
|
return default if value is None else coerce_scale(value, default)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error reading scale for %s: %s", element_key, e)
|
logger.warning("Error reading scale for %s: %s", element_key, e)
|
||||||
return default
|
return default
|
||||||
@@ -1371,12 +1383,6 @@ class ElementStyleResolver:
|
|||||||
return {}
|
return {}
|
||||||
return _lookup_element(block.get('layout'), element_key)
|
return _lookup_element(block.get('layout'), element_key)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _layout_axis(cls, block: Dict[str, Any], element_key: str,
|
|
||||||
axis: str) -> Any:
|
|
||||||
"""``block['layout'][element][axis]``, or None if absent anywhere."""
|
|
||||||
return cls._layout_element(block, element_key).get(axis)
|
|
||||||
|
|
||||||
|
|
||||||
# -- resolution internals -----------------------------------------------
|
# -- resolution internals -----------------------------------------------
|
||||||
|
|
||||||
@@ -1497,7 +1503,7 @@ class ElementStyleResolver:
|
|||||||
user_forced_color=bool(color_forced),
|
user_forced_color=bool(color_forced),
|
||||||
visible=_coerce_bool(visible, True),
|
visible=_coerce_bool(visible, True),
|
||||||
align=_coerce_align(align),
|
align=_coerce_align(align),
|
||||||
scale=_coerce_scale(scale, 1.0),
|
scale=coerce_scale(scale, 1.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _classic_style(self, classic_font: str, classic_size: int,
|
def _classic_style(self, classic_font: str, classic_size: int,
|
||||||
|
|||||||
+16
-3
@@ -26,6 +26,19 @@ from src.exceptions import LEDMatrixError
|
|||||||
from src.redaction import redact_credentials
|
from src.redaction import redact_credentials
|
||||||
|
|
||||||
|
|
||||||
|
def _format_trace(error: BaseException) -> str:
|
||||||
|
"""The traceback carried by ``error`` itself.
|
||||||
|
|
||||||
|
Callers often record an exception after its ``except`` block has ended,
|
||||||
|
or from another thread than the one that raised it (plugin_executor runs
|
||||||
|
plugins on worker threads), where ``traceback.format_exc()`` has nothing
|
||||||
|
to report. The exception object keeps its own ``__traceback__``, so the
|
||||||
|
trace is built from that. An exception that was created but never raised
|
||||||
|
has no traceback, and the result is just its type and message.
|
||||||
|
"""
|
||||||
|
return "".join(traceback.format_exception(type(error), error, error.__traceback__))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ErrorRecord:
|
class ErrorRecord:
|
||||||
"""Record of a single error occurrence."""
|
"""Record of a single error occurrence."""
|
||||||
@@ -145,8 +158,8 @@ class ErrorAggregator:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
error_type = type(error).__name__
|
error_type = type(error).__name__
|
||||||
|
|
||||||
# Extract additional context from LEDMatrixError subclasses
|
# A copy, so the caller's dict is not changed behind its back.
|
||||||
error_context = context or {}
|
error_context = dict(context) if context else {}
|
||||||
if isinstance(error, LEDMatrixError) and error.context:
|
if isinstance(error, LEDMatrixError) and error.context:
|
||||||
error_context.update(error.context)
|
error_context.update(error.context)
|
||||||
|
|
||||||
@@ -157,7 +170,7 @@ class ErrorAggregator:
|
|||||||
context=error_context,
|
context=error_context,
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
stack_trace=traceback.format_exc()
|
stack_trace=_format_trace(error)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add record (with size limit)
|
# Add record (with size limit)
|
||||||
|
|||||||
+52
-73
@@ -40,6 +40,11 @@ from pathlib import Path
|
|||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
|
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
|
||||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||||
|
from src.common.permission_utils import (
|
||||||
|
ensure_directory_permissions,
|
||||||
|
get_assets_dir_mode,
|
||||||
|
get_config_dir_mode,
|
||||||
|
)
|
||||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||||
from src.deprecation import deprecated
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
@@ -57,7 +62,6 @@ class FontManager:
|
|||||||
|
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.fonts_config = config.get("fonts", {})
|
|
||||||
|
|
||||||
# Font discovery and catalog
|
# Font discovery and catalog
|
||||||
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
||||||
@@ -73,10 +77,8 @@ class FontManager:
|
|||||||
# Plugin font management
|
# Plugin font management
|
||||||
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
||||||
self.plugin_font_catalogs: Dict[str, Dict[str, str]] = {} # plugin_id -> {family_name -> file_path}
|
self.plugin_font_catalogs: Dict[str, Dict[str, str]] = {} # plugin_id -> {family_name -> file_path}
|
||||||
self.font_metadata: Dict[str, Dict[str, Any]] = {} # family_name -> metadata
|
|
||||||
self.font_dependencies: Dict[str, List[str]] = {} # family_name -> [required_families]
|
|
||||||
|
|
||||||
# Manager font registration - NEW for manager-centric model
|
# Fonts managers and plugins report using (register_manager_font).
|
||||||
self.manager_fonts: Dict[str, Dict[str, Any]] = {} # manager_id -> {element_key: {family, size_px, color}}
|
self.manager_fonts: Dict[str, Dict[str, Any]] = {} # manager_id -> {element_key: {family, size_px, color}}
|
||||||
self.detected_fonts: Dict[str, Dict[str, Any]] = {} # element_key -> {family, size_px, color, manager_id, usage_count}
|
self.detected_fonts: Dict[str, Dict[str, Any]] = {} # element_key -> {family, size_px, color, manager_id, usage_count}
|
||||||
# Bumped when a manager's registered families change (not when one
|
# Bumped when a manager's registered families change (not when one
|
||||||
@@ -88,13 +90,10 @@ class FontManager:
|
|||||||
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
|
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
|
||||||
self.temp_font_dir.mkdir(exist_ok=True)
|
self.temp_font_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
# Performance monitoring
|
# Counters behind get_performance_stats().
|
||||||
self.performance_stats = {
|
self.performance_stats = {
|
||||||
"font_load_times": {},
|
|
||||||
"cache_hits": 0,
|
"cache_hits": 0,
|
||||||
"cache_misses": 0,
|
"cache_misses": 0,
|
||||||
"render_times": {},
|
|
||||||
"total_renders": 0,
|
|
||||||
"failed_loads": 0,
|
"failed_loads": 0,
|
||||||
"start_time": time.time()
|
"start_time": time.time()
|
||||||
}
|
}
|
||||||
@@ -105,9 +104,6 @@ class FontManager:
|
|||||||
"four_by_six": "assets/fonts/4x6-font.ttf",
|
"four_by_six": "assets/fonts/4x6-font.ttf",
|
||||||
"five_by_seven": "assets/fonts/5x7.bdf",
|
"five_by_seven": "assets/fonts/5x7.bdf",
|
||||||
"tom_thumb": "assets/fonts/tom-thumb.bdf"
|
"tom_thumb": "assets/fonts/tom-thumb.bdf"
|
||||||
# Note: cozette_bdf removed - font file not available
|
|
||||||
# To re-enable: download cozette.bdf from https://github.com/the-moonwitch/Cozette
|
|
||||||
# and add: "cozette_bdf": "assets/fonts/cozette.bdf"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Size tokens for convenience
|
# Size tokens for convenience
|
||||||
@@ -116,7 +112,10 @@ class FontManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Font overrides storage (for manual overrides)
|
# Font overrides storage (for manual overrides)
|
||||||
self.font_overrides_file = "config/font_overrides.json"
|
# Under the install root's config/ (which always exists), not the
|
||||||
|
# cwd: the file itself may not exist yet, and resolve_asset_path
|
||||||
|
# hands back a missing path unchanged.
|
||||||
|
self.font_overrides_file = os.path.join(resolve_asset_path("config"), "font_overrides.json")
|
||||||
self.font_overrides: Dict[str, Dict[str, Any]] = {}
|
self.font_overrides: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
# Bumped whenever cached font objects are invalidated, so holders of
|
# Bumped whenever cached font objects are invalidated, so holders of
|
||||||
@@ -128,7 +127,6 @@ class FontManager:
|
|||||||
def reload_config(self, new_config: Dict[str, Any]):
|
def reload_config(self, new_config: Dict[str, Any]):
|
||||||
"""Reload configuration and refresh font catalog."""
|
"""Reload configuration and refresh font catalog."""
|
||||||
self.config = new_config
|
self.config = new_config
|
||||||
self.fonts_config = new_config.get("fonts", {})
|
|
||||||
self.font_cache.clear() # Clear cache to force reload
|
self.font_cache.clear() # Clear cache to force reload
|
||||||
self.metrics_cache.clear() # Clear metrics cache
|
self.metrics_cache.clear() # Clear metrics cache
|
||||||
self.cache_generation += 1
|
self.cache_generation += 1
|
||||||
@@ -136,7 +134,6 @@ class FontManager:
|
|||||||
logger.info("FontManager configuration reloaded successfully")
|
logger.info("FontManager configuration reloaded successfully")
|
||||||
|
|
||||||
# ==================== Manager Font Registration ====================
|
# ==================== Manager Font Registration ====================
|
||||||
# NEW: Support for managers to register their font choices dynamically
|
|
||||||
|
|
||||||
def register_manager_font(self, manager_id: str, element_key: str,
|
def register_manager_font(self, manager_id: str, element_key: str,
|
||||||
family: str, size_px: int, color: Optional[Tuple[int, int, int]] = None):
|
family: str, size_px: int, color: Optional[Tuple[int, int, int]] = None):
|
||||||
@@ -209,16 +206,23 @@ class FontManager:
|
|||||||
|
|
||||||
# ==================== Plugin Font Management ====================
|
# ==================== Plugin Font Management ====================
|
||||||
|
|
||||||
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any]) -> bool:
|
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any],
|
||||||
|
plugin_dir: Optional[Union[str, Path]] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Register fonts for a specific plugin.
|
Register fonts for a specific plugin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Unique identifier for the plugin
|
plugin_id: Unique identifier for the plugin
|
||||||
font_manifest: Font manifest from plugin's manifest.json
|
font_manifest: The ``fonts`` block of the plugin's manifest.json
|
||||||
|
plugin_dir: The plugin's directory, which ``plugin://`` sources
|
||||||
|
are relative to. PluginManager passes the directory it loaded
|
||||||
|
the plugin from. When omitted, the plugin is looked up in the
|
||||||
|
configured ``plugin_system.plugins_directory`` and then in
|
||||||
|
``plugins/``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if registration successful, False otherwise
|
True if the manifest was valid (individual fonts that fail to load
|
||||||
|
are logged and skipped), False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Validate font manifest structure
|
# Validate font manifest structure
|
||||||
@@ -235,7 +239,7 @@ class FontManager:
|
|||||||
# Process font definitions
|
# Process font definitions
|
||||||
fonts = font_manifest.get("fonts", [])
|
fonts = font_manifest.get("fonts", [])
|
||||||
for font_def in fonts:
|
for font_def in fonts:
|
||||||
if self._register_plugin_font(plugin_id, font_def):
|
if self._register_plugin_font(plugin_id, font_def, plugin_dir):
|
||||||
logger.info(f"Successfully registered font {font_def.get('family')} for plugin {plugin_id}")
|
logger.info(f"Successfully registered font {font_def.get('family')} for plugin {plugin_id}")
|
||||||
|
|
||||||
logger.info(f"Registered {len(fonts)} fonts for plugin {plugin_id}")
|
logger.info(f"Registered {len(fonts)} fonts for plugin {plugin_id}")
|
||||||
@@ -270,7 +274,8 @@ class FontManager:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _register_plugin_font(self, plugin_id: str, font_def: Dict[str, Any]) -> bool:
|
def _register_plugin_font(self, plugin_id: str, font_def: Dict[str, Any],
|
||||||
|
plugin_dir: Optional[Union[str, Path]] = None) -> bool:
|
||||||
"""Register a single font from a plugin."""
|
"""Register a single font from a plugin."""
|
||||||
try:
|
try:
|
||||||
family = font_def["family"]
|
family = font_def["family"]
|
||||||
@@ -284,7 +289,7 @@ class FontManager:
|
|||||||
elif source.startswith("plugin://"):
|
elif source.startswith("plugin://"):
|
||||||
# Relative to plugin directory
|
# Relative to plugin directory
|
||||||
relative_path = source.replace("plugin://", "")
|
relative_path = source.replace("plugin://", "")
|
||||||
font_path = self._resolve_plugin_font_path(plugin_id, relative_path)
|
font_path = self._resolve_plugin_font_path(plugin_id, relative_path, plugin_dir)
|
||||||
else:
|
else:
|
||||||
# Absolute or relative path
|
# Absolute or relative path
|
||||||
font_path = source
|
font_path = source
|
||||||
@@ -298,14 +303,6 @@ class FontManager:
|
|||||||
self.plugin_font_catalogs[plugin_id][family] = font_path
|
self.plugin_font_catalogs[plugin_id][family] = font_path
|
||||||
self.font_catalog[namespaced_family] = font_path
|
self.font_catalog[namespaced_family] = font_path
|
||||||
|
|
||||||
# Store metadata
|
|
||||||
if "metadata" in font_def:
|
|
||||||
self.font_metadata[namespaced_family] = font_def["metadata"]
|
|
||||||
|
|
||||||
# Store dependencies
|
|
||||||
if "dependencies" in font_def:
|
|
||||||
self.font_dependencies[namespaced_family] = font_def["dependencies"]
|
|
||||||
|
|
||||||
logger.info(f"Registered plugin font: {namespaced_family} -> {font_path}")
|
logger.info(f"Registered plugin font: {namespaced_family} -> {font_path}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -367,11 +364,15 @@ class FontManager:
|
|||||||
return '.zip'
|
return '.zip'
|
||||||
return '.ttf' # default
|
return '.ttf' # default
|
||||||
|
|
||||||
def _resolve_plugin_font_path(self, plugin_id: str, relative_path: str) -> Optional[str]:
|
def _resolve_plugin_font_path(self, plugin_id: str, relative_path: str,
|
||||||
"""Resolve a plugin-relative font path."""
|
plugin_dir: Optional[Union[str, Path]] = None) -> Optional[str]:
|
||||||
# Assume plugins are in a 'plugins' directory
|
"""Resolve a ``plugin://`` font path against the plugin's directory."""
|
||||||
plugin_dir = Path("plugins") / plugin_id
|
if plugin_dir is None:
|
||||||
font_path = plugin_dir / relative_path
|
plugin_dir = self._find_plugin_dir(plugin_id)
|
||||||
|
if plugin_dir is None:
|
||||||
|
logger.error(f"Plugin font {relative_path}: directory for plugin {plugin_id} not found")
|
||||||
|
return None
|
||||||
|
font_path = Path(plugin_dir) / relative_path
|
||||||
|
|
||||||
if font_path.exists():
|
if font_path.exists():
|
||||||
return str(font_path)
|
return str(font_path)
|
||||||
@@ -379,6 +380,18 @@ class FontManager:
|
|||||||
logger.error(f"Plugin font not found: {font_path}")
|
logger.error(f"Plugin font not found: {font_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _find_plugin_dir(self, plugin_id: str) -> Optional[Path]:
|
||||||
|
"""The installed directory of ``plugin_id``, for callers of
|
||||||
|
register_plugin_fonts that do not pass one: the configured plugins
|
||||||
|
directory (relative paths are relative to the install root), then the
|
||||||
|
legacy ``plugins/`` directory."""
|
||||||
|
# Imported here: src.plugin_system's package import loads PluginManager.
|
||||||
|
from src.plugin_system.plugin_dirs import resolve_plugin_dir
|
||||||
|
|
||||||
|
configured = (self.config.get("plugin_system") or {}).get("plugins_directory") or "plugin-repos"
|
||||||
|
search_dirs = [Path(resolve_asset_path(configured)), Path(resolve_asset_path("plugins"))]
|
||||||
|
return resolve_plugin_dir(plugin_id, search_dirs, prefix=True)
|
||||||
|
|
||||||
@deprecated("3.7.0")
|
@deprecated("3.7.0")
|
||||||
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
|
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
|
||||||
"""Unregister all fonts for a plugin."""
|
"""Unregister all fonts for a plugin."""
|
||||||
@@ -390,8 +403,6 @@ class FontManager:
|
|||||||
namespaced_family = f"{plugin_id}::{family}"
|
namespaced_family = f"{plugin_id}::{family}"
|
||||||
if namespaced_family in self.font_catalog:
|
if namespaced_family in self.font_catalog:
|
||||||
del self.font_catalog[namespaced_family]
|
del self.font_catalog[namespaced_family]
|
||||||
if namespaced_family in self.font_metadata:
|
|
||||||
del self.font_metadata[namespaced_family]
|
|
||||||
|
|
||||||
del self.plugin_font_catalogs[plugin_id]
|
del self.plugin_font_catalogs[plugin_id]
|
||||||
|
|
||||||
@@ -442,8 +453,6 @@ class FontManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Resolved font object
|
Resolved font object
|
||||||
"""
|
"""
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check for manual overrides first
|
# Check for manual overrides first
|
||||||
if element_key in self.font_overrides:
|
if element_key in self.font_overrides:
|
||||||
@@ -460,14 +469,7 @@ class FontManager:
|
|||||||
if plugin_id in self.plugin_font_catalogs and family in self.plugin_font_catalogs[plugin_id]:
|
if plugin_id in self.plugin_font_catalogs and family in self.plugin_font_catalogs[plugin_id]:
|
||||||
family = f"{plugin_id}::{family}"
|
family = f"{plugin_id}::{family}"
|
||||||
|
|
||||||
# Get the font
|
return self.get_font(family, size_px)
|
||||||
font = self.get_font(family, size_px)
|
|
||||||
|
|
||||||
# Record performance
|
|
||||||
duration = time.time() - start_time
|
|
||||||
self._record_performance_metric("resolve", f"{family}_{size_px}", duration)
|
|
||||||
|
|
||||||
return font
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error resolving font for {element_key}: {e}", exc_info=True)
|
logger.error(f"Error resolving font for {element_key}: {e}", exc_info=True)
|
||||||
@@ -491,7 +493,6 @@ class FontManager:
|
|||||||
return self.font_cache[cache_key]
|
return self.font_cache[cache_key]
|
||||||
|
|
||||||
self.performance_stats["cache_misses"] += 1
|
self.performance_stats["cache_misses"] += 1
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# Load font
|
# Load font
|
||||||
font_path = self.font_catalog.get(family)
|
font_path = self.font_catalog.get(family)
|
||||||
@@ -506,15 +507,13 @@ class FontManager:
|
|||||||
else:
|
else:
|
||||||
font = load_truetype(font_path, size_px)
|
font = load_truetype(font_path, size_px)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# The one log line for a failed load: _load_bdf_font lets
|
||||||
|
# its error propagate to here.
|
||||||
logger.error(f"Error loading font {font_path}: {e}")
|
logger.error(f"Error loading font {font_path}: {e}")
|
||||||
self.performance_stats["failed_loads"] += 1
|
self.performance_stats["failed_loads"] += 1
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
# Cache and record performance
|
|
||||||
self.font_cache[cache_key] = font
|
self.font_cache[cache_key] = font
|
||||||
duration = time.time() - start_time
|
|
||||||
self.performance_stats["font_load_times"][cache_key] = duration
|
|
||||||
|
|
||||||
return font
|
return font
|
||||||
|
|
||||||
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
||||||
@@ -524,11 +523,7 @@ class FontManager:
|
|||||||
rather than failing over to PIL's default font, a different typeface
|
rather than failing over to PIL's default font, a different typeface
|
||||||
(see :func:`src.common.bdf_font.load_bdf_face`).
|
(see :func:`src.common.bdf_font.load_bdf_face`).
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
return load_bdf_face(font_path, size_px)[0]
|
return load_bdf_face(font_path, size_px)[0]
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error loading BDF font {font_path}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
||||||
"""The one true pixel size of a BDF family in the catalog, or None
|
"""The one true pixel size of a BDF family in the catalog, or None
|
||||||
@@ -723,11 +718,6 @@ class FontManager:
|
|||||||
def _save_overrides(self):
|
def _save_overrides(self):
|
||||||
"""Save current font overrides to file."""
|
"""Save current font overrides to file."""
|
||||||
try:
|
try:
|
||||||
from pathlib import Path
|
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_directory_permissions,
|
|
||||||
get_config_dir_mode
|
|
||||||
)
|
|
||||||
font_overrides_path = Path(self.font_overrides_file)
|
font_overrides_path = Path(self.font_overrides_file)
|
||||||
ensure_directory_permissions(font_overrides_path.parent, get_config_dir_mode())
|
ensure_directory_permissions(font_overrides_path.parent, get_config_dir_mode())
|
||||||
with open(self.font_overrides_file, 'w') as f:
|
with open(self.font_overrides_file, 'w') as f:
|
||||||
@@ -754,12 +744,6 @@ class FontManager:
|
|||||||
"""Get available size tokens."""
|
"""Get available size tokens."""
|
||||||
return self.size_tokens.copy()
|
return self.size_tokens.copy()
|
||||||
|
|
||||||
def _record_performance_metric(self, operation: str, font_key: str, duration: float):
|
|
||||||
"""Record a performance metric."""
|
|
||||||
if operation not in self.performance_stats:
|
|
||||||
self.performance_stats[operation] = {}
|
|
||||||
self.performance_stats[operation][font_key] = duration
|
|
||||||
|
|
||||||
@deprecated("3.7.0")
|
@deprecated("3.7.0")
|
||||||
def get_performance_stats(self) -> Dict[str, Any]:
|
def get_performance_stats(self) -> Dict[str, Any]:
|
||||||
"""Get performance statistics."""
|
"""Get performance statistics."""
|
||||||
@@ -789,7 +773,8 @@ class FontManager:
|
|||||||
|
|
||||||
@deprecated("3.7.0")
|
@deprecated("3.7.0")
|
||||||
def add_font(self, font_file_path: str, family_name: str) -> bool:
|
def add_font(self, font_file_path: str, family_name: str) -> bool:
|
||||||
"""Add a new font to the catalog."""
|
"""Add ``font_file_path`` to the catalog as ``family_name``. The file
|
||||||
|
stays where it is; only assets/fonts is created if it is missing."""
|
||||||
try:
|
try:
|
||||||
# Validate font file
|
# Validate font file
|
||||||
if not os.path.exists(font_file_path):
|
if not os.path.exists(font_file_path):
|
||||||
@@ -801,13 +786,7 @@ class FontManager:
|
|||||||
logger.warning(f"Font family '{family_name}' already exists")
|
logger.warning(f"Font family '{family_name}' already exists")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Copy font to assets/fonts directory
|
fonts_dir = Path(resolve_asset_path("assets/fonts"))
|
||||||
from pathlib import Path
|
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_directory_permissions,
|
|
||||||
get_assets_dir_mode
|
|
||||||
)
|
|
||||||
fonts_dir = Path("assets/fonts")
|
|
||||||
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
|
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
|
||||||
|
|
||||||
# Add to catalog
|
# Add to catalog
|
||||||
|
|||||||
+47
-70
@@ -16,7 +16,7 @@ import json
|
|||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
|
from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
|
||||||
from src.common.font_layout import load_truetype
|
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||||
from PIL.PngImagePlugin import PngInfo
|
from PIL.PngImagePlugin import PngInfo
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
@@ -370,22 +370,15 @@ class LogoDownloader:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_logo_filename_variations(abbreviation: str) -> list:
|
def get_logo_filename_variations(abbreviation: str) -> list:
|
||||||
"""Get possible filename variations for a team abbreviation."""
|
"""Filenames a logo for ``abbreviation`` may be stored under: the
|
||||||
variations = []
|
upper-cased abbreviation as given, then its normalize_abbreviation()
|
||||||
|
form (``TA&M.png``, then ``TAANDM.png``)."""
|
||||||
original = abbreviation.upper()
|
original = abbreviation.upper()
|
||||||
normalized = LogoDownloader.normalize_abbreviation(abbreviation)
|
normalized = LogoDownloader.normalize_abbreviation(abbreviation)
|
||||||
|
return [f"{original}.png", f"{normalized}.png"]
|
||||||
|
|
||||||
# Add original and normalized versions
|
# Allowlist for a league name or code that goes into a filesystem path or
|
||||||
variations.extend([f"{original}.png", f"{normalized}.png"])
|
# an ESPN URL: lower-case alphanumerics, underscores and dashes only.
|
||||||
|
|
||||||
# Special handling for known cases
|
|
||||||
if original == 'TA&M':
|
|
||||||
# TA&M has a file named TA&M.png, but normalize creates TAANDM.png
|
|
||||||
variations = [f"{original}.png", f"{normalized}.png"]
|
|
||||||
|
|
||||||
return variations
|
|
||||||
|
|
||||||
# Allowlist for league names used in filesystem paths: alphanumerics, underscores, dashes only
|
|
||||||
_SAFE_LEAGUE_RE = re.compile(r'^[a-z0-9_-]+$')
|
_SAFE_LEAGUE_RE = re.compile(r'^[a-z0-9_-]+$')
|
||||||
|
|
||||||
def get_logo_directory(self, league: str) -> str:
|
def get_logo_directory(self, league: str) -> str:
|
||||||
@@ -462,15 +455,12 @@ class LogoDownloader:
|
|||||||
logger.error(f"Unexpected error downloading logo for {team_abbreviation}: {e}")
|
logger.error(f"Unexpected error downloading logo for {team_abbreviation}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Allowlist for the league_code segment interpolated into ESPN API URLs
|
|
||||||
_SAFE_LEAGUE_CODE_RE = re.compile(r'^[a-z0-9_-]+$')
|
|
||||||
|
|
||||||
def _resolve_api_url(self, league: str) -> Optional[str]:
|
def _resolve_api_url(self, league: str) -> Optional[str]:
|
||||||
"""Resolve the ESPN API teams URL for a league, with dynamic fallback for custom soccer leagues."""
|
"""Resolve the ESPN API teams URL for a league, with dynamic fallback for custom soccer leagues."""
|
||||||
api_url = self.API_ENDPOINTS.get(league)
|
api_url = self.API_ENDPOINTS.get(league)
|
||||||
if not api_url and league.startswith('soccer_'):
|
if not api_url and league.startswith('soccer_'):
|
||||||
league_code = league[len('soccer_'):]
|
league_code = league[len('soccer_'):]
|
||||||
if not self._SAFE_LEAGUE_CODE_RE.match(league_code):
|
if not self._SAFE_LEAGUE_RE.match(league_code):
|
||||||
logger.warning(f"Rejecting unsafe league_code for ESPN URL construction: {league_code!r}")
|
logger.warning(f"Rejecting unsafe league_code for ESPN URL construction: {league_code!r}")
|
||||||
return None
|
return None
|
||||||
api_url = f'https://site.api.espn.com/apis/site/v2/sports/soccer/{league_code}/teams'
|
api_url = f'https://site.api.espn.com/apis/site/v2/sports/soccer/{league_code}/teams'
|
||||||
@@ -501,7 +491,8 @@ class LogoDownloader:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def fetch_single_team(self, league: str, team_id: str) -> Optional[Dict]:
|
def fetch_single_team(self, league: str, team_id: str) -> Optional[Dict]:
|
||||||
"""Fetch team data from ESPN API for a specific league."""
|
"""Fetch one team's record (``<teams endpoint>/<team_id>``) from the
|
||||||
|
ESPN API; None on any request or parse failure."""
|
||||||
api_url = self._resolve_api_url(league)
|
api_url = self._resolve_api_url(league)
|
||||||
if not api_url:
|
if not api_url:
|
||||||
logger.error(f"No API endpoint configured for league: {league}")
|
logger.error(f"No API endpoint configured for league: {league}")
|
||||||
@@ -520,7 +511,7 @@ class LogoDownloader:
|
|||||||
logger.error(f"Error fetching team data for {team_id} in {league}: {e}")
|
logger.error(f"Error fetching team data for {team_id} in {league}: {e}")
|
||||||
return None
|
return None
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error(f"Error parsing JSON response for{team_id} in {league}: {e}")
|
logger.error(f"Error parsing JSON response for {team_id} in {league}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]:
|
def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]:
|
||||||
@@ -626,42 +617,6 @@ class LogoDownloader:
|
|||||||
# Default to FBS for unknown conferences
|
# Default to FBS for unknown conferences
|
||||||
return 'FBS'
|
return 'FBS'
|
||||||
|
|
||||||
def _get_team_name_variations(self, abbreviation: str) -> List[str]:
|
|
||||||
"""Generate common variations of a team abbreviation for matching."""
|
|
||||||
variations = set()
|
|
||||||
abbr = abbreviation.upper()
|
|
||||||
variations.add(abbr)
|
|
||||||
|
|
||||||
# Add normalized version
|
|
||||||
variations.add(self.normalize_abbreviation(abbr))
|
|
||||||
|
|
||||||
# Common substitutions
|
|
||||||
substitutions = {
|
|
||||||
'&': ['AND', 'A'],
|
|
||||||
'A&M': ['TAMU', 'TA&M', 'TEXASAM'],
|
|
||||||
'STATE': ['ST', 'ST.'],
|
|
||||||
'UNIVERSITY': ['U', 'UNIV'],
|
|
||||||
'COLLEGE': ['C', 'COL'],
|
|
||||||
'TECHNICAL': ['TECH', 'T'],
|
|
||||||
'NORTHERN': ['NORTH', 'N'],
|
|
||||||
'SOUTHERN': ['SOUTH', 'S'],
|
|
||||||
'EASTERN': ['EAST', 'E'],
|
|
||||||
'WESTERN': ['WEST', 'W']
|
|
||||||
}
|
|
||||||
|
|
||||||
# Apply substitutions
|
|
||||||
for original, replacements in substitutions.items():
|
|
||||||
if original in abbr:
|
|
||||||
for replacement in replacements:
|
|
||||||
variations.add(abbr.replace(original, replacement))
|
|
||||||
variations.add(abbr.replace(original, '')) # Remove the word entirely
|
|
||||||
|
|
||||||
# Add common abbreviations for Texas A&M
|
|
||||||
if 'A&M' in abbr or 'TAMU' in abbr:
|
|
||||||
variations.update(['TAMU', 'TA&M', 'TEXASAM', 'TEXAS_A&M', 'TEXAS_AM'])
|
|
||||||
|
|
||||||
return list(variations)
|
|
||||||
|
|
||||||
def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]:
|
def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]:
|
||||||
"""Download missing logos for a specific league."""
|
"""Download missing logos for a specific league."""
|
||||||
logger.info(f"Starting logo download for league: {league}")
|
logger.info(f"Starting logo download for league: {league}")
|
||||||
@@ -794,7 +749,9 @@ class LogoDownloader:
|
|||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
logo_url = data["team"]["logos"][0]["href"]
|
logo_url = data["team"]["logos"][0]["href"]
|
||||||
except KeyError:
|
except (KeyError, IndexError, TypeError):
|
||||||
|
# A team without logos comes back with an empty list.
|
||||||
|
logger.debug(f"No logo URL for team {team_id} in {league}")
|
||||||
return False
|
return False
|
||||||
# Download the logo
|
# Download the logo
|
||||||
success = self.download_logo(logo_url, logo_path, team_abbreviation)
|
success = self.download_logo(logo_url, logo_path, team_abbreviation)
|
||||||
@@ -824,24 +781,39 @@ class LogoDownloader:
|
|||||||
logger.info(f"Overall logo download results: {total_downloaded} downloaded, {total_failed} failed")
|
logger.info(f"Overall logo download results: {total_downloaded} downloaded, {total_failed} failed")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def create_placeholder_logo(self, team_abbreviation: str, logo_dir: str) -> bool:
|
def create_placeholder_logo(self, team_abbreviation: str, logo_dir: str,
|
||||||
"""Create a placeholder logo when real logo cannot be downloaded."""
|
filepath: Optional[Path] = None) -> bool:
|
||||||
|
"""Write a grey placeholder with the team abbreviation on it, for when
|
||||||
|
the real logo cannot be downloaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_abbreviation: Drawn on the placeholder.
|
||||||
|
logo_dir: Directory for the file when ``filepath`` is not given;
|
||||||
|
the file is then ``<normalize_abbreviation(abbr)>.png``.
|
||||||
|
filepath: The exact path to write, which is where the caller will
|
||||||
|
look for the logo. ``logo_dir`` is ignored when it is given.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the placeholder was written, False otherwise (logged).
|
||||||
|
"""
|
||||||
|
if filepath is not None:
|
||||||
|
filepath = Path(filepath)
|
||||||
|
logo_dir = str(filepath.parent)
|
||||||
try:
|
try:
|
||||||
# Ensure the logo directory exists
|
|
||||||
if not self.ensure_logo_directory(logo_dir):
|
if not self.ensure_logo_directory(logo_dir):
|
||||||
logger.error(f"Failed to create logo directory: {logo_dir}")
|
logger.error(f"Failed to create logo directory: {logo_dir}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if filepath is None:
|
||||||
filename = f"{self.normalize_abbreviation(team_abbreviation)}.png"
|
filename = f"{self.normalize_abbreviation(team_abbreviation)}.png"
|
||||||
filepath = Path(logo_dir) / filename
|
filepath = Path(logo_dir) / filename
|
||||||
|
|
||||||
# Create a simple placeholder logo
|
logo = Image.new('RGBA', PLACEHOLDER_SIZE, PLACEHOLDER_BG)
|
||||||
logo = Image.new('RGBA', (64, 64), (100, 100, 100, 255)) # Gray background
|
|
||||||
draw = ImageDraw.Draw(logo)
|
draw = ImageDraw.Draw(logo)
|
||||||
|
|
||||||
# Try to load a font, fallback to default
|
# Try to load a font, fallback to default
|
||||||
try:
|
try:
|
||||||
font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
|
font = load_truetype(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"), 12)
|
||||||
except (OSError, IOError):
|
except (OSError, IOError):
|
||||||
try:
|
try:
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
@@ -855,8 +827,8 @@ class LogoDownloader:
|
|||||||
bbox = draw.textbbox((0, 0), text, font=font)
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
text_width = bbox[2] - bbox[0]
|
text_width = bbox[2] - bbox[0]
|
||||||
text_height = bbox[3] - bbox[1]
|
text_height = bbox[3] - bbox[1]
|
||||||
x = (64 - text_width) // 2
|
x = (PLACEHOLDER_SIZE[0] - text_width) // 2
|
||||||
y = (64 - text_height) // 2
|
y = (PLACEHOLDER_SIZE[1] - text_height) // 2
|
||||||
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
|
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
|
||||||
else:
|
else:
|
||||||
# Fallback without font
|
# Fallback without font
|
||||||
@@ -963,14 +935,19 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
|||||||
Convenience function to download a missing team logo.
|
Convenience function to download a missing team logo.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
team_abbreviation: Team abbreviation (e.g., 'UGA', 'BAMA', 'TA&M')
|
|
||||||
league: League identifier (e.g., 'ncaa_fb', 'nfl')
|
league: League identifier (e.g., 'ncaa_fb', 'nfl')
|
||||||
logo_path: Full path to where the logo should be saved
|
team_id: ESPN team id, used to look up the logo URL when
|
||||||
|
``logo_url`` is not given
|
||||||
|
team_abbreviation: Team abbreviation (e.g., 'UGA', 'BAMA', 'TA&M')
|
||||||
|
logo_path: Where the logo (or placeholder) is written; relative paths
|
||||||
|
are relative to the install root
|
||||||
logo_url: Optional direct URL to the logo
|
logo_url: Optional direct URL to the logo
|
||||||
create_placeholder: Whether to create a placeholder if download fails
|
create_placeholder: Whether to create a placeholder if download fails
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if logo exists or was successfully downloaded, False otherwise
|
True if a logo or placeholder is at ``logo_path`` afterwards (it was
|
||||||
|
already there, was downloaded, or a placeholder was written),
|
||||||
|
False otherwise.
|
||||||
"""
|
"""
|
||||||
downloader = shared_downloader()
|
downloader = shared_downloader()
|
||||||
|
|
||||||
@@ -1011,7 +988,7 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
|||||||
time.sleep(0.1) # Small delay
|
time.sleep(0.1) # Small delay
|
||||||
if not success and create_placeholder:
|
if not success and create_placeholder:
|
||||||
logger.info(f"Creating placeholder logo for {team_abbreviation}")
|
logger.info(f"Creating placeholder logo for {team_abbreviation}")
|
||||||
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir)
|
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir, filepath=filepath)
|
||||||
return success
|
return success
|
||||||
|
|
||||||
success = downloader.download_missing_logo_for_team(league, team_id, team_abbreviation, logo_path)
|
success = downloader.download_missing_logo_for_team(league, team_id, team_abbreviation, logo_path)
|
||||||
@@ -1019,7 +996,7 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
|||||||
if not success and create_placeholder:
|
if not success and create_placeholder:
|
||||||
logger.info(f"Creating placeholder logo for {team_abbreviation}")
|
logger.info(f"Creating placeholder logo for {team_abbreviation}")
|
||||||
# Create placeholder as fallback
|
# Create placeholder as fallback
|
||||||
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir)
|
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir, filepath=filepath)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"Successfully handled logo for {team_abbreviation}")
|
logger.info(f"Successfully handled logo for {team_abbreviation}")
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ Stability: Stable - maintains backward compatibility
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
@@ -511,104 +510,53 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
Get the display duration for this plugin instance.
|
Get the display duration for this plugin instance.
|
||||||
|
|
||||||
Automatically detects duration from:
|
Uses, in order, the first positive number among:
|
||||||
1. self.display_duration instance variable (if exists)
|
1. ``self.display_duration`` (a common pattern in scoreboard plugins)
|
||||||
2. self.config.get("display_duration", 15.0) (fallback)
|
2. ``self.config["display_duration"]``
|
||||||
|
3. 15.0
|
||||||
|
|
||||||
Can be overridden by plugins to provide dynamic durations based
|
Numeric strings count as numbers. Can be overridden by plugins to
|
||||||
on content (e.g., longer duration for more complex displays).
|
provide dynamic durations based on content (e.g., longer duration for
|
||||||
|
more complex displays).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Duration in seconds to display this plugin's content
|
Duration in seconds to display this plugin's content
|
||||||
"""
|
"""
|
||||||
# Check for instance variable first (common pattern in scoreboard plugins)
|
|
||||||
if hasattr(self, 'display_duration'):
|
|
||||||
try:
|
try:
|
||||||
duration = getattr(self, 'display_duration')
|
duration = getattr(self, 'display_duration', None)
|
||||||
# Handle None case
|
|
||||||
if duration is None:
|
|
||||||
pass # Fall through to config
|
|
||||||
# Try to convert to float if it's a number or numeric string.
|
|
||||||
# bool is excluded: it's an int subclass, and True would
|
|
||||||
# otherwise read as a 1-second duration.
|
|
||||||
elif isinstance(duration, (int, float)) and not isinstance(duration, bool):
|
|
||||||
if duration > 0:
|
|
||||||
return float(duration)
|
|
||||||
else:
|
|
||||||
self.logger.debug(
|
|
||||||
"display_duration instance variable is non-positive (%s), using config fallback",
|
|
||||||
duration
|
|
||||||
)
|
|
||||||
# Try converting string representations of numbers
|
|
||||||
elif isinstance(duration, str):
|
|
||||||
try:
|
|
||||||
duration_float = float(duration)
|
|
||||||
if duration_float > 0:
|
|
||||||
return duration_float
|
|
||||||
else:
|
|
||||||
self.logger.debug(
|
|
||||||
"display_duration string value is non-positive (%s), using config fallback",
|
|
||||||
duration
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
self.logger.warning(
|
|
||||||
"display_duration instance variable has invalid string value '%s', using config fallback",
|
|
||||||
duration
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.logger.warning(
|
|
||||||
"display_duration instance variable has unexpected type %s (value: %s), using config fallback",
|
|
||||||
type(duration).__name__, duration
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError, AttributeError) as e:
|
except (TypeError, ValueError, AttributeError) as e:
|
||||||
|
# A plugin may define display_duration as a property that raises.
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Error reading display_duration instance variable: %s, using config fallback",
|
"Error reading display_duration instance variable: %s, using config fallback", e)
|
||||||
e
|
duration = None
|
||||||
)
|
if duration is not None:
|
||||||
|
seconds = self._positive_seconds(duration, "display_duration instance variable")
|
||||||
|
if seconds is not None:
|
||||||
|
return seconds
|
||||||
|
|
||||||
# Fall back to config
|
seconds = self._positive_seconds(
|
||||||
config_duration = self.config.get("display_duration", 15.0)
|
self.config.get("display_duration", 15.0), "config display_duration")
|
||||||
|
return seconds if seconds is not None else 15.0
|
||||||
|
|
||||||
|
def _positive_seconds(self, value: Any, source: str) -> Optional[float]:
|
||||||
|
"""``value`` as a positive float, or None (with a log line) if it is not one.
|
||||||
|
|
||||||
|
bool is rejected although it is an int subclass: True would otherwise
|
||||||
|
read as a 1-second duration.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||||
|
self.logger.warning("%s has unexpected type %s (value: %s), ignoring it",
|
||||||
|
source, type(value).__name__, value)
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
# Ensure config value is also a valid float (bool excluded — an
|
seconds = float(value)
|
||||||
# int subclass that would otherwise read True as 1 second)
|
|
||||||
if isinstance(config_duration, (int, float)) and not isinstance(config_duration, bool):
|
|
||||||
if config_duration > 0:
|
|
||||||
return float(config_duration)
|
|
||||||
else:
|
|
||||||
self.logger.debug(
|
|
||||||
"Config display_duration is non-positive (%s), using default 15.0",
|
|
||||||
config_duration
|
|
||||||
)
|
|
||||||
return 15.0
|
|
||||||
elif isinstance(config_duration, str):
|
|
||||||
try:
|
|
||||||
duration_float = float(config_duration)
|
|
||||||
if duration_float > 0:
|
|
||||||
return duration_float
|
|
||||||
else:
|
|
||||||
self.logger.debug(
|
|
||||||
"Config display_duration string is non-positive (%s), using default 15.0",
|
|
||||||
config_duration
|
|
||||||
)
|
|
||||||
return 15.0
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.logger.warning(
|
self.logger.warning("%s has invalid value %r, ignoring it", source, value)
|
||||||
"Config display_duration has invalid string value '%s', using default 15.0",
|
return None
|
||||||
config_duration
|
if seconds > 0:
|
||||||
)
|
return seconds
|
||||||
return 15.0
|
self.logger.debug("%s is non-positive (%s), ignoring it", source, value)
|
||||||
else:
|
return None
|
||||||
self.logger.warning(
|
|
||||||
"Config display_duration has unexpected type %s (value: %s), using default 15.0",
|
|
||||||
type(config_duration).__name__, config_duration
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
self.logger.warning(
|
|
||||||
"Error processing config display_duration: %s, using default 15.0",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
|
|
||||||
return 15.0
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# Dynamic duration support hooks
|
# Dynamic duration support hooks
|
||||||
@@ -926,25 +874,20 @@ class BasePlugin(ABC):
|
|||||||
config_mode, self.plugin_id
|
config_mode, self.plugin_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fall back to mapping legacy content_type
|
# Fall back to mapping legacy content_type. 'none' (excluded from
|
||||||
content_type = self.get_vegas_content_type()
|
# Vegas) also maps to FIXED_SEGMENT: exclusion is decided by checking
|
||||||
if content_type == 'multi':
|
# get_vegas_content_type() separately.
|
||||||
|
if self.get_vegas_content_type() == 'multi':
|
||||||
return VegasDisplayMode.SCROLL
|
return VegasDisplayMode.SCROLL
|
||||||
elif content_type == 'static':
|
|
||||||
return VegasDisplayMode.FIXED_SEGMENT
|
|
||||||
elif content_type == 'none':
|
|
||||||
# 'none' means excluded - return FIXED_SEGMENT as default
|
|
||||||
# The exclusion is handled by checking get_vegas_content_type() separately
|
|
||||||
return VegasDisplayMode.FIXED_SEGMENT
|
|
||||||
|
|
||||||
return VegasDisplayMode.FIXED_SEGMENT
|
return VegasDisplayMode.FIXED_SEGMENT
|
||||||
|
|
||||||
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
|
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
|
||||||
"""
|
"""
|
||||||
Return list of Vegas display modes this plugin supports.
|
Return list of Vegas display modes this plugin supports.
|
||||||
|
|
||||||
Used by the web UI to show available mode options for user configuration.
|
Not currently consulted by core: neither Vegas mode nor the web UI
|
||||||
Override to customize which modes are available for this plugin.
|
calls it. It is kept, and plugins override it, as the declared set of
|
||||||
|
modes a future mode picker would offer.
|
||||||
|
|
||||||
By default:
|
By default:
|
||||||
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
|
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
|
||||||
@@ -972,6 +915,10 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode.
|
Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode.
|
||||||
|
|
||||||
|
Not currently consulted by core: Vegas mode sizes a card from the
|
||||||
|
``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings
|
||||||
|
(see get_vegas_render_width()). Kept because plugins override it.
|
||||||
|
|
||||||
Returns the number of panels this plugin should occupy when displayed
|
Returns the number of panels this plugin should occupy when displayed
|
||||||
as a fixed segment. The actual pixel width is calculated as:
|
as a fixed segment. The actual pixel width is calculated as:
|
||||||
width = panels * single_panel_width
|
width = panels * single_panel_width
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import threading
|
|||||||
import queue
|
import queue
|
||||||
from typing import Dict, Optional, List, Callable, Any
|
from typing import Dict, Optional, List, Callable, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
|
|
||||||
from src.plugin_system.operation_types import (
|
from src.plugin_system.operation_types import (
|
||||||
PluginOperation, OperationType, OperationStatus
|
PluginOperation, OperationType, OperationStatus
|
||||||
@@ -28,28 +26,22 @@ class PluginOperationQueue:
|
|||||||
- Prevents concurrent operations on same plugin
|
- Prevents concurrent operations on same plugin
|
||||||
- Operation status tracking
|
- Operation status tracking
|
||||||
- Operation cancellation
|
- Operation cancellation
|
||||||
- Operation history
|
- In-memory history of finished operations
|
||||||
|
|
||||||
|
The history is not persisted. The web UI's operation history comes from
|
||||||
|
OperationHistory (operation_history.py), which has its own file; a copy
|
||||||
|
written here was never read back by anything.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, max_history: int = 100):
|
||||||
self,
|
|
||||||
history_file: Optional[str] = None,
|
|
||||||
max_history: int = 100,
|
|
||||||
lazy_load: bool = False
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Initialize operation queue.
|
Initialize operation queue.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
history_file: Optional path to file for persisting operation history
|
|
||||||
max_history: Maximum number of operations to keep in history
|
max_history: Maximum number of operations to keep in history
|
||||||
lazy_load: If True, defer loading history file until first access
|
|
||||||
"""
|
"""
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
self.history_file = Path(history_file) if history_file else None
|
|
||||||
self.max_history = max_history
|
self.max_history = max_history
|
||||||
self._lazy_load = lazy_load
|
|
||||||
self._history_loaded = False
|
|
||||||
|
|
||||||
# Operation tracking
|
# Operation tracking
|
||||||
self._operations: Dict[str, PluginOperation] = {}
|
self._operations: Dict[str, PluginOperation] = {}
|
||||||
@@ -62,20 +54,8 @@ class PluginOperationQueue:
|
|||||||
self._worker_thread: Optional[threading.Thread] = None
|
self._worker_thread: Optional[threading.Thread] = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
# Load history from file if it exists (unless lazy loading)
|
|
||||||
if not self._lazy_load and self.history_file and self.history_file.exists():
|
|
||||||
self._load_history()
|
|
||||||
self._history_loaded = True
|
|
||||||
|
|
||||||
# Start worker thread
|
|
||||||
self._start_worker()
|
self._start_worker()
|
||||||
|
|
||||||
def _ensure_loaded(self) -> None:
|
|
||||||
"""Ensure history is loaded (for lazy loading)."""
|
|
||||||
if not self._history_loaded and self.history_file and self.history_file.exists():
|
|
||||||
self._load_history()
|
|
||||||
self._history_loaded = True
|
|
||||||
|
|
||||||
def enqueue_operation(
|
def enqueue_operation(
|
||||||
self,
|
self,
|
||||||
operation_type: OperationType,
|
operation_type: OperationType,
|
||||||
@@ -139,7 +119,6 @@ class PluginOperationQueue:
|
|||||||
Returns:
|
Returns:
|
||||||
PluginOperation if found, None otherwise
|
PluginOperation if found, None otherwise
|
||||||
"""
|
"""
|
||||||
self._ensure_loaded()
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._operations.get(operation_id)
|
return self._operations.get(operation_id)
|
||||||
|
|
||||||
@@ -184,7 +163,6 @@ class PluginOperationQueue:
|
|||||||
Returns:
|
Returns:
|
||||||
List of operations, sorted by creation time (newest first)
|
List of operations, sorted by creation time (newest first)
|
||||||
"""
|
"""
|
||||||
self._ensure_loaded()
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Sort by creation time (newest first)
|
# Sort by creation time (newest first)
|
||||||
history = sorted(
|
history = sorted(
|
||||||
@@ -314,12 +292,8 @@ class PluginOperationQueue:
|
|||||||
if self._active_operations[operation.plugin_id].operation_id == operation.operation_id:
|
if self._active_operations[operation.plugin_id].operation_id == operation.operation_id:
|
||||||
del self._active_operations[operation.plugin_id]
|
del self._active_operations[operation.plugin_id]
|
||||||
|
|
||||||
# Add to history
|
|
||||||
self._add_to_history(operation)
|
self._add_to_history(operation)
|
||||||
|
|
||||||
# Save history to file
|
|
||||||
self._save_history()
|
|
||||||
|
|
||||||
def _add_to_history(self, operation: PluginOperation) -> None:
|
def _add_to_history(self, operation: PluginOperation) -> None:
|
||||||
"""Add operation to history, maintaining max_history limit."""
|
"""Add operation to history, maintaining max_history limit."""
|
||||||
self._operation_history.append(operation)
|
self._operation_history.append(operation)
|
||||||
@@ -330,46 +304,6 @@ class PluginOperationQueue:
|
|||||||
self._operation_history.sort(key=lambda op: op.created_at)
|
self._operation_history.sort(key=lambda op: op.created_at)
|
||||||
self._operation_history = self._operation_history[-self.max_history:]
|
self._operation_history = self._operation_history[-self.max_history:]
|
||||||
|
|
||||||
def _save_history(self) -> None:
|
|
||||||
"""Save operation history to file."""
|
|
||||||
if not self.history_file:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self._lock:
|
|
||||||
# Convert operations to dicts
|
|
||||||
history_data = [op.to_dict() for op in self._operation_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)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Error saving operation history: {e}")
|
|
||||||
|
|
||||||
def _load_history(self) -> None:
|
|
||||||
"""Load operation history from file."""
|
|
||||||
if not self.history_file or not self.history_file.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(self.history_file, 'r') as f:
|
|
||||||
history_data = json.load(f)
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
self._operation_history = [
|
|
||||||
PluginOperation.from_dict(op_data)
|
|
||||||
for op_data in history_data
|
|
||||||
]
|
|
||||||
|
|
||||||
self.logger.info(f"Loaded {len(self._operation_history)} operations from history")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Error loading operation history: {e}")
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Shutdown the operation queue and worker thread."""
|
"""Shutdown the operation queue and worker thread."""
|
||||||
self.logger.info("Shutting down plugin operation queue")
|
self.logger.info("Shutting down plugin operation queue")
|
||||||
@@ -378,6 +312,3 @@ class PluginOperationQueue:
|
|||||||
if self._worker_thread and self._worker_thread.is_alive():
|
if self._worker_thread and self._worker_thread.is_alive():
|
||||||
self._worker_thread.join(timeout=5.0)
|
self._worker_thread.join(timeout=5.0)
|
||||||
|
|
||||||
# Save history one last time
|
|
||||||
self._save_history()
|
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class PluginExecutor:
|
|||||||
if result_container['exception']:
|
if result_container['exception']:
|
||||||
error = result_container['exception']
|
error = result_container['exception']
|
||||||
error_msg = f"{plugin_context} operation failed: {error}"
|
error_msg = f"{plugin_context} operation failed: {error}"
|
||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=error)
|
||||||
record_error(error, plugin_id=plugin_id, operation="execute")
|
record_error(error, plugin_id=plugin_id, operation="execute")
|
||||||
raise PluginError(error_msg, plugin_id=plugin_id) from error
|
raise PluginError(error_msg, plugin_id=plugin_id) from error
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
|||||||
Extracted from PluginManager to improve separation of concerns.
|
Extracted from PluginManager to improve separation of concerns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import importlib.util
|
import importlib.util
|
||||||
@@ -184,6 +185,46 @@ def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def contained_plugin_dir(plugin_dir: Path, plugins_dir: Path) -> Optional[str]:
|
||||||
|
"""``plugin_dir`` rebuilt from an entry enumerated under ``plugins_dir``.
|
||||||
|
|
||||||
|
Returns None when ``plugin_dir`` is not a subdirectory of ``plugins_dir``.
|
||||||
|
Callers derive ``plugin_dir`` from a manifest-declared id, so the path is
|
||||||
|
rebuilt from :func:`find_trusted_subdir`'s answer rather than trusted: a
|
||||||
|
name that came out of ``os.scandir()`` on the trusted root carries no
|
||||||
|
taint, which is a real containment guarantee (and one CodeQL's
|
||||||
|
path-injection query can follow), not a string sanitiser.
|
||||||
|
"""
|
||||||
|
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||||
|
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||||
|
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_real))
|
||||||
|
if matched_name is None:
|
||||||
|
return None
|
||||||
|
return os.path.join(plugins_dir_real, matched_name)
|
||||||
|
|
||||||
|
|
||||||
|
def requirements_to_install(plugin_dir: str, logger: logging.Logger,
|
||||||
|
label: str) -> Optional[str]:
|
||||||
|
"""The plugin's requirements.txt if pip has work to do, else None.
|
||||||
|
|
||||||
|
None when there is no requirements.txt, when it lists nothing (plugins
|
||||||
|
whose dependencies ship with core often keep an all-comments file), or
|
||||||
|
when every requirement is already installed. Shared by the loader and the
|
||||||
|
store so both skip pip for the same reasons; they differ only in how they
|
||||||
|
run it.
|
||||||
|
"""
|
||||||
|
requirements_file = os.path.join(plugin_dir, "requirements.txt")
|
||||||
|
if not os.path.isfile(requirements_file):
|
||||||
|
return None
|
||||||
|
if not requirements_has_real_deps(requirements_file):
|
||||||
|
logger.debug("requirements.txt for %s has no real dependencies, skipping pip", label)
|
||||||
|
return None
|
||||||
|
if requirements_are_satisfied(requirements_file):
|
||||||
|
logger.debug("Dependencies for %s already satisfied, skipping pip", label)
|
||||||
|
return None
|
||||||
|
return requirements_file
|
||||||
|
|
||||||
|
|
||||||
class PluginLoader:
|
class PluginLoader:
|
||||||
"""Handles plugin module loading and class instantiation."""
|
"""Handles plugin module loading and class instantiation."""
|
||||||
|
|
||||||
@@ -273,43 +314,15 @@ class PluginLoader:
|
|||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
safe_plugin_dir = contained_plugin_dir(plugin_dir, plugins_dir)
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
if safe_plugin_dir is None:
|
||||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
|
||||||
requested_name = os.path.basename(plugin_dir_real)
|
|
||||||
|
|
||||||
# Match the requested directory against an entry actually enumerated
|
|
||||||
# from the trusted plugins_dir, and build the path from that entry --
|
|
||||||
# not from requested_name. A name that came out of os.scandir() on a
|
|
||||||
# trusted root carries no taint regardless of what the caller asked
|
|
||||||
# for, so this is a real containment guarantee (an allowlist check
|
|
||||||
# against a trusted source), not a string-sanitisation of untrusted
|
|
||||||
# input that a static analyzer has to trust blindly.
|
|
||||||
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
|
||||||
if matched_name is None:
|
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
|
requirements_file = requirements_to_install(safe_plugin_dir, self.logger, plugin_id)
|
||||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
if requirements_file is None:
|
||||||
|
|
||||||
if not os.path.isfile(requirements_file):
|
|
||||||
return True # No dependencies needed
|
|
||||||
|
|
||||||
if not requirements_has_real_deps(requirements_file):
|
|
||||||
self.logger.debug(
|
|
||||||
"requirements.txt for %s has no real dependencies (comments/blank only), skipping pip",
|
|
||||||
plugin_id
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
if requirements_are_satisfied(requirements_file):
|
|
||||||
self.logger.debug(
|
|
||||||
"Dependencies for %s already satisfied in current environment, skipping pip",
|
|
||||||
plugin_id
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -348,8 +361,8 @@ class PluginLoader:
|
|||||||
# below).
|
# below).
|
||||||
try:
|
try:
|
||||||
# sys.executable is this process's own interpreter (not
|
# sys.executable is this process's own interpreter (not
|
||||||
# attacker-influenced), and requirements_file is a path
|
# attacker-influenced), and requirements_file is rebuilt
|
||||||
# built internally by find_plugin_directory, never raw
|
# by contained_plugin_dir() from a trusted listing, never raw
|
||||||
# external input.
|
# external input.
|
||||||
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||||
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
||||||
@@ -384,10 +397,10 @@ class PluginLoader:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
|
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
|
||||||
return True
|
return True
|
||||||
except (BrokenPipeError, OSError) as e:
|
except OSError as e:
|
||||||
# Handle broken pipe errors (errno 32) which can occur during pip downloads
|
# A broken pipe (EPIPE) happens when pip's output pipe closes
|
||||||
# Often caused by network interruptions or output buffer issues
|
# mid-download, usually a network interruption.
|
||||||
if isinstance(e, OSError) and e.errno == 32:
|
if e.errno == errno.EPIPE:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"Broken pipe error during dependency installation for %s. "
|
"Broken pipe error during dependency installation for %s. "
|
||||||
"This usually indicates a network interruption or pip output buffer issue. "
|
"This usually indicates a network interruption or pip output buffer issue. "
|
||||||
@@ -528,7 +541,7 @@ class PluginLoader:
|
|||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
plugin_dir: Path,
|
plugin_dir: Path,
|
||||||
entry_point: str
|
entry_point: str
|
||||||
) -> Optional[Any]:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Load a plugin module from file.
|
Load a plugin module from file.
|
||||||
|
|
||||||
@@ -547,7 +560,12 @@ class PluginLoader:
|
|||||||
entry_point: Entry point filename (e.g., 'manager.py')
|
entry_point: Entry point filename (e.g., 'manager.py')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Loaded module or None on error
|
The loaded module
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PluginError: If the plugin id, directory or entry point is
|
||||||
|
invalid. Whatever the module raises while executing
|
||||||
|
propagates unchanged.
|
||||||
"""
|
"""
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
plugin_id = os.path.basename(plugin_id or '')
|
||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
@@ -782,8 +800,6 @@ class PluginLoader:
|
|||||||
# Load module
|
# Load module
|
||||||
entry_point = manifest.get('entry_point', 'manager.py')
|
entry_point = manifest.get('entry_point', 'manager.py')
|
||||||
module = self.load_module(plugin_id, plugin_dir, entry_point)
|
module = self.load_module(plugin_id, plugin_dir, entry_point)
|
||||||
if module is None:
|
|
||||||
raise PluginError(f"Failed to load module for plugin {plugin_id}", plugin_id=plugin_id)
|
|
||||||
|
|
||||||
# Get plugin class
|
# Get plugin class
|
||||||
class_name = manifest.get('class_name')
|
class_name = manifest.get('class_name')
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
Plugin Manager
|
Plugin Manager
|
||||||
|
|
||||||
Manages plugin discovery, loading, and lifecycle for the LEDMatrix system.
|
Manages plugin discovery, loading, and lifecycle for the LEDMatrix system.
|
||||||
Handles dynamic plugin loading from the plugins/ directory.
|
Loads plugins from the configured plugins directory
|
||||||
|
(``plugin_system.plugins_directory``, ``plugin-repos/`` by default).
|
||||||
|
|
||||||
API Version: 1.0.0
|
API Version: 1.0.0
|
||||||
"""
|
"""
|
||||||
@@ -40,7 +41,7 @@ class PluginManager:
|
|||||||
Manages plugin discovery, loading, and lifecycle.
|
Manages plugin discovery, loading, and lifecycle.
|
||||||
|
|
||||||
The PluginManager is responsible for:
|
The PluginManager is responsible for:
|
||||||
- Discovering plugins in the plugins/ directory
|
- Discovering plugins in the configured plugins directory
|
||||||
- Loading plugin modules and instantiating plugin classes
|
- Loading plugin modules and instantiating plugin classes
|
||||||
- Managing plugin lifecycle (load, unload, reload)
|
- Managing plugin lifecycle (load, unload, reload)
|
||||||
- Providing access to loaded plugins
|
- Providing access to loaded plugins
|
||||||
@@ -99,10 +100,9 @@ class PluginManager:
|
|||||||
self.plugin_directories: Dict[str, Path] = {}
|
self.plugin_directories: Dict[str, Path] = {}
|
||||||
self.plugin_last_update: Dict[str, float] = {}
|
self.plugin_last_update: Dict[str, float] = {}
|
||||||
|
|
||||||
# Cached data-fetch intervals per plugin_id.
|
# Cached static data-fetch intervals per plugin_id, so the render
|
||||||
# _get_plugin_update_interval falls back to config_manager.get_config()
|
# loop's scheduling tick does not repeat the manifest/config lookup
|
||||||
# (a full dict copy) when the manifest lacks an interval — caching avoids
|
# for every plugin. Cleared on load/unload.
|
||||||
# that copy on every 30-fps tick. Cleared on load/unload.
|
|
||||||
self._update_interval_cache: Dict[str, Optional[float]] = {}
|
self._update_interval_cache: Dict[str, Optional[float]] = {}
|
||||||
|
|
||||||
# Health tracking (optional, set by display_controller if available)
|
# Health tracking (optional, set by display_controller if available)
|
||||||
@@ -110,14 +110,12 @@ class PluginManager:
|
|||||||
self.resource_monitor = None
|
self.resource_monitor = None
|
||||||
|
|
||||||
# --- Asynchronous plugin updates -------------------------------
|
# --- Asynchronous plugin updates -------------------------------
|
||||||
# update() used to run inline in the render loop (execute_update's
|
# Run inline in the render loop, one slow plugin HTTP fetch in
|
||||||
# internal thread.join(timeout=30) blocked it), so one slow plugin
|
# update() freezes scrolling for the whole fetch. Scheduling happens
|
||||||
# HTTP fetch froze scrolling for the whole fetch. Scheduling still
|
# on the render thread (run_scheduled_updates); execution happens on
|
||||||
# happens on the render thread (run_scheduled_updates), but
|
# this single background worker. Per-plugin locks keep a plugin's
|
||||||
# execution moves to this single background worker. Per-plugin
|
# update() and display() mutually exclusive, including across the
|
||||||
# locks keep a plugin's update() and display() mutually exclusive —
|
# post-timeout window.
|
||||||
# today's implicit guarantee, now explicit (and, unlike today,
|
|
||||||
# also held across the post-timeout window).
|
|
||||||
# Kill switch: plugin_system.synchronous_updates: true restores the
|
# Kill switch: plugin_system.synchronous_updates: true restores the
|
||||||
# inline path.
|
# inline path.
|
||||||
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
|
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
|
||||||
@@ -396,7 +394,8 @@ class PluginManager:
|
|||||||
self.font_manager, 'register_plugin_fonts'
|
self.font_manager, 'register_plugin_fonts'
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
self.font_manager.register_plugin_fonts(plugin_id, font_manifest)
|
self.font_manager.register_plugin_fonts(
|
||||||
|
plugin_id, font_manifest, plugin_dir=plugin_dir)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Failed to register fonts for plugin %s: %s", plugin_id, e
|
"Failed to register fonts for plugin %s: %s", plugin_id, e
|
||||||
@@ -661,9 +660,15 @@ class PluginManager:
|
|||||||
if not self.unload_plugin(plugin_id):
|
if not self.unload_plugin(plugin_id):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Re-discover to get updated manifest
|
# Re-read the manifest so an edit to it takes effect, from the
|
||||||
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
|
# directory discovery found the plugin in: a directory's name need not
|
||||||
if manifest_path.exists():
|
# be the id its manifest declares.
|
||||||
|
with self._discovery_lock:
|
||||||
|
directories = dict(self.plugin_directories)
|
||||||
|
plugin_dir = self.plugin_loader.find_plugin_directory(
|
||||||
|
plugin_id, self.plugins_dir, directories)
|
||||||
|
manifest_path = plugin_dir / "manifest.json" if plugin_dir is not None else None
|
||||||
|
if manifest_path is not None and manifest_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
@@ -881,11 +886,12 @@ class PluginManager:
|
|||||||
updating, since a scheduler that propagates a plugin bug stops every
|
updating, since a scheduler that propagates a plugin bug stops every
|
||||||
other plugin too.
|
other plugin too.
|
||||||
|
|
||||||
The static result is cached per plugin_id after the first lookup to
|
The static result is cached per plugin_id after the first lookup, so
|
||||||
avoid calling config_manager.get_config() — which returns a full dict
|
the manifest/config resolution is not repeated on every scheduling
|
||||||
copy — on every tick of the 30-fps display loop. The cache is
|
tick of the display loop. A change to ``update_interval`` in
|
||||||
invalidated when a plugin is loaded or unloaded. The dynamic hook is
|
config.json therefore takes effect when the plugin is next loaded or
|
||||||
deliberately *not* cached: caching it would defeat its only purpose.
|
unloaded, which clears the cache. The dynamic hook is deliberately
|
||||||
|
*not* cached: caching it would defeat its only purpose.
|
||||||
"""
|
"""
|
||||||
dynamic = self._dynamic_update_interval(plugin_id, plugin_instance)
|
dynamic = self._dynamic_update_interval(plugin_id, plugin_instance)
|
||||||
if dynamic is not None:
|
if dynamic is not None:
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ class PluginStateManager:
|
|||||||
self._state_transition_counts: Dict[str, int] = {}
|
self._state_transition_counts: Dict[str, int] = {}
|
||||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||||
self._last_update: Dict[str, datetime] = {}
|
self._last_update: Dict[str, datetime] = {}
|
||||||
self._last_display: Dict[str, datetime] = {}
|
|
||||||
|
|
||||||
def _record_transition(self, plugin_id: str) -> None:
|
def _record_transition(self, plugin_id: str) -> None:
|
||||||
"""Count a state transition. Callers must already hold ``_lock``."""
|
"""Count a state transition. Callers must already hold ``_lock``."""
|
||||||
@@ -182,10 +181,6 @@ class PluginStateManager:
|
|||||||
"""Get timestamp of last update() call."""
|
"""Get timestamp of last update() call."""
|
||||||
return self._last_update.get(plugin_id)
|
return self._last_update.get(plugin_id)
|
||||||
|
|
||||||
def get_last_display(self, plugin_id: str) -> Optional[datetime]:
|
|
||||||
"""Get timestamp of last display() call."""
|
|
||||||
return self._last_display.get(plugin_id)
|
|
||||||
|
|
||||||
def get_state_info(self, plugin_id: str) -> Dict[str, Any]:
|
def get_state_info(self, plugin_id: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get comprehensive state information for a plugin.
|
Get comprehensive state information for a plugin.
|
||||||
@@ -212,7 +207,6 @@ class PluginStateManager:
|
|||||||
'is_error': self.is_error(plugin_id),
|
'is_error': self.is_error(plugin_id),
|
||||||
'can_execute': self.can_execute(plugin_id),
|
'can_execute': self.can_execute(plugin_id),
|
||||||
'last_update': self.get_last_update(plugin_id),
|
'last_update': self.get_last_update(plugin_id),
|
||||||
'last_display': self.get_last_display(plugin_id),
|
|
||||||
'error_info': self.get_error_info(plugin_id),
|
'error_info': self.get_error_info(plugin_id),
|
||||||
'state_history_count': self._state_transition_counts.get(plugin_id, 0)
|
'state_history_count': self._state_transition_counts.get(plugin_id, 0)
|
||||||
}
|
}
|
||||||
@@ -231,5 +225,4 @@ class PluginStateManager:
|
|||||||
self._state_transition_counts.pop(plugin_id, None)
|
self._state_transition_counts.pop(plugin_id, None)
|
||||||
self._error_info.pop(plugin_id, None)
|
self._error_info.pop(plugin_id, None)
|
||||||
self._last_update.pop(plugin_id, None)
|
self._last_update.pop(plugin_id, None)
|
||||||
self._last_display.pop(plugin_id, None)
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Repository URL helpers shared by the plugin store and saved repositories.
|
||||||
|
|
||||||
|
One definition of "the same repository URL", of how a GitHub URL maps to
|
||||||
|
``owner/repo``, and of the headers sent to the GitHub API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
#: Hosts whose URLs name a GitHub repository. Matched against
|
||||||
|
#: ``urlparse(url).hostname``, never by substring: a substring test accepts
|
||||||
|
#: ``https://github.com.example.org/...`` as GitHub.
|
||||||
|
GITHUB_HOSTS = frozenset({'github.com', 'www.github.com'})
|
||||||
|
|
||||||
|
#: Sent on every request the store makes to GitHub.
|
||||||
|
USER_AGENT = 'LEDMatrix-Plugin-Manager/1.0'
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_repo_url(url: str) -> str:
|
||||||
|
"""``url`` without surrounding whitespace, trailing slashes or a trailing ``.git``.
|
||||||
|
|
||||||
|
Only a *trailing* ``.git`` is removed. The unanchored
|
||||||
|
``url.replace('.git', '')`` this replaces turned
|
||||||
|
``https://github.com/user/my.github.io`` into ``.../myhub.io``.
|
||||||
|
Case is preserved; compare with :func:`same_repo`.
|
||||||
|
"""
|
||||||
|
url = url.strip().rstrip('/')
|
||||||
|
if url.endswith('.git'):
|
||||||
|
url = url[:-4]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def same_repo(url_a: str, url_b: str) -> bool:
|
||||||
|
"""Whether two URLs name the same repository.
|
||||||
|
|
||||||
|
GitHub owner and repository names are case-insensitive, so
|
||||||
|
``ChuckBuilds/LEDMatrix-Plugins`` and ``chuckbuilds/ledmatrix-plugins``
|
||||||
|
are one repository.
|
||||||
|
"""
|
||||||
|
return normalize_repo_url(url_a).lower() == normalize_repo_url(url_b).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def github_owner_repo(url: str) -> Optional[Tuple[str, str]]:
|
||||||
|
"""``(owner, repo)`` for a github.com repository URL, else None.
|
||||||
|
|
||||||
|
The first two path segments, so a URL that points inside the repository
|
||||||
|
(``.../owner/repo/tree/main/plugins/x``) still names ``owner/repo``.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(normalize_repo_url(url))
|
||||||
|
if parsed.hostname not in GITHUB_HOSTS:
|
||||||
|
return None
|
||||||
|
parts = [part for part in parsed.path.split('/') if part]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
return parts[0], normalize_repo_url(parts[1])
|
||||||
|
|
||||||
|
|
||||||
|
def github_api_headers(token: Optional[str] = None) -> Dict[str, str]:
|
||||||
|
"""Headers for a GitHub REST API request, authenticated when ``token`` is set.
|
||||||
|
|
||||||
|
An authenticated request gets 5000 requests an hour instead of 60.
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
}
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'token {token}'
|
||||||
|
return headers
|
||||||
@@ -33,7 +33,14 @@ class ResourceLimits:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ResourceMetrics:
|
class ResourceMetrics:
|
||||||
"""Resource usage metrics for a plugin."""
|
"""Resource usage metrics for a plugin.
|
||||||
|
|
||||||
|
``memory_mb`` is the largest growth in this *process's* resident memory
|
||||||
|
seen across a single monitored call -- a high-water mark, not current
|
||||||
|
usage, and not the plugin's own footprint (another thread allocating
|
||||||
|
during the call counts too). ``cpu_percent`` is the whole process's CPU
|
||||||
|
use since the previous sample.
|
||||||
|
"""
|
||||||
memory_mb: float = 0.0
|
memory_mb: float = 0.0
|
||||||
cpu_percent: float = 0.0
|
cpu_percent: float = 0.0
|
||||||
execution_time: float = 0.0
|
execution_time: float = 0.0
|
||||||
@@ -43,11 +50,6 @@ class ResourceMetrics:
|
|||||||
min_execution_time: float = float('inf')
|
min_execution_time: float = float('inf')
|
||||||
last_update_time: float = field(default_factory=time.time)
|
last_update_time: float = field(default_factory=time.time)
|
||||||
|
|
||||||
def update_average_execution_time(self):
|
|
||||||
"""Update average execution time."""
|
|
||||||
if self.call_count > 0:
|
|
||||||
self.total_execution_time = self.total_execution_time / self.call_count
|
|
||||||
|
|
||||||
|
|
||||||
#: How often a plugin's metrics are written to the cache, in seconds.
|
#: How often a plugin's metrics are written to the cache, in seconds.
|
||||||
#:
|
#:
|
||||||
@@ -287,6 +289,7 @@ class PluginResourceMonitor:
|
|||||||
|
|
||||||
# Calculate execution time
|
# Calculate execution time
|
||||||
execution_time = time.time() - start_time
|
execution_time = time.time() - start_time
|
||||||
|
memory_growth_mb = 0.0
|
||||||
|
|
||||||
# Update metrics
|
# Update metrics
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -302,17 +305,17 @@ class PluginResourceMonitor:
|
|||||||
|
|
||||||
# Update memory and CPU if monitoring enabled
|
# Update memory and CPU if monitoring enabled
|
||||||
if self.enable_monitoring:
|
if self.enable_monitoring:
|
||||||
end_memory = self._get_process_memory_mb()
|
memory_growth_mb = self._get_process_memory_mb() - start_memory
|
||||||
metrics.memory_mb = max(metrics.memory_mb, end_memory - start_memory)
|
metrics.memory_mb = max(metrics.memory_mb, memory_growth_mb)
|
||||||
# CPU is harder to measure per-call, so we track it separately
|
# CPU is harder to measure per-call, so we track it separately
|
||||||
metrics.cpu_percent = self._get_process_cpu_percent()
|
metrics.cpu_percent = self._get_process_cpu_percent()
|
||||||
|
|
||||||
# Persist metrics, at most once per interval per plugin.
|
# Persist metrics, at most once per interval per plugin.
|
||||||
self._persist_metrics(plugin_id, metrics)
|
self._persist_metrics(plugin_id, metrics)
|
||||||
|
|
||||||
# Check limits
|
|
||||||
if limits:
|
if limits:
|
||||||
self._check_limits(plugin_id, metrics, limits, execution_time)
|
self._check_limits(plugin_id, metrics, limits, execution_time,
|
||||||
|
memory_growth_mb)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -327,8 +330,16 @@ class PluginResourceMonitor:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def _check_limits(self, plugin_id: str, metrics: ResourceMetrics,
|
def _check_limits(self, plugin_id: str, metrics: ResourceMetrics,
|
||||||
limits: ResourceLimits, execution_time: float) -> None:
|
limits: ResourceLimits, execution_time: float,
|
||||||
"""Check if plugin has exceeded resource limits."""
|
memory_growth_mb: float) -> None:
|
||||||
|
"""Raise ResourceLimitExceeded if this call went over a limit.
|
||||||
|
|
||||||
|
Execution time and memory growth are this call's own; CPU is the
|
||||||
|
latest process sample. Judging memory by the stored high-water mark
|
||||||
|
(``metrics.memory_mb``) instead would fail every call after the first
|
||||||
|
expensive one, so the health tracker's circuit breaker would reopen
|
||||||
|
on every recovery probe and the plugin would never update again.
|
||||||
|
"""
|
||||||
warnings = []
|
warnings = []
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -343,13 +354,13 @@ class PluginResourceMonitor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check memory
|
# Check memory
|
||||||
if limits.max_memory_mb and metrics.memory_mb > limits.max_memory_mb:
|
if limits.max_memory_mb and memory_growth_mb > limits.max_memory_mb:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"Memory usage {metrics.memory_mb:.2f}MB exceeds limit {limits.max_memory_mb:.2f}MB"
|
f"Memory growth {memory_growth_mb:.2f}MB exceeds limit {limits.max_memory_mb:.2f}MB"
|
||||||
)
|
)
|
||||||
elif limits.max_memory_mb and metrics.memory_mb > limits.max_memory_mb * limits.warning_threshold:
|
elif limits.max_memory_mb and memory_growth_mb > limits.max_memory_mb * limits.warning_threshold:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"Memory usage {metrics.memory_mb:.2f}MB approaching limit {limits.max_memory_mb:.2f}MB"
|
f"Memory growth {memory_growth_mb:.2f}MB approaching limit {limits.max_memory_mb:.2f}MB"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check CPU
|
# Check CPU
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
from src.plugin_system.repo_urls import normalize_repo_url
|
||||||
|
|
||||||
|
|
||||||
class SavedRepositoriesManager:
|
class SavedRepositoriesManager:
|
||||||
"""Manages saved GitHub repository URLs."""
|
"""Manages saved GitHub repository URLs."""
|
||||||
@@ -71,18 +73,6 @@ class SavedRepositoriesManager:
|
|||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _clean_url(repo_url: str) -> str:
|
|
||||||
"""Normalize a repo URL: strip whitespace, trailing slashes, and a
|
|
||||||
trailing ``.git`` suffix ONLY. (The old ``.replace('.git', '')``
|
|
||||||
was an unanchored substring replace that mangled URLs merely
|
|
||||||
containing ``.git``, e.g. ``https://github.com/user/my.github.io``.)
|
|
||||||
"""
|
|
||||||
repo_url = repo_url.strip().rstrip('/')
|
|
||||||
if repo_url.endswith('.git'):
|
|
||||||
repo_url = repo_url[:-4]
|
|
||||||
return repo_url
|
|
||||||
|
|
||||||
def get_all(self) -> List[Dict[str, str]]:
|
def get_all(self) -> List[Dict[str, str]]:
|
||||||
"""Get all saved repositories."""
|
"""Get all saved repositories."""
|
||||||
return self.repositories.copy()
|
return self.repositories.copy()
|
||||||
@@ -98,7 +88,7 @@ class SavedRepositoriesManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if added successfully
|
True if added successfully
|
||||||
"""
|
"""
|
||||||
repo_url = self._clean_url(repo_url)
|
repo_url = normalize_repo_url(repo_url)
|
||||||
|
|
||||||
# Check if already exists
|
# Check if already exists
|
||||||
for repo in self.repositories:
|
for repo in self.repositories:
|
||||||
@@ -138,7 +128,7 @@ class SavedRepositoriesManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if removed successfully
|
True if removed successfully
|
||||||
"""
|
"""
|
||||||
repo_url = self._clean_url(repo_url)
|
repo_url = normalize_repo_url(repo_url)
|
||||||
|
|
||||||
previous = self.repositories
|
previous = self.repositories
|
||||||
remaining = [r for r in previous if r.get('url') != repo_url]
|
remaining = [r for r in previous if r.get('url') != repo_url]
|
||||||
@@ -156,7 +146,7 @@ class SavedRepositoriesManager:
|
|||||||
|
|
||||||
def has(self, repo_url: str) -> bool:
|
def has(self, repo_url: str) -> bool:
|
||||||
"""Check if a repository is already saved."""
|
"""Check if a repository is already saved."""
|
||||||
repo_url = self._clean_url(repo_url)
|
repo_url = normalize_repo_url(repo_url)
|
||||||
return any(r.get('url') == repo_url for r in self.repositories)
|
return any(r.get('url') == repo_url for r in self.repositories)
|
||||||
|
|
||||||
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import jsonschema
|
|||||||
from jsonschema import Draft7Validator, ValidationError
|
from jsonschema import Draft7Validator, ValidationError
|
||||||
|
|
||||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||||
|
from src.element_style import expand_style_elements
|
||||||
|
|
||||||
|
|
||||||
def _renders_as_object(prop: Dict[str, Any]) -> bool:
|
def _renders_as_object(prop: Dict[str, Any]) -> bool:
|
||||||
@@ -452,11 +453,7 @@ class SchemaManager:
|
|||||||
# full per-element style blocks (font/size/color + layout
|
# full per-element style blocks (font/size/color + layout
|
||||||
# offsets) the web-UI config form renders. No-op for schemas
|
# offsets) the web-UI config form renders. No-op for schemas
|
||||||
# without the declaration; never raises.
|
# without the declaration; never raises.
|
||||||
try:
|
|
||||||
from src.element_style import expand_style_elements
|
|
||||||
schema = expand_style_elements(schema)
|
schema = expand_style_elements(schema)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Cache the schema
|
# Cache the schema
|
||||||
self._schema_cache[plugin_id] = schema
|
self._schema_cache[plugin_id] = schema
|
||||||
@@ -643,19 +640,11 @@ class SchemaManager:
|
|||||||
[name for name in CORE_PLUGIN_PROPERTIES if name not in declared]
|
[name for name in CORE_PLUGIN_PROPERTIES if name not in declared]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create validator with enhanced schema
|
# iter_errors reports every violation, including one ``required``
|
||||||
|
# error per missing field at every depth.
|
||||||
validator = Draft7Validator(enhanced_schema)
|
validator = Draft7Validator(enhanced_schema)
|
||||||
|
|
||||||
# Collect all validation errors
|
|
||||||
for error in validator.iter_errors(config):
|
for error in validator.iter_errors(config):
|
||||||
error_msg = self._format_validation_error(error, plugin_id)
|
errors.append(self._format_validation_error(error, plugin_id))
|
||||||
errors.append(error_msg)
|
|
||||||
|
|
||||||
# Check required fields
|
|
||||||
required_fields = enhanced_schema.get('required', [])
|
|
||||||
for field in required_fields:
|
|
||||||
if field not in config:
|
|
||||||
errors.append(f"Missing required field: '{field}'")
|
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return False, errors
|
return False, errors
|
||||||
@@ -687,7 +676,15 @@ class SchemaManager:
|
|||||||
field_path = f"'{path}'" if path else "root"
|
field_path = f"'{path}'" if path else "root"
|
||||||
|
|
||||||
if error.validator == 'required':
|
if error.validator == 'required':
|
||||||
missing = error.validator_value
|
# validator_value is the schema's whole ``required`` list; the
|
||||||
|
# error itself is about one field, which jsonschema names only in
|
||||||
|
# its message ("'api_key' is a required property").
|
||||||
|
missing = next(
|
||||||
|
(name for name in error.validator_value
|
||||||
|
if error.message.startswith(f"{name!r} ")),
|
||||||
|
None)
|
||||||
|
if missing is None:
|
||||||
|
return f"Field {field_path}: {error.message}"
|
||||||
return f"Field {field_path}: Missing required property '{missing}'"
|
return f"Field {field_path}: Missing required property '{missing}'"
|
||||||
elif error.validator == 'type':
|
elif error.validator == 'type':
|
||||||
expected = error.validator_value
|
expected = error.validator_value
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ class PluginState:
|
|||||||
version: Optional[str] = None
|
version: Optional[str] = None
|
||||||
installed_at: Optional[datetime] = None
|
installed_at: Optional[datetime] = None
|
||||||
last_updated: Optional[datetime] = None
|
last_updated: Optional[datetime] = None
|
||||||
config_version: int = 1 # For detecting state corruption
|
# Bumped on every update_plugin_state(). Nothing reads it; it stays so
|
||||||
|
# plugin_state.json keeps the shape older releases load with cls(**data).
|
||||||
|
config_version: int = 1
|
||||||
metadata: Dict[str, Any] = None
|
metadata: Dict[str, Any] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
@@ -100,6 +102,8 @@ class PluginStateManager:
|
|||||||
|
|
||||||
# State storage
|
# State storage
|
||||||
self._states: Dict[str, PluginState] = {}
|
self._states: Dict[str, PluginState] = {}
|
||||||
|
# The file's top-level "version", written back as read. Nothing
|
||||||
|
# checks it yet; it is there for a future format change to branch on.
|
||||||
self._state_version = 1
|
self._state_version = 1
|
||||||
|
|
||||||
# Threading
|
# Threading
|
||||||
@@ -193,7 +197,6 @@ class PluginStateManager:
|
|||||||
current_state.metadata = {}
|
current_state.metadata = {}
|
||||||
current_state.metadata.update(updates['metadata'])
|
current_state.metadata.update(updates['metadata'])
|
||||||
|
|
||||||
# Increment config version
|
|
||||||
current_state.config_version += 1
|
current_state.config_version += 1
|
||||||
|
|
||||||
# Store updated state
|
# Store updated state
|
||||||
|
|||||||
+214
-333
@@ -5,6 +5,7 @@ Handles plugin discovery, installation, updates, and uninstallation
|
|||||||
from both the official registry and custom GitHub repositories.
|
from both the official registry and custom GitHub repositories.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
@@ -22,21 +23,19 @@ from pathlib import Path
|
|||||||
from typing import List, Dict, Optional, Any, Tuple, Set
|
from typing import List, Dict, Optional, Any, Tuple, Set
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from jsonschema import Draft7Validator, ValidationError
|
||||||
|
|
||||||
from src.common.permission_utils import sudo_remove_directory, install_requirements_file
|
from src.common.permission_utils import (
|
||||||
from src.plugin_system.plugin_loader import (
|
ensure_directory_permissions, get_plugin_dir_mode, install_requirements_file,
|
||||||
requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir
|
sudo_remove_directory,
|
||||||
)
|
)
|
||||||
|
from src.plugin_system.plugin_loader import contained_plugin_dir, requirements_to_install
|
||||||
from src.plugin_system.plugin_dirs import (
|
from src.plugin_system.plugin_dirs import (
|
||||||
BACKUP_MARKER, PluginDirectoryIndex, resolve_plugin_dir, store_search_dirs,
|
BACKUP_MARKER, PluginDirectoryIndex, resolve_plugin_dir, store_search_dirs,
|
||||||
)
|
)
|
||||||
|
from src.plugin_system.repo_urls import (
|
||||||
try:
|
USER_AGENT, github_api_headers, github_owner_repo, normalize_repo_url, same_repo,
|
||||||
from jsonschema import Draft7Validator, ValidationError
|
)
|
||||||
JSONSCHEMA_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
JSONSCHEMA_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
class PluginStoreManager:
|
class PluginStoreManager:
|
||||||
@@ -93,11 +92,11 @@ class PluginStoreManager:
|
|||||||
self.registry_cache_timeout = 900
|
self.registry_cache_timeout = 900
|
||||||
self.commit_info_cache = {} # Cache for latest commit info: {key: (timestamp, data)}
|
self.commit_info_cache = {} # Cache for latest commit info: {key: (timestamp, data)}
|
||||||
# 30 minutes for commit/manifest caches. Plugin Store users browse
|
# 30 minutes for commit/manifest caches. Plugin Store users browse
|
||||||
# the catalog via /plugins/store/list which fetches commit info and
|
# the catalog via /plugins/store/list, which fetches commit info per
|
||||||
# manifest data per plugin. 5-min TTLs meant every fresh browse on
|
# plugin; with a 5-minute TTL nearly every browse on a Pi4 paid for
|
||||||
# a Pi4 paid for ~3 HTTP requests x N plugins (30-60s serial). 30
|
# an HTTP request per plugin again. 30 minutes keeps the cache warm
|
||||||
# minutes keeps the cache warm across a realistic session while
|
# across a realistic session while still picking up upstream updates
|
||||||
# still picking up upstream updates within a reasonable window.
|
# within a reasonable window.
|
||||||
self.commit_cache_timeout = 1800
|
self.commit_cache_timeout = 1800
|
||||||
self.manifest_cache = {} # Cache for GitHub manifest fetches: {key: (timestamp, data)}
|
self.manifest_cache = {} # Cache for GitHub manifest fetches: {key: (timestamp, data)}
|
||||||
self.manifest_cache_timeout = 1800
|
self.manifest_cache_timeout = 1800
|
||||||
@@ -127,9 +126,9 @@ class PluginStoreManager:
|
|||||||
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
|
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
|
||||||
# head_contents) so a fast-forward update to the current branch
|
# head_contents) so a fast-forward update to the current branch
|
||||||
# (which touches .git/refs/heads/<branch> but NOT .git/HEAD) still
|
# (which touches .git/refs/heads/<branch> but NOT .git/HEAD) still
|
||||||
# invalidates the cache. Before this cache, every
|
# invalidates the cache. Without it every /plugins/installed request
|
||||||
# /plugins/installed request fired 4 git subprocesses per plugin,
|
# runs a git subprocess per plugin, which adds up on a Pi4 with a
|
||||||
# which pegged the CPU on a Pi4 with a dozen plugins. The cached
|
# dozen plugins. The cached
|
||||||
# ``data`` dict is the same shape returned by ``_get_local_git_info``
|
# ``data`` dict is the same shape returned by ``_get_local_git_info``
|
||||||
# itself (sha / short_sha / branch / optional remote_url, date_iso,
|
# itself (sha / short_sha / branch / optional remote_url, date_iso,
|
||||||
# date) — all string-keyed strings.
|
# date) — all string-keyed strings.
|
||||||
@@ -368,13 +367,7 @@ class PluginStoreManager:
|
|||||||
# Validate token by making a lightweight API call to /user endpoint
|
# Validate token by making a lightweight API call to /user endpoint
|
||||||
try:
|
try:
|
||||||
api_url = "https://api.github.com/user"
|
api_url = "https://api.github.com/user"
|
||||||
headers = {
|
response = requests.get(api_url, headers=github_api_headers(token), timeout=5)
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
|
||||||
'User-Agent': 'LEDMatrix-Plugin-Manager/1.0',
|
|
||||||
'Authorization': f'token {token}'
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.get(api_url, headers=headers, timeout=5)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
# Token is valid
|
# Token is valid
|
||||||
@@ -502,9 +495,6 @@ class PluginStoreManager:
|
|||||||
Returns:
|
Returns:
|
||||||
List of validation error messages (empty if valid or schema unavailable)
|
List of validation error messages (empty if valid or schema unavailable)
|
||||||
"""
|
"""
|
||||||
if not JSONSCHEMA_AVAILABLE:
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load manifest schema
|
# Load manifest schema
|
||||||
schema_path = Path(__file__).parent.parent.parent / "schema" / "manifest_schema.json"
|
schema_path = Path(__file__).parent.parent.parent / "schema" / "manifest_schema.json"
|
||||||
@@ -535,48 +525,46 @@ class PluginStoreManager:
|
|||||||
self.logger.debug(f"Error validating manifest schema for {plugin_id}: {e}")
|
self.logger.debug(f"Error validating manifest schema for {plugin_id}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _get_github_repo_info(self, repo_url: str) -> Dict[str, Any]:
|
_EMPTY_REPO_INFO: Dict[str, Any] = {
|
||||||
"""Fetch GitHub repository information (stars, etc.)"""
|
'stars': 0,
|
||||||
# Extract owner/repo from URL
|
'forks': 0,
|
||||||
try:
|
'open_issues': 0,
|
||||||
# Handle different URL formats
|
'updated_at_iso': '',
|
||||||
_parsed_url = urlparse(repo_url)
|
'last_commit_iso': '',
|
||||||
if _parsed_url.hostname in ('github.com', 'www.github.com'):
|
'last_commit_date': '',
|
||||||
parts = repo_url.strip('/').split('/')
|
'language': '',
|
||||||
if len(parts) >= 2:
|
'license': '',
|
||||||
owner = parts[-2]
|
'default_branch': 'main',
|
||||||
repo = parts[-1]
|
}
|
||||||
if repo.endswith('.git'):
|
|
||||||
repo = repo[:-4]
|
|
||||||
|
|
||||||
|
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}"
|
cache_key = f"{owner}/{repo}"
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
if cache_key in self.github_cache:
|
if cache_key in self.github_cache:
|
||||||
cached_time, cached_data = self.github_cache[cache_key]
|
cached_time, cached_data = self.github_cache[cache_key]
|
||||||
if time.time() - cached_time < self.cache_timeout:
|
if time.time() - cached_time < self.cache_timeout:
|
||||||
return cached_data
|
return cached_data
|
||||||
|
|
||||||
# Fetch from GitHub API
|
|
||||||
api_url = f"https://api.github.com/repos/{owner}/{repo}"
|
api_url = f"https://api.github.com/repos/{owner}/{repo}"
|
||||||
headers = {
|
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
|
||||||
'User-Agent': 'LEDMatrix-Plugin-Manager/1.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add authentication if token is available
|
|
||||||
if self.github_token:
|
|
||||||
headers['Authorization'] = f'token {self.github_token}'
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(api_url, headers=headers, timeout=10)
|
response = requests.get(
|
||||||
|
api_url, headers=github_api_headers(self.github_token), timeout=10)
|
||||||
except requests.RequestException as req_err:
|
except requests.RequestException as req_err:
|
||||||
# Network error: prefer a stale cache hit over an
|
# Network error: prefer a stale cache hit over an empty
|
||||||
# empty default so the UI keeps working on a flaky
|
# default so the UI keeps working on a flaky Pi WiFi link.
|
||||||
# Pi WiFi link. Bump the cached entry's timestamp
|
# Bump the cached entry's timestamp into a short backoff
|
||||||
# into a short backoff window so subsequent
|
# window so subsequent requests serve the stale payload
|
||||||
# requests serve the stale payload cheaply instead
|
# cheaply instead of re-hitting the network on every request.
|
||||||
# of re-hitting the network on every request.
|
|
||||||
if cache_key in self.github_cache:
|
if cache_key in self.github_cache:
|
||||||
_, stale = self.github_cache[cache_key]
|
_, stale = self.github_cache[cache_key]
|
||||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||||
@@ -601,18 +589,13 @@ class PluginStoreManager:
|
|||||||
'license': data.get('license', {}).get('name', '') if data.get('license') else '',
|
'license': data.get('license', {}).get('name', '') if data.get('license') else '',
|
||||||
'default_branch': data.get('default_branch', 'main')
|
'default_branch': data.get('default_branch', 'main')
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache the result
|
|
||||||
self.github_cache[cache_key] = (time.time(), repo_info)
|
self.github_cache[cache_key] = (time.time(), repo_info)
|
||||||
return repo_info
|
return repo_info
|
||||||
elif response.status_code == 403:
|
|
||||||
# Rate limit or authentication issue. If we have a
|
if response.status_code == 403:
|
||||||
# previously-cached value, serve it rather than
|
# Rate limit or authentication issue. A stale star count is
|
||||||
# returning empty defaults — a stale star count is
|
# better than a reset to zero, and the backoff bump stops the
|
||||||
# better than a reset to zero. Apply the same
|
# store hammering the API while rate-limited.
|
||||||
# failure-backoff bump as the network-error path
|
|
||||||
# so we don't hammer the API with repeat requests
|
|
||||||
# while rate-limited.
|
|
||||||
if cache_key in self.github_cache:
|
if cache_key in self.github_cache:
|
||||||
_, stale = self.github_cache[cache_key]
|
_, stale = self.github_cache[cache_key]
|
||||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||||
@@ -638,31 +621,11 @@ class PluginStoreManager:
|
|||||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||||
return stale
|
return stale
|
||||||
|
|
||||||
return {
|
return dict(self._EMPTY_REPO_INFO)
|
||||||
'stars': 0,
|
|
||||||
'forks': 0,
|
|
||||||
'open_issues': 0,
|
|
||||||
'updated_at_iso': '',
|
|
||||||
'last_commit_iso': '',
|
|
||||||
'last_commit_date': '',
|
|
||||||
'language': '',
|
|
||||||
'license': '',
|
|
||||||
'default_branch': 'main'
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error fetching GitHub repo info for {repo_url}: {e}")
|
self.logger.error(f"Error fetching GitHub repo info for {repo_url}: {e}")
|
||||||
return {
|
return dict(self._EMPTY_REPO_INFO)
|
||||||
'stars': 0,
|
|
||||||
'forks': 0,
|
|
||||||
'open_issues': 0,
|
|
||||||
'updated_at_iso': '',
|
|
||||||
'last_commit_iso': '',
|
|
||||||
'last_commit_date': '',
|
|
||||||
'language': '',
|
|
||||||
'license': '',
|
|
||||||
'default_branch': 'main'
|
|
||||||
}
|
|
||||||
|
|
||||||
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):
|
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):
|
||||||
"""
|
"""
|
||||||
@@ -697,27 +660,17 @@ class PluginStoreManager:
|
|||||||
Registry dict with plugins list, or None if not found/invalid
|
Registry dict with plugins list, or None if not found/invalid
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Clean up URL
|
repo_url = normalize_repo_url(repo_url)
|
||||||
repo_url = repo_url.rstrip('/').replace('.git', '')
|
|
||||||
|
|
||||||
# Try to find plugins.json in common locations
|
# plugins.json or registry.json at the root of main, then master.
|
||||||
# First try root directory
|
|
||||||
registry_urls = []
|
registry_urls = []
|
||||||
|
owner_repo = github_owner_repo(repo_url)
|
||||||
# Extract owner/repo from URL
|
if owner_repo is not None:
|
||||||
_parsed_repo_url = urlparse(repo_url)
|
owner, repo = owner_repo
|
||||||
if _parsed_repo_url.hostname in ('github.com', 'www.github.com'):
|
|
||||||
parts = repo_url.split('/')
|
|
||||||
if len(parts) >= 2:
|
|
||||||
owner = parts[-2]
|
|
||||||
repo = parts[-1]
|
|
||||||
|
|
||||||
# Try common branch names
|
|
||||||
for branch in ['main', 'master']:
|
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}/plugins.json")
|
||||||
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/registry.json")
|
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/registry.json")
|
||||||
|
|
||||||
# Try each URL
|
|
||||||
for url in registry_urls:
|
for url in registry_urls:
|
||||||
try:
|
try:
|
||||||
response = self._http_get_with_retries(url, timeout=10)
|
response = self._http_get_with_retries(url, timeout=10)
|
||||||
@@ -815,15 +768,20 @@ class PluginStoreManager:
|
|||||||
"""
|
"""
|
||||||
Search for plugins in the registry with enhanced metadata.
|
Search for plugins in the registry with enhanced metadata.
|
||||||
|
|
||||||
GitHub is now treated as the source of truth for live metadata like
|
GitHub supplies live metadata such as stars and last commit
|
||||||
stars and last commit timestamps. The registry provides descriptive
|
timestamps; the registry supplies descriptive information (name,
|
||||||
information (name, description, repo URL, etc.).
|
description, repo URL, etc.).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query string (searches name, description, id)
|
query: Search query string (searches name, description, id, author)
|
||||||
category: Filter by category (e.g., 'sports', 'weather', 'time')
|
category: Filter by category (e.g., 'sports', 'weather', 'time')
|
||||||
tags: Filter by tags (matches any tag in list)
|
tags: Filter by tags (matches any tag in list)
|
||||||
fetch_commit_info: If True (default), fetch commit metadata from GitHub.
|
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:
|
Returns:
|
||||||
List of matching plugin metadata enriched with GitHub information
|
List of matching plugin metadata enriched with GitHub information
|
||||||
@@ -877,11 +835,11 @@ class PluginStoreManager:
|
|||||||
def _enrich(plugin: Dict) -> Dict:
|
def _enrich(plugin: Dict) -> Dict:
|
||||||
"""Enrich a single plugin with GitHub metadata.
|
"""Enrich a single plugin with GitHub metadata.
|
||||||
|
|
||||||
Called concurrently from a ThreadPoolExecutor. Each underlying
|
Called concurrently from a ThreadPoolExecutor. Both HTTP helpers
|
||||||
HTTP helper (``_get_github_repo_info`` / ``_get_latest_commit_info``
|
(``_get_github_repo_info`` / ``_get_latest_commit_info``) are
|
||||||
/ ``_fetch_manifest_from_github``) is thread-safe — they use
|
thread-safe -- they use ``requests`` and write their own cache
|
||||||
``requests`` and write their own cache keys on Python dicts,
|
keys on Python dicts, which is atomic under the GIL for
|
||||||
which is atomic under the GIL for single-key assignments.
|
single-key assignments.
|
||||||
"""
|
"""
|
||||||
enhanced_plugin = plugin.copy()
|
enhanced_plugin = plugin.copy()
|
||||||
repo_url = plugin.get('repo', '')
|
repo_url = plugin.get('repo', '')
|
||||||
@@ -912,24 +870,21 @@ class PluginStoreManager:
|
|||||||
# The registry's plugins.json already carries ``description``
|
# The registry's plugins.json already carries ``description``
|
||||||
# (it is generated from each plugin's manifest by
|
# (it is generated from each plugin's manifest by
|
||||||
# ``update_registry.py``), and ``last_updated`` is filled in
|
# ``update_registry.py``), and ``last_updated`` is filled in
|
||||||
# from the commit info above. An earlier implementation
|
# from the commit info above. Fetching manifest.json per
|
||||||
# fetched manifest.json per plugin anyway, which meant one
|
# plugin costs one extra HTTPS round trip per result; on a Pi4
|
||||||
# extra HTTPS round trip per result; on a Pi4 with a flaky
|
# with a flaky WiFi link the tail retries of that one call
|
||||||
# WiFi link the tail retries of that one extra call
|
|
||||||
# (_http_get_with_retries does 3 attempts with exponential
|
# (_http_get_with_retries does 3 attempts with exponential
|
||||||
# backoff) dominated wall time even after parallelization.
|
# backoff) dominate wall time even with the thread pool.
|
||||||
|
|
||||||
return enhanced_plugin
|
return enhanced_plugin
|
||||||
|
|
||||||
# Fan out the per-plugin GitHub enrichment. The previous
|
# Fan out the per-plugin GitHub enrichment. Serially, a Pi4 with ~15
|
||||||
# implementation did this serially, which on a Pi4 with ~15 plugins
|
# plugins and a cold cache makes 30+ HTTP requests in strict sequence
|
||||||
# and a fresh cache meant 30+ HTTP requests in strict sequence (the
|
# (the "connecting to display" hang users reported). With a thread
|
||||||
# "connecting to display" hang reported by users). With a thread
|
|
||||||
# pool, latency is dominated by the slowest request rather than
|
# pool, latency is dominated by the slowest request rather than
|
||||||
# their sum. Workers capped at 10 to stay well under the
|
# their sum. Workers capped at 10 to stay well under the
|
||||||
# unauthenticated GitHub rate limit burst and avoid overwhelming a
|
# unauthenticated GitHub rate limit burst and avoid overwhelming a
|
||||||
# Pi's WiFi link. For a small number of plugins the pool is
|
# Pi's WiFi link.
|
||||||
# essentially free.
|
|
||||||
if not filtered:
|
if not filtered:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -959,21 +914,11 @@ class PluginStoreManager:
|
|||||||
Manifest data or None if not found
|
Manifest data or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Convert repo URL to raw content URL
|
owner_repo = github_owner_repo(repo_url)
|
||||||
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
|
if owner_repo is None:
|
||||||
_parsed_manifest_url = urlparse(repo_url)
|
return None
|
||||||
if _parsed_manifest_url.hostname in ('github.com', 'www.github.com'):
|
owner, repo = owner_repo
|
||||||
# Handle different URL formats
|
|
||||||
repo_url = repo_url.rstrip('/')
|
|
||||||
if repo_url.endswith('.git'):
|
|
||||||
repo_url = repo_url[:-4]
|
|
||||||
|
|
||||||
parts = repo_url.split('/')
|
|
||||||
if len(parts) >= 2:
|
|
||||||
owner = parts[-2]
|
|
||||||
repo = parts[-1]
|
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
cache_key = f"{owner}/{repo}:{branch}:{manifest_path}"
|
cache_key = f"{owner}/{repo}:{branch}:{manifest_path}"
|
||||||
if not force_refresh and cache_key in self.manifest_cache:
|
if not force_refresh and cache_key in self.manifest_cache:
|
||||||
cached_time, cached_data = self.manifest_cache[cache_key]
|
cached_time, cached_data = self.manifest_cache[cache_key]
|
||||||
@@ -981,15 +926,12 @@ class PluginStoreManager:
|
|||||||
return cached_data
|
return cached_data
|
||||||
|
|
||||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}"
|
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}"
|
||||||
|
|
||||||
response = self._http_get_with_retries(raw_url, timeout=10)
|
response = self._http_get_with_retries(raw_url, timeout=10)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
result = response.json()
|
result = response.json()
|
||||||
self.manifest_cache[cache_key] = (time.time(), result)
|
self.manifest_cache[cache_key] = (time.time(), result)
|
||||||
return result
|
return result
|
||||||
elif response.status_code == 404:
|
if response.status_code == 404 and branch != "main":
|
||||||
# Try main branch instead
|
|
||||||
if branch != "main":
|
|
||||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}"
|
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}"
|
||||||
response = self._http_get_with_retries(raw_url, timeout=10)
|
response = self._http_get_with_retries(raw_url, timeout=10)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
@@ -997,7 +939,8 @@ class PluginStoreManager:
|
|||||||
self.manifest_cache[cache_key] = (time.time(), result)
|
self.manifest_cache[cache_key] = (time.time(), result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Cache negative 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)
|
self.manifest_cache[cache_key] = (time.time(), None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}")
|
self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}")
|
||||||
@@ -1007,21 +950,11 @@ class PluginStoreManager:
|
|||||||
def _get_latest_commit_info(self, repo_url: str, branch: str = "main", force_refresh: bool = False) -> Optional[Dict[str, Any]]:
|
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."""
|
"""Return metadata about the latest commit on the given branch."""
|
||||||
try:
|
try:
|
||||||
if 'github.com' not in repo_url:
|
owner_repo = github_owner_repo(repo_url)
|
||||||
|
if owner_repo is None:
|
||||||
return None
|
return None
|
||||||
|
owner, repo = owner_repo
|
||||||
|
|
||||||
repo_url = repo_url.rstrip('/')
|
|
||||||
if repo_url.endswith('.git'):
|
|
||||||
repo_url = repo_url[:-4]
|
|
||||||
|
|
||||||
parts = repo_url.split('/')
|
|
||||||
if len(parts) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
owner = parts[-2]
|
|
||||||
repo = parts[-1]
|
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
cache_key = f"{owner}/{repo}:{branch}"
|
cache_key = f"{owner}/{repo}:{branch}"
|
||||||
if not force_refresh and cache_key in self.commit_info_cache:
|
if not force_refresh and cache_key in self.commit_info_cache:
|
||||||
cached_time, cached_data = self.commit_info_cache[cache_key]
|
cached_time, cached_data = self.commit_info_cache[cache_key]
|
||||||
@@ -1029,14 +962,7 @@ class PluginStoreManager:
|
|||||||
return cached_data
|
return cached_data
|
||||||
|
|
||||||
branches_to_try = self._distinct_sequence([branch, 'main', 'master'])
|
branches_to_try = self._distinct_sequence([branch, 'main', 'master'])
|
||||||
|
headers = github_api_headers(self.github_token)
|
||||||
headers = {
|
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
|
||||||
'User-Agent': 'LEDMatrix-Plugin-Manager/1.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.github_token:
|
|
||||||
headers['Authorization'] = f'token {self.github_token}'
|
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
for branch_name in branches_to_try:
|
for branch_name in branches_to_try:
|
||||||
@@ -1254,46 +1180,57 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
backup_path = plugin_path.with_name(
|
backup_path = plugin_path.with_name(
|
||||||
f"{plugin_path.name}{BACKUP_MARKER}preinstall")
|
f"{plugin_path.name}{BACKUP_MARKER}preinstall")
|
||||||
if backup_path.exists() and not self._safe_remove_directory(backup_path):
|
problem = self._set_aside(plugin_path, backup_path)
|
||||||
# Can't stage a safety net. Better to attempt the install than
|
if problem:
|
||||||
# to refuse outright, which is what callers got before this
|
# Can't stage a safety net. Attempting the install anyway is
|
||||||
# existed.
|
# what callers got before the net existed; refusing would be
|
||||||
|
# a new failure mode for a direct install.
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Could not clear stale pre-install backup for %s at %s; "
|
"Installing %s without a rollback net: %s", plugin_id, problem)
|
||||||
"installing without a rollback net", plugin_id, backup_path)
|
|
||||||
return self._install_plugin_impl(plugin_id, branch)
|
|
||||||
|
|
||||||
try:
|
|
||||||
plugin_path.rename(backup_path)
|
|
||||||
except OSError as e:
|
|
||||||
self.logger.warning(
|
|
||||||
"Could not set aside existing install of %s (%s); "
|
|
||||||
"installing without a rollback net", plugin_id, e)
|
|
||||||
return self._install_plugin_impl(plugin_id, branch)
|
return self._install_plugin_impl(plugin_id, branch)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
installed = self._install_plugin_impl(plugin_id, branch)
|
installed = self._install_plugin_impl(plugin_id, branch)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._restore_preinstall_backup(plugin_id, plugin_path, backup_path)
|
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if installed:
|
if installed:
|
||||||
if not self._safe_remove_directory(backup_path):
|
self._discard_backup(plugin_id, backup_path, "install")
|
||||||
self.logger.warning(
|
|
||||||
"Install of %s succeeded but the previous copy at %s "
|
|
||||||
"could not be removed; it will be cleared on the next "
|
|
||||||
"install", plugin_id, backup_path)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self._restore_preinstall_backup(plugin_id, plugin_path, backup_path)
|
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _restore_preinstall_backup(
|
def _set_aside(self, plugin_path: Path, backup_path: Path) -> Optional[str]:
|
||||||
self, plugin_id: str, plugin_path: Path, backup_path: Path
|
"""Rename an installed plugin to ``backup_path`` so a failed
|
||||||
|
(re)install can put it back.
|
||||||
|
|
||||||
|
A stale backup left by a crash is cleared first, since it would block
|
||||||
|
the rename. Returns None on success, otherwise why it could not.
|
||||||
|
"""
|
||||||
|
if backup_path.exists() and not self._safe_remove_directory(backup_path):
|
||||||
|
return f"could not clear stale backup at {backup_path}"
|
||||||
|
try:
|
||||||
|
plugin_path.rename(backup_path)
|
||||||
|
except OSError as e:
|
||||||
|
return f"could not set aside {plugin_path}: {e}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _discard_backup(self, plugin_id: str, backup_path: Path, action: str) -> None:
|
||||||
|
"""Remove the set-aside copy after a successful (re)install."""
|
||||||
|
if not self._safe_remove_directory(backup_path):
|
||||||
|
self.logger.warning(
|
||||||
|
"%s of %s succeeded but the previous copy at %s could not be "
|
||||||
|
"removed; it will be cleared on the next %s",
|
||||||
|
action.capitalize(), plugin_id, backup_path, action)
|
||||||
|
|
||||||
|
def _restore_backup(
|
||||||
|
self, plugin_id: str, plugin_path: Path, backup_path: Path, action: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Put the previous install back after a failed (re)install."""
|
"""Put the set-aside copy back after a failed (re)install."""
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"Install of %s failed; restoring the previous version", plugin_id)
|
"%s of %s failed; restoring the previous version", action, plugin_id)
|
||||||
try:
|
try:
|
||||||
if plugin_path.exists():
|
if plugin_path.exists():
|
||||||
# Partial download debris from the failed install.
|
# Partial download debris from the failed install.
|
||||||
@@ -1375,8 +1312,7 @@ class PluginStoreManager:
|
|||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
branch_used = self._install_via_git(repo_url, plugin_path, branch_candidates)
|
branch_used = self._install_via_git(repo_url, plugin_path, branch_candidates)
|
||||||
if branch_used is None and not plugin_path.exists():
|
if branch_used is None:
|
||||||
# Git failed entirely; fall back to zip download
|
|
||||||
self.logger.info("Git not available or clone failed, attempting archive download...")
|
self.logger.info("Git not available or clone failed, attempting archive download...")
|
||||||
for candidate in branch_candidates:
|
for candidate in branch_candidates:
|
||||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||||
@@ -1384,7 +1320,7 @@ class PluginStoreManager:
|
|||||||
branch_used = candidate
|
branch_used = candidate
|
||||||
break
|
break
|
||||||
|
|
||||||
if branch_used is None and not plugin_path.exists():
|
if branch_used is None:
|
||||||
self.logger.error(f"Failed to install plugin {plugin_id} via git or archive download")
|
self.logger.error(f"Failed to install plugin {plugin_id} via git or archive download")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1523,8 +1459,7 @@ class PluginStoreManager:
|
|||||||
branch_info = f" (branch: {branch})" if branch else ""
|
branch_info = f" (branch: {branch})" if branch else ""
|
||||||
self.logger.info(f"Installing plugin from custom URL: {repo_url}{branch_info}" + (f" (subpath: {plugin_path})" if plugin_path else ""))
|
self.logger.info(f"Installing plugin from custom URL: {repo_url}{branch_info}" + (f" (subpath: {plugin_path})" if plugin_path else ""))
|
||||||
|
|
||||||
# Clean up URL (remove .git suffix if present)
|
repo_url = normalize_repo_url(repo_url)
|
||||||
repo_url = repo_url.rstrip('/').replace('.git', '')
|
|
||||||
|
|
||||||
temp_dir = None
|
temp_dir = None
|
||||||
try:
|
try:
|
||||||
@@ -1549,13 +1484,11 @@ class PluginStoreManager:
|
|||||||
'error': f'Failed to download or extract plugin from monorepo subdirectory: {plugin_path}'
|
'error': f'Failed to download or extract plugin from monorepo subdirectory: {plugin_path}'
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Try git clone for direct plugin repos
|
|
||||||
branch_used = self._install_via_git(repo_url, temp_dir, branch_candidates)
|
branch_used = self._install_via_git(repo_url, temp_dir, branch_candidates)
|
||||||
if branch_used:
|
if branch_used is not None:
|
||||||
self.logger.info(f"Cloned via git (branch: {branch_used})")
|
self.logger.info(f"Cloned via git (branch: {branch_used})")
|
||||||
else:
|
else:
|
||||||
# Git failed; try downloading as zip
|
self.logger.info("Git not available or clone failed, attempting archive download...")
|
||||||
branch_used = None
|
|
||||||
for candidate in branch_candidates:
|
for candidate in branch_candidates:
|
||||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||||
if self._install_via_download(download_url, temp_dir):
|
if self._install_via_download(download_url, temp_dir):
|
||||||
@@ -1634,8 +1567,10 @@ class PluginStoreManager:
|
|||||||
json.dump(manifest, f, indent=2)
|
json.dump(manifest, f, indent=2)
|
||||||
self.logger.info(f"Added missing entry_point field to {plugin_id} manifest (defaulted to manager.py)")
|
self.logger.info(f"Added missing entry_point field to {plugin_id} manifest (defaulted to manager.py)")
|
||||||
|
|
||||||
# Move to plugins directory - use manifest ID as source of truth
|
# The directory is named for the caller's plugin_id when one was
|
||||||
# This ensures directory name always matches manifest ID
|
# given (update_plugin passes the installed id), else for the
|
||||||
|
# manifest's id -- so it can differ from the manifest id, which
|
||||||
|
# discovery tolerates by reading the manifest.
|
||||||
final_path = self.plugins_dir / plugin_id
|
final_path = self.plugins_dir / plugin_id
|
||||||
if final_path.exists():
|
if final_path.exists():
|
||||||
self.logger.warning(f"Plugin {plugin_id} already exists, removing existing copy")
|
self.logger.warning(f"Plugin {plugin_id} already exists, removing existing copy")
|
||||||
@@ -1648,8 +1583,6 @@ class PluginStoreManager:
|
|||||||
shutil.move(str(temp_dir), str(final_path))
|
shutil.move(str(temp_dir), str(final_path))
|
||||||
temp_dir = None # Prevent cleanup since we moved it
|
temp_dir = None # Prevent cleanup since we moved it
|
||||||
|
|
||||||
# Note: plugin_id here is already from manifest (line 749), so directory name matches manifest ID
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
self._install_dependencies(final_path)
|
self._install_dependencies(final_path)
|
||||||
|
|
||||||
@@ -1701,7 +1634,6 @@ class PluginStoreManager:
|
|||||||
Class name if found, None otherwise
|
Class name if found, None otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import re
|
|
||||||
with open(manager_file, 'r', encoding='utf-8') as f:
|
with open(manager_file, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
@@ -1723,7 +1655,18 @@ class PluginStoreManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _install_via_git(self, repo_url: str, target_path: Path, branches: Optional[List[str]] = None) -> Optional[str]:
|
def _install_via_git(self, repo_url: str, target_path: Path, branches: Optional[List[str]] = None) -> Optional[str]:
|
||||||
"""Clone a repository into ``target_path``. Returns the branch name on success."""
|
"""Clone a repository into ``target_path``.
|
||||||
|
|
||||||
|
Tries each of ``branches`` (default ``main``, ``master``), then the
|
||||||
|
repository's own default branch, so a repository whose only branch
|
||||||
|
is e.g. ``develop`` still installs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The branch that was cloned, or None when every clone failed and
|
||||||
|
``target_path`` has been removed. After a default-branch clone
|
||||||
|
this is the branch the clone checked out (``'HEAD'`` if the
|
||||||
|
remote's HEAD is detached), never None.
|
||||||
|
"""
|
||||||
branches_to_try = self._distinct_sequence(branches or [])
|
branches_to_try = self._distinct_sequence(branches or [])
|
||||||
if not branches_to_try:
|
if not branches_to_try:
|
||||||
branches_to_try = ['main', 'master']
|
branches_to_try = ['main', 'master']
|
||||||
@@ -1758,7 +1701,7 @@ class PluginStoreManager:
|
|||||||
timeout=60
|
timeout=60
|
||||||
)
|
)
|
||||||
self.logger.debug(f"Successfully cloned {repo_url} (git default branch) to {target_path}")
|
self.logger.debug(f"Successfully cloned {repo_url} (git default branch) to {target_path}")
|
||||||
return None # Unknown branch name, git default used
|
return self._checked_out_branch(target_path)
|
||||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||||
last_error = e
|
last_error = e
|
||||||
if target_path.exists():
|
if target_path.exists():
|
||||||
@@ -1767,6 +1710,19 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Git clone failed for all attempted branches: {last_error}")
|
self.logger.error(f"Git clone failed for all attempted branches: {last_error}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _checked_out_branch(checkout: Path) -> str:
|
||||||
|
"""The branch a fresh clone has checked out, read from ``.git/HEAD``.
|
||||||
|
|
||||||
|
``'HEAD'`` when HEAD is detached or unreadable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
head = (checkout / '.git' / 'HEAD').read_text(encoding='utf-8').strip()
|
||||||
|
except OSError:
|
||||||
|
return 'HEAD'
|
||||||
|
prefix = 'ref: refs/heads/'
|
||||||
|
return head[len(prefix):] if head.startswith(prefix) else 'HEAD'
|
||||||
|
|
||||||
def _install_from_monorepo(self, download_url: str, plugin_subpath: str, target_path: Path) -> bool:
|
def _install_from_monorepo(self, download_url: str, plugin_subpath: str, target_path: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
Install a plugin from a monorepo by downloading only the target subdirectory.
|
Install a plugin from a monorepo by downloading only the target subdirectory.
|
||||||
@@ -1815,14 +1771,6 @@ class PluginStoreManager:
|
|||||||
pass
|
pass
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_repo_url(url: str) -> str:
|
|
||||||
"""Normalize a GitHub repo URL for comparison (strip trailing / and .git)."""
|
|
||||||
url = url.rstrip('/')
|
|
||||||
if url.endswith('.git'):
|
|
||||||
url = url[:-4]
|
|
||||||
return url.lower()
|
|
||||||
|
|
||||||
def _install_from_monorepo_api(self, repo_url: str, branch: str, plugin_subpath: str, target_path: Path) -> bool:
|
def _install_from_monorepo_api(self, repo_url: str, branch: str, plugin_subpath: str, target_path: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
Install a plugin subdirectory using the GitHub Git Trees API.
|
Install a plugin subdirectory using the GitHub Git Trees API.
|
||||||
@@ -1841,25 +1789,15 @@ class PluginStoreManager:
|
|||||||
True if successful, False to trigger ZIP fallback
|
True if successful, False to trigger ZIP fallback
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Parse owner/repo from URL
|
owner_repo = github_owner_repo(repo_url)
|
||||||
clean_url = repo_url.rstrip('/')
|
if owner_repo is None:
|
||||||
if clean_url.endswith('.git'):
|
|
||||||
clean_url = clean_url[:-4]
|
|
||||||
parts = clean_url.split('/')
|
|
||||||
if len(parts) < 2:
|
|
||||||
return False
|
return False
|
||||||
owner, repo = parts[-2], parts[-1]
|
owner, repo = owner_repo
|
||||||
|
|
||||||
# Step 1: Get the recursive tree listing (1 API call)
|
# Step 1: Get the recursive tree listing (1 API call)
|
||||||
api_url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=true"
|
api_url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=true"
|
||||||
headers = {
|
tree_response = self._http_get_with_retries(
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
api_url, timeout=15, headers=github_api_headers(self.github_token))
|
||||||
'User-Agent': 'LEDMatrix-Plugin-Manager/1.0'
|
|
||||||
}
|
|
||||||
if self.github_token:
|
|
||||||
headers['Authorization'] = f'token {self.github_token}'
|
|
||||||
|
|
||||||
tree_response = self._http_get_with_retries(api_url, timeout=15, headers=headers)
|
|
||||||
if tree_response.status_code != 200:
|
if tree_response.status_code != 200:
|
||||||
self.logger.debug(f"Trees API returned {tree_response.status_code} for {owner}/{repo}")
|
self.logger.debug(f"Trees API returned {tree_response.status_code} for {owner}/{repo}")
|
||||||
return False
|
return False
|
||||||
@@ -1892,10 +1830,6 @@ class PluginStoreManager:
|
|||||||
self.logger.info(f"Downloading {len(file_entries)} files for {plugin_subpath} via API")
|
self.logger.info(f"Downloading {len(file_entries)} files for {plugin_subpath} via API")
|
||||||
|
|
||||||
# Step 3: Create target directory and download each file
|
# Step 3: Create target directory and download each file
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_directory_permissions,
|
|
||||||
get_plugin_dir_mode
|
|
||||||
)
|
|
||||||
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
||||||
target_path.mkdir(parents=True, exist_ok=True)
|
target_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -1991,10 +1925,6 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
source_plugin_dir = temp_extract / root_dir / plugin_subpath
|
source_plugin_dir = temp_extract / root_dir / plugin_subpath
|
||||||
|
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_directory_permissions,
|
|
||||||
get_plugin_dir_mode
|
|
||||||
)
|
|
||||||
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
||||||
# Ensure target doesn't exist to prevent shutil.move nesting
|
# Ensure target doesn't exist to prevent shutil.move nesting
|
||||||
if target_path.exists():
|
if target_path.exists():
|
||||||
@@ -2028,7 +1958,7 @@ class PluginStoreManager:
|
|||||||
try:
|
try:
|
||||||
self.logger.info(f"Downloading from: {download_url}")
|
self.logger.info(f"Downloading from: {download_url}")
|
||||||
# Allow redirects (GitHub archive URLs redirect to codeload.github.com)
|
# Allow redirects (GitHub archive URLs redirect to codeload.github.com)
|
||||||
response = self._http_get_with_retries(download_url, timeout=60, stream=True, headers={'User-Agent': 'LEDMatrix-Plugin-Manager/1.0'})
|
response = self._http_get_with_retries(download_url, timeout=60, stream=True, headers={'User-Agent': USER_AGENT})
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# Download to temporary file
|
# Download to temporary file
|
||||||
@@ -2065,10 +1995,6 @@ class PluginStoreManager:
|
|||||||
# Move contents from root_dir to target
|
# Move contents from root_dir to target
|
||||||
source_dir = temp_extract / root_dir
|
source_dir = temp_extract / root_dir
|
||||||
if source_dir.exists():
|
if source_dir.exists():
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_directory_permissions,
|
|
||||||
get_plugin_dir_mode
|
|
||||||
)
|
|
||||||
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
||||||
shutil.move(str(source_dir), str(target_path))
|
shutil.move(str(source_dir), str(target_path))
|
||||||
else:
|
else:
|
||||||
@@ -2094,42 +2020,23 @@ class PluginStoreManager:
|
|||||||
"""
|
"""
|
||||||
Install Python dependencies from requirements.txt.
|
Install Python dependencies from requirements.txt.
|
||||||
|
|
||||||
|
``plugin_path`` is ultimately derived from a plugin-supplied manifest
|
||||||
|
``id``, so it is only used after contained_plugin_dir() has rebuilt it
|
||||||
|
from a listing of ``self.plugins_dir``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_path: Path to plugin directory
|
plugin_path: Path to plugin directory
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful or no requirements file
|
True if successful or no requirements file
|
||||||
"""
|
"""
|
||||||
# Reconstruct the plugin path from the trusted self.plugins_dir base +
|
safe_plugin_dir = contained_plugin_dir(plugin_path, self.plugins_dir)
|
||||||
# an entry actually enumerated from it, rather than trusting
|
if safe_plugin_dir is None:
|
||||||
# plugin_path directly -- callers ultimately derive it from a
|
|
||||||
# plugin-supplied manifest "id" field (see install_plugin_from_url),
|
|
||||||
# so without this a malicious manifest could point requirements_file
|
|
||||||
# outside plugins_dir. find_trusted_subdir()'s return value always
|
|
||||||
# comes from os.scandir() on the trusted root, so building the path
|
|
||||||
# from it (not from the caller's string) is a real containment
|
|
||||||
# guarantee, matching the pattern in PluginLoader.install_dependencies().
|
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_path))
|
|
||||||
plugins_dir_real = os.path.realpath(str(self.plugins_dir))
|
|
||||||
requested_name = os.path.basename(plugin_dir_real)
|
|
||||||
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
|
||||||
if matched_name is None:
|
|
||||||
self.logger.error("Plugin directory not found inside plugins dir for dependency install")
|
self.logger.error("Plugin directory not found inside plugins dir for dependency install")
|
||||||
return False
|
return False
|
||||||
safe_plugin_path = Path(os.path.join(plugins_dir_real, matched_name))
|
|
||||||
|
|
||||||
requirements_file = safe_plugin_path / "requirements.txt"
|
requirements_file = requirements_to_install(safe_plugin_dir, self.logger, plugin_path.name)
|
||||||
|
if requirements_file is None:
|
||||||
if not requirements_file.exists():
|
|
||||||
self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
if not requirements_has_real_deps(str(requirements_file)):
|
|
||||||
self.logger.debug(f"requirements.txt for {plugin_path.name} has no real dependencies, skipping pip")
|
|
||||||
return True
|
|
||||||
|
|
||||||
if requirements_are_satisfied(str(requirements_file)):
|
|
||||||
self.logger.debug(f"Dependencies for {plugin_path.name} already satisfied, skipping pip")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2141,7 +2048,7 @@ class PluginStoreManager:
|
|||||||
# ledmatrix.service, so pip reports success while the package
|
# ledmatrix.service, so pip reports success while the package
|
||||||
# stays invisible to the running plugin (e.g. missing `astral`
|
# stays invisible to the running plugin (e.g. missing `astral`
|
||||||
# for the weather plugin even though "install" succeeded).
|
# for the weather plugin even though "install" succeeded).
|
||||||
result = install_requirements_file(requirements_file, timeout=300)
|
result = install_requirements_file(Path(requirements_file), timeout=300)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"Error installing dependencies for {plugin_path.name}: {result.stderr}"
|
f"Error installing dependencies for {plugin_path.name}: {result.stderr}"
|
||||||
@@ -2153,10 +2060,10 @@ class PluginStoreManager:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self.logger.error("Dependency installation timed out")
|
self.logger.error("Dependency installation timed out")
|
||||||
return False
|
return False
|
||||||
except (BrokenPipeError, OSError) as e:
|
except OSError as e:
|
||||||
# Handle broken pipe errors (errno 32) which can occur during pip downloads
|
# A broken pipe (EPIPE) happens when pip's output pipe closes
|
||||||
# Often caused by network interruptions or output buffer issues
|
# mid-download, usually a network interruption.
|
||||||
if isinstance(e, OSError) and e.errno == 32:
|
if e.errno == errno.EPIPE:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"Broken pipe error during dependency installation for {plugin_path.name}. "
|
f"Broken pipe error during dependency installation for {plugin_path.name}. "
|
||||||
f"This usually indicates a network interruption or pip output buffer issue. "
|
f"This usually indicates a network interruption or pip output buffer issue. "
|
||||||
@@ -2166,7 +2073,6 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"OS error during dependency installation: {e}")
|
self.logger.error(f"OS error during dependency installation: {e}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Catch any other unexpected errors
|
|
||||||
self.logger.error(f"Unexpected error installing dependencies for {plugin_path.name}: {e}", exc_info=True)
|
self.logger.error(f"Unexpected error installing dependencies for {plugin_path.name}: {e}", exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -2241,9 +2147,9 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
Results are cached keyed on a signature that includes HEAD
|
Results are cached keyed on a signature that includes HEAD
|
||||||
contents plus the mtime of HEAD AND the resolved ref (or
|
contents plus the mtime of HEAD AND the resolved ref (or
|
||||||
packed-refs). Repeated calls skip the four ``git`` subprocesses
|
packed-refs). Repeated calls skip the ``git log`` subprocess when
|
||||||
when nothing has changed, and a ``git pull`` that fast-forwards
|
nothing has changed, and a ``git pull`` that fast-forwards the
|
||||||
the branch correctly invalidates the cache.
|
branch correctly invalidates the cache.
|
||||||
"""
|
"""
|
||||||
git_dir = plugin_path / '.git'
|
git_dir = plugin_path / '.git'
|
||||||
if not git_dir.exists():
|
if not git_dir.exists():
|
||||||
@@ -2412,12 +2318,11 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
No ``ledmatrix-`` prefix and no case folding here, unlike the loader:
|
No ``ledmatrix-`` prefix and no case folding here, unlike the loader:
|
||||||
a store operation may delete what this returns, so it only accepts a
|
a store operation may delete what this returns, so it only accepts a
|
||||||
directory that names the id exactly or declares it. Note that this
|
directory that names the id exactly or declares it. So a registry id
|
||||||
leaves registry ids like `stocks` unresolved when the installed
|
such as `stocks` does not resolve to an installed `ledmatrix-stocks/`
|
||||||
plugin is `ledmatrix-stocks/` declaring `ledmatrix-stocks` (the
|
declaring `ledmatrix-stocks` (the monorepo's leaderboard, music,
|
||||||
monorepo's leaderboard, music, stocks and weather); passing
|
stocks and weather); callers pass the installed id, and
|
||||||
``prefix=True`` would resolve them, but update_plugin()'s reinstall
|
update_plugin() maps it back to the registry id itself.
|
||||||
path has not been checked against that yet.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
@@ -2545,11 +2450,9 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
The old install is renamed aside (not deleted) until the new install
|
The old install is renamed aside (not deleted) until the new install
|
||||||
succeeds, then removed; on ANY install failure the old directory is
|
succeeds, then removed; on ANY install failure the old directory is
|
||||||
restored. This is the difference between a failed update and a
|
restored. Deleting first turns a failed download into a destroyed
|
||||||
destroyed plugin: the previous delete-then-install flow permanently
|
plugin: during the monorepo migration a Pi with broken DNS lost every
|
||||||
removed plugins whenever the download failed mid-update (seen in the
|
old-remote plugin that way, with none able to be re-downloaded.
|
||||||
field during the monorepo migration on a Pi with broken DNS — every
|
|
||||||
old-remote plugin was deleted and none could be re-downloaded).
|
|
||||||
|
|
||||||
The aside name embeds BACKUP_MARKER ('.standalone-backup-') so every
|
The aside name embeds BACKUP_MARKER ('.standalone-backup-') so every
|
||||||
plugin directory lookup (src/plugin_system/plugin_dirs.py) ignores it
|
plugin directory lookup (src/plugin_system/plugin_dirs.py) ignores it
|
||||||
@@ -2564,18 +2467,11 @@ class PluginStoreManager:
|
|||||||
with self._get_reinstall_lock(plugin_id):
|
with self._get_reinstall_lock(plugin_id):
|
||||||
backup_path = plugin_path.with_name(
|
backup_path = plugin_path.with_name(
|
||||||
f"{plugin_path.name}{BACKUP_MARKER}migrating")
|
f"{plugin_path.name}{BACKUP_MARKER}migrating")
|
||||||
# A stale aside from a previous crash would block the rename
|
problem = self._set_aside(plugin_path, backup_path)
|
||||||
if backup_path.exists():
|
if problem:
|
||||||
if not self._safe_remove_directory(backup_path):
|
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"Could not clear stale backup for {plugin_id} at "
|
"Not updating %s: %s; the installed version is left in place",
|
||||||
f"{backup_path}; leaving old install in place")
|
plugin_id, problem)
|
||||||
return False
|
|
||||||
try:
|
|
||||||
plugin_path.rename(backup_path)
|
|
||||||
except OSError as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Could not set aside old plugin directory for {plugin_id}: {e}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2585,27 +2481,11 @@ class PluginStoreManager:
|
|||||||
installed = False
|
installed = False
|
||||||
|
|
||||||
if installed:
|
if installed:
|
||||||
if not self._safe_remove_directory(backup_path):
|
self._discard_backup(plugin_id, backup_path, "update")
|
||||||
self.logger.warning(
|
|
||||||
f"Update of {plugin_id} succeeded but the old backup "
|
|
||||||
f"at {backup_path} could not be removed; it will be "
|
|
||||||
f"cleared on the next update")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Install failed (bad network, registry error...) — put the old
|
# Bad network, registry error...: the user keeps a working plugin.
|
||||||
# version back so the user still has a working plugin.
|
self._restore_backup(plugin_id, plugin_path, backup_path, "Reinstall")
|
||||||
self.logger.error(
|
|
||||||
f"Reinstall of {plugin_id} failed; restoring previous version")
|
|
||||||
try:
|
|
||||||
if plugin_path.exists():
|
|
||||||
# partial download debris from the failed install
|
|
||||||
self._safe_remove_directory(plugin_path)
|
|
||||||
backup_path.rename(plugin_path)
|
|
||||||
self.logger.info(f"Restored previous install of {plugin_id}")
|
|
||||||
except OSError as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"CRITICAL: could not restore {plugin_id} from {backup_path}: {e}. "
|
|
||||||
f"The previous install is preserved there — rename it back manually.")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_plugin(self, plugin_id: str) -> bool:
|
def update_plugin(self, plugin_id: str) -> bool:
|
||||||
@@ -2665,7 +2545,7 @@ class PluginStoreManager:
|
|||||||
# while the registry now points to the monorepo. Detect this and reinstall.
|
# while the registry now points to the monorepo. Detect this and reinstall.
|
||||||
registry_repo = plugin_info_remote.get('repo', '')
|
registry_repo = plugin_info_remote.get('repo', '')
|
||||||
local_remote = git_info.get('remote_url', '')
|
local_remote = git_info.get('remote_url', '')
|
||||||
if local_remote and registry_repo and self._normalize_repo_url(local_remote) != self._normalize_repo_url(registry_repo):
|
if local_remote and registry_repo and not same_repo(local_remote, registry_repo):
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
||||||
f"Reinstalling from registry to migrate to new source."
|
f"Reinstalling from registry to migrate to new source."
|
||||||
@@ -2814,7 +2694,6 @@ class PluginStoreManager:
|
|||||||
# If status check times out, assume there might be changes and proceed
|
# 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")
|
self.logger.warning(f"Git status check timed out for {plugin_id}, proceeding with update")
|
||||||
has_changes = True
|
has_changes = True
|
||||||
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
|
||||||
|
|
||||||
stash_info = ""
|
stash_info = ""
|
||||||
# Whether the pull can be undone without destroying work.
|
# Whether the pull can be undone without destroying work.
|
||||||
@@ -2898,7 +2777,7 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
except subprocess.CalledProcessError as git_error:
|
except subprocess.CalledProcessError as git_error:
|
||||||
error_output = git_error.stderr or git_error.stdout or "Unknown error"
|
error_output = git_error.stderr or git_error.stdout or "Unknown error"
|
||||||
cmd_str = ' '.join(git_error.cmd) if hasattr(git_error, 'cmd') else 'unknown'
|
cmd_str = ' '.join(git_error.cmd)
|
||||||
self.logger.error(f"Git update failed for {plugin_id}")
|
self.logger.error(f"Git update failed for {plugin_id}")
|
||||||
self.logger.error(f"Command: {cmd_str}")
|
self.logger.error(f"Command: {cmd_str}")
|
||||||
self.logger.error(f"Return code: {git_error.returncode}")
|
self.logger.error(f"Return code: {git_error.returncode}")
|
||||||
@@ -2914,7 +2793,7 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Authentication failed for {plugin_id}. Check git credentials or repository permissions.")
|
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:
|
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.")
|
self.logger.error(f"Remote branch or repository not found for {plugin_id}. Check repository URL and branch name.")
|
||||||
elif "merge conflict" in error_lower or "conflict" in error_lower:
|
elif "conflict" in error_lower:
|
||||||
self.logger.error(f"Merge conflict detected for {plugin_id}. Resolve conflicts manually or reinstall plugin.")
|
self.logger.error(f"Merge conflict detected for {plugin_id}. Resolve conflicts manually or reinstall plugin.")
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -2922,12 +2801,15 @@ class PluginStoreManager:
|
|||||||
self.logger.warning(f"Git update timed out for {plugin_id}")
|
self.logger.warning(f"Git update timed out for {plugin_id}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Not a git repository - try to get repo URL from git config if it exists
|
# A plugin with its own .git that _get_local_git_info could not
|
||||||
# (in case .git directory was removed but remote URL is still in config)
|
# 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
|
repo_url = None
|
||||||
|
if (plugin_path / '.git').exists():
|
||||||
try:
|
try:
|
||||||
# Use --local to avoid inheriting the parent LEDMatrix repo's git config
|
|
||||||
# when the plugin directory lives inside the main repo (e.g. plugin-repos/).
|
|
||||||
remote_url_result = subprocess.run(
|
remote_url_result = subprocess.run(
|
||||||
['git', '-C', str(plugin_path), 'config', '--local', '--get', 'remote.origin.url'],
|
['git', '-C', str(plugin_path), 'config', '--local', '--get', 'remote.origin.url'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -2936,9 +2818,10 @@ class PluginStoreManager:
|
|||||||
check=False
|
check=False
|
||||||
)
|
)
|
||||||
if remote_url_result.returncode == 0:
|
if remote_url_result.returncode == 0:
|
||||||
repo_url = remote_url_result.stdout.strip()
|
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}")
|
self.logger.info(f"Found git remote URL for {plugin_id}: {repo_url}")
|
||||||
except Exception as e:
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
self.logger.debug(f"Could not get git remote URL: {e}")
|
self.logger.debug(f"Could not get git remote URL: {e}")
|
||||||
|
|
||||||
# Try registry-based update
|
# Try registry-based update
|
||||||
@@ -3027,9 +2910,7 @@ class PluginStoreManager:
|
|||||||
return self._reinstall_with_rollback(registry_id, plugin_path)
|
return self._reinstall_with_rollback(registry_id, plugin_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
self.logger.error(f"Error updating plugin {plugin_id}: {e}", exc_info=True)
|
||||||
self.logger.error(f"Error updating plugin {plugin_id}: {e}")
|
|
||||||
self.logger.debug(traceback.format_exc())
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def list_installed_plugins(self) -> List[str]:
|
def list_installed_plugins(self) -> List[str]:
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Startup Validator
|
Startup Validator
|
||||||
|
|
||||||
Validates system configuration, plugins, and dependencies on startup.
|
Checks configuration, the cache directory, plugins and the installed systemd
|
||||||
Fails fast with clear error messages to prevent runtime issues.
|
units when the display service starts, and reports what it finds.
|
||||||
|
|
||||||
|
validate_all() never raises: it returns (is_valid, errors, warnings) and
|
||||||
|
DisplayController logs them. Startup continues either way, so a problem found
|
||||||
|
here shows up in the log rather than stopping the display. raise_on_errors()
|
||||||
|
turns the errors into exceptions for a caller that does want to stop; the
|
||||||
|
display service does not call it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -180,16 +186,15 @@ class StartupValidator:
|
|||||||
try:
|
try:
|
||||||
config = self.config_manager.load_config()
|
config = self.config_manager.load_config()
|
||||||
|
|
||||||
# Check for required top-level keys
|
|
||||||
required_keys = ['display', 'timezone']
|
required_keys = ['display', 'timezone']
|
||||||
for key in required_keys:
|
for key in required_keys:
|
||||||
if key not in config:
|
if key not in config:
|
||||||
self.errors.append(f"Missing required configuration key: {key}")
|
self.errors.append(f"Missing required configuration key: {key}")
|
||||||
|
|
||||||
# Validate display configuration
|
# A missing display section is reported once, above, and an empty
|
||||||
display_config = config.get('display', {})
|
# one here; _validate_display_config leaves both to this method.
|
||||||
if not display_config:
|
if 'display' in config and not config['display']:
|
||||||
self.errors.append("Display configuration is missing or empty")
|
self.errors.append("Display configuration is empty")
|
||||||
|
|
||||||
except ConfigError as e:
|
except ConfigError as e:
|
||||||
self.errors.append(f"Configuration error: {e}")
|
self.errors.append(f"Configuration error: {e}")
|
||||||
@@ -247,8 +252,7 @@ class StartupValidator:
|
|||||||
display_config = config.get('display', {})
|
display_config = config.get('display', {})
|
||||||
|
|
||||||
if not display_config:
|
if not display_config:
|
||||||
self.errors.append("Display configuration is missing")
|
return # reported by _validate_config
|
||||||
return
|
|
||||||
|
|
||||||
hardware_config = display_config.get('hardware', {})
|
hardware_config = display_config.get('hardware', {})
|
||||||
if not hardware_config:
|
if not hardware_config:
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Putting a submitted plugin config's lists back into list shape.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
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],
|
||||||
|
short_lists_take_default: bool = False) -> None:
|
||||||
|
"""Turn position-keyed dicts into lists wherever the schema has an array.
|
||||||
|
|
||||||
|
Walks ``config`` alongside the schema's ``properties``, in place: into
|
||||||
|
nested objects, and into the objects of an array's items. An empty dict
|
||||||
|
where an array belongs becomes ``[]``.
|
||||||
|
|
||||||
|
``short_lists_take_default`` is for form posts. A form draws a fixed-length
|
||||||
|
list (an RGB colour, say) as one input per element, and a blanked input
|
||||||
|
drops out of the parsed list; the schema default then stands in, as long as
|
||||||
|
it is itself long enough, instead of the save failing on ``minItems``.
|
||||||
|
|
||||||
|
Element types are left alone: normalization after this converts numeric
|
||||||
|
strings to the numbers the schema asks for.
|
||||||
|
"""
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return
|
||||||
|
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 _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):
|
||||||
|
continue
|
||||||
|
min_items = prop_schema.get('minItems')
|
||||||
|
default = prop_schema.get('default')
|
||||||
|
if (short_lists_take_default and min_items is not None
|
||||||
|
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'
|
||||||
|
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:
|
||||||
|
coerce_array_shapes(value, prop_schema['properties'], short_lists_take_default)
|
||||||
+235
-316
@@ -9,24 +9,18 @@ Tested and optimized for:
|
|||||||
- Raspberry Pi OS Bookworm (Debian 12) with NetworkManager
|
- Raspberry Pi OS Bookworm (Debian 12) with NetworkManager
|
||||||
- Raspberry Pi 3B+, 4, 5 with built-in WiFi
|
- Raspberry Pi 3B+, 4, 5 with built-in WiFi
|
||||||
|
|
||||||
Sudoers Requirements:
|
Privileges:
|
||||||
The following sudoers entries are required for passwordless operation.
|
The web interface runs as an unprivileged user and reaches nmcli,
|
||||||
Add to /etc/sudoers.d/ledmatrix_wifi:
|
systemctl, sysctl, nft and rfkill through exact-command sudo rules.
|
||||||
|
scripts/install/configure_wifi_permissions.sh writes those rules (and a
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/nmcli
|
PolicyKit rule for NetworkManager); first_time_install.sh runs it. Use
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl start hostapd
|
that script rather than granting commands by hand. It deliberately
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop hostapd
|
grants neither ``iptables`` nor ``ip``: their rules take a live interface
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl start dnsmasq
|
name, so they would need a wildcard, and ``iptables --modprobe=<path>``
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop dnsmasq
|
and ``ip netns exec`` both run an arbitrary program as root. The code
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart NetworkManager
|
paths that call them with sudo therefore only work where the user has
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/ip
|
broader sudo rights (a stock Raspberry Pi image grants the default user
|
||||||
ledpi ALL=(ALL) NOPASSWD: /sbin/ip
|
blanket NOPASSWD).
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/rfkill
|
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/iptables
|
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/sysctl
|
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
|
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf
|
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -39,6 +33,8 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from src.config_manager_atomic import atomic_write_json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Path for storing WiFi configuration (will be set dynamically)
|
# Path for storing WiFi configuration (will be set dynamically)
|
||||||
@@ -78,6 +74,22 @@ DNSMASQ_SERVICE = "dnsmasq"
|
|||||||
DEFAULT_AP_SSID = "LEDMatrix-Setup"
|
DEFAULT_AP_SSID = "LEDMatrix-Setup"
|
||||||
DEFAULT_AP_CHANNEL = 7
|
DEFAULT_AP_CHANNEL = 7
|
||||||
|
|
||||||
|
#: The access point's own address. Clients get 192.168.4.2-20 from dnsmasq
|
||||||
|
#: (hostapd mode) and every DNS name resolves here, which is what makes phones
|
||||||
|
#: show the captive-portal page.
|
||||||
|
AP_IP = "192.168.4.1"
|
||||||
|
|
||||||
|
#: The web interface's port. The captive portal redirects port 80 to it.
|
||||||
|
PORTAL_PORT = 5000
|
||||||
|
|
||||||
|
#: The NetworkManager profile this module creates for the access point.
|
||||||
|
AP_PROFILE_NAME = "LEDMatrix-Setup-AP"
|
||||||
|
|
||||||
|
#: AP profiles taken down and deleted before a new one is created and when AP
|
||||||
|
#: mode ends: ours, NetworkManager's default hotspot name, and an older name.
|
||||||
|
#: Deleted by name only, never by SSID, so a saved home network is never hit.
|
||||||
|
AP_PROFILE_NAMES = (AP_PROFILE_NAME, "Hotspot", "TickerSetup-AP")
|
||||||
|
|
||||||
# LED status message file (for display_controller integration)
|
# LED status message file (for display_controller integration)
|
||||||
LED_STATUS_FILE = None # Will be set dynamically
|
LED_STATUS_FILE = None # Will be set dynamically
|
||||||
|
|
||||||
@@ -198,30 +210,8 @@ class WiFiManager:
|
|||||||
logger.debug(f"Could not clear LED status message: {e}")
|
logger.debug(f"Could not clear LED status message: {e}")
|
||||||
|
|
||||||
def _check_command(self, command: str) -> bool:
|
def _check_command(self, command: str) -> bool:
|
||||||
"""Check if a command is available"""
|
"""Whether ``command`` is installed (see _find_command_path)."""
|
||||||
try:
|
return self._find_command_path(command) is not None
|
||||||
# First try 'which' command
|
|
||||||
result = subprocess.run(
|
|
||||||
["which", command],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=2
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check common sbin paths (not in standard user PATH)
|
|
||||||
sbin_paths = [
|
|
||||||
f"/usr/sbin/{command}",
|
|
||||||
f"/sbin/{command}",
|
|
||||||
f"/usr/local/sbin/{command}"
|
|
||||||
]
|
|
||||||
for path in sbin_paths:
|
|
||||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _find_command_path(self, command: str) -> Optional[str]:
|
def _find_command_path(self, command: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
@@ -321,14 +311,22 @@ class WiFiManager:
|
|||||||
del self.config["saved_networks"]
|
del self.config["saved_networks"]
|
||||||
self._save_config()
|
self._save_config()
|
||||||
|
|
||||||
def _save_config(self):
|
def _save_config(self) -> bool:
|
||||||
"""Save WiFi configuration to file"""
|
"""Write ``self.config`` to ``self.config_path``.
|
||||||
|
|
||||||
|
The write is atomic and keeps the file's owner and shared group (see
|
||||||
|
atomic_write_json), so a save by the root display service does not
|
||||||
|
lock the web user out of the file. Returns False when the file could
|
||||||
|
not be written, for example when an older root-run save left it
|
||||||
|
owned by root; the in-memory config is kept either way.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with open(self.config_path, 'w') as f:
|
atomic_write_json(self.config_path, self.config)
|
||||||
json.dump(self.config, f, indent=2)
|
except (OSError, TypeError, ValueError) as e:
|
||||||
|
logger.error(f"Failed to save WiFi config to {self.config_path}: {e}")
|
||||||
|
return False
|
||||||
logger.info(f"Saved WiFi config to {self.config_path}")
|
logger.info(f"Saved WiFi config to {self.config_path}")
|
||||||
except Exception as e:
|
return True
|
||||||
logger.error(f"Failed to save WiFi config: {e}")
|
|
||||||
|
|
||||||
def get_wifi_status(self) -> WiFiStatus:
|
def get_wifi_status(self) -> WiFiStatus:
|
||||||
"""
|
"""
|
||||||
@@ -402,8 +400,6 @@ class WiFiManager:
|
|||||||
for line in result.stdout.strip().split('\n'):
|
for line in result.stdout.strip().split('\n'):
|
||||||
if '802-11-wireless.ssid:' in line:
|
if '802-11-wireless.ssid:' in line:
|
||||||
ssid = line.split(':', 1)[1].strip()
|
ssid = line.split(':', 1)[1].strip()
|
||||||
if ssid:
|
|
||||||
continue
|
|
||||||
elif 'WIFI.SIGNAL:' in line:
|
elif 'WIFI.SIGNAL:' in line:
|
||||||
try:
|
try:
|
||||||
signal = int(line.split(':', 1)[1].strip())
|
signal = int(line.split(':', 1)[1].strip())
|
||||||
@@ -426,23 +422,6 @@ class WiFiManager:
|
|||||||
if ssid:
|
if ssid:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Fallback: Get signal strength if not already retrieved
|
|
||||||
if signal == 0 and wlan_device:
|
|
||||||
result = subprocess.run(
|
|
||||||
["nmcli", "-t", "-f", "WIFI.SIGNAL", "device", "show", wlan_device],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
|
||||||
if 'WIFI.SIGNAL:' in line:
|
|
||||||
try:
|
|
||||||
signal = int(line.split(':', 1)[1].strip())
|
|
||||||
break
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Get IP address if connected
|
# Get IP address if connected
|
||||||
if wifi_connected and wlan_device:
|
if wifi_connected and wlan_device:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -536,7 +515,7 @@ class WiFiManager:
|
|||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
ips = result.stdout.strip().split()
|
ips = result.stdout.strip().split()
|
||||||
for ip in ips:
|
for ip in ips:
|
||||||
if not ip.startswith('192.168.4.1'): # Exclude AP IP
|
if ip != AP_IP:
|
||||||
ip_address = ip
|
ip_address = ip
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -778,13 +757,13 @@ class WiFiManager:
|
|||||||
if subprocess.run(
|
if subprocess.run(
|
||||||
["sudo", iptables, "-t", "nat", "-C", "PREROUTING",
|
["sudo", iptables, "-t", "nat", "-C", "PREROUTING",
|
||||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||||
"-j", "REDIRECT", "--to-port", "5000"],
|
"-j", "REDIRECT", "--to-port", str(PORTAL_PORT)],
|
||||||
capture_output=True, timeout=5
|
capture_output=True, timeout=5
|
||||||
).returncode != 0:
|
).returncode != 0:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["sudo", iptables, "-t", "nat", "-A", "PREROUTING",
|
["sudo", iptables, "-t", "nat", "-A", "PREROUTING",
|
||||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||||
"-j", "REDIRECT", "--to-port", "5000"],
|
"-j", "REDIRECT", "--to-port", str(PORTAL_PORT)],
|
||||||
capture_output=True, text=True, timeout=5
|
capture_output=True, text=True, timeout=5
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -794,12 +773,12 @@ class WiFiManager:
|
|||||||
|
|
||||||
if subprocess.run(
|
if subprocess.run(
|
||||||
["sudo", iptables, "-C", "INPUT",
|
["sudo", iptables, "-C", "INPUT",
|
||||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", str(PORTAL_PORT), "-j", "ACCEPT"],
|
||||||
capture_output=True, timeout=5
|
capture_output=True, timeout=5
|
||||||
).returncode != 0:
|
).returncode != 0:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["sudo", iptables, "-A", "INPUT",
|
["sudo", iptables, "-A", "INPUT",
|
||||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", str(PORTAL_PORT), "-j", "ACCEPT"],
|
||||||
capture_output=True, text=True, timeout=5
|
capture_output=True, text=True, timeout=5
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -808,7 +787,7 @@ class WiFiManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
self._redirect_backend = "iptables"
|
self._redirect_backend = "iptables"
|
||||||
logger.info("iptables: port 80→5000 redirect rules added")
|
logger.info(f"iptables: port 80→{PORTAL_PORT} redirect rules added")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _setup_iptables_redirect_nftables(self, nft: str) -> bool:
|
def _setup_iptables_redirect_nftables(self, nft: str) -> bool:
|
||||||
@@ -819,7 +798,7 @@ class WiFiManager:
|
|||||||
["sudo", nft, "add", "chain", "ip", "ledmatrix", "prerouting",
|
["sudo", nft, "add", "chain", "ip", "ledmatrix", "prerouting",
|
||||||
"{", "type", "nat", "hook", "prerouting", "priority", "-100", ";", "}"],
|
"{", "type", "nat", "hook", "prerouting", "priority", "-100", ";", "}"],
|
||||||
["sudo", nft, "add", "rule", "ip", "ledmatrix", "prerouting",
|
["sudo", nft, "add", "rule", "ip", "ledmatrix", "prerouting",
|
||||||
"iif", self._wifi_interface, "tcp", "dport", "80", "redirect", "to", ":5000"],
|
"iif", self._wifi_interface, "tcp", "dport", "80", "redirect", "to", f":{PORTAL_PORT}"],
|
||||||
]
|
]
|
||||||
for cmd in cmds:
|
for cmd in cmds:
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||||
@@ -832,7 +811,7 @@ class WiFiManager:
|
|||||||
logger.debug(f"nft cmd non-zero (may already exist): {r.stderr.strip()}")
|
logger.debug(f"nft cmd non-zero (may already exist): {r.stderr.strip()}")
|
||||||
|
|
||||||
self._redirect_backend = "nftables"
|
self._redirect_backend = "nftables"
|
||||||
logger.info("nftables: port 80→5000 redirect rule added")
|
logger.info(f"nftables: port 80→{PORTAL_PORT} redirect rule added")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _teardown_iptables_redirect(self) -> None:
|
def _teardown_iptables_redirect(self) -> None:
|
||||||
@@ -847,12 +826,12 @@ class WiFiManager:
|
|||||||
subprocess.run(
|
subprocess.run(
|
||||||
["sudo", iptables, "-t", "nat", "-D", "PREROUTING",
|
["sudo", iptables, "-t", "nat", "-D", "PREROUTING",
|
||||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||||
"-j", "REDIRECT", "--to-port", "5000"],
|
"-j", "REDIRECT", "--to-port", str(PORTAL_PORT)],
|
||||||
capture_output=True, timeout=5
|
capture_output=True, timeout=5
|
||||||
)
|
)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["sudo", iptables, "-D", "INPUT",
|
["sudo", iptables, "-D", "INPUT",
|
||||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000",
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", str(PORTAL_PORT),
|
||||||
"-j", "ACCEPT"],
|
"-j", "ACCEPT"],
|
||||||
capture_output=True, timeout=5
|
capture_output=True, timeout=5
|
||||||
)
|
)
|
||||||
@@ -887,7 +866,7 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not tear down port redirect: {e}")
|
logger.warning(f"Could not tear down port redirect: {e}")
|
||||||
|
|
||||||
def _write_nm_dnsmasq_captive_conf(self, ap_ip: str = "192.168.4.1") -> None:
|
def _write_nm_dnsmasq_captive_conf(self, ap_ip: str = AP_IP) -> None:
|
||||||
"""
|
"""
|
||||||
Write the NM dnsmasq-shared.d drop-in that makes NM's built-in dnsmasq
|
Write the NM dnsmasq-shared.d drop-in that makes NM's built-in dnsmasq
|
||||||
resolve every hostname to the AP IP. This triggers the OS captive-portal
|
resolve every hostname to the AP IP. This triggers the OS captive-portal
|
||||||
@@ -1026,23 +1005,40 @@ class WiFiManager:
|
|||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return []
|
return []
|
||||||
|
networks = self._parse_nmcli_wifi_list(result.stdout)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"nmcli cached list failed: {e}")
|
||||||
|
return networks
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_nmcli_wifi_list(stdout: str) -> List[WiFiNetwork]:
|
||||||
|
"""Parse ``nmcli -t -f SSID,SIGNAL,SECURITY,FREQ device wifi list``.
|
||||||
|
|
||||||
|
One entry per SSID (the first line seen for it; hidden networks with
|
||||||
|
an empty SSID are skipped), security reduced to wpa3/wpa2/wpa/open,
|
||||||
|
sorted strongest first. Unparseable lines are skipped.
|
||||||
|
"""
|
||||||
|
networks = []
|
||||||
seen_ssids = set()
|
seen_ssids = set()
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in stdout.strip().split('\n'):
|
||||||
if not line or ':' not in line:
|
if not line or ':' not in line:
|
||||||
continue
|
continue
|
||||||
parts = line.split(':')
|
parts = line.split(':')
|
||||||
if len(parts) >= 3:
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
ssid = parts[0].strip()
|
ssid = parts[0].strip()
|
||||||
if not ssid or ssid in seen_ssids:
|
if not ssid or ssid in seen_ssids:
|
||||||
continue
|
continue
|
||||||
seen_ssids.add(ssid)
|
seen_ssids.add(ssid)
|
||||||
try:
|
try:
|
||||||
signal = int(parts[1].strip())
|
signal = int(parts[1].strip())
|
||||||
security = parts[2].strip() if len(parts) > 2 else "open"
|
security = parts[2].strip()
|
||||||
frequency_str = parts[3].strip() if len(parts) > 3 else "0"
|
frequency_str = parts[3].strip() if len(parts) > 3 else "0"
|
||||||
frequency_str = frequency_str.replace(" MHz", "").replace("MHz", "").strip()
|
frequency_str = frequency_str.replace(" MHz", "").replace("MHz", "").strip()
|
||||||
frequency = float(frequency_str) if frequency_str else 0.0
|
frequency = float(frequency_str) if frequency_str else 0.0
|
||||||
|
except (ValueError, IndexError) as e:
|
||||||
|
logger.debug(f"Skipping network line due to parsing error: {line[:50]}... Error: {e}")
|
||||||
|
continue
|
||||||
if "WPA3" in security:
|
if "WPA3" in security:
|
||||||
sec_type = "wpa3"
|
sec_type = "wpa3"
|
||||||
elif "WPA2" in security:
|
elif "WPA2" in security:
|
||||||
@@ -1051,12 +1047,9 @@ class WiFiManager:
|
|||||||
sec_type = "wpa"
|
sec_type = "wpa"
|
||||||
else:
|
else:
|
||||||
sec_type = "open"
|
sec_type = "open"
|
||||||
networks.append(WiFiNetwork(ssid=ssid, signal=signal, security=sec_type, frequency=frequency))
|
networks.append(WiFiNetwork(ssid=ssid, signal=signal, security=sec_type,
|
||||||
except (ValueError, IndexError):
|
frequency=frequency))
|
||||||
continue
|
|
||||||
networks.sort(key=lambda x: x.signal, reverse=True)
|
networks.sort(key=lambda x: x.signal, reverse=True)
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"nmcli cached list failed: {e}")
|
|
||||||
return networks
|
return networks
|
||||||
|
|
||||||
def _save_cached_scan(self, networks: List[WiFiNetwork]) -> None:
|
def _save_cached_scan(self, networks: List[WiFiNetwork]) -> None:
|
||||||
@@ -1088,7 +1081,6 @@ class WiFiManager:
|
|||||||
|
|
||||||
def _scan_nmcli(self) -> List[WiFiNetwork]:
|
def _scan_nmcli(self) -> List[WiFiNetwork]:
|
||||||
"""Scan networks using nmcli"""
|
"""Scan networks using nmcli"""
|
||||||
networks = []
|
|
||||||
try:
|
try:
|
||||||
# Trigger scan
|
# Trigger scan
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -1108,52 +1100,7 @@ class WiFiManager:
|
|||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return []
|
return []
|
||||||
|
return self._parse_nmcli_wifi_list(result.stdout)
|
||||||
seen_ssids = set()
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
|
||||||
if not line or ':' not in line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
parts = line.split(':')
|
|
||||||
if len(parts) >= 3:
|
|
||||||
ssid = parts[0].strip()
|
|
||||||
if not ssid or ssid in seen_ssids:
|
|
||||||
continue
|
|
||||||
|
|
||||||
seen_ssids.add(ssid)
|
|
||||||
|
|
||||||
try:
|
|
||||||
signal = int(parts[1].strip())
|
|
||||||
security = parts[2].strip() if len(parts) > 2 else "open"
|
|
||||||
|
|
||||||
# Parse frequency - strip " MHz" if present
|
|
||||||
frequency_str = parts[3].strip() if len(parts) > 3 else "0"
|
|
||||||
frequency_str = frequency_str.replace(" MHz", "").replace("MHz", "").strip()
|
|
||||||
frequency = float(frequency_str) if frequency_str else 0.0
|
|
||||||
|
|
||||||
# Normalize security type
|
|
||||||
if "WPA3" in security:
|
|
||||||
sec_type = "wpa3"
|
|
||||||
elif "WPA2" in security:
|
|
||||||
sec_type = "wpa2"
|
|
||||||
elif "WPA" in security:
|
|
||||||
sec_type = "wpa"
|
|
||||||
else:
|
|
||||||
sec_type = "open"
|
|
||||||
|
|
||||||
networks.append(WiFiNetwork(
|
|
||||||
ssid=ssid,
|
|
||||||
signal=signal,
|
|
||||||
security=sec_type,
|
|
||||||
frequency=frequency
|
|
||||||
))
|
|
||||||
except (ValueError, IndexError) as e:
|
|
||||||
logger.debug(f"Skipping network line due to parsing error: {line[:50]}... Error: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sort by signal strength
|
|
||||||
networks.sort(key=lambda x: x.signal, reverse=True)
|
|
||||||
return networks
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error scanning with nmcli: {e}")
|
logger.error(f"Error scanning with nmcli: {e}")
|
||||||
return []
|
return []
|
||||||
@@ -1357,27 +1304,7 @@ class WiFiManager:
|
|||||||
disconnect_success, disconnect_msg = self.disconnect_from_network(skip_ap_check=True)
|
disconnect_success, disconnect_msg = self.disconnect_from_network(skip_ap_check=True)
|
||||||
if disconnect_success:
|
if disconnect_success:
|
||||||
logger.info(f"Disconnected from {original_ssid}: {disconnect_msg}")
|
logger.info(f"Disconnected from {original_ssid}: {disconnect_msg}")
|
||||||
# Wait for device to be ready for new connection
|
if not self._wait_for_device_idle(5):
|
||||||
# Check device state before proceeding
|
|
||||||
max_wait = 5
|
|
||||||
wait_count = 0
|
|
||||||
while wait_count < max_wait:
|
|
||||||
time.sleep(1)
|
|
||||||
result = subprocess.run(
|
|
||||||
["nmcli", "-t", "-f", "STATE", "device", "status", self._wifi_interface],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
state = result.stdout.strip().split(':')[-1] if ':' in result.stdout else result.stdout.strip()
|
|
||||||
# Device is ready if it's disconnected or unavailable (not connecting/connected)
|
|
||||||
if state in ["disconnected", "unavailable", "unmanaged"]:
|
|
||||||
logger.info(f"Device ready for new connection (state: {state})")
|
|
||||||
break
|
|
||||||
wait_count += 1
|
|
||||||
|
|
||||||
if wait_count >= max_wait:
|
|
||||||
logger.warning("Device may not be ready, but proceeding with connection attempt")
|
logger.warning("Device may not be ready, but proceeding with connection attempt")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to disconnect from {original_ssid}: {disconnect_msg}")
|
logger.warning(f"Failed to disconnect from {original_ssid}: {disconnect_msg}")
|
||||||
@@ -1403,26 +1330,15 @@ class WiFiManager:
|
|||||||
return False, f"Failed to connect to {ssid}, restored {original_ssid}"
|
return False, f"Failed to connect to {ssid}, restored {original_ssid}"
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to restore original connection: {original_ssid}")
|
logger.error(f"Failed to restore original connection: {original_ssid}")
|
||||||
# Trigger AP mode as last resort
|
return self._failsafe_ap(
|
||||||
self._show_led_message("Enabling AP mode...", duration=5)
|
"Connection failed and restoration failed. AP mode enabled.",
|
||||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
"Connection failed, restoration failed, and AP mode failed")
|
||||||
if ap_success:
|
|
||||||
logger.info("AP mode enabled as failsafe")
|
|
||||||
return False, "Connection failed and restoration failed. AP mode enabled."
|
|
||||||
else:
|
|
||||||
logger.error(f"Failed to enable AP mode: {ap_msg}")
|
|
||||||
return False, f"Connection failed, restoration failed, and AP mode failed: {ap_msg}"
|
|
||||||
|
|
||||||
# If connection failed and no original connection to restore, enable AP mode
|
# If connection failed and no original connection to restore, enable AP mode
|
||||||
elif not success:
|
elif not success:
|
||||||
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
||||||
self._show_led_message("Enabling AP mode...", duration=5)
|
return self._failsafe_ap("Connection failed. AP mode enabled.",
|
||||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
"Connection failed and AP mode failed")
|
||||||
if ap_success:
|
|
||||||
logger.info("AP mode enabled as failsafe")
|
|
||||||
return False, "Connection failed. AP mode enabled."
|
|
||||||
else:
|
|
||||||
return False, f"Connection failed and AP mode failed: {ap_msg}"
|
|
||||||
|
|
||||||
return success, message
|
return success, message
|
||||||
else:
|
else:
|
||||||
@@ -1443,6 +1359,22 @@ class WiFiManager:
|
|||||||
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
|
def _failsafe_ap(self, enabled_msg: str, failed_msg: str) -> Tuple[bool, str]:
|
||||||
|
"""Force the setup AP up after a connect that left no working network,
|
||||||
|
so the user can still reach the device.
|
||||||
|
|
||||||
|
Returns the (False, message) result for connect_to_network:
|
||||||
|
``enabled_msg`` when the AP came up, else ``failed_msg`` plus the
|
||||||
|
reason it did not.
|
||||||
|
"""
|
||||||
|
self._show_led_message("Enabling AP mode...", duration=5)
|
||||||
|
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||||
|
if ap_success:
|
||||||
|
logger.info("AP mode enabled as failsafe")
|
||||||
|
return False, enabled_msg
|
||||||
|
logger.error(f"Failed to enable AP mode: {ap_msg}")
|
||||||
|
return False, f"{failed_msg}: {ap_msg}"
|
||||||
|
|
||||||
def _restore_original_connection(self, connection_name: str, ssid: str) -> bool:
|
def _restore_original_connection(self, connection_name: str, ssid: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Restore a previously active WiFi connection.
|
Restore a previously active WiFi connection.
|
||||||
@@ -1496,67 +1428,95 @@ class WiFiManager:
|
|||||||
logger.error(f"Error restoring connection: {e}")
|
logger.error(f"Error restoring connection: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _find_profile_for_ssid(self, ssid: str) -> Optional[str]:
|
||||||
|
"""Name of the saved NetworkManager profile for ``ssid``, or None.
|
||||||
|
|
||||||
|
``802-11-wireless.ssid`` is not a column ``nmcli connection show``
|
||||||
|
can list, so this lists the Wi-Fi profiles and asks each one for its
|
||||||
|
SSID. A profile named after the SSID is the fallback, for when the
|
||||||
|
listing fails.
|
||||||
|
"""
|
||||||
|
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
|
||||||
|
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if list_result.returncode == 0:
|
||||||
|
for line in list_result.stdout.strip().split('\n'):
|
||||||
|
# Terse output escapes a colon inside a field as "\:". TYPE
|
||||||
|
# never contains one, so the last colon ends the name.
|
||||||
|
conn_name, sep, conn_type = line.rpartition(':')
|
||||||
|
if not sep or conn_type.strip() != '802-11-wireless':
|
||||||
|
continue
|
||||||
|
conn_name = conn_name.replace('\\:', ':').replace('\\\\', '\\')
|
||||||
|
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
|
||||||
|
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
|
||||||
|
return conn_name
|
||||||
|
|
||||||
|
direct_check = subprocess.run( # nosec B603 B607 - list args, no shell
|
||||||
|
["nmcli", "connection", "show", ssid],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if direct_check.returncode == 0:
|
||||||
|
return ssid
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _wait_for_device_idle(self, attempts: int) -> bool:
|
||||||
|
"""Poll the Wi-Fi device, once a second for up to ``attempts`` checks,
|
||||||
|
until it is disconnected, unavailable or unmanaged: a profile
|
||||||
|
activated while the device is still connecting or tearing down an
|
||||||
|
old link can fail. True if it went idle, False on timeout."""
|
||||||
|
for attempt in range(attempts):
|
||||||
|
result = subprocess.run(
|
||||||
|
["nmcli", "-t", "-f", "STATE", "device", "status", self._wifi_interface],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
state = result.stdout.strip().split(':')[-1]
|
||||||
|
if state in ("disconnected", "unavailable", "unmanaged"):
|
||||||
|
logger.debug(f"Wi-Fi device idle (state: {state})")
|
||||||
|
return True
|
||||||
|
if attempt < attempts - 1:
|
||||||
|
time.sleep(1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _verify_connected(self, ssid: str, attempts: int = 5, delay: float = 2.0,
|
||||||
|
stop_on_other_network: bool = False) -> Optional[WiFiStatus]:
|
||||||
|
"""Wait for the device to report a connection to ``ssid``.
|
||||||
|
|
||||||
|
nmcli returns before DHCP finishes, so the status is polled every
|
||||||
|
``delay`` seconds, up to ``attempts`` times. Returns that status, or
|
||||||
|
None if it never showed ``ssid``. With ``stop_on_other_network`` a
|
||||||
|
connection to a different SSID ends the wait at once as a failure.
|
||||||
|
"""
|
||||||
|
for _ in range(attempts):
|
||||||
|
time.sleep(delay)
|
||||||
|
status = self.get_wifi_status()
|
||||||
|
if not status.connected:
|
||||||
|
continue
|
||||||
|
if status.ssid == ssid:
|
||||||
|
return status
|
||||||
|
if stop_on_other_network and status.ssid:
|
||||||
|
logger.warning(f"Connected to wrong network: {status.ssid} instead of {ssid}")
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
def _connect_nmcli(self, ssid: str, password: str) -> Tuple[bool, str]:
|
def _connect_nmcli(self, ssid: str, password: str) -> Tuple[bool, str]:
|
||||||
"""Connect using nmcli"""
|
"""Connect using nmcli"""
|
||||||
try:
|
try:
|
||||||
# Show LED message
|
# Show LED message
|
||||||
self._show_led_message(f"Connecting to {ssid}...", duration=10)
|
self._show_led_message(f"Connecting to {ssid}...", duration=10)
|
||||||
|
|
||||||
# Find existing NM connection for this SSID.
|
existing_conn_name = self._find_profile_for_ssid(ssid)
|
||||||
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
|
|
||||||
# so list all wifi connections then query each one's SSID individually.
|
|
||||||
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
|
|
||||||
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
existing_conn_name = None
|
|
||||||
if list_result.returncode == 0:
|
|
||||||
for line in list_result.stdout.strip().split('\n'):
|
|
||||||
if ':' not in line:
|
|
||||||
continue
|
|
||||||
parts = line.split(':')
|
|
||||||
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
|
|
||||||
continue
|
|
||||||
conn_name = parts[0].strip()
|
|
||||||
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
|
|
||||||
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
|
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
|
|
||||||
existing_conn_name = conn_name
|
|
||||||
break
|
|
||||||
|
|
||||||
# Also try direct lookup by SSID (in case connection name matches SSID)
|
|
||||||
if not existing_conn_name:
|
|
||||||
direct_check = subprocess.run(
|
|
||||||
["nmcli", "connection", "show", ssid],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
if direct_check.returncode == 0:
|
|
||||||
existing_conn_name = ssid
|
|
||||||
|
|
||||||
if existing_conn_name:
|
if existing_conn_name:
|
||||||
# Connection exists, try to activate it first (faster and more reliable)
|
# Connection exists, try to activate it first (faster and more reliable)
|
||||||
logger.info(f"Found existing connection for {ssid}, activating...")
|
logger.info(f"Found existing connection for {ssid}, activating...")
|
||||||
|
|
||||||
# Ensure device is ready before activating
|
self._wait_for_device_idle(3)
|
||||||
# Wait for device to be in disconnected/unavailable state
|
|
||||||
max_wait = 3
|
|
||||||
for wait_attempt in range(max_wait):
|
|
||||||
device_result = subprocess.run(
|
|
||||||
["nmcli", "-t", "-f", "STATE", "device", "status", self._wifi_interface],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
if device_result.returncode == 0:
|
|
||||||
state = device_result.stdout.strip().split(':')[-1] if ':' in device_result.stdout else device_result.stdout.strip()
|
|
||||||
if state in ["disconnected", "unavailable", "unmanaged"]:
|
|
||||||
break
|
|
||||||
if wait_attempt < max_wait - 1:
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["nmcli", "connection", "up", existing_conn_name],
|
["nmcli", "connection", "up", existing_conn_name],
|
||||||
@@ -1566,19 +1526,8 @@ class WiFiManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# Wait longer for connection to stabilize and verify multiple times
|
status = self._verify_connected(ssid)
|
||||||
max_verification_attempts = 5
|
if status is not None:
|
||||||
verification_delay = 2
|
|
||||||
connected = False
|
|
||||||
|
|
||||||
for attempt in range(max_verification_attempts):
|
|
||||||
time.sleep(verification_delay)
|
|
||||||
status = self.get_wifi_status()
|
|
||||||
if status.connected and status.ssid == ssid:
|
|
||||||
connected = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if connected:
|
|
||||||
ip = status.ip_address or "Unknown"
|
ip = status.ip_address or "Unknown"
|
||||||
self._show_led_message(f"Connected! {ip}", duration=5)
|
self._show_led_message(f"Connected! {ip}", duration=5)
|
||||||
logger.info(f"Successfully connected to {ssid} with IP {ip}")
|
logger.info(f"Successfully connected to {ssid} with IP {ip}")
|
||||||
@@ -1605,25 +1554,8 @@ class WiFiManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# Wait longer for connection to stabilize and verify multiple times
|
status = self._verify_connected(ssid, stop_on_other_network=True)
|
||||||
max_verification_attempts = 5
|
if status is not None:
|
||||||
verification_delay = 2
|
|
||||||
connected = False
|
|
||||||
|
|
||||||
for attempt in range(max_verification_attempts):
|
|
||||||
time.sleep(verification_delay)
|
|
||||||
status = self.get_wifi_status()
|
|
||||||
if status.connected:
|
|
||||||
# Verify we're connected to the correct SSID
|
|
||||||
if status.ssid == ssid:
|
|
||||||
connected = True
|
|
||||||
break
|
|
||||||
elif status.ssid:
|
|
||||||
# Connected to different network - this is a failure
|
|
||||||
logger.warning(f"Connected to wrong network: {status.ssid} instead of {ssid}")
|
|
||||||
break
|
|
||||||
|
|
||||||
if connected:
|
|
||||||
ip = status.ip_address or "Unknown"
|
ip = status.ip_address or "Unknown"
|
||||||
self._show_led_message(f"Connected! {ip}", duration=5)
|
self._show_led_message(f"Connected! {ip}", duration=5)
|
||||||
logger.info(f"Successfully connected to {ssid} with IP {ip}")
|
logger.info(f"Successfully connected to {ssid} with IP {ip}")
|
||||||
@@ -1717,14 +1649,10 @@ class WiFiManager:
|
|||||||
return any(ind in lower for ind in indicators)
|
return any(ind in lower for ind in indicators)
|
||||||
|
|
||||||
def _connect_wpa_supplicant(self, ssid: str, password: str) -> Tuple[bool, str]:
|
def _connect_wpa_supplicant(self, ssid: str, password: str) -> Tuple[bool, str]:
|
||||||
"""Connect using wpa_supplicant (fallback)"""
|
"""Without NetworkManager there is no supported way to connect: doing it
|
||||||
try:
|
through wpa_supplicant would mean editing its config file, which is
|
||||||
# This would require modifying /etc/wpa_supplicant/wpa_supplicant.conf
|
not implemented. Always returns (False, reason)."""
|
||||||
# For now, return not implemented
|
|
||||||
return False, "wpa_supplicant connection not yet implemented. Please use NetworkManager (nmcli)."
|
return False, "wpa_supplicant connection not yet implemented. Please use NetworkManager (nmcli)."
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error connecting with wpa_supplicant: {e}")
|
|
||||||
return False, str(e)
|
|
||||||
|
|
||||||
def disconnect_from_network(self, skip_ap_check: bool = False) -> Tuple[bool, str]:
|
def disconnect_from_network(self, skip_ap_check: bool = False) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
@@ -1745,33 +1673,18 @@ class WiFiManager:
|
|||||||
|
|
||||||
# Disconnect using nmcli
|
# Disconnect using nmcli
|
||||||
if self.has_nmcli:
|
if self.has_nmcli:
|
||||||
# Try to disconnect the specific connection first (more reliable)
|
# Take the profile down first, then the device, so the
|
||||||
|
# device ends up disconnected even when no profile is found.
|
||||||
if status.ssid:
|
if status.ssid:
|
||||||
# Find the connection name for this SSID
|
conn_name = self._find_profile_for_ssid(status.ssid)
|
||||||
conn_result = subprocess.run(
|
if conn_name:
|
||||||
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
|
subprocess.run( # nosec B603 B607 - list args, no shell
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
if conn_result.returncode == 0:
|
|
||||||
for line in conn_result.stdout.strip().split('\n'):
|
|
||||||
if ':' in line:
|
|
||||||
parts = line.split(':')
|
|
||||||
if len(parts) >= 2:
|
|
||||||
conn_name = parts[0].strip()
|
|
||||||
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
|
|
||||||
if conn_ssid == status.ssid:
|
|
||||||
# Disconnect this specific connection
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "down", conn_name],
|
["nmcli", "connection", "down", conn_name],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
logger.info(f"Disconnected connection {conn_name} for {status.ssid}")
|
logger.info(f"Disconnected connection {conn_name} for {status.ssid}")
|
||||||
break
|
|
||||||
|
|
||||||
# Also disconnect the device to ensure clean state
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["nmcli", "device", "disconnect", self._wifi_interface],
|
["nmcli", "device", "disconnect", self._wifi_interface],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -1814,7 +1727,11 @@ class WiFiManager:
|
|||||||
max_retries: Maximum number of retry attempts to enable WiFi radio
|
max_retries: Maximum number of retry attempts to enable WiFi radio
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if WiFi is enabled or was successfully enabled, False otherwise
|
True if the radio is enabled or was enabled here. Also True when
|
||||||
|
the state could not be checked at all (nmcli or rfkill raised on
|
||||||
|
every attempt): callers go ahead rather than refusing to act on a
|
||||||
|
radio that is probably fine. False only when the radio was seen
|
||||||
|
disabled or blocked and could not be turned on.
|
||||||
"""
|
"""
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
@@ -2062,11 +1979,7 @@ class WiFiManager:
|
|||||||
if result[0]:
|
if result[0]:
|
||||||
self._ap_enabled_at = time.time()
|
self._ap_enabled_at = time.time()
|
||||||
if force:
|
if force:
|
||||||
try:
|
self._mark_forced()
|
||||||
self._FORCE_AP_FLAG_PATH.touch()
|
|
||||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||||
@@ -2077,11 +1990,7 @@ class WiFiManager:
|
|||||||
if result[0]:
|
if result[0]:
|
||||||
self._ap_enabled_at = time.time()
|
self._ap_enabled_at = time.time()
|
||||||
if force:
|
if force:
|
||||||
try:
|
self._mark_forced()
|
||||||
self._FORCE_AP_FLAG_PATH.touch()
|
|
||||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||||
@@ -2089,6 +1998,15 @@ class WiFiManager:
|
|||||||
logger.error(f"Error in enable_ap_mode: {e}")
|
logger.error(f"Error in enable_ap_mode: {e}")
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
|
def _mark_forced(self) -> None:
|
||||||
|
"""Record that AP mode was forced on, so the periodic check leaves it
|
||||||
|
up even when Ethernet is connected (see _manage_ap_mode)."""
|
||||||
|
try:
|
||||||
|
self._FORCE_AP_FLAG_PATH.touch()
|
||||||
|
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
||||||
|
|
||||||
def _enable_ap_mode_hostapd(self) -> Tuple[bool, str]:
|
def _enable_ap_mode_hostapd(self) -> Tuple[bool, str]:
|
||||||
"""Enable AP mode using hostapd and dnsmasq (captive portal)"""
|
"""Enable AP mode using hostapd and dnsmasq (captive portal)"""
|
||||||
try:
|
try:
|
||||||
@@ -2115,7 +2033,7 @@ class WiFiManager:
|
|||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["sudo", "ip", "addr", "add", "192.168.4.1/24", "dev", self._wifi_interface],
|
["sudo", "ip", "addr", "add", f"{AP_IP}/24", "dev", self._wifi_interface],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
@@ -2124,7 +2042,7 @@ class WiFiManager:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
logger.info(f"Configured {self._wifi_interface} with IP 192.168.4.1 for AP mode")
|
logger.info(f"Configured {self._wifi_interface} with IP {AP_IP} for AP mode")
|
||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
|
||||||
logger.warning(f"Error setting up {self._wifi_interface} IP: {e}")
|
logger.warning(f"Error setting up {self._wifi_interface} IP: {e}")
|
||||||
|
|
||||||
@@ -2168,7 +2086,7 @@ class WiFiManager:
|
|||||||
# Use the validated SSID so the displayed name matches what hostapd broadcast
|
# Use the validated SSID so the displayed name matches what hostapd broadcast
|
||||||
ap_ssid, _ = self._validate_ap_config()
|
ap_ssid, _ = self._validate_ap_config()
|
||||||
self._show_led_message(
|
self._show_led_message(
|
||||||
f"WiFi Setup\n{ap_ssid}\nNo password\n192.168.4.1:5000", duration=10
|
f"WiFi Setup\n{ap_ssid}\nNo password\n{AP_IP}:{PORTAL_PORT}", duration=10
|
||||||
)
|
)
|
||||||
return True, "AP mode enabled"
|
return True, "AP mode enabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2198,7 +2116,7 @@ class WiFiManager:
|
|||||||
|
|
||||||
# Delete only the specific application-managed AP profiles by name.
|
# Delete only the specific application-managed AP profiles by name.
|
||||||
# Never delete by SSID — that would destroy a user's saved home network.
|
# Never delete by SSID — that would destroy a user's saved home network.
|
||||||
for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
|
for conn_name in AP_PROFILE_NAMES:
|
||||||
subprocess.run(["nmcli", "connection", "down", conn_name],
|
subprocess.run(["nmcli", "connection", "down", conn_name],
|
||||||
capture_output=True, timeout=5)
|
capture_output=True, timeout=5)
|
||||||
subprocess.run(["nmcli", "connection", "delete", conn_name],
|
subprocess.run(["nmcli", "connection", "delete", conn_name],
|
||||||
@@ -2215,14 +2133,14 @@ class WiFiManager:
|
|||||||
cmd = [
|
cmd = [
|
||||||
"nmcli", "connection", "add",
|
"nmcli", "connection", "add",
|
||||||
"type", "wifi",
|
"type", "wifi",
|
||||||
"con-name", "LEDMatrix-Setup-AP",
|
"con-name", AP_PROFILE_NAME,
|
||||||
"ifname", self._wifi_interface,
|
"ifname", self._wifi_interface,
|
||||||
"ssid", ap_ssid,
|
"ssid", ap_ssid,
|
||||||
"802-11-wireless.mode", "ap",
|
"802-11-wireless.mode", "ap",
|
||||||
"802-11-wireless.band", "bg", # 2.4 GHz for maximum compatibility
|
"802-11-wireless.band", "bg", # 2.4 GHz for maximum compatibility
|
||||||
"802-11-wireless.channel", str(ap_channel),
|
"802-11-wireless.channel", str(ap_channel),
|
||||||
"ipv4.method", "shared",
|
"ipv4.method", "shared",
|
||||||
"ipv4.addresses", "192.168.4.1/24",
|
"ipv4.addresses", f"{AP_IP}/24",
|
||||||
# No 802-11-wireless-security section → open network
|
# No 802-11-wireless-security section → open network
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2247,14 +2165,14 @@ class WiFiManager:
|
|||||||
|
|
||||||
logger.info("AP connection profile created, bringing it up...")
|
logger.info("AP connection profile created, bringing it up...")
|
||||||
up_result = subprocess.run(
|
up_result = subprocess.run(
|
||||||
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
|
["nmcli", "connection", "up", AP_PROFILE_NAME],
|
||||||
capture_output=True, text=True, timeout=20
|
capture_output=True, text=True, timeout=20
|
||||||
)
|
)
|
||||||
if up_result.returncode != 0:
|
if up_result.returncode != 0:
|
||||||
error_msg = up_result.stderr.strip() or up_result.stdout.strip()
|
error_msg = up_result.stderr.strip() or up_result.stdout.strip()
|
||||||
logger.error(f"Failed to bring up AP connection: {error_msg}")
|
logger.error(f"Failed to bring up AP connection: {error_msg}")
|
||||||
self._remove_nm_dnsmasq_captive_conf()
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
subprocess.run(["nmcli", "connection", "delete", AP_PROFILE_NAME],
|
||||||
capture_output=True, timeout=10)
|
capture_output=True, timeout=10)
|
||||||
self._show_led_message("AP mode failed", duration=5)
|
self._show_led_message("AP mode failed", duration=5)
|
||||||
return False, f"Failed to start AP: {error_msg}"
|
return False, f"Failed to start AP: {error_msg}"
|
||||||
@@ -2266,9 +2184,9 @@ class WiFiManager:
|
|||||||
if not self._setup_iptables_redirect():
|
if not self._setup_iptables_redirect():
|
||||||
logger.error("Captive-portal redirect setup failed; rolling back AP profile")
|
logger.error("Captive-portal redirect setup failed; rolling back AP profile")
|
||||||
self._remove_nm_dnsmasq_captive_conf()
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
subprocess.run(["nmcli", "connection", "down", AP_PROFILE_NAME],
|
||||||
capture_output=True, timeout=10)
|
capture_output=True, timeout=10)
|
||||||
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
subprocess.run(["nmcli", "connection", "delete", AP_PROFILE_NAME],
|
||||||
capture_output=True, timeout=10)
|
capture_output=True, timeout=10)
|
||||||
self._clear_led_message()
|
self._clear_led_message()
|
||||||
return False, "AP started but captive-portal redirect setup failed"
|
return False, "AP started but captive-portal redirect setup failed"
|
||||||
@@ -2282,17 +2200,17 @@ class WiFiManager:
|
|||||||
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
|
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
if status.get('active'):
|
if status.get('active'):
|
||||||
ip = status.get('ip', '192.168.4.1')
|
ip = status.get('ip', AP_IP)
|
||||||
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||||
self._show_led_message(f"WiFi Setup\n{ap_ssid}\nNo password\n{ip}:5000", duration=10)
|
self._show_led_message(f"WiFi Setup\n{ap_ssid}\nNo password\n{ip}:{PORTAL_PORT}", duration=10)
|
||||||
return True, f"AP mode enabled (open network) - Access at {ip}:5000"
|
return True, f"AP mode enabled (open network) - Access at {ip}:{PORTAL_PORT}"
|
||||||
else:
|
else:
|
||||||
logger.error("AP mode started but not verified by status check — rolling back")
|
logger.error("AP mode started but not verified by status check — rolling back")
|
||||||
self._teardown_iptables_redirect()
|
self._teardown_iptables_redirect()
|
||||||
self._remove_nm_dnsmasq_captive_conf()
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
subprocess.run(["nmcli", "connection", "down", AP_PROFILE_NAME],
|
||||||
capture_output=True, timeout=10)
|
capture_output=True, timeout=10)
|
||||||
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
subprocess.run(["nmcli", "connection", "delete", AP_PROFILE_NAME],
|
||||||
capture_output=True, timeout=10)
|
capture_output=True, timeout=10)
|
||||||
self._clear_led_message()
|
self._clear_led_message()
|
||||||
return False, "AP mode started but verification failed"
|
return False, "AP mode started but verification failed"
|
||||||
@@ -2326,9 +2244,9 @@ class WiFiManager:
|
|||||||
conn_name = parts[0].strip()
|
conn_name = parts[0].strip()
|
||||||
conn_type = parts[1].strip().lower()
|
conn_type = parts[1].strip().lower()
|
||||||
# Match our known AP profile name OR the legacy nmcli hotspot type
|
# Match our known AP profile name OR the legacy nmcli hotspot type
|
||||||
if conn_name == "LEDMatrix-Setup-AP" or 'hotspot' in conn_type:
|
if conn_name == AP_PROFILE_NAME or 'hotspot' in conn_type:
|
||||||
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
|
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
|
||||||
ip = '192.168.4.1'
|
ip = AP_IP
|
||||||
interface = parts[2] if len(parts) > 2 else self._wifi_interface
|
interface = parts[2] if len(parts) > 2 else self._wifi_interface
|
||||||
try:
|
try:
|
||||||
ip_result = subprocess.run(
|
ip_result = subprocess.run(
|
||||||
@@ -2399,7 +2317,7 @@ class WiFiManager:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Disable nmcli hotspot mode (fallback)
|
# Disable nmcli hotspot mode (fallback)
|
||||||
for conn_name in ["LEDMatrix-Setup-AP", "Hotspot", "TickerSetup-AP"]:
|
for conn_name in AP_PROFILE_NAMES:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["nmcli", "connection", "down", conn_name],
|
["nmcli", "connection", "down", conn_name],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -2428,7 +2346,7 @@ class WiFiManager:
|
|||||||
|
|
||||||
# Clean up WiFi interface IP configuration
|
# Clean up WiFi interface IP configuration
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["sudo", "ip", "addr", "del", "192.168.4.1/24", "dev", self._wifi_interface],
|
["sudo", "ip", "addr", "del", f"{AP_IP}/24", "dev", self._wifi_interface],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
@@ -2541,13 +2459,13 @@ ignore_broadcast_ssid=0
|
|||||||
dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h
|
dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h
|
||||||
|
|
||||||
# Captive portal: Redirect all DNS queries to Pi
|
# Captive portal: Redirect all DNS queries to Pi
|
||||||
address=/#/192.168.4.1
|
address=/#/{AP_IP}
|
||||||
|
|
||||||
# Captive portal detection endpoints
|
# Captive portal detection endpoints
|
||||||
address=/captive.apple.com/192.168.4.1
|
address=/captive.apple.com/{AP_IP}
|
||||||
address=/connectivitycheck.gstatic.com/192.168.4.1
|
address=/connectivitycheck.gstatic.com/{AP_IP}
|
||||||
address=/www.msftconnecttest.com/192.168.4.1
|
address=/www.msftconnecttest.com/{AP_IP}
|
||||||
address=/detectportal.firefox.com/192.168.4.1
|
address=/detectportal.firefox.com/{AP_IP}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Write config (requires sudo)
|
# Write config (requires sudo)
|
||||||
@@ -2651,9 +2569,10 @@ address=/detectportal.firefox.com/192.168.4.1
|
|||||||
# Pre-cache a WiFi scan so the captive portal can show networks
|
# Pre-cache a WiFi scan so the captive portal can show networks
|
||||||
try:
|
try:
|
||||||
logger.info("Running pre-AP WiFi scan for captive portal cache...")
|
logger.info("Running pre-AP WiFi scan for captive portal cache...")
|
||||||
|
# AP mode is not up yet, so this is a live scan, and
|
||||||
|
# scan_networks saves its result for the portal.
|
||||||
networks, _cached = self.scan_networks(allow_cached=False)
|
networks, _cached = self.scan_networks(allow_cached=False)
|
||||||
if networks:
|
if networks:
|
||||||
self._save_cached_scan(networks)
|
|
||||||
logger.info(f"Cached {len(networks)} networks for captive portal")
|
logger.info(f"Cached {len(networks)} networks for captive portal")
|
||||||
except Exception as scan_err:
|
except Exception as scan_err:
|
||||||
logger.debug(f"Pre-AP scan failed (non-critical): {scan_err}")
|
logger.debug(f"Pre-AP scan failed (non-critical): {scan_err}")
|
||||||
|
|||||||
+1
-4
@@ -1,9 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Get the current user
|
echo "Starting LED Matrix Display Service..."
|
||||||
CURRENT_USER=$(whoami)
|
|
||||||
|
|
||||||
echo "Starting LED Matrix Display Service for user: $CURRENT_USER..."
|
|
||||||
|
|
||||||
# Start the service
|
# Start the service
|
||||||
sudo systemctl start ledmatrix.service
|
sudo systemctl start ledmatrix.service
|
||||||
|
|||||||
+1
-4
@@ -1,9 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Get the current user
|
echo "Stopping LED Matrix Display Service..."
|
||||||
CURRENT_USER=$(whoami)
|
|
||||||
|
|
||||||
echo "Stopping LED Matrix Display Service for user: $CURRENT_USER..."
|
|
||||||
|
|
||||||
# Stop the service
|
# Stop the service
|
||||||
sudo systemctl stop ledmatrix.service
|
sudo systemctl stop ledmatrix.service
|
||||||
|
|||||||
+42
-13
@@ -92,7 +92,7 @@ class TestGet:
|
|||||||
helper.session.get.assert_not_called()
|
helper.session.get.assert_not_called()
|
||||||
rate_spy.assert_not_called()
|
rate_spy.assert_not_called()
|
||||||
|
|
||||||
def test_cache_miss_fetches_and_caches_without_ttl(self, helper, cache):
|
def test_cache_miss_fetches_and_caches_with_ttl(self, helper, cache):
|
||||||
cache.get.return_value = None
|
cache.get.return_value = None
|
||||||
helper.session.get = Mock(return_value=_make_response({'a': 1}))
|
helper.session.get = Mock(return_value=_make_response({'a': 1}))
|
||||||
|
|
||||||
@@ -100,9 +100,47 @@ class TestGet:
|
|||||||
cache_ttl=999)
|
cache_ttl=999)
|
||||||
|
|
||||||
assert result == {'a': 1}
|
assert result == {'a': 1}
|
||||||
# Pin the ttl-dropped contract: CacheManager.set is called with
|
cache.set.assert_called_once_with('k', {'a': 1}, ttl=999)
|
||||||
# (key, data) only — the cache_ttl argument is discarded.
|
|
||||||
cache.set.assert_called_once_with('k', {'a': 1})
|
def test_set_cache_passes_ttl(self, helper, cache):
|
||||||
|
helper.set_cache('k', {'a': 1}, ttl=42)
|
||||||
|
cache.set.assert_called_once_with('k', {'a': 1}, ttl=42)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCacheLifetimeWithRealCacheManager:
|
||||||
|
"""cache_ttl decides how long a response is reused, in both directions:
|
||||||
|
past CacheManager's 300-second default read age, and not beyond it."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def real_cache(self, tmp_path):
|
||||||
|
from unittest.mock import patch
|
||||||
|
from src.cache_manager import CacheManager
|
||||||
|
with patch('src.cache_manager.CacheManager._get_writable_cache_dir',
|
||||||
|
return_value=str(tmp_path)):
|
||||||
|
cache = CacheManager()
|
||||||
|
yield cache
|
||||||
|
# Releases the class-wide cleanup-thread claim on this directory,
|
||||||
|
# which would otherwise leak into test_cache_cleanup_thread_ownership.
|
||||||
|
cache.stop_cleanup_thread()
|
||||||
|
|
||||||
|
def _fetch_twice(self, real_cache, monkeypatch, ttl, elapsed):
|
||||||
|
helper = APIHelper(cache_manager=real_cache)
|
||||||
|
helper.set_rate_limit(0)
|
||||||
|
helper.session.get = Mock(side_effect=[_make_response({'n': 1}),
|
||||||
|
_make_response({'n': 2})])
|
||||||
|
now = [1_000_000.0]
|
||||||
|
monkeypatch.setattr('src.cache.memory_cache.time.time', lambda: now[0])
|
||||||
|
monkeypatch.setattr('src.cache.disk_cache.time.time', lambda: now[0])
|
||||||
|
monkeypatch.setattr('src.cache_manager.time.time', lambda: now[0])
|
||||||
|
helper.get('https://example.com/api', cache_key='lifetime_test', cache_ttl=ttl)
|
||||||
|
now[0] += elapsed
|
||||||
|
return helper.get('https://example.com/api', cache_key='lifetime_test', cache_ttl=ttl)
|
||||||
|
|
||||||
|
def test_long_ttl_outlives_the_default_read_age(self, real_cache, monkeypatch):
|
||||||
|
assert self._fetch_twice(real_cache, monkeypatch, ttl=3600, elapsed=1000) == {'n': 1}
|
||||||
|
|
||||||
|
def test_short_ttl_expires(self, real_cache, monkeypatch):
|
||||||
|
assert self._fetch_twice(real_cache, monkeypatch, ttl=60, elapsed=120) == {'n': 2}
|
||||||
|
|
||||||
def test_request_exception_returns_none_and_caches_nothing(
|
def test_request_exception_returns_none_and_caches_nothing(
|
||||||
self, helper, cache):
|
self, helper, cache):
|
||||||
@@ -223,15 +261,6 @@ class TestClearCache:
|
|||||||
|
|
||||||
manager.clear_cache.assert_called_once_with()
|
manager.clear_cache.assert_called_once_with()
|
||||||
|
|
||||||
def test_no_pattern_falls_back_to_clear(self):
|
|
||||||
manager = types.SimpleNamespace(clear=Mock())
|
|
||||||
helper = APIHelper(cache_manager=manager)
|
|
||||||
helper.set_rate_limit(0)
|
|
||||||
|
|
||||||
helper.clear_cache()
|
|
||||||
|
|
||||||
manager.clear.assert_called_once_with()
|
|
||||||
|
|
||||||
def test_no_pattern_manager_without_any_clear_is_noop(self):
|
def test_no_pattern_manager_without_any_clear_is_noop(self):
|
||||||
helper = APIHelper(cache_manager=object())
|
helper = APIHelper(cache_manager=object())
|
||||||
helper.set_rate_limit(0)
|
helper.set_rate_limit(0)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""GET /api/v3/health: the plugin count is real, and a failed check is logged.
|
||||||
|
|
||||||
|
The plugin check counted ``plugin_manager.get_available_plugins()``, which
|
||||||
|
PluginManager does not have; a hasattr guard turned that into a permanent 0.
|
||||||
|
Each check that fails answers "see logs for details", so it has to log.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
URL = "/api/v3/health"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_systemctl(monkeypatch):
|
||||||
|
monkeypatch.setattr("web_interface.blueprints.api_v3.misc._get_display_service_status",
|
||||||
|
lambda: {"active": True})
|
||||||
|
|
||||||
|
|
||||||
|
def _checks(client):
|
||||||
|
response = client.get(URL)
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
return response.get_json()["data"]["checks"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_count_is_the_number_of_discovered_plugins(api_v3_client, api_v3_module):
|
||||||
|
api_v3_module.api_v3.plugin_manager.plugin_manifests = {
|
||||||
|
"clock": {"id": "clock"}, "weather": {"id": "weather"}, "stocks": {"id": "stocks"},
|
||||||
|
}
|
||||||
|
|
||||||
|
check = _checks(api_v3_client)["plugin_system"]
|
||||||
|
|
||||||
|
assert check == {"status": "operational", "plugin_count": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_count_discovers_when_nothing_is_discovered_yet(api_v3_client, api_v3_module):
|
||||||
|
pm = api_v3_module.api_v3.plugin_manager
|
||||||
|
pm.plugin_manifests = {}
|
||||||
|
|
||||||
|
def discover():
|
||||||
|
pm.plugin_manifests = {"clock": {"id": "clock"}}
|
||||||
|
pm.discover_plugins.side_effect = discover
|
||||||
|
|
||||||
|
assert _checks(api_v3_client)["plugin_system"]["plugin_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_config_check_is_logged(api_v3_client, api_v3_module, caplog):
|
||||||
|
api_v3_module.api_v3.config_manager.load_config.side_effect = OSError("disk gone")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
check = _checks(api_v3_client)["config_file"]
|
||||||
|
|
||||||
|
assert check["error"] == "see logs for details"
|
||||||
|
logged = [r for r in caplog.records if "config file" in r.getMessage()]
|
||||||
|
assert logged and logged[0].exc_info and "disk gone" in str(logged[0].exc_info[1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_plugin_check_is_logged(api_v3_client, api_v3_module, caplog, monkeypatch):
|
||||||
|
def boom():
|
||||||
|
raise RuntimeError("manifests unreadable")
|
||||||
|
monkeypatch.setattr("web_interface.blueprints.api_v3.misc._discovered_plugin_manifests", boom)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
check = _checks(api_v3_client)["plugin_system"]
|
||||||
|
|
||||||
|
assert check["status"] == "error"
|
||||||
|
logged = [r for r in caplog.records if "count plugins" in r.getMessage()]
|
||||||
|
assert logged and logged[0].exc_info
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_hardware_check_is_logged(api_v3_client, api_v3_module, caplog, monkeypatch):
|
||||||
|
def getmtime(_path):
|
||||||
|
raise PermissionError("denied")
|
||||||
|
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda _p: True, getmtime=getmtime))
|
||||||
|
monkeypatch.setattr("web_interface.blueprints.api_v3.misc.os", fake_os)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
check = _checks(api_v3_client)["hardware"]
|
||||||
|
|
||||||
|
assert check["status"] == "unknown"
|
||||||
|
logged = [r for r in caplog.records if "snapshot" in r.getMessage()]
|
||||||
|
assert logged and logged[0].exc_info
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""GET /plugins/health/<id> and /plugins/metrics/<id> read the display
|
||||||
|
service's latest state, not the web process's first snapshot.
|
||||||
|
|
||||||
|
The display service writes health and metrics to the shared cache; the web
|
||||||
|
process only reads them. Its tracker and monitor keep what they read first in
|
||||||
|
memory, so without ``force_reload`` the per-plugin routes kept answering with
|
||||||
|
that first read while the list routes (which pass it) moved on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_health import PluginHealthTracker # noqa: E402
|
||||||
|
from src.plugin_system.resource_monitor import PluginResourceMonitor # noqa: E402
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
|
||||||
|
class SharedCache:
|
||||||
|
"""The on-disk cache both processes see, reduced to a dict."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.entries = {}
|
||||||
|
|
||||||
|
def get(self, key, max_age=None, memory_ttl=None):
|
||||||
|
return self.entries.get(key)
|
||||||
|
|
||||||
|
def set(self, key, value, *args, **kwargs):
|
||||||
|
self.entries[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shared_cache(api_v3_module):
|
||||||
|
cache = SharedCache()
|
||||||
|
pm = api_v3_module.api_v3.plugin_manager
|
||||||
|
pm.health_tracker = PluginHealthTracker(cache)
|
||||||
|
pm.resource_monitor = PluginResourceMonitor(cache)
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_reflects_failures_recorded_after_the_first_read(api_v3_client, shared_cache):
|
||||||
|
first = api_v3_client.get("/api/v3/plugins/health/weather").get_json()["data"]
|
||||||
|
assert first["total_failures"] == 0
|
||||||
|
|
||||||
|
display_side = PluginHealthTracker(shared_cache)
|
||||||
|
display_side.record_failure("weather", RuntimeError("api down"))
|
||||||
|
display_side.record_failure("weather", RuntimeError("api down"))
|
||||||
|
|
||||||
|
later = api_v3_client.get("/api/v3/plugins/health/weather").get_json()["data"]
|
||||||
|
assert later["total_failures"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_reflect_calls_recorded_after_the_first_read(api_v3_client, shared_cache):
|
||||||
|
first = api_v3_client.get("/api/v3/plugins/metrics/weather").get_json()["data"]
|
||||||
|
assert first["call_count"] == 0
|
||||||
|
|
||||||
|
shared_cache.set("plugin_metrics:weather", {
|
||||||
|
"memory_mb": 12.5, "cpu_percent": 3.0, "execution_time": 0.2,
|
||||||
|
"call_count": 40, "total_execution_time": 8.0,
|
||||||
|
"max_execution_time": 0.5, "min_execution_time": 0.1,
|
||||||
|
"last_update_time": 1000.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
later = api_v3_client.get("/api/v3/plugins/metrics/weather").get_json()["data"]
|
||||||
|
assert later["call_count"] == 40
|
||||||
@@ -66,12 +66,16 @@ class TestRefreshPluginStore:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ["fetch_commit_info", "fetch_latest_versions"])
|
@pytest.mark.parametrize("key", ["fetch_commit_info", "fetch_latest_versions"])
|
||||||
def test_either_commit_info_key_extends_the_message(
|
def test_commit_info_flag_claims_no_refresh_it_does_not_do(
|
||||||
self, api_v3_client, api_v3_module, key):
|
self, api_v3_client, api_v3_module, key):
|
||||||
# fetch_latest_versions is the older spelling; both must work.
|
# The route only re-downloads the registry. It used to append "(with
|
||||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
# refreshed commit metadata from GitHub)" for either flag without
|
||||||
|
# fetching any.
|
||||||
|
store = api_v3_module.api_v3.plugin_store_manager
|
||||||
|
store.fetch_registry.return_value = {"plugins": [{"id": "a"}]}
|
||||||
response = api_v3_client.post(self.URL, json={key: True})
|
response = api_v3_client.post(self.URL, json={key: True})
|
||||||
assert "commit metadata" in response.get_json()["message"]
|
assert response.get_json()["message"] == "Plugin store refreshed"
|
||||||
|
store.fetch_registry.assert_called_once_with(force_refresh=True)
|
||||||
|
|
||||||
def test_message_stays_plain_without_the_flag(self, api_v3_client, api_v3_module):
|
def test_message_stays_plain_without_the_flag(self, api_v3_client, api_v3_module):
|
||||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""POST /config/main refuses a malformed Vegas plugin order or exclusion list.
|
||||||
|
|
||||||
|
Both were parsed with ``except JSONDecodeError: ... = []``, so a bad value
|
||||||
|
cleared the saved list and answered 200. They now fail the save with a 400,
|
||||||
|
as plugin_rotation_order already did.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
URL = "/api/v3/config/main"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def saved(api_v3_module):
|
||||||
|
config = {"display": {"vegas_scroll": {"plugin_order": ["clock", "weather"],
|
||||||
|
"excluded_plugins": ["stocks"]}}}
|
||||||
|
cm = api_v3_module.api_v3.config_manager
|
||||||
|
cm.load_config.return_value = config
|
||||||
|
cm.save_config_atomic.return_value.status.value = 'success'
|
||||||
|
return cm
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["vegas_plugin_order", "vegas_excluded_plugins"])
|
||||||
|
@pytest.mark.parametrize("value", ["[not json", '{"a": 1}', "[1, 2]", 7])
|
||||||
|
def test_malformed_list_is_refused_and_nothing_is_saved(api_v3_client, saved, field, value):
|
||||||
|
response = api_v3_client.post(URL, json={field: value})
|
||||||
|
|
||||||
|
assert response.status_code == 400, response.get_json()
|
||||||
|
assert field in response.get_json()["message"]
|
||||||
|
saved.save_config_atomic.assert_not_called()
|
||||||
|
saved.save_config.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ['["weather", "clock"]', ["weather", "clock"]])
|
||||||
|
def test_json_text_or_array_is_stored(api_v3_client, saved, value):
|
||||||
|
response = api_v3_client.post(URL, json={"vegas_plugin_order": value})
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
stored = saved.save_config_atomic.call_args.args[0]
|
||||||
|
assert stored["display"]["vegas_scroll"]["plugin_order"] == ["weather", "clock"]
|
||||||
|
assert stored["display"]["vegas_scroll"]["excluded_plugins"] == ["stocks"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_order_keeps_its_messages(api_v3_client, saved):
|
||||||
|
response = api_v3_client.post(URL, json={"plugin_rotation_order": "[oops"})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.get_json()["message"] == "plugin_rotation_order must be valid JSON"
|
||||||
@@ -408,6 +408,14 @@ class TestAutoEnableApMode:
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "auto_enable_ap_mode" not in wifi_manager.config
|
assert "auto_enable_ap_mode" not in wifi_manager.config
|
||||||
|
|
||||||
|
def test_a_failed_save_is_reported(self, api_v3_client, wifi_manager):
|
||||||
|
# wifi_config.json left owned by root is the usual cause.
|
||||||
|
wifi_manager.config = {}
|
||||||
|
wifi_manager._save_config.return_value = False
|
||||||
|
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": False})
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.get_json()["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
class TestRadioEnabledAndForceAcceptIntegers:
|
class TestRadioEnabledAndForceAcceptIntegers:
|
||||||
"""`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old
|
"""`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old
|
||||||
|
|||||||
@@ -362,6 +362,3 @@ class TestPriorityIsAcceptedAndIgnored:
|
|||||||
rid = service.submit_fetch_request(
|
rid = service.submit_fetch_request(
|
||||||
"nfl", 2026, "http://example.invalid/x", cache_key="k", priority=5)
|
"nfl", 2026, "http://example.invalid/x", cache_key="k", priority=5)
|
||||||
assert service.get_result(rid).cached is True
|
assert service.get_result(rid).cached is True
|
||||||
|
|
||||||
def test_statistics_still_report_an_empty_queue(self, service):
|
|
||||||
assert service.get_statistics()["queue_size"] == 0
|
|
||||||
|
|||||||
@@ -180,6 +180,31 @@ def test_create_backup_manifest(project: Path, tmp_path: Path) -> None:
|
|||||||
assert set(manifest["contents"]) >= {"config", "secrets", "wifi", "fonts", "plugin_uploads", "plugins"}
|
assert set(manifest["contents"]) >= {"config", "secrets", "wifi", "fonts", "plugin_uploads", "plugins"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_version_is_the_core_release(project: Path, tmp_path: Path) -> None:
|
||||||
|
"""Not a git sha or a truncated "ref: refs/he..." read from .git/HEAD."""
|
||||||
|
from src import __version__
|
||||||
|
git = project / ".git"
|
||||||
|
git.mkdir()
|
||||||
|
(git / "HEAD").write_text("ref: refs/heads/some-branch-that-is-not-there\n", encoding="utf-8")
|
||||||
|
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
manifest = json.loads(zf.read("manifest.json"))
|
||||||
|
assert manifest["ledmatrix_version"] == __version__
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_plugins_come_from_the_configured_directory(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "proj"
|
||||||
|
(root / "config").mkdir(parents=True)
|
||||||
|
(root / "config" / "config.json").write_text(
|
||||||
|
json.dumps({"plugin_system": {"plugins_directory": "plugins"}}), encoding="utf-8")
|
||||||
|
plugin_dir = root / "plugins" / "dev-plugin"
|
||||||
|
plugin_dir.mkdir(parents=True)
|
||||||
|
(plugin_dir / "manifest.json").write_text(
|
||||||
|
json.dumps({"id": "dev-plugin", "version": "0.3.0"}), encoding="utf-8")
|
||||||
|
|
||||||
|
assert [p["plugin_id"] for p in list_installed_plugins(root)] == ["dev-plugin"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Validate
|
# Validate
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -342,7 +342,6 @@ class TestLoadConfiguration:
|
|||||||
'base_odds_manager': {
|
'base_odds_manager': {
|
||||||
'update_interval': 100,
|
'update_interval': 100,
|
||||||
'timeout': 5,
|
'timeout': 5,
|
||||||
'cache_ttl': 42,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +351,6 @@ class TestLoadConfiguration:
|
|||||||
# Key/attr mismatch pin: the config key is 'timeout' but the
|
# Key/attr mismatch pin: the config key is 'timeout' but the
|
||||||
# attribute is request_timeout.
|
# attribute is request_timeout.
|
||||||
assert manager.request_timeout == 5
|
assert manager.request_timeout == 5
|
||||||
assert manager.cache_ttl == 42
|
|
||||||
|
|
||||||
def test_get_config_raising_keeps_defaults(self, cache_manager):
|
def test_get_config_raising_keeps_defaults(self, cache_manager):
|
||||||
config_manager = MagicMock()
|
config_manager = MagicMock()
|
||||||
@@ -362,4 +360,3 @@ class TestLoadConfiguration:
|
|||||||
|
|
||||||
assert manager.update_interval == 3600
|
assert manager.update_interval == 3600
|
||||||
assert manager.request_timeout == 5
|
assert manager.request_timeout == 5
|
||||||
assert manager.cache_ttl == 1800
|
|
||||||
|
|||||||
@@ -299,3 +299,48 @@ class TestDefaultMerging:
|
|||||||
|
|
||||||
assert merged["enabled"] is False
|
assert merged["enabled"] is False
|
||||||
assert merged["display_duration"] == 60
|
assert merged["display_duration"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
class TestMissingRequiredFields:
|
||||||
|
"""One message per missing field, naming that field.
|
||||||
|
|
||||||
|
A manual ``required`` loop used to run after Draft7Validator, which already
|
||||||
|
reports ``required``, so every missing top-level field was listed twice --
|
||||||
|
and the validator's copy printed the schema's whole ``required`` list as
|
||||||
|
if it were the field name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"api_key": {"type": "string"},
|
||||||
|
"city": {"type": "string"},
|
||||||
|
"units": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["api_key", "city", "units"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_each_missing_field_is_reported_once_by_name(self):
|
||||||
|
ok, errors = SchemaManager().validate_config_against_schema(
|
||||||
|
{"units": "metric"}, self.SCHEMA, "test-plugin")
|
||||||
|
|
||||||
|
assert not ok
|
||||||
|
assert errors == [
|
||||||
|
"Field root: Missing required property 'api_key'",
|
||||||
|
"Field root: Missing required property 'city'",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_nested_missing_field_names_the_field_and_its_parent(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"nfl": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"api_key": {"type": "string"}},
|
||||||
|
"required": ["api_key"],
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
ok, errors = SchemaManager().validate_config_against_schema(
|
||||||
|
{"nfl": {}}, schema, "test-plugin")
|
||||||
|
|
||||||
|
assert not ok
|
||||||
|
assert errors == ["Field 'nfl': Missing required property 'api_key'"]
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""GET /api/v3/display/current passes the snapshot PNG through untouched.
|
||||||
|
|
||||||
|
It used to PIL-decode the snapshot and re-encode it, which cost CPU on the Pi
|
||||||
|
for no change in the picture, and it swallowed any read failure with
|
||||||
|
``except Exception: pass``. It now sends the file's own bytes, the payload the
|
||||||
|
/stream/display SSE stream sends, and logs a failed read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
from PIL import Image, PngImagePlugin
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from web_interface import display_preview # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
monkeypatch.setattr(api_v3, 'config_manager', MagicMock(), raising=False)
|
||||||
|
api_v3.config_manager.load_config.return_value = {}
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||||
|
with app.test_client() as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def snapshot(tmp_path, monkeypatch):
|
||||||
|
"""A snapshot PNG carrying a text chunk, which a PIL re-encode drops."""
|
||||||
|
info = PngImagePlugin.PngInfo()
|
||||||
|
info.add_text('written-by', 'display_manager')
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new('RGB', (4, 2), (255, 0, 0)).save(buffer, format='PNG', pnginfo=info)
|
||||||
|
path = tmp_path / 'led_matrix_preview.png'
|
||||||
|
path.write_bytes(buffer.getvalue())
|
||||||
|
monkeypatch.setattr(display_preview, 'SNAPSHOT_PATH', str(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _image(client):
|
||||||
|
response = client.get('/api/v3/display/current')
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.get_json()['data']
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_snapshot_bytes_are_sent_as_they_are(client, snapshot):
|
||||||
|
data = _image(client)
|
||||||
|
assert base64.b64decode(data['image']) == snapshot.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_and_stream_send_the_same_payload_keys(client, snapshot):
|
||||||
|
data = _image(client)
|
||||||
|
assert set(data) == set(display_preview.preview_payload(1, 1, None))
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_snapshot_is_a_null_image_without_a_warning(client, tmp_path, monkeypatch, caplog):
|
||||||
|
monkeypatch.setattr(display_preview, 'SNAPSHOT_PATH', str(tmp_path / 'missing.png'))
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
assert _image(client)['image'] is None
|
||||||
|
assert not [r for r in caplog.records if 'snapshot' in r.getMessage()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreadable_snapshot_is_logged(client, snapshot, monkeypatch, caplog):
|
||||||
|
def denied(_path=None):
|
||||||
|
raise PermissionError('denied')
|
||||||
|
monkeypatch.setattr(display_preview, 'read_snapshot_base64', denied)
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
assert _image(client)['image'] is None
|
||||||
|
assert [r for r in caplog.records if 'snapshot' in r.getMessage() and r.exc_info]
|
||||||
@@ -185,3 +185,35 @@ class TestLogoScale:
|
|||||||
def test_an_unusable_scale_is_ignored(self, logo, bad):
|
def test_an_unusable_scale_is_ignored(self, logo, bad):
|
||||||
helper = LogoHelper(display_width=64, display_height=32)
|
helper = LogoHelper(display_width=64, display_height=32)
|
||||||
assert helper.load_logo("AAA", logo, 32, 32, scale=bad).size == (32, 32)
|
assert helper.load_logo("AAA", logo, 32, 32, scale=bad).size == (32, 32)
|
||||||
|
|
||||||
|
def test_a_scale_the_schema_allows_is_applied(self, logo):
|
||||||
|
"""The Scale field's maximum is honoured, not reset to 1.0."""
|
||||||
|
from src.element_style import MAX_ELEMENT_SCALE
|
||||||
|
helper = LogoHelper(display_width=64, display_height=32)
|
||||||
|
big = helper.load_logo("AAA", logo, 4, 4, scale=MAX_ELEMENT_SCALE)
|
||||||
|
assert big.size == (40, 40)
|
||||||
|
|
||||||
|
def test_a_scale_beyond_the_range_is_clamped(self, logo):
|
||||||
|
from src.element_style import MAX_ELEMENT_SCALE, MIN_ELEMENT_SCALE
|
||||||
|
helper = LogoHelper(display_width=64, display_height=32)
|
||||||
|
assert helper.load_logo("AAA", logo, 4, 4, scale=MAX_ELEMENT_SCALE * 3).size == (40, 40)
|
||||||
|
assert helper.load_logo("AAA", logo, 40, 40, scale=MIN_ELEMENT_SCALE / 2).size == (4, 4)
|
||||||
|
|
||||||
|
|
||||||
|
class TestScaleCoercion:
|
||||||
|
"""One range for the schema, element_scale and LogoHelper."""
|
||||||
|
|
||||||
|
def test_schema_bounds_are_the_clamp_bounds(self):
|
||||||
|
from src.element_style import (MAX_ELEMENT_SCALE, MIN_ELEMENT_SCALE,
|
||||||
|
_offset_block_from_spec)
|
||||||
|
prop = _offset_block_from_spec("home_logo", {"scale": True})["properties"]["scale"]
|
||||||
|
assert (prop["minimum"], prop["maximum"]) == (MIN_ELEMENT_SCALE, MAX_ELEMENT_SCALE)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
(0.5, 0.5), (25, 10.0), (0.01, 0.1),
|
||||||
|
(0, 1.0), (-2, 1.0), ("x", 1.0), (True, 1.0),
|
||||||
|
(float("nan"), 1.0), (float("inf"), 1.0),
|
||||||
|
])
|
||||||
|
def test_element_scale_clamps_and_rejects(self, raw, expected):
|
||||||
|
cfg = {"customization": {"layout": {"home_logo": {"scale": raw}}}}
|
||||||
|
assert element_scale(cfg, "home_logo") == expected
|
||||||
|
|||||||
@@ -124,6 +124,45 @@ class TestErrorRecording:
|
|||||||
assert aggregator._plugin_error_counts["plugin-a"]["ValueError"] == 2
|
assert aggregator._plugin_error_counts["plugin-a"]["ValueError"] == 2
|
||||||
assert aggregator._plugin_error_counts["plugin-b"]["ValueError"] == 1
|
assert aggregator._plugin_error_counts["plugin-b"]["ValueError"] == 1
|
||||||
|
|
||||||
|
def test_stack_trace_recorded_outside_except_block(self):
|
||||||
|
"""The trace comes from the exception, not from the handler in progress.
|
||||||
|
|
||||||
|
plugin_executor records exceptions caught on a worker thread after
|
||||||
|
its except block has ended, where format_exc() only says
|
||||||
|
"NoneType: None".
|
||||||
|
"""
|
||||||
|
def failing_plugin_update():
|
||||||
|
raise ValueError("boom")
|
||||||
|
|
||||||
|
caught = []
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
failing_plugin_update()
|
||||||
|
except ValueError as e:
|
||||||
|
caught.append(e)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=worker)
|
||||||
|
thread.start()
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
record = ErrorAggregator().record_error(caught[0], plugin_id="p")
|
||||||
|
|
||||||
|
assert "NoneType: None" not in record.stack_trace
|
||||||
|
assert "failing_plugin_update" in record.stack_trace
|
||||||
|
assert "ValueError: boom" in record.stack_trace
|
||||||
|
|
||||||
|
def test_record_error_leaves_caller_context_unchanged(self):
|
||||||
|
"""LEDMatrixError context is merged into a copy of the caller's dict."""
|
||||||
|
context = {"caller": "value"}
|
||||||
|
error = PluginError("failed", plugin_id="p", context={"extra": 1})
|
||||||
|
|
||||||
|
record = ErrorAggregator().record_error(error, context=context)
|
||||||
|
|
||||||
|
assert context == {"caller": "value"}
|
||||||
|
assert record.context["caller"] == "value"
|
||||||
|
assert record.context["extra"] == 1
|
||||||
|
|
||||||
|
|
||||||
class TestPatternDetection:
|
class TestPatternDetection:
|
||||||
"""Test error pattern detection."""
|
"""Test error pattern detection."""
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""scripts/fix_perms/fix_web_permissions.sh must not undo the installer's hardening.
|
||||||
|
|
||||||
|
The script chowns the whole project to the web user. That used to include the
|
||||||
|
two helpers /etc/sudoers.d/ledmatrix_web lets the web user run as root
|
||||||
|
(safe_plugin_rm.sh, safe_pip_install.sh) -- a helper the web user owns is a
|
||||||
|
root shell for anyone who can edit it -- and config_secrets.json, which lost
|
||||||
|
the ledmatrix group first_time_install.sh gives it. After the chown the script
|
||||||
|
now puts both back the way the installer's Steps 11 and 11.1 leave them.
|
||||||
|
|
||||||
|
The behavioural test runs the real script against a scratch copy of the
|
||||||
|
project with `sudo`, `getent` and `journalctl` stubbed, and checks the order
|
||||||
|
of what it asked sudo to do.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SCRIPT = ROOT / "scripts" / "fix_perms" / "fix_web_permissions.sh"
|
||||||
|
LIB = ROOT / "scripts" / "install" / "lib_sudoers.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _text(path):
|
||||||
|
return path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _granted_helpers():
|
||||||
|
helpers = set(re.findall(r"scripts/fix_perms/([\w.-]+\.sh) \*", _text(LIB)))
|
||||||
|
assert helpers, "no fix_perms helper grant found in lib_sudoers.sh"
|
||||||
|
return helpers
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_granted_helper_is_rehardened_after_the_chown():
|
||||||
|
text = _text(SCRIPT)
|
||||||
|
chown = text.index('sudo chown -R "$WEB_USER:$WEB_USER" "$PROJECT_DIR"')
|
||||||
|
loop = re.search(r"for helper in ([^;]+); do\n(.*?)\ndone", text, re.S)
|
||||||
|
assert loop, "no helper-hardening loop in fix_web_permissions.sh"
|
||||||
|
assert "sudo chown root:root" in loop.group(2) and "sudo chmod 755" in loop.group(2)
|
||||||
|
assert loop.start() > chown, "helpers are hardened before the chown that undoes it"
|
||||||
|
assert _granted_helpers() <= set(loop.group(1).split())
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_longer_claims_to_configure_sudoers():
|
||||||
|
text = _text(SCRIPT)
|
||||||
|
assert "Configure sudoers for passwordless access" not in text
|
||||||
|
assert "./configure_web_sudo.sh" not in text.replace("scripts/install/configure_web_sudo.sh", "")
|
||||||
|
|
||||||
|
|
||||||
|
_STUB_SUDO = """#!/bin/bash
|
||||||
|
printf '%s\\n' "$*" >> "$SUDO_LOG"
|
||||||
|
# `sudo -n ...` probes and `sudo -u ...` tests: report failure, run nothing.
|
||||||
|
case "$1" in -n|-u) exit 1 ;; esac
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32" or shutil.which("bash") is None,
|
||||||
|
reason="needs a POSIX bash")
|
||||||
|
def test_script_rehardens_helpers_and_secrets(tmp_path):
|
||||||
|
project = tmp_path / "LED Matrix"
|
||||||
|
(project / "scripts" / "fix_perms").mkdir(parents=True)
|
||||||
|
(project / "config").mkdir()
|
||||||
|
script = project / "scripts" / "fix_perms" / "fix_web_permissions.sh"
|
||||||
|
script.write_text(_text(SCRIPT), encoding="utf-8")
|
||||||
|
for helper in ("safe_plugin_rm.sh", "safe_pip_install.sh"):
|
||||||
|
(project / "scripts" / "fix_perms" / helper).write_text("#!/bin/bash\n")
|
||||||
|
(project / "config" / "config_secrets.json").write_text("{}\n")
|
||||||
|
|
||||||
|
stubs = tmp_path / "stubs"
|
||||||
|
stubs.mkdir()
|
||||||
|
for name, body in (("sudo", _STUB_SUDO),
|
||||||
|
("getent", "#!/bin/sh\nexit 0\n"),
|
||||||
|
("journalctl", "#!/bin/sh\nexit 1\n")):
|
||||||
|
(stubs / name).write_text(body)
|
||||||
|
(stubs / name).chmod(0o755)
|
||||||
|
log = tmp_path / "sudo.log"
|
||||||
|
env = dict(os.environ, SUDO_LOG=str(log),
|
||||||
|
PATH=os.pathsep.join([str(stubs), os.environ.get("PATH", "")]))
|
||||||
|
|
||||||
|
result = subprocess.run(["bash", str(script)], input="y", env=env,
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
# The script refuses to run as root; that refusal is the whole test.
|
||||||
|
assert result.returncode == 1 and "should not be run as root" in result.stdout
|
||||||
|
return
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
|
||||||
|
calls = log.read_text().splitlines()
|
||||||
|
user = subprocess.run(["whoami"], capture_output=True, text=True).stdout.strip()
|
||||||
|
chown_all = calls.index(f"chown -R {user}:{user} {project}")
|
||||||
|
for helper in ("safe_plugin_rm.sh", "safe_pip_install.sh"):
|
||||||
|
path = project / "scripts" / "fix_perms" / helper
|
||||||
|
assert calls.index(f"chown root:root {path}") > chown_all, calls
|
||||||
|
assert calls.index(f"chmod 755 {path}") > chown_all, calls
|
||||||
|
secrets = project / "config" / "config_secrets.json"
|
||||||
|
# The owner is the installed web unit's User= when there is one.
|
||||||
|
owner = user
|
||||||
|
unit = Path("/etc/systemd/system/ledmatrix-web.service")
|
||||||
|
if unit.is_file():
|
||||||
|
m = re.search(r"^User=(.*)$", unit.read_text(), re.M)
|
||||||
|
if m and m.group(1):
|
||||||
|
owner = m.group(1)
|
||||||
|
assert calls.index(f"chown {owner}:ledmatrix {secrets}") > chown_all, calls
|
||||||
|
assert calls.index(f"chmod 640 {secrets}") > chown_all, calls
|
||||||
@@ -8,10 +8,14 @@ test here asserts observable behavior: returned font types, cache identity,
|
|||||||
fallback selection, and BDF native-size reading.
|
fallback selection, and BDF native-size reading.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
|
||||||
import freetype
|
import freetype
|
||||||
import pytest
|
import pytest
|
||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
|
|
||||||
|
from src.common.font_layout import resolve_asset_path
|
||||||
from src.font_manager import FontManager
|
from src.font_manager import FontManager
|
||||||
|
|
||||||
|
|
||||||
@@ -132,3 +136,37 @@ class TestCacheLifecycle:
|
|||||||
fm.reload_config({})
|
fm.reload_config({})
|
||||||
assert fm.cache_generation == gen_before + 1
|
assert fm.cache_generation == gen_before + 1
|
||||||
assert not fm.font_cache
|
assert not fm.font_cache
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginFonts:
|
||||||
|
"""plugin:// sources resolve against the plugin's own directory, which
|
||||||
|
by default lives under plugin-repos/, not a cwd-relative plugins/."""
|
||||||
|
|
||||||
|
MANIFEST = {"fonts": [{"family": "bundled", "source": "plugin://fonts/Bundled.ttf"}]}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plugin_with_font(root, name="my-plugin"):
|
||||||
|
plugin_dir = root / name
|
||||||
|
(plugin_dir / "fonts").mkdir(parents=True)
|
||||||
|
(plugin_dir / "manifest.json").write_text(json.dumps({"id": "my-plugin"}))
|
||||||
|
shutil.copy(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"),
|
||||||
|
plugin_dir / "fonts" / "Bundled.ttf")
|
||||||
|
return plugin_dir
|
||||||
|
|
||||||
|
def test_font_resolves_under_the_given_plugin_dir(self, fm, tmp_path):
|
||||||
|
plugin_dir = self._plugin_with_font(tmp_path / "plugin-repos")
|
||||||
|
|
||||||
|
assert fm.register_plugin_fonts("my-plugin", self.MANIFEST, plugin_dir=plugin_dir)
|
||||||
|
|
||||||
|
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
|
||||||
|
font = fm.resolve_font("x.y", "bundled", 8, plugin_id="my-plugin")
|
||||||
|
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||||
|
|
||||||
|
def test_without_a_plugin_dir_the_configured_directory_is_searched(self, tmp_path):
|
||||||
|
plugins_root = tmp_path / "installed"
|
||||||
|
plugin_dir = self._plugin_with_font(plugins_root, name="ledmatrix-my-plugin")
|
||||||
|
fm = FontManager({"plugin_system": {"plugins_directory": str(plugins_root)}})
|
||||||
|
|
||||||
|
assert fm.register_plugin_fonts("my-plugin", self.MANIFEST)
|
||||||
|
|
||||||
|
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
|
||||||
|
|||||||
@@ -133,6 +133,26 @@ class TestAssetPathsIgnoreTheWorkingDirectory:
|
|||||||
rel = f"assets/fonts/{FOUR_BY_SIX}"
|
rel = f"assets/fonts/{FOUR_BY_SIX}"
|
||||||
assert FontManager._resolve_asset_path(rel) == resolve_asset_path(rel)
|
assert FontManager._resolve_asset_path(rel) == resolve_asset_path(rel)
|
||||||
|
|
||||||
|
def test_font_overrides_file_lives_in_the_install_config(self, tmp_path, monkeypatch):
|
||||||
|
from src.font_manager import FontManager
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
fm = FontManager({})
|
||||||
|
assert fm.font_overrides_file == str(PROJECT_ROOT / "config" / "font_overrides.json")
|
||||||
|
|
||||||
|
def test_logo_placeholder_draws_with_the_bundled_font(self, tmp_path, monkeypatch):
|
||||||
|
import src.logo_downloader as logo_downloader
|
||||||
|
from src.logo_downloader import LogoDownloader
|
||||||
|
loaded = []
|
||||||
|
|
||||||
|
def spy(font, size, **kwargs):
|
||||||
|
loaded.append(font)
|
||||||
|
return load_truetype(font, size, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.setattr(logo_downloader, "load_truetype", spy)
|
||||||
|
assert LogoDownloader().create_placeholder_logo("AB", str(tmp_path))
|
||||||
|
assert loaded == [str(PROJECT_ROOT / "assets" / "fonts" / PRESS_START)]
|
||||||
|
|
||||||
|
|
||||||
class TestTheHarnessForkAgreesWithTheCore:
|
class TestTheHarnessForkAgreesWithTheCore:
|
||||||
"""The divergence that let the wrong rendering be blessed as golden.
|
"""The divergence that let the wrong rendering be blessed as golden.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user