Skip to content
led-ticker

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.

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:

my_plugin/__init__.py
from led_ticker.plugin import PluginAPI
def register(api: PluginAPI) -> None:
@api.widget("clock")
class Clock:
def draw(self, canvas, frame): ...
pyproject.toml
[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 the acme plugin registers acme.clock (written in config as type = "acme.clock"). You cannot register a bare name or shadow a built-in.
  • Atomic load. Calls buffer until register() returns cleanly. If register() 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.

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).

MethodFormRegisters
api.widget(name)decoratorA widget class under namespace.name
api.transition(name)decoratorA transition class
api.backend(name)decoratorA rendering backend class (selectable as [display] backend = "namespace.name")
api.color_provider(style)decoratorA color provider class
api.animation(style)decoratorAn animation class
api.border(name)decoratorA border effect class
api.source(name)decoratorA DataSource subclass under namespace.name — powers inline :namespace.name: value tokens
api.easing(name, fn)callAn 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.

MethodFormRegisters
api.emoji(slug, data)callA low-res 8×8 emoji (PixelData) under namespace.slug
api.hires_emoji(slug, data)callA hi-res emoji (HiResEmoji) for scaled-canvas draws
api.font(name, path)callA 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.

MethodFormRegisters
api.overlay(paint)callA paint(canvas) run every frame before the hardware swap
api.on_startup(fn)callA hook run once after the frame + session exist; receives a StartupContext
api.on_shutdown(fn)callA hook run best-effort when the run loop exits

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.

NameWhat it is
PluginAPIThe namespace-bound registrar passed to register(api)
StartupContextFrozen dataclass passed to an on_startup hook (frame, session, config)
ValidationContextGeometry passed to validate_config_warnings (scale, content_height, panel_width, panel_height, config_dir)
API_VERSION(major, minor) tuple of the plugin surface version
NameWhat it is
WidgetThe widget protocol (a draw() or play-style widget)
ContainerMonitor/container widget base (cycles a live feed_stories list)
UpdatableProtocol for widgets with async def update(self)
TransitionThe transition protocol (frame_at)
ColorProviderThe color-provider protocol (color_for)
ColorProviderBaseBase that enforces the frame_invariant class attr
AnimationThe animation protocol (frame_for)
BorderEffectThe border protocol (paint)
BorderEffectBaseBase that enforces the frame_invariant class attr
DataSourceBase class for an inline value source — subclass, implement compute() -> str, register with api.source(name)
PolledDataSourceBase class for async (network-backed) sources — subclass, implement async def update(), call self._set_value(...) inside it
SegmentMessageRe-exported single-line message widget (compose stories from it)
TwoRowMessageRe-exported two-row widget
TickerMessageRe-exported single-region scrolling message widget (compose/subclass for rich stories)
FrameAwareBaseBase for an animated widget — inherit (with @attrs.define) and read effect counters via self.frame_for(name); the engine drives the counters.
NameWhat it is
CanvasThe draw target (SetPixel, width, height)
ColorAn rgbmatrix color value
ColorTupleType alias tuple[int, int, int] for raw RGB
DrawResultThe return shape of a widget draw()
AnimationFrameThe return shape of Animation.frame_for
LensSpecFrozen 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
PixelDatalist[(x, y, r, g, b)] — one tuple per lit pixel of a low-res emoji
HiResEmojiA hi-res emoji sprite — pixels ((x,y,r,g,b) in physical coords), physical_size, optional physical_width
FontA resolved font handle
HiresFontA resolved hi-res font
HiresSpecFrozen sprite spec for a hi-res transition: sprite_path (Path to a gif/webp), flip_horizontal, trail ("none"/"black"/"rainbow")
RotationSurfaceOffscreen 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)
ScaledCanvasThe wrapper widgets receive when default_scale > 1; use is_scaled(canvas) to gate hi-res paths
FONT_DEFAULTThe bundled default BDF font as a ready-to-use Font constant
FONT_SMALLThe bundled small BDF font as a ready-to-use Font constant
NameWhat 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
colorsThe 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_CAPThe 8×8 low-res emoji/sprite height — per-row sprite cap for band layouts
ENGINE_TICK_MSEngine 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_THRESHOLDThe transition t at which to snap to the incoming frame (call snap_reset at/after this)
NameWhat it is
BackendThe rendering-backend protocol (setup(), create_canvas(), swap(), brightness()); brightness is buffered before setup() and live after
BackendNotReadyErrorRaised 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
HeadlessBackendThe shipped software backend (no hardware) — reuse it for tests or as a base for a custom backend
HeadlessCanvasThe 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)

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 is 0.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_color providers 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).

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]. Like validate_config, it runs at config-validation time with raw pre-coercion config; but the returned strings surface as warnings via led-ticker validate — they are never errors and never block the display from loading. The hook receives display geometry via ValidationContext (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 with if API_VERSION >= (1, 1):.
  • font_color injection — to accept the standard font_color color-provider knob, declare a font_color: object = None field on the widget; the loader coerces the TOML value to a ColorProvider and injects it. Without the field, font_color is rejected as unknown.
  • frame_invariantColorProviderBase / BorderEffectBase require subclasses to set a frame_invariant: bool class attr. True means output never varies by frame (enables a static fast path); False forces a per-tick redraw.
  • restart_on_visit — a color provider or border may set restart_on_visit = False to keep a continuous animation phase across a section’s loop_count, instead of resetting on each visit.
  • should_display — a widget may define def should_display(self) -> bool. The engine calls it once per section pass; returning False removes the widget from that pass (it is re-evaluated on the next pass). Absent → always shown. A should_display that 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-in countdown / countup widgets hide while their day count is negative; a custom widget could show only during certain hours.