Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -922,7 +922,7 @@ jobs:
# Verify each plugin has required files
for plugin in plugins/*/; do
plugin_name=$(basename "$plugin")
if [ "$plugin_name" != "_template" ] && [ -d "$plugin" ]; then
if [ "$plugin_name" != "_template" ] && [ "$plugin_name" != "_template_transition" ] && [ -d "$plugin" ]; then
echo "Checking plugin: $plugin_name"
test -f "$plugin/__init__.py" || (echo "❌ $plugin_name/__init__.py missing" && exit 1)
test -f "$plugin/manifest.json" || (echo "❌ $plugin_name/manifest.json missing" && exit 1)
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,6 @@ docs-site/.cache-loader/

# Claude Code worktrees (per-user, ephemeral isolated checkouts).
.claude/worktrees/

# Claude Code scheduled task lock (per-user, not for source control)
.claude/scheduled_tasks.lock
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,21 @@ FiestaBoard has a catalog of **50+ plugins** covering weather, finance, transit,
| [Word of the Day](https://github.com/Fiestaboard/fiestaboard-plugin--word-of-day) | Word, pronunciation, and definition | No |
| [WSDOT Ferries](https://github.com/Fiestaboard/fiestaboard-plugin--wsdot) | WA State ferry schedules and alerts | Yes (free) |

### Transition Plugins (Beta)

> ⚠️ **Experimental.** Enable in Settings → Beta. The plugin SDK is not yet stable — APIs and manifest fields may change before general availability.

Transition plugins drive **frame-by-frame board animations** that aren't possible with Vestaboard's built-in hardware transitions. They're picked per-page (or as the system default) and animate the change from one display to the next. Preview any transition without a real board at `/transitions` (Transition Lab).

<!-- Sorted alphabetically -->
| Plugin | What It Does |
|--------|--------------|
| [Simple Dissolve](./plugins/simple_dissolve/README.md) | Flips changed tiles in a random order for a gradual dissolve |
| [Slot Machine](./plugins/slot_machine/README.md) | Spins each column like a flap reel before locking left-to-right |
| [Typewriter](./plugins/typewriter/README.md) | Reveals the message one character at a time, left-to-right |

Build your own with the [Transition Plugin Development Guide](./docs/development/TRANSITION_PLUGIN_DEVELOPMENT.md).

---

## Features
Expand Down
154 changes: 154 additions & 0 deletions docs/development/TRANSITION_PLUGIN_DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Transition Plugin Development Guide

> ⚠️ **Beta / Experimental.** Transition plugins ship behind the
> ``beta.transition_plugins_enabled`` settings flag (Settings → Beta).
> The SDK contract is not yet stable -- method signatures, manifest
> fields, and runtime semantics may change in future releases before
> general availability. Use it, build with it, send feedback, but don't
> treat your plugin's interface as locked in yet.

Transition plugins drive **frame-by-frame board animations** that change one display state into another. Unlike Vestaboard's built-in hardware transitions (column wave, edges-to-center, etc.), a transition plugin emits a sequence of intermediate board grids and the runtime sends them as separate frames -- enabling typewriter reveals, slot-machine spins, dissolves, and anything else that needs custom per-frame control.

This is a different plugin type from the data plugins documented in [PLUGIN_DEVELOPMENT.md](./PLUGIN_DEVELOPMENT.md). Data plugins fetch information and expose template variables; transition plugins shape *how* a board update happens, not *what* it shows.

## Quick Start

1. Copy the template:
```bash
cp -r plugins/_template_transition plugins/my_transition
```
2. Edit `plugins/my_transition/manifest.json`:
- Set `id` to `my_transition` (must match the directory)
- Set `plugin_type` to `"transition"` (required)
- Set `category` to `"transition"`
- Fill in name, version, description, author
3. Implement `generate_frames()` in `plugins/my_transition/__init__.py`.
4. Add tests under `plugins/my_transition/tests/` aiming for >80% coverage.
5. Run `python scripts/run_plugin_tests.py --plugin=my_transition` to verify.
6. Open Transition Lab in the web UI (`/transitions`) to preview your plugin against arbitrary from/to text.

## The Plugin Class

Transition plugins inherit from `src.plugins.base.TransitionPluginBase` and must implement:

- `plugin_id` (property): unique id matching the manifest
- `generate_frames(from_grid, to_grid, device, config) -> Iterator[(grid, delay_ms)]`

Optional hooks:

- `validate_config(config) -> List[str]`: return error strings for bad config
- `on_config_change(old, new)`: react to config updates
- `cleanup()`: release any resources on disable

### Example: A "Knight Rider" sweep

```python
from typing import Any, Dict, Iterator, List, Tuple
from src.plugins.base import TransitionPluginBase

class KnightRiderTransition(TransitionPluginBase):
@property
def plugin_id(self) -> str:
return "knight_rider"

def generate_frames(
self,
from_grid: List[List[int]],
to_grid: List[List[int]],
device,
config: Dict[str, Any],
) -> Iterator[Tuple[List[List[int]], int]]:
speed_ms = int(config.get("speed_ms", 80))
rows = len(to_grid)
cols = len(to_grid[0]) if rows else 0
revealed = [list(row) for row in from_grid]
# Sweep right
for c in range(cols):
for r in range(rows):
revealed[r][c] = to_grid[r][c]
yield [list(row) for row in revealed], speed_ms
```

## What you get for free

The runtime handles a lot so your generator can stay simple:

- **Final-frame snap**: After your generator exhausts, the runner unconditionally sends `to_grid` to guarantee the board lands on the exact target. You don't have to make your last yield equal `to_grid` exactly.
- **Cancellation**: If a new page or trigger arrives mid-transition and your manifest declares `interruptible: true`, the runner sets a cancel event. Your generator simply gets stopped at the next delay boundary.
- **Caps**: The runner enforces `max_frames`, `max_runtime_seconds`, and a `min_interval_ms` floor on your yielded delays. A runaway loop won't lock up the board.
- **Per-board serialization**: The runner holds the board's send lock for the duration of the transition. Concurrent rotation / trigger sends queue cleanly.

## Manifest fields

```json
{
"id": "my_transition",
"name": "My Transition",
"version": "1.0.0",
"description": "Short one-liner shown in the picker.",
"author": "Your Name",
"icon": "wand-2",
"category": "transition",
"plugin_type": "transition",
"settings_schema": {
"type": "object",
"properties": {
"speed_ms": {
"type": "integer",
"title": "Speed (ms)",
"default": 100,
"minimum": 0,
"maximum": 2000
}
}
},
"transition_settings": {
"interruptible": true,
"min_interval_ms": 50,
"max_frames": 200,
"max_runtime_seconds": 60
}
}
```

### `transition_settings` block

| Field | Default | Purpose |
| ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
| `interruptible` | `true` | When `true`, a new send mid-transition cancels your in-flight transition. Set `false` only if mid-stop looks broken.|
| `min_interval_ms` | `50` | Floor on the delay between frames. The runner uses `max(your_delay, min_interval_ms)`. |
| `max_frames` | `500` | Hard cap on frame count. The runner aborts and snaps to target if exceeded. |
| `max_runtime_seconds` | `120` | Hard cap on wall-clock seconds. |

Choose conservatively. A transition with `max_frames: 5000` and `min_interval_ms: 0` can flood the board API and block any other update for minutes.

## Selecting a transition plugin

Once your plugin is loaded and enabled, users select it from:

- A specific page's Transition picker in the page editor
- The global default in Settings → Transitions

Pages store the choice as `transition_strategy = "plugin:my_transition"`. The runtime parses the `plugin:` prefix and routes the send through `TransitionRunner`.

## Visual testing

The **Transition Lab** at `/transitions` lets you preview any enabled transition plugin against arbitrary from/to text without a real board. It uses `POST /transitions/preview` under the hood, which calls your `generate_frames()` and returns the resulting grids as JSON. Use the timeline scrubber to step through frames and verify each intermediate state.

## Performance & rate limits

- The Vestaboard hardware has internal timing constraints. Sending frames faster than the flap mechanism can settle (~14s for a full revolution under heavy update) will cause the board to drop requests.
- The Cloud API has stricter rate limits than the Local API. Transition plugins are the *only* way to animate on Cloud-mode boards (hardware strategies are ignored), but the practical frame rate is much lower.
- Use `min_interval_ms` to protect users from runaway loops in your own plugin.

## Publishing an external transition plugin

External plugins follow the same registry mechanism as data plugins (see [PLUGIN_DEVELOPMENT.md](./PLUGIN_DEVELOPMENT.md#publishing-an-external-plugin)). Transition plugins use the naming convention `fiestaboard-transition--<name>` (vs `fiestaboard-plugin--<name>` for data plugins). Add your repo to `plugin-registry.json` with `"plugin_type": "transition"` so the loader knows what to expect before cloning.

## Reference

- **Base class**: `src/plugins/base.py` → `TransitionPluginBase`
- **Runner**: `src/transitions/runner.py` → `TransitionRunner`
- **Send chokepoint**: `src/board_client.py` → `BoardClient.render()`
- **API endpoints**: `GET /transitions/plugins`, `POST /transitions/preview` in `src/api_server.py`
- **First-party examples**: `plugins/typewriter`, `plugins/simple_dissolve`, `plugins/slot_machine`
35 changes: 35 additions & 0 deletions plugins/_template_transition/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# My Transition Plugin

Replace this paragraph with a short description of what your transition does.

![My Transition Display](./docs/board-display.png)

**→ [Setup Guide](./docs/SETUP.md)**

## Overview

Two to three sentences describing the visual effect and when users would pick it.

## Template Variables

None. Transition plugins don't expose template variables.

## Example Templates

Select your transition from a page's Transition picker or set it as the global default in Settings → Transitions.

## Configuration

| Setting | Type | Default | Description |
| ---------- | ------- | ------- | ------------------------------------------ |
| `speed_ms` | integer | 100 | Time between frames in milliseconds. |

## Features

- (Describe what makes your transition distinct)
- Tunable speed via `speed_ms`
- Bounded by manifest caps (max_frames / max_runtime_seconds)

## Author

Your Name
42 changes: 42 additions & 0 deletions plugins/_template_transition/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Template transition plugin.

Copy this directory to ``plugins/<your_id>/`` and update the manifest
and class below to build your own transition. See
``docs/development/TRANSITION_PLUGIN_DEVELOPMENT.md`` for the full guide.
"""

from collections.abc import Iterator
from typing import Any

from src.plugins.base import TransitionPluginBase


class MyTransition(TransitionPluginBase):
"""Replace this with your transition's behavior."""

@property
def plugin_id(self) -> str:
# Must match manifest "id".
return "my_transition"

def generate_frames(
self,
from_grid: list[list[int]],
to_grid: list[list[int]],
device: Any,
config: dict[str, Any],
) -> Iterator[tuple[list[list[int]], int]]:
"""Yield (frame_grid, delay_ms_before_next) tuples.

The runner sends each grid to the board then waits delay_ms
(clamped to ``min_interval_ms`` from the manifest) before pulling
the next frame. After your generator exhausts, the runner
unconditionally sends ``to_grid`` so the board lands on target.
"""
speed_ms = int(config.get("speed_ms", 100))

# Trivial example: emit a single intermediate frame, then yield
# nothing more (runner snaps to target). Replace with your
# actual animation.
intermediate = [list(row) for row in from_grid]
yield intermediate, speed_ms
32 changes: 32 additions & 0 deletions plugins/_template_transition/docs/SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# My Transition Setup Guide

How to enable and use this transition plugin.

## Overview

**What it does**: (one-line description)

**Prerequisites**: None (or list any API keys / accounts required).

## Quick Setup

1. **Enable** — Open Settings → Plugins, find this plugin under "Transition Plugins", and toggle it on.
2. **Configure** — Adjust the settings to your taste.
3. **Apply** — Set it as a page's transition (or as the global default in Settings → Transitions).
4. **View** — Watch the next page transition use your effect.

## Template Variables

None.

## Configuration Reference

| Setting | Type | Default | Range | Description |
| ---------- | ------- | ------- | --------- | ------------------------------------ |
| `speed_ms` | integer | 100 | 0-2000 ms | Time between frames in milliseconds. |

No environment variables required.

## Troubleshooting

- **Transition too fast / slow** — Adjust `speed_ms`.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions plugins/_template_transition/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"id": "my_transition",
"name": "My Transition",
"version": "1.0.0",
"description": "Short one-line description shown in the transition picker.",
"author": "Your Name",
"icon": "wand-2",
"category": "transition",
"plugin_type": "transition",
"repository": "https://github.com/YourUsername/fiestaboard-transition--my-transition",
"documentation": "README.md",
"settings_schema": {
"type": "object",
"properties": {
"speed_ms": {
"type": "integer",
"title": "Frame delay (ms)",
"description": "Time to wait between frames.",
"default": 100,
"minimum": 0,
"maximum": 2000
}
}
},
"transition_settings": {
"interruptible": true,
"min_interval_ms": 50,
"max_frames": 200,
"max_runtime_seconds": 60
},
"screenshots": [
{
"src": "docs/board-display.png",
"alt": "My Transition demo",
"primary": true
}
]
}
Empty file.
9 changes: 9 additions & 0 deletions plugins/_template_transition/tests/test_my_transition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Template tests -- replace with your transition's actual tests."""

# This template's tests directory is intentionally minimal. When you
# fork the template for a real plugin, write real tests that exercise:
# * generate_frames yields the expected number of frames
# * the final frame matches (or is close to) to_grid
# * config knobs change behavior as documented
# * edge cases: empty input, identical from/to, mismatched dims
# Aim for >80% coverage of __init__.py.
40 changes: 40 additions & 0 deletions plugins/simple_dissolve/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Simple Dissolve Plugin

A transition plugin that replaces tiles in a random order, creating a gradual dissolve from the current message to the target.

![Simple Dissolve Display](./docs/board-display.png)

**→ [Setup Guide](./docs/SETUP.md)**

## Overview

Simple Dissolve is a transition plugin -- it doesn't display its own content. When the active page changes, it animates the transition by flipping tiles one batch at a time in a random order, until the board matches the target.

Only tiles that actually differ between the current and target grids are flipped. Unchanged content stays in place throughout.

## Template Variables

None. Transition plugins don't expose template variables.

## Example Templates

Select Simple Dissolve from a page's Transition picker or set it as the global default in Settings → Transitions.

## Configuration

| Setting | Type | Default | Description |
| --------------------- | ------- | ------- | -------------------------------------------------------------------------- |
| `tiles_per_frame` | integer | 6 | Tiles flipped per step (1-132). Larger = faster. |
| `frame_interval_ms` | integer | 100 | Pause between steps in milliseconds (0-2000). |
| `seed` | integer | 0 | Fixed integer for deterministic order (useful for previews). 0 = random. |

## Features

- Animates only the tiles that change; unchanged content stays put
- Random shuffle each run (or seeded for predictable previews)
- Bounded: capped at 200 frames / 60 seconds runtime
- Interruptible: a new page or trigger cancels the in-flight dissolve cleanly

## Author

FiestaBoard
Loading
Loading