mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* 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>
846 lines
24 KiB
Markdown
846 lines
24 KiB
Markdown
# Advanced Plugin Development
|
|
|
|
Advanced patterns, examples, and best practices for developing LEDMatrix plugins.
|
|
|
|
> **Adaptive layout:** for plugins that should render legibly on any panel
|
|
> size (fonts that grow on big panels, layouts that degrade gracefully on
|
|
> small ones), use the adaptive layout system — `self.layout`, `draw_fit`,
|
|
> `draw_image`, `scoreboard_regions` — documented in
|
|
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
|
|
|
## Table of Contents
|
|
|
|
- [Using Weather Icons](#using-weather-icons)
|
|
- [Implementing Scrolling with Deferred Updates](#implementing-scrolling-with-deferred-updates)
|
|
- [Cache Strategy Patterns](#cache-strategy-patterns)
|
|
- [Font Management and Overrides](#font-management-and-overrides)
|
|
- [Error Handling Best Practices](#error-handling-best-practices)
|
|
- [Performance Optimization](#performance-optimization)
|
|
- [Testing Plugins with Mocks](#testing-plugins-with-mocks)
|
|
- [Inter-Plugin Communication](#inter-plugin-communication)
|
|
- [Live Priority Implementation](#live-priority-implementation)
|
|
- [Dynamic Duration Support](#dynamic-duration-support)
|
|
|
|
---
|
|
|
|
## Using Weather Icons
|
|
|
|
The Display Manager provides built-in weather icon drawing methods for easy visual representation of weather conditions.
|
|
|
|
### Basic Weather Icon Usage
|
|
|
|
```python
|
|
def display(self, force_clear=False):
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
|
|
# Draw weather icon based on condition
|
|
condition = self.data.get('condition', 'clear')
|
|
self.display_manager.draw_weather_icon(condition, x=5, y=5, size=16)
|
|
|
|
# Draw temperature next to icon
|
|
temp = self.data.get('temp', 72)
|
|
self.display_manager.draw_text(
|
|
f"{temp}°F",
|
|
x=25, y=10,
|
|
color=(255, 255, 255)
|
|
)
|
|
|
|
self.display_manager.update_display()
|
|
```
|
|
|
|
### Supported Weather Conditions
|
|
|
|
The `draw_weather_icon()` method automatically maps condition strings to appropriate icons:
|
|
|
|
- `"clear"`, `"sunny"` → Sun icon
|
|
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
|
|
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
|
|
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
|
|
- `"thunderstorm"`, `"storm"` → Storm icon
|
|
|
|
### Custom Weather Icons
|
|
|
|
For more control, use individual icon methods:
|
|
|
|
```python
|
|
# Draw specific icons
|
|
self.display_manager.draw_sun(x=10, y=10, size=16)
|
|
self.display_manager.draw_cloud(x=10, y=10, size=16, color=(150, 150, 150))
|
|
self.display_manager.draw_rain(x=10, y=10, size=16)
|
|
self.display_manager.draw_snow(x=10, y=10, size=16)
|
|
```
|
|
|
|
### Text with Weather Icons
|
|
|
|
Use `draw_text_with_icons()` to combine text and icons:
|
|
|
|
```python
|
|
icons = [
|
|
("sun", 5, 5), # Sun icon at (5, 5)
|
|
("cloud", 100, 5) # Cloud icon at (100, 5)
|
|
]
|
|
|
|
self.display_manager.draw_text_with_icons(
|
|
"Weather: Sunny, Cloudy",
|
|
icons=icons,
|
|
x=10, y=20,
|
|
color=(255, 255, 255)
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Implementing Scrolling with Deferred Updates
|
|
|
|
For plugins that scroll content (tickers, news feeds, etc.), use scrolling state management to coordinate with the display system.
|
|
|
|
### Basic Scrolling Implementation
|
|
|
|
Scroll with `ScrollHelper`, configured by `src.common.scroll_config`, and
|
|
render one frame per `display()` call. Don't pace the scroll with
|
|
`time.sleep()`: `update_display()` blocks on the panel's
|
|
vsync, which is what paces a scroll. Pass the `frame_hold` that
|
|
`scroll_config.configure()` returned to `set_scrolling_state()`, or the
|
|
scroll runs faster than the configured speed (see
|
|
`set_scrolling_state()` in [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)).
|
|
|
|
```python
|
|
from PIL import Image, ImageDraw
|
|
|
|
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)
|
|
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 _build_scroll_image(self, text):
|
|
font = self.display_manager.regular_font
|
|
width = self.display_manager.get_text_width(text, font)
|
|
img = Image.new("RGB", (width, self.display_manager.height))
|
|
ImageDraw.Draw(img).text((0, 0), text, font=font, fill=(255, 255, 255))
|
|
self.scroll_helper.set_scrolling_image(img)
|
|
|
|
def display(self, force_clear=False):
|
|
if force_clear or self.scroll_helper.cached_image is None:
|
|
self._build_scroll_image(
|
|
"This is a long scrolling message that needs to scroll across the display...")
|
|
|
|
# Mark as scrolling (calling it every frame is fine)
|
|
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():
|
|
# Mark as not scrolling when done
|
|
self.display_manager.set_scrolling_state(False)
|
|
```
|
|
|
|
### Deferred Updates During Scrolling
|
|
|
|
Use `defer_update()` to queue non-critical updates until scrolling completes:
|
|
|
|
```python
|
|
def update(self):
|
|
# Critical update - do immediately
|
|
self.fetch_latest_data()
|
|
|
|
# Non-critical metadata update - defer until not scrolling
|
|
self.display_manager.defer_update(
|
|
lambda: self.update_cache_metadata(),
|
|
priority=1
|
|
)
|
|
|
|
# Low priority cleanup - defer
|
|
self.display_manager.defer_update(
|
|
lambda: self.cleanup_old_data(),
|
|
priority=2
|
|
)
|
|
```
|
|
|
|
### Checking Scroll State
|
|
|
|
Check if currently scrolling before performing expensive operations:
|
|
|
|
```python
|
|
def update(self):
|
|
# Only do expensive operations when not scrolling
|
|
if not self.display_manager.is_currently_scrolling():
|
|
self.perform_expensive_operation()
|
|
else:
|
|
# Defer until scrolling stops
|
|
self.display_manager.defer_update(
|
|
lambda: self.perform_expensive_operation(),
|
|
priority=0
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Cache Strategy Patterns
|
|
|
|
Use appropriate cache strategies for different data types to optimize performance and reduce API calls.
|
|
|
|
### Basic Caching Pattern
|
|
|
|
```python
|
|
def update(self):
|
|
cache_key = f"{self.plugin_id}_data"
|
|
|
|
# Try to get from cache first
|
|
cached = self.cache_manager.get(cache_key, max_age=3600)
|
|
if cached:
|
|
self.data = cached
|
|
self.logger.debug("Using cached data")
|
|
return
|
|
|
|
# Fetch from API if not cached
|
|
try:
|
|
self.data = self._fetch_from_api()
|
|
self.cache_manager.set(cache_key, self.data)
|
|
self.logger.info("Fetched and cached new data")
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to fetch data: {e}")
|
|
# Use stale cache if available (re-fetch with large max_age to bypass expiration)
|
|
expired_cached = self.cache_manager.get(cache_key, max_age=31536000) # 1 year
|
|
if expired_cached:
|
|
self.data = expired_cached
|
|
self.logger.warning("Using stale cached data due to API error")
|
|
```
|
|
|
|
### Using Cache Strategies
|
|
|
|
For automatic TTL selection based on data type:
|
|
|
|
```python
|
|
def update(self):
|
|
cache_key = f"{self.plugin_id}_weather"
|
|
|
|
# Automatically uses appropriate cache duration for weather data
|
|
cached = self.cache_manager.get_cached_data_with_strategy(
|
|
cache_key,
|
|
data_type="weather"
|
|
)
|
|
|
|
if cached:
|
|
self.data = cached
|
|
return
|
|
|
|
# Fetch new data
|
|
self.data = self._fetch_from_api()
|
|
self.cache_manager.set(cache_key, self.data)
|
|
```
|
|
|
|
### Sport-Specific Caching
|
|
|
|
For sports plugins, use sport-specific cache strategies:
|
|
|
|
```python
|
|
def update(self):
|
|
sport_key = "nhl"
|
|
cache_key = f"{self.plugin_id}_{sport_key}_games"
|
|
|
|
# Uses sport-specific live_update_interval from config
|
|
cached = self.cache_manager.get_background_cached_data(
|
|
cache_key,
|
|
sport_key=sport_key
|
|
)
|
|
|
|
if cached:
|
|
self.games = cached
|
|
return
|
|
|
|
# Fetch new games
|
|
self.games = self._fetch_games(sport_key)
|
|
self.cache_manager.set(cache_key, self.games)
|
|
```
|
|
|
|
### Cache Invalidation
|
|
|
|
Clear cache when needed:
|
|
|
|
```python
|
|
def on_config_change(self, new_config):
|
|
# Clear cache when API key changes
|
|
if new_config.get('api_key') != self.config.get('api_key'):
|
|
self.cache_manager.clear_cache(f"{self.plugin_id}_data")
|
|
self.logger.info("Cleared cache due to API key change")
|
|
|
|
super().on_config_change(new_config)
|
|
```
|
|
|
|
---
|
|
|
|
## Font Management and Overrides
|
|
|
|
Use the Font Manager for advanced font handling and user customization.
|
|
|
|
### Using Different Fonts
|
|
|
|
```python
|
|
def display(self, force_clear=False):
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
|
|
# Use regular font for title
|
|
self.display_manager.draw_text(
|
|
"Title",
|
|
x=10, y=5,
|
|
font=self.display_manager.regular_font,
|
|
color=(255, 255, 255)
|
|
)
|
|
|
|
# Use small font for details
|
|
self.display_manager.draw_text(
|
|
"Details",
|
|
x=10, y=20,
|
|
font=self.display_manager.small_font,
|
|
color=(200, 200, 200)
|
|
)
|
|
|
|
# Use calendar font for compact text
|
|
self.display_manager.draw_text(
|
|
"Compact",
|
|
x=10, y=30,
|
|
font=self.display_manager.calendar_font,
|
|
color=(150, 150, 150)
|
|
)
|
|
|
|
self.display_manager.update_display()
|
|
```
|
|
|
|
### Measuring Text
|
|
|
|
Calculate text dimensions for layout:
|
|
|
|
```python
|
|
def display(self, force_clear=False):
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
|
|
text = "Hello, World!"
|
|
font = self.display_manager.regular_font
|
|
|
|
# Get text dimensions
|
|
text_width = self.display_manager.get_text_width(text, font)
|
|
font_height = self.display_manager.get_font_height(font)
|
|
|
|
# Center text horizontally
|
|
x = (self.display_manager.width - text_width) // 2
|
|
|
|
# Center text vertically
|
|
y = (self.display_manager.height - font_height) // 2
|
|
|
|
self.display_manager.draw_text(text, x=x, y=y, font=font)
|
|
self.display_manager.update_display()
|
|
```
|
|
|
|
### Multi-line Text
|
|
|
|
Render multiple lines of text:
|
|
|
|
```python
|
|
def display(self, force_clear=False):
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
|
|
lines = [
|
|
"Line 1",
|
|
"Line 2",
|
|
"Line 3"
|
|
]
|
|
|
|
font = self.display_manager.small_font
|
|
font_height = self.display_manager.get_font_height(font)
|
|
y = 5
|
|
|
|
for line in lines:
|
|
# Center each line
|
|
text_width = self.display_manager.get_text_width(line, font)
|
|
x = (self.display_manager.width - text_width) // 2
|
|
|
|
self.display_manager.draw_text(line, x=x, y=y, font=font)
|
|
y += font_height + 2 # Add spacing between lines
|
|
|
|
self.display_manager.update_display()
|
|
```
|
|
|
|
---
|
|
|
|
## Error Handling Best Practices
|
|
|
|
Implement robust error handling to ensure plugins fail gracefully.
|
|
|
|
### API Error Handling
|
|
|
|
```python
|
|
def update(self):
|
|
cache_key = f"{self.plugin_id}_data"
|
|
|
|
try:
|
|
# Try to fetch from API
|
|
self.data = self._fetch_from_api()
|
|
self.cache_manager.set(cache_key, self.data)
|
|
self.logger.info("Successfully updated data")
|
|
except requests.exceptions.Timeout:
|
|
self.logger.warning("API request timed out, using cached data")
|
|
cached = self.cache_manager.get(cache_key, max_age=7200) # Use older cache
|
|
if cached:
|
|
self.data = cached
|
|
else:
|
|
self.data = None
|
|
except requests.exceptions.RequestException as e:
|
|
self.logger.error(f"API request failed: {e}")
|
|
# Try to use cached data
|
|
cached = self.cache_manager.get(cache_key, max_age=7200)
|
|
if cached:
|
|
self.data = cached
|
|
self.logger.info("Using cached data due to API error")
|
|
else:
|
|
self.data = None
|
|
except Exception as e:
|
|
self.logger.error(f"Unexpected error in update(): {e}", exc_info=True)
|
|
self.data = None
|
|
```
|
|
|
|
### Display Error Handling
|
|
|
|
```python
|
|
def display(self, force_clear=False):
|
|
try:
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
|
|
# Check if we have data
|
|
if not self.data:
|
|
self._display_no_data()
|
|
return
|
|
|
|
# Render main content
|
|
self._render_content()
|
|
self.display_manager.update_display()
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error in display(): {e}", exc_info=True)
|
|
# Show error message to user
|
|
try:
|
|
self.display_manager.clear()
|
|
self.display_manager.draw_text(
|
|
"Error",
|
|
x=10, y=16,
|
|
color=(255, 0, 0)
|
|
)
|
|
self.display_manager.update_display()
|
|
except Exception:
|
|
# If even error display fails, log and continue
|
|
self.logger.critical("Failed to display error message")
|
|
|
|
def _display_no_data(self):
|
|
"""Display a message when no data is available."""
|
|
self.display_manager.clear()
|
|
self.display_manager.draw_text(
|
|
"No data",
|
|
x=10, y=16,
|
|
color=(128, 128, 128)
|
|
)
|
|
self.display_manager.update_display()
|
|
```
|
|
|
|
### Validation Error Handling
|
|
|
|
```python
|
|
def validate_config(self) -> bool:
|
|
"""Validate plugin configuration."""
|
|
try:
|
|
# Check required fields
|
|
required_fields = ['api_key', 'city']
|
|
for field in required_fields:
|
|
if field not in self.config or not self.config[field]:
|
|
self.logger.error(f"Missing required field: {field}")
|
|
return False
|
|
|
|
# Validate field types
|
|
if not isinstance(self.config.get('display_duration'), (int, float)):
|
|
self.logger.error("display_duration must be a number")
|
|
return False
|
|
|
|
# Validate ranges
|
|
duration = self.config.get('display_duration', 15)
|
|
if duration < 1 or duration > 300:
|
|
self.logger.error("display_duration must be between 1 and 300 seconds")
|
|
return False
|
|
|
|
return True
|
|
except Exception as e:
|
|
self.logger.error(f"Error validating config: {e}", exc_info=True)
|
|
return False
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Optimization
|
|
|
|
Optimize plugin performance for smooth operation on Raspberry Pi hardware.
|
|
|
|
### Efficient Data Fetching
|
|
|
|
```python
|
|
def update(self):
|
|
# Only fetch if cache is stale
|
|
cache_key = f"{self.plugin_id}_data"
|
|
cached = self.cache_manager.get(cache_key, max_age=3600)
|
|
|
|
if cached and self._is_data_fresh(cached):
|
|
self.data = cached
|
|
return
|
|
|
|
# Fetch only what's needed
|
|
try:
|
|
# Use appropriate cache strategy
|
|
self.data = self._fetch_minimal_data()
|
|
self.cache_manager.set(cache_key, self.data)
|
|
except Exception as e:
|
|
self.logger.error(f"Update failed: {e}")
|
|
# Use stale cache if available (re-fetch with large max_age to bypass expiration)
|
|
expired_cached = self.cache_manager.get(cache_key, max_age=31536000) # 1 year
|
|
if expired_cached:
|
|
self.data = expired_cached
|
|
self.logger.warning("Using stale cached data due to update failure")
|
|
```
|
|
|
|
### Optimized Rendering
|
|
|
|
```python
|
|
def display(self, force_clear=False):
|
|
# Only clear if necessary
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
else:
|
|
# Reuse existing canvas when possible
|
|
pass
|
|
|
|
# Batch drawing operations
|
|
self._draw_background()
|
|
self._draw_content()
|
|
self._draw_overlay()
|
|
|
|
# Single update call at the end
|
|
self.display_manager.update_display()
|
|
```
|
|
|
|
### Memory Management
|
|
|
|
```python
|
|
def cleanup(self):
|
|
"""Clean up resources to free memory."""
|
|
# Clear large data structures
|
|
if hasattr(self, 'large_cache'):
|
|
self.large_cache.clear()
|
|
|
|
# Close connections
|
|
if hasattr(self, 'api_client'):
|
|
self.api_client.close()
|
|
|
|
# Stop threads
|
|
if hasattr(self, 'worker_thread'):
|
|
self.worker_thread.stop()
|
|
|
|
super().cleanup()
|
|
```
|
|
|
|
### Lazy Loading
|
|
|
|
```python
|
|
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
|
self._heavy_resource = None # Load on demand
|
|
|
|
def _get_heavy_resource(self):
|
|
"""Lazy load expensive resource."""
|
|
if self._heavy_resource is None:
|
|
self._heavy_resource = self._load_expensive_resource()
|
|
return self._heavy_resource
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Plugins with Mocks
|
|
|
|
Use mock objects for testing plugins without hardware dependencies.
|
|
|
|
### Basic Mock Setup
|
|
|
|
```python
|
|
from src.plugin_system.testing.mocks import MockDisplayManager, MockCacheManager, MockPluginManager
|
|
|
|
def test_plugin_display():
|
|
# Create mocks
|
|
display_manager = MockDisplayManager()
|
|
cache_manager = MockCacheManager()
|
|
plugin_manager = MockPluginManager()
|
|
|
|
# Create plugin instance
|
|
config = {"enabled": True, "display_duration": 15}
|
|
plugin = MyPlugin("my-plugin", config, display_manager, cache_manager, plugin_manager)
|
|
|
|
# Test display
|
|
plugin.display(force_clear=True)
|
|
|
|
# Verify display calls
|
|
assert len(display_manager.draw_calls) > 0
|
|
assert display_manager.draw_calls[0]['text'] == "Hello"
|
|
```
|
|
|
|
### Testing Cache Behavior
|
|
|
|
```python
|
|
def test_plugin_caching():
|
|
cache_manager = MockCacheManager()
|
|
plugin = MyPlugin("my-plugin", config, display_manager, cache_manager, plugin_manager)
|
|
|
|
# Test cache miss
|
|
plugin.update()
|
|
assert len(cache_manager.get_calls) > 0
|
|
assert len(cache_manager.set_calls) > 0
|
|
|
|
# Test cache hit
|
|
cache_manager.set("my-plugin_data", {"test": "data"})
|
|
plugin.update()
|
|
# Verify no API call was made
|
|
```
|
|
|
|
### Testing Error Handling
|
|
|
|
```python
|
|
def test_error_handling():
|
|
display_manager = MockDisplayManager()
|
|
cache_manager = MockCacheManager()
|
|
plugin = MyPlugin("my-plugin", config, display_manager, cache_manager, plugin_manager)
|
|
|
|
# Simulate API error
|
|
with patch('plugin._fetch_from_api', side_effect=Exception("API Error")):
|
|
plugin.update()
|
|
# Verify plugin handles error gracefully
|
|
assert plugin.data is not None or hasattr(plugin, 'error_state')
|
|
```
|
|
|
|
---
|
|
|
|
## Inter-Plugin Communication
|
|
|
|
Plugins can communicate with each other through the Plugin Manager.
|
|
|
|
### Getting Data from Another Plugin
|
|
|
|
```python
|
|
def update(self):
|
|
# Get weather data from weather plugin
|
|
weather_plugin = self.plugin_manager.get_plugin("weather")
|
|
if weather_plugin and hasattr(weather_plugin, 'current_temp'):
|
|
self.weather_temp = weather_plugin.current_temp
|
|
self.logger.info(f"Got temperature from weather plugin: {self.weather_temp}")
|
|
```
|
|
|
|
### Checking Plugin Status
|
|
|
|
```python
|
|
def update(self):
|
|
# Check if another plugin is enabled
|
|
enabled_plugins = self.plugin_manager.get_enabled_plugins()
|
|
if "weather" in enabled_plugins:
|
|
# Weather plugin is available
|
|
weather_plugin = self.plugin_manager.get_plugin("weather")
|
|
if weather_plugin:
|
|
# Use weather data
|
|
pass
|
|
```
|
|
|
|
### Sharing Data Between Plugins
|
|
|
|
```python
|
|
class MyPlugin(BasePlugin):
|
|
def __init__(self, ...):
|
|
super().__init__(...)
|
|
self.shared_data = {} # Data accessible to other plugins
|
|
|
|
def update(self):
|
|
self.shared_data['last_update'] = time.time()
|
|
self.shared_data['status'] = 'active'
|
|
|
|
# In another plugin
|
|
def update(self):
|
|
my_plugin = self.plugin_manager.get_plugin("my-plugin")
|
|
if my_plugin and hasattr(my_plugin, 'shared_data'):
|
|
status = my_plugin.shared_data.get('status')
|
|
self.logger.info(f"MyPlugin status: {status}")
|
|
```
|
|
|
|
---
|
|
|
|
## Live Priority Implementation
|
|
|
|
Implement live priority to automatically take over the display when your plugin has urgent content.
|
|
|
|
### Basic Live Priority
|
|
|
|
```python
|
|
class MyPlugin(BasePlugin):
|
|
def __init__(self, ...):
|
|
super().__init__(...)
|
|
# Enable live priority in config
|
|
# "live_priority": true
|
|
|
|
def has_live_content(self) -> bool:
|
|
"""Check if plugin has live content."""
|
|
# Check for live games, breaking news, etc.
|
|
return hasattr(self, 'live_items') and len(self.live_items) > 0
|
|
|
|
def get_live_modes(self) -> List[str]:
|
|
"""Return modes to show during live priority."""
|
|
return ['live_mode'] # Only show live mode, not other modes
|
|
```
|
|
|
|
### Sports Plugin Example
|
|
|
|
```python
|
|
class SportsPlugin(BasePlugin):
|
|
def has_live_content(self) -> bool:
|
|
"""Check if there are any live games."""
|
|
if not hasattr(self, 'games'):
|
|
return False
|
|
|
|
for game in self.games:
|
|
if game.get('status') == 'live':
|
|
return True
|
|
return False
|
|
|
|
def get_live_modes(self) -> List[str]:
|
|
"""Only show live game modes during live priority."""
|
|
return ['nhl_live', 'nba_live'] # Exclude recent/upcoming modes
|
|
```
|
|
|
|
### News Plugin Example
|
|
|
|
```python
|
|
class NewsPlugin(BasePlugin):
|
|
def has_live_content(self) -> bool:
|
|
"""Check for breaking news."""
|
|
if not hasattr(self, 'headlines'):
|
|
return False
|
|
|
|
# Check for breaking news flag
|
|
for headline in self.headlines:
|
|
if headline.get('breaking', False):
|
|
return True
|
|
return False
|
|
|
|
def get_live_modes(self) -> List[str]:
|
|
"""Show breaking news mode during live priority."""
|
|
return ['breaking_news']
|
|
```
|
|
|
|
---
|
|
|
|
## Dynamic Duration Support
|
|
|
|
Implement dynamic duration to extend display time until content cycle completes.
|
|
|
|
### Basic Dynamic Duration
|
|
|
|
```python
|
|
class MyPlugin(BasePlugin):
|
|
def __init__(self, ...):
|
|
super().__init__(...)
|
|
self.current_step = 0
|
|
self.total_steps = 5
|
|
|
|
def supports_dynamic_duration(self) -> bool:
|
|
"""Enable dynamic duration in config."""
|
|
return self.config.get('dynamic_duration', {}).get('enabled', False)
|
|
|
|
def is_cycle_complete(self) -> bool:
|
|
"""Return True when all content has been shown."""
|
|
return self.current_step >= self.total_steps
|
|
|
|
def reset_cycle_state(self) -> None:
|
|
"""Reset cycle tracking when starting new display session."""
|
|
self.current_step = 0
|
|
|
|
def display(self, force_clear=False):
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
self.reset_cycle_state()
|
|
|
|
# Display current step
|
|
self._display_step(self.current_step)
|
|
self.display_manager.update_display()
|
|
|
|
# Advance to next step
|
|
self.current_step += 1
|
|
```
|
|
|
|
### Scrolling Content Example
|
|
|
|
```python
|
|
class ScrollingPlugin(BasePlugin):
|
|
def __init__(self, ...):
|
|
super().__init__(...)
|
|
self.scroll_position = 0
|
|
self.scroll_complete = False
|
|
|
|
def supports_dynamic_duration(self) -> bool:
|
|
return True
|
|
|
|
def is_cycle_complete(self) -> bool:
|
|
"""Return True when scrolling is complete."""
|
|
return self.scroll_complete
|
|
|
|
def reset_cycle_state(self) -> None:
|
|
"""Reset scroll state."""
|
|
self.scroll_position = 0
|
|
self.scroll_complete = False
|
|
|
|
def display(self, force_clear=False):
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
self.reset_cycle_state()
|
|
|
|
# Scroll content
|
|
text = "Long scrolling message..."
|
|
text_width = self.display_manager.get_text_width(text, self.display_manager.regular_font)
|
|
|
|
if self.scroll_position < -text_width:
|
|
# Scrolling complete
|
|
self.scroll_complete = True
|
|
else:
|
|
self.display_manager.clear()
|
|
self.display_manager.draw_text(
|
|
text,
|
|
x=self.scroll_position,
|
|
y=16
|
|
)
|
|
self.display_manager.update_display()
|
|
self.scroll_position -= 2
|
|
```
|
|
|
|
---
|
|
|
|
## See Also
|
|
|
|
- [Plugin API Reference](PLUGIN_API_REFERENCE.md) - Complete API documentation
|
|
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - Development workflow
|
|
- [Plugin Architecture Spec](PLUGIN_ARCHITECTURE_SPEC.md) - System architecture
|
|
- [BasePlugin Source](../src/plugin_system/base_plugin.py) - Base class implementation
|
|
|