Plugin API reference
This page is for developers writing a plugin. It catalogs the entire public led_ticker.plugin surface: the register(api) entry point, every registration method, the names you can import, and the conventions a plugin follows. For a guided, build-it-up introduction, start with the plugin authoring guide; come here when you need the exact shape of something.
The register(api) contract
Section titled “The register(api) contract”A plugin is a Python package that exposes a top-level register(api) function under the led_ticker.plugins entry-point group — an entry point is how an installed package advertises a hook other code can discover. The loader finds it, builds a PluginAPI bound to your plugin’s namespace (the prefix every registered name gets), and calls it:
from led_ticker.plugin import PluginAPI
def register(api: PluginAPI) -> None: @api.widget("clock") class Clock: def draw(self, canvas, frame): ...[project.entry-points."led_ticker.plugins"]my_plugin = "my_plugin:register"Two rules govern every registration:
- Auto-namespacing. Every name you register is prefixed with your plugin’s namespace —
api.widget("clock")in theacmeplugin registersacme.clock(written in config astype = "acme.clock"). You cannot register a bare name or shadow a built-in. - Atomic load. Calls buffer until
register()returns cleanly. Ifregister()raises, the whole plugin is discarded — there is no half-registered state.
API_VERSION (currently (1, 1)) is exported so a plugin can check the surface version it was built against.
Registration methods
Section titled “Registration methods”PluginAPI exposes fourteen registration methods. The decorator forms register a class; the call forms register a value directly. Several examples below draw onto a canvas — the pixel grid for the current frame (the Canvas type, listed under Exported names).
Visual building blocks
Section titled “Visual building blocks”| Method | Form | Registers |
|---|---|---|
api.widget(name) | decorator | A widget class under namespace.name |
api.transition(name) | decorator | A transition class |
api.backend(name) | decorator | A rendering backend class (selectable as [display] backend = "namespace.name") |
api.color_provider(style) | decorator | A color provider class |
api.animation(style) | decorator | An animation class |
api.border(name) | decorator | A border effect class |
api.source(name) | decorator | A DataSource subclass under namespace.name — powers inline :namespace.name: value tokens |
api.easing(name, fn) | call | An easing function (float) -> float |
@api.widget("clock")class Clock: def draw(self, canvas, frame): ...
@api.transition("swirl")class Swirl: def frame_at(self, t, canvas, outgoing, incoming, **kw): ...
@api.backend("telnet")class TelnetBackend: def setup(self): ... def create_canvas(self): ... def swap(self, canvas): ...
api.easing("snap", lambda t: 0.0 if t < 0.5 else 1.0)In a transition’s frame_at, t runs 0 → 1; outgoing and incoming each have a .draw(canvas); you render the in-between frame onto canvas, and the return value is ignored. A full walkthrough (building a wipe) is coming in the Extending section.
Assets
Section titled “Assets”| Method | Form | Registers |
|---|---|---|
api.emoji(slug, data) | call | A low-res 8×8 emoji (PixelData) under namespace.slug |
api.hires_emoji(slug, data) | call | A hi-res emoji (HiResEmoji) for scaled-canvas draws |
api.font(name, path) | call | A font file; path is relative to the plugin root |
→ Worked example: Custom emoji covers the PixelData format, hi-res sprites, and a PNG→pixels recipe.
Lifecycle hooks
Section titled “Lifecycle hooks”| Method | Form | Registers |
|---|---|---|
api.overlay(paint) | call | A paint(canvas) run every frame before the hardware swap |
api.on_startup(fn) | call | A hook run once after the frame + session exist; receives a StartupContext |
api.on_shutdown(fn) | call | A hook run best-effort when the run loop exits |
Exported names
Section titled “Exported names”These are the names you can import from led_ticker.plugin — the types you’ll subclass or annotate against. They’re the stable surface, so anything you build on them keeps working as led-ticker evolves.
| Name | What it is |
|---|---|
PluginAPI | The namespace-bound registrar passed to register(api) |
StartupContext | Frozen dataclass passed to an on_startup hook (frame, session, config) |
ValidationContext | Geometry passed to validate_config_warnings (scale, content_height, panel_width, panel_height, config_dir) |
API_VERSION | (major, minor) tuple of the plugin surface version |
Base classes & protocols
Section titled “Base classes & protocols”| Name | What it is |
|---|---|
Widget | The widget protocol (a draw() or play-style widget) |
Container | Monitor/container widget base (cycles a live feed_stories list) |
Updatable | Protocol for widgets with async def update(self) |
Transition | The transition protocol (frame_at) |
ColorProvider | The color-provider protocol (color_for) |
ColorProviderBase | Base that enforces the frame_invariant class attr |
Animation | The animation protocol (frame_for) |
BorderEffect | The border protocol (paint) |
BorderEffectBase | Base that enforces the frame_invariant class attr |
DataSource | Base class for an inline value source — subclass, implement compute() -> str, register with api.source(name) |
PolledDataSource | Base class for async (network-backed) sources — subclass, implement async def update(), call self._set_value(...) inside it |
SegmentMessage | Re-exported single-line message widget (compose stories from it) |
TwoRowMessage | Re-exported two-row widget |
TickerMessage | Re-exported single-region scrolling message widget (compose/subclass for rich stories) |
FrameAwareBase | Base for an animated widget — inherit (with @attrs.define) and read effect counters via self.frame_for(name); the engine drives the counters. |
Data & types
Section titled “Data & types”| Name | What it is |
|---|---|
Canvas | The draw target (SetPixel, width, height) |
Color | An rgbmatrix color value |
ColorTuple | Type alias tuple[int, int, int] for raw RGB |
DrawResult | The return shape of a widget draw() |
AnimationFrame | The return shape of Animation.frame_for |
LensSpec | Frozen fisheye-lens descriptor an animation returns on AnimationFrame.lens (magnify, edge_squeeze, profile); the engine owns the warp math, a plugin ships only the spec |
PixelData | list[(x, y, r, g, b)] — one tuple per lit pixel of a low-res emoji |
HiResEmoji | A hi-res emoji sprite — pixels ((x,y,r,g,b) in physical coords), physical_size, optional physical_width |
Font | A resolved font handle |
HiresFont | A resolved hi-res font |
HiresSpec | Frozen sprite spec for a hi-res transition: sprite_path (Path to a gif/webp), flip_horizontal, trail ("none"/"black"/"rainbow") |
RotationSurface | Offscreen rotation surface for transition authors — construct once per transition instance, snapshot the outgoing on the first frame, blit per frame; re-fire detection via the t < _last_t pattern (see below) |
ScaledCanvas | The wrapper widgets receive when default_scale > 1; use is_scaled(canvas) to gate hi-res paths |
FONT_DEFAULT | The bundled default BDF font as a ready-to-use Font constant |
FONT_SMALL | The bundled small BDF font as a ready-to-use Font constant |
Helpers
Section titled “Helpers”| Name | What it is |
|---|---|
make_color(r, g, b) | Build a Color from RGB components (0–255) |
make_rotation_surface(canvas) | Factory for a construct-once RotationSurface bound to the canvas’s scale policy — the seam for transition authors; pass the live canvas on each blit() call, not just at construction |
as_color_provider(color) | Wrap a constant Color as a uniform (non-animated) ColorProvider — e.g. a widget’s default font color |
coerce_color_provider(value, context="font_color") | Parse a TOML color spec (constant [r,g,b]; "rainbow"/"color_cycle"/"shimmer"/"random"; or {style=…} table) into a ColorProvider. Use for a plugin’s own color fields. |
draw_text(canvas, font, text, x, y, color) | Draw text (inline :emoji: included); returns the next x |
draw_with_emoji(canvas, font, cursor_pos, y, color, text, ...) | Rich-text renderer accepting a per-char ColorProvider (draw_text is the simpler form when the color is a single Color; both render inline :emoji:) |
count_text_chars(text) | Count rendered characters in text, counting each :emoji: token as one (pair with a per-char provider’s total_chars) |
draw_text_per_char(...) | Lower-level per-character text draw used by data widgets |
get_text_width(font, text) | Pixel width of text in font |
compute_baseline(font, ...) | The baseline y for a font |
compute_cursor(canvas_width, content_width, cursor_pos, padding, center) | Resolve a start cursor + end padding for content of a given width (centering / overflow math); the second return value is inter-widget spacing for side-by-side mode — if you only need centering, use the first return value |
resolve_font(name, ...) | Resolve a font by name (bundled, plugin, or BDF) |
draw_emoji_at(canvas, slug, x, y) | Draw a registered emoji at a position |
measure_emoji_at(canvas, slug) | Measure a registered emoji |
colors | The built-in named-color module |
format_clock(...) | Format a time per led-ticker’s clock conventions (12h / 24h presets, or a strftime template) |
run_monitor_loop(widget, interval, ..., register_monitor=True) | The periodic-refresh loop for a monitor widget; register_monitor=False opts out of status-board registration (busy_light only) |
spawn_tracked(coro) | Spawn a tracked background task from a start() / hook |
safe_scale(canvas) | The canvas’s logical→physical scale (1 when unscaled) |
compute_baseline_for_band(font, h, scale) | Baseline for text within an arbitrary band height (multi-row layouts) |
measure_width(font, text) | Logical width of text incl. inline :emoji: (layout math) |
resolve_band_heights(...) | Split a canvas height into row bands (shared with two-row/image) |
EMOJI_ROW_CAP | The 8×8 low-res emoji/sprite height — per-row sprite cap for band layouts |
ENGINE_TICK_MS | Engine render cadence in milliseconds (50 ms = 20 fps); use for time-based animation math |
font_line_height_logical(font, scale) | A font’s logical line height (never hardcode a cell height) |
unwrap_to_real(canvas) | The raw underlying real canvas for direct SetPixel at native res |
paint_hires(canvas, callback) | Unwrap + forward scale/y_offset_real to a hi-res paint callback |
is_scaled(canvas) | True if canvas is a scaled wrapper — the documented gate for hi-res paths |
snap_reset(canvas, incoming_bg_color) | Clear + repaint a transition’s incoming background (hi-res snap helper) |
render_hires_frame(t, canvas, outgoing, incoming, spec, **kwargs) | Paint one frame of a hi-res sprite traversing the panel for the given HiresSpec (use on a ScaledCanvas) |
SNAP_THRESHOLD | The transition t at which to snap to the incoming frame (call snap_reset at/after this) |
Backend authoring
Section titled “Backend authoring”| Name | What it is |
|---|---|
Backend | The rendering-backend protocol (setup(), create_canvas(), swap(), brightness()); brightness is buffered before setup() and live after |
BackendNotReadyError | Raised when a canvas/swap/brightness op is attempted before setup() |
register_backend(name) | Internal / built-in use only (plugin authors use api.backend() — it auto-namespaces). Class decorator registering a backend under a bare name (selectable via [display] backend = "name") |
run_backend_conformance(factory) | Run the importable conformance suite (the hardware-rendering constraints) against backends from factory |
HeadlessBackend | The shipped software backend (no hardware) — reuse it for tests or as a base for a custom backend |
HeadlessCanvas | The canvas produced by HeadlessBackend; exposes get_pixel(x, y) so a backend can serialize its own pixel state (use to stream/send frames in a custom output backend) |
Using FrameAwareBase for animated effects
Section titled “Using FrameAwareBase for animated effects”FrameAwareBase is the base class for any widget whose color or animation state changes frame-to-frame. Inherit from it (with @attrs.define) and call self.frame_for(name) to read per-effect counters that the engine drives; the counters advance automatically so your draw() doesn’t need to track time itself.
FrameAwareBase also provides frames_to_transition_ready(). The engine calls it at the hold→transition handoff and may extend the hold by up to roughly one second so animated effects can finish at a natural rest point — for example, letting a shimmer sweep reach its pause window before the scene changes, or waiting for a typewriter reveal to finish its last few characters. The method aggregates frames_to_rest(frame, total_chars) across the widget’s effects (color providers, animations) and never raises. Widgets with per-region text (such as a two-row layout with different text in each band) override _effect_total_chars(attr_name) to supply each effect with the character count it actually renders with; the default uses the widget’s text attribute.
AnimationFrame.rotation and emits_rotation
Section titled “AnimationFrame.rotation and emits_rotation”AnimationFrame — the return value of Animation.frame_for — carries two fields:
visible_text: str— the slice of the message text to render this tick (used by typewriter).rotation: float— a clockwise rotation in degrees to apply to the rendered text this tick. The default is0.0, which takes the normal draw path with no rotation. When non-zero, the message widget renders the text into an offscreen surface, rotates that surface around the text block’s center, and composites it back to the canvas.
An animation that emits non-zero rotations must also set emits_rotation = True as a class attribute. This opt-in marker tells the validator to run rule 63.
An animation can instead return a LensSpec on AnimationFrame.lens (default None) to pass the scrolling text through a stationary fisheye lens — the engine owns all of the warp math, so a plugin ships only the declarative spec (magnify, edge_squeeze, profile) and sets emits_lens = True as a class attribute (the marker for validate rule 64, the lens twin of rule 63’s hi-res-font-at-scale-1 warning).
Scale-1 displays (smallsign): pairing a rotation-emitting animation with a hi-res font raises a configuration warning. Hi-res text renders at native-pixel resolution and the offscreen rotate path operates at logical resolution — they interact badly. Rule 63 applies only when scale == 1; on scaled displays the snapshot architecture described below handles the mismatch. For BDF fonts the rotate path is fully supported at any scale.
Scaled displays (bigsign, default_scale > 1): the rotate path uses a snapshot-artifact architecture. Before the spin begins, the widget renders the full text — including hi-res fonts and hi-res emoji at their correct physical size — into a full-resolution offscreen buffer, then downsamples it to half-physical resolution. That half-resolution artifact is what rotates each tick, so the rotation itself runs at half detail. When the spin settles (rotation == 0.0), the widget reverts to the normal full-detail draw path automatically, so the resting state is always sharp.
Two behavior notes for plugin animation authors:
- Mid-spin detail: the artifact renders at half physical resolution during the spin, then sharpens on settle. This is expected and intentional — not a rendering error.
- Animated
font_colorproviders freeze during the spin. The artifact is rendered once and reused for every tick of the spin — so a rainbow or color_cycle sweep that was moving before the spin appears frozen mid-rotation. The provider’s live phase is tracked separately throughout; on settle the widget returns to the live path and colors resume exactly where the phase clock left them. For most spin durations (under two seconds) this is imperceptible; for longer spins, consider a constant color (font_color = [r, g, b]) — every animated provider (rainbow, color_cycle, shimmer) freezes the same way.
from led_ticker.plugin import Animation, AnimationFrame
@api.animation("spin")class SpinAnimation: emits_rotation = True
def frame_for(self, frame: int, full_text: str, canvas_width: int, text_width: int) -> AnimationFrame: degrees = (frame * 6) % 360 # one full revolution every 60 ticks (3 s at 20 fps) return AnimationFrame(visible_text=full_text, rotation=float(degrees))For time-based math in animation classes, ENGINE_TICK_MS (importable from led_ticker.plugin, listed in the Helpers table) gives the engine’s render cadence — 50 ms per tick — so you can convert a desired rotation speed in degrees/second to a per-tick increment without hardcoding the cadence.
Transition authors can reuse the same rotation machinery via make_rotation_surface and RotationSurface (both in the Data & types table and Helpers table): construct a surface once per transition instance, snapshot the outgoing into it on the first frame (t < _last_t detects a new firing and triggers surface.invalidate() so the snapshot is rebuilt fresh), then call surface.blit(canvas, angle, canvas.width / 2) on every subsequent frame — the snapshot is drawn exactly once per firing, and the per-frame blit operates on the cheaper half-resolution artifact on scaled displays (at scale 1 it blits the full buffer directly).
Conventions
Section titled “Conventions”A few behaviors are conventions the type carries, not api.* calls:
validate_config— a widget may define@classmethod validate_config(cls, cfg) -> list[str]. It’s called during config validation with the raw (pre-coercion) TOML for that widget; any returned strings become pre-flight errors. The rule travels with the widget type.validate_config_warnings— a widget may define@classmethod validate_config_warnings(cls, cfg: dict[str, Any], ctx: ValidationContext) -> list[str]. Likevalidate_config, it runs at config-validation time with raw pre-coercion config; but the returned strings surface as warnings vialed-ticker validate— they are never errors and never block the display from loading. The hook receives display geometry viaValidationContext(scale,content_height,panel_width,panel_height,config_dir), making it suitable for checks like “this font size may clip on a 16-tall canvas at scale=4”. A hook that raises is isolated (logged at WARNING level, then ignored). Available from core API(1, 1). If your plugin must also run on older cores, guard registration withif API_VERSION >= (1, 1):.font_colorinjection — to accept the standardfont_colorcolor-provider knob, declare afont_color: object = Nonefield on the widget; the loader coerces the TOML value to aColorProviderand injects it. Without the field,font_coloris rejected as unknown.frame_invariant—ColorProviderBase/BorderEffectBaserequire subclasses to set aframe_invariant: boolclass attr.Truemeans output never varies by frame (enables a static fast path);Falseforces a per-tick redraw.restart_on_visit— a color provider or border may setrestart_on_visit = Falseto keep a continuous animation phase across a section’sloop_count, instead of resetting on each visit.should_display— a widget may definedef should_display(self) -> bool. The engine calls it once per section pass; returningFalseremoves the widget from that pass (it is re-evaluated on the next pass). Absent → always shown. Ashould_displaythat raises keeps the widget — a visibility check must never crash the render loop. Use it to gate a widget on a runtime condition: the built-incountdown/countupwidgets hide while their day count is negative; a custom widget could show only during certain hours.
Where to next
Section titled “Where to next”- Build one step by step: the plugin authoring guide walks from scaffold to a packaged widget.
- Loader internals & edge cases:
docs/plugin-system.mdcovers discovery, the[plugins]config block, deployment, and known surface gaps. - The surfaces you’ll extend: widgets, transitions, color providers, animations, borders.