mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-17 12:29:13 +00:00
* feat(ipc)!: remove the cache-key mailboxes (control socket stage 5) The control socket is now the only way the web interface sends the display a command. The display stops reading display_on_demand_request and plugin_error_clear_request, and the web interface stops writing them. - Display: no mailbox poll (MailboxWatch, the 1 s / 0.25 s cadence, _consume_on_demand_request, the deprecation log) and no persisted display_on_demand_processed_id guard; the error publisher reads no clear request. CacheManager.file_signature and MailboxWatch are removed. - A write to either retired key is dropped by CacheManager.save_cache and logged once per writer, naming the plugin from the call stack (or the request's plugin_id), with the API to move to. - Web: on-demand start with no display listening starts the service (when start_service) and sends the request again once the socket answers (45 s, 10 s for a running service without a socket yet); every other failure is a 503 (400 for invalid_args). Stop answers 503 when no display listens, unless stop_service. errors/clear answers 503 with a reason-specific message instead of writing a request; clear_pending is always false. src.ipc.client.should_fall_back is replaced by display_not_listening. - Kept: display_current_state, display_on_demand_state, plugin_runtime_snapshot and the heartbeat (read whenever the socket cannot answer), and display_on_demand_config (the display's resume record). Tests: mailbox-only tests removed (test_on_demand_mailbox.py, the mailbox cadence, file_signature and MailboxWatch tests); tests that injected requests through the mailbox now use the socket queue or a plugin's in-process request. The run-loop harness sends on-demand requests over its fake control socket, so four golden traces change: on-demand starts and stops land at the request instant instead of the next 0.25 s mailbox look (one frame fewer on the screen they end), and in vegas.json within one frame instead of 263 ms, which shifts the later 1 s-throttled WiFi-notice check by under a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the on-demand routes' other callers ack over the socket, not the mailbox test_api_v3_bool_coercion, test_api_v3_lazy_plugin_discovery and test_web_api's stop test checked the mailbox write; they now get an ack from a mocked control socket. The stop route touches no manager any more, so it leaves the removed-catch-all sample. A request that was sent is never 'not listening' (pins the sent check the mutation run let through). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): a start that waits for the display answers 202 and is delivered in the background The start route held a request open for up to 45 s while a cold-started display loaded its plugins; the MQTT bridge (15 s timeout) and browsers reported a failure for a request that was then delivered. Now, when no display is listening, the route starts the service if asked and answers 202 with status "starting" at once. A single worker in the web process (web_interface/on_demand_dispatch.py) sends the request until the display acknowledges it or the wait runs out (45 s cold start, 10 s for a running service without a socket yet). A newer start supersedes the pending one; a stop cancels it (and succeeds, with cancelled_request_id, even with no display listening). The outcome is reported by /display/on-demand/status (source "web": starting, or error with start-timeout or the socket's reason, until the display publishes something newer) and by /display/current-status as on_demand_pending. Callers: the web UI's on-demand modal and "Preview on display" treat "starting" as taken (an info toast); the MQTT bridge already treats any non-error 2xx as success (now pinned by a test). Tests: the dispatcher (ack, retry then ack, start-timeout, other failures, superseded, an in-flight ack for a superseded start, stop while pending, a per-start wait, outcome lifetime); the routes (202, status routes while pending and after a timeout, a later display state replacing the failure, stop while pending, a new start superseding); a JS suite for app.js. Mutation check: 20 mutants on the worker, the routes and app.js, 20 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a cancelled on-demand start never reaches the display after its replacement CI caught a race in test_a_new_start_supersedes_the_pending_one: the dispatcher's worker could read the old start, the route cancel it and send the new one, and the worker's send of the old one land after it. cancel() now waits out a send already in flight (the worker holds a send lock while it reads the pending start and sends it), so whatever the caller sends next lands after it. Pinned by test_a_cancel_waits_for_a_send_in_flight, which fails without the wait. The Linux-only TestRealSocket test for a display that went away now expects the 202 and the start-timeout that follows, as the route answers since the background dispatcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a delivered on-demand start reads as starting until the display acts on it On ledpi (three cold starts) the display acknowledged the start as its socket opened, then took ~5 s to act on it while Vegas built its first strip; the status routes meanwhile showed the display's own idle state, so a UI polling every 700 ms flashed idle. The display's on-demand state now names the request it answers (request_id). A delivered start keeps reading as status "starting" with delivered: true, in /display/on-demand/status and as on_demand_pending in /display/current-status, until the display publishes state for that request id (a display without the field: any state newer than the delivery), for at most DELIVERED_SHOWN_SECONDS (30 s). The display's startup state, which can be published after the acknowledgement, names no request and does not end it. Tests: stays starting against the startup idle state (no id, an older id); the matching active state and the matching error take over; an older display's newer state takes over; the 30 s cap; the display's state names its request. Mutation check: 11 mutants, 11 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): on_demand_request_id has a class default for controllers built without __init__ _on_demand_state now publishes it, and test_state_stream_readers builds controllers with __new__. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the case-spelling on-demand test patches _send_on_demand now the mailbox is gone Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: blank line before 3.8.7 heading --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1344 lines
49 KiB
Markdown
1344 lines
49 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
|
||
|
||
- [Manifest Required Fields](#manifest-required-fields)
|
||
- [BasePlugin](#baseplugin)
|
||
- [Display Manager](#display-manager)
|
||
- [Cache Manager](#cache-manager)
|
||
- [Plugin Manager](#plugin-manager)
|
||
- [Fetching data](#fetching-data)
|
||
- [Deprecated APIs](#deprecated-apis)
|
||
|
||
---
|
||
|
||
## Manifest Required Fields
|
||
|
||
Three parts of core check `manifest.json`, each for a different set of
|
||
fields:
|
||
|
||
| Check | Fields | What happens when one is missing |
|
||
|---|---|---|
|
||
| JSON schema, [`schema/manifest_schema.json`](../schema/manifest_schema.json) | `id`, `name`, `version`, `author`, `entry_point`, `class_name`, `compatible_versions` | Install from URL logs a warning (`PluginStoreManager._validate_manifest_schema()`); nothing is refused |
|
||
| Plugin Store install, [`src/plugin_system/store_install.py`](../src/plugin_system/store_install.py) | `id`, `name`, `class_name`, `display_modes` | Install is refused. A registry install first tries to detect a missing `class_name` from the entry-point file |
|
||
| Plugin loader, [`src/plugin_system/plugin_loader.py`](../src/plugin_system/plugin_loader.py) | `class_name` | The plugin fails to load |
|
||
|
||
Defaults and other uses:
|
||
|
||
- `entry_point` defaults to `manager.py`; the store writes the default back
|
||
into the manifest on install.
|
||
- `compatible_versions` (a list of semver ranges such as `">=2.0.0"`) is how
|
||
the store decides whether a plugin can run on this core. An install is
|
||
refused only when the field excludes the running version
|
||
(`compatibility.check()` in
|
||
[`src/plugin_system/compatibility.py`](../src/plugin_system/compatibility.py)).
|
||
- `version` is compared with the registry's `latest_version` to decide
|
||
whether an update is available.
|
||
- If `display_modes` is empty at load time, the display controller uses the
|
||
plugin id as the only mode.
|
||
|
||
**Set all eight:** `id`, `name`, `version`, `author`, `entry_point`,
|
||
`class_name`, `display_modes`, `compatible_versions`. That satisfies every
|
||
check. The schema lists the optional fields.
|
||
|
||
```json
|
||
{
|
||
"id": "my-plugin",
|
||
"name": "My Plugin",
|
||
"version": "1.0.0",
|
||
"author": "YourName",
|
||
"entry_point": "manager.py",
|
||
"class_name": "MyPlugin",
|
||
"display_modes": ["my-plugin"],
|
||
"compatible_versions": [">=2.0.0"]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 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 on the plugin's update interval:
|
||
the value `get_update_interval()` returns when it returns a number, otherwise
|
||
the static interval: the `update_interval` in the plugin's manifest, else
|
||
`update_interval` in the plugin's section of `config.json`, else 60 seconds
|
||
(see [`get_update_interval()`](#get_update_interval---optionalfloat) below).
|
||
|
||
**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 the plugin's section of `config.json` changes -- a save in the
|
||
web UI, say. Every lifecycle hook runs in the display process, which is the
|
||
only process that runs plugins: the web interface writes `config.json`, and
|
||
the display's config watcher calls this with the prepared section. See
|
||
[ARCHITECTURE.md](ARCHITECTURE.md#web-and-display-processes-who-runs-plugins).
|
||
|
||
In the display service it runs on the config watcher thread while holding
|
||
the plugin's lock, so it never overlaps your `update()` or `display()`. If
|
||
the plugin stays busy for more than 5 seconds, the change is applied later
|
||
from the update thread: as soon as the plugin is free, and before its next
|
||
`update()` at the latest.
|
||
|
||
#### `on_enable() -> None`
|
||
|
||
Called when the display loads the plugin enabled: at startup, or when it is
|
||
switched on in the web UI.
|
||
|
||
#### `on_disable() -> None`
|
||
|
||
Called when the display unloads the plugin, e.g. when it is switched off in
|
||
the web UI.
|
||
|
||
#### `get_update_interval() -> Optional[float]`
|
||
|
||
How often this plugin wants `update()` called right now, in seconds. The
|
||
manifest's `update_interval` is one static number; override this when the
|
||
right cadence depends on state only the plugin knows, e.g. poll every 15s
|
||
while a game is live and fall back to the manifest value otherwise.
|
||
|
||
**Returns**: seconds as a number, or `None` (the default) for no opinion.
|
||
|
||
How `PluginManager` (`_get_plugin_update_interval` in
|
||
`src/plugin_system/plugin_manager.py`) resolves the interval on each
|
||
scheduling tick:
|
||
|
||
1. It calls `get_update_interval()`. A number wins over everything below.
|
||
Values under `PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL` (5 seconds) are
|
||
raised to it.
|
||
2. If the hook returns `None`, raises, or returns something that isn't a
|
||
finite number (a `bool`, a string, NaN, infinity), it is ignored and the
|
||
static interval applies: the manifest's `update_interval`, else
|
||
`update_interval` in the plugin's section of `config.json`, else 60
|
||
seconds.
|
||
|
||
The static value is cached per plugin until the plugin is loaded or
|
||
unloaded again, so editing `update_interval` in config takes effect on the
|
||
next reload. The hook's return value is never cached: it is called on every
|
||
tick of the display loop, so keep it to attribute reads (no config lookups,
|
||
no I/O, no locks a fetch might hold) and don't let it raise.
|
||
|
||
**Example**:
|
||
```python
|
||
def get_update_interval(self):
|
||
# Fast while something is live, manifest default otherwise.
|
||
if any(m.live_games for m in self._live_managers):
|
||
return self.config.get("live_update_interval", 15)
|
||
return None
|
||
```
|
||
|
||
Added in core 3.4.0; older cores never call it, so a plugin that relies on
|
||
it should floor `ledmatrix_min_version` at `3.4.0`.
|
||
|
||
#### `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.
|
||
|
||
#### `get_vegas_priority_weight() -> Optional[int]`
|
||
|
||
How many slots per Vegas cycle this plugin should get. Default returns
|
||
`None`, which defers to the core.
|
||
|
||
The Vegas ticker is otherwise a strict round robin — every plugin appears
|
||
exactly once per cycle — so with a dozen plugins enabled a live score can be
|
||
minutes stale by the time it comes round. A weight of *N* gives the plugin
|
||
*N* slots per cycle, spread evenly through it rather than clumped.
|
||
|
||
**You usually do not need this.** When the hook returns `None`, the core
|
||
already gives a plugin `vegas_scroll.live_weight` whenever
|
||
`has_live_priority()` and `has_live_content()` are both true. Live sports get
|
||
extra turns with no code at all.
|
||
|
||
Implement it only when the plugin knows something the core cannot. The
|
||
motivating case is favorite teams — the core can see *that* a game is live,
|
||
but not *whose*:
|
||
|
||
```python
|
||
def get_vegas_priority_weight(self):
|
||
if not (self.has_live_priority() and self.has_live_content()):
|
||
return None # let the core decide
|
||
vegas = self.global_config.get('display', {}).get('vegas_scroll', {})
|
||
if self._favorite_is_live():
|
||
return vegas.get('favorite_live_weight', 5)
|
||
return vegas.get('live_weight', 3)
|
||
```
|
||
|
||
The weight is per *plugin*, not per game: a scoreboard showing four live games
|
||
still occupies one slot at a time and rotates its own games within it. Values
|
||
are clamped to 1–10 by the caller. An exception here is caught and logged, and
|
||
the core then falls back to its own live-content check — so a plugin whose
|
||
weight calculation is broken still gets `live_weight` for a game that really
|
||
is live, rather than being demoted to 1.
|
||
|
||
Only consulted while `vegas_scroll.live_in_ticker` is on (the default since
|
||
3.8.0). With it off live content preempts Vegas entirely and there is no
|
||
ticker to be weighted within. See
|
||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||
|
||
### 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.
|
||
|
||
#### Vegas participation
|
||
|
||
Each plugin takes part in Vegas mode in one of three ways:
|
||
|
||
| Participation | What Vegas does |
|
||
|---|---|
|
||
| `'scroll'` | The plugin's content (`get_vegas_content()`) scrolls by with everything else |
|
||
| `'pause'` | The scroll stops when the plugin's turn comes round; its `display()` draws it full screen for `get_display_duration()` seconds, then the scroll resumes |
|
||
| `'exclude'` | The plugin is left out of Vegas mode |
|
||
|
||
Declare the plugin's default in `manifest.json`:
|
||
|
||
```json
|
||
{
|
||
"id": "my-alerts",
|
||
"vegas_participation": "pause"
|
||
}
|
||
```
|
||
|
||
The user can override it per plugin with `vegas_participation` in that
|
||
plugin's config section (it is one of the core-owned properties, see
|
||
[PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md)).
|
||
Vegas resolves it in this order:
|
||
|
||
1. the user's `vegas_participation` config value;
|
||
2. the plugin's `get_vegas_participation()` — the default implementation
|
||
reads the manifest's `vegas_participation`, then derives a value from
|
||
the legacy hooks below;
|
||
3. derived from the legacy hooks: `get_vegas_display_mode()` returning
|
||
`VegasDisplayMode.STATIC` → `'pause'`; otherwise
|
||
`get_vegas_content_type()` returning `'none'` → `'exclude'`; everything
|
||
else → `'scroll'`.
|
||
|
||
Step 3 is exactly what Vegas did before participation existed, so a plugin
|
||
that declares nothing behaves as it always has. Manifest
|
||
`vegas_participation` is new in core 3.8.0; older cores ignore it and use
|
||
the legacy hooks.
|
||
|
||
#### `get_vegas_participation() -> str`
|
||
|
||
Returns `'scroll'`, `'pause'` or `'exclude'`. Override it only when the
|
||
answer depends on state — pause only while an alert is live, exclude while
|
||
there is nothing to show; for a fixed answer use the manifest. Vegas applies
|
||
the user's config value before calling an override, so an override does not
|
||
need to check it. A value that is not one of the three is ignored with a log
|
||
line and the legacy hooks decide.
|
||
|
||
```python
|
||
def get_vegas_participation(self):
|
||
return 'pause' if self._alert_is_live() else 'scroll'
|
||
```
|
||
|
||
#### `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_render_width() -> int`
|
||
|
||
The width Vegas wants this plugin's content to occupy, from the plugin's
|
||
`vegas_width_pct` config value or the global
|
||
`display.vegas_scroll.render_width_pct`. Vegas also narrows
|
||
`display_manager` while it asks for content, so a plugin that sizes itself
|
||
from `display_manager.width` does not need to read this.
|
||
|
||
#### Live Vegas elements
|
||
|
||
*New in core 3.8.0.* Content from `get_vegas_content()` is baked into the
|
||
ticker's strip when the plugin's turn is prefetched, so a score drawn then
|
||
scrolls past with that score however many goals are scored while it crosses
|
||
the panel. A plugin that returns **live elements** instead gets them updated
|
||
in place: after its `update()` the ticker asks again, compares each element
|
||
with what the strip holds, and swaps the changed ones in between two frames
|
||
-- on screen included -- without anything next to them moving.
|
||
|
||
```python
|
||
try:
|
||
from src.plugin_system.vegas_elements import VegasElement
|
||
except ImportError: # core older than 3.8.0: the hook is never called
|
||
VegasElement = None
|
||
|
||
class MyScoreboard(BasePlugin):
|
||
def get_vegas_elements(self):
|
||
if VegasElement is None:
|
||
return None
|
||
return [VegasElement(key=f"game:{g['id']}",
|
||
image=self._card(g), # cache by fingerprint
|
||
version=self._fingerprint(g)) # changes iff pixels would
|
||
for g in self.games]
|
||
```
|
||
|
||
`VegasElement(key, image, version=None, live=True, refresh_hz=0.0)`:
|
||
|
||
| Field | Meaning |
|
||
|---|---|
|
||
| `key` | Names the element across redraws; unique in the list, stable for the same logical item (`"game:nfl:401547417"`, `"map"`). |
|
||
| `image` | The element now, at the display's height. A live element's **width must not depend on its data**: a redraw at another width is never swapped in (it appears the next time the plugin comes round), because nothing on screen may move. |
|
||
| `version` | Anything hashable that changes exactly when the pixels would. Handed back with the **same image object** as last time, it lets the ticker skip converting the element; a new image is always converted and compared by its pixels, so a redraw for new settings is never missed. `None` means "compare pixels". |
|
||
| `live` | `False` places it as plain content (trimmed, never refreshed): separators, decoration. |
|
||
| `refresh_hz` | For content that changes with **time** rather than data (an aircraft moving between position reports): the ticker calls `redraw_vegas_element()` about this often while the element is on or near the screen, capped by `vegas_scroll.live_max_hz` and at 1 Hz without the rebuilt rgbmatrix binding. |
|
||
|
||
**`get_vegas_elements() -> Optional[List[VegasElement]]`** — called on the
|
||
ticker's background thread under the plugin's lock (never while `update()`
|
||
runs), on a canvas of its own and told its render width, exactly like
|
||
`get_vegas_content()`. It is called after every `update()` while any of the
|
||
plugin's elements is on or ahead of the screen, so it must be cheap when
|
||
nothing changed (cache images by version), idempotent, and must not fetch.
|
||
Return `None` to use `get_vegas_content()`, which a plugin must keep working
|
||
for older cores and for the paths that do not ask for elements (the ticker's
|
||
first strip, multi-display sync, the `live_refresh` switch).
|
||
|
||
**`redraw_vegas_element(key, width, height, at) -> Optional[PIL.Image]`** —
|
||
only for elements with `refresh_hz`. Called **without** the plugin's lock,
|
||
possibly while `update()` runs, so read only state `update()` replaces in one
|
||
assignment (an immutable snapshot), never state it mutates in place. `at` is
|
||
the `time.monotonic()` the pixels are expected on the panel: draw the element
|
||
as it should look then. Return exactly `width` x `height`, or `None` to skip
|
||
the tick.
|
||
|
||
**`notify_vegas_data_changed()`** — data that arrives outside `update()` (a
|
||
background thread, a push callback) calls this so the ticker redraws without
|
||
waiting for the next `update()`. Safe from any thread.
|
||
|
||
Live elements are never trimmed to their ink: the ticker pads each with
|
||
`content_padding` black columns either side, the margin trimming would have
|
||
left. A single element wider than the plugin's width budget
|
||
(`vegas_max_width_screens`, not counting that padding) is cropped like any
|
||
other content and scrolls by as plain, no longer live. The user can turn them off per plugin with `vegas_live: false` (a
|
||
core-owned property) or for the whole ticker with
|
||
`display.vegas_scroll.live_refresh: false`; they are always off under
|
||
multi-display sync.
|
||
|
||
`scripts/check_plugin.py` checks the contract for any plugin that implements
|
||
the hook (unique keys, height, width stable with no new data, redraw size,
|
||
slow calls) and prints a `vegas elements` row; the checks are in
|
||
`src/plugin_system/testing/vegas.py`. `test/fixtures/plugins/vegas-live-stub`
|
||
is a small working example.
|
||
|
||
#### Legacy: `get_vegas_content_type()` and `get_vegas_display_mode()`
|
||
|
||
Superseded by participation, and still read to derive it when neither the
|
||
user nor the manifest declares one (step 3 above). Only two answers ever
|
||
mattered: `get_vegas_content_type()` returning `'none'`, and
|
||
`get_vegas_display_mode()` returning `VegasDisplayMode.STATIC`.
|
||
|
||
- `get_vegas_content_type()` returns `'multi'`, `'static'` or `'none'`
|
||
(default `'static'`).
|
||
- `get_vegas_display_mode()` returns a `VegasDisplayMode` member (not a
|
||
string — the string `'static'` never paused anything). The default reads
|
||
the plugin's `vegas_mode` config value (`"scroll"`, `"fixed"` or
|
||
`"static"`), else maps content type `'multi'` to `SCROLL` and anything
|
||
else to `FIXED_SEGMENT`.
|
||
|
||
`SCROLL` and `FIXED_SEGMENT` (and `vegas_mode` `"scroll"` and `"fixed"`)
|
||
have always behaved identically: both scroll. The distinction is deprecated
|
||
and goes away in 3.9.0 — see [Deprecated APIs](#deprecated-apis).
|
||
|
||
#### Deprecated: `get_supported_vegas_modes()` and `get_vegas_segment_width()`
|
||
|
||
Never read by core, and removed in 3.9.0: calling the `BasePlugin`
|
||
implementation logs a deprecation warning. A plugin's own override keeps
|
||
working for the plugin itself. `get_vegas_segment_width()` read the
|
||
`vegas_panel_count` config value, which has never affected Vegas — a card's
|
||
width comes from `get_vegas_content()` and `vegas_width_pct`.
|
||
|
||
### On-demand display
|
||
|
||
A plugin that reacts to something outside the rotation (an MQTT message, a
|
||
timer, a detection) can take the screen for it, and give it back. Both
|
||
methods are safe from any thread, including an MQTT callback: they only
|
||
queue the request, and the display applies it on its render thread within a
|
||
frame or so, exactly like an on-demand start or stop from the web interface.
|
||
|
||
#### `request_on_demand(mode=None, duration=None, pinned=False) -> Optional[str]`
|
||
|
||
Show this plugin now.
|
||
|
||
- `mode`: one of the plugin's display modes; `None` for its first.
|
||
- `duration`: seconds before the rotation resumes; `None` (or `0`) for no
|
||
limit, until `end_on_demand()` or the user stops it.
|
||
- `pinned`: stay on `mode` instead of cycling through the plugin's other
|
||
modes.
|
||
|
||
Returns the request id once the display has queued it, or `None` when
|
||
there is no display in this process to ask (the web interface's plugin
|
||
manager, `scripts/check_plugin.py`) or its queue is full. A bad argument
|
||
(a `mode` that is not a string, a `duration` that is not a number) raises
|
||
`ValueError`.
|
||
|
||
#### `end_on_demand() -> Optional[str]`
|
||
|
||
Give the screen back. Ends only a session this plugin owns: a session the
|
||
user started for another plugin, or one that already ended, is left alone.
|
||
Returns the request id once queued, or `None` as above.
|
||
|
||
#### Older cores: feature detection
|
||
|
||
These methods are new after core 3.8.0 (see `CHANGELOG.md`). Before them,
|
||
plugins wrote the `display_on_demand_request` cache key (the "mailbox")
|
||
themselves. **The mailbox is gone** (see
|
||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5): the display no
|
||
longer reads it, and a write to it is dropped with a warning in the log,
|
||
once per plugin:
|
||
|
||
```
|
||
Ignored a write to the retired 'display_on_demand_request' cache key by plugin 'my-plugin': ...
|
||
```
|
||
|
||
A plugin that only needs to run on cores with these methods calls them and
|
||
treats `None` as "no display took it":
|
||
|
||
```python
|
||
def _show_alert(self):
|
||
if self.request_on_demand(mode="my_alert", duration=15) is None:
|
||
self.logger.info("No display to show the alert on")
|
||
```
|
||
|
||
A plugin that must also work on cores before them can keep the mailbox
|
||
write as its fallback, guarded by `hasattr`: on those cores the display
|
||
still reads it, and on a current core the write is only dropped and logged
|
||
(when the method is missing it never runs at all). Raise
|
||
`ledmatrix_min_version` to the release that added the methods once you no
|
||
longer need it.
|
||
|
||
```python
|
||
import time, uuid
|
||
|
||
def _show_alert(self):
|
||
if hasattr(self, "request_on_demand"):
|
||
self.request_on_demand(mode="my_alert", duration=15)
|
||
return
|
||
# A core older than request_on_demand(): the mailbox it still reads.
|
||
self.cache_manager.set("display_on_demand_request", {
|
||
"request_id": str(uuid.uuid4()), "action": "start",
|
||
"plugin_id": self.plugin_id, "mode": "my_alert",
|
||
"duration": 15, "pinned": False, "timestamp": time.time(),
|
||
})
|
||
```
|
||
|
||
`end_on_demand()` ends only the plugin's own session; a stop written to the
|
||
mailbox on an older core ends any session, whoever started it.
|
||
|
||
Both methods answer a request id only when the plugin manager returned a
|
||
string, so a test that gives the plugin a `MagicMock()` plugin manager gets
|
||
`None`. To test the path where the display takes the request, set
|
||
`plugin_manager.request_on_demand.return_value = "some-id"`.
|
||
|
||
> 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 canonical way to render arbitrary images.
|
||
|
||
### Scrolling State Management
|
||
|
||
For plugins that implement scrolling content, use these methods to coordinate with the display system.
|
||
|
||
#### `set_scrolling_state(is_scrolling: bool, frame_hold: int = 1) -> None`
|
||
|
||
Mark the display as scrolling or not scrolling, and set this scroll's frame
|
||
pacing. Call it when a scroll starts (calling it on every scroll frame is fine)
|
||
and with `False` when it stops.
|
||
|
||
**Parameters**:
|
||
- `is_scrolling` (bool): True if currently scrolling, False otherwise
|
||
- `frame_hold` (int, default 1): how many panel refreshes each pushed frame is
|
||
held for (clamped to 1-255; ignored when `is_scrolling` is False, which
|
||
resets it to 1). Pass the `frame_hold` of the settings
|
||
`src.common.scroll_config.configure()` returned. Added in core 3.4.0.
|
||
|
||
**Why `frame_hold` matters**: `scroll_config.configure()` snaps the speed to
|
||
one the panel can show in whole pixels and sets the `ScrollHelper` to advance a
|
||
fixed number of pixels on every presented frame -- no clock is consulted. The
|
||
panel presents frames at its refresh rate divided by the hold, so the hold is
|
||
part of the speed. Omit it and a 50 px/s scroll (1px every 2nd refresh on a
|
||
100 Hz panel) runs at 100 px/s. The hold is not applied by `configure()`
|
||
because it must not outlive the scroll: plugins share one display manager.
|
||
|
||
**Example**:
|
||
```python
|
||
from src.common import scroll_config
|
||
from src.common.scroll_helper import ScrollHelper
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self.scroll_helper = ScrollHelper(
|
||
self.display_manager.width, self.display_manager.height, self.logger)
|
||
# ...later, hand it content with self.scroll_helper.set_scrolling_image(img)
|
||
self.scroll_settings = scroll_config.configure(
|
||
self.scroll_helper,
|
||
plugin_config=self.config,
|
||
global_config=self.global_config,
|
||
display_manager=self.display_manager,
|
||
plugin_logger=self.logger,
|
||
)
|
||
|
||
def display(self, force_clear=False):
|
||
self.display_manager.set_scrolling_state(
|
||
True, frame_hold=self.scroll_settings.frame_hold)
|
||
self.scroll_helper.update_scroll_position()
|
||
self.display_manager.image = self.scroll_helper.get_visible_portion()
|
||
self.display_manager.update_display()
|
||
if self.scroll_helper.is_scroll_complete():
|
||
self.display_manager.set_scrolling_state(False)
|
||
```
|
||
|
||
Don't pace the loop with `time.sleep()`: `update_display()` blocks on the
|
||
panel's vsync, which is what paces a scroll. See `docs/SCROLL_PERFORMANCE.md`
|
||
for choosing a speed.
|
||
|
||
#### `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.
|
||
|
||
### 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 7 (6 snapped to its pixel grid)
|
||
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")
|
||
```
|
||
|
||
### 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_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
|
||
|
||
### 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']}")
|
||
```
|
||
|
||
---
|
||
|
||
## 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_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')}")
|
||
```
|
||
|
||
#### `request_on_demand(plugin_id, mode=None, duration=None, pinned=False)` / `end_on_demand(plugin_id)`
|
||
|
||
What `BasePlugin.request_on_demand()` and `end_on_demand()` call, with the
|
||
plugin's own id. Call those instead; see
|
||
[On-demand display](#on-demand-display). The display controller routes them
|
||
to itself with `set_on_demand_handler()`; a plugin manager without a
|
||
display behind it answers `None`.
|
||
|
||
#### `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
|
||
weather = self.plugin_manager.plugins.get("weather")
|
||
if weather is not None and weather.enabled:
|
||
pass
|
||
```
|
||
|
||
---
|
||
|
||
## Fetching data
|
||
|
||
Use the core helpers for HTTP rather than a `requests.Session` of your own:
|
||
`APIHelper` (`from src.common import APIHelper`) for JSON APIs, and
|
||
`fetch_espn_scoreboard()` (`src.common.espn_dates`) or
|
||
`BackgroundDataService` for ESPN scoreboards. Since the release after 3.7.0
|
||
these go through the core **fetch service** (`src/common/fetch_service.py`),
|
||
so a plugin that uses them gets the following with no code change. Return
|
||
values, exceptions and retries are what they were.
|
||
|
||
- **Shared connections.** Core sessions with the same retry policy share one
|
||
connection pool per host, instead of one pool per helper.
|
||
- **Merged requests.** Identical GETs in flight at the same time (same URL
|
||
and query, headers, timeout and retry policy) go to the network once, and
|
||
every caller gets its own copy of the response, or the same exception.
|
||
- **Host budgets.** A host can have a token-bucket budget. A request past it
|
||
waits for a token, but never longer than `max_wait_seconds` (2 s by
|
||
default). Only ESPN hosts have one by default (20 requests a second, burst
|
||
200), which normal use never reaches.
|
||
- **Conditional GET.** When a server sends `ETag` or `Last-Modified`, the
|
||
next identical request revalidates, and a `304 Not Modified` comes back to
|
||
your code as the original `200` with its body. ESPN currently sends
|
||
neither, so this does nothing there.
|
||
- **Response cache.** A response whose server says `Cache-Control:
|
||
max-age=N` answers an identical GET for those N seconds without a
|
||
request (ESPN sends 1 to ~500 s). It never hands you a response older
|
||
than you accept: pass `cache_max_age=<your TTL>` to `fetch_get()` or
|
||
`fetch_espn_scoreboard()` (0 always asks the network); without it a
|
||
response is reused for at most 30 seconds.
|
||
- **Counters.** Requests, merged requests, bytes, 304s, errors and time spent
|
||
waiting, and requests answered without the network (`memo_hits` from the
|
||
response cache, `cache_hits` from a shared scoreboard cache entry), are
|
||
counted per plugin and per host, and published for the web UI
|
||
at `GET /api/v3/plugins/fetch-stats` (see
|
||
[REST_API_REFERENCE.md](REST_API_REFERENCE.md#get-fetch-statistics)). A
|
||
request is counted against your plugin when it runs inside your
|
||
`update()`/`display()`, your constructor or `on_enable()`, or anywhere in
|
||
code under your plugin's directory, including threads you start.
|
||
|
||
What is not covered yet: requests a plugin makes with its own `requests.get()`
|
||
or `Session.get()` calls. They work as before but are invisible to the
|
||
budgets and counters.
|
||
|
||
### One cache key per ESPN scoreboard
|
||
|
||
Cache an ESPN scoreboard under `espn_scoreboard_cache_key(sport, league,
|
||
dates)` (`src.common.espn_dates`), not a key of your own, so every plugin
|
||
showing that league shares one fetch and one cached copy. `sport` and
|
||
`league` are ESPN's path segments (`football`, `college-football`), and
|
||
`dates` is what you send as `dates=` (`"20261004"`, `"202610"`,
|
||
`"20260925-20261016"`, a `date`, or `None` for the undated scoreboard).
|
||
|
||
```python
|
||
from src.common.espn_dates import get_espn_scoreboard
|
||
|
||
data = get_espn_scoreboard(
|
||
self.session, "football", "nfl", "20261004",
|
||
cache_manager=self.cache_manager,
|
||
max_age=300, # your TTL: nothing older comes back
|
||
legacy_keys=["my_old_key_20261004"], # read once while upgrading
|
||
)
|
||
```
|
||
|
||
`get_espn_scoreboard` returns a cached copy at most `max_age` seconds old,
|
||
whoever wrote it, and otherwise fetches with `fetch_espn_scoreboard`
|
||
(`limit=500`, ranges split the way ESPN requires) and caches the result
|
||
without a ttl, so each reader applies its own age limit. `max_age=0` always
|
||
fetches but still leaves the copy for others. For a two-step read, use
|
||
`read_espn_scoreboard_cache()` and `store_espn_scoreboard_cache()` around
|
||
your own fetch. Scoreboards built on `SportsFetchMixin` get
|
||
`_schedule_cache_key(datestring)` and `_cached_schedule(key, legacy_keys)`
|
||
for their schedule windows. All of this is in the core release after 3.8.0.
|
||
|
||
The settings live in `config.json` under `fetch_service`, read when the
|
||
display starts and on a config reload:
|
||
|
||
```json
|
||
"fetch_service": {
|
||
"enabled": true,
|
||
"max_wait_seconds": 2,
|
||
"rate_limits": {
|
||
"*.espn.com": {"per_second": 20, "burst": 200},
|
||
"api.example.com": {"per_second": 1, "burst": 5}
|
||
}
|
||
}
|
||
```
|
||
|
||
`rate_limits` keys are a host or a `*.domain` pattern (which also matches
|
||
the bare domain); `"per_second": 0` removes a budget. `"enabled": false`
|
||
turns the whole service into a plain `session.get()`. Two further switches,
|
||
`"single_flight": false` and `"conditional_get": false`, turn off merging and
|
||
revalidation. `"response_cache": {"enabled": false}` turns off the response
|
||
cache; its `default_max_age` (30) is the limit for callers that pass no
|
||
`cache_max_age`.
|
||
|
||
---
|
||
|
||
## 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,
|
||
passing the frame hold `scroll_config.configure()` returned
|
||
```python
|
||
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
|
||
# 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
|
||
|
||
---
|
||
|
||
## Deprecated APIs
|
||
|
||
A deprecated method still works but logs a warning the first time it is
|
||
called (`journalctl -u ledmatrix` shows which one), until the release that
|
||
removes it. [DEPRECATIONS_3.8.md](DEPRECATIONS_3.8.md) is the usage scan
|
||
behind each removal: which of the deprecated methods the official plugins,
|
||
the registry's third-party plugins and core still call or override. Only
|
||
methods that scan reports unused are removed; the rest stay until their
|
||
callers migrate.
|
||
|
||
### Removed in 3.8.0
|
||
|
||
Deprecated in 3.5.0 with a warning on first call, and gone in 3.8.0:
|
||
the scan found no caller in any official or third-party plugin. Calling one
|
||
now raises `AttributeError`.
|
||
|
||
| Object | Methods | Instead |
|
||
|---|---|---|
|
||
| `cache_manager` | `update_cache` | `set()` |
|
||
| `cache_manager` | `get_background_cached_data`, `is_background_data_available` | `get()` |
|
||
| `cache_manager` | `has_data_changed`, `setup_persistent_cache`, `get_sport_live_interval`, `get_sport_key_from_cache_key`, `record_cache_hit`, `record_cache_miss`, `record_fetch_time`, `get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats` | no replacement |
|
||
| `display_manager` | `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`, `draw_snow`, `draw_text_with_icons` | draw your own icons (the weather plugin ships `WeatherIcons`) |
|
||
| `display_manager` | `get_scrolling_stats` | no replacement |
|
||
| `font_manager` | `get_font_catalog`, `get_available_fonts` | read `font_catalog` |
|
||
| `font_manager` | `set_override`, `remove_override`, `get_overrides`, `add_font`, `remove_font`, `validate_font`, `get_size_tokens`, `get_performance_stats`, `get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`, `unregister_plugin_fonts` | no replacement |
|
||
| `plugin_manager` | `get_enabled_plugins` | check `enabled` on the entries in `plugin_manager.plugins` |
|
||
|
||
### Removed in 3.9.0
|
||
|
||
The Vegas APIs that described a fixed-width segment, which Vegas never
|
||
implemented. Vegas participation (`'scroll'`, `'pause'`, `'exclude'`, see
|
||
[Vegas scroll hooks](#vegas-scroll-hooks)) replaces them. Calling one of the
|
||
methods, or setting `vegas_panel_count`, logs a warning once per process.
|
||
No official plugin calls them; calendar, olympics and blackjack override
|
||
`get_supported_vegas_modes()`, which keeps working for the plugin itself.
|
||
|
||
| What | Instead |
|
||
|---|---|
|
||
| `BasePlugin.get_supported_vegas_modes()` | declare `vegas_participation` in the manifest |
|
||
| `BasePlugin.get_vegas_segment_width()` and the `vegas_panel_count` config key | nothing: a card's width comes from `get_vegas_content()` and `vegas_width_pct` |
|
||
| `VegasDisplayMode.SCROLL` vs `FIXED_SEGMENT` (`vegas_mode` `"scroll"` vs `"fixed"`) | `'scroll'` participation; the two always behaved the same |
|