Compare commits

..
Author SHA1 Message Date
ChuckandClaude Sonnet 4.6 b2524e918d feat(web): add Tools tab and row address type setting
Adds a Tools/Utilities tab to the web interface with one-click
maintenance buttons that previously required SSH:
- Git status panel (branch, dirty state, recent commits)
- Pull latest (rebase) and force reset to origin/main
- Reinstall base requirements (pip, with output)
- Reinstall per-plugin requirements (pass/fail per plugin)
- Clear __pycache__ directories
- Quick-access restart for display and web services

Also exposes the hzeller row_address_type option (0–4) in the
Display settings tab. The backend already read this value from
config; the UI, API field list, and validation were missing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 09:41:33 -04:00
Chuck eb6687ceca fix(install): correctly detect already-installed dateutil/websocket-client
Address remaining coderabbitai findings on PR #369:
- check_package_installed() did __import__(package_name) directly, but
  python-dateutil and websocket-client import as dateutil/websocket. Both
  always failed the "already installed" check and were reinstalled on
  every run. Add an IMPORT_NAME_MAP for the mismatched names.
- _run() still read the entire temp file into memory before slicing the
  tail. Stream it line-by-line into a deque(maxlen=ERROR_TAIL_LINES)
  instead so memory use stays bounded for very chatty commands.
2026-06-11 16:21:56 -04:00
Chuck 60b64144a5 fix(install): suppress remaining Codacy subprocess false-positive
Codacy's Semgrep-based check still flagged the cmd-built subprocess.run
call as "without a static string" even with the Bandit nosec applied.
Add a nosemgrep marker alongside it - cmd is always a hardcoded
apt/pip argument list, never user input.
2026-06-11 13:01:33 -04:00
Chuck 5a1a095e16 fix(install): bound subprocess output and dedupe apt update in dependency installer
Address coderabbitai review on PR #369:
- _run() now streams combined stdout/stderr to a temp file and returns
  only the last ERROR_TAIL_LINES lines, instead of buffering full
  output in memory (Codacy also flagged the previous capture_output
  call as a subprocess-without-static-string security issue; the new
  call is annotated as safe since cmd is built from hardcoded args).
- `apt update` now runs once in main() instead of once per package
  needing an apt fallback.
2026-06-11 12:57:30 -04:00
Chuck 6aec2d9b78 feat(install): harden first-time install against common Pi failure modes
- wait_for_apt_lock: apt_update/apt_install now wait (up to 3min) for
  unattended-upgrades to release the dpkg lock instead of failing
  outright with "Command failed after 3 attempts" right after first boot.
- check_disk_space: new pre-flight check (Step 1) so a full SD card fails
  fast with a clear message instead of a cryptic mid-build error.
- Step 6: wrap rpi-rgb-led-matrix git clone/submodule operations in retry
  for resilience to transient network issues.
- Step 6: capture `pip install .` build output and print the last 50
  lines on failure, so the actual cmake/compiler error is visible instead
  of just "Failed to install rpi-rgb-led-matrix Python package".
2026-06-11 12:57:30 -04:00
Chuck 6c9a3510ee feat(install): surface root cause of web dependency install failures
install_dependencies_apt.py previously reported only which packages
failed, not why - the actual apt/pip error was discarded (apt) or
could scroll out of the on_error log tail (pip), leaving "Step 7:
Install web interface dependencies (line 915)" as the only visible
detail.

Capture command output for each install attempt and print a compact
DEPENDENCY INSTALLATION FAILURES summary with the last lines of error
output per package. Also run the installer with `python3 -u` for
real-time, correctly-ordered logging, and widen the on_error tail from
50 to 100 lines so the summary isn't cut off.
2026-06-11 12:57:29 -04:00
Chuck 6ea9862c14 fix(install): don't let outer ERR trap mask first_time_install.sh failures
set +e alone doesn't suppress bash's ERR trap, so any non-zero exit from
first_time_install.sh inside the one-shot installer immediately triggered
the outer on_error handler with a generic "Main installation, line 370"
message — before the script could report the real exit code or point to
logs/. Suspend the trap for that block so the existing if/else handling
runs instead.
2026-06-11 12:57:29 -04:00
123 changed files with 742 additions and 8920 deletions
-44
View File
@@ -1,44 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
-50
View File
@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'
-1
View File
@@ -8,7 +8,6 @@ config/config_secrets.json
config/config.json
config/config.json.backup
config/wifi_config.json
config/uninstalled_plugins.json
credentials.json
token.pickle
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 111 KiB

-5
View File
@@ -115,11 +115,6 @@
"gpio_slowdown": 3,
"rp1_rio": 0
},
"double_sided": {
"enabled": false,
"copies": 2,
"axis": "horizontal"
},
"display_durations": {},
"use_short_date_format": true,
"vegas_scroll": {
-234
View File
@@ -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"`.
-6
View File
@@ -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)
-6
View File
@@ -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
View File
@@ -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
-7
View File
@@ -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:
-5
View File
@@ -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)
-8
View File
@@ -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:
+11 -26
View File
@@ -227,8 +227,8 @@ wait_for_apt_lock() {
done
}
apt_update() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 update; }
apt_install() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 install -y "$@"; }
apt_update() { wait_for_apt_lock; retry apt update; }
apt_install() { wait_for_apt_lock; retry apt install -y "$@"; }
apt_remove() { apt-get remove -y "$@" || true; }
check_network() {
@@ -726,11 +726,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
if command -v timeout >/dev/null 2>&1; then
# Use timeout if available (10 minutes = 600 seconds)
# --ignore-installed: apt-managed packages (e.g. python3-requests)
# ship no pip RECORD file, so upgrading them would otherwise abort
# with "uninstall-no-record-file"; this lays the new version down
# alongside instead of trying to uninstall the apt copy first.
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
INSTALL_SUCCESS=true
else
EXIT_CODE=$?
@@ -738,7 +734,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
echo "✗ Timeout (10 minutes) installing: $line"
echo " This package may require building from source, which can be slow on Raspberry Pi."
echo " You can try installing it manually later with:"
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose '$line'"
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose '$line'"
else
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
fi
@@ -746,7 +742,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
else
# No timeout command available, install without timeout
echo " Note: timeout command not available, installation may take a while..."
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
INSTALL_SUCCESS=true
else
EXIT_CODE=$?
@@ -798,7 +794,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
echo " 1. Ensure you have enough disk space: df -h"
echo " 2. Check available memory: free -h"
echo " 3. Try installing failed packages individually with verbose output:"
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose <package>"
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose <package>"
echo " 4. For packages that build from source (like numpy), consider:"
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
echo " - Or installing via apt if available: sudo apt install python3-<package>"
@@ -820,10 +816,7 @@ echo ""
# Install web interface dependencies
echo "Installing web interface dependencies..."
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
# --ignore-installed: apt-managed packages (e.g. python3-requests) ship no
# pip RECORD file, so upgrading them to the version pinned here would
# otherwise abort the whole install with "uninstall-no-record-file".
if python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
if python3 -m pip install --break-system-packages --prefer-binary -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
echo "✓ Web interface dependencies installed"
# Create marker file to indicate dependencies are installed
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
@@ -864,12 +857,6 @@ if [ "$_SKIP_BUILD" = "1" ]; then
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
else
# Ensure rpi-rgb-led-matrix submodule is initialized
# Wrapper used with retry(): removes any partial clone dir before each attempt
# so git clone doesn't fail with "destination path already exists".
_clone_rpi_rgb() {
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
}
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
cd "$PROJECT_ROOT_DIR"
@@ -879,12 +866,12 @@ else
echo "Initializing rpi-rgb-led-matrix submodule..."
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
echo "⚠ Submodule init failed, cloning directly from GitHub..."
retry _clone_rpi_rgb
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
fi
else
# Fallback: clone directly if submodule not configured
echo "Submodule not configured, cloning directly from GitHub..."
retry _clone_rpi_rgb
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
fi
fi
@@ -898,7 +885,7 @@ else
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
else
retry _clone_rpi_rgb
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
fi
fi
@@ -984,9 +971,7 @@ else
else
echo "Using pip to install dependencies..."
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
# --ignore-installed: see the Step 5 web_interface/requirements.txt
# install above — same apt/pip RECORD-file conflict applies here.
python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r requirements_web_v2.txt
python3 -m pip install --break-system-packages --prefer-binary -r requirements_web_v2.txt
else
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
fi
-3
View File
@@ -43,9 +43,6 @@ websocket-client>=1.8.0,<2.0.0
# JSON Schema validation
jsonschema>=4.20.0,<5.0.0
# Requirement specifier parsing (plugin dependency satisfaction checks)
packaging>=23.0,<27.0
# Testing dependencies
pytest>=9.0.3,<10.0.0
pytest-cov>=4.1.0,<5.0.0
-29
View File
@@ -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"
+20 -56
View File
@@ -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}")
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# Clear all plugin dependency markers to force fresh dependency check
# Useful after updating plugins or troubleshooting dependency issues
echo "Clearing plugin dependency markers..."
# Check both possible cache locations
CACHE_DIRS=(
"/var/cache/ledmatrix"
"$HOME/.cache/ledmatrix"
)
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
if [ -d "$CACHE_DIR" ]; then
echo "Checking $CACHE_DIR..."
marker_count=$(find "$CACHE_DIR" -name "plugin_*_deps_installed" 2>/dev/null | wc -l)
if [ "$marker_count" -gt 0 ]; then
echo "Found $marker_count dependency marker(s) in $CACHE_DIR"
find "$CACHE_DIR" -name "plugin_*_deps_installed" -delete
echo "Cleared $marker_count marker(s)"
else
echo "No dependency markers found in $CACHE_DIR"
fi
fi
done
echo "Done! Dependency markers cleared."
echo "Next startup will check and install dependencies as needed."
+72 -198
View File
@@ -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,118 +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.
A fresh plugin instance per call, mirroring the safety harness, so sizes
never share state.
"""
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)
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()
# Run update()
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")
# Run display()
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)
return {
'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,
}
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."""
@@ -315,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))
@@ -326,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,
})
# --------------------------------------------------------------------------
-74
View File
@@ -1,74 +0,0 @@
#!/bin/bash
# safe_pip_install.sh — Install a requirements.txt as root after validating
# that the resolved path is the project's own requirements.txt or a plugin's
# requirements.txt under plugin-repos/ or plugins/.
#
# This script is intended to be called via sudo from the web interface, so
# that packages a plugin declares end up visible to ledmatrix.service (which
# runs as root) rather than only to whichever non-root user runs the web
# interface. Plugin code already runs as root once loaded, so installing its
# declared dependencies as root is not a new trust boundary.
#
# Usage: safe_pip_install.sh <requirements_txt_path>
set -euo pipefail
if [ $# -ne 1 ]; then
echo "Usage: $0 <requirements_txt_path>" >&2
exit 1
fi
TARGET="$1"
# Determine the project root (parent of scripts/fix_perms/)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# Allowed locations (resolved, no trailing slash):
# - the project's own requirements.txt
# - any requirements.txt under plugin-repos/ or plugins/
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
ALLOWED_BASES=(
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
)
# Resolve the target path (follow symlinks); works even if it doesn't exist.
RESOLVED_TARGET="$(realpath --canonicalize-missing "$TARGET")"
# Must be named requirements.txt — never install from an arbitrary file.
if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
echo "DENIED: $RESOLVED_TARGET is not a requirements.txt file" >&2
exit 2
fi
ALLOWED=false
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
ALLOWED=true
else
for BASE in "${ALLOWED_BASES[@]}"; do
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
ALLOWED=true
break
fi
done
fi
if [ "$ALLOWED" = false ]; then
echo "DENIED: $RESOLVED_TARGET is not an allowed requirements.txt location" >&2
echo "Allowed: $ALLOWED_EXACT, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
exit 2
fi
if [ ! -f "$RESOLVED_TARGET" ]; then
echo "ERROR: $RESOLVED_TARGET does not exist" >&2
exit 3
fi
PYTHON_PATH="$(command -v python3)"
# --ignore-installed: root's site-packages often has apt/dpkg-managed copies
# of common libraries (requests, urllib3, ...) with no pip RECORD file, which
# pip refuses to uninstall in place ("Cannot uninstall: no RECORD file was
# found"). This tells pip to install the newer version alongside rather than
# aborting the whole requirements.txt install over one such conflict.
exec "$PYTHON_PATH" -m pip install --break-system-packages --ignore-installed -r "$RESOLVED_TARGET"
+5 -28
View File
@@ -33,7 +33,6 @@ POWEROFF_PATH=$(command -v poweroff) || true
BASH_PATH=$(command -v bash) || true
JOURNALCTL_PATH=$(command -v journalctl) || true
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
# Validate required commands (systemctl, bash, python3 are essential)
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
@@ -49,15 +48,11 @@ if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
exit 1
fi
# Validate helper scripts exist
# Validate helper script exists
if [ ! -f "$SAFE_RM_PATH" ]; then
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
exit 1
fi
if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
echo "Error: Safe pip install helper not found: $SAFE_PIP_INSTALL_PATH" >&2
exit 1
fi
echo "Command paths:"
echo " Python: $PYTHON_PATH"
@@ -67,7 +62,6 @@ echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
echo " Bash: $BASH_PATH"
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
echo " Safe plugin rm: $SAFE_RM_PATH"
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
# Create a temporary sudoers file
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
@@ -107,22 +101,13 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
fi
# Required: python3, bash
# NOTE: display_controller.py/start_display.sh/stop_display.sh live at the
# project root, not under scripts/install/ (where this script lives) —
# must use PROJECT_ROOT here, not PROJECT_DIR.
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT/display_controller.py"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/start_display.sh"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/stop_display.sh"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh"
echo ""
echo "# Allow web user to remove plugin directories via vetted helper script"
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
echo ""
echo "# Allow web user to install a plugin's requirements.txt as root via vetted"
echo "# helper script, so packages are visible to root-run ledmatrix.service"
echo "# (not just the web interface's own user). The helper validates the target"
echo "# is requirements.txt at the project root or under plugin-repos/ or plugins/."
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
} > "$TEMP_SUDOERS"
echo ""
@@ -141,7 +126,6 @@ echo "- Run display_controller.py directly"
echo "- Execute start_display.sh and stop_display.sh"
echo "- Reboot and shutdown the system"
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
echo "- Install plugin/base requirements.txt as root (so ledmatrix.service can see them)"
echo ""
# Ask for confirmation
@@ -163,13 +147,6 @@ fi
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
fi
echo "Hardening safe_pip_install.sh ownership..."
if ! sudo chown root:root "$SAFE_PIP_INSTALL_PATH"; then
echo "Warning: Could not set ownership on $SAFE_PIP_INSTALL_PATH"
fi
if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
echo "Warning: Could not set permissions on $SAFE_PIP_INSTALL_PATH"
fi
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
echo "Configuration applied successfully!"
@@ -183,7 +160,7 @@ if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
echo "✗ systemctl status ledmatrix.service - Failed"
fi
if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then
if sudo -n test -f "$PROJECT_DIR/start_display.sh"; then
echo "✓ File access test - OK"
else
echo "✗ File access test - Failed"
+7 -16
View File
@@ -10,7 +10,6 @@ import tempfile
import warnings
from collections import deque
from pathlib import Path
from typing import List, Tuple
# How many trailing lines of a failed command's output to keep for the
# end-of-run failure summary. Keeps the root cause near the end of the log,
@@ -18,7 +17,7 @@ from typing import List, Tuple
ERROR_TAIL_LINES = 15
def _run(cmd: List[str]) -> Tuple[bool, str]:
def _run(cmd):
"""Run a command, streaming combined stdout/stderr to a temp file.
Returns (success, output) instead of raising, so callers can report
@@ -38,7 +37,7 @@ def _run(cmd: List[str]) -> Tuple[bool, str]:
return result.returncode == 0, '\n'.join(tail)
def install_via_apt(package_name: str) -> Tuple[bool, str]:
def install_via_apt(package_name):
"""Try to install a package via apt. Returns (success, output)."""
# Map pip package names to apt package names
apt_package_map = {
@@ -60,7 +59,7 @@ def install_via_apt(package_name: str) -> Tuple[bool, str]:
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
print(f"Trying to install {apt_package} via apt...")
success, output = _run(['sudo', 'apt-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', apt_package])
success, output = _run(['sudo', 'apt', 'install', '-y', apt_package])
if success:
print(f"Successfully installed {apt_package} via apt")
return True, ""
@@ -69,27 +68,19 @@ def install_via_apt(package_name: str) -> Tuple[bool, str]:
return False, output
def install_via_pip(package_name: str) -> Tuple[bool, str]:
def install_via_pip(package_name):
"""Install a package via pip with --break-system-packages and --prefer-binary.
--break-system-packages allows pip to install into the system Python on
Debian/Ubuntu-based systems without a virtual environment.
--prefer-binary prefers pre-built wheels over source distributions to avoid
exhausting /tmp space during compilation.
--ignore-installed stops pip from trying to *uninstall* packages that were
installed by apt (e.g. python3-requests). Those Debian packages ship no
pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
and aborts the whole install. With --ignore-installed, pip lays the new
version down in /usr/local where it shadows the apt copy instead of removing
it. This matters when a pip dependency (google-api-python-client pulls a
newer requests) needs to upgrade an apt-managed package.
Returns (success, output).
"""
print(f"Installing {package_name} via pip...")
success, output = _run([
sys.executable, '-m', 'pip', 'install',
'--break-system-packages', '--prefer-binary', '--ignore-installed', package_name
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
])
if success:
print(f"Successfully installed {package_name} via pip")
@@ -106,7 +97,7 @@ IMPORT_NAME_MAP = {
}
def check_package_installed(package_name: str) -> bool:
def check_package_installed(package_name):
"""Check if a package is already installed."""
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
# Suppress deprecation warnings when checking if packages are installed
@@ -120,7 +111,7 @@ def check_package_installed(package_name: str) -> bool:
return False
def print_failure_summary(failed_packages: List[str], failure_details: dict) -> None:
def print_failure_summary(failed_packages, failure_details):
print("\n" + "=" * 60)
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
print("=" * 60)
+1 -125
View File
@@ -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);
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.1.0"
__version__ = "1.0.0"
-174
View File
@@ -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)
-746
View File
@@ -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)
+3 -10
View File
@@ -68,14 +68,13 @@ class DiskCache:
return None
return os.path.join(self.cache_dir, f"{key}.json")
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
"""
Get data from disk cache.
Args:
key: Cache key
max_age: Maximum age in seconds; None disables age-based expiry
(the record never counts as stale). Mirrors MemoryCache.get.
max_age: Maximum age in seconds
Returns:
Cached data or None if not found or expired
@@ -106,13 +105,7 @@ class DiskCache:
record_ts = None
now = time.time()
# max_age=None means "never expires" (mirrors MemoryCache and the
# cache_manager docstring). Guard it explicitly — otherwise the
# comparison below raises TypeError and the record is treated as a
# miss, which silently breaks callers that persist long-lived state
# via get(key, max_age=None) (e.g. plugin health/metrics that must
# survive restarts and be read cross-process).
if record_ts is None or max_age is None or (now - record_ts) <= max_age:
if record_ts is None or (now - record_ts) <= max_age:
return record
else:
# Stale on disk; keep file for potential diagnostics but treat as miss
+3 -13
View File
@@ -574,19 +574,9 @@ class CacheManager:
}
return self.save_cache(data_type, cache_data)
def get(self, key: str, max_age: Optional[int] = 300,
memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
"""Get data from cache if it exists and is not stale.
Args:
key: Cache key
max_age: Max age (seconds) for the on-disk entry; None never expires.
memory_ttl: Max age (seconds) for the in-memory entry. Pass 0 to
bypass the memory tier and force a fresh read from disk — used by
cross-process readers that must observe another process's latest
write rather than a stale first snapshot. Defaults to max_age.
"""
cached_data = self.get_cached_data(key, max_age, memory_ttl=memory_ttl)
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
"""Get data from cache if it exists and is not stale."""
cached_data = self.get_cached_data(key, max_age)
if cached_data and 'data' in cached_data:
return cached_data['data']
return cached_data
-22
View File
@@ -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:
-43
View File
@@ -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',
]
+2 -13
View File
@@ -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)
-137
View File
@@ -8,34 +8,13 @@ files that need to be accessible by both root service and web user.
import os
import logging
import re
import shutil as _shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# Matches the credentials portion of a "scheme://user:pass@host" URL, so pip's
# own error output can be logged/displayed without echoing back a private
# index URL's embedded basic-auth secret verbatim (e.g. from a
# requirements.txt --index-url line or the PIP_INDEX_URL env var).
_URL_CREDENTIALS_RE = re.compile(r'://[^/\s@:]+:[^/\s@]+@')
def _redact_url_credentials(text: Optional[str]) -> str:
"""Replace embedded user:pass@ URL credentials in text with a placeholder.
Safe to call on any subprocess output destined for logs: it only ever
shortens/replaces the credential substring, never changes the presence
or absence of the specific fixed phrases callers check for
(e.g. "a password is required"), so it can't affect control flow.
"""
if not text:
return text or ""
return _URL_CREDENTIALS_RE.sub('://***:***@', text)
# System directories that should never have their permissions modified
# These directories have special system-level permissions that must be preserved
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
@@ -308,119 +287,3 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
return False
def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.CompletedProcess:
"""
Install a requirements.txt file for a plugin (or the project itself).
Prefers the vetted sudo wrapper (scripts/fix_perms/safe_pip_install.sh) so
packages end up visible to root-run ledmatrix.service, not just to
whichever non-root user happens to run the calling process (e.g. the web
interface). Falls back to installing with the calling process's own
interpreter if the wrapper isn't set up yet (the admin hasn't run
scripts/install/configure_web_sudo.sh), so dependency installation still
does *something* useful rather than hard-failing.
Always installs with the interpreter that will actually run the code
(``sys.executable`` in the fallback path, the wrapper's ``python3`` in the
sudo path) rather than a bare ``pip``/``pip3`` off PATH, which can
silently resolve to a different Python installation (e.g. system Python
vs. a virtualenv) than the one importing the package at runtime.
Args:
req_file: Path to a requirements.txt file
timeout: Subprocess timeout in seconds
Returns:
subprocess.CompletedProcess from the pip (or wrapper) invocation.
Never raises on a non-zero exit; callers should check ``returncode``.
``stdout`` is prefixed with an explanatory note when the root wrapper
was unavailable and the fallback path was used.
"""
project_root = Path(__file__).resolve().parent.parent.parent
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
if wrapper.exists():
# See sudo_remove_directory / configure_web_sudo.sh for why bash must
# be invoked with an explicit, known path rather than relying on the
# wrapper's shebang: sudoers matches the exact command line.
bash_candidates = []
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
if candidate and candidate not in bash_candidates:
bash_candidates.append(candidate)
result = None
for bash_path in bash_candidates:
# bash_path and wrapper are fixed, known-good paths, and
# safe_pip_install.sh independently re-validates req_file is an
# allowed requirements.txt before installing anything as root.
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
)
# Redact immediately: pip can echo a private index URL's embedded
# basic-auth credentials back in its own error/progress output
# (e.g. from a requirements.txt --index-url line). Doesn't affect
# the fixed-phrase "denied" check below -- those phrases never
# overlap with URL syntax.
result.stderr = _redact_url_credentials(result.stderr)
result.stdout = _redact_url_credentials(result.stdout)
if result.returncode == 0:
return result
# Distinguish "sudo rejected this exact command line" (worth
# trying the next bash candidate) from "sudo ran it but pip
# itself failed" (a real error — stop and surface it).
denied = any(
phrase in result.stderr
for phrase in ("a password is required", "is not allowed to run", "no tty present")
)
if not denied:
# Deliberately don't interpolate req_file or the pip output here:
# this log line is scanner-visible, and a static analyzer can't
# tell "already redacted above" from "still raw" just by looking
# at this call in isolation. The full (redacted) text is still
# available to callers via the returned CompletedProcess.
logger.warning(
"Root pip install failed (rc=%s); see the returned "
"CompletedProcess.stderr for details.",
result.returncode,
)
return result
# Same reasoning as above: no req_file / pip-output interpolation in
# this log line, only in the returned note/CompletedProcess.
logger.warning(
"Root pip install wrapper denied via sudo for all candidates; "
"falling back to user-level install. See the returned "
"CompletedProcess.stderr for details."
)
note = (
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
"installed for the current process's user only. Packages may not be "
"visible to ledmatrix.service if it runs as a different user — "
"run scripts/install/configure_web_sudo.sh to fix this.]\n"
)
else:
logger.warning(
"safe_pip_install.sh not found; falling back to user-level install."
)
note = (
"[safe_pip_install.sh not found; installed for the current process's "
"user only. Run scripts/install/configure_web_sudo.sh to enable "
"root installs visible to ledmatrix.service.]\n"
)
# sys.executable is this process's own interpreter (not
# attacker-influenced), and req_file is a Path built internally by callers
# (store_manager.py plugin paths, PROJECT_ROOT/requirements.txt), never
# raw external/user input. --ignore-installed matches safe_pip_install.sh:
# apt-managed packages (e.g. python3-requests) ship no pip RECORD file, so
# upgrading them would otherwise abort with "uninstall-no-record-file".
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
)
result.stderr = _redact_url_credentials(result.stderr)
result.stdout = note + _redact_url_credentials(result.stdout)
return result
+9 -7
View File
@@ -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]:
"""
+76 -346
View File
@@ -24,7 +24,7 @@ import time
import os
import json
from pathlib import Path
from typing import Dict, Any, List, Optional, Callable
from typing import Dict, Any, List, Optional
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
import pytz
@@ -163,13 +163,6 @@ class DisplayController:
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
self.mode_to_plugin_id: Dict[str, str] = {}
self.plugin_display_modes: Dict[str, List[str]] = {}
# Per-plugin config-change callbacks, kept so we can unsubscribe a
# plugin when it is disabled live.
self._plugin_config_callbacks: Dict[str, Callable] = {}
# Set by the config-watcher thread when the enabled-plugin set changes;
# the main run loop reconciles (loads/unloads) on its own thread so
# mutating available_modes never races with rendering.
self._pending_plugin_reconcile = False
self.on_demand_active = False
self.on_demand_mode: Optional[str] = None
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
@@ -231,23 +224,6 @@ class DisplayController:
font_manager=self.font_manager
)
# Activate the plugin health/metrics subsystem. PluginManager leaves
# health_tracker/resource_monitor as None by default; wiring real
# instances here turns on the circuit breaker (a repeatedly-failing
# plugin's update() is skipped after consecutive failures, then
# retried after a cooldown) and per-plugin execution-time metrics.
# Both persist to the shared cache so the web UI can surface them.
# Done before discovery/loading so load-time schema warnings have a
# tracker to record against.
try:
from src.plugin_system.plugin_health import PluginHealthTracker
from src.plugin_system.resource_monitor import PluginResourceMonitor
self.plugin_manager.health_tracker = PluginHealthTracker(self.cache_manager)
self.plugin_manager.resource_monitor = PluginResourceMonitor(self.cache_manager)
logger.info("Plugin health tracking and resource monitoring enabled")
except Exception as e:
logger.warning("Could not enable plugin health/resource monitoring: %s", e)
# Validate plugins after plugin manager is created
try:
from src.startup_validator import StartupValidator
@@ -355,9 +331,46 @@ class DisplayController:
logger.info("✓ Loaded plugin %s in %.3f seconds (%d/%d)",
plugin_id, result['load_time'], loaded_count, enabled_count)
# Register the loaded plugin's modes, config subscription
# and dispatch maps (shared with live enable hot-reload).
self._register_loaded_plugin(plugin_id)
# Get plugin instance and manifest
plugin_instance = self.plugin_manager.get_plugin(plugin_id)
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {})
# Prefer plugin's modes attribute if available (dynamic based on enabled leagues)
# Fall back to manifest display_modes if plugin doesn't provide modes
if plugin_instance and hasattr(plugin_instance, 'modes') and plugin_instance.modes:
display_modes = list(plugin_instance.modes)
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
else:
display_modes = manifest.get('display_modes', [plugin_id])
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
if isinstance(display_modes, list) and display_modes:
self.plugin_display_modes[plugin_id] = list(display_modes)
else:
display_modes = [plugin_id]
self.plugin_display_modes[plugin_id] = list(display_modes)
# Subscribe plugin to config changes for hot-reload
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
"""Callback for plugin config changes."""
try:
plugin_instance.on_config_change(new_config)
logger.debug("Plugin %s notified of config change", plugin_id)
except Exception as e:
logger.error("Error in plugin %s config change handler: %s", plugin_id, e, exc_info=True)
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
logger.debug("Subscribed plugin %s to config changes", plugin_id)
# Add plugin modes to available modes
for mode in display_modes:
self.available_modes.append(mode)
self.plugin_modes[mode] = plugin_instance
self.mode_to_plugin_id[mode] = plugin_id
logger.debug(" Added mode: %s", mode)
# Invalidate signature cache so the new instance is re-inspected
self._plugin_accepts_display_mode.pop(plugin_id, None)
# Show progress
progress_pct = int((loaded_count / enabled_count) * 100)
@@ -434,10 +447,6 @@ class DisplayController:
# when the user saves settings via the web UI.
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
self._refresh_config_cache(new_config)
# If a plugin was enabled/disabled, flag a reconcile for the main
# loop to apply (loading/unloading off the watcher thread is unsafe).
if self._enabled_set_changed(old_config, new_config):
self._pending_plugin_reconcile = True
self.config_service.subscribe(_controller_config_change)
@@ -502,10 +511,7 @@ class DisplayController:
# Run plugin updates inside the Vegas loop so the inter-iteration
# gap is <1 ms (nothing left for _tick_plugin_updates() to do).
# Use the Vegas-aware variant so plugins that got fresh data are
# hot-swapped into the scroll promptly instead of waiting for the
# next full cycle.
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates_for_vegas)
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates)
# Wire multi-display sync into Vegas render pipeline
follower_pos = self.config.get("sync", {}).get("follower_position", "left")
@@ -628,24 +634,14 @@ class DisplayController:
# Check if per-day schedule is configured
days_config = schedule_config.get('days')
# Determine which schedule to use. Respect an explicit 'mode' field
# (like the dim schedule does) so a stray/legacy 'days' dict left over
# from config migration or a prior per-day setup can't silently
# override a user's Global schedule selection.
mode = schedule_config.get('mode')
mode_normalized = mode.replace('_', '-') if mode else None
# Determine which schedule to use
use_per_day = False
if mode_normalized == 'global':
use_per_day = False
elif mode_normalized == 'per-day':
use_per_day = bool(days_config and current_day in days_config)
elif days_config:
# No explicit mode recorded (legacy config) - fall back to
# inferring from presence of a 'days' dict for the current day.
if current_day in days_config:
if days_config:
# Check if days dict is not empty and contains current day
if days_config and current_day in days_config:
use_per_day = True
else:
elif days_config:
# Days dict exists but doesn't have current day - fall back to global
logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day)
if use_per_day:
@@ -841,42 +837,6 @@ class DisplayController:
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
self.plugin_manager.health_tracker.record_failure(plugin_id, exc)
def _tick_plugin_updates_for_vegas(self) -> None:
"""Run scheduled plugin updates and tell Vegas mode which plugins
actually got fresh data, so it can hot-swap them into the scroll
without waiting for a full cycle to complete.
Used as the Vegas coordinator's update callback instead of the plain
_tick_plugin_updates() so that a live score change is reflected in
the ticker within a few seconds rather than at the next cycle
boundary (which, depending on min/max_cycle_duration, can be
minutes away). Restores wiring that PR #299 added and PR #330's
sync-mode refactor inadvertently dropped: coordinator.mark_plugin_updated()
has been unreachable dead code since.
Delegates the before/after plugin_last_update snapshot to
PluginManager.run_scheduled_updates_with_changes() so the snapshot,
update pass, and diff are lock-protected against this callback's own
background update-tick thread racing the main render loop.
"""
if not self.plugin_manager or not hasattr(self.plugin_manager, "run_scheduled_updates_with_changes"):
self._tick_plugin_updates()
return
updated = self.plugin_manager.run_scheduled_updates_with_changes()
vc = getattr(self, "vegas_coordinator", None)
if vc is None:
return
if updated:
logger.info("Vegas update tick: %d plugin(s) updated: %s", len(updated), updated)
for plugin_id in updated:
try:
vc.mark_plugin_updated(plugin_id)
except Exception: # pylint: disable=broad-except
logger.exception("Error marking plugin %s updated for Vegas", plugin_id)
def _tick_plugin_updates(self):
"""Run scheduled plugin updates if the plugin manager supports them."""
if not self.plugin_manager:
@@ -1545,76 +1505,37 @@ class DisplayController:
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
self._live_resume_index = None
def _collect_live_modes(self):
"""Return every currently live-priority mode, in registration order.
Scans all registered plugin modes; for each plugin that has live
priority *and* live content, collects the specific live mode(s) it
reports via get_live_modes() (only those actually registered), falling
back to the scanned mode name when it ends in '_live'. Deduplicated,
preserving order. A plugin registered under several mode keys (the
sports plugins register one per league) contributes each live mode once.
def _check_live_priority(self):
"""
Check all plugins for live priority content.
Returns the mode that should be displayed if live content is found, None otherwise.
"""
live = []
seen = set()
for mode_name, plugin_instance in self.plugin_modes.items():
if not (hasattr(plugin_instance, 'has_live_priority')
and hasattr(plugin_instance, 'has_live_content')):
continue
try:
if not (plugin_instance.has_live_priority()
and plugin_instance.has_live_content()):
continue
resolved = []
if hasattr(plugin_instance, 'get_live_modes'):
for suggested_mode in (plugin_instance.get_live_modes() or []):
if suggested_mode in self.plugin_modes:
resolved.append(suggested_mode)
if not resolved and mode_name.endswith('_live'):
resolved.append(mode_name)
for m in resolved:
if m not in seen:
seen.add(m)
live.append(m)
except Exception as e:
logger.warning("Error checking live priority for %s: %s", mode_name, e)
return live
def _check_live_priority(self, advance=False):
"""Return the live-priority mode to display, or None if nothing is live.
When several plugins report live content at once (e.g. a baseball game
and a soccer match), this round-robins between them so the display
alternates each dwell instead of pinning to whichever plugin is first in
registration order.
advance=False (default): a non-advancing peek — returns the live mode
already on screen if it is still live, otherwise the first live mode.
Used by the Vegas coordinator and the vegas-active check, which only
need to know whether *any* game is live (and must not spin the cursor).
advance=True: the rotation pick — returns the live mode *after* the one
currently shown, so each dwell advances to the next live game. The
currently-displayed mode is the cursor, so this stays correct as games
start and end (no separate index to keep in sync).
"""
live_modes = self._collect_live_modes()
if not live_modes:
return None
if self.current_display_mode in live_modes:
if advance:
idx = live_modes.index(self.current_display_mode)
return live_modes[(idx + 1) % len(live_modes)]
return self.current_display_mode
return live_modes[0]
if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'):
try:
if plugin_instance.has_live_priority() and plugin_instance.has_live_content():
# Get the specific live mode from the plugin if available
if hasattr(plugin_instance, 'get_live_modes'):
live_modes = plugin_instance.get_live_modes()
if live_modes and len(live_modes) > 0:
# Verify the mode actually exists before returning it
for suggested_mode in live_modes:
if suggested_mode in self.plugin_modes:
return suggested_mode
# If suggested modes don't exist, fall through to check current mode
# Fallback: if this mode ends with _live, return it
if mode_name.endswith('_live'):
return mode_name
except Exception as e:
logger.warning("Error checking live priority for %s: %s", mode_name, e)
return None
def run(self):
"""Run the display controller, switching between displays."""
if not self.available_modes:
logger.warning(
"No display modes are enabled at startup; idling until a "
"plugin is enabled via the web UI."
)
logger.warning("No display modes are enabled. Exiting.")
self.display_manager.cleanup()
return
try:
# Initialize with cached data for fast startup - let background updates refresh naturally
@@ -1623,25 +1544,6 @@ class DisplayController:
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
while True:
# Apply plugin enable/disable edits saved via the web UI. The
# config-watcher thread only sets the flag; loading/unloading and
# rebuilding available_modes happens here on the render thread so
# it can't race with rendering. Deferred while on-demand is active
# (the flag stays set) so we don't fight its temporary-enable.
if self._pending_plugin_reconcile and not self.on_demand_active:
# Only clear the flag on success -- a retryable failure
# (e.g. discovery) leaves it set so the request isn't lost.
if self._reconcile_enabled_plugins():
self._pending_plugin_reconcile = False
if not self.available_modes:
# Nothing to render yet. Re-check _pending_plugin_reconcile
# every ~1s (rather than a long sleep) so enabling a plugin
# via the web UI is picked up about as promptly as it would
# be once modes exist and the loop is iterating per-frame.
self._sleep_with_plugin_updates(1)
continue
# Handle on-demand commands before rendering
self._poll_on_demand_requests()
self._check_on_demand_expiration()
@@ -1787,11 +1689,9 @@ class DisplayController:
# Display failed, clear the status and continue normally
wifi_status_data = None
# Check for live priority content and switch to it immediately.
# advance=True so multiple simultaneously-live games take turns
# (round-robin) instead of pinning to the first plugin.
# Check for live priority content and switch to it immediately
if not self.on_demand_active and not wifi_status_data:
live_priority_mode = self._check_live_priority(advance=True)
live_priority_mode = self._check_live_priority()
self._apply_live_priority(live_priority_mode)
# Vegas scroll mode - continuous ticker across all plugins
@@ -2379,7 +2279,7 @@ class DisplayController:
except Exception as e:
logger.warning("Error checking live priority for %s: %s", active_mode, e)
if should_rotate and self.available_modes:
if should_rotate:
self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index]
self.last_mode_change = time.time()
@@ -2567,176 +2467,6 @@ class DisplayController:
self.wifi_status_active = False
self.wifi_status_expires_at = None
def _register_loaded_plugin(self, plugin_id: str) -> List[str]:
"""Register an already-loaded plugin's display modes, config-change
subscription and dispatch maps with the controller.
Shared by startup loading and live enable hot-reload so both paths
build identical controller state. Returns the registered modes.
"""
plugin_instance = self.plugin_manager.get_plugin(plugin_id)
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {})
# Prefer the plugin's dynamic modes attribute (e.g. based on enabled
# leagues), else fall back to manifest display_modes, else the id.
if plugin_instance is not None and getattr(plugin_instance, 'modes', None):
display_modes = list(plugin_instance.modes)
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
else:
display_modes = manifest.get('display_modes', [plugin_id])
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
if not (isinstance(display_modes, list) and display_modes):
display_modes = [plugin_id]
self.plugin_display_modes[plugin_id] = list(display_modes)
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
# and instance as defaults so each plugin's callback targets its own
# instance (avoids late-binding when registering many plugins), and
# remember the callback so we can unsubscribe on disable.
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any],
_pid: str = plugin_id, _plugin: Any = plugin_instance) -> None:
"""Callback for plugin config changes."""
try:
_plugin.on_config_change(new_config)
logger.debug("Plugin %s notified of config change", _pid)
except Exception as e:
logger.error("Error in plugin %s config change handler: %s", _pid, e, exc_info=True)
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
self._plugin_config_callbacks[plugin_id] = config_change_callback
logger.debug("Subscribed plugin %s to config changes", plugin_id)
# Add modes to the dispatch maps.
for mode in display_modes:
if mode not in self.available_modes:
self.available_modes.append(mode)
self.plugin_modes[mode] = plugin_instance
self.mode_to_plugin_id[mode] = plugin_id
logger.debug(" Added mode: %s", mode)
# Invalidate signature cache so the new instance is re-inspected.
self._plugin_accepts_display_mode.pop(plugin_id, None)
return display_modes
def _unregister_plugin(self, plugin_id: str) -> None:
"""Remove a plugin's modes, config subscription and instance, then
unload it. Used by live disable hot-reload."""
modes = self.plugin_display_modes.pop(plugin_id, [])
for mode in modes:
if mode in self.available_modes:
self.available_modes.remove(mode)
self.plugin_modes.pop(mode, None)
self.mode_to_plugin_id.pop(mode, None)
# Unsubscribe the plugin's config-change callback. Pop only on a
# successful unsubscribe -- if it raises, keep our reference so a
# later retry (or at least cleanup) still has the real callback
# instead of a lost one.
callback = self._plugin_config_callbacks.get(plugin_id)
if callback is not None and hasattr(self, 'config_service'):
try:
self.config_service.unsubscribe(callback, plugin_id=plugin_id)
except Exception as e:
logger.debug("Error unsubscribing plugin %s from config changes: %s", plugin_id, e)
else:
self._plugin_config_callbacks.pop(plugin_id, None)
else:
self._plugin_config_callbacks.pop(plugin_id, None)
self._plugin_accepts_display_mode.pop(plugin_id, None)
# Tear down the instance (cleanup + on_disable + module unload).
try:
self.plugin_manager.unload_plugin(plugin_id)
except Exception as e:
logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
logger.info("Disabled plugin %s live (removed modes: %s)", plugin_id, modes)
def _enabled_set_changed(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> bool:
"""True if any top-level section's ``enabled`` flag differs between two
configs. A cheap watcher-thread check that gates the full reconcile.
Non-plugin sections (e.g. schedule) may match too; the reconcile
no-ops for anything that isn't a discovered plugin."""
def enabled_map(cfg: Dict[str, Any]) -> Dict[str, bool]:
return {
key: bool(value.get('enabled', False))
for key, value in cfg.items()
if isinstance(value, dict)
}
return enabled_map(old_config) != enabled_map(new_config)
def _reconcile_enabled_plugins(self) -> bool:
"""Load/unload plugins so the running set matches the enabled set in
config. Runs on the main display thread (never the config-watcher
thread) so mutating available_modes is race-free against rendering.
Returns True if reconciliation completed (including a no-op), or
False on a retryable failure -- the caller keeps the pending-reconcile
flag set in that case so the request isn't silently dropped."""
if self.plugin_manager is None:
return True
try:
config = self.config_service.get_config()
except Exception as e:
logger.warning("Plugin reconcile: falling back to cached config: %s", e)
config = self.config
try:
discovered = set(self.plugin_manager.discover_plugins())
except Exception as e:
logger.error("Plugin reconcile: discovery failed: %s", e, exc_info=True)
return False
for p in discovered:
if p in config and not isinstance(config.get(p), dict):
logger.warning(
"Plugin reconcile: config for %s is a %s, not a dict; treating as disabled",
p, type(config.get(p)).__name__
)
desired = {
p for p in discovered
if isinstance(config.get(p), dict) and config.get(p, {}).get('enabled', False)
}
current = set(self.plugin_display_modes.keys())
to_add = desired - current
to_remove = current - desired
if not to_add and not to_remove:
return True
previous_mode = self.current_display_mode
for plugin_id in to_remove:
self._unregister_plugin(plugin_id)
for plugin_id in to_add:
try:
if self.plugin_manager.load_plugin(plugin_id):
modes = self._register_loaded_plugin(plugin_id)
logger.info("Enabled plugin %s live (modes: %s)", plugin_id, modes)
else:
logger.warning("Plugin reconcile: failed to load %s", plugin_id)
except Exception as e:
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
self._resync_mode_index_after_change(previous_mode)
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
sorted(to_add), sorted(to_remove), len(self.available_modes))
return True
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
"""Clamp rotation state after available_modes changed. Stays on the
previous mode if it survived, otherwise restarts cleanly within range."""
if not self.available_modes:
self.current_mode_index = 0
self.current_display_mode = None
return
if previous_mode in self.available_modes:
self.current_mode_index = self.available_modes.index(previous_mode)
else:
self.current_mode_index %= len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index]
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
"""Refresh all config-derived caches when a hot-reload fires.
+6 -154
View File
@@ -33,7 +33,7 @@ else:
from contextlib import contextmanager
from PIL import Image, ImageDraw, ImageFont
import time
from typing import Dict, Any, List, Optional
from typing import Dict, Any, List
import logging
import math
import freetype
@@ -42,106 +42,6 @@ import freetype
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level
class _LogicalMatrix:
"""Proxy that reports a logical (per-screen) size for a physical matrix.
In double-sided mode the physical panel chain shows N identical copies of a
smaller logical screen. Plugins size themselves from ``matrix.width`` /
``matrix.height`` (the documented convention, used at 30+ call sites), so
this proxy reports the logical dimensions while delegating every real
operation ``CreateFrameCanvas``, ``SwapOnVSync``, ``brightness``,
``Clear`` and so on to the underlying physical matrix. The duplication
itself happens once per frame in :meth:`DisplayManager.update_display`.
"""
__slots__ = ("_logical_height", "_logical_width", "_matrix")
def __init__(self, matrix: RGBMatrix, logical_width: int, logical_height: int) -> None:
object.__setattr__(self, "_matrix", matrix)
object.__setattr__(self, "_logical_width", logical_width)
object.__setattr__(self, "_logical_height", logical_height)
@property
def width(self) -> int:
"""Logical (per-screen) width reported to plugins."""
return self._logical_width
@property
def height(self) -> int:
"""Logical (per-screen) height reported to plugins."""
return self._logical_height
def __getattr__(self, name: str) -> Any:
"""Forward any non-overridden attribute access to the physical matrix.
Reached only when normal lookup fails (i.e. not width/height/_*).
"""
return getattr(object.__getattribute__(self, "_matrix"), name)
def __setattr__(self, name: str, value: Any) -> None:
"""Forward attribute writes (e.g. ``matrix.brightness = 80``) to it."""
setattr(object.__getattribute__(self, "_matrix"), name, value)
def _resolve_double_sided(physical_width: int, physical_height: int,
ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Validate the ``display.double_sided`` config against the physical size.
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
feature is enabled and the physical panel divides evenly into ``copies``
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
config is logged and disabled rather than raised a misconfigured panel
should still light up.
"""
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
return None
copies = ds_config.get('copies', 2)
if not isinstance(copies, int) or copies < 2:
logger.warning(
"double_sided: 'copies' must be an integer >= 2 (got %r); "
"disabling double-sided mode", copies)
return None
axis = ds_config.get('axis', 'horizontal')
if axis not in ('horizontal', 'vertical'):
logger.warning(
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
"(got %r); defaulting to 'horizontal'", axis)
axis = 'horizontal'
# Horizontal splits the chain (panels side by side); vertical splits the
# parallel outputs (panels stacked). The split axis must divide evenly.
if axis == 'horizontal':
if physical_width % copies != 0:
logger.warning(
"double_sided: physical width %d is not divisible by copies "
"%d; disabling double-sided mode", physical_width, copies)
return None
logical_width = physical_width // copies
logical_height = physical_height
else:
if physical_height % copies != 0:
logger.warning(
"double_sided: physical height %d is not divisible by copies "
"%d; disabling double-sided mode", physical_height, copies)
return None
logical_width = physical_width
logical_height = physical_height // copies
logger.info(
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
"tiled across physical %dx%d", copies, axis, logical_width,
logical_height, physical_width, physical_height)
return {
'copies': copies,
'axis': axis,
'logical_width': logical_width,
'logical_height': logical_height,
}
class DisplayManager:
"""
Singleton hardware abstraction layer for the RGB LED matrix.
@@ -176,10 +76,6 @@ class DisplayManager:
self._suppress_test_pattern = suppress_test_pattern
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
self._capture_mode_active = False
# Double-sided mode state (resolved in _setup_matrix). When disabled,
# the logical image is blitted to the matrix unchanged.
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
self._physical_image = None # full-chain buffer reused each frame when tiling
# Text-width measurement cache: (text, id(font)) -> pixel_width
# Avoids re-measuring the same string+font on every display() call.
# Cleared on _load_fonts() so stale entries don't survive a font reload.
@@ -273,25 +169,12 @@ class DisplayManager:
self.matrix = RGBMatrix(options=options)
logger.info("RGB Matrix initialized successfully")
# Create double buffer for smooth updates. The canvases are always
# full physical size — they back the real chain regardless of mode.
# Create double buffer for smooth updates
self.offscreen_canvas = self.matrix.CreateFrameCanvas()
self.current_canvas = self.matrix.CreateFrameCanvas()
logger.info("Frame canvases created successfully")
# Double-sided mode: wrap the physical matrix so plugins see the
# logical (per-screen) size, and keep a full-chain buffer to tile
# the rendered screen into once per frame.
ds_config = self.config.get('display', {}).get('double_sided', {})
ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
self._double_sided = ds
if ds is not None:
self._physical_image = Image.new(
'RGB', (self.matrix.width, self.matrix.height))
self.matrix = _LogicalMatrix(
self.matrix, ds['logical_width'], ds['logical_height'])
# Create image with the (logical) display dimensions
# Create image with full chain width
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image)
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
@@ -318,16 +201,8 @@ class DisplayManager:
rows = int(hardware_config.get('rows', 32))
cols = int(hardware_config.get('cols', 64))
chain_length = int(hardware_config.get('chain_length', 2))
parallel = int(hardware_config.get('parallel', 1))
fallback_width = max(1, cols * chain_length)
fallback_height = max(1, rows * parallel)
# Mirror double-sided in fallback so the preview shows one screen.
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
self._double_sided = ds
if ds is not None:
fallback_width = ds['logical_width']
fallback_height = ds['logical_height']
fallback_height = max(1, rows)
except Exception:
fallback_width, fallback_height = 128, 32
@@ -489,25 +364,6 @@ class DisplayManager:
finally:
self._capture_mode_active = False
def _composite_double_sided(self):
"""Tile the logical screen across the full physical chain.
Renders once into ``self._physical_image`` by pasting the rendered
logical image ``copies`` times along the configured axis. The paste is
a single memcpy per copy, so the per-frame cost is negligible and the
plugin render path is untouched.
"""
ds = self._double_sided
phys = self._physical_image
lw = ds['logical_width']
lh = ds['logical_height']
for i in range(ds['copies']):
if ds['axis'] == 'vertical':
phys.paste(self.image, (0, i * lh))
else:
phys.paste(self.image, (i * lw, 0))
return phys
def update_display(self):
"""Update the display using double buffering with proper sync."""
try:
@@ -521,12 +377,8 @@ class DisplayManager:
if self._capture_mode_active:
return # Skip hardware write — content is being captured off-screen
# Copy the current image to the offscreen canvas. In double-sided
# mode the logical screen is first tiled across the full chain.
if self._double_sided is not None:
self.offscreen_canvas.SetImage(self._composite_double_sided())
else:
self.offscreen_canvas.SetImage(self.image)
# Copy the current image to the offscreen canvas
self.offscreen_canvas.SetImage(self.image)
# Swap buffers immediately
self.matrix.SwapOnVSync(self.offscreen_canvas)
-48
View File
@@ -104,10 +104,6 @@ class FontManager:
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()
def reload_config(self, new_config: Dict[str, Any]):
@@ -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()
-6
View File
@@ -1,9 +1,3 @@
"""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
-140
View File
@@ -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,133 +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
def get_display_duration(self) -> float:
"""
Get the display duration for this plugin instance.
+10 -52
View File
@@ -52,17 +52,10 @@ class PluginHealthTracker:
"""Get cache key for plugin health data."""
return f"plugin_health:{plugin_id}"
def _load_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Load health state from cache or return defaults.
``force_reload=True`` bypasses the cache manager's in-memory tier so a
read-only consumer (e.g. the web process) observes the writer process's
latest persisted state instead of a stale first snapshot.
"""
def _load_health_state(self, plugin_id: str) -> Dict[str, Any]:
"""Load health state from cache or return defaults."""
cache_key = self._get_health_key(plugin_id)
cached = self.cache_manager.get(
cache_key, max_age=None, memory_ttl=0 if force_reload else None
)
cached = self.cache_manager.get(cache_key, max_age=None)
if cached:
return cached
@@ -86,17 +79,10 @@ class PluginHealthTracker:
self.cache_manager.set(cache_key, state) # Persist indefinitely
self._health_state[plugin_id] = state
def get_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Get current health state for a plugin.
``force_reload=True`` re-reads the persisted state from the cache,
bypassing the in-memory copy needed by cross-process readers that
would otherwise be pinned to the first snapshot they loaded.
"""
if force_reload or plugin_id not in self._health_state:
self._health_state[plugin_id] = self._load_health_state(
plugin_id, force_reload=force_reload
)
def get_health_state(self, plugin_id: str) -> Dict[str, Any]:
"""Get current health state for a plugin."""
if plugin_id not in self._health_state:
self._health_state[plugin_id] = self._load_health_state(plugin_id)
return self._health_state[plugin_id]
def record_success(self, plugin_id: str) -> None:
@@ -153,28 +139,6 @@ class PluginHealthTracker:
self._save_health_state(plugin_id, state)
def set_degraded(self, plugin_id: str, reason: Optional[str]) -> None:
"""Flag (or clear) a plugin as degraded without touching the circuit breaker.
Used for non-fatal issues e.g. a config that no longer satisfies the
plugin's schema — that should be surfaced to the user but must NOT cause
the plugin to be skipped or counted as a runtime failure. Passing
``reason=None`` clears the flag. The write is skipped when nothing
actually changes, so calling this on every load is cheap.
Args:
plugin_id: Plugin identifier
reason: Human-readable reason string, or None to clear the flag
"""
state = self.get_health_state(plugin_id)
new_degraded = bool(reason)
new_reason = reason if reason else None
if state.get('degraded', False) == new_degraded and state.get('degraded_reason') == new_reason:
return # No change — avoid a redundant cache write
state['degraded'] = new_degraded
state['degraded_reason'] = new_reason
self._save_health_state(plugin_id, state)
def should_skip_plugin(self, plugin_id: str) -> bool:
"""
Check if plugin should be skipped due to circuit breaker.
@@ -217,13 +181,9 @@ class PluginHealthTracker:
return False
def get_health_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Get health summary for a plugin.
``force_reload=True`` refreshes from the persisted cache first so
cross-process readers reflect the writer's latest state.
"""
state = self.get_health_state(plugin_id, force_reload=force_reload)
def get_health_summary(self, plugin_id: str) -> Dict[str, Any]:
"""Get health summary for a plugin."""
state = self.get_health_state(plugin_id)
total_calls = state.get('total_successes', 0) + state.get('total_failures', 0)
success_rate = 0.0
@@ -241,8 +201,6 @@ class PluginHealthTracker:
'last_failure_time': state.get('last_failure_time'),
'last_error': state.get('last_error'),
'is_healthy': state.get('circuit_state') == CircuitState.CLOSED.value,
'degraded': state.get('degraded', False),
'degraded_reason': state.get('degraded_reason'),
'circuit_opened_time': state.get('circuit_opened_time'),
'half_open_start_time': state.get('half_open_start_time')
}
+75 -232
View File
@@ -5,10 +5,10 @@ Handles plugin module imports, dependency installation, and class instantiation.
Extracted from PluginManager to improve separation of concerns.
"""
import importlib
import importlib.metadata
import importlib.util
import hashlib
import json
import importlib
import importlib.util
import os
import sys
import subprocess
@@ -17,101 +17,12 @@ from pathlib import Path
from typing import Dict, Any, Optional, Tuple, Type
import logging
from packaging.requirements import InvalidRequirement, Requirement
from src.exceptions import PluginError
from src.logging_config import get_logger
def requirements_has_real_deps(requirements_file: str) -> bool:
"""
Check whether a requirements.txt actually specifies anything to install.
Plugins that ship all their dependencies with LEDMatrix core often keep a
requirements.txt where every line is commented out, for documentation
purposes only. Running pip against such a file still pays the full
subprocess/resolver cost for zero effect, so callers should skip the
install step entirely when this returns False.
"""
try:
with open(requirements_file, 'r', encoding='utf-8') as fh:
for line in fh:
line = line.strip()
if line and not line.startswith('#'):
return True
except OSError:
# Let the caller's own file handling report the error.
return True
return False
def requirements_are_satisfied(requirements_file: str) -> bool:
"""
Check whether every real requirement line in requirements.txt is already
satisfied by packages installed in the current interpreter.
This replaces marker-file tracking with a direct fact check, so it's
immune to stale/missing/corrupted markers: it looks at what's actually
importable right now rather than trusting a hash comparison from a
previous run. Anything ambiguous (pip options, unparseable lines,
extras, unresolvable versions) conservatively returns False so the
caller falls through to running pip this check only ever saves work,
never masks a real install.
"""
try:
with open(requirements_file, 'r', encoding='utf-8') as fh:
lines = fh.readlines()
except OSError:
return False
for raw_line in lines:
line = raw_line.strip()
if not line or line.startswith('#'):
continue
if line.startswith('-'):
return False # pip option (-r, --index-url, ...), can't verify
try:
req = Requirement(line)
except InvalidRequirement:
return False
if req.extras:
return False # verifying extras' sub-dependencies isn't worth it here
if req.marker is not None and not req.marker.evaluate():
continue # not applicable on this platform/interpreter
try:
installed_version = importlib.metadata.version(req.name)
except importlib.metadata.PackageNotFoundError:
return False
if req.specifier and not req.specifier.contains(installed_version, prereleases=True):
return False
return True
def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
"""Return `name` if it names an actual subdirectory of trusted_dir, else None.
Used as a containment check for a directory name derived from untrusted
input (a manifest-declared plugin id, an externally-supplied plugin
path): the returned value always comes from enumerating trusted_dir
itself via os.scandir(), so a caller that builds a path by joining
trusted_dir with this return value is joining against a name the
filesystem produced under a trusted root -- not the caller's original
string, which could otherwise smuggle a traversal sequence through.
"""
try:
with os.scandir(trusted_dir) as entries:
for entry in entries:
if entry.name == name and entry.is_dir():
return entry.name
except OSError:
pass
return None
from src.common.permission_utils import (
ensure_file_permissions,
get_plugin_file_mode
)
class PluginLoader:
@@ -228,7 +139,7 @@ class PluginLoader:
self,
plugin_dir: Path,
plugin_id: str,
plugins_dir: Path,
plugins_dir: Optional[Path] = None,
timeout: int = 300
) -> bool:
"""
@@ -237,12 +148,7 @@ class PluginLoader:
Args:
plugin_dir: Plugin directory path
plugin_id: Plugin identifier
plugins_dir: Trusted base plugins directory for path containment check.
Required (not optional) so every caller reconstructs the plugin
path through the sanitiser below rather than trusting plugin_dir
directly -- CodeQL's path-injection query (and a malicious
manifest/plugin_id in practice) can't tell a legitimate
plugin_dir from one crafted to traverse outside plugins_dir.
plugins_dir: Trusted base plugins directory for path containment check
timeout: Installation timeout in seconds
Returns:
@@ -254,42 +160,59 @@ class PluginLoader:
# Resolve to a canonical absolute path (normalises .. and symlinks)
plugin_dir_real = os.path.realpath(str(plugin_dir))
plugins_dir_real = os.path.realpath(str(plugins_dir))
requested_name = os.path.basename(plugin_dir_real)
# Match the requested directory against an entry actually enumerated
# from the trusted plugins_dir, and build the path from that entry --
# not from requested_name. A name that came out of os.scandir() on a
# trusted root carries no taint regardless of what the caller asked
# for, so this is a real containment guarantee (an allowlist check
# against a trusted source), not a string-sanitisation of untrusted
# input that a static analyzer has to trust blindly.
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
if matched_name is None:
self.logger.error(
"Plugin directory for %s not found inside plugins dir", plugin_id
)
return False
if plugins_dir is not None:
# Reconstruct the plugin path from a trusted base + a sanitised
# directory name. os.path.basename() is CodeQL's recognised
# py/path-injection sanitiser: it strips all directory components
# so the result cannot contain traversal sequences. Joining it
# with the resolved, trusted plugins_dir produces a path that
# CodeQL considers untainted.
plugins_dir_real = os.path.realpath(str(plugins_dir))
safe_dir_name = os.path.basename(plugin_dir_real)
if not safe_dir_name:
self.logger.error("Could not determine plugin directory name for %s", plugin_id)
return False
safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name)
if not os.path.isdir(safe_plugin_dir):
self.logger.error(
"Plugin directory for %s not found inside plugins dir", plugin_id
)
return False
else:
safe_plugin_dir = plugin_dir_real
if not os.path.isdir(safe_plugin_dir):
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
return False
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
marker_file = os.path.join(safe_plugin_dir, ".dependencies_installed")
if not os.path.isfile(requirements_file):
return True # No dependencies needed
if not requirements_has_real_deps(requirements_file):
self.logger.debug(
"requirements.txt for %s has no real dependencies (comments/blank only), skipping pip",
plugin_id
)
return True
try:
with open(requirements_file, 'rb') as fh:
current_hash = hashlib.sha256(fh.read()).hexdigest()
except OSError as e:
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
return False
if requirements_are_satisfied(requirements_file):
self.logger.debug(
"Dependencies for %s already satisfied in current environment, skipping pip",
plugin_id
)
return True
# Skip if requirements.txt hasn't changed since last install
if os.path.isfile(marker_file):
try:
with open(marker_file, 'r', encoding='utf-8') as fh:
stored_hash = fh.read().strip()
except OSError as e:
self.logger.warning(
"Could not read dependency marker for %s (%s), will reinstall dependencies",
plugin_id, e
)
else:
if stored_hash == current_hash:
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
return True
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
try:
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
@@ -302,54 +225,32 @@ class PluginLoader:
)
if result.returncode == 0:
try:
with open(marker_file, 'w', encoding='utf-8') as fh:
fh.write(current_hash)
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True
else:
stderr = result.stderr or ""
# uninstall-no-record-file means a system-managed copy of a package
# (e.g. apt's python3-requests, which ships no pip RECORD file) is in
# the way of the version this requirements.txt pins. Retry with
# --ignore-installed so pip lays the pinned version down alongside
# the system copy instead of trying to replace it — matching the
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
# Without this retry, the plugin would silently keep running against
# whatever version the system happened to ship.
# uninstall-no-record-file means the package is already present at the
# system level (e.g. installed via dnf/apt without a pip RECORD file).
# pip can't replace it, but it IS installed — write the marker so we
# don't retry on every restart.
if "uninstall-no-record-file" in stderr:
self.logger.warning(
"Dependencies for %s conflict with a system-managed package "
"(no pip RECORD); retrying with --ignore-installed: %s",
"Dependencies for %s include system-managed packages (no pip RECORD). "
"Assuming they are satisfied: %s",
plugin_id, stderr.strip()
)
# Wrapped in its own try/except so a retry timeout is
# tolerated the same way as a retry failure, instead of
# propagating to the outer handler and returning False
# (which would contradict the "assume satisfied" fallback
# below).
try:
# sys.executable is this process's own interpreter (not
# attacker-influenced), and requirements_file is a path
# built internally by find_plugin_directory, never raw
# external input.
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages",
"--ignore-installed", "-r", requirements_file],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
if retry_result.returncode != 0:
self.logger.warning(
"Retry with --ignore-installed also failed for %s; assuming the "
"system-managed version satisfies the requirement: %s",
plugin_id, (retry_result.stderr or "").strip()
)
except subprocess.TimeoutExpired:
self.logger.warning(
"Retry with --ignore-installed timed out for %s; assuming the "
"system-managed version satisfies the requirement",
plugin_id
)
with open(marker_file, 'w', encoding='utf-8') as fh:
fh.write(current_hash)
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
return True
self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s",
@@ -437,7 +338,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 +452,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 +464,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 +584,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,18 +616,8 @@ 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:
raise PluginError(
f"plugins_dir is required to install dependencies for plugin {plugin_id} "
"(needed for path containment; pass install_deps=False if the caller "
"doesn't have a trusted plugins directory to supply)",
plugin_id=plugin_id,
context={'plugin_dir': str(plugin_dir)},
)
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
raise PluginError(
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
+94 -118
View File
@@ -9,9 +9,9 @@ API Version: 1.0.0
import json
import sys
import subprocess
import time
import threading
import types
from pathlib import Path
from typing import Dict, List, Optional, Any
import logging
@@ -76,12 +76,6 @@ class PluginManager:
# concurrent mutation (background reconciliation) and reads (requests).
self._discovery_lock = threading.RLock()
# Lock protecting plugin_last_update from concurrent mutation/iteration.
# It's written from run_scheduled_updates()/update_all_plugins() (main
# loop) and read/diffed by run_scheduled_updates_with_changes(), which
# Vegas mode calls from its own background update-tick thread.
self._plugin_last_update_lock = threading.RLock()
# Active plugins
self.plugins: Dict[str, Any] = {}
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
@@ -183,6 +177,90 @@ class PluginManager:
return plugin_ids
def _get_dependency_marker_path(self, plugin_id: str) -> Path:
"""Get path to dependency installation marker file."""
plugin_dir = self.plugins_dir / plugin_id
if not plugin_dir.exists():
# Try with ledmatrix- prefix
plugin_dir = self.plugins_dir / f"ledmatrix-{plugin_id}"
return plugin_dir / ".dependencies_installed"
def _check_dependencies_installed(self, plugin_id: str) -> bool:
"""Check if dependencies are already installed for a plugin."""
marker_path = self._get_dependency_marker_path(plugin_id)
return marker_path.exists()
def _mark_dependencies_installed(self, plugin_id: str) -> None:
"""Mark dependencies as installed for a plugin."""
marker_path = self._get_dependency_marker_path(plugin_id)
try:
marker_path.touch()
# Set proper file permissions after creating marker
from src.common.permission_utils import (
ensure_file_permissions,
get_plugin_file_mode
)
ensure_file_permissions(marker_path, get_plugin_file_mode())
except (OSError, PermissionError) as e:
self.logger.warning("Could not create dependency marker for %s: %s", plugin_id, e)
def _remove_dependency_marker(self, plugin_id: str) -> None:
"""Remove dependency installation marker."""
marker_path = self._get_dependency_marker_path(plugin_id)
try:
if marker_path.exists():
marker_path.unlink()
except (OSError, PermissionError) as e:
self.logger.warning("Could not remove dependency marker for %s: %s", plugin_id, e)
def _install_plugin_dependencies(self, requirements_file: Path) -> bool:
"""
Install plugin dependencies from requirements.txt.
Args:
requirements_file: Path to requirements.txt
Returns:
True if installation succeeded or not needed, False on error
"""
try:
self.logger.info("Installing dependencies from %s", requirements_file)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--no-cache-dir", "-r", str(requirements_file)],
capture_output=True,
text=True,
timeout=300,
check=False
)
if result.returncode == 0:
self.logger.info("Dependencies installed successfully")
return True
else:
self.logger.warning("Dependency installation returned non-zero exit code: %s", result.stderr)
return False
except subprocess.TimeoutExpired:
self.logger.error("Dependency installation timed out")
return False
except FileNotFoundError as e:
self.logger.warning("Command not found: %s. Skipping dependency installation", e)
return True
except (BrokenPipeError, OSError) as e:
# Handle broken pipe errors (errno 32) which can occur during pip downloads
# Often caused by network interruptions or output buffer issues
if isinstance(e, OSError) and e.errno == 32:
self.logger.error(
"Broken pipe error during dependency installation. "
"This usually indicates a network interruption or pip output buffer issue. "
"Try installing again or check your network connection."
)
else:
self.logger.error("OS error during dependency installation: %s", e)
return False
except Exception as e:
self.logger.error("Unexpected error installing dependencies: %s", e, exc_info=True)
return True
def load_plugin(self, plugin_id: str) -> bool:
"""
Load a plugin by ID.
@@ -313,18 +391,9 @@ class PluginManager:
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False
# Schema validation (warn/degrade only — never blocks loading).
# A config that violates the plugin's JSON schema is surfaced to the
# user (log warning + degraded flag in the health tracker) but the
# plugin still loads exactly as it does today. This deliberately does
# NOT change load_plugin()'s pass/fail behaviour for any plugin that
# loads under the current code.
self._validate_config_schema_soft(plugin_id, config)
# Store plugin instance
self.plugins[plugin_id] = plugin_instance
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = 0.0
self.plugin_last_update[plugin_id] = 0.0
# Invalidate cached interval so next tick re-derives it for this plugin
self._update_interval_cache.pop(plugin_id, None)
@@ -350,59 +419,6 @@ class PluginManager:
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False
def _validate_config_schema_soft(self, plugin_id: str, config: Dict[str, Any]) -> None:
"""Validate a plugin's config against its JSON schema — warn/degrade only.
On a schema violation this logs a warning and marks the plugin degraded
in the health tracker (when one is wired), so the problem is visible in
the web UI. It never raises, never changes plugin state, and never
affects whether the plugin loads. ``config`` here has already been
merged with schema defaults by the caller, so fields that ship a default
never appear "missing" only genuinely user-supplied required fields
(e.g. an API key) can trip the required-field check.
"""
try:
schema = self.schema_manager.load_schema(plugin_id)
except Exception as e: # pragma: no cover - defensive
self.logger.debug("Could not load schema for %s: %s", plugin_id, e)
return
if not schema:
# No schema shipped — nothing to validate. Clear any stale flag.
self._set_degraded_safe(plugin_id, None)
return
try:
is_valid, errors = self.schema_manager.validate_config_against_schema(
config, schema, plugin_id
)
except Exception as e: # pragma: no cover - defensive
# Validation machinery itself failed — do not penalise the plugin.
self.logger.debug("Schema validation raised for %s: %s", plugin_id, e)
return
if is_valid or not errors:
self._set_degraded_safe(plugin_id, None)
return
summary = "; ".join(errors[:5])
if len(errors) > 5:
summary += f" (+{len(errors) - 5} more)"
self.logger.warning(
"Plugin %s config does not match its schema (loading anyway): %s",
plugin_id, summary,
)
self._set_degraded_safe(plugin_id, f"Config schema: {summary}")
def _set_degraded_safe(self, plugin_id: str, reason: Optional[str]) -> None:
"""Best-effort ``health_tracker.set_degraded`` that never raises."""
if not self.health_tracker:
return
try:
self.health_tracker.set_degraded(plugin_id, reason)
except Exception as e: # pragma: no cover - defensive
self.logger.debug("Could not set degraded flag for %s: %s", plugin_id, e)
def unload_plugin(self, plugin_id: str) -> bool:
"""
Unload a plugin by ID.
@@ -436,8 +452,7 @@ class PluginManager:
# Remove from active plugins
del self.plugins[plugin_id]
with self._plugin_last_update_lock:
self.plugin_last_update.pop(plugin_id, None)
self.plugin_last_update.pop(plugin_id, None)
self._update_interval_cache.pop(plugin_id, None)
# Remove main module from sys.modules if present
@@ -706,8 +721,7 @@ class PluginManager:
'recoverable': True,
}
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = failure_time
self.plugin_last_update[plugin_id] = failure_time
self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info, error=err)
if self.health_tracker:
self.health_tracker.record_failure(plugin_id, err)
@@ -740,8 +754,7 @@ class PluginManager:
if interval is None:
continue
with self._plugin_last_update_lock:
last_update = self.plugin_last_update.get(plugin_id, 0.0)
last_update = self.plugin_last_update.get(plugin_id, 0.0)
if last_update == 0.0 or (current_time - last_update) >= interval:
# Update state to RUNNING
@@ -754,26 +767,15 @@ class PluginManager:
# If resource monitor exists, wrap the call
def monitored_update():
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
# SimpleNamespace stores `update` as an *instance*
# attribute, so attribute lookup returns the plain
# function object as-is. A dynamically-built class
# (`type(..., {'update': monitored_update})`) instead
# stores it as a *class* attribute, which the
# descriptor protocol turns into a bound method on
# access -- silently prepending the instance as an
# implicit first argument to a function that takes
# none, raising "monitored_update() takes 0
# positional arguments but 1 was given" on every call.
success = self.plugin_executor.execute_update(
types.SimpleNamespace(update=monitored_update),
type('obj', (object,), {'update': monitored_update})(),
plugin_id
)
else:
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
if success:
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = current_time
self.plugin_last_update[plugin_id] = current_time
self.state_manager.record_update(plugin_id)
# Update state back to ENABLED
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
@@ -786,31 +788,6 @@ class PluginManager:
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
self._record_update_failure(plugin_id, exc=exc)
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
"""
Like run_scheduled_updates(), but also returns the plugin_ids whose
plugin_last_update timestamp actually advanced during this call.
The before/after snapshots and the update pass itself are each
individually lock-protected against concurrent plugin_last_update
mutation (Vegas mode calls this from its own background
update-tick thread, racing the main render loop's plugin updates),
so callers get an atomic "who got fresh data" answer without
reaching into plugin_last_update themselves. The lock is not held
across the update pass so slow/blocking plugin update() calls don't
serialize against other plugin_last_update readers.
"""
with self._plugin_last_update_lock:
old_times = dict(self.plugin_last_update)
self.run_scheduled_updates(current_time)
with self._plugin_last_update_lock:
return [
plugin_id for plugin_id, new_time in self.plugin_last_update.items()
if new_time > old_times.get(plugin_id, 0.0)
]
def update_all_plugins(self) -> None:
"""
Update all enabled plugins.
@@ -833,8 +810,7 @@ class PluginManager:
try:
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
if success:
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = time.time()
self.plugin_last_update[plugin_id] = time.time()
self.state_manager.record_update(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
else:
@@ -860,7 +836,7 @@ class PluginManager:
# Get health tracker metrics if available
if self.health_tracker:
health_info = self.health_tracker.get_health_summary(plugin_id)
health_info = self.health_tracker.get_plugin_health(plugin_id)
plugin_metrics['health'] = health_info
else:
plugin_metrics['health'] = {'status': 'unknown'}
@@ -885,7 +861,7 @@ class PluginManager:
# Get resource monitor metrics if available
if self.resource_monitor:
resource_info = self.resource_monitor.get_metrics_summary(plugin_id)
resource_info = self.resource_monitor.get_plugin_metrics(plugin_id)
plugin_metrics['resources'] = resource_info
else:
plugin_metrics['resources'] = {'status': 'unknown'}
+15 -45
View File
@@ -82,21 +82,6 @@ class PluginResourceMonitor:
# Lock for thread-safe access
self._lock = threading.Lock()
# Cache a single psutil.Process handle. Reusing the same handle is what
# lets cpu_percent() be read non-blocking (interval=None): psutil returns
# the utilisation since the *previous* call on that same object. Creating
# a fresh Process() per call would force interval-based sampling that
# blocks the caller — unacceptable on the display loop's update path.
self._process = None
if self.enable_monitoring:
try:
self._process = psutil.Process()
# Prime cpu_percent so the first real measurement returns a
# meaningful delta instead of 0.0.
self._process.cpu_percent(interval=None)
except Exception: # pragma: no cover - psutil edge cases
self._process = None
if not PSUTIL_AVAILABLE and enable_monitoring:
self.logger.warning(
"psutil not available - resource monitoring will be limited to execution time only"
@@ -110,21 +95,13 @@ class PluginResourceMonitor:
"""Get cache key for plugin limits."""
return f"plugin_limits:{plugin_id}"
def get_metrics(self, plugin_id: str, force_reload: bool = False) -> ResourceMetrics:
"""Get current metrics for a plugin.
``force_reload=True`` bypasses both the in-memory copy and the cache
manager's memory tier so a read-only consumer (e.g. the web process)
sees the writer process's latest persisted metrics rather than a stale
first snapshot.
"""
def get_metrics(self, plugin_id: str) -> ResourceMetrics:
"""Get current metrics for a plugin."""
with self._lock:
if force_reload or plugin_id not in self._metrics:
if plugin_id not in self._metrics:
# Try to load from cache
cache_key = self._get_metrics_key(plugin_id)
cached = self.cache_manager.get(
cache_key, max_age=None, memory_ttl=0 if force_reload else None
)
cached = self.cache_manager.get(cache_key, max_age=None)
if cached:
metrics = ResourceMetrics(**cached)
else:
@@ -160,24 +137,21 @@ class PluginResourceMonitor:
def _get_process_memory_mb(self) -> float:
"""Get current process memory usage in MB."""
if not self.enable_monitoring or self._process is None:
if not self.enable_monitoring:
return 0.0
try:
return self._process.memory_info().rss / 1024 / 1024
process = psutil.Process()
return process.memory_info().rss / 1024 / 1024
except Exception:
return 0.0
def _get_process_cpu_percent(self) -> float:
"""Get current process CPU usage percentage (non-blocking).
Reads cpu_percent(interval=None) against the cached process handle, so
it returns immediately with the utilisation observed since the previous
call rather than blocking to sample a fresh interval.
"""
if not self.enable_monitoring or self._process is None:
def _get_process_cpu_percent(self, interval: float = 0.1) -> float:
"""Get current process CPU usage percentage."""
if not self.enable_monitoring:
return 0.0
try:
return self._process.cpu_percent(interval=None)
process = psutil.Process()
return process.cpu_percent(interval=interval)
except Exception:
return 0.0
@@ -307,13 +281,9 @@ class PluginResourceMonitor:
self.logger.error(error_msg)
raise ResourceLimitExceeded(error_msg)
def get_metrics_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Get metrics summary for a plugin.
``force_reload=True`` refreshes from the persisted cache first so
cross-process readers reflect the writer's latest metrics.
"""
metrics = self.get_metrics(plugin_id, force_reload=force_reload)
def get_metrics_summary(self, plugin_id: str) -> Dict[str, Any]:
"""Get metrics summary for a plugin."""
metrics = self.get_metrics(plugin_id)
limits = self.get_limits(plugin_id)
avg_execution_time = 0.0
@@ -322,19 +322,10 @@ class StateReconciliation:
and hasattr(self.store_manager, 'was_recently_uninstalled')
and self.store_manager.was_recently_uninstalled(plugin_id)
)
# Also refuse to resurrect a plugin the user has persistently
# uninstalled. Unlike the in-memory race guard above, this record
# survives restarts, so the user's removal sticks across updates.
persistently_uninstalled = (
self.store_manager is not None
and hasattr(self.store_manager, 'is_plugin_uninstalled')
and self.store_manager.is_plugin_uninstalled(plugin_id)
)
can_repair = (
self.store_manager is not None
and not previously_unrecoverable
and not recently_uninstalled
and not persistently_uninstalled
)
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
+38 -216
View File
@@ -5,8 +5,8 @@ Handles plugin discovery, installation, updates, and uninstallation
from both the official registry and custom GitHub repositories.
"""
import hashlib
import os
import re
import json
import stat
import subprocess
@@ -19,15 +19,12 @@ import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional, Any, Tuple, Set
from typing import List, Dict, Optional, Any, Tuple
import logging
from urllib.parse import urlparse
from src.common.permission_utils import sudo_remove_directory, install_requirements_file
from src.plugin_system.plugin_loader import (
requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir
)
from src.common.permission_utils import sudo_remove_directory
try:
from jsonschema import Draft7Validator, ValidationError
@@ -47,23 +44,12 @@ class PluginStoreManager:
REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json"
# A valid plugin id is a single path component: starts alphanumeric, then
# alphanumerics / dot / dash / underscore. Used to keep the uninstall
# registry from ever turning a corrupt or hand-edited entry (e.g. "",
# "..", "../x") into a filesystem path that purge_uninstalled_plugins
# would delete — an empty id resolves to the plugins root itself.
_PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def __init__(self, plugins_dir: str = "plugins",
uninstalled_registry_path: Optional[str] = None):
def __init__(self, plugins_dir: str = "plugins"):
"""
Initialize the plugin store manager.
Args:
plugins_dir: Directory where plugins are installed
uninstalled_registry_path: Path to the JSON file recording plugins
the user has uninstalled. Defaults to
``config/uninstalled_plugins.json`` under the project root.
"""
self.plugins_dir = Path(plugins_dir)
self.logger = logging.getLogger(__name__)
@@ -98,25 +84,6 @@ class PluginStoreManager:
self._uninstall_tombstones: Dict[str, float] = {}
self._uninstall_tombstone_ttl = 300 # 5 minutes
# Persistent record of plugins the user has uninstalled. Unlike the
# in-memory tombstones above (a short-lived race guard), this survives
# restarts so that a core ``git pull`` update cannot resurrect a
# built-in plugin the user removed. Built-in plugins (e.g.
# ``web-ui-info``, ``starlark-apps``) are committed into the repo under
# ``plugin-repos/``, so a plain ``git pull`` restores their files even
# after the user deleted them. ``purge_uninstalled_plugins`` re-removes
# any such resurrected directory; ``install_plugin`` clears the record
# when the user deliberately reinstalls. The file is gitignored.
if uninstalled_registry_path is not None:
self._uninstalled_registry_path = Path(uninstalled_registry_path)
else:
self._uninstalled_registry_path = (
Path(__file__).parent.parent.parent / "config" / "uninstalled_plugins.json"
)
# Serializes read-modify-write of the registry file so concurrent
# install/uninstall requests can't lose updates.
self._uninstalled_registry_lock = threading.Lock()
# Cache for _get_local_git_info: {plugin_path_str: (signature, data)}
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
# head_contents) so a fast-forward update to the current branch
@@ -176,135 +143,6 @@ class PluginStoreManager:
return False
return True
def _is_valid_plugin_id(self, plugin_id: Any) -> bool:
"""Return True if ``plugin_id`` is a safe single-component plugin id.
Rejects empty strings, anything with a path separator, and traversal
sequences like ``..`` so a registry entry can never escape (or target
the root of) ``self.plugins_dir`` during a purge.
"""
return isinstance(plugin_id, str) and bool(self._PLUGIN_ID_RE.match(plugin_id))
def _read_uninstalled_registry(self) -> Set[str]:
"""Read the persistent set of uninstalled plugin IDs.
Returns an empty set if the file is missing, unreadable, or corrupt
a broken registry must never block normal plugin operations. Invalid
ids are dropped here so callers never turn them into paths.
"""
try:
if not self._uninstalled_registry_path.exists():
return set()
with open(self._uninstalled_registry_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, list):
self.logger.warning(
"Uninstalled-plugin registry at %s is not a list; ignoring it",
self._uninstalled_registry_path,
)
return set()
valid: Set[str] = set()
for pid in data:
if self._is_valid_plugin_id(pid):
valid.add(pid)
else:
self.logger.warning(
"Ignoring invalid plugin id in uninstall registry: %r", pid
)
return valid
except (OSError, ValueError) as e:
self.logger.warning(
"Could not read uninstalled-plugin registry at %s: %s",
self._uninstalled_registry_path, e,
)
return set()
def _write_uninstalled_registry(self, plugin_ids: Set[str]) -> None:
"""Persist the set of uninstalled plugin IDs (sorted, atomically)."""
path = self._uninstalled_registry_path
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
with open(tmp_path, 'w', encoding='utf-8') as f:
json.dump(sorted(plugin_ids), f, indent=2)
os.replace(tmp_path, path)
except OSError as e:
self.logger.error(
"Failed to write uninstalled-plugin registry at %s: %s", path, e
)
def record_uninstalled_plugin(self, plugin_id: str) -> None:
"""Persistently record that the user uninstalled ``plugin_id``.
Survives restarts so a core update cannot resurrect the plugin.
"""
if not self._is_valid_plugin_id(plugin_id):
self.logger.error("Refusing to record invalid plugin id: %r", plugin_id)
return
with self._uninstalled_registry_lock:
recorded = self._read_uninstalled_registry()
if plugin_id not in recorded:
recorded.add(plugin_id)
self._write_uninstalled_registry(recorded)
self.logger.info("Recorded %s as uninstalled (persistent)", plugin_id)
def forget_uninstalled_plugin(self, *plugin_ids: str) -> None:
"""Drop ``plugin_ids`` from the persistent uninstall registry.
Called when a plugin is deliberately (re)installed so future updates
keep it.
"""
with self._uninstalled_registry_lock:
recorded = self._read_uninstalled_registry()
to_remove = {pid for pid in plugin_ids if pid in recorded}
if to_remove:
self._write_uninstalled_registry(recorded - to_remove)
self.logger.info(
"Cleared uninstall record for %s", ", ".join(sorted(to_remove))
)
def get_uninstalled_plugins(self) -> Set[str]:
"""Return the persistent set of user-uninstalled plugin IDs."""
return self._read_uninstalled_registry()
def is_plugin_uninstalled(self, plugin_id: str) -> bool:
"""Return True if ``plugin_id`` is in the persistent uninstall registry."""
return plugin_id in self._read_uninstalled_registry()
def purge_uninstalled_plugins(self) -> List[str]:
"""Remove on-disk directories for plugins the user has uninstalled.
Built-in plugins committed into the repo are restored on disk by a
core ``git pull``; this re-removes any that the user previously
uninstalled. The registry entries are kept so the purge is idempotent
across every future update (until the user reinstalls). Returns the
list of plugin IDs whose directories were actually removed.
"""
removed: List[str] = []
plugins_root = self.plugins_dir.resolve()
for plugin_id in sorted(self._read_uninstalled_registry()):
plugin_path = self.plugins_dir / plugin_id
# Defense in depth: ids are already validated on read, but never
# remove anything that isn't a direct child of the plugins root.
resolved = plugin_path.resolve()
if resolved == plugins_root or resolved.parent != plugins_root:
self.logger.error(
"Refusing to purge unsafe plugin path for id %r", plugin_id
)
continue
if not plugin_path.exists():
continue
self.logger.info(
"Purging resurrected uninstalled plugin: %s", plugin_id
)
if self._safe_remove_directory(plugin_path):
removed.append(plugin_id)
else:
self.logger.error(
"Failed to purge resurrected plugin directory: %s", plugin_path
)
return removed
def _load_github_token(self) -> Optional[str]:
"""
Load GitHub API token from config_secrets.json if available.
@@ -1186,10 +1024,6 @@ class PluginStoreManager:
branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
# Remember the originally-requested id so we can clear its uninstall
# record on success even if the manifest renames the directory below.
requested_id = plugin_id
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
if not plugin_info:
self.logger.error(f"Plugin not found in registry: {plugin_id}")
@@ -1328,9 +1162,6 @@ class PluginStoreManager:
branch_display = branch_used or plugin_info.get('branch') or plugin_info.get('default_branch', 'unknown')
self.logger.info(f"Successfully installed plugin: {plugin_id} (branch {branch_display})")
# User deliberately (re)installed this plugin — clear any persistent
# uninstall record so future core updates keep it.
self.forget_uninstalled_plugin(requested_id, plugin_id)
return True
except Exception as e:
@@ -1909,56 +1740,33 @@ class PluginStoreManager:
Returns:
True if successful or no requirements file
"""
# Reconstruct the plugin path from the trusted self.plugins_dir base +
# an entry actually enumerated from it, rather than trusting
# plugin_path directly -- callers ultimately derive it from a
# plugin-supplied manifest "id" field (see install_plugin_from_url),
# so without this a malicious manifest could point requirements_file
# outside plugins_dir. find_trusted_subdir()'s return value always
# comes from os.scandir() on the trusted root, so building the path
# from it (not from the caller's string) is a real containment
# guarantee, matching the pattern in PluginLoader.install_dependencies().
plugin_dir_real = os.path.realpath(str(plugin_path))
plugins_dir_real = os.path.realpath(str(self.plugins_dir))
requested_name = os.path.basename(plugin_dir_real)
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
if matched_name is None:
self.logger.error("Plugin directory not found inside plugins dir for dependency install")
return False
safe_plugin_path = Path(os.path.join(plugins_dir_real, matched_name))
requirements_file = safe_plugin_path / "requirements.txt"
requirements_file = plugin_path / "requirements.txt"
if not requirements_file.exists():
self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
return True
if not requirements_has_real_deps(str(requirements_file)):
self.logger.debug(f"requirements.txt for {plugin_path.name} has no real dependencies, skipping pip")
return True
if requirements_are_satisfied(str(requirements_file)):
self.logger.debug(f"Dependencies for {plugin_path.name} already satisfied, skipping pip")
return True
try:
self.logger.info(f"Installing dependencies for {plugin_path.name}")
# Routed through the shared root-visible installer (same one the
# web UI's "Reinstall Plugin Deps" tool uses) rather than a bare
# `pip`/`pip3` off PATH: a bare pip binary can silently resolve to
# a different Python installation than the one that actually runs
# ledmatrix.service, so pip reports success while the package
# stays invisible to the running plugin (e.g. missing `astral`
# for the weather plugin even though "install" succeeded).
result = install_requirements_file(requirements_file, timeout=300)
if result.returncode != 0:
self.logger.error(
f"Error installing dependencies for {plugin_path.name}: {result.stderr}"
)
return False
subprocess.run(
['pip3', 'install', '--break-system-packages', '-r', str(requirements_file)],
check=True,
capture_output=True,
text=True,
timeout=300
)
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
# Write hash marker so plugin_loader skips redundant pip run on next startup
try:
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
return True
except subprocess.CalledProcessError as e:
self.logger.error(f"Error installing dependencies: {e.stderr}")
return False
except subprocess.TimeoutExpired:
self.logger.error("Dependency installation timed out")
return False
@@ -2454,6 +2262,19 @@ class PluginStoreManager:
file_path = line[3:].strip()
untracked_files.append(file_path)
# Remove marker files that are safe to delete (they'll be regenerated)
safe_to_remove = ['.dependencies_installed']
removed_files = []
for file_name in safe_to_remove:
file_path = plugin_path / file_name
if file_path.exists() and file_name in untracked_files:
try:
file_path.unlink()
removed_files.append(file_name)
self.logger.info(f"Removed marker file {file_name} from {plugin_id} before update")
except Exception as e:
self.logger.warning(f"Could not remove {file_name} from {plugin_id}: {e}")
# Check for tracked file changes
status_result = subprocess.run(
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'],
@@ -2464,9 +2285,10 @@ class PluginStoreManager:
)
has_changes = bool(status_result.stdout.strip())
# If there are untracked files, stash them
if untracked_files:
self.logger.info(f"Found {len(untracked_files)} untracked files in {plugin_id}, will stash them")
# If there are remaining untracked files (not safe to remove), stash them
remaining_untracked = [f for f in untracked_files if f not in removed_files]
if remaining_untracked:
self.logger.info(f"Found {len(remaining_untracked)} untracked files in {plugin_id}, will stash them")
has_changes = True
except subprocess.TimeoutExpired:
# If status check times out, assume there might be changes and proceed
@@ -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) --
-74
View File
@@ -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
+1 -9
View File
@@ -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.
"""
-27
View File
@@ -71,7 +71,6 @@ class MockCacheManager:
self.get_calls = []
self.set_calls = []
self.delete_calls = []
self.get_cached_data_with_strategy_calls = []
# Real temp dir for plugins that write/read files under cache_dir.
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
@@ -109,24 +108,6 @@ class MockCacheManager:
self.delete_calls.append(key)
if key in self._cache:
del self._cache[key]
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Any]:
"""Mock of CacheManager.get_cached_data_with_strategy (src/cache_manager.py).
The real method picks a max_age/memory_ttl strategy per data_type
(and extends it during market-closed hours for market data) before
delegating to get_cached_data(). None of that timing nuance matters
for a mock -- plugins under test just need the method to exist and
return whatever was cached, so this delegates straight to get().
"""
self.get_cached_data_with_strategy_calls.append({'key': key, 'data_type': data_type})
return self.get(key)
def save_cache(self, key: str, data: Any) -> None:
"""Mock of CacheManager.save_cache (src/cache_manager.py) -- the
write-side counterpart to get_cached_data_with_strategy, used by the
same real-CacheManager-oriented plugins. Delegates to set()."""
self.set(key, data)
if key in self._cache_timestamps:
del self._cache_timestamps[key]
@@ -137,7 +118,6 @@ class MockCacheManager:
self.get_calls = []
self.set_calls = []
self.delete_calls = []
self.get_cached_data_with_strategy_calls = []
class MockConfigManager:
@@ -181,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
View File
@@ -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
@@ -454,18 +454,6 @@ class VisualTestDisplayManager:
"""Check if display is currently scrolling."""
return self._scrolling_state['is_scrolling']
def process_deferred_updates(self):
"""Process any deferred updates (no-op for testing).
Several ticker-style plugins (news, odds-ticker, leaderboard,
stock-news, stocks) call this unconditionally between
set_scrolling_state() and their scroll-position update, mirroring the
real display_manager's deferred-update queue. This double has no such
queue, so there is nothing to process the no-op just lets those
plugins render under the harness instead of raising AttributeError.
"""
pass
# ------------------------------------------------------------------
# Utility methods
# ------------------------------------------------------------------
-76
View File
@@ -279,19 +279,6 @@ class PluginAdapter:
# Copy the image to prevent modification
img = cached_image.copy()
# Plugins that build their own ticker image via this shared
# ScrollHelper's create_scrolling_image() get a solid-black
# leading margin exactly `display_width` columns wide baked in
# (scroll_helper.py's "initial gap before first item"). Vegas mode
# adds its own leading gap/separator around every item already,
# so leaving this in stacks a second, uncontrolled blank margin on
# top of vegas_scroll.separator_width — making this plugin's
# transitions look inconsistent with plugins that provide content
# via get_vegas_content() (which carries no such margin). Strip it
# here so every plugin contributes only its real content and the
# gap between items is governed solely by separator_width.
img = self._strip_scroll_padding(img, scroll_helper, plugin_id)
# Ensure correct height
if img.height != self.display_height:
logger.info(
@@ -319,69 +306,6 @@ class PluginAdapter:
logger.exception("[%s] Error getting scroll_helper content", plugin_id)
return None
def _strip_scroll_padding(
self, img: Image.Image, scroll_helper: Any, plugin_id: str
) -> Image.Image:
"""
Crop off a plugin's own leading/trailing blank margins, if present.
create_scrolling_image() always pads the *start* of its cached image
with exactly `scroll_helper.display_width` columns of solid black
(0, 0, 0) ("initial gap before first item"). Some ticker-style plugins
also pad the *end* of their own cached image (e.g. so their standalone
display exits cleanly before looping). Vegas mode already adds its own
gap/separator around every item, so either margin left in place stacks
an extra, uncontrolled blank stretch on top of `separator_width`
only when running inside Vegas mode does this matter, since the
plugin's own standalone display still wants that margin. Detect solid
black margins up to `scroll_helper.display_width` wide on each edge and
crop them here. Images built via set_scrolling_image() (no such
margins) are left untouched.
Args:
img: Captured scroll_helper.cached_image (already copied)
scroll_helper: The plugin's ScrollHelper instance
plugin_id: Plugin identifier for logging
Returns:
img, cropped on whichever edge(s) had a matching blank margin
"""
pad_width = getattr(scroll_helper, 'display_width', None)
if not isinstance(pad_width, int) or pad_width <= 0 or pad_width >= img.width:
return img
def is_solid_black(strip: Image.Image) -> bool:
return strip.convert('RGB').getextrema() == ((0, 0), (0, 0), (0, 0))
left = pad_width if is_solid_black(img.crop((0, 0, pad_width, img.height))) else 0
right = (
pad_width
if is_solid_black(img.crop((img.width - pad_width, 0, img.width, img.height)))
else 0
)
if not left and not right:
return img
# Degenerate case (e.g. an all-black cached image): don't crop past
# zero width, just leave the image as-is.
if left + right >= img.width:
return img
cropped = img.crop((left, 0, img.width - right, img.height))
# Both edges matching at once is a much stronger signal of genuine
# baked-in padding than a single edge (which has a small chance of
# coinciding with real all-black content, e.g. a dark logo touching
# one boundary). Log that case at warning level so an unexpected
# double-edge crop is easy to spot in the field.
log = logger.warning if (left and right) else logger.info
log(
"[%s] Stripping scroll_helper padding (left=%dpx, right=%dpx): %dpx -> %dpx",
plugin_id, left, right, img.width, cropped.width
)
return cropped
def _trigger_scroll_content_generation(
self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any
) -> Optional[Image.Image]:
-8
View File
@@ -297,8 +297,6 @@ class RenderPipeline:
Returns True when:
- Cycle is complete and we should start fresh
- Staging buffer has new content
- A plugin currently visible in the scroll has pending updated data
(e.g. a live score changed) standalone (non-sync) mode only
"""
if self._cycle_complete:
return True
@@ -316,12 +314,6 @@ class RenderPipeline:
if buffer_status['staging_count'] > 0:
return True
# Trigger recompose when pending updates affect visible segments, so
# live score/status changes reach the display within a few seconds
# instead of waiting for the next full cycle.
if self.stream_manager.has_pending_updates_for_visible_segments():
return True
return False
def hot_swap_content(self) -> bool:
-89
View File
@@ -1884,95 +1884,6 @@ class WiFiManager:
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
return False
def get_wifi_radio_state(self) -> Dict:
"""
Report whether the WiFi radio is currently enabled, plus whether a wired
fallback exists. Used by the web UI's radio toggle so it can warn before
an action that could disconnect the browser.
Returns:
{
'enabled': Optional[bool], # True/False, or None if undeterminable
'ethernet_connected': bool, # wired fallback present
'available': bool, # nmcli present / radio state readable
}
"""
ethernet_connected = self._is_ethernet_connected()
enabled: Optional[bool] = None
available = False
try:
result = subprocess.run(
["nmcli", "radio", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
status = result.stdout.strip().lower()
if status in ("enabled", "disabled"):
enabled = status == "enabled"
available = True
except Exception as e:
logger.debug(f"Could not read WiFi radio state: {e}")
return {
'enabled': enabled,
'ethernet_connected': ethernet_connected,
'available': available,
}
def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str, Optional[str]]:
"""
Turn the WiFi radio on or off.
Turning the radio OFF from the web interface is dangerous: if the device
is reachable only over WiFi, disabling it disconnects the very page that
issued the request. To prevent that lockout, disabling is refused unless a
wired (Ethernet) fallback is present, or the caller explicitly passes
force=True to acknowledge the risk.
Enabling reuses the hardened _ensure_wifi_radio_enabled() path (handles
rfkill soft-blocks + retries). Both directions rely only on
`nmcli radio wifi on|off`, which is already covered by the passwordless
sudoers allowlist (configure_wifi_permissions.sh) no new privileged
command is introduced.
Returns:
(success, human-readable message, reason_code). reason_code is
'no_ethernet' when a disable is refused for lockout safety, or a
short failure code otherwise; None on success. The web UI keys on
'no_ethernet' to decide whether to offer a force-off prompt.
"""
if enabled:
if self._ensure_wifi_radio_enabled():
return True, "WiFi radio enabled.", None
return False, "Failed to enable WiFi radio. Check logs for details.", 'enable_failed'
# Disabling — guard against locking the user out of the web interface.
if not force and not self._is_ethernet_connected():
return False, (
"Refusing to disable WiFi: no wired (Ethernet) connection was "
"detected, so turning off WiFi would disconnect you from this "
"page. Connect Ethernet first, or force it if you're sure."
), 'no_ethernet'
try:
result = subprocess.run(
["sudo", "nmcli", "radio", "wifi", "off"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
logger.info("WiFi radio disabled via web interface (force=%s)", force)
return True, "WiFi radio disabled.", None
logger.warning("Failed to disable WiFi radio: %s", result.stderr.strip())
return False, "Failed to disable WiFi radio. Check logs for details.", 'command_failed'
except subprocess.TimeoutExpired:
return False, "Command timed out while disabling WiFi radio.", 'timeout'
except (OSError, subprocess.SubprocessError) as e:
logger.error("Error disabling WiFi radio: %s", e, exc_info=True)
return False, "An error occurred while disabling WiFi radio.", 'error'
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
"""
Enable access point mode
-10
View File
@@ -172,16 +172,6 @@ class TestVisualDisplayManager:
vdm.set_scrolling_state(False)
assert vdm.is_currently_scrolling() is False
def test_process_deferred_updates_is_noop(self):
# Ticker-style plugins (news, odds-ticker, leaderboard, stock-news,
# stocks) call this unconditionally alongside set_scrolling_state();
# it must exist and be harmless so those plugins render under the
# harness instead of raising AttributeError.
vdm = VisualTestDisplayManager(width=128, height=32)
vdm.set_scrolling_state(True)
vdm.process_deferred_updates() # should not raise
assert vdm.is_currently_scrolling() is True
def test_format_date_with_ordinal(self):
from datetime import datetime
vdm = VisualTestDisplayManager(width=128, height=32)
-208
View File
@@ -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)
-454
View File
@@ -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
-13
View File
@@ -284,19 +284,6 @@ class TestDiskCache:
result = cache.get("test_key", max_age=0)
assert result is None
def test_get_max_age_none_never_expires(self, tmp_path):
"""max_age=None must return persisted records regardless of age.
Regression: the age comparison raised TypeError for max_age=None,
which was swallowed and treated as a miss silently breaking
long-lived state (plugin health/metrics) read across processes.
"""
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("test_key", {"data": "value", "timestamp": 0}) # epoch → very old
result = cache.get("test_key", max_age=None)
assert result is not None
assert result["data"] == "value"
def test_get_nonexistent(self, tmp_path):
"""Test getting non-existent key."""
cache = DiskCache(cache_dir=str(tmp_path))
-111
View File
@@ -214,104 +214,6 @@ class TestDisplayControllerLivePriority:
assert controller.current_mode_index == 1
assert controller.current_display_mode == "b"
# --- Round-robin between multiple simultaneous live games --------------
@staticmethod
def _live_plugin(live_modes):
"""A mock plugin that is live and reports the given live mode names."""
p = MagicMock()
p.has_live_priority = MagicMock(return_value=True)
p.has_live_content = MagicMock(return_value=True)
p.get_live_modes = MagicMock(return_value=list(live_modes))
return p
def test_collect_live_modes_dedupes_multi_mode_plugin(self, test_display_controller):
"""A sports plugin registered under several mode keys (one per league)
contributes each live mode once, in registration order; plugins with no
live content are skipped."""
controller = test_display_controller
baseball = self._live_plugin(["baseball_live"])
soccer = self._live_plugin(["soccer_fifa.world_live"])
idle = MagicMock()
idle.has_live_priority = MagicMock(return_value=True)
idle.has_live_content = MagicMock(return_value=False)
controller.plugin_modes = {
"baseball_live": baseball,
"baseball_recent": baseball,
"soccer_fifa.world_live": soccer,
"soccer_usa.1_live": soccer,
"soccer_recent": soccer,
"clock": idle,
}
assert controller._collect_live_modes() == [
"baseball_live", "soccer_fifa.world_live"
]
def test_round_robin_alternates_between_simultaneous_live_games(self, test_display_controller):
"""Regression: with two games live at once, the live-priority pick
round-robins each dwell instead of pinning to the first plugin in
registration order (the bug where a baseball game hid a live World Cup
match)."""
controller = test_display_controller
baseball = self._live_plugin(["baseball_live"])
soccer = self._live_plugin(["soccer_fifa.world_live"])
controller.plugin_modes = {
"baseball_live": baseball,
"soccer_fifa.world_live": soccer,
}
# First entry into live priority from an ambient mode -> first live game.
controller.current_display_mode = "clock"
assert controller._check_live_priority(advance=True) == "baseball_live"
# The controller switches to it; the next dwell advances to the other.
controller.current_display_mode = "baseball_live"
assert controller._check_live_priority(advance=True) == "soccer_fifa.world_live"
# And wraps back again.
controller.current_display_mode = "soccer_fifa.world_live"
assert controller._check_live_priority(advance=True) == "baseball_live"
def test_single_live_game_holds_without_flipping(self, test_display_controller):
"""One live game: advancing returns the same mode, so the hold is stable."""
controller = test_display_controller
controller.plugin_modes = {"baseball_live": self._live_plugin(["baseball_live"])}
controller.current_display_mode = "baseball_live"
assert controller._check_live_priority(advance=True) == "baseball_live"
def test_non_advancing_peek_does_not_rotate(self, test_display_controller):
"""The default (advance=False) peek used by the Vegas coordinator must
not spin the cursor: it returns the live mode already on screen."""
controller = test_display_controller
controller.plugin_modes = {
"baseball_live": self._live_plugin(["baseball_live"]),
"soccer_fifa.world_live": self._live_plugin(["soccer_fifa.world_live"]),
}
controller.current_display_mode = "soccer_fifa.world_live"
assert controller._check_live_priority() == "soccer_fifa.world_live"
assert controller._check_live_priority() == "soccer_fifa.world_live"
# From an ambient mode the peek reports the first live game (truthy).
controller.current_display_mode = "clock"
assert controller._check_live_priority() == "baseball_live"
def test_no_live_content_returns_none(self, test_display_controller):
controller = test_display_controller
idle = MagicMock()
idle.has_live_priority = MagicMock(return_value=True)
idle.has_live_content = MagicMock(return_value=False)
controller.plugin_modes = {"clock": idle}
controller.current_display_mode = "clock"
assert controller._check_live_priority(advance=True) is None
def test_fallback_to_mode_name_when_get_live_modes_unhelpful(self, test_display_controller):
"""A live plugin whose get_live_modes returns nothing registered falls
back to its own '_live' mode name (legacy behavior preserved)."""
controller = test_display_controller
legacy = MagicMock()
legacy.has_live_priority = MagicMock(return_value=True)
legacy.has_live_content = MagicMock(return_value=True)
legacy.get_live_modes = MagicMock(return_value=["unregistered_mode"])
controller.plugin_modes = {"hockey_live": legacy}
controller.current_display_mode = "clock"
assert controller._check_live_priority(advance=True) == "hockey_live"
class TestDisplayControllerDynamicDuration:
"""Test dynamic duration handling."""
@@ -391,16 +293,3 @@ class TestDisplayControllerSchedule:
controller._check_schedule()
assert controller.is_display_active is False
class TestPluginHealthWiring:
"""Phase 1: DisplayController activates the dormant plugin health/metrics
subsystem by wiring real tracker/monitor instances onto the plugin manager."""
def test_health_tracker_and_resource_monitor_wired(self, test_display_controller):
from src.plugin_system.plugin_health import PluginHealthTracker
from src.plugin_system.resource_monitor import PluginResourceMonitor
pm = test_display_controller.plugin_manager
assert isinstance(pm.health_tracker, PluginHealthTracker)
assert isinstance(pm.resource_monitor, PluginResourceMonitor)
@@ -1,255 +0,0 @@
"""Tests for live plugin enable/disable hot-reload in DisplayController.
Enabling or disabling a plugin in config used to require a full display
restart because the plugin list and available_modes were built once at init.
These tests cover the reconcile path that loads/unloads plugins and rebuilds
the dispatch maps on the main thread when the enabled set changes.
"""
from unittest.mock import MagicMock
def _make_plugin(modes):
plugin = MagicMock()
plugin.modes = list(modes)
return plugin
def _wire_plugin_manager(controller, plugins, discovered=None):
"""Point the controller's mock plugin_manager at a set of fake plugins.
`plugins` maps plugin_id -> mock instance (with a .modes list).
"""
pm = controller.plugin_manager
pm.discover_plugins.return_value = list(discovered if discovered is not None else plugins.keys())
pm.load_plugin.return_value = True
pm.unload_plugin.return_value = True
pm.plugin_manifests = {}
pm.get_plugin.side_effect = lambda pid: plugins.get(pid)
return pm
def _set_config(controller, cfg):
controller.config_service.get_config = lambda: cfg
class TestPluginEnableDisableHotReload:
def test_enable_plugin_live(self, test_display_controller):
controller = test_display_controller
assert controller.available_modes == []
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin})
_set_config(controller, {"foo": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "foo" in controller.plugin_display_modes
assert "foo" in controller.available_modes
assert controller.plugin_modes["foo"] is plugin
assert controller.mode_to_plugin_id["foo"] == "foo"
controller.plugin_manager.load_plugin.assert_any_call("foo")
def test_disable_plugin_live(self, test_display_controller):
controller = test_display_controller
plugin = _make_plugin(["live", "recent"])
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
# Enable, then disable.
_set_config(controller, {"sports": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "sports" in controller.plugin_display_modes
assert "live" in controller.available_modes and "recent" in controller.available_modes
assert "sports" in controller._plugin_config_callbacks
_set_config(controller, {"sports": {"enabled": False}})
controller._reconcile_enabled_plugins()
assert "sports" not in controller.plugin_display_modes
assert "live" not in controller.available_modes
assert "recent" not in controller.available_modes
assert "live" not in controller.plugin_modes
assert "recent" not in controller.mode_to_plugin_id
controller.plugin_manager.unload_plugin.assert_any_call("sports")
assert "sports" not in controller._plugin_config_callbacks
def test_disable_clamps_current_mode_index(self, test_display_controller):
controller = test_display_controller
p1 = _make_plugin(["a"])
p2 = _make_plugin(["b"])
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
controller._reconcile_enabled_plugins()
# Add order across multiple plugins is set-driven (as at init), so
# compare membership, not order.
assert set(controller.available_modes) == {"a", "b"}
# Pretend we're currently showing p2's mode.
controller.current_mode_index = controller.available_modes.index("b")
controller.current_display_mode = "b"
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": False}})
controller._reconcile_enabled_plugins()
assert controller.available_modes == ["a"]
# Index must be back in range and the display mode no longer the removed one.
assert 0 <= controller.current_mode_index < len(controller.available_modes)
assert controller.current_display_mode == "a"
def test_enable_keeps_current_mode(self, test_display_controller):
controller = test_display_controller
p1 = _make_plugin(["a"])
p2 = _make_plugin(["b"])
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
_set_config(controller, {"p1": {"enabled": True}})
controller._reconcile_enabled_plugins()
controller.current_mode_index = 0
controller.current_display_mode = "a"
# Enabling p2 should not disturb the currently-showing mode.
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "b" in controller.available_modes
assert controller.current_display_mode == "a"
assert controller.available_modes[controller.current_mode_index] == "a"
def test_noop_when_enabled_set_unchanged(self, test_display_controller):
controller = test_display_controller
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
_set_config(controller, {"foo": {"enabled": True}})
controller._reconcile_enabled_plugins()
load_calls = controller.plugin_manager.load_plugin.call_count
unload_calls = controller.plugin_manager.unload_plugin.call_count
# Reconcile again with no change — must not load/unload anything.
controller._reconcile_enabled_plugins()
assert controller.plugin_manager.load_plugin.call_count == load_calls
assert controller.plugin_manager.unload_plugin.call_count == unload_calls
def test_reconcile_ignores_non_dict_config_value(self, test_display_controller, caplog):
"""A malformed config value (e.g. a stray string where a plugin's
section should be a dict) must be treated as disabled, not crash
the reconcile with AttributeError, and should be logged so it's
visible to whoever has to debug the malformed config."""
controller = test_display_controller
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
_set_config(controller, {"foo": "not-a-dict"})
with caplog.at_level("WARNING"):
controller._reconcile_enabled_plugins() # must not raise
assert "foo" not in controller.plugin_display_modes
assert "foo" not in controller.available_modes
assert any("foo" in r.message and "not a dict" in r.message for r in caplog.records)
def test_disable_keeps_callback_when_unsubscribe_fails(self, test_display_controller):
"""If config_service.unsubscribe() raises, _unregister_plugin must
keep the callback in _plugin_config_callbacks rather than losing the
only reference to it (it still tears down the plugin itself)."""
controller = test_display_controller
plugin = _make_plugin(["live"])
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
_set_config(controller, {"sports": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "sports" in controller._plugin_config_callbacks
controller.config_service.unsubscribe = MagicMock(side_effect=RuntimeError("boom"))
_set_config(controller, {"sports": {"enabled": False}})
controller._reconcile_enabled_plugins()
assert "sports" not in controller.plugin_display_modes
assert "sports" in controller._plugin_config_callbacks
class TestReconcileReturnValue:
"""_reconcile_enabled_plugins() returns True/False so the caller (run()'s
loop) only clears _pending_plugin_reconcile on success, keeping a
retryable failure's request alive instead of silently dropping it."""
def test_returns_true_on_success(self, test_display_controller):
controller = test_display_controller
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
_set_config(controller, {"foo": {"enabled": True}})
assert controller._reconcile_enabled_plugins() is True
def test_returns_true_for_noop(self, test_display_controller):
controller = test_display_controller
_wire_plugin_manager(controller, {}, discovered=[])
_set_config(controller, {})
assert controller._reconcile_enabled_plugins() is True
def test_returns_false_on_discovery_failure(self, test_display_controller):
controller = test_display_controller
controller.plugin_manager.discover_plugins.side_effect = RuntimeError("boom")
_set_config(controller, {})
assert controller._reconcile_enabled_plugins() is False
def test_returns_true_when_no_plugin_manager(self, test_display_controller):
controller = test_display_controller
controller.plugin_manager = None
assert controller._reconcile_enabled_plugins() is True
class TestRunWithNoModesEnabled:
"""Before hot-reload, an empty available_modes at startup was permanent
-- the display never came back without a restart. Now that a plugin can
be enabled live from the web UI, run() must idle rather than exit."""
def test_idles_instead_of_exiting(self, test_display_controller):
controller = test_display_controller
assert controller.available_modes == []
sleep_calls = []
def fake_sleep(duration, tick_interval=1.0):
sleep_calls.append(duration)
if len(sleep_calls) >= 3:
# Stand in for the process being torn down; run() catches
# this via its broad except + finally, same as any other
# unexpected error during the loop.
raise RuntimeError("stop-test-loop")
controller._sleep_with_plugin_updates = fake_sleep
controller.run()
# Old behavior returned before ever reaching the loop body, so
# _sleep_with_plugin_updates would never have been called. The idle
# tick is short (not a long sleep) so a plugin enabled via the web
# UI while idle is picked up about as promptly as it would be once
# modes exist and the loop is iterating per-frame.
assert sleep_calls == [1, 1, 1]
class TestEnabledSetChanged:
def test_detects_toggle(self, test_display_controller):
c = test_display_controller
assert c._enabled_set_changed({"a": {"enabled": True}}, {"a": {"enabled": False}}) is True
def test_no_change(self, test_display_controller):
c = test_display_controller
cfg = {"a": {"enabled": True}, "b": {"enabled": False}}
assert c._enabled_set_changed(cfg, dict(cfg)) is False
def test_new_enabled_section(self, test_display_controller):
c = test_display_controller
assert c._enabled_set_changed(
{"a": {"enabled": True}},
{"a": {"enabled": True}, "b": {"enabled": True}},
) is True
def test_ignores_non_enabled_value_edits(self, test_display_controller):
c = test_display_controller
assert c._enabled_set_changed(
{"a": {"enabled": True, "duration": 30}},
{"a": {"enabled": True, "duration": 45}},
) is False
@@ -1,88 +0,0 @@
"""
Regression tests for DisplayController._tick_plugin_updates_for_vegas().
PR #299 added logic to detect which plugins actually got fresh data on a
scheduled-update tick and notify Vegas mode via
vegas_coordinator.mark_plugin_updated(), so a live score change reaches the
scroll within seconds instead of waiting for a full cycle. PR #330's
multi-display sync refactor deleted this method (folding the callback back
to the plain _tick_plugin_updates(), which reports nothing), silently
orphaning VegasModeCoordinator.mark_plugin_updated() -- it has had zero
callers since.
"""
from typing import Dict, List, Optional
from unittest.mock import MagicMock
from src.display_controller import DisplayController
def _make_controller(updated: Optional[List[str]] = None, vegas_coordinator: Optional[MagicMock] = None) -> DisplayController:
dc = object.__new__(DisplayController)
dc.plugin_manager = MagicMock()
dc.plugin_manager.run_scheduled_updates_with_changes.return_value = list(updated or [])
dc.vegas_coordinator = vegas_coordinator
return dc
class TestTickPluginUpdatesForVegas:
def test_marks_only_plugins_whose_timestamp_advanced(self):
vc = MagicMock()
dc = _make_controller(updated=["stock-news"], vegas_coordinator=vc)
dc._tick_plugin_updates_for_vegas()
vc.mark_plugin_updated.assert_called_once_with("stock-news")
def test_no_advance_marks_nothing(self):
vc = MagicMock()
dc = _make_controller(updated=[], vegas_coordinator=vc)
dc._tick_plugin_updates_for_vegas()
vc.mark_plugin_updated.assert_not_called()
def test_no_vegas_coordinator_does_not_raise(self):
dc = _make_controller(updated=["stock-news"], vegas_coordinator=None)
dc._tick_plugin_updates_for_vegas() # must not raise
def test_mark_plugin_updated_exception_does_not_propagate(self):
"""One plugin's mark_plugin_updated failing must not stop the tick
or crash the update loop it runs in."""
vc = MagicMock()
vc.mark_plugin_updated.side_effect = [RuntimeError("boom"), None]
dc = _make_controller(updated=["a", "b"], vegas_coordinator=vc)
dc._tick_plugin_updates_for_vegas() # must not raise
assert vc.mark_plugin_updated.call_count == 2
class TestVegasCoordinatorCallbackWiring:
def test_initialize_wires_vegas_aware_tick_as_update_callback(self):
"""The Vegas coordinator must be given the Vegas-aware
_tick_plugin_updates_for_vegas as its update callback, not the plain
_tick_plugin_updates() -- that's the exact wiring PR #330 dropped."""
dc = object.__new__(DisplayController)
dc.config = {"display": {"vegas_scroll": {"enabled": True}}, "sync": {}}
dc.display_manager = MagicMock()
dc.plugin_manager = MagicMock()
dc.sync_manager = MagicMock()
dc._check_live_priority = MagicMock()
dc._check_vegas_interrupt = MagicMock(return_value=False)
fake_coordinator = MagicMock()
import src.display_controller as dc_module
original_imported = dc_module._vegas_mode_imported
original_class = dc_module.VegasModeCoordinator
try:
dc_module._vegas_mode_imported = True
dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator)
dc._initialize_vegas_mode()
finally:
dc_module._vegas_mode_imported = original_imported
dc_module.VegasModeCoordinator = original_class
fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas)
-103
View File
@@ -117,106 +117,3 @@ class TestDisplayManagerResourceManagement:
dm.cleanup()
dm.matrix.Clear.assert_called()
class TestDisplayManagerDoubleSided:
"""Double-sided mode: render once at logical size, tile across the chain."""
def _config(self, **double_sided):
"""Build a config (physical 128x32) with the given double_sided block."""
return {
'display': {
'hardware': {
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90,
},
'runtime': {'gpio_slowdown': 2},
'double_sided': double_sided,
},
'timezone': 'UTC',
'plugin_system': {'plugins_directory': 'plugins'},
}
def _captured_physical(self, mock_rgb_matrix):
"""Return the image handed to the canvas on the last update_display()."""
canvas = mock_rgb_matrix['matrix_instance'].CreateFrameCanvas.return_value
return canvas.SetImage.call_args[0][0]
def test_horizontal_reports_logical_dimensions(self, mock_rgb_matrix):
"""Plugins see the per-screen size, not the full physical chain."""
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
suppress_test_pattern=True)
# Physical chain is 128x32; two side-by-side copies -> logical 64x32.
assert dm.matrix.width == 64
assert dm.matrix.height == 32
assert (dm.width, dm.height) == (64, 32)
assert dm.image.size == (64, 32)
def test_horizontal_tiles_image_across_chain(self, mock_rgb_matrix):
"""The logical screen is duplicated left/right into a full-chain frame."""
from PIL import Image
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
suppress_test_pattern=True)
logical = Image.new('RGB', (64, 32), (0, 0, 0))
logical.putpixel((5, 5), (255, 0, 0))
dm.image = logical
dm.update_display()
physical = self._captured_physical(mock_rgb_matrix)
assert physical.size == (128, 32)
assert physical.getpixel((5, 5)) == (255, 0, 0)
assert physical.getpixel((69, 5)) == (255, 0, 0) # copy shifted +64
def test_vertical_axis_tiles_stacked(self, mock_rgb_matrix):
"""Vertical axis stacks copies (for panels on parallel outputs)."""
from PIL import Image
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='vertical'),
suppress_test_pattern=True)
# 128x32 split vertically -> logical 128x16.
assert (dm.matrix.width, dm.matrix.height) == (128, 16)
logical = Image.new('RGB', (128, 16), (0, 0, 0))
logical.putpixel((10, 3), (0, 255, 0))
dm.image = logical
dm.update_display()
physical = self._captured_physical(mock_rgb_matrix)
assert physical.size == (128, 32)
assert physical.getpixel((10, 3)) == (0, 255, 0)
assert physical.getpixel((10, 19)) == (0, 255, 0) # copy shifted +16
def test_indivisible_dimension_disables_mode(self, mock_rgb_matrix):
"""A physical size that doesn't divide evenly falls back to single."""
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=3, axis='horizontal'),
suppress_test_pattern=True)
assert dm._double_sided is None # 128 % 3 != 0
assert dm.matrix.width == 128
assert dm.image.size == (128, 32)
def test_disabled_blits_logical_image_unchanged(self, mock_rgb_matrix):
"""With the feature off, the rendered image is sent through untouched."""
from PIL import Image
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=False), suppress_test_pattern=True)
assert dm._double_sided is None
img = Image.new('RGB', (128, 32))
dm.image = img
dm.update_display()
assert self._captured_physical(mock_rgb_matrix) is img
def test_brightness_write_forwards_through_proxy(self, mock_rgb_matrix):
"""Setting brightness via the proxy reaches the real matrix."""
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
suppress_test_pattern=True)
assert dm.set_brightness(70) is True
assert mock_rgb_matrix['matrix_instance'].brightness == 70
-98
View File
@@ -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
-70
View File
@@ -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)
-83
View File
@@ -1,83 +0,0 @@
"""
Tests for src.common.permission_utils's URL-credential redaction.
Covers the fix for a CodeQL clear-text-logging-of-secrets alert:
install_requirements_file() must never let a private index URL's embedded
user:pass@ credentials reach logs or its returned CompletedProcess, since
pip can echo that URL back verbatim in its own stderr/stdout on failure.
"""
from pathlib import Path
from unittest.mock import MagicMock, patch
from src.common.permission_utils import _redact_url_credentials, install_requirements_file
class TestRedactUrlCredentials:
def test_redacts_embedded_basic_auth(self):
text = "Could not fetch URL https://alice:s3cr3t@pypi.example.com/simple/: 403"
redacted = _redact_url_credentials(text)
assert "s3cr3t" not in redacted
assert "alice" not in redacted
assert "https://***:***@pypi.example.com/simple/" in redacted
def test_leaves_credential_free_text_unchanged(self):
text = "ERROR: Could not find a version that satisfies the requirement foo==1.0"
assert _redact_url_credentials(text) == text
def test_handles_none_and_empty(self):
assert _redact_url_credentials(None) == ""
assert _redact_url_credentials("") == ""
def test_does_not_touch_denied_check_phrases(self):
"""The fixed phrases install_requirements_file greps for must survive
redaction untouched -- they don't overlap with URL syntax, but this
pins that assumption so a regex change can't silently break it."""
text = "sudo: a password is required"
assert _redact_url_credentials(text) == text
class TestInstallRequirementsFileRedaction:
@patch('src.common.permission_utils.subprocess.run')
def test_wrapper_path_redacts_stderr_and_stdout(self, mock_run, tmp_path):
"""safe_pip_install.sh exists in this repo, so install_requirements_file
takes the sudo-wrapper branch; a failing result must come back
with any embedded index-URL credentials already redacted."""
req_file = tmp_path / "requirements.txt"
req_file.write_text("requests\n")
mock_run.return_value = MagicMock(
returncode=1,
stdout="Looking in indexes: https://bob:hunter2@pypi.internal/simple\n",
stderr="ERROR https://bob:hunter2@pypi.internal/simple/foo: 401",
)
result = install_requirements_file(req_file, timeout=5)
assert "hunter2" not in result.stdout
assert "hunter2" not in result.stderr
assert "https://***:***@pypi.internal" in result.stdout
assert "https://***:***@pypi.internal" in result.stderr
@patch('src.common.permission_utils.subprocess.run')
@patch('src.common.permission_utils.Path.exists', return_value=False)
def test_no_wrapper_fallback_path_redacts_stderr_and_stdout(self, mock_exists, mock_run, tmp_path):
"""No safe_pip_install.sh wrapper -> falls straight to the
sys.executable pip fallback (the second subprocess.run call site);
its result must come back redacted too, independent of the wrapper
branch's own redaction above."""
req_file = tmp_path / "requirements.txt"
req_file.write_text("requests\n")
mock_run.return_value = MagicMock(
returncode=1,
stdout="Looking in indexes: https://carol:swordfish@pypi.internal/simple\n",
stderr="ERROR https://carol:swordfish@pypi.internal/simple/foo: 401",
)
result = install_requirements_file(req_file, timeout=5)
assert "swordfish" not in result.stdout
assert "swordfish" not in result.stderr
assert "https://***:***@pypi.internal" in result.stdout
assert "https://***:***@pypi.internal" in result.stderr
-93
View File
@@ -1,93 +0,0 @@
"""
Tests for src/plugin_system/plugin_health.py
Focus on the additive ``set_degraded`` mechanism used by the warn-only schema
validation path: it must surface a degraded reason without touching the circuit
breaker or causing the plugin to be skipped.
"""
from unittest.mock import MagicMock
from src.plugin_system.plugin_health import PluginHealthTracker, CircuitState
def _cache():
cache = MagicMock()
cache.get.return_value = None
return cache
def test_set_degraded_marks_and_surfaces_reason():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
summary = tracker.get_health_summary("p")
assert summary["degraded"] is True
assert summary["degraded_reason"] == "bad config"
def test_set_degraded_none_clears():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
tracker.set_degraded("p", None)
summary = tracker.get_health_summary("p")
assert summary["degraded"] is False
assert summary["degraded_reason"] is None
def test_set_degraded_does_not_affect_circuit_breaker():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
summary = tracker.get_health_summary("p")
# Degraded is a *separate* signal from circuit health: the plugin is not
# counted as failing, the circuit stays closed, and it is not skipped.
assert summary["circuit_state"] == CircuitState.CLOSED.value
assert summary["consecutive_failures"] == 0
assert summary["is_healthy"] is True
assert tracker.should_skip_plugin("p") is False
def test_set_degraded_skips_redundant_cache_write():
cache = _cache()
tracker = PluginHealthTracker(cache)
tracker.set_degraded("p", "x")
writes_after_first = cache.set.call_count
assert writes_after_first >= 1
tracker.set_degraded("p", "x") # unchanged → no extra write
assert cache.set.call_count == writes_after_first
def test_default_summary_has_degraded_fields():
tracker = PluginHealthTracker(_cache())
summary = tracker.get_health_summary("never-seen")
assert summary["degraded"] is False
assert summary["degraded_reason"] is None
def test_force_reload_refreshes_stale_in_memory_snapshot():
"""A long-lived reader (e.g. the web process) must not be pinned to the
first snapshot: force_reload re-reads persisted state and bypasses the
cache manager's memory tier so cross-process updates are visible."""
cache = _cache()
tracker = PluginHealthTracker(cache)
# First read snapshots an empty (healthy) state into the in-memory copy.
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
# The display service later persists a failing/open state.
cache.get.return_value = {
"consecutive_failures": 5,
"circuit_state": "open",
"total_failures": 5,
"total_successes": 0,
}
# A plain read is still pinned to the stale snapshot...
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
# ...but force_reload observes the new persisted state.
fresh = tracker.get_health_summary("p", force_reload=True)
assert fresh["consecutive_failures"] == 5
assert fresh["circuit_state"] == "open"
# and it asked the cache to bypass the in-memory tier (memory_ttl=0).
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
+3 -125
View File
@@ -4,8 +4,6 @@ Tests for PluginLoader.
Tests plugin directory discovery, module loading, and class instantiation.
"""
import subprocess
import pytest
from unittest.mock import MagicMock, patch
from src.plugin_system.plugin_loader import PluginLoader
@@ -193,7 +191,7 @@ class TestPluginLoader:
mock_subprocess.return_value = MagicMock(returncode=0)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
assert result is True
mock_subprocess.assert_called_once()
@@ -204,7 +202,7 @@ class TestPluginLoader:
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
assert result is True
mock_subprocess.assert_not_called()
@@ -219,126 +217,6 @@ class TestPluginLoader:
mock_subprocess.return_value = MagicMock(returncode=1)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
assert result is False
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
@patch('subprocess.run')
def test_install_dependencies_retries_with_ignore_installed_on_apt_conflict(
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
):
"""An apt-managed package with no pip RECORD file triggers a retry with
--ignore-installed rather than silently assuming the old version satisfies
the requirement. requirements_are_satisfied() is mocked False here because
this scenario is exactly the case where the installed (apt) version does
NOT satisfy the pin that's why pip attempts a reinstall in the first place."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
first_attempt = MagicMock(
returncode=1,
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
)
retry_attempt = MagicMock(returncode=0, stderr="")
mock_subprocess.side_effect = [first_attempt, retry_attempt]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
assert mock_subprocess.call_count == 2
retry_cmd = mock_subprocess.call_args_list[1][0][0]
assert "--ignore-installed" in retry_cmd
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
@patch('subprocess.run')
def test_install_dependencies_apt_conflict_retry_also_fails(
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
):
"""Still tolerates the failure (returns True) if the --ignore-installed
retry itself fails, matching the prior soft-fallback behavior."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
first_attempt = MagicMock(
returncode=1,
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
)
retry_attempt = MagicMock(returncode=1, stderr="some other pip error")
mock_subprocess.side_effect = [first_attempt, retry_attempt]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
assert mock_subprocess.call_count == 2
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
@patch('subprocess.run')
def test_install_dependencies_apt_conflict_retry_times_out(
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
):
"""A retry timeout must be tolerated the same way as a retry failure
(return True), not propagate to the outer TimeoutExpired handler and
return False."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
first_attempt = MagicMock(
returncode=1,
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
)
mock_subprocess.side_effect = [
first_attempt,
subprocess.TimeoutExpired(cmd="pip", timeout=300),
]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
assert mock_subprocess.call_count == 2
@patch('subprocess.run')
def test_install_dependencies_already_satisfied_skips_pip(self, mock_subprocess, plugin_loader, tmp_plugins_dir):
"""A requirement already satisfied in the current environment shouldn't invoke pip."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("pytest>=1.0\n")
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
mock_subprocess.assert_not_called()
def test_install_dependencies_requires_plugins_dir(self, plugin_loader, tmp_plugins_dir):
"""plugins_dir is a required argument, not an optional trust-me flag --
calling without it must fail loudly (TypeError) rather than silently
falling back to trusting plugin_dir unchecked."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
with pytest.raises(TypeError):
plugin_loader.install_dependencies(plugin_dir, "test_plugin")
@patch('subprocess.run')
def test_install_dependencies_rejects_path_outside_plugins_dir(
self, mock_subprocess, plugin_loader, tmp_path, tmp_plugins_dir
):
"""A plugin_dir that doesn't actually live inside plugins_dir (e.g. a
manifest-derived id crafted to traverse elsewhere) must be rejected
rather than read from -- this is the path-injection containment
check CodeQL flagged as missing."""
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
(outside_dir / "requirements.txt").write_text("requests>=2.0\n")
result = plugin_loader.install_dependencies(outside_dir, "evil_plugin", plugins_dir=tmp_plugins_dir)
assert result is False
mock_subprocess.assert_not_called()
-79
View File
@@ -1,79 +0,0 @@
"""
Tests for PluginManager._validate_config_schema_soft (Phase 1, warn-only schema
validation).
Contract:
- A schema violation logs a warning and marks the plugin degraded in the health
tracker, but never raises and never changes load pass/fail behaviour.
- A valid config (or no schema) clears any stale degraded flag.
- The method is safe when no health tracker is wired.
"""
import tempfile
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.plugin_system.plugin_manager import PluginManager
@pytest.fixture
def pm():
with tempfile.TemporaryDirectory() as tmp:
manager = PluginManager(plugins_dir=str(Path(tmp) / "plugins"))
manager.schema_manager = MagicMock()
yield manager
def test_invalid_config_marks_degraded_without_raising(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.return_value = (
False,
["Missing required field: 'api_key'"],
)
pm._validate_config_schema_soft("youtube-stats", {})
pm.health_tracker.set_degraded.assert_called_once()
plugin_id, reason = pm.health_tracker.set_degraded.call_args[0]
assert plugin_id == "youtube-stats"
assert "api_key" in reason
def test_valid_config_clears_degraded(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.return_value = (True, [])
pm._validate_config_schema_soft("p", {"api_key": "x"})
pm.health_tracker.set_degraded.assert_called_once_with("p", None)
def test_no_schema_clears_degraded(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = None
pm._validate_config_schema_soft("p", {})
pm.health_tracker.set_degraded.assert_called_once_with("p", None)
def test_validation_exception_is_swallowed(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.side_effect = RuntimeError("boom")
# Must not raise — the validation machinery failing must never break loading.
pm._validate_config_schema_soft("p", {})
def test_safe_without_health_tracker(pm):
pm.health_tracker = None
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.return_value = (False, ["err"])
# Must not raise even though there is no tracker to record against.
pm._validate_config_schema_soft("p", {})
+2 -52
View File
@@ -3,7 +3,6 @@ from unittest.mock import MagicMock, patch
from pathlib import Path
from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.plugin_state import PluginState
from src.plugin_system.resource_monitor import PluginResourceMonitor
class TestPluginManager:
"""Test PluginManager functionality."""
@@ -79,63 +78,14 @@ class TestPluginManager:
assert result is False
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR
def test_run_scheduled_updates_calls_update_with_resource_monitor(
self, mock_config_manager, mock_display_manager, mock_cache_manager
):
"""Regression test: run_scheduled_updates() must actually call a
plugin's update() when self.resource_monitor is set (as it is in
every real deployment -- display_controller.py and web_interface/
app.py both assign a real PluginResourceMonitor after construction).
Previously, the resource_monitor branch wrapped the call in a
function stored as a *class* attribute on a dynamically-built type
(`type('obj', (object,), {'update': monitored_update})()`), which
the descriptor protocol turns into a bound method on access --
silently passing the synthetic instance as an implicit first
argument to monitored_update(), which takes none. Every plugin's
scheduled update failed with "monitored_update() takes 0 positional
arguments but 1 was given" and was silently swallowed into a
circuit-breaker retry loop that never succeeded, so plugin data
(scores, odds, etc.) never refreshed.
"""
with patch('src.plugin_system.plugin_manager.ensure_directory_permissions'):
pm = PluginManager(
plugins_dir="plugins",
config_manager=mock_config_manager,
display_manager=mock_display_manager,
cache_manager=mock_cache_manager
)
plugin_instance = MagicMock()
plugin_instance.enabled = True
plugin_instance.update = MagicMock()
pm.plugins["test_plugin"] = plugin_instance
pm.plugin_manifests["test_plugin"] = {"update_interval": 10}
pm.state_manager.set_state("test_plugin", PluginState.ENABLED)
# Plain MagicMock, not the mock_cache_manager fixture: this test
# is about run_scheduled_updates() actually invoking update()
# through the resource-monitor wrapper, not about
# PluginResourceMonitor's own cache-backed metrics persistence
# (which calls cache_manager.get(..., memory_ttl=...) --
# a kwarg the fixture's mock_get() doesn't accept).
pm.resource_monitor = PluginResourceMonitor(MagicMock())
pm.run_scheduled_updates(current_time=time.time())
plugin_instance.update.assert_called_once()
assert "test_plugin" in pm.plugin_last_update
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
class TestPluginLoader:
"""Test PluginLoader functionality."""
def test_dependency_check(self):
"""Test dependency checking logic."""
# Covered by test_plugin_loader.py's install_dependencies tests,
# which exercise requirements_has_real_deps/requirements_are_satisfied
# and the pip subprocess fallback.
# This would test _check_dependencies_installed and _install_plugin_dependencies
# which requires mocking subprocess calls and file operations
class TestPluginExecutor:
-129
View File
@@ -1,129 +0,0 @@
"""
Tests for src/plugin_system/resource_monitor.py
Focus areas:
- Execution-time metrics are captured regardless of psutil availability.
- CPU sampling is non-blocking (regression guard for the previous
``cpu_percent(interval=0.1)`` call that blocked 100 ms per monitored call).
- Resource limits are enforced.
"""
import time
import pytest
from unittest.mock import MagicMock
from src.plugin_system.resource_monitor import (
PluginResourceMonitor,
ResourceLimits,
ResourceLimitExceeded,
PSUTIL_AVAILABLE,
)
def _cache():
cache = MagicMock()
cache.get.return_value = None
return cache
class TestExecutionTimeMetrics:
def test_monitor_call_returns_value_and_records_call(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
result = mon.monitor_call("p", lambda: 42)
assert result == 42
metrics = mon.get_metrics("p")
assert metrics.call_count == 1
assert metrics.total_execution_time >= 0.0
def test_avg_and_max_execution_time(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
mon.monitor_call("p", lambda: time.sleep(0.01))
mon.monitor_call("p", lambda: None)
summary = mon.get_metrics_summary("p")
assert summary["call_count"] == 2
assert summary["max_execution_time"] >= summary["avg_execution_time"] >= 0.0
def test_exception_propagates_but_is_still_timed(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
def boom():
raise ValueError("nope")
with pytest.raises(ValueError):
mon.monitor_call("p", boom)
# Execution time is still recorded even when the call raised.
assert mon.get_metrics("p").execution_time >= 0.0
class TestNonBlockingCpu:
def test_cpu_sampling_is_fast_when_disabled(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
start = time.time()
for _ in range(50):
mon._get_process_cpu_percent()
# The old implementation blocked ~0.1s/call (~5s for 50). Non-blocking
# must complete near-instantly.
assert time.time() - start < 0.5
assert mon._get_process_cpu_percent() == 0.0
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not installed")
def test_cpu_sampling_is_fast_with_psutil(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=True)
assert mon._process is not None
start = time.time()
for _ in range(30):
mon._get_process_cpu_percent()
# 30 blocking 0.1s samples would be ~3s; non-blocking must be well under.
assert time.time() - start < 0.5
def test_monitor_call_does_not_block_on_cpu_sampling(self):
mon = PluginResourceMonitor(_cache()) # enable depends on psutil
start = time.time()
for _ in range(25):
mon.monitor_call("p", lambda: None)
# 25 * 0.1s = 2.5s under the old blocking bug; must be far faster now.
assert time.time() - start < 1.0
class TestResourceLimits:
def test_execution_time_limit_raises(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
mon.set_limits("p", ResourceLimits(max_execution_time=0.001))
with pytest.raises(ResourceLimitExceeded):
mon.monitor_call("p", lambda: time.sleep(0.02))
def test_reset_metrics_clears_counts(self):
cache = _cache()
mon = PluginResourceMonitor(cache, enable_monitoring=False)
mon.monitor_call("p", lambda: None)
assert mon.get_metrics("p").call_count == 1
mon.reset_metrics("p")
assert mon.get_metrics("p").call_count == 0
class TestForceReload:
def test_force_reload_refreshes_stale_snapshot(self):
"""A read-only consumer must see the writer process's latest persisted
metrics rather than a pinned first snapshot."""
cache = MagicMock()
persisted = {"value": None} # only the metrics key returns data
def cache_get(key, max_age=None, memory_ttl=None):
return persisted["value"] if key.startswith("plugin_metrics:") else None
cache.get.side_effect = cache_get
mon = PluginResourceMonitor(cache, enable_monitoring=False)
# First read snapshots empty metrics.
assert mon.get_metrics_summary("p")["call_count"] == 0
# The display service later persists real metrics.
persisted["value"] = {"call_count": 7, "total_execution_time": 1.4}
# Plain read stays stale...
assert mon.get_metrics_summary("p")["call_count"] == 0
# ...force_reload picks up the persisted values and bypasses memory.
fresh = mon.get_metrics_summary("p", force_reload=True)
assert fresh["call_count"] == 7
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
-109
View File
@@ -43,115 +43,6 @@ class TestUninstallTombstone(unittest.TestCase):
self.assertNotIn("foo", self.sm._uninstall_tombstones)
class TestPersistentUninstallRegistry(unittest.TestCase):
"""Regression tests for the persistent uninstall registry that stops a
core `git pull` update from resurrecting built-in plugins the user
removed (plugins committed under plugin-repos/)."""
def setUp(self):
self._tmp = TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.plugins_dir = Path(self._tmp.name) / "plugin-repos"
self.plugins_dir.mkdir()
self.registry_path = Path(self._tmp.name) / "config" / "uninstalled_plugins.json"
self.sm = PluginStoreManager(
plugins_dir=str(self.plugins_dir),
uninstalled_registry_path=str(self.registry_path),
)
def _make_plugin_dir(self, plugin_id):
"""Simulate a built-in plugin restored on disk (e.g. by git pull)."""
d = self.plugins_dir / plugin_id
d.mkdir(parents=True)
(d / "manifest.json").write_text('{"id": "%s"}' % plugin_id)
return d
def test_unrecorded_plugin_is_not_uninstalled(self):
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
def test_record_persists_across_instances(self):
self.sm.record_uninstalled_plugin("web-ui-info")
self.assertTrue(self.registry_path.exists())
# A fresh manager (simulating a service restart after update) still sees it.
fresh = PluginStoreManager(
plugins_dir=str(self.plugins_dir),
uninstalled_registry_path=str(self.registry_path),
)
self.assertTrue(fresh.is_plugin_uninstalled("web-ui-info"))
def test_forget_clears_record(self):
self.sm.record_uninstalled_plugin("web-ui-info")
self.sm.forget_uninstalled_plugin("web-ui-info")
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
def test_purge_removes_resurrected_plugin(self):
# The bug: user removed web-ui-info, then a git pull restored its
# committed files. Recorded uninstall + purge must re-remove it.
self._make_plugin_dir("web-ui-info")
self.sm.record_uninstalled_plugin("web-ui-info")
self.assertTrue((self.plugins_dir / "web-ui-info").exists())
removed = self.sm.purge_uninstalled_plugins()
self.assertEqual(removed, ["web-ui-info"])
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
# Record is kept so the purge stays idempotent across future updates.
self.assertTrue(self.sm.is_plugin_uninstalled("web-ui-info"))
def test_purge_leaves_non_uninstalled_plugins_alone(self):
self._make_plugin_dir("baseball-scoreboard") # present, not recorded
self._make_plugin_dir("web-ui-info")
self.sm.record_uninstalled_plugin("web-ui-info")
self.sm.purge_uninstalled_plugins()
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
def test_purge_noop_when_plugin_absent(self):
# Recorded but never restored on disk — nothing to remove.
self.sm.record_uninstalled_plugin("web-ui-info")
self.assertEqual(self.sm.purge_uninstalled_plugins(), [])
def test_corrupt_registry_is_ignored(self):
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
self.registry_path.write_text("{ not valid json")
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
def _write_raw_registry(self, value):
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
import json as _json
self.registry_path.write_text(_json.dumps(value))
def test_empty_id_does_not_wipe_plugins_root(self):
# An empty id resolves to plugins_dir itself; purge must never delete it.
self._make_plugin_dir("baseball-scoreboard")
self._write_raw_registry([""])
removed = self.sm.purge_uninstalled_plugins()
self.assertEqual(removed, [])
self.assertTrue(self.plugins_dir.exists())
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
# Invalid id is filtered out entirely.
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
def test_traversal_ids_are_ignored(self):
for bad in ["..", "../evil", "a/b", "."]:
with self.subTest(bad=bad):
self.assertFalse(self.sm._is_valid_plugin_id(bad))
self._write_raw_registry(["../evil", "..", "web-ui-info"])
# Only the safe id survives the read.
self.assertEqual(self.sm.get_uninstalled_plugins(), {"web-ui-info"})
def test_record_rejects_invalid_id(self):
self.sm.record_uninstalled_plugin("")
self.sm.record_uninstalled_plugin("../escape")
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
class TestGitInfoCache(unittest.TestCase):
def setUp(self):
self._tmp = TemporaryDirectory()
-45
View File
@@ -1,45 +0,0 @@
"""
Unit tests for src/plugin_system/testing/mocks.py.
MockCacheManager/MockPluginManager stand in for the real production
managers under the plugin safety harness -- a missing method here isn't a
harness bug in the abstract, it's a plugin silently failing to render
under test (confirmed on ledmatrix-leaderboard, which calls
get_cached_data_with_strategy() and previously hit an AttributeError that
its own broad except swallowed, producing an empty-but-green render).
"""
from src.plugin_system.testing.mocks import MockCacheManager
class TestMockCacheManagerStrategyMethod:
def test_get_cached_data_with_strategy_returns_cached_value(self):
cm = MockCacheManager()
cm.set("standings_nfl", {"teams": ["KC", "BUF"]})
result = cm.get_cached_data_with_strategy("standings_nfl", "sports_live")
assert result == {"teams": ["KC", "BUF"]}
def test_get_cached_data_with_strategy_returns_none_when_missing(self):
cm = MockCacheManager()
assert cm.get_cached_data_with_strategy("missing_key") is None
def test_get_cached_data_with_strategy_defaults_data_type(self):
cm = MockCacheManager()
cm.set("k", "v")
assert cm.get_cached_data_with_strategy("k") == "v"
def test_calls_are_tracked(self):
cm = MockCacheManager()
cm.get_cached_data_with_strategy("k", "sports_live")
assert cm.get_cached_data_with_strategy_calls == [{"key": "k", "data_type": "sports_live"}]
def test_save_cache_is_readable_via_strategy_lookup(self):
cm = MockCacheManager()
cm.save_cache("standings_nfl", {"teams": ["KC", "BUF"]})
assert cm.get_cached_data_with_strategy("standings_nfl") == {"teams": ["KC", "BUF"]}
def test_reset_clears_strategy_call_tracking(self):
cm = MockCacheManager()
cm.get_cached_data_with_strategy("k", "sports_live")
cm.reset()
assert cm.get_cached_data_with_strategy_calls == []
-138
View File
@@ -1,138 +0,0 @@
"""
Tests for src/vegas_mode/plugin_adapter.py
Covers PluginAdapter._strip_scroll_padding(): the heuristic that crops a
plugin's own baked-in leading/trailing blank margins before Vegas mode
composites the content, so vegas_scroll.separator_width is the only gap
applied between items.
"""
import logging
import pytest
from PIL import Image
from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.plugin_adapter import PluginAdapter
class FakeDisplayManager:
width = 64
height = 32
class FakePlugin:
def __init__(self, scroll_helper):
self.scroll_helper = scroll_helper
@pytest.fixture
def adapter():
return PluginAdapter(FakeDisplayManager())
def _solid(width: int, height: int, color: tuple) -> Image.Image:
"""Create a solid-color RGB image of the given dimensions."""
return Image.new('RGB', (width, height), color)
class TestStripScrollPadding:
def test_leading_pad_from_create_scrolling_image_is_stripped(self, adapter):
sh = ScrollHelper(64, 32)
item = _solid(40, 32, (200, 50, 50))
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert images[0].width == 40
assert images[0].getpixel((0, 0)) == (200, 50, 50)
def test_leading_and_trailing_pad_both_stripped(self, adapter):
sh = ScrollHelper(64, 32)
content_w = 80
full = _solid(64 + content_w + 64, 32, (0, 0, 0))
full.paste(_solid(content_w, 32, (10, 220, 30)), (64, 0))
sh.set_scrolling_image(full)
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert images[0].width == content_w
assert images[0].getpixel((0, 0)) == (10, 220, 30)
assert images[0].getpixel((content_w - 1, 0)) == (10, 220, 30)
def test_leading_only_pad_stripped_trailing_content_kept(self, adapter):
sh = ScrollHelper(64, 32)
content_w = 80
full = _solid(64 + content_w, 32, (0, 0, 0))
full.paste(_solid(content_w, 32, (5, 5, 250)), (64, 0))
sh.set_scrolling_image(full)
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert images[0].width == content_w
assert images[0].getpixel((0, 0)) == (5, 5, 250)
def test_trailing_only_pad_stripped_leading_content_kept(self, adapter):
sh = ScrollHelper(64, 32)
content_w = 80
full = _solid(content_w + 64, 32, (0, 0, 0))
full.paste(_solid(content_w, 32, (5, 5, 250)), (0, 0))
sh.set_scrolling_image(full)
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert images[0].width == content_w
assert images[0].getpixel((0, 0)) == (5, 5, 250)
def test_no_margin_image_left_untouched(self, adapter):
sh = ScrollHelper(64, 32)
raw = _solid(150, 32, (5, 5, 5))
raw.paste(_solid(50, 32, (123, 45, 67)), (0, 0))
sh.set_scrolling_image(raw)
images = adapter._get_scroll_helper_content(FakePlugin(sh), "no_margin")
assert images[0].width == 150
def test_degenerate_all_black_image_left_untouched(self, adapter):
sh = ScrollHelper(64, 32)
sh.set_scrolling_image(_solid(50, 32, (0, 0, 0)))
images = adapter._get_scroll_helper_content(FakePlugin(sh), "all_black")
assert images[0].width == 50
def test_missing_display_width_attribute_left_untouched(self, adapter):
sh = ScrollHelper(64, 32)
item = _solid(40, 32, (200, 50, 50))
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
original_width = sh.cached_image.width
del sh.display_width
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert images[0].width == original_width
def test_pad_width_not_smaller_than_image_left_untouched(self, adapter):
sh = ScrollHelper(64, 32)
sh.set_scrolling_image(_solid(64, 32, (0, 0, 0)))
images = adapter._get_scroll_helper_content(FakePlugin(sh), "narrow")
assert images[0].width == 64
def test_both_edges_matching_logs_warning(self, adapter, caplog):
sh = ScrollHelper(64, 32)
content_w = 80
full = _solid(64 + content_w + 64, 32, (0, 0, 0))
full.paste(_solid(content_w, 32, (10, 220, 30)), (64, 0))
sh.set_scrolling_image(full)
with caplog.at_level(logging.WARNING, logger="src.vegas_mode.plugin_adapter"):
adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert any("Stripping scroll_helper padding" in r.message for r in caplog.records)
def test_single_edge_match_logs_info_not_warning(self, adapter, caplog):
sh = ScrollHelper(64, 32)
item = _solid(40, 32, (200, 50, 50))
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
with caplog.at_level(logging.INFO, logger="src.vegas_mode.plugin_adapter"):
adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
strip_records = [r for r in caplog.records if "Stripping scroll_helper padding" in r.message]
assert len(strip_records) == 1
assert strip_records[0].levelno == logging.INFO

Some files were not shown because too many files have changed in this diff Show More