Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa3087609d | ||
|
|
05e7c43b27 | ||
|
|
2ffc57cf40 | ||
|
|
aab0e9ade0 | ||
|
|
978a03b42d | ||
|
|
bd9f461f70 | ||
|
|
3b93024993 | ||
|
|
85d321cf33 | ||
|
|
63a233f3ed | ||
|
|
7a9d01342a | ||
|
|
9b2f02681d | ||
|
|
7a6bad29fe | ||
|
|
bea00448d3 | ||
|
|
deaa3d7a98 | ||
|
|
cbb8ec41e8 | ||
|
|
c6ce332d49 | ||
|
|
8e5f66501a | ||
|
|
639e1c3a93 | ||
|
|
6096a22c3d | ||
|
|
fefc2d44a2 | ||
|
|
d297dd6217 | ||
|
|
974d7ea57a | ||
|
|
ab0cfd2362 | ||
|
|
d22d0a3754 | ||
|
|
5beef0aa01 | ||
|
|
cf28a8c0d5 | ||
|
|
a06682981c | ||
|
|
bc027c921d | ||
|
|
e0bd7088fa | ||
|
|
313e35a98f | ||
|
|
122e6d6863 | ||
|
|
d488e8a2ad | ||
|
|
b9dcbb5152 | ||
|
|
f27fd260f7 | ||
|
|
eedf680a8c | ||
|
|
ac3a15bfaa |
@@ -0,0 +1,44 @@
|
|||||||
|
name: Claude Code Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, ready_for_review, reopened]
|
||||||
|
# Optional: Only run on specific file changes
|
||||||
|
# paths:
|
||||||
|
# - "src/**/*.ts"
|
||||||
|
# - "src/**/*.tsx"
|
||||||
|
# - "src/**/*.js"
|
||||||
|
# - "src/**/*.jsx"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
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
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code Review
|
||||||
|
id: claude-review
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||||
|
plugins: 'code-review@claude-code-plugins'
|
||||||
|
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
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
|
additional_permissions: |
|
||||||
|
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 *)'
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
plugin-safety:
|
||||||
|
name: Plugin safety harness + unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
|
pip install RGBMatrixEmulator
|
||||||
|
|
||||||
|
- name: Run harness + visual rendering tests
|
||||||
|
run: |
|
||||||
|
pytest --no-cov \
|
||||||
|
test/plugins/test_harness.py \
|
||||||
|
test/plugins/test_visual_rendering.py \
|
||||||
|
test/plugins/test_plugin_matrix.py
|
||||||
@@ -8,6 +8,7 @@ config/config_secrets.json
|
|||||||
config/config.json
|
config/config.json
|
||||||
config/config.json.backup
|
config/config.json.backup
|
||||||
config/wifi_config.json
|
config/wifi_config.json
|
||||||
|
config/uninstalled_plugins.json
|
||||||
credentials.json
|
credentials.json
|
||||||
token.pickle
|
token.pickle
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 140 KiB |
@@ -115,6 +115,11 @@
|
|||||||
"gpio_slowdown": 3,
|
"gpio_slowdown": 3,
|
||||||
"rp1_rio": 0
|
"rp1_rio": 0
|
||||||
},
|
},
|
||||||
|
"double_sided": {
|
||||||
|
"enabled": false,
|
||||||
|
"copies": 2,
|
||||||
|
"axis": "horizontal"
|
||||||
|
},
|
||||||
"display_durations": {},
|
"display_durations": {},
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Plugin Safety Harness
|
||||||
|
|
||||||
|
Renders a plugin across **every declared screen (mode)** and **a spread of
|
||||||
|
matrix sizes**, and fails if any combination crashes, draws past the panel edge,
|
||||||
|
or — for plugins that ship golden images — drifts visually. The goal: change a
|
||||||
|
plugin without breaking a size or screen you didn't think to test.
|
||||||
|
|
||||||
|
## Sizes: a sample, not a fixed list
|
||||||
|
|
||||||
|
There is **no fixed set of supported panel sizes** — an RGB matrix build can be
|
||||||
|
any width/height and configuration (square, rectangle, 2×2, 4×4, 8×2, long
|
||||||
|
strips, tall stacks). Plugins are expected to read dimensions dynamically
|
||||||
|
(`self.display_manager.matrix.width/height`) and lay themselves out
|
||||||
|
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
|
||||||
|
here.
|
||||||
|
|
||||||
|
The harness therefore renders against a **representative sample** that spans the
|
||||||
|
axes of variation (`DEFAULT_TEST_SIZES` in `src/plugin_system/testing/sizes.py`),
|
||||||
|
not an authoritative list:
|
||||||
|
|
||||||
|
Each module is 64×32; entries are real panel-grid arrangements (cols × rows):
|
||||||
|
|
||||||
|
| Size | Grid | Why it's in the sample |
|
||||||
|
|---------|------|--------------------------------------------|
|
||||||
|
| 64×32 | 1×1 | single panel — tightest common rectangle |
|
||||||
|
| 128×32 | 2×1 | the baseline most plugins are tuned for |
|
||||||
|
| 64×64 | 1×2 | stacked — tall-narrow centering |
|
||||||
|
| 128×64 | 2×2 | block — icon scaling / vertical centering |
|
||||||
|
| 256×32 | 4×1 | long strip — wide horizontal layout |
|
||||||
|
| 128×96 | 2×3 | tall — vertical overflow |
|
||||||
|
| 256×128 | 4×4 | large block — both dimensions big at once |
|
||||||
|
|
||||||
|
**Override the sizes entirely** to test your actual hardware (or any shape):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CLI — one-off:
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple --sizes 8x16,64x64,256x32
|
||||||
|
|
||||||
|
# pytest — force every plugin onto your panel(s):
|
||||||
|
LEDMATRIX_TEST_SIZES="8x16,128x128" pytest test/plugins/test_plugin_matrix.py
|
||||||
|
|
||||||
|
# Per-plugin — declare the shapes a plugin targets in its test/harness.json:
|
||||||
|
# { "sizes": [[8, 16], [64, 64]] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Precedence: `LEDMATRIX_TEST_SIZES` env (global) → per-plugin `harness.json`
|
||||||
|
`sizes` → the default sample. Bounds checking adapts to whatever sizes a run
|
||||||
|
uses — the backing canvas is padded out to the **largest** panel in the run, so
|
||||||
|
a coordinate meant for a big build is still caught when rendering a small one.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Functional + bounds check across all sizes/screens:
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple
|
||||||
|
|
||||||
|
# Every discovered plugin:
|
||||||
|
python scripts/check_plugin.py --all
|
||||||
|
|
||||||
|
# Dump PNGs to eyeball each size/screen:
|
||||||
|
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code is non-zero if any `(plugin, size, screen)` fails. Plugins are
|
||||||
|
discovered in `plugin-repos/` and `plugins/` (override with `--plugin-dir`).
|
||||||
|
|
||||||
|
## What it checks (Phase 1 — always on)
|
||||||
|
|
||||||
|
1. **Loads** and builds its mode list.
|
||||||
|
2. **Renders every screen** at every size without raising. `update()` may fail
|
||||||
|
(no network in CI) and is tolerated; a crash in `display()` is a failure —
|
||||||
|
`display()` must handle the no-data state.
|
||||||
|
3. **Bounds**: nothing is drawn past the right/bottom edge. Implemented by
|
||||||
|
`BoundsCheckingDisplayManager`, which backs the declared panel with an
|
||||||
|
oversized canvas and flags any pixels that land in the margin. (Left/top
|
||||||
|
overflow at negative coordinates and BDF text are not flagged — golden images
|
||||||
|
cover those.)
|
||||||
|
|
||||||
|
## Golden images (Phase 2 — opt-in per plugin)
|
||||||
|
|
||||||
|
A plugin opts in by committing reference PNGs and (usually) a small harness spec:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/<id>/test/harness.json # how to render deterministically
|
||||||
|
plugins/<id>/test/fixtures/mock.json # optional cached data
|
||||||
|
plugins/<id>/test/golden/<WxH>/<mode>.png
|
||||||
|
```
|
||||||
|
|
||||||
|
`test/harness.json` keys (all optional):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": { "timezone": "UTC" },
|
||||||
|
"mock_data": "fixtures/mock.json",
|
||||||
|
"freeze_time": "2025-08-01 15:25:00",
|
||||||
|
"skip_update": false,
|
||||||
|
"sizes": [[128, 32], [128, 64]]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate / refresh goldens after an intentional visual change, then review the
|
||||||
|
diff before committing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple --update-golden \
|
||||||
|
--config '{"timezone":"UTC"}' --freeze-time "2025-08-01 15:25:00"
|
||||||
|
```
|
||||||
|
|
||||||
|
Comparison is exact by default (`compare_images` in `harness.py` accepts a
|
||||||
|
tolerance for known anti-aliasing noise). Determinism requires a pinned Pillow
|
||||||
|
and the bundled fonts — keep both stable when regenerating goldens.
|
||||||
|
|
||||||
|
## Tests & CI
|
||||||
|
|
||||||
|
- `test/plugins/test_harness.py` — unit tests for bounds detection, image
|
||||||
|
comparison, and mode enumeration (run anywhere).
|
||||||
|
- `test/plugins/test_plugin_matrix.py` — parametrized over discovered plugins ×
|
||||||
|
sizes × screens; honors each plugin's `test/harness.json` and goldens. Skips
|
||||||
|
when no plugins are present (e.g. a fresh core checkout); set
|
||||||
|
`LEDMATRIX_REQUIRE_PLUGINS=1` in a pipeline where plugins must be present to
|
||||||
|
turn an empty discovery into a hard failure instead. Point it at the monorepo
|
||||||
|
with `LEDMATRIX_PLUGINS_DIR=/path/to/ledmatrix-plugins/plugins`.
|
||||||
|
- `.github/workflows/test.yml` — runs the harness + visual tests on every PR.
|
||||||
|
|
||||||
|
The plugin monorepo has its own `Plugin Safety` workflow that runs this harness
|
||||||
|
against changed plugins on every PR.
|
||||||
|
|
||||||
|
## Developer workflow
|
||||||
|
|
||||||
|
1. Change the plugin on a branch.
|
||||||
|
2. `python scripts/check_plugin.py --plugin <id> --out-dir /tmp/preview` and
|
||||||
|
eyeball the PNGs.
|
||||||
|
3. Intentional visual change? `--update-golden`, review diffs, commit goldens.
|
||||||
|
4. (Monorepo) bump `manifest.json` version and let the pre-commit hook sync
|
||||||
|
`plugins.json`.
|
||||||
|
5. Push — CI re-runs the harness across all sizes and gates the PR.
|
||||||
@@ -15,8 +15,8 @@ on_error() {
|
|||||||
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
|
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
|
||||||
if [ -n "${LOG_FILE:-}" ]; then
|
if [ -n "${LOG_FILE:-}" ]; then
|
||||||
echo "See the log for details: $LOG_FILE" >&2
|
echo "See the log for details: $LOG_FILE" >&2
|
||||||
echo "-- Last 50 lines from log --" >&2
|
echo "-- Last 100 lines from log --" >&2
|
||||||
tail -n 50 "$LOG_FILE" >&2 || true
|
tail -n 100 "$LOG_FILE" >&2 || true
|
||||||
fi
|
fi
|
||||||
echo "\nCommon fixes:" >&2
|
echo "\nCommon fixes:" >&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
|
||||||
@@ -202,8 +202,33 @@ retry() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
apt_update() { retry apt update; }
|
# Wait for another apt/dpkg process (commonly unattended-upgrades running
|
||||||
apt_install() { retry apt install -y "$@"; }
|
# shortly after first boot) to release its lock before we try apt ourselves.
|
||||||
|
# Without this, apt_update/apt_install can fail outright in the first couple
|
||||||
|
# minutes after a fresh Pi OS boot with a generic "Command failed after 3
|
||||||
|
# attempts" error.
|
||||||
|
wait_for_apt_lock() {
|
||||||
|
command -v flock >/dev/null 2>&1 || return 0
|
||||||
|
local lock_file="/var/lib/dpkg/lock-frontend"
|
||||||
|
local max_wait=180
|
||||||
|
local waited=0
|
||||||
|
local printed=0
|
||||||
|
while ! flock -n "$lock_file" -c true 2>/dev/null; do
|
||||||
|
if [ "$printed" -eq 0 ]; then
|
||||||
|
echo "⚠ Waiting for another apt/dpkg process to finish (e.g. unattended-upgrades on first boot)..."
|
||||||
|
printed=1
|
||||||
|
fi
|
||||||
|
if [ "$waited" -ge "$max_wait" ]; then
|
||||||
|
echo "⚠ Still waiting after ${max_wait}s; proceeding anyway."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
waited=$((waited+5))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
apt_update() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 update; }
|
||||||
|
apt_install() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 install -y "$@"; }
|
||||||
apt_remove() { apt-get remove -y "$@" || true; }
|
apt_remove() { apt-get remove -y "$@" || true; }
|
||||||
|
|
||||||
check_network() {
|
check_network() {
|
||||||
@@ -222,6 +247,22 @@ check_network() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_disk_space() {
|
||||||
|
command -v df >/dev/null 2>&1 || return 0
|
||||||
|
local available_mb
|
||||||
|
available_mb=$(df -m "$PROJECT_ROOT_DIR" | awk 'NR==2{print $4}')
|
||||||
|
available_mb=${available_mb:-0}
|
||||||
|
if [ "$available_mb" -lt 500 ]; then
|
||||||
|
echo "✗ ERROR: Insufficient disk space: ${available_mb}MB available (need at least 500MB)"
|
||||||
|
echo " Free up space first, e.g.: sudo apt clean && sudo apt autoremove"
|
||||||
|
exit 1
|
||||||
|
elif [ "$available_mb" -lt 1024 ]; then
|
||||||
|
echo "⚠ Limited disk space: ${available_mb}MB available (recommend at least 1GB for the rpi-rgb-led-matrix build in Step 6)"
|
||||||
|
else
|
||||||
|
echo "✓ Disk space sufficient: ${available_mb}MB available"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "This script will perform the following steps:"
|
echo "This script will perform the following steps:"
|
||||||
echo "1. Install system dependencies"
|
echo "1. Install system dependencies"
|
||||||
@@ -271,8 +312,9 @@ CURRENT_STEP="Install system dependencies"
|
|||||||
echo "Step 1: Installing system dependencies..."
|
echo "Step 1: Installing system dependencies..."
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
|
|
||||||
# Ensure network is available before APT operations
|
# Pre-flight checks before APT operations
|
||||||
check_network
|
check_network
|
||||||
|
check_disk_space
|
||||||
|
|
||||||
# Update package list
|
# Update package list
|
||||||
apt_update
|
apt_update
|
||||||
@@ -684,7 +726,11 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
|
|
||||||
if command -v timeout >/dev/null 2>&1; then
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
# Use timeout if available (10 minutes = 600 seconds)
|
# Use timeout if available (10 minutes = 600 seconds)
|
||||||
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
# --ignore-installed: apt-managed packages (e.g. python3-requests)
|
||||||
|
# ship no pip RECORD file, so upgrading them would otherwise abort
|
||||||
|
# with "uninstall-no-record-file"; this lays the new version down
|
||||||
|
# alongside instead of trying to uninstall the apt copy first.
|
||||||
|
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||||
INSTALL_SUCCESS=true
|
INSTALL_SUCCESS=true
|
||||||
else
|
else
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
@@ -692,7 +738,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
echo "✗ Timeout (10 minutes) installing: $line"
|
echo "✗ Timeout (10 minutes) installing: $line"
|
||||||
echo " This package may require building from source, which can be slow on Raspberry Pi."
|
echo " This package may require building from source, which can be slow on Raspberry Pi."
|
||||||
echo " You can try installing it manually later with:"
|
echo " You can try installing it manually later with:"
|
||||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose '$line'"
|
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose '$line'"
|
||||||
else
|
else
|
||||||
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
|
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
|
||||||
fi
|
fi
|
||||||
@@ -700,7 +746,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
else
|
else
|
||||||
# No timeout command available, install without timeout
|
# No timeout command available, install without timeout
|
||||||
echo " Note: timeout command not available, installation may take a while..."
|
echo " Note: timeout command not available, installation may take a while..."
|
||||||
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||||
INSTALL_SUCCESS=true
|
INSTALL_SUCCESS=true
|
||||||
else
|
else
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
@@ -752,7 +798,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
echo " 1. Ensure you have enough disk space: df -h"
|
echo " 1. Ensure you have enough disk space: df -h"
|
||||||
echo " 2. Check available memory: free -h"
|
echo " 2. Check available memory: free -h"
|
||||||
echo " 3. Try installing failed packages individually with verbose output:"
|
echo " 3. Try installing failed packages individually with verbose output:"
|
||||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose <package>"
|
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose <package>"
|
||||||
echo " 4. For packages that build from source (like numpy), consider:"
|
echo " 4. For packages that build from source (like numpy), consider:"
|
||||||
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
|
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
|
||||||
echo " - Or installing via apt if available: sudo apt install python3-<package>"
|
echo " - Or installing via apt if available: sudo apt install python3-<package>"
|
||||||
@@ -774,7 +820,10 @@ echo ""
|
|||||||
# Install web interface dependencies
|
# Install web interface dependencies
|
||||||
echo "Installing web interface dependencies..."
|
echo "Installing web interface dependencies..."
|
||||||
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
|
||||||
if python3 -m pip install --break-system-packages --prefer-binary -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
|
# --ignore-installed: apt-managed packages (e.g. python3-requests) ship no
|
||||||
|
# pip RECORD file, so upgrading them to the version pinned here would
|
||||||
|
# otherwise abort the whole install with "uninstall-no-record-file".
|
||||||
|
if python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
|
||||||
echo "✓ Web interface dependencies installed"
|
echo "✓ Web interface dependencies installed"
|
||||||
# Create marker file to indicate dependencies are installed
|
# Create marker file to indicate dependencies are installed
|
||||||
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
||||||
@@ -815,24 +864,30 @@ if [ "$_SKIP_BUILD" = "1" ]; then
|
|||||||
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||||
else
|
else
|
||||||
# Ensure rpi-rgb-led-matrix submodule is initialized
|
# Ensure rpi-rgb-led-matrix submodule is initialized
|
||||||
|
# Wrapper used with retry(): removes any partial clone dir before each attempt
|
||||||
|
# so git clone doesn't fail with "destination path already exists".
|
||||||
|
_clone_rpi_rgb() {
|
||||||
|
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
|
||||||
|
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||||
|
}
|
||||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||||
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
||||||
cd "$PROJECT_ROOT_DIR"
|
cd "$PROJECT_ROOT_DIR"
|
||||||
|
|
||||||
# Try to initialize submodule if .gitmodules exists
|
# Try to initialize submodule if .gitmodules exists
|
||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
echo "Initializing rpi-rgb-led-matrix submodule..."
|
||||||
if ! git submodule update --init --recursive rpi-rgb-led-matrix-master 2>&1; then
|
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
retry _clone_rpi_rgb
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Fallback: clone directly if submodule not configured
|
# Fallback: clone directly if submodule not configured
|
||||||
echo "Submodule not configured, cloning directly from GitHub..."
|
echo "Submodule not configured, cloning directly from GitHub..."
|
||||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
retry _clone_rpi_rgb
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build and install rpi-rgb-led-matrix Python bindings
|
# Build and install rpi-rgb-led-matrix Python bindings
|
||||||
if [ -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
if [ -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||||
# Check if submodule is properly initialized (not empty)
|
# Check if submodule is properly initialized (not empty)
|
||||||
@@ -841,23 +896,34 @@ else
|
|||||||
cd "$PROJECT_ROOT_DIR"
|
cd "$PROJECT_ROOT_DIR"
|
||||||
rm -rf rpi-rgb-led-matrix-master
|
rm -rf rpi-rgb-led-matrix-master
|
||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||||
git submodule update --init --recursive rpi-rgb-led-matrix-master
|
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||||
else
|
else
|
||||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
retry _clone_rpi_rgb
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null
|
pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null
|
||||||
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
|
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
|
||||||
echo " Build deps required: python-dev-is-python3 cmake"
|
echo " Build deps required: python-dev-is-python3 cmake"
|
||||||
echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..."
|
echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..."
|
||||||
if ! python3 -m pip install --break-system-packages .; then
|
BUILD_OUTPUT=$(mktemp)
|
||||||
|
BUILD_SUCCESS=false
|
||||||
|
if python3 -m pip install --break-system-packages . > "$BUILD_OUTPUT" 2>&1; then
|
||||||
|
BUILD_SUCCESS=true
|
||||||
|
fi
|
||||||
|
cat "$BUILD_OUTPUT" >> "$LOG_FILE"
|
||||||
|
if [ "$BUILD_SUCCESS" != true ]; then
|
||||||
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
|
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
|
||||||
echo " Ensure build tools are installed:"
|
echo " Ensure build tools are installed:"
|
||||||
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
|
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
|
||||||
|
echo ""
|
||||||
|
echo "-- Last 50 lines of build output --"
|
||||||
|
tail -n 50 "$BUILD_OUTPUT"
|
||||||
|
rm -f "$BUILD_OUTPUT"
|
||||||
popd >/dev/null
|
popd >/dev/null
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
rm -f "$BUILD_OUTPUT"
|
||||||
popd >/dev/null
|
popd >/dev/null
|
||||||
else
|
else
|
||||||
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
|
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
|
||||||
@@ -912,11 +978,15 @@ else
|
|||||||
# Try to install dependencies using the smart installer if available
|
# Try to install dependencies using the smart installer if available
|
||||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
||||||
echo "Using smart dependency installer..."
|
echo "Using smart dependency installer..."
|
||||||
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
# -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in
|
||||||
|
# real time and in order relative to this script's own echo statements
|
||||||
|
python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||||
else
|
else
|
||||||
echo "Using pip to install dependencies..."
|
echo "Using pip to install dependencies..."
|
||||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||||
python3 -m pip install --break-system-packages --prefer-binary -r requirements_web_v2.txt
|
# --ignore-installed: see the Step 5 web_interface/requirements.txt
|
||||||
|
# install above — same apt/pip RECORD-file conflict applies here.
|
||||||
|
python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r requirements_web_v2.txt
|
||||||
else
|
else
|
||||||
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Test-only dependencies for the plugin safety harness and pytest suite.
|
||||||
|
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
||||||
|
#
|
||||||
|
# pytest, pytest-cov, pytest-mock, and jsonschema are already pinned (with
|
||||||
|
# major-version caps) in requirements.txt, so they are intentionally NOT
|
||||||
|
# repeated here — re-pinning pytest to <9 collided with requirements.txt's
|
||||||
|
# pytest>=9.0.3,<10 and made the two files impossible to install together.
|
||||||
|
# Only declare what requirements.txt doesn't already provide.
|
||||||
|
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
||||||
@@ -43,6 +43,9 @@ websocket-client>=1.8.0,<2.0.0
|
|||||||
# JSON Schema validation
|
# JSON Schema validation
|
||||||
jsonschema>=4.20.0,<5.0.0
|
jsonschema>=4.20.0,<5.0.0
|
||||||
|
|
||||||
|
# Requirement specifier parsing (plugin dependency satisfaction checks)
|
||||||
|
packaging>=23.0,<27.0
|
||||||
|
|
||||||
# Testing dependencies
|
# Testing dependencies
|
||||||
pytest>=9.0.3,<10.0.0
|
pytest>=9.0.3,<10.0.0
|
||||||
pytest-cov>=4.1.0,<5.0.0
|
pytest-cov>=4.1.0,<5.0.0
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Plugin safety checker.
|
||||||
|
|
||||||
|
Renders a plugin across every declared screen (mode) and every supported matrix
|
||||||
|
size, and fails if any screen crashes, overflows the panel, or (for plugins with
|
||||||
|
committed golden images) drifts visually.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Functional + bounds check across all sizes/modes:
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple
|
||||||
|
|
||||||
|
# Every discovered plugin:
|
||||||
|
python scripts/check_plugin.py --all
|
||||||
|
|
||||||
|
# Dump PNGs for each size/mode so you can eyeball them:
|
||||||
|
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
|
||||||
|
|
||||||
|
# Refresh committed golden images after an intentional visual change:
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple --update-golden \
|
||||||
|
--mock-data plugins/clock-simple/test/fixtures/mock.json
|
||||||
|
|
||||||
|
Exit code is non-zero if any (plugin, size, mode) fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
os.environ['EMULATOR'] = 'true'
|
||||||
|
|
||||||
|
from src.logging_config import get_logger # noqa: E402
|
||||||
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
|
find_plugin_dir, load_config_defaults, load_harness_spec,
|
||||||
|
)
|
||||||
|
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||||
|
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||||
|
)
|
||||||
|
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||||
|
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger("[Check Plugin]")
|
||||||
|
|
||||||
|
DEFAULT_SEARCH_DIRS = [
|
||||||
|
str(PROJECT_ROOT / 'plugins'),
|
||||||
|
str(PROJECT_ROOT / 'plugin-repos'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def discover_plugins(search_dirs: List[str]) -> List[str]:
|
||||||
|
"""All plugin ids found across the search dirs (dirs containing manifest.json)."""
|
||||||
|
found = []
|
||||||
|
for d in search_dirs:
|
||||||
|
base = Path(d)
|
||||||
|
if not base.exists():
|
||||||
|
continue
|
||||||
|
for child in sorted(base.iterdir()):
|
||||||
|
if (child / 'manifest.json').exists() and child.name not in found:
|
||||||
|
found.append(child.name)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sizes(spec: Optional[str]):
|
||||||
|
if not spec:
|
||||||
|
return None
|
||||||
|
sizes = []
|
||||||
|
for token in spec.split(','):
|
||||||
|
if not token.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sizes.append(parse_size_token(token))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(str(exc)) from exc
|
||||||
|
return sizes
|
||||||
|
|
||||||
|
|
||||||
|
def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||||
|
config: Dict, run_update: bool, out_dir: Optional[Path],
|
||||||
|
update_golden: bool, golden_dir_override: Optional[Path],
|
||||||
|
freeze_time: Optional[str]) -> List[RenderResult]:
|
||||||
|
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
|
||||||
|
if not plugin_dir:
|
||||||
|
logger.error("Plugin '%s' not found in: %s", plugin_id, search_dirs)
|
||||||
|
return [RenderResult(plugin_id, 0, 0, "<not-found>", error="plugin directory not found")]
|
||||||
|
|
||||||
|
# Per-plugin test/harness.json holds the deterministic settings the committed
|
||||||
|
# goldens were generated with (config, mock data, frozen time, sizes). Load
|
||||||
|
# them so the CLI/CI render reproduces the golden the same way the pytest
|
||||||
|
# matrix path does; explicit CLI flags still override the file.
|
||||||
|
spec = load_harness_spec(plugin_dir)
|
||||||
|
|
||||||
|
# config_schema defaults (real-install behavior), then harness.json config,
|
||||||
|
# then CLI --config — most specific wins.
|
||||||
|
full_config = {"enabled": True}
|
||||||
|
full_config.update(load_config_defaults(plugin_dir))
|
||||||
|
full_config.update(spec.get("config", {}))
|
||||||
|
full_config.update(config)
|
||||||
|
|
||||||
|
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
|
||||||
|
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
|
||||||
|
# CLI value wins when provided, else fall back to the harness.json setting.
|
||||||
|
effective_mock_data = mock_data or spec.get("mock_data_contents", {})
|
||||||
|
effective_freeze = freeze_time or spec.get("freeze_time")
|
||||||
|
effective_run_update = run_update and not spec.get("skip_update", False)
|
||||||
|
|
||||||
|
results = render_plugin_matrix(
|
||||||
|
plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
|
||||||
|
mock_data=effective_mock_data, sizes=effective_sizes,
|
||||||
|
run_update=effective_run_update, freeze_time=effective_freeze,
|
||||||
|
)
|
||||||
|
|
||||||
|
golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden')
|
||||||
|
if update_golden:
|
||||||
|
written = write_goldens(results, golden_dir)
|
||||||
|
logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir)
|
||||||
|
else:
|
||||||
|
compare_to_goldens(results, golden_dir)
|
||||||
|
|
||||||
|
if out_dir:
|
||||||
|
for r in results:
|
||||||
|
if r.image is None:
|
||||||
|
continue
|
||||||
|
dest = out_dir / plugin_id / size_label(r.width, r.height)
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||||
|
"""Print a per-plugin grid. Returns True if everything passed."""
|
||||||
|
everything_ok = True
|
||||||
|
for plugin_id, results in all_results.items():
|
||||||
|
print(f"\n=== {plugin_id} ===")
|
||||||
|
for r in results:
|
||||||
|
if r.ok:
|
||||||
|
status = "PASS"
|
||||||
|
detail = ""
|
||||||
|
if r.golden_checked:
|
||||||
|
detail = " (golden ✓)"
|
||||||
|
if r.update_error is not None:
|
||||||
|
detail += f" (update warn: {r.update_error})"
|
||||||
|
else:
|
||||||
|
everything_ok = False
|
||||||
|
if r.error is not None:
|
||||||
|
status, detail = "FAIL", f" error={r.error}"
|
||||||
|
elif r.overflow is not None:
|
||||||
|
status, detail = "FAIL", f" overflow bbox={r.overflow}"
|
||||||
|
elif r.golden_ok is False:
|
||||||
|
status = "FAIL"
|
||||||
|
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
||||||
|
else:
|
||||||
|
status, detail = "FAIL", ""
|
||||||
|
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||||
|
print()
|
||||||
|
return everything_ok
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Check a plugin renders safely across sizes & screens")
|
||||||
|
group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
group.add_argument('--plugin', '-p', help='Plugin id to check')
|
||||||
|
group.add_argument('--all', action='store_true', help='Check every discovered plugin')
|
||||||
|
parser.add_argument('--plugin-dir', '-d', default=None, help='Directory to search for plugins')
|
||||||
|
parser.add_argument('--sizes', default=None, help='Comma-separated WxH list (default: all supported)')
|
||||||
|
parser.add_argument('--config', '-c', default='{}', help='Plugin config overrides as JSON')
|
||||||
|
parser.add_argument('--mock-data', '-m', default=None, help='Path to JSON file with mock cache data')
|
||||||
|
parser.add_argument('--out-dir', '-o', default=None, help='Also dump rendered PNGs here')
|
||||||
|
parser.add_argument('--skip-update', action='store_true', help='Skip calling update()')
|
||||||
|
parser.add_argument('--update-golden', action='store_true', help='Write/refresh golden images')
|
||||||
|
parser.add_argument('--golden-dir', default=None, help='Override golden dir (default: <plugin>/test/golden)')
|
||||||
|
parser.add_argument('--freeze-time', default=None,
|
||||||
|
help='Freeze wall clock, e.g. "2025-08-01 15:25:00" (for time-dependent plugins)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
search_dirs = [args.plugin_dir] if args.plugin_dir else DEFAULT_SEARCH_DIRS
|
||||||
|
sizes = parse_sizes(args.sizes)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = json.loads(args.config)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error("Invalid --config JSON: %s", e)
|
||||||
|
return 2
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
logger.error("--config must be a JSON object, got %s", type(config).__name__)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
mock_data = {}
|
||||||
|
if args.mock_data:
|
||||||
|
mock_path = Path(args.mock_data)
|
||||||
|
if not mock_path.exists():
|
||||||
|
logger.error("Mock data file not found: %s", args.mock_data)
|
||||||
|
return 2
|
||||||
|
with open(mock_path) as f:
|
||||||
|
mock_data = json.load(f)
|
||||||
|
if not isinstance(mock_data, dict):
|
||||||
|
logger.error("--mock-data must be a JSON object (key -> cache value), got %s",
|
||||||
|
type(mock_data).__name__)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
plugin_ids = discover_plugins(search_dirs) if args.all else [args.plugin]
|
||||||
|
if not plugin_ids:
|
||||||
|
logger.error("No plugins found in: %s", search_dirs)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
out_dir = Path(args.out_dir) if args.out_dir else None
|
||||||
|
golden_dir_override = Path(args.golden_dir) if args.golden_dir else None
|
||||||
|
|
||||||
|
all_results: Dict[str, List[RenderResult]] = {}
|
||||||
|
for plugin_id in plugin_ids:
|
||||||
|
all_results[plugin_id] = check_one(
|
||||||
|
plugin_id=plugin_id, search_dirs=search_dirs, sizes=sizes,
|
||||||
|
mock_data=mock_data, config=config, run_update=not args.skip_update,
|
||||||
|
out_dir=out_dir, update_golden=args.update_golden,
|
||||||
|
golden_dir_override=golden_dir_override, freeze_time=args.freeze_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# When refreshing goldens we skip drift comparison, but a crash or overflow
|
||||||
|
# still means the plugin is broken — never let --update-golden mask that.
|
||||||
|
ok = print_report(all_results)
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Clear all plugin dependency markers to force fresh dependency check
|
|
||||||
# Useful after updating plugins or troubleshooting dependency issues
|
|
||||||
|
|
||||||
echo "Clearing plugin dependency markers..."
|
|
||||||
|
|
||||||
# Check both possible cache locations
|
|
||||||
CACHE_DIRS=(
|
|
||||||
"/var/cache/ledmatrix"
|
|
||||||
"$HOME/.cache/ledmatrix"
|
|
||||||
)
|
|
||||||
|
|
||||||
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
|
|
||||||
if [ -d "$CACHE_DIR" ]; then
|
|
||||||
echo "Checking $CACHE_DIR..."
|
|
||||||
marker_count=$(find "$CACHE_DIR" -name "plugin_*_deps_installed" 2>/dev/null | wc -l)
|
|
||||||
if [ "$marker_count" -gt 0 ]; then
|
|
||||||
echo "Found $marker_count dependency marker(s) in $CACHE_DIR"
|
|
||||||
find "$CACHE_DIR" -name "plugin_*_deps_installed" -delete
|
|
||||||
echo "Cleared $marker_count marker(s)"
|
|
||||||
else
|
|
||||||
echo "No dependency markers found in $CACHE_DIR"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Done! Dependency markers cleared."
|
|
||||||
echo "Next startup will check and install dependencies as needed."
|
|
||||||
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# safe_pip_install.sh — Install a requirements.txt as root after validating
|
||||||
|
# that the resolved path is the project's own requirements.txt or a plugin's
|
||||||
|
# requirements.txt under plugin-repos/ or plugins/.
|
||||||
|
#
|
||||||
|
# This script is intended to be called via sudo from the web interface, so
|
||||||
|
# that packages a plugin declares end up visible to ledmatrix.service (which
|
||||||
|
# runs as root) rather than only to whichever non-root user runs the web
|
||||||
|
# interface. Plugin code already runs as root once loaded, so installing its
|
||||||
|
# declared dependencies as root is not a new trust boundary.
|
||||||
|
#
|
||||||
|
# Usage: safe_pip_install.sh <requirements_txt_path>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <requirements_txt_path>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET="$1"
|
||||||
|
|
||||||
|
# Determine the project root (parent of scripts/fix_perms/)
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
# Allowed locations (resolved, no trailing slash):
|
||||||
|
# - the project's own requirements.txt
|
||||||
|
# - any requirements.txt under plugin-repos/ or plugins/
|
||||||
|
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
|
||||||
|
ALLOWED_BASES=(
|
||||||
|
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
|
||||||
|
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve the target path (follow symlinks); works even if it doesn't exist.
|
||||||
|
RESOLVED_TARGET="$(realpath --canonicalize-missing "$TARGET")"
|
||||||
|
|
||||||
|
# Must be named requirements.txt — never install from an arbitrary file.
|
||||||
|
if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
|
||||||
|
echo "DENIED: $RESOLVED_TARGET is not a requirements.txt file" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
ALLOWED=false
|
||||||
|
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
|
||||||
|
ALLOWED=true
|
||||||
|
else
|
||||||
|
for BASE in "${ALLOWED_BASES[@]}"; do
|
||||||
|
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
|
||||||
|
ALLOWED=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ALLOWED" = false ]; then
|
||||||
|
echo "DENIED: $RESOLVED_TARGET is not an allowed requirements.txt location" >&2
|
||||||
|
echo "Allowed: $ALLOWED_EXACT, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$RESOLVED_TARGET" ]; then
|
||||||
|
echo "ERROR: $RESOLVED_TARGET does not exist" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
PYTHON_PATH="$(command -v python3)"
|
||||||
|
# --ignore-installed: root's site-packages often has apt/dpkg-managed copies
|
||||||
|
# of common libraries (requests, urllib3, ...) with no pip RECORD file, which
|
||||||
|
# pip refuses to uninstall in place ("Cannot uninstall: no RECORD file was
|
||||||
|
# found"). This tells pip to install the newer version alongside rather than
|
||||||
|
# aborting the whole requirements.txt install over one such conflict.
|
||||||
|
exec "$PYTHON_PATH" -m pip install --break-system-packages --ignore-installed -r "$RESOLVED_TARGET"
|
||||||
@@ -33,6 +33,7 @@ POWEROFF_PATH=$(command -v poweroff) || true
|
|||||||
BASH_PATH=$(command -v bash) || true
|
BASH_PATH=$(command -v bash) || true
|
||||||
JOURNALCTL_PATH=$(command -v journalctl) || true
|
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"
|
||||||
|
|
||||||
# Validate required commands (systemctl, bash, python3 are essential)
|
# Validate required commands (systemctl, bash, python3 are essential)
|
||||||
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
||||||
@@ -48,11 +49,15 @@ if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Validate helper script exists
|
# Validate helper scripts exist
|
||||||
if [ ! -f "$SAFE_RM_PATH" ]; then
|
if [ ! -f "$SAFE_RM_PATH" ]; then
|
||||||
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
|
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
|
||||||
|
echo "Error: Safe pip install helper not found: $SAFE_PIP_INSTALL_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Command paths:"
|
echo "Command paths:"
|
||||||
echo " Python: $PYTHON_PATH"
|
echo " Python: $PYTHON_PATH"
|
||||||
@@ -62,6 +67,7 @@ echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
|
|||||||
echo " Bash: $BASH_PATH"
|
echo " Bash: $BASH_PATH"
|
||||||
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
|
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"
|
||||||
|
|
||||||
# Create a temporary sudoers file
|
# Create a temporary sudoers file
|
||||||
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||||
@@ -101,13 +107,22 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Required: python3, bash
|
# Required: python3, bash
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py"
|
# NOTE: display_controller.py/start_display.sh/stop_display.sh live at the
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh"
|
# project root, not under scripts/install/ (where this script lives) —
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh"
|
# must use PROJECT_ROOT here, not PROJECT_DIR.
|
||||||
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT/display_controller.py"
|
||||||
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/start_display.sh"
|
||||||
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/stop_display.sh"
|
||||||
echo ""
|
echo ""
|
||||||
echo "# Allow web user to remove plugin directories via vetted helper script"
|
echo "# Allow web user to remove plugin directories via vetted helper script"
|
||||||
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
|
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
|
||||||
|
echo ""
|
||||||
|
echo "# Allow web user to install a plugin's requirements.txt as root via vetted"
|
||||||
|
echo "# helper script, so packages are visible to root-run ledmatrix.service"
|
||||||
|
echo "# (not just the web interface's own user). The helper validates the target"
|
||||||
|
echo "# is requirements.txt at the project root or under plugin-repos/ or plugins/."
|
||||||
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
|
||||||
} > "$TEMP_SUDOERS"
|
} > "$TEMP_SUDOERS"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -126,6 +141,7 @@ echo "- Run display_controller.py directly"
|
|||||||
echo "- Execute start_display.sh and stop_display.sh"
|
echo "- Execute start_display.sh and stop_display.sh"
|
||||||
echo "- Reboot and shutdown the system"
|
echo "- Reboot and shutdown the system"
|
||||||
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
|
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
|
||||||
|
echo "- Install plugin/base requirements.txt as root (so ledmatrix.service can see them)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Ask for confirmation
|
# Ask for confirmation
|
||||||
@@ -147,6 +163,13 @@ fi
|
|||||||
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
|
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
|
||||||
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
|
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
|
||||||
fi
|
fi
|
||||||
|
echo "Hardening safe_pip_install.sh ownership..."
|
||||||
|
if ! sudo chown root:root "$SAFE_PIP_INSTALL_PATH"; then
|
||||||
|
echo "Warning: Could not set ownership on $SAFE_PIP_INSTALL_PATH"
|
||||||
|
fi
|
||||||
|
if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
|
||||||
|
echo "Warning: Could not set permissions on $SAFE_PIP_INSTALL_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
||||||
echo "Configuration applied successfully!"
|
echo "Configuration applied successfully!"
|
||||||
@@ -160,7 +183,7 @@ if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
|||||||
echo "✗ systemctl status ledmatrix.service - Failed"
|
echo "✗ systemctl status ledmatrix.service - Failed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if sudo -n test -f "$PROJECT_DIR/start_display.sh"; then
|
if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then
|
||||||
echo "✓ File access test - OK"
|
echo "✓ File access test - OK"
|
||||||
else
|
else
|
||||||
echo "✗ File access test - Failed"
|
echo "✗ File access test - Failed"
|
||||||
|
|||||||
@@ -340,9 +340,14 @@ main() {
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Execute with proper error handling and non-interactive mode
|
# Execute with proper error handling and non-interactive mode
|
||||||
# Temporarily disable errexit to capture exit code instead of exiting immediately
|
# Temporarily disable errexit AND the ERR trap to capture exit code instead of
|
||||||
|
# exiting immediately. `set +e` alone does not suppress the ERR trap, so without
|
||||||
|
# `trap '' ERR` a non-zero exit from first_time_install.sh would trigger on_error
|
||||||
|
# here with the generic "Main installation" message instead of the detailed
|
||||||
|
# if/else handling below.
|
||||||
set +e
|
set +e
|
||||||
|
trap '' ERR
|
||||||
|
|
||||||
# Check /tmp permissions - only fix if actually wrong (common in automated scenarios)
|
# Check /tmp permissions - only fix if actually wrong (common in automated scenarios)
|
||||||
# When running manually, /tmp usually has correct permissions (1777)
|
# When running manually, /tmp usually has correct permissions (1777)
|
||||||
TMP_PERMS=$(stat -c '%a' /tmp 2>/dev/null || echo "unknown")
|
TMP_PERMS=$(stat -c '%a' /tmp 2>/dev/null || echo "unknown")
|
||||||
@@ -370,6 +375,7 @@ main() {
|
|||||||
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
|
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
|
||||||
fi
|
fi
|
||||||
INSTALL_EXIT_CODE=$?
|
INSTALL_EXIT_CODE=$?
|
||||||
|
trap 'on_error $LINENO' ERR # Re-enable ERR trap
|
||||||
set -e # Re-enable errexit
|
set -e # Re-enable errexit
|
||||||
|
|
||||||
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
|
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
|
||||||
|
|||||||
@@ -6,82 +6,143 @@ then falls back to pip with --break-system-packages
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import warnings
|
import warnings
|
||||||
|
from collections import deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
def install_via_apt(package_name):
|
# How many trailing lines of a failed command's output to keep for the
|
||||||
"""Try to install a package via apt."""
|
# end-of-run failure summary. Keeps the root cause near the end of the log,
|
||||||
try:
|
# which is where first_time_install.sh's error handler tails from.
|
||||||
# Map pip package names to apt package names
|
ERROR_TAIL_LINES = 15
|
||||||
apt_package_map = {
|
|
||||||
'flask': 'python3-flask',
|
|
||||||
'PIL': 'python3-pil',
|
def _run(cmd: List[str]) -> Tuple[bool, str]:
|
||||||
'freetype': 'python3-freetype',
|
"""Run a command, streaming combined stdout/stderr to a temp file.
|
||||||
'psutil': 'python3-psutil',
|
|
||||||
'werkzeug': 'python3-werkzeug',
|
Returns (success, output) instead of raising, so callers can report
|
||||||
'numpy': 'python3-numpy',
|
*why* a command failed rather than just that it failed. `output` is
|
||||||
'requests': 'python3-requests',
|
bounded to the last ERROR_TAIL_LINES lines so failures from very
|
||||||
'python-dateutil': 'python3-dateutil',
|
chatty commands (e.g. pip build logs) don't get buffered in memory.
|
||||||
'pytz': 'python3-tz',
|
"""
|
||||||
'geopy': 'python3-geopy',
|
with tempfile.TemporaryFile(mode='w+b') as f:
|
||||||
'unidecode': 'python3-unidecode',
|
result = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT) # nosec B603 B607 - hardcoded apt/pip args # nosemgrep
|
||||||
'websockets': 'python3-websockets',
|
f.seek(0)
|
||||||
'websocket-client': 'python3-websocket-client'
|
# Stream line-by-line so only the last ERROR_TAIL_LINES are ever held
|
||||||
}
|
# in memory, regardless of how much output the command produced.
|
||||||
|
tail = deque(
|
||||||
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
(line.decode('utf-8', errors='replace').rstrip('\n') for line in f),
|
||||||
|
maxlen=ERROR_TAIL_LINES,
|
||||||
print(f"Trying to install {apt_package} via apt...")
|
)
|
||||||
subprocess.check_call([
|
return result.returncode == 0, '\n'.join(tail)
|
||||||
'sudo', 'apt', 'update'
|
|
||||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
|
def install_via_apt(package_name: str) -> Tuple[bool, str]:
|
||||||
subprocess.check_call([
|
"""Try to install a package via apt. Returns (success, output)."""
|
||||||
'sudo', 'apt', 'install', '-y', apt_package
|
# Map pip package names to apt package names
|
||||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
apt_package_map = {
|
||||||
|
'flask': 'python3-flask',
|
||||||
|
'PIL': 'python3-pil',
|
||||||
|
'freetype': 'python3-freetype',
|
||||||
|
'psutil': 'python3-psutil',
|
||||||
|
'werkzeug': 'python3-werkzeug',
|
||||||
|
'numpy': 'python3-numpy',
|
||||||
|
'requests': 'python3-requests',
|
||||||
|
'python-dateutil': 'python3-dateutil',
|
||||||
|
'pytz': 'python3-tz',
|
||||||
|
'geopy': 'python3-geopy',
|
||||||
|
'unidecode': 'python3-unidecode',
|
||||||
|
'websockets': 'python3-websockets',
|
||||||
|
'websocket-client': 'python3-websocket-client'
|
||||||
|
}
|
||||||
|
|
||||||
|
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
||||||
|
|
||||||
|
print(f"Trying to install {apt_package} via apt...")
|
||||||
|
success, output = _run(['sudo', 'apt-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', apt_package])
|
||||||
|
if success:
|
||||||
print(f"Successfully installed {apt_package} via apt")
|
print(f"Successfully installed {apt_package} via apt")
|
||||||
return True
|
return True, ""
|
||||||
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
print(f"Failed to install {package_name} via apt, will try pip")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def install_via_pip(package_name):
|
print(f"Failed to install {apt_package} via apt, will try pip")
|
||||||
|
return False, output
|
||||||
|
|
||||||
|
|
||||||
|
def install_via_pip(package_name: str) -> Tuple[bool, str]:
|
||||||
"""Install a package via pip with --break-system-packages and --prefer-binary.
|
"""Install a package via pip with --break-system-packages and --prefer-binary.
|
||||||
|
|
||||||
--break-system-packages allows pip to install into the system Python on
|
--break-system-packages allows pip to install into the system Python on
|
||||||
Debian/Ubuntu-based systems without a virtual environment.
|
Debian/Ubuntu-based systems without a virtual environment.
|
||||||
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
||||||
exhausting /tmp space during compilation.
|
exhausting /tmp space during compilation.
|
||||||
"""
|
--ignore-installed stops pip from trying to *uninstall* packages that were
|
||||||
try:
|
installed by apt (e.g. python3-requests). Those Debian packages ship no
|
||||||
print(f"Installing {package_name} via pip...")
|
pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
|
||||||
subprocess.check_call([
|
and aborts the whole install. With --ignore-installed, pip lays the new
|
||||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
|
version down in /usr/local where it shadows the apt copy instead of removing
|
||||||
])
|
it. This matters when a pip dependency (google-api-python-client pulls a
|
||||||
print(f"Successfully installed {package_name} via pip")
|
newer requests) needs to upgrade an apt-managed package.
|
||||||
return True
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Failed to install {package_name} via pip: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def check_package_installed(package_name):
|
Returns (success, output).
|
||||||
|
"""
|
||||||
|
print(f"Installing {package_name} via pip...")
|
||||||
|
success, output = _run([
|
||||||
|
sys.executable, '-m', 'pip', 'install',
|
||||||
|
'--break-system-packages', '--prefer-binary', '--ignore-installed', package_name
|
||||||
|
])
|
||||||
|
if success:
|
||||||
|
print(f"Successfully installed {package_name} via pip")
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
print(f"Failed to install {package_name} via pip (see failure summary at end of log)")
|
||||||
|
return False, output
|
||||||
|
|
||||||
|
|
||||||
|
# Distribution (pip/apt) names whose importable module name differs.
|
||||||
|
IMPORT_NAME_MAP = {
|
||||||
|
'python-dateutil': 'dateutil',
|
||||||
|
'websocket-client': 'websocket',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_package_installed(package_name: str) -> bool:
|
||||||
"""Check if a package is already installed."""
|
"""Check if a package is already installed."""
|
||||||
|
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
|
||||||
# Suppress deprecation warnings when checking if packages are installed
|
# Suppress deprecation warnings when checking if packages are installed
|
||||||
# (we're just checking, not using them)
|
# (we're just checking, not using them)
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
||||||
try:
|
try:
|
||||||
__import__(package_name)
|
__import__(import_name)
|
||||||
return True
|
return True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def print_failure_summary(failed_packages: List[str], failure_details: dict) -> None:
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
|
||||||
|
print("=" * 60)
|
||||||
|
for package in failed_packages:
|
||||||
|
print(f"\nPackage: {package}")
|
||||||
|
print("-" * 40)
|
||||||
|
output = failure_details.get(package, "").strip()
|
||||||
|
if not output:
|
||||||
|
print(" (no output captured)")
|
||||||
|
continue
|
||||||
|
for line in output.splitlines()[-ERROR_TAIL_LINES:]:
|
||||||
|
print(f" {line}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main installation function."""
|
"""Main installation function."""
|
||||||
print("Installing dependencies for LED Matrix Web Interface V2...")
|
print("Installing dependencies for LED Matrix Web Interface V2...")
|
||||||
|
|
||||||
|
print("Refreshing apt package index...")
|
||||||
|
_run(['sudo', 'apt', 'update']) # best-effort; individual installs surface their own errors
|
||||||
|
|
||||||
# List of required packages
|
# List of required packages
|
||||||
required_packages = [
|
required_packages = [
|
||||||
'flask',
|
'flask',
|
||||||
@@ -98,19 +159,23 @@ def main():
|
|||||||
'websockets',
|
'websockets',
|
||||||
'websocket-client'
|
'websocket-client'
|
||||||
]
|
]
|
||||||
|
|
||||||
failed_packages = []
|
failed_packages = []
|
||||||
|
failure_details = {}
|
||||||
|
|
||||||
for package in required_packages:
|
for package in required_packages:
|
||||||
if check_package_installed(package):
|
if check_package_installed(package):
|
||||||
print(f"{package} is already installed")
|
print(f"{package} is already installed")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Try apt first, then pip
|
# Try apt first, then pip
|
||||||
if not install_via_apt(package):
|
ok, apt_output = install_via_apt(package)
|
||||||
if not install_via_pip(package):
|
if not ok:
|
||||||
|
ok, pip_output = install_via_pip(package)
|
||||||
|
if not ok:
|
||||||
failed_packages.append(package)
|
failed_packages.append(package)
|
||||||
|
failure_details[package] = pip_output or apt_output
|
||||||
|
|
||||||
# Install packages that don't have apt equivalents
|
# Install packages that don't have apt equivalents
|
||||||
special_packages = [
|
special_packages = [
|
||||||
'timezonefinder>=6.5.0,<7.0.0',
|
'timezonefinder>=6.5.0,<7.0.0',
|
||||||
@@ -122,47 +187,49 @@ def main():
|
|||||||
'python-socketio>=5.11.0,<6.0.0',
|
'python-socketio>=5.11.0,<6.0.0',
|
||||||
'python-engineio>=4.9.0,<5.0.0'
|
'python-engineio>=4.9.0,<5.0.0'
|
||||||
]
|
]
|
||||||
|
|
||||||
for package in special_packages:
|
for package in special_packages:
|
||||||
if not install_via_pip(package):
|
ok, pip_output = install_via_pip(package)
|
||||||
|
if not ok:
|
||||||
failed_packages.append(package)
|
failed_packages.append(package)
|
||||||
|
failure_details[package] = pip_output
|
||||||
|
|
||||||
# Install rgbmatrix module from local source (optional - may already be installed in Step 6)
|
# Install rgbmatrix module from local source (optional - may already be installed in Step 6)
|
||||||
# Check if already installed first
|
# Check if already installed first
|
||||||
if check_package_installed('rgbmatrix'):
|
if check_package_installed('rgbmatrix'):
|
||||||
print("rgbmatrix module already installed, skipping...")
|
print("rgbmatrix module already installed, skipping...")
|
||||||
else:
|
else:
|
||||||
print("Installing rgbmatrix module from local source...")
|
print("Installing rgbmatrix module from local source...")
|
||||||
try:
|
# Get project root (parent of scripts directory)
|
||||||
# Get project root (parent of scripts directory)
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
||||||
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
if rgbmatrix_path.exists():
|
||||||
if rgbmatrix_path.exists():
|
# Check if the module has been built (look for setup.py)
|
||||||
# Check if the module has been built (look for setup.py)
|
setup_py = rgbmatrix_path / 'setup.py'
|
||||||
setup_py = rgbmatrix_path / 'setup.py'
|
if setup_py.exists():
|
||||||
if setup_py.exists():
|
# Try installing - use regular install, not editable mode
|
||||||
# Try installing - use regular install, not editable mode
|
# This is optional for web interface and should already be installed in Step 6
|
||||||
# This is optional for web interface and should already be installed in Step 6
|
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)])
|
||||||
subprocess.check_call([
|
if ok:
|
||||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)
|
|
||||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
print("rgbmatrix module installed successfully")
|
print("rgbmatrix module installed successfully")
|
||||||
else:
|
else:
|
||||||
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
||||||
print(" This is normal if Step 6 hasn't completed yet.")
|
# and should be installed in Step 6 of first_time_install.sh
|
||||||
|
print("Warning: Failed to install rgbmatrix module:")
|
||||||
|
for line in output.strip().splitlines()[-ERROR_TAIL_LINES:]:
|
||||||
|
print(f" {line}")
|
||||||
|
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
|
||||||
|
print(" The web interface will work without it.")
|
||||||
else:
|
else:
|
||||||
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
||||||
except subprocess.CalledProcessError as e:
|
print(" This is normal if Step 6 hasn't completed yet.")
|
||||||
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
else:
|
||||||
# and should be installed in Step 6 of first_time_install.sh
|
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
||||||
print(f"Warning: Failed to install rgbmatrix module: {e}")
|
|
||||||
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
|
|
||||||
print(" The web interface will work without it.")
|
|
||||||
# Don't add to failed_packages since it's optional
|
|
||||||
|
|
||||||
if failed_packages:
|
if failed_packages:
|
||||||
print(f"\nFailed to install the following packages: {failed_packages}")
|
print(f"\nFailed to install the following packages: {failed_packages}")
|
||||||
print("You may need to install them manually or check your system configuration.")
|
print("You may need to install them manually or check your system configuration.")
|
||||||
|
print_failure_summary(failed_packages, failure_details)
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
print("\nAll dependencies installed successfully!")
|
print("\nAll dependencies installed successfully!")
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import os
|
|||||||
import json
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Sequence, Union
|
|
||||||
|
|
||||||
# Add project root to path
|
# Add project root to path
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -28,49 +27,15 @@ os.environ['EMULATOR'] = 'true'
|
|||||||
|
|
||||||
# Import logger after path setup so src.logging_config is importable
|
# Import logger after path setup so src.logging_config is importable
|
||||||
from src.logging_config import get_logger # noqa: E402
|
from src.logging_config import get_logger # noqa: E402
|
||||||
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
|
find_plugin_dir, load_manifest, load_config_defaults,
|
||||||
|
)
|
||||||
logger = get_logger("[Render Plugin]")
|
logger = get_logger("[Render Plugin]")
|
||||||
|
|
||||||
MIN_DIMENSION = 1
|
MIN_DIMENSION = 1
|
||||||
MAX_DIMENSION = 512
|
MAX_DIMENSION = 512
|
||||||
|
|
||||||
|
|
||||||
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
|
||||||
"""Find a plugin directory by searching multiple paths."""
|
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
|
||||||
loader = PluginLoader()
|
|
||||||
for search_dir in search_dirs:
|
|
||||||
search_path = Path(search_dir)
|
|
||||||
if not search_path.exists():
|
|
||||||
continue
|
|
||||||
result = loader.find_plugin_directory(plugin_id, search_path)
|
|
||||||
if result:
|
|
||||||
return Path(result)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_manifest(plugin_dir: Path) -> Dict[str, Any]:
|
|
||||||
"""Load and return manifest.json from plugin directory."""
|
|
||||||
manifest_path = plugin_dir / 'manifest.json'
|
|
||||||
if not manifest_path.exists():
|
|
||||||
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
|
||||||
with open(manifest_path, 'r') as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config_defaults(plugin_dir: Path) -> Dict[str, Any]:
|
|
||||||
"""Extract default values from config_schema.json."""
|
|
||||||
schema_path = plugin_dir / 'config_schema.json'
|
|
||||||
if not schema_path.exists():
|
|
||||||
return {}
|
|
||||||
with open(schema_path, 'r') as f:
|
|
||||||
schema = json.load(f)
|
|
||||||
defaults: Dict[str, Any] = {}
|
|
||||||
for key, prop in schema.get('properties', {}).items():
|
|
||||||
if 'default' in prop:
|
|
||||||
defaults[key] = prop['default']
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
||||||
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
||||||
|
|||||||
@@ -68,14 +68,15 @@ class DiskCache:
|
|||||||
return None
|
return None
|
||||||
return os.path.join(self.cache_dir, f"{key}.json")
|
return os.path.join(self.cache_dir, f"{key}.json")
|
||||||
|
|
||||||
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
|
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get data from disk cache.
|
Get data from disk cache.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Cache key
|
key: Cache key
|
||||||
max_age: Maximum age in seconds
|
max_age: Maximum age in seconds; None disables age-based expiry
|
||||||
|
(the record never counts as stale). Mirrors MemoryCache.get.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Cached data or None if not found or expired
|
Cached data or None if not found or expired
|
||||||
"""
|
"""
|
||||||
@@ -105,7 +106,13 @@ class DiskCache:
|
|||||||
record_ts = None
|
record_ts = None
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if record_ts is None or (now - record_ts) <= max_age:
|
# max_age=None means "never expires" (mirrors MemoryCache and the
|
||||||
|
# cache_manager docstring). Guard it explicitly — otherwise the
|
||||||
|
# comparison below raises TypeError and the record is treated as a
|
||||||
|
# miss, which silently breaks callers that persist long-lived state
|
||||||
|
# via get(key, max_age=None) (e.g. plugin health/metrics that must
|
||||||
|
# survive restarts and be read cross-process).
|
||||||
|
if record_ts is None or max_age is None or (now - record_ts) <= max_age:
|
||||||
return record
|
return record
|
||||||
else:
|
else:
|
||||||
# Stale on disk; keep file for potential diagnostics but treat as miss
|
# Stale on disk; keep file for potential diagnostics but treat as miss
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Cache Manager — multi-tier response cache for the LEDMatrix application.
|
||||||
|
|
||||||
|
:class:`CacheManager` provides a unified caching layer used by all plugins
|
||||||
|
to reduce external API calls and survive network outages gracefully.
|
||||||
|
|
||||||
|
Two storage tiers
|
||||||
|
-----------------
|
||||||
|
* **Memory tier** (:class:`~src.cache.memory_cache.MemoryCache`): fast LRU
|
||||||
|
cache (up to 1 000 entries by default). Hit on this tier before touching
|
||||||
|
disk.
|
||||||
|
* **Disk tier** (:class:`~src.cache.disk_cache.DiskCache`): filesystem-backed
|
||||||
|
persistent store that survives process restarts.
|
||||||
|
|
||||||
|
Data written to cache is serialised as JSON. :class:`DateTimeEncoder` handles
|
||||||
|
``datetime`` objects transparently so callers don't have to pre-serialise them.
|
||||||
|
|
||||||
|
Typical plugin usage::
|
||||||
|
|
||||||
|
data = self.cache_manager.get_cached_data('my_key', max_age=300)
|
||||||
|
if data is None:
|
||||||
|
data = fetch_from_api()
|
||||||
|
self.cache_manager.save_cache('my_key', data)
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -15,7 +40,10 @@ from src.cache.cache_metrics import CacheMetrics
|
|||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
class DateTimeEncoder(json.JSONEncoder):
|
class DateTimeEncoder(json.JSONEncoder):
|
||||||
|
"""JSON encoder that serialises ``datetime`` objects as ISO-8601 strings."""
|
||||||
|
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
|
"""Return ISO-8601 string for datetime; delegate all other types to the base encoder."""
|
||||||
if isinstance(obj, datetime):
|
if isinstance(obj, datetime):
|
||||||
return obj.isoformat()
|
return obj.isoformat()
|
||||||
return super().default(obj)
|
return super().default(obj)
|
||||||
@@ -546,9 +574,19 @@ class CacheManager:
|
|||||||
}
|
}
|
||||||
return self.save_cache(data_type, cache_data)
|
return self.save_cache(data_type, cache_data)
|
||||||
|
|
||||||
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
|
def get(self, key: str, max_age: Optional[int] = 300,
|
||||||
"""Get data from cache if it exists and is not stale."""
|
memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||||
cached_data = self.get_cached_data(key, max_age)
|
"""Get data from cache if it exists and is not stale.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Cache key
|
||||||
|
max_age: Max age (seconds) for the on-disk entry; None never expires.
|
||||||
|
memory_ttl: Max age (seconds) for the in-memory entry. Pass 0 to
|
||||||
|
bypass the memory tier and force a fresh read from disk — used by
|
||||||
|
cross-process readers that must observe another process's latest
|
||||||
|
write rather than a stale first snapshot. Defaults to max_age.
|
||||||
|
"""
|
||||||
|
cached_data = self.get_cached_data(key, max_age, memory_ttl=memory_ttl)
|
||||||
if cached_data and 'data' in cached_data:
|
if cached_data and 'data' in cached_data:
|
||||||
return cached_data['data']
|
return cached_data['data']
|
||||||
return cached_data
|
return cached_data
|
||||||
|
|||||||
@@ -8,13 +8,34 @@ files that need to be accessible by both root service and web user.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import shutil as _shutil
|
import shutil as _shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Matches the credentials portion of a "scheme://user:pass@host" URL, so pip's
|
||||||
|
# own error output can be logged/displayed without echoing back a private
|
||||||
|
# index URL's embedded basic-auth secret verbatim (e.g. from a
|
||||||
|
# requirements.txt --index-url line or the PIP_INDEX_URL env var).
|
||||||
|
_URL_CREDENTIALS_RE = re.compile(r'://[^/\s@:]+:[^/\s@]+@')
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_url_credentials(text: Optional[str]) -> str:
|
||||||
|
"""Replace embedded user:pass@ URL credentials in text with a placeholder.
|
||||||
|
|
||||||
|
Safe to call on any subprocess output destined for logs: it only ever
|
||||||
|
shortens/replaces the credential substring, never changes the presence
|
||||||
|
or absence of the specific fixed phrases callers check for
|
||||||
|
(e.g. "a password is required"), so it can't affect control flow.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text or ""
|
||||||
|
return _URL_CREDENTIALS_RE.sub('://***:***@', text)
|
||||||
|
|
||||||
# System directories that should never have their permissions modified
|
# System directories that should never have their permissions modified
|
||||||
# These directories have special system-level permissions that must be preserved
|
# These directories have special system-level permissions that must be preserved
|
||||||
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
|
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
|
||||||
@@ -287,3 +308,119 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
|
|||||||
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
|
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.CompletedProcess:
|
||||||
|
"""
|
||||||
|
Install a requirements.txt file for a plugin (or the project itself).
|
||||||
|
|
||||||
|
Prefers the vetted sudo wrapper (scripts/fix_perms/safe_pip_install.sh) so
|
||||||
|
packages end up visible to root-run ledmatrix.service, not just to
|
||||||
|
whichever non-root user happens to run the calling process (e.g. the web
|
||||||
|
interface). Falls back to installing with the calling process's own
|
||||||
|
interpreter if the wrapper isn't set up yet (the admin hasn't run
|
||||||
|
scripts/install/configure_web_sudo.sh), so dependency installation still
|
||||||
|
does *something* useful rather than hard-failing.
|
||||||
|
|
||||||
|
Always installs with the interpreter that will actually run the code
|
||||||
|
(``sys.executable`` in the fallback path, the wrapper's ``python3`` in the
|
||||||
|
sudo path) rather than a bare ``pip``/``pip3`` off PATH, which can
|
||||||
|
silently resolve to a different Python installation (e.g. system Python
|
||||||
|
vs. a virtualenv) than the one importing the package at runtime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req_file: Path to a requirements.txt file
|
||||||
|
timeout: Subprocess timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
subprocess.CompletedProcess from the pip (or wrapper) invocation.
|
||||||
|
Never raises on a non-zero exit; callers should check ``returncode``.
|
||||||
|
``stdout`` is prefixed with an explanatory note when the root wrapper
|
||||||
|
was unavailable and the fallback path was used.
|
||||||
|
"""
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
|
||||||
|
|
||||||
|
if wrapper.exists():
|
||||||
|
# See sudo_remove_directory / configure_web_sudo.sh for why bash must
|
||||||
|
# be invoked with an explicit, known path rather than relying on the
|
||||||
|
# wrapper's shebang: sudoers matches the exact command line.
|
||||||
|
bash_candidates = []
|
||||||
|
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
|
||||||
|
if candidate and candidate not in bash_candidates:
|
||||||
|
bash_candidates.append(candidate)
|
||||||
|
|
||||||
|
result = None
|
||||||
|
for bash_path in bash_candidates:
|
||||||
|
# bash_path and wrapper are fixed, known-good paths, and
|
||||||
|
# safe_pip_install.sh independently re-validates req_file is an
|
||||||
|
# allowed requirements.txt before installing anything as root.
|
||||||
|
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||||
|
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
|
||||||
|
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
||||||
|
)
|
||||||
|
# Redact immediately: pip can echo a private index URL's embedded
|
||||||
|
# basic-auth credentials back in its own error/progress output
|
||||||
|
# (e.g. from a requirements.txt --index-url line). Doesn't affect
|
||||||
|
# the fixed-phrase "denied" check below -- those phrases never
|
||||||
|
# overlap with URL syntax.
|
||||||
|
result.stderr = _redact_url_credentials(result.stderr)
|
||||||
|
result.stdout = _redact_url_credentials(result.stdout)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result
|
||||||
|
# Distinguish "sudo rejected this exact command line" (worth
|
||||||
|
# trying the next bash candidate) from "sudo ran it but pip
|
||||||
|
# itself failed" (a real error — stop and surface it).
|
||||||
|
denied = any(
|
||||||
|
phrase in result.stderr
|
||||||
|
for phrase in ("a password is required", "is not allowed to run", "no tty present")
|
||||||
|
)
|
||||||
|
if not denied:
|
||||||
|
# Deliberately don't interpolate req_file or the pip output here:
|
||||||
|
# this log line is scanner-visible, and a static analyzer can't
|
||||||
|
# tell "already redacted above" from "still raw" just by looking
|
||||||
|
# at this call in isolation. The full (redacted) text is still
|
||||||
|
# available to callers via the returned CompletedProcess.
|
||||||
|
logger.warning(
|
||||||
|
"Root pip install failed (rc=%s); see the returned "
|
||||||
|
"CompletedProcess.stderr for details.",
|
||||||
|
result.returncode,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Same reasoning as above: no req_file / pip-output interpolation in
|
||||||
|
# this log line, only in the returned note/CompletedProcess.
|
||||||
|
logger.warning(
|
||||||
|
"Root pip install wrapper denied via sudo for all candidates; "
|
||||||
|
"falling back to user-level install. See the returned "
|
||||||
|
"CompletedProcess.stderr for details."
|
||||||
|
)
|
||||||
|
note = (
|
||||||
|
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
|
||||||
|
"installed for the current process's user only. Packages may not be "
|
||||||
|
"visible to ledmatrix.service if it runs as a different user — "
|
||||||
|
"run scripts/install/configure_web_sudo.sh to fix this.]\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"safe_pip_install.sh not found; falling back to user-level install."
|
||||||
|
)
|
||||||
|
note = (
|
||||||
|
"[safe_pip_install.sh not found; installed for the current process's "
|
||||||
|
"user only. Run scripts/install/configure_web_sudo.sh to enable "
|
||||||
|
"root installs visible to ledmatrix.service.]\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# sys.executable is this process's own interpreter (not
|
||||||
|
# attacker-influenced), and req_file is a Path built internally by callers
|
||||||
|
# (store_manager.py plugin paths, PROJECT_ROOT/requirements.txt), never
|
||||||
|
# raw external/user input. --ignore-installed matches safe_pip_install.sh:
|
||||||
|
# apt-managed packages (e.g. python3-requests) ship no pip RECORD file, so
|
||||||
|
# upgrading them would otherwise abort with "uninstall-no-record-file".
|
||||||
|
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||||
|
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
|
||||||
|
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
||||||
|
)
|
||||||
|
result.stderr = _redact_url_credentials(result.stderr)
|
||||||
|
result.stdout = note + _redact_url_credentials(result.stdout)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -347,34 +347,40 @@ class ScrollHelper:
|
|||||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||||
|
|
||||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||||
"""Fast integer pixel extraction (no interpolation)."""
|
"""Fast integer pixel extraction (no interpolation).
|
||||||
# Fast numpy array slicing for normal case (no wrap-around)
|
|
||||||
if end_x <= self.cached_image.width:
|
Uses Image.frombytes instead of Image.fromarray: frombytes skips
|
||||||
# Normal case: single slice - fastest path
|
numpy's array-protocol overhead and is ~50% faster for the display-sized
|
||||||
frame_array = self.cached_array[:, start_x:end_x]
|
slices (128×32 = 12 KB) used here.
|
||||||
# Convert to PIL Image (minimal overhead)
|
"""
|
||||||
return Image.fromarray(frame_array)
|
_size = (self.display_width, self.display_height)
|
||||||
|
img_w = self.cached_image.width
|
||||||
|
|
||||||
|
if end_x <= img_w:
|
||||||
|
# Normal case: single contiguous slice (fastest path)
|
||||||
|
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
|
||||||
|
return Image.frombytes('RGB', _size, frame_array.tobytes())
|
||||||
else:
|
else:
|
||||||
# Wrap-around case: combine two slices using numpy
|
# Ensure frame buffer is allocated for all non-simple paths
|
||||||
width1 = self.cached_image.width - start_x
|
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||||
|
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
width1 = img_w - start_x
|
||||||
if width1 > 0:
|
if width1 > 0:
|
||||||
# Use pre-allocated buffer for output
|
# Wrap-around: tail of image + head of image
|
||||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
|
||||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
|
||||||
|
|
||||||
# First part from end of image (fast numpy slice)
|
|
||||||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
||||||
|
|
||||||
# Second part from beginning of image
|
|
||||||
remaining_width = self.display_width - width1
|
remaining_width = self.display_width - width1
|
||||||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
||||||
|
|
||||||
# Convert combined buffer to PIL Image
|
|
||||||
return Image.fromarray(self._frame_buffer)
|
|
||||||
else:
|
else:
|
||||||
# Edge case: start_x >= image width, wrap to beginning
|
# Edge case: start_x at or past image end — show from beginning,
|
||||||
frame_array = self.cached_array[:, :self.display_width]
|
# clamped to available width (scroll_position should wrap before
|
||||||
return Image.fromarray(frame_array)
|
# reaching this state in normal operation).
|
||||||
|
available = min(self.display_width, img_w)
|
||||||
|
self._frame_buffer[:, :available] = self.cached_array[:, :available]
|
||||||
|
if available < self.display_width:
|
||||||
|
self._frame_buffer[:, available:] = 0
|
||||||
|
|
||||||
|
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||||
|
|
||||||
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Config Manager — reads, writes, and validates ``config/config.json``.
|
||||||
|
|
||||||
|
:class:`ConfigManager` is the single owner of the on-disk configuration
|
||||||
|
files:
|
||||||
|
|
||||||
|
* ``config/config.json`` — main user-editable configuration.
|
||||||
|
* ``config/config_secrets.json`` — sensitive values (API keys, tokens).
|
||||||
|
|
||||||
|
All writes go through :class:`~src.config_manager_atomic.AtomicConfigManager`
|
||||||
|
which performs a backup before overwriting, validates the result, and rolls
|
||||||
|
back on error. This makes config corruption essentially impossible.
|
||||||
|
|
||||||
|
Plugin configuration
|
||||||
|
--------------------
|
||||||
|
Plugin configs are stored inside ``config.json`` under the plugin's ID key
|
||||||
|
and survive plugin reinstalls. Use :meth:`ConfigManager.update_plugin_config`
|
||||||
|
to write plugin settings; never write directly to the plugin directory.
|
||||||
|
|
||||||
|
Hot-reload
|
||||||
|
----------
|
||||||
|
:class:`~src.config_service.ConfigService` wraps ``ConfigManager`` and
|
||||||
|
detects file changes, broadcasting the new config to registered listeners
|
||||||
|
without requiring a restart.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -17,6 +43,13 @@ from src.common.permission_utils import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
|
"""
|
||||||
|
Reads and writes the main application configuration files.
|
||||||
|
|
||||||
|
Wraps :class:`~src.config_manager_atomic.AtomicConfigManager` for safe
|
||||||
|
atomic writes with automatic backup and rollback. Also exposes helpers
|
||||||
|
for plugin configuration persistence and secret-field masking.
|
||||||
|
"""
|
||||||
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
|
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
|
||||||
# Use current working directory as base
|
# Use current working directory as base
|
||||||
self.config_path: str = config_path or "config/config.json"
|
self.config_path: str = config_path or "config/config.json"
|
||||||
@@ -29,9 +62,11 @@ class ConfigManager:
|
|||||||
self._atomic_manager: Optional[AtomicConfigManager] = None
|
self._atomic_manager: Optional[AtomicConfigManager] = None
|
||||||
|
|
||||||
def get_config_path(self) -> str:
|
def get_config_path(self) -> str:
|
||||||
|
"""Return the path to the main config file (``config/config.json``)."""
|
||||||
return self.config_path
|
return self.config_path
|
||||||
|
|
||||||
def get_secrets_path(self) -> str:
|
def get_secrets_path(self) -> str:
|
||||||
|
"""Return the path to the secrets file (``config/config_secrets.json``)."""
|
||||||
return self.secrets_path
|
return self.secrets_path
|
||||||
|
|
||||||
def _get_atomic_manager(self) -> AtomicConfigManager:
|
def _get_atomic_manager(self) -> AtomicConfigManager:
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Display Controller — top-level orchestration for the LEDMatrix application.
|
||||||
|
|
||||||
|
This module owns the main run loop that drives the LED display. It ties
|
||||||
|
together every major subsystem:
|
||||||
|
|
||||||
|
- ConfigManager / ConfigService — loads config.json, hot-reloads on change
|
||||||
|
- DisplayManager — hardware (or emulator) output interface
|
||||||
|
- FontManager — TTF/BDF font loading and caching
|
||||||
|
- CacheManager — multi-tier API response cache
|
||||||
|
- PluginManager — plugin lifecycle (load, update, display)
|
||||||
|
- DisplaySyncManager — optional leader/follower multi-Pi sync
|
||||||
|
- VegasModeCoordinator — optional continuous Vegas scroll mode
|
||||||
|
|
||||||
|
The main loop inside :meth:`DisplayController.run` rotates through enabled
|
||||||
|
plugin display modes, respecting schedule windows, brightness dim schedules,
|
||||||
|
on-demand overrides, and live-priority interrupts.
|
||||||
|
|
||||||
|
Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
|
||||||
|
:meth:`~DisplayController.run`.
|
||||||
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional, Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
||||||
import pytz
|
import pytz
|
||||||
@@ -28,6 +50,24 @@ DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
|||||||
WIFI_STATUS_FILE = None # Will be initialized in __init__
|
WIFI_STATUS_FILE = None # Will be initialized in __init__
|
||||||
|
|
||||||
class DisplayController:
|
class DisplayController:
|
||||||
|
"""
|
||||||
|
Top-level controller that owns the LED display run loop.
|
||||||
|
|
||||||
|
Responsibilities
|
||||||
|
----------------
|
||||||
|
* Initialise and wire together all subsystems at startup.
|
||||||
|
* Rotate through plugin display modes in :meth:`run`.
|
||||||
|
* Honour schedule windows (active/inactive hours) and dim schedules.
|
||||||
|
* Handle on-demand override requests (external callers can pin a
|
||||||
|
specific plugin/mode for a fixed duration via the cache bus).
|
||||||
|
* Coordinate with a follower Pi when multi-display sync is configured.
|
||||||
|
* Delegate all actual content to the plugin system — this class contains
|
||||||
|
no display logic of its own.
|
||||||
|
|
||||||
|
There is exactly one instance per process; call :func:`main` to create
|
||||||
|
it and start the run loop.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
logger.info("Starting DisplayController initialization")
|
logger.info("Starting DisplayController initialization")
|
||||||
@@ -123,6 +163,13 @@ class DisplayController:
|
|||||||
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
||||||
self.mode_to_plugin_id: Dict[str, str] = {}
|
self.mode_to_plugin_id: Dict[str, str] = {}
|
||||||
self.plugin_display_modes: Dict[str, List[str]] = {}
|
self.plugin_display_modes: Dict[str, List[str]] = {}
|
||||||
|
# Per-plugin config-change callbacks, kept so we can unsubscribe a
|
||||||
|
# plugin when it is disabled live.
|
||||||
|
self._plugin_config_callbacks: Dict[str, Callable] = {}
|
||||||
|
# Set by the config-watcher thread when the enabled-plugin set changes;
|
||||||
|
# the main run loop reconciles (loads/unloads) on its own thread so
|
||||||
|
# mutating available_modes never races with rendering.
|
||||||
|
self._pending_plugin_reconcile = False
|
||||||
self.on_demand_active = False
|
self.on_demand_active = False
|
||||||
self.on_demand_mode: Optional[str] = None
|
self.on_demand_mode: Optional[str] = None
|
||||||
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
|
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
|
||||||
@@ -138,7 +185,11 @@ class DisplayController:
|
|||||||
self.on_demand_last_event: Optional[str] = None
|
self.on_demand_last_event: Optional[str] = None
|
||||||
self.on_demand_schedule_override = False
|
self.on_demand_schedule_override = False
|
||||||
self.rotation_resume_index: Optional[int] = None
|
self.rotation_resume_index: Optional[int] = None
|
||||||
|
# Saved rotation position when a live-priority plugin preempts the
|
||||||
|
# rotation, so it resumes where it left off (not after the live plugin)
|
||||||
|
# once live priority ends.
|
||||||
|
self._live_resume_index: Optional[int] = None
|
||||||
|
|
||||||
# WiFi status message tracking
|
# WiFi status message tracking
|
||||||
global WIFI_STATUS_FILE
|
global WIFI_STATUS_FILE
|
||||||
if WIFI_STATUS_FILE is None:
|
if WIFI_STATUS_FILE is None:
|
||||||
@@ -148,7 +199,11 @@ class DisplayController:
|
|||||||
self.wifi_status_file = WIFI_STATUS_FILE
|
self.wifi_status_file = WIFI_STATUS_FILE
|
||||||
self.wifi_status_active = False
|
self.wifi_status_active = False
|
||||||
self.wifi_status_expires_at: Optional[float] = None
|
self.wifi_status_expires_at: Optional[float] = None
|
||||||
|
|
||||||
|
# Plugin display() signature cache — must be initialised before the plugin
|
||||||
|
# loading loop below so the .pop() invalidation at load time is always safe.
|
||||||
|
self._plugin_accepts_display_mode: Dict[str, bool] = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Attempting to import plugin system...")
|
logger.info("Attempting to import plugin system...")
|
||||||
from src.plugin_system import PluginManager
|
from src.plugin_system import PluginManager
|
||||||
@@ -175,7 +230,24 @@ class DisplayController:
|
|||||||
cache_manager=self.cache_manager,
|
cache_manager=self.cache_manager,
|
||||||
font_manager=self.font_manager
|
font_manager=self.font_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Activate the plugin health/metrics subsystem. PluginManager leaves
|
||||||
|
# health_tracker/resource_monitor as None by default; wiring real
|
||||||
|
# instances here turns on the circuit breaker (a repeatedly-failing
|
||||||
|
# plugin's update() is skipped after consecutive failures, then
|
||||||
|
# retried after a cooldown) and per-plugin execution-time metrics.
|
||||||
|
# Both persist to the shared cache so the web UI can surface them.
|
||||||
|
# Done before discovery/loading so load-time schema warnings have a
|
||||||
|
# tracker to record against.
|
||||||
|
try:
|
||||||
|
from src.plugin_system.plugin_health import PluginHealthTracker
|
||||||
|
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||||
|
self.plugin_manager.health_tracker = PluginHealthTracker(self.cache_manager)
|
||||||
|
self.plugin_manager.resource_monitor = PluginResourceMonitor(self.cache_manager)
|
||||||
|
logger.info("Plugin health tracking and resource monitoring enabled")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not enable plugin health/resource monitoring: %s", e)
|
||||||
|
|
||||||
# Validate plugins after plugin manager is created
|
# Validate plugins after plugin manager is created
|
||||||
try:
|
try:
|
||||||
from src.startup_validator import StartupValidator
|
from src.startup_validator import StartupValidator
|
||||||
@@ -283,45 +355,10 @@ class DisplayController:
|
|||||||
logger.info("✓ Loaded plugin %s in %.3f seconds (%d/%d)",
|
logger.info("✓ Loaded plugin %s in %.3f seconds (%d/%d)",
|
||||||
plugin_id, result['load_time'], loaded_count, enabled_count)
|
plugin_id, result['load_time'], loaded_count, enabled_count)
|
||||||
|
|
||||||
# Get plugin instance and manifest
|
# Register the loaded plugin's modes, config subscription
|
||||||
plugin_instance = self.plugin_manager.get_plugin(plugin_id)
|
# and dispatch maps (shared with live enable hot-reload).
|
||||||
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {})
|
self._register_loaded_plugin(plugin_id)
|
||||||
|
|
||||||
# Prefer plugin's modes attribute if available (dynamic based on enabled leagues)
|
|
||||||
# Fall back to manifest display_modes if plugin doesn't provide modes
|
|
||||||
if plugin_instance and hasattr(plugin_instance, 'modes') and plugin_instance.modes:
|
|
||||||
display_modes = list(plugin_instance.modes)
|
|
||||||
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
|
|
||||||
else:
|
|
||||||
display_modes = manifest.get('display_modes', [plugin_id])
|
|
||||||
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
|
|
||||||
|
|
||||||
if isinstance(display_modes, list) and display_modes:
|
|
||||||
self.plugin_display_modes[plugin_id] = list(display_modes)
|
|
||||||
else:
|
|
||||||
display_modes = [plugin_id]
|
|
||||||
self.plugin_display_modes[plugin_id] = list(display_modes)
|
|
||||||
|
|
||||||
# Subscribe plugin to config changes for hot-reload
|
|
||||||
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
|
|
||||||
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
|
||||||
"""Callback for plugin config changes."""
|
|
||||||
try:
|
|
||||||
plugin_instance.on_config_change(new_config)
|
|
||||||
logger.debug("Plugin %s notified of config change", plugin_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error in plugin %s config change handler: %s", plugin_id, e, exc_info=True)
|
|
||||||
|
|
||||||
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
|
|
||||||
logger.debug("Subscribed plugin %s to config changes", plugin_id)
|
|
||||||
|
|
||||||
# Add plugin modes to available modes
|
|
||||||
for mode in display_modes:
|
|
||||||
self.available_modes.append(mode)
|
|
||||||
self.plugin_modes[mode] = plugin_instance
|
|
||||||
self.mode_to_plugin_id[mode] = plugin_id
|
|
||||||
logger.debug(" Added mode: %s", mode)
|
|
||||||
|
|
||||||
# Show progress
|
# Show progress
|
||||||
progress_pct = int((loaded_count / enabled_count) * 100)
|
progress_pct = int((loaded_count / enabled_count) * 100)
|
||||||
elapsed = time.time() - plugin_time
|
elapsed = time.time() - plugin_time
|
||||||
@@ -367,11 +404,43 @@ class DisplayController:
|
|||||||
self.is_display_active = True
|
self.is_display_active = True
|
||||||
self._was_display_active = True # Track previous state for schedule change detection
|
self._was_display_active = True # Track previous state for schedule change detection
|
||||||
|
|
||||||
|
# --- Opt #2: cached config values ---
|
||||||
|
# Avoids chained dict.get() with temporary {} defaults on every hot path call.
|
||||||
|
# Refreshed via _refresh_config_cache() on every hot-reload.
|
||||||
|
self._normal_brightness: int = (
|
||||||
|
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||||
|
)
|
||||||
|
self._scroll_speed: float = (
|
||||||
|
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||||
|
)
|
||||||
|
|
||||||
# Brightness state tracking for dim schedule
|
# Brightness state tracking for dim schedule
|
||||||
self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
self.current_brightness = self._normal_brightness
|
||||||
self.is_dimmed = False
|
self.is_dimmed = False
|
||||||
self._was_dimmed = False
|
self._was_dimmed = False
|
||||||
|
|
||||||
|
# --- Opt #3: schedule minute-gate ---
|
||||||
|
# Both _check_schedule and _check_dim_schedule re-evaluated at most once per
|
||||||
|
# clock minute. Storing the (hour, minute) tuple that was last evaluated lets
|
||||||
|
# the methods skip all timezone / strptime work within the same minute.
|
||||||
|
# Reset to None on config change so the next call re-evaluates immediately.
|
||||||
|
self._tz = None # pytz timezone, lazily built from config
|
||||||
|
self._schedule_checked_minute: Optional[tuple] = None
|
||||||
|
self._dim_checked_minute: Optional[tuple] = None
|
||||||
|
self._cached_target_brightness: int = self._normal_brightness
|
||||||
|
|
||||||
|
# Register controller-level hot-reload callback so cached config values
|
||||||
|
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
|
||||||
|
# when the user saves settings via the web UI.
|
||||||
|
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
||||||
|
self._refresh_config_cache(new_config)
|
||||||
|
# If a plugin was enabled/disabled, flag a reconcile for the main
|
||||||
|
# loop to apply (loading/unloading off the watcher thread is unsafe).
|
||||||
|
if self._enabled_set_changed(old_config, new_config):
|
||||||
|
self._pending_plugin_reconcile = True
|
||||||
|
|
||||||
|
self.config_service.subscribe(_controller_config_change)
|
||||||
|
|
||||||
# Publish initial on-demand state
|
# Publish initial on-demand state
|
||||||
try:
|
try:
|
||||||
self._publish_on_demand_state()
|
self._publish_on_demand_state()
|
||||||
@@ -533,17 +602,24 @@ class DisplayController:
|
|||||||
logger.debug("Schedule is disabled - display always active")
|
logger.debug("Schedule is disabled - display always active")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get configured timezone, default to UTC
|
# Lazily build the timezone object once; reuse on every subsequent call.
|
||||||
timezone_str = self.config.get('timezone', 'UTC')
|
if self._tz is None:
|
||||||
try:
|
timezone_str = self.config.get('timezone', 'UTC')
|
||||||
tz = pytz.timezone(timezone_str)
|
try:
|
||||||
except pytz.UnknownTimeZoneError:
|
self._tz = pytz.timezone(timezone_str)
|
||||||
logger.warning(f"Unknown timezone '{timezone_str}', using UTC")
|
except pytz.UnknownTimeZoneError:
|
||||||
tz = pytz.UTC
|
logger.warning("Unknown timezone '%s', using UTC", timezone_str)
|
||||||
|
self._tz = pytz.UTC
|
||||||
|
|
||||||
# Use timezone-aware current time
|
current_time = datetime.now(self._tz)
|
||||||
current_time = datetime.now(tz)
|
# Gate: schedule state can only change on a minute boundary, so skip
|
||||||
current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
|
# all the strptime / comparison work if we already evaluated this minute.
|
||||||
|
current_minute_key = (current_time.hour, current_time.minute)
|
||||||
|
if current_minute_key == self._schedule_checked_minute:
|
||||||
|
return
|
||||||
|
self._schedule_checked_minute = current_minute_key
|
||||||
|
|
||||||
|
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
|
||||||
current_time_only = current_time.time()
|
current_time_only = current_time.time()
|
||||||
|
|
||||||
# Check if per-day schedule is configured
|
# Check if per-day schedule is configured
|
||||||
@@ -632,8 +708,8 @@ class DisplayController:
|
|||||||
Target brightness level (dim_brightness if in dim period,
|
Target brightness level (dim_brightness if in dim period,
|
||||||
normal brightness otherwise)
|
normal brightness otherwise)
|
||||||
"""
|
"""
|
||||||
# Get normal brightness from config
|
# Opt #2: use cached brightness rather than re-traversing config dict
|
||||||
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
normal_brightness = self._normal_brightness
|
||||||
|
|
||||||
# If display is OFF via schedule, don't process dim schedule
|
# If display is OFF via schedule, don't process dim schedule
|
||||||
if not self.is_display_active:
|
if not self.is_display_active:
|
||||||
@@ -647,15 +723,21 @@ class DisplayController:
|
|||||||
self.is_dimmed = False
|
self.is_dimmed = False
|
||||||
return normal_brightness
|
return normal_brightness
|
||||||
|
|
||||||
# Get configured timezone
|
# Opt #3: lazily build timezone; gate full re-parse to once per clock minute
|
||||||
timezone_str = self.config.get('timezone', 'UTC')
|
if self._tz is None:
|
||||||
try:
|
timezone_str = self.config.get('timezone', 'UTC')
|
||||||
tz = pytz.timezone(timezone_str)
|
try:
|
||||||
except pytz.UnknownTimeZoneError:
|
self._tz = pytz.timezone(timezone_str)
|
||||||
logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC")
|
except pytz.UnknownTimeZoneError:
|
||||||
tz = pytz.UTC
|
logger.warning("Unknown timezone '%s' in dim schedule, using UTC", timezone_str)
|
||||||
|
self._tz = pytz.UTC
|
||||||
|
|
||||||
|
current_time = datetime.now(self._tz)
|
||||||
|
current_minute_key = (current_time.hour, current_time.minute)
|
||||||
|
if current_minute_key == self._dim_checked_minute:
|
||||||
|
return self._cached_target_brightness
|
||||||
|
self._dim_checked_minute = current_minute_key
|
||||||
|
|
||||||
current_time = datetime.now(tz)
|
|
||||||
current_day = current_time.strftime('%A').lower()
|
current_day = current_time.strftime('%A').lower()
|
||||||
current_time_only = current_time.time()
|
current_time_only = current_time.time()
|
||||||
|
|
||||||
@@ -703,10 +785,12 @@ class DisplayController:
|
|||||||
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
|
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
|
||||||
|
|
||||||
self._was_dimmed = self.is_dimmed
|
self._was_dimmed = self.is_dimmed
|
||||||
|
self._cached_target_brightness = target_brightness # persist for minute-gate
|
||||||
return target_brightness
|
return target_brightness
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Invalid dim schedule time format: {e}")
|
logger.warning("Invalid dim schedule time format: %s", e)
|
||||||
|
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||||
return normal_brightness
|
return normal_brightness
|
||||||
|
|
||||||
def _update_modules(self):
|
def _update_modules(self):
|
||||||
@@ -1382,38 +1466,107 @@ class DisplayController:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error logging memory stats: {e}")
|
logger.debug(f"Error logging memory stats: {e}")
|
||||||
|
|
||||||
def _check_live_priority(self):
|
def _apply_live_priority(self, live_priority_mode):
|
||||||
|
"""Switch to a live-priority mode, or resume rotation when it ends.
|
||||||
|
|
||||||
|
When a live-priority plugin preempts the rotation, the position the
|
||||||
|
rotation had reached is saved so that, once live priority ends, the
|
||||||
|
rotation resumes from there instead of continuing after the live
|
||||||
|
plugin's mode (which would skip every mode between the two). The save
|
||||||
|
happens only on the initial switch, not on each re-check while the
|
||||||
|
live hold continues.
|
||||||
"""
|
"""
|
||||||
Check all plugins for live priority content.
|
if live_priority_mode:
|
||||||
Returns the mode that should be displayed if live content is found, None otherwise.
|
if self.current_display_mode != live_priority_mode:
|
||||||
"""
|
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
|
||||||
for mode_name, plugin_instance in self.plugin_modes.items():
|
if self._live_resume_index is None:
|
||||||
if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'):
|
self._live_resume_index = self.current_mode_index
|
||||||
|
self.current_display_mode = live_priority_mode
|
||||||
|
self.force_change = True
|
||||||
|
# Update mode index to match the new mode
|
||||||
try:
|
try:
|
||||||
if plugin_instance.has_live_priority() and plugin_instance.has_live_content():
|
self.current_mode_index = self.available_modes.index(live_priority_mode)
|
||||||
# Get the specific live mode from the plugin if available
|
except ValueError:
|
||||||
if hasattr(plugin_instance, 'get_live_modes'):
|
pass
|
||||||
live_modes = plugin_instance.get_live_modes()
|
elif self._live_resume_index is not None and self.available_modes:
|
||||||
if live_modes and len(live_modes) > 0:
|
# Live priority ended — resume rotation where it was interrupted.
|
||||||
# Verify the mode actually exists before returning it
|
self.current_mode_index = self._live_resume_index % len(self.available_modes)
|
||||||
for suggested_mode in live_modes:
|
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||||
if suggested_mode in self.plugin_modes:
|
self.force_change = True
|
||||||
return suggested_mode
|
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||||
# If suggested modes don't exist, fall through to check current mode
|
self._live_resume_index = None
|
||||||
# Fallback: if this mode ends with _live, return it
|
|
||||||
if mode_name.endswith('_live'):
|
def _collect_live_modes(self):
|
||||||
return mode_name
|
"""Return every currently live-priority mode, in registration order.
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
Scans all registered plugin modes; for each plugin that has live
|
||||||
return None
|
priority *and* live content, collects the specific live mode(s) it
|
||||||
|
reports via get_live_modes() (only those actually registered), falling
|
||||||
|
back to the scanned mode name when it ends in '_live'. Deduplicated,
|
||||||
|
preserving order. A plugin registered under several mode keys (the
|
||||||
|
sports plugins register one per league) contributes each live mode once.
|
||||||
|
"""
|
||||||
|
live = []
|
||||||
|
seen = set()
|
||||||
|
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||||
|
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||||
|
and hasattr(plugin_instance, 'has_live_content')):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if not (plugin_instance.has_live_priority()
|
||||||
|
and plugin_instance.has_live_content()):
|
||||||
|
continue
|
||||||
|
resolved = []
|
||||||
|
if hasattr(plugin_instance, 'get_live_modes'):
|
||||||
|
for suggested_mode in (plugin_instance.get_live_modes() or []):
|
||||||
|
if suggested_mode in self.plugin_modes:
|
||||||
|
resolved.append(suggested_mode)
|
||||||
|
if not resolved and mode_name.endswith('_live'):
|
||||||
|
resolved.append(mode_name)
|
||||||
|
for m in resolved:
|
||||||
|
if m not in seen:
|
||||||
|
seen.add(m)
|
||||||
|
live.append(m)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||||
|
return live
|
||||||
|
|
||||||
|
def _check_live_priority(self, advance=False):
|
||||||
|
"""Return the live-priority mode to display, or None if nothing is live.
|
||||||
|
|
||||||
|
When several plugins report live content at once (e.g. a baseball game
|
||||||
|
and a soccer match), this round-robins between them so the display
|
||||||
|
alternates each dwell instead of pinning to whichever plugin is first in
|
||||||
|
registration order.
|
||||||
|
|
||||||
|
advance=False (default): a non-advancing peek — returns the live mode
|
||||||
|
already on screen if it is still live, otherwise the first live mode.
|
||||||
|
Used by the Vegas coordinator and the vegas-active check, which only
|
||||||
|
need to know whether *any* game is live (and must not spin the cursor).
|
||||||
|
|
||||||
|
advance=True: the rotation pick — returns the live mode *after* the one
|
||||||
|
currently shown, so each dwell advances to the next live game. The
|
||||||
|
currently-displayed mode is the cursor, so this stays correct as games
|
||||||
|
start and end (no separate index to keep in sync).
|
||||||
|
"""
|
||||||
|
live_modes = self._collect_live_modes()
|
||||||
|
if not live_modes:
|
||||||
|
return None
|
||||||
|
if self.current_display_mode in live_modes:
|
||||||
|
if advance:
|
||||||
|
idx = live_modes.index(self.current_display_mode)
|
||||||
|
return live_modes[(idx + 1) % len(live_modes)]
|
||||||
|
return self.current_display_mode
|
||||||
|
return live_modes[0]
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Run the display controller, switching between displays."""
|
"""Run the display controller, switching between displays."""
|
||||||
if not self.available_modes:
|
if not self.available_modes:
|
||||||
logger.warning("No display modes are enabled. Exiting.")
|
logger.warning(
|
||||||
self.display_manager.cleanup()
|
"No display modes are enabled at startup; idling until a "
|
||||||
return
|
"plugin is enabled via the web UI."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Initialize with cached data for fast startup - let background updates refresh naturally
|
# Initialize with cached data for fast startup - let background updates refresh naturally
|
||||||
logger.info("Starting display with cached data (fast startup mode)")
|
logger.info("Starting display with cached data (fast startup mode)")
|
||||||
@@ -1421,6 +1574,25 @@ class DisplayController:
|
|||||||
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
# Apply plugin enable/disable edits saved via the web UI. The
|
||||||
|
# config-watcher thread only sets the flag; loading/unloading and
|
||||||
|
# rebuilding available_modes happens here on the render thread so
|
||||||
|
# it can't race with rendering. Deferred while on-demand is active
|
||||||
|
# (the flag stays set) so we don't fight its temporary-enable.
|
||||||
|
if self._pending_plugin_reconcile and not self.on_demand_active:
|
||||||
|
# Only clear the flag on success -- a retryable failure
|
||||||
|
# (e.g. discovery) leaves it set so the request isn't lost.
|
||||||
|
if self._reconcile_enabled_plugins():
|
||||||
|
self._pending_plugin_reconcile = False
|
||||||
|
|
||||||
|
if not self.available_modes:
|
||||||
|
# Nothing to render yet. Re-check _pending_plugin_reconcile
|
||||||
|
# every ~1s (rather than a long sleep) so enabling a plugin
|
||||||
|
# via the web UI is picked up about as promptly as it would
|
||||||
|
# be once modes exist and the loop is iterating per-frame.
|
||||||
|
self._sleep_with_plugin_updates(1)
|
||||||
|
continue
|
||||||
|
|
||||||
# Handle on-demand commands before rendering
|
# Handle on-demand commands before rendering
|
||||||
self._poll_on_demand_requests()
|
self._poll_on_demand_requests()
|
||||||
self._check_on_demand_expiration()
|
self._check_on_demand_expiration()
|
||||||
@@ -1483,12 +1655,8 @@ class DisplayController:
|
|||||||
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
||||||
width = self.display_manager.width
|
width = self.display_manager.width
|
||||||
|
|
||||||
# Advance local position at Vegas scroll speed (px/s → px/tick)
|
# Opt #2: use pre-cached scroll speed (constant for the run)
|
||||||
vegas_speed = (
|
vegas_speed = self._scroll_speed
|
||||||
self.config.get('display', {})
|
|
||||||
.get('vegas_scroll', {})
|
|
||||||
.get('scroll_speed', 75)
|
|
||||||
)
|
|
||||||
local_x = getattr(self, '_follower_local_x', None)
|
local_x = getattr(self, '_follower_local_x', None)
|
||||||
if local_x is None:
|
if local_x is None:
|
||||||
local_x = float(width) # safe start (past pre-roll guard)
|
local_x = float(width) # safe start (past pre-roll guard)
|
||||||
@@ -1570,18 +1738,12 @@ class DisplayController:
|
|||||||
# Display failed, clear the status and continue normally
|
# Display failed, clear the status and continue normally
|
||||||
wifi_status_data = None
|
wifi_status_data = None
|
||||||
|
|
||||||
# Check for live priority content and switch to it immediately
|
# Check for live priority content and switch to it immediately.
|
||||||
|
# advance=True so multiple simultaneously-live games take turns
|
||||||
|
# (round-robin) instead of pinning to the first plugin.
|
||||||
if not self.on_demand_active and not wifi_status_data:
|
if not self.on_demand_active and not wifi_status_data:
|
||||||
live_priority_mode = self._check_live_priority()
|
live_priority_mode = self._check_live_priority(advance=True)
|
||||||
if live_priority_mode and self.current_display_mode != live_priority_mode:
|
self._apply_live_priority(live_priority_mode)
|
||||||
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
|
|
||||||
self.current_display_mode = live_priority_mode
|
|
||||||
self.force_change = True
|
|
||||||
# Update mode index to match the new mode
|
|
||||||
try:
|
|
||||||
self.current_mode_index = self.available_modes.index(live_priority_mode)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Vegas scroll mode - continuous ticker across all plugins
|
# Vegas scroll mode - continuous ticker across all plugins
|
||||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||||
@@ -1628,7 +1790,8 @@ class DisplayController:
|
|||||||
|
|
||||||
manager_to_display = None
|
manager_to_display = None
|
||||||
|
|
||||||
logger.info(f"Processing mode: {active_mode}, available_modes: {len(self.available_modes)}, plugin_modes: {list(self.plugin_modes.keys())}")
|
logger.info("Processing mode: %s (%d available)", active_mode, len(self.available_modes))
|
||||||
|
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
|
||||||
|
|
||||||
# Handle plugin-based display modes
|
# Handle plugin-based display modes
|
||||||
if active_mode in self.plugin_modes:
|
if active_mode in self.plugin_modes:
|
||||||
@@ -1664,17 +1827,22 @@ class DisplayController:
|
|||||||
try:
|
try:
|
||||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||||
if hasattr(manager_to_display, 'display'):
|
if hasattr(manager_to_display, 'display'):
|
||||||
# Check if plugin accepts display_mode parameter
|
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
||||||
import inspect
|
_cache_key = plugin_id
|
||||||
sig = inspect.signature(manager_to_display.display)
|
if _cache_key not in self._plugin_accepts_display_mode:
|
||||||
|
import inspect as _inspect
|
||||||
|
self._plugin_accepts_display_mode[_cache_key] = (
|
||||||
|
'display_mode' in _inspect.signature(manager_to_display.display).parameters
|
||||||
|
)
|
||||||
|
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
|
||||||
|
|
||||||
# Use PluginExecutor for safe execution with timeout
|
# Use PluginExecutor for safe execution with timeout
|
||||||
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
||||||
result = self.plugin_manager.plugin_executor.execute_display(
|
result = self.plugin_manager.plugin_executor.execute_display(
|
||||||
manager_to_display,
|
manager_to_display,
|
||||||
plugin_id,
|
plugin_id,
|
||||||
force_clear=self.force_change,
|
force_clear=self.force_change,
|
||||||
display_mode=active_mode if 'display_mode' in sig.parameters else None
|
display_mode=active_mode if _accepts_display_mode else None
|
||||||
)
|
)
|
||||||
# execute_display returns bool, convert to expected format
|
# execute_display returns bool, convert to expected format
|
||||||
if result:
|
if result:
|
||||||
@@ -1683,7 +1851,7 @@ class DisplayController:
|
|||||||
result = False # Failed
|
result = False # Failed
|
||||||
else:
|
else:
|
||||||
# Fallback to direct call if executor not available
|
# Fallback to direct call if executor not available
|
||||||
if 'display_mode' in sig.parameters:
|
if _accepts_display_mode:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
||||||
else:
|
else:
|
||||||
result = manager_to_display.display(force_clear=self.force_change)
|
result = manager_to_display.display(force_clear=self.force_change)
|
||||||
@@ -1820,9 +1988,9 @@ class DisplayController:
|
|||||||
min_duration = base_duration
|
min_duration = base_duration
|
||||||
if dynamic_enabled:
|
if dynamic_enabled:
|
||||||
# Try to get plugin-calculated cycle duration first
|
# Try to get plugin-calculated cycle duration first
|
||||||
logger.info("Attempting to get cycle duration for mode %s", active_mode)
|
logger.debug("Attempting to get cycle duration for mode %s", active_mode)
|
||||||
plugin_cycle_duration = self._plugin_cycle_duration(manager_to_display, active_mode)
|
plugin_cycle_duration = self._plugin_cycle_duration(manager_to_display, active_mode)
|
||||||
logger.info("Got cycle duration: %s", plugin_cycle_duration)
|
logger.debug("Got cycle duration: %s", plugin_cycle_duration)
|
||||||
|
|
||||||
# Get caps for validation
|
# Get caps for validation
|
||||||
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
|
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
|
||||||
@@ -1904,10 +2072,21 @@ class DisplayController:
|
|||||||
|
|
||||||
# For plugins, call display multiple times to allow game rotation
|
# For plugins, call display multiple times to allow game rotation
|
||||||
if manager_to_display and hasattr(manager_to_display, 'display'):
|
if manager_to_display and hasattr(manager_to_display, 'display'):
|
||||||
# Check if plugin needs high FPS (like stock ticker)
|
# High-FPS decision, in precedence order:
|
||||||
# Always enable high-FPS for static-image plugin (for GIF animation support)
|
# 1. A plugin that declares needs_high_fps knows best
|
||||||
|
# (e.g. static-image sets it False for still PNGs,
|
||||||
|
# True for animated GIFs).
|
||||||
|
# 2. Back-compat: older static-image versions without
|
||||||
|
# the attribute keep the historical forced high-FPS
|
||||||
|
# (GIF support).
|
||||||
|
# 3. Otherwise scrolling plugins get high FPS.
|
||||||
plugin_id = getattr(manager_to_display, 'plugin_id', None)
|
plugin_id = getattr(manager_to_display, 'plugin_id', None)
|
||||||
if plugin_id == 'static-image':
|
declared = getattr(manager_to_display, 'needs_high_fps', None)
|
||||||
|
if declared is not None:
|
||||||
|
needs_high_fps = bool(declared)
|
||||||
|
logger.debug("FPS check for %s - plugin declares needs_high_fps=%s",
|
||||||
|
active_mode, needs_high_fps)
|
||||||
|
elif plugin_id == 'static-image':
|
||||||
needs_high_fps = True
|
needs_high_fps = True
|
||||||
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
|
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
|
||||||
else:
|
else:
|
||||||
@@ -1962,7 +2141,7 @@ class DisplayController:
|
|||||||
if needs_high_fps:
|
if needs_high_fps:
|
||||||
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
|
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
|
||||||
display_interval = 0.008
|
display_interval = 0.008
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
||||||
active_mode,
|
active_mode,
|
||||||
display_interval,
|
display_interval,
|
||||||
@@ -1972,7 +2151,7 @@ class DisplayController:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Pass display_mode to maintain sticky manager state
|
# Pass display_mode to maintain sticky manager state
|
||||||
if 'display_mode' in sig.parameters:
|
if _accepts_display_mode:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||||
else:
|
else:
|
||||||
result = manager_to_display.display(force_clear=False)
|
result = manager_to_display.display(force_clear=False)
|
||||||
@@ -2014,7 +2193,7 @@ class DisplayController:
|
|||||||
else:
|
else:
|
||||||
# Normal FPS for other plugins (1 second)
|
# Normal FPS for other plugins (1 second)
|
||||||
display_interval = 1.0
|
display_interval = 1.0
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Entering normal FPS loop for %s with display_interval=%.3fs",
|
"Entering normal FPS loop for %s with display_interval=%.3fs",
|
||||||
active_mode,
|
active_mode,
|
||||||
display_interval
|
display_interval
|
||||||
@@ -2036,7 +2215,7 @@ class DisplayController:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Pass display_mode to maintain sticky manager state
|
# Pass display_mode to maintain sticky manager state
|
||||||
if 'display_mode' in sig.parameters:
|
if _accepts_display_mode:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||||
else:
|
else:
|
||||||
result = manager_to_display.display(force_clear=False)
|
result = manager_to_display.display(force_clear=False)
|
||||||
@@ -2069,6 +2248,23 @@ class DisplayController:
|
|||||||
loop_completed = True
|
loop_completed = True
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# LOAD-BEARING: if current_display_mode changed mid-loop (on-demand
|
||||||
|
# activation, live priority, etc.), restart the main loop now instead
|
||||||
|
# of falling into the "honour minimum duration" sleep below. That sleep
|
||||||
|
# can run for up to the *previous* mode's full display_duration (default
|
||||||
|
# 30s) and doesn't poll on-demand requests or re-check the mode, so a
|
||||||
|
# freshly-requested mode switch would sit invisible for up to 30s — or
|
||||||
|
# get clobbered by a queued stop request — before ever rendering.
|
||||||
|
#
|
||||||
|
# This guard was added in #298 (live priority interrupting long display
|
||||||
|
# durations) and was accidentally dropped in #330 as collateral damage of
|
||||||
|
# an unrelated time.monotonic() -> time.time() cleanup in the same hunk.
|
||||||
|
# Removing it again will silently reintroduce both issues. _activate_on_demand
|
||||||
|
# already sets force_change=True and clears the display, so the next loop
|
||||||
|
# iteration renders the new mode immediately.
|
||||||
|
if self.current_display_mode != active_mode:
|
||||||
|
continue
|
||||||
|
|
||||||
# Ensure we honour minimum duration when not dynamic and loop ended early
|
# Ensure we honour minimum duration when not dynamic and loop ended early
|
||||||
if (
|
if (
|
||||||
not dynamic_enabled
|
not dynamic_enabled
|
||||||
@@ -2145,7 +2341,7 @@ class DisplayController:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error checking live priority for %s: %s", active_mode, e)
|
logger.warning("Error checking live priority for %s: %s", active_mode, e)
|
||||||
|
|
||||||
if should_rotate:
|
if should_rotate and self.available_modes:
|
||||||
self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes)
|
self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes)
|
||||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||||
self.last_mode_change = time.time()
|
self.last_mode_change = time.time()
|
||||||
@@ -2333,6 +2529,200 @@ class DisplayController:
|
|||||||
self.wifi_status_active = False
|
self.wifi_status_active = False
|
||||||
self.wifi_status_expires_at = None
|
self.wifi_status_expires_at = None
|
||||||
|
|
||||||
|
def _register_loaded_plugin(self, plugin_id: str) -> List[str]:
|
||||||
|
"""Register an already-loaded plugin's display modes, config-change
|
||||||
|
subscription and dispatch maps with the controller.
|
||||||
|
|
||||||
|
Shared by startup loading and live enable hot-reload so both paths
|
||||||
|
build identical controller state. Returns the registered modes.
|
||||||
|
"""
|
||||||
|
plugin_instance = self.plugin_manager.get_plugin(plugin_id)
|
||||||
|
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {})
|
||||||
|
|
||||||
|
# Prefer the plugin's dynamic modes attribute (e.g. based on enabled
|
||||||
|
# leagues), else fall back to manifest display_modes, else the id.
|
||||||
|
if plugin_instance is not None and getattr(plugin_instance, 'modes', None):
|
||||||
|
display_modes = list(plugin_instance.modes)
|
||||||
|
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
|
||||||
|
else:
|
||||||
|
display_modes = manifest.get('display_modes', [plugin_id])
|
||||||
|
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
|
||||||
|
if not (isinstance(display_modes, list) and display_modes):
|
||||||
|
display_modes = [plugin_id]
|
||||||
|
self.plugin_display_modes[plugin_id] = list(display_modes)
|
||||||
|
|
||||||
|
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
|
||||||
|
# and instance as defaults so each plugin's callback targets its own
|
||||||
|
# instance (avoids late-binding when registering many plugins), and
|
||||||
|
# remember the callback so we can unsubscribe on disable.
|
||||||
|
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
|
||||||
|
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any],
|
||||||
|
_pid: str = plugin_id, _plugin: Any = plugin_instance) -> None:
|
||||||
|
"""Callback for plugin config changes."""
|
||||||
|
try:
|
||||||
|
_plugin.on_config_change(new_config)
|
||||||
|
logger.debug("Plugin %s notified of config change", _pid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error in plugin %s config change handler: %s", _pid, e, exc_info=True)
|
||||||
|
|
||||||
|
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
|
||||||
|
self._plugin_config_callbacks[plugin_id] = config_change_callback
|
||||||
|
logger.debug("Subscribed plugin %s to config changes", plugin_id)
|
||||||
|
|
||||||
|
# Add modes to the dispatch maps.
|
||||||
|
for mode in display_modes:
|
||||||
|
if mode not in self.available_modes:
|
||||||
|
self.available_modes.append(mode)
|
||||||
|
self.plugin_modes[mode] = plugin_instance
|
||||||
|
self.mode_to_plugin_id[mode] = plugin_id
|
||||||
|
logger.debug(" Added mode: %s", mode)
|
||||||
|
# Invalidate signature cache so the new instance is re-inspected.
|
||||||
|
self._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||||
|
return display_modes
|
||||||
|
|
||||||
|
def _unregister_plugin(self, plugin_id: str) -> None:
|
||||||
|
"""Remove a plugin's modes, config subscription and instance, then
|
||||||
|
unload it. Used by live disable hot-reload."""
|
||||||
|
modes = self.plugin_display_modes.pop(plugin_id, [])
|
||||||
|
for mode in modes:
|
||||||
|
if mode in self.available_modes:
|
||||||
|
self.available_modes.remove(mode)
|
||||||
|
self.plugin_modes.pop(mode, None)
|
||||||
|
self.mode_to_plugin_id.pop(mode, None)
|
||||||
|
|
||||||
|
# Unsubscribe the plugin's config-change callback. Pop only on a
|
||||||
|
# successful unsubscribe -- if it raises, keep our reference so a
|
||||||
|
# later retry (or at least cleanup) still has the real callback
|
||||||
|
# instead of a lost one.
|
||||||
|
callback = self._plugin_config_callbacks.get(plugin_id)
|
||||||
|
if callback is not None and hasattr(self, 'config_service'):
|
||||||
|
try:
|
||||||
|
self.config_service.unsubscribe(callback, plugin_id=plugin_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Error unsubscribing plugin %s from config changes: %s", plugin_id, e)
|
||||||
|
else:
|
||||||
|
self._plugin_config_callbacks.pop(plugin_id, None)
|
||||||
|
else:
|
||||||
|
self._plugin_config_callbacks.pop(plugin_id, None)
|
||||||
|
|
||||||
|
self._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||||
|
|
||||||
|
# Tear down the instance (cleanup + on_disable + module unload).
|
||||||
|
try:
|
||||||
|
self.plugin_manager.unload_plugin(plugin_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
|
||||||
|
|
||||||
|
logger.info("Disabled plugin %s live (removed modes: %s)", plugin_id, modes)
|
||||||
|
|
||||||
|
def _enabled_set_changed(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> bool:
|
||||||
|
"""True if any top-level section's ``enabled`` flag differs between two
|
||||||
|
configs. A cheap watcher-thread check that gates the full reconcile.
|
||||||
|
Non-plugin sections (e.g. schedule) may match too; the reconcile
|
||||||
|
no-ops for anything that isn't a discovered plugin."""
|
||||||
|
def enabled_map(cfg: Dict[str, Any]) -> Dict[str, bool]:
|
||||||
|
return {
|
||||||
|
key: bool(value.get('enabled', False))
|
||||||
|
for key, value in cfg.items()
|
||||||
|
if isinstance(value, dict)
|
||||||
|
}
|
||||||
|
return enabled_map(old_config) != enabled_map(new_config)
|
||||||
|
|
||||||
|
def _reconcile_enabled_plugins(self) -> bool:
|
||||||
|
"""Load/unload plugins so the running set matches the enabled set in
|
||||||
|
config. Runs on the main display thread (never the config-watcher
|
||||||
|
thread) so mutating available_modes is race-free against rendering.
|
||||||
|
|
||||||
|
Returns True if reconciliation completed (including a no-op), or
|
||||||
|
False on a retryable failure -- the caller keeps the pending-reconcile
|
||||||
|
flag set in that case so the request isn't silently dropped."""
|
||||||
|
if self.plugin_manager is None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
config = self.config_service.get_config()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Plugin reconcile: falling back to cached config: %s", e)
|
||||||
|
config = self.config
|
||||||
|
try:
|
||||||
|
discovered = set(self.plugin_manager.discover_plugins())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Plugin reconcile: discovery failed: %s", e, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
for p in discovered:
|
||||||
|
if p in config and not isinstance(config.get(p), dict):
|
||||||
|
logger.warning(
|
||||||
|
"Plugin reconcile: config for %s is a %s, not a dict; treating as disabled",
|
||||||
|
p, type(config.get(p)).__name__
|
||||||
|
)
|
||||||
|
|
||||||
|
desired = {
|
||||||
|
p for p in discovered
|
||||||
|
if isinstance(config.get(p), dict) and config.get(p, {}).get('enabled', False)
|
||||||
|
}
|
||||||
|
current = set(self.plugin_display_modes.keys())
|
||||||
|
to_add = desired - current
|
||||||
|
to_remove = current - desired
|
||||||
|
if not to_add and not to_remove:
|
||||||
|
return True
|
||||||
|
|
||||||
|
previous_mode = self.current_display_mode
|
||||||
|
|
||||||
|
for plugin_id in to_remove:
|
||||||
|
self._unregister_plugin(plugin_id)
|
||||||
|
|
||||||
|
for plugin_id in to_add:
|
||||||
|
try:
|
||||||
|
if self.plugin_manager.load_plugin(plugin_id):
|
||||||
|
modes = self._register_loaded_plugin(plugin_id)
|
||||||
|
logger.info("Enabled plugin %s live (modes: %s)", plugin_id, modes)
|
||||||
|
else:
|
||||||
|
logger.warning("Plugin reconcile: failed to load %s", plugin_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
|
||||||
|
|
||||||
|
self._resync_mode_index_after_change(previous_mode)
|
||||||
|
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
|
||||||
|
sorted(to_add), sorted(to_remove), len(self.available_modes))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
|
||||||
|
"""Clamp rotation state after available_modes changed. Stays on the
|
||||||
|
previous mode if it survived, otherwise restarts cleanly within range."""
|
||||||
|
if not self.available_modes:
|
||||||
|
self.current_mode_index = 0
|
||||||
|
self.current_display_mode = None
|
||||||
|
return
|
||||||
|
if previous_mode in self.available_modes:
|
||||||
|
self.current_mode_index = self.available_modes.index(previous_mode)
|
||||||
|
else:
|
||||||
|
self.current_mode_index %= len(self.available_modes)
|
||||||
|
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||||
|
|
||||||
|
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
|
||||||
|
"""Refresh all config-derived caches when a hot-reload fires.
|
||||||
|
|
||||||
|
Called by the controller-level ConfigService subscriber. Keeps
|
||||||
|
``_normal_brightness``, ``_scroll_speed``, the cached timezone, and the
|
||||||
|
schedule minute-gates consistent with the live config so callers never
|
||||||
|
read stale values after the user saves settings via the web UI.
|
||||||
|
"""
|
||||||
|
self.config = new_config
|
||||||
|
self._normal_brightness = (
|
||||||
|
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||||
|
)
|
||||||
|
self._scroll_speed = (
|
||||||
|
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||||
|
)
|
||||||
|
# Force the timezone to be re-derived from the new config on next schedule check
|
||||||
|
self._tz = None
|
||||||
|
# Invalidate minute-gates so the new schedule/dim times take effect immediately
|
||||||
|
self._schedule_checked_minute = None
|
||||||
|
self._dim_checked_minute = None
|
||||||
|
self._cached_target_brightness = self._normal_brightness
|
||||||
|
logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)",
|
||||||
|
self._normal_brightness, self._scroll_speed)
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
# Shutdown config service if it exists
|
# Shutdown config service if it exists
|
||||||
@@ -2347,6 +2737,7 @@ class DisplayController:
|
|||||||
logger.info("Cleanup complete.")
|
logger.info("Cleanup complete.")
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
"""Application entry point — create a DisplayController and run until interrupted."""
|
||||||
controller = DisplayController()
|
controller = DisplayController()
|
||||||
controller.run()
|
controller.run()
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Display Manager — hardware abstraction layer for the RGB LED matrix.
|
||||||
|
|
||||||
|
This module provides :class:`DisplayManager`, the single interface between
|
||||||
|
application code and the physical (or emulated) LED panel.
|
||||||
|
|
||||||
|
Key responsibilities
|
||||||
|
--------------------
|
||||||
|
* Initialise the ``RGBMatrix`` (hardware) or ``RGBMatrixEmulator`` depending
|
||||||
|
on the ``EMULATOR`` environment variable.
|
||||||
|
* Expose a PIL ``Image``/``ImageDraw`` canvas that plugins draw into, then
|
||||||
|
flush it to the matrix via double-buffering (:meth:`DisplayManager.update_display`).
|
||||||
|
* Load and cache TTF/BDF fonts; expose ``draw_text`` for consistent text rendering.
|
||||||
|
* Provide ``width`` / ``height`` properties — always use these instead of
|
||||||
|
hard-coding display dimensions.
|
||||||
|
* Write periodic PNG snapshots to ``/tmp/led_matrix_preview.png`` for the
|
||||||
|
web-interface live preview.
|
||||||
|
* Track scrolling state and gate deferred updates so plugins don't race with
|
||||||
|
an in-progress scroll.
|
||||||
|
|
||||||
|
Singleton: only one ``DisplayManager`` instance exists per process. The
|
||||||
|
first call to ``DisplayManager(config)`` creates it; subsequent calls return
|
||||||
|
the same object.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -8,16 +33,135 @@ else:
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List, Optional
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import zlib
|
||||||
import freetype
|
import freetype
|
||||||
|
|
||||||
# Get logger without configuring
|
# Get logger without configuring
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.INFO) # Set to INFO level
|
logger.setLevel(logging.INFO) # Set to INFO level
|
||||||
|
|
||||||
|
|
||||||
|
class _LogicalMatrix:
|
||||||
|
"""Proxy that reports a logical (per-screen) size for a physical matrix.
|
||||||
|
|
||||||
|
In double-sided mode the physical panel chain shows N identical copies of a
|
||||||
|
smaller logical screen. Plugins size themselves from ``matrix.width`` /
|
||||||
|
``matrix.height`` (the documented convention, used at 30+ call sites), so
|
||||||
|
this proxy reports the logical dimensions while delegating every real
|
||||||
|
operation — ``CreateFrameCanvas``, ``SwapOnVSync``, ``brightness``,
|
||||||
|
``Clear`` and so on — to the underlying physical matrix. The duplication
|
||||||
|
itself happens once per frame in :meth:`DisplayManager.update_display`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_logical_height", "_logical_width", "_matrix")
|
||||||
|
|
||||||
|
def __init__(self, matrix: RGBMatrix, logical_width: int, logical_height: int) -> None:
|
||||||
|
object.__setattr__(self, "_matrix", matrix)
|
||||||
|
object.__setattr__(self, "_logical_width", logical_width)
|
||||||
|
object.__setattr__(self, "_logical_height", logical_height)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> int:
|
||||||
|
"""Logical (per-screen) width reported to plugins."""
|
||||||
|
return self._logical_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> int:
|
||||||
|
"""Logical (per-screen) height reported to plugins."""
|
||||||
|
return self._logical_height
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
"""Forward any non-overridden attribute access to the physical matrix.
|
||||||
|
|
||||||
|
Reached only when normal lookup fails (i.e. not width/height/_*).
|
||||||
|
"""
|
||||||
|
return getattr(object.__getattribute__(self, "_matrix"), name)
|
||||||
|
|
||||||
|
def __setattr__(self, name: str, value: Any) -> None:
|
||||||
|
"""Forward attribute writes (e.g. ``matrix.brightness = 80``) to it."""
|
||||||
|
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_double_sided(physical_width: int, physical_height: int,
|
||||||
|
ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Validate the ``display.double_sided`` config against the physical size.
|
||||||
|
|
||||||
|
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
|
||||||
|
feature is enabled and the physical panel divides evenly into ``copies``
|
||||||
|
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
|
||||||
|
config is logged and disabled rather than raised — a misconfigured panel
|
||||||
|
should still light up.
|
||||||
|
"""
|
||||||
|
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
copies = ds_config.get('copies', 2)
|
||||||
|
if not isinstance(copies, int) or copies < 2:
|
||||||
|
logger.warning(
|
||||||
|
"double_sided: 'copies' must be an integer >= 2 (got %r); "
|
||||||
|
"disabling double-sided mode", copies)
|
||||||
|
return None
|
||||||
|
|
||||||
|
axis = ds_config.get('axis', 'horizontal')
|
||||||
|
if axis not in ('horizontal', 'vertical'):
|
||||||
|
logger.warning(
|
||||||
|
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
|
||||||
|
"(got %r); defaulting to 'horizontal'", axis)
|
||||||
|
axis = 'horizontal'
|
||||||
|
|
||||||
|
# Horizontal splits the chain (panels side by side); vertical splits the
|
||||||
|
# parallel outputs (panels stacked). The split axis must divide evenly.
|
||||||
|
if axis == 'horizontal':
|
||||||
|
if physical_width % copies != 0:
|
||||||
|
logger.warning(
|
||||||
|
"double_sided: physical width %d is not divisible by copies "
|
||||||
|
"%d; disabling double-sided mode", physical_width, copies)
|
||||||
|
return None
|
||||||
|
logical_width = physical_width // copies
|
||||||
|
logical_height = physical_height
|
||||||
|
else:
|
||||||
|
if physical_height % copies != 0:
|
||||||
|
logger.warning(
|
||||||
|
"double_sided: physical height %d is not divisible by copies "
|
||||||
|
"%d; disabling double-sided mode", physical_height, copies)
|
||||||
|
return None
|
||||||
|
logical_width = physical_width
|
||||||
|
logical_height = physical_height // copies
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
|
||||||
|
"tiled across physical %dx%d", copies, axis, logical_width,
|
||||||
|
logical_height, physical_width, physical_height)
|
||||||
|
return {
|
||||||
|
'copies': copies,
|
||||||
|
'axis': axis,
|
||||||
|
'logical_width': logical_width,
|
||||||
|
'logical_height': logical_height,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DisplayManager:
|
class DisplayManager:
|
||||||
|
"""
|
||||||
|
Singleton hardware abstraction layer for the RGB LED matrix.
|
||||||
|
|
||||||
|
Plugins should never interact with ``RGBMatrix`` directly; they use this
|
||||||
|
class to draw content and call :meth:`update_display` to push frames to
|
||||||
|
the panel.
|
||||||
|
|
||||||
|
Typical plugin usage::
|
||||||
|
|
||||||
|
canvas = Image.new('RGB', (self.display_manager.width,
|
||||||
|
self.display_manager.height), (0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(canvas)
|
||||||
|
# ... draw content ...
|
||||||
|
self.display_manager.image = canvas
|
||||||
|
self.display_manager.draw = ImageDraw.Draw(self.display_manager.image)
|
||||||
|
self.display_manager.update_display()
|
||||||
|
"""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
_initialized = False
|
_initialized = False
|
||||||
|
|
||||||
@@ -33,10 +177,24 @@ class DisplayManager:
|
|||||||
self._suppress_test_pattern = suppress_test_pattern
|
self._suppress_test_pattern = suppress_test_pattern
|
||||||
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||||||
self._capture_mode_active = False
|
self._capture_mode_active = False
|
||||||
|
# Double-sided mode state (resolved in _setup_matrix). When disabled,
|
||||||
|
# the logical image is blitted to the matrix unchanged.
|
||||||
|
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
||||||
|
self._physical_image = None # full-chain buffer reused each frame when tiling
|
||||||
|
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
||||||
|
# Avoids re-measuring the same string+font on every display() call.
|
||||||
|
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
||||||
|
self._text_width_cache: Dict[tuple, int] = {}
|
||||||
# Snapshot settings for web preview integration (service writes, web reads)
|
# Snapshot settings for web preview integration (service writes, web reads)
|
||||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||||
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
||||||
self._last_snapshot_ts = 0.0
|
self._last_snapshot_ts = 0.0
|
||||||
|
# Dirty tracking: (image digest, brightness) of the last frame pushed
|
||||||
|
# to the panel; update_display() skips identical pushes. Kill switch:
|
||||||
|
# display.dirty_tracking: false.
|
||||||
|
self._dirty_tracking_enabled = bool(
|
||||||
|
self.config.get('display', {}).get('dirty_tracking', True))
|
||||||
|
self._last_pushed_digest = None
|
||||||
|
|
||||||
# Scrolling state tracking for graceful updates
|
# Scrolling state tracking for graceful updates
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
@@ -121,13 +279,26 @@ class DisplayManager:
|
|||||||
# Initialize the matrix
|
# Initialize the matrix
|
||||||
self.matrix = RGBMatrix(options=options)
|
self.matrix = RGBMatrix(options=options)
|
||||||
logger.info("RGB Matrix initialized successfully")
|
logger.info("RGB Matrix initialized successfully")
|
||||||
|
|
||||||
# Create double buffer for smooth updates
|
# Create double buffer for smooth updates. The canvases are always
|
||||||
|
# full physical size — they back the real chain regardless of mode.
|
||||||
self.offscreen_canvas = self.matrix.CreateFrameCanvas()
|
self.offscreen_canvas = self.matrix.CreateFrameCanvas()
|
||||||
self.current_canvas = self.matrix.CreateFrameCanvas()
|
self.current_canvas = self.matrix.CreateFrameCanvas()
|
||||||
logger.info("Frame canvases created successfully")
|
logger.info("Frame canvases created successfully")
|
||||||
|
|
||||||
# Create image with full chain width
|
# Double-sided mode: wrap the physical matrix so plugins see the
|
||||||
|
# logical (per-screen) size, and keep a full-chain buffer to tile
|
||||||
|
# the rendered screen into once per frame.
|
||||||
|
ds_config = self.config.get('display', {}).get('double_sided', {})
|
||||||
|
ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
|
||||||
|
self._double_sided = ds
|
||||||
|
if ds is not None:
|
||||||
|
self._physical_image = Image.new(
|
||||||
|
'RGB', (self.matrix.width, self.matrix.height))
|
||||||
|
self.matrix = _LogicalMatrix(
|
||||||
|
self.matrix, ds['logical_width'], ds['logical_height'])
|
||||||
|
|
||||||
|
# Create image with the (logical) display dimensions
|
||||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||||||
@@ -154,8 +325,16 @@ class DisplayManager:
|
|||||||
rows = int(hardware_config.get('rows', 32))
|
rows = int(hardware_config.get('rows', 32))
|
||||||
cols = int(hardware_config.get('cols', 64))
|
cols = int(hardware_config.get('cols', 64))
|
||||||
chain_length = int(hardware_config.get('chain_length', 2))
|
chain_length = int(hardware_config.get('chain_length', 2))
|
||||||
|
parallel = int(hardware_config.get('parallel', 1))
|
||||||
fallback_width = max(1, cols * chain_length)
|
fallback_width = max(1, cols * chain_length)
|
||||||
fallback_height = max(1, rows)
|
fallback_height = max(1, rows * parallel)
|
||||||
|
# Mirror double-sided in fallback so the preview shows one screen.
|
||||||
|
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
|
||||||
|
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
|
||||||
|
self._double_sided = ds
|
||||||
|
if ds is not None:
|
||||||
|
fallback_width = ds['logical_width']
|
||||||
|
fallback_height = ds['logical_height']
|
||||||
except Exception:
|
except Exception:
|
||||||
fallback_width, fallback_height = 128, 32
|
fallback_width, fallback_height = 128, 32
|
||||||
|
|
||||||
@@ -246,6 +425,10 @@ class DisplayManager:
|
|||||||
try:
|
try:
|
||||||
# RGBMatrix accepts brightness as a property
|
# RGBMatrix accepts brightness as a property
|
||||||
self.matrix.brightness = brightness
|
self.matrix.brightness = brightness
|
||||||
|
# Brightness applies on the next swap — force a re-push even if
|
||||||
|
# the image itself is unchanged (belt-and-braces: brightness is
|
||||||
|
# also part of the dirty-tracking digest when readable).
|
||||||
|
self._last_pushed_digest = None
|
||||||
logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%")
|
logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%")
|
||||||
return True
|
return True
|
||||||
except AttributeError as e:
|
except AttributeError as e:
|
||||||
@@ -317,8 +500,40 @@ class DisplayManager:
|
|||||||
finally:
|
finally:
|
||||||
self._capture_mode_active = False
|
self._capture_mode_active = False
|
||||||
|
|
||||||
|
def _composite_double_sided(self):
|
||||||
|
"""Tile the logical screen across the full physical chain.
|
||||||
|
|
||||||
|
Renders once into ``self._physical_image`` by pasting the rendered
|
||||||
|
logical image ``copies`` times along the configured axis. The paste is
|
||||||
|
a single memcpy per copy, so the per-frame cost is negligible and the
|
||||||
|
plugin render path is untouched.
|
||||||
|
"""
|
||||||
|
ds = self._double_sided
|
||||||
|
phys = self._physical_image
|
||||||
|
lw = ds['logical_width']
|
||||||
|
lh = ds['logical_height']
|
||||||
|
for i in range(ds['copies']):
|
||||||
|
if ds['axis'] == 'vertical':
|
||||||
|
phys.paste(self.image, (0, i * lh))
|
||||||
|
else:
|
||||||
|
phys.paste(self.image, (i * lw, 0))
|
||||||
|
return phys
|
||||||
|
|
||||||
def update_display(self):
|
def update_display(self):
|
||||||
"""Update the display using double buffering with proper sync."""
|
"""Update the display using double buffering with proper sync.
|
||||||
|
|
||||||
|
Skips the panel push entirely when the frame is byte-identical to
|
||||||
|
the last pushed one (same image digest AND same brightness) — static
|
||||||
|
content re-rendered every second, and 125 fps loops between actual
|
||||||
|
scroll steps, otherwise re-walk the full framebuffer for nothing.
|
||||||
|
The panel keeps refreshing the current frame from its own thread,
|
||||||
|
so skipping a swap never blanks or freezes the hardware.
|
||||||
|
|
||||||
|
Correctness hinges on invalidation: clear() resets the digest (it
|
||||||
|
writes to the matrix directly), and brightness is PART of the digest
|
||||||
|
so a dim-schedule change is never skipped. Disable via config
|
||||||
|
``display.dirty_tracking: false`` if a redraw issue is ever suspected.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if self.matrix is None:
|
if self.matrix is None:
|
||||||
# Fallback mode - no actual hardware to update
|
# Fallback mode - no actual hardware to update
|
||||||
@@ -330,15 +545,34 @@ class DisplayManager:
|
|||||||
if self._capture_mode_active:
|
if self._capture_mode_active:
|
||||||
return # Skip hardware write — content is being captured off-screen
|
return # Skip hardware write — content is being captured off-screen
|
||||||
|
|
||||||
# Copy the current image to the offscreen canvas
|
digest = None
|
||||||
self.offscreen_canvas.SetImage(self.image)
|
if self._dirty_tracking_enabled:
|
||||||
|
try:
|
||||||
|
brightness = getattr(self.matrix, 'brightness', None)
|
||||||
|
except Exception:
|
||||||
|
brightness = None
|
||||||
|
digest = (zlib.adler32(self.image.tobytes()), brightness)
|
||||||
|
if digest == self._last_pushed_digest:
|
||||||
|
# Nothing changed since the last push — the panel is
|
||||||
|
# already showing exactly this frame.
|
||||||
|
self._write_snapshot_if_due()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Copy the current image to the offscreen canvas. In double-sided
|
||||||
|
# mode the logical screen is first tiled across the full chain.
|
||||||
|
if self._double_sided is not None:
|
||||||
|
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
||||||
|
else:
|
||||||
|
self.offscreen_canvas.SetImage(self.image)
|
||||||
|
|
||||||
# Swap buffers immediately
|
# Swap buffers immediately
|
||||||
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
||||||
|
|
||||||
# Swap our canvas references
|
# Swap our canvas references
|
||||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||||
|
|
||||||
|
self._last_pushed_digest = digest
|
||||||
|
|
||||||
# Write a snapshot for the web preview (throttled)
|
# Write a snapshot for the web preview (throttled)
|
||||||
self._write_snapshot_if_due()
|
self._write_snapshot_if_due()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -374,6 +608,9 @@ class DisplayManager:
|
|||||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||||
# Failures are non-fatal — the image buffer is already black above, so
|
# Failures are non-fatal — the image buffer is already black above, so
|
||||||
# the next update_display() call will push clean content regardless.
|
# the next update_display() call will push clean content regardless.
|
||||||
|
# The matrix content no longer matches the last pushed digest,
|
||||||
|
# so dirty tracking must not skip the next push.
|
||||||
|
self._last_pushed_digest = None
|
||||||
try:
|
try:
|
||||||
self.offscreen_canvas.Clear()
|
self.offscreen_canvas.Clear()
|
||||||
except (RuntimeError, OSError) as e:
|
except (RuntimeError, OSError) as e:
|
||||||
@@ -437,6 +674,9 @@ class DisplayManager:
|
|||||||
|
|
||||||
def _load_fonts(self):
|
def _load_fonts(self):
|
||||||
"""Load fonts with proper error handling."""
|
"""Load fonts with proper error handling."""
|
||||||
|
# Font objects get new id()s after reload, so the text-width cache would
|
||||||
|
# return stale measurements keyed on the old ids. Clear it here.
|
||||||
|
self._text_width_cache.clear()
|
||||||
try:
|
try:
|
||||||
# Load Press Start 2P font
|
# Load Press Start 2P font
|
||||||
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||||
@@ -497,22 +737,32 @@ class DisplayManager:
|
|||||||
|
|
||||||
|
|
||||||
def get_text_width(self, text, font):
|
def get_text_width(self, text, font):
|
||||||
"""Get the width of text when rendered with the given font."""
|
"""Get the width of text when rendered with the given font.
|
||||||
|
|
||||||
|
Results are cached by (text, font identity) so plugins that measure
|
||||||
|
the same string every frame (e.g. to centre a score) pay only one
|
||||||
|
measurement per unique (text, font) pair.
|
||||||
|
"""
|
||||||
|
cache_key = (text, id(font))
|
||||||
|
cached = self._text_width_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
# For FreeType faces, calculate width using freetype
|
|
||||||
width = 0
|
width = 0
|
||||||
for char in text:
|
for char in text:
|
||||||
font.load_char(char)
|
font.load_char(char)
|
||||||
width += font.glyph.advance.x >> 6
|
width += font.glyph.advance.x >> 6
|
||||||
return width
|
|
||||||
else:
|
else:
|
||||||
# For PIL fonts, use textbbox
|
|
||||||
bbox = self.draw.textbbox((0, 0), text, font=font)
|
bbox = self.draw.textbbox((0, 0), text, font=font)
|
||||||
return bbox[2] - bbox[0]
|
width = bbox[2] - bbox[0]
|
||||||
except Exception as e:
|
except (AttributeError, TypeError, ValueError, OSError) as e:
|
||||||
logger.error(f"Error getting text width: {e}")
|
logger.error("Error getting text width: %s", e)
|
||||||
return 0 # Return 0 as fallback
|
return 0
|
||||||
|
|
||||||
|
self._text_width_cache[cache_key] = width
|
||||||
|
return width
|
||||||
|
|
||||||
def get_font_height(self, font):
|
def get_font_height(self, font):
|
||||||
"""Get the height of the given font for line spacing purposes."""
|
"""Get the height of the given font for line spacing purposes."""
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Font Manager — TTF/BDF font loading, caching, and dynamic registration.
|
||||||
|
|
||||||
|
:class:`FontManager` serves two purposes:
|
||||||
|
|
||||||
|
1. **System fonts** — loads the configured small/medium/large TTF fonts (and
|
||||||
|
their BDF bitmap equivalents) at startup, caches metrics, and exposes them
|
||||||
|
via ``DisplayManager`` attributes (``small_font``, ``medium_font``, etc.).
|
||||||
|
|
||||||
|
2. **Plugin fonts** — lets plugins register their own fonts at runtime via
|
||||||
|
:meth:`FontManager.register_manager_font` and resolve them later via
|
||||||
|
:meth:`FontManager.resolve_font`. Registered fonts are namespaced by
|
||||||
|
plugin ID so they cannot collide.
|
||||||
|
|
||||||
|
Font sources
|
||||||
|
------------
|
||||||
|
* Local paths relative to the project root.
|
||||||
|
* Remote URLs — downloaded once, cached to disk, and never re-fetched while
|
||||||
|
the cached copy is fresh.
|
||||||
|
|
||||||
|
BDF fallback
|
||||||
|
------------
|
||||||
|
Pixel-accurate LED fonts are stored as ``.bdf`` (Bitmap Distribution Format)
|
||||||
|
files. When PIL cannot measure BDF glyphs natively, ``freetype-py`` is used
|
||||||
|
for accurate width/height calculations.
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import freetype
|
import freetype
|
||||||
|
|||||||
@@ -52,11 +52,18 @@ class PluginHealthTracker:
|
|||||||
"""Get cache key for plugin health data."""
|
"""Get cache key for plugin health data."""
|
||||||
return f"plugin_health:{plugin_id}"
|
return f"plugin_health:{plugin_id}"
|
||||||
|
|
||||||
def _load_health_state(self, plugin_id: str) -> Dict[str, Any]:
|
def _load_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||||
"""Load health state from cache or return defaults."""
|
"""Load health state from cache or return defaults.
|
||||||
|
|
||||||
|
``force_reload=True`` bypasses the cache manager's in-memory tier so a
|
||||||
|
read-only consumer (e.g. the web process) observes the writer process's
|
||||||
|
latest persisted state instead of a stale first snapshot.
|
||||||
|
"""
|
||||||
cache_key = self._get_health_key(plugin_id)
|
cache_key = self._get_health_key(plugin_id)
|
||||||
cached = self.cache_manager.get(cache_key, max_age=None)
|
cached = self.cache_manager.get(
|
||||||
|
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
||||||
|
)
|
||||||
|
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
@@ -79,10 +86,17 @@ class PluginHealthTracker:
|
|||||||
self.cache_manager.set(cache_key, state) # Persist indefinitely
|
self.cache_manager.set(cache_key, state) # Persist indefinitely
|
||||||
self._health_state[plugin_id] = state
|
self._health_state[plugin_id] = state
|
||||||
|
|
||||||
def get_health_state(self, plugin_id: str) -> Dict[str, Any]:
|
def get_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||||
"""Get current health state for a plugin."""
|
"""Get current health state for a plugin.
|
||||||
if plugin_id not in self._health_state:
|
|
||||||
self._health_state[plugin_id] = self._load_health_state(plugin_id)
|
``force_reload=True`` re-reads the persisted state from the cache,
|
||||||
|
bypassing the in-memory copy — needed by cross-process readers that
|
||||||
|
would otherwise be pinned to the first snapshot they loaded.
|
||||||
|
"""
|
||||||
|
if force_reload or plugin_id not in self._health_state:
|
||||||
|
self._health_state[plugin_id] = self._load_health_state(
|
||||||
|
plugin_id, force_reload=force_reload
|
||||||
|
)
|
||||||
return self._health_state[plugin_id]
|
return self._health_state[plugin_id]
|
||||||
|
|
||||||
def record_success(self, plugin_id: str) -> None:
|
def record_success(self, plugin_id: str) -> None:
|
||||||
@@ -139,6 +153,28 @@ class PluginHealthTracker:
|
|||||||
|
|
||||||
self._save_health_state(plugin_id, state)
|
self._save_health_state(plugin_id, state)
|
||||||
|
|
||||||
|
def set_degraded(self, plugin_id: str, reason: Optional[str]) -> None:
|
||||||
|
"""Flag (or clear) a plugin as degraded without touching the circuit breaker.
|
||||||
|
|
||||||
|
Used for non-fatal issues — e.g. a config that no longer satisfies the
|
||||||
|
plugin's schema — that should be surfaced to the user but must NOT cause
|
||||||
|
the plugin to be skipped or counted as a runtime failure. Passing
|
||||||
|
``reason=None`` clears the flag. The write is skipped when nothing
|
||||||
|
actually changes, so calling this on every load is cheap.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: Plugin identifier
|
||||||
|
reason: Human-readable reason string, or None to clear the flag
|
||||||
|
"""
|
||||||
|
state = self.get_health_state(plugin_id)
|
||||||
|
new_degraded = bool(reason)
|
||||||
|
new_reason = reason if reason else None
|
||||||
|
if state.get('degraded', False) == new_degraded and state.get('degraded_reason') == new_reason:
|
||||||
|
return # No change — avoid a redundant cache write
|
||||||
|
state['degraded'] = new_degraded
|
||||||
|
state['degraded_reason'] = new_reason
|
||||||
|
self._save_health_state(plugin_id, state)
|
||||||
|
|
||||||
def should_skip_plugin(self, plugin_id: str) -> bool:
|
def should_skip_plugin(self, plugin_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if plugin should be skipped due to circuit breaker.
|
Check if plugin should be skipped due to circuit breaker.
|
||||||
@@ -181,9 +217,13 @@ class PluginHealthTracker:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_health_summary(self, plugin_id: str) -> Dict[str, Any]:
|
def get_health_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||||
"""Get health summary for a plugin."""
|
"""Get health summary for a plugin.
|
||||||
state = self.get_health_state(plugin_id)
|
|
||||||
|
``force_reload=True`` refreshes from the persisted cache first so
|
||||||
|
cross-process readers reflect the writer's latest state.
|
||||||
|
"""
|
||||||
|
state = self.get_health_state(plugin_id, force_reload=force_reload)
|
||||||
|
|
||||||
total_calls = state.get('total_successes', 0) + state.get('total_failures', 0)
|
total_calls = state.get('total_successes', 0) + state.get('total_failures', 0)
|
||||||
success_rate = 0.0
|
success_rate = 0.0
|
||||||
@@ -201,6 +241,8 @@ class PluginHealthTracker:
|
|||||||
'last_failure_time': state.get('last_failure_time'),
|
'last_failure_time': state.get('last_failure_time'),
|
||||||
'last_error': state.get('last_error'),
|
'last_error': state.get('last_error'),
|
||||||
'is_healthy': state.get('circuit_state') == CircuitState.CLOSED.value,
|
'is_healthy': state.get('circuit_state') == CircuitState.CLOSED.value,
|
||||||
|
'degraded': state.get('degraded', False),
|
||||||
|
'degraded_reason': state.get('degraded_reason'),
|
||||||
'circuit_opened_time': state.get('circuit_opened_time'),
|
'circuit_opened_time': state.get('circuit_opened_time'),
|
||||||
'half_open_start_time': state.get('half_open_start_time')
|
'half_open_start_time': state.get('half_open_start_time')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ 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 hashlib
|
|
||||||
import json
|
|
||||||
import importlib
|
import importlib
|
||||||
|
import importlib.metadata
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -17,12 +17,101 @@ from pathlib import Path
|
|||||||
from typing import Dict, Any, Optional, Tuple, Type
|
from typing import Dict, Any, Optional, Tuple, Type
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from packaging.requirements import InvalidRequirement, Requirement
|
||||||
|
|
||||||
from src.exceptions import PluginError
|
from src.exceptions import PluginError
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_file_permissions,
|
|
||||||
get_plugin_file_mode
|
def requirements_has_real_deps(requirements_file: str) -> bool:
|
||||||
)
|
"""
|
||||||
|
Check whether a requirements.txt actually specifies anything to install.
|
||||||
|
|
||||||
|
Plugins that ship all their dependencies with LEDMatrix core often keep a
|
||||||
|
requirements.txt where every line is commented out, for documentation
|
||||||
|
purposes only. Running pip against such a file still pays the full
|
||||||
|
subprocess/resolver cost for zero effect, so callers should skip the
|
||||||
|
install step entirely when this returns False.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(requirements_file, 'r', encoding='utf-8') as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
# Let the caller's own file handling report the error.
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def requirements_are_satisfied(requirements_file: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check whether every real requirement line in requirements.txt is already
|
||||||
|
satisfied by packages installed in the current interpreter.
|
||||||
|
|
||||||
|
This replaces marker-file tracking with a direct fact check, so it's
|
||||||
|
immune to stale/missing/corrupted markers: it looks at what's actually
|
||||||
|
importable right now rather than trusting a hash comparison from a
|
||||||
|
previous run. Anything ambiguous (pip options, unparseable lines,
|
||||||
|
extras, unresolvable versions) conservatively returns False so the
|
||||||
|
caller falls through to running pip — this check only ever saves work,
|
||||||
|
never masks a real install.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(requirements_file, 'r', encoding='utf-8') as fh:
|
||||||
|
lines = fh.readlines()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for raw_line in lines:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
if line.startswith('-'):
|
||||||
|
return False # pip option (-r, --index-url, ...), can't verify
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = Requirement(line)
|
||||||
|
except InvalidRequirement:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if req.extras:
|
||||||
|
return False # verifying extras' sub-dependencies isn't worth it here
|
||||||
|
|
||||||
|
if req.marker is not None and not req.marker.evaluate():
|
||||||
|
continue # not applicable on this platform/interpreter
|
||||||
|
|
||||||
|
try:
|
||||||
|
installed_version = importlib.metadata.version(req.name)
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if req.specifier and not req.specifier.contains(installed_version, prereleases=True):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
|
||||||
|
"""Return `name` if it names an actual subdirectory of trusted_dir, else None.
|
||||||
|
|
||||||
|
Used as a containment check for a directory name derived from untrusted
|
||||||
|
input (a manifest-declared plugin id, an externally-supplied plugin
|
||||||
|
path): the returned value always comes from enumerating trusted_dir
|
||||||
|
itself via os.scandir(), so a caller that builds a path by joining
|
||||||
|
trusted_dir with this return value is joining against a name the
|
||||||
|
filesystem produced under a trusted root -- not the caller's original
|
||||||
|
string, which could otherwise smuggle a traversal sequence through.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with os.scandir(trusted_dir) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
if entry.name == name and entry.is_dir():
|
||||||
|
return entry.name
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class PluginLoader:
|
class PluginLoader:
|
||||||
@@ -132,14 +221,14 @@ class PluginLoader:
|
|||||||
except (json.JSONDecodeError, Exception) as e:
|
except (json.JSONDecodeError, Exception) as e:
|
||||||
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
|
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def install_dependencies(
|
def install_dependencies(
|
||||||
self,
|
self,
|
||||||
plugin_dir: Path,
|
plugin_dir: Path,
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
plugins_dir: Optional[Path] = None,
|
plugins_dir: Path,
|
||||||
timeout: int = 300
|
timeout: int = 300
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -148,7 +237,12 @@ class PluginLoader:
|
|||||||
Args:
|
Args:
|
||||||
plugin_dir: Plugin directory path
|
plugin_dir: Plugin directory path
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
plugins_dir: Trusted base plugins directory for path containment check
|
plugins_dir: Trusted base plugins directory for path containment check.
|
||||||
|
Required (not optional) so every caller reconstructs the plugin
|
||||||
|
path through the sanitiser below rather than trusting plugin_dir
|
||||||
|
directly -- CodeQL's path-injection query (and a malicious
|
||||||
|
manifest/plugin_id in practice) can't tell a legitimate
|
||||||
|
plugin_dir from one crafted to traverse outside plugins_dir.
|
||||||
timeout: Installation timeout in seconds
|
timeout: Installation timeout in seconds
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -160,59 +254,42 @@ class PluginLoader:
|
|||||||
|
|
||||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||||
|
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||||
|
requested_name = os.path.basename(plugin_dir_real)
|
||||||
|
|
||||||
if plugins_dir is not None:
|
# Match the requested directory against an entry actually enumerated
|
||||||
# Reconstruct the plugin path from a trusted base + a sanitised
|
# from the trusted plugins_dir, and build the path from that entry --
|
||||||
# directory name. os.path.basename() is CodeQL's recognised
|
# not from requested_name. A name that came out of os.scandir() on a
|
||||||
# py/path-injection sanitiser: it strips all directory components
|
# trusted root carries no taint regardless of what the caller asked
|
||||||
# so the result cannot contain traversal sequences. Joining it
|
# for, so this is a real containment guarantee (an allowlist check
|
||||||
# with the resolved, trusted plugins_dir produces a path that
|
# against a trusted source), not a string-sanitisation of untrusted
|
||||||
# CodeQL considers untainted.
|
# input that a static analyzer has to trust blindly.
|
||||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
||||||
safe_dir_name = os.path.basename(plugin_dir_real)
|
if matched_name is None:
|
||||||
if not safe_dir_name:
|
self.logger.error(
|
||||||
self.logger.error("Could not determine plugin directory name for %s", plugin_id)
|
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||||
return False
|
)
|
||||||
safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name)
|
return False
|
||||||
if not os.path.isdir(safe_plugin_dir):
|
|
||||||
self.logger.error(
|
|
||||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
safe_plugin_dir = plugin_dir_real
|
|
||||||
if not os.path.isdir(safe_plugin_dir):
|
|
||||||
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
|
||||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
||||||
marker_file = os.path.join(safe_plugin_dir, ".dependencies_installed")
|
|
||||||
|
|
||||||
if not os.path.isfile(requirements_file):
|
if not os.path.isfile(requirements_file):
|
||||||
return True # No dependencies needed
|
return True # No dependencies needed
|
||||||
|
|
||||||
try:
|
if not requirements_has_real_deps(requirements_file):
|
||||||
with open(requirements_file, 'rb') as fh:
|
self.logger.debug(
|
||||||
current_hash = hashlib.sha256(fh.read()).hexdigest()
|
"requirements.txt for %s has no real dependencies (comments/blank only), skipping pip",
|
||||||
except OSError as e:
|
plugin_id
|
||||||
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
|
)
|
||||||
return False
|
return True
|
||||||
|
|
||||||
# Skip if requirements.txt hasn't changed since last install
|
if requirements_are_satisfied(requirements_file):
|
||||||
if os.path.isfile(marker_file):
|
self.logger.debug(
|
||||||
try:
|
"Dependencies for %s already satisfied in current environment, skipping pip",
|
||||||
with open(marker_file, 'r', encoding='utf-8') as fh:
|
plugin_id
|
||||||
stored_hash = fh.read().strip()
|
)
|
||||||
except OSError as e:
|
return True
|
||||||
self.logger.warning(
|
|
||||||
"Could not read dependency marker for %s (%s), will reinstall dependencies",
|
|
||||||
plugin_id, e
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if stored_hash == current_hash:
|
|
||||||
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
|
|
||||||
return True
|
|
||||||
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||||
@@ -225,32 +302,54 @@ class PluginLoader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
try:
|
|
||||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
|
||||||
fh.write(current_hash)
|
|
||||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
|
||||||
except OSError as marker_err:
|
|
||||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
|
||||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
stderr = result.stderr or ""
|
stderr = result.stderr or ""
|
||||||
# uninstall-no-record-file means the package is already present at the
|
# uninstall-no-record-file means a system-managed copy of a package
|
||||||
# system level (e.g. installed via dnf/apt without a pip RECORD file).
|
# (e.g. apt's python3-requests, which ships no pip RECORD file) is in
|
||||||
# pip can't replace it, but it IS installed — write the marker so we
|
# the way of the version this requirements.txt pins. Retry with
|
||||||
# don't retry on every restart.
|
# --ignore-installed so pip lays the pinned version down alongside
|
||||||
|
# the system copy instead of trying to replace it — matching the
|
||||||
|
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
|
||||||
|
# Without this retry, the plugin would silently keep running against
|
||||||
|
# whatever version the system happened to ship.
|
||||||
if "uninstall-no-record-file" in stderr:
|
if "uninstall-no-record-file" in stderr:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Dependencies for %s include system-managed packages (no pip RECORD). "
|
"Dependencies for %s conflict with a system-managed package "
|
||||||
"Assuming they are satisfied: %s",
|
"(no pip RECORD); retrying with --ignore-installed: %s",
|
||||||
plugin_id, stderr.strip()
|
plugin_id, stderr.strip()
|
||||||
)
|
)
|
||||||
|
# Wrapped in its own try/except so a retry timeout is
|
||||||
|
# tolerated the same way as a retry failure, instead of
|
||||||
|
# propagating to the outer handler and returning False
|
||||||
|
# (which would contradict the "assume satisfied" fallback
|
||||||
|
# below).
|
||||||
try:
|
try:
|
||||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
# sys.executable is this process's own interpreter (not
|
||||||
fh.write(current_hash)
|
# attacker-influenced), and requirements_file is a path
|
||||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
# built internally by find_plugin_directory, never raw
|
||||||
except OSError as marker_err:
|
# external input.
|
||||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||||
|
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
||||||
|
"--ignore-installed", "-r", requirements_file],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
if retry_result.returncode != 0:
|
||||||
|
self.logger.warning(
|
||||||
|
"Retry with --ignore-installed also failed for %s; assuming the "
|
||||||
|
"system-managed version satisfies the requirement: %s",
|
||||||
|
plugin_id, (retry_result.stderr or "").strip()
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.logger.warning(
|
||||||
|
"Retry with --ignore-installed timed out for %s; assuming the "
|
||||||
|
"system-managed version satisfies the requirement",
|
||||||
|
plugin_id
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Dependency installation returned non-zero exit code for %s: %s",
|
"Dependency installation returned non-zero exit code for %s: %s",
|
||||||
@@ -618,6 +717,14 @@ class PluginLoader:
|
|||||||
"""
|
"""
|
||||||
# Install dependencies if needed
|
# Install dependencies if needed
|
||||||
if install_deps:
|
if install_deps:
|
||||||
|
if plugins_dir is None:
|
||||||
|
raise PluginError(
|
||||||
|
f"plugins_dir is required to install dependencies for plugin {plugin_id} "
|
||||||
|
"(needed for path containment; pass install_deps=False if the caller "
|
||||||
|
"doesn't have a trusted plugins directory to supply)",
|
||||||
|
plugin_id=plugin_id,
|
||||||
|
context={'plugin_dir': str(plugin_dir)},
|
||||||
|
)
|
||||||
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
||||||
raise PluginError(
|
raise PluginError(
|
||||||
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ API Version: 1.0.0
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
import types
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
import logging
|
import logging
|
||||||
from src.exceptions import PluginError
|
from src.exceptions import PluginError, ConfigError
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
from src.plugin_system.plugin_executor import PluginExecutor
|
from src.plugin_system.plugin_executor import PluginExecutor
|
||||||
@@ -81,7 +81,13 @@ class PluginManager:
|
|||||||
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
||||||
self.plugin_modules: Dict[str, Any] = {}
|
self.plugin_modules: Dict[str, Any] = {}
|
||||||
self.plugin_last_update: Dict[str, float] = {}
|
self.plugin_last_update: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# Cached data-fetch intervals per plugin_id.
|
||||||
|
# _get_plugin_update_interval falls back to config_manager.get_config()
|
||||||
|
# (a full dict copy) when the manifest lacks an interval — caching avoids
|
||||||
|
# that copy on every 30-fps tick. Cleared on load/unload.
|
||||||
|
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)
|
||||||
self.health_tracker = None
|
self.health_tracker = None
|
||||||
self.resource_monitor = None
|
self.resource_monitor = None
|
||||||
@@ -171,90 +177,6 @@ class PluginManager:
|
|||||||
|
|
||||||
return plugin_ids
|
return plugin_ids
|
||||||
|
|
||||||
def _get_dependency_marker_path(self, plugin_id: str) -> Path:
|
|
||||||
"""Get path to dependency installation marker file."""
|
|
||||||
plugin_dir = self.plugins_dir / plugin_id
|
|
||||||
if not plugin_dir.exists():
|
|
||||||
# Try with ledmatrix- prefix
|
|
||||||
plugin_dir = self.plugins_dir / f"ledmatrix-{plugin_id}"
|
|
||||||
return plugin_dir / ".dependencies_installed"
|
|
||||||
|
|
||||||
def _check_dependencies_installed(self, plugin_id: str) -> bool:
|
|
||||||
"""Check if dependencies are already installed for a plugin."""
|
|
||||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
|
||||||
return marker_path.exists()
|
|
||||||
|
|
||||||
def _mark_dependencies_installed(self, plugin_id: str) -> None:
|
|
||||||
"""Mark dependencies as installed for a plugin."""
|
|
||||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
|
||||||
try:
|
|
||||||
marker_path.touch()
|
|
||||||
# Set proper file permissions after creating marker
|
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_file_permissions,
|
|
||||||
get_plugin_file_mode
|
|
||||||
)
|
|
||||||
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
|
||||||
except (OSError, PermissionError) as e:
|
|
||||||
self.logger.warning("Could not create dependency marker for %s: %s", plugin_id, e)
|
|
||||||
|
|
||||||
def _remove_dependency_marker(self, plugin_id: str) -> None:
|
|
||||||
"""Remove dependency installation marker."""
|
|
||||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
|
||||||
try:
|
|
||||||
if marker_path.exists():
|
|
||||||
marker_path.unlink()
|
|
||||||
except (OSError, PermissionError) as e:
|
|
||||||
self.logger.warning("Could not remove dependency marker for %s: %s", plugin_id, e)
|
|
||||||
|
|
||||||
def _install_plugin_dependencies(self, requirements_file: Path) -> bool:
|
|
||||||
"""
|
|
||||||
Install plugin dependencies from requirements.txt.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
requirements_file: Path to requirements.txt
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if installation succeeded or not needed, False on error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.logger.info("Installing dependencies from %s", requirements_file)
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--no-cache-dir", "-r", str(requirements_file)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300,
|
|
||||||
check=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
self.logger.info("Dependencies installed successfully")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.logger.warning("Dependency installation returned non-zero exit code: %s", result.stderr)
|
|
||||||
return False
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
self.logger.error("Dependency installation timed out")
|
|
||||||
return False
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
self.logger.warning("Command not found: %s. Skipping dependency installation", e)
|
|
||||||
return True
|
|
||||||
except (BrokenPipeError, OSError) as e:
|
|
||||||
# Handle broken pipe errors (errno 32) which can occur during pip downloads
|
|
||||||
# Often caused by network interruptions or output buffer issues
|
|
||||||
if isinstance(e, OSError) and e.errno == 32:
|
|
||||||
self.logger.error(
|
|
||||||
"Broken pipe error during dependency installation. "
|
|
||||||
"This usually indicates a network interruption or pip output buffer issue. "
|
|
||||||
"Try installing again or check your network connection."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.logger.error("OS error during dependency installation: %s", e)
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error("Unexpected error installing dependencies: %s", e, exc_info=True)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def load_plugin(self, plugin_id: str) -> bool:
|
def load_plugin(self, plugin_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Load a plugin by ID.
|
Load a plugin by ID.
|
||||||
@@ -384,10 +306,20 @@ class PluginManager:
|
|||||||
self.logger.error("Error validating plugin %s config: %s", plugin_id, e, exc_info=True)
|
self.logger.error("Error validating plugin %s config: %s", plugin_id, e, exc_info=True)
|
||||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Schema validation (warn/degrade only — never blocks loading).
|
||||||
|
# A config that violates the plugin's JSON schema is surfaced to the
|
||||||
|
# user (log warning + degraded flag in the health tracker) but the
|
||||||
|
# plugin still loads exactly as it does today. This deliberately does
|
||||||
|
# NOT change load_plugin()'s pass/fail behaviour for any plugin that
|
||||||
|
# loads under the current code.
|
||||||
|
self._validate_config_schema_soft(plugin_id, config)
|
||||||
|
|
||||||
# Store plugin instance
|
# Store plugin instance
|
||||||
self.plugins[plugin_id] = plugin_instance
|
self.plugins[plugin_id] = plugin_instance
|
||||||
self.plugin_last_update[plugin_id] = 0.0
|
self.plugin_last_update[plugin_id] = 0.0
|
||||||
|
# Invalidate cached interval so next tick re-derives it for this plugin
|
||||||
|
self._update_interval_cache.pop(plugin_id, None)
|
||||||
|
|
||||||
# Update state based on enabled status
|
# Update state based on enabled status
|
||||||
if config.get('enabled', True):
|
if config.get('enabled', True):
|
||||||
@@ -411,6 +343,59 @@ class PluginManager:
|
|||||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _validate_config_schema_soft(self, plugin_id: str, config: Dict[str, Any]) -> None:
|
||||||
|
"""Validate a plugin's config against its JSON schema — warn/degrade only.
|
||||||
|
|
||||||
|
On a schema violation this logs a warning and marks the plugin degraded
|
||||||
|
in the health tracker (when one is wired), so the problem is visible in
|
||||||
|
the web UI. It never raises, never changes plugin state, and never
|
||||||
|
affects whether the plugin loads. ``config`` here has already been
|
||||||
|
merged with schema defaults by the caller, so fields that ship a default
|
||||||
|
never appear "missing" — only genuinely user-supplied required fields
|
||||||
|
(e.g. an API key) can trip the required-field check.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
schema = self.schema_manager.load_schema(plugin_id)
|
||||||
|
except Exception as e: # pragma: no cover - defensive
|
||||||
|
self.logger.debug("Could not load schema for %s: %s", plugin_id, e)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not schema:
|
||||||
|
# No schema shipped — nothing to validate. Clear any stale flag.
|
||||||
|
self._set_degraded_safe(plugin_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_valid, errors = self.schema_manager.validate_config_against_schema(
|
||||||
|
config, schema, plugin_id
|
||||||
|
)
|
||||||
|
except Exception as e: # pragma: no cover - defensive
|
||||||
|
# Validation machinery itself failed — do not penalise the plugin.
|
||||||
|
self.logger.debug("Schema validation raised for %s: %s", plugin_id, e)
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_valid or not errors:
|
||||||
|
self._set_degraded_safe(plugin_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
summary = "; ".join(errors[:5])
|
||||||
|
if len(errors) > 5:
|
||||||
|
summary += f" (+{len(errors) - 5} more)"
|
||||||
|
self.logger.warning(
|
||||||
|
"Plugin %s config does not match its schema (loading anyway): %s",
|
||||||
|
plugin_id, summary,
|
||||||
|
)
|
||||||
|
self._set_degraded_safe(plugin_id, f"Config schema: {summary}")
|
||||||
|
|
||||||
|
def _set_degraded_safe(self, plugin_id: str, reason: Optional[str]) -> None:
|
||||||
|
"""Best-effort ``health_tracker.set_degraded`` that never raises."""
|
||||||
|
if not self.health_tracker:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.health_tracker.set_degraded(plugin_id, reason)
|
||||||
|
except Exception as e: # pragma: no cover - defensive
|
||||||
|
self.logger.debug("Could not set degraded flag for %s: %s", plugin_id, e)
|
||||||
|
|
||||||
def unload_plugin(self, plugin_id: str) -> bool:
|
def unload_plugin(self, plugin_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Unload a plugin by ID.
|
Unload a plugin by ID.
|
||||||
@@ -444,8 +429,8 @@ class PluginManager:
|
|||||||
|
|
||||||
# Remove from active plugins
|
# Remove from active plugins
|
||||||
del self.plugins[plugin_id]
|
del self.plugins[plugin_id]
|
||||||
if plugin_id in self.plugin_last_update:
|
self.plugin_last_update.pop(plugin_id, None)
|
||||||
del self.plugin_last_update[plugin_id]
|
self._update_interval_cache.pop(plugin_id, None)
|
||||||
|
|
||||||
# Remove main module from sys.modules if present
|
# Remove main module from sys.modules if present
|
||||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||||
@@ -639,41 +624,46 @@ class PluginManager:
|
|||||||
|
|
||||||
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
||||||
"""
|
"""
|
||||||
Get the update interval for a plugin.
|
Get the data-fetch interval for a plugin (seconds between update() calls).
|
||||||
|
|
||||||
Args:
|
Result is cached per plugin_id after the first lookup to avoid calling
|
||||||
plugin_id: Plugin identifier
|
config_manager.get_config() — which returns a full dict copy — on every
|
||||||
plugin_instance: Plugin instance
|
tick of the 30-fps display loop. The cache is invalidated when a plugin
|
||||||
|
is loaded or unloaded.
|
||||||
Returns:
|
|
||||||
Update interval in seconds or None if not configured
|
|
||||||
"""
|
"""
|
||||||
# Check manifest first
|
if plugin_id in self._update_interval_cache:
|
||||||
|
return self._update_interval_cache[plugin_id]
|
||||||
|
|
||||||
|
interval: Optional[float] = None
|
||||||
|
|
||||||
|
# 1. Manifest (immutable after load — preferred source)
|
||||||
manifest = self.plugin_manifests.get(plugin_id, {})
|
manifest = self.plugin_manifests.get(plugin_id, {})
|
||||||
update_interval = manifest.get('update_interval')
|
raw = manifest.get('update_interval')
|
||||||
|
if raw is not None:
|
||||||
if update_interval:
|
|
||||||
try:
|
try:
|
||||||
return float(update_interval)
|
interval = float(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check plugin config
|
# 2. Plugin config (mutable; only read once and then cached)
|
||||||
if self.config_manager:
|
if interval is None and self.config_manager:
|
||||||
try:
|
try:
|
||||||
config = self.config_manager.get_config()
|
config = self.config_manager.get_config()
|
||||||
plugin_config = config.get(plugin_id, {})
|
raw = config.get(plugin_id, {}).get('update_interval')
|
||||||
update_interval = plugin_config.get('update_interval')
|
if raw is not None:
|
||||||
if update_interval:
|
|
||||||
try:
|
try:
|
||||||
return float(update_interval)
|
interval = float(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except (ConfigError, OSError, ValueError, TypeError) as e:
|
||||||
self.logger.debug("Could not get update interval from config: %s", e)
|
self.logger.debug("Could not get update interval from config: %s", e)
|
||||||
|
|
||||||
# Default: 60 seconds
|
# 3. Default
|
||||||
return 60.0
|
if interval is None:
|
||||||
|
interval = 60.0
|
||||||
|
|
||||||
|
self._update_interval_cache[plugin_id] = interval
|
||||||
|
return interval
|
||||||
|
|
||||||
def _record_update_failure(
|
def _record_update_failure(
|
||||||
self,
|
self,
|
||||||
@@ -754,8 +744,18 @@ class PluginManager:
|
|||||||
# If resource monitor exists, wrap the call
|
# If resource monitor exists, wrap the call
|
||||||
def monitored_update():
|
def monitored_update():
|
||||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
||||||
|
# SimpleNamespace stores `update` as an *instance*
|
||||||
|
# attribute, so attribute lookup returns the plain
|
||||||
|
# function object as-is. A dynamically-built class
|
||||||
|
# (`type(..., {'update': monitored_update})`) instead
|
||||||
|
# stores it as a *class* attribute, which the
|
||||||
|
# descriptor protocol turns into a bound method on
|
||||||
|
# access -- silently prepending the instance as an
|
||||||
|
# implicit first argument to a function that takes
|
||||||
|
# none, raising "monitored_update() takes 0
|
||||||
|
# positional arguments but 1 was given" on every call.
|
||||||
success = self.plugin_executor.execute_update(
|
success = self.plugin_executor.execute_update(
|
||||||
type('obj', (object,), {'update': monitored_update})(),
|
types.SimpleNamespace(update=monitored_update),
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -823,7 +823,7 @@ class PluginManager:
|
|||||||
|
|
||||||
# Get health tracker metrics if available
|
# Get health tracker metrics if available
|
||||||
if self.health_tracker:
|
if self.health_tracker:
|
||||||
health_info = self.health_tracker.get_plugin_health(plugin_id)
|
health_info = self.health_tracker.get_health_summary(plugin_id)
|
||||||
plugin_metrics['health'] = health_info
|
plugin_metrics['health'] = health_info
|
||||||
else:
|
else:
|
||||||
plugin_metrics['health'] = {'status': 'unknown'}
|
plugin_metrics['health'] = {'status': 'unknown'}
|
||||||
@@ -848,7 +848,7 @@ class PluginManager:
|
|||||||
|
|
||||||
# Get resource monitor metrics if available
|
# Get resource monitor metrics if available
|
||||||
if self.resource_monitor:
|
if self.resource_monitor:
|
||||||
resource_info = self.resource_monitor.get_plugin_metrics(plugin_id)
|
resource_info = self.resource_monitor.get_metrics_summary(plugin_id)
|
||||||
plugin_metrics['resources'] = resource_info
|
plugin_metrics['resources'] = resource_info
|
||||||
else:
|
else:
|
||||||
plugin_metrics['resources'] = {'status': 'unknown'}
|
plugin_metrics['resources'] = {'status': 'unknown'}
|
||||||
|
|||||||
@@ -71,17 +71,32 @@ class PluginResourceMonitor:
|
|||||||
self.cache_manager = cache_manager
|
self.cache_manager = cache_manager
|
||||||
self.enable_monitoring = enable_monitoring and PSUTIL_AVAILABLE
|
self.enable_monitoring = enable_monitoring and PSUTIL_AVAILABLE
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Resource metrics per plugin
|
# Resource metrics per plugin
|
||||||
self._metrics: Dict[str, ResourceMetrics] = {}
|
self._metrics: Dict[str, ResourceMetrics] = {}
|
||||||
self._limits: Dict[str, ResourceLimits] = {}
|
self._limits: Dict[str, ResourceLimits] = {}
|
||||||
|
|
||||||
# Thread-local storage for execution tracking
|
# Thread-local storage for execution tracking
|
||||||
self._local = threading.local()
|
self._local = threading.local()
|
||||||
|
|
||||||
# Lock for thread-safe access
|
# Lock for thread-safe access
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# Cache a single psutil.Process handle. Reusing the same handle is what
|
||||||
|
# lets cpu_percent() be read non-blocking (interval=None): psutil returns
|
||||||
|
# the utilisation since the *previous* call on that same object. Creating
|
||||||
|
# a fresh Process() per call would force interval-based sampling that
|
||||||
|
# blocks the caller — unacceptable on the display loop's update path.
|
||||||
|
self._process = None
|
||||||
|
if self.enable_monitoring:
|
||||||
|
try:
|
||||||
|
self._process = psutil.Process()
|
||||||
|
# Prime cpu_percent so the first real measurement returns a
|
||||||
|
# meaningful delta instead of 0.0.
|
||||||
|
self._process.cpu_percent(interval=None)
|
||||||
|
except Exception: # pragma: no cover - psutil edge cases
|
||||||
|
self._process = None
|
||||||
|
|
||||||
if not PSUTIL_AVAILABLE and enable_monitoring:
|
if not PSUTIL_AVAILABLE and enable_monitoring:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"psutil not available - resource monitoring will be limited to execution time only"
|
"psutil not available - resource monitoring will be limited to execution time only"
|
||||||
@@ -95,13 +110,21 @@ class PluginResourceMonitor:
|
|||||||
"""Get cache key for plugin limits."""
|
"""Get cache key for plugin limits."""
|
||||||
return f"plugin_limits:{plugin_id}"
|
return f"plugin_limits:{plugin_id}"
|
||||||
|
|
||||||
def get_metrics(self, plugin_id: str) -> ResourceMetrics:
|
def get_metrics(self, plugin_id: str, force_reload: bool = False) -> ResourceMetrics:
|
||||||
"""Get current metrics for a plugin."""
|
"""Get current metrics for a plugin.
|
||||||
|
|
||||||
|
``force_reload=True`` bypasses both the in-memory copy and the cache
|
||||||
|
manager's memory tier so a read-only consumer (e.g. the web process)
|
||||||
|
sees the writer process's latest persisted metrics rather than a stale
|
||||||
|
first snapshot.
|
||||||
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if plugin_id not in self._metrics:
|
if force_reload or plugin_id not in self._metrics:
|
||||||
# Try to load from cache
|
# Try to load from cache
|
||||||
cache_key = self._get_metrics_key(plugin_id)
|
cache_key = self._get_metrics_key(plugin_id)
|
||||||
cached = self.cache_manager.get(cache_key, max_age=None)
|
cached = self.cache_manager.get(
|
||||||
|
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
||||||
|
)
|
||||||
if cached:
|
if cached:
|
||||||
metrics = ResourceMetrics(**cached)
|
metrics = ResourceMetrics(**cached)
|
||||||
else:
|
else:
|
||||||
@@ -137,21 +160,24 @@ class PluginResourceMonitor:
|
|||||||
|
|
||||||
def _get_process_memory_mb(self) -> float:
|
def _get_process_memory_mb(self) -> float:
|
||||||
"""Get current process memory usage in MB."""
|
"""Get current process memory usage in MB."""
|
||||||
if not self.enable_monitoring:
|
if not self.enable_monitoring or self._process is None:
|
||||||
return 0.0
|
return 0.0
|
||||||
try:
|
try:
|
||||||
process = psutil.Process()
|
return self._process.memory_info().rss / 1024 / 1024
|
||||||
return process.memory_info().rss / 1024 / 1024
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
def _get_process_cpu_percent(self, interval: float = 0.1) -> float:
|
def _get_process_cpu_percent(self) -> float:
|
||||||
"""Get current process CPU usage percentage."""
|
"""Get current process CPU usage percentage (non-blocking).
|
||||||
if not self.enable_monitoring:
|
|
||||||
|
Reads cpu_percent(interval=None) against the cached process handle, so
|
||||||
|
it returns immediately with the utilisation observed since the previous
|
||||||
|
call rather than blocking to sample a fresh interval.
|
||||||
|
"""
|
||||||
|
if not self.enable_monitoring or self._process is None:
|
||||||
return 0.0
|
return 0.0
|
||||||
try:
|
try:
|
||||||
process = psutil.Process()
|
return self._process.cpu_percent(interval=None)
|
||||||
return process.cpu_percent(interval=interval)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
@@ -281,9 +307,13 @@ class PluginResourceMonitor:
|
|||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
raise ResourceLimitExceeded(error_msg)
|
raise ResourceLimitExceeded(error_msg)
|
||||||
|
|
||||||
def get_metrics_summary(self, plugin_id: str) -> Dict[str, Any]:
|
def get_metrics_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||||
"""Get metrics summary for a plugin."""
|
"""Get metrics summary for a plugin.
|
||||||
metrics = self.get_metrics(plugin_id)
|
|
||||||
|
``force_reload=True`` refreshes from the persisted cache first so
|
||||||
|
cross-process readers reflect the writer's latest metrics.
|
||||||
|
"""
|
||||||
|
metrics = self.get_metrics(plugin_id, force_reload=force_reload)
|
||||||
limits = self.get_limits(plugin_id)
|
limits = self.get_limits(plugin_id)
|
||||||
|
|
||||||
avg_execution_time = 0.0
|
avg_execution_time = 0.0
|
||||||
|
|||||||
@@ -322,10 +322,19 @@ class StateReconciliation:
|
|||||||
and hasattr(self.store_manager, 'was_recently_uninstalled')
|
and hasattr(self.store_manager, 'was_recently_uninstalled')
|
||||||
and self.store_manager.was_recently_uninstalled(plugin_id)
|
and self.store_manager.was_recently_uninstalled(plugin_id)
|
||||||
)
|
)
|
||||||
|
# Also refuse to resurrect a plugin the user has persistently
|
||||||
|
# uninstalled. Unlike the in-memory race guard above, this record
|
||||||
|
# survives restarts, so the user's removal sticks across updates.
|
||||||
|
persistently_uninstalled = (
|
||||||
|
self.store_manager is not None
|
||||||
|
and hasattr(self.store_manager, 'is_plugin_uninstalled')
|
||||||
|
and self.store_manager.is_plugin_uninstalled(plugin_id)
|
||||||
|
)
|
||||||
can_repair = (
|
can_repair = (
|
||||||
self.store_manager is not None
|
self.store_manager is not None
|
||||||
and not previously_unrecoverable
|
and not previously_unrecoverable
|
||||||
and not recently_uninstalled
|
and not recently_uninstalled
|
||||||
|
and not persistently_uninstalled
|
||||||
)
|
)
|
||||||
inconsistencies.append(Inconsistency(
|
inconsistencies.append(Inconsistency(
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ 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 hashlib
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import json
|
import json
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -19,12 +19,15 @@ import time
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional, Any, Tuple
|
from typing import List, Dict, Optional, Any, Tuple, Set
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from src.common.permission_utils import sudo_remove_directory
|
from src.common.permission_utils import sudo_remove_directory, install_requirements_file
|
||||||
|
from src.plugin_system.plugin_loader import (
|
||||||
|
requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from jsonschema import Draft7Validator, ValidationError
|
from jsonschema import Draft7Validator, ValidationError
|
||||||
@@ -43,13 +46,24 @@ class PluginStoreManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json"
|
REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json"
|
||||||
|
|
||||||
|
# A valid plugin id is a single path component: starts alphanumeric, then
|
||||||
|
# alphanumerics / dot / dash / underscore. Used to keep the uninstall
|
||||||
|
# registry from ever turning a corrupt or hand-edited entry (e.g. "",
|
||||||
|
# "..", "../x") into a filesystem path that purge_uninstalled_plugins
|
||||||
|
# would delete — an empty id resolves to the plugins root itself.
|
||||||
|
_PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||||
|
|
||||||
def __init__(self, plugins_dir: str = "plugins"):
|
def __init__(self, plugins_dir: str = "plugins",
|
||||||
|
uninstalled_registry_path: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the plugin store manager.
|
Initialize the plugin store manager.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugins_dir: Directory where plugins are installed
|
plugins_dir: Directory where plugins are installed
|
||||||
|
uninstalled_registry_path: Path to the JSON file recording plugins
|
||||||
|
the user has uninstalled. Defaults to
|
||||||
|
``config/uninstalled_plugins.json`` under the project root.
|
||||||
"""
|
"""
|
||||||
self.plugins_dir = Path(plugins_dir)
|
self.plugins_dir = Path(plugins_dir)
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
@@ -84,6 +98,25 @@ class PluginStoreManager:
|
|||||||
self._uninstall_tombstones: Dict[str, float] = {}
|
self._uninstall_tombstones: Dict[str, float] = {}
|
||||||
self._uninstall_tombstone_ttl = 300 # 5 minutes
|
self._uninstall_tombstone_ttl = 300 # 5 minutes
|
||||||
|
|
||||||
|
# Persistent record of plugins the user has uninstalled. Unlike the
|
||||||
|
# in-memory tombstones above (a short-lived race guard), this survives
|
||||||
|
# restarts so that a core ``git pull`` update cannot resurrect a
|
||||||
|
# built-in plugin the user removed. Built-in plugins (e.g.
|
||||||
|
# ``web-ui-info``, ``starlark-apps``) are committed into the repo under
|
||||||
|
# ``plugin-repos/``, so a plain ``git pull`` restores their files even
|
||||||
|
# after the user deleted them. ``purge_uninstalled_plugins`` re-removes
|
||||||
|
# any such resurrected directory; ``install_plugin`` clears the record
|
||||||
|
# when the user deliberately reinstalls. The file is gitignored.
|
||||||
|
if uninstalled_registry_path is not None:
|
||||||
|
self._uninstalled_registry_path = Path(uninstalled_registry_path)
|
||||||
|
else:
|
||||||
|
self._uninstalled_registry_path = (
|
||||||
|
Path(__file__).parent.parent.parent / "config" / "uninstalled_plugins.json"
|
||||||
|
)
|
||||||
|
# Serializes read-modify-write of the registry file so concurrent
|
||||||
|
# install/uninstall requests can't lose updates.
|
||||||
|
self._uninstalled_registry_lock = threading.Lock()
|
||||||
|
|
||||||
# Cache for _get_local_git_info: {plugin_path_str: (signature, data)}
|
# Cache for _get_local_git_info: {plugin_path_str: (signature, data)}
|
||||||
# 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
|
||||||
@@ -143,6 +176,135 @@ class PluginStoreManager:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _is_valid_plugin_id(self, plugin_id: Any) -> bool:
|
||||||
|
"""Return True if ``plugin_id`` is a safe single-component plugin id.
|
||||||
|
|
||||||
|
Rejects empty strings, anything with a path separator, and traversal
|
||||||
|
sequences like ``..`` so a registry entry can never escape (or target
|
||||||
|
the root of) ``self.plugins_dir`` during a purge.
|
||||||
|
"""
|
||||||
|
return isinstance(plugin_id, str) and bool(self._PLUGIN_ID_RE.match(plugin_id))
|
||||||
|
|
||||||
|
def _read_uninstalled_registry(self) -> Set[str]:
|
||||||
|
"""Read the persistent set of uninstalled plugin IDs.
|
||||||
|
|
||||||
|
Returns an empty set if the file is missing, unreadable, or corrupt —
|
||||||
|
a broken registry must never block normal plugin operations. Invalid
|
||||||
|
ids are dropped here so callers never turn them into paths.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not self._uninstalled_registry_path.exists():
|
||||||
|
return set()
|
||||||
|
with open(self._uninstalled_registry_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
self.logger.warning(
|
||||||
|
"Uninstalled-plugin registry at %s is not a list; ignoring it",
|
||||||
|
self._uninstalled_registry_path,
|
||||||
|
)
|
||||||
|
return set()
|
||||||
|
valid: Set[str] = set()
|
||||||
|
for pid in data:
|
||||||
|
if self._is_valid_plugin_id(pid):
|
||||||
|
valid.add(pid)
|
||||||
|
else:
|
||||||
|
self.logger.warning(
|
||||||
|
"Ignoring invalid plugin id in uninstall registry: %r", pid
|
||||||
|
)
|
||||||
|
return valid
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
self.logger.warning(
|
||||||
|
"Could not read uninstalled-plugin registry at %s: %s",
|
||||||
|
self._uninstalled_registry_path, e,
|
||||||
|
)
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def _write_uninstalled_registry(self, plugin_ids: Set[str]) -> None:
|
||||||
|
"""Persist the set of uninstalled plugin IDs (sorted, atomically)."""
|
||||||
|
path = self._uninstalled_registry_path
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(sorted(plugin_ids), f, indent=2)
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
except OSError as e:
|
||||||
|
self.logger.error(
|
||||||
|
"Failed to write uninstalled-plugin registry at %s: %s", path, e
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_uninstalled_plugin(self, plugin_id: str) -> None:
|
||||||
|
"""Persistently record that the user uninstalled ``plugin_id``.
|
||||||
|
|
||||||
|
Survives restarts so a core update cannot resurrect the plugin.
|
||||||
|
"""
|
||||||
|
if not self._is_valid_plugin_id(plugin_id):
|
||||||
|
self.logger.error("Refusing to record invalid plugin id: %r", plugin_id)
|
||||||
|
return
|
||||||
|
with self._uninstalled_registry_lock:
|
||||||
|
recorded = self._read_uninstalled_registry()
|
||||||
|
if plugin_id not in recorded:
|
||||||
|
recorded.add(plugin_id)
|
||||||
|
self._write_uninstalled_registry(recorded)
|
||||||
|
self.logger.info("Recorded %s as uninstalled (persistent)", plugin_id)
|
||||||
|
|
||||||
|
def forget_uninstalled_plugin(self, *plugin_ids: str) -> None:
|
||||||
|
"""Drop ``plugin_ids`` from the persistent uninstall registry.
|
||||||
|
|
||||||
|
Called when a plugin is deliberately (re)installed so future updates
|
||||||
|
keep it.
|
||||||
|
"""
|
||||||
|
with self._uninstalled_registry_lock:
|
||||||
|
recorded = self._read_uninstalled_registry()
|
||||||
|
to_remove = {pid for pid in plugin_ids if pid in recorded}
|
||||||
|
if to_remove:
|
||||||
|
self._write_uninstalled_registry(recorded - to_remove)
|
||||||
|
self.logger.info(
|
||||||
|
"Cleared uninstall record for %s", ", ".join(sorted(to_remove))
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_uninstalled_plugins(self) -> Set[str]:
|
||||||
|
"""Return the persistent set of user-uninstalled plugin IDs."""
|
||||||
|
return self._read_uninstalled_registry()
|
||||||
|
|
||||||
|
def is_plugin_uninstalled(self, plugin_id: str) -> bool:
|
||||||
|
"""Return True if ``plugin_id`` is in the persistent uninstall registry."""
|
||||||
|
return plugin_id in self._read_uninstalled_registry()
|
||||||
|
|
||||||
|
def purge_uninstalled_plugins(self) -> List[str]:
|
||||||
|
"""Remove on-disk directories for plugins the user has uninstalled.
|
||||||
|
|
||||||
|
Built-in plugins committed into the repo are restored on disk by a
|
||||||
|
core ``git pull``; this re-removes any that the user previously
|
||||||
|
uninstalled. The registry entries are kept so the purge is idempotent
|
||||||
|
across every future update (until the user reinstalls). Returns the
|
||||||
|
list of plugin IDs whose directories were actually removed.
|
||||||
|
"""
|
||||||
|
removed: List[str] = []
|
||||||
|
plugins_root = self.plugins_dir.resolve()
|
||||||
|
for plugin_id in sorted(self._read_uninstalled_registry()):
|
||||||
|
plugin_path = self.plugins_dir / plugin_id
|
||||||
|
# Defense in depth: ids are already validated on read, but never
|
||||||
|
# remove anything that isn't a direct child of the plugins root.
|
||||||
|
resolved = plugin_path.resolve()
|
||||||
|
if resolved == plugins_root or resolved.parent != plugins_root:
|
||||||
|
self.logger.error(
|
||||||
|
"Refusing to purge unsafe plugin path for id %r", plugin_id
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not plugin_path.exists():
|
||||||
|
continue
|
||||||
|
self.logger.info(
|
||||||
|
"Purging resurrected uninstalled plugin: %s", plugin_id
|
||||||
|
)
|
||||||
|
if self._safe_remove_directory(plugin_path):
|
||||||
|
removed.append(plugin_id)
|
||||||
|
else:
|
||||||
|
self.logger.error(
|
||||||
|
"Failed to purge resurrected plugin directory: %s", plugin_path
|
||||||
|
)
|
||||||
|
return removed
|
||||||
|
|
||||||
def _load_github_token(self) -> Optional[str]:
|
def _load_github_token(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Load GitHub API token from config_secrets.json if available.
|
Load GitHub API token from config_secrets.json if available.
|
||||||
@@ -1024,6 +1186,10 @@ class PluginStoreManager:
|
|||||||
branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
|
branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
|
||||||
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
|
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
|
||||||
|
|
||||||
|
# Remember the originally-requested id so we can clear its uninstall
|
||||||
|
# record on success even if the manifest renames the directory below.
|
||||||
|
requested_id = plugin_id
|
||||||
|
|
||||||
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||||
if not plugin_info:
|
if not plugin_info:
|
||||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||||
@@ -1162,6 +1328,9 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
branch_display = branch_used or plugin_info.get('branch') or plugin_info.get('default_branch', 'unknown')
|
branch_display = branch_used or plugin_info.get('branch') or plugin_info.get('default_branch', 'unknown')
|
||||||
self.logger.info(f"Successfully installed plugin: {plugin_id} (branch {branch_display})")
|
self.logger.info(f"Successfully installed plugin: {plugin_id} (branch {branch_display})")
|
||||||
|
# User deliberately (re)installed this plugin — clear any persistent
|
||||||
|
# uninstall record so future core updates keep it.
|
||||||
|
self.forget_uninstalled_plugin(requested_id, plugin_id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1733,40 +1902,63 @@ class PluginStoreManager:
|
|||||||
def _install_dependencies(self, plugin_path: Path) -> bool:
|
def _install_dependencies(self, plugin_path: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
Install Python dependencies from requirements.txt.
|
Install Python dependencies from requirements.txt.
|
||||||
|
|
||||||
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
|
||||||
"""
|
"""
|
||||||
requirements_file = plugin_path / "requirements.txt"
|
# Reconstruct the plugin path from the trusted self.plugins_dir base +
|
||||||
|
# an entry actually enumerated from it, rather than trusting
|
||||||
|
# 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")
|
||||||
|
return False
|
||||||
|
safe_plugin_path = Path(os.path.join(plugins_dir_real, matched_name))
|
||||||
|
|
||||||
|
requirements_file = safe_plugin_path / "requirements.txt"
|
||||||
|
|
||||||
if not requirements_file.exists():
|
if not requirements_file.exists():
|
||||||
self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
|
self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
|
||||||
return True
|
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
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.info(f"Installing dependencies for {plugin_path.name}")
|
self.logger.info(f"Installing dependencies for {plugin_path.name}")
|
||||||
subprocess.run(
|
# Routed through the shared root-visible installer (same one the
|
||||||
['pip3', 'install', '--break-system-packages', '-r', str(requirements_file)],
|
# web UI's "Reinstall Plugin Deps" tool uses) rather than a bare
|
||||||
check=True,
|
# `pip`/`pip3` off PATH: a bare pip binary can silently resolve to
|
||||||
capture_output=True,
|
# a different Python installation than the one that actually runs
|
||||||
text=True,
|
# ledmatrix.service, so pip reports success while the package
|
||||||
timeout=300
|
# stays invisible to the running plugin (e.g. missing `astral`
|
||||||
)
|
# for the weather plugin even though "install" succeeded).
|
||||||
|
result = install_requirements_file(requirements_file, timeout=300)
|
||||||
|
if result.returncode != 0:
|
||||||
|
self.logger.error(
|
||||||
|
f"Error installing dependencies for {plugin_path.name}: {result.stderr}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
||||||
# Write hash marker so plugin_loader skips redundant pip run on next startup
|
|
||||||
try:
|
|
||||||
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
|
|
||||||
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
|
|
||||||
except OSError as marker_err:
|
|
||||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
self.logger.error(f"Error installing dependencies: {e.stderr}")
|
|
||||||
return False
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self.logger.error("Dependency installation timed out")
|
self.logger.error("Dependency installation timed out")
|
||||||
return False
|
return False
|
||||||
@@ -2262,19 +2454,6 @@ class PluginStoreManager:
|
|||||||
file_path = line[3:].strip()
|
file_path = line[3:].strip()
|
||||||
untracked_files.append(file_path)
|
untracked_files.append(file_path)
|
||||||
|
|
||||||
# Remove marker files that are safe to delete (they'll be regenerated)
|
|
||||||
safe_to_remove = ['.dependencies_installed']
|
|
||||||
removed_files = []
|
|
||||||
for file_name in safe_to_remove:
|
|
||||||
file_path = plugin_path / file_name
|
|
||||||
if file_path.exists() and file_name in untracked_files:
|
|
||||||
try:
|
|
||||||
file_path.unlink()
|
|
||||||
removed_files.append(file_name)
|
|
||||||
self.logger.info(f"Removed marker file {file_name} from {plugin_id} before update")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Could not remove {file_name} from {plugin_id}: {e}")
|
|
||||||
|
|
||||||
# Check for tracked file changes
|
# Check for tracked file changes
|
||||||
status_result = subprocess.run(
|
status_result = subprocess.run(
|
||||||
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'],
|
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'],
|
||||||
@@ -2285,10 +2464,9 @@ class PluginStoreManager:
|
|||||||
)
|
)
|
||||||
has_changes = bool(status_result.stdout.strip())
|
has_changes = bool(status_result.stdout.strip())
|
||||||
|
|
||||||
# If there are remaining untracked files (not safe to remove), stash them
|
# If there are untracked files, stash them
|
||||||
remaining_untracked = [f for f in untracked_files if f not in removed_files]
|
if untracked_files:
|
||||||
if remaining_untracked:
|
self.logger.info(f"Found {len(untracked_files)} untracked files in {plugin_id}, will stash them")
|
||||||
self.logger.info(f"Found {len(remaining_untracked)} untracked files in {plugin_id}, will stash them")
|
|
||||||
has_changes = True
|
has_changes = True
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
# If status check times out, assume there might be changes and proceed
|
# If status check times out, assume there might be changes and proceed
|
||||||
|
|||||||
@@ -7,13 +7,22 @@ Provides base classes and utilities for testing LEDMatrix plugins.
|
|||||||
from .plugin_test_base import PluginTestCase
|
from .plugin_test_base import PluginTestCase
|
||||||
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
|
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
|
||||||
from .visual_display_manager import VisualTestDisplayManager
|
from .visual_display_manager import VisualTestDisplayManager
|
||||||
|
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||||
|
from .sizes import (
|
||||||
|
DEFAULT_TEST_SIZES, SUPPORTED_SIZES, resolve_test_sizes, size_label,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'PluginTestCase',
|
'PluginTestCase',
|
||||||
'VisualTestDisplayManager',
|
'VisualTestDisplayManager',
|
||||||
|
'BoundsCheckingDisplayManager',
|
||||||
'MockDisplayManager',
|
'MockDisplayManager',
|
||||||
'MockCacheManager',
|
'MockCacheManager',
|
||||||
'MockConfigManager',
|
'MockConfigManager',
|
||||||
'MockPluginManager',
|
'MockPluginManager',
|
||||||
|
'DEFAULT_TEST_SIZES',
|
||||||
|
'SUPPORTED_SIZES',
|
||||||
|
'resolve_test_sizes',
|
||||||
|
'size_label',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Bounds-checking display manager.
|
||||||
|
|
||||||
|
A VisualTestDisplayManager that draws onto an oversized canvas (the declared
|
||||||
|
panel size plus a right/bottom margin) while still reporting the declared size
|
||||||
|
to the plugin. Content that a plugin draws past the right or bottom edge lands
|
||||||
|
in the margin instead of being silently clipped by PIL, so the harness can
|
||||||
|
detect overflow — the classic symptom of hardcoded coordinates or fonts/icons
|
||||||
|
that don't scale down to a smaller panel.
|
||||||
|
|
||||||
|
Limitations (documented on purpose):
|
||||||
|
- Overflow past the LEFT or TOP edge (negative coordinates) is still clipped by
|
||||||
|
PIL and not detected here. The dominant real-world breakage is content that is
|
||||||
|
too wide/tall for a smaller panel, which this catches.
|
||||||
|
- BDF text is clipped to the declared bounds by the parent's bitmap drawer, so
|
||||||
|
BDF overflow is not flagged. Golden-image regression covers those plugins.
|
||||||
|
- If a plugin replaces the canvas with its own image (display_manager.image = ...),
|
||||||
|
the margin can't be measured and overflow is reported as undetermined (None).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from .sizes import DEFAULT_TEST_SIZES
|
||||||
|
from .visual_display_manager import VisualTestDisplayManager, _MatrixProxy
|
||||||
|
|
||||||
|
# Smallest extra band kept on the right/bottom so a few pixels of overflow are
|
||||||
|
# still visible even on the largest panel in a run.
|
||||||
|
_BASE_MARGIN = 16
|
||||||
|
# Fallback overflow reference when a caller doesn't pass one: the largest shape
|
||||||
|
# in the default sample. We extend every (smaller) canvas out to at least this
|
||||||
|
# size so content drawn at a coordinate meant for a bigger build — e.g. x=200 on
|
||||||
|
# a 64-wide panel — lands in the padded region and is flagged, instead of being
|
||||||
|
# clipped off-canvas and read as a false pass.
|
||||||
|
_DEFAULT_EXTENT_WIDTH = max(w for w, _ in DEFAULT_TEST_SIZES)
|
||||||
|
_DEFAULT_EXTENT_HEIGHT = max(h for _, h in DEFAULT_TEST_SIZES)
|
||||||
|
|
||||||
|
|
||||||
|
class BoundsCheckingDisplayManager(VisualTestDisplayManager):
|
||||||
|
"""Detects drawing that overflows the declared panel size."""
|
||||||
|
|
||||||
|
# Kept for backwards compatibility; real padding is computed per-axis below.
|
||||||
|
MARGIN = _BASE_MARGIN
|
||||||
|
|
||||||
|
def __init__(self, width: int = 128, height: int = 32,
|
||||||
|
overflow_extent: Optional[Tuple[int, int]] = None):
|
||||||
|
self._declared_width = int(width)
|
||||||
|
self._declared_height = int(height)
|
||||||
|
# Pad the canvas out to at least `overflow_extent` (the largest panel
|
||||||
|
# this run cares about) plus a base margin, so coordinates meant for a
|
||||||
|
# bigger build are caught — not clipped — when rendering a smaller panel.
|
||||||
|
# Defaults to the largest shape in the sample when no run is known.
|
||||||
|
ext_w, ext_h = overflow_extent or (_DEFAULT_EXTENT_WIDTH, _DEFAULT_EXTENT_HEIGHT)
|
||||||
|
self._canvas_width = max(self._declared_width, int(ext_w)) + _BASE_MARGIN
|
||||||
|
self._canvas_height = max(self._declared_height, int(ext_h)) + _BASE_MARGIN
|
||||||
|
# Parent builds the (oversized) backing canvas + fonts.
|
||||||
|
super().__init__(self._canvas_width, self._canvas_height)
|
||||||
|
# Plugins must see the DECLARED size, not the padded canvas size.
|
||||||
|
self.matrix = _MatrixProxy(self._declared_width, self._declared_height)
|
||||||
|
|
||||||
|
# -- declared dimensions (override parent's image-derived properties) --
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> int:
|
||||||
|
return self._declared_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> int:
|
||||||
|
return self._declared_height
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_width(self) -> int:
|
||||||
|
return self._declared_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_height(self) -> int:
|
||||||
|
return self._declared_height
|
||||||
|
|
||||||
|
# -- overflow detection --
|
||||||
|
|
||||||
|
def _canvas_is_padded(self) -> bool:
|
||||||
|
return self.image.size == (self._canvas_width, self._canvas_height)
|
||||||
|
|
||||||
|
def check_overflow(self) -> Optional[Tuple[int, int, int, int]]:
|
||||||
|
"""Bounding box (in full-canvas coords) of any drawing beyond the
|
||||||
|
declared panel, or None if nothing overflowed / undetermined."""
|
||||||
|
if not self._canvas_is_padded():
|
||||||
|
return None
|
||||||
|
|
||||||
|
exp_w = self._canvas_width
|
||||||
|
exp_h = self._canvas_height
|
||||||
|
boxes = []
|
||||||
|
|
||||||
|
right = self.image.crop((self._declared_width, 0, exp_w, exp_h)).getbbox()
|
||||||
|
if right:
|
||||||
|
boxes.append((right[0] + self._declared_width, right[1],
|
||||||
|
right[2] + self._declared_width, right[3]))
|
||||||
|
|
||||||
|
bottom = self.image.crop((0, self._declared_height, exp_w, exp_h)).getbbox()
|
||||||
|
if bottom:
|
||||||
|
boxes.append((bottom[0], bottom[1] + self._declared_height,
|
||||||
|
bottom[2], bottom[3] + self._declared_height))
|
||||||
|
|
||||||
|
if not boxes:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
min(b[0] for b in boxes), min(b[1] for b in boxes),
|
||||||
|
max(b[2] for b in boxes), max(b[3] for b in boxes),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- snapshot/image accessors return the cropped, true-panel image --
|
||||||
|
|
||||||
|
def declared_image(self):
|
||||||
|
"""The visible panel: the canvas cropped to the declared size."""
|
||||||
|
if self._canvas_is_padded():
|
||||||
|
return self.image.crop((0, 0, self._declared_width, self._declared_height))
|
||||||
|
return self.image
|
||||||
|
|
||||||
|
def save_snapshot(self, path: str) -> None:
|
||||||
|
self.declared_image().save(path, format='PNG')
|
||||||
|
|
||||||
|
def get_image(self):
|
||||||
|
return self.declared_image()
|
||||||
|
|
||||||
|
def get_image_base64(self) -> str:
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
self.declared_image().save(buffer, format='PNG')
|
||||||
|
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"""
|
||||||
|
Plugin safety harness.
|
||||||
|
|
||||||
|
Renders a plugin across every declared screen (mode) and every supported matrix
|
||||||
|
size, capturing crashes and overflow. Used by scripts/check_plugin.py and the
|
||||||
|
pytest matrix test to guarantee a plugin change doesn't break a screen at a size
|
||||||
|
the author didn't try.
|
||||||
|
|
||||||
|
The render flow mirrors scripts/render_plugin.py (same PluginLoader call), but
|
||||||
|
this module adds: multi-size iteration, per-mode rendering, overflow detection
|
||||||
|
via BoundsCheckingDisplayManager, and golden-image comparison.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import http.client
|
||||||
|
import inspect
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
|
||||||
|
from src.logging_config import get_logger
|
||||||
|
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||||
|
from .loading import load_config_defaults, load_manifest
|
||||||
|
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
|
||||||
|
|
||||||
|
logger = get_logger("[Plugin Harness]")
|
||||||
|
|
||||||
|
|
||||||
|
def _tolerated_update_errors() -> Tuple[type, ...]:
|
||||||
|
"""Exception types from update() we treat as a tolerated no-connectivity
|
||||||
|
failure (expected in CI / headless dev) rather than a real plugin bug.
|
||||||
|
|
||||||
|
Anything NOT in this set is a genuine regression — a plugin that lets a
|
||||||
|
non-network exception escape update() should fail the harness, not pass
|
||||||
|
green because display() happened to survive.
|
||||||
|
"""
|
||||||
|
types: List[type] = [
|
||||||
|
ConnectionError, TimeoutError, # builtins
|
||||||
|
socket.gaierror, socket.timeout, # DNS / socket timeouts
|
||||||
|
ssl.SSLError,
|
||||||
|
urllib.error.URLError,
|
||||||
|
http.client.HTTPException,
|
||||||
|
]
|
||||||
|
try: # requests is optional; cover its whole error tree when present
|
||||||
|
import requests
|
||||||
|
types.append(requests.exceptions.RequestException)
|
||||||
|
except ImportError: # pragma: no cover - requests not installed
|
||||||
|
logger.debug("requests not installed; its connectivity errors won't be specifically tolerated")
|
||||||
|
return tuple(types)
|
||||||
|
|
||||||
|
|
||||||
|
_TOLERATED_UPDATE_ERRORS = _tolerated_update_errors()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderResult:
|
||||||
|
"""Outcome of rendering one (size, mode) of a plugin."""
|
||||||
|
plugin_id: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
mode: str
|
||||||
|
image: Optional[Image.Image] = None
|
||||||
|
error: Optional[str] = None # fatal: load/display crash, or a non-network update() error
|
||||||
|
update_error: Optional[str] = None # tolerated: connectivity error from update() (no network in CI)
|
||||||
|
overflow: Optional[Tuple[int, int, int, int]] = None # bbox past the panel
|
||||||
|
# golden comparison (populated only when a golden was provided)
|
||||||
|
golden_checked: bool = False
|
||||||
|
golden_ok: Optional[bool] = None
|
||||||
|
golden_diff_pixels: int = 0
|
||||||
|
golden_max_delta: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_label(self) -> str:
|
||||||
|
return size_label(self.width, self.height)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
"""Phase-1 pass: rendered without crashing and without overflow, and if a
|
||||||
|
golden was checked it matched."""
|
||||||
|
if self.error is not None or self.overflow is not None:
|
||||||
|
return False
|
||||||
|
if self.golden_checked and self.golden_ok is False:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_modes(plugin_instance: Any, manifest: Dict[str, Any], plugin_id: str) -> List[str]:
|
||||||
|
"""Enumerate a plugin's screens: instance.modes wins, then manifest
|
||||||
|
display_modes, then the plugin id as a single mode."""
|
||||||
|
modes = getattr(plugin_instance, "modes", None)
|
||||||
|
if modes:
|
||||||
|
return [str(m) for m in modes]
|
||||||
|
declared = manifest.get("display_modes")
|
||||||
|
if declared:
|
||||||
|
return [str(m) for m in declared]
|
||||||
|
return [plugin_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||||
|
config: Dict[str, Any], mock_data: Dict[str, Any],
|
||||||
|
display_manager: Any) -> Any:
|
||||||
|
"""Load and construct a plugin instance with mocked managers."""
|
||||||
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
|
from src.plugin_system.testing import MockCacheManager, MockPluginManager
|
||||||
|
|
||||||
|
cache_manager = MockCacheManager()
|
||||||
|
for key, value in (mock_data or {}).items():
|
||||||
|
cache_manager.set(key, value)
|
||||||
|
|
||||||
|
loader = PluginLoader()
|
||||||
|
plugin_instance, _module = loader.load_plugin(
|
||||||
|
plugin_id=plugin_id,
|
||||||
|
manifest=manifest,
|
||||||
|
plugin_dir=plugin_dir,
|
||||||
|
config=config,
|
||||||
|
display_manager=display_manager,
|
||||||
|
cache_manager=cache_manager,
|
||||||
|
plugin_manager=MockPluginManager(),
|
||||||
|
install_deps=False,
|
||||||
|
)
|
||||||
|
return plugin_instance
|
||||||
|
|
||||||
|
|
||||||
|
def _render_mode(plugin_instance: Any, mode: str) -> None:
|
||||||
|
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||||
|
drive the plugin's internal mode state machine (first display() call renders
|
||||||
|
modes[current_mode_index] when current_display_mode is None)."""
|
||||||
|
sig = inspect.signature(plugin_instance.display)
|
||||||
|
if "display_mode" in sig.parameters:
|
||||||
|
plugin_instance.display(force_clear=True, display_mode=mode)
|
||||||
|
return
|
||||||
|
|
||||||
|
modes = getattr(plugin_instance, "modes", None)
|
||||||
|
if modes and mode in modes:
|
||||||
|
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||||
|
if hasattr(plugin_instance, "current_display_mode"):
|
||||||
|
plugin_instance.current_display_mode = None
|
||||||
|
plugin_instance.display(force_clear=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _freeze(freeze_time: Optional[str]):
|
||||||
|
"""Context manager that freezes wall-clock time when freeze_time is given,
|
||||||
|
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
|
||||||
|
if not freeze_time:
|
||||||
|
return contextlib.nullcontext()
|
||||||
|
try:
|
||||||
|
from freezegun import freeze_time as _ft
|
||||||
|
except ImportError as e: # pragma: no cover - only hit without the dep
|
||||||
|
raise RuntimeError(
|
||||||
|
"freeze_time requires the 'freezegun' package (pip install freezegun)"
|
||||||
|
) from e
|
||||||
|
return _ft(freeze_time)
|
||||||
|
|
||||||
|
|
||||||
|
def render_plugin_matrix(
|
||||||
|
plugin_id: str,
|
||||||
|
plugin_dir: Path,
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
mock_data: Optional[Dict[str, Any]] = None,
|
||||||
|
sizes: Optional[List[Tuple[int, int]]] = None,
|
||||||
|
run_update: bool = True,
|
||||||
|
freeze_time: Optional[str] = None,
|
||||||
|
) -> List[RenderResult]:
|
||||||
|
"""Render every (size, mode) combination for a plugin.
|
||||||
|
|
||||||
|
Returns a flat list of RenderResult. A fresh plugin instance is built per
|
||||||
|
(size, mode) so state never leaks between screens. Pass freeze_time (e.g.
|
||||||
|
"2025-08-01 15:25:00") to make time-dependent plugins reproducible.
|
||||||
|
"""
|
||||||
|
plugin_dir = Path(plugin_dir)
|
||||||
|
manifest = load_manifest(plugin_dir)
|
||||||
|
# Start from config_schema.json defaults so the plugin behaves like a real
|
||||||
|
# install; explicit caller config still wins over a schema default.
|
||||||
|
config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
|
||||||
|
sizes = sizes or DEFAULT_TEST_SIZES
|
||||||
|
results: List[RenderResult] = []
|
||||||
|
|
||||||
|
# The largest panel in this run. Every (smaller) canvas is padded out to it
|
||||||
|
# so a coordinate meant for the biggest configuration is still caught when
|
||||||
|
# rendering a smaller one, instead of being clipped into a false pass.
|
||||||
|
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
|
||||||
|
|
||||||
|
with _freeze(freeze_time):
|
||||||
|
for width, height in sizes:
|
||||||
|
results.extend(_render_size(
|
||||||
|
plugin_id, manifest, plugin_dir, config, mock_data or {},
|
||||||
|
width, height, run_update, extent,
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||||
|
width, height, run_update, extent) -> List[RenderResult]:
|
||||||
|
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
|
||||||
|
results: List[RenderResult] = []
|
||||||
|
|
||||||
|
# Discover modes once per size (instance build can depend on config).
|
||||||
|
try:
|
||||||
|
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||||
|
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm)
|
||||||
|
modes = list_modes(probe, manifest, plugin_id)
|
||||||
|
except Exception as e: # noqa: BLE001 — surface any load failure as a result
|
||||||
|
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
|
||||||
|
|
||||||
|
for mode in modes:
|
||||||
|
result = RenderResult(plugin_id, width, height, mode)
|
||||||
|
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||||
|
try:
|
||||||
|
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm)
|
||||||
|
if run_update:
|
||||||
|
try:
|
||||||
|
inst.update()
|
||||||
|
except _TOLERATED_UPDATE_ERRORS as e:
|
||||||
|
# Expected when CI / headless dev has no network: record it
|
||||||
|
# (surfaced in the report) but don't fail the run.
|
||||||
|
result.update_error = repr(e)
|
||||||
|
logger.debug("update() connectivity error for %s [%s]: %s", plugin_id, mode, e)
|
||||||
|
except Exception as e: # noqa: BLE001 — a non-network update() failure is a real bug
|
||||||
|
# A regression in update() must not pass green just because
|
||||||
|
# display() survives, so treat it as a failure of this render.
|
||||||
|
result.error = repr(e)
|
||||||
|
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||||
|
plugin_id, mode, e)
|
||||||
|
if result.error is None:
|
||||||
|
_render_mode(inst, mode)
|
||||||
|
result.image = dm.get_image()
|
||||||
|
result.overflow = dm.check_overflow()
|
||||||
|
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||||
|
result.error = repr(e)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Golden-image comparison
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compare_images(rendered: Image.Image, golden: Image.Image,
|
||||||
|
max_delta: int = 0, max_diff_pixels: int = 0) -> Tuple[bool, int, int]:
|
||||||
|
"""Compare two images. Returns (ok, diff_pixel_count, max_per_channel_delta).
|
||||||
|
|
||||||
|
Tolerances default to exact match; bump them only to absorb known platform
|
||||||
|
anti-aliasing noise (requires a pinned Pillow + bundled fonts for stability).
|
||||||
|
"""
|
||||||
|
if rendered.size != golden.size:
|
||||||
|
return False, rendered.size[0] * rendered.size[1], 255
|
||||||
|
a = rendered.convert("RGB")
|
||||||
|
b = golden.convert("RGB")
|
||||||
|
diff = ImageChops.difference(a, b)
|
||||||
|
bbox = diff.getbbox()
|
||||||
|
if bbox is None:
|
||||||
|
return True, 0, 0
|
||||||
|
# Count pixels whose largest per-channel delta exceeds the allowed tolerance,
|
||||||
|
# and track the worst delta seen (for reporting).
|
||||||
|
diff_pixels = 0
|
||||||
|
observed_max = 0
|
||||||
|
for px in diff.crop(bbox).getdata():
|
||||||
|
m = max(px) if isinstance(px, tuple) else px
|
||||||
|
if m > observed_max:
|
||||||
|
observed_max = m
|
||||||
|
if m > max_delta:
|
||||||
|
diff_pixels += 1
|
||||||
|
# Pass when the number of out-of-tolerance pixels is within budget.
|
||||||
|
ok = diff_pixels <= max_diff_pixels
|
||||||
|
return ok, diff_pixels, observed_max
|
||||||
|
|
||||||
|
|
||||||
|
def golden_path(golden_dir: Path, width: int, height: int, mode: str) -> Path:
|
||||||
|
"""Location of a golden image: <golden_dir>/<WxH>/<mode>.png.
|
||||||
|
|
||||||
|
The mode is sanitized to a safe basename so a mode name with '/' or '..'
|
||||||
|
can't read or write outside the golden directory.
|
||||||
|
"""
|
||||||
|
return Path(golden_dir) / size_label(width, height) / f"{safe_mode_filename(mode)}.png"
|
||||||
|
|
||||||
|
|
||||||
|
def compare_to_goldens(results: List[RenderResult], golden_dir: Path,
|
||||||
|
max_delta: int = 0, max_diff_pixels: int = 0) -> List[RenderResult]:
|
||||||
|
"""Compare rendered results against committed goldens, mutating each result's
|
||||||
|
golden_* fields. Results with no golden file on disk are left unchecked."""
|
||||||
|
for r in results:
|
||||||
|
if r.image is None:
|
||||||
|
continue
|
||||||
|
gp = golden_path(golden_dir, r.width, r.height, r.mode)
|
||||||
|
if not gp.exists():
|
||||||
|
continue
|
||||||
|
r.golden_checked = True
|
||||||
|
with Image.open(gp) as g:
|
||||||
|
ok, diff_pixels, observed_max = compare_images(
|
||||||
|
r.image, g, max_delta=max_delta, max_diff_pixels=max_diff_pixels)
|
||||||
|
r.golden_ok = ok
|
||||||
|
r.golden_diff_pixels = diff_pixels
|
||||||
|
r.golden_max_delta = observed_max
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def write_goldens(results: List[RenderResult], golden_dir: Path) -> int:
|
||||||
|
"""Write each successfully-rendered result to its golden path. Returns count."""
|
||||||
|
written = 0
|
||||||
|
for r in results:
|
||||||
|
if r.image is None or r.error is not None:
|
||||||
|
continue
|
||||||
|
gp = golden_path(golden_dir, r.width, r.height, r.mode)
|
||||||
|
gp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
r.image.save(gp, format="PNG")
|
||||||
|
written += 1
|
||||||
|
return written
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Shared helpers for loading a plugin headlessly.
|
||||||
|
|
||||||
|
Used by scripts/render_plugin.py, scripts/check_plugin.py, and the harness so
|
||||||
|
plugin discovery / manifest / config-default logic lives in exactly one place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, Sequence, Union
|
||||||
|
|
||||||
|
|
||||||
|
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
||||||
|
"""Find a plugin directory by searching multiple paths."""
|
||||||
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
|
loader = PluginLoader()
|
||||||
|
for search_dir in search_dirs:
|
||||||
|
search_path = Path(search_dir)
|
||||||
|
if not search_path.exists():
|
||||||
|
continue
|
||||||
|
result = loader.find_plugin_directory(plugin_id, search_path)
|
||||||
|
if result:
|
||||||
|
return Path(result)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||||
|
"""Load and return manifest.json from a plugin directory."""
|
||||||
|
manifest_path = Path(plugin_dir) / 'manifest.json'
|
||||||
|
if not manifest_path.exists():
|
||||||
|
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
||||||
|
with open(manifest_path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||||
|
"""Extract default values from a plugin's config_schema.json (empty if none)."""
|
||||||
|
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||||
|
if not schema_path.exists():
|
||||||
|
return {}
|
||||||
|
with open(schema_path, 'r') as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
defaults: Dict[str, Any] = {}
|
||||||
|
for key, prop in schema.get('properties', {}).items():
|
||||||
|
if isinstance(prop, dict) and 'default' in prop:
|
||||||
|
defaults[key] = prop['default']
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||||
|
"""Optional per-plugin harness settings from <plugin>/test/harness.json.
|
||||||
|
|
||||||
|
Lets a plugin opt into golden-image testing by declaring how to render it
|
||||||
|
deterministically. All keys optional:
|
||||||
|
{
|
||||||
|
"config": {...}, # config overrides
|
||||||
|
"mock_data": "fixtures/mock.json", # path (relative to plugin dir) to cache fixtures
|
||||||
|
"freeze_time": "2025-08-01 15:25:00",
|
||||||
|
"skip_update": false
|
||||||
|
}
|
||||||
|
Returns {} when no harness.json exists.
|
||||||
|
"""
|
||||||
|
spec_path = Path(plugin_dir) / 'test' / 'harness.json'
|
||||||
|
if not spec_path.exists():
|
||||||
|
return {}
|
||||||
|
with open(spec_path, 'r') as f:
|
||||||
|
spec = json.load(f)
|
||||||
|
|
||||||
|
# Resolve mock_data path and inline its contents for convenience.
|
||||||
|
mock_rel = spec.get('mock_data')
|
||||||
|
if mock_rel:
|
||||||
|
mock_path = Path(plugin_dir) / mock_rel
|
||||||
|
if not mock_path.exists():
|
||||||
|
# A declared-but-missing fixture is a harness config error: failing
|
||||||
|
# loudly beats silently rendering the plugin with no mock data.
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"harness.json references mock_data '{mock_rel}' but "
|
||||||
|
f"{mock_path} does not exist"
|
||||||
|
)
|
||||||
|
with open(mock_path, 'r') as mf:
|
||||||
|
spec['mock_data_contents'] = json.load(mf)
|
||||||
|
return spec
|
||||||
@@ -63,11 +63,23 @@ class MockCacheManager:
|
|||||||
"""Mock cache manager for testing."""
|
"""Mock cache manager for testing."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import weakref
|
||||||
self._cache: Dict[str, Any] = {}
|
self._cache: Dict[str, Any] = {}
|
||||||
self._cache_timestamps: Dict[str, float] = {}
|
self._cache_timestamps: Dict[str, float] = {}
|
||||||
self.get_calls = []
|
self.get_calls = []
|
||||||
self.set_calls = []
|
self.set_calls = []
|
||||||
self.delete_calls = []
|
self.delete_calls = []
|
||||||
|
# Real temp dir for plugins that write/read files under cache_dir.
|
||||||
|
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
|
||||||
|
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
|
||||||
|
self._finalizer = weakref.finalize(
|
||||||
|
self, shutil.rmtree, self.cache_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Remove the temp cache directory created for this instance."""
|
||||||
|
self._finalizer()
|
||||||
|
|
||||||
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
|
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
|
||||||
"""Get a value from cache."""
|
"""Get a value from cache."""
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
LED matrix sizes the plugin safety harness renders against.
|
||||||
|
|
||||||
|
There is no fixed set of "supported" panel sizes — an RGB matrix build can be
|
||||||
|
any width/height and configuration (square, rectangle, 2x2, 4x4, 8x2, long
|
||||||
|
strips, tall stacks, ...). Plugins are expected to read width/height
|
||||||
|
dynamically and lay themselves out accordingly, so the harness's job is to
|
||||||
|
prove a plugin survives a *spread* of shapes, not a canonical list.
|
||||||
|
|
||||||
|
`DEFAULT_TEST_SIZES` is therefore a representative SAMPLE chosen to span the
|
||||||
|
axes of variation (narrow, wide, square, tall, small, long), not an
|
||||||
|
exhaustive or authoritative list. Callers can override it entirely:
|
||||||
|
|
||||||
|
- CLI: scripts/check_plugin.py --sizes 8x16,64x64,256x32
|
||||||
|
- pytest: LEDMATRIX_TEST_SIZES="8x16,64x64" env var (all plugins), or
|
||||||
|
per-plugin test/harness.json {"sizes": [[8, 16], [64, 64]]}
|
||||||
|
|
||||||
|
so anyone can point the harness at the exact panel(s) their build uses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Iterable, List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
|
# A spread of real panel-grid arrangements (each module is 64x32), not a list of
|
||||||
|
# "blessed" sizes. Each entry exercises a different layout assumption a plugin
|
||||||
|
# might accidentally bake in. Annotations are the panel grid (cols x rows).
|
||||||
|
DEFAULT_TEST_SIZES: List[Tuple[int, int]] = [
|
||||||
|
(64, 32), # 1x1 — single panel, the tightest common rectangle
|
||||||
|
(128, 32), # 2x1 — the baseline most plugins are tuned for
|
||||||
|
(64, 64), # 1x2 — stacked, exercises tall-narrow centering
|
||||||
|
(128, 64), # 2x2 — block, icon scaling / vertical centering
|
||||||
|
(256, 32), # 4x1 — long strip, wide horizontal layout
|
||||||
|
(128, 96), # 2x3 — tall, exercises vertical overflow
|
||||||
|
(256, 128), # 4x4 — large block, both dimensions big at once
|
||||||
|
]
|
||||||
|
|
||||||
|
# Backwards-compatible alias. Prefer DEFAULT_TEST_SIZES in new code — the old
|
||||||
|
# name implied these were the only valid panel sizes, which they are not.
|
||||||
|
SUPPORTED_SIZES = DEFAULT_TEST_SIZES
|
||||||
|
|
||||||
|
|
||||||
|
def size_label(width: int, height: int) -> str:
|
||||||
|
"""Human/path-friendly label for a size, e.g. '128x32'."""
|
||||||
|
return f"{width}x{height}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size_token(token: str) -> Tuple[int, int]:
|
||||||
|
"""Parse a single 'WxH' token into an (int, int) pair.
|
||||||
|
|
||||||
|
Raises ValueError (with a user-friendly message) on malformed input so
|
||||||
|
callers can surface it however they like.
|
||||||
|
"""
|
||||||
|
cleaned = token.strip().lower()
|
||||||
|
if "x" not in cleaned:
|
||||||
|
raise ValueError(f"Invalid size '{token}' (expected WxH, e.g. 128x32)")
|
||||||
|
w, h = cleaned.split("x", 1)
|
||||||
|
try:
|
||||||
|
width, height = int(w), int(h)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid size '{token}' (expected numeric WxH, e.g. 128x32)"
|
||||||
|
) from exc
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid size '{token}' (width and height must be positive, e.g. 128x32)"
|
||||||
|
)
|
||||||
|
return (width, height)
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_sizes(
|
||||||
|
value: Union[str, Iterable[Sequence[int]], None]
|
||||||
|
) -> Optional[List[Tuple[int, int]]]:
|
||||||
|
"""Normalize a size spec into a list of (w, h) tuples, or None if empty.
|
||||||
|
|
||||||
|
Accepts a comma-separated 'WxH,WxH' string (CLI / env var) or an iterable
|
||||||
|
of [w, h] / (w, h) pairs (harness.json). Returns None when value is falsy
|
||||||
|
so callers can fall back to the default sample.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [parse_size_token(tok) for tok in value.split(",") if tok.strip()]
|
||||||
|
sizes: List[Tuple[int, int]] = []
|
||||||
|
for pair in value:
|
||||||
|
w, h = pair # raises if not a 2-element sequence
|
||||||
|
width, height = int(w), int(h)
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError(f"Invalid size pair {pair!r} (width and height must be positive)")
|
||||||
|
sizes.append((width, height))
|
||||||
|
return sizes or None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_test_sizes(
|
||||||
|
spec_sizes: Union[str, Iterable[Sequence[int]], None] = None,
|
||||||
|
) -> List[Tuple[int, int]]:
|
||||||
|
"""Decide which sizes to render, by precedence:
|
||||||
|
|
||||||
|
1. LEDMATRIX_TEST_SIZES env var — a global "test on my hardware" override
|
||||||
|
that wins for every plugin.
|
||||||
|
2. spec_sizes — e.g. a per-plugin harness.json "sizes" list.
|
||||||
|
3. DEFAULT_TEST_SIZES — the representative sample.
|
||||||
|
"""
|
||||||
|
env = coerce_sizes(os.environ.get("LEDMATRIX_TEST_SIZES"))
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
spec = coerce_sizes(spec_sizes)
|
||||||
|
if spec:
|
||||||
|
return spec
|
||||||
|
return list(DEFAULT_TEST_SIZES)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_mode_filename(mode: str) -> str:
|
||||||
|
"""A filesystem-safe basename for a plugin mode.
|
||||||
|
|
||||||
|
Mode names come from plugin metadata/render state, so a value containing
|
||||||
|
'/' or '..' could otherwise escape the intended output directory. Collapse
|
||||||
|
anything that isn't alphanumeric / dash / underscore to '_'.
|
||||||
|
"""
|
||||||
|
cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in mode)
|
||||||
|
return cleaned or "mode"
|
||||||
@@ -454,6 +454,18 @@ class VisualTestDisplayManager:
|
|||||||
"""Check if display is currently scrolling."""
|
"""Check if display is currently scrolling."""
|
||||||
return self._scrolling_state['is_scrolling']
|
return self._scrolling_state['is_scrolling']
|
||||||
|
|
||||||
|
def process_deferred_updates(self):
|
||||||
|
"""Process any deferred updates (no-op for testing).
|
||||||
|
|
||||||
|
Several ticker-style plugins (news, odds-ticker, leaderboard,
|
||||||
|
stock-news, stocks) call this unconditionally between
|
||||||
|
set_scrolling_state() and their scroll-position update, mirroring the
|
||||||
|
real display_manager's deferred-update queue. This double has no such
|
||||||
|
queue, so there is nothing to process — the no-op just lets those
|
||||||
|
plugins render under the harness instead of raising AttributeError.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Utility methods
|
# Utility methods
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -279,6 +279,19 @@ class PluginAdapter:
|
|||||||
# Copy the image to prevent modification
|
# Copy the image to prevent modification
|
||||||
img = cached_image.copy()
|
img = cached_image.copy()
|
||||||
|
|
||||||
|
# Plugins that build their own ticker image via this shared
|
||||||
|
# ScrollHelper's create_scrolling_image() get a solid-black
|
||||||
|
# leading margin exactly `display_width` columns wide baked in
|
||||||
|
# (scroll_helper.py's "initial gap before first item"). Vegas mode
|
||||||
|
# adds its own leading gap/separator around every item already,
|
||||||
|
# so leaving this in stacks a second, uncontrolled blank margin on
|
||||||
|
# top of vegas_scroll.separator_width — making this plugin's
|
||||||
|
# transitions look inconsistent with plugins that provide content
|
||||||
|
# via get_vegas_content() (which carries no such margin). Strip it
|
||||||
|
# here so every plugin contributes only its real content and the
|
||||||
|
# gap between items is governed solely by separator_width.
|
||||||
|
img = self._strip_scroll_padding(img, scroll_helper, plugin_id)
|
||||||
|
|
||||||
# Ensure correct height
|
# Ensure correct height
|
||||||
if img.height != self.display_height:
|
if img.height != self.display_height:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -306,6 +319,69 @@ class PluginAdapter:
|
|||||||
logger.exception("[%s] Error getting scroll_helper content", plugin_id)
|
logger.exception("[%s] Error getting scroll_helper content", plugin_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _strip_scroll_padding(
|
||||||
|
self, img: Image.Image, scroll_helper: Any, plugin_id: str
|
||||||
|
) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Crop off a plugin's own leading/trailing blank margins, if present.
|
||||||
|
|
||||||
|
create_scrolling_image() always pads the *start* of its cached image
|
||||||
|
with exactly `scroll_helper.display_width` columns of solid black
|
||||||
|
(0, 0, 0) ("initial gap before first item"). Some ticker-style plugins
|
||||||
|
also pad the *end* of their own cached image (e.g. so their standalone
|
||||||
|
display exits cleanly before looping). Vegas mode already adds its own
|
||||||
|
gap/separator around every item, so either margin left in place stacks
|
||||||
|
an extra, uncontrolled blank stretch on top of `separator_width` —
|
||||||
|
only when running inside Vegas mode does this matter, since the
|
||||||
|
plugin's own standalone display still wants that margin. Detect solid
|
||||||
|
black margins up to `scroll_helper.display_width` wide on each edge and
|
||||||
|
crop them here. Images built via set_scrolling_image() (no such
|
||||||
|
margins) are left untouched.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
img: Captured scroll_helper.cached_image (already copied)
|
||||||
|
scroll_helper: The plugin's ScrollHelper instance
|
||||||
|
plugin_id: Plugin identifier for logging
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
img, cropped on whichever edge(s) had a matching blank margin
|
||||||
|
"""
|
||||||
|
pad_width = getattr(scroll_helper, 'display_width', None)
|
||||||
|
if not isinstance(pad_width, int) or pad_width <= 0 or pad_width >= img.width:
|
||||||
|
return img
|
||||||
|
|
||||||
|
def is_solid_black(strip: Image.Image) -> bool:
|
||||||
|
return strip.convert('RGB').getextrema() == ((0, 0), (0, 0), (0, 0))
|
||||||
|
|
||||||
|
left = pad_width if is_solid_black(img.crop((0, 0, pad_width, img.height))) else 0
|
||||||
|
right = (
|
||||||
|
pad_width
|
||||||
|
if is_solid_black(img.crop((img.width - pad_width, 0, img.width, img.height)))
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if not left and not right:
|
||||||
|
return img
|
||||||
|
|
||||||
|
# Degenerate case (e.g. an all-black cached image): don't crop past
|
||||||
|
# zero width, just leave the image as-is.
|
||||||
|
if left + right >= img.width:
|
||||||
|
return img
|
||||||
|
|
||||||
|
cropped = img.crop((left, 0, img.width - right, img.height))
|
||||||
|
|
||||||
|
# Both edges matching at once is a much stronger signal of genuine
|
||||||
|
# baked-in padding than a single edge (which has a small chance of
|
||||||
|
# coinciding with real all-black content, e.g. a dark logo touching
|
||||||
|
# one boundary). Log that case at warning level so an unexpected
|
||||||
|
# double-edge crop is easy to spot in the field.
|
||||||
|
log = logger.warning if (left and right) else logger.info
|
||||||
|
log(
|
||||||
|
"[%s] Stripping scroll_helper padding (left=%dpx, right=%dpx): %dpx -> %dpx",
|
||||||
|
plugin_id, left, right, img.width, cropped.width
|
||||||
|
)
|
||||||
|
return cropped
|
||||||
|
|
||||||
def _trigger_scroll_content_generation(
|
def _trigger_scroll_content_generation(
|
||||||
self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any
|
self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any
|
||||||
) -> Optional[Image.Image]:
|
) -> Optional[Image.Image]:
|
||||||
|
|||||||
@@ -1883,7 +1883,96 @@ class WiFiManager:
|
|||||||
|
|
||||||
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
|
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_wifi_radio_state(self) -> Dict:
|
||||||
|
"""
|
||||||
|
Report whether the WiFi radio is currently enabled, plus whether a wired
|
||||||
|
fallback exists. Used by the web UI's radio toggle so it can warn before
|
||||||
|
an action that could disconnect the browser.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
'enabled': Optional[bool], # True/False, or None if undeterminable
|
||||||
|
'ethernet_connected': bool, # wired fallback present
|
||||||
|
'available': bool, # nmcli present / radio state readable
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
ethernet_connected = self._is_ethernet_connected()
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
available = False
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["nmcli", "radio", "wifi"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
status = result.stdout.strip().lower()
|
||||||
|
if status in ("enabled", "disabled"):
|
||||||
|
enabled = status == "enabled"
|
||||||
|
available = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not read WiFi radio state: {e}")
|
||||||
|
return {
|
||||||
|
'enabled': enabled,
|
||||||
|
'ethernet_connected': ethernet_connected,
|
||||||
|
'available': available,
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Turn the WiFi radio on or off.
|
||||||
|
|
||||||
|
Turning the radio OFF from the web interface is dangerous: if the device
|
||||||
|
is reachable only over WiFi, disabling it disconnects the very page that
|
||||||
|
issued the request. To prevent that lockout, disabling is refused unless a
|
||||||
|
wired (Ethernet) fallback is present, or the caller explicitly passes
|
||||||
|
force=True to acknowledge the risk.
|
||||||
|
|
||||||
|
Enabling reuses the hardened _ensure_wifi_radio_enabled() path (handles
|
||||||
|
rfkill soft-blocks + retries). Both directions rely only on
|
||||||
|
`nmcli radio wifi on|off`, which is already covered by the passwordless
|
||||||
|
sudoers allowlist (configure_wifi_permissions.sh) — no new privileged
|
||||||
|
command is introduced.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, human-readable message, reason_code). reason_code is
|
||||||
|
'no_ethernet' when a disable is refused for lockout safety, or a
|
||||||
|
short failure code otherwise; None on success. The web UI keys on
|
||||||
|
'no_ethernet' to decide whether to offer a force-off prompt.
|
||||||
|
"""
|
||||||
|
if enabled:
|
||||||
|
if self._ensure_wifi_radio_enabled():
|
||||||
|
return True, "WiFi radio enabled.", None
|
||||||
|
return False, "Failed to enable WiFi radio. Check logs for details.", 'enable_failed'
|
||||||
|
|
||||||
|
# Disabling — guard against locking the user out of the web interface.
|
||||||
|
if not force and not self._is_ethernet_connected():
|
||||||
|
return False, (
|
||||||
|
"Refusing to disable WiFi: no wired (Ethernet) connection was "
|
||||||
|
"detected, so turning off WiFi would disconnect you from this "
|
||||||
|
"page. Connect Ethernet first, or force it if you're sure."
|
||||||
|
), 'no_ethernet'
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["sudo", "nmcli", "radio", "wifi", "off"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
logger.info("WiFi radio disabled via web interface (force=%s)", force)
|
||||||
|
return True, "WiFi radio disabled.", None
|
||||||
|
logger.warning("Failed to disable WiFi radio: %s", result.stderr.strip())
|
||||||
|
return False, "Failed to disable WiFi radio. Check logs for details.", 'command_failed'
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Command timed out while disabling WiFi radio.", 'timeout'
|
||||||
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
|
logger.error("Error disabling WiFi radio: %s", e, exc_info=True)
|
||||||
|
return False, "An error occurred while disabling WiFi radio.", 'error'
|
||||||
|
|
||||||
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
|
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Enable access point mode
|
Enable access point mode
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ class TestBasketballScoreboardPlugin(PluginTestBase):
|
|||||||
"""Test that plugin has display modes."""
|
"""Test that plugin has display modes."""
|
||||||
manifest = self.load_plugin_manifest(plugin_id)
|
manifest = self.load_plugin_manifest(plugin_id)
|
||||||
assert 'display_modes' in manifest
|
assert 'display_modes' in manifest
|
||||||
assert 'basketball_live' in manifest['display_modes']
|
# Manifest uses league-prefixed modes (nba_, wnba_, ncaam_, ncaaw_)
|
||||||
assert 'basketball_recent' in manifest['display_modes']
|
assert 'nba_live' in manifest['display_modes']
|
||||||
assert 'basketball_upcoming' in manifest['display_modes']
|
assert 'nba_recent' in manifest['display_modes']
|
||||||
|
assert 'nba_upcoming' in manifest['display_modes']
|
||||||
|
|
||||||
def test_plugin_has_get_display_modes(self, plugin_id):
|
def test_plugin_has_get_display_modes(self, plugin_id):
|
||||||
"""Test that plugin can return display modes."""
|
"""Test that plugin can return display modes."""
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the plugin safety harness primitives:
|
||||||
|
bounds detection, image comparison, and mode enumeration.
|
||||||
|
|
||||||
|
These don't load real plugins, so they run anywhere (including core CI where
|
||||||
|
plugin-repos is empty).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.plugin_system.testing.bounds_display_manager import BoundsCheckingDisplayManager
|
||||||
|
from src.plugin_system.testing.harness import (
|
||||||
|
_TOLERATED_UPDATE_ERRORS, compare_images, list_modes,
|
||||||
|
)
|
||||||
|
from src.plugin_system.testing.sizes import (
|
||||||
|
DEFAULT_TEST_SIZES, coerce_sizes, parse_size_token, resolve_test_sizes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBoundsDetection:
|
||||||
|
def test_reports_declared_size_not_canvas_size(self):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
assert dm.width == 64 and dm.height == 32
|
||||||
|
assert dm.matrix.width == 64 and dm.matrix.height == 32
|
||||||
|
# Backing canvas is padded out past the declared panel so far-overshoot
|
||||||
|
# coordinates land on-canvas and get flagged instead of clipped.
|
||||||
|
canvas_w, canvas_h = dm.image.size
|
||||||
|
assert canvas_w > 64 and canvas_h > 32
|
||||||
|
|
||||||
|
def test_far_overshoot_on_small_panel_is_detected(self):
|
||||||
|
# A coordinate meant for a wide build (x past 64) must still be caught
|
||||||
|
# when the declared panel is only 64 wide.
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
dm.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||||
|
bbox = dm.check_overflow()
|
||||||
|
assert bbox is not None
|
||||||
|
assert bbox[0] >= 64
|
||||||
|
|
||||||
|
def test_in_bounds_drawing_has_no_overflow(self):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
dm.draw.rectangle([0, 0, 63, 31], fill=(255, 255, 255))
|
||||||
|
assert dm.check_overflow() is None
|
||||||
|
|
||||||
|
def test_right_overflow_is_detected(self):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
# Draw a few pixels past the right edge.
|
||||||
|
dm.draw.rectangle([60, 5, 70, 10], fill=(255, 0, 0))
|
||||||
|
bbox = dm.check_overflow()
|
||||||
|
assert bbox is not None
|
||||||
|
assert bbox[0] >= 64 # overflow starts at or past the declared width
|
||||||
|
|
||||||
|
def test_bottom_overflow_is_detected(self):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
dm.draw.rectangle([5, 30, 10, 40], fill=(0, 255, 0))
|
||||||
|
bbox = dm.check_overflow()
|
||||||
|
assert bbox is not None
|
||||||
|
assert bbox[3] > 32 # overflow extends past the declared height
|
||||||
|
|
||||||
|
def test_declared_image_is_cropped_to_panel(self):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
assert dm.get_image().size == (64, 32)
|
||||||
|
|
||||||
|
def test_snapshot_saves_cropped_panel(self, tmp_path):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=128, height=32)
|
||||||
|
out = tmp_path / "snap.png"
|
||||||
|
dm.save_snapshot(str(out))
|
||||||
|
with Image.open(out) as img:
|
||||||
|
assert img.size == (128, 32)
|
||||||
|
|
||||||
|
|
||||||
|
class TestArbitraryPanelSizes:
|
||||||
|
"""The harness must handle any panel shape, not a fixed supported list."""
|
||||||
|
|
||||||
|
def test_overflow_extent_pads_to_largest_in_run(self):
|
||||||
|
# A wide run (extent 256) means content at x=200 on a 64-wide panel is
|
||||||
|
# caught; the same draw with a small extent would be clipped (false pass).
|
||||||
|
wide = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(256, 32))
|
||||||
|
wide.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||||
|
assert wide.check_overflow() is not None
|
||||||
|
|
||||||
|
tight = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(64, 32))
|
||||||
|
tight.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||||
|
assert tight.check_overflow() is None # clipped beyond the small canvas
|
||||||
|
|
||||||
|
def test_unusual_shapes_report_their_declared_size(self):
|
||||||
|
for w, h in [(8, 2), (6, 6), (200, 8), (64, 96)]:
|
||||||
|
dm = BoundsCheckingDisplayManager(width=w, height=h)
|
||||||
|
assert dm.width == w and dm.height == h
|
||||||
|
assert dm.matrix.width == w and dm.matrix.height == h
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateErrorClassification:
|
||||||
|
"""update() may fail for lack of network (tolerated) but a logic bug must
|
||||||
|
not pass green just because display() survives."""
|
||||||
|
|
||||||
|
def test_connectivity_errors_are_tolerated(self):
|
||||||
|
import socket
|
||||||
|
import urllib.error
|
||||||
|
for exc in (ConnectionError("x"), TimeoutError("x"), socket.gaierror("x"),
|
||||||
|
urllib.error.URLError("x")):
|
||||||
|
assert isinstance(exc, _TOLERATED_UPDATE_ERRORS)
|
||||||
|
|
||||||
|
def test_logic_errors_are_not_tolerated(self):
|
||||||
|
for exc in (ValueError("x"), KeyError("x"), AttributeError("x"), TypeError("x")):
|
||||||
|
assert not isinstance(exc, _TOLERATED_UPDATE_ERRORS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSizeParsing:
|
||||||
|
def test_parse_size_token_ok(self):
|
||||||
|
assert parse_size_token(" 128X32 ") == (128, 32)
|
||||||
|
|
||||||
|
def test_parse_size_token_rejects_garbage(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_size_token("128xabc")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_size_token("128-32")
|
||||||
|
|
||||||
|
def test_rejects_non_positive_dimensions(self):
|
||||||
|
for bad in ("0x32", "-64x32", "64x0", "64x-1"):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_size_token(bad)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
coerce_sizes([[0, 32]])
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
coerce_sizes("64x-1")
|
||||||
|
|
||||||
|
def test_coerce_sizes_from_string_and_pairs(self):
|
||||||
|
assert coerce_sizes("8x16,64x64") == [(8, 16), (64, 64)]
|
||||||
|
assert coerce_sizes([[8, 16], (64, 64)]) == [(8, 16), (64, 64)]
|
||||||
|
assert coerce_sizes(None) is None
|
||||||
|
assert coerce_sizes("") is None
|
||||||
|
|
||||||
|
def test_resolve_precedence_env_then_spec_then_default(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("LEDMATRIX_TEST_SIZES", raising=False)
|
||||||
|
assert resolve_test_sizes(None) == list(DEFAULT_TEST_SIZES)
|
||||||
|
assert resolve_test_sizes([[8, 16]]) == [(8, 16)]
|
||||||
|
monkeypatch.setenv("LEDMATRIX_TEST_SIZES", "5x5")
|
||||||
|
# env wins over a per-plugin spec
|
||||||
|
assert resolve_test_sizes([[8, 16]]) == [(5, 5)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompareImages:
|
||||||
|
def test_identical_images_match(self):
|
||||||
|
a = Image.new("RGB", (16, 16), (10, 20, 30))
|
||||||
|
b = a.copy()
|
||||||
|
ok, diff_pixels, max_delta = compare_images(a, b)
|
||||||
|
assert ok and diff_pixels == 0 and max_delta == 0
|
||||||
|
|
||||||
|
def test_different_images_fail_at_zero_tolerance(self):
|
||||||
|
a = Image.new("RGB", (16, 16), (0, 0, 0))
|
||||||
|
b = a.copy()
|
||||||
|
b.putpixel((1, 1), (255, 255, 255))
|
||||||
|
ok, diff_pixels, max_delta = compare_images(a, b)
|
||||||
|
assert not ok and diff_pixels == 1 and max_delta == 255
|
||||||
|
|
||||||
|
def test_tolerance_absorbs_small_noise(self):
|
||||||
|
a = Image.new("RGB", (16, 16), (100, 100, 100))
|
||||||
|
b = a.copy()
|
||||||
|
b.putpixel((2, 2), (103, 100, 100)) # delta 3
|
||||||
|
ok, _, max_delta = compare_images(a, b, max_delta=5, max_diff_pixels=0)
|
||||||
|
assert ok and max_delta == 3
|
||||||
|
|
||||||
|
def test_size_mismatch_fails(self):
|
||||||
|
a = Image.new("RGB", (16, 16))
|
||||||
|
b = Image.new("RGB", (32, 16))
|
||||||
|
ok, _, _ = compare_images(a, b)
|
||||||
|
assert not ok
|
||||||
|
|
||||||
|
|
||||||
|
class TestListModes:
|
||||||
|
def test_instance_modes_take_precedence(self):
|
||||||
|
inst = type("P", (), {"modes": ["a", "b"]})()
|
||||||
|
assert list_modes(inst, {"display_modes": ["x"]}, "pid") == ["a", "b"]
|
||||||
|
|
||||||
|
def test_falls_back_to_manifest_display_modes(self):
|
||||||
|
inst = type("P", (), {})()
|
||||||
|
assert list_modes(inst, {"display_modes": ["x", "y"]}, "pid") == ["x", "y"]
|
||||||
|
|
||||||
|
def test_falls_back_to_plugin_id(self):
|
||||||
|
inst = type("P", (), {})()
|
||||||
|
assert list_modes(inst, {}, "pid") == ["pid"]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_check_plugin_cli():
|
||||||
|
"""Load scripts/check_plugin.py by path (it isn't an importable package)."""
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
path = root / "scripts" / "check_plugin.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("check_plugin_cli", path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fixture_plugin(tmp_path, harness):
|
||||||
|
"""Create a minimal plugin dir with a test/harness.json; return its parent
|
||||||
|
(the search dir)."""
|
||||||
|
pdir = tmp_path / "plugins" / "demo-clock"
|
||||||
|
(pdir / "test").mkdir(parents=True)
|
||||||
|
(pdir / "manifest.json").write_text(json.dumps({
|
||||||
|
"id": "demo-clock", "name": "Demo Clock", "version": "1.0.0",
|
||||||
|
"author": "test", "entry_point": "manager.py", "class_name": "DemoClock",
|
||||||
|
"display_modes": ["demo-clock"], "compatible_versions": ["*"],
|
||||||
|
}))
|
||||||
|
(pdir / "test" / "harness.json").write_text(json.dumps(harness))
|
||||||
|
return pdir.parent
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckPluginHonorsHarnessJson:
|
||||||
|
"""Regression: check_plugin.py (the CI tool) must apply test/harness.json so
|
||||||
|
its render reproduces the committed goldens — otherwise time/data-dependent
|
||||||
|
plugins drift on every CI run."""
|
||||||
|
|
||||||
|
def test_harness_json_supplies_render_settings(self, tmp_path, monkeypatch):
|
||||||
|
mod = _load_check_plugin_cli()
|
||||||
|
search = _make_fixture_plugin(tmp_path, {
|
||||||
|
"config": {"timezone": "UTC"},
|
||||||
|
"freeze_time": "2025-08-01 15:25:00",
|
||||||
|
"sizes": [[128, 32]],
|
||||||
|
})
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(mod, "render_plugin_matrix",
|
||||||
|
lambda **kw: captured.update(kw) or [])
|
||||||
|
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
|
||||||
|
mod.check_one(
|
||||||
|
plugin_id="demo-clock", search_dirs=[str(search)], sizes=None,
|
||||||
|
mock_data={}, config={}, run_update=True, out_dir=None,
|
||||||
|
update_golden=False, golden_dir_override=None, freeze_time=None,
|
||||||
|
)
|
||||||
|
assert captured["freeze_time"] == "2025-08-01 15:25:00"
|
||||||
|
assert captured["config"]["timezone"] == "UTC"
|
||||||
|
assert captured["sizes"] == [(128, 32)]
|
||||||
|
|
||||||
|
def test_cli_flags_override_harness_json(self, tmp_path, monkeypatch):
|
||||||
|
mod = _load_check_plugin_cli()
|
||||||
|
search = _make_fixture_plugin(tmp_path, {
|
||||||
|
"config": {"timezone": "UTC"},
|
||||||
|
"freeze_time": "2025-08-01 15:25:00",
|
||||||
|
})
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(mod, "render_plugin_matrix",
|
||||||
|
lambda **kw: captured.update(kw) or [])
|
||||||
|
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
|
||||||
|
mod.check_one(
|
||||||
|
plugin_id="demo-clock", search_dirs=[str(search)], sizes=None,
|
||||||
|
mock_data={}, config={"timezone": "America/New_York"},
|
||||||
|
run_update=True, out_dir=None, update_golden=False,
|
||||||
|
golden_dir_override=None, freeze_time="2030-01-01 00:00:00",
|
||||||
|
)
|
||||||
|
assert captured["freeze_time"] == "2030-01-01 00:00:00"
|
||||||
|
assert captured["config"]["timezone"] == "America/New_York"
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Cross-size / cross-screen plugin safety test.
|
||||||
|
|
||||||
|
For every discovered plugin, render every declared screen at every supported
|
||||||
|
matrix size and assert it: loads, renders without crashing, stays within the
|
||||||
|
panel bounds, and — for plugins that ship golden images — matches them.
|
||||||
|
|
||||||
|
Plugin discovery (first match wins):
|
||||||
|
- $LEDMATRIX_PLUGINS_DIR (os.pathsep-separated list of dirs), else
|
||||||
|
- <project_root>/plugin-repos and <project_root>/plugins
|
||||||
|
|
||||||
|
A plugin opts into golden-image checks by adding test/golden/<WxH>/<mode>.png
|
||||||
|
(and usually test/harness.json for deterministic config / mock data / time).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.testing.harness import (
|
||||||
|
render_plugin_matrix, compare_to_goldens,
|
||||||
|
)
|
||||||
|
from src.plugin_system.testing.loading import load_config_defaults, load_harness_spec
|
||||||
|
from src.plugin_system.testing.sizes import resolve_test_sizes
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
# Set LEDMATRIX_REQUIRE_PLUGINS=1 in any CI/hardware pipeline where plugins are
|
||||||
|
# expected to be present, so a discovery drift (empty search path) fails loudly
|
||||||
|
# instead of silently skipping and losing this safety signal.
|
||||||
|
_REQUIRE_PLUGINS = os.environ.get("LEDMATRIX_REQUIRE_PLUGINS") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_search_dirs() -> List[Path]:
|
||||||
|
env = os.environ.get("LEDMATRIX_PLUGINS_DIR")
|
||||||
|
if env:
|
||||||
|
return [Path(p) for p in env.split(os.pathsep) if p]
|
||||||
|
return [PROJECT_ROOT / "plugin-repos", PROJECT_ROOT / "plugins"]
|
||||||
|
|
||||||
|
|
||||||
|
def _discover() -> Dict[str, Path]:
|
||||||
|
"""Map plugin_id -> plugin_dir for all plugins on the search path."""
|
||||||
|
found: Dict[str, Path] = {}
|
||||||
|
for base in _plugin_search_dirs():
|
||||||
|
if not base.exists():
|
||||||
|
continue
|
||||||
|
for child in sorted(base.iterdir()):
|
||||||
|
if (child / "manifest.json").exists() and child.name not in found:
|
||||||
|
found[child.name] = child
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
_PLUGINS = _discover()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.plugin
|
||||||
|
def test_plugins_were_discovered() -> None:
|
||||||
|
"""Guard against silently skipping the whole matrix when discovery drifts.
|
||||||
|
|
||||||
|
Local dev and the plugin-less core CI legitimately have no plugins, so we
|
||||||
|
skip there; but when LEDMATRIX_REQUIRE_PLUGINS=1 an empty search path is a
|
||||||
|
hard failure rather than a green no-op.
|
||||||
|
"""
|
||||||
|
if _PLUGINS:
|
||||||
|
return
|
||||||
|
search = [str(p) for p in _plugin_search_dirs()]
|
||||||
|
if _REQUIRE_PLUGINS:
|
||||||
|
pytest.fail(
|
||||||
|
"LEDMATRIX_REQUIRE_PLUGINS=1 but no plugins were discovered on the "
|
||||||
|
f"search path: {search}"
|
||||||
|
)
|
||||||
|
pytest.skip(f"no plugins found on the search path: {search}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.plugin
|
||||||
|
@pytest.mark.skipif(not _PLUGINS, reason="no plugins found on the search path")
|
||||||
|
@pytest.mark.parametrize("plugin_id", sorted(_PLUGINS))
|
||||||
|
def test_plugin_renders_across_sizes_and_screens(plugin_id: str) -> None:
|
||||||
|
plugin_dir = _PLUGINS[plugin_id]
|
||||||
|
spec = load_harness_spec(plugin_dir)
|
||||||
|
|
||||||
|
config = {"enabled": True}
|
||||||
|
config.update(load_config_defaults(plugin_dir))
|
||||||
|
config.update(spec.get("config", {}))
|
||||||
|
|
||||||
|
# Sizes: LEDMATRIX_TEST_SIZES env (test on real hardware) wins, then the
|
||||||
|
# plugin's own harness.json "sizes", else the default representative sample.
|
||||||
|
sizes = resolve_test_sizes(spec.get("sizes"))
|
||||||
|
|
||||||
|
results = render_plugin_matrix(
|
||||||
|
plugin_id=plugin_id,
|
||||||
|
plugin_dir=plugin_dir,
|
||||||
|
config=config,
|
||||||
|
mock_data=spec.get("mock_data_contents", {}),
|
||||||
|
sizes=sizes,
|
||||||
|
run_update=not spec.get("skip_update", False),
|
||||||
|
freeze_time=spec.get("freeze_time"),
|
||||||
|
)
|
||||||
|
compare_to_goldens(results, plugin_dir / "test" / "golden")
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
for r in results:
|
||||||
|
if r.error is not None:
|
||||||
|
failures.append(f"{r.size_label} {r.mode}: crashed: {r.error}")
|
||||||
|
elif r.overflow is not None:
|
||||||
|
failures.append(f"{r.size_label} {r.mode}: overflow past panel bbox={r.overflow}")
|
||||||
|
elif r.golden_checked and r.golden_ok is False:
|
||||||
|
failures.append(
|
||||||
|
f"{r.size_label} {r.mode}: golden drift {r.golden_diff_pixels}px "
|
||||||
|
f"(max Δ={r.golden_max_delta})"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not failures, f"{plugin_id} failed:\n " + "\n ".join(failures)
|
||||||
@@ -172,6 +172,16 @@ class TestVisualDisplayManager:
|
|||||||
vdm.set_scrolling_state(False)
|
vdm.set_scrolling_state(False)
|
||||||
assert vdm.is_currently_scrolling() is False
|
assert vdm.is_currently_scrolling() is False
|
||||||
|
|
||||||
|
def test_process_deferred_updates_is_noop(self):
|
||||||
|
# Ticker-style plugins (news, odds-ticker, leaderboard, stock-news,
|
||||||
|
# stocks) call this unconditionally alongside set_scrolling_state();
|
||||||
|
# it must exist and be harmless so those plugins render under the
|
||||||
|
# harness instead of raising AttributeError.
|
||||||
|
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||||
|
vdm.set_scrolling_state(True)
|
||||||
|
vdm.process_deferred_updates() # should not raise
|
||||||
|
assert vdm.is_currently_scrolling() is True
|
||||||
|
|
||||||
def test_format_date_with_ordinal(self):
|
def test_format_date_with_ordinal(self):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||||
|
|||||||
@@ -279,10 +279,23 @@ class TestDiskCache:
|
|||||||
"""Test getting expired cache entry."""
|
"""Test getting expired cache entry."""
|
||||||
cache = DiskCache(cache_dir=str(tmp_path))
|
cache = DiskCache(cache_dir=str(tmp_path))
|
||||||
cache.set("test_key", {"data": "value"})
|
cache.set("test_key", {"data": "value"})
|
||||||
|
|
||||||
# Get with max_age=0 to force expiration
|
# Get with max_age=0 to force expiration
|
||||||
result = cache.get("test_key", max_age=0)
|
result = cache.get("test_key", max_age=0)
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_max_age_none_never_expires(self, tmp_path):
|
||||||
|
"""max_age=None must return persisted records regardless of age.
|
||||||
|
|
||||||
|
Regression: the age comparison raised TypeError for max_age=None,
|
||||||
|
which was swallowed and treated as a miss — silently breaking
|
||||||
|
long-lived state (plugin health/metrics) read across processes.
|
||||||
|
"""
|
||||||
|
cache = DiskCache(cache_dir=str(tmp_path))
|
||||||
|
cache.set("test_key", {"data": "value", "timestamp": 0}) # epoch → very old
|
||||||
|
result = cache.get("test_key", max_age=None)
|
||||||
|
assert result is not None
|
||||||
|
assert result["data"] == "value"
|
||||||
|
|
||||||
def test_get_nonexistent(self, tmp_path):
|
def test_get_nonexistent(self, tmp_path):
|
||||||
"""Test getting non-existent key."""
|
"""Test getting non-existent key."""
|
||||||
|
|||||||
@@ -167,6 +167,151 @@ class TestDisplayControllerLivePriority:
|
|||||||
assert controller.current_display_mode == "test_plugin_live"
|
assert controller.current_display_mode == "test_plugin_live"
|
||||||
assert controller.force_change is True
|
assert controller.force_change is True
|
||||||
|
|
||||||
|
def test_live_priority_resume_continues_rotation(self, test_display_controller):
|
||||||
|
"""Regression: when live priority ends, rotation resumes where it was
|
||||||
|
interrupted, not after the live plugin's mode.
|
||||||
|
|
||||||
|
Without the fix, _apply_live_priority left current_mode_index pointing at
|
||||||
|
the live plugin's slot, so the next rotation step skipped every mode
|
||||||
|
between the interrupted position and the live plugin (e.g. elections,
|
||||||
|
which sits just before a flights plugin in the order)."""
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.available_modes = [
|
||||||
|
"weather", "forecast", "almanac", "election_ticker", "flight_live"
|
||||||
|
]
|
||||||
|
# Rotation is about to show the 3rd mode (index 2).
|
||||||
|
controller.current_mode_index = 2
|
||||||
|
controller.current_display_mode = "almanac"
|
||||||
|
controller._live_resume_index = None
|
||||||
|
|
||||||
|
# Live priority (e.g. planes overhead) preempts -> flight_live (index 4).
|
||||||
|
controller._apply_live_priority("flight_live")
|
||||||
|
assert controller.current_display_mode == "flight_live"
|
||||||
|
assert controller.current_mode_index == 4
|
||||||
|
assert controller._live_resume_index == 2 # saved rotation position
|
||||||
|
|
||||||
|
# Re-checks while the hold continues must not move the saved position.
|
||||||
|
controller._apply_live_priority("flight_live")
|
||||||
|
assert controller._live_resume_index == 2
|
||||||
|
|
||||||
|
# Live priority ends -> resume at the saved index (almanac), so the next
|
||||||
|
# rotation step lands on election_ticker (index 3) rather than skipping it.
|
||||||
|
controller._apply_live_priority(None)
|
||||||
|
assert controller.current_mode_index == 2
|
||||||
|
assert controller.current_display_mode == "almanac"
|
||||||
|
assert controller._live_resume_index is None
|
||||||
|
|
||||||
|
def test_live_priority_no_resume_when_idle(self, test_display_controller):
|
||||||
|
"""No saved position + no live content is a no-op (normal rotation)."""
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.available_modes = ["a", "b", "c"]
|
||||||
|
controller.current_mode_index = 1
|
||||||
|
controller.current_display_mode = "b"
|
||||||
|
controller._live_resume_index = None
|
||||||
|
|
||||||
|
controller._apply_live_priority(None)
|
||||||
|
|
||||||
|
assert controller.current_mode_index == 1
|
||||||
|
assert controller.current_display_mode == "b"
|
||||||
|
|
||||||
|
# --- Round-robin between multiple simultaneous live games --------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _live_plugin(live_modes):
|
||||||
|
"""A mock plugin that is live and reports the given live mode names."""
|
||||||
|
p = MagicMock()
|
||||||
|
p.has_live_priority = MagicMock(return_value=True)
|
||||||
|
p.has_live_content = MagicMock(return_value=True)
|
||||||
|
p.get_live_modes = MagicMock(return_value=list(live_modes))
|
||||||
|
return p
|
||||||
|
|
||||||
|
def test_collect_live_modes_dedupes_multi_mode_plugin(self, test_display_controller):
|
||||||
|
"""A sports plugin registered under several mode keys (one per league)
|
||||||
|
contributes each live mode once, in registration order; plugins with no
|
||||||
|
live content are skipped."""
|
||||||
|
controller = test_display_controller
|
||||||
|
baseball = self._live_plugin(["baseball_live"])
|
||||||
|
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||||
|
idle = MagicMock()
|
||||||
|
idle.has_live_priority = MagicMock(return_value=True)
|
||||||
|
idle.has_live_content = MagicMock(return_value=False)
|
||||||
|
controller.plugin_modes = {
|
||||||
|
"baseball_live": baseball,
|
||||||
|
"baseball_recent": baseball,
|
||||||
|
"soccer_fifa.world_live": soccer,
|
||||||
|
"soccer_usa.1_live": soccer,
|
||||||
|
"soccer_recent": soccer,
|
||||||
|
"clock": idle,
|
||||||
|
}
|
||||||
|
assert controller._collect_live_modes() == [
|
||||||
|
"baseball_live", "soccer_fifa.world_live"
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_round_robin_alternates_between_simultaneous_live_games(self, test_display_controller):
|
||||||
|
"""Regression: with two games live at once, the live-priority pick
|
||||||
|
round-robins each dwell instead of pinning to the first plugin in
|
||||||
|
registration order (the bug where a baseball game hid a live World Cup
|
||||||
|
match)."""
|
||||||
|
controller = test_display_controller
|
||||||
|
baseball = self._live_plugin(["baseball_live"])
|
||||||
|
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||||
|
controller.plugin_modes = {
|
||||||
|
"baseball_live": baseball,
|
||||||
|
"soccer_fifa.world_live": soccer,
|
||||||
|
}
|
||||||
|
# First entry into live priority from an ambient mode -> first live game.
|
||||||
|
controller.current_display_mode = "clock"
|
||||||
|
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||||
|
# The controller switches to it; the next dwell advances to the other.
|
||||||
|
controller.current_display_mode = "baseball_live"
|
||||||
|
assert controller._check_live_priority(advance=True) == "soccer_fifa.world_live"
|
||||||
|
# And wraps back again.
|
||||||
|
controller.current_display_mode = "soccer_fifa.world_live"
|
||||||
|
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||||
|
|
||||||
|
def test_single_live_game_holds_without_flipping(self, test_display_controller):
|
||||||
|
"""One live game: advancing returns the same mode, so the hold is stable."""
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.plugin_modes = {"baseball_live": self._live_plugin(["baseball_live"])}
|
||||||
|
controller.current_display_mode = "baseball_live"
|
||||||
|
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||||
|
|
||||||
|
def test_non_advancing_peek_does_not_rotate(self, test_display_controller):
|
||||||
|
"""The default (advance=False) peek used by the Vegas coordinator must
|
||||||
|
not spin the cursor: it returns the live mode already on screen."""
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.plugin_modes = {
|
||||||
|
"baseball_live": self._live_plugin(["baseball_live"]),
|
||||||
|
"soccer_fifa.world_live": self._live_plugin(["soccer_fifa.world_live"]),
|
||||||
|
}
|
||||||
|
controller.current_display_mode = "soccer_fifa.world_live"
|
||||||
|
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||||
|
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||||
|
# From an ambient mode the peek reports the first live game (truthy).
|
||||||
|
controller.current_display_mode = "clock"
|
||||||
|
assert controller._check_live_priority() == "baseball_live"
|
||||||
|
|
||||||
|
def test_no_live_content_returns_none(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
idle = MagicMock()
|
||||||
|
idle.has_live_priority = MagicMock(return_value=True)
|
||||||
|
idle.has_live_content = MagicMock(return_value=False)
|
||||||
|
controller.plugin_modes = {"clock": idle}
|
||||||
|
controller.current_display_mode = "clock"
|
||||||
|
assert controller._check_live_priority(advance=True) is None
|
||||||
|
|
||||||
|
def test_fallback_to_mode_name_when_get_live_modes_unhelpful(self, test_display_controller):
|
||||||
|
"""A live plugin whose get_live_modes returns nothing registered falls
|
||||||
|
back to its own '_live' mode name (legacy behavior preserved)."""
|
||||||
|
controller = test_display_controller
|
||||||
|
legacy = MagicMock()
|
||||||
|
legacy.has_live_priority = MagicMock(return_value=True)
|
||||||
|
legacy.has_live_content = MagicMock(return_value=True)
|
||||||
|
legacy.get_live_modes = MagicMock(return_value=["unregistered_mode"])
|
||||||
|
controller.plugin_modes = {"hockey_live": legacy}
|
||||||
|
controller.current_display_mode = "clock"
|
||||||
|
assert controller._check_live_priority(advance=True) == "hockey_live"
|
||||||
|
|
||||||
|
|
||||||
class TestDisplayControllerDynamicDuration:
|
class TestDisplayControllerDynamicDuration:
|
||||||
"""Test dynamic duration handling."""
|
"""Test dynamic duration handling."""
|
||||||
@@ -229,18 +374,33 @@ class TestDisplayControllerSchedule:
|
|||||||
def test_inactive_hours(self, test_display_controller):
|
def test_inactive_hours(self, test_display_controller):
|
||||||
"""Test inactive hours check."""
|
"""Test inactive hours check."""
|
||||||
controller = test_display_controller
|
controller = test_display_controller
|
||||||
|
# Inject schedule directly into self.config (what _check_schedule actually reads)
|
||||||
|
# and reset the minute gate so the cached result from any prior call is cleared.
|
||||||
|
controller.config['schedule'] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "17:00",
|
||||||
|
}
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
with patch('src.display_controller.datetime') as mock_datetime:
|
with patch('src.display_controller.datetime') as mock_datetime:
|
||||||
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
||||||
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
|
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
|
||||||
mock_datetime.strptime = datetime.strptime
|
mock_datetime.strptime = datetime.strptime
|
||||||
|
|
||||||
schedule_config = {
|
controller._check_schedule()
|
||||||
"schedule": {
|
assert controller.is_display_active is False
|
||||||
"enabled": True,
|
|
||||||
"start_time": "09:00",
|
|
||||||
"end_time": "17:00"
|
class TestPluginHealthWiring:
|
||||||
}
|
"""Phase 1: DisplayController activates the dormant plugin health/metrics
|
||||||
}
|
subsystem by wiring real tracker/monitor instances onto the plugin manager."""
|
||||||
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
|
|
||||||
controller._check_schedule()
|
def test_health_tracker_and_resource_monitor_wired(self, test_display_controller):
|
||||||
assert controller.is_display_active is False
|
from src.plugin_system.plugin_health import PluginHealthTracker
|
||||||
|
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||||
|
|
||||||
|
pm = test_display_controller.plugin_manager
|
||||||
|
assert isinstance(pm.health_tracker, PluginHealthTracker)
|
||||||
|
assert isinstance(pm.resource_monitor, PluginResourceMonitor)
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Tests for the three display_controller.py optimizations:
|
||||||
|
|
||||||
|
Opt #1 — inspect.signature() caching per plugin_id
|
||||||
|
Opt #2 — pre-cached config values (_normal_brightness, _scroll_speed)
|
||||||
|
Opt #3 — schedule minute-gate (_check_schedule, _check_dim_schedule)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock, patch, call
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controller(test_display_controller):
|
||||||
|
"""Return a ready DisplayController from the existing suite fixture."""
|
||||||
|
return test_display_controller
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Opt #1 — signature cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSignatureCache:
|
||||||
|
"""inspect.signature() should be called at most once per plugin_id."""
|
||||||
|
|
||||||
|
class _PluginWithMode:
|
||||||
|
"""Real class whose display() accepts display_mode — inspectable by signature."""
|
||||||
|
plugin_id = "mode_plugin"
|
||||||
|
def display(self, display_mode=None, force_clear=False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
class _PluginNoMode:
|
||||||
|
"""Real class whose display() does NOT accept display_mode."""
|
||||||
|
plugin_id = "no_mode_plugin"
|
||||||
|
def display(self, force_clear=False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def test_cache_starts_empty(self, controller):
|
||||||
|
assert controller._plugin_accepts_display_mode == {}
|
||||||
|
|
||||||
|
def test_signature_computed_and_cached(self, controller):
|
||||||
|
"""After the first cache population, the dict holds a bool and stays unchanged
|
||||||
|
if queried again without explicitly deleting the key."""
|
||||||
|
import inspect as _inspect
|
||||||
|
plugin = self._PluginNoMode()
|
||||||
|
key = "sig_test"
|
||||||
|
if key not in controller._plugin_accepts_display_mode:
|
||||||
|
controller._plugin_accepts_display_mode[key] = (
|
||||||
|
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||||
|
)
|
||||||
|
original = controller._plugin_accepts_display_mode[key]
|
||||||
|
|
||||||
|
# Accessing cache again should not change the value
|
||||||
|
second = controller._plugin_accepts_display_mode[key]
|
||||||
|
assert second == original
|
||||||
|
|
||||||
|
def test_cache_stores_false_for_no_display_mode(self, controller):
|
||||||
|
"""Plugin whose display() doesn't accept display_mode → cached False."""
|
||||||
|
import inspect as _inspect
|
||||||
|
plugin = self._PluginNoMode()
|
||||||
|
controller._plugin_accepts_display_mode["no_mode_plugin"] = (
|
||||||
|
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||||
|
)
|
||||||
|
assert controller._plugin_accepts_display_mode["no_mode_plugin"] is False
|
||||||
|
|
||||||
|
def test_cache_stores_true_for_display_mode(self, controller):
|
||||||
|
"""Plugin whose display() accepts display_mode → cached True."""
|
||||||
|
import inspect as _inspect
|
||||||
|
plugin = self._PluginWithMode()
|
||||||
|
controller._plugin_accepts_display_mode["mode_plugin"] = (
|
||||||
|
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||||
|
)
|
||||||
|
assert controller._plugin_accepts_display_mode["mode_plugin"] is True
|
||||||
|
|
||||||
|
def test_cache_cleared_on_plugin_reload(self, controller):
|
||||||
|
"""Populating plugin_modes for an id that's already cached must clear the entry."""
|
||||||
|
plugin = MagicMock()
|
||||||
|
controller._plugin_accepts_display_mode["reload_plugin"] = False
|
||||||
|
|
||||||
|
# Simulate the plugin_modes population code path (as in __init__)
|
||||||
|
plugin_id = "reload_plugin"
|
||||||
|
controller.plugin_modes["reload_plugin"] = plugin
|
||||||
|
if hasattr(controller, "_plugin_accepts_display_mode"):
|
||||||
|
controller._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||||
|
|
||||||
|
assert "reload_plugin" not in controller._plugin_accepts_display_mode
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Opt #2 — cached config values
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCachedConfigValues:
|
||||||
|
"""_normal_brightness and _scroll_speed are populated from config at init."""
|
||||||
|
|
||||||
|
def test_normal_brightness_cached(self, controller):
|
||||||
|
"""_normal_brightness must equal what the config says."""
|
||||||
|
expected = (
|
||||||
|
controller.config
|
||||||
|
.get("display", {})
|
||||||
|
.get("hardware", {})
|
||||||
|
.get("brightness", 90)
|
||||||
|
)
|
||||||
|
assert controller._normal_brightness == expected
|
||||||
|
|
||||||
|
def test_scroll_speed_cached(self, controller):
|
||||||
|
"""_scroll_speed must equal what the config says."""
|
||||||
|
expected = (
|
||||||
|
controller.config
|
||||||
|
.get("display", {})
|
||||||
|
.get("vegas_scroll", {})
|
||||||
|
.get("scroll_speed", 75)
|
||||||
|
)
|
||||||
|
assert controller._scroll_speed == expected
|
||||||
|
|
||||||
|
def test_current_brightness_uses_cached_value(self, controller):
|
||||||
|
"""current_brightness is initialised from _normal_brightness."""
|
||||||
|
assert controller.current_brightness == controller._normal_brightness
|
||||||
|
|
||||||
|
def test_cached_target_brightness_init(self, controller):
|
||||||
|
"""_cached_target_brightness starts equal to _normal_brightness."""
|
||||||
|
assert controller._cached_target_brightness == controller._normal_brightness
|
||||||
|
|
||||||
|
def test_normal_brightness_default_is_90(self, controller):
|
||||||
|
"""If config has no brightness key the default is 90."""
|
||||||
|
controller.config = {}
|
||||||
|
controller._normal_brightness = (
|
||||||
|
controller.config.get("display", {})
|
||||||
|
.get("hardware", {})
|
||||||
|
.get("brightness", 90)
|
||||||
|
)
|
||||||
|
assert controller._normal_brightness == 90
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Opt #3 — schedule minute-gate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestScheduleMinuteGate:
|
||||||
|
"""_check_schedule and _check_dim_schedule skip re-evaluation within the same minute."""
|
||||||
|
|
||||||
|
# ── _check_schedule ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_schedule_checked_minute_starts_none(self, controller):
|
||||||
|
assert controller._schedule_checked_minute is None
|
||||||
|
|
||||||
|
def test_first_call_sets_checked_minute(self, controller):
|
||||||
|
"""After the first real evaluation the minute key is stored."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller._schedule_checked_minute is not None
|
||||||
|
|
||||||
|
def test_second_call_same_minute_does_not_re_evaluate(self, controller):
|
||||||
|
"""A second call with the same (hour, minute) returns without changing state."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._tz = None
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
|
||||||
|
# First call — evaluates and marks as active (whole-day window)
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller.is_display_active is True
|
||||||
|
first_minute_key = controller._schedule_checked_minute
|
||||||
|
|
||||||
|
# Force is_display_active to False so we can tell if it gets re-evaluated
|
||||||
|
controller.is_display_active = False
|
||||||
|
|
||||||
|
# Second call within the same minute — gate fires, is_display_active unchanged
|
||||||
|
controller._schedule_checked_minute = first_minute_key # same minute
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller.is_display_active is False, (
|
||||||
|
"Second call in same minute should return immediately without re-evaluation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_new_minute_forces_re_evaluation(self, controller):
|
||||||
|
"""A different (hour, minute) key causes a full re-evaluation."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
# Plant a stale minute key from yesterday
|
||||||
|
controller._schedule_checked_minute = (-1, -1)
|
||||||
|
controller.is_display_active = False # wrong value to be corrected
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller.is_display_active is True, (
|
||||||
|
"A new minute key should trigger re-evaluation and correct is_display_active"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_gate_skipped_when_schedule_disabled(self, controller):
|
||||||
|
"""When schedule.enabled=False the method returns before reaching the gate."""
|
||||||
|
controller.config["schedule"] = {"enabled": False}
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
# The early-return path doesn't set the minute key
|
||||||
|
assert controller._schedule_checked_minute is None
|
||||||
|
|
||||||
|
# ── _check_dim_schedule ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_dim_checked_minute_starts_none(self, controller):
|
||||||
|
assert controller._dim_checked_minute is None
|
||||||
|
|
||||||
|
def test_first_dim_call_sets_checked_minute(self, controller):
|
||||||
|
"""First call with dim schedule enabled stores the minute key."""
|
||||||
|
controller.config["dim_schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "22:00",
|
||||||
|
"end_time": "06:00",
|
||||||
|
}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_dim_schedule()
|
||||||
|
assert controller._dim_checked_minute is not None
|
||||||
|
|
||||||
|
def test_dim_second_call_returns_cached_brightness(self, controller):
|
||||||
|
"""Second call with same minute returns _cached_target_brightness immediately."""
|
||||||
|
controller.config["dim_schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "22:00",
|
||||||
|
"end_time": "06:00",
|
||||||
|
}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
# First call stores the result
|
||||||
|
first_result = controller._check_dim_schedule()
|
||||||
|
assert controller._cached_target_brightness == first_result
|
||||||
|
minute_key = controller._dim_checked_minute
|
||||||
|
|
||||||
|
# Corrupt cached value to something recognisable
|
||||||
|
controller._cached_target_brightness = 42
|
||||||
|
|
||||||
|
# Second call in same minute — must return the cached 42
|
||||||
|
controller._dim_checked_minute = minute_key
|
||||||
|
second_result = controller._check_dim_schedule()
|
||||||
|
assert second_result == 42, (
|
||||||
|
"Same-minute call must return cached brightness, not re-compute"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dim_gate_skipped_when_display_off(self, controller):
|
||||||
|
"""When display is off the method exits before the minute gate."""
|
||||||
|
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
|
||||||
|
controller.is_display_active = False
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_dim_schedule()
|
||||||
|
# Early-exit path does not set the minute key
|
||||||
|
assert controller._dim_checked_minute is None
|
||||||
|
|
||||||
|
def test_dim_cached_target_brightness_updated_after_full_evaluation(self, controller):
|
||||||
|
"""After a full evaluation _cached_target_brightness reflects the result."""
|
||||||
|
controller.config["dim_schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "22:00",
|
||||||
|
"end_time": "06:00",
|
||||||
|
}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._dim_checked_minute = None # force full re-evaluation
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
result = controller._check_dim_schedule()
|
||||||
|
assert controller._cached_target_brightness == result
|
||||||
|
|
||||||
|
# ── timezone lazy init ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_tz_starts_none(self, controller):
|
||||||
|
assert controller._tz is None
|
||||||
|
|
||||||
|
def test_tz_lazily_initialised_on_first_schedule_check(self, controller):
|
||||||
|
"""_tz is None until _check_schedule or _check_dim_schedule is called."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._tz = None
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller._tz is not None
|
||||||
|
|
||||||
|
def test_tz_shared_between_schedule_and_dim(self, controller):
|
||||||
|
"""Both methods use the same cached _tz instance."""
|
||||||
|
controller.config["schedule"] = {"enabled": True, "start_time": "00:00", "end_time": "23:59"}
|
||||||
|
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._tz = None
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
tz_after_schedule = controller._tz
|
||||||
|
|
||||||
|
controller._check_dim_schedule()
|
||||||
|
assert controller._tz is tz_after_schedule, (
|
||||||
|
"_check_dim_schedule should reuse the _tz set by _check_schedule"
|
||||||
|
)
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""Tests for live plugin enable/disable hot-reload in DisplayController.
|
||||||
|
|
||||||
|
Enabling or disabling a plugin in config used to require a full display
|
||||||
|
restart because the plugin list and available_modes were built once at init.
|
||||||
|
These tests cover the reconcile path that loads/unloads plugins and rebuilds
|
||||||
|
the dispatch maps on the main thread when the enabled set changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
def _make_plugin(modes):
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.modes = list(modes)
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_plugin_manager(controller, plugins, discovered=None):
|
||||||
|
"""Point the controller's mock plugin_manager at a set of fake plugins.
|
||||||
|
|
||||||
|
`plugins` maps plugin_id -> mock instance (with a .modes list).
|
||||||
|
"""
|
||||||
|
pm = controller.plugin_manager
|
||||||
|
pm.discover_plugins.return_value = list(discovered if discovered is not None else plugins.keys())
|
||||||
|
pm.load_plugin.return_value = True
|
||||||
|
pm.unload_plugin.return_value = True
|
||||||
|
pm.plugin_manifests = {}
|
||||||
|
pm.get_plugin.side_effect = lambda pid: plugins.get(pid)
|
||||||
|
return pm
|
||||||
|
|
||||||
|
|
||||||
|
def _set_config(controller, cfg):
|
||||||
|
controller.config_service.get_config = lambda: cfg
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginEnableDisableHotReload:
|
||||||
|
def test_enable_plugin_live(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
assert controller.available_modes == []
|
||||||
|
|
||||||
|
plugin = _make_plugin(["foo"])
|
||||||
|
_wire_plugin_manager(controller, {"foo": plugin})
|
||||||
|
_set_config(controller, {"foo": {"enabled": True}})
|
||||||
|
|
||||||
|
controller._reconcile_enabled_plugins()
|
||||||
|
|
||||||
|
assert "foo" in controller.plugin_display_modes
|
||||||
|
assert "foo" in controller.available_modes
|
||||||
|
assert controller.plugin_modes["foo"] is plugin
|
||||||
|
assert controller.mode_to_plugin_id["foo"] == "foo"
|
||||||
|
controller.plugin_manager.load_plugin.assert_any_call("foo")
|
||||||
|
|
||||||
|
def test_disable_plugin_live(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
plugin = _make_plugin(["live", "recent"])
|
||||||
|
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
|
||||||
|
|
||||||
|
# Enable, then disable.
|
||||||
|
_set_config(controller, {"sports": {"enabled": True}})
|
||||||
|
controller._reconcile_enabled_plugins()
|
||||||
|
assert "sports" in controller.plugin_display_modes
|
||||||
|
assert "live" in controller.available_modes and "recent" in controller.available_modes
|
||||||
|
assert "sports" in controller._plugin_config_callbacks
|
||||||
|
|
||||||
|
_set_config(controller, {"sports": {"enabled": False}})
|
||||||
|
controller._reconcile_enabled_plugins()
|
||||||
|
|
||||||
|
assert "sports" not in controller.plugin_display_modes
|
||||||
|
assert "live" not in controller.available_modes
|
||||||
|
assert "recent" not in controller.available_modes
|
||||||
|
assert "live" not in controller.plugin_modes
|
||||||
|
assert "recent" not in controller.mode_to_plugin_id
|
||||||
|
controller.plugin_manager.unload_plugin.assert_any_call("sports")
|
||||||
|
assert "sports" not in controller._plugin_config_callbacks
|
||||||
|
|
||||||
|
def test_disable_clamps_current_mode_index(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
p1 = _make_plugin(["a"])
|
||||||
|
p2 = _make_plugin(["b"])
|
||||||
|
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
|
||||||
|
|
||||||
|
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
|
||||||
|
controller._reconcile_enabled_plugins()
|
||||||
|
# Add order across multiple plugins is set-driven (as at init), so
|
||||||
|
# compare membership, not order.
|
||||||
|
assert set(controller.available_modes) == {"a", "b"}
|
||||||
|
|
||||||
|
# Pretend we're currently showing p2's mode.
|
||||||
|
controller.current_mode_index = controller.available_modes.index("b")
|
||||||
|
controller.current_display_mode = "b"
|
||||||
|
|
||||||
|
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": False}})
|
||||||
|
controller._reconcile_enabled_plugins()
|
||||||
|
|
||||||
|
assert controller.available_modes == ["a"]
|
||||||
|
# Index must be back in range and the display mode no longer the removed one.
|
||||||
|
assert 0 <= controller.current_mode_index < len(controller.available_modes)
|
||||||
|
assert controller.current_display_mode == "a"
|
||||||
|
|
||||||
|
def test_enable_keeps_current_mode(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
p1 = _make_plugin(["a"])
|
||||||
|
p2 = _make_plugin(["b"])
|
||||||
|
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
|
||||||
|
|
||||||
|
_set_config(controller, {"p1": {"enabled": True}})
|
||||||
|
controller._reconcile_enabled_plugins()
|
||||||
|
controller.current_mode_index = 0
|
||||||
|
controller.current_display_mode = "a"
|
||||||
|
|
||||||
|
# Enabling p2 should not disturb the currently-showing mode.
|
||||||
|
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
|
||||||
|
controller._reconcile_enabled_plugins()
|
||||||
|
|
||||||
|
assert "b" in controller.available_modes
|
||||||
|
assert controller.current_display_mode == "a"
|
||||||
|
assert controller.available_modes[controller.current_mode_index] == "a"
|
||||||
|
|
||||||
|
def test_noop_when_enabled_set_unchanged(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
plugin = _make_plugin(["foo"])
|
||||||
|
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
|
||||||
|
_set_config(controller, {"foo": {"enabled": True}})
|
||||||
|
controller._reconcile_enabled_plugins()
|
||||||
|
|
||||||
|
load_calls = controller.plugin_manager.load_plugin.call_count
|
||||||
|
unload_calls = controller.plugin_manager.unload_plugin.call_count
|
||||||
|
|
||||||
|
# Reconcile again with no change — must not load/unload anything.
|
||||||
|
controller._reconcile_enabled_plugins()
|
||||||
|
assert controller.plugin_manager.load_plugin.call_count == load_calls
|
||||||
|
assert controller.plugin_manager.unload_plugin.call_count == unload_calls
|
||||||
|
|
||||||
|
def test_reconcile_ignores_non_dict_config_value(self, test_display_controller, caplog):
|
||||||
|
"""A malformed config value (e.g. a stray string where a plugin's
|
||||||
|
section should be a dict) must be treated as disabled, not crash
|
||||||
|
the reconcile with AttributeError, and should be logged so it's
|
||||||
|
visible to whoever has to debug the malformed config."""
|
||||||
|
controller = test_display_controller
|
||||||
|
plugin = _make_plugin(["foo"])
|
||||||
|
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
|
||||||
|
_set_config(controller, {"foo": "not-a-dict"})
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
controller._reconcile_enabled_plugins() # must not raise
|
||||||
|
|
||||||
|
assert "foo" not in controller.plugin_display_modes
|
||||||
|
assert "foo" not in controller.available_modes
|
||||||
|
assert any("foo" in r.message and "not a dict" in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
def test_disable_keeps_callback_when_unsubscribe_fails(self, test_display_controller):
|
||||||
|
"""If config_service.unsubscribe() raises, _unregister_plugin must
|
||||||
|
keep the callback in _plugin_config_callbacks rather than losing the
|
||||||
|
only reference to it (it still tears down the plugin itself)."""
|
||||||
|
controller = test_display_controller
|
||||||
|
plugin = _make_plugin(["live"])
|
||||||
|
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
|
||||||
|
|
||||||
|
_set_config(controller, {"sports": {"enabled": True}})
|
||||||
|
controller._reconcile_enabled_plugins()
|
||||||
|
assert "sports" in controller._plugin_config_callbacks
|
||||||
|
|
||||||
|
controller.config_service.unsubscribe = MagicMock(side_effect=RuntimeError("boom"))
|
||||||
|
|
||||||
|
_set_config(controller, {"sports": {"enabled": False}})
|
||||||
|
controller._reconcile_enabled_plugins()
|
||||||
|
|
||||||
|
assert "sports" not in controller.plugin_display_modes
|
||||||
|
assert "sports" in controller._plugin_config_callbacks
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcileReturnValue:
|
||||||
|
"""_reconcile_enabled_plugins() returns True/False so the caller (run()'s
|
||||||
|
loop) only clears _pending_plugin_reconcile on success, keeping a
|
||||||
|
retryable failure's request alive instead of silently dropping it."""
|
||||||
|
|
||||||
|
def test_returns_true_on_success(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
plugin = _make_plugin(["foo"])
|
||||||
|
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
|
||||||
|
_set_config(controller, {"foo": {"enabled": True}})
|
||||||
|
assert controller._reconcile_enabled_plugins() is True
|
||||||
|
|
||||||
|
def test_returns_true_for_noop(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
_wire_plugin_manager(controller, {}, discovered=[])
|
||||||
|
_set_config(controller, {})
|
||||||
|
assert controller._reconcile_enabled_plugins() is True
|
||||||
|
|
||||||
|
def test_returns_false_on_discovery_failure(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.plugin_manager.discover_plugins.side_effect = RuntimeError("boom")
|
||||||
|
_set_config(controller, {})
|
||||||
|
assert controller._reconcile_enabled_plugins() is False
|
||||||
|
|
||||||
|
def test_returns_true_when_no_plugin_manager(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.plugin_manager = None
|
||||||
|
assert controller._reconcile_enabled_plugins() is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunWithNoModesEnabled:
|
||||||
|
"""Before hot-reload, an empty available_modes at startup was permanent
|
||||||
|
-- the display never came back without a restart. Now that a plugin can
|
||||||
|
be enabled live from the web UI, run() must idle rather than exit."""
|
||||||
|
|
||||||
|
def test_idles_instead_of_exiting(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
assert controller.available_modes == []
|
||||||
|
|
||||||
|
sleep_calls = []
|
||||||
|
|
||||||
|
def fake_sleep(duration, tick_interval=1.0):
|
||||||
|
sleep_calls.append(duration)
|
||||||
|
if len(sleep_calls) >= 3:
|
||||||
|
# Stand in for the process being torn down; run() catches
|
||||||
|
# this via its broad except + finally, same as any other
|
||||||
|
# unexpected error during the loop.
|
||||||
|
raise RuntimeError("stop-test-loop")
|
||||||
|
|
||||||
|
controller._sleep_with_plugin_updates = fake_sleep
|
||||||
|
|
||||||
|
controller.run()
|
||||||
|
|
||||||
|
# Old behavior returned before ever reaching the loop body, so
|
||||||
|
# _sleep_with_plugin_updates would never have been called. The idle
|
||||||
|
# tick is short (not a long sleep) so a plugin enabled via the web
|
||||||
|
# UI while idle is picked up about as promptly as it would be once
|
||||||
|
# modes exist and the loop is iterating per-frame.
|
||||||
|
assert sleep_calls == [1, 1, 1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnabledSetChanged:
|
||||||
|
def test_detects_toggle(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
assert c._enabled_set_changed({"a": {"enabled": True}}, {"a": {"enabled": False}}) is True
|
||||||
|
|
||||||
|
def test_no_change(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
cfg = {"a": {"enabled": True}, "b": {"enabled": False}}
|
||||||
|
assert c._enabled_set_changed(cfg, dict(cfg)) is False
|
||||||
|
|
||||||
|
def test_new_enabled_section(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
assert c._enabled_set_changed(
|
||||||
|
{"a": {"enabled": True}},
|
||||||
|
{"a": {"enabled": True}, "b": {"enabled": True}},
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_ignores_non_enabled_value_edits(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
assert c._enabled_set_changed(
|
||||||
|
{"a": {"enabled": True, "duration": 30}},
|
||||||
|
{"a": {"enabled": True, "duration": 45}},
|
||||||
|
) is False
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Tests for update_display dirty tracking (src/display_manager.py).
|
||||||
|
|
||||||
|
Runs against RGBMatrixEmulator (EMULATOR=true), exercising the REAL
|
||||||
|
DisplayManager — not a mock — so the skip logic, its invalidation hooks,
|
||||||
|
and the kill switch are verified off-Pi.
|
||||||
|
|
||||||
|
The invariants:
|
||||||
|
- identical frames are pushed exactly once (SwapOnVSync not re-called)
|
||||||
|
- ANY pixel change pushes
|
||||||
|
- clear() and set_brightness() invalidate (the two paths that alter panel
|
||||||
|
state outside the digest's view)
|
||||||
|
- the kill switch (display.dirty_tracking: false) restores always-push
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
os.environ["EMULATOR"] = "true"
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def dm():
|
||||||
|
"""One real DisplayManager on the emulator (it's a process singleton)."""
|
||||||
|
from src.display_manager import DisplayManager
|
||||||
|
DisplayManager._instance = None
|
||||||
|
DisplayManager._initialized = False
|
||||||
|
manager = DisplayManager({
|
||||||
|
"display": {
|
||||||
|
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
||||||
|
"parallel": 1, "brightness": 90},
|
||||||
|
"runtime": {"gpio_slowdown": 0},
|
||||||
|
},
|
||||||
|
}, suppress_test_pattern=True)
|
||||||
|
yield manager
|
||||||
|
|
||||||
|
|
||||||
|
class _SwapSpy:
|
||||||
|
"""Counts SwapOnVSync calls through the real matrix object."""
|
||||||
|
|
||||||
|
def __init__(self, matrix):
|
||||||
|
self.matrix = matrix
|
||||||
|
self.count = 0
|
||||||
|
self._orig = matrix.SwapOnVSync
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
def counting(canvas):
|
||||||
|
self.count += 1
|
||||||
|
return self._orig(canvas)
|
||||||
|
self.matrix.SwapOnVSync = counting
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self.matrix.SwapOnVSync = self._orig
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirtyTracking:
|
||||||
|
def test_identical_frames_push_once(self, dm):
|
||||||
|
dm.draw.rectangle([0, 0, 10, 10], fill=(255, 0, 0))
|
||||||
|
with _SwapSpy(dm.matrix) as spy:
|
||||||
|
dm.update_display()
|
||||||
|
dm.update_display()
|
||||||
|
dm.update_display()
|
||||||
|
assert spy.count == 1
|
||||||
|
|
||||||
|
def test_pixel_change_pushes(self, dm):
|
||||||
|
dm.update_display()
|
||||||
|
with _SwapSpy(dm.matrix) as spy:
|
||||||
|
dm.draw.point((5, 5), fill=(0, 255, 0))
|
||||||
|
dm.update_display()
|
||||||
|
dm.update_display() # unchanged again
|
||||||
|
assert spy.count == 1
|
||||||
|
|
||||||
|
def test_clear_invalidates(self, dm):
|
||||||
|
dm.draw.rectangle([0, 0, 20, 20], fill=(0, 0, 255))
|
||||||
|
dm.update_display()
|
||||||
|
dm.clear() # writes to the matrix directly; digest must reset
|
||||||
|
with _SwapSpy(dm.matrix) as spy:
|
||||||
|
dm.update_display() # black frame after clear must still push
|
||||||
|
assert spy.count == 1
|
||||||
|
|
||||||
|
def test_brightness_change_forces_push(self, dm):
|
||||||
|
dm.draw.rectangle([0, 0, 20, 20], fill=(200, 200, 200))
|
||||||
|
dm.update_display()
|
||||||
|
with _SwapSpy(dm.matrix) as spy:
|
||||||
|
dm.update_display() # identical -> skipped
|
||||||
|
assert spy.count == 0
|
||||||
|
dm.set_brightness(40) # dim schedule scenario
|
||||||
|
dm.update_display() # same image, new brightness -> push
|
||||||
|
assert spy.count == 1
|
||||||
|
dm.set_brightness(90)
|
||||||
|
|
||||||
|
def test_snapshot_still_written_on_skip(self, dm, tmp_path):
|
||||||
|
"""The web preview path must keep working through skipped pushes."""
|
||||||
|
dm._snapshot_path = str(tmp_path / "snap.png")
|
||||||
|
dm._last_snapshot_ts = 0.0
|
||||||
|
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
|
||||||
|
dm.update_display() # push + snapshot
|
||||||
|
assert os.path.exists(dm._snapshot_path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestKillSwitch:
|
||||||
|
def test_dirty_tracking_can_be_disabled(self, dm):
|
||||||
|
dm._dirty_tracking_enabled = False
|
||||||
|
try:
|
||||||
|
dm.draw.rectangle([0, 0, 10, 10], fill=(1, 2, 3))
|
||||||
|
with _SwapSpy(dm.matrix) as spy:
|
||||||
|
dm.update_display()
|
||||||
|
dm.update_display()
|
||||||
|
dm.update_display()
|
||||||
|
assert spy.count == 3 # always-push, exactly the old behavior
|
||||||
|
finally:
|
||||||
|
dm._dirty_tracking_enabled = True
|
||||||
|
dm._last_pushed_digest = None
|
||||||
|
|
||||||
|
def test_config_flag_wires_through(self):
|
||||||
|
from src.display_manager import DisplayManager
|
||||||
|
DisplayManager._instance = None
|
||||||
|
DisplayManager._initialized = False
|
||||||
|
manager = DisplayManager({
|
||||||
|
"display": {
|
||||||
|
"hardware": {"rows": 32, "cols": 64, "chain_length": 1,
|
||||||
|
"parallel": 1},
|
||||||
|
"runtime": {"gpio_slowdown": 0},
|
||||||
|
"dirty_tracking": False,
|
||||||
|
},
|
||||||
|
}, suppress_test_pattern=True)
|
||||||
|
assert manager._dirty_tracking_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(pytest.main([__file__, "-v"]))
|
||||||
@@ -109,11 +109,114 @@ class TestDisplayManagerDrawing:
|
|||||||
|
|
||||||
class TestDisplayManagerResourceManagement:
|
class TestDisplayManagerResourceManagement:
|
||||||
"""Test resource management."""
|
"""Test resource management."""
|
||||||
|
|
||||||
def test_cleanup(self, test_config, mock_rgb_matrix):
|
def test_cleanup(self, test_config, mock_rgb_matrix):
|
||||||
"""Test cleanup operation."""
|
"""Test cleanup operation."""
|
||||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||||
dm = DisplayManager(test_config)
|
dm = DisplayManager(test_config)
|
||||||
dm.cleanup()
|
dm.cleanup()
|
||||||
|
|
||||||
dm.matrix.Clear.assert_called()
|
dm.matrix.Clear.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisplayManagerDoubleSided:
|
||||||
|
"""Double-sided mode: render once at logical size, tile across the chain."""
|
||||||
|
|
||||||
|
def _config(self, **double_sided):
|
||||||
|
"""Build a config (physical 128x32) with the given double_sided block."""
|
||||||
|
return {
|
||||||
|
'display': {
|
||||||
|
'hardware': {
|
||||||
|
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
|
||||||
|
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90,
|
||||||
|
},
|
||||||
|
'runtime': {'gpio_slowdown': 2},
|
||||||
|
'double_sided': double_sided,
|
||||||
|
},
|
||||||
|
'timezone': 'UTC',
|
||||||
|
'plugin_system': {'plugins_directory': 'plugins'},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _captured_physical(self, mock_rgb_matrix):
|
||||||
|
"""Return the image handed to the canvas on the last update_display()."""
|
||||||
|
canvas = mock_rgb_matrix['matrix_instance'].CreateFrameCanvas.return_value
|
||||||
|
return canvas.SetImage.call_args[0][0]
|
||||||
|
|
||||||
|
def test_horizontal_reports_logical_dimensions(self, mock_rgb_matrix):
|
||||||
|
"""Plugins see the per-screen size, not the full physical chain."""
|
||||||
|
DisplayManager._instance = None
|
||||||
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||||
|
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
|
||||||
|
suppress_test_pattern=True)
|
||||||
|
# Physical chain is 128x32; two side-by-side copies -> logical 64x32.
|
||||||
|
assert dm.matrix.width == 64
|
||||||
|
assert dm.matrix.height == 32
|
||||||
|
assert (dm.width, dm.height) == (64, 32)
|
||||||
|
assert dm.image.size == (64, 32)
|
||||||
|
|
||||||
|
def test_horizontal_tiles_image_across_chain(self, mock_rgb_matrix):
|
||||||
|
"""The logical screen is duplicated left/right into a full-chain frame."""
|
||||||
|
from PIL import Image
|
||||||
|
DisplayManager._instance = None
|
||||||
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||||
|
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
|
||||||
|
suppress_test_pattern=True)
|
||||||
|
logical = Image.new('RGB', (64, 32), (0, 0, 0))
|
||||||
|
logical.putpixel((5, 5), (255, 0, 0))
|
||||||
|
dm.image = logical
|
||||||
|
dm.update_display()
|
||||||
|
|
||||||
|
physical = self._captured_physical(mock_rgb_matrix)
|
||||||
|
assert physical.size == (128, 32)
|
||||||
|
assert physical.getpixel((5, 5)) == (255, 0, 0)
|
||||||
|
assert physical.getpixel((69, 5)) == (255, 0, 0) # copy shifted +64
|
||||||
|
|
||||||
|
def test_vertical_axis_tiles_stacked(self, mock_rgb_matrix):
|
||||||
|
"""Vertical axis stacks copies (for panels on parallel outputs)."""
|
||||||
|
from PIL import Image
|
||||||
|
DisplayManager._instance = None
|
||||||
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||||
|
dm = DisplayManager(self._config(enabled=True, copies=2, axis='vertical'),
|
||||||
|
suppress_test_pattern=True)
|
||||||
|
# 128x32 split vertically -> logical 128x16.
|
||||||
|
assert (dm.matrix.width, dm.matrix.height) == (128, 16)
|
||||||
|
logical = Image.new('RGB', (128, 16), (0, 0, 0))
|
||||||
|
logical.putpixel((10, 3), (0, 255, 0))
|
||||||
|
dm.image = logical
|
||||||
|
dm.update_display()
|
||||||
|
|
||||||
|
physical = self._captured_physical(mock_rgb_matrix)
|
||||||
|
assert physical.size == (128, 32)
|
||||||
|
assert physical.getpixel((10, 3)) == (0, 255, 0)
|
||||||
|
assert physical.getpixel((10, 19)) == (0, 255, 0) # copy shifted +16
|
||||||
|
|
||||||
|
def test_indivisible_dimension_disables_mode(self, mock_rgb_matrix):
|
||||||
|
"""A physical size that doesn't divide evenly falls back to single."""
|
||||||
|
DisplayManager._instance = None
|
||||||
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||||
|
dm = DisplayManager(self._config(enabled=True, copies=3, axis='horizontal'),
|
||||||
|
suppress_test_pattern=True)
|
||||||
|
assert dm._double_sided is None # 128 % 3 != 0
|
||||||
|
assert dm.matrix.width == 128
|
||||||
|
assert dm.image.size == (128, 32)
|
||||||
|
|
||||||
|
def test_disabled_blits_logical_image_unchanged(self, mock_rgb_matrix):
|
||||||
|
"""With the feature off, the rendered image is sent through untouched."""
|
||||||
|
from PIL import Image
|
||||||
|
DisplayManager._instance = None
|
||||||
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||||
|
dm = DisplayManager(self._config(enabled=False), suppress_test_pattern=True)
|
||||||
|
assert dm._double_sided is None
|
||||||
|
img = Image.new('RGB', (128, 32))
|
||||||
|
dm.image = img
|
||||||
|
dm.update_display()
|
||||||
|
assert self._captured_physical(mock_rgb_matrix) is img
|
||||||
|
|
||||||
|
def test_brightness_write_forwards_through_proxy(self, mock_rgb_matrix):
|
||||||
|
"""Setting brightness via the proxy reaches the real matrix."""
|
||||||
|
DisplayManager._instance = None
|
||||||
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||||
|
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
|
||||||
|
suppress_test_pattern=True)
|
||||||
|
assert dm.set_brightness(70) is True
|
||||||
|
assert mock_rgb_matrix['matrix_instance'].brightness == 70
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
Tests for src.common.permission_utils's URL-credential redaction.
|
||||||
|
|
||||||
|
Covers the fix for a CodeQL clear-text-logging-of-secrets alert:
|
||||||
|
install_requirements_file() must never let a private index URL's embedded
|
||||||
|
user:pass@ credentials reach logs or its returned CompletedProcess, since
|
||||||
|
pip can echo that URL back verbatim in its own stderr/stdout on failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from src.common.permission_utils import _redact_url_credentials, install_requirements_file
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedactUrlCredentials:
|
||||||
|
def test_redacts_embedded_basic_auth(self):
|
||||||
|
text = "Could not fetch URL https://alice:s3cr3t@pypi.example.com/simple/: 403"
|
||||||
|
redacted = _redact_url_credentials(text)
|
||||||
|
assert "s3cr3t" not in redacted
|
||||||
|
assert "alice" not in redacted
|
||||||
|
assert "https://***:***@pypi.example.com/simple/" in redacted
|
||||||
|
|
||||||
|
def test_leaves_credential_free_text_unchanged(self):
|
||||||
|
text = "ERROR: Could not find a version that satisfies the requirement foo==1.0"
|
||||||
|
assert _redact_url_credentials(text) == text
|
||||||
|
|
||||||
|
def test_handles_none_and_empty(self):
|
||||||
|
assert _redact_url_credentials(None) == ""
|
||||||
|
assert _redact_url_credentials("") == ""
|
||||||
|
|
||||||
|
def test_does_not_touch_denied_check_phrases(self):
|
||||||
|
"""The fixed phrases install_requirements_file greps for must survive
|
||||||
|
redaction untouched -- they don't overlap with URL syntax, but this
|
||||||
|
pins that assumption so a regex change can't silently break it."""
|
||||||
|
text = "sudo: a password is required"
|
||||||
|
assert _redact_url_credentials(text) == text
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstallRequirementsFileRedaction:
|
||||||
|
@patch('src.common.permission_utils.subprocess.run')
|
||||||
|
def test_wrapper_path_redacts_stderr_and_stdout(self, mock_run, tmp_path):
|
||||||
|
"""safe_pip_install.sh exists in this repo, so install_requirements_file
|
||||||
|
takes the sudo-wrapper branch; a failing result must come back
|
||||||
|
with any embedded index-URL credentials already redacted."""
|
||||||
|
req_file = tmp_path / "requirements.txt"
|
||||||
|
req_file.write_text("requests\n")
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=1,
|
||||||
|
stdout="Looking in indexes: https://bob:hunter2@pypi.internal/simple\n",
|
||||||
|
stderr="ERROR https://bob:hunter2@pypi.internal/simple/foo: 401",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = install_requirements_file(req_file, timeout=5)
|
||||||
|
|
||||||
|
assert "hunter2" not in result.stdout
|
||||||
|
assert "hunter2" not in result.stderr
|
||||||
|
assert "https://***:***@pypi.internal" in result.stdout
|
||||||
|
assert "https://***:***@pypi.internal" in result.stderr
|
||||||
|
|
||||||
|
@patch('src.common.permission_utils.subprocess.run')
|
||||||
|
@patch('src.common.permission_utils.Path.exists', return_value=False)
|
||||||
|
def test_no_wrapper_fallback_path_redacts_stderr_and_stdout(self, mock_exists, mock_run, tmp_path):
|
||||||
|
"""No safe_pip_install.sh wrapper -> falls straight to the
|
||||||
|
sys.executable pip fallback (the second subprocess.run call site);
|
||||||
|
its result must come back redacted too, independent of the wrapper
|
||||||
|
branch's own redaction above."""
|
||||||
|
req_file = tmp_path / "requirements.txt"
|
||||||
|
req_file.write_text("requests\n")
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=1,
|
||||||
|
stdout="Looking in indexes: https://carol:swordfish@pypi.internal/simple\n",
|
||||||
|
stderr="ERROR https://carol:swordfish@pypi.internal/simple/foo: 401",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = install_requirements_file(req_file, timeout=5)
|
||||||
|
|
||||||
|
assert "swordfish" not in result.stdout
|
||||||
|
assert "swordfish" not in result.stderr
|
||||||
|
assert "https://***:***@pypi.internal" in result.stdout
|
||||||
|
assert "https://***:***@pypi.internal" in result.stderr
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Tests for src/plugin_system/plugin_health.py
|
||||||
|
|
||||||
|
Focus on the additive ``set_degraded`` mechanism used by the warn-only schema
|
||||||
|
validation path: it must surface a degraded reason without touching the circuit
|
||||||
|
breaker or causing the plugin to be skipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_health import PluginHealthTracker, CircuitState
|
||||||
|
|
||||||
|
|
||||||
|
def _cache():
|
||||||
|
cache = MagicMock()
|
||||||
|
cache.get.return_value = None
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_degraded_marks_and_surfaces_reason():
|
||||||
|
tracker = PluginHealthTracker(_cache())
|
||||||
|
tracker.set_degraded("p", "bad config")
|
||||||
|
summary = tracker.get_health_summary("p")
|
||||||
|
assert summary["degraded"] is True
|
||||||
|
assert summary["degraded_reason"] == "bad config"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_degraded_none_clears():
|
||||||
|
tracker = PluginHealthTracker(_cache())
|
||||||
|
tracker.set_degraded("p", "bad config")
|
||||||
|
tracker.set_degraded("p", None)
|
||||||
|
summary = tracker.get_health_summary("p")
|
||||||
|
assert summary["degraded"] is False
|
||||||
|
assert summary["degraded_reason"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_degraded_does_not_affect_circuit_breaker():
|
||||||
|
tracker = PluginHealthTracker(_cache())
|
||||||
|
tracker.set_degraded("p", "bad config")
|
||||||
|
summary = tracker.get_health_summary("p")
|
||||||
|
# Degraded is a *separate* signal from circuit health: the plugin is not
|
||||||
|
# counted as failing, the circuit stays closed, and it is not skipped.
|
||||||
|
assert summary["circuit_state"] == CircuitState.CLOSED.value
|
||||||
|
assert summary["consecutive_failures"] == 0
|
||||||
|
assert summary["is_healthy"] is True
|
||||||
|
assert tracker.should_skip_plugin("p") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_degraded_skips_redundant_cache_write():
|
||||||
|
cache = _cache()
|
||||||
|
tracker = PluginHealthTracker(cache)
|
||||||
|
tracker.set_degraded("p", "x")
|
||||||
|
writes_after_first = cache.set.call_count
|
||||||
|
assert writes_after_first >= 1
|
||||||
|
tracker.set_degraded("p", "x") # unchanged → no extra write
|
||||||
|
assert cache.set.call_count == writes_after_first
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_summary_has_degraded_fields():
|
||||||
|
tracker = PluginHealthTracker(_cache())
|
||||||
|
summary = tracker.get_health_summary("never-seen")
|
||||||
|
assert summary["degraded"] is False
|
||||||
|
assert summary["degraded_reason"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_reload_refreshes_stale_in_memory_snapshot():
|
||||||
|
"""A long-lived reader (e.g. the web process) must not be pinned to the
|
||||||
|
first snapshot: force_reload re-reads persisted state and bypasses the
|
||||||
|
cache manager's memory tier so cross-process updates are visible."""
|
||||||
|
cache = _cache()
|
||||||
|
tracker = PluginHealthTracker(cache)
|
||||||
|
|
||||||
|
# First read snapshots an empty (healthy) state into the in-memory copy.
|
||||||
|
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
|
||||||
|
|
||||||
|
# The display service later persists a failing/open state.
|
||||||
|
cache.get.return_value = {
|
||||||
|
"consecutive_failures": 5,
|
||||||
|
"circuit_state": "open",
|
||||||
|
"total_failures": 5,
|
||||||
|
"total_successes": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# A plain read is still pinned to the stale snapshot...
|
||||||
|
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
|
||||||
|
|
||||||
|
# ...but force_reload observes the new persisted state.
|
||||||
|
fresh = tracker.get_health_summary("p", force_reload=True)
|
||||||
|
assert fresh["consecutive_failures"] == 5
|
||||||
|
assert fresh["circuit_state"] == "open"
|
||||||
|
|
||||||
|
# and it asked the cache to bypass the in-memory tier (memory_ttl=0).
|
||||||
|
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
|
||||||
@@ -4,6 +4,8 @@ Tests for PluginLoader.
|
|||||||
Tests plugin directory discovery, module loading, and class instantiation.
|
Tests plugin directory discovery, module loading, and class instantiation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
@@ -191,7 +193,7 @@ class TestPluginLoader:
|
|||||||
|
|
||||||
mock_subprocess.return_value = MagicMock(returncode=0)
|
mock_subprocess.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_subprocess.assert_called_once()
|
mock_subprocess.assert_called_once()
|
||||||
@@ -202,7 +204,7 @@ class TestPluginLoader:
|
|||||||
plugin_dir = tmp_plugins_dir / "test_plugin"
|
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||||
plugin_dir.mkdir()
|
plugin_dir.mkdir()
|
||||||
|
|
||||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_subprocess.assert_not_called()
|
mock_subprocess.assert_not_called()
|
||||||
@@ -214,9 +216,129 @@ class TestPluginLoader:
|
|||||||
plugin_dir.mkdir()
|
plugin_dir.mkdir()
|
||||||
requirements_file = plugin_dir / "requirements.txt"
|
requirements_file = plugin_dir / "requirements.txt"
|
||||||
requirements_file.write_text("package1==1.0.0\n")
|
requirements_file.write_text("package1==1.0.0\n")
|
||||||
|
|
||||||
mock_subprocess.return_value = MagicMock(returncode=1)
|
mock_subprocess.return_value = MagicMock(returncode=1)
|
||||||
|
|
||||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_install_dependencies_retries_with_ignore_installed_on_apt_conflict(
|
||||||
|
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
|
||||||
|
):
|
||||||
|
"""An apt-managed package with no pip RECORD file triggers a retry with
|
||||||
|
--ignore-installed rather than silently assuming the old version satisfies
|
||||||
|
the requirement. requirements_are_satisfied() is mocked False here because
|
||||||
|
this scenario is exactly the case where the installed (apt) version does
|
||||||
|
NOT satisfy the pin — that's why pip attempts a reinstall in the first place."""
|
||||||
|
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
requirements_file = plugin_dir / "requirements.txt"
|
||||||
|
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
|
||||||
|
|
||||||
|
first_attempt = MagicMock(
|
||||||
|
returncode=1,
|
||||||
|
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
|
||||||
|
)
|
||||||
|
retry_attempt = MagicMock(returncode=0, stderr="")
|
||||||
|
mock_subprocess.side_effect = [first_attempt, retry_attempt]
|
||||||
|
|
||||||
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_subprocess.call_count == 2
|
||||||
|
retry_cmd = mock_subprocess.call_args_list[1][0][0]
|
||||||
|
assert "--ignore-installed" in retry_cmd
|
||||||
|
|
||||||
|
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_install_dependencies_apt_conflict_retry_also_fails(
|
||||||
|
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
|
||||||
|
):
|
||||||
|
"""Still tolerates the failure (returns True) if the --ignore-installed
|
||||||
|
retry itself fails, matching the prior soft-fallback behavior."""
|
||||||
|
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
requirements_file = plugin_dir / "requirements.txt"
|
||||||
|
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
|
||||||
|
|
||||||
|
first_attempt = MagicMock(
|
||||||
|
returncode=1,
|
||||||
|
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
|
||||||
|
)
|
||||||
|
retry_attempt = MagicMock(returncode=1, stderr="some other pip error")
|
||||||
|
mock_subprocess.side_effect = [first_attempt, retry_attempt]
|
||||||
|
|
||||||
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_subprocess.call_count == 2
|
||||||
|
|
||||||
|
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_install_dependencies_apt_conflict_retry_times_out(
|
||||||
|
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
|
||||||
|
):
|
||||||
|
"""A retry timeout must be tolerated the same way as a retry failure
|
||||||
|
(return True), not propagate to the outer TimeoutExpired handler and
|
||||||
|
return False."""
|
||||||
|
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
requirements_file = plugin_dir / "requirements.txt"
|
||||||
|
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
|
||||||
|
|
||||||
|
first_attempt = MagicMock(
|
||||||
|
returncode=1,
|
||||||
|
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
|
||||||
|
)
|
||||||
|
mock_subprocess.side_effect = [
|
||||||
|
first_attempt,
|
||||||
|
subprocess.TimeoutExpired(cmd="pip", timeout=300),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_subprocess.call_count == 2
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_install_dependencies_already_satisfied_skips_pip(self, mock_subprocess, plugin_loader, tmp_plugins_dir):
|
||||||
|
"""A requirement already satisfied in the current environment shouldn't invoke pip."""
|
||||||
|
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
requirements_file = plugin_dir / "requirements.txt"
|
||||||
|
requirements_file.write_text("pytest>=1.0\n")
|
||||||
|
|
||||||
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_subprocess.assert_not_called()
|
||||||
|
|
||||||
|
def test_install_dependencies_requires_plugins_dir(self, plugin_loader, tmp_plugins_dir):
|
||||||
|
"""plugins_dir is a required argument, not an optional trust-me flag --
|
||||||
|
calling without it must fail loudly (TypeError) rather than silently
|
||||||
|
falling back to trusting plugin_dir unchecked."""
|
||||||
|
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_install_dependencies_rejects_path_outside_plugins_dir(
|
||||||
|
self, mock_subprocess, plugin_loader, tmp_path, tmp_plugins_dir
|
||||||
|
):
|
||||||
|
"""A plugin_dir that doesn't actually live inside plugins_dir (e.g. a
|
||||||
|
manifest-derived id crafted to traverse elsewhere) must be rejected
|
||||||
|
rather than read from -- this is the path-injection containment
|
||||||
|
check CodeQL flagged as missing."""
|
||||||
|
outside_dir = tmp_path / "outside"
|
||||||
|
outside_dir.mkdir()
|
||||||
|
(outside_dir / "requirements.txt").write_text("requests>=2.0\n")
|
||||||
|
|
||||||
|
result = plugin_loader.install_dependencies(outside_dir, "evil_plugin", plugins_dir=tmp_plugins_dir)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_subprocess.assert_not_called()
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Tests for PluginManager._validate_config_schema_soft (Phase 1, warn-only schema
|
||||||
|
validation).
|
||||||
|
|
||||||
|
Contract:
|
||||||
|
- A schema violation logs a warning and marks the plugin degraded in the health
|
||||||
|
tracker, but never raises and never changes load pass/fail behaviour.
|
||||||
|
- A valid config (or no schema) clears any stale degraded flag.
|
||||||
|
- The method is safe when no health tracker is wired.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pm():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
manager = PluginManager(plugins_dir=str(Path(tmp) / "plugins"))
|
||||||
|
manager.schema_manager = MagicMock()
|
||||||
|
yield manager
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_config_marks_degraded_without_raising(pm):
|
||||||
|
pm.health_tracker = MagicMock()
|
||||||
|
pm.schema_manager.load_schema.return_value = {"type": "object"}
|
||||||
|
pm.schema_manager.validate_config_against_schema.return_value = (
|
||||||
|
False,
|
||||||
|
["Missing required field: 'api_key'"],
|
||||||
|
)
|
||||||
|
|
||||||
|
pm._validate_config_schema_soft("youtube-stats", {})
|
||||||
|
|
||||||
|
pm.health_tracker.set_degraded.assert_called_once()
|
||||||
|
plugin_id, reason = pm.health_tracker.set_degraded.call_args[0]
|
||||||
|
assert plugin_id == "youtube-stats"
|
||||||
|
assert "api_key" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_config_clears_degraded(pm):
|
||||||
|
pm.health_tracker = MagicMock()
|
||||||
|
pm.schema_manager.load_schema.return_value = {"type": "object"}
|
||||||
|
pm.schema_manager.validate_config_against_schema.return_value = (True, [])
|
||||||
|
|
||||||
|
pm._validate_config_schema_soft("p", {"api_key": "x"})
|
||||||
|
|
||||||
|
pm.health_tracker.set_degraded.assert_called_once_with("p", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_schema_clears_degraded(pm):
|
||||||
|
pm.health_tracker = MagicMock()
|
||||||
|
pm.schema_manager.load_schema.return_value = None
|
||||||
|
|
||||||
|
pm._validate_config_schema_soft("p", {})
|
||||||
|
|
||||||
|
pm.health_tracker.set_degraded.assert_called_once_with("p", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_exception_is_swallowed(pm):
|
||||||
|
pm.health_tracker = MagicMock()
|
||||||
|
pm.schema_manager.load_schema.return_value = {"type": "object"}
|
||||||
|
pm.schema_manager.validate_config_against_schema.side_effect = RuntimeError("boom")
|
||||||
|
|
||||||
|
# Must not raise — the validation machinery failing must never break loading.
|
||||||
|
pm._validate_config_schema_soft("p", {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_without_health_tracker(pm):
|
||||||
|
pm.health_tracker = None
|
||||||
|
pm.schema_manager.load_schema.return_value = {"type": "object"}
|
||||||
|
pm.schema_manager.validate_config_against_schema.return_value = (False, ["err"])
|
||||||
|
|
||||||
|
# Must not raise even though there is no tracker to record against.
|
||||||
|
pm._validate_config_schema_soft("p", {})
|
||||||
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from src.plugin_system.plugin_manager import PluginManager
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
from src.plugin_system.plugin_state import PluginState
|
from src.plugin_system.plugin_state import PluginState
|
||||||
|
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||||
|
|
||||||
class TestPluginManager:
|
class TestPluginManager:
|
||||||
"""Test PluginManager functionality."""
|
"""Test PluginManager functionality."""
|
||||||
@@ -74,18 +75,67 @@ class TestPluginManager:
|
|||||||
|
|
||||||
# No manifest in pm.plugin_manifests
|
# No manifest in pm.plugin_manifests
|
||||||
result = pm.load_plugin("non_existent_plugin")
|
result = pm.load_plugin("non_existent_plugin")
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR
|
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR
|
||||||
|
|
||||||
|
def test_run_scheduled_updates_calls_update_with_resource_monitor(
|
||||||
|
self, mock_config_manager, mock_display_manager, mock_cache_manager
|
||||||
|
):
|
||||||
|
"""Regression test: run_scheduled_updates() must actually call a
|
||||||
|
plugin's update() when self.resource_monitor is set (as it is in
|
||||||
|
every real deployment -- display_controller.py and web_interface/
|
||||||
|
app.py both assign a real PluginResourceMonitor after construction).
|
||||||
|
|
||||||
|
Previously, the resource_monitor branch wrapped the call in a
|
||||||
|
function stored as a *class* attribute on a dynamically-built type
|
||||||
|
(`type('obj', (object,), {'update': monitored_update})()`), which
|
||||||
|
the descriptor protocol turns into a bound method on access --
|
||||||
|
silently passing the synthetic instance as an implicit first
|
||||||
|
argument to monitored_update(), which takes none. Every plugin's
|
||||||
|
scheduled update failed with "monitored_update() takes 0 positional
|
||||||
|
arguments but 1 was given" and was silently swallowed into a
|
||||||
|
circuit-breaker retry loop that never succeeded, so plugin data
|
||||||
|
(scores, odds, etc.) never refreshed.
|
||||||
|
"""
|
||||||
|
with patch('src.plugin_system.plugin_manager.ensure_directory_permissions'):
|
||||||
|
pm = PluginManager(
|
||||||
|
plugins_dir="plugins",
|
||||||
|
config_manager=mock_config_manager,
|
||||||
|
display_manager=mock_display_manager,
|
||||||
|
cache_manager=mock_cache_manager
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin_instance = MagicMock()
|
||||||
|
plugin_instance.enabled = True
|
||||||
|
plugin_instance.update = MagicMock()
|
||||||
|
|
||||||
|
pm.plugins["test_plugin"] = plugin_instance
|
||||||
|
pm.plugin_manifests["test_plugin"] = {"update_interval": 10}
|
||||||
|
pm.state_manager.set_state("test_plugin", PluginState.ENABLED)
|
||||||
|
# Plain MagicMock, not the mock_cache_manager fixture: this test
|
||||||
|
# is about run_scheduled_updates() actually invoking update()
|
||||||
|
# through the resource-monitor wrapper, not about
|
||||||
|
# PluginResourceMonitor's own cache-backed metrics persistence
|
||||||
|
# (which calls cache_manager.get(..., memory_ttl=...) --
|
||||||
|
# a kwarg the fixture's mock_get() doesn't accept).
|
||||||
|
pm.resource_monitor = PluginResourceMonitor(MagicMock())
|
||||||
|
|
||||||
|
pm.run_scheduled_updates(current_time=time.time())
|
||||||
|
|
||||||
|
plugin_instance.update.assert_called_once()
|
||||||
|
assert "test_plugin" in pm.plugin_last_update
|
||||||
|
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
|
||||||
|
|
||||||
|
|
||||||
class TestPluginLoader:
|
class TestPluginLoader:
|
||||||
"""Test PluginLoader functionality."""
|
"""Test PluginLoader functionality."""
|
||||||
|
|
||||||
def test_dependency_check(self):
|
def test_dependency_check(self):
|
||||||
"""Test dependency checking logic."""
|
"""Test dependency checking logic."""
|
||||||
# This would test _check_dependencies_installed and _install_plugin_dependencies
|
# Covered by test_plugin_loader.py's install_dependencies tests,
|
||||||
# which requires mocking subprocess calls and file operations
|
# which exercise requirements_has_real_deps/requirements_are_satisfied
|
||||||
|
# and the pip subprocess fallback.
|
||||||
|
|
||||||
|
|
||||||
class TestPluginExecutor:
|
class TestPluginExecutor:
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Tests for src/plugin_system/resource_monitor.py
|
||||||
|
|
||||||
|
Focus areas:
|
||||||
|
- Execution-time metrics are captured regardless of psutil availability.
|
||||||
|
- CPU sampling is non-blocking (regression guard for the previous
|
||||||
|
``cpu_percent(interval=0.1)`` call that blocked 100 ms per monitored call).
|
||||||
|
- Resource limits are enforced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from src.plugin_system.resource_monitor import (
|
||||||
|
PluginResourceMonitor,
|
||||||
|
ResourceLimits,
|
||||||
|
ResourceLimitExceeded,
|
||||||
|
PSUTIL_AVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cache():
|
||||||
|
cache = MagicMock()
|
||||||
|
cache.get.return_value = None
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecutionTimeMetrics:
|
||||||
|
def test_monitor_call_returns_value_and_records_call(self):
|
||||||
|
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||||
|
result = mon.monitor_call("p", lambda: 42)
|
||||||
|
assert result == 42
|
||||||
|
metrics = mon.get_metrics("p")
|
||||||
|
assert metrics.call_count == 1
|
||||||
|
assert metrics.total_execution_time >= 0.0
|
||||||
|
|
||||||
|
def test_avg_and_max_execution_time(self):
|
||||||
|
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||||
|
mon.monitor_call("p", lambda: time.sleep(0.01))
|
||||||
|
mon.monitor_call("p", lambda: None)
|
||||||
|
summary = mon.get_metrics_summary("p")
|
||||||
|
assert summary["call_count"] == 2
|
||||||
|
assert summary["max_execution_time"] >= summary["avg_execution_time"] >= 0.0
|
||||||
|
|
||||||
|
def test_exception_propagates_but_is_still_timed(self):
|
||||||
|
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||||
|
|
||||||
|
def boom():
|
||||||
|
raise ValueError("nope")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mon.monitor_call("p", boom)
|
||||||
|
# Execution time is still recorded even when the call raised.
|
||||||
|
assert mon.get_metrics("p").execution_time >= 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestNonBlockingCpu:
|
||||||
|
def test_cpu_sampling_is_fast_when_disabled(self):
|
||||||
|
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||||
|
start = time.time()
|
||||||
|
for _ in range(50):
|
||||||
|
mon._get_process_cpu_percent()
|
||||||
|
# The old implementation blocked ~0.1s/call (~5s for 50). Non-blocking
|
||||||
|
# must complete near-instantly.
|
||||||
|
assert time.time() - start < 0.5
|
||||||
|
assert mon._get_process_cpu_percent() == 0.0
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not installed")
|
||||||
|
def test_cpu_sampling_is_fast_with_psutil(self):
|
||||||
|
mon = PluginResourceMonitor(_cache(), enable_monitoring=True)
|
||||||
|
assert mon._process is not None
|
||||||
|
start = time.time()
|
||||||
|
for _ in range(30):
|
||||||
|
mon._get_process_cpu_percent()
|
||||||
|
# 30 blocking 0.1s samples would be ~3s; non-blocking must be well under.
|
||||||
|
assert time.time() - start < 0.5
|
||||||
|
|
||||||
|
def test_monitor_call_does_not_block_on_cpu_sampling(self):
|
||||||
|
mon = PluginResourceMonitor(_cache()) # enable depends on psutil
|
||||||
|
start = time.time()
|
||||||
|
for _ in range(25):
|
||||||
|
mon.monitor_call("p", lambda: None)
|
||||||
|
# 25 * 0.1s = 2.5s under the old blocking bug; must be far faster now.
|
||||||
|
assert time.time() - start < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestResourceLimits:
|
||||||
|
def test_execution_time_limit_raises(self):
|
||||||
|
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||||
|
mon.set_limits("p", ResourceLimits(max_execution_time=0.001))
|
||||||
|
with pytest.raises(ResourceLimitExceeded):
|
||||||
|
mon.monitor_call("p", lambda: time.sleep(0.02))
|
||||||
|
|
||||||
|
def test_reset_metrics_clears_counts(self):
|
||||||
|
cache = _cache()
|
||||||
|
mon = PluginResourceMonitor(cache, enable_monitoring=False)
|
||||||
|
mon.monitor_call("p", lambda: None)
|
||||||
|
assert mon.get_metrics("p").call_count == 1
|
||||||
|
mon.reset_metrics("p")
|
||||||
|
assert mon.get_metrics("p").call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestForceReload:
|
||||||
|
def test_force_reload_refreshes_stale_snapshot(self):
|
||||||
|
"""A read-only consumer must see the writer process's latest persisted
|
||||||
|
metrics rather than a pinned first snapshot."""
|
||||||
|
cache = MagicMock()
|
||||||
|
persisted = {"value": None} # only the metrics key returns data
|
||||||
|
|
||||||
|
def cache_get(key, max_age=None, memory_ttl=None):
|
||||||
|
return persisted["value"] if key.startswith("plugin_metrics:") else None
|
||||||
|
|
||||||
|
cache.get.side_effect = cache_get
|
||||||
|
mon = PluginResourceMonitor(cache, enable_monitoring=False)
|
||||||
|
|
||||||
|
# First read snapshots empty metrics.
|
||||||
|
assert mon.get_metrics_summary("p")["call_count"] == 0
|
||||||
|
|
||||||
|
# The display service later persists real metrics.
|
||||||
|
persisted["value"] = {"call_count": 7, "total_execution_time": 1.4}
|
||||||
|
|
||||||
|
# Plain read stays stale...
|
||||||
|
assert mon.get_metrics_summary("p")["call_count"] == 0
|
||||||
|
# ...force_reload picks up the persisted values and bypasses memory.
|
||||||
|
fresh = mon.get_metrics_summary("p", force_reload=True)
|
||||||
|
assert fresh["call_count"] == 7
|
||||||
|
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
|
||||||
@@ -43,6 +43,115 @@ class TestUninstallTombstone(unittest.TestCase):
|
|||||||
self.assertNotIn("foo", self.sm._uninstall_tombstones)
|
self.assertNotIn("foo", self.sm._uninstall_tombstones)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPersistentUninstallRegistry(unittest.TestCase):
|
||||||
|
"""Regression tests for the persistent uninstall registry that stops a
|
||||||
|
core `git pull` update from resurrecting built-in plugins the user
|
||||||
|
removed (plugins committed under plugin-repos/)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.plugins_dir = Path(self._tmp.name) / "plugin-repos"
|
||||||
|
self.plugins_dir.mkdir()
|
||||||
|
self.registry_path = Path(self._tmp.name) / "config" / "uninstalled_plugins.json"
|
||||||
|
self.sm = PluginStoreManager(
|
||||||
|
plugins_dir=str(self.plugins_dir),
|
||||||
|
uninstalled_registry_path=str(self.registry_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_plugin_dir(self, plugin_id):
|
||||||
|
"""Simulate a built-in plugin restored on disk (e.g. by git pull)."""
|
||||||
|
d = self.plugins_dir / plugin_id
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
(d / "manifest.json").write_text('{"id": "%s"}' % plugin_id)
|
||||||
|
return d
|
||||||
|
|
||||||
|
def test_unrecorded_plugin_is_not_uninstalled(self):
|
||||||
|
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||||
|
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||||
|
|
||||||
|
def test_record_persists_across_instances(self):
|
||||||
|
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||||
|
self.assertTrue(self.registry_path.exists())
|
||||||
|
# A fresh manager (simulating a service restart after update) still sees it.
|
||||||
|
fresh = PluginStoreManager(
|
||||||
|
plugins_dir=str(self.plugins_dir),
|
||||||
|
uninstalled_registry_path=str(self.registry_path),
|
||||||
|
)
|
||||||
|
self.assertTrue(fresh.is_plugin_uninstalled("web-ui-info"))
|
||||||
|
|
||||||
|
def test_forget_clears_record(self):
|
||||||
|
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||||
|
self.sm.forget_uninstalled_plugin("web-ui-info")
|
||||||
|
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||||
|
|
||||||
|
def test_purge_removes_resurrected_plugin(self):
|
||||||
|
# The bug: user removed web-ui-info, then a git pull restored its
|
||||||
|
# committed files. Recorded uninstall + purge must re-remove it.
|
||||||
|
self._make_plugin_dir("web-ui-info")
|
||||||
|
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||||
|
self.assertTrue((self.plugins_dir / "web-ui-info").exists())
|
||||||
|
|
||||||
|
removed = self.sm.purge_uninstalled_plugins()
|
||||||
|
|
||||||
|
self.assertEqual(removed, ["web-ui-info"])
|
||||||
|
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
|
||||||
|
# Record is kept so the purge stays idempotent across future updates.
|
||||||
|
self.assertTrue(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||||
|
|
||||||
|
def test_purge_leaves_non_uninstalled_plugins_alone(self):
|
||||||
|
self._make_plugin_dir("baseball-scoreboard") # present, not recorded
|
||||||
|
self._make_plugin_dir("web-ui-info")
|
||||||
|
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||||
|
|
||||||
|
self.sm.purge_uninstalled_plugins()
|
||||||
|
|
||||||
|
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
|
||||||
|
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
|
||||||
|
|
||||||
|
def test_purge_noop_when_plugin_absent(self):
|
||||||
|
# Recorded but never restored on disk — nothing to remove.
|
||||||
|
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||||
|
self.assertEqual(self.sm.purge_uninstalled_plugins(), [])
|
||||||
|
|
||||||
|
def test_corrupt_registry_is_ignored(self):
|
||||||
|
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.registry_path.write_text("{ not valid json")
|
||||||
|
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||||
|
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||||
|
|
||||||
|
def _write_raw_registry(self, value):
|
||||||
|
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
import json as _json
|
||||||
|
self.registry_path.write_text(_json.dumps(value))
|
||||||
|
|
||||||
|
def test_empty_id_does_not_wipe_plugins_root(self):
|
||||||
|
# An empty id resolves to plugins_dir itself; purge must never delete it.
|
||||||
|
self._make_plugin_dir("baseball-scoreboard")
|
||||||
|
self._write_raw_registry([""])
|
||||||
|
|
||||||
|
removed = self.sm.purge_uninstalled_plugins()
|
||||||
|
|
||||||
|
self.assertEqual(removed, [])
|
||||||
|
self.assertTrue(self.plugins_dir.exists())
|
||||||
|
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
|
||||||
|
# Invalid id is filtered out entirely.
|
||||||
|
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||||
|
|
||||||
|
def test_traversal_ids_are_ignored(self):
|
||||||
|
for bad in ["..", "../evil", "a/b", "."]:
|
||||||
|
with self.subTest(bad=bad):
|
||||||
|
self.assertFalse(self.sm._is_valid_plugin_id(bad))
|
||||||
|
self._write_raw_registry(["../evil", "..", "web-ui-info"])
|
||||||
|
# Only the safe id survives the read.
|
||||||
|
self.assertEqual(self.sm.get_uninstalled_plugins(), {"web-ui-info"})
|
||||||
|
|
||||||
|
def test_record_rejects_invalid_id(self):
|
||||||
|
self.sm.record_uninstalled_plugin("")
|
||||||
|
self.sm.record_uninstalled_plugin("../escape")
|
||||||
|
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||||
|
|
||||||
|
|
||||||
class TestGitInfoCache(unittest.TestCase):
|
class TestGitInfoCache(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._tmp = TemporaryDirectory()
|
self._tmp = TemporaryDirectory()
|
||||||
@@ -58,19 +167,15 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||||
|
|
||||||
def _fake_subprocess_run(self, *args, **kwargs):
|
def _fake_subprocess_run(self, *args, **kwargs):
|
||||||
# Return different dummy values depending on which git subcommand
|
# _get_local_git_info now reads branch and remote_url directly from
|
||||||
# was invoked so the code paths that parse output all succeed.
|
# .git/HEAD and .git/config (no subprocess) and uses a single
|
||||||
|
# ``git log --format=%H%n%cI`` call that returns SHA on line 1 and
|
||||||
|
# ISO date on line 2. Adjust the fake accordingly.
|
||||||
cmd = args[0]
|
cmd = args[0]
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.returncode = 0
|
result.returncode = 0
|
||||||
if "rev-parse" in cmd and "HEAD" in cmd and "--abbrev-ref" not in cmd:
|
if "log" in cmd:
|
||||||
result.stdout = "abcdef1234567890\n"
|
result.stdout = "abcdef1234567890\n2026-04-08T12:00:00+00:00\n"
|
||||||
elif "--abbrev-ref" in cmd:
|
|
||||||
result.stdout = "main\n"
|
|
||||||
elif "config" in cmd:
|
|
||||||
result.stdout = "https://example.com/repo.git\n"
|
|
||||||
elif "log" in cmd:
|
|
||||||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
|
||||||
else:
|
else:
|
||||||
result.stdout = ""
|
result.stdout = ""
|
||||||
return result
|
return result
|
||||||
@@ -84,7 +189,8 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
self.assertIsNotNone(first)
|
self.assertIsNotNone(first)
|
||||||
self.assertEqual(first["short_sha"], "abcdef1")
|
self.assertEqual(first["short_sha"], "abcdef1")
|
||||||
calls_after_first = mock_run.call_count
|
calls_after_first = mock_run.call_count
|
||||||
self.assertEqual(calls_after_first, 4)
|
# Production code now uses a single ``git log`` call.
|
||||||
|
self.assertEqual(calls_after_first, 1)
|
||||||
|
|
||||||
# Second call with unchanged HEAD: zero new subprocess calls.
|
# Second call with unchanged HEAD: zero new subprocess calls.
|
||||||
second = self.sm._get_local_git_info(self.plugin_path)
|
second = self.sm._get_local_git_info(self.plugin_path)
|
||||||
@@ -105,7 +211,8 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
os.utime(head, (new_time, new_time))
|
os.utime(head, (new_time, new_time))
|
||||||
|
|
||||||
self.sm._get_local_git_info(self.plugin_path)
|
self.sm._get_local_git_info(self.plugin_path)
|
||||||
self.assertEqual(mock_run.call_count, calls_after_first + 4)
|
# One new ``git log`` call after cache invalidation.
|
||||||
|
self.assertEqual(mock_run.call_count, calls_after_first + 1)
|
||||||
|
|
||||||
def test_no_git_directory_returns_none(self):
|
def test_no_git_directory_returns_none(self):
|
||||||
non_git = self.plugins_dir / "no_git"
|
non_git = self.plugins_dir / "no_git"
|
||||||
@@ -192,14 +299,11 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.returncode = 0
|
result.returncode = 0
|
||||||
cmd = args[0]
|
cmd = args[0]
|
||||||
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
|
# Production code now uses a single ``git log --format=%H%n%cI``.
|
||||||
result.stdout = branch_file.read_text().strip() + "\n"
|
# Branch and remote_url are read directly from .git/HEAD/.git/config.
|
||||||
elif "--abbrev-ref" in cmd:
|
if "log" in cmd:
|
||||||
result.stdout = "main\n"
|
sha = branch_file.read_text().strip()
|
||||||
elif "config" in cmd:
|
result.stdout = f"{sha}\n2026-04-08T12:00:00+00:00\n"
|
||||||
result.stdout = "https://example.com/repo.git\n"
|
|
||||||
elif "log" in cmd:
|
|
||||||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
|
||||||
else:
|
else:
|
||||||
result.stdout = ""
|
result.stdout = ""
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
Tests for src/vegas_mode/plugin_adapter.py
|
||||||
|
|
||||||
|
Covers PluginAdapter._strip_scroll_padding(): the heuristic that crops a
|
||||||
|
plugin's own baked-in leading/trailing blank margins before Vegas mode
|
||||||
|
composites the content, so vegas_scroll.separator_width is the only gap
|
||||||
|
applied between items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.common.scroll_helper import ScrollHelper
|
||||||
|
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDisplayManager:
|
||||||
|
width = 64
|
||||||
|
height = 32
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlugin:
|
||||||
|
def __init__(self, scroll_helper):
|
||||||
|
self.scroll_helper = scroll_helper
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter():
|
||||||
|
return PluginAdapter(FakeDisplayManager())
|
||||||
|
|
||||||
|
|
||||||
|
def _solid(width: int, height: int, color: tuple) -> Image.Image:
|
||||||
|
"""Create a solid-color RGB image of the given dimensions."""
|
||||||
|
return Image.new('RGB', (width, height), color)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripScrollPadding:
|
||||||
|
def test_leading_pad_from_create_scrolling_image_is_stripped(self, adapter):
|
||||||
|
sh = ScrollHelper(64, 32)
|
||||||
|
item = _solid(40, 32, (200, 50, 50))
|
||||||
|
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
|
||||||
|
|
||||||
|
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
||||||
|
assert images[0].width == 40
|
||||||
|
assert images[0].getpixel((0, 0)) == (200, 50, 50)
|
||||||
|
|
||||||
|
def test_leading_and_trailing_pad_both_stripped(self, adapter):
|
||||||
|
sh = ScrollHelper(64, 32)
|
||||||
|
content_w = 80
|
||||||
|
full = _solid(64 + content_w + 64, 32, (0, 0, 0))
|
||||||
|
full.paste(_solid(content_w, 32, (10, 220, 30)), (64, 0))
|
||||||
|
sh.set_scrolling_image(full)
|
||||||
|
|
||||||
|
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
||||||
|
assert images[0].width == content_w
|
||||||
|
assert images[0].getpixel((0, 0)) == (10, 220, 30)
|
||||||
|
assert images[0].getpixel((content_w - 1, 0)) == (10, 220, 30)
|
||||||
|
|
||||||
|
def test_leading_only_pad_stripped_trailing_content_kept(self, adapter):
|
||||||
|
sh = ScrollHelper(64, 32)
|
||||||
|
content_w = 80
|
||||||
|
full = _solid(64 + content_w, 32, (0, 0, 0))
|
||||||
|
full.paste(_solid(content_w, 32, (5, 5, 250)), (64, 0))
|
||||||
|
sh.set_scrolling_image(full)
|
||||||
|
|
||||||
|
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
||||||
|
assert images[0].width == content_w
|
||||||
|
assert images[0].getpixel((0, 0)) == (5, 5, 250)
|
||||||
|
|
||||||
|
def test_trailing_only_pad_stripped_leading_content_kept(self, adapter):
|
||||||
|
sh = ScrollHelper(64, 32)
|
||||||
|
content_w = 80
|
||||||
|
full = _solid(content_w + 64, 32, (0, 0, 0))
|
||||||
|
full.paste(_solid(content_w, 32, (5, 5, 250)), (0, 0))
|
||||||
|
sh.set_scrolling_image(full)
|
||||||
|
|
||||||
|
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
||||||
|
assert images[0].width == content_w
|
||||||
|
assert images[0].getpixel((0, 0)) == (5, 5, 250)
|
||||||
|
|
||||||
|
def test_no_margin_image_left_untouched(self, adapter):
|
||||||
|
sh = ScrollHelper(64, 32)
|
||||||
|
raw = _solid(150, 32, (5, 5, 5))
|
||||||
|
raw.paste(_solid(50, 32, (123, 45, 67)), (0, 0))
|
||||||
|
sh.set_scrolling_image(raw)
|
||||||
|
|
||||||
|
images = adapter._get_scroll_helper_content(FakePlugin(sh), "no_margin")
|
||||||
|
assert images[0].width == 150
|
||||||
|
|
||||||
|
def test_degenerate_all_black_image_left_untouched(self, adapter):
|
||||||
|
sh = ScrollHelper(64, 32)
|
||||||
|
sh.set_scrolling_image(_solid(50, 32, (0, 0, 0)))
|
||||||
|
|
||||||
|
images = adapter._get_scroll_helper_content(FakePlugin(sh), "all_black")
|
||||||
|
assert images[0].width == 50
|
||||||
|
|
||||||
|
def test_missing_display_width_attribute_left_untouched(self, adapter):
|
||||||
|
sh = ScrollHelper(64, 32)
|
||||||
|
item = _solid(40, 32, (200, 50, 50))
|
||||||
|
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
|
||||||
|
original_width = sh.cached_image.width
|
||||||
|
del sh.display_width
|
||||||
|
|
||||||
|
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
||||||
|
assert images[0].width == original_width
|
||||||
|
|
||||||
|
def test_pad_width_not_smaller_than_image_left_untouched(self, adapter):
|
||||||
|
sh = ScrollHelper(64, 32)
|
||||||
|
sh.set_scrolling_image(_solid(64, 32, (0, 0, 0)))
|
||||||
|
|
||||||
|
images = adapter._get_scroll_helper_content(FakePlugin(sh), "narrow")
|
||||||
|
assert images[0].width == 64
|
||||||
|
|
||||||
|
def test_both_edges_matching_logs_warning(self, adapter, caplog):
|
||||||
|
sh = ScrollHelper(64, 32)
|
||||||
|
content_w = 80
|
||||||
|
full = _solid(64 + content_w + 64, 32, (0, 0, 0))
|
||||||
|
full.paste(_solid(content_w, 32, (10, 220, 30)), (64, 0))
|
||||||
|
sh.set_scrolling_image(full)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="src.vegas_mode.plugin_adapter"):
|
||||||
|
adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
||||||
|
|
||||||
|
assert any("Stripping scroll_helper padding" in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
def test_single_edge_match_logs_info_not_warning(self, adapter, caplog):
|
||||||
|
sh = ScrollHelper(64, 32)
|
||||||
|
item = _solid(40, 32, (200, 50, 50))
|
||||||
|
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="src.vegas_mode.plugin_adapter"):
|
||||||
|
adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
||||||
|
|
||||||
|
strip_records = [r for r in caplog.records if "Stripping scroll_helper padding" in r.message]
|
||||||
|
assert len(strip_records) == 1
|
||||||
|
assert strip_records[0].levelno == logging.INFO
|
||||||
@@ -141,9 +141,62 @@ class TestConfigAPI:
|
|||||||
data=json.dumps(invalid_config),
|
data=json.dumps(invalid_config),
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code in [400, 500]
|
assert response.status_code in [400, 500]
|
||||||
|
|
||||||
|
def test_save_double_sided_settings(self, client, mock_config_manager):
|
||||||
|
"""Double-sided form fields are persisted under display.double_sided."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={
|
||||||
|
'double_sided_enabled': 'true',
|
||||||
|
'double_sided_copies': '2',
|
||||||
|
'double_sided_axis': 'vertical',
|
||||||
|
},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||||
|
assert saved['display']['double_sided'] == {
|
||||||
|
'enabled': True, 'copies': 2, 'axis': 'vertical',
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_save_double_sided_unchecked_disables(self, client, mock_config_manager):
|
||||||
|
"""An omitted 'enabled' checkbox is saved as disabled, not left stale."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={'double_sided_copies': '4', 'double_sided_axis': 'horizontal'},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
|
||||||
|
assert ds['enabled'] is False
|
||||||
|
assert ds['copies'] == 4
|
||||||
|
|
||||||
|
def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager):
|
||||||
|
"""copies < 2 is rejected with a 400 before any save."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={'double_sided_enabled': 'true', 'double_sided_copies': '1'},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
mock_config_manager.save_config_atomic.assert_not_called()
|
||||||
|
|
||||||
|
def test_save_double_sided_invalid_axis_rejected(self, client, mock_config_manager):
|
||||||
|
"""An unknown axis is rejected with a 400 before any save."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={'double_sided_enabled': 'true', 'double_sided_axis': 'diagonal'},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
mock_config_manager.save_config_atomic.assert_not_called()
|
||||||
|
|
||||||
def test_get_secrets_config(self, client, mock_config_manager):
|
def test_get_secrets_config(self, client, mock_config_manager):
|
||||||
"""Test getting secrets configuration."""
|
"""Test getting secrets configuration."""
|
||||||
response = client.get('/api/v3/config/secrets')
|
response = client.get('/api/v3/config/secrets')
|
||||||
@@ -617,7 +670,8 @@ class TestDottedKeyNormalization:
|
|||||||
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
|
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
|
||||||
}
|
}
|
||||||
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||||
schema_mgr.validate_config_against_schema.return_value = []
|
# Must be a (bool, list) tuple: the endpoint does is_valid, errors = validate_config_against_schema(...)
|
||||||
|
schema_mgr.validate_config_against_schema.return_value = (True, [])
|
||||||
api_v3.schema_manager = schema_mgr
|
api_v3.schema_manager = schema_mgr
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
@@ -679,7 +733,7 @@ class TestDottedKeyNormalization:
|
|||||||
'leagues': {'eng.1': {'favorite_teams': []}},
|
'leagues': {'eng.1': {'favorite_teams': []}},
|
||||||
}
|
}
|
||||||
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||||
schema_mgr.validate_config_against_schema.return_value = []
|
schema_mgr.validate_config_against_schema.return_value = (True, [])
|
||||||
api_v3.schema_manager = schema_mgr
|
api_v3.schema_manager = schema_mgr
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
@@ -705,3 +759,54 @@ class TestDottedKeyNormalization:
|
|||||||
teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams')
|
teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams')
|
||||||
assert isinstance(teams, list), f"Expected list, got: {type(teams)}"
|
assert isinstance(teams, list), f"Expected list, got: {type(teams)}"
|
||||||
assert teams == [], f"Expected empty default list, got: {teams}"
|
assert teams == [], f"Expected empty default list, got: {teams}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginHealthRoutes:
|
||||||
|
"""Phase 1: /plugins/health and /plugins/metrics build per-installed-id so
|
||||||
|
they surface cross-process data persisted by the display service."""
|
||||||
|
|
||||||
|
def test_health_route_builds_per_installed_id(self, client, mock_plugin_manager):
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
from src.plugin_system.plugin_health import PluginHealthTracker
|
||||||
|
|
||||||
|
cache = MagicMock()
|
||||||
|
cache.get.return_value = None
|
||||||
|
api_v3.plugin_manager = mock_plugin_manager
|
||||||
|
mock_plugin_manager.plugin_manifests = {'p1': {}, 'p2': {}}
|
||||||
|
mock_plugin_manager.health_tracker = PluginHealthTracker(cache)
|
||||||
|
|
||||||
|
resp = client.get('/api/v3/plugins/health')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()['data']
|
||||||
|
assert set(data.keys()) == {'p1', 'p2'}
|
||||||
|
assert data['p1']['is_healthy'] is True
|
||||||
|
assert data['p1']['degraded'] is False
|
||||||
|
|
||||||
|
def test_health_route_reports_not_available_without_tracker(self, client, mock_plugin_manager):
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
api_v3.plugin_manager = mock_plugin_manager
|
||||||
|
mock_plugin_manager.health_tracker = None
|
||||||
|
|
||||||
|
resp = client.get('/api/v3/plugins/health')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body['data'] == {}
|
||||||
|
assert 'not available' in body['message'].lower()
|
||||||
|
|
||||||
|
def test_metrics_route_builds_per_installed_id(self, client, mock_plugin_manager):
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||||
|
|
||||||
|
cache = MagicMock()
|
||||||
|
cache.get.return_value = None
|
||||||
|
api_v3.plugin_manager = mock_plugin_manager
|
||||||
|
mock_plugin_manager.plugin_manifests = {'p1': {}}
|
||||||
|
mock_plugin_manager.resource_monitor = PluginResourceMonitor(
|
||||||
|
cache, enable_monitoring=False
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get('/api/v3/plugins/metrics')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()['data']
|
||||||
|
assert 'p1' in data
|
||||||
|
assert data['p1']['call_count'] == 0
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
Smoke tests for the settings tooltips + search UI.
|
||||||
|
|
||||||
|
These render the settings partials through Flask and assert that every settings
|
||||||
|
field carries:
|
||||||
|
- a stable search anchor id (`id="setting-..."` on its .form-group), and
|
||||||
|
- an info tooltip (`class="help-tip"` emitted by the help_tip macro).
|
||||||
|
|
||||||
|
They guard against macro/import breakage and against fields losing their anchor
|
||||||
|
or tooltip when partials are edited. See web_interface/static/v3/js/tooltips.js
|
||||||
|
and settings-search.js for the consumers of this markup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
# A realistic-enough config so the hand-written partials render every field.
|
||||||
|
REALISTIC_CONFIG = {
|
||||||
|
"web_display_autostart": True,
|
||||||
|
"timezone": "America/Chicago",
|
||||||
|
"location": {"city": "Dallas", "state": "Texas", "country": "US"},
|
||||||
|
"plugin_system": {
|
||||||
|
"auto_discover": True,
|
||||||
|
"auto_load_enabled": True,
|
||||||
|
"development_mode": False,
|
||||||
|
"plugins_directory": "plugin-repos",
|
||||||
|
},
|
||||||
|
"schedule": {},
|
||||||
|
"dim_schedule": {"dim_brightness": 30},
|
||||||
|
"sync": {"role": "standalone", "port": 5765, "follower_position": "left"},
|
||||||
|
"display": {
|
||||||
|
"hardware": {
|
||||||
|
"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1,
|
||||||
|
"brightness": 95, "hardware_mapping": "adafruit-hat-pwm",
|
||||||
|
"led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "",
|
||||||
|
"row_address_type": 0, "scan_mode": 0, "pwm_bits": 9,
|
||||||
|
"pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130,
|
||||||
|
"limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False,
|
||||||
|
"inverse_colors": False, "show_refresh_rate": False,
|
||||||
|
},
|
||||||
|
"runtime": {"gpio_slowdown": 3, "rp1_rio": 0},
|
||||||
|
"double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"},
|
||||||
|
"use_short_date_format": False,
|
||||||
|
"dynamic_duration": {"max_duration_seconds": 180},
|
||||||
|
"vegas_scroll": {
|
||||||
|
"enabled": False, "scroll_speed": 50, "separator_width": 32,
|
||||||
|
"target_fps": 125, "buffer_ahead": 2,
|
||||||
|
"plugin_order": [], "excluded_plugins": [],
|
||||||
|
},
|
||||||
|
"display_durations": {"clock": 15, "weather": 30},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
base = PROJECT_ROOT / "web_interface"
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder=str(base / "templates"),
|
||||||
|
static_folder=str(base / "static"),
|
||||||
|
)
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
|
||||||
|
from web_interface.blueprints import pages_v3 as pv
|
||||||
|
|
||||||
|
mock_cm = MagicMock()
|
||||||
|
mock_cm.load_config.return_value = REALISTIC_CONFIG
|
||||||
|
mock_cm.get_raw_file_content.return_value = REALISTIC_CONFIG
|
||||||
|
mock_cm.get_config_path.return_value = "config/config.json"
|
||||||
|
mock_cm.get_secrets_path.return_value = "config/config_secrets.json"
|
||||||
|
pv.pages_v3.config_manager = mock_cm
|
||||||
|
pv.pages_v3.plugin_manager = MagicMock(plugins={})
|
||||||
|
|
||||||
|
app.register_blueprint(pv.pages_v3, url_prefix="/v3")
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
# Settings tabs that must expose searchable, tooltipped fields.
|
||||||
|
SETTINGS_TABS = ["general", "display", "durations", "schedule", "wifi"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tab", SETTINGS_TABS)
|
||||||
|
def test_settings_partial_has_tooltips_and_anchors(client, tab):
|
||||||
|
resp = client.get(f"/v3/partials/{tab}")
|
||||||
|
assert resp.status_code == 200, f"{tab} partial failed to render"
|
||||||
|
body = resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
assert 'class="help-tip"' in body, f"{tab}: no tooltips rendered"
|
||||||
|
assert 'id="setting-' in body, f"{tab}: no search anchors rendered"
|
||||||
|
# Every settings field should be both anchored and tooltipped; tooltip count
|
||||||
|
# should not exceed anchor count (each field has at most one help_tip).
|
||||||
|
anchors = body.count('id="setting-')
|
||||||
|
tips = body.count('class="help-tip"')
|
||||||
|
assert tips >= 1 and anchors >= 1
|
||||||
|
assert tips <= anchors, f"{tab}: more tooltips ({tips}) than anchors ({anchors})"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tab", SETTINGS_TABS)
|
||||||
|
def test_settings_partial_has_per_tab_filter(client, tab):
|
||||||
|
body = client.get(f"/v3/partials/{tab}").get_data(as_text=True)
|
||||||
|
assert 'class="settings-filter' in body, f"{tab}: per-tab filter box missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_tooltip_carries_rich_text(client):
|
||||||
|
# The brightness tooltip should include the authored guidance, not just a label.
|
||||||
|
body = client.get("/v3/partials/display").get_data(as_text=True)
|
||||||
|
assert 'id="setting-display-brightness"' in body
|
||||||
|
assert "Recommended:" in body # rich detail authored into a tooltip
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_index_endpoint(client):
|
||||||
|
"""The server-built search index powers the global settings search."""
|
||||||
|
resp = client.get("/v3/settings/search-index")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert isinstance(data, dict) and isinstance(data.get("fields"), list)
|
||||||
|
fields = data["fields"]
|
||||||
|
assert len(fields) >= 40, "expected the core settings fields to be indexed"
|
||||||
|
|
||||||
|
by_id = {f["anchorId"]: f for f in fields}
|
||||||
|
# Representative fields across tabs must be present with usable text.
|
||||||
|
for anchor in ("setting-general-timezone", "setting-display-brightness",
|
||||||
|
"setting-wifi-password", "setting-durations-clock"):
|
||||||
|
assert anchor in by_id, f"{anchor} missing from search index"
|
||||||
|
entry = by_id[anchor]
|
||||||
|
assert entry["label"], f"{anchor} has no label"
|
||||||
|
assert entry["help"], f"{anchor} has no tooltip help"
|
||||||
|
assert entry["tab"] and entry["tabLabel"]
|
||||||
|
|
||||||
|
# Every entry must carry a non-empty label and a stable anchor id.
|
||||||
|
assert all(f["label"] and f["anchorId"].startswith("setting-") for f in fields)
|
||||||
|
# Section context is captured for grouped fields (e.g. Display hardware).
|
||||||
|
assert by_id["setting-display-brightness"]["section"] == "Hardware Configuration"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_config_partial_has_filter_and_nested_anchors():
|
||||||
|
"""Plugin config tabs expose the per-tab filter and anchor nested fields.
|
||||||
|
|
||||||
|
The client fixture has no installed plugins, so render the partial directly
|
||||||
|
with a schema that includes a nested section (render_nested_section).
|
||||||
|
"""
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
env = Environment(
|
||||||
|
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
|
||||||
|
autoescape=select_autoescape(["html"]),
|
||||||
|
)
|
||||||
|
plugin = {
|
||||||
|
"id": "demo-plugin", "name": "Demo Plugin", "description": "A demo",
|
||||||
|
"enabled": True, "author": "me", "version": "1.0.0",
|
||||||
|
}
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title_text": {"type": "string", "title": "Title Text",
|
||||||
|
"description": "The heading."},
|
||||||
|
"advanced": {
|
||||||
|
"type": "object", "title": "Advanced Options",
|
||||||
|
"description": "Nested options.",
|
||||||
|
"properties": {
|
||||||
|
"scroll_speed": {"type": "integer", "title": "Scroll Speed",
|
||||||
|
"description": "Pixels per second."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config = {"title_text": "Hi", "advanced": {"scroll_speed": 50}}
|
||||||
|
html = env.get_template("v3/partials/plugin_config.html").render(
|
||||||
|
plugin=plugin, schema=schema, config=config
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'class="settings-filter' in html, "plugin config: per-tab filter box missing"
|
||||||
|
assert "nested-content" in html, "plugin config: nested section not rendered"
|
||||||
|
assert 'id="setting-' in html, "plugin config: no search anchors rendered"
|
||||||
|
assert 'class="help-tip"' in html, "plugin config: no tooltips rendered"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Regression test for saving plugin config fields whose schema keys contain dots
|
||||||
|
(e.g. soccer league keys like "fifa.world", "eng.1", "usa.1").
|
||||||
|
|
||||||
|
Bug: the web config form posts form-data with dotted paths such as
|
||||||
|
"leagues.fifa.world.enabled". The helpers that resolve those paths split on every
|
||||||
|
dot, so the dotted league key "fifa.world" was mistaken for nested "fifa" ->
|
||||||
|
"world" objects. Per-league edits (enable, favorite_teams, nested booleans) were
|
||||||
|
written to a fabricated "leagues.fifa.world" branch while the real league object
|
||||||
|
was never updated, so the save silently dropped the change and the saved config
|
||||||
|
came out byte-identical.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
_get_schema_property,
|
||||||
|
_set_nested_value,
|
||||||
|
_parse_form_value_with_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"leagues": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"fifa.world": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {"type": "boolean"},
|
||||||
|
"favorite_teams": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
},
|
||||||
|
"display_modes": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"live": {"type": "boolean"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDottedLeagueKeys(unittest.TestCase):
|
||||||
|
def test_schema_lookup_resolves_dotted_league_key(self):
|
||||||
|
prop = _get_schema_property(SCHEMA, "leagues.fifa.world.favorite_teams")
|
||||||
|
self.assertIsNotNone(prop, "dotted league key path should resolve")
|
||||||
|
self.assertEqual(prop.get("type"), "array")
|
||||||
|
|
||||||
|
def test_schema_lookup_resolves_nested_object_beneath_dotted_key(self):
|
||||||
|
live = _get_schema_property(SCHEMA, "leagues.fifa.world.display_modes.live")
|
||||||
|
self.assertIsNotNone(live)
|
||||||
|
self.assertEqual(live.get("type"), "boolean")
|
||||||
|
|
||||||
|
def test_parse_typed_value_for_dotted_key(self):
|
||||||
|
# Comma-separated text input "USA" must become an array, not the raw string.
|
||||||
|
parsed = _parse_form_value_with_schema(
|
||||||
|
"USA", "leagues.fifa.world.favorite_teams", SCHEMA
|
||||||
|
)
|
||||||
|
self.assertEqual(parsed, ["USA"])
|
||||||
|
|
||||||
|
def test_set_value_updates_real_league_not_fabricated_branch(self):
|
||||||
|
config = {"leagues": {"fifa.world": {"enabled": False, "favorite_teams": []}}}
|
||||||
|
_set_nested_value(config, "leagues.fifa.world.enabled", True)
|
||||||
|
_set_nested_value(config, "leagues.fifa.world.favorite_teams", ["USA"])
|
||||||
|
|
||||||
|
self.assertTrue(config["leagues"]["fifa.world"]["enabled"])
|
||||||
|
self.assertEqual(config["leagues"]["fifa.world"]["favorite_teams"], ["USA"])
|
||||||
|
# The real league must be updated and no fabricated "fifa" branch created.
|
||||||
|
self.assertNotIn("fifa", config["leagues"])
|
||||||
|
|
||||||
|
def test_set_value_into_missing_leaf_lands_in_real_league(self):
|
||||||
|
# A leaf that does not exist yet still resolves into the real dotted league.
|
||||||
|
config = {"leagues": {"fifa.world": {"enabled": False}}}
|
||||||
|
_set_nested_value(config, "leagues.fifa.world.display_modes.live", True)
|
||||||
|
self.assertTrue(
|
||||||
|
config["leagues"]["fifa.world"]["display_modes"]["live"]
|
||||||
|
)
|
||||||
|
self.assertNotIn("fifa", config["leagues"])
|
||||||
|
|
||||||
|
def test_plain_nested_paths_still_work(self):
|
||||||
|
config = {}
|
||||||
|
_set_nested_value(config, "customization.text.font", "small")
|
||||||
|
self.assertEqual(config["customization"]["text"]["font"], "small")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -224,20 +224,14 @@ class TestStateReconciliation(unittest.TestCase):
|
|||||||
with open(manifest_path, 'w') as f:
|
with open(manifest_path, 'w') as f:
|
||||||
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
||||||
|
|
||||||
# Mock save_config to track calls
|
|
||||||
saved_configs = []
|
|
||||||
def save_config(config):
|
|
||||||
saved_configs.append(config)
|
|
||||||
|
|
||||||
self.config_manager.save_config = save_config
|
|
||||||
|
|
||||||
# Run reconciliation
|
# Run reconciliation
|
||||||
result = self.reconciler.reconcile_state()
|
result = self.reconciler.reconcile_state()
|
||||||
|
|
||||||
# Verify fix was attempted
|
# config.json is the source of truth for enabled state. The fix syncs
|
||||||
|
# the state manager to match config (config says True → state set True),
|
||||||
|
# rather than overwriting the config with the stale state value.
|
||||||
self.assertEqual(len(result.inconsistencies_fixed), 1)
|
self.assertEqual(len(result.inconsistencies_fixed), 1)
|
||||||
self.assertEqual(len(saved_configs), 1)
|
self.state_manager.set_plugin_enabled.assert_called_once_with("plugin1", True)
|
||||||
self.assertEqual(saved_configs[0]["plugin1"]["enabled"], False)
|
|
||||||
|
|
||||||
def test_multiple_inconsistencies(self):
|
def test_multiple_inconsistencies(self):
|
||||||
"""Test reconciliation with multiple inconsistencies."""
|
"""Test reconciliation with multiple inconsistencies."""
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Guards that every privileged systemctl call the web interface makes is
|
||||||
|
covered by a passwordless-sudo grant in configure_web_sudo.sh.
|
||||||
|
|
||||||
|
The web interface runs headless (no TTY), so any `sudo` call that is not
|
||||||
|
matched by a NOPASSWD rule in /etc/sudoers.d/ledmatrix_web falls back to a
|
||||||
|
password prompt and fails with:
|
||||||
|
|
||||||
|
sudo: a terminal is required to read the password
|
||||||
|
|
||||||
|
sudo matches the command line by exact string, so `systemctl start ledmatrix`
|
||||||
|
and `systemctl start ledmatrix.service` are NOT interchangeable. This test
|
||||||
|
parses both the production blueprint and the sudoers-generator script and
|
||||||
|
asserts the (verb, unit) pairs line up, catching the suffix-mismatch class of
|
||||||
|
bug before it ships.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
|
||||||
|
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _sudo_systemctl_calls(source: str) -> set[tuple[str, str]]:
|
||||||
|
"""Return (verb, unit) for every list literal beginning with
|
||||||
|
['sudo', 'systemctl', ...] passed to a subprocess call in the source."""
|
||||||
|
calls: set[tuple[str, str]] = set()
|
||||||
|
for node in ast.walk(ast.parse(source)):
|
||||||
|
if not isinstance(node, ast.List):
|
||||||
|
continue
|
||||||
|
elts = node.elts
|
||||||
|
if len(elts) < 4:
|
||||||
|
continue
|
||||||
|
if not all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in elts[:4]):
|
||||||
|
continue
|
||||||
|
if elts[0].value == "sudo" and elts[1].value == "systemctl":
|
||||||
|
calls.add((elts[2].value, elts[3].value))
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
|
||||||
|
"""Return (verb, unit) for each `$SYSTEMCTL_PATH <verb> <unit>` NOPASSWD
|
||||||
|
grant emitted by the sudoers-generator script."""
|
||||||
|
rules: set[tuple[str, str]] = set()
|
||||||
|
for match in re.finditer(r"\$SYSTEMCTL_PATH\s+(\S+)\s+(\S+)", script):
|
||||||
|
verb, unit = match.group(1), match.group(2).rstrip('"')
|
||||||
|
rules.add((verb, unit))
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_sudo_systemctl_call_is_granted() -> None:
|
||||||
|
calls = _sudo_systemctl_calls(API_V3.read_text())
|
||||||
|
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
|
||||||
|
|
||||||
|
assert calls, "expected to find sudo systemctl calls in api_v3.py"
|
||||||
|
|
||||||
|
uncovered = {c for c in calls if c not in rules}
|
||||||
|
assert not uncovered, (
|
||||||
|
"These sudo systemctl calls have no matching NOPASSWD grant in "
|
||||||
|
"configure_web_sudo.sh; they will fail headless with "
|
||||||
|
"'sudo: a terminal is required to read the password': "
|
||||||
|
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(uncovered))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_units_are_fully_qualified() -> None:
|
||||||
|
"""Privileged systemctl calls must name the unit as <name>.service so they
|
||||||
|
match the sudoers grants, which use the fully-qualified unit name."""
|
||||||
|
calls = _sudo_systemctl_calls(API_V3.read_text())
|
||||||
|
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
|
||||||
|
assert not unqualified, (
|
||||||
|
"sudo systemctl calls must use fully-qualified .service unit names: "
|
||||||
|
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(unqualified))
|
||||||
|
)
|
||||||
@@ -3,6 +3,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -27,6 +28,7 @@ from src.plugin_system.health_monitor import PluginHealthMonitor
|
|||||||
|
|
||||||
_JOURNALCTL = shutil.which('journalctl')
|
_JOURNALCTL = shutil.which('journalctl')
|
||||||
_SYSTEMCTL = shutil.which('systemctl')
|
_SYSTEMCTL = shutil.which('systemctl')
|
||||||
|
_VCGENCMD = shutil.which('vcgencmd')
|
||||||
|
|
||||||
# Create Flask app
|
# Create Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -79,6 +81,21 @@ plugin_manager = PluginManager(
|
|||||||
cache_manager=None # Not needed for web interface
|
cache_manager=None # Not needed for web interface
|
||||||
)
|
)
|
||||||
plugin_store_manager = PluginStoreManager(plugins_dir=str(plugins_dir))
|
plugin_store_manager = PluginStoreManager(plugins_dir=str(plugins_dir))
|
||||||
|
# A core `git pull` update (or any checkout) restores built-in plugins
|
||||||
|
# committed under plugin-repos/, even ones the user uninstalled. Re-remove any
|
||||||
|
# the user previously uninstalled at startup so a manual update on the Pi
|
||||||
|
# doesn't resurrect them.
|
||||||
|
try:
|
||||||
|
_purged = plugin_store_manager.purge_uninstalled_plugins()
|
||||||
|
if _purged:
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
"Re-removed %d uninstalled plugin(s) restored since last run: %s",
|
||||||
|
len(_purged), ", ".join(_purged),
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError) as _purge_err:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"Startup plugin purge failed: %s", _purge_err
|
||||||
|
)
|
||||||
saved_repositories_manager = SavedRepositoriesManager()
|
saved_repositories_manager = SavedRepositoriesManager()
|
||||||
|
|
||||||
# Initialize schema manager
|
# Initialize schema manager
|
||||||
@@ -143,6 +160,22 @@ api_v3.health_monitor = health_monitor
|
|||||||
from src.cache_manager import CacheManager
|
from src.cache_manager import CacheManager
|
||||||
api_v3.cache_manager = CacheManager()
|
api_v3.cache_manager = CacheManager()
|
||||||
|
|
||||||
|
# Wire plugin health/metrics for the web process. The display service records
|
||||||
|
# health and execution-time metrics to the shared on-disk cache; giving the web
|
||||||
|
# process its own tracker/monitor backed by that same cache lets the health API
|
||||||
|
# routes (/api/v3/plugins/health, /plugins/metrics) read that persisted data.
|
||||||
|
# Guarded so any init failure degrades to "not available" rather than breaking
|
||||||
|
# the web server.
|
||||||
|
try:
|
||||||
|
from src.plugin_system.plugin_health import PluginHealthTracker
|
||||||
|
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||||
|
plugin_manager.health_tracker = PluginHealthTracker(api_v3.cache_manager)
|
||||||
|
plugin_manager.resource_monitor = PluginResourceMonitor(api_v3.cache_manager)
|
||||||
|
except Exception as _hm_err: # pragma: no cover - defensive startup guard
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"Could not enable plugin health/metrics for web UI: %s", _hm_err
|
||||||
|
)
|
||||||
|
|
||||||
app.register_blueprint(pages_v3, url_prefix='/v3')
|
app.register_blueprint(pages_v3, url_prefix='/v3')
|
||||||
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||||
|
|
||||||
@@ -391,6 +424,22 @@ def captive_portal_redirect():
|
|||||||
# Redirect to lightweight captive portal setup page (not the full UI)
|
# Redirect to lightweight captive portal setup page (not the full UI)
|
||||||
return redirect(url_for('pages_v3.captive_setup'), code=302)
|
return redirect(url_for('pages_v3.captive_setup'), code=302)
|
||||||
|
|
||||||
|
# Append a content-version query param (file mtime) to every static URL so the
|
||||||
|
# long-lived `immutable` cache (see add_security_headers below) is actually safe:
|
||||||
|
# when a static file changes its URL changes, so browsers refetch it. Without
|
||||||
|
# this, edited JS/CSS were served immutable under an unchanging URL and never
|
||||||
|
# reached clients until a manual cache clear.
|
||||||
|
@app.url_defaults
|
||||||
|
def add_static_version(endpoint, values):
|
||||||
|
if endpoint == 'static' and values.get('filename'):
|
||||||
|
try:
|
||||||
|
file_path = os.path.join(app.static_folder, values['filename'])
|
||||||
|
values['v'] = int(os.path.getmtime(file_path))
|
||||||
|
except OSError:
|
||||||
|
# File missing (e.g. plugin asset not yet installed) — skip versioning.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Add security headers and caching to all responses
|
# Add security headers and caching to all responses
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def add_security_headers(response):
|
def add_security_headers(response):
|
||||||
@@ -467,6 +516,37 @@ class _StreamBroadcaster:
|
|||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _get_power_status():
|
||||||
|
"""Check Raspberry Pi under-voltage/throttling status via vcgencmd.
|
||||||
|
|
||||||
|
Returns a dict of decoded flags, or None on non-Pi platforms (no
|
||||||
|
vcgencmd) or if the call fails for any reason. See:
|
||||||
|
https://www.raspberrypi.com/documentation/computers/os.html#get_throttled
|
||||||
|
"""
|
||||||
|
if not _VCGENCMD:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[_VCGENCMD, 'get_throttled'], capture_output=True, text=True, timeout=2
|
||||||
|
)
|
||||||
|
match = re.search(r'0x([0-9a-fA-F]+)', result.stdout)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
bits = int(match.group(1), 16)
|
||||||
|
return {
|
||||||
|
'under_voltage_now': bool(bits & 0x1),
|
||||||
|
'freq_capped_now': bool(bits & 0x2),
|
||||||
|
'throttled_now': bool(bits & 0x4),
|
||||||
|
'soft_temp_limit_now': bool(bits & 0x8),
|
||||||
|
'under_voltage_occurred': bool(bits & 0x10000),
|
||||||
|
'freq_capped_occurred': bool(bits & 0x20000),
|
||||||
|
'throttled_occurred': bool(bits & 0x40000),
|
||||||
|
'soft_temp_limit_occurred': bool(bits & 0x80000),
|
||||||
|
}
|
||||||
|
except (subprocess.SubprocessError, OSError, ValueError) as e:
|
||||||
|
app.logger.warning("vcgencmd get_throttled failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
# System status generator for SSE
|
# System status generator for SSE
|
||||||
def system_status_generator():
|
def system_status_generator():
|
||||||
"""Generate system status updates"""
|
"""Generate system status updates"""
|
||||||
@@ -513,7 +593,8 @@ def system_status_generator():
|
|||||||
'cpu_percent': cpu_percent,
|
'cpu_percent': cpu_percent,
|
||||||
'memory_used_percent': memory_used_percent,
|
'memory_used_percent': memory_used_percent,
|
||||||
'cpu_temp': cpu_temp,
|
'cpu_temp': cpu_temp,
|
||||||
'disk_used_percent': 0
|
'disk_used_percent': 0,
|
||||||
|
'power': _get_power_status()
|
||||||
}
|
}
|
||||||
yield status
|
yield status
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,9 +26,51 @@ from src.web_interface.validators import (
|
|||||||
validate_file_upload
|
validate_file_upload
|
||||||
)
|
)
|
||||||
from src.error_aggregator import get_error_aggregator
|
from src.error_aggregator import get_error_aggregator
|
||||||
|
from src.common.permission_utils import install_requirements_file
|
||||||
|
|
||||||
_SUDO = shutil.which('sudo')
|
_SUDO = shutil.which('sudo')
|
||||||
_JOURNALCTL = shutil.which('journalctl')
|
_JOURNALCTL = shutil.which('journalctl')
|
||||||
|
_GIT = shutil.which('git')
|
||||||
|
|
||||||
|
# Cap subprocess output returned to the browser — pip can produce MBs on build failures.
|
||||||
|
_MAX_OUTPUT_BYTES = 51_200 # 50 KB
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_output(stdout: str, stderr: str) -> str:
|
||||||
|
"""Combine stdout+stderr and truncate to _MAX_OUTPUT_BYTES (keeping the tail)."""
|
||||||
|
combined = (stdout + stderr).strip()
|
||||||
|
if len(combined) > _MAX_OUTPUT_BYTES:
|
||||||
|
combined = '[...output truncated...]\n' + combined[-_MAX_OUTPUT_BYTES:]
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
def _pip_install_requirements(req_file: Path, timeout: int) -> subprocess.CompletedProcess:
|
||||||
|
"""Install a requirements.txt file, preferring the vetted sudo wrapper so
|
||||||
|
the packages are visible to root-run ledmatrix.service — not just to
|
||||||
|
whichever non-root user runs this web process. Falls back to installing
|
||||||
|
for the current process only if the wrapper isn't set up yet (i.e. the
|
||||||
|
admin hasn't run scripts/install/configure_web_sudo.sh since upgrading),
|
||||||
|
so the button still does *something* useful rather than hard-failing.
|
||||||
|
|
||||||
|
Thin wrapper around the shared implementation in permission_utils so the
|
||||||
|
Plugin Store's own dependency installation (store_manager.py) follows the
|
||||||
|
exact same root-visible install path instead of a divergent one.
|
||||||
|
"""
|
||||||
|
return install_requirements_file(req_file, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_git_remote_url(url: str) -> str:
|
||||||
|
"""Strip embedded username/password from an HTTPS remote URL before returning it to the UI."""
|
||||||
|
try:
|
||||||
|
p = urlparse(url)
|
||||||
|
if p.scheme in ('http', 'https') and (p.username or p.password):
|
||||||
|
netloc = p.hostname or ''
|
||||||
|
if p.port:
|
||||||
|
netloc += f':{p.port}'
|
||||||
|
return urlunparse(p._replace(netloc=netloc))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return url
|
||||||
|
|
||||||
# Will be initialized when blueprint is registered
|
# Will be initialized when blueprint is registered
|
||||||
config_manager = None
|
config_manager = None
|
||||||
@@ -190,7 +233,7 @@ def _ensure_display_service_running():
|
|||||||
if status.get('active'):
|
if status.get('active'):
|
||||||
status['started'] = False
|
status['started'] = False
|
||||||
return status
|
return status
|
||||||
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix'])
|
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service'])
|
||||||
service_status = _get_display_service_status()
|
service_status = _get_display_service_status()
|
||||||
result['started'] = result.get('returncode') == 0
|
result['started'] = result.get('returncode') == 0
|
||||||
result['active'] = service_status.get('active')
|
result['active'] = service_status.get('active')
|
||||||
@@ -199,7 +242,7 @@ def _ensure_display_service_running():
|
|||||||
|
|
||||||
def _stop_display_service():
|
def _stop_display_service():
|
||||||
"""Stop the ledmatrix display service."""
|
"""Stop the ledmatrix display service."""
|
||||||
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix'])
|
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service'])
|
||||||
status = _get_display_service_status()
|
status = _get_display_service_status()
|
||||||
result['active'] = status.get('active')
|
result['active'] = status.get('active')
|
||||||
result['status'] = status
|
result['status'] = status
|
||||||
@@ -705,7 +748,8 @@ def save_main_config():
|
|||||||
display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping',
|
display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping',
|
||||||
'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
|
'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
|
||||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format',
|
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format',
|
||||||
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type']
|
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type',
|
||||||
|
'row_address_type']
|
||||||
|
|
||||||
if any(k in data for k in display_fields):
|
if any(k in data for k in display_fields):
|
||||||
if 'display' not in current_config:
|
if 'display' not in current_config:
|
||||||
@@ -736,14 +780,23 @@ def save_main_config():
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
|
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
|
||||||
|
|
||||||
|
# Validate row_address_type
|
||||||
|
if 'row_address_type' in data:
|
||||||
|
try:
|
||||||
|
rat_val = int(data['row_address_type'])
|
||||||
|
if rat_val < 0 or rat_val > 4:
|
||||||
|
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
|
||||||
|
|
||||||
# Handle hardware settings
|
# Handle hardware settings
|
||||||
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
||||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||||
'led_rgb_sequence', 'multiplexing', 'panel_type']:
|
'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type']:
|
||||||
if field in data:
|
if field in data:
|
||||||
if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode',
|
if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode',
|
||||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||||
'multiplexing']:
|
'multiplexing', 'row_address_type']:
|
||||||
current_config['display']['hardware'][field] = int(data[field])
|
current_config['display']['hardware'][field] = int(data[field])
|
||||||
else:
|
else:
|
||||||
current_config['display']['hardware'][field] = data[field]
|
current_config['display']['hardware'][field] = data[field]
|
||||||
@@ -773,6 +826,46 @@ def save_main_config():
|
|||||||
current_config['display']['dynamic_duration'] = {}
|
current_config['display']['dynamic_duration'] = {}
|
||||||
current_config['display']['dynamic_duration']['max_duration_seconds'] = int(data['max_dynamic_duration_seconds'])
|
current_config['display']['dynamic_duration']['max_duration_seconds'] = int(data['max_dynamic_duration_seconds'])
|
||||||
|
|
||||||
|
# Handle double-sided display settings
|
||||||
|
double_sided_fields = ['double_sided_enabled', 'double_sided_copies', 'double_sided_axis']
|
||||||
|
if any(k in data for k in double_sided_fields):
|
||||||
|
if 'display' not in current_config:
|
||||||
|
current_config['display'] = {}
|
||||||
|
if 'double_sided' not in current_config['display']:
|
||||||
|
current_config['display']['double_sided'] = {}
|
||||||
|
ds_config = current_config['display']['double_sided']
|
||||||
|
|
||||||
|
# Enabled checkbox: omitted from the form when unchecked.
|
||||||
|
ds_config['enabled'] = _coerce_to_bool(data.get('double_sided_enabled'))
|
||||||
|
|
||||||
|
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None):
|
||||||
|
try:
|
||||||
|
copies = int(data['double_sided_copies'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
|
||||||
|
if not (2 <= copies <= 8):
|
||||||
|
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
|
||||||
|
# Validate divisibility against the relevant hardware dimension.
|
||||||
|
# Use axis from this request if provided, else from stored config.
|
||||||
|
hw = current_config.get('display', {}).get('hardware', {})
|
||||||
|
effective_axis = (data.get('double_sided_axis')
|
||||||
|
or current_config.get('display', {}).get('double_sided', {}).get('axis', 'horizontal'))
|
||||||
|
if effective_axis == 'horizontal':
|
||||||
|
chain_length = int(hw.get('chain_length', 2) or 2)
|
||||||
|
if chain_length % copies != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"}), 400
|
||||||
|
elif effective_axis == 'vertical':
|
||||||
|
parallel = int(hw.get('parallel', 1) or 1)
|
||||||
|
if parallel % copies != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"}), 400
|
||||||
|
ds_config['copies'] = copies
|
||||||
|
|
||||||
|
if 'double_sided_axis' in data:
|
||||||
|
axis = data['double_sided_axis']
|
||||||
|
if axis not in ('horizontal', 'vertical'):
|
||||||
|
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
|
||||||
|
ds_config['axis'] = axis
|
||||||
|
|
||||||
# Handle Vegas scroll mode settings
|
# Handle Vegas scroll mode settings
|
||||||
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
|
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
|
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
|
||||||
@@ -1018,6 +1111,8 @@ def save_main_config():
|
|||||||
continue
|
continue
|
||||||
if key in vegas_fields:
|
if key in vegas_fields:
|
||||||
continue
|
continue
|
||||||
|
if key in double_sided_fields:
|
||||||
|
continue
|
||||||
# For any remaining keys (including plugin keys), use deep merge to preserve existing settings
|
# For any remaining keys (including plugin keys), use deep merge to preserve existing settings
|
||||||
if key in current_config and isinstance(current_config[key], dict) and isinstance(data[key], dict):
|
if key in current_config and isinstance(current_config[key], dict) and isinstance(data[key], dict):
|
||||||
# Deep merge to preserve existing settings
|
# Deep merge to preserve existing settings
|
||||||
@@ -1461,7 +1556,7 @@ def execute_system_action():
|
|||||||
# For on-demand modes, we would need to integrate with the display controller
|
# For on-demand modes, we would need to integrate with the display controller
|
||||||
# For now, just start the display service
|
# For now, just start the display service
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
except subprocess.TimeoutExpired as e:
|
except subprocess.TimeoutExpired as e:
|
||||||
logger.error("start_display (%s) timed out: %s", mode, e)
|
logger.error("start_display (%s) timed out: %s", mode, e)
|
||||||
@@ -1478,16 +1573,16 @@ def execute_system_action():
|
|||||||
resp['stderr'] = result.stderr.strip()
|
resp['stderr'] = result.stderr.strip()
|
||||||
return jsonify(resp)
|
return jsonify(resp)
|
||||||
else:
|
else:
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'stop_display':
|
elif action == 'stop_display':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'enable_autostart':
|
elif action == 'enable_autostart':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'disable_autostart':
|
elif action == 'disable_autostart':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'reboot_system':
|
elif action == 'reboot_system':
|
||||||
result = subprocess.run(['sudo', 'reboot'],
|
result = subprocess.run(['sudo', 'reboot'],
|
||||||
@@ -1559,6 +1654,20 @@ def execute_system_action():
|
|||||||
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
|
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
|
||||||
if result.stdout and "Already up to date" not in result.stdout:
|
if result.stdout and "Already up to date" not in result.stdout:
|
||||||
pull_message = f"Code updated successfully.{stash_info}"
|
pull_message = f"Code updated successfully.{stash_info}"
|
||||||
|
# A `git pull` restores built-in plugins (committed under
|
||||||
|
# plugin-repos/) even if the user uninstalled them. Re-remove
|
||||||
|
# any the user previously uninstalled so the update doesn't
|
||||||
|
# resurrect them.
|
||||||
|
if api_v3.plugin_store_manager:
|
||||||
|
try:
|
||||||
|
purged = api_v3.plugin_store_manager.purge_uninstalled_plugins()
|
||||||
|
if purged:
|
||||||
|
logger.info(
|
||||||
|
"Re-removed %d uninstalled plugin(s) restored by update: %s",
|
||||||
|
len(purged), ", ".join(purged),
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError) as purge_err:
|
||||||
|
logger.warning("Post-update plugin purge failed: %s", purge_err)
|
||||||
else:
|
else:
|
||||||
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
|
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
|
||||||
pull_message = "Update failed; check logs for details"
|
pull_message = "Update failed; check logs for details"
|
||||||
@@ -1568,12 +1677,87 @@ def execute_system_action():
|
|||||||
'message': pull_message,
|
'message': pull_message,
|
||||||
})
|
})
|
||||||
elif action == 'restart_display_service':
|
elif action == 'restart_display_service':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'restart_web_service':
|
elif action == 'restart_web_service':
|
||||||
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'],
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
|
elif action == 'install_base_requirements':
|
||||||
|
req_file = PROJECT_ROOT / 'requirements.txt'
|
||||||
|
if not req_file.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
||||||
|
result = _pip_install_requirements(req_file, timeout=120)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if result.returncode == 0 else 'error',
|
||||||
|
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
|
||||||
|
'output': _truncate_output(result.stdout, result.stderr)
|
||||||
|
})
|
||||||
|
elif action == 'install_plugin_requirements':
|
||||||
|
active_pm = getattr(api_v3, 'plugin_manager', None)
|
||||||
|
if active_pm:
|
||||||
|
plugins_dir = Path(active_pm.plugins_dir)
|
||||||
|
else:
|
||||||
|
_cm = getattr(api_v3, 'config_manager', None)
|
||||||
|
_cfg = _cm.load_config() if _cm else {}
|
||||||
|
_dir_name = _cfg.get('plugin_system', {}).get('plugins_directory', 'plugin-repos')
|
||||||
|
plugins_dir = Path(_dir_name) if os.path.isabs(_dir_name) else PROJECT_ROOT / _dir_name
|
||||||
|
results = []
|
||||||
|
if plugins_dir.exists():
|
||||||
|
for p in sorted(plugins_dir.iterdir()):
|
||||||
|
req = p / 'requirements.txt'
|
||||||
|
if p.is_dir() and req.exists():
|
||||||
|
try:
|
||||||
|
r = _pip_install_requirements(req, timeout=60)
|
||||||
|
results.append({
|
||||||
|
'plugin': p.name,
|
||||||
|
'ok': r.returncode == 0,
|
||||||
|
'output': _truncate_output(r.stdout, r.stderr)
|
||||||
|
})
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
results.append({'plugin': p.name, 'ok': False, 'output': 'pip install timed out'})
|
||||||
|
except OSError as exc:
|
||||||
|
results.append({'plugin': p.name, 'ok': False, 'output': exc.strerror or 'OS error'})
|
||||||
|
ok_count = sum(1 for r in results if r['ok'])
|
||||||
|
all_ok = all(r['ok'] for r in results) if results else True
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if all_ok else 'error',
|
||||||
|
'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found',
|
||||||
|
'details': results
|
||||||
|
})
|
||||||
|
elif action == 'force_git_reset':
|
||||||
|
if not _GIT:
|
||||||
|
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
|
||||||
|
project_dir = str(PROJECT_ROOT)
|
||||||
|
fetch = subprocess.run(
|
||||||
|
[_GIT, 'fetch', 'origin'],
|
||||||
|
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||||
|
)
|
||||||
|
if fetch.returncode != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()})
|
||||||
|
reset = subprocess.run(
|
||||||
|
[_GIT, 'reset', '--hard', 'origin/main'],
|
||||||
|
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if reset.returncode == 0 else 'error',
|
||||||
|
'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed',
|
||||||
|
'output': (reset.stdout + reset.stderr).strip()
|
||||||
|
})
|
||||||
|
elif action == 'clear_pycache':
|
||||||
|
cleared = 0
|
||||||
|
failed = 0
|
||||||
|
for d in PROJECT_ROOT.rglob('__pycache__'):
|
||||||
|
if d.is_dir():
|
||||||
|
try:
|
||||||
|
shutil.rmtree(d)
|
||||||
|
cleared += 1
|
||||||
|
except OSError:
|
||||||
|
failed += 1
|
||||||
|
msg = f'Cleared {cleared} __pycache__ directories'
|
||||||
|
if failed:
|
||||||
|
msg += f' ({failed} could not be removed)'
|
||||||
|
return jsonify({'status': 'success', 'message': msg})
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
||||||
|
|
||||||
@@ -1596,6 +1780,35 @@ def execute_system_action():
|
|||||||
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
|
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
|
||||||
|
|
||||||
|
@api_v3.route('/system/git-info', methods=['GET'])
|
||||||
|
def get_git_info():
|
||||||
|
"""Return branch, dirty state, recent commits and remote URL for the Tools tab."""
|
||||||
|
if not _GIT:
|
||||||
|
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
|
||||||
|
d = str(PROJECT_ROOT)
|
||||||
|
try:
|
||||||
|
branch = subprocess.run([_GIT, 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||||
|
if branch.returncode != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': f'git branch failed: {branch.stderr.strip()}'}), 500
|
||||||
|
|
||||||
|
status = subprocess.run([_GIT, 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d)
|
||||||
|
if status.returncode != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': f'git status failed: {status.stderr.strip()}'}), 500
|
||||||
|
|
||||||
|
log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||||
|
remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||||
|
return jsonify({
|
||||||
|
'branch': branch.stdout.strip(),
|
||||||
|
'dirty': bool(status.stdout.strip()),
|
||||||
|
'status': status.stdout.strip(),
|
||||||
|
'recent_commits': log.stdout.strip() if log.returncode == 0 else '',
|
||||||
|
'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_git_info failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/hardware/status', methods=['GET'])
|
@api_v3.route('/hardware/status', methods=['GET'])
|
||||||
def get_hardware_status():
|
def get_hardware_status():
|
||||||
"""Return LED matrix hardware initialization status written by display_manager at startup."""
|
"""Return LED matrix hardware initialization status written by display_manager at startup."""
|
||||||
@@ -1860,6 +2073,18 @@ def get_installed_plugins():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _build_plugin_entry_inner(plugin_info, plugin_id):
|
def _build_plugin_entry_inner(plugin_info, plugin_id):
|
||||||
|
# Capture runtime state (state machine + error context) before the
|
||||||
|
# manifest merge below can shadow the 'state' key. get_all_plugin_info
|
||||||
|
# attaches this via PluginStateManager.get_state_info(); surfacing it
|
||||||
|
# lets the UI show *why* a plugin isn't running instead of just
|
||||||
|
# 'loaded: false'.
|
||||||
|
state_info = plugin_info.get('state')
|
||||||
|
plugin_state = None
|
||||||
|
plugin_error_info = None
|
||||||
|
if isinstance(state_info, dict):
|
||||||
|
plugin_state = state_info.get('state')
|
||||||
|
plugin_error_info = state_info.get('error_info')
|
||||||
|
|
||||||
# Re-read manifest from disk to ensure we have the latest metadata
|
# Re-read manifest from disk to ensure we have the latest metadata
|
||||||
manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
||||||
if manifest_path.exists():
|
if manifest_path.exists():
|
||||||
@@ -1941,6 +2166,8 @@ def get_installed_plugins():
|
|||||||
'enabled': enabled,
|
'enabled': enabled,
|
||||||
'verified': verified,
|
'verified': verified,
|
||||||
'loaded': plugin_info.get('loaded', False),
|
'loaded': plugin_info.get('loaded', False),
|
||||||
|
'state': plugin_state,
|
||||||
|
'error_info': plugin_error_info,
|
||||||
'last_updated': last_updated,
|
'last_updated': last_updated,
|
||||||
'last_commit': last_commit,
|
'last_commit': last_commit,
|
||||||
'last_commit_message': last_commit_message,
|
'last_commit_message': last_commit_message,
|
||||||
@@ -1960,6 +2187,31 @@ def get_installed_plugins():
|
|||||||
logger.error('Error in get_installed_plugins', exc_info=True)
|
logger.error('Error in get_installed_plugins', exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
|
def _installed_plugin_ids():
|
||||||
|
"""Best-effort list of installed plugin IDs for the web process.
|
||||||
|
|
||||||
|
Health/metrics state is written by the separate display service to the
|
||||||
|
shared on-disk cache, so the tracker's in-memory set is empty here. We
|
||||||
|
enumerate the installed plugins and read each one's persisted summary by ID
|
||||||
|
instead of relying on the tracker's in-memory `get_all_*` view.
|
||||||
|
"""
|
||||||
|
pm = api_v3.plugin_manager
|
||||||
|
manifests = getattr(pm, 'plugin_manifests', None)
|
||||||
|
if not manifests:
|
||||||
|
# Only pay for a discovery scan when we haven't discovered anything yet;
|
||||||
|
# subsequent polls reuse the already-populated manifest map.
|
||||||
|
try:
|
||||||
|
pm.discover_plugins()
|
||||||
|
except Exception:
|
||||||
|
logger.debug('discover_plugins failed while listing plugin ids', exc_info=True)
|
||||||
|
manifests = getattr(pm, 'plugin_manifests', None)
|
||||||
|
try:
|
||||||
|
return list(manifests.keys()) if manifests else []
|
||||||
|
except Exception:
|
||||||
|
logger.debug('listing plugin_manifests failed while building plugin ids', exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/plugins/health', methods=['GET'])
|
@api_v3.route('/plugins/health', methods=['GET'])
|
||||||
def get_plugin_health():
|
def get_plugin_health():
|
||||||
"""Get health metrics for all plugins"""
|
"""Get health metrics for all plugins"""
|
||||||
@@ -1975,8 +2227,23 @@ def get_plugin_health():
|
|||||||
'message': 'Health tracking not available'
|
'message': 'Health tracking not available'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Get health summaries for all plugins
|
tracker = api_v3.plugin_manager.health_tracker
|
||||||
health_summaries = api_v3.plugin_manager.health_tracker.get_all_health_summaries()
|
# Build per-plugin summaries by ID so persisted (cross-process) health
|
||||||
|
# is included, then fold in any in-memory-only entries.
|
||||||
|
health_summaries = {}
|
||||||
|
for pid in _installed_plugin_ids():
|
||||||
|
try:
|
||||||
|
# force_reload: this process only reads; bypass the in-memory
|
||||||
|
# snapshot so each poll reflects the display service's latest
|
||||||
|
# persisted state.
|
||||||
|
health_summaries[pid] = tracker.get_health_summary(pid, force_reload=True)
|
||||||
|
except Exception:
|
||||||
|
logger.debug('Could not read health summary for %s', pid, exc_info=True)
|
||||||
|
try:
|
||||||
|
for pid, summary in tracker.get_all_health_summaries().items():
|
||||||
|
health_summaries.setdefault(pid, summary)
|
||||||
|
except Exception:
|
||||||
|
logger.debug('get_all_health_summaries failed', exc_info=True)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
@@ -2051,8 +2318,22 @@ def get_plugin_metrics():
|
|||||||
'message': 'Resource monitoring not available'
|
'message': 'Resource monitoring not available'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Get metrics summaries for all plugins
|
monitor = api_v3.plugin_manager.resource_monitor
|
||||||
metrics_summaries = api_v3.plugin_manager.resource_monitor.get_all_metrics_summaries()
|
# Build per-plugin summaries by ID so persisted (cross-process) metrics
|
||||||
|
# are included, then fold in any in-memory-only entries.
|
||||||
|
metrics_summaries = {}
|
||||||
|
for pid in _installed_plugin_ids():
|
||||||
|
try:
|
||||||
|
# force_reload: read-only path — bypass the in-memory snapshot so
|
||||||
|
# each poll reflects the display service's latest persisted metrics.
|
||||||
|
metrics_summaries[pid] = monitor.get_metrics_summary(pid, force_reload=True)
|
||||||
|
except Exception:
|
||||||
|
logger.debug('Could not read metrics summary for %s', pid, exc_info=True)
|
||||||
|
try:
|
||||||
|
for pid, summary in monitor.get_all_metrics_summaries().items():
|
||||||
|
metrics_summaries.setdefault(pid, summary)
|
||||||
|
except Exception:
|
||||||
|
logger.debug('get_all_metrics_summaries failed', exc_info=True)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
@@ -2412,6 +2693,13 @@ def reconcile_plugin_state():
|
|||||||
|
|
||||||
from src.plugin_system.state_reconciliation import StateReconciliation
|
from src.plugin_system.state_reconciliation import StateReconciliation
|
||||||
|
|
||||||
|
# Parse optional `force` flag from request body, guarding against
|
||||||
|
# non-dict bodies (bare string, array, null) that would raise AttributeError.
|
||||||
|
payload = request.get_json(silent=True)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
force = _coerce_to_bool(payload.get('force', False))
|
||||||
|
|
||||||
reconciler = StateReconciliation(
|
reconciler = StateReconciliation(
|
||||||
state_manager=api_v3.plugin_state_manager,
|
state_manager=api_v3.plugin_state_manager,
|
||||||
config_manager=api_v3.config_manager,
|
config_manager=api_v3.config_manager,
|
||||||
@@ -2419,7 +2707,7 @@ def reconcile_plugin_state():
|
|||||||
plugins_dir=Path(api_v3.plugin_manager.plugins_dir)
|
plugins_dir=Path(api_v3.plugin_manager.plugins_dir)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = reconciler.reconcile_state()
|
result = reconciler.reconcile_state(force=force)
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
@@ -2846,6 +3134,96 @@ def update_plugin():
|
|||||||
status_code=500
|
status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _do_transactional_uninstall(plugin_id, preserve_config):
|
||||||
|
"""Execute an uninstall with snapshot-based rollback.
|
||||||
|
|
||||||
|
Order of operations:
|
||||||
|
1. Snapshot main config + secrets (abort on unexpected errors, proceed on expected I/O errors).
|
||||||
|
2. Clean up plugin config (abort with 500 if this raises — avoids orphaned files).
|
||||||
|
3. Unload plugin from runtime if loaded (rollback + 500 if this raises).
|
||||||
|
4. Remove plugin files (rollback + 500 if this returns False or raises).
|
||||||
|
5. Finish (remove state, invalidate caches).
|
||||||
|
|
||||||
|
Rollback restores the config snapshot and, if the plugin had been
|
||||||
|
loaded before unload, calls load_plugin to restore runtime state.
|
||||||
|
|
||||||
|
Returns (True, None) on success or (False, error_message) on failure.
|
||||||
|
"""
|
||||||
|
from src.exceptions import ConfigError
|
||||||
|
|
||||||
|
# --- Step 1: snapshot main + secrets ---
|
||||||
|
main_snapshot = None
|
||||||
|
secrets_snapshot = None
|
||||||
|
try:
|
||||||
|
main_snapshot = api_v3.config_manager.get_raw_file_content('main')
|
||||||
|
except (OSError, ConfigError):
|
||||||
|
pass # Proceed without snapshot; narrow catch preserves TypeError/AttributeError
|
||||||
|
try:
|
||||||
|
secrets_snapshot = api_v3.config_manager.get_raw_file_content('secrets')
|
||||||
|
except (OSError, ConfigError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Step 2: cleanup config first (abort before touching filesystem) ---
|
||||||
|
if not preserve_config:
|
||||||
|
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
||||||
|
|
||||||
|
# Record whether the plugin was running before we touch anything.
|
||||||
|
was_loaded = (
|
||||||
|
api_v3.plugin_manager is not None
|
||||||
|
and plugin_id in api_v3.plugin_manager.plugins
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rollback(reload_plugin):
|
||||||
|
if main_snapshot is not None:
|
||||||
|
try:
|
||||||
|
api_v3.config_manager.save_raw_file_content('main', main_snapshot)
|
||||||
|
except Exception as restore_err:
|
||||||
|
logger.error("Failed to restore main config snapshot for %s: %s", plugin_id, restore_err)
|
||||||
|
if secrets_snapshot is not None:
|
||||||
|
try:
|
||||||
|
api_v3.config_manager.save_raw_file_content('secrets', secrets_snapshot)
|
||||||
|
except Exception as restore_err:
|
||||||
|
logger.error("Failed to restore secrets snapshot for %s: %s", plugin_id, restore_err)
|
||||||
|
if reload_plugin and api_v3.plugin_manager is not None:
|
||||||
|
try:
|
||||||
|
api_v3.plugin_manager.load_plugin(plugin_id)
|
||||||
|
except Exception as reload_err:
|
||||||
|
logger.error("Failed to reload plugin %s during rollback: %s", plugin_id, reload_err)
|
||||||
|
|
||||||
|
# --- Step 3: unload ---
|
||||||
|
if was_loaded:
|
||||||
|
try:
|
||||||
|
api_v3.plugin_manager.unload_plugin(plugin_id)
|
||||||
|
except Exception as unload_err:
|
||||||
|
_rollback(reload_plugin=False) # unload failed — runtime state unchanged
|
||||||
|
return False, f"Failed to unload plugin {plugin_id}: {unload_err}"
|
||||||
|
|
||||||
|
# --- Step 4: remove files ---
|
||||||
|
try:
|
||||||
|
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
||||||
|
except Exception as remove_err:
|
||||||
|
_rollback(reload_plugin=was_loaded)
|
||||||
|
return False, f"Failed to remove plugin {plugin_id}: {remove_err}"
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
_rollback(reload_plugin=was_loaded)
|
||||||
|
return False, f"Failed to uninstall plugin {plugin_id}"
|
||||||
|
|
||||||
|
# --- Step 5: finish ---
|
||||||
|
if api_v3.schema_manager:
|
||||||
|
api_v3.schema_manager.invalidate_cache(plugin_id)
|
||||||
|
if api_v3.plugin_state_manager:
|
||||||
|
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
||||||
|
# Persistently record the uninstall so a later core `git pull` update
|
||||||
|
# cannot resurrect a built-in plugin (committed under plugin-repos/) that
|
||||||
|
# the user removed. Best-effort: never fail the uninstall over this.
|
||||||
|
try:
|
||||||
|
api_v3.plugin_store_manager.record_uninstalled_plugin(plugin_id)
|
||||||
|
except Exception as record_err:
|
||||||
|
logger.warning("Could not record uninstall for %s: %s", plugin_id, record_err)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/plugins/uninstall', methods=['POST'])
|
@api_v3.route('/plugins/uninstall', methods=['POST'])
|
||||||
def uninstall_plugin():
|
def uninstall_plugin():
|
||||||
"""Uninstall plugin"""
|
"""Uninstall plugin"""
|
||||||
@@ -2865,19 +3243,13 @@ def uninstall_plugin():
|
|||||||
plugin_id = data['plugin_id']
|
plugin_id = data['plugin_id']
|
||||||
preserve_config = data.get('preserve_config', False)
|
preserve_config = data.get('preserve_config', False)
|
||||||
|
|
||||||
# Use operation queue if available
|
# Both queued and direct paths use the same transactional helper so
|
||||||
|
# snapshot/rollback behaviour is consistent regardless of deployment.
|
||||||
if api_v3.operation_queue:
|
if api_v3.operation_queue:
|
||||||
def uninstall_callback(operation):
|
def uninstall_callback(operation):
|
||||||
"""Callback to execute plugin uninstallation."""
|
"""Callback to execute plugin uninstallation via transactional helper."""
|
||||||
# Unload the plugin first if it's loaded
|
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
|
||||||
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
|
|
||||||
api_v3.plugin_manager.unload_plugin(plugin_id)
|
|
||||||
|
|
||||||
# Uninstall the plugin
|
|
||||||
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
error_msg = f'Failed to uninstall plugin {plugin_id}'
|
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
@@ -2885,24 +3257,7 @@ def uninstall_plugin():
|
|||||||
status="failed",
|
status="failed",
|
||||||
error=error_msg
|
error=error_msg
|
||||||
)
|
)
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg or f'Failed to uninstall plugin {plugin_id}')
|
||||||
|
|
||||||
# Invalidate schema cache
|
|
||||||
if api_v3.schema_manager:
|
|
||||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
|
||||||
|
|
||||||
# Clean up plugin configuration if not preserving
|
|
||||||
if not preserve_config:
|
|
||||||
try:
|
|
||||||
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
|
||||||
except Exception as cleanup_err:
|
|
||||||
logger.warning("Failed to cleanup config after uninstall: %s", cleanup_err)
|
|
||||||
|
|
||||||
# Remove from state manager
|
|
||||||
if api_v3.plugin_state_manager:
|
|
||||||
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
|
||||||
|
|
||||||
# Record in history
|
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
@@ -2910,7 +3265,6 @@ def uninstall_plugin():
|
|||||||
status="success",
|
status="success",
|
||||||
details={"preserve_config": preserve_config}
|
details={"preserve_config": preserve_config}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {'success': True, 'message': 'Plugin uninstalled successfully'}
|
return {'success': True, 'message': 'Plugin uninstalled successfully'}
|
||||||
|
|
||||||
# Enqueue operation
|
# Enqueue operation
|
||||||
@@ -2925,31 +3279,10 @@ def uninstall_plugin():
|
|||||||
message='Plugin uninstallation queued'
|
message='Plugin uninstallation queued'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback to direct uninstall
|
# Direct (non-queued) transactional uninstall
|
||||||
# Unload the plugin first if it's loaded
|
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
|
||||||
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
|
|
||||||
api_v3.plugin_manager.unload_plugin(plugin_id)
|
|
||||||
|
|
||||||
# Uninstall the plugin
|
|
||||||
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
# Invalidate schema cache
|
|
||||||
if api_v3.schema_manager:
|
|
||||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
|
||||||
|
|
||||||
# Clean up plugin configuration if not preserving
|
|
||||||
if not preserve_config:
|
|
||||||
try:
|
|
||||||
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
|
||||||
except Exception as cleanup_err:
|
|
||||||
logger.warning("Failed to cleanup config after uninstall: %s", cleanup_err)
|
|
||||||
|
|
||||||
# Remove from state manager
|
|
||||||
if api_v3.plugin_state_manager:
|
|
||||||
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
|
||||||
|
|
||||||
# Record in history
|
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
@@ -2957,7 +3290,6 @@ def uninstall_plugin():
|
|||||||
status="success",
|
status="success",
|
||||||
details={"preserve_config": preserve_config}
|
details={"preserve_config": preserve_config}
|
||||||
)
|
)
|
||||||
|
|
||||||
return success_response(message='Plugin uninstalled successfully')
|
return success_response(message='Plugin uninstalled successfully')
|
||||||
else:
|
else:
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
@@ -2965,12 +3297,11 @@ def uninstall_plugin():
|
|||||||
"uninstall",
|
"uninstall",
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
status="failed",
|
status="failed",
|
||||||
error='Plugin uninstall failed'
|
error=error_msg
|
||||||
)
|
)
|
||||||
|
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCode.PLUGIN_UNINSTALL_FAILED,
|
ErrorCode.PLUGIN_UNINSTALL_FAILED,
|
||||||
'Plugin uninstall failed',
|
error_msg or 'Plugin uninstall failed',
|
||||||
status_code=500
|
status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3494,21 +3825,29 @@ def _get_schema_property(schema, key_path):
|
|||||||
|
|
||||||
parts = key_path.split('.')
|
parts = key_path.split('.')
|
||||||
current = schema['properties']
|
current = schema['properties']
|
||||||
|
i = 0
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
while i < len(parts):
|
||||||
if part not in current:
|
# Try progressively longer candidates, longest first, so schema keys that
|
||||||
return None
|
# themselves contain dots (e.g. league keys like "fifa.world") are matched
|
||||||
|
# instead of being mistaken for nested "fifa" -> "world" objects.
|
||||||
prop = current[part]
|
matched = False
|
||||||
|
for j in range(len(parts), i, -1):
|
||||||
# If this is the last part, return the property
|
candidate = '.'.join(parts[i:j])
|
||||||
if i == len(parts) - 1:
|
if isinstance(current, dict) and candidate in current:
|
||||||
return prop
|
prop = current[candidate]
|
||||||
|
# Consumed all remaining parts — this is the target property.
|
||||||
# If this is an object with properties, navigate deeper
|
if j == len(parts):
|
||||||
if isinstance(prop, dict) and 'properties' in prop:
|
return prop
|
||||||
current = prop['properties']
|
# Navigate deeper through an object with properties.
|
||||||
else:
|
if isinstance(prop, dict) and 'properties' in prop:
|
||||||
|
current = prop['properties']
|
||||||
|
i = j
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
# Matched a non-object before consuming the path — can't go deeper.
|
||||||
|
return None
|
||||||
|
if not matched:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -3666,13 +4005,14 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return prop.get('default', 0.0)
|
return prop.get('default', 0.0)
|
||||||
|
|
||||||
# Try parsing as number (fallback)
|
# Try parsing as number (fallback) — skip when schema explicitly says string
|
||||||
try:
|
if not (prop and prop.get('type') == 'string'):
|
||||||
if '.' in stripped:
|
try:
|
||||||
return float(stripped)
|
if '.' in stripped:
|
||||||
return int(stripped)
|
return float(stripped)
|
||||||
except ValueError:
|
return int(stripped)
|
||||||
pass
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Return as string
|
# Return as string
|
||||||
return value
|
return value
|
||||||
@@ -3680,10 +4020,45 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_key_segments(key_path, config):
|
||||||
|
"""Split a dot-notation path into segments, greedily preserving keys that
|
||||||
|
themselves contain dots (e.g. league keys like "fifa.world").
|
||||||
|
|
||||||
|
At each level the longest candidate that matches a key already present in the
|
||||||
|
config wins; otherwise the path splits on the next dot (the normal
|
||||||
|
nested-create case). Because dotted keys such as ``leagues."fifa.world"``
|
||||||
|
always exist in the saved config being updated, this routes the value to the
|
||||||
|
real league object instead of fabricating a ``leagues.fifa.world`` tree.
|
||||||
|
"""
|
||||||
|
parts = key_path.split('.')
|
||||||
|
segments = []
|
||||||
|
node = config
|
||||||
|
i = 0
|
||||||
|
while i < len(parts):
|
||||||
|
matched = False
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for j in range(len(parts), i, -1):
|
||||||
|
candidate = '.'.join(parts[i:j])
|
||||||
|
if candidate in node:
|
||||||
|
segments.append(candidate)
|
||||||
|
node = node[candidate]
|
||||||
|
i = j
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
part = parts[i]
|
||||||
|
segments.append(part)
|
||||||
|
node = node.get(part) if isinstance(node, dict) else None
|
||||||
|
i += 1
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
def _set_nested_value(config, key_path, value):
|
def _set_nested_value(config, key_path, value):
|
||||||
"""
|
"""
|
||||||
Set a value in a nested dict using dot notation path.
|
Set a value in a nested dict using dot notation path.
|
||||||
Handles existing nested dicts correctly by merging instead of replacing.
|
Handles existing nested dicts correctly by merging instead of replacing.
|
||||||
|
Keys containing dots (e.g. league keys like "fifa.world") are preserved when
|
||||||
|
they already exist in the config rather than being split into nested objects.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: The config dict to modify
|
config: The config dict to modify
|
||||||
@@ -3693,22 +4068,22 @@ def _set_nested_value(config, key_path, value):
|
|||||||
# Skip setting if value is the sentinel
|
# Skip setting if value is the sentinel
|
||||||
if value is _SKIP_FIELD:
|
if value is _SKIP_FIELD:
|
||||||
return
|
return
|
||||||
|
|
||||||
parts = key_path.split('.')
|
segments = _resolve_key_segments(key_path, config)
|
||||||
current = config
|
current = config
|
||||||
|
|
||||||
# Navigate/create intermediate dicts
|
# Navigate/create intermediate dicts
|
||||||
for i, part in enumerate(parts[:-1]):
|
for seg in segments[:-1]:
|
||||||
if part not in current:
|
if seg not in current:
|
||||||
current[part] = {}
|
current[seg] = {}
|
||||||
elif not isinstance(current[part], dict):
|
elif not isinstance(current[seg], dict):
|
||||||
# If the existing value is not a dict, replace it with a dict
|
# If the existing value is not a dict, replace it with a dict
|
||||||
current[part] = {}
|
current[seg] = {}
|
||||||
current = current[part]
|
current = current[seg]
|
||||||
|
|
||||||
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
|
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
|
||||||
if value is not None or parts[-1] not in current:
|
if value is not None or segments[-1] not in current:
|
||||||
current[parts[-1]] = value
|
current[segments[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
||||||
@@ -4217,7 +4592,9 @@ def save_plugin_config():
|
|||||||
nested_dict = config_dict.get(prop_key)
|
nested_dict = config_dict.get(prop_key)
|
||||||
|
|
||||||
if isinstance(nested_dict, dict):
|
if isinstance(nested_dict, dict):
|
||||||
fix_array_structures(nested_dict, prop_schema['properties'], nested_prefix)
|
# Pass no prefix: config_dict is already the navigated sub-dict,
|
||||||
|
# so path segments from the parent would mis-navigate it.
|
||||||
|
fix_array_structures(nested_dict, prop_schema['properties'])
|
||||||
|
|
||||||
# Also ensure array fields that are None get converted to empty arrays
|
# Also ensure array fields that are None get converted to empty arrays
|
||||||
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
||||||
@@ -4277,7 +4654,8 @@ def save_plugin_config():
|
|||||||
nested_dict = config_dict[prop_key]
|
nested_dict = config_dict[prop_key]
|
||||||
|
|
||||||
if isinstance(nested_dict, dict):
|
if isinstance(nested_dict, dict):
|
||||||
ensure_array_defaults(nested_dict, prop_schema['properties'], nested_prefix)
|
# Pass no prefix: config_dict is already navigated.
|
||||||
|
ensure_array_defaults(nested_dict, prop_schema['properties'])
|
||||||
|
|
||||||
if schema and 'properties' in schema:
|
if schema and 'properties' in schema:
|
||||||
# First, fix any dict structures that should be arrays
|
# First, fix any dict structures that should be arrays
|
||||||
@@ -4325,6 +4703,49 @@ def save_plugin_config():
|
|||||||
if 'application/json' in content_type:
|
if 'application/json' in content_type:
|
||||||
schema = schema_mgr.load_schema(plugin_id, use_cache=False)
|
schema = schema_mgr.load_schema(plugin_id, use_cache=False)
|
||||||
|
|
||||||
|
# JSON path: fix numeric-keyed dicts that should be arrays.
|
||||||
|
# JS dotToNested() converts feeds.custom_feeds.0.name → {'0': {name:...}}
|
||||||
|
# instead of [{name:...}]. The form-data path has fix_array_structures for this;
|
||||||
|
# mirror that logic here for JSON submissions.
|
||||||
|
if 'application/json' in content_type and schema and 'properties' in schema:
|
||||||
|
def _fix_json_arrays(cfg, props):
|
||||||
|
for k, ps in props.items():
|
||||||
|
if not isinstance(cfg, dict) or k not in cfg:
|
||||||
|
continue
|
||||||
|
pt = ps.get('type')
|
||||||
|
val = cfg[k]
|
||||||
|
if pt == 'array':
|
||||||
|
items_schema = ps.get('items', {})
|
||||||
|
item_type = items_schema.get('type')
|
||||||
|
if isinstance(val, dict):
|
||||||
|
keys = list(val.keys())
|
||||||
|
if keys and all(str(x).isdigit() for x in keys):
|
||||||
|
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
||||||
|
arr = [val[sk] for sk in sorted_keys]
|
||||||
|
if item_type in ('integer', 'number'):
|
||||||
|
converted = []
|
||||||
|
for v in arr:
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
converted.append(int(v) if item_type == 'integer' else float(v))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
converted.append(v)
|
||||||
|
else:
|
||||||
|
converted.append(v)
|
||||||
|
arr = converted
|
||||||
|
cfg[k] = arr
|
||||||
|
elif not keys:
|
||||||
|
cfg[k] = []
|
||||||
|
# Recurse into each element when items are objects with properties,
|
||||||
|
# covering both freshly-converted and already-list values.
|
||||||
|
if item_type == 'object' and 'properties' in items_schema:
|
||||||
|
for elem in (cfg[k] if isinstance(cfg[k], list) else []):
|
||||||
|
if isinstance(elem, dict):
|
||||||
|
_fix_json_arrays(elem, items_schema['properties'])
|
||||||
|
elif pt == 'object' and 'properties' in ps and isinstance(val, dict):
|
||||||
|
_fix_json_arrays(val, ps['properties'])
|
||||||
|
_fix_json_arrays(plugin_config, schema['properties'])
|
||||||
|
|
||||||
# PRE-PROCESSING: Preserve 'enabled' state if not in request
|
# PRE-PROCESSING: Preserve 'enabled' state if not in request
|
||||||
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
|
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
|
||||||
if 'enabled' not in plugin_config:
|
if 'enabled' not in plugin_config:
|
||||||
@@ -4377,6 +4798,21 @@ def save_plugin_config():
|
|||||||
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
||||||
plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults)
|
plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults)
|
||||||
|
|
||||||
|
# After merging defaults, replace any None array values with their schema defaults.
|
||||||
|
# merge_with_defaults gives user config higher priority, so a None submitted by
|
||||||
|
# the client can survive the merge — this pass cleans those up.
|
||||||
|
def _fix_none_arrays(cfg, props):
|
||||||
|
for k, pschema in props.items():
|
||||||
|
if pschema.get('type') == 'array':
|
||||||
|
if isinstance(cfg, dict) and (k not in cfg or cfg[k] is None):
|
||||||
|
cfg[k] = pschema.get('default', [])
|
||||||
|
elif pschema.get('type') == 'object' and 'properties' in pschema:
|
||||||
|
if isinstance(cfg, dict) and isinstance(cfg.get(k), dict):
|
||||||
|
_fix_none_arrays(cfg[k], pschema['properties'])
|
||||||
|
|
||||||
|
if schema and 'properties' in schema and isinstance(plugin_config, dict):
|
||||||
|
_fix_none_arrays(plugin_config, schema['properties'])
|
||||||
|
|
||||||
# Ensure enabled state is preserved after defaults merge
|
# Ensure enabled state is preserved after defaults merge
|
||||||
# Defaults should not overwrite an explicitly preserved enabled value
|
# Defaults should not overwrite an explicitly preserved enabled value
|
||||||
if preserved_enabled is not None:
|
if preserved_enabled is not None:
|
||||||
@@ -6816,6 +7252,74 @@ def set_auto_enable_ap_mode():
|
|||||||
'message': 'An error occurred; see logs for details'
|
'message': 'An error occurred; see logs for details'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
@api_v3.route('/wifi/radio', methods=['GET'])
|
||||||
|
def get_wifi_radio():
|
||||||
|
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
state = wifi_manager.get_wifi_radio_state()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': state
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error getting WiFi radio state", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@api_v3.route('/wifi/radio', methods=['POST'])
|
||||||
|
def set_wifi_radio():
|
||||||
|
"""Turn the WiFi radio on or off.
|
||||||
|
|
||||||
|
Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused
|
||||||
|
unless Ethernet is connected or force=True, to avoid locking the user out
|
||||||
|
of this web interface.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if 'enabled' not in data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'enabled is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Parse defensively: bool("false") is True, so mirror the string-aware
|
||||||
|
# coercion used for `force` — the endpoint is a public contract, not just
|
||||||
|
# the shipped UI (which always sends real JSON booleans).
|
||||||
|
_enabled_raw = data['enabled']
|
||||||
|
enabled = _enabled_raw is True or (isinstance(_enabled_raw, str) and _enabled_raw.lower() in ('true', '1', 'yes'))
|
||||||
|
_force_raw = data.get('force', False)
|
||||||
|
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes'))
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message,
|
||||||
|
'data': wifi_manager.get_wifi_radio_state()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message,
|
||||||
|
'reason': reason
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error setting WiFi radio state", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details'
|
||||||
|
}), 500
|
||||||
|
|
||||||
@api_v3.route('/cache/list', methods=['GET'])
|
@api_v3.route('/cache/list', methods=['GET'])
|
||||||
def list_cache_files():
|
def list_cache_files():
|
||||||
"""List all cache files with metadata"""
|
"""List all cache files with metadata"""
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from flask import Blueprint, render_template, flash
|
from flask import Blueprint, render_template, flash, jsonify
|
||||||
|
from jinja2 import TemplateNotFound
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
|
from html.parser import HTMLParser
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -21,6 +23,114 @@ plugin_store_manager = None
|
|||||||
|
|
||||||
pages_v3 = Blueprint('pages_v3', __name__)
|
pages_v3 = Blueprint('pages_v3', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _SettingsIndexParser(HTMLParser):
|
||||||
|
"""Extract searchable settings fields from a rendered partial's HTML.
|
||||||
|
|
||||||
|
Captures one entry per ``<div class="form-group" id="setting-…">``: the
|
||||||
|
anchor id, ``data-setting-key``, the field's ``<label>`` text, the
|
||||||
|
``.help-tip`` tooltip text (``data-tooltip``), and the nearest preceding
|
||||||
|
``<h3>``/``<h4>`` section heading. Parsing the *rendered* HTML (rather than
|
||||||
|
the schema) guarantees the anchor ids match the live DOM exactly, so the
|
||||||
|
search index cannot drift from what users actually see.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tab, tab_label):
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.tab = tab
|
||||||
|
self.tab_label = tab_label
|
||||||
|
self.fields = []
|
||||||
|
self._section = ''
|
||||||
|
self._field = None
|
||||||
|
self._depth = 0 # open-div depth within the current field
|
||||||
|
self._in_label = False
|
||||||
|
self._label_parts = []
|
||||||
|
self._in_heading = False
|
||||||
|
self._heading_parts = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
a = {k: (v or '') for k, v in attrs}
|
||||||
|
classes = a.get('class', '').split()
|
||||||
|
# Section headings (only when not already inside a field)
|
||||||
|
if tag in ('h3', 'h4') and self._field is None:
|
||||||
|
self._in_heading = True
|
||||||
|
self._heading_parts = []
|
||||||
|
if tag == 'div':
|
||||||
|
fid = a.get('id', '')
|
||||||
|
if self._field is None and 'form-group' in classes and fid.startswith('setting-'):
|
||||||
|
self._field = {
|
||||||
|
'anchorId': fid,
|
||||||
|
'key': a.get('data-setting-key', '') or fid[len('setting-'):],
|
||||||
|
'label': '',
|
||||||
|
'help': '',
|
||||||
|
'section': self._section,
|
||||||
|
'tab': self.tab,
|
||||||
|
'tabLabel': self.tab_label,
|
||||||
|
}
|
||||||
|
self._depth = 1
|
||||||
|
return
|
||||||
|
if self._field is not None:
|
||||||
|
self._depth += 1
|
||||||
|
if self._field is not None:
|
||||||
|
if tag == 'label' and not self._field['label']:
|
||||||
|
self._in_label = True
|
||||||
|
self._label_parts = []
|
||||||
|
if tag == 'button' and 'help-tip' in classes and not self._field['help']:
|
||||||
|
self._field['help'] = a.get('data-tooltip', '')
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self._in_label:
|
||||||
|
self._label_parts.append(data)
|
||||||
|
elif self._in_heading:
|
||||||
|
self._heading_parts.append(data)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag in ('h3', 'h4') and self._in_heading:
|
||||||
|
self._in_heading = False
|
||||||
|
self._section = ' '.join(''.join(self._heading_parts).split()).strip()
|
||||||
|
return
|
||||||
|
if self._field is None:
|
||||||
|
return
|
||||||
|
if tag == 'label' and self._in_label:
|
||||||
|
self._in_label = False
|
||||||
|
self._field['label'] = ' '.join(''.join(self._label_parts).split()).strip()
|
||||||
|
elif tag == 'div':
|
||||||
|
self._depth -= 1
|
||||||
|
if self._depth <= 0:
|
||||||
|
if self._field['label']:
|
||||||
|
self.fields.append(self._field)
|
||||||
|
self._field = None
|
||||||
|
self._depth = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _partial_html(loader):
|
||||||
|
"""Run a partial loader and return its HTML string ('' on error)."""
|
||||||
|
try:
|
||||||
|
result = loader()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("search-index: partial render failed", exc_info=True)
|
||||||
|
return ''
|
||||||
|
if isinstance(result, str):
|
||||||
|
return result
|
||||||
|
if isinstance(result, tuple): # loaders return (msg, status) on error
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
return result.get_data(as_text=True)
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_settings_fields(html, tab, tab_label):
|
||||||
|
parser = _SettingsIndexParser(tab, tab_label)
|
||||||
|
parser.feed(html)
|
||||||
|
return parser.fields
|
||||||
|
|
||||||
|
|
||||||
|
# Cache the built index keyed on the installed-plugin set. Core labels/tooltips
|
||||||
|
# are static template text, so only a change in installed plugins invalidates it.
|
||||||
|
_SEARCH_INDEX_CACHE = {'sig': None, 'fields': None}
|
||||||
|
|
||||||
|
|
||||||
@pages_v3.route('/')
|
@pages_v3.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Main v3 interface page"""
|
"""Main v3 interface page"""
|
||||||
@@ -90,6 +200,8 @@ def load_partial(partial_name):
|
|||||||
return _load_cache_partial()
|
return _load_cache_partial()
|
||||||
elif partial_name == 'operation-history':
|
elif partial_name == 'operation-history':
|
||||||
return _load_operation_history_partial()
|
return _load_operation_history_partial()
|
||||||
|
elif partial_name == 'tools':
|
||||||
|
return _load_tools_partial()
|
||||||
else:
|
else:
|
||||||
return "Partial not found", 404
|
return "Partial not found", 404
|
||||||
|
|
||||||
@@ -108,6 +220,56 @@ def load_plugin_config_partial(plugin_id):
|
|||||||
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@pages_v3.route('/settings/search-index')
|
||||||
|
def settings_search_index():
|
||||||
|
"""Return a flat JSON index of every searchable setting (core + plugin).
|
||||||
|
|
||||||
|
Powers the web UI's global settings search. Built by rendering the settings
|
||||||
|
partials server-side and extracting field metadata, then cached per
|
||||||
|
installed-plugin set so it is off the display's hot path.
|
||||||
|
"""
|
||||||
|
# Core settings tabs: (activeTab value, human label, loader).
|
||||||
|
core_tabs = [
|
||||||
|
('general', 'General', _load_general_partial),
|
||||||
|
('display', 'Display', _load_display_partial),
|
||||||
|
('durations', 'Durations', _load_durations_partial),
|
||||||
|
('schedule', 'Schedule', _load_schedule_partial),
|
||||||
|
('wifi', 'WiFi', _load_wifi_partial),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
plugin_ids = []
|
||||||
|
if pages_v3.plugin_manager:
|
||||||
|
try:
|
||||||
|
pages_v3.plugin_manager.discover_plugins()
|
||||||
|
plugin_ids = sorted(
|
||||||
|
pi.get('id') for pi in pages_v3.plugin_manager.get_all_plugin_info()
|
||||||
|
if pi.get('id')
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("search-index: could not enumerate plugins", exc_info=True)
|
||||||
|
|
||||||
|
sig = tuple(plugin_ids)
|
||||||
|
if _SEARCH_INDEX_CACHE['sig'] == sig and _SEARCH_INDEX_CACHE['fields'] is not None:
|
||||||
|
return jsonify({'fields': _SEARCH_INDEX_CACHE['fields']})
|
||||||
|
|
||||||
|
fields = []
|
||||||
|
for tab, label, loader in core_tabs:
|
||||||
|
fields.extend(_extract_settings_fields(_partial_html(loader), tab, label))
|
||||||
|
|
||||||
|
for pid in plugin_ids:
|
||||||
|
info = pages_v3.plugin_manager.get_plugin_info(pid) or {}
|
||||||
|
label = info.get('name', pid)
|
||||||
|
html = _partial_html(lambda pid=pid: _load_plugin_config_partial(pid))
|
||||||
|
fields.extend(_extract_settings_fields(html, pid, label))
|
||||||
|
|
||||||
|
_SEARCH_INDEX_CACHE['sig'] = sig
|
||||||
|
_SEARCH_INDEX_CACHE['fields'] = fields
|
||||||
|
return jsonify({'fields': fields})
|
||||||
|
except Exception:
|
||||||
|
logger.error("Error building settings search index", exc_info=True)
|
||||||
|
return jsonify({'fields': []}), 500
|
||||||
|
|
||||||
|
|
||||||
@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
|
@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
|
||||||
def serve_plugin_web_ui(plugin_id, filename):
|
def serve_plugin_web_ui(plugin_id, filename):
|
||||||
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
|
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
|
||||||
@@ -448,6 +610,18 @@ def _load_operation_history_partial():
|
|||||||
return "Error loading partial", 500
|
return "Error loading partial", 500
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tools_partial():
|
||||||
|
"""Load tools/utilities partial."""
|
||||||
|
try:
|
||||||
|
return render_template('v3/partials/tools.html')
|
||||||
|
except TemplateNotFound:
|
||||||
|
logger.error("[Pages V3][Tools] Template not found: v3/partials/tools.html", exc_info=True)
|
||||||
|
return "[Pages V3][Tools] Template is missing.", 500
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("[Pages V3][Tools] I/O error loading tools partial: %s", exc, exc_info=True)
|
||||||
|
return "[Pages V3][Tools] Failed to load due to a file system error. Check logs.", 500
|
||||||
|
|
||||||
|
|
||||||
def _load_plugin_config_partial(plugin_id):
|
def _load_plugin_config_partial(plugin_id):
|
||||||
"""
|
"""
|
||||||
Load plugin configuration partial - server-side rendered form.
|
Load plugin configuration partial - server-side rendered form.
|
||||||
|
|||||||
@@ -84,6 +84,8 @@
|
|||||||
[data-theme="dark"] .hover\:text-gray-700:hover { color: #e5e7eb; }
|
[data-theme="dark"] .hover\:text-gray-700:hover { color: #e5e7eb; }
|
||||||
[data-theme="dark"] .hover\:border-gray-300:hover { border-color: #6b7280; }
|
[data-theme="dark"] .hover\:border-gray-300:hover { border-color: #6b7280; }
|
||||||
[data-theme="dark"] .bg-red-100 { background-color: #450a0a; }
|
[data-theme="dark"] .bg-red-100 { background-color: #450a0a; }
|
||||||
|
[data-theme="dark"] .bg-yellow-100 { background-color: #422006; }
|
||||||
|
[data-theme="dark"] .bg-green-100 { background-color: #022c22; }
|
||||||
[data-theme="dark"] .text-red-700 { color: #fca5a5; }
|
[data-theme="dark"] .text-red-700 { color: #fca5a5; }
|
||||||
[data-theme="dark"] .hover\:bg-red-200:hover { background-color: #7f1d1d; }
|
[data-theme="dark"] .hover\:bg-red-200:hover { background-color: #7f1d1d; }
|
||||||
|
|
||||||
@@ -137,6 +139,14 @@ body {
|
|||||||
.text-green-600 { color: #059669; }
|
.text-green-600 { color: #059669; }
|
||||||
.text-red-600 { color: #dc2626; }
|
.text-red-600 { color: #dc2626; }
|
||||||
|
|
||||||
|
/* Status badge chips (e.g. tools.html's dirty/clean and power-status badges) */
|
||||||
|
.bg-red-100 { background-color: #fee2e2; }
|
||||||
|
.bg-yellow-100 { background-color: #fef9c3; }
|
||||||
|
.bg-green-100 { background-color: #dcfce7; }
|
||||||
|
.text-red-800 { color: #991b1b; }
|
||||||
|
.text-yellow-800 { color: #854d0e; }
|
||||||
|
.text-green-800 { color: #166534; }
|
||||||
|
|
||||||
.border-gray-200 { border-color: #e5e7eb; }
|
.border-gray-200 { border-color: #e5e7eb; }
|
||||||
.border-gray-300 { border-color: #d1d5db; }
|
.border-gray-300 { border-color: #d1d5db; }
|
||||||
.border-gray-700 { border-color: #374151; }
|
.border-gray-700 { border-color: #374151; }
|
||||||
@@ -756,6 +766,153 @@ button.bg-white {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================ */
|
||||||
|
/* Settings tooltips (help_tip macro + tooltips.js) */
|
||||||
|
/* ============================================================================ */
|
||||||
|
|
||||||
|
/* The (i) info trigger placed next to a setting label. */
|
||||||
|
.help-tip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
margin-left: 0.375rem;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: help;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tip:hover,
|
||||||
|
.help-tip:focus-visible {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tip:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Singleton tooltip panel appended to <body> by tooltips.js. */
|
||||||
|
#ledm-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
max-width: 20rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-line; /* renders authored "\n" line breaks */
|
||||||
|
pointer-events: none; /* never steals hover/click from the page */
|
||||||
|
animation: tooltipFade 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ledm-tooltip[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tooltipFade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================ */
|
||||||
|
/* Settings search — global header dropdown + per-tab filter */
|
||||||
|
/* ============================================================================ */
|
||||||
|
|
||||||
|
#settings-search-results {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 50;
|
||||||
|
padding: 0.25rem;
|
||||||
|
max-height: min(60vh, 24rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssr-group {
|
||||||
|
padding: 0.375rem 0.625rem 0.25rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssr-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.625rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssr-option:hover,
|
||||||
|
.ssr-option.active {
|
||||||
|
background: var(--color-info-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssr-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssr-help {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssr-empty {
|
||||||
|
padding: 0.75rem 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flash highlight applied to a field after search navigation. */
|
||||||
|
@keyframes settingFlash {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
|
||||||
|
25% { box-shadow: 0 0 0 3px var(--color-primary); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-flash {
|
||||||
|
animation: settingFlash 1.4s ease;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#ledm-tooltip { animation: none; }
|
||||||
|
.setting-flash {
|
||||||
|
animation: none;
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Removed .divider and .divider-light - not used anywhere */
|
/* Removed .divider and .divider-light - not used anywhere */
|
||||||
|
|
||||||
/* Enhanced Spacing Utilities - Only unique classes not in main utility section */
|
/* Enhanced Spacing Utilities - Only unique classes not in main utility section */
|
||||||
@@ -1040,3 +1197,26 @@ button.bg-white {
|
|||||||
[data-theme="dark"] .update-banner-dismiss {
|
[data-theme="dark"] .update-banner-dismiss {
|
||||||
color: #93c5fd;
|
color: #93c5fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Under-voltage / throttling warning banner */
|
||||||
|
.power-warning-banner {
|
||||||
|
background-color: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
.power-warning-banner-dismiss {
|
||||||
|
color: #991b1b;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.power-warning-banner-dismiss:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .power-warning-banner {
|
||||||
|
background-color: #450a0a;
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .power-warning-banner-dismiss {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|||||||
@@ -340,11 +340,25 @@ const PluginAPI = {
|
|||||||
* @returns {Promise<Object>} Health data
|
* @returns {Promise<Object>} Health data
|
||||||
*/
|
*/
|
||||||
async getPluginHealth(pluginId = null) {
|
async getPluginHealth(pluginId = null) {
|
||||||
const endpoint = pluginId
|
const endpoint = pluginId
|
||||||
? `/plugins/health/${pluginId}`
|
? `/plugins/health/${pluginId}`
|
||||||
: '/plugins/health';
|
: '/plugins/health';
|
||||||
const response = await this.request(endpoint);
|
const response = await this.request(endpoint);
|
||||||
return response.data || {};
|
return response.data || {};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get plugin resource metrics (execution time, memory, cpu).
|
||||||
|
*
|
||||||
|
* @param {string} pluginId - Optional plugin identifier (null for all)
|
||||||
|
* @returns {Promise<Object>} Metrics data keyed by plugin id
|
||||||
|
*/
|
||||||
|
async getPluginMetrics(pluginId = null) {
|
||||||
|
const endpoint = pluginId
|
||||||
|
? `/plugins/metrics/${pluginId}`
|
||||||
|
: '/plugins/metrics';
|
||||||
|
const response = await this.request(endpoint);
|
||||||
|
return response.data || {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
/*
|
||||||
|
* settings-search.js — global settings search + per-tab filter for the v3 UI.
|
||||||
|
*
|
||||||
|
* Two features share one lightweight index built from the same markup the
|
||||||
|
* tooltip work standardizes (.form-group[id^="setting-"] + <label> +
|
||||||
|
* .help-tip[data-tooltip]), so it can never drift from what is rendered:
|
||||||
|
*
|
||||||
|
* 1. Global search (header box): finds settings across ALL tabs, even ones
|
||||||
|
* not yet opened, by fetching a single server-side JSON index
|
||||||
|
* (/v3/settings/search-index) built from all rendered partials.
|
||||||
|
* Clicking a result switches to the tab, waits for the field to load,
|
||||||
|
* then scrolls to and flashes it.
|
||||||
|
* 2. Per-tab filter (the .settings-filter box under a partial title):
|
||||||
|
* hides non-matching fields on the current tab. Delegated, so it keeps
|
||||||
|
* working across HTMX swaps.
|
||||||
|
*
|
||||||
|
* The server owns index generation (including plugin enumeration) and caches
|
||||||
|
* it per installed-plugin set, so the client makes exactly one JSON request.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (window._settingsSearchInit) return;
|
||||||
|
window._settingsSearchInit = true;
|
||||||
|
|
||||||
|
var MAX_RESULTS = 25;
|
||||||
|
|
||||||
|
function debounce(fn, ms) {
|
||||||
|
var t;
|
||||||
|
return function () {
|
||||||
|
var args = arguments, ctx = this;
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(function () { fn.apply(ctx, args); }, ms);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when every search term is present in the haystack.
|
||||||
|
function termsMatch(hay, terms) {
|
||||||
|
return terms.every(function (t) { return hay.indexOf(t) !== -1; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function textOf(el) {
|
||||||
|
return (el && el.textContent ? el.textContent : '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Index building -------------------------------------------------------
|
||||||
|
|
||||||
|
var buildPromise = null;
|
||||||
|
|
||||||
|
// Fetch the prebuilt index from the server (one literal-URL JSON request)
|
||||||
|
// and cache it for the session. Each entry gets a lowercased `hay` haystack
|
||||||
|
// for matching. The server owns which tabs/plugins are included.
|
||||||
|
function buildIndex(force) {
|
||||||
|
if (window._settingsIndex && !force) return Promise.resolve(window._settingsIndex);
|
||||||
|
if (buildPromise && !force) return buildPromise;
|
||||||
|
|
||||||
|
buildPromise = fetch('/v3/settings/search-index', { headers: { 'X-Requested-With': 'settings-search' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : { fields: [] }; })
|
||||||
|
.then(function (data) {
|
||||||
|
var fields = (data && data.fields) || [];
|
||||||
|
fields.forEach(function (f) {
|
||||||
|
f.hay = [f.label, f.help, f.key, f.tabLabel, f.section].join(' ').toLowerCase();
|
||||||
|
});
|
||||||
|
window._settingsIndex = fields;
|
||||||
|
return fields;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// Don't cache the failure: clear the in-flight promise so a
|
||||||
|
// later call can retry after a transient fetch error.
|
||||||
|
buildPromise = null;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
return buildPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Global search UI -----------------------------------------------------
|
||||||
|
|
||||||
|
var input = null, resultsBox = null, activeIndex = -1, currentResults = [];
|
||||||
|
|
||||||
|
function search(q) {
|
||||||
|
q = q.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
var terms = q.split(/\s+/);
|
||||||
|
var out = [];
|
||||||
|
(window._settingsIndex || []).some(function (entry) {
|
||||||
|
if (termsMatch(entry.hay, terms)) out.push(entry);
|
||||||
|
return out.length >= MAX_RESULTS; // stop once we have enough
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function span(cls, text) {
|
||||||
|
var s = document.createElement('span');
|
||||||
|
s.className = cls;
|
||||||
|
s.textContent = text;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the dropdown with DOM nodes + textContent (never innerHTML) so
|
||||||
|
// setting labels/help can never be interpreted as markup.
|
||||||
|
function renderResults(results) {
|
||||||
|
currentResults = results;
|
||||||
|
activeIndex = -1;
|
||||||
|
resultsBox.textContent = '';
|
||||||
|
if (!results.length) {
|
||||||
|
resultsBox.appendChild(span('ssr-empty', 'No settings found.'));
|
||||||
|
openResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var lastTab = null;
|
||||||
|
results.forEach(function (r, i) {
|
||||||
|
if (r.tabLabel !== lastTab) {
|
||||||
|
const group = document.createElement('div');
|
||||||
|
group.className = 'ssr-group';
|
||||||
|
group.textContent = r.tabLabel;
|
||||||
|
resultsBox.appendChild(group);
|
||||||
|
lastTab = r.tabLabel;
|
||||||
|
}
|
||||||
|
var sub = r.section ? (r.section + ' · ') : '';
|
||||||
|
var snippet = r.help ? r.help.split('\n')[0] : '';
|
||||||
|
var opt = document.createElement('button');
|
||||||
|
opt.type = 'button';
|
||||||
|
opt.className = 'ssr-option';
|
||||||
|
opt.setAttribute('role', 'option');
|
||||||
|
opt.id = 'ssr-' + i;
|
||||||
|
opt.setAttribute('data-idx', String(i));
|
||||||
|
opt.appendChild(span('ssr-label', r.label));
|
||||||
|
var helpText = snippet ? (sub + snippet) : (sub ? r.section : '');
|
||||||
|
if (helpText) opt.appendChild(span('ssr-help', helpText));
|
||||||
|
resultsBox.appendChild(opt);
|
||||||
|
});
|
||||||
|
openResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResults() {
|
||||||
|
resultsBox.classList.remove('hidden');
|
||||||
|
// .hidden has no effect without a matching CSS rule (this app's stylesheet
|
||||||
|
// is a hand-picked utility subset, not full Tailwind) - force it directly,
|
||||||
|
// same as the revealNode/collapseNode fallback below.
|
||||||
|
resultsBox.style.display = '';
|
||||||
|
if (input) input.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
function closeResults() {
|
||||||
|
resultsBox.classList.add('hidden');
|
||||||
|
resultsBox.style.display = 'none';
|
||||||
|
activeIndex = -1;
|
||||||
|
if (input) {
|
||||||
|
input.setAttribute('aria-expanded', 'false');
|
||||||
|
input.removeAttribute('aria-activedescendant');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlight(idx) {
|
||||||
|
var opts = resultsBox.querySelectorAll('.ssr-option');
|
||||||
|
opts.forEach(function (o) { o.classList.remove('active'); });
|
||||||
|
if (idx < 0 || idx >= opts.length) { activeIndex = -1; return; }
|
||||||
|
activeIndex = idx;
|
||||||
|
var el = opts.item(idx);
|
||||||
|
el.classList.add('active');
|
||||||
|
el.scrollIntoView({ block: 'nearest' });
|
||||||
|
input.setAttribute('aria-activedescendant', el.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navigation to a setting ---------------------------------------------
|
||||||
|
|
||||||
|
function getAppData() {
|
||||||
|
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
||||||
|
if (!appEl) return null;
|
||||||
|
if (appEl._x_dataStack && appEl._x_dataStack[0]) return appEl._x_dataStack[0];
|
||||||
|
if (appEl.__x && appEl.__x.$data) return appEl.__x.$data;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveTab(tab) {
|
||||||
|
var data = getAppData();
|
||||||
|
if (data) { data.activeTab = tab; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForElement(id, timeout) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var existing = document.getElementById(id);
|
||||||
|
if (existing) { resolve(existing); return; }
|
||||||
|
var host = document.getElementById('tab-content') || document.body;
|
||||||
|
var done = false;
|
||||||
|
var obs = new MutationObserver(function () {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el && !done) {
|
||||||
|
done = true;
|
||||||
|
obs.disconnect();
|
||||||
|
resolve(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
obs.observe(host, { childList: true, subtree: true });
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!done) { done = true; obs.disconnect(); resolve(document.getElementById(id)); }
|
||||||
|
}, timeout || 6000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeHidden(node) {
|
||||||
|
return node.classList.contains('hidden') ||
|
||||||
|
(node.style && node.style.display === 'none') ||
|
||||||
|
window.getComputedStyle(node).display === 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealNode(node) {
|
||||||
|
// toggleSection handles the class, inline display, and chevron.
|
||||||
|
if (node.id && typeof window.toggleSection === 'function') {
|
||||||
|
window.toggleSection(node.id);
|
||||||
|
} else {
|
||||||
|
node.classList.remove('hidden');
|
||||||
|
node.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-collapse a nested section the filter previously opened. toggleSection is
|
||||||
|
// state-based, so only toggle while the node is actually visible.
|
||||||
|
function collapseNode(node) {
|
||||||
|
if (isNodeHidden(node)) return;
|
||||||
|
if (node.id && typeof window.toggleSection === 'function') {
|
||||||
|
window.toggleSection(node.id);
|
||||||
|
} else {
|
||||||
|
node.classList.add('hidden');
|
||||||
|
node.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal any collapsed nested section (from render_nested_section) so the
|
||||||
|
// target field is actually visible before we scroll to it.
|
||||||
|
function revealAncestors(el) {
|
||||||
|
var node = el.parentElement;
|
||||||
|
while (node && node !== document.body) {
|
||||||
|
if (node.classList && node.classList.contains('nested-content') && isNodeHidden(node)) {
|
||||||
|
revealNode(node);
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like revealAncestors, but tags each section we open so the per-tab filter
|
||||||
|
// can restore the original collapsed layout once the query is cleared.
|
||||||
|
function expandNestedFor(el) {
|
||||||
|
var node = el.parentElement;
|
||||||
|
while (node && node !== document.body) {
|
||||||
|
if (node.classList && node.classList.contains('nested-content') && isNodeHidden(node)) {
|
||||||
|
revealNode(node);
|
||||||
|
node.dataset.filterExpanded = '1';
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(el) {
|
||||||
|
el.classList.remove('setting-flash');
|
||||||
|
// force reflow so re-adding the class restarts the animation
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('setting-flash');
|
||||||
|
var clear = function () { el.classList.remove('setting-flash'); el.removeEventListener('animationend', clear); };
|
||||||
|
el.addEventListener('animationend', clear);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToSetting(entry) {
|
||||||
|
closeResults();
|
||||||
|
// Clear the box so it doesn't re-open stale results when refocused.
|
||||||
|
if (input) input.value = '';
|
||||||
|
setActiveTab(entry.tab);
|
||||||
|
waitForElement(entry.anchorId, 6000).then(function (el) {
|
||||||
|
if (!el) return;
|
||||||
|
revealAncestors(el);
|
||||||
|
// Let the tab transition settle before scrolling.
|
||||||
|
setTimeout(function () {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
flash(el);
|
||||||
|
}, 60);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wire up the header search box ----------------------------------------
|
||||||
|
|
||||||
|
function initSearchBox() {
|
||||||
|
input = document.getElementById('settings-search');
|
||||||
|
resultsBox = document.getElementById('settings-search-results');
|
||||||
|
if (!input || !resultsBox) return;
|
||||||
|
|
||||||
|
// Warm the index in the background so the first search is instant.
|
||||||
|
var warm = function () { buildIndex().catch(function () {}); };
|
||||||
|
if ('requestIdleCallback' in window) {
|
||||||
|
requestIdleCallback(warm, { timeout: 4000 });
|
||||||
|
} else {
|
||||||
|
setTimeout(warm, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('focus', function () {
|
||||||
|
buildIndex().then(function () {
|
||||||
|
if (input.value.trim()) renderResults(search(input.value));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('input', debounce(function () {
|
||||||
|
var q = input.value;
|
||||||
|
if (!q.trim()) { closeResults(); return; }
|
||||||
|
// Focus may have left during the debounce (typed then clicked away);
|
||||||
|
// don't re-open a dropdown the user has already dismissed.
|
||||||
|
if (document.activeElement !== input) return;
|
||||||
|
buildIndex().then(function () {
|
||||||
|
if (document.activeElement === input) renderResults(search(q));
|
||||||
|
});
|
||||||
|
}, 200));
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
var opts = resultsBox.querySelectorAll('.ssr-option');
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (resultsBox.classList.contains('hidden')) { renderResults(search(input.value)); return; }
|
||||||
|
highlight(Math.min(activeIndex + 1, opts.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlight(Math.max(activeIndex - 1, 0));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
const chosen = currentResults.at(activeIndex >= 0 ? activeIndex : 0);
|
||||||
|
if (chosen) {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateToSetting(chosen);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
closeResults();
|
||||||
|
input.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resultsBox.addEventListener('mousedown', function (e) {
|
||||||
|
// mousedown (not click) so it fires before the input blur closes us
|
||||||
|
var opt = e.target.closest('.ssr-option');
|
||||||
|
if (!opt) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = parseInt(opt.getAttribute('data-idx'), 10);
|
||||||
|
const chosen = currentResults.at(idx);
|
||||||
|
if (chosen) navigateToSetting(chosen);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close when a click/tap lands outside the search widget. Capture phase
|
||||||
|
// (the `true`) runs on the way DOWN, before any bubbling stopPropagation
|
||||||
|
// from Alpine/HTMX/widget handlers can swallow the event — a plain
|
||||||
|
// bubble-phase document listener was being eaten and never closing us.
|
||||||
|
// pointerdown also covers touch (Raspberry Pi screen).
|
||||||
|
document.addEventListener('pointerdown', function (e) {
|
||||||
|
if (!input || resultsBox.classList.contains('hidden')) return;
|
||||||
|
var wrap = document.getElementById('settings-search-wrap');
|
||||||
|
var inside = wrap ? wrap.contains(e.target)
|
||||||
|
: (e.target === input || resultsBox.contains(e.target));
|
||||||
|
if (!inside) closeResults();
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Reliable dismiss: close shortly after focus leaves the box. Result
|
||||||
|
// selection uses mousedown + preventDefault (focus stays on the input),
|
||||||
|
// so this never fires on a result click; the guard covers focus landing
|
||||||
|
// in the results list (e.g. dragging its scrollbar).
|
||||||
|
input.addEventListener('blur', function () {
|
||||||
|
setTimeout(function () {
|
||||||
|
if (resultsBox && resultsBox.contains(document.activeElement)) return;
|
||||||
|
closeResults();
|
||||||
|
}, 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A tab swap (including our own search navigation) should dismiss it.
|
||||||
|
document.body.addEventListener('htmx:afterSwap', closeResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-tab filter (delegated) -------------------------------------------
|
||||||
|
|
||||||
|
function filterScope(input) {
|
||||||
|
// Return the nearest tab/content container, or null — never `document`,
|
||||||
|
// which would let the filter hide setting fields across unrelated tabs.
|
||||||
|
return input.closest('.plugin-config-tab') ||
|
||||||
|
input.closest('[id$="-content"]') ||
|
||||||
|
input.closest('.bg-white') ||
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldHay(fg) {
|
||||||
|
var label = textOf(fg.querySelector('label'));
|
||||||
|
var tip = fg.querySelector('.help-tip');
|
||||||
|
var help = tip ? (tip.getAttribute('data-tooltip') || '') : '';
|
||||||
|
var key = fg.getAttribute('data-setting-key') || fg.id.replace(/^setting-/, '');
|
||||||
|
return (label + ' ' + help + ' ' + key).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTabFilter(scope, q) {
|
||||||
|
q = q.trim().toLowerCase();
|
||||||
|
var terms = q ? q.split(/\s+/) : [];
|
||||||
|
var fields = scope.querySelectorAll('.form-group[id^="setting-"]');
|
||||||
|
var anyVisible = false;
|
||||||
|
|
||||||
|
fields.forEach(function (fg) {
|
||||||
|
var show = !terms.length || termsMatch(fieldHay(fg), terms);
|
||||||
|
fg.style.display = show ? '' : 'none';
|
||||||
|
if (show) {
|
||||||
|
anyVisible = true;
|
||||||
|
// Expand any collapsed nested section holding this match so it
|
||||||
|
// is actually visible (plugin tabs default their sections shut).
|
||||||
|
if (terms.length) expandNestedFor(fg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!terms.length) {
|
||||||
|
// Filter cleared: restore the sections we opened and un-hide every
|
||||||
|
// nested-section wrapper, leaving user-expanded sections untouched.
|
||||||
|
scope.querySelectorAll('.nested-content[data-filter-expanded]').forEach(function (nc) {
|
||||||
|
collapseNode(nc);
|
||||||
|
delete nc.dataset.filterExpanded;
|
||||||
|
});
|
||||||
|
scope.querySelectorAll('.nested-section').forEach(function (ns) { ns.style.display = ''; });
|
||||||
|
} else {
|
||||||
|
// Hide nested-section wrappers whose fields all filtered out.
|
||||||
|
scope.querySelectorAll('.nested-section').forEach(function (ns) {
|
||||||
|
var secFields = ns.querySelectorAll('.form-group[id^="setting-"]');
|
||||||
|
var visible = 0;
|
||||||
|
secFields.forEach(function (f) { if (f.style.display !== 'none') visible++; });
|
||||||
|
ns.style.display = (secFields.length > 0 && visible === 0) ? 'none' : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide section headings whose settings all got filtered out. A visible
|
||||||
|
// nested-section (plugin tabs) counts as content for its parent heading,
|
||||||
|
// so a heading isn't hidden while a subsection below it still has matches.
|
||||||
|
var nodes = scope.querySelectorAll('h3, h4, .form-group, .nested-section');
|
||||||
|
var headings = [];
|
||||||
|
var current = null;
|
||||||
|
nodes.forEach(function (node) {
|
||||||
|
if (node.tagName === 'H3' || node.tagName === 'H4') {
|
||||||
|
current = { el: node, total: 0, visible: 0 };
|
||||||
|
headings.push(current);
|
||||||
|
} else if (current && node.matches('.form-group[id^="setting-"]')) {
|
||||||
|
current.total++;
|
||||||
|
if (node.style.display !== 'none') current.visible++;
|
||||||
|
} else if (current && node.classList.contains('nested-section')) {
|
||||||
|
current.total++;
|
||||||
|
if (node.style.display !== 'none') current.visible++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
headings.forEach(function (h) {
|
||||||
|
// Only auto-hide headings that exclusively group settings fields.
|
||||||
|
h.el.style.display = (terms.length && h.total > 0 && h.visible === 0) ? 'none' : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle the "no matches" note if the filter box provides one.
|
||||||
|
const wrap = scope.querySelector('.settings-filter-wrap');
|
||||||
|
if (wrap) {
|
||||||
|
const empty = wrap.querySelector('.settings-filter-empty');
|
||||||
|
if (empty) empty.classList.toggle('hidden', !(terms.length && !anyVisible));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('input', function (e) {
|
||||||
|
var box = e.target.closest ? e.target.closest('.settings-filter') : null;
|
||||||
|
if (!box) return;
|
||||||
|
var scope = filterScope(box);
|
||||||
|
if (scope) applyTabFilter(scope, box.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Boot -----------------------------------------------------------------
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initSearchBox);
|
||||||
|
} else {
|
||||||
|
initSearchBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for debugging / programmatic use.
|
||||||
|
window.LEDMatrixSettingsSearch = {
|
||||||
|
buildIndex: buildIndex,
|
||||||
|
navigateToSetting: navigateToSetting
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[SettingsSearch] registered');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/*
|
||||||
|
* tooltips.js — accessible, delegated tooltip controller for the v3 web UI.
|
||||||
|
*
|
||||||
|
* A single controller handles every `.help-tip` trigger on the page, including
|
||||||
|
* ones inside partials that HTMX swaps in later, with zero per-field wiring.
|
||||||
|
* Triggers are emitted by the `help_tip` Jinja macro (partials/_macros.html) as
|
||||||
|
* <button class="help-tip" data-tooltip="..."><i class="fas fa-circle-info">.
|
||||||
|
*
|
||||||
|
* Behaviour:
|
||||||
|
* - hover (mouse) -> show / hide
|
||||||
|
* - keyboard focus -> show / hide (only for :focus-visible)
|
||||||
|
* - click / tap -> toggle (the touch path)
|
||||||
|
* - Escape / outside click -> hide
|
||||||
|
* The tooltip text is set via textContent (XSS-safe) and supports "\n" line
|
||||||
|
* breaks via CSS `white-space: pre-line`. Styling lives in app.css and uses the
|
||||||
|
* --color-* theme vars, so light/dark mode work automatically.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (window._tooltipsInit) return;
|
||||||
|
window._tooltipsInit = true;
|
||||||
|
|
||||||
|
var panel = null;
|
||||||
|
var currentTrigger = null;
|
||||||
|
|
||||||
|
function getPanel() {
|
||||||
|
if (panel) return panel;
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.id = 'ledm-tooltip';
|
||||||
|
panel.setAttribute('role', 'tooltip');
|
||||||
|
panel.hidden = true;
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPanel(trigger) {
|
||||||
|
var p = getPanel();
|
||||||
|
var margin = 8;
|
||||||
|
var rect = trigger.getBoundingClientRect();
|
||||||
|
var pw = p.offsetWidth;
|
||||||
|
var ph = p.offsetHeight;
|
||||||
|
var vw = document.documentElement.clientWidth;
|
||||||
|
var vh = document.documentElement.clientHeight;
|
||||||
|
|
||||||
|
// Prefer above the trigger; flip below if it would clip the top.
|
||||||
|
var top = rect.top - ph - margin;
|
||||||
|
var placedBelow = false;
|
||||||
|
if (top < margin) {
|
||||||
|
top = rect.bottom + margin;
|
||||||
|
placedBelow = true;
|
||||||
|
}
|
||||||
|
// Keep it on screen vertically as a last resort.
|
||||||
|
if (top + ph > vh - margin) top = Math.max(margin, vh - ph - margin);
|
||||||
|
|
||||||
|
// Center horizontally on the trigger, clamped to the viewport.
|
||||||
|
var left = rect.left + rect.width / 2 - pw / 2;
|
||||||
|
if (left < margin) left = margin;
|
||||||
|
if (left + pw > vw - margin) left = Math.max(margin, vw - pw - margin);
|
||||||
|
|
||||||
|
p.style.top = Math.round(top) + 'px';
|
||||||
|
p.style.left = Math.round(left) + 'px';
|
||||||
|
p.setAttribute('data-placement', placedBelow ? 'below' : 'above');
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(trigger) {
|
||||||
|
var text = trigger.getAttribute('data-tooltip');
|
||||||
|
if (!text) return;
|
||||||
|
var p = getPanel();
|
||||||
|
p.textContent = text;
|
||||||
|
p.hidden = false;
|
||||||
|
// Measure after it is displayed, then position.
|
||||||
|
positionPanel(trigger);
|
||||||
|
trigger.setAttribute('aria-describedby', 'ledm-tooltip');
|
||||||
|
currentTrigger = trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
if (!panel) return;
|
||||||
|
panel.hidden = true;
|
||||||
|
if (currentTrigger) {
|
||||||
|
currentTrigger.removeAttribute('aria-describedby');
|
||||||
|
currentTrigger = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFrom(target) {
|
||||||
|
return target && target.closest ? target.closest('.help-tip') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delegated listeners on document (survive HTMX swaps) ---
|
||||||
|
|
||||||
|
document.addEventListener('mouseover', function (e) {
|
||||||
|
var t = triggerFrom(e.target);
|
||||||
|
if (t) show(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseout', function (e) {
|
||||||
|
var t = triggerFrom(e.target);
|
||||||
|
if (!t) return;
|
||||||
|
// Ignore moves that stay within the same trigger.
|
||||||
|
var to = e.relatedTarget;
|
||||||
|
if (to && t.contains(to)) return;
|
||||||
|
if (currentTrigger === t) hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('focusin', function (e) {
|
||||||
|
var t = triggerFrom(e.target);
|
||||||
|
if (!t) return;
|
||||||
|
// Only auto-show on keyboard focus, so a mouse/touch focus does not
|
||||||
|
// fight the click handler below.
|
||||||
|
var focusVisible;
|
||||||
|
try {
|
||||||
|
focusVisible = t.matches(':focus-visible');
|
||||||
|
} catch { // older browsers without :focus-visible
|
||||||
|
focusVisible = true;
|
||||||
|
}
|
||||||
|
if (focusVisible) show(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('focusout', function (e) {
|
||||||
|
var t = triggerFrom(e.target);
|
||||||
|
if (t && currentTrigger === t) hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var t = triggerFrom(e.target);
|
||||||
|
if (t) {
|
||||||
|
// Prevent an enclosing <label> from toggling its control, and
|
||||||
|
// prevent form submission.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (currentTrigger === t && !getPanel().hidden) {
|
||||||
|
hide();
|
||||||
|
} else {
|
||||||
|
show(t);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Click anywhere else closes an open tooltip.
|
||||||
|
if (panel && !panel.hidden && !panel.contains(e.target)) hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && panel && !panel.hidden) hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reposition while visible; close when content is swapped out.
|
||||||
|
window.addEventListener('scroll', function () {
|
||||||
|
if (currentTrigger && panel && !panel.hidden) positionPanel(currentTrigger);
|
||||||
|
}, true);
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
if (currentTrigger && panel && !panel.hidden) positionPanel(currentTrigger);
|
||||||
|
});
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function () {
|
||||||
|
// The current trigger may have been removed by the swap.
|
||||||
|
if (currentTrigger && !document.body.contains(currentTrigger)) hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Tooltips] controller registered');
|
||||||
|
})();
|
||||||
@@ -174,11 +174,16 @@
|
|||||||
cell.style.verticalAlign = 'middle';
|
cell.style.verticalAlign = 'middle';
|
||||||
|
|
||||||
if (colType === 'boolean') {
|
if (colType === 'boolean') {
|
||||||
// Boolean: hidden sentinel + visible checkbox
|
// Boolean: hidden sentinel + visible checkbox, same `name` (so
|
||||||
|
// unchecked boxes still submit "false"). Keep the hidden's value
|
||||||
|
// synced to the checkbox at all times — some form-collection
|
||||||
|
// paths prefer whichever of the two same-named inputs comes
|
||||||
|
// first/last in the DOM, and a stale hidden value previously
|
||||||
|
// caused this field to silently revert to false on every save.
|
||||||
const hidden = document.createElement('input');
|
const hidden = document.createElement('input');
|
||||||
hidden.type = 'hidden';
|
hidden.type = 'hidden';
|
||||||
hidden.name = inputName;
|
hidden.name = inputName;
|
||||||
hidden.value = 'false';
|
hidden.value = String(Boolean(colValue));
|
||||||
cell.appendChild(hidden);
|
cell.appendChild(hidden);
|
||||||
|
|
||||||
const cb = document.createElement('input');
|
const cb = document.createElement('input');
|
||||||
@@ -187,6 +192,9 @@
|
|||||||
cb.checked = Boolean(colValue);
|
cb.checked = Boolean(colValue);
|
||||||
cb.value = 'true';
|
cb.value = 'true';
|
||||||
cb.className = 'h-4 w-4 text-blue-600';
|
cb.className = 'h-4 w-4 text-blue-600';
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
hidden.value = String(cb.checked);
|
||||||
|
});
|
||||||
cell.appendChild(cb);
|
cell.appendChild(cb);
|
||||||
|
|
||||||
} else if (colType === 'integer' || colType === 'number') {
|
} else if (colType === 'integer' || colType === 'number') {
|
||||||
@@ -440,7 +448,7 @@
|
|||||||
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||||
</div>`;
|
</div>`);
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'px-5 py-4 space-y-4';
|
body.className = 'px-5 py-4 space-y-4';
|
||||||
@@ -512,7 +520,7 @@
|
|||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||||
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
|
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
|
||||||
<button type="button" id="array-row-editor-save"
|
<button type="button" id="array-row-editor-save"
|
||||||
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md">Save</button>`;
|
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md">Save</button>`);
|
||||||
|
|
||||||
// Save handler
|
// Save handler
|
||||||
footer.querySelector('#array-row-editor-save').onclick = function() {
|
footer.querySelector('#array-row-editor-save').onclick = function() {
|
||||||
|
|||||||