Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73a5304194 |
@@ -0,0 +1,277 @@
|
||||
name: Security Audit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
# Weekly full scan — catches new CVEs in existing deps
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# SAST — Static Application Security Testing
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
sast:
|
||||
name: Static Analysis (bandit + semgrep)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install SAST tools
|
||||
run: pip install bandit==1.8.3 semgrep
|
||||
|
||||
# Bandit — Python-specific security linter
|
||||
# --exit-zero: findings are warnings, not CI blockers.
|
||||
# The security-report job interprets severity.
|
||||
- name: Run bandit
|
||||
run: |
|
||||
bandit -r src/ web_interface/ \
|
||||
-c bandit.yaml \
|
||||
-f json \
|
||||
-o bandit-results.json \
|
||||
--exit-zero
|
||||
|
||||
# Semgrep — broader pattern-based analysis
|
||||
# || true: prevents network/rate-limit errors from blocking the workflow
|
||||
- name: Run semgrep
|
||||
run: |
|
||||
semgrep --config "p/python" \
|
||||
--config "p/flask" \
|
||||
--json \
|
||||
--output semgrep-results.json \
|
||||
src/ web_interface/ \
|
||||
|| true
|
||||
|
||||
- name: Upload SAST artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: sast-results
|
||||
path: |
|
||||
bandit-results.json
|
||||
semgrep-results.json
|
||||
retention-days: 30
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Dependency Vulnerability Scanning
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
dependency-audit:
|
||||
name: Dependency Audit (pip-audit + safety)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install audit tools
|
||||
run: pip install pip-audit safety
|
||||
|
||||
# Install project deps. Hardware-specific packages (rgbmatrix) will fail
|
||||
# to build on Ubuntu runners — || true handles this gracefully.
|
||||
# pip-audit operates on installed packages; partial install is acceptable.
|
||||
- name: Install project dependencies
|
||||
run: |
|
||||
pip install -r requirements.txt || true
|
||||
pip install -r web_interface/requirements.txt || true
|
||||
pip install -r requirements-emulator.txt || true
|
||||
|
||||
- name: Run pip-audit
|
||||
run: |
|
||||
pip-audit --format json --output pip-audit-results.json || true
|
||||
|
||||
- name: Run safety check
|
||||
run: |
|
||||
safety check --output json > safety-results.json 2>&1 || true
|
||||
|
||||
- name: Upload dependency audit artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: dependency-audit-results
|
||||
path: |
|
||||
pip-audit-results.json
|
||||
safety-results.json
|
||||
retention-days: 30
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Secrets Detection
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
secrets-scan:
|
||||
name: Secrets Scan (gitleaks)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history for scanning all commits
|
||||
|
||||
# continue-on-error: config/config_secrets.template.json contains
|
||||
# placeholder strings (YOUR_*) that may trigger gitleaks rules.
|
||||
# The generate_report.py script suppresses these false positives.
|
||||
- name: Run gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload secrets scan artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: secrets-scan-results
|
||||
path: results.sarif
|
||||
retention-days: 30
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# LEDMatrix-Specific Security Proofs
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
ledmatrix-security-proofs:
|
||||
name: LEDMatrix Security Proofs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt || true
|
||||
|
||||
# Script exits 1 only on CRITICAL findings.
|
||||
# Warnings are reported but do not block the workflow.
|
||||
- name: Run security proofs
|
||||
run: |
|
||||
python scripts/prove_security.py \
|
||||
--output security-proofs-results.json \
|
||||
--verbose
|
||||
|
||||
- name: Upload proofs artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: security-proofs-results
|
||||
path: security-proofs-results.json
|
||||
retention-days: 30
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Plugin Security Audit
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
plugin-audit:
|
||||
name: Plugin Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
# Script exits 1 only on CRITICAL findings (eval/exec in plugins).
|
||||
# Missing manifest.json etc are warnings.
|
||||
- name: Run plugin audit
|
||||
run: |
|
||||
python scripts/audit_plugins.py \
|
||||
--output plugin-audit-results.json \
|
||||
--verbose
|
||||
|
||||
- name: Upload plugin audit artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: plugin-audit-results
|
||||
path: plugin-audit-results.json
|
||||
retention-days: 30
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Aggregate Report
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
security-report:
|
||||
name: Security Report
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- sast
|
||||
- dependency-audit
|
||||
- secrets-scan
|
||||
- ledmatrix-security-proofs
|
||||
- plugin-audit
|
||||
if: always() # Run even if upstream jobs fail or are skipped
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: audit-artifacts/
|
||||
|
||||
- name: Generate consolidated report
|
||||
run: |
|
||||
python scripts/generate_report.py \
|
||||
--artifact-dir audit-artifacts/ \
|
||||
--output security-report.md \
|
||||
--verbose
|
||||
|
||||
- name: Upload consolidated report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: security-report
|
||||
path: security-report.md
|
||||
retention-days: 90
|
||||
|
||||
- name: Comment on PR
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const report = fs.readFileSync('security-report.md', 'utf8');
|
||||
// Use sticky comment — update existing comment rather than adding a new one each run
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
});
|
||||
const botComment = comments.find(c =>
|
||||
c.user.type === 'Bot' && c.body.includes('🔒 Security Audit')
|
||||
);
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
comment_id: botComment.id,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: report,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: report,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: pytest (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false # rgbmatrix submodule not needed in EMULATOR mode
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
# Optional deps that some test modules import
|
||||
pip install scipy psutil Flask-Limiter
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
EMULATOR: "true"
|
||||
run: |
|
||||
pytest \
|
||||
-m "not hardware and not slow" \
|
||||
--tb=short
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,33 @@
|
||||
# bandit.yaml — LEDMatrix bandit configuration
|
||||
# https://bandit.readthedocs.io/en/latest/config.html
|
||||
#
|
||||
# Skips are justified by the specific codebase context documented below.
|
||||
# Do not remove skips without updating the justification comment.
|
||||
|
||||
skips:
|
||||
# B104: Binding to all interfaces (0.0.0.0)
|
||||
# Intentional — the Flask server binds 0.0.0.0 for LAN access on a Raspberry Pi.
|
||||
# This is not internet-facing and is documented in web_interface/app.py.
|
||||
- B104
|
||||
|
||||
# B603: subprocess call without shell=True
|
||||
# All subprocess.run() calls in this codebase use list arguments (confirmed by
|
||||
# grep — zero uses of shell=True in src/ or web_interface/). List args prevent
|
||||
# shell injection. See src/common/permission_utils.py for the primary usage.
|
||||
- B603
|
||||
|
||||
# B607: Starting a process with a partial executable path
|
||||
# The subprocess calls invoke system utilities (systemctl, sudo, git) by name.
|
||||
# These are fixed-list invocations, not user-controlled, and rely on PATH.
|
||||
- B607
|
||||
|
||||
exclude_dirs:
|
||||
- tests
|
||||
- test
|
||||
- venv
|
||||
- .venv
|
||||
- rpi-rgb-led-matrix-master
|
||||
# prove_security.py intentionally contains detection patterns as string literals
|
||||
# (e.g. "eval(", "exec(") to search for in other files — bandit would flag
|
||||
# these as false positives.
|
||||
- scripts/prove_security.py
|
||||
@@ -1,234 +0,0 @@
|
||||
# Adaptive Layout & Font Scaling
|
||||
|
||||
`src/adaptive_layout.py` lets a plugin render legibly on **any** panel size
|
||||
(64x32, 128x32, 96x48, 128x64, 256x64, ...) without hand-tuned per-display
|
||||
layouts. It is **opt-in**: nothing changes for plugins that don't use it.
|
||||
|
||||
It generalizes three patterns proven in the plugin ecosystem:
|
||||
|
||||
| Pattern | Origin | Core API |
|
||||
|---|---|---|
|
||||
| Geometry scale factor vs. a design size | f1-scoreboard | `ctx.px(base)` / `ctx.scale` |
|
||||
| Breakpoint tiers | masters-tournament | `ctx.tier` / `ctx.by_tier({...})` |
|
||||
| "Largest crisp font that fits" ladder | baseball-scoreboard | `ctx.fit_text(...)` and friends |
|
||||
|
||||
## Quick start
|
||||
|
||||
Every `BasePlugin` has a lazy `self.layout` (a `LayoutContext` for the
|
||||
current logical display size, rebuilt automatically if the size changes)
|
||||
and a one-liner `self.draw_fit(...)`:
|
||||
|
||||
```python
|
||||
def display(self, force_clear=False):
|
||||
from src.adaptive_layout import LADDER_ARCADE
|
||||
|
||||
b = self.layout.bounds.inset(1) # Region(0,0,W,H) minus 1px margin
|
||||
rows = b.split_v(3, 1, 1, gap=1) # 3/5 for time, 1/5 each for the rest
|
||||
|
||||
self.draw_fit(self.time_str, rows[0], ladder=LADDER_ARCADE)
|
||||
self.draw_fit(self.weekday, rows[1]) # default LADDER_GRID
|
||||
self.draw_fit(self.date_str, rows[2])
|
||||
self.display_manager.update_display()
|
||||
```
|
||||
|
||||
On 128x64 the time renders at press_start 24px; on 64x32 it steps down to
|
||||
8px. The rows partition the height, so bands can never overlap — no more
|
||||
`y = height - 7` magic numbers.
|
||||
|
||||
## Region — rect algebra
|
||||
|
||||
`Region(x, y, w, h)` is a frozen dataclass. All carving clamps to
|
||||
non-negative dimensions, so degenerate panels behave.
|
||||
|
||||
- Carving: `inset(dx, dy)`, `top_band(h)`, `bottom_band(h)`,
|
||||
`middle(top_h, bottom_h)`, `left_col(w)`, `right_col(w)`,
|
||||
`split_h(*weights, gap=0)`, `split_v(*weights, gap=0)`
|
||||
- Placement: `align_xy(w, h, align, valign)`, `center_xy(w, h)`,
|
||||
`contains(w, h)`, `.center`, `.right`, `.bottom`
|
||||
|
||||
Scoreboard-style layout:
|
||||
|
||||
```python
|
||||
b = self.layout.bounds
|
||||
status = b.top_band(self.layout.px(7))
|
||||
detail = b.bottom_band(self.layout.px(7))
|
||||
score_area = b.middle(status.h, detail.h)
|
||||
away_slot, home_slot = b.left_col(b.h), b.right_col(b.h)
|
||||
```
|
||||
|
||||
## Font ladders — discrete, never fractional
|
||||
|
||||
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes, so
|
||||
fonts are never scaled continuously. A `FontLadder` is an ordered tuple of
|
||||
`FontStep(family, size_px)` rungs, largest first; fitting walks down until
|
||||
the measured text fits.
|
||||
|
||||
- `LADDER_GRID` (default): X11 BDFs at native sizes — 10x20 → 9x18 → 9x15 →
|
||||
8x13 → 7x13 → 6x13 → 6x12 → 6x10 → 6x9 → 5x8 → 5x7 → 4x6 → tom-thumb.
|
||||
Body text, labels, multi-row content.
|
||||
- `LADDER_ARCADE`: PressStart2P at 32/24/16/8 (integer multiples of its 8px
|
||||
grid). Headline text: clocks, scores.
|
||||
|
||||
Custom ladders are just tuples — e.g. to add your plugin's registered font
|
||||
on top: `(FontStep("myplugin::digits", 16),) + LADDER_GRID`.
|
||||
|
||||
## LayoutContext
|
||||
|
||||
Built per (width, height); exposes facts and fit queries:
|
||||
|
||||
- `bounds`, `width`, `height`, `aspect`
|
||||
- `tier` by height (`xs`≤16, `sm`≤32, `md`≤48, `lg`≤64, `xl`) and
|
||||
`width_tier` (`narrow`≤64, `normal`≤128, `wide`≤256, `ultrawide`)
|
||||
- `is_wide_short` — aspect ≥ 2.5 and height ≤ 32 (the classic 128x32 shape)
|
||||
- `scale` — `min(w/design_w, h/design_h)` vs. your manifest's
|
||||
`display.design_size` (default 128x32). **Geometry only** — gaps, icon
|
||||
and logo sizes via `px(base, minimum, maximum)`; fonts use ladders.
|
||||
- `by_tier({"sm": 10, "lg": 18})` — value for the nearest defined tier
|
||||
at-or-below the panel's tier.
|
||||
- `fit_text(text, box, ladder, ellipsis=True)` → `FitResult` — largest rung
|
||||
that fits; ellipsizes as a last resort. Cached per (text, box, ladder).
|
||||
- `fit_text_proportional(text, box, base_size_px, ladder, ellipsis=True, scale=None)` —
|
||||
rung closest to (not exceeding) `base_size_px * scale`, still capped to
|
||||
what fits the box. Use this instead of `fit_text` when several
|
||||
independently-fitted elements need to stay visually harmonious as the
|
||||
panel grows — `fit_text` maximizes *each one* within its own region,
|
||||
which can make one element (e.g. a score with a generous box) balloon
|
||||
out of proportion to a neighbor that scales by geometry (e.g. logos
|
||||
sized via `px()`), even though each individual pick is "correct" in
|
||||
isolation. `base_size_px` is normally the element's existing classic/
|
||||
fixed font size. `scale` defaults to `self.scale` (the conservative
|
||||
min-of-both-axes factor `px()` uses); pass an axis-specific value when
|
||||
the surrounding composition already scales that way — e.g. a scoreboard
|
||||
whose logo slots track height alone (`min(height, width // 2)`) should
|
||||
size its text by `height / design_height` too, or the text reads as
|
||||
under-scaled next to bigger logos on a panel that only grew taller.
|
||||
- `fit_lines(lines, box, ladder, spacing)` — every line fits the width and
|
||||
the stack fits the height (measures the actual strings).
|
||||
- `font_for_rows(rows, box_h, ladder)` — largest rung whose line height
|
||||
fits `rows` rows.
|
||||
|
||||
`FitResult` carries the ready-to-use `font` (drops straight into
|
||||
`display_manager.draw_text(font=...)`), the possibly-ellipsized `text`,
|
||||
ink `width`/`height`, `baseline`, `y_offset`, `line_height`, and `fits`.
|
||||
|
||||
## Adaptive images
|
||||
|
||||
`src/adaptive_images.py` is the image counterpart to `fit_text`, exposed as
|
||||
`self.layout.fit_image(...)` (cached per panel size) and the one-liner
|
||||
`self.draw_image(...)`:
|
||||
|
||||
```python
|
||||
# Team logo: trim its transparent padding, fill the slot height (the
|
||||
# football/hockey pattern), cached across frames by a stable key
|
||||
self.draw_image(logo, regs.away_slot, mode="fill_height",
|
||||
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||
|
||||
# Album art: cover-crop a square, faces kept by the top anchor
|
||||
self.draw_image(art, row.art, mode="cover", anchor="top")
|
||||
|
||||
# Pixel flags / sprite icons: NEAREST keeps hard edges
|
||||
from src.adaptive_images import RESAMPLE_NEAREST
|
||||
self.draw_image(flag, box, resample=RESAMPLE_NEAREST)
|
||||
```
|
||||
|
||||
Modes: `contain` (letterbox, default), `cover` (crop-to-fill),
|
||||
`fill_height` (logo-style), `stretch`. Unlike PIL's `thumbnail()`
|
||||
(downscale-only — why imagery stays tiny on big panels) fitting **upscales
|
||||
by default**; pass `upscale=False` for the legacy behavior. Results are
|
||||
cached per (image, box size, options) with a bounded LRU — always pass a
|
||||
stable `cache_key` (e.g. `"logo:KC"`) for images you reload. The module
|
||||
also exports the Pillow-compat `RESAMPLE_LANCZOS`/`RESAMPLE_NEAREST`
|
||||
constants so plugins can drop their local shims.
|
||||
|
||||
## Composite layouts
|
||||
|
||||
Pre-carved Region arrangements for the layouts plugins keep rebuilding:
|
||||
|
||||
```python
|
||||
from src.adaptive_layout import scoreboard_regions, media_row
|
||||
|
||||
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||
# regs.away_slot / home_slot — logo slots (logo_slot = min(H, W // 2),
|
||||
# capped so a center reserve always exists —
|
||||
# see below)
|
||||
# regs.status_band — top band (replaces the magic y = 1)
|
||||
# regs.score_area — center gap, plus a controlled bleed into
|
||||
# each logo slot (replaces y = H//2 - 3)
|
||||
# regs.detail_band — bottom band (replaces y = H - 7)
|
||||
# regs.bottom_left / bottom_right — record/timeout corners
|
||||
|
||||
row = media_row(self.layout.bounds, ctx=self.layout) # art left, text right
|
||||
```
|
||||
|
||||
Both work on the full panel or on a scroll-mode card Region. They return
|
||||
Regions and never draw — compose them with `draw_fit`/`draw_image`.
|
||||
|
||||
**`scoreboard_regions`'s center reserve.** The raw `logo_slot = min(H, W//2)`
|
||||
formula has a blind spot: at exactly 2:1 aspect ratio (width = 2×height —
|
||||
two, four, or more square modules stacked into a taller panel, e.g.
|
||||
96x48, 128x64, 256x128) the two logo slots mathematically claim the
|
||||
*entire* width, leaving zero pixels for a center column no matter how
|
||||
big the panel gets. Wide panels (the 128x32 design baseline, 192x48,
|
||||
256x32) never hit this, since height is already the tighter constraint
|
||||
there. Two parameters fix it without any plugin-side code:
|
||||
`min_center_fraction`/`min_center_design_px` guarantee a real minimum
|
||||
center reserve at any aspect ratio, and `score_bleed_fraction` lets the
|
||||
score's *fit box* extend a controlled amount into each logo slot — the
|
||||
same way a real broadcast scoreboard's numbers cross slightly into the
|
||||
team marks flanking them — so a short score string never has to truncate
|
||||
even on the tightest aspect ratios. All three have sane defaults; override
|
||||
them per call if a plugin's card proportions genuinely differ.
|
||||
|
||||
## Preserving user customization
|
||||
|
||||
Adaptive layout supplies *defaults*; explicit user configuration wins:
|
||||
|
||||
- **User-set fonts win.** If the plugin's config has an explicit
|
||||
`font`/`font_size` for an element, load it as before and skip the ladder —
|
||||
fit only when the user hasn't overridden (see the football-scoreboard
|
||||
`_resolve_element_fit` pattern).
|
||||
- **Offsets apply on top.** `customization.layout.<element>.{x_offset,y_offset}`
|
||||
style knobs translate the *computed* region as a final step:
|
||||
`region.offset(user_dx, user_dy)`. `draw_image(..., offset=(dx, dy))`
|
||||
does the same for images.
|
||||
- **Colors pass through.** `draw_fit`/`draw_fitted_text` take explicit
|
||||
`color=` params; adaptive mode never repaints semantic or user-chosen
|
||||
colors.
|
||||
|
||||
## Manifest declaration
|
||||
|
||||
Declare the size your layout was authored against so `ctx.scale` means
|
||||
something:
|
||||
|
||||
```json
|
||||
"display": { "design_size": { "width": 128, "height": 32 } }
|
||||
```
|
||||
|
||||
Also available under `requires.display_size`: `min_width`, `min_height`,
|
||||
`max_width`, `max_height`.
|
||||
|
||||
## Performance notes (Pi)
|
||||
|
||||
Fit queries are cached, so cost is O(unique strings). For per-second text
|
||||
(clocks, live scores), fit on a **shape placeholder** and reuse the font:
|
||||
|
||||
```python
|
||||
fit = self.layout.fit_text("00:00", box, ladder=LADDER_ARCADE) # cached once
|
||||
self.display_manager.draw_text(current_time, font=fit.font, ...)
|
||||
```
|
||||
|
||||
## Testing across sizes
|
||||
|
||||
The harness already renders every plugin at a spread of sizes (now
|
||||
including 96x48):
|
||||
|
||||
```bash
|
||||
python scripts/check_plugin.py <plugin-dir> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||
python scripts/render_plugin.py <plugin-dir> --width 96 --height 48
|
||||
```
|
||||
|
||||
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
||||
mediated draw calls with negative coordinates in
|
||||
`negative_coordinate_calls` (raw-PIL draws remain uncovered).
|
||||
|
||||
Reference migration: the **text-display** plugin's `font_mode: "auto"`.
|
||||
@@ -2,12 +2,6 @@
|
||||
|
||||
Advanced patterns, examples, and best practices for developing LEDMatrix plugins.
|
||||
|
||||
> **Adaptive layout:** for plugins that should render legibly on any panel
|
||||
> size (fonts that grow on big panels, layouts that degrade gracefully on
|
||||
> small ones), use the adaptive layout system — `self.layout`, `draw_fit`,
|
||||
> `draw_image`, `scoreboard_regions` — documented in
|
||||
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Using Weather Icons](#using-weather-icons)
|
||||
|
||||
@@ -48,12 +48,6 @@ display_manager.draw_text("Centered", centered=True) # Auto-center
|
||||
width = display_manager.get_text_width("Text", font)
|
||||
height = display_manager.get_font_height(font)
|
||||
|
||||
# Adaptive layout (recommended for multi-size support — text and images
|
||||
# that scale to any panel; see docs/ADAPTIVE_LAYOUT.md)
|
||||
rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||
|
||||
# Weather icons
|
||||
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
||||
|
||||
|
||||
@@ -6,12 +6,6 @@ Tools for rapid plugin development without deploying to the RPi.
|
||||
|
||||
Interactive web UI for tweaking plugin configs and seeing the rendered display in real time.
|
||||
|
||||
The size inputs have a preset dropdown with the harness's standard panel
|
||||
sizes, and the **All Sizes** button renders the current config at every
|
||||
harness size in a side-by-side gallery (`POST /api/render-matrix`) — the
|
||||
quickest way to eyeball adaptive-layout behavior across panels
|
||||
(see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)).
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# FontManager Usage Guide
|
||||
|
||||
> **Picking a size automatically:** if you want the *largest font that fits
|
||||
> a given area* rather than a fixed size, use the adaptive layout system's
|
||||
> font ladders, which resolve through this FontManager. `BasePlugin`
|
||||
> subclasses get this as `self.layout.fit_text(...)`; other code can build
|
||||
> a `LayoutContext(width, height, font_manager)` directly — see
|
||||
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Overview
|
||||
|
||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
||||
|
||||
@@ -248,7 +248,6 @@ test/
|
||||
├── test_config_service.py # Config service tests
|
||||
├── test_config_validation_edge_cases.py # Config edge cases
|
||||
├── test_font_manager.py # Font manager tests
|
||||
├── test_layout_manager.py # Layout manager tests
|
||||
├── test_text_helper.py # Text helper tests
|
||||
├── test_error_handling.py # Error handling tests
|
||||
├── test_error_aggregator.py # Error aggregation tests
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
|
||||
Complete API reference for plugin developers. This document describes all methods and properties available to plugins through the Display Manager, Cache Manager, and Plugin Manager.
|
||||
|
||||
> **Adaptive layout:** every `BasePlugin` also exposes `self.layout`,
|
||||
> `self.draw_fit(text, region)` and `self.draw_image(img, region, ...)` —
|
||||
> the recommended way to render text and images that scale to any panel
|
||||
> size. See [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [BasePlugin](#baseplugin)
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
|
||||
This guide explains how to set up a development workflow for plugins that are maintained in separate Git repositories while still being able to test them within the LEDMatrix project.
|
||||
|
||||
> **Rendering guidance:** plugins should read the display size dynamically
|
||||
> (`self.display_manager.matrix.width/height`) rather than hardcoding one
|
||||
> panel. For plugins that want to *scale* their layout to any panel, the
|
||||
> opt-in adaptive layout system ([ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md))
|
||||
> provides the shared helpers — fonts, images, and composite layouts that
|
||||
> scale. Existing plugins keep their classic rendering unless they adopt
|
||||
> those APIs; nothing migrates automatically.
|
||||
|
||||
## Overview
|
||||
|
||||
When developing plugins in separate repositories, you need a way to:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/7-segment-clock
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/baseball-scoreboard
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/basketball-scoreboard
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/calendar
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/christmas-countdown
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/clock-simple
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/countdown
|
||||
@@ -0,0 +1 @@
|
||||
/home/chuck/Github/ledmatrix-plugins/plugins/f1-scoreboard
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/football-scoreboard
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/hello-world
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/hockey-scoreboard
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/ledmatrix-flights
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/ledmatrix-leaderboard
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/ledmatrix-music
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/ledmatrix-stocks
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/ledmatrix-weather
|
||||
|
After Width: | Height: | Size: 476 B |
|
After Width: | Height: | Size: 459 B |
|
After Width: | Height: | Size: 545 B |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 561 B |
|
After Width: | Height: | Size: 538 B |
|
After Width: | Height: | Size: 521 B |
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "March Madness Plugin Configuration",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable the March Madness tournament display"
|
||||
},
|
||||
"leagues": {
|
||||
"type": "object",
|
||||
"title": "Tournament Leagues",
|
||||
"description": "Which NCAA tournaments to display",
|
||||
"properties": {
|
||||
"ncaam": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show NCAA Men's Tournament games"
|
||||
},
|
||||
"ncaaw": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show NCAA Women's Tournament games"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"favorite_teams": {
|
||||
"type": "array",
|
||||
"title": "Favorite Teams",
|
||||
"description": "Team abbreviations to highlight (e.g., DUKE, UNC). Leave empty to show all teams equally.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
},
|
||||
"display_options": {
|
||||
"type": "object",
|
||||
"title": "Display Options",
|
||||
"x-collapsed": true,
|
||||
"properties": {
|
||||
"show_seeds": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show tournament seeds (1-16) next to team names"
|
||||
},
|
||||
"show_round_logos": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show round logo separators between game groups"
|
||||
},
|
||||
"highlight_upsets": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Highlight upset winners (higher seed beating lower seed) in gold"
|
||||
},
|
||||
"show_bracket_progress": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show which teams are still alive in each region"
|
||||
},
|
||||
"scroll_speed": {
|
||||
"type": "number",
|
||||
"default": 1.0,
|
||||
"minimum": 0.5,
|
||||
"maximum": 5.0,
|
||||
"description": "Scroll speed (pixels per frame)"
|
||||
},
|
||||
"scroll_delay": {
|
||||
"type": "number",
|
||||
"default": 0.02,
|
||||
"minimum": 0.001,
|
||||
"maximum": 0.1,
|
||||
"description": "Delay between scroll frames (seconds)"
|
||||
},
|
||||
"target_fps": {
|
||||
"type": "integer",
|
||||
"default": 120,
|
||||
"minimum": 30,
|
||||
"maximum": 200,
|
||||
"description": "Target frames per second"
|
||||
},
|
||||
"loop": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Loop the scroll continuously"
|
||||
},
|
||||
"dynamic_duration": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically adjust display duration based on content width"
|
||||
},
|
||||
"min_duration": {
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"minimum": 10,
|
||||
"maximum": 300,
|
||||
"description": "Minimum display duration in seconds"
|
||||
},
|
||||
"max_duration": {
|
||||
"type": "integer",
|
||||
"default": 300,
|
||||
"minimum": 30,
|
||||
"maximum": 600,
|
||||
"description": "Maximum display duration in seconds"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"data_settings": {
|
||||
"type": "object",
|
||||
"title": "Data Settings",
|
||||
"x-collapsed": true,
|
||||
"properties": {
|
||||
"update_interval": {
|
||||
"type": "integer",
|
||||
"default": 300,
|
||||
"minimum": 60,
|
||||
"maximum": 3600,
|
||||
"description": "How often to refresh tournament data (seconds). Automatically shortens to 60s when live games are detected."
|
||||
},
|
||||
"request_timeout": {
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"minimum": 5,
|
||||
"maximum": 60,
|
||||
"description": "API request timeout in seconds"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"x-propertyOrder": ["enabled", "leagues", "favorite_teams", "display_options", "data_settings"]
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
"""March Madness Plugin — NCAA Tournament bracket tracker for LED Matrix.
|
||||
|
||||
Displays a horizontally-scrolling ticker of NCAA Tournament games grouped by
|
||||
round, with seeds, round logos, live scores, and upset highlighting.
|
||||
"""
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pytz
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
try:
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
except ImportError:
|
||||
ScrollHelper = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCOREBOARD_URLS = {
|
||||
"ncaam": "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard",
|
||||
"ncaaw": "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard",
|
||||
}
|
||||
|
||||
ROUND_ORDER = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6}
|
||||
|
||||
ROUND_DISPLAY_NAMES = {
|
||||
"NCG": "Championship",
|
||||
"F4": "Final Four",
|
||||
"E8": "Elite Eight",
|
||||
"S16": "Sweet Sixteen",
|
||||
"R32": "Round of 32",
|
||||
"R64": "Round of 64",
|
||||
}
|
||||
|
||||
ROUND_LOGO_FILES = {
|
||||
"NCG": "CHAMPIONSHIP.png",
|
||||
"F4": "FINAL_4.png",
|
||||
"E8": "ELITE_8.png",
|
||||
"S16": "SWEET_16.png",
|
||||
"R32": "ROUND_32.png",
|
||||
"R64": "ROUND_64.png",
|
||||
}
|
||||
|
||||
REGION_ORDER = {"E": 0, "W": 1, "S": 2, "MW": 3, "": 4}
|
||||
|
||||
# Colors
|
||||
COLOR_WHITE = (255, 255, 255)
|
||||
COLOR_GOLD = (255, 215, 0)
|
||||
COLOR_GRAY = (160, 160, 160)
|
||||
COLOR_DIM = (100, 100, 100)
|
||||
COLOR_RED = (255, 60, 60)
|
||||
COLOR_GREEN = (60, 200, 60)
|
||||
COLOR_BLACK = (0, 0, 0)
|
||||
COLOR_DARK_BG = (20, 20, 20)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MarchMadnessPlugin(BasePlugin):
|
||||
"""NCAA March Madness tournament bracket tracker."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: Dict[str, Any],
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any,
|
||||
):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
|
||||
# Config
|
||||
leagues_config = config.get("leagues", {})
|
||||
self.show_ncaam: bool = leagues_config.get("ncaam", True)
|
||||
self.show_ncaaw: bool = leagues_config.get("ncaaw", True)
|
||||
self.favorite_teams: List[str] = [t.upper() for t in config.get("favorite_teams", [])]
|
||||
|
||||
display_options = config.get("display_options", {})
|
||||
self.show_seeds: bool = display_options.get("show_seeds", True)
|
||||
self.show_round_logos: bool = display_options.get("show_round_logos", True)
|
||||
self.highlight_upsets: bool = display_options.get("highlight_upsets", True)
|
||||
self.show_bracket_progress: bool = display_options.get("show_bracket_progress", True)
|
||||
self.scroll_speed: float = display_options.get("scroll_speed", 1.0)
|
||||
self.scroll_delay: float = display_options.get("scroll_delay", 0.02)
|
||||
self.target_fps: int = display_options.get("target_fps", 120)
|
||||
self.loop: bool = display_options.get("loop", True)
|
||||
self.dynamic_duration_enabled: bool = display_options.get("dynamic_duration", True)
|
||||
self.min_duration: int = display_options.get("min_duration", 30)
|
||||
self.max_duration: int = display_options.get("max_duration", 300)
|
||||
if self.min_duration > self.max_duration:
|
||||
self.logger.warning(
|
||||
f"min_duration ({self.min_duration}) > max_duration ({self.max_duration}); swapping values"
|
||||
)
|
||||
self.min_duration, self.max_duration = self.max_duration, self.min_duration
|
||||
|
||||
data_settings = config.get("data_settings", {})
|
||||
self.update_interval: int = data_settings.get("update_interval", 300)
|
||||
self.request_timeout: int = data_settings.get("request_timeout", 30)
|
||||
|
||||
# Scrolling flag for display controller
|
||||
self.enable_scrolling = True
|
||||
|
||||
# State
|
||||
self.games_data: List[Dict] = []
|
||||
self.ticker_image: Optional[Image.Image] = None
|
||||
self.last_update: float = 0
|
||||
self.dynamic_duration: float = 60
|
||||
self.total_scroll_width: int = 0
|
||||
self._display_start_time: Optional[float] = None
|
||||
self._end_reached_logged: bool = False
|
||||
self._update_lock = threading.Lock()
|
||||
self._has_live_games: bool = False
|
||||
self._cached_dynamic_duration: Optional[float] = None
|
||||
self._duration_cache_time: float = 0
|
||||
|
||||
# Display dimensions
|
||||
self.display_width: int = self.display_manager.matrix.width
|
||||
self.display_height: int = self.display_manager.matrix.height
|
||||
|
||||
# HTTP session with retry
|
||||
self.session = requests.Session()
|
||||
retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
|
||||
self.session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||
self.headers = {"User-Agent": "LEDMatrix/2.0"}
|
||||
|
||||
# ScrollHelper
|
||||
if ScrollHelper:
|
||||
self.scroll_helper = ScrollHelper(self.display_width, self.display_height, logger=self.logger)
|
||||
if hasattr(self.scroll_helper, "set_frame_based_scrolling"):
|
||||
self.scroll_helper.set_frame_based_scrolling(True)
|
||||
self.scroll_helper.set_scroll_speed(self.scroll_speed)
|
||||
self.scroll_helper.set_scroll_delay(self.scroll_delay)
|
||||
if hasattr(self.scroll_helper, "set_target_fps"):
|
||||
self.scroll_helper.set_target_fps(self.target_fps)
|
||||
self.scroll_helper.set_dynamic_duration_settings(
|
||||
enabled=self.dynamic_duration_enabled,
|
||||
min_duration=self.min_duration,
|
||||
max_duration=self.max_duration,
|
||||
buffer=0.1,
|
||||
)
|
||||
else:
|
||||
self.scroll_helper = None
|
||||
self.logger.warning("ScrollHelper not available")
|
||||
|
||||
# Fonts
|
||||
self.fonts = self._load_fonts()
|
||||
|
||||
# Logos
|
||||
self._round_logos: Dict[str, Image.Image] = {}
|
||||
self._team_logo_cache: Dict[str, Optional[Image.Image]] = {}
|
||||
self._march_madness_logo: Optional[Image.Image] = None
|
||||
self._load_round_logos()
|
||||
|
||||
self.logger.info(
|
||||
f"MarchMadnessPlugin initialized — NCAAM: {self.show_ncaam}, "
|
||||
f"NCAAW: {self.show_ncaaw}, favorites: {self.favorite_teams}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fonts
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
|
||||
fonts = {}
|
||||
try:
|
||||
fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
||||
except IOError:
|
||||
fonts["score"] = ImageFont.load_default()
|
||||
try:
|
||||
fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
except IOError:
|
||||
fonts["time"] = ImageFont.load_default()
|
||||
try:
|
||||
fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
||||
except IOError:
|
||||
fonts["detail"] = ImageFont.load_default()
|
||||
return fonts
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Logo loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_round_logos(self) -> None:
|
||||
logo_dir = Path(__file__).parent / "assets" / "logos"
|
||||
for round_key, filename in ROUND_LOGO_FILES.items():
|
||||
path = logo_dir / filename
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
# Resize to fit display height
|
||||
target_h = self.display_height - 4
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
self._round_logos[round_key] = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(f"Could not load round logo {filename}: {e}")
|
||||
except Exception:
|
||||
self.logger.exception(f"Unexpected error loading round logo {filename}")
|
||||
|
||||
# March Madness banner logo (also in plugin assets)
|
||||
mm_path = logo_dir / "MARCH_MADNESS.png"
|
||||
try:
|
||||
img = Image.open(mm_path).convert("RGBA")
|
||||
target_h = self.display_height - 4
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
self._march_madness_logo = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(f"Could not load March Madness logo: {e}")
|
||||
except Exception:
|
||||
self.logger.exception("Unexpected error loading March Madness logo")
|
||||
|
||||
def _get_team_logo(self, abbr: str) -> Optional[Image.Image]:
|
||||
if abbr in self._team_logo_cache:
|
||||
return self._team_logo_cache[abbr]
|
||||
logo_dir = Path("assets/sports/ncaa_logos")
|
||||
path = logo_dir / f"{abbr}.png"
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
target_h = self.display_height - 6
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
self._team_logo_cache[abbr] = img
|
||||
return img
|
||||
except (FileNotFoundError, OSError, ValueError):
|
||||
self._team_logo_cache[abbr] = None
|
||||
return None
|
||||
except Exception:
|
||||
self.logger.exception(f"Unexpected error loading team logo for {abbr}")
|
||||
self._team_logo_cache[abbr] = None
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data fetching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _is_tournament_window(self) -> bool:
|
||||
today = datetime.now(pytz.utc)
|
||||
return (3, 10) <= (today.month, today.day) <= (4, 10)
|
||||
|
||||
def _fetch_tournament_data(self) -> List[Dict]:
|
||||
"""Fetch tournament games from ESPN scoreboard API."""
|
||||
all_games: List[Dict] = []
|
||||
|
||||
leagues = []
|
||||
if self.show_ncaam:
|
||||
leagues.append("ncaam")
|
||||
if self.show_ncaaw:
|
||||
leagues.append("ncaaw")
|
||||
|
||||
for league_key in leagues:
|
||||
url = SCOREBOARD_URLS.get(league_key)
|
||||
if not url:
|
||||
continue
|
||||
|
||||
cache_key = f"march_madness_{league_key}_scoreboard"
|
||||
cache_max_age = 60 if self._has_live_games else self.update_interval
|
||||
cached = self.cache_manager.get(cache_key, max_age=cache_max_age)
|
||||
if cached:
|
||||
all_games.extend(cached)
|
||||
continue
|
||||
|
||||
try:
|
||||
# NCAA basketball scoreboard without dates param returns current games
|
||||
params = {"limit": 1000, "groups": 100}
|
||||
resp = self.session.get(url, params=params, headers=self.headers, timeout=self.request_timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
events = data.get("events", [])
|
||||
|
||||
league_games = []
|
||||
for event in events:
|
||||
game = self._parse_event(event, league_key)
|
||||
if game:
|
||||
league_games.append(game)
|
||||
|
||||
self.cache_manager.set(cache_key, league_games)
|
||||
self.logger.info(f"Fetched {len(league_games)} {league_key} tournament games")
|
||||
all_games.extend(league_games)
|
||||
|
||||
except Exception:
|
||||
self.logger.exception(f"Error fetching {league_key} tournament data")
|
||||
|
||||
return all_games
|
||||
|
||||
def _parse_event(self, event: Dict, league_key: str) -> Optional[Dict]:
|
||||
"""Parse an ESPN event into a game dict."""
|
||||
competitions = event.get("competitions", [])
|
||||
if not competitions:
|
||||
return None
|
||||
comp = competitions[0]
|
||||
|
||||
# Confirm tournament game
|
||||
comp_type = comp.get("type", {})
|
||||
is_tournament = comp_type.get("abbreviation") == "TRNMNT"
|
||||
notes = comp.get("notes", [])
|
||||
headline = ""
|
||||
if notes:
|
||||
headline = notes[0].get("headline", "")
|
||||
if not is_tournament and "Championship" in headline:
|
||||
is_tournament = True
|
||||
if not is_tournament:
|
||||
return None
|
||||
|
||||
# Status
|
||||
status = comp.get("status", {}).get("type", {})
|
||||
state = status.get("state", "pre")
|
||||
status_detail = status.get("shortDetail", "")
|
||||
|
||||
# Teams
|
||||
competitors = comp.get("competitors", [])
|
||||
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
|
||||
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
|
||||
if not home_team or not away_team:
|
||||
return None
|
||||
|
||||
home_abbr = home_team.get("team", {}).get("abbreviation", "???")
|
||||
away_abbr = away_team.get("team", {}).get("abbreviation", "???")
|
||||
home_score = home_team.get("score", "0")
|
||||
away_score = away_team.get("score", "0")
|
||||
|
||||
# Seeds
|
||||
home_seed = home_team.get("curatedRank", {}).get("current", 0)
|
||||
away_seed = away_team.get("curatedRank", {}).get("current", 0)
|
||||
if home_seed >= 99:
|
||||
home_seed = 0
|
||||
if away_seed >= 99:
|
||||
away_seed = 0
|
||||
|
||||
# Round and region
|
||||
tournament_round = self._parse_round(headline)
|
||||
tournament_region = self._parse_region(headline)
|
||||
|
||||
# Date/time
|
||||
date_str = event.get("date", "")
|
||||
start_time_utc = None
|
||||
game_date = ""
|
||||
game_time = ""
|
||||
try:
|
||||
if date_str.endswith("Z"):
|
||||
date_str = date_str.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(date_str)
|
||||
if dt.tzinfo is None:
|
||||
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
||||
else:
|
||||
start_time_utc = dt.astimezone(pytz.UTC)
|
||||
local = start_time_utc.astimezone(pytz.timezone("US/Eastern"))
|
||||
game_date = local.strftime("%-m/%-d")
|
||||
game_time = local.strftime("%-I:%M%p").replace("AM", "am").replace("PM", "pm")
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# Period / clock for live games
|
||||
period = 0
|
||||
clock = ""
|
||||
period_text = ""
|
||||
is_halftime = False
|
||||
if state == "in":
|
||||
status_obj = comp.get("status", {})
|
||||
period = status_obj.get("period", 0)
|
||||
clock = status_obj.get("displayClock", "")
|
||||
detail_lower = status_detail.lower()
|
||||
uses_quarters = league_key == "ncaaw" or "quarter" in detail_lower or detail_lower.startswith("q")
|
||||
if period <= (4 if uses_quarters else 2):
|
||||
period_text = f"Q{period}" if uses_quarters else f"H{period}"
|
||||
else:
|
||||
ot_num = period - (4 if uses_quarters else 2)
|
||||
period_text = f"OT{ot_num}" if ot_num > 1 else "OT"
|
||||
if "halftime" in detail_lower:
|
||||
is_halftime = True
|
||||
elif state == "post":
|
||||
period_text = status.get("shortDetail", "Final")
|
||||
if "Final" not in period_text:
|
||||
period_text = "Final"
|
||||
|
||||
# Determine winner and upset
|
||||
is_final = state == "post"
|
||||
is_upset = False
|
||||
winner_side = ""
|
||||
if is_final:
|
||||
try:
|
||||
h = int(float(home_score))
|
||||
a = int(float(away_score))
|
||||
if h > a:
|
||||
winner_side = "home"
|
||||
if home_seed > away_seed > 0:
|
||||
is_upset = True
|
||||
elif a > h:
|
||||
winner_side = "away"
|
||||
if away_seed > home_seed > 0:
|
||||
is_upset = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": event.get("id", ""),
|
||||
"league": league_key,
|
||||
"home_abbr": home_abbr,
|
||||
"away_abbr": away_abbr,
|
||||
"home_score": str(home_score),
|
||||
"away_score": str(away_score),
|
||||
"home_seed": home_seed,
|
||||
"away_seed": away_seed,
|
||||
"tournament_round": tournament_round,
|
||||
"tournament_region": tournament_region,
|
||||
"state": state,
|
||||
"is_final": is_final,
|
||||
"is_live": state == "in",
|
||||
"is_upcoming": state == "pre",
|
||||
"is_halftime": is_halftime,
|
||||
"period": period,
|
||||
"period_text": period_text,
|
||||
"clock": clock,
|
||||
"status_detail": status_detail,
|
||||
"game_date": game_date,
|
||||
"game_time": game_time,
|
||||
"start_time_utc": start_time_utc,
|
||||
"is_upset": is_upset,
|
||||
"winner_side": winner_side,
|
||||
"headline": headline,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_round(headline: str) -> str:
|
||||
hl = headline.lower()
|
||||
if "national championship" in hl:
|
||||
return "NCG"
|
||||
if "final four" in hl:
|
||||
return "F4"
|
||||
if "elite 8" in hl or "elite eight" in hl:
|
||||
return "E8"
|
||||
if "sweet 16" in hl or "sweet sixteen" in hl:
|
||||
return "S16"
|
||||
if "2nd round" in hl or "second round" in hl:
|
||||
return "R32"
|
||||
if "1st round" in hl or "first round" in hl:
|
||||
return "R64"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_region(headline: str) -> str:
|
||||
if "East Region" in headline:
|
||||
return "E"
|
||||
if "West Region" in headline:
|
||||
return "W"
|
||||
if "South Region" in headline:
|
||||
return "S"
|
||||
if "Midwest Region" in headline:
|
||||
return "MW"
|
||||
m = re.search(r"Regional (\d+)", headline)
|
||||
if m:
|
||||
return f"R{m.group(1)}"
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Game processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _process_games(self, games: List[Dict]) -> Dict[str, List[Dict]]:
|
||||
"""Group games by round, sorted by round significance then region/seed."""
|
||||
grouped: Dict[str, List[Dict]] = {}
|
||||
for game in games:
|
||||
rnd = game.get("tournament_round", "")
|
||||
grouped.setdefault(rnd, []).append(game)
|
||||
|
||||
# Sort each round's games by region then seed matchup
|
||||
for rnd, round_games in grouped.items():
|
||||
round_games.sort(
|
||||
key=lambda g: (
|
||||
REGION_ORDER.get(g.get("tournament_region", ""), 4),
|
||||
min(g.get("away_seed", 99), g.get("home_seed", 99)),
|
||||
)
|
||||
)
|
||||
|
||||
return grouped
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _draw_text_with_outline(
|
||||
self,
|
||||
draw: ImageDraw.Draw,
|
||||
text: str,
|
||||
xy: tuple,
|
||||
font: ImageFont.FreeTypeFont,
|
||||
fill: tuple = COLOR_WHITE,
|
||||
outline: tuple = COLOR_BLACK,
|
||||
) -> None:
|
||||
x, y = xy
|
||||
for dx in (-1, 0, 1):
|
||||
for dy in (-1, 0, 1):
|
||||
if dx or dy:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline)
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
def _create_round_separator(self, round_key: str) -> Image.Image:
|
||||
"""Create a separator tile for a tournament round."""
|
||||
height = self.display_height
|
||||
name = ROUND_DISPLAY_NAMES.get(round_key, round_key)
|
||||
font = self.fonts["time"]
|
||||
|
||||
# Measure text
|
||||
tmp = Image.new("RGB", (1, 1))
|
||||
tmp_draw = ImageDraw.Draw(tmp)
|
||||
text_width = int(tmp_draw.textlength(name, font=font))
|
||||
|
||||
# Logo on each side
|
||||
logo = self._round_logos.get(round_key, self._march_madness_logo)
|
||||
logo_w = logo.width if logo else 0
|
||||
padding = 6
|
||||
|
||||
total_w = padding + logo_w + padding + text_width + padding + logo_w + padding
|
||||
total_w = max(total_w, 80)
|
||||
|
||||
img = Image.new("RGB", (total_w, height), COLOR_DARK_BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw logos
|
||||
x = padding
|
||||
if logo:
|
||||
logo_y = (height - logo.height) // 2
|
||||
img.paste(logo, (x, logo_y), logo)
|
||||
x += logo_w + padding
|
||||
|
||||
# Draw round name
|
||||
text_y = (height - 8) // 2 # 8px font
|
||||
self._draw_text_with_outline(draw, name, (x, text_y), font, fill=COLOR_GOLD)
|
||||
x += text_width + padding
|
||||
|
||||
if logo:
|
||||
logo_y = (height - logo.height) // 2
|
||||
img.paste(logo, (x, logo_y), logo)
|
||||
|
||||
return img
|
||||
|
||||
def _create_game_tile(self, game: Dict) -> Image.Image:
|
||||
"""Create a single game tile for the scrolling ticker."""
|
||||
height = self.display_height
|
||||
font_score = self.fonts["score"]
|
||||
font_time = self.fonts["time"]
|
||||
font_detail = self.fonts["detail"]
|
||||
|
||||
# Load team logos
|
||||
away_logo = self._get_team_logo(game["away_abbr"])
|
||||
home_logo = self._get_team_logo(game["home_abbr"])
|
||||
logo_w = 0
|
||||
if away_logo:
|
||||
logo_w = max(logo_w, away_logo.width)
|
||||
if home_logo:
|
||||
logo_w = max(logo_w, home_logo.width)
|
||||
if logo_w == 0:
|
||||
logo_w = 24
|
||||
|
||||
# Build text elements
|
||||
away_seed_str = f"({game['away_seed']})" if self.show_seeds and game.get("away_seed", 0) > 0 else ""
|
||||
home_seed_str = f"({game['home_seed']})" if self.show_seeds and game.get("home_seed", 0) > 0 else ""
|
||||
away_text = f"{away_seed_str}{game['away_abbr']}"
|
||||
home_text = f"{game['home_abbr']}{home_seed_str}"
|
||||
|
||||
# Measure text widths
|
||||
tmp = Image.new("RGB", (1, 1))
|
||||
tmp_draw = ImageDraw.Draw(tmp)
|
||||
away_text_w = int(tmp_draw.textlength(away_text, font=font_detail))
|
||||
home_text_w = int(tmp_draw.textlength(home_text, font=font_detail))
|
||||
|
||||
# Center content: status line
|
||||
if game["is_live"]:
|
||||
if game["is_halftime"]:
|
||||
status_text = "Halftime"
|
||||
else:
|
||||
status_text = f"{game['period_text']} {game['clock']}".strip()
|
||||
elif game["is_final"]:
|
||||
status_text = game.get("period_text", "Final")
|
||||
else:
|
||||
status_text = f"{game['game_date']} {game['game_time']}".strip()
|
||||
|
||||
status_w = int(tmp_draw.textlength(status_text, font=font_time))
|
||||
|
||||
# Score line (for live/final)
|
||||
score_text = ""
|
||||
if game["is_live"] or game["is_final"]:
|
||||
score_text = f"{game['away_score']}-{game['home_score']}"
|
||||
score_w = int(tmp_draw.textlength(score_text, font=font_score)) if score_text else 0
|
||||
|
||||
# Calculate tile width
|
||||
h_pad = 4
|
||||
center_w = max(status_w, score_w, 40)
|
||||
tile_w = h_pad + logo_w + h_pad + away_text_w + h_pad + center_w + h_pad + home_text_w + h_pad + logo_w + h_pad
|
||||
|
||||
img = Image.new("RGB", (tile_w, height), COLOR_BLACK)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Paste away logo
|
||||
x = h_pad
|
||||
if away_logo:
|
||||
logo_y = (height - away_logo.height) // 2
|
||||
img.paste(away_logo, (x, logo_y), away_logo)
|
||||
x += logo_w + h_pad
|
||||
|
||||
# Away team text (seed + abbr)
|
||||
is_fav_away = game["away_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||
away_color = COLOR_GOLD if is_fav_away else COLOR_WHITE
|
||||
if game["is_final"] and game["winner_side"] == "away" and self.highlight_upsets and game["is_upset"]:
|
||||
away_color = COLOR_GOLD
|
||||
team_text_y = (height - 6) // 2 - 5 # Upper half
|
||||
self._draw_text_with_outline(draw, away_text, (x, team_text_y), font_detail, fill=away_color)
|
||||
x += away_text_w + h_pad
|
||||
|
||||
# Center block
|
||||
center_x = x
|
||||
center_mid = center_x + center_w // 2
|
||||
|
||||
# Status text (top center of center block)
|
||||
status_x = center_mid - status_w // 2
|
||||
status_y = 2
|
||||
status_color = COLOR_GREEN if game["is_live"] else COLOR_GRAY
|
||||
self._draw_text_with_outline(draw, status_text, (status_x, status_y), font_time, fill=status_color)
|
||||
|
||||
# Score (bottom center of center block, for live/final)
|
||||
if score_text:
|
||||
score_x = center_mid - score_w // 2
|
||||
score_y = height - 13
|
||||
# Upset highlighting
|
||||
if game["is_final"] and game["is_upset"] and self.highlight_upsets:
|
||||
score_color = COLOR_GOLD
|
||||
elif game["is_live"]:
|
||||
score_color = COLOR_WHITE
|
||||
else:
|
||||
score_color = COLOR_WHITE
|
||||
self._draw_text_with_outline(draw, score_text, (score_x, score_y), font_score, fill=score_color)
|
||||
|
||||
# Date for final games (below score)
|
||||
if game["is_final"] and game.get("game_date"):
|
||||
date_w = int(draw.textlength(game["game_date"], font=font_detail))
|
||||
date_x = center_mid - date_w // 2
|
||||
date_y = height - 6
|
||||
self._draw_text_with_outline(draw, game["game_date"], (date_x, date_y), font_detail, fill=COLOR_DIM)
|
||||
|
||||
x = center_x + center_w + h_pad
|
||||
|
||||
# Home team text
|
||||
is_fav_home = game["home_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||
home_color = COLOR_GOLD if is_fav_home else COLOR_WHITE
|
||||
if game["is_final"] and game["winner_side"] == "home" and self.highlight_upsets and game["is_upset"]:
|
||||
home_color = COLOR_GOLD
|
||||
self._draw_text_with_outline(draw, home_text, (x, team_text_y), font_detail, fill=home_color)
|
||||
x += home_text_w + h_pad
|
||||
|
||||
# Paste home logo
|
||||
if home_logo:
|
||||
logo_y = (height - home_logo.height) // 2
|
||||
img.paste(home_logo, (x, logo_y), home_logo)
|
||||
|
||||
return img
|
||||
|
||||
def _create_ticker_image(self) -> None:
|
||||
"""Build the full scrolling ticker image from game tiles."""
|
||||
if not self.games_data:
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
grouped = self._process_games(self.games_data)
|
||||
content_items: List[Image.Image] = []
|
||||
|
||||
# Order rounds by significance (most important first)
|
||||
sorted_rounds = sorted(grouped.keys(), key=lambda r: ROUND_ORDER.get(r, 6))
|
||||
|
||||
for rnd in sorted_rounds:
|
||||
games = grouped[rnd]
|
||||
if not games:
|
||||
continue
|
||||
|
||||
# Add round separator
|
||||
if self.show_round_logos and rnd:
|
||||
separator = self._create_round_separator(rnd)
|
||||
content_items.append(separator)
|
||||
|
||||
# Add game tiles
|
||||
for game in games:
|
||||
tile = self._create_game_tile(game)
|
||||
content_items.append(tile)
|
||||
|
||||
if not content_items:
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
if not self.scroll_helper:
|
||||
self.ticker_image = None
|
||||
return
|
||||
|
||||
gap_width = 16
|
||||
|
||||
# Use ScrollHelper to create the scrolling image
|
||||
self.ticker_image = self.scroll_helper.create_scrolling_image(
|
||||
content_items=content_items,
|
||||
item_gap=gap_width,
|
||||
element_gap=0,
|
||||
)
|
||||
|
||||
self.total_scroll_width = self.scroll_helper.total_scroll_width
|
||||
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||
|
||||
self.logger.info(
|
||||
f"Ticker image created: {self.ticker_image.width}px wide, "
|
||||
f"{len(self.games_data)} games, dynamic_duration={self.dynamic_duration:.0f}s"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plugin lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update(self) -> None:
|
||||
"""Fetch and process tournament data."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
# Use shorter interval if live games detected
|
||||
interval = 60 if self._has_live_games else self.update_interval
|
||||
if current_time - self.last_update < interval:
|
||||
return
|
||||
|
||||
with self._update_lock:
|
||||
self.last_update = current_time
|
||||
|
||||
if not self._is_tournament_window():
|
||||
self.logger.debug("Outside tournament window, skipping fetch")
|
||||
self.games_data = []
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
try:
|
||||
games = self._fetch_tournament_data()
|
||||
self._has_live_games = any(g["is_live"] for g in games)
|
||||
self.games_data = games
|
||||
self._create_ticker_image()
|
||||
self.logger.info(
|
||||
f"Updated: {len(games)} games, "
|
||||
f"live={self._has_live_games}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Update error: {e}", exc_info=True)
|
||||
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
"""Render one scroll frame."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
if force_clear or self._display_start_time is None:
|
||||
self._display_start_time = time.time()
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.reset_scroll()
|
||||
self._end_reached_logged = False
|
||||
|
||||
if not self.games_data or self.ticker_image is None:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
if not self.scroll_helper:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
try:
|
||||
if self.loop or not self.scroll_helper.is_scroll_complete():
|
||||
self.scroll_helper.update_scroll_position()
|
||||
elif not self._end_reached_logged:
|
||||
self.logger.info("Scroll complete")
|
||||
self._end_reached_logged = True
|
||||
|
||||
visible = self.scroll_helper.get_visible_portion()
|
||||
if visible is None:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||
|
||||
matrix_w = self.display_manager.matrix.width
|
||||
matrix_h = self.display_manager.matrix.height
|
||||
if not hasattr(self.display_manager, "image") or self.display_manager.image is None:
|
||||
self.display_manager.image = Image.new("RGB", (matrix_w, matrix_h), COLOR_BLACK)
|
||||
self.display_manager.image.paste(visible, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
self.scroll_helper.log_frame_rate()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Display error: {e}", exc_info=True)
|
||||
self._display_fallback()
|
||||
|
||||
def _display_fallback(self) -> None:
|
||||
w = self.display_manager.matrix.width
|
||||
h = self.display_manager.matrix.height
|
||||
img = Image.new("RGB", (w, h), COLOR_BLACK)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if self._is_tournament_window():
|
||||
text = "No games"
|
||||
else:
|
||||
text = "Off-season"
|
||||
|
||||
text_w = int(draw.textlength(text, font=self.fonts["time"]))
|
||||
text_x = (w - text_w) // 2
|
||||
text_y = (h - 8) // 2
|
||||
draw.text((text_x, text_y), text, font=self.fonts["time"], fill=COLOR_GRAY)
|
||||
|
||||
# Show March Madness logo if available
|
||||
if self._march_madness_logo:
|
||||
logo_y = (h - self._march_madness_logo.height) // 2
|
||||
img.paste(self._march_madness_logo, (2, logo_y), self._march_madness_logo)
|
||||
|
||||
self.display_manager.image = img
|
||||
self.display_manager.update_display()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Duration / cycle management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
current_time = time.time()
|
||||
if self._cached_dynamic_duration is not None:
|
||||
cache_age = current_time - self._duration_cache_time
|
||||
if cache_age < 5.0:
|
||||
return self._cached_dynamic_duration
|
||||
|
||||
self._cached_dynamic_duration = self.dynamic_duration
|
||||
self._duration_cache_time = current_time
|
||||
return self.dynamic_duration
|
||||
|
||||
def supports_dynamic_duration(self) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
return self.dynamic_duration_enabled
|
||||
|
||||
def is_cycle_complete(self) -> bool:
|
||||
if not self.supports_dynamic_duration():
|
||||
return True
|
||||
if self._display_start_time is not None and self.dynamic_duration > 0:
|
||||
elapsed = time.time() - self._display_start_time
|
||||
if elapsed >= self.dynamic_duration:
|
||||
return True
|
||||
if not self.loop and self.scroll_helper and self.scroll_helper.is_scroll_complete():
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset_cycle_state(self) -> None:
|
||||
super().reset_cycle_state()
|
||||
self._display_start_time = None
|
||||
self._end_reached_logged = False
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.reset_scroll()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Vegas mode
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_vegas_content(self):
|
||||
if not self.games_data:
|
||||
return None
|
||||
tiles = []
|
||||
for game in self.games_data:
|
||||
tiles.append(self._create_game_tile(game))
|
||||
return tiles if tiles else None
|
||||
|
||||
def get_vegas_content_type(self) -> str:
|
||||
return "multi"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Info / cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_info(self) -> Dict:
|
||||
info = super().get_info()
|
||||
info["total_games"] = len(self.games_data)
|
||||
info["has_live_games"] = self._has_live_games
|
||||
info["dynamic_duration"] = self.dynamic_duration
|
||||
info["tournament_window"] = self._is_tournament_window()
|
||||
return info
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.games_data = []
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
self._team_logo_cache.clear()
|
||||
if self.session:
|
||||
self.session.close()
|
||||
self.session = None
|
||||
super().cleanup()
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"id": "march-madness",
|
||||
"name": "March Madness",
|
||||
"version": "1.0.0",
|
||||
"description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting",
|
||||
"author": "ChuckBuilds",
|
||||
"category": "sports",
|
||||
"tags": [
|
||||
"ncaa",
|
||||
"basketball",
|
||||
"march-madness",
|
||||
"tournament",
|
||||
"bracket",
|
||||
"scrolling"
|
||||
],
|
||||
"repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
|
||||
"branch": "main",
|
||||
"plugin_path": "plugins/march-madness",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"ledmatrix_min": "2.0.0",
|
||||
"released": "2026-02-16"
|
||||
}
|
||||
],
|
||||
"stars": 0,
|
||||
"downloads": 0,
|
||||
"last_updated": "2026-02-16",
|
||||
"verified": true,
|
||||
"screenshot": "",
|
||||
"display_modes": [
|
||||
"march_madness"
|
||||
],
|
||||
"dependencies": {},
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "MarchMadnessPlugin"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
requests>=2.33.0
|
||||
urllib3>=2.6.3
|
||||
Pillow>=12.2.0
|
||||
pytz>=2022.1
|
||||
numpy>=1.24.0
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/mqtt-notifications
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/news
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/odds-ticker
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/of-the-day
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/olympics
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/soccer-scoreboard
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/static-image
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/stock-news
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/text-display
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/ufc-scoreboard
|
||||
@@ -0,0 +1 @@
|
||||
../../ledmatrix-plugins/plugins/youtube-stats
|
||||
@@ -8,16 +8,11 @@ numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
|
||||
|
||||
# Timezone handling
|
||||
pytz>=2024.2,<2025.0 # Updated for latest timezone data
|
||||
timezonefinder>=6.5.0,<7.0.0 # Updated for better performance and accuracy
|
||||
geopy>=2.4.1,<3.0.0
|
||||
|
||||
# HTTP requests
|
||||
requests>=2.33.0,<3.0.0
|
||||
|
||||
# Google API integration
|
||||
google-auth-oauthlib>=1.2.0,<2.0.0
|
||||
google-auth-httplib2>=0.2.0,<1.0.0
|
||||
google-api-python-client>=2.147.0,<3.0.0
|
||||
|
||||
# Font rendering
|
||||
freetype-py>=2.5.1,<3.0.0
|
||||
@@ -29,10 +24,8 @@ spotipy>=2.25.2,<3.0.0
|
||||
Flask>=3.1.3,<4.0.0
|
||||
|
||||
# Text processing
|
||||
unidecode>=1.3.8,<2.0.0
|
||||
|
||||
# Calendar integration
|
||||
icalevents>=0.1.27,<1.0.0
|
||||
|
||||
# WebSocket support
|
||||
python-socketio>=5.14.0,<6.0.0
|
||||
|
||||
@@ -90,40 +90,11 @@
|
||||
"min_height": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"max_width": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"max_height": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"display": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"design_size": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"width": {
|
||||
"type": "integer",
|
||||
"minimum": 8
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"minimum": 8
|
||||
}
|
||||
},
|
||||
"required": ["width", "height"],
|
||||
"description": "Panel size the plugin's layout was authored against; core derives the adaptive-layout scale factor from it. Defaults to 128x32 when omitted."
|
||||
}
|
||||
},
|
||||
"description": "Display/layout hints for the adaptive layout system"
|
||||
},
|
||||
"config_schema": {
|
||||
"type": "string",
|
||||
"description": "Path to configuration schema file"
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LEDMatrix Plugin Security Auditor
|
||||
|
||||
Performs AST-based security analysis of all Python files in plugin directories.
|
||||
Designed to run in CI — exits non-zero on CRITICAL findings only.
|
||||
|
||||
Usage:
|
||||
python scripts/audit_plugins.py
|
||||
python scripts/audit_plugins.py --verbose
|
||||
python scripts/audit_plugins.py --plugin hello-world
|
||||
python scripts/audit_plugins.py --output results.json
|
||||
"""
|
||||
|
||||
import ast
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
PLUGIN_BASE_DIRS = [
|
||||
PROJECT_ROOT / "plugins",
|
||||
PROJECT_ROOT / "plugin-repos",
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Finding dataclass
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
plugin_id: str
|
||||
file: str
|
||||
line: int
|
||||
severity: str # CRITICAL | WARNING | INFO
|
||||
rule: str
|
||||
message: str
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# AST visitor
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _PluginVisitor(ast.NodeVisitor):
|
||||
"""Collect security findings from a single plugin Python file."""
|
||||
|
||||
def __init__(self, filepath: Path, plugin_id: str):
|
||||
self.filepath = filepath
|
||||
self.plugin_id = plugin_id
|
||||
self.findings: list[Finding] = []
|
||||
|
||||
def _add(self, node: ast.AST, severity: str, rule: str, message: str) -> None:
|
||||
self.findings.append(Finding(
|
||||
plugin_id=self.plugin_id,
|
||||
file=str(self.filepath.relative_to(PROJECT_ROOT)),
|
||||
line=getattr(node, "lineno", 0),
|
||||
severity=severity,
|
||||
rule=rule,
|
||||
message=message,
|
||||
))
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
# eval() / exec() — arbitrary code execution
|
||||
if isinstance(node.func, ast.Name):
|
||||
if node.func.id == "eval":
|
||||
self._add(node, "CRITICAL", "PLUGIN-001",
|
||||
"eval() call — arbitrary code execution risk")
|
||||
elif node.func.id == "exec":
|
||||
self._add(node, "CRITICAL", "PLUGIN-002",
|
||||
"exec() call — arbitrary code execution risk")
|
||||
elif node.func.id == "compile":
|
||||
self._add(node, "WARNING", "PLUGIN-003",
|
||||
"compile() call — dynamic code compilation")
|
||||
|
||||
# subprocess.*(shell=True)
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
is_subprocess = (
|
||||
isinstance(node.func.value, ast.Name) and
|
||||
node.func.value.id == "subprocess" and
|
||||
node.func.attr in ("run", "call", "Popen", "check_call", "check_output")
|
||||
)
|
||||
if is_subprocess:
|
||||
for kw in node.keywords:
|
||||
if (kw.arg == "shell" and
|
||||
isinstance(kw.value, ast.Constant) and
|
||||
kw.value.value is True):
|
||||
self._add(node, "WARNING", "PLUGIN-004",
|
||||
f"subprocess.{node.func.attr}(shell=True) — "
|
||||
f"shell injection risk if args include user input")
|
||||
|
||||
# os.system() — shell execution
|
||||
is_os_system = (
|
||||
isinstance(node.func.value, ast.Name) and
|
||||
node.func.value.id == "os" and
|
||||
node.func.attr == "system"
|
||||
)
|
||||
if is_os_system:
|
||||
self._add(node, "WARNING", "PLUGIN-005",
|
||||
"os.system() call — prefer subprocess with list args")
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
for alias in node.names:
|
||||
self._check_import(node, alias.name)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
if node.module:
|
||||
self._check_import(node, node.module)
|
||||
self.generic_visit(node)
|
||||
|
||||
def _check_import(self, node: ast.AST, module_name: str) -> None:
|
||||
dangerous = {
|
||||
"ctypes": ("WARNING", "PLUGIN-010", "ctypes import — native code execution"),
|
||||
"cffi": ("WARNING", "PLUGIN-011", "cffi import — native code execution"),
|
||||
"pickle": ("WARNING", "PLUGIN-012",
|
||||
"pickle import — deserialization can execute arbitrary code"),
|
||||
"marshal": ("WARNING", "PLUGIN-013",
|
||||
"marshal import — deserialization risk"),
|
||||
}
|
||||
for mod, (severity, rule, msg) in dangerous.items():
|
||||
if module_name == mod or module_name.startswith(mod + "."):
|
||||
self._add(node, severity, rule, msg)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Per-plugin audit
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def audit_plugin(plugin_dir: Path) -> list[Finding]:
|
||||
"""Audit a single plugin directory. Returns all findings."""
|
||||
findings: list[Finding] = []
|
||||
plugin_id = plugin_dir.name
|
||||
|
||||
# Check for required files
|
||||
for required_file, rule, msg in [
|
||||
("manifest.json", "PLUGIN-020",
|
||||
"manifest.json missing — plugin may be incomplete"),
|
||||
("config_schema.json", "PLUGIN-021",
|
||||
"config_schema.json missing — no input validation schema declared"),
|
||||
]:
|
||||
if not (plugin_dir / required_file).exists():
|
||||
findings.append(Finding(
|
||||
plugin_id=plugin_id,
|
||||
file=str((plugin_dir / required_file).relative_to(PROJECT_ROOT)),
|
||||
line=0,
|
||||
severity="WARNING",
|
||||
rule=rule,
|
||||
message=msg,
|
||||
))
|
||||
|
||||
# AST analysis of all Python files
|
||||
for py_file in sorted(plugin_dir.rglob("*.py")):
|
||||
try:
|
||||
source = py_file.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(py_file))
|
||||
visitor = _PluginVisitor(py_file, plugin_id)
|
||||
visitor.visit(tree)
|
||||
findings.extend(visitor.findings)
|
||||
except SyntaxError as exc:
|
||||
findings.append(Finding(
|
||||
plugin_id=plugin_id,
|
||||
file=str(py_file.relative_to(PROJECT_ROOT)),
|
||||
line=getattr(exc, "lineno", 0) or 0,
|
||||
severity="WARNING",
|
||||
rule="PLUGIN-030",
|
||||
message=f"Python syntax error — cannot be parsed: {exc}",
|
||||
))
|
||||
except OSError as exc:
|
||||
findings.append(Finding(
|
||||
plugin_id=plugin_id,
|
||||
file=str(py_file.relative_to(PROJECT_ROOT)),
|
||||
line=0,
|
||||
severity="INFO",
|
||||
rule="PLUGIN-031",
|
||||
message=f"Could not read file: {exc}",
|
||||
))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="LEDMatrix plugin security auditor",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--plugin", "-p", default=None,
|
||||
help="Audit a specific plugin ID only")
|
||||
parser.add_argument("--output", "-o", default=None,
|
||||
help="Write JSON results to this file")
|
||||
parser.add_argument("--verbose", "-v", action="store_true",
|
||||
help="Show all findings, not just summary")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("LEDMatrix Plugin Security Audit")
|
||||
print(f"Project root: {PROJECT_ROOT}")
|
||||
print("=" * 60)
|
||||
|
||||
all_findings: list[Finding] = []
|
||||
plugins_scanned = 0
|
||||
|
||||
for base_dir in PLUGIN_BASE_DIRS:
|
||||
if not base_dir.exists():
|
||||
if args.verbose:
|
||||
print(f" ⏭️ Skipping {base_dir.name}/ (directory not found)")
|
||||
continue
|
||||
|
||||
base_label = base_dir.relative_to(PROJECT_ROOT)
|
||||
print(f"\n Scanning {base_label}/")
|
||||
|
||||
for plugin_dir in sorted(base_dir.iterdir()):
|
||||
if not plugin_dir.is_dir():
|
||||
continue
|
||||
if plugin_dir.name.startswith((".", "_")):
|
||||
continue
|
||||
if args.plugin and plugin_dir.name != args.plugin:
|
||||
continue
|
||||
|
||||
findings = audit_plugin(plugin_dir)
|
||||
all_findings.extend(findings)
|
||||
plugins_scanned += 1
|
||||
|
||||
critical = [f for f in findings if f.severity == "CRITICAL"]
|
||||
warnings = [f for f in findings if f.severity == "WARNING"]
|
||||
|
||||
if critical:
|
||||
icon, label = "🚨", "CRITICAL"
|
||||
elif warnings:
|
||||
icon, label = "⚠️ ", "WARN "
|
||||
else:
|
||||
icon, label = "✅", "PASS "
|
||||
|
||||
print(f" {icon} [{label}] {plugin_dir.name}"
|
||||
f" — {len(critical)} critical, {len(warnings)} warnings")
|
||||
|
||||
if args.verbose:
|
||||
for f in findings:
|
||||
severity_icon = {"CRITICAL": "🚨", "WARNING": "⚠️ ", "INFO": "ℹ️ "}.get(
|
||||
f.severity, " "
|
||||
)
|
||||
print(f" {severity_icon} {f.rule} {f.file}:{f.line} — {f.message}")
|
||||
|
||||
# Summary
|
||||
critical_findings = [f for f in all_findings if f.severity == "CRITICAL"]
|
||||
warning_findings = [f for f in all_findings if f.severity == "WARNING"]
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Plugins scanned : {plugins_scanned}")
|
||||
print(f" CRITICAL : {len(critical_findings)}")
|
||||
print(f" WARNING : {len(warning_findings)}")
|
||||
|
||||
if critical_findings:
|
||||
print("\n 🚨 CRITICAL findings:")
|
||||
for f in critical_findings:
|
||||
print(f" {f.plugin_id} | {Path(f.file).name}:{f.line} | {f.message}")
|
||||
|
||||
# Write JSON output
|
||||
if args.output:
|
||||
output_data = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"plugins_scanned": plugins_scanned,
|
||||
"summary": {
|
||||
"critical": len(critical_findings),
|
||||
"warnings": len(warning_findings),
|
||||
},
|
||||
"findings": [f.to_dict() for f in all_findings],
|
||||
}
|
||||
Path(args.output).write_text(
|
||||
json.dumps(output_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(f"\n Results written to: {args.output}")
|
||||
|
||||
if critical_findings:
|
||||
print("\n 🚨 Blocking — CRITICAL issues must be resolved")
|
||||
return 1
|
||||
|
||||
print("\n ✅ No critical issues found")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -37,11 +37,10 @@ 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, load_manifest,
|
||||
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,
|
||||
check_scale_up,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
||||
@@ -111,55 +110,28 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
effective_freeze = freeze_time or spec.get("freeze_time")
|
||||
effective_run_update = run_update and not spec.get("skip_update", False)
|
||||
|
||||
# The plugin's declared design size drives the scale-up fill check
|
||||
# (panels >= 2x the design size must not be left mostly empty).
|
||||
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||
fill_strict = spec.get("fill_check") == "strict"
|
||||
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,
|
||||
)
|
||||
|
||||
# Every run: the base config, plus one per harness.json "variant" —
|
||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
||||
# tested alongside the classic default).
|
||||
runs = [(None, {}, golden_dir_override or (plugin_dir / 'test' / 'golden'))]
|
||||
for variant in spec.get("variants", []):
|
||||
name = variant.get("name") or "variant"
|
||||
vdir = plugin_dir / variant.get("golden_dir", f"test/golden-{name}")
|
||||
runs.append((name, variant.get("config", {}), vdir))
|
||||
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)
|
||||
|
||||
all_run_results: List[RenderResult] = []
|
||||
for variant_name, overlay, golden_dir in runs:
|
||||
run_config = {**full_config, **overlay}
|
||||
results = render_plugin_matrix(
|
||||
plugin_id=plugin_id, plugin_dir=plugin_dir, config=run_config,
|
||||
mock_data=effective_mock_data, sizes=effective_sizes,
|
||||
run_update=effective_run_update, freeze_time=effective_freeze,
|
||||
)
|
||||
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")
|
||||
|
||||
if update_golden:
|
||||
written = write_goldens(results, golden_dir)
|
||||
logger.info("Wrote %d golden image(s) for %s%s to %s", written, plugin_id,
|
||||
f" [{variant_name}]" if variant_name else "", golden_dir)
|
||||
else:
|
||||
compare_to_goldens(results, golden_dir)
|
||||
|
||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
||||
|
||||
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||
if variant_name:
|
||||
for r in results:
|
||||
r.mode = f"{r.mode}@{variant_name}"
|
||||
|
||||
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")
|
||||
|
||||
all_run_results.extend(results)
|
||||
|
||||
return all_run_results
|
||||
return results
|
||||
|
||||
|
||||
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
@@ -175,10 +147,6 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
detail = " (golden ✓)"
|
||||
if r.update_error is not None:
|
||||
detail += f" (update warn: {r.update_error})"
|
||||
if r.fill_checked and r.fill_ok is None and r.fill_extent:
|
||||
# warn-only underfill: big panel left mostly empty
|
||||
ex, ey = r.fill_extent
|
||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
||||
else:
|
||||
everything_ok = False
|
||||
if r.error is not None:
|
||||
@@ -188,10 +156,6 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
elif r.golden_ok is False:
|
||||
status = "FAIL"
|
||||
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
||||
elif r.fill_ok is False:
|
||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||
status = "FAIL"
|
||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
|
||||
@@ -55,7 +55,7 @@ def main():
|
||||
failures += not check("draw.textbbox",
|
||||
lambda: draw.textbbox((0, 0), "Test", font=font))
|
||||
|
||||
print("\nResampling (used in logo_helper, image_utils, sports base):")
|
||||
print("\nResampling (used in logo_helper, sports base):")
|
||||
logo = Image.new('RGBA', (200, 200), (255, 128, 0, 200))
|
||||
failures += not check("Image.Resampling.LANCZOS exists",
|
||||
lambda: str(Image.Resampling.LANCZOS))
|
||||
|
||||
@@ -16,7 +16,6 @@ Opens at http://localhost:5001
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import argparse
|
||||
import logging
|
||||
@@ -45,10 +44,6 @@ MAX_HEIGHT = 512
|
||||
MIN_WIDTH = 1
|
||||
MIN_HEIGHT = 1
|
||||
|
||||
# plugin_id arrives in request input and is used to build filesystem paths —
|
||||
# allowlist it (same pattern the web UI's pages_v3 uses)
|
||||
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Plugin discovery
|
||||
@@ -111,30 +106,15 @@ def discover_plugins() -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||
"""Find a plugin directory by ID.
|
||||
|
||||
plugin_id comes from request input: it must pass an allowlist match,
|
||||
and the resulting directory is normalized and required to live inside
|
||||
one of the plugin search dirs, so a crafted id can never name a path
|
||||
outside them.
|
||||
"""
|
||||
if not isinstance(plugin_id, str) or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
return None
|
||||
"""Find a plugin directory by ID."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
for search_dir in get_search_dirs():
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
result = loader.find_plugin_directory(plugin_id, search_dir)
|
||||
if not result:
|
||||
continue
|
||||
# Normalize WITHOUT following symlinks (dev plugins are often
|
||||
# symlinked into plugins/) and require lexical containment in the
|
||||
# search dir, so no id can ever name a path outside it.
|
||||
result_abs = os.path.abspath(str(result))
|
||||
root_abs = os.path.abspath(str(search_dir))
|
||||
if os.path.commonpath([result_abs, root_abs]) == root_abs:
|
||||
return Path(result_abs)
|
||||
if result:
|
||||
return Path(result)
|
||||
return None
|
||||
|
||||
|
||||
@@ -196,72 +176,6 @@ def api_plugin_defaults(plugin_id):
|
||||
return jsonify({'defaults': defaults})
|
||||
|
||||
|
||||
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
|
||||
skip_update):
|
||||
"""Render one plugin at one size. Returns the /api/render response dict.
|
||||
|
||||
Thin wrapper over the shared render service (also used by the web UI's
|
||||
config-page preview); a fresh plugin instance per call, mirroring the
|
||||
safety harness, so sizes never share state.
|
||||
"""
|
||||
from src.plugin_system.testing.render_service import render_plugin_once
|
||||
|
||||
return render_plugin_once(
|
||||
plugin_id, plugin_dir, manifest=manifest, config=config,
|
||||
mock_data=mock_data, width=width, height=height,
|
||||
skip_update=skip_update)
|
||||
|
||||
|
||||
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
|
||||
"""Re-derive a plugin directory from the search dirs' own listings.
|
||||
|
||||
Path-injection barrier: unlike ``Path.iterdir()`` (which CodeQL doesn't
|
||||
recognize as a taint-clearing enumeration), ``os.scandir()`` is. The
|
||||
returned Path is built from a trusted root plus a name the filesystem
|
||||
itself produced under that root via scandir — request-derived strings
|
||||
never enter its construction — so a crafted plugin id can never make
|
||||
downstream file access leave the plugin search dirs. Comparison is by
|
||||
name, deliberately without symlink resolution (dev plugins are
|
||||
commonly symlinked into plugins/).
|
||||
"""
|
||||
wanted_name = Path(os.path.normpath(str(plugin_dir))).name
|
||||
for search_dir in get_search_dirs():
|
||||
search_dir_str = str(search_dir)
|
||||
try:
|
||||
with os.scandir(search_dir_str) as entries:
|
||||
for entry in entries:
|
||||
if entry.name == wanted_name and entry.is_dir():
|
||||
return Path(search_dir_str) / entry.name
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_render_request(data):
|
||||
"""Shared /api/render* request prep. Returns (plugin_dir, manifest, config,
|
||||
mock_data, skip_update) or raises ValueError with a client message."""
|
||||
plugin_id = data['plugin_id']
|
||||
candidate_dir = find_plugin_dir(plugin_id)
|
||||
# Never reuse `candidate_dir` past this point: it's built from
|
||||
# request-derived input, and a variable reassigned only on some paths
|
||||
# isn't a barrier CodeQL's flow analysis honors. `trusted_dir` is the
|
||||
# sole name used below, always the scandir-sourced result.
|
||||
trusted_dir = _trusted_plugin_dir(candidate_dir) if candidate_dir else None
|
||||
if not trusted_dir:
|
||||
raise LookupError(f'Plugin not found: {plugin_id}')
|
||||
|
||||
manifest_path = trusted_dir / 'manifest.json'
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config: schema defaults + user overrides
|
||||
config = {'enabled': True}
|
||||
config.update(load_config_defaults(trusted_dir))
|
||||
config.update(data.get('config', {}))
|
||||
|
||||
return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
|
||||
|
||||
|
||||
@app.route('/api/render', methods=['POST'])
|
||||
def api_render():
|
||||
"""Render a plugin and return the display as base64 PNG."""
|
||||
@@ -269,6 +183,11 @@ def api_render():
|
||||
if not data or 'plugin_id' not in data:
|
||||
return jsonify({'error': 'plugin_id is required'}), 400
|
||||
|
||||
plugin_id = data['plugin_id']
|
||||
user_config = data.get('config', {})
|
||||
mock_data = data.get('mock_data', {})
|
||||
skip_update = data.get('skip_update', False)
|
||||
|
||||
try:
|
||||
width = int(data.get('width', 128))
|
||||
height = int(data.get('height', 32))
|
||||
@@ -280,77 +199,78 @@ def api_render():
|
||||
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
||||
|
||||
try:
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
||||
except Exception:
|
||||
# Bad manifest.json / schema / fixture — details go to the dev's
|
||||
# console, not the HTTP response
|
||||
app.logger.exception('render request preparation failed')
|
||||
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
||||
# Find plugin
|
||||
plugin_dir = find_plugin_dir(plugin_id)
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
# Load manifest
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config: schema defaults + user overrides
|
||||
config_defaults = load_config_defaults(plugin_dir)
|
||||
config = {'enabled': True}
|
||||
config.update(config_defaults)
|
||||
config.update(user_config)
|
||||
|
||||
# Create display manager and mocks
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
# Load plugin
|
||||
loader = PluginLoader()
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
|
||||
mock_data, width, height, skip_update)
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during render')
|
||||
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
||||
return jsonify(result)
|
||||
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=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Failed to load plugin: {e}'}), 500
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
@app.route('/api/sizes')
|
||||
def api_sizes():
|
||||
"""The representative panel-size sample the safety harness renders at."""
|
||||
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||
return jsonify({'sizes': [list(s) for s in DEFAULT_TEST_SIZES]})
|
||||
|
||||
|
||||
MAX_MATRIX_SIZES = 12
|
||||
|
||||
|
||||
@app.route('/api/render-matrix', methods=['POST'])
|
||||
def api_render_matrix():
|
||||
"""Render a plugin at a list of sizes (default: the harness sample) so the
|
||||
UI can show a side-by-side multi-resolution gallery."""
|
||||
data = request.get_json()
|
||||
if not data or 'plugin_id' not in data:
|
||||
return jsonify({'error': 'plugin_id is required'}), 400
|
||||
|
||||
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||
sizes = data.get('sizes') or [list(s) for s in DEFAULT_TEST_SIZES]
|
||||
if len(sizes) > MAX_MATRIX_SIZES:
|
||||
return jsonify({'error': f'at most {MAX_MATRIX_SIZES} sizes per request'}), 400
|
||||
parsed_sizes = []
|
||||
for pair in sizes:
|
||||
# Run update()
|
||||
if not skip_update:
|
||||
try:
|
||||
w, h = int(pair[0]), int(pair[1])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return jsonify({'error': f'invalid size entry {pair!r} (expected [w, h])'}), 400
|
||||
if not (MIN_WIDTH <= w <= MAX_WIDTH and MIN_HEIGHT <= h <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'size {w}x{h} out of bounds'}), 400
|
||||
parsed_sizes.append((w, h))
|
||||
plugin_instance.update()
|
||||
except Exception as e:
|
||||
warnings.append(f"update() raised: {e}")
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
||||
except Exception:
|
||||
app.logger.exception('render request preparation failed')
|
||||
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
||||
plugin_instance.display(force_clear=True)
|
||||
except Exception as e:
|
||||
errors.append(f"display() raised: {e}")
|
||||
|
||||
results = []
|
||||
for w, h in parsed_sizes:
|
||||
try:
|
||||
results.append(_render_once(data['plugin_id'], plugin_dir, manifest,
|
||||
config, mock_data, w, h, skip_update))
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during %dx%d render', w, h)
|
||||
results.append({'image': None, 'width': w, 'height': h,
|
||||
'render_time_ms': 0,
|
||||
'errors': ['Failed to load plugin; see server log'],
|
||||
'warnings': []})
|
||||
return jsonify({'results': results})
|
||||
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
||||
|
||||
return jsonify({
|
||||
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'render_time_ms': render_time_ms,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Security Report Generator
|
||||
|
||||
Aggregates JSON output from all CI security audit jobs into a single
|
||||
Markdown report suitable for PR comments and artifact storage.
|
||||
|
||||
Expected artifact layout (from actions/download-artifact@v4):
|
||||
<artifact-dir>/
|
||||
sast-results/
|
||||
bandit-results.json
|
||||
semgrep-results.json
|
||||
dependency-audit-results/
|
||||
pip-audit-results.json
|
||||
safety-results.json
|
||||
secrets-scan-results/
|
||||
gitleaks-results.json
|
||||
security-proofs-results/
|
||||
security-proofs-results.json
|
||||
plugin-audit-results/
|
||||
plugin-audit-results.json
|
||||
|
||||
Usage:
|
||||
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md
|
||||
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md --verbose
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Gitleaks matches containing these strings are template placeholders, not real secrets
|
||||
_GITLEAKS_SUPPRESS = [
|
||||
"YOUR_",
|
||||
"PLACEHOLDER",
|
||||
"_HERE",
|
||||
"example.com",
|
||||
"config_secrets.template",
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load(path: Path) -> dict | list | None:
|
||||
"""Load JSON file, returning None on any error."""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, FileNotFoundError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _md_table_row(*cells: str) -> str:
|
||||
return "| " + " | ".join(str(c) for c in cells) + " |"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Per-tool summarizers
|
||||
# Returns: (markdown_lines: list[str], critical_count: int)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _summarize_bandit(artifact_dir: Path) -> tuple[list[str], int]:
|
||||
data = _load(artifact_dir / "sast-results" / "bandit-results.json")
|
||||
if data is None:
|
||||
return ["_bandit results not available_"], 0
|
||||
|
||||
results = data.get("results", [])
|
||||
high = [r for r in results if r.get("issue_severity") == "HIGH"]
|
||||
medium = [r for r in results if r.get("issue_severity") == "MEDIUM"]
|
||||
low = [r for r in results if r.get("issue_severity") == "LOW"]
|
||||
|
||||
lines = [
|
||||
f"**Bandit**: {len(high)} HIGH · {len(medium)} MEDIUM · {len(low)} LOW"
|
||||
]
|
||||
|
||||
if high:
|
||||
lines += [
|
||||
"",
|
||||
"| Severity | File | Line | Issue |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
for r in high[:10]:
|
||||
fname = Path(r.get("filename", "")).name
|
||||
lines.append(_md_table_row(
|
||||
"HIGH", f"`{fname}`",
|
||||
str(r.get("line_number", "?")),
|
||||
r.get("issue_text", "")
|
||||
))
|
||||
if len(high) > 10:
|
||||
lines.append(f"_… and {len(high) - 10} more HIGH findings_")
|
||||
|
||||
return lines, len(high)
|
||||
|
||||
|
||||
def _summarize_pip_audit(artifact_dir: Path) -> tuple[list[str], int]:
|
||||
data = _load(artifact_dir / "dependency-audit-results" / "pip-audit-results.json")
|
||||
if data is None:
|
||||
return ["_pip-audit results not available_"], 0
|
||||
|
||||
# pip-audit JSON format: {"dependencies": [{"name": ..., "vulns": [...]}]}
|
||||
vulns: list[dict] = []
|
||||
for dep in data.get("dependencies", []):
|
||||
for v in dep.get("vulns", []):
|
||||
vulns.append({"package": dep.get("name", "?"), **v})
|
||||
|
||||
lines = [f"**pip-audit**: {len(vulns)} vulnerabilities found"]
|
||||
|
||||
if vulns:
|
||||
lines += ["", "| Package | ID | Fix |", "| --- | --- | --- |"]
|
||||
for v in vulns[:10]:
|
||||
fix = v.get("fix_versions", ["none"])
|
||||
fix_str = ", ".join(fix) if fix else "none"
|
||||
lines.append(_md_table_row(
|
||||
v.get("package", "?"),
|
||||
v.get("id", "?"),
|
||||
fix_str,
|
||||
))
|
||||
|
||||
# Treat known vulnerabilities as warnings, not critical (they may be unavoidable)
|
||||
return lines, 0
|
||||
|
||||
|
||||
def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int]:
|
||||
data = _load(artifact_dir / "secrets-scan-results" / "gitleaks-results.json")
|
||||
if data is None:
|
||||
return ["_gitleaks results not available_"], 0
|
||||
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
|
||||
real_findings = []
|
||||
suppressed = 0
|
||||
for finding in data:
|
||||
secret_val = str(finding.get("Secret", "") or finding.get("Match", ""))
|
||||
if any(p in secret_val for p in _GITLEAKS_SUPPRESS):
|
||||
suppressed += 1
|
||||
else:
|
||||
real_findings.append(finding)
|
||||
|
||||
lines = [
|
||||
f"**Gitleaks**: {len(real_findings)} finding(s) "
|
||||
f"({suppressed} suppressed as template placeholders)"
|
||||
]
|
||||
|
||||
if real_findings:
|
||||
lines += ["", "| Rule | File | Line | Description |", "| --- | --- | --- | --- |"]
|
||||
for f in real_findings[:10]:
|
||||
fname = Path(f.get("File", "")).name
|
||||
lines.append(_md_table_row(
|
||||
f.get("RuleID", "?"),
|
||||
f"`{fname}`",
|
||||
str(f.get("StartLine", "?")),
|
||||
f.get("Description", ""),
|
||||
))
|
||||
|
||||
critical = len(real_findings) # any real secret is critical
|
||||
return lines, critical
|
||||
|
||||
|
||||
def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int]:
|
||||
data = _load(artifact_dir / "security-proofs-results" / "security-proofs-results.json")
|
||||
if data is None:
|
||||
return ["_security proofs results not available_"], 0
|
||||
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
|
||||
critical = [r for r in data if r.get("severity") == "CRITICAL"]
|
||||
warnings = [r for r in data if r.get("severity") == "WARNING"]
|
||||
passed = [r for r in data if r.get("severity") == "PASS"]
|
||||
skipped = [r for r in data if r.get("severity") == "SKIP"]
|
||||
|
||||
lines = [
|
||||
f"**Security Proofs**: "
|
||||
f"{len(passed)} PASS · {len(warnings)} WARN · "
|
||||
f"{len(critical)} CRITICAL · {len(skipped)} SKIP",
|
||||
"",
|
||||
]
|
||||
|
||||
_icon = {"PASS": "✅", "INFO": "ℹ️", "WARNING": "⚠️",
|
||||
"CRITICAL": "🚨", "SKIP": "⏭️"}
|
||||
for r in data:
|
||||
icon = _icon.get(r.get("severity", ""), "❓")
|
||||
lines.append(
|
||||
f"- {icon} **{r.get('test_id', '?')}**: {r.get('message', '')}"
|
||||
)
|
||||
if r.get("details") and r.get("severity") in ("CRITICAL", "WARNING"):
|
||||
lines.append(f" - _{r['details']}_")
|
||||
|
||||
return lines, len(critical)
|
||||
|
||||
|
||||
def _summarize_plugin_audit(artifact_dir: Path) -> tuple[list[str], int]:
|
||||
data = _load(artifact_dir / "plugin-audit-results" / "plugin-audit-results.json")
|
||||
if data is None:
|
||||
return ["_plugin audit results not available_"], 0
|
||||
|
||||
summary = data.get("summary", {})
|
||||
findings = data.get("findings", [])
|
||||
critical_findings = [f for f in findings if f.get("severity") == "CRITICAL"]
|
||||
warning_findings = [f for f in findings if f.get("severity") == "WARNING"]
|
||||
|
||||
lines = [
|
||||
f"**Plugin Audit**: {data.get('plugins_scanned', '?')} plugins scanned — "
|
||||
f"{summary.get('critical', 0)} CRITICAL · {summary.get('warnings', 0)} WARNINGS"
|
||||
]
|
||||
|
||||
if critical_findings:
|
||||
lines += ["", "| Plugin | File | Line | Rule | Message |",
|
||||
"| --- | --- | --- | --- | --- |"]
|
||||
for f in critical_findings[:10]:
|
||||
fname = Path(f.get("file", "")).name
|
||||
lines.append(_md_table_row(
|
||||
f.get("plugin_id", "?"),
|
||||
f"`{fname}`",
|
||||
str(f.get("line", "?")),
|
||||
f.get("rule", "?"),
|
||||
f.get("message", ""),
|
||||
))
|
||||
|
||||
if warning_findings and not critical_findings:
|
||||
lines.append(f"\n_{len(warning_findings)} warning(s) found — see artifact for details_")
|
||||
|
||||
return lines, summary.get("critical", 0)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate consolidated security audit report",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--artifact-dir", required=True,
|
||||
help="Directory containing downloaded CI artifacts")
|
||||
parser.add_argument("--output", "-o", required=True,
|
||||
help="Output Markdown file path")
|
||||
parser.add_argument("--verbose", "-v", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
artifact_dir = Path(args.artifact_dir)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
bandit_lines, bandit_crit = _summarize_bandit(artifact_dir)
|
||||
pip_audit_lines, pip_audit_crit = _summarize_pip_audit(artifact_dir)
|
||||
gitleaks_lines, gitleaks_crit = _summarize_gitleaks(artifact_dir)
|
||||
proofs_lines, proofs_crit = _summarize_security_proofs(artifact_dir)
|
||||
plugins_lines, plugins_crit = _summarize_plugin_audit(artifact_dir)
|
||||
|
||||
total_critical = bandit_crit + pip_audit_crit + gitleaks_crit + proofs_crit + plugins_crit
|
||||
overall = "ACTION REQUIRED 🚨" if total_critical > 0 else "PASSED ✅"
|
||||
|
||||
def section(title: str, lines: list[str]) -> str:
|
||||
return f"### {title}\n\n" + "\n".join(lines) + "\n"
|
||||
|
||||
report = f"""## 🔒 Security Audit — {overall}
|
||||
|
||||
_Generated: {timestamp}_
|
||||
|
||||
| Critical | High/Warn | Overall |
|
||||
| :---: | :---: | :---: |
|
||||
| {'🚨 ' + str(total_critical) if total_critical else '✅ 0'} | ⚠️ see below | {overall} |
|
||||
|
||||
---
|
||||
|
||||
{section('SAST — Bandit', bandit_lines)}
|
||||
{section('Dependencies — pip-audit', pip_audit_lines)}
|
||||
{section('Secrets — Gitleaks', gitleaks_lines)}
|
||||
{section('LEDMatrix Security Proofs', proofs_lines)}
|
||||
{section('Plugin Security Audit', plugins_lines)}
|
||||
---
|
||||
|
||||
_Total critical findings: **{total_critical}**_
|
||||
"""
|
||||
|
||||
output_path = Path(args.output)
|
||||
output_path.write_text(report, encoding="utf-8")
|
||||
|
||||
if args.verbose:
|
||||
print(f" Report written to: {output_path}")
|
||||
print(f" Status: {overall}")
|
||||
print(f" Critical findings: {total_critical}")
|
||||
print(f" bandit={bandit_crit} pip-audit={pip_audit_crit} "
|
||||
f"gitleaks={gitleaks_crit} proofs={proofs_crit} plugins={plugins_crit}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,504 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LEDMatrix Security Proof Tests
|
||||
|
||||
Automated proofs that run in CI to verify security properties hold on every
|
||||
commit. Inspired by the Huntarr security review approach of using standard
|
||||
tooling to confirm specific vulnerability classes are absent.
|
||||
|
||||
Usage:
|
||||
python scripts/prove_security.py
|
||||
python scripts/prove_security.py --verbose
|
||||
python scripts/prove_security.py --output results.json
|
||||
|
||||
Exit code: 1 only if CRITICAL findings are detected. Warnings are reported
|
||||
but do not block CI.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Result dataclass
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
test_id: str
|
||||
severity: str # PASS | INFO | WARNING | CRITICAL | SKIP
|
||||
message: str
|
||||
details: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return {
|
||||
"PASS": "✅",
|
||||
"INFO": "ℹ️ ",
|
||||
"WARNING": "⚠️ ",
|
||||
"CRITICAL": "🚨",
|
||||
"SKIP": "⏭️ ",
|
||||
}.get(self.severity, "❓")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T1: Plugin Loading / Zip Slip
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_t1a_zip_slip_protection() -> TestResult:
|
||||
"""
|
||||
Verify that zip-slip protection exists in store_manager.py.
|
||||
|
||||
The protection lives at src/plugin_system/store_manager.py and uses
|
||||
Path.is_relative_to() to validate each zip member before extraction.
|
||||
This test confirms the guard is present — it should always pass green.
|
||||
"""
|
||||
store_manager = PROJECT_ROOT / "src" / "plugin_system" / "store_manager.py"
|
||||
if not store_manager.exists():
|
||||
return TestResult("T1a", "CRITICAL",
|
||||
"store_manager.py not found",
|
||||
f"Expected at {store_manager}")
|
||||
|
||||
content = store_manager.read_text(encoding="utf-8")
|
||||
|
||||
has_relative_to = "is_relative_to" in content
|
||||
has_log_message = "Zip-slip detected" in content
|
||||
|
||||
if not has_relative_to:
|
||||
return TestResult("T1a", "CRITICAL",
|
||||
"Zip-slip protection (is_relative_to) NOT FOUND in store_manager.py",
|
||||
"The is_relative_to() guard must be present before zipfile.extractall()")
|
||||
|
||||
if not has_log_message:
|
||||
return TestResult("T1a", "WARNING",
|
||||
"is_relative_to() found but 'Zip-slip detected' log message missing",
|
||||
"Verify the protection block is still active and the log was not removed")
|
||||
|
||||
return TestResult("T1a", "PASS",
|
||||
"Zip-slip protection verified",
|
||||
"is_relative_to() guard + 'Zip-slip detected' log present in store_manager.py")
|
||||
|
||||
|
||||
def test_t1b_dangerous_plugin_calls() -> list[TestResult]:
|
||||
"""
|
||||
Scan plugin directories for dangerous function calls (eval, exec).
|
||||
These represent arbitrary code execution risks in plugin code.
|
||||
"""
|
||||
results = []
|
||||
plugin_dirs = [
|
||||
PROJECT_ROOT / "plugins",
|
||||
PROJECT_ROOT / "plugin-repos",
|
||||
]
|
||||
|
||||
violations: list[str] = []
|
||||
files_scanned = 0
|
||||
|
||||
for base in plugin_dirs:
|
||||
if not base.exists():
|
||||
continue
|
||||
for plugin_dir in sorted(base.iterdir()):
|
||||
if not plugin_dir.is_dir() or plugin_dir.name.startswith(('.', '_')):
|
||||
continue
|
||||
for py_file in plugin_dir.rglob("*.py"):
|
||||
files_scanned += 1
|
||||
try:
|
||||
source = py_file.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(py_file))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
if node.func.id in ("eval", "exec"):
|
||||
rel = py_file.relative_to(PROJECT_ROOT)
|
||||
violations.append(
|
||||
f"{rel}:{node.lineno} — {node.func.id}() call")
|
||||
except (SyntaxError, OSError):
|
||||
pass
|
||||
|
||||
if violations:
|
||||
results.append(TestResult(
|
||||
"T1b", "CRITICAL",
|
||||
f"Dangerous function calls found in plugins ({len(violations)} instance(s))",
|
||||
"; ".join(violations[:10])
|
||||
))
|
||||
else:
|
||||
results.append(TestResult(
|
||||
"T1b", "PASS",
|
||||
f"No eval()/exec() calls found in plugins",
|
||||
f"{files_scanned} plugin Python files scanned"
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T2: API Surface Inventory
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_t2a_api_surface_inventory() -> TestResult:
|
||||
"""
|
||||
Document the API surface area.
|
||||
|
||||
This app intentionally has no authentication (local-only Raspberry Pi
|
||||
design, documented in web_interface/app.py). This test produces an
|
||||
inventory for audit purposes and warns only if the design-intent comment
|
||||
is removed from app.py (which would indicate someone deleted the rationale
|
||||
without adding auth, rather than a deliberate undocumented change).
|
||||
"""
|
||||
api_file = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
|
||||
app_file = PROJECT_ROOT / "web_interface" / "app.py"
|
||||
|
||||
if not api_file.exists():
|
||||
return TestResult("T2a", "WARNING", "api_v3.py not found", str(api_file))
|
||||
|
||||
api_content = api_file.read_text(encoding="utf-8")
|
||||
routes = re.findall(r"@api_v3\.route\('([^']+)'", api_content)
|
||||
|
||||
csrf_documented = False
|
||||
if app_file.exists():
|
||||
app_content = app_file.read_text(encoding="utf-8")
|
||||
csrf_documented = "CSRF protection disabled for local-only" in app_content
|
||||
|
||||
summary = (
|
||||
f"{len(routes)} API routes in api_v3.py. "
|
||||
f"No auth decorators (intentional local-only design). "
|
||||
f"CSRF disabled: {'YES — design intent documented in app.py' if csrf_documented else 'YES — but design intent comment NOT found in app.py'}. "
|
||||
f"Rate limiting: 1000/min."
|
||||
)
|
||||
|
||||
if not csrf_documented:
|
||||
return TestResult(
|
||||
"T2a", "WARNING",
|
||||
"CSRF is disabled but the design-intent comment is missing from app.py",
|
||||
"Add the rationale comment back, or add proper CSRF protection if "
|
||||
"the app is now internet-facing"
|
||||
)
|
||||
|
||||
return TestResult("T2a", "INFO", "API surface documented", summary)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T3: Secrets & Credential Handling
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Patterns that suggest real credentials (must be >8 chars, not placeholders)
|
||||
_SECRET_PATTERNS = [
|
||||
(r'(?i)password\s*=\s*["\'](?!none|empty|placeholder|example|test|default|""|'')[^"\']{8,}["\']', "WARNING"),
|
||||
(r'(?i)api[_-]?key\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING"),
|
||||
(r'(?i)secret\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING"),
|
||||
# Real GitHub token pattern
|
||||
(r'ghp_[a-zA-Z0-9]{36}', "CRITICAL"),
|
||||
# Generic long bearer tokens
|
||||
(r'Bearer\s+[a-zA-Z0-9\-_\.]{32,}', "WARNING"),
|
||||
]
|
||||
|
||||
_TEMPLATE_SKIP_STRINGS = [
|
||||
"YOUR_", "PLACEHOLDER", "_HERE", "example.com", "config_secrets.template",
|
||||
"prove_security", # this file itself
|
||||
]
|
||||
|
||||
_SCAN_DIRS = ["src", "web_interface", "scripts"]
|
||||
|
||||
|
||||
def test_t3a_hardcoded_secrets() -> TestResult:
|
||||
"""Scan source code for hardcoded credentials."""
|
||||
violations: list[str] = []
|
||||
|
||||
for dir_name in _SCAN_DIRS:
|
||||
scan_dir = PROJECT_ROOT / dir_name
|
||||
if not scan_dir.exists():
|
||||
continue
|
||||
for py_file in scan_dir.rglob("*.py"):
|
||||
# Skip test files and this script
|
||||
if "test" in str(py_file).lower() or "prove_security" in str(py_file):
|
||||
continue
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for pattern, severity in _SECRET_PATTERNS:
|
||||
for match in re.finditer(pattern, content):
|
||||
line_content = match.group(0)
|
||||
# Skip lines containing template placeholder strings
|
||||
if any(skip in line_content for skip in _TEMPLATE_SKIP_STRINGS):
|
||||
continue
|
||||
rel = py_file.relative_to(PROJECT_ROOT)
|
||||
line_no = content[: match.start()].count("\n") + 1
|
||||
violations.append(
|
||||
f"[{severity}] {rel}:{line_no} — {line_content[:60]}"
|
||||
)
|
||||
|
||||
critical_violations = [v for v in violations if "[CRITICAL]" in v]
|
||||
if critical_violations:
|
||||
return TestResult(
|
||||
"T3a", "CRITICAL",
|
||||
f"Hardcoded secrets found ({len(critical_violations)} critical)",
|
||||
"; ".join(critical_violations[:5])
|
||||
)
|
||||
if violations:
|
||||
return TestResult(
|
||||
"T3a", "WARNING",
|
||||
f"Potential hardcoded secrets found ({len(violations)} instance(s))",
|
||||
"; ".join(violations[:5])
|
||||
)
|
||||
|
||||
return TestResult("T3a", "PASS", "No hardcoded secrets detected",
|
||||
f"Scanned {', '.join(_SCAN_DIRS)}")
|
||||
|
||||
|
||||
def test_t3b_plaintext_password_storage() -> TestResult:
|
||||
"""
|
||||
Check for user account password storage without hashing.
|
||||
|
||||
The LEDMatrix app has no user account system, so this should produce INFO.
|
||||
It would only CRITICAL if someone added user auth and stored passwords without hashing.
|
||||
|
||||
We require all three of: a password *variable assignment or DB operation*,
|
||||
a clear storage call (INSERT / db commit / ORM save), and no hashing lib present
|
||||
— to avoid false positives from files that contain 'password' for WiFi handling
|
||||
and '.save()' for image/file saving in unrelated functions.
|
||||
"""
|
||||
hashing_libs = ["bcrypt", "argon2", "pbkdf2", "scrypt",
|
||||
"generate_password_hash", "hashpw", "make_password"]
|
||||
# Patterns that indicate password being stored in a database / ORM context.
|
||||
# Must be specific enough to avoid matching set.add(), file.save(), etc.
|
||||
db_storage_patterns = ["INSERT INTO", "db.session", "session.add(", "session.commit(", "orm.save"]
|
||||
|
||||
password_storage_found = False
|
||||
|
||||
for dir_name in _SCAN_DIRS:
|
||||
scan_dir = PROJECT_ROOT / dir_name
|
||||
if not scan_dir.exists():
|
||||
continue
|
||||
for py_file in scan_dir.rglob("*.py"):
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
# Require DB/ORM context specifically — not just any .save() call
|
||||
if ("password" in content.lower() and
|
||||
any(store in content for store in db_storage_patterns) and
|
||||
not any(h in content for h in hashing_libs)):
|
||||
password_storage_found = True
|
||||
|
||||
if password_storage_found:
|
||||
return TestResult(
|
||||
"T3b", "CRITICAL",
|
||||
"Potential plaintext password storage in database/ORM detected",
|
||||
"Found password + database storage operations without a recognized hashing library"
|
||||
)
|
||||
|
||||
return TestResult("T3b", "INFO",
|
||||
"No plaintext password storage detected",
|
||||
"App has no user account system — expected result")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T4: Path Traversal
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_t4a_path_traversal() -> TestResult:
|
||||
"""
|
||||
Verify static file serving uses send_from_directory (safe) rather than
|
||||
open() with user-supplied paths. Also checks for extractall() calls that
|
||||
lack the is_relative_to() guard.
|
||||
"""
|
||||
issues: list[str] = []
|
||||
|
||||
app_file = PROJECT_ROOT / "web_interface" / "app.py"
|
||||
if app_file.exists():
|
||||
content = app_file.read_text(encoding="utf-8")
|
||||
# The file-serve route should use send_from_directory or commonpath
|
||||
if "send_from_directory" not in content and "commonpath" not in content:
|
||||
issues.append("app.py: file-serve routes may not use send_from_directory/commonpath")
|
||||
|
||||
# Check all extractall() calls have a preceding is_relative_to guard
|
||||
for py_file in (PROJECT_ROOT / "src").rglob("*.py"):
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
if "extractall(" in content and "is_relative_to" not in content:
|
||||
rel = py_file.relative_to(PROJECT_ROOT)
|
||||
issues.append(f"{rel}: extractall() without is_relative_to() guard")
|
||||
|
||||
if issues:
|
||||
return TestResult(
|
||||
"T4a", "WARNING",
|
||||
f"Potential path traversal patterns found ({len(issues)})",
|
||||
"; ".join(issues)
|
||||
)
|
||||
|
||||
return TestResult("T4a", "PASS",
|
||||
"Path traversal mitigations verified",
|
||||
"send_from_directory/commonpath used for file serving; "
|
||||
"extractall() calls have is_relative_to() guards")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T5: Auth Bypass Patterns
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_t5a_auth_bypass_patterns() -> TestResult:
|
||||
"""
|
||||
Look for broken auth bypass patterns — not the intentional no-auth design
|
||||
(T2a covers that), but patterns that suggest auth was INTENDED to exist
|
||||
but has an exploitable bypass: broad substring matching, debug-mode skips,
|
||||
or if-True conditions.
|
||||
"""
|
||||
bypass_signals = [
|
||||
(r'if\s+True\s*:', "if True: bypass"),
|
||||
(r'if\s+debug\s*:', "debug-mode auth skip"),
|
||||
(r'request\.path\s+in\s+', "substring path matching in auth (Huntarr pattern)"),
|
||||
(r'EXEMPT_ROUTES\s*=', "exempt routes list"),
|
||||
]
|
||||
|
||||
findings: list[str] = []
|
||||
|
||||
for dir_name in ["src", "web_interface"]:
|
||||
scan_dir = PROJECT_ROOT / dir_name
|
||||
if not scan_dir.exists():
|
||||
continue
|
||||
for py_file in scan_dir.rglob("*.py"):
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
for pattern, label in bypass_signals:
|
||||
if re.search(pattern, content):
|
||||
# Only flag if the file also contains auth-related terms
|
||||
if any(auth in content.lower() for auth in
|
||||
["auth", "login", "authenticate", "token", "permission"]):
|
||||
rel = py_file.relative_to(PROJECT_ROOT)
|
||||
findings.append(f"{rel}: {label}")
|
||||
|
||||
if findings:
|
||||
return TestResult(
|
||||
"T5a", "WARNING",
|
||||
f"Potential auth bypass patterns found ({len(findings)})",
|
||||
"; ".join(findings[:5])
|
||||
)
|
||||
|
||||
return TestResult("T5a", "PASS",
|
||||
"No auth bypass patterns detected",
|
||||
"Checked src/ and web_interface/ for bypass signals")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T6: Docker / Container Hardening
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_t6_docker_hardening() -> TestResult:
|
||||
"""Container security — skipped if no Dockerfile exists."""
|
||||
dockerfile = PROJECT_ROOT / "Dockerfile"
|
||||
if not dockerfile.exists():
|
||||
return TestResult("T6", "SKIP",
|
||||
"No Dockerfile found — container security scan not applicable",
|
||||
"If Docker support is added in future, enable hadolint/trivy scanning "
|
||||
"in .github/workflows/security-audit.yml")
|
||||
|
||||
content = dockerfile.read_text(encoding="utf-8")
|
||||
issues: list[str] = []
|
||||
|
||||
# Check for non-root USER directive
|
||||
user_lines = [l for l in content.splitlines() if l.strip().startswith("USER")]
|
||||
if not user_lines or user_lines[-1].strip() == "USER root":
|
||||
issues.append("Container runs as root — use USER directive to drop privileges")
|
||||
|
||||
# Check for pinned base image tags
|
||||
from_lines = [l for l in content.splitlines() if l.strip().startswith("FROM")]
|
||||
for from_line in from_lines:
|
||||
parts = from_line.split()
|
||||
if len(parts) >= 2:
|
||||
image = parts[1]
|
||||
if ":" not in image or image.endswith(":latest"):
|
||||
issues.append(f"Unpinned base image: {image}")
|
||||
|
||||
if issues:
|
||||
return TestResult("T6", "WARNING",
|
||||
f"Dockerfile hardening issues ({len(issues)})",
|
||||
"; ".join(issues))
|
||||
|
||||
return TestResult("T6", "PASS", "Dockerfile hardening checks passed", "")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Runner
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="LEDMatrix security proof tests",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--output", "-o", default=None,
|
||||
help="Write JSON results to this file")
|
||||
parser.add_argument("--verbose", "-v", action="store_true",
|
||||
help="Show details for each check")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("LEDMatrix Security Proof Tests")
|
||||
print(f"Project root: {PROJECT_ROOT}")
|
||||
print("=" * 60)
|
||||
|
||||
all_results: list[TestResult] = []
|
||||
|
||||
# Run all test groups
|
||||
all_results.append(test_t1a_zip_slip_protection())
|
||||
all_results.extend(test_t1b_dangerous_plugin_calls())
|
||||
all_results.append(test_t2a_api_surface_inventory())
|
||||
all_results.append(test_t3a_hardcoded_secrets())
|
||||
all_results.append(test_t3b_plaintext_password_storage())
|
||||
all_results.append(test_t4a_path_traversal())
|
||||
all_results.append(test_t5a_auth_bypass_patterns())
|
||||
all_results.append(test_t6_docker_hardening())
|
||||
|
||||
# Print results
|
||||
print()
|
||||
for r in all_results:
|
||||
line = f" {r.icon} [{r.severity:<8}] {r.test_id}: {r.message}"
|
||||
print(line)
|
||||
if args.verbose and r.details:
|
||||
print(f" {r.details}")
|
||||
|
||||
# Tally
|
||||
critical = [r for r in all_results if r.severity == "CRITICAL"]
|
||||
warnings = [r for r in all_results if r.severity == "WARNING"]
|
||||
passed = [r for r in all_results if r.severity == "PASS"]
|
||||
skipped = [r for r in all_results if r.severity == "SKIP"]
|
||||
|
||||
print()
|
||||
print(f" Results: {len(passed)} PASS {len(warnings)} WARN "
|
||||
f"{len(critical)} CRITICAL {len(skipped)} SKIP")
|
||||
|
||||
# Write JSON output
|
||||
if args.output:
|
||||
output_data = [r.to_dict() for r in all_results]
|
||||
Path(args.output).write_text(
|
||||
json.dumps(output_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(f" Results written to: {args.output}")
|
||||
|
||||
if critical:
|
||||
print(f"\n 🚨 {len(critical)} CRITICAL issue(s) found — blocking")
|
||||
return 1
|
||||
|
||||
if warnings:
|
||||
print(f"\n ⚠️ {len(warnings)} warning(s) found — non-blocking")
|
||||
|
||||
print("\n ✅ All checks passed (warnings are non-blocking)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -209,11 +209,6 @@
|
||||
onchange="onConfigChange()">
|
||||
<span class="text-xs ml-2" style="color: var(--text-secondary);">px</span>
|
||||
</div>
|
||||
<select id="sizePreset" onchange="applySizePreset()"
|
||||
class="w-full mt-2 px-2 py-1.5 rounded text-xs"
|
||||
style="background: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
||||
<option value="">Preset sizes…</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Config form -->
|
||||
@@ -247,18 +242,13 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Render buttons -->
|
||||
<!-- Render button -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="renderPlugin()" id="renderBtn"
|
||||
class="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style="background: var(--accent);">
|
||||
Render
|
||||
</button>
|
||||
<button onclick="renderAllSizes()" id="renderAllBtn" title="Render at every harness test size"
|
||||
class="px-4 py-2.5 rounded-lg text-sm font-medium"
|
||||
style="background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color);">
|
||||
All Sizes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -321,15 +311,6 @@
|
||||
<div id="messagesPanel" class="panel p-3 hidden">
|
||||
<div id="messagesList" class="text-xs font-mono space-y-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-size gallery -->
|
||||
<div id="galleryPanel" class="panel p-4 hidden">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs font-medium" style="color: var(--text-secondary);">All Sizes</span>
|
||||
<span class="text-xs" style="color: var(--text-secondary);" id="galleryStatus"></span>
|
||||
</div>
|
||||
<div id="galleryGrid" class="flex flex-wrap gap-4 items-start"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -359,30 +340,8 @@
|
||||
opt.textContent = `${p.name} (${p.id})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
// Load harness size presets
|
||||
try {
|
||||
const sizesRes = await fetch('/api/sizes');
|
||||
const sizesData = await sizesRes.json();
|
||||
const preset = document.getElementById('sizePreset');
|
||||
(sizesData.sizes || []).forEach(([w, h]) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `${w}x${h}`;
|
||||
opt.textContent = `${w} x ${h}`;
|
||||
preset.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* presets are a convenience; ignore */ }
|
||||
});
|
||||
|
||||
function applySizePreset() {
|
||||
const value = document.getElementById('sizePreset').value;
|
||||
if (!value) return;
|
||||
const [w, h] = value.split('x');
|
||||
document.getElementById('displayWidth').value = w;
|
||||
document.getElementById('displayHeight').value = h;
|
||||
onConfigChange();
|
||||
}
|
||||
|
||||
// ---------- Plugin selection ----------
|
||||
async function onPluginChange() {
|
||||
const pluginId = document.getElementById('pluginSelect').value;
|
||||
@@ -526,89 +485,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Multi-size gallery ----------
|
||||
async function renderAllSizes() {
|
||||
if (!currentPluginId) return;
|
||||
|
||||
const btn = document.getElementById('renderAllBtn');
|
||||
const panel = document.getElementById('galleryPanel');
|
||||
const grid = document.getElementById('galleryGrid');
|
||||
const status = document.getElementById('galleryStatus');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Rendering…';
|
||||
panel.classList.remove('hidden');
|
||||
grid.innerHTML = '';
|
||||
status.textContent = 'Rendering at all harness sizes…';
|
||||
|
||||
const config = jsonEditor ? jsonEditor.getValue() : {};
|
||||
config.enabled = true;
|
||||
let mockData = {};
|
||||
const mockInput = document.getElementById('mockDataInput').value.trim();
|
||||
if (mockInput) {
|
||||
try { mockData = JSON.parse(mockInput); }
|
||||
catch (e) { showMessages([], [`Mock data JSON error: ${e.message}`]); }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/render-matrix', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plugin_id: currentPluginId,
|
||||
config: config,
|
||||
mock_data: mockData,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
status.textContent = data.error;
|
||||
return;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
(data.results || []).forEach(r => {
|
||||
const cell = document.createElement('div');
|
||||
cell.style.cssText = 'display:flex;flex-direction:column;gap:4px;';
|
||||
const failed = (r.errors || []).length > 0 || !r.image;
|
||||
if (failed) failures++;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'text-xs font-mono';
|
||||
label.style.color = failed ? '#f87171' : 'var(--text-secondary)';
|
||||
label.textContent = `${r.width}x${r.height} · ${r.render_time_ms}ms`;
|
||||
cell.appendChild(label);
|
||||
|
||||
if (r.image) {
|
||||
const img = document.createElement('img');
|
||||
img.src = r.image;
|
||||
// Small panels get 2x zoom so they stay legible in the grid
|
||||
const zoom = r.height >= 128 ? 1 : 2;
|
||||
img.style.cssText =
|
||||
`image-rendering: pixelated; width:${r.width * zoom}px; ` +
|
||||
`height:${r.height * zoom}px; ` +
|
||||
`border:1px solid ${failed ? '#f87171' : 'var(--border-color)'};`;
|
||||
cell.appendChild(img);
|
||||
}
|
||||
if (failed) {
|
||||
const err = document.createElement('span');
|
||||
err.className = 'text-xs font-mono';
|
||||
err.style.color = '#f87171';
|
||||
err.textContent = (r.errors || ['render failed']).join('; ');
|
||||
cell.appendChild(err);
|
||||
}
|
||||
grid.appendChild(cell);
|
||||
});
|
||||
status.textContent = failures
|
||||
? `${failures} size(s) failed`
|
||||
: `${(data.results || []).length} sizes rendered`;
|
||||
} catch (e) {
|
||||
status.textContent = `Network error: ${e.message}`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'All Sizes';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Zoom ----------
|
||||
function updateZoom() {
|
||||
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
||||
|
||||
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.1.0"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
"""
|
||||
Adaptive image fitting for plugins — the image counterpart to
|
||||
src/adaptive_layout.py's text fitting.
|
||||
|
||||
Promotes the proven in-field image patterns into one shared helper so
|
||||
plugins stop hand-copying resize/cache code:
|
||||
|
||||
- "crop transparent padding, then fill the row height" (football/hockey
|
||||
logo pattern) -> ``crop_to_ink=True, mode="fill_height"``
|
||||
- "crop-to-fill with a top anchor for faces" (masters-tournament headshot
|
||||
pattern) -> ``mode="cover", anchor="top"``
|
||||
- "letterbox to fit, centered on a background" (static-image pattern)
|
||||
-> ``mode="contain"``
|
||||
- NEAREST for pixel art/flags vs LANCZOS for photos (masters flag pattern)
|
||||
-> ``resample=RESAMPLE_NEAREST``
|
||||
|
||||
Unlike PIL's ``thumbnail()`` (downscale-only — the reason plugin imagery
|
||||
stays tiny on big panels), ``fit_image`` upscales by default so content
|
||||
genuinely adapts to larger displays; pass ``upscale=False`` for the old
|
||||
behavior.
|
||||
|
||||
Use via ``LayoutContext.fit_image(...)`` (cached per panel size) or
|
||||
``BasePlugin.draw_image(...)``; the module-level functions are the
|
||||
uncached primitives.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# The one Pillow >= 9.1 compat shim (replaces the per-plugin copies).
|
||||
try:
|
||||
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
|
||||
RESAMPLE_NEAREST = Image.Resampling.NEAREST
|
||||
except AttributeError: # Pillow < 9.1
|
||||
RESAMPLE_LANCZOS = Image.LANCZOS
|
||||
RESAMPLE_NEAREST = Image.NEAREST
|
||||
|
||||
FIT_MODES = ("contain", "cover", "fill_height", "stretch")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageFitResult:
|
||||
"""A processed RGBA copy of a source image, sized for a target box."""
|
||||
image: Image.Image
|
||||
width: int
|
||||
height: int
|
||||
scale: float # scale applied vs the (possibly ink-cropped) source
|
||||
mode: str
|
||||
source_size: Tuple[int, int]
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.width <= 0 or self.height <= 0
|
||||
|
||||
|
||||
_EMPTY_IMAGE = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
|
||||
|
||||
|
||||
def _empty_result(mode: str, source_size: Tuple[int, int]) -> ImageFitResult:
|
||||
return ImageFitResult(_EMPTY_IMAGE, 0, 0, 0.0, mode, source_size)
|
||||
|
||||
|
||||
def _box_dims(box: Any) -> Tuple[int, int]:
|
||||
"""Accept a Region (duck-typed .w/.h) or a (w, h) tuple."""
|
||||
if hasattr(box, "w") and hasattr(box, "h"):
|
||||
return (int(box.w), int(box.h))
|
||||
w, h = box
|
||||
return (int(w), int(h))
|
||||
|
||||
|
||||
def fit_image(img: Image.Image, box: Any, *, mode: str = "contain",
|
||||
crop_to_ink: bool = False, anchor: str = "center",
|
||||
resample: Any = None, upscale: bool = True) -> ImageFitResult:
|
||||
"""Fit an image into a box, preserving crispness policy per content type.
|
||||
|
||||
Args:
|
||||
img: Source PIL image (any mode; output is always RGBA).
|
||||
box: Region or (w, h) target box.
|
||||
mode: "contain" (letterbox), "cover" (crop-to-fill),
|
||||
"fill_height" (height == box height, contain-capped by width),
|
||||
"stretch" (exact resize).
|
||||
crop_to_ink: Trim fully-transparent padding (getbbox) before fitting —
|
||||
logos shipped with generous padding otherwise render small.
|
||||
anchor: For "cover" crops: "center" or "top" (keeps faces/tops).
|
||||
resample: PIL resampling filter; defaults to RESAMPLE_LANCZOS.
|
||||
Use RESAMPLE_NEAREST for pixel art, flags, and sprite icons.
|
||||
upscale: Allow scaling above source size (default True — the adaptive
|
||||
point). False mimics the legacy thumbnail() behavior.
|
||||
"""
|
||||
if mode not in FIT_MODES:
|
||||
raise ValueError(f"Unknown fit mode '{mode}' (expected one of {FIT_MODES})")
|
||||
box_w, box_h = _box_dims(box)
|
||||
if box_w <= 0 or box_h <= 0 or img.width <= 0 or img.height <= 0:
|
||||
return _empty_result(mode, img.size)
|
||||
|
||||
resample = RESAMPLE_LANCZOS if resample is None else resample
|
||||
|
||||
work = img if img.mode == "RGBA" else img.convert("RGBA")
|
||||
if crop_to_ink:
|
||||
bbox = work.getbbox()
|
||||
if bbox is None: # fully transparent
|
||||
return _empty_result(mode, img.size)
|
||||
work = work.crop(bbox)
|
||||
|
||||
src_w, src_h = work.size
|
||||
|
||||
if mode == "stretch":
|
||||
out = work.resize((box_w, box_h), resample)
|
||||
return ImageFitResult(out, box_w, box_h, box_w / src_w, mode, (src_w, src_h))
|
||||
|
||||
if mode == "cover":
|
||||
scale = max(box_w / src_w, box_h / src_h)
|
||||
if not upscale:
|
||||
scale = min(scale, 1.0)
|
||||
scaled_w = max(1, round(src_w * scale))
|
||||
scaled_h = max(1, round(src_h * scale))
|
||||
out = work.resize((scaled_w, scaled_h), resample)
|
||||
# Crop the overhang down to the box (only when the scaled image is
|
||||
# larger; with upscale=False it may be smaller and is left as-is).
|
||||
crop_w, crop_h = min(box_w, scaled_w), min(box_h, scaled_h)
|
||||
left = (scaled_w - crop_w) // 2
|
||||
top = 0 if anchor == "top" else (scaled_h - crop_h) // 2
|
||||
out = out.crop((left, top, left + crop_w, top + crop_h))
|
||||
return ImageFitResult(out, out.width, out.height, scale, mode, (src_w, src_h))
|
||||
|
||||
# contain / fill_height share the "preserve aspect, no crop" path
|
||||
if mode == "fill_height":
|
||||
scale = box_h / src_h
|
||||
# contain-cap: never exceed the box width (football's logo_slot rule)
|
||||
scale = min(scale, box_w / src_w)
|
||||
else: # contain
|
||||
scale = min(box_w / src_w, box_h / src_h)
|
||||
if not upscale:
|
||||
scale = min(scale, 1.0)
|
||||
out_w = max(1, round(src_w * scale))
|
||||
out_h = max(1, round(src_h * scale))
|
||||
if (out_w, out_h) == (src_w, src_h):
|
||||
# No resize needed — but `work` may still BE the caller's original
|
||||
# image (RGBA source, no ink crop). The result must always be an
|
||||
# independent copy: LayoutContext caches ImageFitResults, and an
|
||||
# aliased image would let later mutations of the source corrupt
|
||||
# cached fits (or vice versa).
|
||||
out = work.copy() if work is img else work
|
||||
else:
|
||||
out = work.resize((out_w, out_h), resample)
|
||||
return ImageFitResult(out, out_w, out_h, scale, mode, (src_w, src_h))
|
||||
|
||||
|
||||
def draw_fitted_image(display_manager: Any, ifit: ImageFitResult, box: Any, *,
|
||||
align: str = "center", valign: str = "center",
|
||||
offset: Tuple[int, int] = (0, 0)) -> Optional[Tuple[int, int]]:
|
||||
"""Paste a fitted image aligned within a Region onto the display canvas.
|
||||
|
||||
Pastes with the image's own alpha mask. Returns the (x, y) actually used
|
||||
so callers can position adjacent decorations, or None when nothing was
|
||||
drawn (empty fit / no canvas).
|
||||
"""
|
||||
if ifit is None or ifit.is_empty:
|
||||
return None
|
||||
image = getattr(display_manager, "image", None)
|
||||
if image is None:
|
||||
return None
|
||||
if hasattr(box, "align_xy"):
|
||||
x, y = box.align_xy(ifit.width, ifit.height, align, valign)
|
||||
else:
|
||||
box_w, box_h = _box_dims(box)
|
||||
x = (box_w - ifit.width) // 2
|
||||
y = (box_h - ifit.height) // 2
|
||||
x += int(offset[0])
|
||||
y += int(offset[1])
|
||||
image.paste(ifit.image, (x, y), ifit.image)
|
||||
return (x, y)
|
||||
@@ -1,746 +0,0 @@
|
||||
"""
|
||||
Adaptive layout and font scaling helpers for plugins.
|
||||
|
||||
Generalizes the three size-adaptation patterns proven in the plugin
|
||||
ecosystem into small composable core helpers, so plugins render legibly on
|
||||
any panel size (64x32, 128x32, 96x48, 128x64, 256x64, ...) without
|
||||
hand-tuned per-display layouts:
|
||||
|
||||
- Region: integer rect algebra (bands, columns, weighted splits, centering).
|
||||
Regions partition space, so text bands can't overlap by construction —
|
||||
replacing the magic ``y = 1`` / ``y = height - 7`` offsets tuned for 128x32.
|
||||
- Font ladders: ordered (family, size) steps known to render crisply.
|
||||
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes,
|
||||
so fonts are never scaled continuously — fitting walks a ladder from the
|
||||
largest rung down until the measured text fits the target box. This is
|
||||
baseball-scoreboard's fallback-ladder pattern promoted to core.
|
||||
- LayoutContext: per-(width, height) facts — breakpoint tiers
|
||||
(masters-tournament's pattern), a geometry scale factor vs. a declared
|
||||
design size (f1-scoreboard's pattern), and cached fit-text queries.
|
||||
|
||||
Everything is opt-in: plugins get a context via ``self.layout`` on
|
||||
BasePlugin (or construct one directly) and existing plugins are unaffected.
|
||||
|
||||
Fonts are resolved through FontManager's catalog (family names are
|
||||
lowercased file stems from assets/fonts, e.g. "9x15", "tom-thumb", plus
|
||||
aliases like "press_start"). FitResult.font is a plain PIL font or
|
||||
freetype.Face, so it drops straight into DisplayManager.draw_text().
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
import freetype
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Height-based breakpoint tiers, smallest to largest. A 32px-tall panel is
|
||||
# the ecosystem baseline ("sm"); 96x48 lands in "md"; 128x64 in "lg".
|
||||
_HEIGHT_TIERS: Tuple[Tuple[str, int], ...] = (
|
||||
("xs", 16), ("sm", 32), ("md", 48), ("lg", 64), ("xl", 10 ** 9),
|
||||
)
|
||||
TIER_ORDER: Tuple[str, ...] = tuple(name for name, _ in _HEIGHT_TIERS)
|
||||
|
||||
_WIDTH_TIERS: Tuple[Tuple[str, int], ...] = (
|
||||
("narrow", 64), ("normal", 128), ("wide", 256), ("ultrawide", 10 ** 9),
|
||||
)
|
||||
WIDTH_TIER_ORDER: Tuple[str, ...] = tuple(name for name, _ in _WIDTH_TIERS)
|
||||
|
||||
# The panel size most existing plugins were authored against.
|
||||
DEFAULT_DESIGN_SIZE: Tuple[int, int] = (128, 32)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Region:
|
||||
"""An integer rectangle. Carving methods return sub-Regions clamped to
|
||||
non-negative dimensions, so degenerate panels never produce negative
|
||||
boxes — a band request larger than the region simply consumes it all."""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
w: int
|
||||
h: int
|
||||
|
||||
def __post_init__(self):
|
||||
object.__setattr__(self, "w", max(0, int(self.w)))
|
||||
object.__setattr__(self, "h", max(0, int(self.h)))
|
||||
object.__setattr__(self, "x", int(self.x))
|
||||
object.__setattr__(self, "y", int(self.y))
|
||||
|
||||
@property
|
||||
def right(self) -> int:
|
||||
return self.x + self.w
|
||||
|
||||
@property
|
||||
def bottom(self) -> int:
|
||||
return self.y + self.h
|
||||
|
||||
@property
|
||||
def center(self) -> Tuple[int, int]:
|
||||
return (self.x + self.w // 2, self.y + self.h // 2)
|
||||
|
||||
# ---- carving -----------------------------------------------------
|
||||
|
||||
def inset(self, dx: int, dy: Optional[int] = None) -> "Region":
|
||||
"""Shrink by dx horizontally and dy (default dx) vertically, each side."""
|
||||
if dy is None:
|
||||
dy = dx
|
||||
return Region(self.x + dx, self.y + dy, self.w - 2 * dx, self.h - 2 * dy)
|
||||
|
||||
def offset(self, dx: int, dy: int) -> "Region":
|
||||
"""Translate without resizing — the hook for user x/y-offset
|
||||
customization: compute regions first, then apply the user's
|
||||
configured offsets as a final translation."""
|
||||
return Region(self.x + dx, self.y + dy, self.w, self.h)
|
||||
|
||||
def top_band(self, h: int) -> "Region":
|
||||
return Region(self.x, self.y, self.w, min(h, self.h))
|
||||
|
||||
def bottom_band(self, h: int) -> "Region":
|
||||
h = min(h, self.h)
|
||||
return Region(self.x, self.bottom - h, self.w, h)
|
||||
|
||||
def middle(self, top_h: int = 0, bottom_h: int = 0) -> "Region":
|
||||
"""What remains between a top band and a bottom band."""
|
||||
return Region(self.x, self.y + top_h, self.w, self.h - top_h - bottom_h)
|
||||
|
||||
def left_col(self, w: int) -> "Region":
|
||||
return Region(self.x, self.y, min(w, self.w), self.h)
|
||||
|
||||
def right_col(self, w: int) -> "Region":
|
||||
w = min(w, self.w)
|
||||
return Region(self.right - w, self.y, w, self.h)
|
||||
|
||||
def split_h(self, *weights: float, gap: int = 0) -> List["Region"]:
|
||||
"""Side-by-side columns sized by weight; gaps between them."""
|
||||
sizes = _weighted_sizes(self.w, weights, gap)
|
||||
cols, cursor = [], self.x
|
||||
for size in sizes:
|
||||
cols.append(Region(cursor, self.y, size, self.h))
|
||||
cursor += size + gap
|
||||
return cols
|
||||
|
||||
def split_v(self, *weights: float, gap: int = 0) -> List["Region"]:
|
||||
"""Stacked rows sized by weight; gaps between them."""
|
||||
sizes = _weighted_sizes(self.h, weights, gap)
|
||||
rows, cursor = [], self.y
|
||||
for size in sizes:
|
||||
rows.append(Region(self.x, cursor, self.w, size))
|
||||
cursor += size + gap
|
||||
return rows
|
||||
|
||||
# ---- placement ---------------------------------------------------
|
||||
|
||||
def align_xy(self, w: int, h: int, align: str = "center",
|
||||
valign: str = "center") -> Tuple[int, int]:
|
||||
"""Top-left position for a w x h box aligned within this region.
|
||||
align: left|center|right; valign: top|center|bottom."""
|
||||
if align == "left":
|
||||
x = self.x
|
||||
elif align == "right":
|
||||
x = self.right - w
|
||||
else:
|
||||
x = self.x + (self.w - w) // 2
|
||||
if valign == "top":
|
||||
y = self.y
|
||||
elif valign == "bottom":
|
||||
y = self.bottom - h
|
||||
else:
|
||||
y = self.y + (self.h - h) // 2
|
||||
return (x, y)
|
||||
|
||||
def center_xy(self, w: int, h: int) -> Tuple[int, int]:
|
||||
return self.align_xy(w, h)
|
||||
|
||||
def contains(self, w: int, h: int) -> bool:
|
||||
return w <= self.w and h <= self.h
|
||||
|
||||
|
||||
def _weighted_sizes(total: int, weights: Sequence[float], gap: int) -> List[int]:
|
||||
"""Integer sizes proportional to weights, remainder spread left-to-right."""
|
||||
if not weights:
|
||||
return []
|
||||
usable = max(0, total - gap * (len(weights) - 1))
|
||||
weight_sum = sum(weights) or 1
|
||||
sizes = [int(usable * w / weight_sum) for w in weights]
|
||||
remainder = usable - sum(sizes)
|
||||
for i in range(remainder):
|
||||
sizes[i % len(sizes)] += 1
|
||||
return sizes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Font ladders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FontStep:
|
||||
"""One rung: a FontManager catalog family at a size it renders crisply."""
|
||||
family: str
|
||||
size_px: int
|
||||
|
||||
|
||||
FontLadder = Tuple[FontStep, ...]
|
||||
|
||||
# X11 BDF bitmap fonts at their native pixel sizes, largest to smallest —
|
||||
# baseball-scoreboard's fallback ladder extended upward. Same-height rungs
|
||||
# are ordered widest first so width-constrained text steps to a narrower
|
||||
# face before dropping a size.
|
||||
LADDER_GRID: FontLadder = (
|
||||
FontStep("10x20", 20),
|
||||
FontStep("9x18", 18),
|
||||
FontStep("9x15", 15),
|
||||
FontStep("8x13", 13),
|
||||
FontStep("7x13", 13),
|
||||
FontStep("6x13", 13),
|
||||
FontStep("6x12", 12),
|
||||
FontStep("6x10", 10),
|
||||
FontStep("6x9", 9),
|
||||
FontStep("5x8", 8),
|
||||
FontStep("5x7", 7),
|
||||
FontStep("4x6", 6),
|
||||
FontStep("tom-thumb", 6),
|
||||
)
|
||||
|
||||
# PressStart2P at integer multiples of its 8px pixel grid only — fractional
|
||||
# sizes blur a pixel font. For headline text (clocks, scores).
|
||||
LADDER_ARCADE: FontLadder = (
|
||||
FontStep("press_start", 32),
|
||||
FontStep("press_start", 24),
|
||||
FontStep("press_start", 16),
|
||||
FontStep("press_start", 8),
|
||||
)
|
||||
|
||||
LADDER_DEFAULT: FontLadder = LADDER_GRID
|
||||
|
||||
ELLIPSIS = "…"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FitResult:
|
||||
"""A fitted font plus the ink metrics of the (possibly ellipsized) text.
|
||||
|
||||
``y_offset`` is the gap between the y passed to draw_text() and where
|
||||
ink actually starts; subtract it from the desired ink-top position when
|
||||
drawing (draw_fitted_text does this for you).
|
||||
"""
|
||||
font: Any
|
||||
family: str
|
||||
size_px: int
|
||||
text: str
|
||||
width: int
|
||||
height: int
|
||||
baseline: int
|
||||
y_offset: int
|
||||
fits: bool
|
||||
line_height: int = 0
|
||||
|
||||
|
||||
def measure_ink(text: str, font: Any) -> Tuple[int, int, int, int]:
|
||||
"""Measure the ink box of text: (width, height, baseline, y_offset).
|
||||
|
||||
y_offset is the distance from the y coordinate DisplayManager.draw_text()
|
||||
is given to the top of the actual ink — PIL draws TTF from the em-box
|
||||
top and _draw_bdf_text derives the baseline from y + ascender, so both
|
||||
leave a font-dependent gap that matters when centering in short bands.
|
||||
"""
|
||||
if isinstance(font, freetype.Face):
|
||||
width = 0
|
||||
ascender = font.size.ascender >> 6
|
||||
ink_top, ink_bottom = None, None
|
||||
for char in text:
|
||||
font.load_char(char)
|
||||
width += font.glyph.advance.x >> 6
|
||||
rows = font.glyph.bitmap.rows
|
||||
if rows:
|
||||
top = ascender - font.glyph.bitmap_top
|
||||
ink_top = top if ink_top is None else min(ink_top, top)
|
||||
ink_bottom = top + rows if ink_bottom is None else max(ink_bottom, top + rows)
|
||||
if ink_top is None:
|
||||
ink_top, ink_bottom = 0, 0
|
||||
return (width, ink_bottom - ink_top, ascender, ink_top)
|
||||
bbox = font.getbbox(text)
|
||||
return (bbox[2] - bbox[0], bbox[3] - bbox[1], -bbox[1], bbox[1])
|
||||
|
||||
|
||||
def font_line_height(font: Any) -> int:
|
||||
"""Recommended line spacing for a font (matches DisplayManager.get_font_height)."""
|
||||
if isinstance(font, freetype.Face):
|
||||
return font.size.height >> 6
|
||||
ascent, descent = font.getmetrics()
|
||||
return ascent + descent
|
||||
|
||||
|
||||
def measure_font_crispness(font: Any, sample_text: str = "Ay0",
|
||||
canvas_size: Tuple[int, int] = (250, 60)) -> float:
|
||||
"""Fraction of the rendered sample's ink-bbox pixels that are neither
|
||||
pure black nor pure white — i.e. antialiased.
|
||||
|
||||
BDF (freetype.Face) glyphs are true bitmaps and always render at 0.0.
|
||||
"Pixel-style" TTFs (PressStart2P, and similar fonts bundled for
|
||||
plugins that draw through ImageDraw.text() and so can't take a BDF
|
||||
face) are NOT automatically crisp at arbitrary sizes — PIL antialiases
|
||||
TTF outlines by default, and a pixel-grid font only lands on whole
|
||||
pixels at specific sizes (for PressStart2P: exact multiples of 8).
|
||||
Requesting an unverified size silently produces soft/blurry glyphs on
|
||||
an LED panel, which reads as fuzzy compared to a true BDF rung.
|
||||
|
||||
Use this to vet any custom FontLadder rung that mixes TTF fonts before
|
||||
shipping it — see test_adaptive_layout.py::test_ladder_is_crisp for the
|
||||
pattern. A rung should score 0.0 (or very close, to allow for the odd
|
||||
diagonal stroke) before it belongs in a "crisp" ladder.
|
||||
"""
|
||||
if isinstance(font, freetype.Face):
|
||||
return 0.0
|
||||
from PIL import Image, ImageDraw
|
||||
img = Image.new("L", canvas_size, 0)
|
||||
ImageDraw.Draw(img).text((2, 2), sample_text, font=font, fill=255)
|
||||
bbox = img.getbbox()
|
||||
if bbox is None:
|
||||
return 0.0
|
||||
pixels = img.crop(bbox).tobytes()
|
||||
pure = sum(1 for p in pixels if p == 0 or p == 255)
|
||||
return (len(pixels) - pure) / len(pixels)
|
||||
|
||||
|
||||
class LayoutContext:
|
||||
"""Per-render-size layout facts and fit-text queries for one panel size.
|
||||
|
||||
Construct once per (width, height); BasePlugin.layout does this and
|
||||
rebuilds automatically when the logical display size changes.
|
||||
"""
|
||||
|
||||
def __init__(self, width: int, height: int, font_manager: Any,
|
||||
design_size: Tuple[int, int] = DEFAULT_DESIGN_SIZE):
|
||||
self.width = int(width)
|
||||
self.height = int(height)
|
||||
self.font_manager = font_manager
|
||||
self.design_size = design_size
|
||||
self.bounds = Region(0, 0, self.width, self.height)
|
||||
self.aspect = self.width / max(1, self.height)
|
||||
self.tier = _pick_tier(_HEIGHT_TIERS, self.height)
|
||||
self.width_tier = _pick_tier(_WIDTH_TIERS, self.width)
|
||||
self.is_wide_short = self.aspect >= 2.5 and self.height <= 32
|
||||
design_w, design_h = design_size
|
||||
# Geometry scale only (gaps, icon/logo sizes) — never applied to
|
||||
# fonts, which step between crisp ladder rungs instead.
|
||||
self.scale = min(self.width / max(1, design_w),
|
||||
self.height / max(1, design_h))
|
||||
# LRU-bounded: entries are small, but keys embed the fitted TEXT —
|
||||
# a plugin fitting changing text (a live game clock, a ticker) on a
|
||||
# 24/7 service would otherwise grow this without bound.
|
||||
self._fit_cache: "OrderedDict[Any, FitResult]" = OrderedDict()
|
||||
# LRU-bounded (images are big). Entries hold a strong reference to
|
||||
# the source image when keyed by id() so the id can't be recycled
|
||||
# out from under the cache.
|
||||
self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict()
|
||||
|
||||
_IMAGE_CACHE_MAX = 64
|
||||
_FIT_CACHE_MAX = 512
|
||||
|
||||
def _fit_cache_get(self, key: Any) -> Optional["FitResult"]:
|
||||
cached = self._fit_cache.get(key)
|
||||
if cached is not None:
|
||||
self._fit_cache.move_to_end(key)
|
||||
return cached
|
||||
|
||||
def _fit_cache_put(self, key: Any, result: "FitResult") -> None:
|
||||
self._fit_cache[key] = result
|
||||
while len(self._fit_cache) > self._FIT_CACHE_MAX:
|
||||
self._fit_cache.popitem(last=False)
|
||||
|
||||
# ---- the three adaptation patterns --------------------------------
|
||||
|
||||
def px(self, base: int, minimum: int = 1, maximum: Optional[int] = None) -> int:
|
||||
"""Scale a design-size pixel measurement (f1's pattern): gaps,
|
||||
icon sizes, logo slots. Clamped to [minimum, maximum]."""
|
||||
value = max(minimum, round(base * self.scale))
|
||||
if maximum is not None:
|
||||
value = min(value, maximum)
|
||||
return value
|
||||
|
||||
def by_tier(self, mapping: Dict[str, Any], default: Any = None) -> Any:
|
||||
"""Pick the value for the nearest defined tier at-or-below the
|
||||
panel's height tier (masters' pattern). Falls forward to the
|
||||
smallest defined tier above, then to default.
|
||||
|
||||
by_tier({"sm": 10, "lg": 18}) -> 10 on 128x32, 18 on 128x64.
|
||||
Keys may also use width tiers ("narrow", "wide", ...)."""
|
||||
order = TIER_ORDER if any(k in TIER_ORDER for k in mapping) else WIDTH_TIER_ORDER
|
||||
current = self.tier if order is TIER_ORDER else self.width_tier
|
||||
idx = order.index(current)
|
||||
for name in reversed(order[: idx + 1]):
|
||||
if name in mapping:
|
||||
return mapping[name]
|
||||
for name in order[idx + 1:]:
|
||||
if name in mapping:
|
||||
return mapping[name]
|
||||
return default
|
||||
|
||||
def fit_text(self, text: str, box: Union[Region, Tuple[int, int]],
|
||||
ladder: FontLadder = LADDER_DEFAULT,
|
||||
ellipsis: bool = True) -> FitResult:
|
||||
"""Largest ladder rung whose rendered text fits the box (baseball's
|
||||
pattern). If even the smallest rung is too wide, the text is
|
||||
ellipsized to fit (unless ellipsis=False); fits=False only when no
|
||||
acceptable rendering exists."""
|
||||
box_w, box_h = _box_dims(box)
|
||||
key = ("text", text, box_w, box_h, ladder, ellipsis)
|
||||
cached = self._fit_cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = self._walk_ladder(text, ladder, box_w, box_h, ellipsis)
|
||||
self._fit_cache_put(key, result)
|
||||
return result
|
||||
|
||||
def fit_text_proportional(self, text: str, box: Union[Region, Tuple[int, int]],
|
||||
base_size_px: int, ladder: FontLadder = LADDER_DEFAULT,
|
||||
ellipsis: bool = True,
|
||||
scale: Optional[float] = None) -> FitResult:
|
||||
"""Ladder rung closest to (but not exceeding) ``base_size_px * scale``
|
||||
that still fits the box — proportional sizing instead of ``fit_text``'s
|
||||
"always maximize" behavior.
|
||||
|
||||
Use this when several independently-fitted elements need to stay
|
||||
visually harmonious as the panel grows (e.g. a scoreboard's score,
|
||||
status, and detail text) — ``fit_text`` maximizes each one within
|
||||
its own region, which can make one element balloon out of
|
||||
proportion to its neighbors (a huge score overlapping logos it fit
|
||||
fine at the design size) even though every individual pick is
|
||||
independently "correct". ``base_size_px`` is the size that element
|
||||
renders at on the design size (``design_size``, typically 128x32)
|
||||
— commonly a plugin's existing classic/fixed font size for that
|
||||
element.
|
||||
|
||||
``scale`` defaults to ``self.scale`` (the same conservative
|
||||
min(width_ratio, height_ratio) factor ``px()`` uses — safe for
|
||||
content whose aspect ratio matters). Pass an explicit axis-specific
|
||||
value when the surrounding composition already scales that way —
|
||||
e.g. a scoreboard whose logos scale with height alone
|
||||
(``logo_slot = min(height, width // 2)``) should size its score
|
||||
text by ``height / design_height`` too, or its text will look
|
||||
under-scaled next to bigger logos on a panel that only grew taller.
|
||||
|
||||
Falls back to the smallest rung when even that exceeds the target
|
||||
(a tiny scale factor), and to fit_text's ordinary smaller-rung
|
||||
fallback when the closest-to-target rung doesn't actually fit the
|
||||
box.
|
||||
"""
|
||||
box_w, box_h = _box_dims(box)
|
||||
effective_scale = self.scale if scale is None else scale
|
||||
key = ("text_prop", text, box_w, box_h, ladder, base_size_px, ellipsis, effective_scale)
|
||||
cached = self._fit_cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
target = base_size_px * effective_scale
|
||||
eligible = [step for step in ladder if step.size_px <= target]
|
||||
candidates = eligible if eligible else (min(ladder, key=lambda s: s.size_px),)
|
||||
result = self._walk_ladder(text, candidates, box_w, box_h, ellipsis)
|
||||
self._fit_cache_put(key, result)
|
||||
return result
|
||||
|
||||
def _walk_ladder(self, text: str, ladder: Sequence[FontStep],
|
||||
box_w: int, box_h: int, ellipsis: bool) -> FitResult:
|
||||
"""Shared by fit_text/fit_text_proportional: first ladder entry (in
|
||||
the order given) whose rendered text fits, ellipsizing the last one
|
||||
tried if none do."""
|
||||
result = None
|
||||
for step in ladder:
|
||||
font = self.font_manager.get_font(step.family, step.size_px)
|
||||
width, height, baseline, y_offset = measure_ink(text, font)
|
||||
result = FitResult(font, step.family, step.size_px, text,
|
||||
width, height, baseline, y_offset,
|
||||
fits=(width <= box_w and height <= box_h),
|
||||
line_height=font_line_height(font))
|
||||
if result.fits:
|
||||
break
|
||||
|
||||
if result is not None and not result.fits and ellipsis:
|
||||
short = self.ellipsize(text, result.font, box_w)
|
||||
width, height, baseline, y_offset = measure_ink(short, result.font)
|
||||
result = FitResult(result.font, result.family, result.size_px,
|
||||
short, width, height, baseline, y_offset,
|
||||
fits=(width <= box_w and height <= box_h),
|
||||
line_height=result.line_height)
|
||||
return result
|
||||
|
||||
def fit_lines(self, lines: Sequence[str], box: Union[Region, Tuple[int, int]],
|
||||
ladder: FontLadder = LADDER_DEFAULT,
|
||||
spacing: int = 1) -> FitResult:
|
||||
"""Largest rung where every line fits the box width and the stacked
|
||||
lines (line_height + spacing apart) fit the box height. Measures the
|
||||
actual strings, so a long line pushes the ladder down a rung a short
|
||||
one wouldn't (baseball's multiline pattern). Text is the widest line."""
|
||||
box_w, box_h = _box_dims(box)
|
||||
key = ("lines", tuple(lines), box_w, box_h, ladder, spacing)
|
||||
cached = self._fit_cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
rows = max(1, len(lines))
|
||||
result = None
|
||||
for step in ladder:
|
||||
font = self.font_manager.get_font(step.family, step.size_px)
|
||||
line_h = font_line_height(font)
|
||||
widest, metrics = "", (0, 0, 0, 0)
|
||||
for line in lines:
|
||||
m = measure_ink(line, font)
|
||||
if m[0] >= metrics[0]:
|
||||
widest, metrics = line, m
|
||||
total_h = rows * line_h + (rows - 1) * spacing
|
||||
result = FitResult(font, step.family, step.size_px, widest,
|
||||
metrics[0], metrics[1], metrics[2], metrics[3],
|
||||
fits=(metrics[0] <= box_w and total_h <= box_h),
|
||||
line_height=line_h)
|
||||
if result.fits:
|
||||
break
|
||||
|
||||
self._fit_cache_put(key, result)
|
||||
return result
|
||||
|
||||
def font_for_rows(self, rows: int, box_h: int,
|
||||
ladder: FontLadder = LADDER_GRID) -> FitResult:
|
||||
"""Largest rung whose line height lets `rows` rows fit in box_h
|
||||
(baseball's traditional-scoreboard pattern). Measures a digit/cap
|
||||
sample rather than specific strings."""
|
||||
key = ("rows", rows, box_h, ladder)
|
||||
cached = self._fit_cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
sample = "0Ay"
|
||||
result = None
|
||||
for step in ladder:
|
||||
font = self.font_manager.get_font(step.family, step.size_px)
|
||||
line_h = font_line_height(font)
|
||||
width, height, baseline, y_offset = measure_ink(sample, font)
|
||||
result = FitResult(font, step.family, step.size_px, sample,
|
||||
width, height, baseline, y_offset,
|
||||
fits=(max(1, rows) * line_h <= box_h),
|
||||
line_height=line_h)
|
||||
if result.fits:
|
||||
break
|
||||
|
||||
self._fit_cache_put(key, result)
|
||||
return result
|
||||
|
||||
# ---- images ---------------------------------------------------------
|
||||
|
||||
def fit_image(self, img: Any, box: Union[Region, Tuple[int, int]], *,
|
||||
mode: str = "contain", crop_to_ink: bool = False,
|
||||
anchor: str = "center", resample: Any = None,
|
||||
upscale: bool = True, cache_key: Any = None) -> Any:
|
||||
"""Fit an image into a box (see src/adaptive_images.py for modes),
|
||||
cached per (image, box size, options) for this panel size.
|
||||
|
||||
Prefer a stable ``cache_key`` (e.g. "logo:KC") for images that get
|
||||
reloaded — the default id()-based key is safe (the entry pins the
|
||||
source image) but misses across reloads of the same content.
|
||||
"""
|
||||
from src.adaptive_images import fit_image as _fit_image
|
||||
|
||||
box_w, box_h = _box_dims(box)
|
||||
resample_name = getattr(resample, "name", repr(resample)) if resample is not None else "default"
|
||||
identity = cache_key if cache_key is not None else ("id", id(img))
|
||||
key = ("image", identity, img.size, box_w, box_h, mode,
|
||||
crop_to_ink, anchor, resample_name, upscale)
|
||||
|
||||
cached = self._image_cache.get(key)
|
||||
if cached is not None:
|
||||
self._image_cache.move_to_end(key)
|
||||
return cached[0]
|
||||
|
||||
result = _fit_image(img, (box_w, box_h), mode=mode,
|
||||
crop_to_ink=crop_to_ink, anchor=anchor,
|
||||
resample=resample, upscale=upscale)
|
||||
# Pin the source only for id()-keyed entries (see docstring).
|
||||
self._image_cache[key] = (result, img if cache_key is None else None)
|
||||
while len(self._image_cache) > self._IMAGE_CACHE_MAX:
|
||||
self._image_cache.popitem(last=False)
|
||||
return result
|
||||
|
||||
# ---- text utilities ------------------------------------------------
|
||||
|
||||
def ellipsize(self, text: str, font: Any, max_w: int) -> str:
|
||||
"""Trim text to fit max_w, appending an ellipsis. Returns '' when
|
||||
not even the ellipsis fits."""
|
||||
if measure_ink(text, font)[0] <= max_w:
|
||||
return text
|
||||
for end in range(len(text) - 1, 0, -1):
|
||||
candidate = text[:end].rstrip() + ELLIPSIS
|
||||
if measure_ink(candidate, font)[0] <= max_w:
|
||||
return candidate
|
||||
return ELLIPSIS if measure_ink(ELLIPSIS, font)[0] <= max_w else ""
|
||||
|
||||
def measure(self, text: str, font: Any) -> Tuple[int, int, int]:
|
||||
"""Ink (width, height, baseline) of text — see measure_ink."""
|
||||
width, height, baseline, _ = measure_ink(text, font)
|
||||
return (width, height, baseline)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Drop cached fit results (call after fonts are reloaded)."""
|
||||
self._fit_cache.clear()
|
||||
self._image_cache.clear()
|
||||
|
||||
|
||||
def _pick_tier(tiers: Tuple[Tuple[str, int], ...], value: int) -> str:
|
||||
for name, limit in tiers:
|
||||
if value <= limit:
|
||||
return name
|
||||
return tiers[-1][0]
|
||||
|
||||
|
||||
def _box_dims(box: Union[Region, Tuple[int, int]]) -> Tuple[int, int]:
|
||||
if isinstance(box, Region):
|
||||
return (box.w, box.h)
|
||||
w, h = box
|
||||
return (int(w), int(h))
|
||||
|
||||
|
||||
def draw_fitted_text(display_manager: Any, fit: FitResult,
|
||||
box: Union[Region, Tuple[int, int]],
|
||||
color: Tuple[int, int, int] = (255, 255, 255),
|
||||
align: str = "center", valign: str = "center") -> None:
|
||||
"""Draw a FitResult's text aligned within a Region via
|
||||
DisplayManager.draw_text(), compensating for the font's ink offset so
|
||||
the ink (not the em box) is what gets aligned."""
|
||||
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
||||
x, y = region.align_xy(fit.width, fit.height, align, valign)
|
||||
display_manager.draw_text(fit.text, x=x, y=y - fit.y_offset,
|
||||
color=color, font=fit.font)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Composite layouts — the region arrangements repeated across plugins,
|
||||
# expressed as Region math so migrated plugins stop hand-copying coordinate
|
||||
# formulas. Deliberately tiny: these return Regions, they don't draw.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreboardRegions:
|
||||
"""The two-logos-plus-center-score card shared by the sports plugins."""
|
||||
bounds: Region
|
||||
logo_slot: int # width of each logo slot: min(H, W // 2), center-reserved
|
||||
away_slot: Region # left logo slot
|
||||
home_slot: Region # right logo slot
|
||||
center_col: Region # column between the slots (>= min_center_fraction of width)
|
||||
status_band: Region # top band (replaces the magic y = 1)
|
||||
score_area: Region # center_col's true width, between the bands (replaces y = H//2 - 3)
|
||||
detail_band: Region # bottom band (replaces the magic y = H - 7)
|
||||
bottom_left: Region # bottom corner: away records / timeouts
|
||||
bottom_right: Region # bottom corner: home records / timeouts
|
||||
|
||||
|
||||
def scoreboard_regions(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
|
||||
status_h: Optional[int] = None,
|
||||
detail_h: Optional[int] = None,
|
||||
min_center_fraction: float = 0.15,
|
||||
min_center_design_px: int = 40,
|
||||
score_bleed_fraction: float = 0.5) -> ScoreboardRegions:
|
||||
"""Carve a game-card Region into the standard scoreboard arrangement.
|
||||
|
||||
Encodes the invariant duplicated across the sports plugins:
|
||||
``logo_slot = min(height, width // 2)`` (capped at half the card so the
|
||||
home slot never collapses), away logo centered in the left slot, home in
|
||||
the right.
|
||||
|
||||
That formula alone has a blind spot: at exactly 2:1 aspect ratio
|
||||
(width == 2 * height — a very common shape, e.g. two, four, or more
|
||||
square modules stacked into a taller panel) ``width // 2`` and
|
||||
``height`` are equal, so the two logo slots claim the *entire* width
|
||||
and leave zero pixels for a center column, no matter how large the
|
||||
panel gets. It isn't a "small panel" problem: 96x48, 128x64, and
|
||||
256x128 (all exactly 2:1) hit it identically, while wide panels like
|
||||
the 128x32 design baseline or a 192x48/256x32 panel never do, because
|
||||
height is already the tighter constraint there.
|
||||
|
||||
Two knobs fix it, both defaulted to values verified against the full
|
||||
harness size spread (see test_adaptive_layout.py::TestScoreboardRegions):
|
||||
|
||||
- ``min_center_fraction`` / ``min_center_design_px`` reserve at least
|
||||
``max(width * min_center_fraction, min_center_design_px * ctx.scale)``
|
||||
for the center column, capping ``logo_slot`` further when needed. The
|
||||
design-px term (scaled by the context's geometry factor, so it grows
|
||||
on bigger panels like everything else in ``px()``) matters most on
|
||||
small panels where a flat fraction alone reserves too little absolute
|
||||
space for even a short score string. On wide panels the height
|
||||
constraint already leaves more room than either reserves, so both are
|
||||
a no-op there — 128x32/192x48-style layouts are unaffected.
|
||||
- ``score_bleed_fraction`` extends the score's own *fit box* (not the
|
||||
logo slots themselves) an extra ``logo_slot * score_bleed_fraction``
|
||||
into each side — controlled, intentional overlap with the logo art,
|
||||
the same way real broadcast scoreboards let a big score number's
|
||||
edges cross into the team marks flanking it. Without this, on a
|
||||
square-ish panel the center reserve alone can be too narrow for even
|
||||
a modest score to render without truncating (`"17-21"` -> `"17-2…"`),
|
||||
which is worse than a little overlap.
|
||||
|
||||
status_band and detail_band span the FULL card width and overlay the
|
||||
logo slots — matching the classic layouts, where short outlined status/
|
||||
date text is drawn over the logos without issue; only score_area (the
|
||||
one element whose size actively grows with the panel) uses the
|
||||
narrower, bleed-adjusted box. Band heights default to the classic
|
||||
128x32 values, scaled by the context's geometry factor when one is
|
||||
provided. Works on a full panel or on a scroll-mode card Region.
|
||||
"""
|
||||
if status_h is None:
|
||||
status_h = ctx.px(9, minimum=7) if ctx else 9
|
||||
if detail_h is None:
|
||||
detail_h = ctx.px(8, minimum=7) if ctx else 8
|
||||
|
||||
logo_slot = min(bounds.h, bounds.w // 2)
|
||||
design_reserve = int(min_center_design_px * (ctx.scale if ctx else 1.0))
|
||||
min_center_w = max(1, int(bounds.w * min_center_fraction), design_reserve)
|
||||
max_logo_slot_by_center = max(1, (bounds.w - min_center_w) // 2)
|
||||
logo_slot = min(logo_slot, max_logo_slot_by_center)
|
||||
away_slot = bounds.left_col(logo_slot)
|
||||
home_slot = bounds.right_col(logo_slot)
|
||||
center_col = Region(bounds.x + logo_slot, bounds.y,
|
||||
bounds.w - 2 * logo_slot, bounds.h)
|
||||
status_band = bounds.top_band(status_h)
|
||||
detail_band = bounds.bottom_band(detail_h)
|
||||
middle = bounds.middle(status_band.h, detail_band.h)
|
||||
# score_area is the true center gap's width plus a controlled bleed
|
||||
# into each logo slot (see score_bleed_fraction above) -- narrower than
|
||||
# the full card width status/detail get, since it's the one element
|
||||
# whose size actively grows with the panel and needs its *fit box* to
|
||||
# reflect real available space, but generous enough that a short score
|
||||
# string never has to truncate on a square-ish panel.
|
||||
bleed = int(logo_slot * score_bleed_fraction)
|
||||
score_area = Region(center_col.x - bleed, middle.y,
|
||||
center_col.w + 2 * bleed, middle.h)
|
||||
bottom = bounds.bottom_band(detail_h)
|
||||
return ScoreboardRegions(
|
||||
bounds=bounds, logo_slot=logo_slot,
|
||||
away_slot=away_slot, home_slot=home_slot, center_col=center_col,
|
||||
status_band=status_band, score_area=score_area, detail_band=detail_band,
|
||||
bottom_left=bottom.left_col(logo_slot),
|
||||
bottom_right=bottom.right_col(logo_slot),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaRow:
|
||||
"""Art/icon on the left, text column on the right (music's idiom)."""
|
||||
art: Region
|
||||
body: Region
|
||||
|
||||
|
||||
def media_row(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
|
||||
square: bool = True, gap: Optional[int] = None) -> MediaRow:
|
||||
"""Split a Region into an art slot and a body column.
|
||||
|
||||
With ``square=True`` the art slot is bounds.h wide (album-art style);
|
||||
otherwise it takes the left half. The gap defaults to 2px scaled by the
|
||||
context's geometry factor.
|
||||
"""
|
||||
if gap is None:
|
||||
gap = ctx.px(2, minimum=1) if ctx else 2
|
||||
art_w = bounds.h if square else bounds.w // 2
|
||||
art_w = min(art_w, bounds.w)
|
||||
art = bounds.left_col(art_w)
|
||||
body = Region(bounds.x + art_w + gap, bounds.y,
|
||||
bounds.w - art_w - gap, bounds.h)
|
||||
return MediaRow(art=art, body=body)
|
||||
@@ -1,134 +0,0 @@
|
||||
"""
|
||||
Background Cache Mixin for Sports Managers
|
||||
|
||||
This mixin provides common caching functionality to eliminate code duplication
|
||||
across all sports managers. It implements the background service cache pattern
|
||||
where Recent/Upcoming managers consume data from the background service cache.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
|
||||
|
||||
class BackgroundCacheMixin:
|
||||
"""
|
||||
Mixin class that provides background service cache functionality to sports managers.
|
||||
|
||||
This mixin eliminates code duplication by providing a common implementation
|
||||
for the background service cache pattern used across all sports managers.
|
||||
|
||||
Note: For non-sports managers (weather, stocks, news, etc.), use
|
||||
GenericCacheMixin instead. See src/generic_cache_mixin.py for details.
|
||||
"""
|
||||
|
||||
def _fetch_data_with_background_cache(self,
|
||||
sport_key: str,
|
||||
api_fetch_method: Callable,
|
||||
live_manager_class: type = None) -> Optional[Dict]:
|
||||
"""
|
||||
Common logic for fetching data with background service cache support.
|
||||
|
||||
This method implements the background service cache pattern:
|
||||
1. Live managers always fetch fresh data
|
||||
2. Recent/Upcoming managers try background cache first
|
||||
3. Fallback to direct API call if background data unavailable
|
||||
|
||||
Args:
|
||||
sport_key: Sport identifier (e.g., 'nba', 'nfl', 'ncaa_fb')
|
||||
api_fetch_method: Method to call for direct API fetch
|
||||
live_manager_class: Class to check if this is a live manager
|
||||
|
||||
Returns:
|
||||
Cached or fresh data from API
|
||||
"""
|
||||
start_time = time.time()
|
||||
cache_hit = False
|
||||
cache_source = None
|
||||
|
||||
try:
|
||||
# For Live managers, always fetch fresh data
|
||||
if live_manager_class and isinstance(self, live_manager_class):
|
||||
self.logger.info(f"[{sport_key.upper()}] Live manager - fetching fresh data")
|
||||
result = api_fetch_method(use_cache=False)
|
||||
cache_source = "live_fresh"
|
||||
else:
|
||||
# For Recent/Upcoming managers, try background service cache first
|
||||
cache_key = self.cache_manager.generate_sport_cache_key(sport_key)
|
||||
|
||||
# Check if background service has fresh data
|
||||
if self.cache_manager.is_background_data_available(cache_key, sport_key):
|
||||
cached_data = self.cache_manager.get_background_cached_data(cache_key, sport_key)
|
||||
if cached_data:
|
||||
self.logger.info(f"[{sport_key.upper()}] Using background service cache for {cache_key}")
|
||||
result = cached_data
|
||||
cache_hit = True
|
||||
cache_source = "background_cache"
|
||||
else:
|
||||
self.logger.warning(f"[{sport_key.upper()}] Background cache check passed but no data returned for {cache_key}")
|
||||
result = None
|
||||
cache_source = "background_miss"
|
||||
else:
|
||||
self.logger.info(f"[{sport_key.upper()}] Background data not available for {cache_key}")
|
||||
result = None
|
||||
cache_source = "background_unavailable"
|
||||
|
||||
# Fallback to direct API call if background data not available
|
||||
if result is None:
|
||||
self.logger.info(f"[{sport_key.upper()}] Fetching directly from API for {cache_key}")
|
||||
result = api_fetch_method(use_cache=True)
|
||||
cache_source = "api_fallback"
|
||||
|
||||
# Record performance metrics
|
||||
duration = time.time() - start_time
|
||||
self.cache_manager.record_fetch_time(duration)
|
||||
|
||||
# Log performance metrics
|
||||
self._log_fetch_performance(sport_key, duration, cache_hit, cache_source)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
self.logger.error(f"[{sport_key.upper()}] Error in background cache fetch after {duration:.2f}s: {e}")
|
||||
self.cache_manager.record_fetch_time(duration)
|
||||
raise
|
||||
|
||||
def _log_fetch_performance(self, sport_key: str, duration: float, cache_hit: bool, cache_source: str):
|
||||
"""
|
||||
Log detailed performance metrics for fetch operations.
|
||||
|
||||
Args:
|
||||
sport_key: Sport identifier
|
||||
duration: Fetch operation duration in seconds
|
||||
cache_hit: Whether this was a cache hit
|
||||
cache_source: Source of the data (background_cache, api_fallback, etc.)
|
||||
"""
|
||||
# Log basic performance info
|
||||
self.logger.info(f"[{sport_key.upper()}] Fetch completed in {duration:.2f}s "
|
||||
f"(cache_hit={cache_hit}, source={cache_source})")
|
||||
|
||||
# Log detailed metrics every 10 operations
|
||||
if hasattr(self, '_fetch_count'):
|
||||
self._fetch_count += 1
|
||||
else:
|
||||
self._fetch_count = 1
|
||||
|
||||
if self._fetch_count % 10 == 0:
|
||||
metrics = self.cache_manager.get_cache_metrics()
|
||||
self.logger.info(f"[{sport_key.upper()}] Cache Performance Summary - "
|
||||
f"Hit Rate: {metrics['cache_hit_rate']:.2%}, "
|
||||
f"Background Hit Rate: {metrics['background_hit_rate']:.2%}, "
|
||||
f"API Calls Saved: {metrics['api_calls_saved']}")
|
||||
|
||||
def get_cache_performance_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache performance summary for this manager.
|
||||
|
||||
Returns:
|
||||
Dictionary containing cache performance metrics
|
||||
"""
|
||||
return self.cache_manager.get_cache_metrics()
|
||||
|
||||
def log_cache_performance(self):
|
||||
"""Log current cache performance metrics."""
|
||||
self.cache_manager.log_cache_metrics()
|
||||
@@ -2,28 +2,6 @@
|
||||
|
||||
This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules.
|
||||
|
||||
## Adaptive Layout & Images (`src/adaptive_layout.py`, `src/adaptive_images.py`)
|
||||
|
||||
The recommended way to lay out plugins that render legibly on **any** panel
|
||||
size (64x32 through 256x128+) without hand-tuned coordinates. Re-exported
|
||||
from `src.common` for convenience; canonical import paths are
|
||||
`src.adaptive_layout` / `src.adaptive_images`.
|
||||
|
||||
```python
|
||||
# Every BasePlugin already has self.layout and the draw helpers:
|
||||
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
|
||||
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||
self.draw_fit(score_text, regs.score_area) # largest crisp font that fits
|
||||
self.draw_fit(status, regs.status_band)
|
||||
```
|
||||
|
||||
Key pieces: `Region` (rect algebra: bands/columns/splits/offset),
|
||||
font ladders (`LADDER_GRID`, `LADDER_ARCADE` — discrete crisp sizes, never
|
||||
fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`,
|
||||
`px`), and composite carvers `scoreboard_regions()` / `media_row()`.
|
||||
Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Error Handling (`error_handler.py`)
|
||||
|
||||
Common error handling patterns and utilities:
|
||||
|
||||
@@ -26,31 +26,6 @@ from src.common.scroll_helper import ScrollHelper
|
||||
from src.common.logo_helper import LogoHelper
|
||||
from src.common.text_helper import TextHelper
|
||||
|
||||
# Adaptive layout & images (canonical homes: src.adaptive_layout /
|
||||
# src.adaptive_images — re-exported here so plugin authors find them in the
|
||||
# blessed-helpers package). See docs/ADAPTIVE_LAYOUT.md.
|
||||
from src.adaptive_layout import (
|
||||
Region,
|
||||
LayoutContext,
|
||||
FontStep,
|
||||
FontLadder,
|
||||
LADDER_GRID,
|
||||
LADDER_ARCADE,
|
||||
FitResult,
|
||||
draw_fitted_text,
|
||||
ScoreboardRegions,
|
||||
scoreboard_regions,
|
||||
MediaRow,
|
||||
media_row,
|
||||
)
|
||||
from src.adaptive_images import (
|
||||
ImageFitResult,
|
||||
fit_image,
|
||||
draw_fitted_image,
|
||||
RESAMPLE_LANCZOS,
|
||||
RESAMPLE_NEAREST,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'handle_file_operation',
|
||||
'handle_json_operation',
|
||||
@@ -62,22 +37,4 @@ __all__ = [
|
||||
'ScrollHelper',
|
||||
'LogoHelper',
|
||||
'TextHelper',
|
||||
# adaptive layout & images
|
||||
'Region',
|
||||
'LayoutContext',
|
||||
'FontStep',
|
||||
'FontLadder',
|
||||
'LADDER_GRID',
|
||||
'LADDER_ARCADE',
|
||||
'FitResult',
|
||||
'draw_fitted_text',
|
||||
'ScoreboardRegions',
|
||||
'scoreboard_regions',
|
||||
'MediaRow',
|
||||
'media_row',
|
||||
'ImageFitResult',
|
||||
'fit_image',
|
||||
'draw_fitted_image',
|
||||
'RESAMPLE_LANCZOS',
|
||||
'RESAMPLE_NEAREST',
|
||||
]
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
"""
|
||||
Example: Basketball Plugin using LEDMatrix Common Helpers
|
||||
|
||||
This example shows how to refactor the basketball plugin to use the
|
||||
ledmatrix-common package for cleaner, more maintainable code.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# Import common helpers
|
||||
from src.common import (
|
||||
LogoHelper, TextHelper, APIHelper, DisplayHelper,
|
||||
GameHelper, ConfigHelper
|
||||
)
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
|
||||
class BasketballPluginManager(BasePlugin):
|
||||
"""
|
||||
Basketball scoreboard plugin using LEDMatrix Common helpers.
|
||||
|
||||
This version is much cleaner and more maintainable than the original
|
||||
because it delegates common functionality to the shared helpers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: Dict[str, Any],
|
||||
display_manager,
|
||||
cache_manager,
|
||||
plugin_manager
|
||||
):
|
||||
"""Initialize the basketball plugin with common helpers."""
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
|
||||
# Get display dimensions
|
||||
self.display_width = display_manager.matrix.width
|
||||
self.display_height = display_manager.matrix.height
|
||||
|
||||
# Initialize common helpers
|
||||
self._init_helpers()
|
||||
|
||||
# Load configuration
|
||||
self._load_config()
|
||||
|
||||
# State tracking
|
||||
self.current_games = []
|
||||
self.current_game = None
|
||||
|
||||
# Log initialization
|
||||
enabled_leagues = [k for k, v in self.league_configs.items() if v['enabled']]
|
||||
self.logger.info(f"Basketball plugin initialized with leagues: {enabled_leagues}")
|
||||
|
||||
def _init_helpers(self):
|
||||
"""Initialize all common helpers."""
|
||||
# Logo helper for team logos
|
||||
self.logo_helper = LogoHelper(
|
||||
display_width=self.display_width,
|
||||
display_height=self.display_height,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Text helper for rendering
|
||||
self.text_helper = TextHelper(logger=self.logger)
|
||||
self.fonts = self.text_helper.load_fonts()
|
||||
|
||||
# API helper for ESPN data
|
||||
self.api_helper = APIHelper(
|
||||
cache_manager=self.cache_manager,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Display helper for layouts
|
||||
self.display_helper = DisplayHelper(
|
||||
display_width=self.display_width,
|
||||
display_height=self.display_height,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Game helper for data processing
|
||||
self.game_helper = GameHelper(
|
||||
timezone_str=self.config.get('timezone', 'UTC'),
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Config helper for configuration management
|
||||
self.config_helper = ConfigHelper(logger=self.logger)
|
||||
|
||||
def _load_config(self):
|
||||
"""Load and validate configuration."""
|
||||
# Get basketball-specific config
|
||||
basketball_config = self.config_helper.get_sports_config(self.config, 'basketball')
|
||||
|
||||
# Build league configurations
|
||||
self.league_configs = {
|
||||
'nba': {
|
||||
'enabled': basketball_config.get('nba_enabled', True),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard',
|
||||
'logo_dir': Path('assets/sports/nba_logos'),
|
||||
'favorite_teams': basketball_config.get('nba_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'nba_live': basketball_config.get('nba_display_modes_live', True),
|
||||
'nba_recent': basketball_config.get('nba_display_modes_recent', True),
|
||||
'nba_upcoming': basketball_config.get('nba_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
'wnba': {
|
||||
'enabled': basketball_config.get('wnba_enabled', False),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard',
|
||||
'logo_dir': Path('assets/sports/wnba_logos'),
|
||||
'favorite_teams': basketball_config.get('wnba_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'wnba_live': basketball_config.get('wnba_display_modes_live', True),
|
||||
'wnba_recent': basketball_config.get('wnba_display_modes_recent', True),
|
||||
'wnba_upcoming': basketball_config.get('wnba_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
'ncaam': {
|
||||
'enabled': basketball_config.get('ncaam_basketball_enabled', False),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard',
|
||||
'logo_dir': Path('assets/sports/ncaa_logos'),
|
||||
'favorite_teams': basketball_config.get('ncaam_basketball_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'ncaam_basketball_live': basketball_config.get('ncaam_basketball_display_modes_live', True),
|
||||
'ncaam_basketball_recent': basketball_config.get('ncaam_basketball_display_modes_recent', True),
|
||||
'ncaam_basketball_upcoming': basketball_config.get('ncaam_basketball_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
'ncaaw': {
|
||||
'enabled': basketball_config.get('ncaaw_basketball_enabled', False),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard',
|
||||
'logo_dir': Path('assets/sports/ncaa_logos'),
|
||||
'favorite_teams': basketball_config.get('ncaaw_basketball_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'ncaaw_basketball_live': basketball_config.get('ncaaw_basketball_display_modes_live', True),
|
||||
'ncaaw_basketball_recent': basketball_config.get('ncaaw_basketball_display_modes_recent', True),
|
||||
'ncaaw_basketball_upcoming': basketball_config.get('ncaaw_basketball_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update game data for all enabled leagues."""
|
||||
try:
|
||||
all_games = []
|
||||
|
||||
for league_key, league_config in self.league_configs.items():
|
||||
if not league_config['enabled']:
|
||||
continue
|
||||
|
||||
games = self._fetch_league_games(league_key, league_config)
|
||||
for game in games:
|
||||
game['league_key'] = league_key
|
||||
game['league_config'] = league_config
|
||||
all_games.extend(games)
|
||||
|
||||
self.current_games = all_games
|
||||
self.logger.debug(f"Updated basketball data: {len(all_games)} total games")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating basketball data: {e}", exc_info=True)
|
||||
|
||||
def _fetch_league_games(self, league_key: str, league_config: Dict) -> List[Dict]:
|
||||
"""Fetch games for a specific league using API helper."""
|
||||
try:
|
||||
# Use API helper to fetch ESPN data with caching
|
||||
data = self.api_helper.fetch_espn_scoreboard(
|
||||
sport='basketball',
|
||||
league=league_key,
|
||||
cache_key=f"basketball_{league_key}",
|
||||
cache_ttl=300 # 5 minutes cache
|
||||
)
|
||||
|
||||
if not data:
|
||||
return []
|
||||
|
||||
# Use game helper to process events
|
||||
events = data.get('events', [])
|
||||
games = self.game_helper.process_games(events, sport='basketball')
|
||||
|
||||
# Add logo paths to games
|
||||
for game in games:
|
||||
logo_dir = league_config['logo_dir']
|
||||
game['home_logo_path'] = logo_dir / f"{game['home_abbr']}.png"
|
||||
game['away_logo_path'] = logo_dir / f"{game['away_abbr']}.png"
|
||||
|
||||
return games
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching {league_key} games: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def display(self, force_clear: bool = False, display_mode: str = None) -> None:
|
||||
"""Display basketball games using display helper."""
|
||||
try:
|
||||
mode = display_mode or self._determine_display_mode()
|
||||
|
||||
if not mode:
|
||||
self._display_no_games()
|
||||
return
|
||||
|
||||
# Filter games for mode
|
||||
filtered_games = self._filter_games_for_mode(mode)
|
||||
|
||||
if not filtered_games:
|
||||
self._display_no_games()
|
||||
return
|
||||
|
||||
# Display first game
|
||||
self.current_game = filtered_games[0]
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying game: {e}", exc_info=True)
|
||||
|
||||
def _determine_display_mode(self) -> Optional[str]:
|
||||
"""Determine display mode based on available games."""
|
||||
# Priority: live > recent > upcoming
|
||||
for game in self.current_games:
|
||||
if game.get('is_live'):
|
||||
return f"{game['league_key']}_live"
|
||||
for game in self.current_games:
|
||||
if game.get('is_final'):
|
||||
return f"{game['league_key']}_recent"
|
||||
for game in self.current_games:
|
||||
if game.get('is_upcoming'):
|
||||
return f"{game['league_key']}_upcoming"
|
||||
return None
|
||||
|
||||
def _filter_games_for_mode(self, mode: str) -> List[Dict]:
|
||||
"""Filter games based on display mode."""
|
||||
filtered = []
|
||||
|
||||
for game in self.current_games:
|
||||
league_config = game.get('league_config', {})
|
||||
display_modes = league_config.get('display_modes', {})
|
||||
|
||||
if mode in display_modes and display_modes[mode]:
|
||||
if 'live' in mode and game.get('is_live'):
|
||||
filtered.append(game)
|
||||
elif 'recent' in mode and game.get('is_final'):
|
||||
filtered.append(game)
|
||||
elif 'upcoming' in mode and game.get('is_upcoming'):
|
||||
filtered.append(game)
|
||||
|
||||
return filtered[:5]
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Draw the basketball scorebug layout using display helper."""
|
||||
try:
|
||||
# Load logos using logo helper
|
||||
home_logo = self.logo_helper.load_logo(
|
||||
game['home_abbr'],
|
||||
game['home_logo_path']
|
||||
)
|
||||
away_logo = self.logo_helper.load_logo(
|
||||
game['away_abbr'],
|
||||
game['away_logo_path']
|
||||
)
|
||||
|
||||
if not home_logo or not away_logo:
|
||||
self.logger.error("Failed to load logos")
|
||||
self._display_error("Logo Error")
|
||||
return
|
||||
|
||||
# Use display helper to create scorebug layout
|
||||
final_img = self.display_helper.draw_scorebug_layout(
|
||||
game_data=game,
|
||||
fonts=self.fonts,
|
||||
home_logo=home_logo,
|
||||
away_logo=away_logo
|
||||
)
|
||||
|
||||
# Display the image
|
||||
self.display_manager.image.paste(final_img, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error drawing scorebug: {e}", exc_info=True)
|
||||
|
||||
def _display_no_games(self) -> None:
|
||||
"""Display 'no games' message using display helper."""
|
||||
try:
|
||||
img = self.display_helper.draw_no_data_message("No Games")
|
||||
self.display_manager.image = img.copy()
|
||||
self.display_manager.update_display()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying no games: {e}", exc_info=True)
|
||||
|
||||
def _display_error(self, message: str) -> None:
|
||||
"""Display error message using display helper."""
|
||||
try:
|
||||
img = self.display_helper.draw_error_message(message)
|
||||
self.display_manager.image = img.copy()
|
||||
self.display_manager.update_display()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying error message: {e}", exc_info=True)
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
"""Get display duration."""
|
||||
return self.config.get('display_duration', 15)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup resources."""
|
||||
self.current_games = []
|
||||
self.logger.info("Basketball plugin cleaned up")
|
||||
|
||||
|
||||
# Example usage and benefits:
|
||||
"""
|
||||
Benefits of using LEDMatrix Common helpers:
|
||||
|
||||
1. **Cleaner Code**: The plugin is much shorter and more readable
|
||||
2. **Reusable Components**: Common functionality is shared across plugins
|
||||
3. **Better Testing**: Each helper can be tested independently
|
||||
4. **Easier Maintenance**: Bug fixes in helpers benefit all plugins
|
||||
5. **Consistent Behavior**: All plugins use the same underlying logic
|
||||
6. **Reduced Dependencies**: Plugins don't need to import LEDMatrix core
|
||||
7. **Better Error Handling**: Centralized error handling in helpers
|
||||
8. **Configuration Management**: Consistent config handling across plugins
|
||||
|
||||
The original basketball plugin was 326 lines. This version is much cleaner
|
||||
and delegates most functionality to the common helpers, making it easier to
|
||||
maintain and extend.
|
||||
"""
|
||||
@@ -72,20 +72,9 @@ class LogoHelper:
|
||||
|
||||
Returns:
|
||||
PIL Image object or None if loading fails
|
||||
|
||||
Note: for new adaptive-layout code prefer ``BasePlugin.draw_image``
|
||||
/ ``LayoutContext.fit_image`` (src/adaptive_images.py) for the
|
||||
fitting step — LogoHelper remains useful for its download and
|
||||
placeholder logic.
|
||||
"""
|
||||
# Resolve the effective target size BEFORE the cache lookup so the
|
||||
# key is size-qualified — a panel-size change must not return a
|
||||
# logo resized for the old dimensions.
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * 1.5)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * 1.5)
|
||||
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
|
||||
# Check cache first
|
||||
cache_key = f"{team_abbr}_{logo_path}"
|
||||
if cache_key in self._logo_cache:
|
||||
self.logger.debug(f"Using cached logo for {team_abbr}")
|
||||
# Update LRU order (move to end)
|
||||
|
||||
@@ -11,9 +11,6 @@ from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
|
||||
|
||||
class TextHelper:
|
||||
"""
|
||||
@@ -115,10 +112,10 @@ class TextHelper:
|
||||
Width in pixels
|
||||
"""
|
||||
try:
|
||||
return int(_measure_draw.textlength(text, font=font))
|
||||
return draw.textlength(text, font=font)
|
||||
except AttributeError:
|
||||
# Fallback for older PIL versions
|
||||
bbox = _measure_draw.textbbox((0, 0), text, font=font)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0]
|
||||
|
||||
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||
@@ -132,8 +129,13 @@ class TextHelper:
|
||||
Returns:
|
||||
Height in pixels
|
||||
"""
|
||||
bbox = _measure_draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[3] - bbox[1]
|
||||
try:
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[3] - bbox[1]
|
||||
except AttributeError:
|
||||
# Fallback for older PIL versions
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[3] - bbox[1]
|
||||
|
||||
def get_text_dimensions(self, text: str, font: ImageFont.ImageFont) -> Tuple[int, int]:
|
||||
"""
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
"""Universal per-element style resolution for plugin customization.
|
||||
|
||||
Plugins expose per-element user customization under ``config['customization']``:
|
||||
|
||||
"customization": {
|
||||
"score_text": {"font": "PressStart2P-Regular.ttf", "font_size": 10,
|
||||
"text_color": [255, 255, 255]},
|
||||
"layout": {"score": {"x_offset": 2, "y_offset": 0}}
|
||||
}
|
||||
|
||||
Before this module, every plugin re-implemented the same three pieces —
|
||||
a font loader, an x/y-offset reader, and (for adaptive layout mode) a
|
||||
"did the user actually override this?" check. The loaders diverged four
|
||||
ways across the sports plugins and music, the offset reader was copied
|
||||
twice, and the override check is subtle enough that it shipped broken
|
||||
twice: the web UI's save flow (schema_manager.merge_with_defaults) writes
|
||||
the FULL schema default object into config.json on every save, and the
|
||||
plugin manager merges defaults into ``config`` again before instantiation,
|
||||
so a key being *present* never means the user set it. The only correct
|
||||
test is "present AND different from the schema default", which requires
|
||||
knowing the schema defaults — previously a hand-maintained dict per plugin.
|
||||
|
||||
This module is that logic, once:
|
||||
|
||||
resolver = ElementStyleResolver(config, schema_defaults)
|
||||
style = resolver.style('score_text', classic_font='PressStart2P-Regular.ttf',
|
||||
classic_size=10)
|
||||
style.font # loaded PIL font, ready for draw.text
|
||||
style.user_forced # True only for a genuine user override
|
||||
dx, dy = resolver.offset('score')
|
||||
|
||||
``BasePlugin.element_style()`` wires this up automatically (schema defaults
|
||||
come from the plugin's own config_schema.json via the schema manager).
|
||||
Standalone helper classes (e.g. a plugin's GameRenderer) should receive a
|
||||
resolver from their owning plugin rather than build one themselves.
|
||||
|
||||
Deliberately pure PIL + stdlib: no imports from the plugin system or web
|
||||
layer, so it is usable from any renderer and trivially testable.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
from PIL import ImageFont
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Font-family aliases accepted in customization configs. Filenames pass
|
||||
# through unchanged. (Supersedes the per-plugin copies in the baseball
|
||||
# plugin; keep names in sync with the web UI's /fonts/catalog so the
|
||||
# font-selector widget and this loader agree.)
|
||||
FONT_ALIASES: Dict[str, str] = {
|
||||
"press_start": "PressStart2P-Regular.ttf",
|
||||
"four_by_six": "4x6-font.ttf",
|
||||
"five_by_seven": "5x7.bdf",
|
||||
}
|
||||
|
||||
DEFAULT_FONTS_DIR = os.path.join("assets", "fonts")
|
||||
DEFAULT_FALLBACK_FONT = "PressStart2P-Regular.ttf"
|
||||
|
||||
PILFont = Union[ImageFont.FreeTypeFont, ImageFont.ImageFont]
|
||||
|
||||
|
||||
def resolve_font_name(font_name: str) -> str:
|
||||
"""Resolve a font family alias to its filename, leaving filenames as-is."""
|
||||
return FONT_ALIASES.get(font_name, font_name)
|
||||
|
||||
|
||||
def extract_schema_defaults(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Nested defaults dict from a JSON Schema (mirrors
|
||||
SchemaManager.extract_defaults_from_schema, kept here so this module
|
||||
stays importable without the plugin system — the parity test in
|
||||
test_element_style.py guards against drift).
|
||||
|
||||
An object property carrying its own ``default`` short-circuits recursion,
|
||||
and array-typed properties without one default to ``[]`` (or a
|
||||
single-item list when the items schema declares a default), matching
|
||||
the schema manager's behavior exactly.
|
||||
"""
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in (schema.get("properties") or {}).items():
|
||||
if not isinstance(prop, dict):
|
||||
continue
|
||||
if "default" in prop:
|
||||
defaults[key] = prop["default"]
|
||||
elif prop.get("type") == "object" and "properties" in prop:
|
||||
nested = extract_schema_defaults(prop)
|
||||
if nested:
|
||||
defaults[key] = nested
|
||||
elif prop.get("type") == "array" and "items" in prop:
|
||||
items = prop.get("items")
|
||||
if isinstance(items, dict) and "default" in items and \
|
||||
not (items.get("type") == "object" and "properties" in items):
|
||||
defaults[key] = [items["default"]]
|
||||
else:
|
||||
defaults[key] = []
|
||||
return defaults
|
||||
|
||||
|
||||
def defaults_from_schema_file(schema_path: str) -> Dict[str, Any]:
|
||||
"""Schema defaults straight from a plugin's own config_schema.json.
|
||||
|
||||
Plugins that hand a resolver to standalone helper classes should build
|
||||
it with this, pointed at their own schema file — it works identically
|
||||
in production, the test harness, and the dev server, unlike the plugin
|
||||
manager's schema manager (absent under mocks). x-style-elements
|
||||
declarations are expanded first, so declared elements' defaults are
|
||||
included exactly as the web UI's schema manager sees them. Returns {}
|
||||
on any error.
|
||||
"""
|
||||
try:
|
||||
with open(schema_path, "r", encoding="utf-8") as f:
|
||||
schema = json.load(f)
|
||||
if isinstance(schema, dict):
|
||||
return extract_schema_defaults(expand_style_elements(schema))
|
||||
except Exception as e:
|
||||
logger.debug("Could not load schema defaults from %s: %s",
|
||||
schema_path, e)
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# x-style-elements schema expansion
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A plugin declares its styleable display elements ONCE, compactly, on its
|
||||
# customization object instead of hand-copying ~50-line property blocks:
|
||||
#
|
||||
# "customization": {
|
||||
# "type": "object",
|
||||
# "x-style-elements": {
|
||||
# "score_text": {
|
||||
# "title": "Game Score",
|
||||
# "font": {"default": "PressStart2P-Regular.ttf"},
|
||||
# "size": {"default": 10, "min": 4, "max": 16},
|
||||
# "color": true, # or {"default": [r,g,b]}
|
||||
# "offsets": true
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# expand_style_elements() turns each declaration into full font/font_size/
|
||||
# text_color/layout-offset property blocks (marked "x-style-managed": true)
|
||||
# using widgets the web config form already renders. The declaration stays
|
||||
# in the schema — it doubles as the element registry for tooling. Expansion
|
||||
# is idempotent, and a hand-written property block for the same element
|
||||
# always wins over the generated one.
|
||||
#
|
||||
# SchemaManager.load_schema() applies this at serve time (so the web form,
|
||||
# save path, validation, and defaults generation all see the expanded
|
||||
# shape), and defaults_from_schema_file() applies it when plugins read
|
||||
# their own schema — one implementation, no drift.
|
||||
|
||||
def get_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""The x-style-elements declaration from a schema ({} if none)."""
|
||||
try:
|
||||
decl = schema.get("properties", {}).get("customization", {}).get("x-style-elements")
|
||||
return decl if isinstance(decl, dict) else {}
|
||||
except AttributeError:
|
||||
return {}
|
||||
|
||||
|
||||
def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Expand x-style-elements into full customization property blocks.
|
||||
|
||||
Returns the schema unchanged (same object) when there is nothing to
|
||||
expand; otherwise returns an expanded DEEP COPY, leaving the input
|
||||
untouched. Never raises — on any error the original schema is returned
|
||||
so a malformed declaration can't take a plugin down.
|
||||
"""
|
||||
import copy
|
||||
|
||||
try:
|
||||
declarations = get_style_elements(schema)
|
||||
if not declarations:
|
||||
return schema
|
||||
|
||||
schema = copy.deepcopy(schema)
|
||||
customization = schema["properties"]["customization"]
|
||||
properties = customization.setdefault("properties", {})
|
||||
order = customization.get("x-propertyOrder")
|
||||
|
||||
offset_elements = []
|
||||
for element_key, declaration in declarations.items():
|
||||
if not isinstance(declaration, dict):
|
||||
continue
|
||||
if declaration.get("offsets") is True:
|
||||
offset_elements.append((element_key, declaration))
|
||||
if element_key in properties:
|
||||
# Hand-written (or previously expanded) block wins.
|
||||
continue
|
||||
properties[element_key] = _style_element_block(element_key, declaration)
|
||||
if isinstance(order, list) and element_key not in order:
|
||||
# Keep generated elements ahead of the layout section.
|
||||
insert_at = order.index("layout") if "layout" in order else len(order)
|
||||
order.insert(insert_at, element_key)
|
||||
|
||||
if offset_elements:
|
||||
_expand_offset_blocks(properties, order, offset_elements)
|
||||
|
||||
return schema
|
||||
except Exception as e:
|
||||
logger.error("x-style-elements expansion failed: %s", e)
|
||||
return schema
|
||||
|
||||
|
||||
def _style_element_block(element_key: str, declaration: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""One generated customization.<element> property block."""
|
||||
title = declaration.get("title") or element_key.replace("_", " ").title()
|
||||
font_decl = declaration.get("font") if isinstance(declaration.get("font"), dict) else {}
|
||||
size_decl = declaration.get("size") if isinstance(declaration.get("size"), dict) else {}
|
||||
|
||||
block_properties: Dict[str, Any] = {
|
||||
"font": {
|
||||
"type": "string",
|
||||
"title": "Font Family",
|
||||
"description": "Select the font to use",
|
||||
"x-widget": "font-selector",
|
||||
"default": font_decl.get("default", DEFAULT_FALLBACK_FONT),
|
||||
},
|
||||
"font_size": {
|
||||
"type": "integer",
|
||||
"title": "Font Size",
|
||||
"description": ("Font size in pixels (BDF fonts are fixed-size "
|
||||
"and ignore this)"),
|
||||
"minimum": size_decl.get("min", 4),
|
||||
"maximum": size_decl.get("max", 32),
|
||||
"default": size_decl.get("default", 8),
|
||||
},
|
||||
}
|
||||
block_order = ["font", "font_size"]
|
||||
|
||||
color_decl = declaration.get("color")
|
||||
if color_decl:
|
||||
default_color = [255, 255, 255]
|
||||
if isinstance(color_decl, dict) and isinstance(color_decl.get("default"), list):
|
||||
default_color = color_decl["default"]
|
||||
# The default doubles as the "untouched" sentinel: the resolver only
|
||||
# honors a color that DIFFERS from it, so untouched saves (the web
|
||||
# form always posts the RGB inputs) can't clobber a plugin's
|
||||
# semantic/state-dependent colors.
|
||||
block_properties["text_color"] = {
|
||||
"type": "array",
|
||||
"title": "Text Color",
|
||||
"description": "RGB color as [red, green, blue] (0-255 each)",
|
||||
"items": {"type": "integer", "minimum": 0, "maximum": 255},
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"x-widget": "color-picker",
|
||||
"default": default_color,
|
||||
}
|
||||
block_order.append("text_color")
|
||||
|
||||
return {
|
||||
"type": "object",
|
||||
"title": title,
|
||||
"description": f"Style settings for {title}",
|
||||
"x-style-managed": True,
|
||||
"properties": block_properties,
|
||||
"x-propertyOrder": block_order,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def _expand_offset_blocks(properties: Dict[str, Any], order,
|
||||
offset_elements) -> None:
|
||||
"""Generate customization.layout.<element> x/y offset blocks."""
|
||||
layout = properties.get("layout")
|
||||
if not isinstance(layout, dict):
|
||||
layout = {
|
||||
"type": "object",
|
||||
"title": "Layout Positioning",
|
||||
"description": ("Adjust X,Y coordinate offsets for elements. "
|
||||
"Values are relative to default positions; "
|
||||
"negative moves left/up, positive right/down."),
|
||||
"x-style-managed": True,
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
properties["layout"] = layout
|
||||
if isinstance(order, list) and "layout" not in order:
|
||||
order.append("layout")
|
||||
|
||||
layout_properties = layout.setdefault("properties", {})
|
||||
layout_order = layout.get("x-propertyOrder")
|
||||
for element_key, declaration in offset_elements:
|
||||
if element_key in layout_properties:
|
||||
continue # hand-written layout entry wins
|
||||
title = declaration.get("title") or element_key.replace("_", " ").title()
|
||||
layout_properties[element_key] = {
|
||||
"type": "object",
|
||||
"title": title,
|
||||
"x-style-managed": True,
|
||||
"properties": {
|
||||
"x_offset": {
|
||||
"type": "integer",
|
||||
"title": "X Offset",
|
||||
"description": "Horizontal offset in pixels (default: 0)",
|
||||
"default": 0,
|
||||
},
|
||||
"y_offset": {
|
||||
"type": "integer",
|
||||
"title": "Y Offset",
|
||||
"description": "Vertical offset in pixels (default: 0)",
|
||||
"default": 0,
|
||||
},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
if isinstance(layout_order, list) and element_key not in layout_order:
|
||||
layout_order.append(element_key)
|
||||
|
||||
|
||||
def load_font(font_name: str, size: int, *,
|
||||
fonts_dir: str = DEFAULT_FONTS_DIR,
|
||||
fallback_font: str = DEFAULT_FALLBACK_FONT) -> PILFont:
|
||||
"""Load a font by name at a pixel size, never raising.
|
||||
|
||||
Resolution order:
|
||||
1. alias -> filename (``FONT_ALIASES``)
|
||||
2. ``ImageFont.truetype`` — handles .ttf/.otf, and .bdf too (FreeType
|
||||
loads BDF strikes at their native size; a non-native size raises
|
||||
"invalid pixel size" and falls through)
|
||||
3. for .bdf: a pre-converted ``.pil`` sidecar via ``ImageFont.load``
|
||||
4. ``fallback_font`` at the requested size
|
||||
5. ``ImageFont.load_default()``
|
||||
"""
|
||||
font_name = resolve_font_name(font_name or "")
|
||||
font_path = os.path.join(fonts_dir, font_name)
|
||||
lower = font_name.lower()
|
||||
|
||||
if os.path.exists(font_path):
|
||||
try:
|
||||
return ImageFont.truetype(font_path, size)
|
||||
except Exception as e:
|
||||
logger.debug("truetype failed for %s@%s: %s", font_name, size, e)
|
||||
if lower.endswith(".bdf"):
|
||||
pil_path = font_path.rsplit(".", 1)[0] + ".pil"
|
||||
if os.path.exists(pil_path):
|
||||
try:
|
||||
return ImageFont.load(pil_path)
|
||||
except Exception as e:
|
||||
logger.debug("PIL sidecar failed for %s: %s", pil_path, e)
|
||||
logger.warning(
|
||||
"BDF font %s could not be loaded at size %s (BDF fonts are "
|
||||
"fixed-size; font_size must match the native size). Falling "
|
||||
"back to %s.", font_name, size, fallback_font)
|
||||
else:
|
||||
logger.warning("Font file not found: %s, falling back to %s",
|
||||
font_path, fallback_font)
|
||||
|
||||
fallback_path = os.path.join(fonts_dir, resolve_font_name(fallback_font))
|
||||
try:
|
||||
return ImageFont.truetype(fallback_path, size)
|
||||
except Exception as e:
|
||||
logger.warning("Fallback font %s failed (%s); using PIL default",
|
||||
fallback_font, e)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ElementStyle:
|
||||
"""Resolved style for one display element."""
|
||||
font: PILFont
|
||||
font_name: str
|
||||
font_size: int
|
||||
#: The user's color when they genuinely changed it, else the plugin's
|
||||
#: classic color (which may be state-dependent — e.g. a score that turns
|
||||
#: gold on a touchdown — so an untouched schema default must never
|
||||
#: clobber it; the web form always posts the color inputs).
|
||||
color: Optional[Tuple[int, int, int]]
|
||||
#: Additive (dx, dy) translation from customization.layout offsets.
|
||||
offset: Tuple[int, int]
|
||||
#: True when the configured value genuinely differs from the schema
|
||||
#: default (NOT merely present — saved configs always contain defaults).
|
||||
user_forced_font: bool
|
||||
user_forced_size: bool
|
||||
user_forced_color: bool = False
|
||||
|
||||
@property
|
||||
def user_forced(self) -> bool:
|
||||
"""True when the user pinned this element's font or size; adaptive
|
||||
layouts must use the font as-is instead of ladder-fitting. (Color is
|
||||
deliberately excluded — it never affects sizing.)"""
|
||||
return self.user_forced_font or self.user_forced_size
|
||||
|
||||
|
||||
def _as_int(value: Any, default: int) -> int:
|
||||
"""Int coercion tolerant of floats and numeric strings from configs."""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _as_color(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||
"""[r, g, b] list/tuple -> tuple; anything else -> None."""
|
||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||
try:
|
||||
return tuple(max(0, min(255, int(c))) for c in value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class ElementStyleResolver:
|
||||
"""Resolves per-element fonts, colors and offsets from a plugin config.
|
||||
|
||||
``schema_defaults`` is the nested defaults dict extracted from the
|
||||
plugin's config_schema.json (SchemaManager.extract_defaults_from_schema).
|
||||
It is the reference for the user-override check: a configured value
|
||||
equal to its schema default is treated as untouched, because the save
|
||||
flow persists all defaults. When ``schema_defaults`` is empty (older
|
||||
cores, unit tests), the check degrades to comparing against the
|
||||
``classic_*`` values the caller supplies.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]],
|
||||
schema_defaults: Optional[Dict[str, Any]] = None, *,
|
||||
fonts_dir: str = DEFAULT_FONTS_DIR,
|
||||
fallback_font: str = DEFAULT_FALLBACK_FONT):
|
||||
self._config = config if isinstance(config, dict) else {}
|
||||
self._defaults = schema_defaults if isinstance(schema_defaults, dict) else {}
|
||||
self._fonts_dir = fonts_dir
|
||||
self._fallback_font = fallback_font
|
||||
self._cache: Dict[Any, ElementStyle] = {}
|
||||
|
||||
# -- internals ----------------------------------------------------
|
||||
|
||||
def _element_config(self, element_key: str) -> Dict[str, Any]:
|
||||
cust = self._config.get("customization")
|
||||
if not isinstance(cust, dict):
|
||||
return {}
|
||||
element = cust.get(element_key)
|
||||
return element if isinstance(element, dict) else {}
|
||||
|
||||
def _element_defaults(self, element_key: str) -> Dict[str, Any]:
|
||||
cust = self._defaults.get("customization")
|
||||
if not isinstance(cust, dict):
|
||||
return {}
|
||||
element = cust.get(element_key)
|
||||
return element if isinstance(element, dict) else {}
|
||||
|
||||
# -- public API ---------------------------------------------------
|
||||
|
||||
def style(self, element_key: str, *, classic_font: str, classic_size: int,
|
||||
classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle:
|
||||
"""Resolve the style for one element.
|
||||
|
||||
``classic_font``/``classic_size``/``classic_color`` are the plugin's
|
||||
hardcoded defaults for this element — used when the config has no
|
||||
value, and as the override reference when schema defaults are
|
||||
unavailable.
|
||||
"""
|
||||
cache_key = (element_key, classic_font, classic_size, classic_color)
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
element_cfg = self._element_config(element_key)
|
||||
element_defaults = self._element_defaults(element_key)
|
||||
|
||||
configured_font = element_cfg.get("font")
|
||||
configured_size = element_cfg.get("font_size")
|
||||
|
||||
# Reference for "did the user change it": schema default when known,
|
||||
# else the plugin's classic default.
|
||||
reference_font = element_defaults.get("font", classic_font)
|
||||
reference_size = _as_int(element_defaults.get("font_size"), classic_size)
|
||||
|
||||
user_forced_font = (configured_font is not None
|
||||
and configured_font != reference_font)
|
||||
user_forced_size = (configured_size is not None
|
||||
and _as_int(configured_size, reference_size) != reference_size)
|
||||
|
||||
font_name = configured_font if configured_font is not None else classic_font
|
||||
font_size = _as_int(configured_size, classic_size)
|
||||
font = load_font(font_name, font_size, fonts_dir=self._fonts_dir,
|
||||
fallback_font=self._fallback_font)
|
||||
|
||||
# Color follows the same provenance rule as fonts: the web form
|
||||
# always posts the RGB inputs, so a saved config carries the schema
|
||||
# default whether or not the user touched it — only a value that
|
||||
# DIFFERS from the schema default is a real override. Otherwise keep
|
||||
# classic_color, which may be state-dependent (semantic colors like
|
||||
# a gold touchdown score) and must not be clobbered by a default.
|
||||
configured_color = _as_color(element_cfg.get("text_color"))
|
||||
default_color = _as_color(element_defaults.get("text_color"))
|
||||
if configured_color is None:
|
||||
user_forced_color = False
|
||||
elif default_color is None:
|
||||
# no schema default to compare against — presence is intent
|
||||
user_forced_color = True
|
||||
else:
|
||||
user_forced_color = configured_color != default_color
|
||||
color = configured_color if user_forced_color else classic_color
|
||||
|
||||
resolved = ElementStyle(
|
||||
font=font, font_name=font_name, font_size=font_size,
|
||||
color=color, offset=self.offset(element_key),
|
||||
user_forced_font=user_forced_font, user_forced_size=user_forced_size,
|
||||
user_forced_color=user_forced_color,
|
||||
)
|
||||
self._cache[cache_key] = resolved
|
||||
return resolved
|
||||
|
||||
def offset_value(self, element_key: str, axis: str, default: int = 0) -> int:
|
||||
"""One offset axis for an element (e.g. 'x_offset', 'away_x_offset').
|
||||
|
||||
Reads ``customization.layout.<element>`` first (the deployed sports
|
||||
convention), falling back to ``customization.<element>`` for plugins
|
||||
that keep offsets on the element itself.
|
||||
"""
|
||||
cust = self._config.get("customization")
|
||||
if not isinstance(cust, dict):
|
||||
return default
|
||||
layout = cust.get("layout")
|
||||
if isinstance(layout, dict):
|
||||
element = layout.get(element_key)
|
||||
if isinstance(element, dict) and axis in element:
|
||||
return _as_int(element.get(axis), default)
|
||||
element = cust.get(element_key)
|
||||
if isinstance(element, dict) and axis in element:
|
||||
return _as_int(element.get(axis), default)
|
||||
return default
|
||||
|
||||
def offset(self, element_key: str) -> Tuple[int, int]:
|
||||
"""(dx, dy) additive translation for an element; (0, 0) when unset."""
|
||||
return (self.offset_value(element_key, "x_offset"),
|
||||
self.offset_value(element_key, "y_offset"))
|
||||
|
||||
def is_for(self, config: Optional[Dict[str, Any]]) -> bool:
|
||||
"""True when this resolver was built over exactly this config object.
|
||||
|
||||
Callers that cache a resolver (plugins whose config dict gets
|
||||
REPLACED on config change) use this to decide when to rebuild —
|
||||
preferred over reaching into the private ``_config``.
|
||||
"""
|
||||
return self._config is config
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
self._cache.clear()
|
||||
@@ -103,10 +103,6 @@ class FontManager:
|
||||
# Font overrides storage (for manual overrides)
|
||||
self.font_overrides_file = "config/font_overrides.json"
|
||||
self.font_overrides: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Bumped whenever cached font objects are invalidated, so holders of
|
||||
# derived caches (e.g. adaptive-layout fit results) know to rebuild.
|
||||
self.cache_generation = 0
|
||||
|
||||
self._initialize_fonts()
|
||||
|
||||
@@ -116,7 +112,6 @@ class FontManager:
|
||||
self.fonts_config = new_config.get("fonts", {})
|
||||
self.font_cache.clear() # Clear cache to force reload
|
||||
self.metrics_cache.clear() # Clear metrics cache
|
||||
self.cache_generation += 1
|
||||
self._initialize_fonts()
|
||||
logger.info("FontManager configuration reloaded successfully")
|
||||
|
||||
@@ -487,14 +482,6 @@ class FontManager:
|
||||
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
||||
"""Load a BDF font using FreeType."""
|
||||
try:
|
||||
native_size = self._read_bdf_native_size(font_path)
|
||||
if native_size is not None and native_size != size_px:
|
||||
# BDF is a fixed-strike bitmap format: FreeType renders the
|
||||
# native size no matter what set_char_size asks for.
|
||||
logger.debug(
|
||||
"BDF font %s requested at %spx but renders at its native "
|
||||
"%spx", font_path, size_px, native_size
|
||||
)
|
||||
face = freetype.Face(font_path)
|
||||
# Set character size (width, height) in 1/64th of points
|
||||
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
|
||||
@@ -503,41 +490,6 @@ class FontManager:
|
||||
logger.error(f"Error loading BDF font {font_path}: {e}")
|
||||
raise
|
||||
|
||||
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
||||
"""The one true pixel size of a BDF family in the catalog, or None
|
||||
for scalable (TTF) families / unknown families."""
|
||||
font_path = self.font_catalog.get(family)
|
||||
if not font_path or not font_path.endswith('.bdf'):
|
||||
return None
|
||||
return self._read_bdf_native_size(font_path)
|
||||
|
||||
@staticmethod
|
||||
def _read_bdf_native_size(bdf_path: str) -> Optional[int]:
|
||||
"""Read a BDF file's own header to find its one true pixel size.
|
||||
Prefers the PIXEL_SIZE property, which states the real pixel height
|
||||
directly; falls back to the SIZE line's point-size only if PIXEL_SIZE
|
||||
is absent, since point-size only equals pixel height at exactly
|
||||
100dpi — several bundled fonts (e.g. 6x13.bdf, 5x8.bdf) are defined
|
||||
at 75dpi, where the two values genuinely differ."""
|
||||
size_line_value = None
|
||||
try:
|
||||
with open(bdf_path, "r", encoding="ascii", errors="ignore") as f:
|
||||
for line in f:
|
||||
if line.startswith("PIXEL_SIZE"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
return int(float(parts[1]))
|
||||
elif line.startswith("SIZE") and size_line_value is None:
|
||||
# Format: "SIZE <point_size> <xres> <yres>"
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
size_line_value = int(float(parts[1]))
|
||||
elif line.startswith("STARTCHAR"):
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return size_line_value
|
||||
|
||||
def _get_fallback_font(self) -> ImageFont.ImageFont:
|
||||
"""Get a fallback font when loading fails."""
|
||||
return ImageFont.load_default()
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import os
|
||||
import freetype
|
||||
from PIL import ImageDraw, ImageFont
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class FontTestManager:
|
||||
"""Manager for testing fonts with easy BDF/TTF switching."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager):
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
self.logger = logging.getLogger('FontTest')
|
||||
|
||||
# FONT CONFIGURATION - EASY SWITCHING
|
||||
# Set to 'bdf' or 'ttf' to switch font types
|
||||
self.font_type = 'bdf' # Change this to 'ttf' to use TTF font
|
||||
|
||||
# Font configurations
|
||||
self.font_configs = {
|
||||
'bdf': {
|
||||
'path': "assets/fonts/cozette.bdf",
|
||||
'display_name': "Cozette BTF",
|
||||
'description': "BTF font Test"
|
||||
},
|
||||
'ttf': {
|
||||
'path': "assets/fonts/5by7.regular.ttf",
|
||||
'display_name': "5by7 TTF",
|
||||
'description': "TTF font test"
|
||||
}
|
||||
}
|
||||
|
||||
# Get current font configuration
|
||||
self.current_config = self.font_configs[self.font_type]
|
||||
self.font_path = self.current_config['path']
|
||||
|
||||
# Verify font exists
|
||||
if not os.path.exists(self.font_path):
|
||||
self.logger.error(f"Font file not found: {self.font_path}")
|
||||
raise FileNotFoundError(f"Font file not found: {self.font_path}")
|
||||
|
||||
# Load the font based on type
|
||||
if self.font_type == 'bdf':
|
||||
self._load_bdf_font()
|
||||
else:
|
||||
self._load_ttf_font()
|
||||
|
||||
self.logger.info(f"Initialized FontTestManager with {self.current_config['description']}")
|
||||
|
||||
def _load_bdf_font(self):
|
||||
"""Load BDF font using freetype."""
|
||||
try:
|
||||
self.face = freetype.Face(self.font_path)
|
||||
self.logger.info(f"Successfully loaded BDF font from {self.font_path}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load BDF font: {e}")
|
||||
raise
|
||||
|
||||
def _load_ttf_font(self):
|
||||
"""Load TTF font using PIL."""
|
||||
try:
|
||||
self.font = ImageFont.truetype(self.font_path, 8) # Size 8 for 5x7 font
|
||||
self.logger.info(f"Successfully loaded TTF font from {self.font_path}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load TTF font: {e}")
|
||||
raise
|
||||
|
||||
def update(self):
|
||||
"""No update needed for static display."""
|
||||
|
||||
def display(self, force_clear: bool = False):
|
||||
"""Display the font with sample text."""
|
||||
try:
|
||||
# Clear the display
|
||||
self.display_manager.clear()
|
||||
|
||||
# Draw font name at the top
|
||||
self.display_manager.draw_text(self.current_config['display_name'], y=2, color=(255, 255, 255))
|
||||
|
||||
# Draw sample text
|
||||
draw = ImageDraw.Draw(self.display_manager.image)
|
||||
sample_text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
# Calculate starting position
|
||||
x = 10 # Start 10 pixels from the left
|
||||
y = 10 # Start 10 pixels from the top
|
||||
|
||||
# Draw text based on font type
|
||||
if self.font_type == 'bdf':
|
||||
self._draw_bdf_text(draw, sample_text, x, y)
|
||||
else:
|
||||
self._draw_ttf_text(draw, sample_text, x, y)
|
||||
|
||||
# Update the display once
|
||||
self.display_manager.update_display()
|
||||
|
||||
# Log that display is complete
|
||||
self.logger.info("Font test display complete.")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying font test: {e}", exc_info=True)
|
||||
|
||||
def _draw_bdf_text(self, draw, text, x, y):
|
||||
"""Draw text using BDF font."""
|
||||
for char in text:
|
||||
# Load the glyph
|
||||
self.face.load_char(char)
|
||||
bitmap = self.face.glyph.bitmap
|
||||
|
||||
# Draw the glyph
|
||||
for i in range(bitmap.rows):
|
||||
for j in range(bitmap.width):
|
||||
try:
|
||||
# Get the byte containing the pixel
|
||||
byte_index = i * bitmap.pitch + (j // 8)
|
||||
if byte_index < len(bitmap.buffer):
|
||||
byte = bitmap.buffer[byte_index]
|
||||
# Check if the specific bit is set
|
||||
if byte & (1 << (7 - (j % 8))):
|
||||
draw.point((x + j, y + i), fill=(255, 255, 255))
|
||||
except IndexError:
|
||||
self.logger.warning(f"Index out of range for char '{char}' at position ({i}, {j})")
|
||||
continue
|
||||
|
||||
# Move to next character position
|
||||
x += self.face.glyph.advance.x >> 6
|
||||
|
||||
def _draw_ttf_text(self, draw, text, x, y):
|
||||
"""Draw text using TTF font."""
|
||||
draw.text((x, y), text, font=self.font, fill=(255, 255, 255))
|
||||
@@ -1,150 +0,0 @@
|
||||
"""
|
||||
Generic Cache Mixin for Any Manager
|
||||
|
||||
This mixin provides caching functionality that can be used by any manager
|
||||
that needs to cache data, not just sports managers. It's a more general
|
||||
version of BackgroundCacheMixin that works for weather, stocks, news, etc.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
|
||||
|
||||
class GenericCacheMixin:
|
||||
"""
|
||||
Generic mixin class that provides caching functionality to any manager.
|
||||
|
||||
This mixin can be used by weather, stock, news, or any other manager
|
||||
that needs to cache data with performance monitoring.
|
||||
|
||||
Note: For sports managers that need background service cache integration,
|
||||
use BackgroundCacheMixin instead. See src/background_cache_mixin.py for details.
|
||||
"""
|
||||
|
||||
def _fetch_data_with_cache(self,
|
||||
cache_key: str,
|
||||
api_fetch_method: Callable,
|
||||
cache_ttl: int = 300,
|
||||
force_refresh: bool = False) -> Optional[Dict]:
|
||||
"""
|
||||
Generic caching pattern for any manager.
|
||||
|
||||
Args:
|
||||
cache_key: Unique cache key for this data
|
||||
api_fetch_method: Method to call for fresh data
|
||||
cache_ttl: Time-to-live in seconds (default: 5 minutes)
|
||||
force_refresh: Skip cache and fetch fresh data
|
||||
|
||||
Returns:
|
||||
Cached or fresh data from API
|
||||
"""
|
||||
start_time = time.time()
|
||||
cache_hit = False
|
||||
cache_source = None
|
||||
|
||||
try:
|
||||
# Check cache first (unless forcing refresh)
|
||||
if not force_refresh:
|
||||
cached_data = self.cache_manager.get_cached_data(cache_key, cache_ttl)
|
||||
if cached_data:
|
||||
self.logger.info(f"Using cached data for {cache_key}")
|
||||
cache_hit = True
|
||||
cache_source = "cache"
|
||||
self.cache_manager.record_cache_hit('regular')
|
||||
|
||||
# Record performance metrics
|
||||
duration = time.time() - start_time
|
||||
self.cache_manager.record_fetch_time(duration)
|
||||
self._log_fetch_performance(cache_key, duration, cache_hit, cache_source)
|
||||
|
||||
return cached_data
|
||||
|
||||
# Fetch fresh data
|
||||
self.logger.info(f"Fetching fresh data for {cache_key}")
|
||||
result = api_fetch_method()
|
||||
cache_source = "api_fresh"
|
||||
|
||||
# Store in cache if we got data
|
||||
if result:
|
||||
self.cache_manager.save_cache(cache_key, result)
|
||||
self.cache_manager.record_cache_miss('regular')
|
||||
else:
|
||||
self.logger.warning(f"No data returned for {cache_key}")
|
||||
|
||||
# Record performance metrics
|
||||
duration = time.time() - start_time
|
||||
self.cache_manager.record_fetch_time(duration)
|
||||
|
||||
# Log performance
|
||||
self._log_fetch_performance(cache_key, duration, cache_hit, cache_source)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
self.logger.error(f"Error fetching data for {cache_key} after {duration:.2f}s: {e}")
|
||||
self.cache_manager.record_fetch_time(duration)
|
||||
raise
|
||||
|
||||
def _log_fetch_performance(self, cache_key: str, duration: float, cache_hit: bool, cache_source: str):
|
||||
"""
|
||||
Log detailed performance metrics for fetch operations.
|
||||
|
||||
Args:
|
||||
cache_key: Cache key that was accessed
|
||||
duration: Fetch operation duration in seconds
|
||||
cache_hit: Whether this was a cache hit
|
||||
cache_source: Source of the data (cache, api_fresh, etc.)
|
||||
"""
|
||||
# Log basic performance info
|
||||
self.logger.info(f"Fetch completed for {cache_key} in {duration:.2f}s "
|
||||
f"(cache_hit={cache_hit}, source={cache_source})")
|
||||
|
||||
# Log detailed metrics every 10 operations
|
||||
if hasattr(self, '_fetch_count'):
|
||||
self._fetch_count += 1
|
||||
else:
|
||||
self._fetch_count = 1
|
||||
|
||||
if self._fetch_count % 10 == 0:
|
||||
metrics = self.cache_manager.get_cache_metrics()
|
||||
self.logger.info(f"Cache Performance Summary - "
|
||||
f"Hit Rate: {metrics['cache_hit_rate']:.2%}, "
|
||||
f"API Calls Saved: {metrics['api_calls_saved']}, "
|
||||
f"Avg Fetch Time: {metrics['average_fetch_time']:.2f}s")
|
||||
|
||||
def get_cache_performance_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache performance summary for this manager.
|
||||
|
||||
Returns:
|
||||
Dictionary containing cache performance metrics
|
||||
"""
|
||||
return self.cache_manager.get_cache_metrics()
|
||||
|
||||
def log_cache_performance(self):
|
||||
"""Log current cache performance metrics."""
|
||||
self.cache_manager.log_cache_metrics()
|
||||
|
||||
def clear_cache_for_key(self, cache_key: str):
|
||||
"""Clear cache for a specific key."""
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
self.logger.info(f"Cleared cache for {cache_key}")
|
||||
|
||||
def get_cache_info(self, cache_key: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about a cached item.
|
||||
|
||||
Args:
|
||||
cache_key: Cache key to check
|
||||
|
||||
Returns:
|
||||
Dictionary with cache information
|
||||
"""
|
||||
# This would need to be implemented in CacheManager
|
||||
# For now, just return basic info
|
||||
return {
|
||||
'key': cache_key,
|
||||
'exists': self.cache_manager.get_cached_data(cache_key, 0) is not None,
|
||||
'ttl': 'unknown' # Would need to be implemented
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Deprecated: use src/adaptive_images.py (fit_image) instead.
|
||||
|
||||
This module predates the adaptive image system and has no known callers.
|
||||
It is kept only so any out-of-tree code importing it keeps working.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def scale_to_max_dimensions(img, max_width, max_height):
|
||||
h_to_w_ratio = img.height / img.width
|
||||
w_to_h_ratio = img.width / img.height
|
||||
|
||||
if img.height > max_height:
|
||||
img = img.resize((int(max_height * w_to_h_ratio), max_height), Image.Resampling.LANCZOS)
|
||||
|
||||
if img.width > max_width:
|
||||
img = img.resize((max_width, int(max_width * h_to_w_ratio)), Image.Resampling.LANCZOS)
|
||||
|
||||
return img
|
||||
@@ -1,409 +0,0 @@
|
||||
"""
|
||||
Layout Manager for LED Matrix Display
|
||||
Handles custom layouts, element positioning, and display composition.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LayoutManager:
|
||||
def __init__(self, display_manager=None, config_path="config/custom_layouts.json"):
|
||||
self.display_manager = display_manager
|
||||
self.config_path = config_path
|
||||
self.layouts = self.load_layouts()
|
||||
self.current_layout = None
|
||||
|
||||
def load_layouts(self) -> Dict[str, Any]:
|
||||
"""Load saved layouts from file."""
|
||||
try:
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading layouts: {e}")
|
||||
return {}
|
||||
|
||||
def save_layouts(self) -> bool:
|
||||
"""Save layouts to file."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_config_dir_mode
|
||||
)
|
||||
config_path_obj = Path(self.config_path)
|
||||
ensure_directory_permissions(config_path_obj.parent, get_config_dir_mode())
|
||||
with open(self.config_path, 'w') as f:
|
||||
json.dump(self.layouts, f, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving layouts: {e}")
|
||||
return False
|
||||
|
||||
def create_layout(self, name: str, elements: List[Dict], description: str = "") -> bool:
|
||||
"""Create a new layout."""
|
||||
try:
|
||||
self.layouts[name] = {
|
||||
'elements': elements,
|
||||
'description': description,
|
||||
'created': datetime.now().isoformat(),
|
||||
'modified': datetime.now().isoformat()
|
||||
}
|
||||
return self.save_layouts()
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating layout '{name}': {e}")
|
||||
return False
|
||||
|
||||
def update_layout(self, name: str, elements: List[Dict], description: str = None) -> bool:
|
||||
"""Update an existing layout."""
|
||||
try:
|
||||
if name not in self.layouts:
|
||||
return False
|
||||
|
||||
self.layouts[name]['elements'] = elements
|
||||
self.layouts[name]['modified'] = datetime.now().isoformat()
|
||||
|
||||
if description is not None:
|
||||
self.layouts[name]['description'] = description
|
||||
|
||||
return self.save_layouts()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating layout '{name}': {e}")
|
||||
return False
|
||||
|
||||
def delete_layout(self, name: str) -> bool:
|
||||
"""Delete a layout."""
|
||||
try:
|
||||
if name in self.layouts:
|
||||
del self.layouts[name]
|
||||
return self.save_layouts()
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting layout '{name}': {e}")
|
||||
return False
|
||||
|
||||
def get_layout(self, name: str) -> Dict[str, Any]:
|
||||
"""Get a specific layout."""
|
||||
return self.layouts.get(name, {})
|
||||
|
||||
def list_layouts(self) -> List[str]:
|
||||
"""Get list of all layout names."""
|
||||
return list(self.layouts.keys())
|
||||
|
||||
def set_current_layout(self, name: str) -> bool:
|
||||
"""Set the current active layout."""
|
||||
if name in self.layouts:
|
||||
self.current_layout = name
|
||||
return True
|
||||
return False
|
||||
|
||||
def render_layout(self, layout_name: str = None, data_context: Dict = None) -> bool:
|
||||
"""Render a layout to the display."""
|
||||
if not self.display_manager:
|
||||
logger.error("No display manager available")
|
||||
return False
|
||||
|
||||
layout_name = layout_name or self.current_layout
|
||||
if not layout_name or layout_name not in self.layouts:
|
||||
logger.error(f"Layout '{layout_name}' not found")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Clear the display
|
||||
self.display_manager.clear()
|
||||
|
||||
# Get layout elements
|
||||
elements = self.layouts[layout_name]['elements']
|
||||
|
||||
# Render each element
|
||||
for element in elements:
|
||||
self.render_element(element, data_context or {})
|
||||
|
||||
# Update the display
|
||||
self.display_manager.update_display()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering layout '{layout_name}': {e}")
|
||||
return False
|
||||
|
||||
def render_element(self, element: Dict, data_context: Dict) -> None:
|
||||
"""Render a single element."""
|
||||
element_type = element.get('type')
|
||||
x = element.get('x', 0)
|
||||
y = element.get('y', 0)
|
||||
properties = element.get('properties', {})
|
||||
|
||||
try:
|
||||
if element_type == 'text':
|
||||
self._render_text_element(x, y, properties, data_context)
|
||||
elif element_type == 'weather_icon':
|
||||
self._render_weather_icon_element(x, y, properties, data_context)
|
||||
elif element_type == 'rectangle':
|
||||
self._render_rectangle_element(x, y, properties)
|
||||
elif element_type == 'line':
|
||||
self._render_line_element(x, y, properties)
|
||||
elif element_type == 'clock':
|
||||
self._render_clock_element(x, y, properties)
|
||||
elif element_type == 'data_text':
|
||||
self._render_data_text_element(x, y, properties, data_context)
|
||||
else:
|
||||
logger.warning(f"Unknown element type: {element_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering element {element_type}: {e}")
|
||||
|
||||
def _render_text_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
|
||||
"""Render a text element."""
|
||||
text = properties.get('text', 'Sample Text')
|
||||
color = tuple(properties.get('color', [255, 255, 255]))
|
||||
font_size = properties.get('font_size', 'normal')
|
||||
|
||||
# Support template variables in text
|
||||
text = self._process_template_text(text, data_context)
|
||||
|
||||
# Select font
|
||||
if font_size == 'small':
|
||||
font = self.display_manager.small_font
|
||||
elif font_size == 'large':
|
||||
font = self.display_manager.regular_font
|
||||
else:
|
||||
font = self.display_manager.regular_font
|
||||
|
||||
self.display_manager.draw_text(text, x, y, color, font=font)
|
||||
|
||||
def _render_weather_icon_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
|
||||
"""Render a weather icon element."""
|
||||
condition = properties.get('condition', 'sunny')
|
||||
size = properties.get('size', 16)
|
||||
|
||||
# Use weather data from context if available
|
||||
if 'weather' in data_context and 'condition' in data_context['weather']:
|
||||
condition = data_context['weather']['condition'].lower()
|
||||
|
||||
self.display_manager.draw_weather_icon(condition, x, y, size)
|
||||
|
||||
def _render_rectangle_element(self, x: int, y: int, properties: Dict) -> None:
|
||||
"""Render a rectangle element."""
|
||||
width = properties.get('width', 10)
|
||||
height = properties.get('height', 10)
|
||||
color = tuple(properties.get('color', [255, 255, 255]))
|
||||
filled = properties.get('filled', False)
|
||||
|
||||
if filled:
|
||||
self.display_manager.draw.rectangle(
|
||||
[x, y, x + width, y + height],
|
||||
fill=color
|
||||
)
|
||||
else:
|
||||
self.display_manager.draw.rectangle(
|
||||
[x, y, x + width, y + height],
|
||||
outline=color
|
||||
)
|
||||
|
||||
def _render_line_element(self, x: int, y: int, properties: Dict) -> None:
|
||||
"""Render a line element."""
|
||||
x2 = properties.get('x2', x + 10)
|
||||
y2 = properties.get('y2', y)
|
||||
color = tuple(properties.get('color', [255, 255, 255]))
|
||||
width = properties.get('width', 1)
|
||||
|
||||
self.display_manager.draw.line([x, y, x2, y2], fill=color, width=width)
|
||||
|
||||
def _render_clock_element(self, x: int, y: int, properties: Dict) -> None:
|
||||
"""Render a clock element."""
|
||||
format_str = properties.get('format', '%H:%M')
|
||||
color = tuple(properties.get('color', [255, 255, 255]))
|
||||
|
||||
current_time = datetime.now().strftime(format_str)
|
||||
self.display_manager.draw_text(current_time, x, y, color)
|
||||
|
||||
def _render_data_text_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
|
||||
"""Render a data-driven text element."""
|
||||
data_key = properties.get('data_key', '')
|
||||
format_str = properties.get('format', '{value}')
|
||||
color = tuple(properties.get('color', [255, 255, 255]))
|
||||
default_value = properties.get('default', 'N/A')
|
||||
|
||||
# Extract data from context
|
||||
value = self._get_nested_value(data_context, data_key, default_value)
|
||||
|
||||
# Format the text
|
||||
try:
|
||||
text = format_str.format(value=value)
|
||||
except (ValueError, TypeError, KeyError, IndexError):
|
||||
text = str(value)
|
||||
|
||||
self.display_manager.draw_text(text, x, y, color)
|
||||
|
||||
def _process_template_text(self, text: str, data_context: Dict) -> str:
|
||||
"""Process template variables in text."""
|
||||
try:
|
||||
# Simple template processing - replace {key} with values from context
|
||||
for key, value in data_context.items():
|
||||
placeholder = f"{{{key}}}"
|
||||
if placeholder in text:
|
||||
text = text.replace(placeholder, str(value))
|
||||
return text
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing template text: {e}")
|
||||
return text
|
||||
|
||||
def _get_nested_value(self, data: Dict, key: str, default=None):
|
||||
"""Get a nested value from a dictionary using dot notation."""
|
||||
try:
|
||||
keys = key.split('.')
|
||||
value = data
|
||||
for k in keys:
|
||||
value = value[k]
|
||||
return value
|
||||
except (KeyError, TypeError):
|
||||
return default
|
||||
|
||||
def create_preset_layouts(self) -> None:
|
||||
"""Create some preset layouts for common use cases."""
|
||||
# Basic clock layout
|
||||
clock_layout = [
|
||||
{
|
||||
'type': 'clock',
|
||||
'x': 10,
|
||||
'y': 10,
|
||||
'properties': {
|
||||
'format': '%H:%M',
|
||||
'color': [255, 255, 255]
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'clock',
|
||||
'x': 10,
|
||||
'y': 20,
|
||||
'properties': {
|
||||
'format': '%m/%d',
|
||||
'color': [100, 100, 255]
|
||||
}
|
||||
}
|
||||
]
|
||||
self.create_layout('basic_clock', clock_layout, 'Simple clock with date')
|
||||
|
||||
# Weather layout
|
||||
weather_layout = [
|
||||
{
|
||||
'type': 'weather_icon',
|
||||
'x': 5,
|
||||
'y': 5,
|
||||
'properties': {
|
||||
'condition': 'sunny',
|
||||
'size': 20
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'data_text',
|
||||
'x': 30,
|
||||
'y': 8,
|
||||
'properties': {
|
||||
'data_key': 'weather.temperature',
|
||||
'format': '{value}°',
|
||||
'color': [255, 200, 0],
|
||||
'default': '--°'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'data_text',
|
||||
'x': 30,
|
||||
'y': 18,
|
||||
'properties': {
|
||||
'data_key': 'weather.condition',
|
||||
'format': '{value}',
|
||||
'color': [200, 200, 200],
|
||||
'default': 'Unknown'
|
||||
}
|
||||
}
|
||||
]
|
||||
self.create_layout('weather_display', weather_layout, 'Weather icon with temperature and condition')
|
||||
|
||||
# Mixed dashboard layout
|
||||
dashboard_layout = [
|
||||
{
|
||||
'type': 'clock',
|
||||
'x': 2,
|
||||
'y': 2,
|
||||
'properties': {
|
||||
'format': '%H:%M',
|
||||
'color': [255, 255, 255]
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'weather_icon',
|
||||
'x': 50,
|
||||
'y': 2,
|
||||
'properties': {
|
||||
'size': 16
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'data_text',
|
||||
'x': 70,
|
||||
'y': 5,
|
||||
'properties': {
|
||||
'data_key': 'weather.temperature',
|
||||
'format': '{value}°',
|
||||
'color': [255, 200, 0],
|
||||
'default': '--°'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'line',
|
||||
'x': 0,
|
||||
'y': 15,
|
||||
'properties': {
|
||||
'x2': 128,
|
||||
'y2': 15,
|
||||
'color': [100, 100, 100]
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'data_text',
|
||||
'x': 2,
|
||||
'y': 18,
|
||||
'properties': {
|
||||
'data_key': 'stocks.AAPL.price',
|
||||
'format': 'AAPL: ${value}',
|
||||
'color': [0, 255, 0],
|
||||
'default': 'AAPL: N/A'
|
||||
}
|
||||
}
|
||||
]
|
||||
self.create_layout('dashboard', dashboard_layout, 'Mixed dashboard with clock, weather, and stocks')
|
||||
|
||||
logger.info("Created preset layouts")
|
||||
|
||||
def get_layout_preview(self, layout_name: str) -> Dict[str, Any]:
|
||||
"""Get a preview representation of a layout."""
|
||||
if layout_name not in self.layouts:
|
||||
return {}
|
||||
|
||||
layout = self.layouts[layout_name]
|
||||
elements = layout['elements']
|
||||
|
||||
# Create a simple preview representation
|
||||
preview = {
|
||||
'name': layout_name,
|
||||
'description': layout.get('description', ''),
|
||||
'element_count': len(elements),
|
||||
'elements': []
|
||||
}
|
||||
|
||||
for element in elements:
|
||||
preview['elements'].append({
|
||||
'type': element.get('type'),
|
||||
'position': f"({element.get('x', 0)}, {element.get('y', 0)})",
|
||||
'properties': list(element.get('properties', {}).keys())
|
||||
})
|
||||
|
||||
return preview
|
||||
@@ -15,19 +15,6 @@ import logging
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
_shared_fallback_font_manager: Optional[Any] = None
|
||||
|
||||
|
||||
def _fallback_font_manager() -> Any:
|
||||
"""Shared FontManager for environments (unit tests, mocks) where the
|
||||
plugin manager doesn't carry one. Scans assets/fonts like the real one."""
|
||||
global _shared_fallback_font_manager
|
||||
if _shared_fallback_font_manager is None:
|
||||
from src.font_manager import FontManager
|
||||
_shared_fallback_font_manager = FontManager({})
|
||||
return _shared_fallback_font_manager
|
||||
|
||||
|
||||
class VegasDisplayMode(Enum):
|
||||
"""
|
||||
Display mode for Vegas scroll integration.
|
||||
@@ -143,191 +130,6 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
raise NotImplementedError("Plugins must implement display()")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Adaptive layout support (opt-in)
|
||||
# -------------------------------------------------------------------------
|
||||
@property
|
||||
def layout(self) -> Any:
|
||||
"""
|
||||
LayoutContext for the current logical display size.
|
||||
|
||||
Lazily built and rebuilt automatically when the display size changes
|
||||
(e.g. Vegas segment widths, double-sided logical screens). Provides
|
||||
Region carving (self.layout.bounds), breakpoint tiers, a geometry
|
||||
scale factor vs. the manifest's display.design_size, and fit-text
|
||||
queries against font ladders. See src/adaptive_layout.py.
|
||||
|
||||
Example:
|
||||
rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||
self.draw_fit(big_text, rows[0], ladder=LADDER_ARCADE)
|
||||
self.draw_fit(small_text, rows[1])
|
||||
"""
|
||||
from src.adaptive_layout import LayoutContext
|
||||
|
||||
width = getattr(self.display_manager, "width", None)
|
||||
height = getattr(self.display_manager, "height", None)
|
||||
if not width or not height:
|
||||
matrix = getattr(self.display_manager, "matrix", None)
|
||||
width = getattr(matrix, "width", 128)
|
||||
height = getattr(matrix, "height", 32)
|
||||
|
||||
font_manager = self._get_font_manager()
|
||||
generation = getattr(font_manager, "cache_generation", 0)
|
||||
cached = getattr(self, "_layout_context", None)
|
||||
if (cached is not None
|
||||
and (cached.width, cached.height) == (width, height)
|
||||
and getattr(self, "_layout_font_generation", None) == generation):
|
||||
return cached
|
||||
|
||||
context = LayoutContext(
|
||||
width, height, font_manager,
|
||||
design_size=self._get_design_size(),
|
||||
)
|
||||
self._layout_context = context
|
||||
self._layout_font_generation = generation
|
||||
return context
|
||||
|
||||
def draw_fit(self, text: str, box: Any,
|
||||
color: tuple = (255, 255, 255),
|
||||
ladder: Optional[Any] = None,
|
||||
align: str = "center", valign: str = "center") -> Any:
|
||||
"""
|
||||
Fit text to a Region with the largest crisp font that fits, then draw
|
||||
it aligned within that region via the display manager.
|
||||
|
||||
Args:
|
||||
text: Text to display (ellipsized if even the smallest rung is too wide)
|
||||
box: Region (or (w, h) tuple anchored at 0,0) to fit and align within
|
||||
color: RGB color tuple
|
||||
ladder: FontLadder to walk (default LADDER_GRID; use LADDER_ARCADE
|
||||
for headline text like clocks and scores)
|
||||
align/valign: alignment of the text ink within the box
|
||||
|
||||
Returns:
|
||||
FitResult (font, family, size_px, text, ink metrics, fits flag)
|
||||
"""
|
||||
from src.adaptive_layout import LADDER_DEFAULT, draw_fitted_text
|
||||
|
||||
fit = self.layout.fit_text(text, box, ladder=ladder or LADDER_DEFAULT)
|
||||
draw_fitted_text(self.display_manager, fit, box,
|
||||
color=color, align=align, valign=valign)
|
||||
return fit
|
||||
|
||||
def draw_image(self, img: Any, box: Any, *,
|
||||
mode: str = "contain", align: str = "center",
|
||||
valign: str = "center", crop_to_ink: bool = False,
|
||||
anchor: str = "center", resample: Optional[Any] = None,
|
||||
cache_key: Optional[Any] = None,
|
||||
offset: tuple = (0, 0)) -> Any:
|
||||
"""
|
||||
Fit an image into a Region and paste it aligned within that region
|
||||
onto the display canvas — the image counterpart to draw_fit().
|
||||
|
||||
Args:
|
||||
img: Source PIL image (logos, art, icons)
|
||||
box: Region (or (w, h) tuple) to fit and align within
|
||||
mode: "contain" (letterbox), "cover" (crop-to-fill),
|
||||
"fill_height" (logo-style), "stretch"
|
||||
crop_to_ink: Trim transparent padding before fitting
|
||||
anchor: "center" or "top" for cover crops
|
||||
resample: PIL filter; default LANCZOS. Use RESAMPLE_NEAREST
|
||||
(from src.adaptive_images) for pixel art/flags
|
||||
cache_key: Stable identity (e.g. "logo:KC") for cross-reload
|
||||
caching; defaults to the image object's identity
|
||||
offset: Final (dx, dy) translation — the hook for user
|
||||
x/y-offset customization
|
||||
|
||||
Returns:
|
||||
ImageFitResult (processed image + dimensions + scale)
|
||||
"""
|
||||
from src.adaptive_images import draw_fitted_image
|
||||
|
||||
ifit = self.layout.fit_image(img, box, mode=mode,
|
||||
crop_to_ink=crop_to_ink, anchor=anchor,
|
||||
resample=resample, cache_key=cache_key)
|
||||
draw_fitted_image(self.display_manager, ifit, box,
|
||||
align=align, valign=valign, offset=offset)
|
||||
return ifit
|
||||
|
||||
def _get_font_manager(self) -> Any:
|
||||
"""The shared FontManager, or a module-level fallback when running
|
||||
under mocks/harnesses that don't provide one."""
|
||||
font_manager = getattr(self.plugin_manager, "font_manager", None)
|
||||
if font_manager is not None and hasattr(font_manager, "get_font"):
|
||||
return font_manager
|
||||
return _fallback_font_manager()
|
||||
|
||||
def _get_design_size(self) -> tuple:
|
||||
"""Panel size this plugin's layout was authored against, from the
|
||||
manifest's optional display.design_size (defaults to 128x32)."""
|
||||
from src.adaptive_layout import DEFAULT_DESIGN_SIZE
|
||||
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, "plugin_manifests"):
|
||||
manifest = self.plugin_manager.plugin_manifests.get(self.plugin_id, {})
|
||||
declared = manifest.get("display", {}).get("design_size", {})
|
||||
width, height = declared.get("width"), declared.get("height")
|
||||
if width and height:
|
||||
return (int(width), int(height))
|
||||
return DEFAULT_DESIGN_SIZE
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Element style resolution (per-element user customization)
|
||||
# -------------------------------------------------------------------------
|
||||
@property
|
||||
def style_resolver(self) -> Any:
|
||||
"""
|
||||
ElementStyleResolver for this plugin's customization config.
|
||||
|
||||
Lazily built with the schema defaults from this plugin's own
|
||||
config_schema.json (via the plugin manager's schema manager), so
|
||||
"did the user override this font?" is answered correctly even though
|
||||
saved configs always contain the schema defaults. Rebuilt when the
|
||||
config changes (see on_config_change). Pass it into standalone helper
|
||||
classes (game renderers etc.) instead of letting them build their own.
|
||||
|
||||
See src/element_style.py.
|
||||
"""
|
||||
from src.element_style import ElementStyleResolver
|
||||
|
||||
cached = getattr(self, "_style_resolver", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
schema_defaults: Dict[str, Any] = {}
|
||||
schema_manager = getattr(self.plugin_manager, "schema_manager", None)
|
||||
if schema_manager is not None:
|
||||
try:
|
||||
schema = schema_manager.load_schema(self.plugin_id)
|
||||
if schema:
|
||||
schema_defaults = schema_manager.extract_defaults_from_schema(schema)
|
||||
except Exception as e:
|
||||
self.logger.debug("Schema defaults unavailable for %s: %s",
|
||||
self.plugin_id, e)
|
||||
|
||||
resolver = ElementStyleResolver(self.config, schema_defaults)
|
||||
self._style_resolver = resolver
|
||||
return resolver
|
||||
|
||||
def element_style(self, element_key: str, *, classic_font: str,
|
||||
classic_size: int,
|
||||
classic_color: Optional[tuple] = None) -> Any:
|
||||
"""
|
||||
Resolved font/color/offset for one display element, honoring the
|
||||
user's customization.<element> config. classic_* are this plugin's
|
||||
hardcoded defaults for the element.
|
||||
|
||||
Example:
|
||||
style = self.element_style('title_text',
|
||||
classic_font='PressStart2P-Regular.ttf',
|
||||
classic_size=8)
|
||||
draw.text((x + style.offset[0], y + style.offset[1]),
|
||||
title, font=style.font,
|
||||
fill=style.color or (255, 255, 255))
|
||||
"""
|
||||
return self.style_resolver.style(element_key, classic_font=classic_font,
|
||||
classic_size=classic_size,
|
||||
classic_color=classic_color)
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
"""
|
||||
Get the display duration for this plugin instance.
|
||||
@@ -790,9 +592,6 @@ class BasePlugin(ABC):
|
||||
# Update simple flags
|
||||
self.enabled = self.config.get("enabled", self.enabled)
|
||||
|
||||
# Invalidate the cached style resolver — it captured the old config
|
||||
self._style_resolver = None
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return plugin info for display in web UI.
|
||||
|
||||
@@ -437,7 +437,8 @@ class PluginLoader:
|
||||
if not Path(existing_file).resolve().is_relative_to(resolved_dir):
|
||||
evicted[mod_name] = sys.modules.pop(mod_name)
|
||||
self.logger.debug(
|
||||
"Evicted stale bare-name module '%s' before loading plugin", mod_name,
|
||||
"Evicted stale module '%s' (from %s) before loading plugin in %s",
|
||||
mod_name, existing_file, plugin_dir,
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
@@ -550,7 +551,7 @@ class PluginLoader:
|
||||
plugin_dir_str = str(plugin_dir)
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
self.logger.debug("Added plugin %s's directory to sys.path", plugin_id)
|
||||
self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str)
|
||||
|
||||
# Import the plugin module
|
||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||
@@ -562,8 +563,8 @@ class PluginLoader:
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, entry_file)
|
||||
if spec is None or spec.loader is None:
|
||||
self.logger.error("Could not create module spec for plugin %s", plugin_id)
|
||||
error_msg = f"Could not create module spec for {entry_file}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
@@ -682,55 +683,6 @@ class PluginLoader:
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id) from e
|
||||
|
||||
@staticmethod
|
||||
def _parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||
"""Parse 'X.Y.Z' (extra parts/suffixes ignored) into a comparable
|
||||
3-tuple, or None when unparseable."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
parts = value.strip().lstrip('v').split('.')
|
||||
try:
|
||||
nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]]
|
||||
except ValueError:
|
||||
return None
|
||||
while len(nums) < 3:
|
||||
nums.append(0)
|
||||
return tuple(nums) # type: ignore[return-value]
|
||||
|
||||
def _warn_if_incompatible(self, plugin_id: str, manifest: Dict[str, Any]) -> None:
|
||||
"""Log one warning when a plugin declares a minimum LEDMatrix version
|
||||
newer than the running core. Advisory only — never raises — so a
|
||||
plugin that guards optional features with try/except keeps working.
|
||||
"""
|
||||
declared = (
|
||||
manifest.get('min_ledmatrix_version')
|
||||
or manifest.get('requires', {}).get('min_ledmatrix_version')
|
||||
)
|
||||
if not declared:
|
||||
versions = manifest.get('versions') or []
|
||||
if versions and isinstance(versions[0], dict):
|
||||
declared = (versions[0].get('ledmatrix_min_version')
|
||||
or versions[0].get('ledmatrix_min'))
|
||||
needed = self._parse_semver(declared)
|
||||
if needed is None:
|
||||
return
|
||||
|
||||
from src import __version__ as core_version
|
||||
current = self._parse_semver(core_version)
|
||||
# Anti-spam guard: if the core's own version number is stale (below
|
||||
# the ecosystem floor every shipped plugin declares), comparing would
|
||||
# warn on nearly everything — skip with a debug note instead.
|
||||
if current is None or current < (2, 0, 0):
|
||||
self.logger.debug(
|
||||
"Skipping version compatibility check for %s: core __version__ "
|
||||
"(%s) is below the ecosystem floor", plugin_id, core_version)
|
||||
return
|
||||
if needed > current:
|
||||
self.logger.warning(
|
||||
"Plugin %s declares min LEDMatrix version %s but this core is %s — "
|
||||
"features it relies on may be missing; update the core or expect "
|
||||
"degraded fallbacks", plugin_id, declared, core_version)
|
||||
|
||||
def load_plugin(
|
||||
self,
|
||||
plugin_id: str,
|
||||
@@ -763,8 +715,6 @@ class PluginLoader:
|
||||
Raises:
|
||||
PluginError: If loading fails
|
||||
"""
|
||||
self._warn_if_incompatible(plugin_id, manifest)
|
||||
|
||||
# Install dependencies if needed
|
||||
if install_deps:
|
||||
if plugins_dir is None:
|
||||
|
||||
@@ -110,17 +110,12 @@ class SchemaManager:
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
|
||||
|
||||
# Validate schema structure (basic check)
|
||||
if not isinstance(schema, dict):
|
||||
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
|
||||
return None
|
||||
|
||||
# Expand x-style-elements declarations BEFORE caching, so every
|
||||
# consumer (config form GET, save path, validation, defaults
|
||||
# generation) sees the identical expanded shape.
|
||||
schema = self._expand_style_elements(schema)
|
||||
|
||||
|
||||
# Cache the schema
|
||||
self._schema_cache[plugin_id] = schema
|
||||
|
||||
@@ -137,41 +132,10 @@ class SchemaManager:
|
||||
self.logger.error(f"Error loading schema for {plugin_id}: {e}")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# x-style-elements expansion
|
||||
# ------------------------------------------------------------------
|
||||
# Plugins declare styleable display elements compactly via an
|
||||
# "x-style-elements" object on their customization schema; load_schema
|
||||
# expands each declaration into full font/font_size/text_color/offset
|
||||
# property blocks before caching, so the config form, save path,
|
||||
# validation, and defaults generation all see the same expanded shape.
|
||||
# The single implementation lives in src.element_style (pure, also used
|
||||
# by plugins reading their own schema file) — see that module for the
|
||||
# declaration format.
|
||||
|
||||
@staticmethod
|
||||
def get_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""The x-style-elements declaration from a schema ({} if none)."""
|
||||
from src.element_style import get_style_elements
|
||||
return get_style_elements(schema)
|
||||
|
||||
def _expand_style_elements(self, schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Expand x-style-elements declarations (no-op without any).
|
||||
|
||||
Never raises; on any failure the original schema is returned so a
|
||||
malformed declaration can't take a plugin down.
|
||||
"""
|
||||
try:
|
||||
from src.element_style import expand_style_elements
|
||||
return expand_style_elements(schema)
|
||||
except Exception as e:
|
||||
self.logger.error(f"x-style-elements expansion failed: {e}")
|
||||
return schema
|
||||
|
||||
def invalidate_cache(self, plugin_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
Invalidate schema cache for a plugin or all plugins.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier to invalidate, or None to clear all
|
||||
"""
|
||||
|
||||
@@ -10,11 +10,8 @@ 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 pixel-wise here. The dominant real-world breakage is
|
||||
content that is too wide/tall for a smaller panel, which this catches.
|
||||
As a partial net, draw_text/draw_image calls made with negative coordinates
|
||||
through this manager are recorded in `negative_coordinate_calls` — but draws
|
||||
made directly on the raw PIL canvas remain uncovered.
|
||||
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 = ...),
|
||||
@@ -59,21 +56,6 @@ class BoundsCheckingDisplayManager(VisualTestDisplayManager):
|
||||
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)
|
||||
# (text-or-'image', x, y) for every mediated draw call given a
|
||||
# negative coordinate — PIL clips these silently, so record them.
|
||||
self.negative_coordinate_calls: list = []
|
||||
|
||||
# -- negative-coordinate (left/top overflow) recording --
|
||||
|
||||
def draw_text(self, text, x=None, y=None, *args, **kwargs):
|
||||
if (x is not None and x < 0) or (y is not None and y < 0):
|
||||
self.negative_coordinate_calls.append((text, x, y))
|
||||
return super().draw_text(text, x, y, *args, **kwargs)
|
||||
|
||||
def draw_image(self, image, x, y, *args, **kwargs):
|
||||
if x < 0 or y < 0:
|
||||
self.negative_coordinate_calls.append(('image', x, y))
|
||||
return super().draw_image(image, x, y, *args, **kwargs)
|
||||
|
||||
# -- declared dimensions (override parent's image-derived properties) --
|
||||
|
||||
|
||||
@@ -73,10 +73,6 @@ class RenderResult:
|
||||
golden_ok: Optional[bool] = None
|
||||
golden_diff_pixels: int = 0
|
||||
golden_max_delta: int = 0
|
||||
# fill / scale-up check (populated only for sizes >= 2x the design size)
|
||||
fill_checked: bool = False
|
||||
fill_ok: Optional[bool] = None # False only in strict mode
|
||||
fill_extent: Optional[Tuple[float, float]] = None # (extent_x, extent_y)
|
||||
|
||||
@property
|
||||
def size_label(self) -> str:
|
||||
@@ -90,8 +86,6 @@ class RenderResult:
|
||||
return False
|
||||
if self.golden_checked and self.golden_ok is False:
|
||||
return False
|
||||
if self.fill_ok is False:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -307,74 +301,6 @@ def compare_to_goldens(results: List[RenderResult], golden_dir: Path,
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fill / scale-up check
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Overflow catches content that is too BIG for a panel; nothing catches
|
||||
# content that stays tiny on a panel much larger than the plugin's design
|
||||
# size (e.g. 128x32 content in the corner of a 256x128 renders "green").
|
||||
# These helpers measure how much of the panel the lit content spans so the
|
||||
# harness can flag plugins that don't scale up.
|
||||
|
||||
# A pixel counts as "lit" above this luminance — low enough to catch dim
|
||||
# content, high enough to ignore near-black noise.
|
||||
_LIT_THRESHOLD = 16
|
||||
# Content must span at least this fraction of an axis that is >= 2x the
|
||||
# design size. Lenient on purpose: margins are fine, a tiny corner is not.
|
||||
_MIN_FILL_EXTENT = 0.5
|
||||
|
||||
|
||||
def fill_metrics(image: Image.Image) -> Tuple[float, float, float]:
|
||||
"""Measure lit-content coverage: (extent_x, extent_y, ink_ratio).
|
||||
|
||||
extent_* are the lit bounding box's spans as fractions of the panel;
|
||||
ink_ratio is the fraction of pixels lit (reporting only — sparse pixel
|
||||
fonts legitimately have low ink ratios)."""
|
||||
lit = image.convert("L").point(lambda p: 255 if p > _LIT_THRESHOLD else 0)
|
||||
bbox = lit.getbbox()
|
||||
if bbox is None:
|
||||
return (0.0, 0.0, 0.0)
|
||||
extent_x = (bbox[2] - bbox[0]) / image.width
|
||||
extent_y = (bbox[3] - bbox[1]) / image.height
|
||||
ink = sum(1 for p in lit.getdata() if p) / (image.width * image.height)
|
||||
return (extent_x, extent_y, ink)
|
||||
|
||||
|
||||
def check_scale_up(results: List[RenderResult],
|
||||
design_size: Tuple[int, int] = (128, 32),
|
||||
min_extent: float = _MIN_FILL_EXTENT,
|
||||
strict: bool = False) -> List[RenderResult]:
|
||||
"""Flag renders that leave a big panel mostly empty.
|
||||
|
||||
For each result whose panel is at least 2x the design size on an axis,
|
||||
require the lit content to span >= min_extent of that axis. Mutates the
|
||||
results' fill_* fields. In the default warn-only mode fill_ok is left
|
||||
None (reported, never failing); strict=True sets fill_ok=False, which
|
||||
fails RenderResult.ok — opt in per plugin via harness.json
|
||||
{"fill_check": "strict"} once its adaptive layout is in place.
|
||||
"""
|
||||
design_w, design_h = design_size
|
||||
for r in results:
|
||||
if r.image is None or r.error is not None:
|
||||
continue
|
||||
check_x = r.width >= 2 * design_w
|
||||
check_y = r.height >= 2 * design_h
|
||||
if not (check_x or check_y):
|
||||
continue
|
||||
extent_x, extent_y, _ink = fill_metrics(r.image)
|
||||
r.fill_checked = True
|
||||
r.fill_extent = (round(extent_x, 3), round(extent_y, 3))
|
||||
underfilled = ((check_x and extent_x < min_extent)
|
||||
or (check_y and extent_y < min_extent))
|
||||
if underfilled and strict:
|
||||
r.fill_ok = False
|
||||
elif not underfilled:
|
||||
r.fill_ok = True
|
||||
# warn-only underfill: fill_ok stays None; fill_extent tells the story
|
||||
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
|
||||
|
||||
@@ -56,15 +56,7 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
"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,
|
||||
"fill_check": "warn", # or "strict": underfilled big panels FAIL
|
||||
"variants": [ # extra runs with config overlays and
|
||||
{ # their own golden dirs — e.g. an
|
||||
"name": "adaptive", # opt-in adaptive mode tested beside
|
||||
"config": {"layout_mode": "adaptive"}, # the classic default
|
||||
"golden_dir": "test/golden-adaptive"
|
||||
}
|
||||
]
|
||||
"skip_update": false
|
||||
}
|
||||
Returns {} when no harness.json exists.
|
||||
"""
|
||||
|
||||
@@ -161,13 +161,6 @@ class MockPluginManager:
|
||||
self.plugin_manifests: Dict[str, Dict] = {}
|
||||
self.get_plugin_calls = []
|
||||
self.get_all_plugins_calls = []
|
||||
# Real FontManager so BasePlugin.layout / draw_fit behave identically
|
||||
# under the harness (it only needs assets/fonts on disk).
|
||||
try:
|
||||
from src.font_manager import FontManager
|
||||
self.font_manager: Optional[Any] = FontManager({})
|
||||
except Exception:
|
||||
self.font_manager = None
|
||||
|
||||
def get_plugin(self, plugin_id: str) -> Optional[Any]:
|
||||
"""Get a plugin instance."""
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Headless single-render service for plugins.
|
||||
|
||||
Renders one plugin instance at one panel size to an in-memory PIL image —
|
||||
no hardware, no singletons, no pip (install_deps is always False). Shared
|
||||
by the dev server's /api/render endpoints and the production web UI's
|
||||
config-page live preview.
|
||||
|
||||
A fresh plugin instance is created per call (mirroring the safety
|
||||
harness), so repeated renders never share instance state. The plugin's
|
||||
module does stay imported in the process — module-level globals persist
|
||||
across calls, which is fine for previewing but worth knowing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def render_plugin_once(plugin_id: str, plugin_dir: Path,
|
||||
manifest: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
mock_data: Optional[Dict[str, Any]] = None,
|
||||
width: int = 128, height: int = 32,
|
||||
skip_update: bool = True) -> Dict[str, Any]:
|
||||
"""Render one plugin at one size. Returns a response-shaped dict:
|
||||
|
||||
{'image': 'data:image/png;base64,...', 'width', 'height',
|
||||
'render_time_ms', 'errors': [...], 'warnings': [...]}
|
||||
|
||||
``skip_update`` defaults to True: update() may block on live network
|
||||
(sports APIs, Spotify) — callers that want real data should prime
|
||||
``mock_data`` (e.g. from the plugin's test/harness.json fixture, see
|
||||
``load_harness_spec``) or explicitly pass skip_update=False.
|
||||
|
||||
Raises on plugin load failure; update()/display() exceptions are
|
||||
captured into warnings/errors instead so a broken render still shows
|
||||
whatever was drawn.
|
||||
"""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.testing import (
|
||||
MockCacheManager, MockPluginManager, VisualTestDisplayManager)
|
||||
|
||||
plugin_dir = Path(plugin_dir)
|
||||
if manifest is None:
|
||||
with open(plugin_dir / 'manifest.json', 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
config = config or {'enabled': True}
|
||||
mock_data = mock_data or {}
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
loader = PluginLoader()
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
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=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Exception detail policy (matches the dev server's convention): full
|
||||
# tracebacks go to the server log; clients get only the exception class
|
||||
# name — CodeQL flags raw exception text in responses as stack-trace
|
||||
# exposure, and the log is where developers look anyway.
|
||||
try:
|
||||
if not skip_update:
|
||||
try:
|
||||
plugin_instance.update()
|
||||
except Exception as e:
|
||||
logger.warning("update() raised for plugin %s", plugin_id,
|
||||
exc_info=True)
|
||||
warnings.append(f"update() raised: {type(e).__name__} — see server log")
|
||||
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
except Exception as e:
|
||||
logger.warning("display() raised for plugin %s", plugin_id,
|
||||
exc_info=True)
|
||||
errors.append(f"display() raised: {type(e).__name__} — see server log")
|
||||
|
||||
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
||||
# Capture BEFORE cleanup — a plugin's cleanup may clear the canvas
|
||||
image_b64 = display_manager.get_image_base64()
|
||||
finally:
|
||||
# The instance is throwaway, but its __init__ may have opened
|
||||
# sessions or started threads (music's clients, sports API
|
||||
# sessions). In a long-running web process, previews without
|
||||
# cleanup would leak those per request.
|
||||
try:
|
||||
plugin_instance.cleanup()
|
||||
except Exception as e:
|
||||
logger.warning("cleanup() raised for plugin %s", plugin_id,
|
||||
exc_info=True)
|
||||
warnings.append(f"cleanup() raised: {type(e).__name__} — see server log")
|
||||
|
||||
return {
|
||||
'image': f'data:image/png;base64,{image_b64}',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'render_time_ms': render_time_ms,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
}
|
||||
@@ -28,7 +28,6 @@ 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
|
||||
(96, 48), # non-64x32-module panel (e.g. Waveshare), off-grid dims
|
||||
(128, 64), # 2x2 — block, icon scaling / vertical centering
|
||||
(256, 32), # 4x1 — long strip, wide horizontal layout
|
||||
(128, 96), # 2x3 — tall, exercises vertical overflow
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Tests for adaptive image fitting (src/adaptive_images.py) and the
|
||||
LayoutContext image cache."""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.adaptive_images import (
|
||||
RESAMPLE_LANCZOS,
|
||||
RESAMPLE_NEAREST,
|
||||
ImageFitResult,
|
||||
draw_fitted_image,
|
||||
fit_image,
|
||||
)
|
||||
from src.adaptive_layout import LayoutContext, Region
|
||||
from src.font_manager import FontManager
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def font_manager():
|
||||
return FontManager({})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(font_manager):
|
||||
return LayoutContext(128, 32, font_manager)
|
||||
|
||||
|
||||
def _solid(w, h, color=(255, 0, 0, 255)):
|
||||
return Image.new("RGBA", (w, h), color)
|
||||
|
||||
|
||||
def _padded_logo(ink_w=10, ink_h=10, pad=10):
|
||||
"""Transparent canvas with a solid ink block in the middle — models a
|
||||
logo shipped with generous transparent padding."""
|
||||
img = Image.new("RGBA", (ink_w + 2 * pad, ink_h + 2 * pad), (0, 0, 0, 0))
|
||||
img.paste(_solid(ink_w, ink_h), (pad, pad))
|
||||
return img
|
||||
|
||||
|
||||
class TestFitModes:
|
||||
def test_contain_letterboxes_and_upscales(self):
|
||||
fit = fit_image(_solid(10, 5), (40, 40))
|
||||
assert (fit.width, fit.height) == (40, 20) # aspect preserved
|
||||
assert fit.scale == 4.0
|
||||
|
||||
def test_contain_no_upscale(self):
|
||||
fit = fit_image(_solid(10, 5), (40, 40), upscale=False)
|
||||
assert (fit.width, fit.height) == (10, 5)
|
||||
assert fit.scale == 1.0
|
||||
|
||||
def test_cover_fills_and_crops(self):
|
||||
fit = fit_image(_solid(10, 20), (40, 40), mode="cover")
|
||||
assert (fit.width, fit.height) == (40, 40)
|
||||
|
||||
def test_cover_top_anchor(self):
|
||||
# top half red, bottom half blue; cover-crop a wide box with top anchor
|
||||
img = Image.new("RGBA", (20, 40), (0, 0, 255, 255))
|
||||
img.paste(_solid(20, 20, (255, 0, 0, 255)), (0, 0))
|
||||
fit = fit_image(img, (20, 20), mode="cover", anchor="top")
|
||||
assert fit.image.getpixel((10, 5))[:3] == (255, 0, 0) # kept the top
|
||||
|
||||
def test_fill_height_matches_box_height(self):
|
||||
fit = fit_image(_solid(10, 10), (64, 32), mode="fill_height")
|
||||
assert fit.height == 32 and fit.width == 32
|
||||
|
||||
def test_fill_height_capped_by_width(self):
|
||||
# very wide source: height-fill would overflow the box width
|
||||
fit = fit_image(_solid(100, 10), (40, 32), mode="fill_height")
|
||||
assert fit.width <= 40
|
||||
|
||||
def test_stretch_exact(self):
|
||||
fit = fit_image(_solid(3, 7), (25, 13), mode="stretch")
|
||||
assert (fit.width, fit.height) == (25, 13)
|
||||
|
||||
def test_crop_to_ink(self):
|
||||
fit = fit_image(_padded_logo(), (30, 30), crop_to_ink=True)
|
||||
# 10x10 ink upscaled to fill 30x30 (padding would have kept it small)
|
||||
assert (fit.width, fit.height) == (30, 30)
|
||||
no_crop = fit_image(_padded_logo(), (30, 30), crop_to_ink=False)
|
||||
assert no_crop.width == 30 # whole padded canvas scaled instead
|
||||
|
||||
def test_fully_transparent_source(self):
|
||||
img = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
|
||||
fit = fit_image(img, (20, 20), crop_to_ink=True)
|
||||
assert fit.is_empty
|
||||
|
||||
def test_degenerate_box(self):
|
||||
assert fit_image(_solid(10, 10), (0, 20)).is_empty
|
||||
assert fit_image(_solid(10, 10), Region(0, 0, 20, 0)).is_empty
|
||||
|
||||
def test_output_always_rgba(self):
|
||||
rgb = Image.new("RGB", (10, 10), (1, 2, 3))
|
||||
assert fit_image(rgb, (20, 20)).image.mode == "RGBA"
|
||||
|
||||
def test_nearest_keeps_hard_edges(self):
|
||||
# 2x2 checker scaled 8x: NEAREST keeps pure colors, LANCZOS blends
|
||||
img = Image.new("RGBA", (2, 2), (0, 0, 0, 255))
|
||||
img.putpixel((0, 0), (255, 255, 255, 255))
|
||||
near = fit_image(img, (16, 16), mode="stretch", resample=RESAMPLE_NEAREST)
|
||||
colors = {near.image.getpixel((x, y))[:3] for x in range(16) for y in range(16)}
|
||||
assert colors == {(255, 255, 255), (0, 0, 0)}
|
||||
|
||||
def test_unknown_mode_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
fit_image(_solid(4, 4), (8, 8), mode="tile")
|
||||
|
||||
|
||||
class TestDrawFittedImage:
|
||||
class _DM:
|
||||
def __init__(self, w=64, h=32):
|
||||
self.image = Image.new("RGB", (w, h), (0, 0, 0))
|
||||
|
||||
def test_pastes_aligned_in_region(self):
|
||||
dm = self._DM()
|
||||
box = Region(10, 4, 20, 20)
|
||||
fit = fit_image(_solid(10, 10), box)
|
||||
xy = draw_fitted_image(dm, fit, box)
|
||||
assert xy == box.align_xy(fit.width, fit.height)
|
||||
assert dm.image.getpixel((xy[0] + 1, xy[1] + 1)) == (255, 0, 0)
|
||||
|
||||
def test_offset_translates(self):
|
||||
dm = self._DM()
|
||||
box = Region(0, 0, 20, 20)
|
||||
fit = fit_image(_solid(10, 10), box)
|
||||
x, y = draw_fitted_image(dm, fit, box, align="left", valign="top",
|
||||
offset=(3, 5))
|
||||
assert (x, y) == (3, 5)
|
||||
|
||||
def test_empty_fit_noops(self):
|
||||
dm = self._DM()
|
||||
fit = fit_image(_solid(10, 10), (0, 0))
|
||||
assert draw_fitted_image(dm, fit, Region(0, 0, 10, 10)) is None
|
||||
|
||||
|
||||
class TestContextImageCache:
|
||||
def test_size_keyed_hit_and_miss(self, ctx):
|
||||
img = _solid(10, 10)
|
||||
a = ctx.fit_image(img, (20, 20), cache_key="logo:A")
|
||||
assert ctx.fit_image(img, (20, 20), cache_key="logo:A") is a
|
||||
b = ctx.fit_image(img, (30, 30), cache_key="logo:A")
|
||||
assert b is not a and b.width == 30 # different box size = new entry
|
||||
|
||||
def test_id_keyed_default(self, ctx):
|
||||
img = _solid(10, 10)
|
||||
a = ctx.fit_image(img, (20, 20))
|
||||
assert ctx.fit_image(img, (20, 20)) is a
|
||||
|
||||
def test_id_safety_pins_source(self, ctx):
|
||||
# id()-keyed entries must pin the source image so a recycled id
|
||||
# can't alias a dead image's cache entry.
|
||||
img = _solid(10, 10)
|
||||
ctx.fit_image(img, (20, 20))
|
||||
pinned = [entry[1] for entry in ctx._image_cache.values()]
|
||||
assert img in pinned
|
||||
|
||||
def test_cache_key_entries_do_not_pin(self, ctx):
|
||||
img = _solid(10, 10)
|
||||
ctx.fit_image(img, (20, 20), cache_key="logo:X")
|
||||
key = next(k for k in ctx._image_cache if k[1] == "logo:X")
|
||||
assert ctx._image_cache[key][1] is None
|
||||
|
||||
def test_lru_eviction(self, ctx):
|
||||
for i in range(ctx._IMAGE_CACHE_MAX + 5):
|
||||
ctx.fit_image(_solid(4, 4), (8, 8), cache_key=f"k{i}")
|
||||
assert len(ctx._image_cache) == ctx._IMAGE_CACHE_MAX
|
||||
assert not any(k[1] == "k0" for k in ctx._image_cache) # oldest evicted
|
||||
|
||||
def test_clear_cache_clears_images(self, ctx):
|
||||
ctx.fit_image(_solid(4, 4), (8, 8), cache_key="k")
|
||||
ctx.clear_cache()
|
||||
assert len(ctx._image_cache) == 0
|
||||
|
||||
|
||||
class TestBasePluginDrawImage:
|
||||
def test_draw_image_end_to_end(self):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||
)
|
||||
|
||||
class _P(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
plugin = _P("t", {}, MockDisplayManager(64, 32),
|
||||
MockCacheManager(), MockPluginManager())
|
||||
logo = _padded_logo()
|
||||
box = plugin.layout.bounds.left_col(32)
|
||||
ifit = plugin.draw_image(logo, box, mode="fill_height",
|
||||
crop_to_ink=True, cache_key="logo:T")
|
||||
assert ifit.height == 32
|
||||
# pasted onto the mock's canvas
|
||||
assert plugin.display_manager.image.getpixel((16, 16)) != (0, 0, 0)
|
||||
|
||||
|
||||
class TestResultIndependence:
|
||||
def test_same_size_fit_never_aliases_the_source(self):
|
||||
"""LayoutContext caches ImageFitResults — an aliased image would let
|
||||
later mutations of the source corrupt cached fits (or vice versa)."""
|
||||
from PIL import ImageDraw
|
||||
src = Image.new("RGBA", (20, 20), (255, 0, 0, 255))
|
||||
fit = fit_image(src, (20, 20))
|
||||
assert fit.image is not src
|
||||
ImageDraw.Draw(src).rectangle([0, 0, 19, 19], fill=(0, 255, 0, 255))
|
||||
assert fit.image.getpixel((5, 5)) == (255, 0, 0, 255)
|
||||
@@ -1,454 +0,0 @@
|
||||
"""Tests for the adaptive layout system (src/adaptive_layout.py)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.adaptive_layout import (
|
||||
DEFAULT_DESIGN_SIZE,
|
||||
LADDER_ARCADE,
|
||||
LADDER_GRID,
|
||||
LayoutContext,
|
||||
Region,
|
||||
draw_fitted_text,
|
||||
measure_font_crispness,
|
||||
measure_ink,
|
||||
media_row,
|
||||
scoreboard_regions,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||
from src.font_manager import FontManager
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def font_manager():
|
||||
"""Real FontManager over assets/fonts — the ladders depend on it."""
|
||||
return FontManager({})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(font_manager):
|
||||
return LayoutContext(128, 32, font_manager)
|
||||
|
||||
|
||||
class TestRegion:
|
||||
"""Pure integer rect algebra."""
|
||||
|
||||
def test_bands_partition_without_overlap(self):
|
||||
r = Region(0, 0, 128, 32)
|
||||
top = r.top_band(7)
|
||||
bottom = r.bottom_band(7)
|
||||
middle = r.middle(7, 7)
|
||||
assert top.bottom == middle.y
|
||||
assert middle.bottom == bottom.y
|
||||
assert top.h + middle.h + bottom.h == r.h
|
||||
|
||||
def test_bands_clamp_on_short_panel(self):
|
||||
# The classic failure: y=1 top band and y=height-7 bottom band
|
||||
# overlapping on a short panel. Bands can't exceed the region.
|
||||
r = Region(0, 0, 32, 8)
|
||||
assert r.top_band(16).h == 8
|
||||
assert r.bottom_band(16).h == 8
|
||||
assert r.middle(8, 8).h == 0 # degenerate, never negative
|
||||
|
||||
def test_split_v_weights_sum_to_height(self):
|
||||
r = Region(0, 0, 64, 33)
|
||||
rows = r.split_v(3, 1, 1, gap=1)
|
||||
assert len(rows) == 3
|
||||
assert sum(row.h for row in rows) == 33 - 2 # two 1px gaps
|
||||
assert rows[0].h > rows[1].h
|
||||
assert rows[-1].bottom == r.bottom
|
||||
|
||||
def test_split_h_columns_advance(self):
|
||||
r = Region(0, 0, 100, 32)
|
||||
cols = r.split_h(1, 1, gap=2)
|
||||
assert cols[0].right + 2 == cols[1].x
|
||||
assert cols[1].right == r.right
|
||||
|
||||
def test_degenerate_sizes_never_negative(self):
|
||||
for w, h in [(8, 8), (32, 16), (1, 1)]:
|
||||
r = Region(0, 0, w, h).inset(4)
|
||||
assert r.w >= 0 and r.h >= 0
|
||||
for sub in r.split_v(1, 1) + r.split_h(1, 1, gap=3):
|
||||
assert sub.w >= 0 and sub.h >= 0
|
||||
|
||||
def test_align_xy(self):
|
||||
r = Region(10, 10, 100, 20)
|
||||
assert r.align_xy(20, 10, "left", "top") == (10, 10)
|
||||
assert r.align_xy(20, 10, "right", "bottom") == (90, 20)
|
||||
assert r.align_xy(20, 10) == (50, 15)
|
||||
|
||||
def test_left_right_cols(self):
|
||||
r = Region(0, 0, 128, 32)
|
||||
assert r.left_col(32) == Region(0, 0, 32, 32)
|
||||
assert r.right_col(32) == Region(96, 0, 32, 32)
|
||||
|
||||
def test_offset_translates_without_resizing(self):
|
||||
r = Region(5, 5, 20, 10).offset(3, -2)
|
||||
assert r == Region(8, 3, 20, 10)
|
||||
|
||||
|
||||
class TestScoreboardRegions:
|
||||
@pytest.mark.parametrize("w,h", DEFAULT_TEST_SIZES + [(8, 8)])
|
||||
def test_invariants_at_all_sizes(self, w, h):
|
||||
regs = scoreboard_regions(Region(0, 0, w, h))
|
||||
assert regs.logo_slot <= min(h, w // 2)
|
||||
# slots hug the edges and never overlap the center column
|
||||
assert regs.away_slot.x == 0 and regs.home_slot.right == w
|
||||
assert regs.away_slot.right <= regs.center_col.x or regs.center_col.w == 0
|
||||
assert regs.center_col.right <= regs.home_slot.x or regs.center_col.w == 0
|
||||
# bands stack inside the center column without overlap
|
||||
assert regs.status_band.bottom <= regs.score_area.y or regs.score_area.h == 0
|
||||
assert regs.score_area.bottom <= regs.detail_band.y or regs.score_area.h == 0
|
||||
# everything within bounds, nothing negative
|
||||
for reg in (regs.away_slot, regs.home_slot, regs.center_col,
|
||||
regs.status_band, regs.score_area, regs.detail_band,
|
||||
regs.bottom_left, regs.bottom_right):
|
||||
assert reg.w >= 0 and reg.h >= 0
|
||||
assert reg.x >= 0 and reg.y >= 0
|
||||
assert reg.right <= w and reg.bottom <= h
|
||||
|
||||
@pytest.mark.parametrize("w,h", [(96, 48), (128, 64), (256, 128), (64, 32), (128, 96)])
|
||||
def test_2to1_aspect_gets_a_center_reserve(self, w, h):
|
||||
"""These sizes are all <= 2:1 aspect, where the raw min(h, w//2)
|
||||
formula claims the entire width for logos and leaves zero pixels
|
||||
for a center column — the bug this reserve exists to fix."""
|
||||
regs = scoreboard_regions(Region(0, 0, w, h))
|
||||
assert regs.center_col.w >= int(w * 0.15) - 1 # -1 for int() rounding
|
||||
|
||||
@pytest.mark.parametrize("w,h", [(128, 32), (192, 48), (256, 32)])
|
||||
def test_wide_panels_unaffected_by_center_reserve(self, w, h):
|
||||
"""Wide (>= ~4:1) panels already have height as the tighter
|
||||
constraint, so the center reserve must be a no-op there — the
|
||||
design-size baseline's proportions shouldn't shift."""
|
||||
regs = scoreboard_regions(Region(0, 0, w, h))
|
||||
assert regs.logo_slot == min(h, w // 2)
|
||||
|
||||
def test_center_reserve_fraction_is_configurable(self):
|
||||
# min_center_design_px=0 isolates the fraction term (otherwise the
|
||||
# scaled absolute floor can dominate and mask a fraction change).
|
||||
regs_default = scoreboard_regions(Region(0, 0, 128, 64), min_center_design_px=0)
|
||||
regs_wider = scoreboard_regions(Region(0, 0, 128, 64), min_center_fraction=0.5,
|
||||
min_center_design_px=0)
|
||||
assert regs_wider.center_col.w > regs_default.center_col.w
|
||||
assert regs_wider.logo_slot < regs_default.logo_slot
|
||||
|
||||
def test_score_bleed_extends_past_center_col(self):
|
||||
regs = scoreboard_regions(Region(0, 0, 128, 64), score_bleed_fraction=0.5)
|
||||
assert regs.score_area.w > regs.center_col.w
|
||||
assert regs.score_area.x < regs.center_col.x
|
||||
assert regs.score_area.right > regs.center_col.right
|
||||
|
||||
def test_score_bleed_zero_matches_center_col(self):
|
||||
regs = scoreboard_regions(Region(0, 0, 128, 64), score_bleed_fraction=0.0)
|
||||
assert regs.score_area.w == regs.center_col.w
|
||||
assert regs.score_area.x == regs.center_col.x
|
||||
|
||||
@pytest.mark.parametrize("w,h", [(64, 32), (96, 48), (128, 64), (256, 128), (128, 96)])
|
||||
def test_score_never_needs_ellipsis_for_a_short_score(self, w, h, font_manager):
|
||||
"""The concrete regression this whole reserve/bleed system exists to
|
||||
prevent: a real game score like '17-21' must always render in full,
|
||||
never truncated, at every 2:1-or-tighter aspect ratio in the sample."""
|
||||
ctx = LayoutContext(w, h, font_manager)
|
||||
regs = scoreboard_regions(Region(0, 0, w, h), ctx=ctx)
|
||||
height_scale = h / 32.0
|
||||
fit = ctx.fit_text_proportional("17-21", regs.score_area, base_size_px=10,
|
||||
ladder=LADDER_ARCADE, scale=height_scale)
|
||||
assert fit.text == "17-21"
|
||||
assert fit.fits
|
||||
|
||||
def test_ctx_scales_band_heights(self, font_manager):
|
||||
small = scoreboard_regions(Region(0, 0, 128, 32),
|
||||
ctx=LayoutContext(128, 32, font_manager))
|
||||
big = scoreboard_regions(Region(0, 0, 256, 64),
|
||||
ctx=LayoutContext(256, 64, font_manager))
|
||||
assert big.status_band.h > small.status_band.h
|
||||
|
||||
def test_works_on_offset_card_region(self):
|
||||
card = Region(10, 4, 100, 24)
|
||||
regs = scoreboard_regions(card)
|
||||
assert regs.away_slot.x == 10
|
||||
assert regs.home_slot.right == card.right
|
||||
|
||||
|
||||
class TestMediaRow:
|
||||
def test_square_art_plus_body(self):
|
||||
row = media_row(Region(0, 0, 128, 32))
|
||||
assert row.art == Region(0, 0, 32, 32)
|
||||
assert row.body.x == 32 + 2 and row.body.right == 128
|
||||
|
||||
def test_non_square(self):
|
||||
row = media_row(Region(0, 0, 100, 20), square=False, gap=4)
|
||||
assert row.art.w == 50
|
||||
assert row.body.x == 54
|
||||
|
||||
def test_narrow_panel_clamps(self):
|
||||
row = media_row(Region(0, 0, 16, 32))
|
||||
assert row.art.w == 16 and row.body.w == 0
|
||||
|
||||
|
||||
class TestLayoutContext:
|
||||
def test_tiers(self, font_manager):
|
||||
assert LayoutContext(128, 32, font_manager).tier == "sm"
|
||||
assert LayoutContext(96, 48, font_manager).tier == "md"
|
||||
assert LayoutContext(128, 64, font_manager).tier == "lg"
|
||||
assert LayoutContext(64, 16, font_manager).tier == "xs"
|
||||
assert LayoutContext(256, 128, font_manager).tier == "xl"
|
||||
|
||||
def test_wide_short_flag(self, font_manager):
|
||||
assert LayoutContext(128, 32, font_manager).is_wide_short
|
||||
assert not LayoutContext(128, 64, font_manager).is_wide_short
|
||||
|
||||
def test_scale_against_design_size(self, font_manager):
|
||||
assert LayoutContext(128, 32, font_manager).scale == 1.0
|
||||
assert LayoutContext(256, 64, font_manager).scale == 2.0
|
||||
# min() of the two axes: don't overscale the constrained one
|
||||
assert LayoutContext(256, 32, font_manager).scale == 1.0
|
||||
assert DEFAULT_DESIGN_SIZE == (128, 32)
|
||||
|
||||
def test_px_scales_and_clamps(self, font_manager):
|
||||
big = LayoutContext(256, 64, font_manager)
|
||||
assert big.px(4) == 8
|
||||
assert big.px(4, maximum=6) == 6
|
||||
tiny = LayoutContext(32, 16, font_manager)
|
||||
assert tiny.px(4, minimum=2) == 2
|
||||
|
||||
def test_by_tier_nearest_at_or_below(self, font_manager):
|
||||
mapping = {"sm": 10, "lg": 18}
|
||||
assert LayoutContext(128, 32, font_manager).by_tier(mapping) == 10
|
||||
assert LayoutContext(96, 48, font_manager).by_tier(mapping) == 10 # md -> sm
|
||||
assert LayoutContext(128, 64, font_manager).by_tier(mapping) == 18
|
||||
assert LayoutContext(256, 128, font_manager).by_tier(mapping) == 18 # xl -> lg
|
||||
# nothing at-or-below: fall forward to smallest defined above
|
||||
assert LayoutContext(64, 16, font_manager).by_tier(mapping) == 10
|
||||
|
||||
|
||||
class TestFontFitting:
|
||||
def test_ladder_monotonic(self, font_manager):
|
||||
"""Each ladder rung must render no taller than the one before it."""
|
||||
for ladder in (LADDER_GRID, LADDER_ARCADE):
|
||||
heights = []
|
||||
for step in ladder:
|
||||
font = font_manager.get_font(step.family, step.size_px)
|
||||
heights.append(measure_ink("Ay0", font)[1])
|
||||
assert heights == sorted(heights, reverse=True), (
|
||||
f"ladder not monotonically shrinking: {heights}")
|
||||
|
||||
def test_ladder_grid_is_crisp(self, font_manager):
|
||||
"""LADDER_GRID's BDF fonts are real bitmaps — always 0% antialiased."""
|
||||
for step in LADDER_GRID:
|
||||
font = font_manager.get_font(step.family, step.size_px)
|
||||
assert measure_font_crispness(font, "Ay0") == 0.0
|
||||
|
||||
def test_ladder_arcade_is_crisp(self, font_manager):
|
||||
"""PressStart2P only rasterizes without antialiasing at exact
|
||||
multiples of its 8px design grid — every LADDER_ARCADE rung must
|
||||
land on one."""
|
||||
for step in LADDER_ARCADE:
|
||||
assert step.size_px % 8 == 0, f"{step} is not a multiple of 8"
|
||||
font = font_manager.get_font(step.family, step.size_px)
|
||||
assert measure_font_crispness(font, "17-21") == 0.0
|
||||
|
||||
def test_crispness_catches_a_bad_size(self, font_manager):
|
||||
"""Sanity check the measurement itself: a known-bad size for a
|
||||
pixel-grid font must NOT read as crisp."""
|
||||
font = font_manager.get_font("press_start", 10) # not a multiple of 8
|
||||
assert measure_font_crispness(font, "17-21") > 0.1
|
||||
|
||||
def test_fit_text_grows_on_taller_panel(self, font_manager):
|
||||
small = LayoutContext(64, 32, font_manager)
|
||||
large = LayoutContext(128, 64, font_manager)
|
||||
text = "12:34"
|
||||
fit_small = small.fit_text(text, small.bounds, ladder=LADDER_ARCADE)
|
||||
fit_large = large.fit_text(text, large.bounds, ladder=LADDER_ARCADE)
|
||||
assert fit_small.fits and fit_large.fits
|
||||
assert fit_large.size_px > fit_small.size_px
|
||||
|
||||
def test_fit_text_fits_the_box(self, ctx):
|
||||
box = ctx.bounds.inset(1)
|
||||
fit = ctx.fit_text("HELLO WORLD", box)
|
||||
assert fit.fits
|
||||
assert fit.width <= box.w and fit.height <= box.h
|
||||
|
||||
def test_fit_text_ellipsizes_overlong_text(self, font_manager):
|
||||
tiny = LayoutContext(32, 16, font_manager)
|
||||
fit = tiny.fit_text("SUPERCALIFRAGILISTIC", tiny.bounds)
|
||||
assert fit.text != "SUPERCALIFRAGILISTIC"
|
||||
assert fit.text.endswith("…")
|
||||
assert fit.width <= tiny.bounds.w
|
||||
|
||||
def test_fit_text_cached(self, ctx):
|
||||
first = ctx.fit_text("CACHED", ctx.bounds)
|
||||
second = ctx.fit_text("CACHED", ctx.bounds)
|
||||
assert first is second
|
||||
ctx.clear_cache()
|
||||
assert ctx.fit_text("CACHED", ctx.bounds) is not first
|
||||
|
||||
def test_fit_text_proportional_tracks_design_scale(self, font_manager):
|
||||
# design size 128x32, base_size_px=10 (a typical classic score size):
|
||||
# at 2x scale the target is 20px -> nearest LADDER_ARCADE rung <= 20
|
||||
# is 16px, not the largest that merely fits the box (32).
|
||||
ctx = LayoutContext(256, 64, font_manager) # scale = min(2,2) = 2
|
||||
fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
assert fit.size_px == 16
|
||||
|
||||
def test_fit_text_proportional_does_not_exceed_max_fit(self, ctx):
|
||||
# at scale=1 (128x32, the design size itself) the target equals
|
||||
# base_size_px, so proportional should never pick something LARGER
|
||||
# than plain fit_text would for the same box.
|
||||
prop = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
maxed = ctx.fit_text("17-21", ctx.bounds, ladder=LADDER_ARCADE)
|
||||
assert prop.size_px <= maxed.size_px
|
||||
|
||||
def test_fit_text_proportional_floors_at_smallest_rung(self, font_manager):
|
||||
# scale so small the target is below every rung -> use the smallest
|
||||
# rung as a floor rather than refusing to render anything.
|
||||
ctx = LayoutContext(32, 8, font_manager) # scale = min(32/128, 8/32) = 0.25
|
||||
fit = ctx.fit_text_proportional("HI", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
assert fit.size_px == min(s.size_px for s in LADDER_ARCADE)
|
||||
|
||||
def test_fit_text_proportional_falls_through_when_target_rung_overflows(self, font_manager):
|
||||
# a long string at the target rung might not fit a narrow box even
|
||||
# though the target size is "correct" -- must fall through to a
|
||||
# smaller rung exactly like fit_text does, not just refuse to fit.
|
||||
ctx = LayoutContext(256, 64, font_manager)
|
||||
narrow_box = Region(0, 0, 40, 64)
|
||||
fit = ctx.fit_text_proportional("A REALLY LONG STRING HERE", narrow_box,
|
||||
base_size_px=10, ladder=LADDER_ARCADE)
|
||||
assert fit.fits or fit.text.endswith("…")
|
||||
|
||||
def test_fit_text_proportional_cached(self, ctx):
|
||||
first = ctx.fit_text_proportional("X", ctx.bounds, base_size_px=10)
|
||||
second = ctx.fit_text_proportional("X", ctx.bounds, base_size_px=10)
|
||||
assert first is second
|
||||
|
||||
def test_fit_text_proportional_scale_override(self, font_manager):
|
||||
# 128x64 vs design 128x32: self.scale (min of both axes) is 1.0
|
||||
# since width didn't grow, but a caller whose composition scales by
|
||||
# HEIGHT alone (e.g. logo_slot = min(h, w//2)) should be able to
|
||||
# override the reference scale so text grows with it too.
|
||||
ctx = LayoutContext(128, 64, font_manager)
|
||||
assert ctx.scale == 1.0
|
||||
default_fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
height_scale = 64 / 32 # matches design height
|
||||
scaled_fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE, scale=height_scale)
|
||||
assert scaled_fit.size_px > default_fit.size_px
|
||||
|
||||
def test_fit_lines_stacks_within_height(self, ctx):
|
||||
box = ctx.bounds
|
||||
lines = ["LINE ONE", "LINE TWO", "LINE THREE"]
|
||||
fit = ctx.fit_lines(lines, box, spacing=1)
|
||||
assert fit.fits
|
||||
assert 3 * fit.line_height + 2 <= box.h
|
||||
|
||||
def test_font_for_rows(self, ctx):
|
||||
fit = ctx.font_for_rows(4, 32)
|
||||
assert fit.fits
|
||||
assert 4 * fit.line_height <= 32
|
||||
|
||||
def test_ellipsize_returns_original_when_it_fits(self, ctx):
|
||||
font = ctx.font_manager.get_font("4x6", 6)
|
||||
assert ctx.ellipsize("HI", font, 1000) == "HI"
|
||||
|
||||
|
||||
class TestDrawFittedText:
|
||||
def test_draws_within_region(self, ctx):
|
||||
calls = []
|
||||
|
||||
class _DM:
|
||||
def draw_text(self, text, x=None, y=None, color=None, font=None):
|
||||
calls.append((text, x, y))
|
||||
|
||||
box = Region(10, 4, 100, 24)
|
||||
fit = ctx.fit_text("SCORE", box)
|
||||
draw_fitted_text(_DM(), fit, box)
|
||||
text, x, y = calls[0]
|
||||
assert text == "SCORE"
|
||||
assert box.x <= x <= box.right - fit.width
|
||||
# the ink (y + y_offset .. + height) must land inside the box
|
||||
assert box.y <= y + fit.y_offset
|
||||
assert y + fit.y_offset + fit.height <= box.bottom
|
||||
|
||||
|
||||
class TestBasePluginIntegration:
|
||||
def test_layout_property_and_draw_fit(self):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||
)
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
plugin = _Plugin("test-plugin", {}, MockDisplayManager(96, 48),
|
||||
MockCacheManager(), MockPluginManager())
|
||||
assert (plugin.layout.width, plugin.layout.height) == (96, 48)
|
||||
assert plugin.layout is plugin.layout # cached
|
||||
fit = plugin.draw_fit("HELLO", plugin.layout.bounds.inset(1))
|
||||
assert fit.fits
|
||||
assert plugin.display_manager.draw_calls # actually drew
|
||||
|
||||
def test_layout_rebuilds_on_size_change(self):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||
)
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
dm = MockDisplayManager(128, 32)
|
||||
plugin = _Plugin("test-plugin", {}, dm,
|
||||
MockCacheManager(), MockPluginManager())
|
||||
assert plugin.layout.tier == "sm"
|
||||
dm.width, dm.height = 128, 64
|
||||
assert plugin.layout.tier == "lg"
|
||||
|
||||
def test_design_size_from_manifest(self):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||
)
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
pm = MockPluginManager()
|
||||
pm.plugin_manifests["test-plugin"] = {
|
||||
"display": {"design_size": {"width": 64, "height": 32}}
|
||||
}
|
||||
plugin = _Plugin("test-plugin", {}, MockDisplayManager(128, 64),
|
||||
MockCacheManager(), pm)
|
||||
assert plugin.layout.design_size == (64, 32)
|
||||
assert plugin.layout.scale == 2.0
|
||||
|
||||
|
||||
class TestFitCacheBound:
|
||||
def test_fit_cache_is_lru_bounded(self, ctx):
|
||||
"""A plugin fitting changing text (live game clock, ticker) on a
|
||||
24/7 service must not grow the fit cache without bound."""
|
||||
for i in range(ctx._FIT_CACHE_MAX + 100):
|
||||
ctx.fit_text(f"tick {i}", Region(0, 0, 100, 20))
|
||||
assert len(ctx._fit_cache) <= ctx._FIT_CACHE_MAX
|
||||
|
||||
def test_lru_keeps_recent_entries_hot(self, ctx):
|
||||
hot = ctx.fit_text("stay hot", Region(0, 0, 100, 20))
|
||||
for i in range(ctx._FIT_CACHE_MAX - 1):
|
||||
ctx.fit_text(f"cold {i}", Region(0, 0, 100, 20))
|
||||
ctx.fit_text("stay hot", Region(0, 0, 100, 20)) # keep touching it
|
||||
assert ctx.fit_text("stay hot", Region(0, 0, 100, 20)) is hot
|
||||
@@ -1,519 +0,0 @@
|
||||
"""Tests for src/element_style.py — universal per-element style resolution.
|
||||
|
||||
The load-bearing behavior is the user-override check: saved configs ALWAYS
|
||||
contain the schema defaults (merge_with_defaults runs at save time and again
|
||||
before plugin instantiation), so "key present" must never be read as "user
|
||||
set it". Only "present and different from the schema default" counts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from PIL import ImageFont
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from src.element_style import ( # noqa: E402
|
||||
ElementStyle,
|
||||
ElementStyleResolver,
|
||||
FONT_ALIASES,
|
||||
defaults_from_schema_file,
|
||||
extract_schema_defaults,
|
||||
load_font,
|
||||
resolve_font_name,
|
||||
)
|
||||
|
||||
PRESS_START = "PressStart2P-Regular.ttf"
|
||||
FOUR_BY_SIX = "4x6-font.ttf"
|
||||
FIVE_BY_SEVEN_BDF = "5x7.bdf"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_font
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLoadFont:
|
||||
def test_ttf(self):
|
||||
font = load_font(PRESS_START, 8)
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font.size == 8
|
||||
|
||||
def test_bdf_at_native_size(self):
|
||||
"""FreeType loads BDF strikes directly at their native size."""
|
||||
font = load_font(FIVE_BY_SEVEN_BDF, 7)
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
|
||||
def test_bdf_at_wrong_size_falls_back(self):
|
||||
"""BDF fonts are fixed-size; a non-native size falls back to the
|
||||
fallback font at the requested size rather than raising."""
|
||||
font = load_font(FIVE_BY_SEVEN_BDF, 14)
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font.size == 14 # fallback font honored the requested size
|
||||
|
||||
def test_alias_resolves(self):
|
||||
assert resolve_font_name("press_start") == PRESS_START
|
||||
font = load_font("press_start", 16)
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font.size == 16
|
||||
|
||||
def test_filename_passes_through_alias(self):
|
||||
assert resolve_font_name(FOUR_BY_SIX) == FOUR_BY_SIX
|
||||
|
||||
def test_missing_file_falls_back(self):
|
||||
font = load_font("no-such-font.ttf", 10)
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font.size == 10
|
||||
|
||||
@pytest.mark.parametrize("garbage", ["", None, "../../etc/passwd", "x" * 300])
|
||||
def test_garbage_never_raises(self, garbage):
|
||||
font = load_font(garbage, 8)
|
||||
assert font is not None
|
||||
|
||||
def test_everything_missing_uses_pil_default(self):
|
||||
font = load_font("nope.ttf", 8, fonts_dir="/nonexistent",
|
||||
fallback_font="also-nope.ttf")
|
||||
assert font is not None # ImageFont.load_default()
|
||||
|
||||
def test_aliases_cover_the_baseball_set(self):
|
||||
"""The centralized map must be a superset of the per-plugin copies
|
||||
it replaces (baseball game_renderer.py + sports.py)."""
|
||||
assert FONT_ALIASES["press_start"] == PRESS_START
|
||||
assert FONT_ALIASES["four_by_six"] == FOUR_BY_SIX
|
||||
assert FONT_ALIASES["five_by_seven"] == FIVE_BY_SEVEN_BDF
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# user_forced provenance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCHEMA_DEFAULTS = {
|
||||
"customization": {
|
||||
"score_text": {"font": PRESS_START, "font_size": 10},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _style(config, defaults=SCHEMA_DEFAULTS):
|
||||
return ElementStyleResolver(config, defaults).style(
|
||||
"score_text", classic_font=PRESS_START, classic_size=10)
|
||||
|
||||
|
||||
class TestUserForced:
|
||||
def test_absent_is_not_forced(self):
|
||||
style = _style({})
|
||||
assert not style.user_forced
|
||||
assert style.font_name == PRESS_START
|
||||
assert style.font_size == 10
|
||||
|
||||
def test_schema_default_present_is_not_forced(self):
|
||||
"""THE bug this module exists to fix: the save flow writes schema
|
||||
defaults into every saved config, so their presence means nothing."""
|
||||
config = {"customization": {"score_text": {
|
||||
"font": PRESS_START, "font_size": 10}}}
|
||||
style = _style(config)
|
||||
assert not style.user_forced
|
||||
|
||||
def test_different_font_is_forced(self):
|
||||
config = {"customization": {"score_text": {
|
||||
"font": FOUR_BY_SIX, "font_size": 10}}}
|
||||
style = _style(config)
|
||||
assert style.user_forced_font
|
||||
assert not style.user_forced_size
|
||||
assert style.user_forced
|
||||
|
||||
def test_different_size_is_forced(self):
|
||||
config = {"customization": {"score_text": {
|
||||
"font": PRESS_START, "font_size": 14}}}
|
||||
style = _style(config)
|
||||
assert style.user_forced_size
|
||||
assert not style.user_forced_font
|
||||
assert style.font_size == 14
|
||||
|
||||
def test_string_size_equal_to_default_is_not_forced(self):
|
||||
config = {"customization": {"score_text": {"font_size": "10"}}}
|
||||
assert not _style(config).user_forced
|
||||
|
||||
def test_without_schema_defaults_compares_against_classic(self):
|
||||
"""Degraded mode (old cores, tests): classic_* is the reference."""
|
||||
config = {"customization": {"score_text": {
|
||||
"font": PRESS_START, "font_size": 10}}}
|
||||
style = _style(config, defaults={})
|
||||
assert not style.user_forced
|
||||
forced = _style({"customization": {"score_text": {"font_size": 12}}},
|
||||
defaults={})
|
||||
assert forced.user_forced
|
||||
|
||||
def test_schema_default_differing_from_classic_wins_as_reference(self):
|
||||
"""When the schema declares a different default than the classic_*
|
||||
args, the schema is the reference — a config equal to the schema
|
||||
default is untouched."""
|
||||
defaults = {"customization": {"score_text": {
|
||||
"font": FOUR_BY_SIX, "font_size": 6}}}
|
||||
config = {"customization": {"score_text": {
|
||||
"font": FOUR_BY_SIX, "font_size": 6}}}
|
||||
style = ElementStyleResolver(config, defaults).style(
|
||||
"score_text", classic_font=PRESS_START, classic_size=10)
|
||||
assert not style.user_forced
|
||||
|
||||
def test_unknown_element_uses_classic(self):
|
||||
style = ElementStyleResolver({}, SCHEMA_DEFAULTS).style(
|
||||
"no_such_element", classic_font=FOUR_BY_SIX, classic_size=6)
|
||||
assert not style.user_forced
|
||||
assert style.font_name == FOUR_BY_SIX
|
||||
assert style.font_size == 6
|
||||
|
||||
def test_malformed_customization_is_tolerated(self):
|
||||
for bad in [{"customization": "oops"},
|
||||
{"customization": {"score_text": "oops"}},
|
||||
{"customization": {"score_text": {"font_size": "huge"}}},
|
||||
None]:
|
||||
style = _style(bad)
|
||||
assert not style.user_forced
|
||||
assert style.font_size == 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# color
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestColor:
|
||||
"""Color provenance mirrors fonts: the web form ALWAYS posts the RGB
|
||||
inputs, so a saved config carries the schema-default color whether or
|
||||
not the user touched it. Only a value differing from the schema default
|
||||
is an override; otherwise the plugin's classic color survives — critical
|
||||
for state-dependent colors (a score that turns gold on a touchdown)."""
|
||||
|
||||
COLOR_DEFAULTS = {"customization": {"score_text": {
|
||||
"font": PRESS_START, "font_size": 10, "text_color": [255, 255, 255]}}}
|
||||
|
||||
def _color_style(self, config, defaults=None, classic_color=(255, 215, 0)):
|
||||
return ElementStyleResolver(config, defaults or self.COLOR_DEFAULTS).style(
|
||||
"score_text", classic_font=PRESS_START, classic_size=10,
|
||||
classic_color=classic_color)
|
||||
|
||||
def test_absent_returns_classic_color(self):
|
||||
style = self._color_style({})
|
||||
assert style.color == (255, 215, 0)
|
||||
assert not style.user_forced_color
|
||||
|
||||
def test_absent_with_no_classic_is_none(self):
|
||||
assert _style({}).color is None
|
||||
|
||||
def test_schema_default_present_keeps_classic_color(self):
|
||||
"""A saved config always contains the default — it must not clobber
|
||||
the plugin's (possibly semantic) classic color."""
|
||||
config = {"customization": {"score_text": {"text_color": [255, 255, 255]}}}
|
||||
style = self._color_style(config)
|
||||
assert style.color == (255, 215, 0)
|
||||
assert not style.user_forced_color
|
||||
|
||||
def test_changed_color_is_an_override(self):
|
||||
config = {"customization": {"score_text": {"text_color": [0, 128, 255]}}}
|
||||
style = self._color_style(config)
|
||||
assert style.color == (0, 128, 255)
|
||||
assert style.user_forced_color
|
||||
|
||||
def test_present_without_schema_default_is_an_override(self):
|
||||
"""Hand-written schemas without a text_color default: presence is
|
||||
intent (there is nothing to compare against)."""
|
||||
config = {"customization": {"score_text": {"text_color": [0, 128, 255]}}}
|
||||
style = self._color_style(config, defaults=SCHEMA_DEFAULTS)
|
||||
assert style.color == (0, 128, 255)
|
||||
assert style.user_forced_color
|
||||
|
||||
def test_values_clamped(self):
|
||||
config = {"customization": {"score_text": {"text_color": [300, -5, 128]}}}
|
||||
assert self._color_style(config).color == (255, 0, 128)
|
||||
|
||||
def test_color_never_affects_user_forced_sizing(self):
|
||||
config = {"customization": {"score_text": {"text_color": [0, 128, 255]}}}
|
||||
style = self._color_style(config)
|
||||
assert style.user_forced_color
|
||||
assert not style.user_forced
|
||||
|
||||
@pytest.mark.parametrize("bad", [[1, 2], [1, 2, 3, 4], "red",
|
||||
["a", "b", "c"], 255, None])
|
||||
def test_malformed_falls_back_to_classic(self, bad):
|
||||
config = {"customization": {"score_text": {"text_color": bad}}}
|
||||
style = self._color_style(config, classic_color=(1, 2, 3))
|
||||
assert style.color == (1, 2, 3)
|
||||
assert not style.user_forced_color
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# offsets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOffsets:
|
||||
def test_unset_is_zero(self):
|
||||
assert ElementStyleResolver({}).offset("score") == (0, 0)
|
||||
|
||||
def test_layout_section(self):
|
||||
"""The deployed sports convention: customization.layout.<element>."""
|
||||
config = {"customization": {"layout": {"score": {
|
||||
"x_offset": 3, "y_offset": -2}}}}
|
||||
assert ElementStyleResolver(config).offset("score") == (3, -2)
|
||||
|
||||
def test_element_section_fallback(self):
|
||||
config = {"customization": {"score": {"x_offset": 5}}}
|
||||
assert ElementStyleResolver(config).offset("score") == (5, 0)
|
||||
|
||||
def test_layout_section_wins_over_element_section(self):
|
||||
config = {"customization": {
|
||||
"layout": {"score": {"x_offset": 1}},
|
||||
"score": {"x_offset": 9, "y_offset": 9},
|
||||
}}
|
||||
resolver = ElementStyleResolver(config)
|
||||
assert resolver.offset_value("score", "x_offset") == 1
|
||||
# y_offset absent from layout section -> element section supplies it
|
||||
assert resolver.offset_value("score", "y_offset") == 9
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(2, 2), (2.7, 2), ("3", 3), ("2.0", 2), ("-4", -4),
|
||||
(None, 0), ("junk", 0), ([], 0), (True, 0),
|
||||
])
|
||||
def test_coercion_matches_sports_helper(self, raw, expected):
|
||||
"""Same tolerance as the sports.py/_get_layout_offset copies this
|
||||
replaces: int/float/numeric-string pass, anything else -> default."""
|
||||
config = {"customization": {"layout": {"e": {"x_offset": raw}}}}
|
||||
assert ElementStyleResolver(config).offset_value("e", "x_offset") == expected
|
||||
|
||||
def test_custom_axis_names(self):
|
||||
"""Football's records use away_x_offset/home_x_offset."""
|
||||
config = {"customization": {"layout": {"records": {
|
||||
"away_x_offset": 4, "home_x_offset": -4}}}}
|
||||
resolver = ElementStyleResolver(config)
|
||||
assert resolver.offset_value("records", "away_x_offset") == 4
|
||||
assert resolver.offset_value("records", "home_x_offset") == -4
|
||||
|
||||
def test_style_carries_offset(self):
|
||||
config = {"customization": {"layout": {"score_text": {
|
||||
"x_offset": 2, "y_offset": 1}}}}
|
||||
assert _style(config).offset == (2, 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# caching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCaching:
|
||||
def test_same_call_is_cached(self):
|
||||
resolver = ElementStyleResolver({}, SCHEMA_DEFAULTS)
|
||||
a = resolver.style("score_text", classic_font=PRESS_START, classic_size=10)
|
||||
b = resolver.style("score_text", classic_font=PRESS_START, classic_size=10)
|
||||
assert a is b
|
||||
|
||||
def test_clear_cache(self):
|
||||
resolver = ElementStyleResolver({}, SCHEMA_DEFAULTS)
|
||||
a = resolver.style("score_text", classic_font=PRESS_START, classic_size=10)
|
||||
resolver.clear_cache()
|
||||
b = resolver.style("score_text", classic_font=PRESS_START, classic_size=10)
|
||||
assert a is not b
|
||||
# PIL fonts compare by identity; compare the value fields
|
||||
assert (a.font_name, a.font_size, a.color, a.offset, a.user_forced) == \
|
||||
(b.font_name, b.font_size, b.color, b.offset, b.user_forced)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# schema default extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSchemaDefaults:
|
||||
def test_matches_schema_manager_extraction(self):
|
||||
"""The pure helper must agree with SchemaManager.extract_defaults_from_schema
|
||||
on a real plugin-style schema — it exists so plugins get the same
|
||||
answer in harness contexts where the schema manager is absent."""
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"score_text": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"font": {"type": "string", "default": PRESS_START},
|
||||
"font_size": {"type": "integer", "default": 10},
|
||||
"y_percent": {"type": "number"},
|
||||
},
|
||||
},
|
||||
"layout": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"score": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x_offset": {"type": "integer", "default": 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"opaque_with_default": {"type": "object", "default": {},
|
||||
"properties": {"x": {"default": 1}}},
|
||||
# array shapes the schema manager special-cases — parity
|
||||
# must hold for these too (found divergent in review)
|
||||
"plain_array": {"type": "array", "items": {"type": "string"}},
|
||||
"obj_array": {"type": "array", "items": {
|
||||
"type": "object", "properties": {"x": {"default": 1}}}},
|
||||
"item_default_array": {"type": "array",
|
||||
"items": {"type": "string", "default": "a"}},
|
||||
},
|
||||
}
|
||||
pure = extract_schema_defaults(schema)
|
||||
managed = SchemaManager().extract_defaults_from_schema(schema)
|
||||
assert pure == managed
|
||||
assert pure["customization"]["score_text"]["font"] == PRESS_START
|
||||
# object-level default short-circuits recursion (both must agree)
|
||||
assert pure["opaque_with_default"] == {}
|
||||
assert pure["plain_array"] == []
|
||||
assert pure["obj_array"] == []
|
||||
assert pure["item_default_array"] == ["a"]
|
||||
|
||||
def test_defaults_from_schema_file(self, tmp_path):
|
||||
schema_path = tmp_path / "config_schema.json"
|
||||
schema_path.write_text(json.dumps({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title_text": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"font": {"type": "string", "default": PRESS_START},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
defaults = defaults_from_schema_file(str(schema_path))
|
||||
assert defaults["customization"]["title_text"]["font"] == PRESS_START
|
||||
|
||||
def test_defaults_from_missing_or_bad_file(self, tmp_path):
|
||||
assert defaults_from_schema_file("/nonexistent/schema.json") == {}
|
||||
bad = tmp_path / "bad.json"
|
||||
bad.write_text("{not json")
|
||||
assert defaults_from_schema_file(str(bad)) == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BasePlugin integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _StubSchemaManager:
|
||||
"""Schema manager double exposing the two methods the resolver path uses."""
|
||||
|
||||
def __init__(self, schema):
|
||||
self._schema = schema
|
||||
|
||||
def load_schema(self, plugin_id, use_cache=True):
|
||||
return self._schema
|
||||
|
||||
def extract_defaults_from_schema(self, schema, prefix=""):
|
||||
# Mirror the real nested-dict extraction for this simple shape
|
||||
def walk(props):
|
||||
out = {}
|
||||
for key, spec in props.get("properties", {}).items():
|
||||
if "default" in spec:
|
||||
out[key] = spec["default"]
|
||||
elif spec.get("type") == "object" and "properties" in spec:
|
||||
nested = walk(spec)
|
||||
if nested:
|
||||
out[key] = nested
|
||||
return out
|
||||
return walk(schema)
|
||||
|
||||
|
||||
def _make_plugin(config, schema=None):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockPluginManager)
|
||||
from src.plugin_system.testing.visual_display_manager import (
|
||||
VisualTestDisplayManager)
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
return True
|
||||
|
||||
def display(self, force_clear=False):
|
||||
return None
|
||||
|
||||
plugin_manager = MockPluginManager()
|
||||
if schema is not None:
|
||||
plugin_manager.schema_manager = _StubSchemaManager(schema)
|
||||
return _Plugin("test-plugin", config,
|
||||
VisualTestDisplayManager(64, 32),
|
||||
MockCacheManager(), plugin_manager)
|
||||
|
||||
|
||||
TEST_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"score_text": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"font": {"type": "string", "default": PRESS_START},
|
||||
"font_size": {"type": "integer", "default": 10},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestBasePluginIntegration:
|
||||
def test_element_style_with_schema_defaults(self):
|
||||
"""The full path: saved config carries schema defaults, plugin's
|
||||
element_style still reports not-forced."""
|
||||
config = {"enabled": True, "customization": {"score_text": {
|
||||
"font": PRESS_START, "font_size": 10}}}
|
||||
plugin = _make_plugin(config, schema=TEST_SCHEMA)
|
||||
style = plugin.element_style("score_text", classic_font=PRESS_START,
|
||||
classic_size=10)
|
||||
assert not style.user_forced
|
||||
|
||||
def test_element_style_detects_real_override(self):
|
||||
config = {"enabled": True, "customization": {"score_text": {
|
||||
"font": PRESS_START, "font_size": 14}}}
|
||||
plugin = _make_plugin(config, schema=TEST_SCHEMA)
|
||||
style = plugin.element_style("score_text", classic_font=PRESS_START,
|
||||
classic_size=10)
|
||||
assert style.user_forced_size
|
||||
assert style.font_size == 14
|
||||
|
||||
def test_works_without_schema_manager(self):
|
||||
"""MockPluginManager has no schema_manager attribute by default —
|
||||
the resolver degrades to classic-default comparison, no crash."""
|
||||
config = {"enabled": True, "customization": {"score_text": {
|
||||
"font_size": 12}}}
|
||||
plugin = _make_plugin(config, schema=None)
|
||||
style = plugin.element_style("score_text", classic_font=PRESS_START,
|
||||
classic_size=10)
|
||||
assert style.user_forced_size # 12 != classic 10
|
||||
|
||||
def test_resolver_is_cached_and_invalidated_on_config_change(self):
|
||||
plugin = _make_plugin({"enabled": True}, schema=TEST_SCHEMA)
|
||||
first = plugin.style_resolver
|
||||
assert plugin.style_resolver is first
|
||||
plugin.on_config_change({"enabled": True, "customization": {
|
||||
"score_text": {"font_size": 14}}})
|
||||
second = plugin.style_resolver
|
||||
assert second is not first
|
||||
style = plugin.element_style("score_text", classic_font=PRESS_START,
|
||||
classic_size=10)
|
||||
assert style.user_forced_size
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Tests for the harness fill / scale-up check (src/plugin_system/testing/harness.py)."""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.plugin_system.testing.harness import (
|
||||
RenderResult,
|
||||
check_scale_up,
|
||||
fill_metrics,
|
||||
)
|
||||
|
||||
|
||||
def _canvas(w, h):
|
||||
return Image.new("RGB", (w, h), (0, 0, 0))
|
||||
|
||||
|
||||
def _with_block(w, h, bx, by, bw, bh, color=(255, 255, 255)):
|
||||
img = _canvas(w, h)
|
||||
img.paste(Image.new("RGB", (bw, bh), color), (bx, by))
|
||||
return img
|
||||
|
||||
|
||||
def _result(w, h, image):
|
||||
return RenderResult("p", w, h, "mode", image=image)
|
||||
|
||||
|
||||
class TestFillMetrics:
|
||||
def test_full_white(self):
|
||||
ex, ey, ink = fill_metrics(Image.new("RGB", (64, 32), (255, 255, 255)))
|
||||
assert (ex, ey, ink) == (1.0, 1.0, 1.0)
|
||||
|
||||
def test_black_is_empty(self):
|
||||
assert fill_metrics(_canvas(64, 32)) == (0.0, 0.0, 0.0)
|
||||
|
||||
def test_corner_dot(self):
|
||||
ex, ey, ink = fill_metrics(_with_block(100, 100, 0, 0, 10, 10))
|
||||
assert ex == 0.1 and ey == 0.1
|
||||
assert ink == 0.01
|
||||
|
||||
def test_centered_half(self):
|
||||
ex, ey, _ = fill_metrics(_with_block(100, 100, 25, 25, 50, 50))
|
||||
assert ex == 0.5 and ey == 0.5
|
||||
|
||||
def test_dim_pixels_ignored(self):
|
||||
img = _canvas(10, 10)
|
||||
img.putpixel((5, 5), (10, 10, 10)) # below the lit threshold
|
||||
assert fill_metrics(img) == (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
class TestCheckScaleUp:
|
||||
def test_not_checked_below_2x(self):
|
||||
# 128x64 vs design 128x32: only height is 2x -> checked on y only;
|
||||
# 128x32 itself: not checked at all
|
||||
r = _result(128, 32, _with_block(128, 32, 0, 0, 10, 10))
|
||||
check_scale_up([r], design_size=(128, 32))
|
||||
assert not r.fill_checked
|
||||
|
||||
def test_warn_mode_records_but_passes(self):
|
||||
# tiny corner content on a 256x128 (2x both axes)
|
||||
r = _result(256, 128, _with_block(256, 128, 0, 0, 20, 20))
|
||||
check_scale_up([r], design_size=(128, 32), strict=False)
|
||||
assert r.fill_checked
|
||||
assert r.fill_ok is None # warn-only: not a failure
|
||||
assert r.ok # still passes
|
||||
assert r.fill_extent[0] < 0.5
|
||||
|
||||
def test_strict_mode_fails_underfill(self):
|
||||
r = _result(256, 128, _with_block(256, 128, 0, 0, 20, 20))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is False
|
||||
assert not r.ok
|
||||
|
||||
def test_well_filled_passes_strict(self):
|
||||
r = _result(256, 128, _with_block(256, 128, 10, 10, 200, 100))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is True and r.ok
|
||||
|
||||
def test_axis_selection_wide_only(self):
|
||||
# 256x32 vs design 128x32: width is 2x, height is not -> only the
|
||||
# x-extent matters; content spanning full width but few rows passes
|
||||
r = _result(256, 32, _with_block(256, 32, 0, 12, 250, 8))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is True
|
||||
|
||||
def test_axis_selection_wide_only_underfill(self):
|
||||
r = _result(256, 32, _with_block(256, 32, 0, 12, 60, 8))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is False
|
||||
|
||||
def test_errored_render_skipped(self):
|
||||
r = RenderResult("p", 256, 128, "m", error="boom")
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert not r.fill_checked
|
||||
|
||||
def test_custom_design_size(self):
|
||||
# 128x64 with design 64x32 IS 2x both axes
|
||||
r = _result(128, 64, _with_block(128, 64, 0, 0, 10, 10))
|
||||
check_scale_up([r], design_size=(64, 32), strict=False)
|
||||
assert r.fill_checked
|
||||
@@ -1,392 +0,0 @@
|
||||
"""
|
||||
Tests for LayoutManager.
|
||||
|
||||
Tests layout creation, management, rendering, and element positioning.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from src.layout_manager import LayoutManager
|
||||
|
||||
|
||||
class TestLayoutManager:
|
||||
"""Test LayoutManager functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_layout_file(self, tmp_path):
|
||||
"""Create a temporary layout file."""
|
||||
layout_file = tmp_path / "custom_layouts.json"
|
||||
return str(layout_file)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_display_manager(self):
|
||||
"""Create a mock display manager."""
|
||||
dm = MagicMock()
|
||||
dm.clear = MagicMock()
|
||||
dm.update_display = MagicMock()
|
||||
dm.draw_text = MagicMock()
|
||||
dm.draw_weather_icon = MagicMock()
|
||||
dm.small_font = MagicMock()
|
||||
dm.regular_font = MagicMock()
|
||||
return dm
|
||||
|
||||
@pytest.fixture
|
||||
def layout_manager(self, tmp_layout_file, mock_display_manager):
|
||||
"""Create a LayoutManager instance."""
|
||||
return LayoutManager(
|
||||
display_manager=mock_display_manager,
|
||||
config_path=tmp_layout_file
|
||||
)
|
||||
|
||||
def test_init(self, tmp_layout_file, mock_display_manager):
|
||||
"""Test LayoutManager initialization."""
|
||||
lm = LayoutManager(
|
||||
display_manager=mock_display_manager,
|
||||
config_path=tmp_layout_file
|
||||
)
|
||||
|
||||
assert lm.display_manager == mock_display_manager
|
||||
assert lm.config_path == tmp_layout_file
|
||||
assert lm.layouts == {}
|
||||
assert lm.current_layout is None
|
||||
|
||||
def test_load_layouts_file_exists(self, tmp_path, mock_display_manager):
|
||||
"""Test loading layouts from existing file."""
|
||||
layout_file = tmp_path / "custom_layouts.json"
|
||||
layout_data = {
|
||||
"test_layout": {
|
||||
"elements": [{"type": "text", "x": 0, "y": 0}],
|
||||
"description": "Test layout"
|
||||
}
|
||||
}
|
||||
with open(layout_file, 'w') as f:
|
||||
json.dump(layout_data, f)
|
||||
|
||||
lm = LayoutManager(
|
||||
display_manager=mock_display_manager,
|
||||
config_path=str(layout_file)
|
||||
)
|
||||
|
||||
assert "test_layout" in lm.layouts
|
||||
assert lm.layouts["test_layout"]["description"] == "Test layout"
|
||||
|
||||
def test_load_layouts_file_not_exists(self, tmp_layout_file, mock_display_manager):
|
||||
"""Test loading layouts when file doesn't exist."""
|
||||
lm = LayoutManager(
|
||||
display_manager=mock_display_manager,
|
||||
config_path=tmp_layout_file
|
||||
)
|
||||
|
||||
assert lm.layouts == {}
|
||||
|
||||
def test_create_layout(self, layout_manager):
|
||||
"""Test creating a new layout."""
|
||||
elements = [{"type": "text", "x": 10, "y": 20, "properties": {"text": "Hello"}}]
|
||||
|
||||
result = layout_manager.create_layout("test_layout", elements, "Test description")
|
||||
|
||||
assert result is True
|
||||
assert "test_layout" in layout_manager.layouts
|
||||
assert layout_manager.layouts["test_layout"]["elements"] == elements
|
||||
assert layout_manager.layouts["test_layout"]["description"] == "Test description"
|
||||
assert "created" in layout_manager.layouts["test_layout"]
|
||||
assert "modified" in layout_manager.layouts["test_layout"]
|
||||
|
||||
def test_update_layout(self, layout_manager):
|
||||
"""Test updating an existing layout."""
|
||||
# Create a layout first
|
||||
elements1 = [{"type": "text", "x": 0, "y": 0}]
|
||||
layout_manager.create_layout("test_layout", elements1, "Original")
|
||||
|
||||
# Update it
|
||||
elements2 = [{"type": "text", "x": 10, "y": 20}]
|
||||
result = layout_manager.update_layout("test_layout", elements2, "Updated")
|
||||
|
||||
assert result is True
|
||||
assert layout_manager.layouts["test_layout"]["elements"] == elements2
|
||||
assert layout_manager.layouts["test_layout"]["description"] == "Updated"
|
||||
assert "modified" in layout_manager.layouts["test_layout"]
|
||||
|
||||
def test_update_layout_not_exists(self, layout_manager):
|
||||
"""Test updating a non-existent layout."""
|
||||
elements = [{"type": "text", "x": 0, "y": 0}]
|
||||
result = layout_manager.update_layout("nonexistent", elements)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_delete_layout(self, layout_manager):
|
||||
"""Test deleting a layout."""
|
||||
elements = [{"type": "text", "x": 0, "y": 0}]
|
||||
layout_manager.create_layout("test_layout", elements)
|
||||
|
||||
result = layout_manager.delete_layout("test_layout")
|
||||
|
||||
assert result is True
|
||||
assert "test_layout" not in layout_manager.layouts
|
||||
|
||||
def test_delete_layout_not_exists(self, layout_manager):
|
||||
"""Test deleting a non-existent layout."""
|
||||
result = layout_manager.delete_layout("nonexistent")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_get_layout(self, layout_manager):
|
||||
"""Test getting a specific layout."""
|
||||
elements = [{"type": "text", "x": 0, "y": 0}]
|
||||
layout_manager.create_layout("test_layout", elements)
|
||||
|
||||
layout = layout_manager.get_layout("test_layout")
|
||||
|
||||
assert layout is not None
|
||||
assert layout["elements"] == elements
|
||||
|
||||
def test_get_layout_not_exists(self, layout_manager):
|
||||
"""Test getting a non-existent layout."""
|
||||
layout = layout_manager.get_layout("nonexistent")
|
||||
|
||||
assert layout == {}
|
||||
|
||||
def test_list_layouts(self, layout_manager):
|
||||
"""Test listing all layouts."""
|
||||
layout_manager.create_layout("layout1", [])
|
||||
layout_manager.create_layout("layout2", [])
|
||||
layout_manager.create_layout("layout3", [])
|
||||
|
||||
layouts = layout_manager.list_layouts()
|
||||
|
||||
assert len(layouts) == 3
|
||||
assert "layout1" in layouts
|
||||
assert "layout2" in layouts
|
||||
assert "layout3" in layouts
|
||||
|
||||
def test_set_current_layout(self, layout_manager):
|
||||
"""Test setting the current layout."""
|
||||
layout_manager.create_layout("test_layout", [])
|
||||
|
||||
result = layout_manager.set_current_layout("test_layout")
|
||||
|
||||
assert result is True
|
||||
assert layout_manager.current_layout == "test_layout"
|
||||
|
||||
def test_set_current_layout_not_exists(self, layout_manager):
|
||||
"""Test setting a non-existent layout as current."""
|
||||
result = layout_manager.set_current_layout("nonexistent")
|
||||
|
||||
assert result is False
|
||||
assert layout_manager.current_layout is None
|
||||
|
||||
def test_render_layout(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a layout."""
|
||||
elements = [
|
||||
{"type": "text", "x": 0, "y": 0, "properties": {"text": "Hello"}},
|
||||
{"type": "text", "x": 10, "y": 10, "properties": {"text": "World"}}
|
||||
]
|
||||
layout_manager.create_layout("test_layout", elements)
|
||||
|
||||
result = layout_manager.render_layout("test_layout")
|
||||
|
||||
assert result is True
|
||||
mock_display_manager.clear.assert_called_once()
|
||||
mock_display_manager.update_display.assert_called_once()
|
||||
assert mock_display_manager.draw_text.call_count == 2
|
||||
|
||||
def test_render_layout_no_display_manager(self, tmp_layout_file):
|
||||
"""Test rendering without display manager."""
|
||||
lm = LayoutManager(display_manager=None, config_path=tmp_layout_file)
|
||||
lm.create_layout("test_layout", [])
|
||||
|
||||
result = lm.render_layout("test_layout")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_render_layout_not_exists(self, layout_manager):
|
||||
"""Test rendering a non-existent layout."""
|
||||
result = layout_manager.render_layout("nonexistent")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_render_element_text(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a text element."""
|
||||
element = {
|
||||
"type": "text",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"text": "Hello",
|
||||
"color": [255, 0, 0],
|
||||
"font_size": "small"
|
||||
}
|
||||
}
|
||||
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
mock_display_manager.draw_text.assert_called_once()
|
||||
call_args = mock_display_manager.draw_text.call_args
|
||||
assert call_args[0][0] == "Hello" # text
|
||||
assert call_args[0][1] == 10 # x
|
||||
assert call_args[0][2] == 20 # y
|
||||
|
||||
def test_render_element_weather_icon(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a weather icon element."""
|
||||
element = {
|
||||
"type": "weather_icon",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"condition": "sunny",
|
||||
"size": 16
|
||||
}
|
||||
}
|
||||
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
mock_display_manager.draw_weather_icon.assert_called_once_with("sunny", 10, 20, 16)
|
||||
|
||||
def test_render_element_weather_icon_from_context(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering weather icon with data from context."""
|
||||
element = {
|
||||
"type": "weather_icon",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {"size": 16}
|
||||
}
|
||||
data_context = {
|
||||
"weather": {
|
||||
"condition": "cloudy"
|
||||
}
|
||||
}
|
||||
|
||||
layout_manager.render_element(element, data_context)
|
||||
|
||||
mock_display_manager.draw_weather_icon.assert_called_once_with("cloudy", 10, 20, 16)
|
||||
|
||||
def test_render_element_rectangle(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a rectangle element."""
|
||||
element = {
|
||||
"type": "rectangle",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"width": 50,
|
||||
"height": 30,
|
||||
"color": [255, 0, 0],
|
||||
"filled": True
|
||||
}
|
||||
}
|
||||
|
||||
# Mock the draw object and rectangle method
|
||||
mock_draw = MagicMock()
|
||||
mock_display_manager.draw = mock_draw
|
||||
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
# Verify rectangle was drawn
|
||||
mock_draw.rectangle.assert_called_once()
|
||||
|
||||
def test_render_element_unknown_type(self, layout_manager):
|
||||
"""Test rendering an unknown element type."""
|
||||
element = {
|
||||
"type": "unknown_type",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"properties": {}
|
||||
}
|
||||
|
||||
# Should not raise an exception
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
def test_process_template_text(self, layout_manager):
|
||||
"""Test template text processing."""
|
||||
text = "Hello {name}, temperature is {temp}°F"
|
||||
data_context = {
|
||||
"name": "World",
|
||||
"temp": 72
|
||||
}
|
||||
|
||||
result = layout_manager._process_template_text(text, data_context)
|
||||
|
||||
assert result == "Hello World, temperature is 72°F"
|
||||
|
||||
def test_process_template_text_no_context(self, layout_manager):
|
||||
"""Test template text with missing context."""
|
||||
text = "Hello {name}"
|
||||
data_context = {}
|
||||
|
||||
result = layout_manager._process_template_text(text, data_context)
|
||||
|
||||
# Should leave template as-is or handle gracefully
|
||||
assert "{name}" in result or result == "Hello "
|
||||
|
||||
def test_save_layouts_error_handling(self, layout_manager):
|
||||
"""Test error handling when saving layouts."""
|
||||
# Create a layout
|
||||
layout_manager.create_layout("test", [])
|
||||
|
||||
# Make save fail by using invalid path
|
||||
layout_manager.config_path = "/nonexistent/directory/layouts.json"
|
||||
|
||||
result = layout_manager.save_layouts()
|
||||
|
||||
# Should handle error gracefully
|
||||
assert result is False
|
||||
|
||||
def test_render_element_line(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a line element."""
|
||||
element = {
|
||||
"type": "line",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"x2": 50,
|
||||
"y2": 30,
|
||||
"color": [255, 0, 0],
|
||||
"width": 2
|
||||
}
|
||||
}
|
||||
|
||||
mock_draw = MagicMock()
|
||||
mock_display_manager.draw = mock_draw
|
||||
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
mock_draw.line.assert_called_once()
|
||||
|
||||
def test_render_element_clock(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a clock element."""
|
||||
element = {
|
||||
"type": "clock",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"format": "%H:%M",
|
||||
"color": [255, 255, 255]
|
||||
}
|
||||
}
|
||||
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
mock_display_manager.draw_text.assert_called_once()
|
||||
|
||||
def test_render_element_data_text(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a data text element."""
|
||||
element = {
|
||||
"type": "data_text",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"data_key": "weather.temperature",
|
||||
"format": "Temp: {value}°F",
|
||||
"color": [255, 255, 255],
|
||||
"default": "N/A"
|
||||
}
|
||||
}
|
||||
data_context = {
|
||||
"weather": {
|
||||
"temperature": 72
|
||||
}
|
||||
}
|
||||
|
||||
layout_manager.render_element(element, data_context)
|
||||
|
||||
mock_display_manager.draw_text.assert_called_once()
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Tests for the plugin loader's advisory version-compatibility warning."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loader():
|
||||
return PluginLoader(logger=logging.getLogger("test-loader"))
|
||||
|
||||
|
||||
def _warnings(caplog):
|
||||
return [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
|
||||
|
||||
class TestParseSemver:
|
||||
def test_basic(self, loader):
|
||||
assert loader._parse_semver("3.1.0") == (3, 1, 0)
|
||||
assert loader._parse_semver("v2.0") == (2, 0, 0)
|
||||
assert loader._parse_semver("2.0.0-beta.1") == (2, 0, 0)
|
||||
|
||||
def test_unparseable(self, loader):
|
||||
assert loader._parse_semver(None) is None
|
||||
assert loader._parse_semver(123) is None
|
||||
|
||||
|
||||
class TestWarnIfIncompatible:
|
||||
def test_warns_when_plugin_needs_newer_core(self, loader, caplog, monkeypatch):
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "3.1.0")
|
||||
with caplog.at_level(logging.WARNING, logger="test-loader"):
|
||||
loader._warn_if_incompatible("p", {"min_ledmatrix_version": "9.0.0"})
|
||||
assert len(_warnings(caplog)) == 1
|
||||
assert "9.0.0" in _warnings(caplog)[0].message
|
||||
|
||||
def test_silent_when_compatible(self, loader, caplog, monkeypatch):
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "3.1.0")
|
||||
with caplog.at_level(logging.WARNING, logger="test-loader"):
|
||||
loader._warn_if_incompatible("p", {"min_ledmatrix_version": "2.0.0"})
|
||||
assert not _warnings(caplog)
|
||||
|
||||
def test_silent_when_field_absent(self, loader, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="test-loader"):
|
||||
loader._warn_if_incompatible("p", {"name": "no version fields"})
|
||||
assert not _warnings(caplog)
|
||||
|
||||
def test_reads_requires_and_versions_spellings(self, loader, caplog, monkeypatch):
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "3.1.0")
|
||||
with caplog.at_level(logging.WARNING, logger="test-loader"):
|
||||
loader._warn_if_incompatible(
|
||||
"a", {"requires": {"min_ledmatrix_version": "9.0.0"}})
|
||||
loader._warn_if_incompatible(
|
||||
"b", {"versions": [{"ledmatrix_min_version": "9.0.0"}]})
|
||||
loader._warn_if_incompatible(
|
||||
"c", {"versions": [{"ledmatrix_min": "9.0.0"}]})
|
||||
assert len(_warnings(caplog)) == 3
|
||||
|
||||
def test_stale_core_version_skips_comparison(self, loader, caplog, monkeypatch):
|
||||
# Anti-spam guard: a core whose __version__ is below the ecosystem
|
||||
# floor must not warn about every plugin.
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "1.0.0")
|
||||
with caplog.at_level(logging.WARNING, logger="test-loader"):
|
||||
loader._warn_if_incompatible("p", {"min_ledmatrix_version": "2.0.0"})
|
||||
assert not _warnings(caplog)
|
||||
@@ -1,273 +0,0 @@
|
||||
"""Tests for x-style-elements schema expansion.
|
||||
|
||||
A plugin declares styleable elements once, compactly; expansion generates
|
||||
the full customization property blocks at schema-load time. The invariants
|
||||
that matter:
|
||||
|
||||
- idempotent (expand(expand(s)) == expand(s)) and the input is never mutated
|
||||
- both load paths (cached GET, uncached save) see the identical shape
|
||||
- generated defaults flow into generate_default_config, and saving twice is
|
||||
round-trip stable (merge_with_defaults produces no churn)
|
||||
- hand-written property blocks for the same element always win
|
||||
- defaults_from_schema_file (what plugins use to build resolvers from their
|
||||
RAW schema file) agrees exactly with the schema manager's expanded view
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from src.element_style import ( # noqa: E402
|
||||
defaults_from_schema_file,
|
||||
expand_style_elements,
|
||||
extract_schema_defaults,
|
||||
get_style_elements,
|
||||
)
|
||||
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
|
||||
|
||||
PRESS_START = "PressStart2P-Regular.ttf"
|
||||
|
||||
|
||||
def _declared_schema():
|
||||
return {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"title": "Display Customization",
|
||||
"x-style-elements": {
|
||||
"score_text": {
|
||||
"title": "Game Score",
|
||||
"font": {"default": PRESS_START},
|
||||
"size": {"default": 10, "min": 4, "max": 16},
|
||||
"color": True,
|
||||
"offsets": True,
|
||||
},
|
||||
"detail_text": {
|
||||
"font": {"default": "4x6-font.ttf"},
|
||||
"size": {"default": 6},
|
||||
},
|
||||
},
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestExpansionShape:
|
||||
def test_generates_element_blocks(self):
|
||||
expanded = expand_style_elements(_declared_schema())
|
||||
cust = expanded["properties"]["customization"]["properties"]
|
||||
score = cust["score_text"]
|
||||
assert score["x-style-managed"] is True
|
||||
assert score["title"] == "Game Score"
|
||||
assert score["properties"]["font"]["default"] == PRESS_START
|
||||
assert score["properties"]["font"]["x-widget"] == "font-selector"
|
||||
assert score["properties"]["font_size"]["default"] == 10
|
||||
assert score["properties"]["font_size"]["minimum"] == 4
|
||||
assert score["properties"]["font_size"]["maximum"] == 16
|
||||
assert score["properties"]["text_color"]["x-widget"] == "color-picker"
|
||||
assert score["properties"]["text_color"]["default"] == [255, 255, 255]
|
||||
assert score["additionalProperties"] is False
|
||||
|
||||
def test_color_and_offsets_are_optional(self):
|
||||
expanded = expand_style_elements(_declared_schema())
|
||||
cust = expanded["properties"]["customization"]["properties"]
|
||||
detail = cust["detail_text"]
|
||||
assert "text_color" not in detail["properties"]
|
||||
assert detail["title"] == "Detail Text" # prettified from the key
|
||||
layout = cust["layout"]["properties"]
|
||||
assert "score_text" in layout
|
||||
assert "detail_text" not in layout
|
||||
|
||||
def test_offsets_block_shape(self):
|
||||
expanded = expand_style_elements(_declared_schema())
|
||||
layout = expanded["properties"]["customization"]["properties"]["layout"]
|
||||
assert layout["x-style-managed"] is True
|
||||
entry = layout["properties"]["score_text"]
|
||||
assert entry["properties"]["x_offset"]["default"] == 0
|
||||
assert entry["properties"]["y_offset"]["default"] == 0
|
||||
|
||||
def test_declared_color_default(self):
|
||||
schema = _declared_schema()
|
||||
decl = schema["properties"]["customization"]["x-style-elements"]
|
||||
decl["score_text"]["color"] = {"default": [255, 215, 0]}
|
||||
expanded = expand_style_elements(schema)
|
||||
color = (expanded["properties"]["customization"]["properties"]
|
||||
["score_text"]["properties"]["text_color"])
|
||||
assert color["default"] == [255, 215, 0]
|
||||
|
||||
def test_declaration_survives_expansion(self):
|
||||
"""The declaration is the element registry for tooling — it must
|
||||
remain readable from the expanded schema."""
|
||||
expanded = expand_style_elements(_declared_schema())
|
||||
assert set(get_style_elements(expanded)) == {"score_text", "detail_text"}
|
||||
assert set(SchemaManager.get_style_elements(expanded)) == {
|
||||
"score_text", "detail_text"}
|
||||
|
||||
def test_no_declaration_returns_same_object(self):
|
||||
schema = {"type": "object", "properties": {"enabled": {"default": True}}}
|
||||
assert expand_style_elements(schema) is schema
|
||||
|
||||
def test_valid_draft7(self):
|
||||
jsonschema.Draft7Validator.check_schema(
|
||||
expand_style_elements(_declared_schema()))
|
||||
|
||||
def test_property_order_updated_when_present(self):
|
||||
schema = _declared_schema()
|
||||
schema["properties"]["customization"]["x-propertyOrder"] = []
|
||||
expanded = expand_style_elements(schema)
|
||||
order = expanded["properties"]["customization"]["x-propertyOrder"]
|
||||
# generated elements before layout (the template only renders keys
|
||||
# in x-propertyOrder when one exists)
|
||||
assert set(order) == {"score_text", "detail_text", "layout"}
|
||||
assert order.index("score_text") < order.index("layout")
|
||||
|
||||
def test_malformed_declaration_is_harmless(self):
|
||||
schema = _declared_schema()
|
||||
schema["properties"]["customization"]["x-style-elements"] = {
|
||||
"bad": "not a dict", "score_text": {"size": {"default": 10}}}
|
||||
expanded = expand_style_elements(schema)
|
||||
cust = expanded["properties"]["customization"]["properties"]
|
||||
assert "bad" not in cust
|
||||
assert "score_text" in cust
|
||||
|
||||
|
||||
class TestExpansionInvariants:
|
||||
def test_idempotent(self):
|
||||
once = expand_style_elements(_declared_schema())
|
||||
twice = expand_style_elements(once)
|
||||
assert once == twice
|
||||
|
||||
def test_input_never_mutated(self):
|
||||
schema = _declared_schema()
|
||||
snapshot = copy.deepcopy(schema)
|
||||
expand_style_elements(schema)
|
||||
assert schema == snapshot
|
||||
|
||||
def test_hand_written_block_wins(self):
|
||||
schema = _declared_schema()
|
||||
hand_written = {
|
||||
"type": "object",
|
||||
"properties": {"font": {"type": "string", "default": "custom.ttf"}},
|
||||
}
|
||||
schema["properties"]["customization"]["properties"]["score_text"] = \
|
||||
copy.deepcopy(hand_written)
|
||||
expanded = expand_style_elements(schema)
|
||||
assert (expanded["properties"]["customization"]["properties"]["score_text"]
|
||||
== hand_written)
|
||||
|
||||
def test_hand_written_layout_entry_wins(self):
|
||||
schema = _declared_schema()
|
||||
schema["properties"]["customization"]["properties"]["layout"] = {
|
||||
"type": "object",
|
||||
"properties": {"score_text": {"type": "object", "properties": {
|
||||
"x_offset": {"type": "integer", "default": 5}}}},
|
||||
}
|
||||
expanded = expand_style_elements(schema)
|
||||
layout = expanded["properties"]["customization"]["properties"]["layout"]
|
||||
assert layout["properties"]["score_text"]["properties"]["x_offset"]["default"] == 5
|
||||
|
||||
|
||||
class TestSchemaManagerIntegration:
|
||||
def _manager_with_schema(self, tmp_path, schema):
|
||||
plugin_dir = tmp_path / "test-plugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "config_schema.json").write_text(json.dumps(schema))
|
||||
return SchemaManager(plugins_dir=tmp_path)
|
||||
|
||||
def test_load_schema_expands(self, tmp_path):
|
||||
mgr = self._manager_with_schema(tmp_path, _declared_schema())
|
||||
loaded = mgr.load_schema("test-plugin")
|
||||
assert "score_text" in loaded["properties"]["customization"]["properties"]
|
||||
|
||||
def test_cached_and_uncached_loads_agree(self, tmp_path):
|
||||
"""The save path uses use_cache=False while the form GET uses the
|
||||
cache — they must see the identical expanded shape."""
|
||||
mgr = self._manager_with_schema(tmp_path, _declared_schema())
|
||||
cached = mgr.load_schema("test-plugin", use_cache=True)
|
||||
again = mgr.load_schema("test-plugin", use_cache=True)
|
||||
uncached = mgr.load_schema("test-plugin", use_cache=False)
|
||||
assert cached == uncached == again
|
||||
|
||||
def test_disk_file_untouched(self, tmp_path):
|
||||
schema = _declared_schema()
|
||||
mgr = self._manager_with_schema(tmp_path, schema)
|
||||
mgr.load_schema("test-plugin")
|
||||
on_disk = json.loads(
|
||||
(tmp_path / "test-plugin" / "config_schema.json").read_text())
|
||||
assert on_disk == schema
|
||||
assert "score_text" not in on_disk["properties"]["customization"]["properties"]
|
||||
|
||||
def test_defaults_include_generated_elements(self, tmp_path):
|
||||
mgr = self._manager_with_schema(tmp_path, _declared_schema())
|
||||
defaults = mgr.generate_default_config("test-plugin")
|
||||
assert defaults["customization"]["score_text"]["font"] == PRESS_START
|
||||
assert defaults["customization"]["score_text"]["font_size"] == 10
|
||||
assert defaults["customization"]["score_text"]["text_color"] == [255, 255, 255]
|
||||
assert defaults["customization"]["layout"]["score_text"]["x_offset"] == 0
|
||||
|
||||
def test_save_twice_is_round_trip_stable(self, tmp_path):
|
||||
"""merge_with_defaults(merged, defaults) must be a fixed point —
|
||||
saving a config twice can't keep growing/altering it."""
|
||||
mgr = self._manager_with_schema(tmp_path, _declared_schema())
|
||||
defaults = mgr.generate_default_config("test-plugin")
|
||||
user_config = {"enabled": True, "customization": {
|
||||
"score_text": {"font_size": 14}}}
|
||||
merged_once = mgr.merge_with_defaults(user_config, defaults)
|
||||
merged_twice = mgr.merge_with_defaults(merged_once, defaults)
|
||||
assert merged_once == merged_twice
|
||||
assert merged_once["customization"]["score_text"]["font_size"] == 14
|
||||
|
||||
|
||||
class TestResolverParity:
|
||||
def test_defaults_from_schema_file_matches_manager_view(self, tmp_path):
|
||||
"""Plugins build resolvers from their RAW schema file; the web UI
|
||||
merges defaults from the EXPANDED schema. Both must produce the
|
||||
same defaults or override detection diverges between contexts."""
|
||||
schema = _declared_schema()
|
||||
plugin_dir = tmp_path / "test-plugin"
|
||||
plugin_dir.mkdir()
|
||||
schema_path = plugin_dir / "config_schema.json"
|
||||
schema_path.write_text(json.dumps(schema))
|
||||
|
||||
mgr = SchemaManager(plugins_dir=tmp_path)
|
||||
manager_defaults = mgr.extract_defaults_from_schema(
|
||||
mgr.load_schema("test-plugin"))
|
||||
raw_file_defaults = defaults_from_schema_file(str(schema_path))
|
||||
assert raw_file_defaults == manager_defaults
|
||||
|
||||
def test_resolver_treats_generated_defaults_as_untouched(self, tmp_path):
|
||||
"""End to end: a config saved through the web UI (all generated
|
||||
defaults baked in) must not read as a user override, and the
|
||||
schema-default color must not clobber a classic color."""
|
||||
from src.element_style import ElementStyleResolver
|
||||
schema_path = tmp_path / "config_schema.json"
|
||||
schema_path.write_text(json.dumps(_declared_schema()))
|
||||
defaults = defaults_from_schema_file(str(schema_path))
|
||||
|
||||
saved_config = {"enabled": True, "customization": {
|
||||
"score_text": {"font": PRESS_START, "font_size": 10,
|
||||
"text_color": [255, 255, 255]},
|
||||
"layout": {"score_text": {"x_offset": 0, "y_offset": 0}},
|
||||
}}
|
||||
resolver = ElementStyleResolver(saved_config, defaults)
|
||||
style = resolver.style("score_text", classic_font=PRESS_START,
|
||||
classic_size=10, classic_color=(255, 215, 0))
|
||||
assert not style.user_forced
|
||||
assert not style.user_forced_color
|
||||
assert style.color == (255, 215, 0) # semantic classic color survives
|
||||
assert style.offset == (0, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -1,252 +0,0 @@
|
||||
"""Tests for POST /api/v3/plugins/preview — the config-page live preview.
|
||||
|
||||
The endpoint renders a plugin headlessly (pure PIL, no hardware, no pip)
|
||||
with a CANDIDATE config: either the current form state (parsed by the same
|
||||
parse_plugin_config_form used by save, so preview and save can never
|
||||
disagree) or a JSON config body.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from PIL import Image
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from web_interface.blueprints import api_v3 as api_v3_module # noqa: E402
|
||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
PLUGIN_ID = "preview-test-plugin"
|
||||
|
||||
MANAGER_PY = '''
|
||||
from PIL import ImageFont
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
|
||||
class PreviewTestPlugin(BasePlugin):
|
||||
def update(self):
|
||||
return True
|
||||
|
||||
def display(self, force_clear=False):
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
text = self.config.get("message", "hello")
|
||||
self.display_manager.draw.text((1, 1), text, fill=(255, 255, 255))
|
||||
self.display_manager.update_display()
|
||||
'''
|
||||
|
||||
MANIFEST = {
|
||||
"id": PLUGIN_ID,
|
||||
"name": "Preview Test Plugin",
|
||||
"version": "1.0.0",
|
||||
"class_name": "PreviewTestPlugin",
|
||||
"entry_point": "manager.py",
|
||||
"display_modes": ["preview_test"],
|
||||
}
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"message": {"type": "string", "default": "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(tmp_path):
|
||||
plugin = tmp_path / PLUGIN_ID
|
||||
plugin.mkdir()
|
||||
(plugin / "manager.py").write_text(MANAGER_PY)
|
||||
(plugin / "manifest.json").write_text(json.dumps(MANIFEST))
|
||||
(plugin / "config_schema.json").write_text(json.dumps(SCHEMA))
|
||||
return plugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(plugin_dir, tmp_path):
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
|
||||
test_app = Flask(__name__)
|
||||
test_app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
|
||||
config_manager = MagicMock()
|
||||
config_manager.load_config.return_value = {
|
||||
"display": {"hardware": {"cols": 64, "chain_length": 2,
|
||||
"rows": 32, "parallel": 1}},
|
||||
PLUGIN_ID: {"enabled": False, "message": "saved"},
|
||||
}
|
||||
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.plugins_dir = str(tmp_path)
|
||||
|
||||
old = (getattr(api_v3_module.api_v3, "config_manager", None),
|
||||
getattr(api_v3_module.api_v3, "plugin_manager", None),
|
||||
getattr(api_v3_module.api_v3, "schema_manager", None))
|
||||
api_v3_module.api_v3.config_manager = config_manager
|
||||
api_v3_module.api_v3.plugin_manager = plugin_manager
|
||||
api_v3_module.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path)
|
||||
|
||||
with test_app.test_client() as c:
|
||||
yield c
|
||||
|
||||
(api_v3_module.api_v3.config_manager,
|
||||
api_v3_module.api_v3.plugin_manager,
|
||||
api_v3_module.api_v3.schema_manager) = old
|
||||
|
||||
|
||||
def _decode_image(data_url):
|
||||
assert data_url.startswith("data:image/png;base64,")
|
||||
raw = base64.b64decode(data_url.split(",", 1)[1])
|
||||
return Image.open(io.BytesIO(raw))
|
||||
|
||||
|
||||
class TestPreviewEndpoint:
|
||||
def test_json_body_renders_at_default_panel_size(self, client):
|
||||
"""No width/height -> the user's real panel (64*2 x 32*1)."""
|
||||
resp = client.post(f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}",
|
||||
json={"config": {"message": "hi"}})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()["data"]
|
||||
img = _decode_image(data["image"])
|
||||
assert img.size == (128, 32)
|
||||
assert data["errors"] == []
|
||||
|
||||
def test_explicit_size(self, client):
|
||||
resp = client.post(
|
||||
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=64&height=64",
|
||||
json={"config": {}})
|
||||
assert resp.status_code == 200
|
||||
img = _decode_image(resp.get_json()["data"]["image"])
|
||||
assert img.size == (64, 64)
|
||||
|
||||
def test_form_encoding_matches_json(self, client):
|
||||
"""The form path (what HTMX posts) and the JSON path must render
|
||||
the same candidate config identically."""
|
||||
via_json = client.post(
|
||||
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
|
||||
json={"config": {"message": "same"}})
|
||||
via_form = client.post(
|
||||
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
|
||||
data={"message": "same"})
|
||||
a = _decode_image(via_json.get_json()["data"]["image"])
|
||||
b = _decode_image(via_form.get_json()["data"]["image"])
|
||||
assert list(a.getdata()) == list(b.getdata())
|
||||
|
||||
def test_candidate_config_wins_over_saved(self, client):
|
||||
"""The preview must show the UNSAVED form state, not the saved
|
||||
config ('saved' vs 'candidate' render differently)."""
|
||||
saved = client.post(
|
||||
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
|
||||
json={"config": {}})
|
||||
candidate = client.post(
|
||||
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
|
||||
json={"config": {"message": "candidate"}})
|
||||
a = _decode_image(saved.get_json()["data"]["image"])
|
||||
b = _decode_image(candidate.get_json()["data"]["image"])
|
||||
assert list(a.getdata()) != list(b.getdata())
|
||||
|
||||
def test_disabled_plugin_still_previews(self, client):
|
||||
"""Saved config has enabled: False — preview forces enabled."""
|
||||
resp = client.post(f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}",
|
||||
json={"config": {}})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["data"]["errors"] == []
|
||||
|
||||
def test_preview_size_form_field(self, client):
|
||||
"""The UI size selector posts __preview_size=WxH via hx-vals (htmx
|
||||
caches hx-post's path, so it can't ride the query string). It must
|
||||
set the render size and must NOT leak into the candidate config."""
|
||||
resp = client.post(f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}",
|
||||
data={"message": "hi", "__preview_size": "64x64"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()["data"]
|
||||
img = _decode_image(data["image"])
|
||||
assert img.size == (64, 64)
|
||||
assert data["errors"] == []
|
||||
|
||||
def test_query_args_beat_preview_size_field(self, client):
|
||||
resp = client.post(
|
||||
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
|
||||
data={"message": "hi", "__preview_size": "64x64"})
|
||||
img = _decode_image(resp.get_json()["data"]["image"])
|
||||
assert img.size == (128, 32)
|
||||
|
||||
def test_malformed_preview_size_falls_back_to_panel(self, client):
|
||||
resp = client.post(f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}",
|
||||
data={"message": "hi", "__preview_size": "bogus x"})
|
||||
img = _decode_image(resp.get_json()["data"]["image"])
|
||||
assert img.size == (128, 32) # cols*chain x rows*parallel
|
||||
|
||||
def test_json_candidate_deep_merges_onto_saved_config(self, client):
|
||||
"""A partial JSON candidate must not wipe saved sibling values in
|
||||
the same nested section (form path and save both deep-merge)."""
|
||||
# Saved config has message "saved"; posting an unrelated nested key
|
||||
# must not discard it — render must still differ from a candidate
|
||||
# that explicitly changes message.
|
||||
keep_saved = client.post(
|
||||
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
|
||||
json={"config": {}})
|
||||
explicit = client.post(
|
||||
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
|
||||
json={"config": {"message": "saved"}})
|
||||
a = _decode_image(keep_saved.get_json()["data"]["image"])
|
||||
b = _decode_image(explicit.get_json()["data"]["image"])
|
||||
assert list(a.getdata()) == list(b.getdata())
|
||||
|
||||
def test_hanging_plugin_times_out(self, client, plugin_dir, monkeypatch):
|
||||
"""A plugin whose display() hangs must not pin the web worker.
|
||||
|
||||
Uses its own plugin id: the loader caches the module per id, so
|
||||
reusing PLUGIN_ID would run the already-imported (non-hanging) code
|
||||
when this test follows others in the suite.
|
||||
"""
|
||||
from web_interface.blueprints import api_v3 as api_v3_module
|
||||
monkeypatch.setattr(api_v3_module, "PREVIEW_RENDER_TIMEOUT_SEC", 1)
|
||||
hang_id = "preview-hang-plugin"
|
||||
hang_dir = plugin_dir.parent / hang_id
|
||||
hang_dir.mkdir()
|
||||
(hang_dir / "manager.py").write_text(MANAGER_PY.replace(
|
||||
"self.display_manager.update_display()",
|
||||
"import time; time.sleep(10); self.display_manager.update_display()"))
|
||||
manifest = dict(MANIFEST, id=hang_id, name="Hang Plugin")
|
||||
(hang_dir / "manifest.json").write_text(json.dumps(manifest))
|
||||
(hang_dir / "config_schema.json").write_text(json.dumps(SCHEMA))
|
||||
resp = client.post(f"/api/v3/plugins/preview?plugin_id={hang_id}",
|
||||
json={"config": {}})
|
||||
assert resp.status_code == 504
|
||||
|
||||
def test_htmx_gets_html_fragment(self, client):
|
||||
resp = client.post(
|
||||
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=64&height=32",
|
||||
data={"message": "hi"}, headers={"HX-Request": "true"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.mimetype == "text/html"
|
||||
body = resp.get_data(as_text=True)
|
||||
assert "<img" in body and "data:image/png;base64," in body
|
||||
|
||||
def test_unknown_plugin_404(self, client):
|
||||
resp = client.post("/api/v3/plugins/preview?plugin_id=nope",
|
||||
json={"config": {}})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_missing_plugin_id_400(self, client):
|
||||
resp = client.post("/api/v3/plugins/preview", json={"config": {}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_absurd_size_rejected(self, client):
|
||||
resp = client.post(
|
||||
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=99999&height=32",
|
||||
json={"config": {}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -4282,373 +4282,6 @@ def _filter_config_by_schema(config, schema, prefix=''):
|
||||
return filtered
|
||||
|
||||
|
||||
def parse_plugin_config_form(form, schema, existing_config):
|
||||
"""Convert an HTMX config-form submission into a nested plugin config.
|
||||
|
||||
form is the werkzeug form MultiDict; existing_config is the saved
|
||||
config to merge updates onto (mutated and returned). Handles dotted
|
||||
field names, bracket/indexed array fields (color pickers), schema-
|
||||
driven type coercion, and unchecked-checkbox fixup.
|
||||
|
||||
Shared by save_plugin_config and the plugin preview endpoint so the
|
||||
two interpretations of the form can never drift apart."""
|
||||
plugin_config = existing_config
|
||||
# Convert form data to config dict
|
||||
# Form fields can use dot notation for nested values (e.g., "transition.type")
|
||||
form_data = form.to_dict()
|
||||
|
||||
# First pass: handle bracket notation array fields (e.g., "field_name[]" from checkbox-group)
|
||||
# These fields use getlist() to preserve all values, then replace in form_data
|
||||
# Sentinel empty value ("") allows clearing array to [] when all checkboxes unchecked
|
||||
bracket_array_fields = {} # Maps base field path to list of values
|
||||
for key in form.keys():
|
||||
# Check if key ends with "[]" (bracket notation for array fields)
|
||||
if key.endswith('[]'):
|
||||
base_path = key[:-2] # Remove "[]" suffix
|
||||
values = form.getlist(key)
|
||||
# Filter out sentinel empty string - if only sentinel present, array should be []
|
||||
# If sentinel + values present, use the actual values
|
||||
filtered_values = [v for v in values if v and v.strip()]
|
||||
# If no non-empty values but key exists, it means all checkboxes unchecked (empty array)
|
||||
bracket_array_fields[base_path] = filtered_values
|
||||
# Remove the bracket notation key from form_data if present
|
||||
if key in form_data:
|
||||
del form_data[key]
|
||||
|
||||
# Process bracket notation fields and set directly in plugin_config
|
||||
# Use JSON encoding instead of comma-join to handle values containing commas
|
||||
import json
|
||||
for base_path, values in bracket_array_fields.items():
|
||||
# Get schema property to verify it's an array
|
||||
base_prop = _get_schema_property(schema, base_path)
|
||||
if base_prop and base_prop.get('type') == 'array':
|
||||
# Filter out empty values and sentinel empty strings
|
||||
filtered_values = [v for v in values if v and v.strip()]
|
||||
# Set directly in plugin_config (values are already strings, no need to parse)
|
||||
# Empty array (all unchecked) is represented as []
|
||||
_set_nested_value(plugin_config, base_path, filtered_values)
|
||||
logger.debug(f"Processed bracket notation array field {base_path}: {values} -> {filtered_values}")
|
||||
# Remove from form_data to avoid double processing
|
||||
if base_path in form_data:
|
||||
del form_data[base_path]
|
||||
|
||||
# Second pass: detect and combine array index fields (e.g., "text_color.0", "text_color.1" -> "text_color" as array)
|
||||
# This handles cases where forms send array fields as indexed inputs
|
||||
array_fields = {} # Maps base field path to list of (index, value) tuples
|
||||
processed_keys = set()
|
||||
indexed_base_paths = set() # Track which base paths have indexed fields
|
||||
|
||||
for key, value in form_data.items():
|
||||
# Check if this looks like an array index field (ends with .0, .1, .2, etc.)
|
||||
if '.' in key:
|
||||
parts = key.rsplit('.', 1) # Split on last dot
|
||||
if len(parts) == 2:
|
||||
base_path, last_part = parts
|
||||
# Check if last part is a numeric string (array index)
|
||||
if last_part.isdigit():
|
||||
# Get schema property for the base path to verify it's an array
|
||||
base_prop = _get_schema_property(schema, base_path)
|
||||
if base_prop and base_prop.get('type') == 'array':
|
||||
# This is an array index field
|
||||
index = int(last_part)
|
||||
if base_path not in array_fields:
|
||||
array_fields[base_path] = []
|
||||
array_fields[base_path].append((index, value))
|
||||
processed_keys.add(key)
|
||||
indexed_base_paths.add(base_path)
|
||||
continue
|
||||
|
||||
# Process combined array fields
|
||||
for base_path, index_values in array_fields.items():
|
||||
# Sort by index and extract values
|
||||
index_values.sort(key=lambda x: x[0])
|
||||
values = [v for _, v in index_values]
|
||||
# Combine values into comma-separated string for parsing
|
||||
combined_value = ', '.join(str(v) for v in values)
|
||||
# Parse as array using schema
|
||||
parsed_value = _parse_form_value_with_schema(combined_value, base_path, schema)
|
||||
# Debug logging
|
||||
logger.debug(f"Combined indexed array field {base_path}: {values} -> {combined_value} -> {parsed_value}")
|
||||
# Only set if not skipped
|
||||
if parsed_value is not _SKIP_FIELD:
|
||||
_set_nested_value(plugin_config, base_path, parsed_value)
|
||||
|
||||
# Process remaining (non-indexed) fields
|
||||
# Skip any base paths that were processed as indexed arrays
|
||||
for key, value in form_data.items():
|
||||
if key not in processed_keys:
|
||||
# Skip if this key is a base path that was processed as indexed array
|
||||
# (to avoid overwriting the combined array with a single value)
|
||||
if key not in indexed_base_paths:
|
||||
# Parse value using schema to determine correct type
|
||||
parsed_value = _parse_form_value_with_schema(value, key, schema)
|
||||
# Debug logging for array fields
|
||||
if schema:
|
||||
prop = _get_schema_property(schema, key)
|
||||
if prop and prop.get('type') == 'array':
|
||||
logger.debug(f"Array field {key}: form value='{value}' -> parsed={parsed_value}")
|
||||
# Use helper to set nested values correctly (skips if _SKIP_FIELD)
|
||||
if parsed_value is not _SKIP_FIELD:
|
||||
_set_nested_value(plugin_config, key, parsed_value)
|
||||
|
||||
# Post-process: Fix array fields that might have been incorrectly structured
|
||||
# This handles cases where array fields are stored as dicts (e.g., from indexed form fields)
|
||||
def fix_array_structures(config_dict, schema_props, prefix=''):
|
||||
"""Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)"""
|
||||
for prop_key, prop_schema in schema_props.items():
|
||||
prop_type = prop_schema.get('type')
|
||||
|
||||
if prop_type == 'array':
|
||||
# Navigate to the field location
|
||||
if prefix:
|
||||
parent_parts = prefix.split('.')
|
||||
parent = config_dict
|
||||
for part in parent_parts:
|
||||
if isinstance(parent, dict) and part in parent:
|
||||
parent = parent[part]
|
||||
else:
|
||||
parent = None
|
||||
break
|
||||
|
||||
if parent is not None and isinstance(parent, dict) and prop_key in parent:
|
||||
current_value = parent[prop_key]
|
||||
# If it's a dict with numeric string keys, convert to array
|
||||
if isinstance(current_value, dict) and not isinstance(current_value, list):
|
||||
try:
|
||||
# Check if all keys are numeric strings (array indices)
|
||||
keys = [k for k in current_value.keys()]
|
||||
if all(k.isdigit() for k in keys):
|
||||
# Convert to sorted array by index
|
||||
sorted_keys = sorted(keys, key=int)
|
||||
array_value = [current_value[k] for k in sorted_keys]
|
||||
# Convert array elements to correct types based on schema
|
||||
items_schema = prop_schema.get('items', {})
|
||||
item_type = items_schema.get('type')
|
||||
if item_type in ('number', 'integer'):
|
||||
converted_array = []
|
||||
for v in array_value:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
if item_type == 'integer':
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
array_value = converted_array
|
||||
parent[prop_key] = array_value
|
||||
current_value = array_value # Update for length check below
|
||||
except (ValueError, KeyError, TypeError):
|
||||
# Conversion failed, check if we should use default
|
||||
pass
|
||||
|
||||
# If it's an array, ensure correct types and check minItems
|
||||
if isinstance(current_value, list):
|
||||
# First, ensure array elements are correct types
|
||||
items_schema = prop_schema.get('items', {})
|
||||
item_type = items_schema.get('type')
|
||||
if item_type in ('number', 'integer'):
|
||||
converted_array = []
|
||||
for v in current_value:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
if item_type == 'integer':
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
parent[prop_key] = converted_array
|
||||
current_value = converted_array
|
||||
|
||||
# Then check minItems
|
||||
min_items = prop_schema.get('minItems')
|
||||
if min_items is not None and len(current_value) < min_items:
|
||||
# Use default if available, otherwise keep as-is (validation will catch it)
|
||||
default = prop_schema.get('default')
|
||||
if default and isinstance(default, list) and len(default) >= min_items:
|
||||
parent[prop_key] = default
|
||||
else:
|
||||
# Top-level field
|
||||
if prop_key in config_dict:
|
||||
current_value = config_dict[prop_key]
|
||||
# If it's a dict with numeric string keys, convert to array
|
||||
if isinstance(current_value, dict) and not isinstance(current_value, list):
|
||||
try:
|
||||
keys = list(current_value.keys())
|
||||
if keys and all(str(k).isdigit() for k in keys):
|
||||
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
||||
array_value = [current_value[k] for k in sorted_keys]
|
||||
# Convert array elements to correct types based on schema
|
||||
items_schema = prop_schema.get('items', {})
|
||||
item_type = items_schema.get('type')
|
||||
if item_type in ('number', 'integer'):
|
||||
converted_array = []
|
||||
for v in array_value:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
if item_type == 'integer':
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
array_value = converted_array
|
||||
config_dict[prop_key] = array_value
|
||||
current_value = array_value # Update for length check below
|
||||
except (ValueError, KeyError, TypeError) as e:
|
||||
logger.debug(f"Failed to convert {prop_key} to array: {e}")
|
||||
|
||||
# If it's an array, ensure correct types and check minItems
|
||||
if isinstance(current_value, list):
|
||||
# First, ensure array elements are correct types
|
||||
items_schema = prop_schema.get('items', {})
|
||||
item_type = items_schema.get('type')
|
||||
if item_type in ('number', 'integer'):
|
||||
converted_array = []
|
||||
for v in current_value:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
if item_type == 'integer':
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
config_dict[prop_key] = converted_array
|
||||
current_value = converted_array
|
||||
|
||||
# Then check minItems
|
||||
min_items = prop_schema.get('minItems')
|
||||
if min_items is not None and len(current_value) < min_items:
|
||||
default = prop_schema.get('default')
|
||||
if default and isinstance(default, list) and len(default) >= min_items:
|
||||
config_dict[prop_key] = default
|
||||
|
||||
# Recurse into nested objects
|
||||
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
|
||||
if prefix:
|
||||
parent_parts = prefix.split('.')
|
||||
parent = config_dict
|
||||
for part in parent_parts:
|
||||
if isinstance(parent, dict) and part in parent:
|
||||
parent = parent[part]
|
||||
else:
|
||||
parent = None
|
||||
break
|
||||
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
|
||||
else:
|
||||
nested_dict = config_dict.get(prop_key)
|
||||
|
||||
if isinstance(nested_dict, dict):
|
||||
# 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
|
||||
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
||||
"""Recursively ensure array fields have defaults if None"""
|
||||
for prop_key, prop_schema in schema_props.items():
|
||||
prop_type = prop_schema.get('type')
|
||||
|
||||
if prop_type == 'array':
|
||||
if prefix:
|
||||
parent_parts = prefix.split('.')
|
||||
parent = config_dict
|
||||
for part in parent_parts:
|
||||
if isinstance(parent, dict) and part in parent:
|
||||
parent = parent[part]
|
||||
else:
|
||||
parent = None
|
||||
break
|
||||
|
||||
if parent is not None and isinstance(parent, dict):
|
||||
if prop_key not in parent or parent[prop_key] is None:
|
||||
default = prop_schema.get('default', [])
|
||||
parent[prop_key] = default if default else []
|
||||
else:
|
||||
if prop_key not in config_dict or config_dict[prop_key] is None:
|
||||
default = prop_schema.get('default', [])
|
||||
config_dict[prop_key] = default if default else []
|
||||
|
||||
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
|
||||
if prefix:
|
||||
parent_parts = prefix.split('.')
|
||||
parent = config_dict
|
||||
for part in parent_parts:
|
||||
if isinstance(parent, dict) and part in parent:
|
||||
parent = parent[part]
|
||||
else:
|
||||
parent = None
|
||||
break
|
||||
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
|
||||
else:
|
||||
nested_dict = config_dict.get(prop_key)
|
||||
|
||||
if nested_dict is None:
|
||||
if prefix:
|
||||
parent_parts = prefix.split('.')
|
||||
parent = config_dict
|
||||
for part in parent_parts:
|
||||
if part not in parent:
|
||||
parent[part] = {}
|
||||
parent = parent[part]
|
||||
if prop_key not in parent:
|
||||
parent[prop_key] = {}
|
||||
nested_dict = parent[prop_key]
|
||||
else:
|
||||
if prop_key not in config_dict:
|
||||
config_dict[prop_key] = {}
|
||||
nested_dict = config_dict[prop_key]
|
||||
|
||||
if isinstance(nested_dict, dict):
|
||||
# Pass no prefix: config_dict is already navigated.
|
||||
ensure_array_defaults(nested_dict, prop_schema['properties'])
|
||||
|
||||
if schema and 'properties' in schema:
|
||||
# First, fix any dict structures that should be arrays
|
||||
# This must be called BEFORE validation to convert dicts with numeric keys to arrays
|
||||
fix_array_structures(plugin_config, schema['properties'])
|
||||
# Then, ensure None arrays get defaults
|
||||
ensure_array_defaults(plugin_config, schema['properties'])
|
||||
|
||||
# Debug: Log the structure after fixing
|
||||
if 'feeds' in plugin_config and 'custom_feeds' in plugin_config.get('feeds', {}):
|
||||
custom_feeds = plugin_config['feeds']['custom_feeds']
|
||||
logger.debug(f"After fix_array_structures: custom_feeds type={type(custom_feeds)}, value={custom_feeds}")
|
||||
|
||||
# Force fix for feeds.custom_feeds if it's still a dict (fallback)
|
||||
if 'feeds' in plugin_config:
|
||||
feeds_config = plugin_config.get('feeds') or {}
|
||||
if feeds_config and 'custom_feeds' in feeds_config and isinstance(feeds_config['custom_feeds'], dict):
|
||||
custom_feeds_dict = feeds_config['custom_feeds']
|
||||
# Check if all keys are numeric
|
||||
keys = list(custom_feeds_dict.keys())
|
||||
if keys and all(str(k).isdigit() for k in keys):
|
||||
# Convert to array
|
||||
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
||||
feeds_config['custom_feeds'] = [custom_feeds_dict[k] for k in sorted_keys]
|
||||
logger.info(f"Force-converted feeds.custom_feeds from dict to array: {len(feeds_config['custom_feeds'])} items")
|
||||
|
||||
# Fix unchecked boolean checkboxes: HTML checkboxes don't submit values
|
||||
# when unchecked, so the existing config value (potentially True) persists.
|
||||
# Walk the schema and set any boolean fields missing from form data to False.
|
||||
if schema and 'properties' in schema:
|
||||
form_keys = set(form.keys())
|
||||
_set_missing_booleans_to_false(plugin_config, schema['properties'], form_keys)
|
||||
return plugin_config
|
||||
|
||||
|
||||
@api_v3.route('/plugins/config', methods=['POST'])
|
||||
def save_plugin_config():
|
||||
"""Save plugin configuration, separating secrets from regular config"""
|
||||
@@ -4702,9 +4335,359 @@ def save_plugin_config():
|
||||
# Start with existing config and apply form updates
|
||||
plugin_config = existing_config
|
||||
|
||||
# Convert form data to config dict (shared with the preview
|
||||
# endpoint — see parse_plugin_config_form)
|
||||
plugin_config = parse_plugin_config_form(request.form, schema, plugin_config)
|
||||
# Convert form data to config dict
|
||||
# Form fields can use dot notation for nested values (e.g., "transition.type")
|
||||
form_data = request.form.to_dict()
|
||||
|
||||
# First pass: handle bracket notation array fields (e.g., "field_name[]" from checkbox-group)
|
||||
# These fields use getlist() to preserve all values, then replace in form_data
|
||||
# Sentinel empty value ("") allows clearing array to [] when all checkboxes unchecked
|
||||
bracket_array_fields = {} # Maps base field path to list of values
|
||||
for key in request.form.keys():
|
||||
# Check if key ends with "[]" (bracket notation for array fields)
|
||||
if key.endswith('[]'):
|
||||
base_path = key[:-2] # Remove "[]" suffix
|
||||
values = request.form.getlist(key)
|
||||
# Filter out sentinel empty string - if only sentinel present, array should be []
|
||||
# If sentinel + values present, use the actual values
|
||||
filtered_values = [v for v in values if v and v.strip()]
|
||||
# If no non-empty values but key exists, it means all checkboxes unchecked (empty array)
|
||||
bracket_array_fields[base_path] = filtered_values
|
||||
# Remove the bracket notation key from form_data if present
|
||||
if key in form_data:
|
||||
del form_data[key]
|
||||
|
||||
# Process bracket notation fields and set directly in plugin_config
|
||||
# Use JSON encoding instead of comma-join to handle values containing commas
|
||||
import json
|
||||
for base_path, values in bracket_array_fields.items():
|
||||
# Get schema property to verify it's an array
|
||||
base_prop = _get_schema_property(schema, base_path)
|
||||
if base_prop and base_prop.get('type') == 'array':
|
||||
# Filter out empty values and sentinel empty strings
|
||||
filtered_values = [v for v in values if v and v.strip()]
|
||||
# Set directly in plugin_config (values are already strings, no need to parse)
|
||||
# Empty array (all unchecked) is represented as []
|
||||
_set_nested_value(plugin_config, base_path, filtered_values)
|
||||
logger.debug(f"Processed bracket notation array field {base_path}: {values} -> {filtered_values}")
|
||||
# Remove from form_data to avoid double processing
|
||||
if base_path in form_data:
|
||||
del form_data[base_path]
|
||||
|
||||
# Second pass: detect and combine array index fields (e.g., "text_color.0", "text_color.1" -> "text_color" as array)
|
||||
# This handles cases where forms send array fields as indexed inputs
|
||||
array_fields = {} # Maps base field path to list of (index, value) tuples
|
||||
processed_keys = set()
|
||||
indexed_base_paths = set() # Track which base paths have indexed fields
|
||||
|
||||
for key, value in form_data.items():
|
||||
# Check if this looks like an array index field (ends with .0, .1, .2, etc.)
|
||||
if '.' in key:
|
||||
parts = key.rsplit('.', 1) # Split on last dot
|
||||
if len(parts) == 2:
|
||||
base_path, last_part = parts
|
||||
# Check if last part is a numeric string (array index)
|
||||
if last_part.isdigit():
|
||||
# Get schema property for the base path to verify it's an array
|
||||
base_prop = _get_schema_property(schema, base_path)
|
||||
if base_prop and base_prop.get('type') == 'array':
|
||||
# This is an array index field
|
||||
index = int(last_part)
|
||||
if base_path not in array_fields:
|
||||
array_fields[base_path] = []
|
||||
array_fields[base_path].append((index, value))
|
||||
processed_keys.add(key)
|
||||
indexed_base_paths.add(base_path)
|
||||
continue
|
||||
|
||||
# Process combined array fields
|
||||
for base_path, index_values in array_fields.items():
|
||||
# Sort by index and extract values
|
||||
index_values.sort(key=lambda x: x[0])
|
||||
values = [v for _, v in index_values]
|
||||
# Combine values into comma-separated string for parsing
|
||||
combined_value = ', '.join(str(v) for v in values)
|
||||
# Parse as array using schema
|
||||
parsed_value = _parse_form_value_with_schema(combined_value, base_path, schema)
|
||||
# Debug logging
|
||||
logger.debug(f"Combined indexed array field {base_path}: {values} -> {combined_value} -> {parsed_value}")
|
||||
# Only set if not skipped
|
||||
if parsed_value is not _SKIP_FIELD:
|
||||
_set_nested_value(plugin_config, base_path, parsed_value)
|
||||
|
||||
# Process remaining (non-indexed) fields
|
||||
# Skip any base paths that were processed as indexed arrays
|
||||
for key, value in form_data.items():
|
||||
if key not in processed_keys:
|
||||
# Skip if this key is a base path that was processed as indexed array
|
||||
# (to avoid overwriting the combined array with a single value)
|
||||
if key not in indexed_base_paths:
|
||||
# Parse value using schema to determine correct type
|
||||
parsed_value = _parse_form_value_with_schema(value, key, schema)
|
||||
# Debug logging for array fields
|
||||
if schema:
|
||||
prop = _get_schema_property(schema, key)
|
||||
if prop and prop.get('type') == 'array':
|
||||
logger.debug(f"Array field {key}: form value='{value}' -> parsed={parsed_value}")
|
||||
# Use helper to set nested values correctly (skips if _SKIP_FIELD)
|
||||
if parsed_value is not _SKIP_FIELD:
|
||||
_set_nested_value(plugin_config, key, parsed_value)
|
||||
|
||||
# Post-process: Fix array fields that might have been incorrectly structured
|
||||
# This handles cases where array fields are stored as dicts (e.g., from indexed form fields)
|
||||
def fix_array_structures(config_dict, schema_props, prefix=''):
|
||||
"""Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)"""
|
||||
for prop_key, prop_schema in schema_props.items():
|
||||
prop_type = prop_schema.get('type')
|
||||
|
||||
if prop_type == 'array':
|
||||
# Navigate to the field location
|
||||
if prefix:
|
||||
parent_parts = prefix.split('.')
|
||||
parent = config_dict
|
||||
for part in parent_parts:
|
||||
if isinstance(parent, dict) and part in parent:
|
||||
parent = parent[part]
|
||||
else:
|
||||
parent = None
|
||||
break
|
||||
|
||||
if parent is not None and isinstance(parent, dict) and prop_key in parent:
|
||||
current_value = parent[prop_key]
|
||||
# If it's a dict with numeric string keys, convert to array
|
||||
if isinstance(current_value, dict) and not isinstance(current_value, list):
|
||||
try:
|
||||
# Check if all keys are numeric strings (array indices)
|
||||
keys = [k for k in current_value.keys()]
|
||||
if all(k.isdigit() for k in keys):
|
||||
# Convert to sorted array by index
|
||||
sorted_keys = sorted(keys, key=int)
|
||||
array_value = [current_value[k] for k in sorted_keys]
|
||||
# Convert array elements to correct types based on schema
|
||||
items_schema = prop_schema.get('items', {})
|
||||
item_type = items_schema.get('type')
|
||||
if item_type in ('number', 'integer'):
|
||||
converted_array = []
|
||||
for v in array_value:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
if item_type == 'integer':
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
array_value = converted_array
|
||||
parent[prop_key] = array_value
|
||||
current_value = array_value # Update for length check below
|
||||
except (ValueError, KeyError, TypeError):
|
||||
# Conversion failed, check if we should use default
|
||||
pass
|
||||
|
||||
# If it's an array, ensure correct types and check minItems
|
||||
if isinstance(current_value, list):
|
||||
# First, ensure array elements are correct types
|
||||
items_schema = prop_schema.get('items', {})
|
||||
item_type = items_schema.get('type')
|
||||
if item_type in ('number', 'integer'):
|
||||
converted_array = []
|
||||
for v in current_value:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
if item_type == 'integer':
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
parent[prop_key] = converted_array
|
||||
current_value = converted_array
|
||||
|
||||
# Then check minItems
|
||||
min_items = prop_schema.get('minItems')
|
||||
if min_items is not None and len(current_value) < min_items:
|
||||
# Use default if available, otherwise keep as-is (validation will catch it)
|
||||
default = prop_schema.get('default')
|
||||
if default and isinstance(default, list) and len(default) >= min_items:
|
||||
parent[prop_key] = default
|
||||
else:
|
||||
# Top-level field
|
||||
if prop_key in config_dict:
|
||||
current_value = config_dict[prop_key]
|
||||
# If it's a dict with numeric string keys, convert to array
|
||||
if isinstance(current_value, dict) and not isinstance(current_value, list):
|
||||
try:
|
||||
keys = list(current_value.keys())
|
||||
if keys and all(str(k).isdigit() for k in keys):
|
||||
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
||||
array_value = [current_value[k] for k in sorted_keys]
|
||||
# Convert array elements to correct types based on schema
|
||||
items_schema = prop_schema.get('items', {})
|
||||
item_type = items_schema.get('type')
|
||||
if item_type in ('number', 'integer'):
|
||||
converted_array = []
|
||||
for v in array_value:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
if item_type == 'integer':
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
array_value = converted_array
|
||||
config_dict[prop_key] = array_value
|
||||
current_value = array_value # Update for length check below
|
||||
except (ValueError, KeyError, TypeError) as e:
|
||||
logger.debug(f"Failed to convert {prop_key} to array: {e}")
|
||||
|
||||
# If it's an array, ensure correct types and check minItems
|
||||
if isinstance(current_value, list):
|
||||
# First, ensure array elements are correct types
|
||||
items_schema = prop_schema.get('items', {})
|
||||
item_type = items_schema.get('type')
|
||||
if item_type in ('number', 'integer'):
|
||||
converted_array = []
|
||||
for v in current_value:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
if item_type == 'integer':
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
config_dict[prop_key] = converted_array
|
||||
current_value = converted_array
|
||||
|
||||
# Then check minItems
|
||||
min_items = prop_schema.get('minItems')
|
||||
if min_items is not None and len(current_value) < min_items:
|
||||
default = prop_schema.get('default')
|
||||
if default and isinstance(default, list) and len(default) >= min_items:
|
||||
config_dict[prop_key] = default
|
||||
|
||||
# Recurse into nested objects
|
||||
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
|
||||
if prefix:
|
||||
parent_parts = prefix.split('.')
|
||||
parent = config_dict
|
||||
for part in parent_parts:
|
||||
if isinstance(parent, dict) and part in parent:
|
||||
parent = parent[part]
|
||||
else:
|
||||
parent = None
|
||||
break
|
||||
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
|
||||
else:
|
||||
nested_dict = config_dict.get(prop_key)
|
||||
|
||||
if isinstance(nested_dict, dict):
|
||||
# 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
|
||||
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
||||
"""Recursively ensure array fields have defaults if None"""
|
||||
for prop_key, prop_schema in schema_props.items():
|
||||
prop_type = prop_schema.get('type')
|
||||
|
||||
if prop_type == 'array':
|
||||
if prefix:
|
||||
parent_parts = prefix.split('.')
|
||||
parent = config_dict
|
||||
for part in parent_parts:
|
||||
if isinstance(parent, dict) and part in parent:
|
||||
parent = parent[part]
|
||||
else:
|
||||
parent = None
|
||||
break
|
||||
|
||||
if parent is not None and isinstance(parent, dict):
|
||||
if prop_key not in parent or parent[prop_key] is None:
|
||||
default = prop_schema.get('default', [])
|
||||
parent[prop_key] = default if default else []
|
||||
else:
|
||||
if prop_key not in config_dict or config_dict[prop_key] is None:
|
||||
default = prop_schema.get('default', [])
|
||||
config_dict[prop_key] = default if default else []
|
||||
|
||||
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
|
||||
if prefix:
|
||||
parent_parts = prefix.split('.')
|
||||
parent = config_dict
|
||||
for part in parent_parts:
|
||||
if isinstance(parent, dict) and part in parent:
|
||||
parent = parent[part]
|
||||
else:
|
||||
parent = None
|
||||
break
|
||||
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
|
||||
else:
|
||||
nested_dict = config_dict.get(prop_key)
|
||||
|
||||
if nested_dict is None:
|
||||
if prefix:
|
||||
parent_parts = prefix.split('.')
|
||||
parent = config_dict
|
||||
for part in parent_parts:
|
||||
if part not in parent:
|
||||
parent[part] = {}
|
||||
parent = parent[part]
|
||||
if prop_key not in parent:
|
||||
parent[prop_key] = {}
|
||||
nested_dict = parent[prop_key]
|
||||
else:
|
||||
if prop_key not in config_dict:
|
||||
config_dict[prop_key] = {}
|
||||
nested_dict = config_dict[prop_key]
|
||||
|
||||
if isinstance(nested_dict, dict):
|
||||
# Pass no prefix: config_dict is already navigated.
|
||||
ensure_array_defaults(nested_dict, prop_schema['properties'])
|
||||
|
||||
if schema and 'properties' in schema:
|
||||
# First, fix any dict structures that should be arrays
|
||||
# This must be called BEFORE validation to convert dicts with numeric keys to arrays
|
||||
fix_array_structures(plugin_config, schema['properties'])
|
||||
# Then, ensure None arrays get defaults
|
||||
ensure_array_defaults(plugin_config, schema['properties'])
|
||||
|
||||
# Debug: Log the structure after fixing
|
||||
if 'feeds' in plugin_config and 'custom_feeds' in plugin_config.get('feeds', {}):
|
||||
custom_feeds = plugin_config['feeds']['custom_feeds']
|
||||
logger.debug(f"After fix_array_structures: custom_feeds type={type(custom_feeds)}, value={custom_feeds}")
|
||||
|
||||
# Force fix for feeds.custom_feeds if it's still a dict (fallback)
|
||||
if 'feeds' in plugin_config:
|
||||
feeds_config = plugin_config.get('feeds') or {}
|
||||
if feeds_config and 'custom_feeds' in feeds_config and isinstance(feeds_config['custom_feeds'], dict):
|
||||
custom_feeds_dict = feeds_config['custom_feeds']
|
||||
# Check if all keys are numeric
|
||||
keys = list(custom_feeds_dict.keys())
|
||||
if keys and all(str(k).isdigit() for k in keys):
|
||||
# Convert to array
|
||||
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
||||
feeds_config['custom_feeds'] = [custom_feeds_dict[k] for k in sorted_keys]
|
||||
logger.info(f"Force-converted feeds.custom_feeds from dict to array: {len(feeds_config['custom_feeds'])} items")
|
||||
|
||||
# Fix unchecked boolean checkboxes: HTML checkboxes don't submit values
|
||||
# when unchecked, so the existing config value (potentially True) persists.
|
||||
# Walk the schema and set any boolean fields missing from form data to False.
|
||||
if schema and 'properties' in schema:
|
||||
form_keys = set(request.form.keys())
|
||||
_set_missing_booleans_to_false(plugin_config, schema['properties'], form_keys)
|
||||
|
||||
# Get schema manager instance (for JSON requests)
|
||||
schema_mgr = api_v3.schema_manager
|
||||
@@ -5306,233 +5289,6 @@ def get_plugin_schema():
|
||||
logger.error('Error in get_plugin_schema', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
|
||||
|
||||
# plugin_id arrives in request input and is used to build filesystem paths —
|
||||
# allowlist it (same pattern pages_v3 uses)
|
||||
_SAFE_PREVIEW_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
|
||||
|
||||
def _find_plugin_dir_for_preview(plugin_id: str) -> 'Path | None':
|
||||
"""Locate an installed plugin's directory (store dir, then dev dirs) —
|
||||
same search order the schema manager uses. Rejects any id that could
|
||||
name a path outside the plugin directories."""
|
||||
if not isinstance(plugin_id, str) or not _SAFE_PREVIEW_PLUGIN_ID_RE.match(plugin_id):
|
||||
return None
|
||||
candidates = []
|
||||
active_pm = getattr(api_v3, 'plugin_manager', None)
|
||||
if active_pm and getattr(active_pm, 'plugins_dir', None):
|
||||
candidates.append(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')
|
||||
candidates.append(Path(_dir_name) if os.path.isabs(_dir_name)
|
||||
else PROJECT_ROOT / _dir_name)
|
||||
candidates.append(PROJECT_ROOT / 'plugins')
|
||||
candidates.append(PROJECT_ROOT / 'plugin-repos')
|
||||
for base in candidates:
|
||||
plugin_dir = base / plugin_id
|
||||
if (plugin_dir / 'manifest.json').exists():
|
||||
return plugin_dir
|
||||
return None
|
||||
|
||||
|
||||
PREVIEW_MIN_SIZE, PREVIEW_MAX_W, PREVIEW_MAX_H = 8, 1024, 512
|
||||
PREVIEW_RENDER_TIMEOUT_SEC = 15
|
||||
|
||||
|
||||
@api_v3.route('/plugins/preview', methods=['POST'])
|
||||
def preview_plugin_render():
|
||||
"""Render a plugin headlessly with a CANDIDATE (unsaved) config.
|
||||
|
||||
Powers the config page's live preview: the browser posts the current
|
||||
form state (same encoding as save — parsed by the same
|
||||
parse_plugin_config_form, so preview and save can never disagree) or a
|
||||
JSON body {"config": {...}}, and gets back a base64 PNG of what the
|
||||
panel would show.
|
||||
|
||||
Entirely hardware-free: renders through VisualTestDisplayManager (pure
|
||||
PIL) with install_deps=False. update() is skipped by default so the
|
||||
request never blocks on live APIs — plugins with a test/harness.json
|
||||
get their mock-data fixture primed into the cache instead, and
|
||||
?skip_update=0 opts into a real update() for plugins that need it.
|
||||
|
||||
Query params: plugin_id (required); width/height (defaults: the real
|
||||
panel size from display.hardware); skip_update (default 1).
|
||||
"""
|
||||
try:
|
||||
plugin_id = request.args.get('plugin_id')
|
||||
if not plugin_id:
|
||||
return error_response(ErrorCode.INVALID_INPUT,
|
||||
'plugin_id required in query string',
|
||||
status_code=400)
|
||||
|
||||
plugin_dir = _find_plugin_dir_for_preview(plugin_id)
|
||||
if not plugin_dir:
|
||||
return error_response(ErrorCode.PLUGIN_NOT_FOUND,
|
||||
f'Plugin not found: {plugin_id}',
|
||||
status_code=404)
|
||||
|
||||
schema_mgr = api_v3.schema_manager
|
||||
if not schema_mgr:
|
||||
return error_response(ErrorCode.SYSTEM_ERROR,
|
||||
'Schema manager not initialized',
|
||||
status_code=500)
|
||||
|
||||
# ---- panel size: explicit query args, else the real panel ----
|
||||
main_config = {}
|
||||
if api_v3.config_manager:
|
||||
try:
|
||||
main_config = api_v3.config_manager.load_config() or {}
|
||||
except Exception:
|
||||
main_config = {}
|
||||
hardware = main_config.get('display', {}).get('hardware', {})
|
||||
default_width = int(hardware.get('cols', 64)) * int(hardware.get('chain_length', 2))
|
||||
default_height = int(hardware.get('rows', 32)) * int(hardware.get('parallel', 1))
|
||||
# The UI's size selector posts "__preview_size=WxH" via the button's
|
||||
# hx-vals (evaluated at request time — htmx caches hx-post's path at
|
||||
# process time, so a dynamically updated query string doesn't work).
|
||||
# Explicit query args still take precedence for API callers.
|
||||
preview_size = request.values.get('__preview_size', '')
|
||||
if preview_size and 'x' in preview_size and 'width' not in request.args:
|
||||
size_w, _, size_h = preview_size.partition('x')
|
||||
try:
|
||||
default_width, default_height = int(size_w), int(size_h)
|
||||
except (TypeError, ValueError):
|
||||
pass # malformed selector value — fall back to panel size
|
||||
try:
|
||||
width = int(request.args.get('width', default_width))
|
||||
height = int(request.args.get('height', default_height))
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCode.INVALID_INPUT,
|
||||
'width and height must be integers',
|
||||
status_code=400)
|
||||
if not (PREVIEW_MIN_SIZE <= width <= PREVIEW_MAX_W
|
||||
and PREVIEW_MIN_SIZE <= height <= PREVIEW_MAX_H):
|
||||
return error_response(
|
||||
ErrorCode.INVALID_INPUT,
|
||||
f'size must be within {PREVIEW_MIN_SIZE}x{PREVIEW_MIN_SIZE} '
|
||||
f'and {PREVIEW_MAX_W}x{PREVIEW_MAX_H}',
|
||||
status_code=400)
|
||||
|
||||
# ---- candidate config: saved config + submitted changes + defaults ----
|
||||
schema = schema_mgr.load_schema(plugin_id, use_cache=False)
|
||||
existing_config = (main_config.get(plugin_id) or {}).copy()
|
||||
|
||||
content_type = request.content_type or ''
|
||||
if 'application/json' in content_type:
|
||||
import copy
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Deep-merge the candidate onto the saved config, matching how
|
||||
# the form path (and save) treat partial updates — a shallow
|
||||
# update() would silently drop the user's saved values in any
|
||||
# nested section the candidate touches (e.g. posting one
|
||||
# element's color would discard the saved font of its sibling).
|
||||
def _deep_merge(base, overlay):
|
||||
for key, value in overlay.items():
|
||||
if (isinstance(value, dict)
|
||||
and isinstance(base.get(key), dict)):
|
||||
_deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
|
||||
plugin_config = copy.deepcopy(existing_config)
|
||||
_deep_merge(plugin_config, data.get('config', {}))
|
||||
else:
|
||||
# Strip the preview-only control field so it never lands in the
|
||||
# candidate config the plugin sees
|
||||
form = request.form.copy()
|
||||
form.poplist('__preview_size')
|
||||
plugin_config = parse_plugin_config_form(form, schema,
|
||||
existing_config)
|
||||
|
||||
if schema:
|
||||
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
||||
plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults)
|
||||
# Preview regardless of the enabled toggle
|
||||
plugin_config['enabled'] = True
|
||||
|
||||
# ---- deterministic data: the plugin's own harness fixture ----
|
||||
mock_data = {}
|
||||
try:
|
||||
from src.plugin_system.testing.loading import load_harness_spec
|
||||
spec = load_harness_spec(plugin_dir)
|
||||
mock_data = spec.get('mock_data_contents', {}) or {}
|
||||
harness_config = spec.get('config') or {}
|
||||
if harness_config:
|
||||
# harness settings under the candidate config: user's
|
||||
# in-form values always win
|
||||
merged = dict(harness_config)
|
||||
merged.update(plugin_config)
|
||||
plugin_config = merged
|
||||
except Exception as e:
|
||||
logger.debug(f'No usable harness spec for {plugin_id}: {e}')
|
||||
|
||||
skip_update = request.args.get('skip_update', '1') not in ('0', 'false')
|
||||
|
||||
# Bounded render: a plugin whose update()/display() hangs must not
|
||||
# pin a web worker forever. The runaway thread can't be killed, but
|
||||
# the request returns and the thread is daemonized so it can't block
|
||||
# shutdown either.
|
||||
from src.plugin_system.testing.render_service import render_plugin_once
|
||||
import threading
|
||||
render_out: dict = {}
|
||||
|
||||
def _do_render():
|
||||
try:
|
||||
render_out['result'] = render_plugin_once(
|
||||
plugin_id, plugin_dir, config=plugin_config,
|
||||
mock_data=mock_data, width=width, height=height,
|
||||
skip_update=skip_update)
|
||||
except Exception as e: # surfaced below
|
||||
render_out['error'] = e
|
||||
|
||||
render_thread = threading.Thread(target=_do_render, daemon=True,
|
||||
name=f'preview-{plugin_id}')
|
||||
render_thread.start()
|
||||
render_thread.join(timeout=PREVIEW_RENDER_TIMEOUT_SEC)
|
||||
if render_thread.is_alive():
|
||||
logger.warning('preview render timed out for %s after %ss',
|
||||
plugin_id, PREVIEW_RENDER_TIMEOUT_SEC)
|
||||
if request.headers.get('HX-Request'):
|
||||
return Response('<p class="text-xs text-red-600">Preview timed '
|
||||
'out — the plugin took too long to render.</p>',
|
||||
mimetype='text/html')
|
||||
return error_response(ErrorCode.SYSTEM_ERROR,
|
||||
'Preview render timed out', status_code=504)
|
||||
if 'error' in render_out:
|
||||
raise render_out['error']
|
||||
result = render_out['result']
|
||||
|
||||
# HTMX callers get a ready-to-swap fragment; API callers get JSON
|
||||
if request.headers.get('HX-Request'):
|
||||
import html as _html
|
||||
meta = f"{result['width']}×{result['height']} · {result['render_time_ms']} ms"
|
||||
errors_html = ''
|
||||
if result['errors'] or result['warnings']:
|
||||
notes = _html.escape('; '.join(result['errors'] + result['warnings']))
|
||||
errors_html = (f'<p class="text-xs text-red-600 mt-1">'
|
||||
f'{notes}</p>')
|
||||
return Response(
|
||||
f'<img src="{result["image"]}" alt="Plugin preview" '
|
||||
f'class="preview-pixelated" '
|
||||
f'style="image-rendering: pixelated; width: 100%; max-width: '
|
||||
f'{result["width"] * 4}px; border: 1px solid #333; '
|
||||
f'border-radius: 4px; background: #000;">'
|
||||
f'<p class="text-xs text-gray-500 mt-1">{meta}</p>'
|
||||
f'{errors_html}',
|
||||
mimetype='text/html')
|
||||
return jsonify({'status': 'success', 'data': result})
|
||||
except Exception:
|
||||
logger.error('Error in preview_plugin_render', exc_info=True)
|
||||
if request.headers.get('HX-Request'):
|
||||
return Response('<p class="text-xs text-red-600">Preview failed — '
|
||||
'see logs for details.</p>', mimetype='text/html')
|
||||
return error_response(ErrorCode.SYSTEM_ERROR,
|
||||
'Preview failed; see logs for details',
|
||||
status_code=500)
|
||||
|
||||
@api_v3.route('/plugins/config/reset', methods=['POST'])
|
||||
def reset_plugin_config():
|
||||
"""Reset plugin configuration to schema defaults"""
|
||||
|
||||
@@ -0,0 +1,837 @@
|
||||
"""
|
||||
Plugin Composer blueprint — drag-and-drop plugin builder for LEDMatrix.
|
||||
|
||||
Routes:
|
||||
GET /composer/ — Composer page
|
||||
POST /composer/api/generate — Generate and return plugin ZIP
|
||||
POST /composer/api/install — Write plugin directly to plugins_dir
|
||||
GET /composer/api/fonts/<name> — Serve TTF font files for canvas rendering
|
||||
GET /composer/api/validate-id/<id> — Check if a plugin ID is already taken
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
import jsonschema
|
||||
from flask import Blueprint, jsonify, render_template, request, send_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
composer_bp = Blueprint('composer', __name__)
|
||||
|
||||
# Module-level attributes injected by app.py at registration time
|
||||
composer_bp.config_manager = None
|
||||
composer_bp.plugin_manager = None
|
||||
composer_bp.plugins_dir = None
|
||||
composer_bp.project_root = None
|
||||
|
||||
# Fonts safe to serve to the browser for canvas rendering
|
||||
_ALLOWED_FONTS = frozenset({'PressStart2P-Regular.ttf', '4x6-font.ttf', '5by7.regular.ttf'})
|
||||
|
||||
# Map composer font keys → DisplayManager attribute names
|
||||
_FONT_ATTR_MAP = {
|
||||
'press_start': 'regular_font',
|
||||
'four_by_six': 'extra_small_font',
|
||||
'five_by_seven': 'bdf_5x7_font',
|
||||
}
|
||||
|
||||
# Font sizes in LED pixels (used to compute second-line Y offsets)
|
||||
_FONT_SIZE_MAP = {
|
||||
'press_start': 8,
|
||||
'four_by_six': 6,
|
||||
'five_by_seven': 7,
|
||||
}
|
||||
|
||||
_PLUGIN_ID_RE = re.compile(r'^[a-z][a-z0-9-]{0,62}$')
|
||||
_PYTHON_IDENT_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
|
||||
|
||||
# ── Jinja2 environment (separate from Flask's; autoescape=False for code gen) ──
|
||||
|
||||
_jinja_env: jinja2.Environment | None = None
|
||||
|
||||
|
||||
def _get_jinja_env() -> jinja2.Environment:
|
||||
global _jinja_env
|
||||
if _jinja_env is None:
|
||||
template_dir = Path(__file__).parent.parent / 'templates' / 'v3' / 'composer'
|
||||
_jinja_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(str(template_dir)),
|
||||
autoescape=False,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
_jinja_env.filters['as_rgb'] = _as_rgb_filter
|
||||
_jinja_env.filters['as_fill'] = _as_fill_filter
|
||||
return _jinja_env
|
||||
|
||||
|
||||
def _as_rgb_filter(val) -> str:
|
||||
"""[r, g, b] → '(r, g, b)'"""
|
||||
if val is None:
|
||||
return 'None'
|
||||
return f'({int(val[0])}, {int(val[1])}, {int(val[2])})'
|
||||
|
||||
|
||||
def _as_fill_filter(val) -> str:
|
||||
"""[r, g, b] or None → '(r, g, b)' or 'None'"""
|
||||
if val is None:
|
||||
return 'None'
|
||||
return _as_rgb_filter(val)
|
||||
|
||||
|
||||
# ── Helper functions ──────────────────────────────────────────────────────────
|
||||
|
||||
def _to_class_name(name: str) -> str:
|
||||
"""'My Clock' → 'MyClockPlugin' (avoids double-suffix if name already ends with Plugin)"""
|
||||
words = re.sub(r'[^a-zA-Z0-9]', ' ', name).split()
|
||||
base = ''.join(w.capitalize() for w in words)
|
||||
return base if base.endswith('Plugin') else base + 'Plugin'
|
||||
|
||||
|
||||
def _compute_pos_expr(val: int, anchor: str | None, dim_var: str) -> str:
|
||||
"""Produce a Python expression string for an anchored or fixed position.
|
||||
|
||||
anchor=None/'left'/'top' → fixed pixel value
|
||||
anchor='center' → dim_var // 2 ± offset
|
||||
anchor='right'/'bottom' → dim_var - offset
|
||||
"""
|
||||
if not anchor or anchor in ('left', 'top'):
|
||||
return str(val)
|
||||
if anchor in ('center', 'middle'):
|
||||
if val == 0:
|
||||
return f"{dim_var} // 2"
|
||||
return f"{dim_var} // 2 + {val}" if val > 0 else f"{dim_var} // 2 - {abs(val)}"
|
||||
if anchor in ('right', 'bottom'):
|
||||
return dim_var if val == 0 else f"{dim_var} - {val}"
|
||||
return str(val)
|
||||
|
||||
|
||||
# Character widths in LED pixels per font (for text-alignment x offset math)
|
||||
_FONT_CHAR_W = {
|
||||
'press_start': 8,
|
||||
'four_by_six': 4,
|
||||
'five_by_seven': 5,
|
||||
}
|
||||
|
||||
|
||||
def _aligned_x_expr(x_base_expr: str, text_align: str, char_count: int, char_w: int) -> str:
|
||||
"""Return Python x expression for text alignment.
|
||||
|
||||
left → x_base_expr (no change)
|
||||
center → x_base_expr - half_text_width
|
||||
right → x_base_expr - text_width
|
||||
"""
|
||||
if text_align == 'left' or not text_align:
|
||||
return x_base_expr
|
||||
text_px = char_count * char_w
|
||||
if text_align == 'center':
|
||||
offset = text_px // 2
|
||||
return f"({x_base_expr}) - {offset}" if offset else x_base_expr
|
||||
if text_align == 'right':
|
||||
return f"({x_base_expr}) - {text_px}" if text_px else x_base_expr
|
||||
return x_base_expr
|
||||
|
||||
|
||||
def _preprocess_elements(elements: list) -> list:
|
||||
"""Expand raw element dicts into template-ready dicts with anchor expressions.
|
||||
|
||||
Invisible elements (visible=False) are excluded from generated code entirely.
|
||||
"""
|
||||
result = []
|
||||
for el in elements:
|
||||
# Skip hidden elements — they exist only in the preview
|
||||
if el.get('visible') is False:
|
||||
continue
|
||||
|
||||
p = dict(el)
|
||||
t = el.get('type', '')
|
||||
|
||||
# Section elements are layer-list annotations only — no canvas output
|
||||
if t == 'section':
|
||||
continue
|
||||
|
||||
x_anchor = el.get('xAnchor') or None
|
||||
y_anchor = el.get('yAnchor') or None
|
||||
p['min_width'] = int(el.get('minWidth', 0) or 0)
|
||||
|
||||
if t in ('text', 'clock'):
|
||||
font_key = el.get('font', 'press_start')
|
||||
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
|
||||
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
|
||||
text_align = el.get('textAlign', 'left')
|
||||
raw_x = el.get('x', 0)
|
||||
x_base_expr = _compute_pos_expr(raw_x, x_anchor, 'width')
|
||||
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
font_size = _FONT_SIZE_MAP.get(font_key, 8)
|
||||
char_w = _FONT_CHAR_W.get(font_key, 8)
|
||||
line_spacing = int(el.get('lineSpacing', 2))
|
||||
y_expr = p['y_expr']
|
||||
p['y2_expr'] = f"({y_expr}) + {font_size + line_spacing}"
|
||||
if t == 'text':
|
||||
t1 = el.get('text', '') or ''
|
||||
t2 = el.get('text2', '') or ''
|
||||
p['text2'] = t2
|
||||
# Detect {variable} tokens — generate format_map() call instead of literal
|
||||
_var_re = re.compile(r'\{([a-zA-Z_]\w*)\}')
|
||||
p['text_is_template'] = bool(_var_re.search(t1) or _var_re.search(t2))
|
||||
ref_len = max(len(t1), len(t2)) if t2 else len(t1)
|
||||
p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w)
|
||||
p['x2_expr'] = p['x_expr'] # second line uses same x
|
||||
else: # clock
|
||||
fmt1 = el.get('format', '%H:%M') or '%H:%M'
|
||||
fmt2 = el.get('format2', '') or ''
|
||||
p['format2'] = fmt2
|
||||
ref_len = max(len(fmt1), len(fmt2)) if fmt2 else len(fmt1)
|
||||
p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w)
|
||||
p['x2_expr'] = p['x_expr']
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'dynamic_text':
|
||||
binding = el.get('binding', {})
|
||||
p['binding_source'] = binding.get('source', 'config')
|
||||
p['binding_key'] = binding.get('key', '')
|
||||
p['binding_format'] = binding.get('format')
|
||||
font_key = el.get('font', 'press_start')
|
||||
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
|
||||
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 100)})"
|
||||
x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||
p['x_expr'] = x_base_expr # dynamic text: runtime content determines width; use raw pos
|
||||
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'rectangle':
|
||||
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
w = el.get('width', 10)
|
||||
h = el.get('height', 8)
|
||||
p['x_expr'] = x_expr
|
||||
p['y_expr'] = y_expr
|
||||
# x2/y2 as runtime expressions to support anchored positions
|
||||
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||
fill = (
|
||||
[el.get('fillR', 0), el.get('fillG', 0), el.get('fillB', 128)]
|
||||
if el.get('hasFill', True) else None
|
||||
)
|
||||
outline = (
|
||||
[el.get('outR', 255), el.get('outG', 255), el.get('outB', 255)]
|
||||
if el.get('hasOutline', True) else None
|
||||
)
|
||||
p['fill_tuple'] = _as_fill_filter(fill)
|
||||
p['outline_tuple'] = _as_fill_filter(outline)
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t in ('line', 'divider'):
|
||||
if t == 'divider':
|
||||
orient = el.get('orientation', 'horizontal')
|
||||
if orient == 'horizontal':
|
||||
y_val = el.get('y', 16)
|
||||
y_expr = _compute_pos_expr(y_val, y_anchor, 'height')
|
||||
p.update(x0_expr='0', y0_expr=y_expr, x1_expr='width - 1', y1_expr=y_expr)
|
||||
else:
|
||||
x_val = el.get('x', 64)
|
||||
x_expr = _compute_pos_expr(x_val, x_anchor, 'width')
|
||||
p.update(x0_expr=x_expr, y0_expr='0', x1_expr=x_expr, y1_expr='height - 1')
|
||||
else:
|
||||
p['x0_expr'] = _compute_pos_expr(el.get('x0', 0), x_anchor, 'width')
|
||||
p['y0_expr'] = _compute_pos_expr(el.get('y0', 0), y_anchor, 'height')
|
||||
p['x1_expr'] = str(el.get('x1', 127))
|
||||
p['y1_expr'] = str(el.get('y1', 0))
|
||||
p['rgb_tuple'] = f"({el.get('r', 180)}, {el.get('g', 180)}, {el.get('b', 180)})"
|
||||
p['line_width'] = el.get('lineWidth', 1)
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'progress_bar':
|
||||
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
p['bar_width'] = int(el.get('barWidth', 40))
|
||||
p['bar_height'] = int(el.get('barHeight', 6))
|
||||
binding = el.get('binding', {})
|
||||
p['binding_key'] = binding.get('key', '')
|
||||
p['fill_tuple'] = f"({el.get('r', 100)}, {el.get('g', 200)}, {el.get('b', 100)})"
|
||||
bg = (
|
||||
[el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)]
|
||||
if el.get('hasBg', True) else None
|
||||
)
|
||||
outline = (
|
||||
[el.get('outR', 100), el.get('outG', 100), el.get('outB', 100)]
|
||||
if el.get('hasOutline', True) else None
|
||||
)
|
||||
p['bg_tuple'] = _as_fill_filter(bg)
|
||||
p['outline_tuple'] = _as_fill_filter(outline)
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'arc':
|
||||
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
w = el.get('width', 24)
|
||||
h = el.get('height', 24)
|
||||
p['x_expr'] = x_expr
|
||||
p['y_expr'] = y_expr
|
||||
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||
p['start_angle'] = int(el.get('startAngle', 0))
|
||||
p['end_angle'] = int(el.get('endAngle', 270))
|
||||
p['line_width'] = max(1, int(el.get('lineWidth', 2)))
|
||||
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})"
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'ellipse':
|
||||
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
w = el.get('width', 24)
|
||||
h = el.get('height', 12)
|
||||
p['x_expr'] = x_expr
|
||||
p['y_expr'] = y_expr
|
||||
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||
fill = (
|
||||
[el.get('fillR', 0), el.get('fillG', 100), el.get('fillB', 200)]
|
||||
if el.get('hasFill', True) else None
|
||||
)
|
||||
outline = (
|
||||
[el.get('outR', 100), el.get('outG', 180), el.get('outB', 255)]
|
||||
if el.get('hasOutline', True) else None
|
||||
)
|
||||
p['fill_tuple'] = _as_fill_filter(fill)
|
||||
p['outline_tuple'] = _as_fill_filter(outline)
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'pixel':
|
||||
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'rounded_rectangle':
|
||||
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
w = el.get('width', 24)
|
||||
h = el.get('height', 10)
|
||||
p['x_expr'] = x_expr
|
||||
p['y_expr'] = y_expr
|
||||
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||
p['border_radius'] = int(el.get('borderRadius', 3))
|
||||
fill = (
|
||||
[el.get('fillR', 0), el.get('fillG', 80), el.get('fillB', 180)]
|
||||
if el.get('hasFill', True) else None
|
||||
)
|
||||
outline = (
|
||||
[el.get('outR', 120), el.get('outG', 180), el.get('outB', 255)]
|
||||
if el.get('hasOutline', True) else None
|
||||
)
|
||||
p['fill_tuple'] = _as_fill_filter(fill)
|
||||
p['outline_tuple'] = _as_fill_filter(outline)
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'countdown':
|
||||
font_key = el.get('font', 'four_by_six')
|
||||
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font')
|
||||
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 180)}, {el.get('b', 0)})"
|
||||
binding = el.get('binding', {})
|
||||
p['binding_key'] = binding.get('key', '')
|
||||
p['countdown_format'] = el.get('countdownFormat', 'dh')
|
||||
x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||
p['x_expr'] = x_base_expr
|
||||
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'pips':
|
||||
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
p['pip_count'] = max(1, int(el.get('count', 5)))
|
||||
p['pip_size'] = max(1, int(el.get('pipSize', 4)))
|
||||
p['pip_spacing'] = max(0, int(el.get('pipSpacing', 2)))
|
||||
p['show_empty'] = bool(el.get('showEmpty', True))
|
||||
binding = el.get('binding', {})
|
||||
p['binding_key'] = binding.get('key', '')
|
||||
p['fill_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})"
|
||||
p['empty_tuple'] = f"({el.get('emptyR', 50)}, {el.get('emptyG', 50)}, {el.get('emptyB', 50)})"
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'sparkline':
|
||||
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
p['x_expr'] = x_expr
|
||||
p['y_expr'] = y_expr
|
||||
p['bar_width_px'] = int(el.get('width', 40))
|
||||
p['bar_height_px'] = int(el.get('height', 12))
|
||||
p['bar_count'] = max(1, int(el.get('barCount', 8)))
|
||||
p['bar_spacing'] = max(0, int(el.get('barSpacing', 1)))
|
||||
binding = el.get('binding', {})
|
||||
p['binding_key'] = binding.get('key', '')
|
||||
p['fill_tuple'] = f"({el.get('r', 80)}, {el.get('g', 200)}, {el.get('b', 120)})"
|
||||
bg = [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] if el.get('hasBg', False) else None
|
||||
p['bg_tuple'] = _as_fill_filter(bg)
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'gauge':
|
||||
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
w = el.get('width', 32)
|
||||
h = el.get('height', 32)
|
||||
p['x_expr'] = x_expr
|
||||
p['y_expr'] = y_expr
|
||||
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||
p['start_angle'] = int(el.get('startAngle', 135))
|
||||
p['end_angle'] = int(el.get('endAngle', 45))
|
||||
p['line_width'] = max(1, int(el.get('lineWidth', 3)))
|
||||
p['rgb_tuple'] = f"({el.get('r', 80)}, {el.get('g', 220)}, {el.get('b', 80)})"
|
||||
track = (
|
||||
[el.get('trackR', 40), el.get('trackG', 40), el.get('trackB', 40)]
|
||||
if el.get('hasTrack', True) else None
|
||||
)
|
||||
p['track_tuple'] = _as_fill_filter(track)
|
||||
binding = el.get('binding', {})
|
||||
p['binding_key'] = binding.get('key', '')
|
||||
font_key = el.get('font', 'four_by_six')
|
||||
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font')
|
||||
p['show_label'] = bool(el.get('showLabel', True))
|
||||
p['label_tuple'] = f"({el.get('labelR', 200)}, {el.get('labelG', 200)}, {el.get('labelB', 200)})"
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
elif t == 'marquee':
|
||||
font_key = el.get('font', 'press_start')
|
||||
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
|
||||
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
|
||||
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||
p['text'] = el.get('text', 'Scrolling text')
|
||||
p['char_w'] = _FONT_CHAR_W.get(font_key, 8)
|
||||
p['gap'] = int(el.get('gap', 16))
|
||||
p['scroll_speed'] = max(1, int(el.get('scrollSpeed', 1)))
|
||||
p['direction'] = el.get('direction', 'left')
|
||||
# Data key stored in self._data for stateful scrolling across display() calls
|
||||
raw_id = str(el.get('id', 0)).replace('-', '_')
|
||||
p['data_key'] = f"mq_{raw_id}"
|
||||
p['blink'] = bool(el.get('blink', False))
|
||||
|
||||
result.append(p)
|
||||
return result
|
||||
|
||||
|
||||
def _generate_plugin_files(data: dict) -> dict:
|
||||
"""
|
||||
Generate all plugin file contents as strings.
|
||||
|
||||
Returns dict: {'manager.py', 'manifest.json', 'config_schema.json', 'requirements.txt'}
|
||||
Raises ValueError with a human-readable message on any validation failure.
|
||||
"""
|
||||
metadata = data.get('metadata', {})
|
||||
elements = data.get('elements', [])
|
||||
data_model = data.get('dataModel', {})
|
||||
config_vars = data_model.get('configVars', [])
|
||||
|
||||
plugin_id = metadata.get('id', '').strip()
|
||||
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||
raise ValueError(
|
||||
'Plugin ID must start with a lowercase letter and contain only '
|
||||
'lowercase letters, numbers, and hyphens (max 63 chars).'
|
||||
)
|
||||
|
||||
plugin_name = metadata.get('name', '').strip()
|
||||
if not plugin_name:
|
||||
raise ValueError('Plugin name is required.')
|
||||
|
||||
author = metadata.get('author', '').strip()
|
||||
if not author:
|
||||
raise ValueError('Author is required.')
|
||||
|
||||
version = metadata.get('version', '1.0.0').strip()
|
||||
|
||||
# Validate config var keys are valid Python identifiers
|
||||
for cv in config_vars:
|
||||
key = cv.get('key', '')
|
||||
if not _PYTHON_IDENT_RE.match(key):
|
||||
raise ValueError(f'Config variable key "{key}" is not a valid Python identifier.')
|
||||
|
||||
class_name = _to_class_name(plugin_name)
|
||||
# Only consider visible elements for code generation flags
|
||||
visible_elements = [e for e in elements if e.get('visible') is not False]
|
||||
processed = _preprocess_elements(elements)
|
||||
has_clock = any(e.get('type') == 'clock' for e in visible_elements)
|
||||
has_blink = any(e.get('blink') for e in visible_elements)
|
||||
has_countdown = any(e.get('type') == 'countdown' for e in visible_elements)
|
||||
_var_re = re.compile(r'\{[a-zA-Z_]\w*\}')
|
||||
has_text_template = any(
|
||||
e.get('type') == 'text' and (
|
||||
_var_re.search(e.get('text', '') or '') or
|
||||
_var_re.search(e.get('text2', '') or '')
|
||||
)
|
||||
for e in visible_elements
|
||||
)
|
||||
|
||||
# Background fill color (None → don't render, use LED panel's native black)
|
||||
bg_color: str | None = None
|
||||
bg_raw = metadata.get('bgColor')
|
||||
if isinstance(bg_raw, dict):
|
||||
r, g, b = int(bg_raw.get('r', 0)), int(bg_raw.get('g', 0)), int(bg_raw.get('b', 0))
|
||||
if r or g or b:
|
||||
bg_color = f'({r}, {g}, {b})'
|
||||
|
||||
# Render manager.py
|
||||
env = _get_jinja_env()
|
||||
try:
|
||||
tmpl = env.get_template('manager.py.j2')
|
||||
except jinja2.TemplateNotFound:
|
||||
raise ValueError('Code generation template not found. This is a server configuration issue.')
|
||||
|
||||
manager_py = tmpl.render(
|
||||
plugin_name=plugin_name,
|
||||
class_name=class_name,
|
||||
plugin_id=plugin_id,
|
||||
generated_date=datetime.now().strftime('%Y-%m-%d'),
|
||||
config_vars=config_vars,
|
||||
elements=processed,
|
||||
has_clock=has_clock,
|
||||
has_blink=has_blink,
|
||||
has_countdown=has_countdown,
|
||||
has_text_template=has_text_template,
|
||||
bg_color=bg_color,
|
||||
)
|
||||
|
||||
# Syntax-check the generated Python
|
||||
try:
|
||||
ast.parse(manager_py)
|
||||
except SyntaxError as exc:
|
||||
raise ValueError(f'Generated code has a syntax error: {exc}') from exc
|
||||
|
||||
# Build manifest
|
||||
manifest = {
|
||||
'id': plugin_id,
|
||||
'name': plugin_name,
|
||||
'version': version,
|
||||
'author': author,
|
||||
'description': metadata.get('description', f'Custom plugin created with LEDMatrix Plugin Composer'),
|
||||
'category': metadata.get('category', 'custom'),
|
||||
'tags': ['composer', 'custom'],
|
||||
'entry_point': 'manager.py',
|
||||
'class_name': class_name,
|
||||
'display_modes': [plugin_id],
|
||||
'compatible_versions': ['>=2.0.0'],
|
||||
'last_updated': datetime.now().strftime('%Y-%m-%d'),
|
||||
'update_interval': int(metadata.get('update_interval', 60)),
|
||||
'default_duration': float(metadata.get('display_duration', 15)),
|
||||
'versions': [
|
||||
{'released': datetime.now().strftime('%Y-%m-%d'), 'version': version}
|
||||
],
|
||||
}
|
||||
|
||||
# Validate manifest against the project's schema
|
||||
if composer_bp.project_root:
|
||||
schema_path = Path(composer_bp.project_root) / 'schema' / 'manifest_schema.json'
|
||||
if schema_path.exists():
|
||||
schema = json.loads(schema_path.read_text())
|
||||
validator = jsonschema.Draft7Validator(schema)
|
||||
errors = list(validator.iter_errors(manifest))
|
||||
if errors:
|
||||
msgs = '; '.join(e.message for e in errors[:3])
|
||||
raise ValueError(f'Manifest validation failed: {msgs}')
|
||||
|
||||
# Build config_schema
|
||||
type_map = {
|
||||
'string': {'type': 'string'},
|
||||
'number': {'type': 'number', 'minimum': 0},
|
||||
'boolean': {'type': 'boolean'},
|
||||
'color': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
|
||||
'minItems': 3,
|
||||
'maxItems': 3,
|
||||
},
|
||||
}
|
||||
|
||||
config_properties = {
|
||||
'enabled': {'type': 'boolean', 'default': True},
|
||||
'display_duration': {'type': 'number', 'minimum': 1, 'default': float(metadata.get('display_duration', 15))},
|
||||
}
|
||||
for cv in config_vars:
|
||||
cv_type = cv.get('type', 'string')
|
||||
prop = dict(type_map.get(cv_type, {'type': 'string'}))
|
||||
if cv.get('description'):
|
||||
prop['description'] = cv['description']
|
||||
if cv.get('label'):
|
||||
prop['title'] = cv['label']
|
||||
default = cv.get('default', '')
|
||||
if cv_type == 'number':
|
||||
try:
|
||||
prop['default'] = float(default) if default != '' else 0
|
||||
except (TypeError, ValueError):
|
||||
prop['default'] = 0
|
||||
elif cv_type == 'boolean':
|
||||
prop['default'] = bool(default)
|
||||
else:
|
||||
prop['default'] = default
|
||||
config_properties[cv['key']] = prop
|
||||
|
||||
config_schema = {
|
||||
'$schema': 'http://json-schema.org/draft-07/schema#',
|
||||
'type': 'object',
|
||||
'properties': config_properties,
|
||||
}
|
||||
|
||||
return {
|
||||
'manager.py': manager_py,
|
||||
'manifest.json': json.dumps(manifest, indent=2),
|
||||
'config_schema.json': json.dumps(config_schema, indent=2),
|
||||
'requirements.txt': '',
|
||||
}
|
||||
|
||||
|
||||
def _save_composer_state(target_dir: Path, payload: dict) -> None:
|
||||
"""Persist the raw composer payload alongside the generated plugin files."""
|
||||
(target_dir / '_composer_state.json').write_text(
|
||||
json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8'
|
||||
)
|
||||
|
||||
|
||||
def _pack_zip(files: dict, plugin_id: str) -> io.BytesIO:
|
||||
"""Pack generated plugin files into an in-memory ZIP."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for filename, content in files.items():
|
||||
info = zipfile.ZipInfo(f'{plugin_id}/{filename}')
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
zf.writestr(info, content.encode('utf-8') if isinstance(content, str) else content)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@composer_bp.route('/')
|
||||
def index():
|
||||
return render_template('v3/composer.html')
|
||||
|
||||
|
||||
@composer_bp.route('/api/generate', methods=['POST'])
|
||||
def generate_zip():
|
||||
data = request.get_json(force=True, silent=True)
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
|
||||
try:
|
||||
files = _generate_plugin_files(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'status': 'error', 'message': str(exc)}), 422
|
||||
|
||||
plugin_id = data.get('metadata', {}).get('id', 'plugin')
|
||||
files['_composer_state.json'] = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
zip_buf = _pack_zip(files, plugin_id)
|
||||
return send_file(
|
||||
zip_buf,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=f'{plugin_id}.zip',
|
||||
)
|
||||
|
||||
|
||||
@composer_bp.route('/api/install', methods=['POST'])
|
||||
def install_locally():
|
||||
if not composer_bp.plugins_dir:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503
|
||||
|
||||
data = request.get_json(force=True, silent=True)
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
|
||||
|
||||
try:
|
||||
files = _generate_plugin_files(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'status': 'error', 'message': str(exc)}), 422
|
||||
|
||||
plugin_id = data.get('metadata', {}).get('id', '')
|
||||
target = Path(composer_bp.plugins_dir) / plugin_id
|
||||
force = bool(data.get('_force', False))
|
||||
|
||||
if target.exists() and not force:
|
||||
return jsonify({
|
||||
'status': 'conflict',
|
||||
'message': f'Plugin "{plugin_id}" is already installed.',
|
||||
}), 409
|
||||
|
||||
try:
|
||||
if target.exists() and force:
|
||||
import shutil as _shutil
|
||||
_shutil.rmtree(target)
|
||||
target.mkdir(parents=True, exist_ok=False)
|
||||
for filename, content in files.items():
|
||||
(target / filename).write_text(content, encoding='utf-8')
|
||||
_save_composer_state(target, data)
|
||||
except OSError as exc:
|
||||
return jsonify({'status': 'error', 'message': f'Failed to write plugin files: {exc}'}), 500
|
||||
|
||||
# Trigger plugin discovery so it shows up in the Plugin Manager immediately
|
||||
if composer_bp.plugin_manager:
|
||||
try:
|
||||
composer_bp.plugin_manager.discover_plugins()
|
||||
except Exception as exc:
|
||||
logger.warning('discover_plugins() failed after composer install: %s', exc)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Plugin "{plugin_id}" installed successfully.',
|
||||
'plugin_id': plugin_id,
|
||||
})
|
||||
|
||||
|
||||
@composer_bp.route('/api/fonts/<font_name>')
|
||||
def serve_font(font_name):
|
||||
"""Serve an allowlisted font file for canvas FontFace loading."""
|
||||
if font_name not in _ALLOWED_FONTS:
|
||||
return '', 404
|
||||
if not composer_bp.project_root:
|
||||
return '', 503
|
||||
font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / font_name
|
||||
if not font_path.exists():
|
||||
return '', 404
|
||||
return send_file(str(font_path), mimetype='font/ttf')
|
||||
|
||||
|
||||
@composer_bp.route('/api/validate-id/<plugin_id>')
|
||||
def validate_id(plugin_id):
|
||||
"""Check whether a plugin ID is valid and available."""
|
||||
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||
return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'})
|
||||
if composer_bp.plugins_dir:
|
||||
taken = (Path(composer_bp.plugins_dir) / plugin_id).exists()
|
||||
if taken:
|
||||
return jsonify({'valid': True, 'available': False, 'reason': 'Already installed'})
|
||||
return jsonify({'valid': True, 'available': True})
|
||||
|
||||
|
||||
@composer_bp.route('/api/plugins')
|
||||
def list_plugins():
|
||||
"""List installed plugins, flagging which ones have a saved composer state."""
|
||||
if not composer_bp.plugins_dir:
|
||||
return jsonify([])
|
||||
plugins_dir = Path(composer_bp.plugins_dir)
|
||||
results = []
|
||||
for entry in sorted(plugins_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
manifest_path = entry / 'manifest.json'
|
||||
if not manifest_path.exists():
|
||||
continue
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
has_state = (entry / '_composer_state.json').exists()
|
||||
results.append({
|
||||
'id': manifest.get('id', entry.name),
|
||||
'name': manifest.get('name', entry.name),
|
||||
'version': manifest.get('version', ''),
|
||||
'author': manifest.get('author', ''),
|
||||
'has_composer_state': has_state,
|
||||
})
|
||||
return jsonify(results)
|
||||
|
||||
|
||||
@composer_bp.route('/api/preview', methods=['POST'])
|
||||
def preview_code():
|
||||
"""Generate plugin files and return them as JSON for the code preview modal."""
|
||||
data = request.get_json(force=True, silent=True)
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
|
||||
try:
|
||||
files = _generate_plugin_files(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'status': 'error', 'message': str(exc)}), 422
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'files': {
|
||||
'manager.py': files['manager.py'],
|
||||
'manifest.json': files['manifest.json'],
|
||||
'config_schema.json': files['config_schema.json'],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@composer_bp.route('/api/load/<plugin_id>')
|
||||
def load_plugin(plugin_id):
|
||||
"""Load a plugin's composer state for editing.
|
||||
|
||||
If a _composer_state.json exists, return it verbatim.
|
||||
Otherwise, extract config vars from config_schema.json for a partial import.
|
||||
"""
|
||||
if not composer_bp.plugins_dir:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503
|
||||
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
|
||||
|
||||
plugin_dir = Path(composer_bp.plugins_dir) / plugin_id
|
||||
if not plugin_dir.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||
|
||||
# Full composer state
|
||||
state_path = plugin_dir / '_composer_state.json'
|
||||
if state_path.exists():
|
||||
try:
|
||||
state = json.loads(state_path.read_text())
|
||||
return jsonify({'status': 'ok', 'source': 'composer', 'state': state})
|
||||
except Exception as exc:
|
||||
return jsonify({'status': 'error', 'message': f'Failed to read state: {exc}'}), 500
|
||||
|
||||
# Partial import from config_schema.json
|
||||
schema_path = plugin_dir / 'config_schema.json'
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
config_vars = []
|
||||
|
||||
if schema_path.exists():
|
||||
try:
|
||||
schema = json.loads(schema_path.read_text())
|
||||
props = schema.get('properties', {})
|
||||
skip = {'enabled', 'display_duration', 'update_interval'}
|
||||
type_map = {'boolean': 'boolean', 'number': 'number', 'integer': 'number', 'string': 'string'}
|
||||
for key, prop in props.items():
|
||||
if key in skip:
|
||||
continue
|
||||
prop_type = prop.get('type', 'string')
|
||||
if isinstance(prop_type, list):
|
||||
prop_type = next((t for t in prop_type if t != 'null'), 'string')
|
||||
# Detect color arrays
|
||||
if prop_type == 'array' and prop.get('maxItems') == 3:
|
||||
cv_type = 'color'
|
||||
else:
|
||||
cv_type = type_map.get(prop_type, 'string')
|
||||
config_vars.append({
|
||||
'key': key,
|
||||
'label': prop.get('title', key.replace('_', ' ').title()),
|
||||
'type': cv_type,
|
||||
'default': prop.get('default', ''),
|
||||
'description': prop.get('description', ''),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
manifest = {}
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
partial_state = {
|
||||
'composer_version': '1.0',
|
||||
'metadata': {
|
||||
'id': manifest.get('id', plugin_id),
|
||||
'name': manifest.get('name', plugin_id),
|
||||
'author': manifest.get('author', ''),
|
||||
'version': manifest.get('version', '1.0.0'),
|
||||
'description': manifest.get('description', ''),
|
||||
'category': manifest.get('category', 'custom'),
|
||||
'display_duration': manifest.get('default_duration', 15),
|
||||
'update_interval': manifest.get('update_interval', 60),
|
||||
'api_requirements': manifest.get('api_requirements', []),
|
||||
},
|
||||
'elements': [],
|
||||
'dataModel': {'configVars': config_vars, 'dataSources': [], 'computedVars': []},
|
||||
}
|
||||
return jsonify({'status': 'ok', 'source': 'schema_import', 'state': partial_state})
|
||||
@@ -706,12 +706,6 @@ def _load_plugin_config_partial(plugin_id):
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
# Expand x-style-elements declarations into full property
|
||||
# blocks — the same expansion SchemaManager.load_schema
|
||||
# applies on the API paths. The form must render the exact
|
||||
# shape the save path parses and validates against.
|
||||
from src.element_style import expand_style_elements
|
||||
schema = expand_style_elements(schema)
|
||||
except Exception as e:
|
||||
logger.warning("Could not load schema for plugin: %s", e)
|
||||
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
/**
|
||||
* ComposerCanvas — stateless LED matrix canvas renderer.
|
||||
*
|
||||
* Coordinate system: LED pixels (integers). All drawing multiplies by SCALE.
|
||||
* PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline.
|
||||
* → Canvas text cy = (actualY + fontSizePx) * SCALE
|
||||
*
|
||||
* Anchors: element x/y are offsets from their anchor point:
|
||||
* xAnchor=null/'left' → x is fixed offset from left
|
||||
* xAnchor='center' → x is offset from width/2
|
||||
* xAnchor='right' → x is offset inward from right edge
|
||||
* yAnchor follows the same pattern with 'top'/'middle'/'bottom'
|
||||
*
|
||||
* Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity.
|
||||
*
|
||||
* Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids).
|
||||
*/
|
||||
window.ComposerCanvas = (() => {
|
||||
'use strict';
|
||||
|
||||
let _canvas = null;
|
||||
let _ctx = null;
|
||||
let _showGrid = true;
|
||||
|
||||
const DISPLAY_PRESETS = [
|
||||
{ label: '64×32', w: 64, h: 32 },
|
||||
{ label: '128×32', w: 128, h: 32 },
|
||||
{ label: '128×64', w: 128, h: 64 },
|
||||
{ label: '256×32', w: 256, h: 32 },
|
||||
{ label: '256×64', w: 256, h: 64 },
|
||||
];
|
||||
|
||||
const FONT_MAP = {
|
||||
press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 },
|
||||
four_by_six: { family: 'monospace', sizePx: 6, charW: 4 },
|
||||
five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 },
|
||||
};
|
||||
|
||||
const ELEMENT_DEFAULTS = {
|
||||
text: {
|
||||
text: 'Hello', font: 'press_start',
|
||||
r: 255, g: 255, b: 255,
|
||||
text2: '', lineSpacing: 2, textAlign: 'left',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
dynamic_text: {
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
font: 'press_start', textAlign: 'left',
|
||||
r: 255, g: 200, b: 100,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
clock: {
|
||||
format: '%H:%M', font: 'press_start',
|
||||
r: 100, g: 255, b: 100,
|
||||
format2: '', lineSpacing: 2, textAlign: 'left',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
rectangle: {
|
||||
width: 20, height: 8,
|
||||
fillR: 0, fillG: 0, fillB: 128, hasFill: true,
|
||||
outR: 255, outG: 255, outB: 255, hasOutline: true,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
ellipse: {
|
||||
width: 24, height: 12,
|
||||
fillR: 0, fillG: 100, fillB: 200, hasFill: true,
|
||||
outR: 100, outG: 180, outB: 255, hasOutline: true,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
arc: {
|
||||
width: 24, height: 24,
|
||||
startAngle: 0, endAngle: 270, lineWidth: 2,
|
||||
r: 255, g: 200, b: 0,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
pixel: {
|
||||
r: 255, g: 255, b: 255,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
rounded_rectangle: {
|
||||
width: 24, height: 10, borderRadius: 3,
|
||||
fillR: 0, fillG: 80, fillB: 180, hasFill: true,
|
||||
outR: 120, outG: 180, outB: 255, hasOutline: true,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
line: {
|
||||
x0: 0, y0: 16, x1: 63, y1: 16,
|
||||
r: 180, g: 180, b: 180, lineWidth: 1,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
divider: {
|
||||
orientation: 'horizontal', y: 16, x: 64,
|
||||
r: 100, g: 100, b: 100,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
progress_bar: {
|
||||
barWidth: 60, barHeight: 6,
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
r: 80, g: 200, b: 80,
|
||||
bgR: 30, bgG: 30, bgB: 30, hasBg: true,
|
||||
outR: 100, outG: 100, outB: 100, hasOutline: true,
|
||||
previewPct: 65,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
countdown: {
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
countdownFormat: 'dh',
|
||||
font: 'four_by_six', textAlign: 'left',
|
||||
r: 255, g: 180, b: 0,
|
||||
previewText: '42d 3h',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
marquee: {
|
||||
text: 'Scrolling text', font: 'press_start',
|
||||
r: 255, g: 255, b: 255,
|
||||
scrollSpeed: 1, gap: 16, direction: 'left',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
section: {
|
||||
label: 'Section',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
pips: {
|
||||
count: 5, filled: 3, pipSize: 4, pipSpacing: 2,
|
||||
r: 255, g: 200, b: 0,
|
||||
emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true,
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
sparkline: {
|
||||
width: 40, height: 12,
|
||||
barCount: 8, barSpacing: 1,
|
||||
r: 80, g: 200, b: 120,
|
||||
bgR: 30, bgG: 30, bgB: 30, hasBg: false,
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
gauge: {
|
||||
width: 32, height: 32,
|
||||
startAngle: 135, endAngle: 45, lineWidth: 3,
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
r: 80, g: 220, b: 80,
|
||||
trackR: 40, trackG: 40, trackB: 40, hasTrack: true,
|
||||
showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200,
|
||||
previewPct: 65,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Anchor resolution ────────────────────────────────────────────────
|
||||
function resolveAnchor(val, anchor, dim) {
|
||||
if (!anchor || anchor === 'left' || anchor === 'top') return val;
|
||||
if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val;
|
||||
if (anchor === 'right' || anchor === 'bottom') return dim - val;
|
||||
return val;
|
||||
}
|
||||
|
||||
function computeActualPos(el, matrixW, matrixH) {
|
||||
const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW);
|
||||
const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH);
|
||||
return { x: ax, y: ay };
|
||||
}
|
||||
|
||||
// ── Bounding box (LED pixel space) ──────────────────────────────────
|
||||
function getBoundingBox(el, matrixW, matrixH) {
|
||||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||
|
||||
switch (el.type) {
|
||||
case 'text': {
|
||||
const t1 = el.text || '', t2 = el.text2 || '';
|
||||
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
||||
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||
return { x: bx, y: ay, w, h };
|
||||
}
|
||||
case 'dynamic_text': {
|
||||
const key = el.binding?.key || '?';
|
||||
const w = (`{${key}}`).length * finfo.charW;
|
||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||
return { x: bx, y: ay, w, h: finfo.sizePx };
|
||||
}
|
||||
case 'clock': {
|
||||
const t1 = el.format || '%H:%M', t2 = el.format2 || '';
|
||||
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
||||
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||
return { x: bx, y: ay, w, h };
|
||||
}
|
||||
case 'countdown': {
|
||||
const pt = el.previewText || '--d --h';
|
||||
const w = pt.length * finfo.charW;
|
||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||
return { x: bx, y: ay, w, h: finfo.sizePx };
|
||||
}
|
||||
case 'rectangle':
|
||||
case 'rounded_rectangle':
|
||||
case 'ellipse':
|
||||
case 'arc':
|
||||
return { x: ax, y: ay, w: el.width, h: el.height };
|
||||
case 'pixel':
|
||||
return { x: ax, y: ay, w: 1, h: 1 };
|
||||
case 'line':
|
||||
return {
|
||||
x: Math.min(el.x0, el.x1), y: Math.min(el.y0, el.y1),
|
||||
w: Math.max(1, Math.abs(el.x1 - el.x0)),
|
||||
h: Math.max(1, Math.abs(el.y1 - el.y0)),
|
||||
};
|
||||
case 'divider':
|
||||
return el.orientation === 'horizontal'
|
||||
? { x: 0, y: ay, w: matrixW, h: 1 }
|
||||
: { x: ax, y: 0, w: 1, h: matrixH };
|
||||
case 'progress_bar':
|
||||
return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 };
|
||||
case 'marquee': {
|
||||
const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||
return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx };
|
||||
}
|
||||
case 'gauge':
|
||||
return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 };
|
||||
case 'sparkline':
|
||||
return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 };
|
||||
case 'pips': {
|
||||
const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2;
|
||||
return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps };
|
||||
}
|
||||
case 'section':
|
||||
return { x: ax, y: ay, w: 0, h: 0 };
|
||||
default:
|
||||
return { x: ax, y: ay, w: 4, h: 4 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resize handle support ─────────────────────────────────────────────
|
||||
// Returns 8 handle points for a rectangle in LED pixel space
|
||||
function _getRectHandles(el, matrixW, matrixH) {
|
||||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||
const w = el.width, h = el.height;
|
||||
const cx = ax + w / 2, cy = ay + h / 2;
|
||||
return {
|
||||
nw: { x: ax, y: ay },
|
||||
n: { x: cx, y: ay },
|
||||
ne: { x: ax + w, y: ay },
|
||||
w: { x: ax, y: cy },
|
||||
e: { x: ax + w, y: cy },
|
||||
sw: { x: ax, y: ay + h },
|
||||
s: { x: cx, y: ay + h },
|
||||
se: { x: ax + w, y: ay + h },
|
||||
};
|
||||
}
|
||||
|
||||
// Returns the handle direction under LED-space point (lx, ly), or null
|
||||
function getResizeHandle(el, lx, ly, matrixW, matrixH) {
|
||||
if (!['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) return null;
|
||||
const handles = _getRectHandles(el, matrixW, matrixH);
|
||||
const PAD = 4;
|
||||
for (const [dir, pt] of Object.entries(handles)) {
|
||||
if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const _HANDLE_CURSORS = {
|
||||
nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize',
|
||||
w: 'w-resize', e: 'e-resize',
|
||||
sw: 'sw-resize', s: 's-resize', se: 'se-resize',
|
||||
};
|
||||
function getCursorForHandle(handle) {
|
||||
return _HANDLE_CURSORS[handle] || 'crosshair';
|
||||
}
|
||||
|
||||
// ── Hit test ─────────────────────────────────────────────────────────
|
||||
function hitTest(el, lx, ly, matrixW, matrixH) {
|
||||
const PAD = 3;
|
||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||
return (
|
||||
lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD &&
|
||||
ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD
|
||||
);
|
||||
}
|
||||
|
||||
// ── Draw a single element ─────────────────────────────────────────────
|
||||
function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) {
|
||||
const s = SCALE;
|
||||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||
const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth;
|
||||
const hidden = el.visible === false;
|
||||
|
||||
ctx.save();
|
||||
if (hidden) ctx.globalAlpha = 0.12;
|
||||
else if (belowBreakpoint) ctx.globalAlpha = 0.25;
|
||||
|
||||
// Blink animation: when blinkOff, fully hide blinking elements
|
||||
if (el.blink) {
|
||||
if (opts.blinkOff) { ctx.restore(); return; }
|
||||
ctx.globalAlpha *= 0.55;
|
||||
}
|
||||
|
||||
// Helper: compute draw X for text alignment
|
||||
const _textX = (text, finfo) => {
|
||||
const tw = text.length * finfo.charW * s;
|
||||
if (el.textAlign === 'center') return ax * s - tw / 2;
|
||||
if (el.textAlign === 'right') return ax * s - tw;
|
||||
return ax * s;
|
||||
};
|
||||
|
||||
try {
|
||||
switch (el.type) {
|
||||
case 'text':
|
||||
case 'dynamic_text':
|
||||
case 'clock': {
|
||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||
const key = el.binding?.key || '?';
|
||||
const pv = opts.previewValues?.[key];
|
||||
// Substitute {variable} tokens in text using previewValues
|
||||
const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => {
|
||||
const v = opts.previewValues?.[k];
|
||||
return v !== undefined && v !== '' ? String(v) : `{${k}}`;
|
||||
});
|
||||
const displayText =
|
||||
el.type === 'text' ? _subVars(el.text || '')
|
||||
: el.type === 'clock' ? (el.format || '%H:%M')
|
||||
: (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`);
|
||||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s);
|
||||
// Second line (text and clock)
|
||||
if (el.type === 'text' && el.text2) {
|
||||
const t2 = _subVars(el.text2);
|
||||
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
||||
ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s);
|
||||
}
|
||||
if (el.type === 'clock' && el.format2) {
|
||||
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
||||
ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'countdown': {
|
||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||
const t = el.previewText || '--d --h';
|
||||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rectangle': {
|
||||
const rx = ax * s, ry = ay * s;
|
||||
const rw = el.width * s, rh = el.height * s;
|
||||
if (el.hasFill) {
|
||||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||
ctx.fillRect(rx, ry, rw, rh);
|
||||
}
|
||||
if (el.hasOutline) {
|
||||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(rx, ry, rw, rh);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ellipse': {
|
||||
const cx = (ax + el.width / 2) * s;
|
||||
const cy = (ay + el.height / 2) * s;
|
||||
const rx = (el.width / 2) * s;
|
||||
const ry = (el.height / 2) * s;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
if (el.hasFill) {
|
||||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||
ctx.fill();
|
||||
}
|
||||
if (el.hasOutline) {
|
||||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'arc': {
|
||||
const cx = (ax + el.width / 2) * s;
|
||||
const cy = (ay + el.height / 2) * s;
|
||||
const rx = (el.width / 2) * s;
|
||||
const ry = (el.height / 2) * s;
|
||||
// PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false
|
||||
const startRad = (el.startAngle ?? 0) * Math.PI / 180;
|
||||
const endRad = (el.endAngle ?? 270) * Math.PI / 180;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false);
|
||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.lineWidth = Math.max(1, el.lineWidth || 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pixel': {
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.fillRect(ax * s, ay * s, s, s);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rounded_rectangle': {
|
||||
const rx = ax * s, ry = ay * s;
|
||||
const rw = el.width * s, rh = el.height * s;
|
||||
const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(rx, ry, rw, rh, rad);
|
||||
if (el.hasFill) {
|
||||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||
ctx.fill();
|
||||
}
|
||||
if (el.hasOutline) {
|
||||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'line': {
|
||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.lineWidth = Math.max(1, el.lineWidth || 1);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(el.x0 * s, el.y0 * s);
|
||||
ctx.lineTo(el.x1 * s, el.y1 * s);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'divider': {
|
||||
const isH = (el.orientation || 'horizontal') === 'horizontal';
|
||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
if (isH) {
|
||||
ctx.moveTo(0, ay * s + 0.5);
|
||||
ctx.lineTo(_canvas.width, ay * s + 0.5);
|
||||
} else {
|
||||
ctx.moveTo(ax * s + 0.5, 0);
|
||||
ctx.lineTo(ax * s + 0.5, _canvas.height);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pips': {
|
||||
const pipCount = Math.max(1, el.count ?? 5);
|
||||
const pvPips = opts.previewValues?.[el.binding?.key];
|
||||
const filledN = pvPips !== undefined
|
||||
? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0)))
|
||||
: Math.max(0, Math.min(pipCount, el.filled ?? 3));
|
||||
const ps = Math.max(1, el.pipSize ?? 4);
|
||||
const pg = Math.max(0, el.pipSpacing ?? 2);
|
||||
for (let i = 0; i < pipCount; i++) {
|
||||
const isFilled = i < filledN;
|
||||
if (!isFilled && !el.showEmpty) continue;
|
||||
ctx.fillStyle = isFilled
|
||||
? `rgb(${el.r},${el.g},${el.b})`
|
||||
: `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`;
|
||||
ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sparkline': {
|
||||
const slW = el.width ?? 40, slH = el.height ?? 12;
|
||||
const count = Math.max(1, el.barCount ?? 8);
|
||||
const spacing = el.barSpacing ?? 1;
|
||||
const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count));
|
||||
const rawVals = (el.previewData || '').split(',')
|
||||
.map(v => parseFloat(v.trim())).filter(n => !isNaN(n));
|
||||
while (rawVals.length < count) rawVals.push(0);
|
||||
const maxV = Math.max(...rawVals.slice(0, count), 0.001);
|
||||
const rx = ax * s, ry = ay * s;
|
||||
if (el.hasBg) {
|
||||
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
||||
ctx.fillRect(rx, ry, slW * s, slH * s);
|
||||
}
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const norm = Math.max(0, Math.min(1, rawVals[i] / maxV));
|
||||
const barH = Math.max(1, Math.round(slH * norm));
|
||||
const bx = rx + (barW + spacing) * i * s;
|
||||
const by = ry + (slH - barH) * s;
|
||||
ctx.fillRect(bx, by, barW * s, barH * s);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'gauge': {
|
||||
const gw = (el.width ?? 32), gh = (el.height ?? 32);
|
||||
const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s;
|
||||
const rx = (gw / 2) * s, ry = (gh / 2) * s;
|
||||
const lw = Math.max(1, (el.lineWidth ?? 3));
|
||||
const startDeg = el.startAngle ?? 135;
|
||||
const endDeg = el.endAngle ?? 45;
|
||||
// Arc sweep: from startDeg clockwise to endDeg (PIL convention)
|
||||
const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360;
|
||||
const pvGauge = opts.previewValues?.[el.binding?.key];
|
||||
const pct = pvGauge !== undefined
|
||||
? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100
|
||||
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
||||
const fillSweep = totalSweep * pct;
|
||||
const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90
|
||||
|
||||
// Track arc
|
||||
if (el.hasTrack !== false) {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false);
|
||||
ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`;
|
||||
ctx.lineWidth = lw * s;
|
||||
ctx.stroke();
|
||||
}
|
||||
// Fill arc
|
||||
if (pct > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false);
|
||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.lineWidth = lw * s;
|
||||
ctx.stroke();
|
||||
}
|
||||
// Centre label
|
||||
if (el.showLabel) {
|
||||
const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six;
|
||||
const labelText = Math.round(pct * 100) + '%';
|
||||
ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`;
|
||||
ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`;
|
||||
const ltw = ctx.measureText(labelText).width;
|
||||
ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'marquee': {
|
||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||
const text = el.text || 'Scrolling text';
|
||||
const tw = text.length * finfo.charW * s;
|
||||
const gap = (el.gap ?? 16) * s;
|
||||
const totalW = tw + gap;
|
||||
const tick = opts.animTick ?? 0;
|
||||
const speed = (el.scrollSpeed ?? 1) * 2;
|
||||
const scrolled = (tick * speed) % totalW;
|
||||
// left: text enters from right; right: text enters from left
|
||||
const startX = el.direction === 'right'
|
||||
? scrolled - tw
|
||||
: matrixW * s - scrolled;
|
||||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
// Clip to canvas width so text doesn't bleed outside
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s);
|
||||
ctx.clip();
|
||||
for (let i = -1; i <= 2; i++) {
|
||||
ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s);
|
||||
}
|
||||
ctx.restore();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'progress_bar': {
|
||||
const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6;
|
||||
const pvPb = opts.previewValues?.[el.binding?.key];
|
||||
const pct = pvPb !== undefined
|
||||
? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100
|
||||
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
||||
const rx = ax * s, ry = ay * s;
|
||||
if (el.hasBg) {
|
||||
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
||||
ctx.fillRect(rx, ry, bw * s, bh * s);
|
||||
}
|
||||
const fillW = Math.max(0, Math.round(bw * pct));
|
||||
if (fillW > 0) {
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.fillRect(rx, ry, fillW * s, bh * s);
|
||||
}
|
||||
if (el.hasOutline) {
|
||||
ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(rx, ry, bw * s, bh * s);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (belowBreakpoint) {
|
||||
ctx.globalAlpha = 0.6;
|
||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||
ctx.font = `${Math.max(8, s * 2)}px monospace`;
|
||||
ctx.fillStyle = '#facc15';
|
||||
ctx.fillText(`≥${el.minWidth}px`, bb.x * s, (bb.y + 4) * s);
|
||||
}
|
||||
} finally {
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection indicator ──────────────────────────────────────────────
|
||||
function _drawSelection(ctx, el, SCALE, matrixW, matrixH) {
|
||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||
const PAD = 2, s = SCALE;
|
||||
const rx = bb.x * s - PAD, ry = bb.y * s - PAD;
|
||||
const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([3, 2]);
|
||||
ctx.strokeRect(rx, ry, rw, rh);
|
||||
ctx.setLineDash([]);
|
||||
|
||||
if (el.xAnchor || el.yAnchor) {
|
||||
ctx.font = `${Math.max(7, s)}px sans-serif`;
|
||||
ctx.fillStyle = '#a78bfa';
|
||||
const anchorText = [
|
||||
el.xAnchor ? `x:${el.xAnchor[0]}` : '',
|
||||
el.yAnchor ? `y:${el.yAnchor[0]}` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2);
|
||||
}
|
||||
|
||||
// Resize handles: on rect, rounded rect, ellipse
|
||||
if (['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) {
|
||||
const handles = _getRectHandles(el, matrixW, matrixH);
|
||||
const HS = 5;
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.strokeStyle = '#2563eb';
|
||||
ctx.lineWidth = 1;
|
||||
for (const pt of Object.values(handles)) {
|
||||
const hx = pt.x * s - HS / 2;
|
||||
const hy = pt.y * s - HS / 2;
|
||||
ctx.fillRect(hx, hy, HS, HS);
|
||||
ctx.strokeRect(hx, hy, HS, HS);
|
||||
}
|
||||
} else {
|
||||
// Corner dots for non-rectangle elements
|
||||
ctx.fillStyle = '#3b82f6';
|
||||
const HS = 4;
|
||||
for (const [hx, hy] of [
|
||||
[rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2],
|
||||
[rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2],
|
||||
]) ctx.fillRect(hx, hy, HS, HS);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Dimension tooltip while dragging ─────────────────────────────────
|
||||
function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) {
|
||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||
const label = el.type === 'rectangle'
|
||||
? `${el.width}×${el.height}`
|
||||
: `${bb.x},${bb.y}`;
|
||||
const s = SCALE;
|
||||
ctx.save();
|
||||
ctx.font = `${Math.max(9, s * 1.5)}px monospace`;
|
||||
const tw = ctx.measureText(label).width;
|
||||
const tx = bb.x * s, ty = (bb.y - 2) * s;
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.fillRect(tx - 2, ty - 10, tw + 4, 12);
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillText(label, tx, ty);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
function init(canvasEl) {
|
||||
_canvas = canvasEl;
|
||||
_ctx = canvasEl.getContext('2d');
|
||||
}
|
||||
|
||||
function setGrid(show) { _showGrid = show; }
|
||||
|
||||
function updateCanvasSize(matrixW, matrixH, SCALE) {
|
||||
if (!_canvas) return;
|
||||
_canvas.width = matrixW * SCALE;
|
||||
_canvas.height = matrixH * SCALE;
|
||||
}
|
||||
|
||||
function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) {
|
||||
if (!_ctx) return;
|
||||
const cW = matrixW * SCALE, cH = matrixH * SCALE;
|
||||
|
||||
const bg = opts.bgColor;
|
||||
_ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000';
|
||||
_ctx.fillRect(0, 0, cW, cH);
|
||||
|
||||
if (_showGrid) {
|
||||
_ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
||||
_ctx.lineWidth = 0.5;
|
||||
for (let x = SCALE; x < cW; x += SCALE) {
|
||||
_ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke();
|
||||
}
|
||||
for (let y = SCALE; y < cH; y += SCALE) {
|
||||
_ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts);
|
||||
|
||||
if (opts.showRuler) {
|
||||
_ctx.save();
|
||||
_ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||
_ctx.fillRect(0, 0, cW, SCALE); // top strip
|
||||
_ctx.fillRect(0, 0, SCALE, cH); // left strip
|
||||
_ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||||
_ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
_ctx.font = `${Math.max(5, SCALE - 1)}px monospace`;
|
||||
const step = SCALE >= 4 ? 8 : 16;
|
||||
for (let px = 0; px <= matrixW; px += step) {
|
||||
const cx = px * SCALE;
|
||||
const major = px % 32 === 0;
|
||||
_ctx.lineWidth = 0.5;
|
||||
_ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke();
|
||||
if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1);
|
||||
}
|
||||
for (let py = 0; py <= matrixH; py += step) {
|
||||
const cy = py * SCALE;
|
||||
const major = py % 32 === 0;
|
||||
_ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke();
|
||||
if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1);
|
||||
}
|
||||
_ctx.restore();
|
||||
}
|
||||
|
||||
if (opts.showGuides) {
|
||||
_ctx.save();
|
||||
_ctx.strokeStyle = 'rgba(255,60,60,0.45)';
|
||||
_ctx.lineWidth = 1;
|
||||
_ctx.setLineDash([4, 3]);
|
||||
const mx = Math.floor(cW / 2) + 0.5;
|
||||
const my = Math.floor(cH / 2) + 0.5;
|
||||
_ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke();
|
||||
_ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke();
|
||||
_ctx.setLineDash([]);
|
||||
_ctx.restore();
|
||||
}
|
||||
|
||||
const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null;
|
||||
if (sel) {
|
||||
_drawSelection(_ctx, sel, SCALE, matrixW, matrixH);
|
||||
if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init, render, setGrid, updateCanvasSize,
|
||||
hitTest, getBoundingBox, computeActualPos, resolveAnchor,
|
||||
getResizeHandle, getCursorForHandle,
|
||||
ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS,
|
||||
};
|
||||
})();
|
||||