mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
feat(plugins): adaptive-lib discoverability + advisory version compat warning
Discoverability: re-export the adaptive layout/image API from src.common (the blessed-helpers package plugin authors already know) — canonical paths stay src.adaptive_layout / src.adaptive_images so nothing breaks. Document it in src/common/README.md and cross-link ADAPTIVE_LAYOUT.md from the developer docs authors actually read (quick reference, API reference, advanced dev, font manager, dev preview, plugin dev guide); ADAPTIVE_LAYOUT.md gains adaptive-images, composite-layouts and preserving-user-customization sections. Compat: PluginLoader now logs one advisory warning (never raises) when a plugin's manifest declares a min LEDMatrix version newer than the running core, checking the min_ledmatrix_version / requires.* / versions[] spellings found in the wild. Guarded against stale core version numbers. src/__init__.py __version__ bumped 1.0.0 -> 3.1.0 to match the latest release tag (v3.1.0) — it had never been updated and the compat check needs a truthful number. NOTE: verify this matches the intended release numbering before the next tag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,71 @@ Built per (width, height); exposes facts and fit queries:
|
||||
`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))
|
||||
# regs.status_band — top band (replaces the magic y = 1)
|
||||
# regs.score_area — center region (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`.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Advanced patterns, examples, and best practices for developing LEDMatrix plugins.
|
||||
|
||||
> **Adaptive layout:** for plugins that should render legibly on any panel
|
||||
> size (fonts that grow on big panels, layouts that degrade gracefully on
|
||||
> small ones), use the adaptive layout system — `self.layout`, `draw_fit`,
|
||||
> `draw_image`, `scoreboard_regions` — documented in
|
||||
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Using Weather Icons](#using-weather-icons)
|
||||
|
||||
@@ -48,6 +48,12 @@ 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,6 +6,12 @@ Tools for rapid plugin development without deploying to the RPi.
|
||||
|
||||
Interactive web UI for tweaking plugin configs and seeing the rendered display in real time.
|
||||
|
||||
The size inputs have a preset dropdown with the harness's standard panel
|
||||
sizes, and the **All Sizes** button renders the current config at every
|
||||
harness size in a side-by-side gallery (`POST /api/render-matrix`) — the
|
||||
quickest way to eyeball adaptive-layout behavior across panels
|
||||
(see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)).
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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 (`self.layout.fit_text(...)`) which resolve through this
|
||||
> FontManager — see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Overview
|
||||
|
||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Complete API reference for plugin developers. This document describes all methods and properties available to plugins through the Display Manager, Cache Manager, and Plugin Manager.
|
||||
|
||||
> **Adaptive layout:** every `BasePlugin` also exposes `self.layout`,
|
||||
> `self.draw_fit(text, region)` and `self.draw_image(img, region, ...)` —
|
||||
> the recommended way to render text and images that scale to any panel
|
||||
> size. See [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [BasePlugin](#baseplugin)
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
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 are expected to read the display size
|
||||
> dynamically and lay themselves out for any panel. The adaptive layout
|
||||
> system ([ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)) provides the shared
|
||||
> helpers for that — fonts, images, and composite layouts that scale.
|
||||
|
||||
## Overview
|
||||
|
||||
When developing plugins in separate repositories, you need a way to:
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "3.1.0"
|
||||
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
|
||||
This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules.
|
||||
|
||||
## Adaptive Layout & Images (`src/adaptive_layout.py`, `src/adaptive_images.py`)
|
||||
|
||||
The recommended way to lay out plugins that render legibly on **any** panel
|
||||
size (64x32 through 256x128+) without hand-tuned coordinates. Re-exported
|
||||
from `src.common` for convenience; canonical import paths are
|
||||
`src.adaptive_layout` / `src.adaptive_images`.
|
||||
|
||||
```python
|
||||
# Every BasePlugin already has self.layout and the draw helpers:
|
||||
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
|
||||
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||
self.draw_fit(score_text, regs.score_area) # largest crisp font that fits
|
||||
self.draw_fit(status, regs.status_band)
|
||||
```
|
||||
|
||||
Key pieces: `Region` (rect algebra: bands/columns/splits/offset),
|
||||
font ladders (`LADDER_GRID`, `LADDER_ARCADE` — discrete crisp sizes, never
|
||||
fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`,
|
||||
`px`), and composite carvers `scoreboard_regions()` / `media_row()`.
|
||||
Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Error Handling (`error_handler.py`)
|
||||
|
||||
Common error handling patterns and utilities:
|
||||
|
||||
@@ -26,6 +26,31 @@ 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',
|
||||
@@ -37,4 +62,22 @@ __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',
|
||||
]
|
||||
|
||||
@@ -683,6 +683,55 @@ 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,
|
||||
@@ -715,6 +764,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:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user