Files
LEDMatrix/docs/PLUGIN_API_REFERENCE.md
T
ChuckandClaude Opus 5.5 a231d4dbc7 chore: delete unreferenced scripts and archived docs; fix stale doc claims (#607)
* chore(scripts): delete unreferenced helper scripts

None of these is referenced by an installer, systemd unit, CI workflow,
test, the web UI or src/:

- utils/cleanup_venv.sh removes venv_web_v2, which nothing creates
- utils/clear_python_cache.sh hardcodes ~/LEDMatrix and a .webassets-cache
  nothing uses
- install/migrate_config.sh only copies the template, which the installer
  and ConfigManager already do
- install/debug_install.sh, debug/debug_web_manual.py
- diagnose_web_ui.sh and verify_web_ui.sh overlap diagnose_web_interface.sh,
  which the docs point to
- fix_internet_connectivity.sh is iptables-only (stale on nftables)
- diagnose_plugin_permissions.sh, dev/validate_python.py
- download_nba_logos.py + README_NBA_LOGOS.md: logo_downloader fetches
  logos on demand
- setup_plugin_repos.py linked into the production plugin-repos/ dir; the
  dev workflow is scripts/dev/dev_plugin_setup.sh, and
  MULTI_ROOT_WORKSPACE_SETUP.md now uses it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(config): drop unused plugin_system flags and a dead unit comment

- config.template.json: remove plugin_system.auto_discover,
  auto_load_enabled and development_mode. Nothing reads them; the web UI
  only stores them when a client sends them. ConfigManager's migration
  only adds template keys, so existing configs keep theirs unchanged.
- config.template.json: re-indent vegas_scroll's live_* keys.
- systemd/ledmatrix.service: remove the comment documenting
  LEDMATRIX_ON_DEMAND_PLUGIN / on_demand_env.conf; nothing reads either.
- CONFIG_REFERENCE.md: say the legacy keys are no longer in the template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: delete docs/archive and PLUGIN_IMPLEMENTATION_SUMMARY.md

- docs/archive/: superseded guides; the repository history keeps them
  and no live doc links into the directory. The one open document in it,
  WEB_UI_AUDIT_2026-09.md, moves to docs/audits/ and is linked from the
  docs index.
- PLUGIN_IMPLEMENTATION_SUMMARY.md invented usage statistics, called
  v2.0.0 current, listed shipped auto-updates as future work and
  documented a BasePlugin.get_config() that does not exist.
- docs/README.md: drop both, and stop telling contributors to archive
  obsolete pages instead of deleting them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plugin-api): fix extra_small_font size, cache metric key and scroll pacing example

- PLUGIN_API_REFERENCE: extra_small_font loads at 7, not 6 (crisp_size
  snaps it, src/display_manager.py); get_cache_metrics() returns
  cache_hit_rate, not hit_rate (src/cache/cache_metrics.py).
- ADVANCED_PLUGIN_DEVELOPMENT: the basic scrolling example slept in a loop
  and never passed frame_hold; use ScrollHelper + scroll_config.configure()
  and set_scrolling_state(True, frame_hold=...) as PLUGIN_API_REFERENCE does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plugin-config): match the config tab, icon and web-action docs to the code

- PLUGIN_CONFIG_QUICK_START / PLUGIN_CONFIGURATION_TABS /
  PLUGIN_CONFIGURATION_GUIDE: there is no "Reset to Defaults" button (the
  tab has Refresh, Update, Uninstall, Save Configuration); plugin config
  hot-reloads (ConfigService + on_config_change), so no restart; the
  schema is found by the fixed name config_schema.json, not a manifest
  config_schema field; the tab row is "Plugin Manager", not "Plugins";
  forms are server-rendered from /v3/partials/plugin-config/<id>; the
  duration hook is get_display_duration()/display_duration; a class_name
  mismatch raises PluginError; the store requires id, name, class_name and
  display_modes (not version); plugin_system.debug/log_level do not exist
  (use run.py -d / LEDMATRIX_DEBUG). Drop "future" features that shipped.
- PLUGIN_CONFIG_CORE_PROPERTIES: list all of CORE_PLUGIN_PROPERTIES,
  including skin, skin_options and the vegas_* tuning keys.
- PLUGIN_CUSTOM_ICONS: icon is only a Font Awesome class (fallback
  fa-puzzle-piece); emoji/URL icons and getPluginIcon() never existed in
  v3. Note that /api/v3/plugins/installed currently omits icon.
- PLUGIN_WEB_UI_ACTIONS (+ example JSON): success_message, error_message
  and step1_message are never read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(store): describe the monorepo registry and the store UI as they are

- PLUGIN_STORE_GUIDE: the Plugin Store is a section of the Plugin
  Manager tab; URL installs are "Install from GitHub" -> "Install Single
  Plugin"; bulk update exists (Check & Update All) plus opt-in weekly
  auto-update; PluginStoreManager() defaults to plugins/, so the Python
  examples pass plugin-repos; registry plugins are downloaded (GitHub API,
  ZIP fallback), not cloned; updates compare version with latest_version.
- PLUGIN_REGISTRY_SETUP_GUIDE: replace the per-plugin-repo + tag
  walkthrough with a short page on the monorepo registry (plugin_path,
  latest_version, update_registry.py) that points at the monorepo's own
  SUBMISSION.md. Drops the reference to the deleted
  PLUGIN_IMPLEMENTATION_SUMMARY.md and setup_plugin_repos.py.
- plugin_registry_template.json: use the real entry shape.
- PLUGIN_QUICK_REFERENCE: automatic background updates exist (opt-in);
  registry example and publishing steps use the monorepo, not tags.
- PLUGIN_DEVELOPMENT_GUIDE: tags/releases are not read by the store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(readme): fix the Triple Bonnet mapping, install prerequisites and backup names

- README: the Adafruit Triple Bonnet uses `regular` (3 outputs), not
  `regular-pi1` (1 output) -- src/matrix_support.py MAPPING_OUTPUTS, and
  the README's own hardware_mapping section; the template default mapping
  is adafruit-hat, the PWM mod switches it to adafruit-hat-pwm; manual
  install only needs git up front (first_time_install.sh installs
  python-dev-is-python3, cmake, ninja-build etc.; cython3/scons are not
  used); the Pi Zero 2 W is a supported low-memory board, consistent with
  PRODUCT.md, LOW_MEMORY_BOARDS.md and the installer's low-memory build;
  fix the "First_time_install.sh" spelling, an orphan "2." list item and
  the hello-world starter link (it lives in the plugins monorepo).
- CONFIG_DEBUGGING: automatic backups are
  config/backups/config.json.backup.<YYYYMMDD_HHMMSS_ffffff> (five kept),
  not config_YYYYMMDD_HHMMSS.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(dev): correct the test-running and rgbmatrix build instructions

- HOW_TO_RUN_TESTS: coverage is not collected by a plain pytest run and
  pytest.ini has no threshold; the only one is --cov-fail-under=52 in the
  core unit-test job of .github/workflows/test.yml, which runs the whole
  test/ tree (not an allowlist). Almost no tests carry markers, so
  -m integration / -m slow select nothing; drop them and -m unit as the
  quick check. Replace the hardcoded /home/chuck path.
- DEVELOPMENT: the rgbmatrix package is built with pip install . from
  the submodule root (scikit-build-core + CMake + Ninja), as
  first_time_install.sh does; there is no make build-python /
  bindings/python step, and the build deps are python-dev-is-python3,
  cmake and ninja-build, not cython3/scons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(wifi): the setup AP is open; auto-enable can be turned off without code changes

- WIFI_NETWORK_SETUP / SSH_UNAVAILABLE_AFTER_INSTALL: both AP paths in
  src/wifi_manager.py create an open network and nothing reads
  ap_password, so drop the "ledmatrix123" password and the ap_password
  key/advice.
- SSH_UNAVAILABLE_AFTER_INSTALL: disabling automatic AP mode does not
  need code changes -- auto_enable_ap_mode is a WiFi-tab toggle and
  POST /api/v3/wifi/ap/auto-enable; note the monitor daemon reads
  wifi_config.json at start, so restart it after changing the setting.
  Use the ledpi username and a relative install path like the other docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(reference): add auto_update, drop drifted line numbers, fix UI and service details

- CONFIG_REFERENCE: document the top-level auto_update.enabled key (read
  by web_interface/auto_update.py and src/auto_update_setup.py); replace
  drifted file:line references with function names; the template's
  dim_schedule mode is "global".
- ADVANCED_FEATURES: core does not read a per-plugin background_service
  block (the sports plugins read their own), and priority is "higher
  number = higher priority" on FetchRequest but not used for ordering.
- WEB_INTERFACE_GUIDE: the General tab toggle is "Web Display Autostart"
  (web interface service), brightness is 1-100, and config paths are
  relative to the LEDMatrix folder, not /config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: drop references to code removed in #608

get_installed_plugin_info, WiFiManager's saved_networks and the six
always-skipping plugin test files are deleted there. NetworkManager already
remembers joined networks; LEDMatrix no longer stores WiFi passwords.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: don't link SKIN_SYSTEM.md from the core-properties page

#615 deletes SKIN_SYSTEM.md; with this link, whichever of the two merged
second would break test_doc_links. The skin/skin_options entries go when
#615 removes the keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 12:36:38 -04:00

1077 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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 plugin configuration is updated via web API.
#### `on_enable() -> None`
Called when plugin is enabled.
#### `on_disable() -> None`
Called when plugin is disabled.
#### `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 when the user has set `vegas_scroll.live_in_ticker`. With the
default (`false`) 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.
#### `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 number of *panels* the segment
occupies in the scroll (pixel width = panels × `single_panel_width`,
from `display.hardware.cols`). `None` uses the default of 1 panel.
> 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, 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.
#### `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 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")
```
#### `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 (`total_requests`, `cache_hit_rate`, `background_hit_rate`, `api_calls_saved`, `average_fetch_time`, etc.)
**Example**:
```python
metrics = self.cache_manager.get_cache_metrics()
self.logger.info(f"Cache hit rate: {metrics['cache_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,
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