This guide walks you through creating custom plugins for zeClock. Plugins render content to the DMD display during attract mode — the periods between clock display.
- Getting Started
- Plugin Interface Reference
- Using PluginHelpers
- Using ConfettiAnimation
- Configuration
- Lifecycle
- Reconfiguring Plugins
- Signaling Completion
- Using PagedPlugin
- Error Handling Best Practices
- Installation
- Testing Your Plugin
- Example Plugins
Here's a minimal "hello world" plugin that displays a greeting on the DMD:
"""hello_world_plugin.py - A minimal zeClock plugin."""
from typing import Optional
from PIL import Image
from zeclock.plugins.base import ClockPlugin
class HelloWorldPlugin(ClockPlugin):
"""Displays 'Hello World' on the DMD display."""
@property
def name(self) -> str:
return "hello-world"
@property
def description(self) -> str:
return "Displays a hello world message"
@property
def frame_delay_ms(self) -> int:
return 1000 # Update once per second
async def initialize(self, config: dict) -> None:
self._helpers = config["_helpers"]
self._upscale_mode = config.get("_upscale_mode", "epx")
self._frames_rendered = 0
self._max_frames = config.get("duration_seconds", 5)
async def render_frame(self, width: int, height: int) -> Optional[Image.Image]:
if self._frames_rendered >= self._max_frames:
return None # Signal completion
frame = self._helpers.create_frame()
text_frame = self._helpers.render_text(
"Hello World!", centered=True, y=12, color=(255, 128, 0)
)
frame = self._helpers.composite_frames(frame, text_frame)
self._frames_rendered += 1
return frame
async def cleanup(self) -> None:
self._frames_rendered = 0Save this file to ~/.zeclock/plugins/hello_world_plugin.py and it will be automatically discovered on the next zeClock startup.
All plugins must subclass ClockPlugin from zeclock.plugins.base. The abstract base class enforces the contract at class definition time — missing methods will raise TypeError when you try to instantiate your plugin.
The PLUGIN_API_VERSION constant (currently "1.0") identifies the version of the plugin interface. It is available as a module-level string in zeclock.plugins.base and re-exported from zeclock.plugins.
from zeclock.plugins import PLUGIN_API_VERSION
# Check the API version if your plugin depends on specific features
if PLUGIN_API_VERSION < "2.0":
# Use v1 compatible approach
passPlugin authors can use this constant to gate features or emit warnings when running against an unexpected API version. The version follows semantic versioning principles: minor bumps add features in a backward-compatible way, major bumps may remove or change existing behavior.
| Property | Type | Description | Constraints |
|---|---|---|---|
name |
str |
Unique plugin identifier | 1–64 chars, lowercase alphanumeric, hyphens, underscores. Pattern: ^[a-z0-9_-]{1,64}$ |
description |
str |
Human-readable description | 1–256 characters, non-empty |
frame_delay_ms |
int |
Delay between frames in milliseconds | Integer in range [20, 5000] |
rotatable |
bool |
Whether the plugin participates in automatic rotation | Default: True. Override to False for on-demand-only plugins |
By default, all plugins participate in the scheduler's automatic rotation — they are randomly selected based on their configured frequency. Override rotatable to return False if your plugin should only be activated on demand (e.g. via the web UI force_plugin command or MQTT):
@property
def rotatable(self) -> bool:
return False # Only activated via force_plugin / web UINon-rotatable plugins are still discovered, loaded, and listed by --list-plugins, but the scheduler never selects them automatically. Use this for plugins like a speaker timer or a manual notification display that only make sense when explicitly triggered.
Called once before the first render_frame() call. Use this to:
- Store the
PluginHelpersinstance fromconfig["_helpers"] - Read plugin-specific settings from the config dict
- Fetch initial data (API calls, file loading, etc.)
- Set up internal state
Timeout: 10 seconds. If your initialize() takes longer or raises an exception, the plugin is marked as failed and excluded from scheduling for the session.
Called repeatedly to produce frames for the DMD display. Must return:
- A
PIL.Image.Imagein RGB mode with dimensions(width, height)— the next frame to display None— to signal that the plugin has finished rendering
Timeout: 2 seconds per frame. If a frame takes longer or raises an exception, the last good frame is held on display.
Parameters:
width: Display width in pixels (typically 128 or 256)height: Display height in pixels (typically 32 or 64)
Called when the plugin is deactivated. Use this to:
- Close network connections
- Release file handles
- Reset internal state for the next activation
The PluginHelpers instance is injected into your plugin via the config dict during initialize(). It provides common rendering utilities so you don't need to work with raw PIL operations for typical tasks.
async def initialize(self, config: dict) -> None:
self._helpers = config["_helpers"] # PluginHelpers instanceCreates a blank RGB frame at the display dimensions.
# Black background (default)
frame = self._helpers.create_frame()
# Custom background color
frame = self._helpers.create_frame(color=(0, 0, 32)) # Dark bluerender_text(text, x=0, y=0, color=(255, 128, 0), font_name="STANDARD", centered=False) -> Image.Image
Renders text using the DotClk bitmap font system. Returns a frame-sized image with the text rendered on a black background.
# Positioned text using MENU font (has uppercase letters)
text_frame = self._helpers.render_text("SCORE: 42", x=10, y=5, color=(0, 255, 0), font_name="MENU")
# Centered text
title_frame = self._helpers.render_text("WEATHER", centered=True, y=0, color=(255, 200, 0), font_name="MENU")
# Small text with full ASCII (SYSTEM font)
desc_frame = self._helpers.render_text("Partly cloudy", x=2, y=20, color=(200, 200, 200), font_name="SYSTEM")Parameters:
text: The string to renderx: X position (ignored ifcentered=True)y: Y position for the text topcolor: RGB color tuplefont_name: Font name without extension (e.g."STANDARD","MENU","SYSTEM")centered: Center text horizontally on the frame
The DotClk bitmap fonts have fixed character sets. Characters not in a font render as blank space — no error is raised.
| Font | Height | Characters | Best For |
|---|---|---|---|
STANDARD |
21px | 0-9 : / A M P (space) |
Main clock display only. Too large for plugin content. |
MENU |
11px | 0-9 A-Z & + - . / : < > (space) |
Temperatures, labels, headings. Uppercase only. |
SYSTEM |
7px | Full ASCII (32–126): letters, digits, punctuation | Descriptions, city names, any text. Smallest font. |
Important: The ° (degree) symbol is not available in any font. Use just the unit letter (e.g. "23C" instead of "23°C").
Choosing a font:
- Need full text (lowercase, punctuation)? →
SYSTEM(7px) - Need readable labels/numbers? →
MENU(11px) - Only digits for a clock? →
STANDARD(21px)
On a 32px tall display, you can fit:
- 1 line of STANDARD (21px) — leaves 11px
- 2 lines of MENU (11px each) — leaves 10px
- 4 lines of SYSTEM (7px each) — leaves 4px
- Mix: 1 MENU line + 2 SYSTEM lines = 25px
HD font variants (automatic): On ZeDMD HD displays (256×64), PluginHelpers automatically loads _HD font variants when available (e.g. MENU_HD.fnt instead of MENU.fnt). These provide pixel-perfect 2× glyphs without runtime upscaling. No code changes are needed in your plugin — just call render_text() with the standard font name and the HD variant is used transparently. If no _HD variant exists for a font, the standard version is used with runtime scaling as before.
Draws a pixel-art icon onto an existing frame. Icons are stored as raw bitmap bytes (1 bit per pixel, row-major, MSB first).
# Define a simple 8x8 heart icon (8 bytes)
heart_icon = bytes([
0x66, # .##..##.
0xFF, # ########
0xFF, # ########
0xFF, # ########
0x7E, # .######.
0x3C, # ..####..
0x18, # ...##...
0x00, # ........
])
frame = self._helpers.draw_icon(frame, heart_icon, x=5, y=8, size=(8, 8), color=(255, 0, 0))Composites the foreground onto the background using OR blending (DotBlt style). Black pixels in the foreground are treated as transparent.
frame = self._helpers.create_frame()
text_frame = self._helpers.render_text("Hello", x=10, y=5)
frame = self._helpers.composite_frames(frame, text_frame)This is the standard pattern: create a base frame, render elements into separate layers, then composite them together.
Lists available font names in the resources directory.
fonts = self._helpers.get_font_names()
# e.g. ["MENU", "STANDARD", "SYSTEM"]Calculates the pixel width of text without rendering. Useful for layout calculations.
width = self._helpers.get_text_width("Temperature")
x_pos = (128 - width) // 2 # Center manuallyResolves a color name to an RGB tuple using the shared palette. Useful for letting users configure colors by name in plugin settings.
# Resolve a color from config
color = self._helpers.resolve_color("blue") # (0, 0, 255) or similar
color = self._helpers.resolve_color("nope", "red") # Falls back to redrender_text_right_aligned(text, y, margin=1, color=(255, 128, 0), font_name="STANDARD") -> Image.Image
Renders text right-aligned on the frame with an optional right margin. Handy for scores, values, or any content that should hug the right edge.
# Right-aligned temperature value
temp_frame = self._helpers.render_text_right_aligned("23C", y=5, color=(255, 200, 0), font_name="MENU")
frame = self._helpers.composite_frames(frame, temp_frame)Renders text centered horizontally around a specific x coordinate. Useful for centering text within a column or sub-region of the display.
# Center text in the left half of a 128px display
col_frame = self._helpers.render_text_centered_at("MON", cx=32, y=2, font_name="MENU")
frame = self._helpers.composite_frames(frame, col_frame)Draws a blinking red dot in the top-right corner of the frame. Use this to indicate that displayed data is stale (e.g., cached beyond its freshness window). The dot blinks at approximately 500ms intervals based on the frame count.
if self._is_cache_stale():
total_frames = self._current_page * self._frames_per_page + self._frame_count
self._helpers.draw_staleness_indicator(frame, total_frames, self._frame_delay_ms)render_alert(text, frame_index, border_color=(255, 0, 0), text_color=(255, 255, 255), font_name=None, border_width=3, blink_interval_frames=5) -> Image.Image
Renders a complete alert frame with a blinking colored border and auto-wrapped centered text. Handles word-wrapping automatically so the message fits inside the bordered area. Useful for notification or alarm plugins that need to draw attention.
# Render an alert with a red blinking border
frame = self._helpers.render_alert(
"PERSON DETECTED",
frame_index=self._frame_count,
border_color=(255, 0, 0),
text_color=(255, 255, 255),
font_name="MENU",
)
# Render an alert with an icon
frame = self._helpers.render_alert(
"PERSON DETECTED",
frame_index=self._frame_count,
border_color=(255, 0, 0),
icon="person",
)Parameters:
text: Alert message to display (automatically word-wrapped to fit)frame_index: Current frame counter (used for blink timing)border_color: RGB tuple for the border color (default: red)text_color: RGB tuple for the text color (default: white)font_name: Font to use (default: plugin helpers default font)border_width: Border thickness in pixels (default: 3)blink_interval_frames: Toggle border visibility every N frames (default: 5)icon: Optional alert icon name to display left of the text. Available icons:"beacon","person","vehicle","animal","motion","warning","camera","info". Icons are rendered at 16x16 (SD) or 32x32 (HD) automatically.
The border blinks by alternating between the specified color and black. Text is automatically uppercased (for DMD font compatibility) and centered both horizontally and vertically within the inner area. When an icon is specified, it is displayed vertically centered on the left side and the text area is reduced accordingly.
The ConfettiAnimation class in zeclock.plugins.helpers provides a ready-made particle effect for celebrations, scoring events, or any visual flourish. Particles shoot upward from the bottom (like confetti cannons) and fall back down with gravity.
from zeclock.plugins.helpers import ConfettiAnimation
class MyGamePlugin(ClockPlugin):
async def initialize(self, config: dict) -> None:
self._helpers = config["_helpers"]
self._confetti = ConfettiAnimation(width=128, height=32)
async def render_frame(self, width: int, height: int) -> Optional[Image.Image]:
frame = self._helpers.create_frame()
# Trigger confetti on a score event
if self._just_scored:
self._confetti.start(intensity="medium")
self._just_scored = False
# Update and draw each frame
if self._confetti.is_active:
self._confetti.update()
self._confetti.draw(frame)
return frame| Intensity | Particles | Duration | Use Case |
|---|---|---|---|
"small" |
8 | 1 second | Minor event (point scored) |
"medium" |
20 | 2 seconds | Moderate celebration |
"big" |
40 | 3.5 seconds | Major event (match won) |
Create an animation instance sized to your display.
Launch the confetti. Parameters:
intensity:"small","medium", or"big".colors: Custom color palette as a list of RGB tuples. Defaults toCONFETTI_COLORS_PARTY.origin_x: X position for a single cannon.Nonefires from both left and right sides.
Advance particle physics by one frame. Call once per render loop iteration.
Draw particles onto a PIL Image (modified in place).
Stop the animation immediately and clear all particles.
True while the animation is playing.
True when the animation duration has elapsed.
Three built-in palettes are available as module-level constants:
| Constant | Colors |
|---|---|
CONFETTI_COLORS_PARTY |
Yellow, orange, green, blue, pink, white (default) |
CONFETTI_COLORS_WARM |
Yellow, gold, orange, red-orange, white |
CONFETTI_COLORS_COOL |
Cyan, blue, green, purple, white |
from zeclock.plugins.helpers import ConfettiAnimation, CONFETTI_COLORS_COOL
self._confetti.start(intensity="big", colors=CONFETTI_COLORS_COOL)You can also pass any custom list of (R, G, B) tuples.
Plugins receive their configuration through the config dict passed to initialize(). Plugin-specific settings are defined in ~/.zeclock/config/plugins.yaml.
# ~/.zeclock/config/plugins.yaml
clock_display_seconds: 5 # How long clock shows between plugins (1-300)
plugins:
- name: hello-world
frequency: 30 # 30% chance of selection
settings:
duration_seconds: 5
message: "Hello!"
- name: weather
frequency: 70 # 70% chance of selection
settings:
latitude: 48.8566
longitude: 2.3522
city_name: "Paris"
temperature_unit: "celsius"
page_duration_seconds: 4The config dict passed to initialize() contains everything from the settings map in the YAML, plus special injected keys:
| Key | Type | Description |
|---|---|---|
_helpers |
PluginHelpers |
Rendering utilities (text, icons, frames) |
_upscale_mode |
str |
Upscaling algorithm in use: "epx", "hq2x", or "nearest" |
_font |
str |
Global font name (e.g. "STANDARD") |
language |
str |
Global language setting (e.g. "en", "fr"). Always injected from the top-level config — cannot be overridden per-plugin. |
_context |
PluginContext |
Typed context object (see below) |
async def initialize(self, config: dict) -> None:
self._helpers = config["_helpers"]
self._upscale_mode = config.get("_upscale_mode", "epx") # "epx", "hq2x", or "nearest"
# Read your plugin-specific settings
self._message = config.get("message", "Hello!")
self._duration = config.get("duration_seconds", 5)Use _upscale_mode if your plugin generates imagery that should adapt to the active scaling algorithm — for example, choosing between pixel-art-friendly EPX upscaling and simple nearest-neighbour scaling when compositing custom graphics.
As an alternative to accessing raw dict keys, you can use config["_context"] which is a PluginContext dataclass instance. This provides typed, IDE-friendly access to infrastructure objects and separates them from user settings.
from zeclock.plugins.base import PluginContext
async def initialize(self, config: dict) -> None:
ctx: PluginContext = config["_context"]
# Infrastructure (typed attributes)
self._helpers = ctx.helpers # Same as config["_helpers"]
self._upscale_mode = ctx.upscale_mode # Same as config["_upscale_mode"]
self._font = ctx.font # Same as config["_font"]
# User settings from plugins.yaml (dict)
self._city = ctx.settings.get("city_name", "")
self._api_key = ctx.settings.get("api_key")The PluginContext fields are:
| Field | Type | Default | Description |
|---|---|---|---|
helpers |
PluginHelpers |
(required) | The PluginHelpers rendering instance |
upscale_mode |
str |
"epx" |
Active upscaling algorithm |
font |
str |
"STANDARD" |
Global font name |
settings |
Dict[str, Any] |
{} |
User YAML settings for this plugin |
The raw dict keys (config["_helpers"], config["_upscale_mode"], config["_font"]) remain available for backward compatibility with existing plugins.
If your plugin declares a config_schema with required fields, the PluginManager validates the user settings before calling initialize(). Specifically, for each field where required=True and default is None, the manager checks that the field name is present in the user YAML settings. If it is missing, the plugin is automatically marked as "unconfigured" (not "failed") and a "configure me" message is displayed on the DMD.
This means you no longer need to manually check for missing required fields inside initialize() and raise PluginNotConfiguredError yourself. The validation happens transparently:
@property
def config_schema(self) -> List[ConfigField]:
return [
ConfigField(name="api_key", label="API Key", field_type="text",
required=True, description="Your API key"),
ConfigField(name="refresh_interval", label="Refresh", field_type="number",
required=True, default=60, description="Seconds between refreshes"),
]In this example:
- If
api_keyis missing from the YAML settings, the plugin is automatically marked unconfigured (becauserequired=Trueanddefault=None). - If
refresh_intervalis missing, the plugin still initializes because it has a non-Nonedefaultvalue of60.
Fields with required=False are never validated regardless of their default value.
| Attribute | Type | Default | Description |
|---|---|---|---|
name |
str |
(required) | Key in plugins.yaml settings dict |
label |
str |
(required) | Human-readable label for Web UI (max 50 chars) |
field_type |
str |
(required) | Widget type (see table below) |
required |
bool |
True |
Whether the field must be present in config |
description |
str |
"" |
Help text shown in the Web UI (max 200 chars) |
default |
Any |
None |
Default value when not provided by user |
options |
List[Dict[str, str]] |
[] |
Choices for "select" fields (list of {"value": "...", "label": "..."} dicts) |
field_type |
Web UI Widget | Notes |
|---|---|---|
"text" |
Free-text input | General string values |
"number" |
Numeric input | Integer or float values |
"boolean" |
Toggle switch | Stored as "yes"/"no" in config |
"city" |
City autocomplete | Deprecated — use "location" instead |
"location" |
Location picker with autocomplete | Stores {"display_name", "latitude", "longitude", "country"} |
"list" |
Free-text input | Comma-separated values |
"select" |
Dropdown list | Requires options field with {"value": "...", "label": "..."} entries |
Example using "select":
@property
def config_schema(self) -> List[ConfigField]:
return [
ConfigField(
name="unit", label="Temperature Unit", field_type="select",
required=True, default="celsius",
description="Display unit for temperature values",
options=[
{"value": "celsius", "label": "Celsius (°C)"},
{"value": "fahrenheit", "label": "Fahrenheit (°F)"},
],
),
]The pixel-art upscaling functions from overlay.py are re-exported from zeclock.plugins, so you can import them directly without reaching into internal modules:
from zeclock.plugins import upscale_nx, upscale_2x, hq2x, scale3x, epx_upscale_2x, nearest_upscale_2x| Function | Description |
|---|---|
upscale_nx(img, scale, mode="epx") |
Preferred entry point for arbitrary integer scale factors. Routes to the best algorithm: upscale_2x for scale=2, scale3x for scale=3 with mode="epx", nearest-neighbor for all other scales. |
upscale_2x(img, mode="epx") |
Dispatcher for 2× upscaling. mode="epx" (default), mode="hq2x", or mode="nearest". |
hq2x(img) |
hq2x (High Quality 2×) algorithm. Smoother curves and anti-aliased diagonals via interpolation; may introduce intermediate gray values. Best for pre-computed content. Works on grayscale (L mode) images. |
scale3x(img) |
Scale3x/AdvMAME3x algorithm. Extends EPX to 3× — each source pixel expands to a 3×3 block. Works on grayscale (L mode) images. Useful for e.g. 128×32 → 384×96. |
epx_upscale_2x(img) |
EPX/Scale2x algorithm. Smooths diagonal edges without introducing new colors. Works on grayscale (L mode) images. |
nearest_upscale_2x(img) |
Simple pixel doubling via Image.Resampling.NEAREST. Fastest, works on any PIL mode. |
All functions preserve mask_data / mask_width_bytes attributes on the source image, so DotBlt compositing remains correct after scaling.
Typical use case — scaling a custom SD graphic to fit an HD display at any scale factor:
from PIL import Image
from zeclock.plugins import upscale_nx
async def render_frame(self, width: int, height: int) -> Optional[Image.Image]:
sd_frame = self._render_sd_content() # returns a 128×32 'L' mode image
sd_w, sd_h = 128, 32
if width != sd_w:
scale = width // sd_w # e.g. 2 for 256×64, 3 for 384×96
sd_frame = upscale_nx(sd_frame, scale, mode=self._upscale_mode)
# ... composite and return ...Use self._upscale_mode (from config.get("_upscale_mode", "epx")) so your plugin respects the user's configured algorithm.
The frequency value (0–100) determines how likely your plugin is to be selected when the clock transitions to attract mode. Frequencies are normalized across all active plugins so they sum to 100%. For example, if two plugins have frequencies 70 and 30, they'll be selected 70% and 30% of the time respectively.
Understanding the plugin lifecycle helps you manage resources correctly.
Discovery → Loading → Initialization → Activation (Rendering) → Deactivation
-
Discovery: On startup, the Plugin Manager scans plugin directories for Python files containing a
ClockPluginsubclass. -
Loading: Valid plugin classes are imported and registered by name. Invalid files (syntax errors, missing dependencies) are skipped with a warning.
-
Initialization: When a plugin is selected for display,
initialize(config)is called with the plugin's configuration. You have 10 seconds to complete initialization. -
Activation (Rendering):
render_frame(width, height)is called repeatedly at the interval specified byframe_delay_ms. This continues until:- Your plugin returns
None(signaling completion) - 30 seconds of total render time elapses (maximum duration)
- 5 consecutive render errors occur (automatic deactivation)
- Your plugin returns
-
Deactivation:
cleanup()is called to release resources. The display transitions back to clock-only mode.
| Phase | Timeout | Consequence of Timeout |
|---|---|---|
initialize() |
10 seconds | Plugin marked as failed, excluded from session |
render_frame() |
2 seconds per call | Last good frame held, error counter incremented |
| Total activation | 30 seconds max | Normal completion, transition to clock |
The reconfigure(config) method is called when plugin settings are changed via the Web UI without restarting the application. The default implementation simply calls initialize(config) again, which works for stateless plugins.
Override reconfigure() if your plugin:
- Holds open network connections (HTTP sessions, WebSocket connections)
- Runs background tasks or timers
- Caches data that should be invalidated on config change
- Manages resources that need explicit teardown
The PluginManager does NOT call cleanup() before reconfigure(). This is a deliberate design choice: reconfiguration should be lighter than a full deactivate/reactivate cycle. However, it means your plugin must handle resource cleanup internally:
async def reconfigure(self, config: dict) -> None:
# Close existing resources BEFORE re-initializing
if self._session:
await self._session.close()
if self._background_task:
self._background_task.cancel()
# Now re-initialize with new config
await self.initialize(config)If you do not close resources in reconfigure(), you may leak connections or create duplicate background tasks.
If you do not override reconfigure(), the default implementation calls initialize(config). This is safe for plugins that:
- Store only simple configuration values
- Do not hold persistent connections
- Re-fetch data on each activation anyway
# Default implementation (inherited from ClockPlugin):
async def reconfigure(self, config: dict) -> None:
await self.initialize(config)If reconfigure() raises PluginNotConfiguredError, the plugin is marked as unconfigured (same as during initial activation). If it raises any other exception, the reconfiguration is considered failed but the plugin is not removed from rotation.
Return None from render_frame() to tell the Plugin Manager that your plugin has finished displaying its content:
async def render_frame(self, width: int, height: int) -> Optional[Image.Image]:
if self._done:
return None # Done! Transition back to clock.
# ... render your frame ...
return frameAfter returning None:
cleanup()is called- The display transitions back to clock-only mode
- After
clock_display_secondselapses, another plugin is selected
If you never return None, the 30-second maximum duration will stop your plugin automatically. This is not treated as an error.
For plugins with ongoing activity (games, simulations), you can yield back to the clock at natural break points rather than at arbitrary frame counts. This ensures the clock never interrupts mid-action:
async def render_frame(self, width: int, height: int) -> Optional[Image.Image]:
# Only yield during a natural pause (e.g., between rounds)
if self._point_scored and self._in_pause_state and self._frames_rendered > 20:
return None # Clean break — clock takes over between points
# Max duration reached — but only yield if we're at a safe point
if self._frames_rendered >= self._max_frames and self._in_pause_state:
return None
# ... continue rendering ...
return frameThe plugin will be re-activated later and can resume from where it left off (see Persistent State below).
Any plugin can be configured as the default plugin (shown between rotations) via the default_plugin field in plugins.yaml. The built-in clock plugin is the default.
When used as the default plugin:
- Display: The plugin's
render_frame()is called continuously forclock_display_seconds(configured globally). - Transition: When time elapses, the system calls
cleanup()then selects a rotation plugin. - Resume: After the rotation plugin finishes, the default plugin is re-initialized via
initialize()and displayed again.
This means cleanup() is called between each display cycle. Plugins with multiple pages can use this to advance their page counter:
class MyDefaultPlugin(ClockPlugin):
def __init__(self):
self._current_page = 0
self._total_pages = 3
async def render_frame(self, width, height):
# Always render the current page (never return None as default plugin)
return self._render_page(self._current_page, width, height)
async def cleanup(self):
# Advance to next page for the next display cycle
self._current_page = (self._current_page + 1) % self._total_pagesKey rules for default plugins:
render_frame()should never returnNone— the main loop controls the display duration- Use
cleanup()to advance internal state (page counter, animation phase, etc.) - State set in
__init__()persists across activations (the instance is reused) initialize()is called before each display cycle — reset timers but preserve page position
By default, plugin state is reset in cleanup() between activations. However, some plugins (games, ongoing simulations) benefit from preserving state so they can resume where they left off.
Use class-level attributes to persist state across activations:
class MyGamePlugin(ClockPlugin):
"""A game that persists across activations."""
# Class-level state — survives across activations
_score: int = 0
_level: int = 1
_game_initialized: bool = False
async def initialize(self, config: dict) -> None:
self._helpers = config["_helpers"]
# Only set up game state on first-ever activation
if not MyGamePlugin._game_initialized:
MyGamePlugin._score = 0
MyGamePlugin._level = 1
MyGamePlugin._game_initialized = True
# Per-activation state (instance-level, reset each time)
self._frames_rendered = 0
async def render_frame(self, width: int, height: int) -> Optional[Image.Image]:
# Read/write persistent state via the class
MyGamePlugin._score += 1
# ...
async def cleanup(self) -> None:
# Only reset per-activation state — persistent state stays on the class
self._frames_rendered = 0Key points:
- Class-level attributes persist for the lifetime of the process (until zeClock restarts)
- Instance attributes (
self._foo) are reset each activation viacleanup() - Initialize persistent state only once using a
_game_initializedflag - Combine with cooperative yielding to create games that resume between clock displays
For plugins that display multiple pages of content (e.g., weather forecasts, stock tickers), subclass PagedPlugin instead of ClockPlugin. It handles frame counting, page advancement, and automatic completion signaling.
"""paged_example.py - Plugin using PagedPlugin for multi-page display."""
from PIL import Image
from zeclock.plugins.base import PagedPlugin
class MyPagedPlugin(PagedPlugin):
"""Cycles through multiple pages of content."""
@property
def name(self) -> str:
return "my-paged-plugin"
@property
def description(self) -> str:
return "Displays multiple pages of information"
async def initialize(self, config: dict) -> None:
self._helpers = config["_helpers"]
self._data = ["Page 1 content", "Page 2 content", "Page 3 content"]
# Set up paging: 3 pages, 5 seconds each, 10 FPS
page_duration = config.get("page_duration_seconds", 5)
self._init_paging(
total_pages=len(self._data),
page_duration_seconds=page_duration,
frame_delay_ms=100,
)
def render_page(self, page: int, width: int, height: int) -> Image.Image:
"""Render a single page. Called automatically by the paging logic."""
frame = self._helpers.create_frame()
text_frame = self._helpers.render_text(
self._data[page], centered=True, y=12, font_name="MENU"
)
return self._helpers.composite_frames(frame, text_frame)| Method / Attribute | Description |
|---|---|
_init_paging(total_pages, page_duration_seconds=4, frame_delay_ms=100) |
Call from initialize() to set up page cycling. page_duration_seconds is clamped to 2–30. |
render_page(page, width, height) -> Image.Image |
Abstract — implement this instead of render_frame(). Receives the zero-based page index. |
frame_delay_ms |
Managed automatically by PagedPlugin (set via _init_paging). |
render_frame() |
Handled internally — advances pages and returns None after all pages are shown. |
cleanup() |
Resets paging state. Override and call await super().cleanup() if you need additional cleanup. |
The Plugin Manager is designed to be resilient. Here's how errors are handled and how to write robust plugins:
| Situation | Plugin Manager Response |
|---|---|
render_frame() raises an exception |
Last good frame held on display, error counter +1 |
render_frame() exceeds 2s timeout |
Same as exception — last frame held, counter +1 |
| 5 consecutive render errors | Plugin deactivated for the session |
| Successful frame after errors | Error counter resets to 0 |
- Handle network failures gracefully: If your plugin fetches data, cache it and display cached data when the network is unavailable.
async def render_frame(self, width: int, height: int) -> Optional[Image.Image]:
if self._data is None:
# No data available, signal completion rather than erroring
return None
# ... render with cached data ...- Don't let exceptions bubble up from render_frame: Catch expected errors and either display a fallback or signal completion.
async def render_frame(self, width: int, height: int) -> Optional[Image.Image]:
try:
data = self._process_data()
return self._render(data, width, height)
except ValueError:
# Data is bad, signal completion
return None-
Keep render_frame fast: The 2-second timeout is generous, but aim for milliseconds. Do heavy work in
initialize()or in a background task. -
Use initialize() for setup that can fail: If your plugin needs an API key, file, or network resource, validate it in
initialize(). Raising there cleanly excludes the plugin rather than causing repeated render errors. -
Clean up resources in cleanup(): Close connections, cancel timers, and reset state so the plugin is ready for the next activation.
Place your plugin file in the user plugin directory:
~/.zeclock/plugins/your_plugin.py
The directory is created automatically on first startup if it doesn't exist. Your plugin will be discovered on the next zeClock startup.
The file name doesn't matter for discovery — the Plugin Manager looks for classes that subclass ClockPlugin. However, using a descriptive name like weather_plugin.py or scoreboard_plugin.py is recommended.
If your plugin returns the same name as a built-in plugin (e.g. "weather" or "pinball"), your version replaces the built-in. This is logged at INFO level.
Add your plugin to ~/.zeclock/config/plugins.yaml:
plugins:
- name: hello-world
frequency: 50
settings:
duration_seconds: 10Or use the CLI to test it immediately:
# Run only your plugin
zeclock --plugins hello-world
# Run alongside other plugins
zeclock --plugins hello-world,weatherUse --list-plugins to confirm your plugin was discovered:
zeclock --list-pluginsOutput:
pinball Pinball animation display active
pong Pong game with real scoring and human-like AI active
gif Displays animated GIFs on the DMD active
weather Current weather and forecast display active
stock Stock prices and daily change display active
hello-world Displays a hello world message active
You can test your plugin in isolation without running the full zeClock application.
"""test_my_plugin.py - Test a plugin in isolation."""
import asyncio
from pathlib import Path
from PIL import Image
from zeclock.plugins.helpers import PluginHelpers
# Import your plugin
from my_plugin import MyPlugin
async def test_plugin():
# Set up helpers (adjust resources_path to your zeClock installation)
resources_path = Path.home() / ".zeclock" / "resources"
helpers = PluginHelpers(width=128, height=32, resources_path=resources_path)
# Create and initialize the plugin
plugin = MyPlugin()
config = {
"_helpers": helpers,
"_upscale_mode": "epx",
# Add your plugin-specific settings here
"my_setting": "value",
}
await plugin.initialize(config)
# Render frames until completion
frame_count = 0
while True:
frame = await plugin.render_frame(128, 32)
if frame is None:
print(f"Plugin completed after {frame_count} frames")
break
# Validate frame
assert isinstance(frame, Image.Image), "Must return PIL Image"
assert frame.mode == "RGB", f"Expected RGB, got {frame.mode}"
assert frame.size == (128, 32), f"Expected (128, 32), got {frame.size}"
frame_count += 1
if frame_count > 1000:
print("Safety limit reached")
break
await plugin.cleanup()
print("Test passed!")
if __name__ == "__main__":
asyncio.run(test_plugin())"""test_my_plugin.py - pytest tests for a custom plugin."""
import pytest
import pytest_asyncio
from pathlib import Path
from unittest.mock import MagicMock
from PIL import Image
from zeclock.plugins.helpers import PluginHelpers
@pytest.fixture
def helpers(tmp_path):
"""Create a PluginHelpers instance for testing."""
fonts_dir = tmp_path / "Fonts"
fonts_dir.mkdir()
return PluginHelpers(width=128, height=32, resources_path=tmp_path)
@pytest.fixture
def config(helpers):
"""Create a basic config dict."""
return {
"_helpers": helpers,
"_upscale_mode": "epx",
"my_setting": "test_value",
}
@pytest.mark.asyncio
async def test_plugin_initializes(config):
from my_plugin import MyPlugin
plugin = MyPlugin()
await plugin.initialize(config)
# No exception means success
@pytest.mark.asyncio
async def test_plugin_renders_valid_frames(config):
from my_plugin import MyPlugin
plugin = MyPlugin()
await plugin.initialize(config)
frame = await plugin.render_frame(128, 32)
assert frame is not None
assert isinstance(frame, Image.Image)
assert frame.mode == "RGB"
assert frame.size == (128, 32)
await plugin.cleanup()
@pytest.mark.asyncio
async def test_plugin_signals_completion(config):
from my_plugin import MyPlugin
plugin = MyPlugin()
await plugin.initialize(config)
# Render until completion
for _ in range(10000):
frame = await plugin.render_frame(128, 32)
if frame is None:
break
else:
pytest.fail("Plugin never signaled completion")
await plugin.cleanup()# Save individual frames as PNG for visual debugging
frame = await plugin.render_frame(128, 32)
frame.save("frame_001.png")
# Or scale up for easier viewing (DMD pixels are tiny)
scaled = frame.resize((512, 128), Image.NEAREST)
scaled.save("frame_001_scaled.png")The built-in weather plugin (zeclock/plugins/weather_plugin.py) is a complete real-world example. Here's an annotated walkthrough of its key patterns:
"""WeatherPlugin - Annotated example of a production zeClock plugin."""
from typing import Optional
from PIL import Image
from zeclock.plugins.base import ClockPlugin
class WeatherPlugin(ClockPlugin):
"""Displays weather conditions and forecasts from Open-Meteo API."""
# --- Properties ---
# These are simple, returning fixed or computed values.
@property
def name(self) -> str:
return "weather" # Lowercase, alphanumeric + hyphens/underscores
@property
def description(self) -> str:
return "Current weather and forecast display" # Max 256 chars
@property
def frame_delay_ms(self) -> int:
return self._frame_delay_ms # 100ms = 10 FPS for smooth transitions
# --- Initialization ---
# Validate config, set up state, fetch initial data.
async def initialize(self, config: dict) -> None:
# Always grab the helpers instance first
self._helpers = config.get("_helpers")
# Read required configuration — signal completion if missing
self._latitude = config.get("latitude")
self._longitude = config.get("longitude")
self._city_name = config.get("city_name", "")
# Language is a global setting — always injected into config by PluginConfig
self._language = config.get("language", "en")
missing = []
if self._latitude is None:
missing.append("latitude")
if self._longitude is None:
missing.append("longitude")
if not self._city_name:
missing.append("city_name")
if missing:
# Log and mark as not initialized — render_frame will return None
self._initialized = False
return
# Read optional settings with defaults and clamping
self._temperature_unit = config.get("temperature_unit", "celsius")
page_duration = config.get("page_duration_seconds", 4)
self._page_duration_seconds = max(2, min(30, int(page_duration)))
# Pre-calculate frame counts for page cycling
self._frames_per_page = (
self._page_duration_seconds * 1000 + self._frame_delay_ms - 1
) // self._frame_delay_ms
# Reset state for this activation
self._current_page = 0
self._frame_count = 0
self._initialized = True
# Fetch weather data (uses 15-minute cache)
await self._refresh_cache_if_needed()
# --- Rendering ---
# Cycle through 4 pages, return None when done.
async def render_frame(self, width: int, height: int) -> Optional[Image.Image]:
# Guard: not initialized or no data
if not self._initialized:
return None
if self._cache is None:
return None
# All pages displayed? Signal completion.
if self._current_page >= 4:
return None
# Render the current page using helpers
frame = self._render_page(self._current_page, width, height)
# Advance frame counter, switch pages when duration reached
self._frame_count += 1
if self._frame_count >= self._frames_per_page:
self._frame_count = 0
self._current_page += 1
return frame
# --- Cleanup ---
# Reset state so the plugin is ready for next activation.
async def cleanup(self) -> None:
self._current_page = 0
self._frame_count = 0
# --- Rendering Helpers (private) ---
# Each page is a separate method for clarity.
def _render_page(self, page: int, width: int, height: int) -> Image.Image:
frame = self._helpers.create_frame()
if page == 0:
# Current conditions: icon + temperature + description + city
icon_data = get_weather_icon(self._cache.current_condition_code)
frame = self._helpers.draw_icon(frame, icon_data, 2, 1, (16, 16))
temp_str = f"{round(self._cache.current_temp)}°C"
temp_frame = self._helpers.render_text(temp_str, x=20, y=1, color=(255, 128, 0))
frame = self._helpers.composite_frames(frame, temp_frame)
elif page == 1:
# Tomorrow: label + icon + high/low temps
label = self._helpers.render_text("Tomorrow", x=2, y=0, color=(255, 200, 0))
frame = self._helpers.composite_frames(frame, label)
elif page == 2:
# 3-day outlook: 3 columns with icons and high temps
col_width = width // 3
for i, day in enumerate(self._cache.forecast_days[:3]):
icon_x = i * col_width + (col_width - 16) // 2
icon_data = get_weather_icon(day.condition_code)
frame = self._helpers.draw_icon(frame, icon_data, icon_x, 2, (16, 16))
elif page == 3:
# 7-day overview: 7 narrow columns with single-letter day,
# 12x12 icon, and high temp
col_width = width // 7
for i, day in enumerate(self._cache.forecast_days[:7]):
col_x = i * col_width
icon_x = col_x + (col_width - 12) // 2
icon_data = get_weather_icon(day.condition_code)
frame = self._helpers.draw_icon(frame, icon_data, icon_x, 9, (12, 12))
return frame- Store helpers in initialize:
self._helpers = config["_helpers"] - Validate config early: Check required fields in
initialize(), set a flag if invalid - Guard render_frame: Return
Noneimmediately if not properly initialized - Track frame count for page timing: Use
frame_delay_msand page duration to calculate frames per page - Use composite_frames for layering: Render text/icons into separate frames, then composite onto the base
- Signal completion explicitly: Return
Nonewhen your content is fully displayed - Reset state in cleanup: Prepare for the next activation cycle