mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
* feat(layout): adaptive layout & font scaling system for plugins Add src/adaptive_layout.py — opt-in core helpers so plugins render legibly on any panel size without hand-tuned per-display layouts: - Region: integer rect algebra (bands/columns/weighted splits/centering) that partitions space so text bands can't overlap by construction - Font ladders: ordered (family, size) steps known to render crisply (LADDER_GRID: X11 BDFs at native sizes; LADDER_ARCADE: PressStart2P at 8px multiples) — fitting walks the ladder instead of scaling pixel fonts fractionally - LayoutContext: breakpoint tiers, geometry scale vs. a declared design size, and cached fit_text/fit_lines/font_for_rows queries Generalizes the three patterns proven in the field: f1-scoreboard's scale factor, masters-tournament's tiers, baseball-scoreboard's font fallback ladder. Wiring: BasePlugin gains a lazy .layout property and draw_fit(); FontManager gains get_native_bdf_size() and a cache_generation counter; manifest schema gains display.design_size and requires.display_size max_width/max_height; 96x48 joins DEFAULT_TEST_SIZES; the bounds-check harness records negative-coordinate draws; TextHelper's broken measurement helpers are fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): adaptive image fitting + composite region helpers Add src/adaptive_images.py — the image counterpart to fit_text: - fit_image(img, box, mode=contain|cover|fill_height|stretch, crop_to_ink, anchor, resample, upscale) promoting the proven plugin patterns (football's crop-to-ink fill-height logos, masters' cover crop + NEAREST flags, static-image's letterbox). Upscales by default — thumbnail()'s downscale-only behavior is why imagery stays tiny on big panels. - draw_fitted_image() pastes aligned within a Region with alpha mask. - One central Pillow>=9.1 RESAMPLE shim replacing ~15 plugin copies. LayoutContext.fit_image() caches results per (identity, box size, options) with a 64-entry LRU; id()-keyed entries pin the source image. BasePlugin.draw_image() is the one-liner adoption path beside draw_fit. Composites in adaptive_layout.py: Region.offset() (user x/y-offset passthrough), scoreboard_regions() (the two-logos-plus-score card math duplicated across six sports plugins, logo_slot = min(H, W//2)), and media_row() (art-left/text-right). Fix LogoHelper's size-blind cache key (stale sizes on panel change); deprecation note on dead image_utils.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(harness): scale-up fill check, config variants, multi-size dev gallery Quality gates for adaptive layout: - fill_metrics()/check_scale_up() in the safety harness: overflow catches content too big for a panel, but nothing caught content that stays tiny on panels >= 2x the plugin's declared design size. The check measures lit-content extents and warns (or fails, when a plugin opts into "fill_check": "strict" in test/harness.json) below 50% coverage on the doubled axis. Warn-only by default so no existing plugin breaks. - harness.json "variants": extra runs with config overlays and their own golden dirs, so an opt-in mode (e.g. layout_mode: adaptive) is golden- tested beside the classic default. check_plugin.py loops base + variants and labels variant results mode@name. - Dev preview server: GET /api/sizes (harness size sample), POST /api/render-matrix (render at up to 12 sizes in one call), size-preset dropdown, and an "All Sizes" side-by-side gallery in the preview UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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> * feat(layout): add measure_font_crispness — verify a ladder rung isn't blurry PIL antialiases TTF outlines by default; a 'pixel-style' font only rasterizes without antialiasing at specific sizes (for PressStart2P: exact multiples of its 8px design grid). A ladder rung at an unverified size silently renders blurry on an LED panel — this exact bug shipped in both text-display's and football-scoreboard's custom TTF ladders (non-8-multiple PressStart2P sizes, and '5by7.regular'/'4x6-font' at sizes that were never actually crisp). measure_font_crispness(font, sample_text) renders the sample and reports the fraction of ink-bbox pixels that are neither pure black nor pure white. BDF fonts (real bitmaps) always score 0.0; TTF ladders should be verified against this before shipping — see the new TestFontFitting::test_ladder_arcade_is_crisp pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): add fit_text_proportional — proportional sizing vs. always-maximize fit_text always picks the largest ladder rung that fits its box. That's right when an element owns dedicated space, but wrong when several independently-fitted elements need to stay visually harmonious as the panel grows: a score's box might have generous room while a neighboring logo scales by a fixed geometry factor via px() — fit_text lets the score balloon out of proportion (even overlapping the logo) even though its individual pick is technically correct. fit_text_proportional(text, box, base_size_px, ladder) instead targets base_size_px * self.scale (the same scale factor px() already uses), picking the nearest ladder rung at or below that target, still capped to what fits the box, floored at the smallest rung when the target is below every rung. Refactored the shared largest-that-fits/ellipsize walk into _walk_ladder() so fit_text and fit_text_proportional don't duplicate it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): fit_text_proportional gains an axis-specific scale override self.scale (min(width_ratio, height_ratio)) is the right conservative default for anything whose aspect ratio matters, but a caller whose surrounding composition already scales along a single axis — e.g. football-scoreboard's logo_slot = min(height, width // 2), which tracks height alone — needs text sized the same way, or it reads as under-scaled next to logos that grew on a panel that only got taller (128x32 -> 128x64: self.scale stays 1.0 since width didn't grow, but logos still double). fit_text_proportional(..., scale=None) now accepts an explicit override; None keeps the existing self.scale default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(layout): scoreboard_regions reserves real center space at 2:1 aspect ratios logo_slot = min(height, width // 2) has a blind spot: at exactly 2:1 aspect ratio (width == 2 * height -- a very common shape: 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. Not a 'small panel' problem -- 96x48, 128x64, and 256x128 (all exactly 2:1) hit it identically, while the 128x32 design baseline and panels like 192x48 or 256x32 never do, because height is already the tighter constraint there. Two new parameters fix it in the one shared helper every scoreboard-style plugin composes through: - min_center_fraction / min_center_design_px reserve at least max(width * fraction, design_px * ctx.scale) for the center column, capping logo_slot further when needed. The scaled design-px term matters on small panels where a flat fraction alone reserves too little absolute space. - score_bleed_fraction extends the score's own fit box (not the logo slots themselves) a controlled amount into each side -- the same way real broadcast scoreboards let a big score number's edges cross into the team marks flanking it. Without this the reserve alone can still be too narrow for a short score to render without truncating. score_area is now genuinely narrower than the full card width (previously identical to status_band/detail_band, which still span the full width and overlay the logos -- short text there was never the problem). Verified against the full harness size spread: a real game score like '17-21' never needs ellipsis at any tested 2:1-or-tighter aspect ratio (test_score_never_needs_ellipsis_for_a_short_score), and wide panels (128x32/192x48/256x32-style) are provably unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document scoreboard_regions' center-reserve and score-bleed params Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address CodeRabbit review on PR #393 - docs: scope the self.layout note to BasePlugin subclasses (others build a LayoutContext directly) and make explicit that adaptive layout is opt-in — classic rendering stays unless a plugin adopts the APIs. - dev_server: broaden the render-request catch (a bad manifest.json now returns a clean 400 instead of an unhandled 500) and stop echoing raw exception text in the loader-failure responses — full tracebacks go to the dev server's console log instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): allowlist plugin_id before any path lookup CodeQL (py/path-injection): plugin_id arrives in request input and flows into filesystem paths via find_plugin_dir. Gate it with the same ^[a-zA-Z0-9_-]{1,64}$ allowlist the web UI's pages_v3 uses, at the single choke point every route resolves through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): lexical containment check on resolved plugin dirs CodeQL doesn't recognize the interprocedural allowlist as a path-injection barrier; add the canonical one — normalize (without following symlinks, since dev plugins are commonly symlinked into plugins/) and require the result to stay inside the search dir. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): inline normpath containment barrier before render CodeQL doesn't credit the sanitization inside find_plugin_dir along this flow; apply its documented barrier (normpath + startswith against the allowed roots) inline in _parse_render_request, on the exact path that reaches the render/load sinks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): derive plugin dir from trusted directory listings CodeQL's barrier-guard recognition doesn't see a startswith check inside an any() comprehension, so the normalize-and-prefix approach still flagged. Break the taint outright instead: after lookup, re-derive the directory by enumerating the search dirs (iterdir) and matching by path equality — the Path used for all downstream file access is built solely from trusted listings, never from request input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): use os.scandir for path-injection barrier, redact stack traces from render responses CodeQL doesn't model Path.iterdir() as a taint-clearing enumeration the way it does os.scandir() -- _trusted_plugin_dir's iterdir-based rebuild still traced plugin_id through to the manifest.json open(). Switched to scandir, matching the pattern already verified clean on PR #396. Also stops surfacing raw exception text (update()/display() failures) in the JSON render response -- logs full detail server-side via exc_info instead, returning only the exception class name to the client. And drops path values from three plugin_loader debug/error logs that CodeQL flags as clear-text-logging of externally-influenced data, keeping plugin_id (not flagged) for context. * fix(dev-server): remove conditional-reassignment ambiguity in plugin_dir resolution CodeQL's path-injection flow still traced through _parse_render_request after the scandir fix -- the tainted find_plugin_dir() result and the scandir-derived _trusted_plugin_dir() result shared the same variable name (plugin_dir), reassigned only on the truthy branch. That merge point apparently isn't treated as a barrier by the flow analysis, so it kept tracing the pre-reassignment value through to the manifest open(). Split into two distinct names -- candidate_dir (tainted, used only to call _trusted_plugin_dir) and trusted_dir (the only name used for any downstream file access) -- so there's no reassigned variable for the flow to walk through. * fix: remove unused imports flagged by Codacy Union in adaptive_images.py and field in adaptive_layout.py are both imported but never used -- the last two Codacy findings on this PR, matching the same fix already applied on PR #396. * fix(layout): bound the fit cache; never alias the source image in fits Two latent issues found in a self-review pass: - LayoutContext._fit_cache was an unbounded dict (the image cache got an LRU cap, the text-fit cache didn't). Cache keys embed the fitted TEXT, so a plugin fitting changing strings — a live game clock, a ticker — on a 24/7 service grows it forever. Now LRU-bounded at 512 entries via the same pattern as the image cache. - fit_image returned the caller's ORIGINAL image object when the source was already RGBA at target size (contain/fill_height, no ink crop). ImageFitResult is documented as an independent copy, and LayoutContext caches results — an aliased image lets later mutations of the source corrupt cached fits (or vice versa). Copy in that branch. Both covered by new regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
952 lines
26 KiB
Markdown
952 lines
26 KiB
Markdown
# Plugin API Reference
|
|
|
|
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)
|
|
- [Display Manager](#display-manager)
|
|
- [Cache Manager](#cache-manager)
|
|
- [Plugin Manager](#plugin-manager)
|
|
|
|
---
|
|
|
|
## BasePlugin
|
|
|
|
All plugins must inherit from `BasePlugin` and implement the required methods. The base class provides access to managers and common functionality.
|
|
|
|
### Available Properties
|
|
|
|
```python
|
|
self.plugin_id # Plugin identifier (string)
|
|
self.config # Plugin configuration dictionary
|
|
self.display_manager # DisplayManager instance
|
|
self.cache_manager # CacheManager instance
|
|
self.plugin_manager # PluginManager instance
|
|
self.logger # Plugin-specific logger
|
|
self.enabled # Boolean enabled status
|
|
```
|
|
|
|
### Required Methods
|
|
|
|
#### `update() -> None`
|
|
|
|
Fetch/update data for this plugin. Called based on `update_interval` specified in the plugin's manifest.
|
|
|
|
**Example**:
|
|
```python
|
|
def update(self):
|
|
cache_key = f"{self.plugin_id}_data"
|
|
cached = self.cache_manager.get(cache_key, max_age=3600)
|
|
if cached:
|
|
self.data = cached
|
|
return
|
|
|
|
self.data = self._fetch_from_api()
|
|
self.cache_manager.set(cache_key, self.data)
|
|
```
|
|
|
|
#### `display(force_clear: bool = False) -> None`
|
|
|
|
Render this plugin's display. Called during display rotation or when explicitly requested.
|
|
|
|
**Parameters**:
|
|
- `force_clear` (bool): If True, clear display before rendering
|
|
|
|
**Example**:
|
|
```python
|
|
def display(self, force_clear=False):
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
|
|
self.display_manager.draw_text(
|
|
"Hello, World!",
|
|
x=5, y=15,
|
|
color=(255, 255, 255)
|
|
)
|
|
|
|
self.display_manager.update_display()
|
|
```
|
|
|
|
### Optional Methods
|
|
|
|
#### `validate_config() -> bool`
|
|
|
|
Validate plugin configuration. Override to implement custom validation.
|
|
|
|
**Returns**: `True` if config is valid, `False` otherwise
|
|
|
|
#### `has_live_content() -> bool`
|
|
|
|
Check if plugin currently has live content. Override for live priority plugins.
|
|
|
|
**Returns**: `True` if plugin has live content
|
|
|
|
#### `get_live_modes() -> List[str]`
|
|
|
|
Get list of display modes to show during live priority takeover.
|
|
|
|
**Returns**: List of mode names
|
|
|
|
#### `cleanup() -> None`
|
|
|
|
Clean up resources when plugin is unloaded. Override to close connections, stop threads, etc.
|
|
|
|
#### `on_config_change(new_config: Dict[str, Any]) -> None`
|
|
|
|
Called after plugin configuration is updated via web API.
|
|
|
|
#### `on_enable() -> None`
|
|
|
|
Called when plugin is enabled.
|
|
|
|
#### `on_disable() -> None`
|
|
|
|
Called when plugin is disabled.
|
|
|
|
#### `get_display_duration() -> float`
|
|
|
|
Get display duration for this plugin. Can be overridden for dynamic durations.
|
|
|
|
**Returns**: Duration in seconds
|
|
|
|
#### `get_info() -> Dict[str, Any]`
|
|
|
|
Return plugin info for display in web UI. Override to provide additional state information.
|
|
|
|
### Dynamic-duration hooks
|
|
|
|
Plugins that render multi-step content (e.g. cycling through several games)
|
|
can extend their display time until they've shown everything. To opt in,
|
|
either set `dynamic_duration.enabled: true` in the plugin's config or
|
|
override `supports_dynamic_duration()`.
|
|
|
|
#### `supports_dynamic_duration() -> bool`
|
|
|
|
Return `True` if this plugin should use dynamic durations. Default reads
|
|
`config["dynamic_duration"]["enabled"]`.
|
|
|
|
#### `get_dynamic_duration_cap() -> Optional[float]`
|
|
|
|
Maximum number of seconds the controller will keep this plugin on screen
|
|
in dynamic mode. Default reads
|
|
`config["dynamic_duration"]["max_duration_seconds"]`.
|
|
|
|
#### `is_cycle_complete() -> bool`
|
|
|
|
Override this to return `True` only after the plugin has rendered all of
|
|
its content for the current rotation. Default returns `True` immediately,
|
|
which means a single `display()` call counts as a full cycle.
|
|
|
|
#### `reset_cycle_state() -> None`
|
|
|
|
Called by the controller before each new dynamic-duration session. Reset
|
|
internal counters/iterators here.
|
|
|
|
### Live priority hooks
|
|
|
|
Live priority lets a plugin temporarily take over the rotation when it has
|
|
urgent content (live games, breaking news). Enable by setting
|
|
`live_priority: true` in the plugin's config and overriding
|
|
`has_live_content()`.
|
|
|
|
#### `has_live_priority() -> bool`
|
|
|
|
Whether live priority is enabled in config (default reads
|
|
`config["live_priority"]`).
|
|
|
|
#### `has_live_content() -> bool`
|
|
|
|
Override to return `True` when the plugin currently has urgent content.
|
|
Default returns `False`.
|
|
|
|
#### `get_live_modes() -> List[str]`
|
|
|
|
List of display modes to show during a live takeover. Default returns the
|
|
plugin's `display_modes` from its manifest.
|
|
|
|
### Vegas scroll hooks
|
|
|
|
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
|
rotating one at a time. Plugins control how their content appears via
|
|
these hooks. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for the user
|
|
side of Vegas mode.
|
|
|
|
#### `get_vegas_content() -> Optional[PIL.Image | List[PIL.Image] | None]`
|
|
|
|
Return content to inject into the scroll. Multi-item plugins (sports,
|
|
odds, news) should return a *list* of PIL Images so each item scrolls
|
|
independently. Static plugins (clock, weather) can return a single image.
|
|
Returning `None` falls back to capturing whatever `display()` produces.
|
|
|
|
#### `get_vegas_content_type() -> str`
|
|
|
|
`'multi'`, `'static'`, or `'none'`. Affects how Vegas mode treats the
|
|
plugin. Default `'static'`.
|
|
|
|
#### `get_vegas_display_mode() -> VegasDisplayMode`
|
|
|
|
Returns one of `VegasDisplayMode.SCROLL`, `FIXED_SEGMENT`, or `STATIC`.
|
|
Read from `config["vegas_mode"]` or override directly.
|
|
|
|
#### `get_supported_vegas_modes() -> List[VegasDisplayMode]`
|
|
|
|
The set of Vegas modes this plugin can render. Used by the UI to populate
|
|
the mode selector for this plugin.
|
|
|
|
#### `get_vegas_segment_width() -> Optional[int]`
|
|
|
|
For `FIXED_SEGMENT` plugins, the width in pixels of the segment they
|
|
occupy in the scroll. `None` lets the controller pick a default.
|
|
|
|
> The full source for `BasePlugin` lives in
|
|
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
|
> source, the source wins — please open an issue or PR to fix the doc.
|
|
|
|
---
|
|
|
|
## Display Manager
|
|
|
|
The Display Manager handles all rendering operations on the LED matrix. Available as `self.display_manager` in plugins.
|
|
|
|
### Properties
|
|
|
|
```python
|
|
display_manager.width # Display width in pixels (int)
|
|
display_manager.height # Display height in pixels (int)
|
|
```
|
|
|
|
### Core Methods
|
|
|
|
#### `clear() -> None`
|
|
|
|
Clear the display completely. Creates a new black image.
|
|
|
|
**Note**: Does not call `update_display()` automatically. Call `update_display()` after drawing new content.
|
|
|
|
**Example**:
|
|
```python
|
|
self.display_manager.clear()
|
|
# Draw new content...
|
|
self.display_manager.update_display()
|
|
```
|
|
|
|
#### `update_display() -> None`
|
|
|
|
Update the physical display using double buffering. Call this after drawing all content.
|
|
|
|
**Example**:
|
|
```python
|
|
self.display_manager.draw_text("Hello", x=10, y=10)
|
|
self.display_manager.update_display() # Actually show on display
|
|
```
|
|
|
|
### Text Rendering
|
|
|
|
#### `draw_text(text: str, x: int = None, y: int = None, color: tuple = (255, 255, 255), small_font: bool = False, font: ImageFont = None, centered: bool = False) -> None`
|
|
|
|
Draw text on the canvas.
|
|
|
|
**Parameters**:
|
|
- `text` (str): Text to display
|
|
- `x` (int, optional): X position. If `None`, text is centered horizontally. If `centered=True`, x is treated as center point.
|
|
- `y` (int, optional): Y position (default: 0, top of display)
|
|
- `color` (tuple): RGB color tuple (default: white)
|
|
- `small_font` (bool): Use small font if True
|
|
- `font` (ImageFont, optional): Custom font object (overrides small_font)
|
|
- `centered` (bool): If True, x is treated as center point; if False, x is left edge
|
|
|
|
**Example**:
|
|
```python
|
|
# Centered text
|
|
self.display_manager.draw_text("Hello", color=(255, 255, 0))
|
|
|
|
# Left-aligned at specific position
|
|
self.display_manager.draw_text("World", x=10, y=20, color=(0, 255, 0))
|
|
|
|
# Centered at specific x position
|
|
self.display_manager.draw_text("Center", x=64, y=16, centered=True)
|
|
```
|
|
|
|
#### `get_text_width(text: str, font) -> int`
|
|
|
|
Get the width of text when rendered with the given font.
|
|
|
|
**Parameters**:
|
|
- `text` (str): Text to measure
|
|
- `font`: Font object (ImageFont or freetype.Face)
|
|
|
|
**Returns**: Width in pixels
|
|
|
|
**Example**:
|
|
```python
|
|
width = self.display_manager.get_text_width("Hello", self.display_manager.regular_font)
|
|
x = (self.display_manager.width - width) // 2 # Center text
|
|
```
|
|
|
|
#### `get_font_height(font) -> int`
|
|
|
|
Get the height of the given font for line spacing purposes.
|
|
|
|
**Parameters**:
|
|
- `font`: Font object (ImageFont or freetype.Face)
|
|
|
|
**Returns**: Height in pixels
|
|
|
|
**Example**:
|
|
```python
|
|
font_height = self.display_manager.get_font_height(self.display_manager.regular_font)
|
|
y = 10 + font_height # Position next line
|
|
```
|
|
|
|
#### `format_date_with_ordinal(dt: datetime) -> str`
|
|
|
|
Format a datetime object into 'Mon Aug 30th' style with ordinal suffix.
|
|
|
|
**Parameters**:
|
|
- `dt`: datetime object
|
|
|
|
**Returns**: Formatted date string
|
|
|
|
**Example**:
|
|
```python
|
|
from datetime import datetime
|
|
date_str = self.display_manager.format_date_with_ordinal(datetime.now())
|
|
# Returns: "Jan 15th"
|
|
```
|
|
|
|
### Image Rendering
|
|
|
|
The display manager doesn't provide a dedicated `draw_image()` method.
|
|
Instead, plugins paste directly onto the underlying PIL Image
|
|
(`display_manager.image`), then call `update_display()` to push the buffer
|
|
to the matrix.
|
|
|
|
```python
|
|
from PIL import Image
|
|
|
|
logo = Image.open("assets/logo.png").convert("RGB")
|
|
self.display_manager.image.paste(logo, (10, 10))
|
|
self.display_manager.update_display()
|
|
```
|
|
|
|
For transparency support, paste using a mask:
|
|
|
|
```python
|
|
icon = Image.open("assets/icon.png").convert("RGBA")
|
|
self.display_manager.image.paste(icon, (5, 5), icon)
|
|
self.display_manager.update_display()
|
|
```
|
|
|
|
This is the same pattern the bundled scoreboard base classes
|
|
(`src/base_classes/baseball.py`, `basketball.py`, `football.py`,
|
|
`hockey.py`) use, so it's the canonical way to render arbitrary images.
|
|
|
|
### Weather Icons
|
|
|
|
#### `draw_weather_icon(condition: str, x: int, y: int, size: int = 16) -> None`
|
|
|
|
Draw a weather icon based on the condition string.
|
|
|
|
**Parameters**:
|
|
- `condition` (str): Weather condition (e.g., "clear", "cloudy", "rain", "snow", "storm")
|
|
- `x` (int): X position
|
|
- `y` (int): Y position
|
|
- `size` (int): Icon size in pixels (default: 16)
|
|
|
|
**Supported Conditions**:
|
|
- `"clear"`, `"sunny"` → Sun icon
|
|
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
|
|
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
|
|
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
|
|
- `"thunderstorm"`, `"storm"` → Storm icon
|
|
|
|
**Example**:
|
|
```python
|
|
self.display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
|
```
|
|
|
|
#### `draw_sun(x: int, y: int, size: int = 16) -> None`
|
|
|
|
Draw a sun icon with rays.
|
|
|
|
**Parameters**:
|
|
- `x` (int): X position
|
|
- `y` (int): Y position
|
|
- `size` (int): Icon size (default: 16)
|
|
|
|
#### `draw_cloud(x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)) -> None`
|
|
|
|
Draw a cloud icon.
|
|
|
|
**Parameters**:
|
|
- `x` (int): X position
|
|
- `y` (int): Y position
|
|
- `size` (int): Icon size (default: 16)
|
|
- `color` (tuple): RGB color (default: light gray)
|
|
|
|
#### `draw_rain(x: int, y: int, size: int = 16) -> None`
|
|
|
|
Draw rain icon with cloud and droplets.
|
|
|
|
#### `draw_snow(x: int, y: int, size: int = 16) -> None`
|
|
|
|
Draw snow icon with cloud and snowflakes.
|
|
|
|
#### `draw_text_with_icons(text: str, icons: List[tuple] = None, x: int = None, y: int = None, color: tuple = (255, 255, 255)) -> None`
|
|
|
|
Draw text with weather icons at specified positions.
|
|
|
|
**Parameters**:
|
|
- `text` (str): Text to display
|
|
- `icons` (List[tuple], optional): List of (icon_type, x, y) tuples
|
|
- `x` (int, optional): X position for text
|
|
- `y` (int, optional): Y position for text
|
|
- `color` (tuple): Text color
|
|
|
|
**Note**: Automatically calls `update_display()` after drawing.
|
|
|
|
**Example**:
|
|
```python
|
|
icons = [
|
|
("sun", 5, 5),
|
|
("cloud", 100, 5)
|
|
]
|
|
self.display_manager.draw_text_with_icons(
|
|
"Weather: Sunny, Cloudy",
|
|
icons=icons,
|
|
x=10, y=20
|
|
)
|
|
```
|
|
|
|
### Scrolling State Management
|
|
|
|
For plugins that implement scrolling content, use these methods to coordinate with the display system.
|
|
|
|
#### `set_scrolling_state(is_scrolling: bool) -> None`
|
|
|
|
Mark the display as scrolling or not scrolling. Call when scrolling starts/stops.
|
|
|
|
**Parameters**:
|
|
- `is_scrolling` (bool): True if currently scrolling, False otherwise
|
|
|
|
**Example**:
|
|
```python
|
|
def display(self, force_clear=False):
|
|
self.display_manager.set_scrolling_state(True)
|
|
# Scroll content...
|
|
self.display_manager.set_scrolling_state(False)
|
|
```
|
|
|
|
#### `is_currently_scrolling() -> bool`
|
|
|
|
Check if the display is currently in a scrolling state.
|
|
|
|
**Returns**: `True` if scrolling, `False` otherwise
|
|
|
|
#### `defer_update(update_func: Callable, priority: int = 0) -> None`
|
|
|
|
Defer an update function to be called when not scrolling. Useful for non-critical updates that should wait until scrolling completes.
|
|
|
|
**Parameters**:
|
|
- `update_func`: Function to call when not scrolling
|
|
- `priority` (int): Priority level (lower numbers = higher priority, default: 0)
|
|
|
|
**Example**:
|
|
```python
|
|
def update(self):
|
|
# Critical update - do immediately
|
|
self.fetch_data()
|
|
|
|
# Non-critical update - defer until not scrolling
|
|
self.display_manager.defer_update(
|
|
lambda: self.update_cache_metadata(),
|
|
priority=1
|
|
)
|
|
```
|
|
|
|
#### `process_deferred_updates() -> None`
|
|
|
|
Process any deferred updates if not currently scrolling. Called automatically by the display controller, but can be called manually if needed.
|
|
|
|
**Note**: Plugins typically don't need to call this directly.
|
|
|
|
#### `get_scrolling_stats() -> dict`
|
|
|
|
Get current scrolling statistics for debugging.
|
|
|
|
**Returns**: Dictionary with scrolling state information
|
|
|
|
**Example**:
|
|
```python
|
|
stats = self.display_manager.get_scrolling_stats()
|
|
self.logger.debug(f"Scrolling: {stats['is_scrolling']}, Deferred: {stats['deferred_count']}")
|
|
```
|
|
|
|
### Available Fonts
|
|
|
|
The Display Manager provides several pre-loaded fonts:
|
|
|
|
```python
|
|
display_manager.regular_font # Press Start 2P, size 8
|
|
display_manager.small_font # Press Start 2P, size 8
|
|
display_manager.calendar_font # 5x7 BDF font
|
|
display_manager.extra_small_font # 4x6 TTF font, size 6
|
|
display_manager.bdf_5x7_font # Alias for calendar_font
|
|
```
|
|
|
|
---
|
|
|
|
## Cache Manager
|
|
|
|
The Cache Manager handles data caching to reduce API calls and improve performance. Available as `self.cache_manager` in plugins.
|
|
|
|
### Basic Methods
|
|
|
|
#### `get(key: str, max_age: int = 300) -> Optional[Dict[str, Any]]`
|
|
|
|
Get data from cache if it exists and is not stale.
|
|
|
|
**Parameters**:
|
|
- `key` (str): Cache key
|
|
- `max_age` (int): Maximum age in seconds (default: 300)
|
|
|
|
**Returns**: Cached data dictionary, or `None` if not found or stale
|
|
|
|
**Example**:
|
|
```python
|
|
cached = self.cache_manager.get("weather_data", max_age=600)
|
|
if cached:
|
|
return cached
|
|
```
|
|
|
|
#### `set(key: str, data: Dict[str, Any], ttl: Optional[int] = None) -> None`
|
|
|
|
Store data in cache with current timestamp.
|
|
|
|
**Parameters**:
|
|
- `key` (str): Cache key
|
|
- `data` (Dict): Data to cache
|
|
- `ttl` (int, optional): Time-to-live in seconds (for compatibility)
|
|
|
|
**Example**:
|
|
```python
|
|
self.cache_manager.set("weather_data", {
|
|
"temp": 72,
|
|
"condition": "sunny"
|
|
})
|
|
```
|
|
|
|
#### `clear_cache(key: Optional[str] = None) -> None`
|
|
|
|
Remove a specific cache entry, or all cache entries when called without
|
|
arguments.
|
|
|
|
**Parameters**:
|
|
- `key` (str, optional): Cache key to delete. If omitted, every cached
|
|
entry (memory + disk) is cleared.
|
|
|
|
**Example**:
|
|
```python
|
|
# Drop one stale entry
|
|
self.cache_manager.clear_cache("weather_data")
|
|
|
|
# Nuke everything (rare — typically only used by maintenance tooling)
|
|
self.cache_manager.clear_cache()
|
|
```
|
|
|
|
### Advanced Methods
|
|
|
|
#### `get_cached_data(key: str, max_age: int = 300, memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]`
|
|
|
|
Get data from cache with separate memory and disk TTLs.
|
|
|
|
**Parameters**:
|
|
- `key` (str): Cache key
|
|
- `max_age` (int): TTL for persisted (on-disk) entry
|
|
- `memory_ttl` (int, optional): TTL for in-memory entry (defaults to max_age)
|
|
|
|
**Returns**: Cached data, or `None` if not found or stale
|
|
|
|
**Example**:
|
|
```python
|
|
# Use memory cache for 60 seconds, disk cache for 1 hour
|
|
data = self.cache_manager.get_cached_data(
|
|
"api_response",
|
|
max_age=3600,
|
|
memory_ttl=60
|
|
)
|
|
```
|
|
|
|
#### `get_cached_data_with_strategy(key: str, data_type: str = 'default') -> Optional[Dict[str, Any]]`
|
|
|
|
Get data using data-type-specific cache strategy. Automatically selects appropriate TTL based on data type.
|
|
|
|
**Parameters**:
|
|
- `key` (str): Cache key
|
|
- `data_type` (str): Data type for strategy selection (e.g., 'weather', 'sports_live', 'stocks')
|
|
|
|
**Returns**: Cached data, or `None` if not found or stale
|
|
|
|
**Example**:
|
|
```python
|
|
# Automatically uses appropriate cache duration for weather data
|
|
weather = self.cache_manager.get_cached_data_with_strategy(
|
|
"weather_current",
|
|
data_type="weather"
|
|
)
|
|
```
|
|
|
|
#### `get_with_auto_strategy(key: str) -> Optional[Dict[str, Any]]`
|
|
|
|
Get data with automatic strategy detection from cache key.
|
|
|
|
**Parameters**:
|
|
- `key` (str): Cache key (strategy inferred from key name)
|
|
|
|
**Returns**: Cached data, or `None` if not found or stale
|
|
|
|
**Example**:
|
|
```python
|
|
# Strategy automatically detected from key name
|
|
data = self.cache_manager.get_with_auto_strategy("nhl_live_scores")
|
|
```
|
|
|
|
#### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]`
|
|
|
|
Get background service cached data with sport-specific intervals.
|
|
|
|
**Parameters**:
|
|
- `key` (str): Cache key
|
|
- `sport_key` (str, optional): Sport identifier (e.g., 'nhl', 'nba') for live interval lookup
|
|
|
|
**Returns**: Cached data, or `None` if not found or stale
|
|
|
|
**Example**:
|
|
```python
|
|
# Uses sport-specific live_update_interval from config
|
|
games = self.cache_manager.get_background_cached_data(
|
|
"nhl_games",
|
|
sport_key="nhl"
|
|
)
|
|
```
|
|
|
|
### Strategy Methods
|
|
|
|
#### `get_cache_strategy(data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]`
|
|
|
|
Get cache strategy configuration for a data type.
|
|
|
|
**Parameters**:
|
|
- `data_type` (str): Data type (e.g., 'weather', 'sports_live', 'stocks')
|
|
- `sport_key` (str, optional): Sport identifier for sport-specific strategies
|
|
|
|
**Returns**: Dictionary with strategy configuration (max_age, memory_ttl, etc.)
|
|
|
|
**Example**:
|
|
```python
|
|
strategy = self.cache_manager.get_cache_strategy("sports_live", sport_key="nhl")
|
|
max_age = strategy['max_age'] # Get configured max age
|
|
```
|
|
|
|
#### `get_sport_live_interval(sport_key: str) -> int`
|
|
|
|
Get the live_update_interval for a specific sport from config.
|
|
|
|
**Parameters**:
|
|
- `sport_key` (str): Sport identifier (e.g., 'nhl', 'nba')
|
|
|
|
**Returns**: Live update interval in seconds
|
|
|
|
**Example**:
|
|
```python
|
|
interval = self.cache_manager.get_sport_live_interval("nhl")
|
|
# Returns configured live_update_interval for NHL
|
|
```
|
|
|
|
#### `get_data_type_from_key(key: str) -> str`
|
|
|
|
Extract data type from cache key to determine appropriate cache strategy.
|
|
|
|
**Parameters**:
|
|
- `key` (str): Cache key
|
|
|
|
**Returns**: Inferred data type string
|
|
|
|
#### `get_sport_key_from_cache_key(key: str) -> Optional[str]`
|
|
|
|
Extract sport key from cache key for sport-specific strategies.
|
|
|
|
**Parameters**:
|
|
- `key` (str): Cache key
|
|
|
|
**Returns**: Sport identifier, or `None` if not found
|
|
|
|
### Utility Methods
|
|
|
|
#### `clear_cache(key: Optional[str] = None) -> None`
|
|
|
|
Clear cache for a specific key or all keys.
|
|
|
|
**Parameters**:
|
|
- `key` (str, optional): Specific key to clear. If `None`, clears all cache.
|
|
|
|
**Example**:
|
|
```python
|
|
# Clear specific key
|
|
self.cache_manager.clear_cache("weather_data")
|
|
|
|
# Clear all cache
|
|
self.cache_manager.clear_cache()
|
|
```
|
|
|
|
#### `get_cache_dir() -> Optional[str]`
|
|
|
|
Get the cache directory path.
|
|
|
|
**Returns**: Cache directory path string, or `None` if not available
|
|
|
|
#### `list_cache_files() -> List[Dict[str, Any]]`
|
|
|
|
List all cache files with metadata.
|
|
|
|
**Returns**: List of dictionaries with cache file information (key, age, size, path, etc.)
|
|
|
|
**Example**:
|
|
```python
|
|
files = self.cache_manager.list_cache_files()
|
|
for file_info in files:
|
|
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
|
|
```
|
|
|
|
### Metrics Methods
|
|
|
|
#### `get_cache_metrics() -> Dict[str, Any]`
|
|
|
|
Get cache performance metrics.
|
|
|
|
**Returns**: Dictionary with cache statistics (hits, misses, hit rate, etc.)
|
|
|
|
**Example**:
|
|
```python
|
|
metrics = self.cache_manager.get_cache_metrics()
|
|
self.logger.info(f"Cache hit rate: {metrics['hit_rate']:.2%}")
|
|
```
|
|
|
|
#### `get_memory_cache_stats() -> Dict[str, Any]`
|
|
|
|
Get memory cache statistics.
|
|
|
|
**Returns**: Dictionary with memory cache stats (size, max_size, etc.)
|
|
|
|
---
|
|
|
|
## Plugin Manager
|
|
|
|
The Plugin Manager provides access to other plugins and plugin system information. Available as `self.plugin_manager` in plugins.
|
|
|
|
### Methods
|
|
|
|
#### `get_plugin(plugin_id: str) -> Optional[Any]`
|
|
|
|
Get a plugin instance by ID.
|
|
|
|
**Parameters**:
|
|
- `plugin_id` (str): Plugin identifier
|
|
|
|
**Returns**: Plugin instance, or `None` if not found
|
|
|
|
**Example**:
|
|
```python
|
|
weather_plugin = self.plugin_manager.get_plugin("weather")
|
|
if weather_plugin:
|
|
# Access weather plugin data
|
|
pass
|
|
```
|
|
|
|
#### `get_all_plugins() -> Dict[str, Any]`
|
|
|
|
Get all loaded plugin instances.
|
|
|
|
**Returns**: Dictionary mapping plugin_id to plugin instance
|
|
|
|
**Example**:
|
|
```python
|
|
all_plugins = self.plugin_manager.get_all_plugins()
|
|
for plugin_id, plugin in all_plugins.items():
|
|
self.logger.info(f"Plugin {plugin_id} is loaded")
|
|
```
|
|
|
|
#### `get_enabled_plugins() -> List[str]`
|
|
|
|
Get list of enabled plugin IDs.
|
|
|
|
**Returns**: List of plugin identifier strings
|
|
|
|
#### `get_plugin_info(plugin_id: str) -> Optional[Dict[str, Any]]`
|
|
|
|
Get plugin information including manifest and runtime info.
|
|
|
|
**Parameters**:
|
|
- `plugin_id` (str): Plugin identifier
|
|
|
|
**Returns**: Dictionary with plugin information, or `None` if not found
|
|
|
|
**Example**:
|
|
```python
|
|
info = self.plugin_manager.get_plugin_info("weather")
|
|
if info:
|
|
self.logger.info(f"Plugin: {info['name']}, Version: {info.get('version')}")
|
|
```
|
|
|
|
#### `get_all_plugin_info() -> List[Dict[str, Any]]`
|
|
|
|
Get information for all plugins.
|
|
|
|
**Returns**: List of plugin information dictionaries
|
|
|
|
#### `get_plugin_directory(plugin_id: str) -> Optional[str]`
|
|
|
|
Get the directory path for a plugin.
|
|
|
|
**Parameters**:
|
|
- `plugin_id` (str): Plugin identifier
|
|
|
|
**Returns**: Directory path string, or `None` if not found
|
|
|
|
#### `get_plugin_display_modes(plugin_id: str) -> List[str]`
|
|
|
|
Get list of display modes for a plugin.
|
|
|
|
**Parameters**:
|
|
- `plugin_id` (str): Plugin identifier
|
|
|
|
**Returns**: List of display mode names
|
|
|
|
**Example**:
|
|
```python
|
|
modes = self.plugin_manager.get_plugin_display_modes("football-scoreboard")
|
|
# Returns: ['nfl_live', 'nfl_recent', 'nfl_upcoming', ...]
|
|
```
|
|
|
|
### Plugin Manifests
|
|
|
|
Access plugin manifests through `self.plugin_manager.plugin_manifests`:
|
|
|
|
```python
|
|
# Get manifest for a plugin
|
|
manifest = self.plugin_manager.plugin_manifests.get(self.plugin_id, {})
|
|
|
|
# Access manifest fields
|
|
display_modes = manifest.get('display_modes', [])
|
|
version = manifest.get('version')
|
|
```
|
|
|
|
### Inter-Plugin Communication
|
|
|
|
Plugins can communicate with each other through the Plugin Manager:
|
|
|
|
**Example - Getting data from another plugin**:
|
|
```python
|
|
def update(self):
|
|
# Get weather plugin
|
|
weather_plugin = self.plugin_manager.get_plugin("weather")
|
|
if weather_plugin and hasattr(weather_plugin, 'current_temp'):
|
|
self.temp = weather_plugin.current_temp
|
|
```
|
|
|
|
**Example - Checking if another plugin is enabled**:
|
|
```python
|
|
enabled_plugins = self.plugin_manager.get_enabled_plugins()
|
|
if "weather" in enabled_plugins:
|
|
# Weather plugin is enabled
|
|
pass
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
### Caching
|
|
|
|
1. **Use appropriate cache keys**: Include plugin ID and data type in keys
|
|
```python
|
|
cache_key = f"{self.plugin_id}_weather_current"
|
|
```
|
|
|
|
2. **Use cache strategies**: Prefer `get_cached_data_with_strategy()` for automatic TTL selection
|
|
```python
|
|
data = self.cache_manager.get_cached_data_with_strategy(
|
|
f"{self.plugin_id}_data",
|
|
data_type="weather"
|
|
)
|
|
```
|
|
|
|
3. **Handle cache misses**: Always check for `None` return values
|
|
```python
|
|
cached = self.cache_manager.get(key, max_age=3600)
|
|
if not cached:
|
|
cached = self._fetch_from_api()
|
|
self.cache_manager.set(key, cached)
|
|
```
|
|
|
|
### Display Rendering
|
|
|
|
1. **Always call update_display()**: After drawing content, call `update_display()`
|
|
```python
|
|
self.display_manager.draw_text("Hello", x=10, y=10)
|
|
self.display_manager.update_display() # Required!
|
|
```
|
|
|
|
2. **Use clear() appropriately**: Only clear when necessary (e.g., `force_clear=True`)
|
|
```python
|
|
def display(self, force_clear=False):
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
# Draw content...
|
|
self.display_manager.update_display()
|
|
```
|
|
|
|
3. **Handle scrolling state**: If your plugin scrolls, use scrolling state methods
|
|
```python
|
|
self.display_manager.set_scrolling_state(True)
|
|
# Scroll content...
|
|
self.display_manager.set_scrolling_state(False)
|
|
```
|
|
|
|
### Error Handling
|
|
|
|
1. **Log errors appropriately**: Use `self.logger` for plugin-specific logging
|
|
```python
|
|
try:
|
|
data = self._fetch_data()
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to fetch data: {e}")
|
|
return
|
|
```
|
|
|
|
2. **Handle missing data gracefully**: Provide fallback displays when data is unavailable
|
|
```python
|
|
if not self.data:
|
|
self.display_manager.draw_text("No data available", x=10, y=16)
|
|
self.display_manager.update_display()
|
|
return
|
|
```
|
|
|
|
---
|
|
|
|
## See Also
|
|
|
|
- [BasePlugin Source](../src/plugin_system/base_plugin.py) - Base plugin implementation
|
|
- [Display Manager Source](../src/display_manager.py) - Display manager implementation
|
|
- [Cache Manager Source](../src/cache_manager.py) - Cache manager implementation
|
|
- [Plugin Manager Source](../src/plugin_system/plugin_manager.py) - Plugin manager implementation
|
|
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete development guide
|
|
- [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples
|
|
|