diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 090f1f96a..911a292b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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) diff --git a/.gitignore b/.gitignore index a6a14484c..a5c03e85e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index c59fe4f74..6fc3292b1 100644 --- a/README.md +++ b/README.md @@ -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). + + +| 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 diff --git a/docs/development/TRANSITION_PLUGIN_DEVELOPMENT.md b/docs/development/TRANSITION_PLUGIN_DEVELOPMENT.md new file mode 100644 index 000000000..5bbf573a4 --- /dev/null +++ b/docs/development/TRANSITION_PLUGIN_DEVELOPMENT.md @@ -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--` (vs `fiestaboard-plugin--` 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` diff --git a/plugins/_template_transition/README.md b/plugins/_template_transition/README.md new file mode 100644 index 000000000..9b699ada8 --- /dev/null +++ b/plugins/_template_transition/README.md @@ -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 diff --git a/plugins/_template_transition/__init__.py b/plugins/_template_transition/__init__.py new file mode 100644 index 000000000..8f3c5f259 --- /dev/null +++ b/plugins/_template_transition/__init__.py @@ -0,0 +1,42 @@ +"""Template transition plugin. + +Copy this directory to ``plugins//`` 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 diff --git a/plugins/_template_transition/docs/SETUP.md b/plugins/_template_transition/docs/SETUP.md new file mode 100644 index 000000000..bcec7164a --- /dev/null +++ b/plugins/_template_transition/docs/SETUP.md @@ -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`. diff --git a/plugins/_template_transition/docs/board-display.png b/plugins/_template_transition/docs/board-display.png new file mode 100644 index 000000000..909c66db1 Binary files /dev/null and b/plugins/_template_transition/docs/board-display.png differ diff --git a/plugins/_template_transition/manifest.json b/plugins/_template_transition/manifest.json new file mode 100644 index 000000000..7ab5c5609 --- /dev/null +++ b/plugins/_template_transition/manifest.json @@ -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 + } + ] +} diff --git a/plugins/_template_transition/tests/__init__.py b/plugins/_template_transition/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/_template_transition/tests/test_my_transition.py b/plugins/_template_transition/tests/test_my_transition.py new file mode 100644 index 000000000..0c63e598f --- /dev/null +++ b/plugins/_template_transition/tests/test_my_transition.py @@ -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. diff --git a/plugins/simple_dissolve/README.md b/plugins/simple_dissolve/README.md new file mode 100644 index 000000000..9f269522b --- /dev/null +++ b/plugins/simple_dissolve/README.md @@ -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 diff --git a/plugins/simple_dissolve/__init__.py b/plugins/simple_dissolve/__init__.py new file mode 100644 index 000000000..1c4974d36 --- /dev/null +++ b/plugins/simple_dissolve/__init__.py @@ -0,0 +1,73 @@ +"""Simple dissolve transition plugin. + +Reveals the target grid by flipping tiles in a randomized order. Only the +tiles that actually differ between ``from_grid`` and ``to_grid`` are +shuffled, so unchanged content stays in place. +""" + +import random +from collections.abc import Iterator +from typing import Any + +from src.plugins.base import TransitionPluginBase + + +class SimpleDissolveTransition(TransitionPluginBase): + """Random-order tile reveal from the current grid to the target grid.""" + + @property + def plugin_id(self) -> str: + return "simple_dissolve" + + 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]]: + tiles_per_frame = max(1, int(config.get("tiles_per_frame", 6))) + frame_interval_ms = max(0, int(config.get("frame_interval_ms", 100))) + seed = config.get("seed") or 0 + + rows = len(to_grid) + cols = len(to_grid[0]) if rows else 0 + if rows == 0 or cols == 0: + return + + # Collect positions that actually differ. A new instance of + # ``random.Random`` is used so each transition has its own shuffle + # state independent of the global RNG. + diff_positions: list[tuple[int, int]] = [] + for r in range(rows): + row_to = to_grid[r] + row_from = from_grid[r] if r < len(from_grid) else [0] * cols + for c in range(cols): + src = row_from[c] if c < len(row_from) else 0 + if src != row_to[c]: + diff_positions.append((r, c)) + + if not diff_positions: + # Nothing to do; runner will still snap to to_grid. + return + + rng = random.Random(seed) if seed else random.Random() + rng.shuffle(diff_positions) + + working = [list(row) for row in from_grid] if from_grid else [[0] * cols for _ in range(rows)] + # Right-pad / truncate working to match to_grid shape in case the + # caller handed us a mismatched from_grid (defensive only). + if len(working) != rows: + working = [working[r] if r < len(working) else [0] * cols for r in range(rows)] + for r in range(rows): + if len(working[r]) != cols: + working[r] = (working[r] + [0] * cols)[:cols] + + flipped = 0 + total = len(diff_positions) + while flipped < total: + batch = diff_positions[flipped : flipped + tiles_per_frame] + for r, c in batch: + working[r][c] = to_grid[r][c] + flipped += len(batch) + yield [list(row) for row in working], frame_interval_ms diff --git a/plugins/simple_dissolve/docs/SETUP.md b/plugins/simple_dissolve/docs/SETUP.md new file mode 100644 index 000000000..4b3e8b8ed --- /dev/null +++ b/plugins/simple_dissolve/docs/SETUP.md @@ -0,0 +1,37 @@ +# Simple Dissolve Setup Guide + +How to enable Simple Dissolve and use it on a page. + +## Overview + +**What it does**: Animates board updates by flipping changed tiles in a random order, producing a gradual dissolve from the current message to the target. + +**Prerequisites**: None. + +## Quick Setup + +1. **Enable** — Open Settings → Plugins, find Simple Dissolve under "Transition Plugins", and toggle it on. +2. **Configure** — Optionally adjust `tiles_per_frame`, `frame_interval_ms`, and `seed`. +3. **Apply** — Open a page in the editor, set its Transition to "Simple Dissolve", and save. Or set it as the global default in Settings → Transitions. +4. **View** — The next page transition will dissolve into the new content. + +## Template Variables + +None. + +## Configuration Reference + +| Setting | Type | Default | Range | Description | +| ------------------- | ------- | ------- | ----------- | -------------------------------------------------------------------------- | +| `tiles_per_frame` | integer | 6 | 1-132 | Tiles flipped per step. | +| `frame_interval_ms` | integer | 100 | 0-2000 ms | Pause between steps in milliseconds. | +| `seed` | integer | 0 | any | Fixed integer for deterministic ordering. 0 = fresh random each run. | + +No environment variables required. + +## Troubleshooting + +- **Dissolve looks the same every time** — You've set a non-zero `seed`. Set it back to 0 for fresh randomness. +- **Want a preview that always plays the same way** — Set `seed` to any non-zero integer. +- **Transition feels chunky** — Lower `tiles_per_frame` and/or raise `frame_interval_ms`. +- **Transition is too slow** — Raise `tiles_per_frame` or lower `frame_interval_ms`. diff --git a/plugins/simple_dissolve/docs/board-display.png b/plugins/simple_dissolve/docs/board-display.png new file mode 100644 index 000000000..909c66db1 Binary files /dev/null and b/plugins/simple_dissolve/docs/board-display.png differ diff --git a/plugins/simple_dissolve/manifest.json b/plugins/simple_dissolve/manifest.json new file mode 100644 index 000000000..5722aa616 --- /dev/null +++ b/plugins/simple_dissolve/manifest.json @@ -0,0 +1,51 @@ +{ + "id": "simple_dissolve", + "name": "Simple Dissolve", + "version": "1.0.0", + "description": "Replaces tiles in a random order, creating a gradual dissolve from the current message to the target.", + "author": "FiestaBoard", + "icon": "shuffle", + "category": "transition", + "plugin_type": "transition", + "documentation": "README.md", + "settings_schema": { + "type": "object", + "properties": { + "tiles_per_frame": { + "type": "integer", + "title": "Tiles per frame", + "description": "How many tiles flip in each step. Larger = faster, less granular.", + "default": 6, + "minimum": 1, + "maximum": 132 + }, + "frame_interval_ms": { + "type": "integer", + "title": "Delay between frames (ms)", + "description": "Time to pause between each step.", + "default": 100, + "minimum": 0, + "maximum": 2000 + }, + "seed": { + "type": "integer", + "title": "Random seed", + "description": "Set to a fixed integer for deterministic playback (useful for previews). Leave blank for a fresh random order each run.", + "default": 0 + } + } + }, + "transition_settings": { + "interruptible": true, + "min_interval_ms": 50, + "max_frames": 200, + "max_runtime_seconds": 60 + }, + "screenshots": [ + { + "src": "docs/board-display.png", + "alt": "Simple dissolve transition", + "primary": true + } + ] +} diff --git a/plugins/simple_dissolve/tests/__init__.py b/plugins/simple_dissolve/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/simple_dissolve/tests/test_simple_dissolve.py b/plugins/simple_dissolve/tests/test_simple_dissolve.py new file mode 100644 index 000000000..9a090f8f7 --- /dev/null +++ b/plugins/simple_dissolve/tests/test_simple_dissolve.py @@ -0,0 +1,149 @@ +"""Tests for the simple_dissolve transition plugin.""" + +import json +from pathlib import Path + +import pytest + +from plugins.simple_dissolve import SimpleDissolveTransition +from src.devices import DEVICE_DIMENSIONS + +MANIFEST = json.loads((Path(__file__).resolve().parents[1] / "manifest.json").read_text()) + + +@pytest.fixture +def plugin() -> SimpleDissolveTransition: + return SimpleDissolveTransition(MANIFEST) + + +def _grid(value: int, rows: int = 6, cols: int = 22): + return [[value] * cols for _ in range(rows)] + + +def test_plugin_id_matches_manifest(plugin): + assert plugin.plugin_id == MANIFEST["id"] + + +def test_no_differences_yields_no_frames(plugin): + """When from and to grids are identical, the plugin yields nothing.""" + grid = _grid(5) + frames = list( + plugin.generate_frames( + from_grid=grid, + to_grid=grid, + device=DEVICE_DIMENSIONS["flagship"], + config={"seed": 1}, + ) + ) + assert frames == [] + + +def test_full_change_yields_expected_frame_count(plugin): + """132 changed tiles at 6 per frame = 22 frames.""" + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=_grid(1), + device=DEVICE_DIMENSIONS["flagship"], + config={"tiles_per_frame": 6, "frame_interval_ms": 0, "seed": 42}, + ) + ) + assert len(frames) == 22 + assert frames[-1][0] == _grid(1) + + +def test_only_differing_tiles_are_changed(plugin): + """If only one tile differs, exactly one tile flips in the only frame.""" + from_grid = _grid(0) + to_grid = _grid(0) + to_grid[2][7] = 9 + frames = list( + plugin.generate_frames( + from_grid=from_grid, + to_grid=to_grid, + device=DEVICE_DIMENSIONS["flagship"], + config={"tiles_per_frame": 10, "frame_interval_ms": 0, "seed": 1}, + ) + ) + assert len(frames) == 1 + final = frames[0][0] + assert final == to_grid + + +def test_deterministic_seed_produces_same_order(): + """Same seed = same shuffle order = identical frame sequence.""" + p1 = SimpleDissolveTransition(MANIFEST) + p2 = SimpleDissolveTransition(MANIFEST) + cfg = {"tiles_per_frame": 4, "frame_interval_ms": 0, "seed": 12345} + f1 = list(p1.generate_frames(_grid(0), _grid(1), DEVICE_DIMENSIONS["flagship"], cfg)) + f2 = list(p2.generate_frames(_grid(0), _grid(1), DEVICE_DIMENSIONS["flagship"], cfg)) + assert f1 == f2 + + +def test_seed_zero_uses_random_default(plugin): + """seed=0 is treated as 'no seed' (fresh random each run).""" + cfg = {"tiles_per_frame": 4, "frame_interval_ms": 0, "seed": 0} + f1 = list(plugin.generate_frames(_grid(0), _grid(1), DEVICE_DIMENSIONS["flagship"], cfg)) + # Different plugin, same args → likely a different order (statistical). + plugin2 = SimpleDissolveTransition(MANIFEST) + f2 = list(plugin2.generate_frames(_grid(0), _grid(1), DEVICE_DIMENSIONS["flagship"], cfg)) + # Both terminate on the target. + assert f1[-1][0] == _grid(1) + assert f2[-1][0] == _grid(1) + + +def test_handles_note_device(plugin): + """Note dims (3x15).""" + frames = list( + plugin.generate_frames( + from_grid=_grid(0, rows=3, cols=15), + to_grid=_grid(2, rows=3, cols=15), + device=DEVICE_DIMENSIONS["note"], + config={"tiles_per_frame": 9, "frame_interval_ms": 30, "seed": 7}, + ) + ) + assert len(frames) == 5 # 45 / 9 + assert frames[-1][0] == _grid(2, rows=3, cols=15) + + +def test_handles_empty_to_grid(plugin): + frames = list(plugin.generate_frames(from_grid=[], to_grid=[], device=DEVICE_DIMENSIONS["flagship"], config={})) + assert frames == [] + + +def test_tiles_per_frame_clamped_to_at_least_one(plugin): + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=_grid(1), + device=DEVICE_DIMENSIONS["flagship"], + config={"tiles_per_frame": 0, "frame_interval_ms": 0, "seed": 1}, + ) + ) + # treated as 1 → 132 frames + assert len(frames) == 132 + + +def test_partial_change_with_unchanged_tiles_preserved(plugin): + """Tiles that don't differ are never modified during the dissolve.""" + from_grid = _grid(0) + to_grid = _grid(0) + to_grid[0][0] = 5 + to_grid[5][21] = 7 + frames = list( + plugin.generate_frames( + from_grid=from_grid, + to_grid=to_grid, + device=DEVICE_DIMENSIONS["flagship"], + config={"tiles_per_frame": 1, "frame_interval_ms": 0, "seed": 1}, + ) + ) + # 2 changed tiles → 2 frames. + assert len(frames) == 2 + # Every intermediate frame keeps non-diff tiles at 0. + for grid, _ in frames: + for r in range(6): + for c in range(22): + if (r, c) not in {(0, 0), (5, 21)}: + assert grid[r][c] == 0 + assert frames[-1][0] == to_grid diff --git a/plugins/slot_machine/README.md b/plugins/slot_machine/README.md new file mode 100644 index 000000000..c388799fa --- /dev/null +++ b/plugins/slot_machine/README.md @@ -0,0 +1,42 @@ +# Slot Machine Plugin + +A transition plugin where each column "spins" through random characters like a slot-machine reel before locking on the target. Columns stagger left-to-right so the board settles in a satisfying cascade. + +![Slot Machine Display](./docs/board-display.png) + +**→ [Setup Guide](./docs/SETUP.md)** + +## Overview + +Slot Machine is a transition plugin -- it doesn't display its own content. When the active page changes, each column of the board spins through a sequence of random tile codes (mimicking a flap reel cycling) and then locks on the target. The stagger setting controls how much later each column starts locking than the previous one, producing a left-to-right cascade. + +This plugin plays to the unique nature of the Vestaboard's split-flap mechanism: where hardware transitions like "wave" or "edges-to-center" simply *move* the final state into place, Slot Machine animates the *flaps themselves* as if the board were a row of mechanical reels. + +## Template Variables + +None. Transition plugins don't expose template variables. + +## Example Templates + +Select Slot Machine from a page's Transition picker or set it as the global default in Settings → Transitions. + +## Configuration + +| Setting | Type | Default | Description | +| --------------------- | ------- | ------- | ---------------------------------------------------------------------------------------- | +| `spin_frames` | integer | 6 | Random-character frames each column shows before locking (1-30). | +| `column_stagger` | integer | 1 | Frames between adjacent column locks. 0 = simultaneous, higher = more pronounced cascade. | +| `frame_interval_ms` | integer | 80 | Pause between frames in milliseconds (0-1000). Smaller = faster spin. | +| `seed` | integer | 0 | Fixed integer for deterministic playback (useful for previews). 0 = fresh random. | + +## Features + +- Visually striking flap-reel effect that plays to the Vestaboard's mechanical character +- Tunable cascade pattern via `column_stagger` +- Random spin per run (or seeded for predictable previews) +- Bounded: capped at 300 frames / 60 seconds runtime +- Interruptible: a new page or trigger cancels the in-flight spin cleanly + +## Author + +FiestaBoard diff --git a/plugins/slot_machine/__init__.py b/plugins/slot_machine/__init__.py new file mode 100644 index 000000000..ed83910a9 --- /dev/null +++ b/plugins/slot_machine/__init__.py @@ -0,0 +1,69 @@ +"""Slot machine transition plugin. + +Each column "spins" through a sequence of random tile codes (mimicking +a flap reel cycling) before locking on the target column. Columns lock +left-to-right with a configurable stagger so the whole board settles in +a cascade rather than all at once. + +Unchanged tiles still spin -- the visual effect is what we're after, and +the noise / mechanical wear is a deliberate trade for the spectacle. +The default cap of 300 frames and 60-second runtime keep things bounded. +""" + +import random +from collections.abc import Iterator +from typing import Any + +from src.plugins.base import TransitionPluginBase + +# Char codes we'll sample for the spin animation. Letters (1-26) and +# digits (27-36) read as natural slot-reel content; we deliberately skip +# the color tiles (63-71) so spinning columns never flash giant blocks +# of color in the middle of plain text. +_SPIN_CODES = list(range(1, 37)) + + +class SlotMachineTransition(TransitionPluginBase): + """Per-column flap-reel spin transition.""" + + @property + def plugin_id(self) -> str: + return "slot_machine" + + 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]]: + spin_frames = max(1, int(config.get("spin_frames", 6))) + column_stagger = max(0, int(config.get("column_stagger", 1))) + frame_interval_ms = max(0, int(config.get("frame_interval_ms", 80))) + seed = config.get("seed") or 0 + + rows = len(to_grid) + cols = len(to_grid[0]) if rows else 0 + if rows == 0 or cols == 0: + return + + rng = random.Random(seed) if seed else random.Random() + + # Per-column lock frame: column c locks at frame ``c * stagger + spin_frames``. + # The transition runs until the last column has locked. + lock_at = [c * column_stagger + spin_frames for c in range(cols)] + last_lock = lock_at[-1] if lock_at else 0 + + for frame_idx in range(1, last_lock + 1): + frame = [] + for r in range(rows): + row_chars = [] + for c in range(cols): + if frame_idx >= lock_at[c]: + # Column has locked -- show the target tile. + row_chars.append(to_grid[r][c]) + else: + # Still spinning -- pick a random tile each frame. + row_chars.append(rng.choice(_SPIN_CODES)) + frame.append(row_chars) + yield frame, frame_interval_ms diff --git a/plugins/slot_machine/docs/SETUP.md b/plugins/slot_machine/docs/SETUP.md new file mode 100644 index 000000000..2789a3727 --- /dev/null +++ b/plugins/slot_machine/docs/SETUP.md @@ -0,0 +1,38 @@ +# Slot Machine Setup Guide + +How to enable Slot Machine and use it on a page. + +## Overview + +**What it does**: Animates board updates by spinning each column through random characters like a slot-machine reel before locking on the target. Columns lock left-to-right with a configurable stagger. + +**Prerequisites**: None. + +## Quick Setup + +1. **Enable** — Open Settings → Plugins, find Slot Machine under "Transition Plugins", and toggle it on. +2. **Configure** — Optionally adjust `spin_frames`, `column_stagger`, `frame_interval_ms`, and `seed`. +3. **Apply** — Open a page in the editor, set its Transition to "Slot Machine", and save. Or set it as the global default in Settings → Transitions. +4. **View** — The next page transition will spin each column before locking on the new content. + +## Template Variables + +None. + +## Configuration Reference + +| Setting | Type | Default | Range | Description | +| ------------------- | ------- | ------- | ----------- | -------------------------------------------------------------------------- | +| `spin_frames` | integer | 6 | 1-30 | Random-character frames each column shows before locking. | +| `column_stagger` | integer | 1 | 0-10 | Frames between adjacent column locks. 0 = simultaneous. | +| `frame_interval_ms` | integer | 80 | 0-1000 ms | Pause between frames. Smaller = faster spin. | +| `seed` | integer | 0 | any | Fixed integer for deterministic spin. 0 = fresh random each run. | + +No environment variables required. + +## Troubleshooting + +- **Whole board flashes random characters too long** — Lower `spin_frames` (e.g. to 3-4). +- **All columns lock at the same time, no cascade** — Raise `column_stagger` (try 2-3). +- **Cascade takes too long** — Lower `column_stagger` and/or `frame_interval_ms`. +- **Want a preview that always plays the same way** — Set `seed` to any non-zero integer. diff --git a/plugins/slot_machine/docs/board-display.png b/plugins/slot_machine/docs/board-display.png new file mode 100644 index 000000000..909c66db1 Binary files /dev/null and b/plugins/slot_machine/docs/board-display.png differ diff --git a/plugins/slot_machine/manifest.json b/plugins/slot_machine/manifest.json new file mode 100644 index 000000000..fd8bb0d1a --- /dev/null +++ b/plugins/slot_machine/manifest.json @@ -0,0 +1,59 @@ +{ + "id": "slot_machine", + "name": "Slot Machine", + "version": "1.0.0", + "description": "Each column spins through random characters like a slot-machine reel before locking on the target. Columns stagger left-to-right so the board settles in a satisfying cascade.", + "author": "FiestaBoard", + "icon": "dices", + "category": "transition", + "plugin_type": "transition", + "documentation": "README.md", + "settings_schema": { + "type": "object", + "properties": { + "spin_frames": { + "type": "integer", + "title": "Spin frames per column", + "description": "How many random-character frames each column shows before locking on the target.", + "default": 6, + "minimum": 1, + "maximum": 30 + }, + "column_stagger": { + "type": "integer", + "title": "Column stagger (frames)", + "description": "How many frames later each column starts locking, relative to the previous column. 0 = all columns lock simultaneously; higher values produce a left-to-right cascade.", + "default": 1, + "minimum": 0, + "maximum": 10 + }, + "frame_interval_ms": { + "type": "integer", + "title": "Delay between frames (ms)", + "description": "Time to pause between each step. Smaller = faster spin.", + "default": 80, + "minimum": 0, + "maximum": 1000 + }, + "seed": { + "type": "integer", + "title": "Random seed", + "description": "Set to a fixed integer for deterministic playback (useful for previews). Leave 0 for a fresh random spin each run.", + "default": 0 + } + } + }, + "transition_settings": { + "interruptible": true, + "min_interval_ms": 50, + "max_frames": 300, + "max_runtime_seconds": 60 + }, + "screenshots": [ + { + "src": "docs/board-display.png", + "alt": "Slot machine column spin transition", + "primary": true + } + ] +} diff --git a/plugins/slot_machine/tests/__init__.py b/plugins/slot_machine/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/slot_machine/tests/test_slot_machine.py b/plugins/slot_machine/tests/test_slot_machine.py new file mode 100644 index 000000000..cdf18beb5 --- /dev/null +++ b/plugins/slot_machine/tests/test_slot_machine.py @@ -0,0 +1,211 @@ +"""Tests for the slot_machine transition plugin.""" + +import json +from pathlib import Path + +import pytest + +from plugins.slot_machine import _SPIN_CODES, SlotMachineTransition +from src.devices import DEVICE_DIMENSIONS + +MANIFEST = json.loads((Path(__file__).resolve().parents[1] / "manifest.json").read_text()) + + +@pytest.fixture +def plugin() -> SlotMachineTransition: + return SlotMachineTransition(MANIFEST) + + +def _grid(value: int, rows: int = 6, cols: int = 22): + return [[value] * cols for _ in range(rows)] + + +def test_plugin_id_matches_manifest(plugin): + assert plugin.plugin_id == MANIFEST["id"] + + +def test_default_interruptible_per_manifest(plugin): + assert plugin.transition_settings["interruptible"] is True + + +def test_no_stagger_yields_spin_frames_count(plugin): + """With column_stagger=0, every column locks at the same frame.""" + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=_grid(1), + device=DEVICE_DIMENSIONS["flagship"], + config={ + "spin_frames": 4, + "column_stagger": 0, + "frame_interval_ms": 0, + "seed": 1, + }, + ) + ) + # last_lock = (cols-1)*0 + 4 = 4 → 4 frames + assert len(frames) == 4 + # Final frame must equal target. + assert frames[-1][0] == _grid(1) + + +def test_stagger_extends_frame_count(plugin): + """column_stagger>0 staggers the last lock further out.""" + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=_grid(1), + device=DEVICE_DIMENSIONS["flagship"], + config={ + "spin_frames": 3, + "column_stagger": 2, + "frame_interval_ms": 0, + "seed": 1, + }, + ) + ) + # last_lock = (22-1)*2 + 3 = 45 + assert len(frames) == 45 + assert frames[-1][0] == _grid(1) + + +def test_columns_lock_left_to_right(plugin): + """At intermediate frames, the leftmost columns are locked but rightmost are still spinning.""" + target = _grid(1) + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=target, + device=DEVICE_DIMENSIONS["flagship"], + config={ + "spin_frames": 2, + "column_stagger": 3, + "frame_interval_ms": 0, + "seed": 1, + }, + ) + ) + # last_lock = 21*3 + 2 = 65 + # At frame 5 (the 5th yielded), only columns where lock_at <= 5 are locked. + # lock_at[c] = c*3 + 2 → c=0 locks at 2, c=1 at 5, c=2 at 8. + mid_grid = frames[4][0] # 5th frame + # Columns 0 and 1 should be locked (value 1). + assert mid_grid[0][0] == 1 + assert mid_grid[0][1] == 1 + # Column 2 still spinning - either a random tile or, by coincidence, the target. + # We can't assert exact value, but we can assert it's a valid spin code or + # the target. + assert mid_grid[0][2] in [*_SPIN_CODES, 1] + + +def test_spinning_columns_use_spin_codes_only(plugin): + """While spinning, columns show codes from the SPIN_CODES set (letters + digits).""" + # Use a target of value 50 (clearly out of SPIN_CODES range). + target = _grid(50) + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=target, + device=DEVICE_DIMENSIONS["flagship"], + config={ + "spin_frames": 4, + "column_stagger": 5, + "frame_interval_ms": 0, + "seed": 1, + }, + ) + ) + # First frame: no columns are locked (lock_at[0] = 4 > 1). All tiles + # should be spin codes, none should be 50. + first = frames[0][0] + for row in first: + for tile in row: + assert tile in _SPIN_CODES + assert tile != 50 + + +def test_deterministic_seed_produces_same_sequence(): + cfg = { + "spin_frames": 4, + "column_stagger": 1, + "frame_interval_ms": 0, + "seed": 12345, + } + p1 = SlotMachineTransition(MANIFEST) + p2 = SlotMachineTransition(MANIFEST) + f1 = list(p1.generate_frames(_grid(0), _grid(1), DEVICE_DIMENSIONS["flagship"], cfg)) + f2 = list(p2.generate_frames(_grid(0), _grid(1), DEVICE_DIMENSIONS["flagship"], cfg)) + assert f1 == f2 + + +def test_seed_zero_is_nondeterministic(plugin): + """seed=0 means 'fresh random each call'.""" + cfg = { + "spin_frames": 2, + "column_stagger": 1, + "frame_interval_ms": 0, + "seed": 0, + } + # Just verify it terminates and finishes on target. + frames = list(plugin.generate_frames(_grid(0), _grid(1), DEVICE_DIMENSIONS["flagship"], cfg)) + assert frames[-1][0] == _grid(1) + + +def test_handles_note_device(plugin): + """Note board (3×15) works.""" + frames = list( + plugin.generate_frames( + from_grid=_grid(0, rows=3, cols=15), + to_grid=_grid(2, rows=3, cols=15), + device=DEVICE_DIMENSIONS["note"], + config={ + "spin_frames": 3, + "column_stagger": 1, + "frame_interval_ms": 0, + "seed": 7, + }, + ) + ) + # last_lock = 14*1 + 3 = 17 + assert len(frames) == 17 + assert frames[-1][0] == _grid(2, rows=3, cols=15) + + +def test_clamps_spin_frames_to_at_least_one(plugin): + """spin_frames=0 is clamped to 1.""" + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=_grid(1), + device=DEVICE_DIMENSIONS["flagship"], + config={ + "spin_frames": 0, + "column_stagger": 0, + "frame_interval_ms": 0, + "seed": 1, + }, + ) + ) + assert len(frames) == 1 + assert frames[0][0] == _grid(1) + + +def test_empty_to_grid_produces_no_frames(plugin): + frames = list(plugin.generate_frames(from_grid=[], to_grid=[], device=None, config={})) + assert frames == [] + + +def test_uses_defaults_when_config_missing(plugin): + """Empty config still produces a valid run using manifest defaults.""" + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=_grid(1), + device=DEVICE_DIMENSIONS["flagship"], + config={}, + ) + ) + # defaults: spin_frames=6, column_stagger=1 → last_lock = 21*1 + 6 = 27 + assert len(frames) == 27 + assert frames[0][1] == 80 # default frame_interval_ms + assert frames[-1][0] == _grid(1) diff --git a/plugins/typewriter/README.md b/plugins/typewriter/README.md new file mode 100644 index 000000000..c4213ac60 --- /dev/null +++ b/plugins/typewriter/README.md @@ -0,0 +1,37 @@ +# Typewriter Plugin + +A transition plugin that reveals the target message left-to-right, character by character, like an old typewriter. + +![Typewriter Display](./docs/board-display.png) + +**→ [Setup Guide](./docs/SETUP.md)** + +## Overview + +Typewriter is a transition plugin -- it doesn't display its own content. Instead, when one page replaces another on the board, Typewriter animates the change by revealing the new content one tile at a time, sweeping left-to-right and top-to-bottom. + +## Template Variables + +None. Transition plugins don't expose template variables; they shape *how* a board update happens, not *what* it shows. + +## Example Templates + +Transition plugins are selected per page from the page editor's "Transition" picker (or globally from Settings → Transitions). They aren't placed in templates directly. + +## Configuration + +| Setting | Type | Default | Description | +| --------------------- | ------- | ------- | -------------------------------------------------------- | +| `chars_per_frame` | integer | 1 | Tiles flipped per step (1-22). Larger = faster. | +| `frame_interval_ms` | integer | 120 | Pause between steps in milliseconds (0-2000). | + +## Features + +- Smooth left-to-right reveal of the target grid +- Tunable speed: choose how many tiles per step and how long between steps +- Bounded: capped at 200 frames / 60 seconds runtime +- Interruptible: a new page or trigger cancels the in-flight typewriter cleanly + +## Author + +FiestaBoard diff --git a/plugins/typewriter/__init__.py b/plugins/typewriter/__init__.py new file mode 100644 index 000000000..22258f837 --- /dev/null +++ b/plugins/typewriter/__init__.py @@ -0,0 +1,49 @@ +"""Typewriter transition plugin. + +Reveals the target grid one tile at a time, left-to-right and top-to-bottom, +producing the classic "typewriter" effect. The reveal moves through every +position on the board -- positions that are unchanged still tick by, so the +animation feels uniform regardless of how much content has actually changed. +""" + +from collections.abc import Iterator +from typing import Any + +from src.plugins.base import TransitionPluginBase + + +class TypewriterTransition(TransitionPluginBase): + """Left-to-right, char-by-char reveal of the target grid.""" + + @property + def plugin_id(self) -> str: + return "typewriter" + + 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]]: + chars_per_frame = max(1, int(config.get("chars_per_frame", 1))) + frame_interval_ms = max(0, int(config.get("frame_interval_ms", 120))) + + rows = len(to_grid) + cols = len(to_grid[0]) if rows else 0 + if rows == 0 or cols == 0: + return + + # Copy the from-grid; we'll overwrite tiles one batch at a time as + # the typewriter head sweeps across every position. + working = [list(row) for row in from_grid] + + revealed = 0 + total = rows * cols + while revealed < total: + end = min(revealed + chars_per_frame, total) + for idx in range(revealed, end): + r, c = divmod(idx, cols) + working[r][c] = to_grid[r][c] + revealed = end + yield [list(row) for row in working], frame_interval_ms diff --git a/plugins/typewriter/docs/SETUP.md b/plugins/typewriter/docs/SETUP.md new file mode 100644 index 000000000..c1f790160 --- /dev/null +++ b/plugins/typewriter/docs/SETUP.md @@ -0,0 +1,35 @@ +# Typewriter Setup Guide + +How to enable the Typewriter transition and use it on a page. + +## Overview + +**What it does**: Reveals new content on the board left-to-right, one tile (or small batch of tiles) at a time, like a typewriter. + +**Prerequisites**: None. Transition plugins ship as part of FiestaBoard and don't require API keys or external accounts. + +## Quick Setup + +1. **Enable** — Open Settings → Plugins, find Typewriter under "Transition Plugins", and toggle it on. +2. **Configure** — Optionally adjust `chars_per_frame` and `frame_interval_ms` (defaults are a good starting point). +3. **Apply** — Open the page editor for any page, set the Transition picker to "Typewriter", and save. Or set it as the global default in Settings → Transitions. +4. **View** — The next time that page becomes active, the board sweeps left-to-right as the new content lands. + +## Template Variables + +None. + +## Configuration Reference + +| Setting | Type | Default | Range | Description | +| ------------------- | ------- | ------- | ----------- | ------------------------------------------------- | +| `chars_per_frame` | integer | 1 | 1-22 | Tiles flipped per step. | +| `frame_interval_ms` | integer | 120 | 0-2000 ms | Pause between steps in milliseconds. | + +No environment variables required. + +## Troubleshooting + +- **Transition is too slow** — Lower `frame_interval_ms` or raise `chars_per_frame`. +- **Transition is too fast to see** — Raise `frame_interval_ms` (e.g. to 200-300). +- **Transition not visible** — Confirm the plugin is enabled in Settings → Plugins and selected on the page (or as the global default). diff --git a/plugins/typewriter/docs/board-display.png b/plugins/typewriter/docs/board-display.png new file mode 100644 index 000000000..909c66db1 Binary files /dev/null and b/plugins/typewriter/docs/board-display.png differ diff --git a/plugins/typewriter/manifest.json b/plugins/typewriter/manifest.json new file mode 100644 index 000000000..10093136f --- /dev/null +++ b/plugins/typewriter/manifest.json @@ -0,0 +1,45 @@ +{ + "id": "typewriter", + "name": "Typewriter", + "version": "1.0.0", + "description": "Reveals the target message left-to-right, character by character, like a typewriter.", + "author": "FiestaBoard", + "icon": "type", + "category": "transition", + "plugin_type": "transition", + "documentation": "README.md", + "settings_schema": { + "type": "object", + "properties": { + "chars_per_frame": { + "type": "integer", + "title": "Characters per frame", + "description": "How many tiles flip in each step. Larger = faster.", + "default": 1, + "minimum": 1, + "maximum": 22 + }, + "frame_interval_ms": { + "type": "integer", + "title": "Delay between frames (ms)", + "description": "Time to pause between each step.", + "default": 120, + "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": "Typewriter transition reveal", + "primary": true + } + ] +} diff --git a/plugins/typewriter/tests/__init__.py b/plugins/typewriter/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/typewriter/tests/test_typewriter.py b/plugins/typewriter/tests/test_typewriter.py new file mode 100644 index 000000000..b4fd9b2d4 --- /dev/null +++ b/plugins/typewriter/tests/test_typewriter.py @@ -0,0 +1,137 @@ +"""Tests for the typewriter transition plugin.""" + +import json +from pathlib import Path + +import pytest + +from plugins.typewriter import TypewriterTransition +from src.devices import DEVICE_DIMENSIONS + +MANIFEST = json.loads((Path(__file__).resolve().parents[1] / "manifest.json").read_text()) + + +@pytest.fixture +def plugin() -> TypewriterTransition: + return TypewriterTransition(MANIFEST) + + +def _grid(value: int, rows: int = 6, cols: int = 22): + return [[value] * cols for _ in range(rows)] + + +def test_plugin_id_matches_manifest(plugin): + assert plugin.plugin_id == MANIFEST["id"] + + +def test_transition_settings_loaded_from_manifest(plugin): + settings = plugin.transition_settings + assert settings["interruptible"] is True + assert settings["max_frames"] == 200 + assert settings["max_runtime_seconds"] == 60 + + +def test_generates_one_frame_per_chars_per_frame(plugin): + """With chars_per_frame=22 (one full row), 6 frames cover the flagship grid.""" + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=_grid(1), + device=DEVICE_DIMENSIONS["flagship"], + config={"chars_per_frame": 22, "frame_interval_ms": 0}, + ) + ) + assert len(frames) == 6 + # Last frame should equal target. + assert frames[-1][0] == _grid(1) + # All delays are 0. + assert all(delay == 0 for _, delay in frames) + + +def test_first_frame_only_reveals_first_char(): + """chars_per_frame=1 means the first frame differs from from_grid in exactly one tile.""" + plugin = TypewriterTransition(MANIFEST) + from_grid = _grid(0) + to_grid = _grid(1) + frames = list( + plugin.generate_frames( + from_grid=from_grid, + to_grid=to_grid, + device=DEVICE_DIMENSIONS["flagship"], + config={"chars_per_frame": 1, "frame_interval_ms": 50}, + ) + ) + # First frame: only the top-left tile is revealed. + first_grid, first_delay = frames[0] + assert first_grid[0][0] == 1 + assert first_grid[0][1] == 0 + assert first_delay == 50 + + +def test_total_frames_matches_grid_area_for_one_per_frame(): + """6 rows × 22 cols = 132 frames at chars_per_frame=1.""" + plugin = TypewriterTransition(MANIFEST) + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=_grid(1), + device=DEVICE_DIMENSIONS["flagship"], + config={"chars_per_frame": 1, "frame_interval_ms": 0}, + ) + ) + assert len(frames) == 132 + + +def test_handles_note_device(plugin): + """Note board is 3 rows × 15 cols = 45 tiles.""" + note_grid = _grid(0, rows=3, cols=15) + target = _grid(7, rows=3, cols=15) + frames = list( + plugin.generate_frames( + from_grid=note_grid, + to_grid=target, + device=DEVICE_DIMENSIONS["note"], + config={"chars_per_frame": 5, "frame_interval_ms": 30}, + ) + ) + assert len(frames) == 9 # 45 / 5 + assert frames[-1][0] == target + + +def test_chars_per_frame_clamped_to_at_least_one(plugin): + """Even with invalid config, the loop terminates.""" + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=_grid(1), + device=DEVICE_DIMENSIONS["flagship"], + config={"chars_per_frame": 0}, + ) + ) + assert len(frames) == 132 # treated as 1 per frame + + +def test_empty_grid_produces_no_frames(plugin): + frames = list( + plugin.generate_frames( + from_grid=[], + to_grid=[], + device=DEVICE_DIMENSIONS["flagship"], + config={}, + ) + ) + assert frames == [] + + +def test_uses_defaults_when_config_missing(plugin): + """Calling with empty config still produces frames using manifest defaults.""" + frames = list( + plugin.generate_frames( + from_grid=_grid(0), + to_grid=_grid(1), + device=DEVICE_DIMENSIONS["flagship"], + config={}, + ) + ) + assert len(frames) == 132 + assert frames[0][1] == 120 # default frame_interval_ms diff --git a/scripts/run_plugin_tests.py b/scripts/run_plugin_tests.py index 0f5a03ede..d168944ec 100644 --- a/scripts/run_plugin_tests.py +++ b/scripts/run_plugin_tests.py @@ -35,7 +35,7 @@ # Default settings DEFAULT_FAIL_UNDER = 80 # Minimum coverage percentage required WARNING_THRESHOLD = 80 # Warn if coverage is below this percentage -SKIP_DIRECTORIES = {"_template", "__pycache__"} +SKIP_DIRECTORIES = {"_template", "_template_transition", "__pycache__"} class PluginTestResult: diff --git a/scripts/validate_plugins.py b/scripts/validate_plugins.py index 62d1c213c..7a2dc8235 100644 --- a/scripts/validate_plugins.py +++ b/scripts/validate_plugins.py @@ -37,7 +37,7 @@ PLUGINS_DIR = PROJECT_ROOT / "plugins" # Directories to skip -SKIP_DIRECTORIES = {"_template", "__pycache__"} +SKIP_DIRECTORIES = {"_template", "_template_transition", "__pycache__"} class ValidationResult: @@ -185,7 +185,16 @@ def validate_manifest_schema(manifest: dict, plugin_dir_name: str) -> list[str]: errors.append("icon must be a string") # Validate category if present - valid_categories = ["art", "data", "transit", "weather", "entertainment", "utility", "home"] + valid_categories = [ + "art", + "data", + "transit", + "weather", + "entertainment", + "utility", + "home", + "transition", + ] category = manifest.get("category", "") if category and category not in valid_categories: errors.append(f"category must be one of: {', '.join(valid_categories)}") diff --git a/src/api_server.py b/src/api_server.py index 731c9da94..37358be16 100644 --- a/src/api_server.py +++ b/src/api_server.py @@ -2937,11 +2937,11 @@ async def send_message(request: MessageRequest): settings_service = get_settings_service() transition = settings_service.get_transition_settings() - success, was_sent = service.vb_client.send_characters( + success, was_sent = service.vb_client.render( board_array, strategy=transition.strategy, step_interval_ms=transition.step_interval_ms, - step_size=transition.step_size + step_size=transition.step_size, ) if success: if was_sent: @@ -3087,12 +3087,13 @@ async def send_welcome_message(): dims = get_dimensions(device_type) board_array = text_to_board_array(welcome_text, rows=dims.rows, cols=dims.cols) - success, was_sent = board_client.send_characters( + success, was_sent = board_client.render( board_array, strategy=transition.strategy, step_interval_ms=transition.step_interval_ms, step_size=transition.step_size, - force=True # Force send even if cached + force=True, # Force send even if cached + device_type=device_type, ) if success: @@ -4122,11 +4123,12 @@ async def send_display( device_type = board_settings.boards[0].get("device_type", "flagship") dims = get_dimensions(device_type) board_array = text_to_board_array(result.formatted, rows=dims.rows, cols=dims.cols) - success, was_sent = service.vb_client.send_characters( + success, was_sent = service.vb_client.render( board_array, strategy=transition.strategy, step_interval_ms=transition.step_interval_ms, - step_size=transition.step_size + step_size=transition.step_size, + device_type=device_type, ) sent_to_board = was_sent if not success: @@ -4977,13 +4979,255 @@ async def get_transition_settings(): } +def _ensure_transition_plugins_beta() -> None: + """Gate transition-plugin endpoints behind the beta flag. + + The SDK is experimental and its contract may change. Until the + operator opts in via Settings → Beta the endpoints respond 404 so + the feature is fully hidden -- no plugin picker, no preview page, + no surface area for users to start depending on something we may + reshape. + """ + settings_service = get_settings_service() + beta = settings_service.get_beta_settings() + if not beta.transition_plugins_enabled: + raise HTTPException( + status_code=404, + detail=( + "Transition plugins are an experimental beta. Enable them " + "in Settings → Beta to use this endpoint." + ), + ) + + +def _reject_plugin_strategy_when_beta_off(strategy: str | None) -> None: + """Reject ``plugin:`` strategies when the transition-plugin beta + flag is off. + + Applied to page create / update so a page can't persist a plugin + strategy that the runtime won't actually honor. Symmetric with the + settings-service guard on ``update_transition_settings``. + """ + if not isinstance(strategy, str): + return + from .settings.service import TRANSITION_PLUGIN_PREFIX # local: avoid cycle + + if not strategy.startswith(TRANSITION_PLUGIN_PREFIX): + return + settings_service = get_settings_service() + if not settings_service.get_beta_settings().transition_plugins_enabled: + raise HTTPException( + status_code=400, + detail=( + "Transition plugins are an experimental beta. Enable them " + "in Settings → Beta before assigning a 'plugin:' " + "strategy to a page." + ), + ) + + +@app.get("/transitions/plugins") +async def list_transition_plugins(): + """List enabled transition plugins available for selection. + + Each entry includes the plugin id, display name, manifest metadata, + its ``settings_schema`` (so the UI can render a config form), and the + plugin's ``transition_settings`` caps. Only enabled transition + plugins are returned -- disabled ones can't be invoked. + + Gated behind ``beta.transition_plugins_enabled``. + """ + _ensure_transition_plugins_beta() + + from .plugins.base import TransitionPluginBase + from .plugins.registry import get_plugin_registry + + registry = get_plugin_registry() + out = [] + for plugin_id, plugin in registry._plugins.items(): # noqa: SLF001 - registry is in-process + if not isinstance(plugin, TransitionPluginBase): + continue + if not registry.is_enabled(plugin_id): + continue + manifest = registry.get_manifest(plugin_id) + if manifest is None: + continue + out.append({ + "id": plugin_id, + "name": manifest.name, + "description": manifest.description, + "icon": manifest.icon, + "version": manifest.version, + "author": manifest.author, + "settings_schema": manifest.settings_schema, + "transition_settings": plugin.transition_settings, + "config": dict(plugin.config or {}), + "strategy": f"plugin:{plugin_id}", + }) + out.sort(key=lambda e: e["name"].lower()) + return {"plugins": out} + + +@app.post("/transitions/preview") +async def preview_transition(request: dict): + """Drive a transition plugin once and return its frame sequence. + + Gated behind ``beta.transition_plugins_enabled`` -- see + ``_ensure_transition_plugins_beta``. + """ + _ensure_transition_plugins_beta() + return await _preview_transition_impl(request) + + +async def _preview_transition_impl(request: dict): + """Run a transition plugin once and return the resulting frame sequence. + + Designed for the standalone /transitions test harness in the web UI. + Frames are generated in-process and returned as JSON; nothing is sent + to a real board. + + Request body: + - plugin_id (str, required): the transition plugin to drive + - from_text (str, optional): text to render into the from-grid + (uses text_to_board_array). Defaults to a blank grid. + - to_text (str, required): text to render into the to-grid + - config (dict, optional): per-run overrides for the plugin's + settings_schema fields. Merged on top of the plugin's + currently-bound config. + - device_type (str, optional): "flagship" (default) or "note" + + Response: + ``{"frames": [{"grid": [[..]], "delay_ms": int}, ...], + "total_delay_ms": int, "capped": bool, "plugin_id": str}`` + + The runner's caps (max_frames, max_runtime_seconds, min_interval_ms) + are honored. If the plugin exceeds either max_frames or + max_runtime_seconds the response is truncated and ``capped`` is set + to true so the UI can display a hint. + + Iteration happens on a worker thread (``asyncio.to_thread``) so a + slow / runaway plugin generator cannot block FastAPI's event loop. + """ + import asyncio + + from .devices import DEVICE_DIMENSIONS, get_dimensions + from .plugins.registry import get_plugin_registry + from .text_to_board import text_to_board_array + + plugin_id = request.get("plugin_id") + if not plugin_id or not isinstance(plugin_id, str): + raise HTTPException(status_code=400, detail="plugin_id is required") + + registry = get_plugin_registry() + plugin = registry.get_transition_plugin(plugin_id) + if plugin is None: + raise HTTPException( + status_code=404, + detail=f"Transition plugin {plugin_id!r} not loaded or not enabled", + ) + + device_type = request.get("device_type", "flagship") + if device_type not in DEVICE_DIMENSIONS: + raise HTTPException( + status_code=400, detail=f"Unknown device_type: {device_type}" + ) + device = get_dimensions(device_type) + + from_text = request.get("from_text", "") + to_text = request.get("to_text", "") + from_grid = text_to_board_array(from_text, rows=device.rows, cols=device.cols) + to_grid = text_to_board_array(to_text, rows=device.rows, cols=device.cols) + + # Merge override config on top of the plugin's currently-bound config. + config = dict(plugin.config or {}) + overrides = request.get("config") or {} + if isinstance(overrides, dict): + config.update(overrides) + + caps = plugin.transition_settings + max_frames = int(caps["max_frames"]) + min_interval_ms = int(caps["min_interval_ms"]) + max_runtime_s = int(caps["max_runtime_seconds"]) + + def _collect_frames() -> tuple[list, int, bool, str | None]: + """Run on a worker thread; returns (frames, total_delay, capped, error).""" + import time + + frames: list = [] + total_delay = 0 + capped_flag = False + started = time.monotonic() + try: + for raw_frame in plugin.generate_frames(from_grid, to_grid, device, config): + if len(frames) >= max_frames: + capped_flag = True + break + if (time.monotonic() - started) >= max_runtime_s: + capped_flag = True + break + if isinstance(raw_frame, tuple) and len(raw_frame) == 2: + grid, delay = raw_frame + else: + grid, delay = raw_frame, 0 + try: + delay = int(delay or 0) + except (TypeError, ValueError): + delay = 0 + delay = max(delay, min_interval_ms) + if not isinstance(grid, list) or not grid or not isinstance(grid[0], list): + continue + frames.append({"grid": grid, "delay_ms": delay}) + total_delay += delay + except Exception as exc: + return frames, total_delay, capped_flag, str(exc) + return frames, total_delay, capped_flag, None + + try: + # Bound the thread itself: if iteration takes longer than the cap + + # a small grace period, give up rather than letting a runaway + # plugin tie up a worker thread indefinitely. + frames, total_delay, capped, error = await asyncio.wait_for( + asyncio.to_thread(_collect_frames), + timeout=max_runtime_s + 5, + ) + except TimeoutError as exc: + logger.warning( + "Transition preview for %s exceeded %ds; aborting", + plugin_id, + max_runtime_s, + ) + raise HTTPException( + status_code=504, + detail=( + f"Plugin {plugin_id!r} exceeded the {max_runtime_s}s " + "runtime cap and was aborted." + ), + ) from exc + + if error is not None: + logger.warning("Transition preview failed for %s: %s", plugin_id, error) + raise HTTPException(status_code=500, detail=f"Plugin error: {error}") + + return { + "plugin_id": plugin_id, + "device_type": device_type, + "frames": frames, + "frame_count": len(frames), + "total_delay_ms": total_delay, + "capped": capped, + "from_grid": from_grid, + "to_grid": to_grid, + } + + @app.put("/settings/transitions") async def update_transition_settings(request: dict): """ Update transition animation settings. Body can include: - - strategy: One of column, reverse-column, edges-to-center, row, diagonal, random, or null + - strategy: One of column, reverse-column, edges-to-center, row, diagonal, random, + "plugin:" to drive a transition plugin, or null to disable. - step_interval_ms: Delay between animation steps (ms), or null for default - step_size: How many columns/rows animate at once, or null for default """ @@ -5110,11 +5354,12 @@ async def set_active_page(request: dict): dims = get_dimensions(page.device_type) board_array = text_to_board_array(result.formatted, rows=dims.rows, cols=dims.cols) - success, was_sent = service.vb_client.send_characters( + success, was_sent = service.vb_client.render( board_array, strategy=strategy, step_interval_ms=interval_ms, - step_size=step_size + step_size=step_size, + device_type=page.device_type, ) sent_to_board = was_sent if not success: @@ -6074,6 +6319,7 @@ async def create_page(page_data: PageCreate): - composite: Combine rows from multiple sources (set rows) - template: Custom templated content (set template) """ + _reject_plugin_strategy_when_beta_off(page_data.transition_strategy) page_service = get_page_service() try: @@ -6101,6 +6347,7 @@ async def get_page(page_id: str): @app.put("/pages/{page_id}") async def update_page(page_id: str, page_data: PageUpdate): """Update an existing page.""" + _reject_plugin_strategy_when_beta_off(page_data.transition_strategy) page_service = get_page_service() try: @@ -6185,8 +6432,11 @@ async def import_page(body: PageImportRequest): page_service = get_page_service() try: page_create = PageCreate(**{k: v for k, v in page_data.items() if k in PageCreate.model_fields}) + _reject_plugin_strategy_when_beta_off(page_create.transition_strategy) page = page_service.create_page(page_create) return {"status": "success", "page": page.model_dump()} + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=422, detail=str(e)) from e @@ -6437,11 +6687,12 @@ async def send_page(page_id: str, target: str | None = None): # Convert to board array with dimensions for page's device type (flagship vs note) dims = get_dimensions(page.device_type) board_array = text_to_board_array(result.formatted, rows=dims.rows, cols=dims.cols) - success, was_sent = service.vb_client.send_characters( + success, was_sent = service.vb_client.render( board_array, strategy=strategy, step_interval_ms=interval_ms, - step_size=step_size + step_size=step_size, + device_type=page.device_type, ) sent_to_board = was_sent if not success: diff --git a/src/board_client.py b/src/board_client.py index 64c3734b6..80180f32d 100644 --- a/src/board_client.py +++ b/src/board_client.py @@ -16,12 +16,17 @@ import json import logging import re +import threading from typing import Any, Literal, Optional import requests logger = logging.getLogger(__name__) +# Sentinel prefix on the strategy string that routes a render() call through +# a transition plugin instead of the hardware's built-in strategies. +TRANSITION_PLUGIN_PREFIX = "plugin:" + # Regex pattern to match color markers like {63}, {red}, {/}, {/red} COLOR_MARKER_PATTERN = re.compile( r"\{(?:" @@ -183,6 +188,143 @@ def __init__( self._last_text: str | None = None self._last_characters: list[list[int]] | None = None + # Per-board lock serializing sends. Used by the transition runner to + # make sure rotation / manual API / trigger sends don't interleave + # frames mid-transition. RLock so render() → send_characters chains + # don't self-deadlock. + self._send_lock = threading.RLock() + + # Cancellation flag for the currently-running interruptible transition. + # Each render() call installs a fresh Event under the send lock and + # passes it to the runner. A concurrent render() signals the + # currently-active event *before* acquiring the lock so an in-flight + # transition actually wakes up between frames. Using a per-run Event + # (rather than set/clear-ing one shared Event) means a freshly-started + # transition can't be pre-cancelled by a signal that was meant for the + # previous run. + self._cancel_transition: threading.Event = threading.Event() + + # Pluggable transition runner. Set via set_transition_runner() by + # the service layer at startup. When None, "plugin:" strategies + # fall back to a plain send (logged warning). + self._transition_runner: Any | None = None + + def set_transition_runner(self, runner: Any | None) -> None: + """Attach (or detach) the transition runner used by :meth:`render`. + + Decouples this module from the runner implementation; the service + layer injects the runner once both are constructed. + """ + self._transition_runner = runner + + @staticmethod + def _transition_plugins_beta_enabled() -> bool: + """Return whether the transition-plugin beta flag is currently on. + + Imported lazily so this module has no hard dependency on the + settings layer. Failures default to *False* -- if the settings + service can't be reached, we'd rather fall back to a no-strategy + send than execute experimental code. + """ + try: + from .settings.service import get_settings_service + + return bool(get_settings_service().get_beta_settings().transition_plugins_enabled) + except Exception as exc: # pragma: no cover - defensive + logger.warning("render: could not read transition_plugins beta flag: %s", exc) + return False + + def render( + self, + characters: list[list[int]], + *, + strategy: str | None = None, + step_interval_ms: int | None = None, + step_size: int | None = None, + force: bool = False, + device_type: str | None = None, + ) -> tuple[bool, bool]: + """High-level send that understands transition-plugin strategies. + + Behaves identically to :meth:`send_characters` for built-in + strategies (``column``, ``edges-to-center``, etc. or *None*). When + *strategy* starts with ``"plugin:"`` and a transition runner is + attached, the runner drives a frame-by-frame animation toward + *characters*; the final frame is always *characters* itself. + + Args: + characters: Target grid to render. + strategy: Built-in strategy name, ``"plugin:"``, or *None*. + step_interval_ms: Forwarded to built-in strategies. + step_size: Forwarded to built-in strategies. + force: Bypass the unchanged-message cache. + device_type: Optional device hint forwarded to the transition + runner so plugins receive the right dimensions. + + Returns: + ``(success, was_sent)`` mirroring :meth:`send_characters`. + """ + is_plugin = isinstance(strategy, str) and strategy.startswith(TRANSITION_PLUGIN_PREFIX) + + # Signal any in-flight transition to wind down *before* we wait on + # the send lock. Without this, a built-in render() arriving during + # a plugin transition would block on the lock instead of preempting + # the animation, and the in-flight runner would never see a cancel. + self._cancel_transition.set() + + with self._send_lock: + # Install a fresh Event for this run so a stale set() from the + # previous caller can't immediately cancel us. The runner of + # the just-cancelled transition still holds its own reference + # to the old Event, so its cancellation signal isn't lost. + run_cancel_event = threading.Event() + self._cancel_transition = run_cancel_event + + if not is_plugin: + return self.send_characters( + characters, + strategy=strategy, + step_interval_ms=step_interval_ms, + step_size=step_size, + force=force, + ) + + plugin_id = strategy[len(TRANSITION_PLUGIN_PREFIX) :].strip() + if not plugin_id: + logger.warning( + "render: empty transition plugin id in strategy %r; sending as-is", + strategy, + ) + return self.send_characters(characters, strategy=None, force=force) + + # Defense in depth: if the operator toggled the beta flag off + # after pages were saved with a plugin: strategy, the runtime + # must not execute plugin code anyway -- the API surface is + # gated, and so is the execution path. Import locally to avoid + # a hard dependency from this module on settings. + if not self._transition_plugins_beta_enabled(): + logger.warning( + "render: transition_plugins beta is off; plugin:%s ignored, snapping to target", + plugin_id, + ) + return self.send_characters(characters, strategy=None, force=force) + + runner = self._transition_runner + if runner is None: + logger.warning( + "render: no transition runner attached; plugin:%s ignored, snapping to target grid", + plugin_id, + ) + return self.send_characters(characters, strategy=None, force=force) + + return runner.run( + plugin_id=plugin_id, + to_grid=characters, + board_client=self, + cancel_event=run_cancel_event, + device_type=device_type, + ) + def send_text(self, text: str, force: bool = False) -> tuple[bool, bool]: """ Send plain text message to the board. diff --git a/src/main.py b/src/main.py index 5898bf576..5fa6de815 100644 --- a/src/main.py +++ b/src/main.py @@ -57,6 +57,7 @@ def _build_board_clients(self): client = board_client_from_board_dict(first) if client: self.vb_client = client + self._attach_transition_runner(client) try: self.vb_client.read_current_message(sync_cache=True) except Exception as e: @@ -69,11 +70,29 @@ def _build_board_clients(self): use_cloud=use_cloud, skip_unchanged=True, ) + self._attach_transition_runner(self.vb_client) try: self.vb_client.read_current_message(sync_cache=True) except Exception as e: logger.warning(f"Could not sync cache with board: {e}") + @staticmethod + def _attach_transition_runner(client: BoardClient) -> None: + """Attach the global transition runner so render("plugin:...") works. + + Imports are local so test scaffolding can build clients without + pulling in the plugin registry. + """ + try: + from .plugins.registry import get_plugin_registry + from .transitions import TransitionRunner + + registry = get_plugin_registry() + runner = TransitionRunner(resolver=registry.get_transition_plugin) + client.set_transition_runner(runner) + except Exception as exc: # pragma: no cover - defensive + logger.warning(f"Could not attach transition runner: {exc}") + def reinitialize_board_client(self) -> bool: """Reinitialize the board client with current config. @@ -372,8 +391,12 @@ def check_and_send_active_page(self) -> bool: dims = get_dimensions(page.device_type) board_array = text_to_board_array(content_to_send, rows=dims.rows, cols=dims.cols) - success, was_sent = self.vb_client.send_characters( - board_array, strategy=strategy, step_interval_ms=interval_ms, step_size=step_size + success, was_sent = self.vb_client.render( + board_array, + strategy=strategy, + step_interval_ms=interval_ms, + step_size=step_size, + device_type=page.device_type, ) if success: @@ -411,11 +434,12 @@ def _send_blank_board(self) -> bool: settings_service = get_settings_service() system_transition = settings_service.get_transition_settings() - success, was_sent = self.vb_client.send_characters( + success, was_sent = self.vb_client.render( board_array, strategy=system_transition.strategy, step_interval_ms=system_transition.step_interval_ms, step_size=system_transition.step_size, + device_type=device_type, ) if success: @@ -493,11 +517,12 @@ def _send_silence_indicator(self, page_device_type: str) -> bool: system_transition = settings_service.get_transition_settings() board_array = self._build_silence_indicator_array(device_type) - success, was_sent = self.vb_client.send_characters( + success, was_sent = self.vb_client.render( board_array, strategy=system_transition.strategy, step_interval_ms=system_transition.step_interval_ms, step_size=system_transition.step_size, + device_type=device_type, ) if success: @@ -552,11 +577,12 @@ def _send_silence_page(self) -> bool: dims = get_dimensions(page.device_type) board_array = text_to_board_array(result.formatted, rows=dims.rows, cols=dims.cols) - success, was_sent = self.vb_client.send_characters( + success, was_sent = self.vb_client.render( board_array, strategy=strategy, step_interval_ms=interval_ms, step_size=step_size, + device_type=page.device_type, ) if success: @@ -634,14 +660,16 @@ def _send_trigger_content(self, content: str) -> bool: settings_service = get_settings_service() system_transition = settings_service.get_transition_settings() - dims = get_dimensions(self._silence_device_type()) + device_type = self._silence_device_type() + dims = get_dimensions(device_type) board_array = text_to_board_array(content, rows=dims.rows, cols=dims.cols) - success, was_sent = self.vb_client.send_characters( + success, was_sent = self.vb_client.render( board_array, strategy=system_transition.strategy, step_interval_ms=system_transition.step_interval_ms, step_size=system_transition.step_size, + device_type=device_type, ) if success: diff --git a/src/pages/models.py b/src/pages/models.py index 0bfc64545..16a1116bd 100644 --- a/src/pages/models.py +++ b/src/pages/models.py @@ -77,8 +77,11 @@ class Page(BaseModel): # Rotation settings duration_seconds: int = Field(default=300, ge=10, le=3600) # 10s to 1h - # Transition settings (per-page override, None means use system defaults) - # Valid strategies: column, reverse-column, edges-to-center, row, diagonal, random + # Transition settings (per-page override, None means use system defaults). + # Valid strategies: column, reverse-column, edges-to-center, row, diagonal, + # random, or "plugin:" to drive a frame-by-frame transition plugin + # (e.g. "plugin:typewriter"). Pydantic stores any string; the strategy + # is validated lazily at send-time by board_client.render(). transition_strategy: str | None = None transition_interval_ms: int | None = Field(default=None, ge=0, le=5000) transition_step_size: int | None = Field(default=None, ge=1) diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py index c4c8fe270..41badd779 100644 --- a/src/plugins/__init__.py +++ b/src/plugins/__init__.py @@ -12,7 +12,13 @@ 3. **Git URL** – arbitrary public git repositories specified by the user. """ -from .base import PluginBase, PluginResult, TriggerResult +from .base import ( + PluginBase, + PluginResult, + TransitionFrame, + TransitionPluginBase, + TriggerResult, +) from .loader import PluginLoader from .manifest import DemoPageSchema, PluginManifest, validate_manifest from .registry import INSTANCE_SEPARATOR, PluginRegistry, get_plugin_registry @@ -34,6 +40,8 @@ "PluginResult", "PluginSource", "RegistryEntry", + "TransitionFrame", + "TransitionPluginBase", "TriggerResult", "get_plugin_registry", "load_registry", diff --git a/src/plugins/base.py b/src/plugins/base.py index e8c30be79..8aa32e560 100644 --- a/src/plugins/base.py +++ b/src/plugins/base.py @@ -1,10 +1,13 @@ """Base classes for FiestaBoard plugins. -All plugins must inherit from PluginBase and implement the required methods. +All data plugins must inherit from :class:`PluginBase`. Transition +plugins (frame-by-frame board animations) inherit from +:class:`TransitionPluginBase` instead. """ import logging from abc import ABC, abstractmethod +from collections.abc import Iterator from dataclasses import dataclass from datetime import datetime from typing import Any @@ -534,3 +537,167 @@ def get_env_vars(self) -> list[dict[str, Any]]: List of env var definitions with name, required, description. """ return self._manifest.get("env_vars", []) + + +# Defaults for transition plugin manifest's transition_settings block. +DEFAULT_TRANSITION_INTERRUPTIBLE = True +DEFAULT_TRANSITION_MIN_INTERVAL_MS = 50 +DEFAULT_TRANSITION_MAX_FRAMES = 500 +DEFAULT_TRANSITION_MAX_RUNTIME_SECONDS = 120 + + +# Type alias documenting the (frame_grid, delay_ms_before_next) tuple a +# transition plugin yields. A grid is a list of rows of character codes; +# delay_ms is how long the runner should wait after sending this frame +# before pulling the next one from the iterator (clamped to the manifest's +# min_interval_ms floor). +TransitionFrame = tuple[list[list[int]], int] + + +class TransitionPluginBase(ABC): + """Abstract base class for transition plugins. + + Transition plugins produce a *sequence* of board frames that move the + display from one grid (``from_grid``) to another (``to_grid``). They + are driven by the host's :class:`~src.transitions.runner.TransitionRunner`, + which calls :meth:`generate_frames` and sends each yielded frame to the + board with an interruptible sleep between them. + + Unlike :class:`PluginBase`, transition plugins do not return template + variables, do not have a refresh cadence, and have no triggers. They + are configured solely via their own ``settings_schema``; other plugins + cannot influence transition behavior. + + Subclasses must implement: + * :attr:`plugin_id` + * :meth:`generate_frames` + + Optional hooks: + * :meth:`validate_config` + * :meth:`on_config_change` + * :meth:`cleanup` + """ + + def __init__(self, manifest: dict[str, Any]): + """Initialize the transition plugin with its manifest dict.""" + self._manifest = manifest + self._config: dict[str, Any] = {} + self._enabled = False + logger.debug(f"TransitionPlugin initialized: {self.plugin_id}") + + @property + @abstractmethod + def plugin_id(self) -> str: + """Return unique plugin identifier (must match manifest ``id``).""" + + @property + def manifest(self) -> dict[str, Any]: + """Return the plugin's raw manifest dictionary.""" + return self._manifest + + @property + def info(self) -> PluginInfo: + """Return plugin metadata extracted from the manifest.""" + return PluginInfo( + id=self._manifest.get("id", self.plugin_id), + name=self._manifest.get("name", self.plugin_id), + version=self._manifest.get("version", "0.0.0"), + description=self._manifest.get("description", ""), + author=self._manifest.get("author", "Unknown"), + repository=self._manifest.get("repository", ""), + documentation=self._manifest.get("documentation", "README.md"), + ) + + @property + def config(self) -> dict[str, Any]: + """Return current plugin configuration.""" + return self._config + + @config.setter + def config(self, value: dict[str, Any]) -> None: + old_config = self._config + self._config = value + if old_config != value: + self.on_config_change(old_config, value) + + @property + def enabled(self) -> bool: + """Return whether the plugin is enabled.""" + return self._enabled + + @enabled.setter + def enabled(self, value: bool) -> None: + if self._enabled != value: + self._enabled = value + if value: + logger.info(f"TransitionPlugin enabled: {self.plugin_id}") + else: + logger.info(f"TransitionPlugin disabled: {self.plugin_id}") + self.cleanup() + + def get_settings_schema(self) -> dict[str, Any]: + """Return the JSON schema for the plugin's settings form.""" + return self._manifest.get("settings_schema", {}) + + @property + def transition_settings(self) -> dict[str, Any]: + """Return the merged ``transition_settings`` block from manifest. + + Falls back to module-level defaults for any missing keys so callers + always get a fully populated dict. + """ + raw = self._manifest.get("transition_settings", {}) or {} + return { + "interruptible": bool(raw.get("interruptible", DEFAULT_TRANSITION_INTERRUPTIBLE)), + "min_interval_ms": int(raw.get("min_interval_ms", DEFAULT_TRANSITION_MIN_INTERVAL_MS)), + "max_frames": int(raw.get("max_frames", DEFAULT_TRANSITION_MAX_FRAMES)), + "max_runtime_seconds": int(raw.get("max_runtime_seconds", DEFAULT_TRANSITION_MAX_RUNTIME_SECONDS)), + } + + @abstractmethod + def generate_frames( + self, + from_grid: list[list[int]], + to_grid: list[list[int]], + device: Any, + config: dict[str, Any], + ) -> Iterator[TransitionFrame]: + """Yield (frame_grid, delay_ms) tuples driving the transition. + + The runner sends each ``frame_grid`` to the board, then waits + ``delay_ms`` (clamped to ``min_interval_ms``) before pulling the + next frame. The runner also enforces ``max_frames`` and + ``max_runtime_seconds`` caps from the manifest -- if the generator + exceeds either, the runner aborts and snaps the board to + ``to_grid``. + + Args: + from_grid: The grid currently displayed on the board. May be + a blank grid if the previous state is unknown. + to_grid: The target grid the transition is moving toward. + device: The :class:`~src.devices.DeviceDimensions` for the + target board (carries rows/cols). + config: The resolved plugin config dict (already merged with + schema defaults by the caller). + + Yields: + ``(grid, delay_ms_before_next)`` tuples. The final frame need + not equal ``to_grid`` -- the runner always sends ``to_grid`` + once the generator is exhausted to guarantee the board lands + on the exact target. + """ + + def validate_config(self, config: dict[str, Any]) -> list[str]: + """Validate a config dict. Override to add custom checks. + + Returns: + List of error messages (empty if valid). + """ + return [] + + def on_config_change(self, old_config: dict[str, Any], new_config: dict[str, Any]) -> None: + """Hook called when config changes. Override to react.""" + logger.debug(f"Config changed for transition plugin {self.plugin_id}") + + def cleanup(self) -> None: # noqa: B027 - intentional optional override + """Hook called when the plugin is disabled or unloaded.""" diff --git a/src/plugins/loader.py b/src/plugins/loader.py index 5283d94f3..0ef8375ca 100644 --- a/src/plugins/loader.py +++ b/src/plugins/loader.py @@ -11,13 +11,16 @@ from pathlib import Path from typing import Any -from .base import PluginBase +from .base import PluginBase, TransitionPluginBase from .manifest import PluginManifest, load_manifest from .sources import ( EXTERNAL_PLUGINS_DIR, PluginSource, ) +# A loaded plugin instance can be either a data plugin or a transition plugin. +AnyPlugin = PluginBase | TransitionPluginBase + logger = logging.getLogger(__name__) # Default plugins directory (relative to project root) @@ -120,8 +123,8 @@ def __init__( else: self._external_dirs = list(external_dirs) - self._loaded_plugins: dict[str, tuple[PluginBase, PluginManifest]] = {} - self._plugin_classes: dict[str, type[PluginBase]] = {} + self._loaded_plugins: dict[str, tuple[AnyPlugin, PluginManifest]] = {} + self._plugin_classes: dict[str, type[AnyPlugin]] = {} self._load_errors: dict[str, list[str]] = {} self._plugin_sources: dict[str, PluginSource] = {} @@ -132,10 +135,32 @@ def __init__( ) @property - def loaded_plugins(self) -> dict[str, tuple[PluginBase, PluginManifest]]: - """Return all successfully loaded plugins.""" + def loaded_plugins(self) -> dict[str, tuple[AnyPlugin, PluginManifest]]: + """Return all successfully loaded plugins (data + transition).""" return self._loaded_plugins.copy() + @property + def data_plugins(self) -> dict[str, tuple[PluginBase, PluginManifest]]: + """Return only loaded *data* plugins (PluginBase subclasses).""" + return {pid: (inst, m) for pid, (inst, m) in self._loaded_plugins.items() if isinstance(inst, PluginBase)} + + @property + def transition_plugins(self) -> dict[str, tuple[TransitionPluginBase, PluginManifest]]: + """Return only loaded *transition* plugins.""" + return { + pid: (inst, m) for pid, (inst, m) in self._loaded_plugins.items() if isinstance(inst, TransitionPluginBase) + } + + def get_transition_plugin(self, plugin_id: str) -> TransitionPluginBase | None: + """Return a loaded transition plugin instance, or None.""" + entry = self._loaded_plugins.get(plugin_id) + if entry is None: + return None + instance, _ = entry + if isinstance(instance, TransitionPluginBase): + return instance + return None + @property def load_errors(self) -> dict[str, list[str]]: """Return load errors by plugin directory name.""" @@ -221,7 +246,7 @@ def _source_for_dir(self, plugin_dir: Path) -> PluginSource: # ── loading ────────────────────────────────────────────────────────── - def load_plugin(self, plugin_name: str) -> PluginBase | None: + def load_plugin(self, plugin_name: str) -> AnyPlugin | None: """Load a single plugin by directory name. Args: @@ -284,18 +309,47 @@ def load_plugin(self, plugin_name: str) -> PluginBase | None: return None try: - # Import the plugin module dynamically + # Import the plugin module dynamically. If the module has + # already been *fully* imported via the normal Python import + # machinery (e.g. a test holds ``from plugins.date_time import X``), + # reuse that existing entry instead of clobbering it -- a + # replaced sys.modules entry leaves the prior import's + # references pointing at a stale module object, which breaks + # any patches the caller has applied. + # + # We must NOT reuse a partially-loaded module: when a previous + # exec_module raised mid-execution, sys.modules can still hold + # a module object whose ``__file__`` matches. Reusing that + # half-initialized module would mask the original failure. + # The ``__fiestaboard_loaded__`` sentinel is set only after a + # successful exec_module below. module_name = f"plugins.{plugin_name}" - spec = importlib.util.spec_from_file_location(module_name, init_path) - - if spec is None or spec.loader is None: - errors.append(f"Failed to create module spec for {plugin_name}") - self._load_errors[plugin_name] = errors - return None - - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) + existing = sys.modules.get(module_name) + existing_path = getattr(existing, "__file__", None) if existing is not None else None + if ( + existing is not None + and existing_path == str(init_path) + and getattr(existing, "__fiestaboard_loaded__", False) + ): + module = existing + else: + spec = importlib.util.spec_from_file_location(module_name, init_path) + + if spec is None or spec.loader is None: + errors.append(f"Failed to create module spec for {plugin_name}") + self._load_errors[plugin_name] = errors + return None + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception: + # Drop the half-loaded module so a retry doesn't pick + # up an inconsistent object. + sys.modules.pop(module_name, None) + raise + module.__fiestaboard_loaded__ = True except Exception as e: errors.append(f"Failed to import plugin module: {e}") @@ -303,10 +357,13 @@ def load_plugin(self, plugin_name: str) -> PluginBase | None: logger.exception(f"Error importing plugin {plugin_name}") return None - # Find PluginBase subclass - plugin_class = self._find_plugin_class(module, manifest.id) + # Find PluginBase or TransitionPluginBase subclass. The manifest's + # plugin_type determines which we expect; mismatches are errors. + expected_type = manifest.plugin_type or "data" + plugin_class = self._find_plugin_class(module, expected_type) if plugin_class is None: - errors.append(f"No PluginBase subclass found in {plugin_name}") + base_name = "TransitionPluginBase" if expected_type == "transition" else "PluginBase" + errors.append(f"No {base_name} subclass found in {plugin_name} (manifest plugin_type={expected_type!r})") self._load_errors[plugin_name] = errors return None @@ -336,36 +393,32 @@ def load_plugin(self, plugin_name: str) -> PluginBase | None: logger.exception(f"Error instantiating plugin {plugin_name}") return None - def _find_plugin_class(self, module: Any, expected_id: str) -> type[PluginBase] | None: - """Find the PluginBase subclass in a module. + def _find_plugin_class(self, module: Any, expected_type: str = "data") -> type[AnyPlugin] | None: + """Find a plugin class in *module* matching *expected_type*. Args: - module: Loaded Python module - expected_id: Expected plugin_id for validation + module: Loaded Python module to scan. + expected_type: ``"data"`` (look for :class:`PluginBase` subclass) + or ``"transition"`` (look for :class:`TransitionPluginBase`). Returns: - PluginBase subclass, or None if not found + The matching plugin class, or None if not found. """ - # Look for exported Plugin class + base_class: type[AnyPlugin] = TransitionPluginBase if expected_type == "transition" else PluginBase + for attr_name in dir(module): attr = getattr(module, attr_name) - - # Skip non-classes if not isinstance(attr, type): continue - - # Skip PluginBase itself - if attr is PluginBase: + # Skip the base classes themselves. + if attr is PluginBase or attr is TransitionPluginBase: continue - - # Check if it's a PluginBase subclass - if issubclass(attr, PluginBase): - logger.debug(f"Found plugin class: {attr_name}") + if issubclass(attr, base_class): + logger.debug(f"Found {expected_type} plugin class: {attr_name}") return attr - return None - def load_all_plugins(self) -> dict[str, PluginBase]: + def load_all_plugins(self) -> dict[str, AnyPlugin]: """Discover and load all available plugins. Returns: @@ -387,7 +440,7 @@ def load_all_plugins(self) -> dict[str, PluginBase]: return loaded - def reload_plugin(self, plugin_id: str) -> PluginBase | None: + def reload_plugin(self, plugin_id: str) -> AnyPlugin | None: """Reload a plugin (unload and load again). Args: @@ -459,7 +512,7 @@ def get_source(self, plugin_id: str) -> PluginSource | None: """ return self._plugin_sources.get(plugin_id) - def get_plugin_class(self, plugin_id: str) -> type[PluginBase] | None: + def get_plugin_class(self, plugin_id: str) -> type[AnyPlugin] | None: """Get the plugin class for a loaded plugin. This is used to create additional instances of the same plugin type. @@ -468,11 +521,12 @@ def get_plugin_class(self, plugin_id: str) -> type[PluginBase] | None: plugin_id: Plugin ID Returns: - The PluginBase subclass or None if not loaded. + The plugin class (PluginBase or TransitionPluginBase subclass) + or None if not loaded. """ return self._plugin_classes.get(plugin_id) - def create_instance(self, plugin_id: str) -> PluginBase | None: + def create_instance(self, plugin_id: str) -> AnyPlugin | None: """Create a new instance of a loaded plugin. Returns a fresh PluginBase instance using the stored class and diff --git a/src/plugins/manifest.py b/src/plugins/manifest.py index ce52f002e..206646107 100644 --- a/src/plugins/manifest.py +++ b/src/plugins/manifest.py @@ -139,9 +139,44 @@ def _inject_trigger_page_id(settings_schema: dict[str, Any]) -> dict[str, Any]: "icon": {"type": "string", "description": "Icon name from Lucide icons"}, "category": { "type": "string", - "enum": ["art", "data", "transit", "weather", "entertainment", "utility", "home"], + "enum": ["art", "data", "transit", "weather", "entertainment", "utility", "home", "transition"], "description": "Plugin category for organization", }, + "plugin_type": { + "type": "string", + "enum": ["data", "transition"], + "default": "data", + "description": "Plugin kind. 'data' (default) returns template variables; 'transition' produces frame-by-frame board animations.", + }, + "transition_settings": { + "type": "object", + "description": "Per-plugin caps and behavior flags for transition plugins (only used when plugin_type='transition').", + "properties": { + "interruptible": { + "type": "boolean", + "default": True, + "description": "When true, a new page or trigger arriving mid-transition cancels the current transition. When false, the transition runs to completion before the new state is applied.", + }, + "min_interval_ms": { + "type": "integer", + "minimum": 0, + "default": 50, + "description": "Floor on the delay between frame sends. Protects against runaway loops and respects board API rate limits regardless of what the plugin yields.", + }, + "max_frames": { + "type": "integer", + "minimum": 1, + "default": 500, + "description": "Hard cap on the number of frames the runner will send before aborting and snapping to the target grid.", + }, + "max_runtime_seconds": { + "type": "integer", + "minimum": 1, + "default": 120, + "description": "Hard cap on wall-clock seconds the transition may run before the runner aborts and snaps to the target grid.", + }, + }, + }, "fiestaboard_version": { "type": "string", "description": "Minimum FiestaBoard version required (semver constraint, e.g. '>=2.10.0')", @@ -339,6 +374,8 @@ class PluginManifest: supports_triggers: bool = False screenshots: list[Screenshot] = field(default_factory=list) demo: dict[str, DemoPageSchema] | None = None # keyed by device_type + plugin_type: str = "data" # "data" or "transition" + transition_settings: dict[str, Any] = field(default_factory=dict) raw: dict[str, Any] = field(default_factory=dict) @classmethod @@ -498,6 +535,8 @@ def from_dict(cls, data: dict[str, Any]) -> "PluginManifest": supports_triggers=supports_triggers, screenshots=screenshots, demo=demo, + plugin_type=data.get("plugin_type", "data"), + transition_settings=dict(data.get("transition_settings", {})), raw=raw, ) @@ -520,6 +559,8 @@ def to_dict(self) -> dict[str, Any]: "category": self.category, "fiestaboard_version": self.fiestaboard_version, "supports_triggers": self.supports_triggers, + "plugin_type": self.plugin_type, + "transition_settings": self.transition_settings, "screenshots": [ { "src": s.src, @@ -640,6 +681,31 @@ def validate_manifest(data: dict[str, Any]) -> tuple[bool, list[str]]: if not isinstance(value, int) or value < 1: errors.append(f"max_lengths.{key} must be a positive integer") + # Validate plugin_type if present + plugin_type = data.get("plugin_type", "data") + if plugin_type not in ("data", "transition"): + errors.append(f"plugin_type must be 'data' or 'transition', got '{plugin_type}'") + + # Validate transition_settings if present + transition_settings = data.get("transition_settings") + if transition_settings is not None: + if not isinstance(transition_settings, dict): + errors.append("transition_settings must be an object") + else: + for key, expected_type, min_value in ( + ("min_interval_ms", int, 0), + ("max_frames", int, 1), + ("max_runtime_seconds", int, 1), + ): + if key in transition_settings: + value = transition_settings[key] + if not isinstance(value, expected_type) or isinstance(value, bool): + errors.append(f"transition_settings.{key} must be an integer") + elif value < min_value: + errors.append(f"transition_settings.{key} must be >= {min_value}") + if "interruptible" in transition_settings and not isinstance(transition_settings["interruptible"], bool): + errors.append("transition_settings.interruptible must be a boolean") + # Validate demo section if present demo = data.get("demo") if demo is not None: diff --git a/src/plugins/registry.py b/src/plugins/registry.py index 9e8e7b0c2..a1b9183fe 100644 --- a/src/plugins/registry.py +++ b/src/plugins/registry.py @@ -447,6 +447,25 @@ def get_plugin(self, plugin_id: str) -> PluginBase | None: """ return self._plugins.get(plugin_id) + def get_transition_plugin(self, plugin_id: str): + """Return a loaded *transition* plugin instance, or None. + + Transition plugins inherit from + :class:`~src.plugins.base.TransitionPluginBase` and produce + frame-by-frame board animations; data plugins are filtered out so + ``board_client.render("plugin:typewriter")`` can never accidentally + invoke a data source. Only plugins that are currently enabled are + returned to prevent disabled plugins from running on the board. + """ + from .base import TransitionPluginBase # local import to avoid cycles + + plugin = self._plugins.get(plugin_id) + if plugin is None or not isinstance(plugin, TransitionPluginBase): + return None + if not self._enabled.get(plugin_id, False): + return None + return plugin + def get_manifest(self, plugin_id: str) -> PluginManifest | None: """Get a plugin's manifest. diff --git a/src/plugins/sources.py b/src/plugins/sources.py index a8902a080..982af7b23 100644 --- a/src/plugins/sources.py +++ b/src/plugins/sources.py @@ -33,9 +33,15 @@ REGISTRY_FILENAME = "plugin-registry.json" EXTERNAL_PLUGINS_DIR = "external_plugins" -# Naming convention for registry plugins +# Naming convention for registry plugins. Data plugins use the +# ``fiestaboard-plugin--`` prefix; transition plugins use the +# distinct ``fiestaboard-transition--`` prefix so the loader and +# UI can distinguish them by repo name alone before any cloning happens. REGISTRY_PREFIX = "fiestaboard-plugin--" -REGISTRY_NAME_RE = re.compile(r"^fiestaboard-plugin--[a-z][a-z0-9-]*$") +REGISTRY_TRANSITION_PREFIX = "fiestaboard-transition--" +REGISTRY_NAME_RE = re.compile( + r"^(?:fiestaboard-plugin--|fiestaboard-transition--)[a-z][a-z0-9-]*$" +) # Plugin id must be a safe single-segment identifier so it can be used as a # directory name without enabling path traversal. Same character set as a @@ -187,23 +193,28 @@ def validate_registry_repo_name(repo_url: str) -> tuple[bool, str]: return ( False, f"Repository name '{repo_name}' does not follow the required " - f"'{REGISTRY_PREFIX}{{name}}' naming convention", + f"'{REGISTRY_PREFIX}{{name}}' or " + f"'{REGISTRY_TRANSITION_PREFIX}{{name}}' naming convention", ) return True, "" def plugin_id_from_repo_name(repo_name: str) -> str: - """Derive the plugin id from a ``fiestaboard-plugin--{name}`` repo name. + """Derive the plugin id from a registry repo name. - Dashes in the suffix are converted to underscores to match manifest id + Accepts both ``fiestaboard-plugin--{name}`` (data plugins) and + ``fiestaboard-transition--{name}`` (transition plugins). Dashes in + the suffix are converted to underscores to match manifest id conventions. >>> plugin_id_from_repo_name("fiestaboard-plugin--my-weather") 'my_weather' + >>> plugin_id_from_repo_name("fiestaboard-transition--my-fade") + 'my_fade' """ - if repo_name.startswith(REGISTRY_PREFIX): - suffix = repo_name[len(REGISTRY_PREFIX):] - return suffix.replace("-", "_") + for prefix in (REGISTRY_PREFIX, REGISTRY_TRANSITION_PREFIX): + if repo_name.startswith(prefix): + return repo_name[len(prefix):].replace("-", "_") return repo_name.replace("-", "_") diff --git a/src/settings/service.py b/src/settings/service.py index 0d8d1a6e6..9b38494ab 100644 --- a/src/settings/service.py +++ b/src/settings/service.py @@ -14,13 +14,39 @@ logger = logging.getLogger(__name__) -# Valid values +# Valid values for the built-in (hardware) transition strategies. Plugin +# transitions use the ``plugin:`` form and are validated dynamically +# against the transition-plugin registry rather than this list. VALID_STRATEGIES = ["column", "reverse-column", "edges-to-center", "row", "diagonal", "random"] VALID_OUTPUT_TARGETS = ["ui", "board", "both"] OutputTarget = Literal["ui", "board", "both"] TransitionStrategy = Literal["column", "reverse-column", "edges-to-center", "row", "diagonal", "random"] +# Prefix that marks a strategy string as referring to a transition plugin +# (e.g. ``"plugin:typewriter"``). Kept in sync with +# :data:`src.board_client.TRANSITION_PLUGIN_PREFIX`. +TRANSITION_PLUGIN_PREFIX = "plugin:" + + +def is_valid_strategy(strategy: str | None) -> bool: + """Return True if *strategy* is None, a built-in, or a plugin reference. + + Plugin references must carry a non-empty id after stripping whitespace; + ``"plugin: "`` and similar typos are rejected here rather than being + silently accepted and then falling through to a non-animated send at + render time. The runtime separately checks whether the named plugin + is actually loaded and enabled before driving a transition. + """ + if strategy is None: + return True + if strategy in VALID_STRATEGIES: + return True + if isinstance(strategy, str) and strategy.startswith(TRANSITION_PLUGIN_PREFIX): + plugin_id = strategy[len(TRANSITION_PLUGIN_PREFIX) :].strip() + return bool(plugin_id) + return False + @dataclass class TransitionSettings: @@ -348,16 +374,25 @@ class BetaSettings: external port using a per-instance self-signed certificate generated at container startup. Toggling this requires a restart to take effect. + - transition_plugins_enabled: When true, transition plugins (frame-by- + frame board animations driven by the TransitionPluginBase SDK) + become selectable from page editors and Settings → Transitions, and + the /transitions test harness page is reachable. Off by default -- + the SDK is experimental and its contract may change. """ https_enabled: bool = False + transition_plugins_enabled: bool = False def to_dict(self) -> dict: return asdict(self) @classmethod def from_dict(cls, data: dict) -> "BetaSettings": - return cls(https_enabled=bool(data.get("https_enabled", False))) + return cls( + https_enabled=bool(data.get("https_enabled", False)), + transition_plugins_enabled=bool(data.get("transition_plugins_enabled", False)), + ) @dataclass @@ -659,8 +694,18 @@ def update_transition_settings( Updated TransitionSettings """ if strategy is not ...: - if strategy is not None and strategy not in VALID_STRATEGIES: - raise ValueError(f"Invalid strategy: {strategy}. Must be one of {VALID_STRATEGIES}") + if not is_valid_strategy(strategy): + raise ValueError(f"Invalid strategy: {strategy}. Must be one of {VALID_STRATEGIES} or 'plugin:'") + if ( + isinstance(strategy, str) + and strategy.startswith(TRANSITION_PLUGIN_PREFIX) + and not self._beta.transition_plugins_enabled + ): + raise ValueError( + "Transition plugins are an experimental beta. " + "Enable them in Settings → Beta before selecting a " + "plugin: strategy." + ) self._transition.strategy = strategy if step_interval_ms is not ...: @@ -1079,6 +1124,8 @@ def update_beta_settings(self, updates: dict) -> "BetaSettings": """ if "https_enabled" in updates: self._beta.https_enabled = bool(updates["https_enabled"]) + if "transition_plugins_enabled" in updates: + self._beta.transition_plugins_enabled = bool(updates["transition_plugins_enabled"]) self._save_to_file() logger.info(f"Beta settings updated: {self._beta}") return self._beta diff --git a/src/transitions/__init__.py b/src/transitions/__init__.py new file mode 100644 index 000000000..c488cdc54 --- /dev/null +++ b/src/transitions/__init__.py @@ -0,0 +1,13 @@ +"""Transition plugin runtime. + +This package wires the transition-plugin SDK (``TransitionPluginBase``) +into the board send path. The :class:`TransitionRunner` resolves a +plugin id to a loaded plugin instance, iterates its frame generator, and +sends each frame via the :class:`~src.board_client.BoardClient` while +honoring per-plugin caps (max frames, max runtime, min interval) and +cancellation events from concurrent sends. +""" + +from .runner import TransitionResolver, TransitionRunner, TransitionRunResult + +__all__ = ["TransitionResolver", "TransitionRunResult", "TransitionRunner"] diff --git a/src/transitions/runner.py b/src/transitions/runner.py new file mode 100644 index 000000000..9d1cef630 --- /dev/null +++ b/src/transitions/runner.py @@ -0,0 +1,356 @@ +"""Host-side execution loop for transition plugins. + +A :class:`TransitionRunner` is constructed once with a resolver callable +that maps a plugin id to a loaded +:class:`~src.plugins.base.TransitionPluginBase` instance. At runtime the +:class:`~src.board_client.BoardClient` invokes :meth:`TransitionRunner.run` +with the target grid and a cancellation event. The runner: + +1. Resolves the plugin and reads its ``transition_settings`` caps. +2. Determines the "from" grid (current board state or a blank fallback). +3. Iterates the plugin's :meth:`generate_frames`, sending each frame via + ``board_client.send_characters(..., force=True)`` so the cache cannot + silently drop intentional repeats. +4. Sleeps the requested ``delay_ms`` (clamped to ``min_interval_ms``) on + the cancel event so cancellation lands cleanly between frames. +5. Aborts when caps are exceeded or the event is set. +6. Always sends ``to_grid`` once the generator is exhausted (or aborted) + so the board lands on the exact target. + +The runner is intentionally synchronous -- it runs on the caller's thread +holding the board's send lock. Long transitions therefore block the +caller; this is by design so rotation / triggers / manual sends serialize +naturally. Callers that need fire-and-forget should spawn a thread. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Callable +from dataclasses import dataclass +from threading import Event +from typing import Any + +from src.devices import DEVICE_DIMENSIONS, DeviceDimensions, get_dimensions +from src.plugins.base import TransitionPluginBase + +logger = logging.getLogger(__name__) + + +TransitionResolver = Callable[[str], TransitionPluginBase | None] + + +@dataclass +class TransitionRunResult: + """Outcome of a single :meth:`TransitionRunner.run` invocation. + + Attributes: + completed: True if the plugin's generator exhausted normally. + False if cancelled or capped early. + frames_sent: Total frames pushed to the board (excludes the final + ``to_grid`` snap if no plugin frames ran). + elapsed_seconds: Wall-clock duration of the run. + cancelled: True if the cancel event tripped during execution. + capped: True if a max_frames / max_runtime cap aborted the run. + reason: Short human-readable reason string for logs. + """ + + completed: bool + frames_sent: int + elapsed_seconds: float + cancelled: bool = False + capped: bool = False + reason: str = "" + + +class TransitionRunner: + """Drives a transition plugin's frame generator against a board client. + + The runner is stateless across runs; a single instance can safely + service many concurrent boards as long as each call uses its own + ``board_client`` and ``cancel_event``. + """ + + def __init__(self, resolver: TransitionResolver): + """Construct a runner. + + Args: + resolver: Callable that returns the loaded + :class:`TransitionPluginBase` instance for a plugin id, or + *None* if the plugin is unknown / disabled. + """ + self._resolver = resolver + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def run( + self, + plugin_id: str, + to_grid: list[list[int]], + board_client: Any, + cancel_event: Event | None = None, + device_type: str | None = None, + from_grid: list[list[int]] | None = None, + config: dict | None = None, + ) -> tuple[bool, bool]: + """Run *plugin_id*'s transition toward *to_grid*. + + Args: + plugin_id: Transition plugin id to invoke. + to_grid: Target grid the transition should end on. + board_client: Object with ``send_characters(grid, strategy=None, + force=False)`` and optional ``read_current_message()``. + cancel_event: Threading event that, when set, asks the runner + to wind down at the next delay boundary. + device_type: Optional ``"flagship"`` / ``"note"`` hint used to + resolve dimensions. Defaults to the grid's shape. + from_grid: Optional explicit starting grid. When *None* the + runner reads from ``board_client._last_characters`` (the + cache populated by previous sends), falling back to + ``read_current_message()`` and finally a blank grid. + config: Optional plugin config override. When *None* the + runner uses the plugin's currently bound ``config`` dict. + + Returns: + ``(success, was_sent)`` matching ``send_characters``'s contract. + ``success`` is True unless every send failed; ``was_sent`` is + True if at least one frame (including the final snap) reached + the board. + """ + plugin = self._resolver(plugin_id) + if plugin is None: + logger.warning( + "TransitionRunner: plugin %r not found; snapping to target", + plugin_id, + ) + return board_client.send_characters(to_grid, strategy=None, force=True) + + device = self._resolve_device(to_grid, device_type) + from_grid_resolved = self._resolve_from_grid(board_client, to_grid, from_grid) + config_resolved = dict(config) if config is not None else dict(plugin.config or {}) + caps = plugin.transition_settings + + result = self._drive_generator( + plugin=plugin, + from_grid=from_grid_resolved, + to_grid=to_grid, + device=device, + config=config_resolved, + board_client=board_client, + cancel_event=cancel_event, + caps=caps, + ) + + # Snap to to_grid unless we were cancelled. On cancellation the + # caller that interrupted us has its own target; sending our + # to_grid here would flash the wrong content on the board before + # the new transition begins. Capped / errored runs still snap so + # the board lands somewhere coherent. + if result.cancelled: + logger.info( + "TransitionRunner: plugin=%s frames=%d elapsed=%.2fs %s (skipping final snap)", + plugin_id, + result.frames_sent, + result.elapsed_seconds, + result.reason, + ) + return (True, bool(result.frames_sent)) + + snap_success, snap_sent = board_client.send_characters(to_grid, strategy=None, force=True) + + logger.info( + "TransitionRunner: plugin=%s frames=%d elapsed=%.2fs %s", + plugin_id, + result.frames_sent, + result.elapsed_seconds, + result.reason, + ) + + was_sent = bool(result.frames_sent) or snap_sent + return (snap_success, was_sent) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _resolve_device( + self, + to_grid: list[list[int]], + device_type: str | None, + ) -> DeviceDimensions: + """Pick a :class:`DeviceDimensions` for the grid being rendered.""" + if device_type and device_type in DEVICE_DIMENSIONS: + return DEVICE_DIMENSIONS[device_type] + rows = len(to_grid) + cols = len(to_grid[0]) if rows else 0 + for dims in DEVICE_DIMENSIONS.values(): + if dims.rows == rows and dims.cols == cols: + return dims + # Fall back to flagship if the grid's shape is unknown; the runner + # never uses this for validation, only as a hint to the plugin. + return get_dimensions("flagship") + + def _resolve_from_grid( + self, + board_client: Any, + to_grid: list[list[int]], + explicit: list[list[int]] | None, + ) -> list[list[int]]: + """Pick a starting grid for the transition. + + Priority: explicit override → cached ``_last_characters`` → blank + grid sized like ``to_grid``. We deliberately do *not* fall back to + a live ``read_current_message()`` call: that's a network round-trip + under the send lock, and historically it returns text rather than + a grid (so the result is rejected anyway). A blank from-grid is a + safe default — the runner's final snap (or the next non-cancelled + run) lands the board on the correct target regardless. + """ + if explicit is not None: + return explicit + + cached = getattr(board_client, "_last_characters", None) + if isinstance(cached, list) and cached: + return [list(row) for row in cached] + + rows = len(to_grid) + cols = len(to_grid[0]) if rows else 0 + return [[0] * cols for _ in range(rows)] + + def _drive_generator( + self, + plugin: TransitionPluginBase, + from_grid: list[list[int]], + to_grid: list[list[int]], + device: DeviceDimensions, + config: dict, + board_client: Any, + cancel_event: Event | None, + caps: dict, + ) -> TransitionRunResult: + """Iterate the plugin's frame generator and send frames. + + Encapsulates cap enforcement, cancellation checks, and per-frame + error handling. Returns a structured result; the caller is + responsible for the final snap-to-target send. + """ + min_interval_ms = int(caps.get("min_interval_ms", 50)) + max_frames = int(caps.get("max_frames", 500)) + max_runtime_s = int(caps.get("max_runtime_seconds", 120)) + respect_cancel = bool(caps.get("interruptible", True)) + + frames_sent = 0 + started = time.monotonic() + reason = "completed" + cancelled = False + capped = False + + try: + generator = plugin.generate_frames(from_grid, to_grid, device, config) + except Exception as exc: + logger.exception( + "TransitionRunner: generate_frames raised for %s: %s", + plugin.plugin_id, + exc, + ) + return TransitionRunResult( + completed=False, + frames_sent=0, + elapsed_seconds=time.monotonic() - started, + reason=f"generator error: {exc}", + ) + + # Generators are lazy: errors fire when ``next()`` is called, not + # when the function is invoked. Pull frames with manual ``next`` + # so we can catch and log per-iteration crashes. + while True: + try: + frame = next(generator) + except StopIteration: + break + except Exception as exc: + logger.exception( + "TransitionRunner: plugin %s raised during iteration: %s", + plugin.plugin_id, + exc, + ) + reason = f"iteration error: {exc}" + break + + # Cap checks happen *before* sending so a runaway generator + # can't burn one extra send. + if frames_sent >= max_frames: + capped = True + reason = f"max_frames cap ({max_frames}) reached" + break + elapsed = time.monotonic() - started + if elapsed >= max_runtime_s: + capped = True + reason = f"max_runtime_seconds cap ({max_runtime_s}s) reached" + break + if respect_cancel and cancel_event is not None and cancel_event.is_set(): + cancelled = True + reason = "cancelled by concurrent send" + break + + grid, delay_ms = self._unpack_frame(frame) + if grid is None: + reason = "plugin yielded malformed frame" + break + + try: + board_client.send_characters(grid, strategy=None, force=True) + except Exception as exc: # pragma: no cover - logged + continue + logger.warning( + "TransitionRunner: send_characters raised mid-transition: %s", + exc, + ) + reason = f"send error: {exc}" + break + + frames_sent += 1 + + # Sleep on the cancel event so wakeups are immediate. + effective_delay_ms = max(int(delay_ms or 0), min_interval_ms) + if respect_cancel and cancel_event is not None: + if cancel_event.wait(effective_delay_ms / 1000.0): + cancelled = True + reason = "cancelled by concurrent send" + break + elif effective_delay_ms: + time.sleep(effective_delay_ms / 1000.0) + + return TransitionRunResult( + completed=not (cancelled or capped), + frames_sent=frames_sent, + elapsed_seconds=time.monotonic() - started, + cancelled=cancelled, + capped=capped, + reason=reason, + ) + + @staticmethod + def _unpack_frame( + frame: Any, + ) -> tuple[list[list[int]] | None, int]: + """Coerce a plugin's yielded value into ``(grid, delay_ms)``. + + Accepts ``(grid, delay)`` tuples and bare grids (treated as zero + delay). Returns ``(None, 0)`` on shapes we can't make sense of so + the caller aborts the run with a logged reason. + """ + if isinstance(frame, tuple) and len(frame) == 2: + grid, delay = frame + try: + delay_int = int(delay) + except (TypeError, ValueError): + delay_int = 0 + if isinstance(grid, list) and grid and isinstance(grid[0], list): + return grid, delay_int + return None, 0 + if isinstance(frame, list) and frame and isinstance(frame[0], list): + return frame, 0 + return None, 0 diff --git a/tests/test_api_coverage.py b/tests/test_api_coverage.py index dde4a7eca..b2c710648 100644 --- a/tests/test_api_coverage.py +++ b/tests/test_api_coverage.py @@ -31,6 +31,7 @@ def mock_service(): service = Mock() service.vb_client = Mock() service.vb_client.send_characters.return_value = (True, True) + service.vb_client.render.return_value = (True, True) service.vb_client.clear_cache.return_value = None service.vb_client.test_connection.return_value = True service.vb_client.get_cache_status.return_value = {"has_cached_text": False} @@ -267,6 +268,7 @@ def test_welcome_success(self, client): board_client = Mock() board_client.send_characters.return_value = (True, True) + board_client.render.return_value = (True, True) MockBoardClient.return_value = board_client mock_ttba.return_value = [[0] * 22 for _ in range(6)] @@ -298,6 +300,7 @@ def test_welcome_send_failure(self, client): board_client = Mock() board_client.send_characters.return_value = (False, False) + board_client.render.return_value = (False, False) MockBoardClient.return_value = board_client mock_ttba.return_value = [[0] * 22 for _ in range(6)] @@ -328,6 +331,7 @@ def test_welcome_unchanged(self, client): board_client = Mock() board_client.send_characters.return_value = (True, False) + board_client.render.return_value = (True, False) MockBoardClient.return_value = board_client mock_ttba.return_value = [[0] * 22 for _ in range(6)] @@ -361,6 +365,7 @@ def test_welcome_uses_note_template_for_note_board(self, client): board_client = Mock() board_client.send_characters.return_value = (True, True) + board_client.render.return_value = (True, True) MockBoardClient.return_value = board_client mock_ttba.return_value = [[0] * 15 for _ in range(3)] @@ -410,6 +415,7 @@ def test_welcome_uses_flagship_template_for_flagship_board(self, client): board_client = Mock() board_client.send_characters.return_value = (True, True) + board_client.render.return_value = (True, True) MockBoardClient.return_value = board_client mock_ttba.return_value = [[0] * 22 for _ in range(6)] @@ -802,6 +808,7 @@ def test_set_active_page_send_fails( """Board send failure still returns success but sent_to_board is False.""" mock_settings_service.should_send_to_board.return_value = True mock_service.vb_client.send_characters.return_value = (False, False) + mock_service.vb_client.render.return_value = (False, False) with ( patch("src.api_server.get_dimensions") as mock_dims, patch("src.api_server.text_to_board_array") as mock_ttba, @@ -967,6 +974,7 @@ def test_send_display_board_failure(self, client, mock_service, mock_settings_se """Board send failure → 500.""" mock_settings_service.should_send_to_board.return_value = True mock_service.vb_client.send_characters.return_value = (False, False) + mock_service.vb_client.render.return_value = (False, False) with ( patch("src.api_server.get_display_service") as mock_ds, patch("src.api_server.text_to_board_array") as mock_ttba, @@ -1074,6 +1082,7 @@ def test_send_page_board_failure(self, client, mock_service, mock_settings_servi """Board send failure → 500.""" mock_settings_service.should_send_to_board.return_value = True mock_service.vb_client.send_characters.return_value = (False, False) + mock_service.vb_client.render.return_value = (False, False) with ( patch("src.api_server.Config") as mock_config, patch("src.api_server.get_dimensions") as mock_dims, @@ -1167,6 +1176,7 @@ def test_blank_send_failure(self, client): ): bc = Mock() bc.send_characters.return_value = (False, False) + bc.render.return_value = (False, False) mock_bc.return_value = bc ss = Mock() ss.should_send_to_board.return_value = True @@ -1215,6 +1225,7 @@ def test_fill_send_failure(self, client): ): bc = Mock() bc.send_characters.return_value = (False, False) + bc.render.return_value = (False, False) mock_bc.return_value = bc ss = Mock() ss.should_send_to_board.return_value = True @@ -1283,6 +1294,7 @@ def test_info_send_failure(self, client): ): bc = Mock() bc.send_characters.return_value = (False, False) + bc.render.return_value = (False, False) mock_bc.return_value = bc ss = Mock() ss.should_send_to_board.return_value = True diff --git a/tests/test_api_extended.py b/tests/test_api_extended.py index 953231c8d..efecf5a62 100644 --- a/tests/test_api_extended.py +++ b/tests/test_api_extended.py @@ -299,6 +299,7 @@ def mock_service(): svc = Mock() svc.vb_client = Mock() svc.vb_client.send_characters.return_value = (True, True) + svc.vb_client.render.return_value = (True, True) svc.vb_client.get_cache_status.return_value = {"has_cached_text": False} svc.vb_client.clear_cache.return_value = None svc.vb_client.use_cloud = False @@ -909,6 +910,7 @@ def test_render_template_live_success(self, client, mock_template_engine, mock_s with patch("src.api_server.board_client_from_board_dict") as mock_bcfbd: mock_board_client = Mock() mock_board_client.send_characters.return_value = (True, True) + mock_board_client.render.return_value = (True, True) mock_bcfbd.return_value = mock_board_client response = client.post( "/templates/render/live", @@ -1344,6 +1346,7 @@ def test_send_message_success(self, client, mock_service, mock_settings_service) def test_send_message_skipped(self, client, mock_service, mock_settings_service): mock_service.vb_client.send_characters.return_value = (True, False) + mock_service.vb_client.render.return_value = (True, False) with patch("src.api_server.Config.is_silence_mode_active", return_value=False): response = client.post("/send-message", json={"text": "Hello"}) assert response.status_code == 200 @@ -1351,6 +1354,7 @@ def test_send_message_skipped(self, client, mock_service, mock_settings_service) def test_send_message_failure(self, client, mock_service, mock_settings_service): mock_service.vb_client.send_characters.return_value = (False, False) + mock_service.vb_client.render.return_value = (False, False) with patch("src.api_server.Config.is_silence_mode_active", return_value=False): response = client.post("/send-message", json={"text": "Hello"}) assert response.status_code == 500 diff --git a/tests/test_debug_endpoints.py b/tests/test_debug_endpoints.py index b08fe1aad..cc2232a41 100644 --- a/tests/test_debug_endpoints.py +++ b/tests/test_debug_endpoints.py @@ -20,6 +20,7 @@ def mock_board_client(): with patch("src.api_server._get_board_client") as mock: client = Mock() client.send_characters.return_value = (True, True) + client.render.return_value = (True, True) client.test_connection.return_value = True client.clear_cache.return_value = None client.get_cache_status.return_value = { diff --git a/tests/test_integration_multi_features.py b/tests/test_integration_multi_features.py index 0001b408e..73a5d1b01 100644 --- a/tests/test_integration_multi_features.py +++ b/tests/test_integration_multi_features.py @@ -275,6 +275,7 @@ def test_manual_mode_uses_manual_active_page(self, services): mock_client_instance = Mock() mock_client_instance.read_current_message.return_value = None mock_client_instance.send_characters.return_value = (True, True) + mock_client_instance.render.return_value = (True, True) mock_board_client.return_value = mock_client_instance with patch("src.main.get_page_service", return_value=page_service): @@ -326,6 +327,7 @@ def test_schedule_mode_uses_scheduled_page(self, services): mock_client_instance = Mock() mock_client_instance.read_current_message.return_value = None mock_client_instance.send_characters.return_value = (True, True) + mock_client_instance.render.return_value = (True, True) mock_board_client.return_value = mock_client_instance with patch("src.main.get_page_service", return_value=page_service): @@ -387,6 +389,7 @@ def test_schedule_mode_with_no_match_uses_default(self, services): mock_client_instance = Mock() mock_client_instance.read_current_message.return_value = None mock_client_instance.send_characters.return_value = (True, True) + mock_client_instance.render.return_value = (True, True) mock_board_client.return_value = mock_client_instance with patch("src.main.get_page_service", return_value=page_service): diff --git a/tests/test_live_output.py b/tests/test_live_output.py index e1bec6115..f0903e5cf 100644 --- a/tests/test_live_output.py +++ b/tests/test_live_output.py @@ -85,6 +85,7 @@ def test_render_and_send_to_default_board(self, mock_settings, mock_engine, mock mock_board_client = Mock() mock_board_client.send_characters.return_value = (True, True) + mock_board_client.render.return_value = (True, True) mock_client_factory.return_value = mock_board_client board_settings = Mock() @@ -117,6 +118,7 @@ def test_render_and_send_to_specific_board(self, mock_settings, mock_engine, cli mock_board_client = Mock() mock_board_client.send_characters.return_value = (True, True) + mock_board_client.render.return_value = (True, True) board_settings = Mock() board_settings.boards = [ @@ -234,6 +236,7 @@ def test_board_not_actually_sent_returns_false(self, mock_settings, mock_engine, mock_board_client = Mock() mock_board_client.send_characters.return_value = (True, False) + mock_board_client.render.return_value = (True, False) board_settings = Mock() board_settings.boards = [{"id": "board-1", "name": "Flagship", "device_type": "flagship"}] @@ -295,6 +298,7 @@ def test_force_flag_passed_to_board_client(self, mock_settings, mock_engine, cli mock_board_client = Mock() mock_board_client.send_characters.return_value = (True, True) + mock_board_client.render.return_value = (True, True) board_settings = Mock() board_settings.boards = [{"id": "board-1", "name": "Flagship", "device_type": "flagship"}] @@ -324,6 +328,7 @@ def test_note_device_type_uses_correct_dimensions(self, mock_settings, mock_engi mock_board_client = Mock() mock_board_client.send_characters.return_value = (True, True) + mock_board_client.render.return_value = (True, True) board_settings = Mock() board_settings.boards = [{"id": "note-1", "name": "Note", "device_type": "note"}] @@ -343,6 +348,7 @@ def test_note_device_type_uses_correct_dimensions(self, mock_settings, mock_engi ): mock_t2b.return_value = [[0] * 15] * 3 mock_board_client.send_characters.return_value = (True, True) + mock_board_client.render.return_value = (True, True) response = client.post( "/templates/render/live", @@ -411,6 +417,7 @@ def test_transition_settings_passed_to_board(self, mock_settings, mock_engine, c mock_board_client = Mock() mock_board_client.send_characters.return_value = (True, True) + mock_board_client.render.return_value = (True, True) board_settings = Mock() board_settings.boards = [{"id": "board-1", "name": "Flagship", "device_type": "flagship"}] diff --git a/tests/test_plugin_validation.py b/tests/test_plugin_validation.py index 216508806..180686a36 100644 --- a/tests/test_plugin_validation.py +++ b/tests/test_plugin_validation.py @@ -17,7 +17,7 @@ PLUGINS_DIR = PROJECT_ROOT / "plugins" # Directories to skip -SKIP_DIRECTORIES = {"_template", "__pycache__"} +SKIP_DIRECTORIES = {"_template", "_template_transition", "__pycache__"} def get_plugin_directories() -> list[Path]: @@ -701,7 +701,12 @@ def test_all_manifests_have_settings_schema(self): assert not missing, "Plugins missing 'settings_schema':\n" + "\n".join(f" - {m}" for m in missing) def test_all_manifests_have_variables(self): - """CI Test: All plugin manifests should define a variables section.""" + """CI Test: All data plugin manifests should define a variables section. + + Transition plugins (``plugin_type == 'transition'``) don't expose + template variables -- they shape board updates frame-by-frame -- + so they're exempt from this requirement. + """ plugins = get_plugin_directories() if not plugins: @@ -715,6 +720,8 @@ def test_all_manifests_have_variables(self): continue manifest = load_manifest(plugin_dir) + if manifest.get("plugin_type") == "transition": + continue if "variables" not in manifest: missing.append(plugin_dir.name) @@ -782,7 +789,7 @@ def test_category_values_are_valid(self): if not plugins: pytest.skip("No plugins found") - valid_categories = {"art", "data", "transit", "weather", "entertainment", "utility", "home"} + valid_categories = {"art", "data", "transit", "weather", "entertainment", "utility", "home", "transition"} invalid: list[str] = [] for plugin_dir in plugins: diff --git a/tests/test_silence_mode_display.py b/tests/test_silence_mode_display.py index db98f8123..5312341d2 100644 --- a/tests/test_silence_mode_display.py +++ b/tests/test_silence_mode_display.py @@ -22,6 +22,7 @@ def service(): svc = DisplayService() svc.vb_client = Mock() svc.vb_client.send_characters.return_value = (True, True) + svc.vb_client.render.return_value = (True, True) return svc @@ -61,7 +62,7 @@ def test_note_indicator_fits_and_does_not_overlay(self, service): assert sent is True # Captured board array - must be 3x15 (Note dims) and contain only # SNOOZING (no other characters). - args, _ = service.vb_client.send_characters.call_args + args, _ = service.vb_client.render.call_args board_array = args[0] assert len(board_array) == 3 assert all(len(row) == 15 for row in board_array) @@ -82,7 +83,7 @@ def test_flagship_indicator_fits(self, service): assert service._send_silence_indicator("flagship") is True - args, _ = service.vb_client.send_characters.call_args + args, _ = service.vb_client.render.call_args board_array = args[0] assert len(board_array) == 6 assert all(len(row) == 22 for row in board_array) @@ -134,7 +135,7 @@ def test_freeze_mode_does_not_send(self, service): sent = service.check_and_send_active_page() assert sent is False - service.vb_client.send_characters.assert_not_called() + service.vb_client.render.assert_not_called() assert service._last_silence_mode_active is True def test_freeze_mode_blocks_subsequent_ticks(self, service): @@ -150,7 +151,7 @@ def test_freeze_mode_blocks_subsequent_ticks(self, service): sent = service.check_and_send_active_page() assert sent is False - service.vb_client.send_characters.assert_not_called() + service.vb_client.render.assert_not_called() def test_indicator_mode_sends_once(self, service): _, page_service, settings, config = self._patch_common(mode="indicator") @@ -163,16 +164,16 @@ def test_indicator_mode_sends_once(self, service): ): # First tick: enters silence, sends indicator service.check_and_send_active_page() - assert service.vb_client.send_characters.call_count == 1 + assert service.vb_client.render.call_count == 1 # The board should display ONLY SNOOZING - not the page content - args, _ = service.vb_client.send_characters.call_args + args, _ = service.vb_client.render.call_args board_array = args[0] assert _decode_board_text(board_array).strip() == "SNOOZING" # Second tick: still silenced, must NOT send again service.check_and_send_active_page() - assert service.vb_client.send_characters.call_count == 1 + assert service.vb_client.render.call_count == 1 def test_page_mode_renders_configured_page(self, service): active_page, page_service, settings, config = self._patch_common(mode="page", page_id="silence-page") @@ -210,12 +211,12 @@ def _preview(pid, force_refresh=False): sent = service.check_and_send_active_page() assert sent is True # The board content should be the silence page, not the active page. - args, _ = service.vb_client.send_characters.call_args + args, _ = service.vb_client.render.call_args board_array = args[0] assert "GOOD NIGHT" in _decode_board_text(board_array) # And further ticks must not send again. service.check_and_send_active_page() - assert service.vb_client.send_characters.call_count == 1 + assert service.vb_client.render.call_count == 1 def test_page_mode_falls_back_to_indicator_when_page_missing(self, service): active_page, page_service, settings, config = self._patch_common(mode="page", page_id="missing-page") @@ -237,7 +238,7 @@ def _get_page(pid): sent = service.check_and_send_active_page() assert sent is True - args, _ = service.vb_client.send_characters.call_args + args, _ = service.vb_client.render.call_args board_array = args[0] assert _decode_board_text(board_array).strip() == "SNOOZING" @@ -284,7 +285,7 @@ def test_indicator_uses_custom_text(self, service): ): service.check_and_send_active_page() - args, _ = service.vb_client.send_characters.call_args + args, _ = service.vb_client.render.call_args board_array = args[0] text = _decode_board_text(board_array).strip() assert text == "ZZZ" @@ -300,7 +301,7 @@ def test_indicator_at_bottom_right_for_flagship(self, service): ): service.check_and_send_active_page() - args, _ = service.vb_client.send_characters.call_args + args, _ = service.vb_client.render.call_args board_array = args[0] # Flagship: 6 rows x 22 cols. Bottom row, right-aligned ZZZ at cols 19-21. assert len(board_array) == 6 @@ -329,7 +330,7 @@ def test_freeze_is_default_when_mode_unset(self, service): sent = service.check_and_send_active_page() assert sent is False - service.vb_client.send_characters.assert_not_called() + service.vb_client.render.assert_not_called() class TestSendTriggerContent: @@ -359,4 +360,4 @@ def test_returns_false_when_no_client(self): def test_returns_false_when_content_unchanged(self, service): service._last_active_page_content = "SAME" assert service._send_trigger_content("SAME") is False - service.vb_client.send_characters.assert_not_called() + service.vb_client.render.assert_not_called() diff --git a/tests/test_silence_schedule_polling.py b/tests/test_silence_schedule_polling.py index 94c6fb23d..9c2839d8a 100644 --- a/tests/test_silence_schedule_polling.py +++ b/tests/test_silence_schedule_polling.py @@ -76,6 +76,7 @@ def _make(is_silence: bool): svc = DisplayService() svc.vb_client = Mock() svc.vb_client.send_characters.return_value = (True, True) + svc.vb_client.render.return_value = (True, True) return svc, mocks, page_service, patches @@ -113,7 +114,7 @@ def test_steady_silence_skips_render_and_send(self, service_factory): # CRITICAL: plugin rendering must NOT happen during steady silence. page_service.preview_page.assert_not_called() # Board must not be touched. - svc.vb_client.send_characters.assert_not_called() + svc.vb_client.render.assert_not_called() def test_steady_silence_does_not_evaluate_triggers(self, service_factory): """Trigger plugins must not be polled during silence.""" @@ -141,12 +142,12 @@ def test_entering_silence_sends_once_with_indicator(self, service_factory): result = svc.check_and_send_active_page() assert result is True - svc.vb_client.send_characters.assert_called_once() + svc.vb_client.render.assert_called_once() assert svc._snoozing_message_sent is True assert svc._last_silence_mode_active is True # Verify SNOOZING was stamped on the board array (center row by default). - sent_array = svc.vb_client.send_characters.call_args.args[0] + sent_array = svc.vb_client.render.call_args.args[0] center_row = sent_array[len(sent_array) // 2] center_row_text = "".join(chr(c + 64) if 1 <= c <= 26 else "?" for c in center_row) assert "SNOOZING" in center_row_text @@ -160,13 +161,13 @@ def test_second_tick_after_entering_silence_is_a_noop(self, service_factory): with patch.object(svc, "_check_trigger_override", return_value=None): svc.check_and_send_active_page() page_service.preview_page.reset_mock() - svc.vb_client.send_characters.reset_mock() + svc.vb_client.render.reset_mock() # Next tick — still silenced. svc.check_and_send_active_page() page_service.preview_page.assert_not_called() - svc.vb_client.send_characters.assert_not_called() + svc.vb_client.render.assert_not_called() class TestExitingSilence: @@ -188,11 +189,11 @@ def test_exiting_silence_forces_resend_even_if_content_unchanged(self, service_f # MUST re-send to clear the SNOOZING indicator. assert result is True - svc.vb_client.send_characters.assert_called_once() + svc.vb_client.render.assert_called_once() assert svc._snoozing_message_sent is False assert svc._last_silence_mode_active is False # Indicator should NOT be present on the freshly-sent board. - sent_array = svc.vb_client.send_characters.call_args.args[0] + sent_array = svc.vb_client.render.call_args.args[0] last_row_text = "".join(chr(c + 64) if 1 <= c <= 26 else "?" for c in sent_array[-1]) assert "SNOOZING" not in last_row_text diff --git a/tests/test_transition_plugin_base.py b/tests/test_transition_plugin_base.py new file mode 100644 index 000000000..43da7a915 --- /dev/null +++ b/tests/test_transition_plugin_base.py @@ -0,0 +1,399 @@ +"""Tests for TransitionPluginBase and its manifest / loader integration.""" + +import json +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest + +from src.plugins.base import ( + DEFAULT_TRANSITION_INTERRUPTIBLE, + DEFAULT_TRANSITION_MAX_FRAMES, + DEFAULT_TRANSITION_MAX_RUNTIME_SECONDS, + DEFAULT_TRANSITION_MIN_INTERVAL_MS, + TransitionFrame, + TransitionPluginBase, +) +from src.plugins.loader import PluginLoader +from src.plugins.manifest import PluginManifest, validate_manifest + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _FakeTransition(TransitionPluginBase): + """Minimal in-memory transition plugin for unit tests.""" + + @property + def plugin_id(self) -> str: + return self._manifest.get("id", "fake_transition") + + def generate_frames( + self, + from_grid: list[list[int]], + to_grid: list[list[int]], + device: Any, + config: dict[str, Any], + ) -> Iterator[TransitionFrame]: + yield from_grid, 10 + yield to_grid, 0 + + +def _manifest(**overrides: Any) -> dict[str, Any]: + base = { + "id": "fake_transition", + "name": "Fake Transition", + "version": "0.0.1", + "plugin_type": "transition", + "transition_settings": {}, + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# Base class behavior +# --------------------------------------------------------------------------- + + +def test_plugin_id_required(): + """TransitionPluginBase cannot be instantiated without plugin_id.""" + with pytest.raises(TypeError): + TransitionPluginBase(_manifest()) # type: ignore[abstract] + + +def test_info_pulls_from_manifest(): + plugin = _FakeTransition( + _manifest( + id="my_t", + name="My Transition", + version="1.2.3", + description="d", + author="a", + ) + ) + info = plugin.info + assert info.id == "my_t" + assert info.name == "My Transition" + assert info.version == "1.2.3" + assert info.description == "d" + assert info.author == "a" + + +def test_transition_settings_defaults_when_missing(): + plugin = _FakeTransition(_manifest(transition_settings={})) + settings = plugin.transition_settings + assert settings == { + "interruptible": DEFAULT_TRANSITION_INTERRUPTIBLE, + "min_interval_ms": DEFAULT_TRANSITION_MIN_INTERVAL_MS, + "max_frames": DEFAULT_TRANSITION_MAX_FRAMES, + "max_runtime_seconds": DEFAULT_TRANSITION_MAX_RUNTIME_SECONDS, + } + + +def test_transition_settings_uses_manifest_values(): + plugin = _FakeTransition( + _manifest( + transition_settings={ + "interruptible": False, + "min_interval_ms": 250, + "max_frames": 10, + "max_runtime_seconds": 5, + } + ) + ) + settings = plugin.transition_settings + assert settings["interruptible"] is False + assert settings["min_interval_ms"] == 250 + assert settings["max_frames"] == 10 + assert settings["max_runtime_seconds"] == 5 + + +def test_transition_settings_no_block_in_manifest(): + """Missing transition_settings block falls back to all defaults.""" + plugin = _FakeTransition({"id": "x", "name": "x", "version": "0.0.1"}) + settings = plugin.transition_settings + assert settings["interruptible"] == DEFAULT_TRANSITION_INTERRUPTIBLE + assert settings["max_frames"] == DEFAULT_TRANSITION_MAX_FRAMES + + +def test_enabled_setter_fires_cleanup_on_disable(): + cleanup_calls: list[int] = [] + + class _T(_FakeTransition): + def cleanup(self) -> None: + cleanup_calls.append(1) + + plugin = _T(_manifest()) + plugin.enabled = True + assert plugin.enabled is True + plugin.enabled = False + assert cleanup_calls == [1] + + +def test_config_setter_triggers_on_config_change(): + captured: list[tuple] = [] + + class _T(_FakeTransition): + def on_config_change(self, old, new): + captured.append((old, new)) + + plugin = _T(_manifest()) + plugin.config = {"a": 1} + plugin.config = {"a": 1} # identical — should not trigger + plugin.config = {"a": 2} + assert captured == [({}, {"a": 1}), ({"a": 1}, {"a": 2})] + + +def test_default_validate_config_is_empty(): + plugin = _FakeTransition(_manifest()) + assert plugin.validate_config({"anything": True}) == [] + + +def test_generate_frames_yields_expected_sequence(): + plugin = _FakeTransition(_manifest()) + from_grid = [[0, 0], [0, 0]] + to_grid = [[1, 1], [1, 1]] + frames = list(plugin.generate_frames(from_grid, to_grid, None, {})) + assert frames == [(from_grid, 10), (to_grid, 0)] + + +# --------------------------------------------------------------------------- +# Manifest validation +# --------------------------------------------------------------------------- + + +def test_manifest_accepts_transition_plugin_type(): + ok, errors = validate_manifest( + { + "id": "t", + "name": "T", + "version": "1.0.0", + "plugin_type": "transition", + "category": "transition", + } + ) + assert ok, errors + + +def test_manifest_rejects_bad_plugin_type(): + ok, errors = validate_manifest( + { + "id": "t", + "name": "T", + "version": "1.0.0", + "plugin_type": "weird", + } + ) + assert not ok + assert any("plugin_type" in e for e in errors) + + +def test_manifest_rejects_bad_transition_settings_types(): + ok, errors = validate_manifest( + { + "id": "t", + "name": "T", + "version": "1.0.0", + "plugin_type": "transition", + "transition_settings": { + "min_interval_ms": "fast", + "max_frames": 0, + "interruptible": "yes", + }, + } + ) + assert not ok + joined = "\n".join(errors) + assert "min_interval_ms" in joined + assert "max_frames" in joined + assert "interruptible" in joined + + +def test_manifest_rejects_non_object_transition_settings(): + ok, errors = validate_manifest( + { + "id": "t", + "name": "T", + "version": "1.0.0", + "transition_settings": "broken", + } + ) + assert not ok + assert any("transition_settings" in e for e in errors) + + +def test_manifest_data_plugin_default(): + """Manifests with no plugin_type field still parse as data plugins.""" + parsed = PluginManifest.from_dict({"id": "d", "name": "D", "version": "1.0.0"}) + assert parsed.plugin_type == "data" + assert parsed.transition_settings == {} + + +def test_manifest_transition_round_trip(): + """plugin_type and transition_settings survive to_dict.""" + parsed = PluginManifest.from_dict( + { + "id": "t", + "name": "T", + "version": "1.0.0", + "plugin_type": "transition", + "transition_settings": {"max_frames": 99, "interruptible": False}, + } + ) + out = parsed.to_dict() + assert out["plugin_type"] == "transition" + assert out["transition_settings"]["max_frames"] == 99 + assert out["transition_settings"]["interruptible"] is False + + +def test_manifest_category_transition_allowed(): + """'transition' is a valid category.""" + parsed = PluginManifest.from_dict( + { + "id": "t", + "name": "T", + "version": "1.0.0", + "plugin_type": "transition", + "category": "transition", + } + ) + assert parsed.category == "transition" + + +# --------------------------------------------------------------------------- +# Loader: dual registry +# --------------------------------------------------------------------------- + + +def _write_transition_plugin(plugins_dir: Path, plugin_id: str) -> Path: + plugin_dir = plugins_dir / plugin_id + plugin_dir.mkdir() + (plugin_dir / "manifest.json").write_text( + json.dumps( + { + "id": plugin_id, + "name": "T", + "version": "1.0.0", + "plugin_type": "transition", + "category": "transition", + } + ) + ) + (plugin_dir / "__init__.py").write_text( + f'''"""Test transition plugin {plugin_id}.""" +from src.plugins.base import TransitionPluginBase + +class Plugin(TransitionPluginBase): + @property + def plugin_id(self) -> str: + return "{plugin_id}" + + def generate_frames(self, from_grid, to_grid, device, config): + yield to_grid, 0 +''' + ) + return plugin_dir + + +def _write_data_plugin(plugins_dir: Path, plugin_id: str) -> Path: + plugin_dir = plugins_dir / plugin_id + plugin_dir.mkdir() + (plugin_dir / "manifest.json").write_text( + json.dumps( + { + "id": plugin_id, + "name": "D", + "version": "1.0.0", + "variables": {"simple": ["x"]}, + } + ) + ) + (plugin_dir / "__init__.py").write_text( + f'''"""Test data plugin {plugin_id}.""" +from src.plugins.base import PluginBase, PluginResult + +class Plugin(PluginBase): + @property + def plugin_id(self) -> str: + return "{plugin_id}" + + def fetch_data(self) -> PluginResult: + return PluginResult(available=True, data={{"x": 1}}) +''' + ) + return plugin_dir + + +def test_loader_loads_transition_plugin(tmp_path): + _write_transition_plugin(tmp_path, "t_only") + loader = PluginLoader(plugins_dir=tmp_path, external_dirs=[]) + plugin = loader.load_plugin("t_only") + assert plugin is not None + assert isinstance(plugin, TransitionPluginBase) + assert plugin.plugin_id == "t_only" + + +def test_loader_dual_registry_separates_types(tmp_path): + _write_data_plugin(tmp_path, "d_one") + _write_transition_plugin(tmp_path, "t_one") + + loader = PluginLoader(plugins_dir=tmp_path, external_dirs=[]) + loader.load_all_plugins() + + assert set(loader.data_plugins.keys()) == {"d_one"} + assert set(loader.transition_plugins.keys()) == {"t_one"} + assert set(loader.loaded_plugins.keys()) == {"d_one", "t_one"} + + +def test_loader_get_transition_plugin_returns_none_for_data(tmp_path): + _write_data_plugin(tmp_path, "d_two") + loader = PluginLoader(plugins_dir=tmp_path, external_dirs=[]) + loader.load_all_plugins() + assert loader.get_transition_plugin("d_two") is None + assert loader.get_transition_plugin("missing") is None + + +def test_loader_get_transition_plugin_returns_instance(tmp_path): + _write_transition_plugin(tmp_path, "t_two") + loader = PluginLoader(plugins_dir=tmp_path, external_dirs=[]) + loader.load_all_plugins() + plugin = loader.get_transition_plugin("t_two") + assert plugin is not None + assert plugin.plugin_id == "t_two" + + +def test_loader_errors_on_missing_transition_subclass(tmp_path): + """A manifest declaring plugin_type=transition needs a TransitionPluginBase subclass.""" + plugin_dir = tmp_path / "broken" + plugin_dir.mkdir() + (plugin_dir / "manifest.json").write_text( + json.dumps( + { + "id": "broken", + "name": "B", + "version": "1.0.0", + "plugin_type": "transition", + } + ) + ) + (plugin_dir / "__init__.py").write_text( + '''"""Wrong base class — uses PluginBase instead of TransitionPluginBase.""" +from src.plugins.base import PluginBase, PluginResult + +class Plugin(PluginBase): + @property + def plugin_id(self) -> str: + return "broken" + + def fetch_data(self) -> PluginResult: + return PluginResult(available=True, data={}) +''' + ) + loader = PluginLoader(plugins_dir=tmp_path, external_dirs=[]) + plugin = loader.load_plugin("broken") + assert plugin is None + errors = loader.load_errors["broken"] + assert any("TransitionPluginBase" in e for e in errors) diff --git a/tests/test_transitions_api.py b/tests/test_transitions_api.py new file mode 100644 index 000000000..a049286e0 --- /dev/null +++ b/tests/test_transitions_api.py @@ -0,0 +1,272 @@ +"""Tests for /transitions/* endpoint handlers. + +Tests call the endpoint handler functions directly rather than going +through TestClient, because spinning up TestClient triggers the FastAPI +lifespan which initializes the plugin registry and the resulting +sys.modules pollution interferes with other plugin tests that patch +their own module's datetime. +""" + +import asyncio +from collections.abc import Iterator + +import pytest +from fastapi import HTTPException + +from src.api_server import list_transition_plugins, preview_transition +from src.plugins.base import TransitionPluginBase +from src.plugins.manifest import PluginManifest + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +class _FakeTypewriter(TransitionPluginBase): + @property + def plugin_id(self) -> str: + return "fake_typewriter" + + def generate_frames(self, from_grid, to_grid, device, config) -> Iterator[tuple[list[list[int]], int]]: + intermediate = [list(row) for row in from_grid] + yield intermediate, int(config.get("frame_interval_ms", 100)) + yield to_grid, 0 + + +class _FakeForever(TransitionPluginBase): + @property + def plugin_id(self) -> str: + return "fake_forever" + + def generate_frames(self, from_grid, to_grid, device, config): + while True: + yield to_grid, 10 + + +_TYPEWRITER_MANIFEST = { + "id": "fake_typewriter", + "name": "Fake Typewriter", + "version": "1.0.0", + "description": "test", + "author": "test", + "icon": "type", + "category": "transition", + "plugin_type": "transition", + "settings_schema": { + "type": "object", + "properties": {"frame_interval_ms": {"type": "integer", "default": 100}}, + }, + "transition_settings": { + "interruptible": True, + "min_interval_ms": 25, + "max_frames": 5, + "max_runtime_seconds": 60, + }, +} + +_FOREVER_MANIFEST = { + "id": "fake_forever", + "name": "Fake Forever", + "version": "1.0.0", + "description": "test", + "author": "test", + "icon": "infinity", + "category": "transition", + "plugin_type": "transition", + "settings_schema": {"type": "object", "properties": {}}, + "transition_settings": { + "interruptible": True, + "min_interval_ms": 1, + "max_frames": 3, + "max_runtime_seconds": 60, + }, +} + + +@pytest.fixture(autouse=True) +def _enable_transitions_beta(monkeypatch): + """Enable the beta flag for every test in this file. + + The /transitions endpoints are gated behind + ``beta.transition_plugins_enabled``; without this fixture the + handlers would return 404 before reaching the registry. + """ + from src.settings.service import get_settings_service + + settings = get_settings_service() + original = settings.get_beta_settings().transition_plugins_enabled + settings.update_beta_settings({"transition_plugins_enabled": True}) + yield + settings.update_beta_settings({"transition_plugins_enabled": original}) + + +@pytest.fixture +def patched_registry(monkeypatch): + """Swap the global plugin registry singleton for a hand-built stub. + + We deliberately skip ``PluginRegistry.initialize()`` -- that method + loads every plugin via importlib.util and clobbers + ``sys.modules["plugins."]``, which would break other plugin + test files that hold imported references to those modules. + Instead we construct a blank registry and inject only the fakes we + need. After the test, the original singleton is restored. + """ + from src.plugins import registry as registry_mod + + # Build a fresh, uninitialized registry. ``PluginRegistry.__init__`` + # constructs a loader but doesn't scan/import anything. + fresh = registry_mod.PluginRegistry() + + type_plug = _FakeTypewriter(_TYPEWRITER_MANIFEST) + forever_plug = _FakeForever(_FOREVER_MANIFEST) + fresh._plugins["fake_typewriter"] = type_plug + fresh._plugins["fake_forever"] = forever_plug + fresh._manifests["fake_typewriter"] = PluginManifest.from_dict(_TYPEWRITER_MANIFEST) + fresh._manifests["fake_forever"] = PluginManifest.from_dict(_FOREVER_MANIFEST) + fresh._enabled["fake_typewriter"] = True + fresh._enabled["fake_forever"] = True + type_plug.config = {"frame_interval_ms": 50} + forever_plug.config = {} + + monkeypatch.setattr(registry_mod, "_registry", fresh) + yield fresh + + +def _run(coro): + """Synchronously run an async endpoint coroutine.""" + return asyncio.run(coro) + + +# --------------------------------------------------------------------------- +# list_transition_plugins +# --------------------------------------------------------------------------- + + +def test_list_transition_plugins_returns_enabled(patched_registry): + data = _run(list_transition_plugins()) + ids = {p["id"] for p in data["plugins"]} + assert {"fake_typewriter", "fake_forever"} <= ids + + +def test_list_transition_plugins_includes_settings_schema(patched_registry): + data = _run(list_transition_plugins()) + by_id = {p["id"]: p for p in data["plugins"]} + tw = by_id["fake_typewriter"] + assert tw["settings_schema"]["properties"]["frame_interval_ms"]["default"] == 100 + assert tw["transition_settings"]["max_frames"] == 5 + assert tw["strategy"] == "plugin:fake_typewriter" + + +def test_list_transition_plugins_excludes_disabled(patched_registry): + patched_registry._enabled["fake_typewriter"] = False + data = _run(list_transition_plugins()) + ids = {p["id"] for p in data["plugins"]} + assert "fake_typewriter" not in ids + + +def test_list_transition_plugins_omits_data_plugins(patched_registry): + """Only TransitionPluginBase subclasses should be listed.""" + data = _run(list_transition_plugins()) + for entry in data["plugins"]: + assert entry["strategy"].startswith("plugin:") + + +# --------------------------------------------------------------------------- +# preview_transition +# --------------------------------------------------------------------------- + + +def test_preview_requires_plugin_id(patched_registry): + with pytest.raises(HTTPException) as exc: + _run(preview_transition({"to_text": "HELLO"})) + assert exc.value.status_code == 400 + + +def test_preview_unknown_plugin_returns_404(patched_registry): + with pytest.raises(HTTPException) as exc: + _run(preview_transition({"plugin_id": "ghost", "to_text": "HELLO"})) + assert exc.value.status_code == 404 + + +def test_preview_returns_frames_with_grid_and_delay(patched_registry): + data = _run( + preview_transition( + { + "plugin_id": "fake_typewriter", + "from_text": "", + "to_text": "HELLO", + "device_type": "flagship", + "config": {"frame_interval_ms": 50}, + } + ) + ) + assert data["plugin_id"] == "fake_typewriter" + assert data["device_type"] == "flagship" + assert data["frame_count"] == 2 + for frame in data["frames"]: + assert len(frame["grid"]) == 6 + assert all(len(row) == 22 for row in frame["grid"]) + assert isinstance(frame["delay_ms"], int) + # Total delay = 50 + max(0, 25) (clamped to min_interval_ms). + assert data["total_delay_ms"] == 50 + 25 + + +def test_preview_honors_max_frames_cap_and_sets_capped(patched_registry): + """Forever plugin yields infinitely; preview caps at max_frames=3.""" + data = _run( + preview_transition( + { + "plugin_id": "fake_forever", + "from_text": "", + "to_text": "X", + "device_type": "flagship", + } + ) + ) + assert data["frame_count"] == 3 + assert data["capped"] is True + + +def test_preview_rejects_bad_device_type(patched_registry): + with pytest.raises(HTTPException) as exc: + _run(preview_transition({"plugin_id": "fake_typewriter", "to_text": "HI", "device_type": "wat"})) + assert exc.value.status_code == 400 + + +def test_preview_handles_note_device(patched_registry): + data = _run( + preview_transition( + { + "plugin_id": "fake_typewriter", + "to_text": "HI", + "device_type": "note", + } + ) + ) + for frame in data["frames"]: + assert len(frame["grid"]) == 3 + assert all(len(row) == 15 for row in frame["grid"]) + + +# --------------------------------------------------------------------------- +# Beta gating +# --------------------------------------------------------------------------- + + +def test_endpoints_404_when_beta_disabled(patched_registry): + """With the beta flag off, both endpoints return 404.""" + from src.settings.service import get_settings_service + + settings = get_settings_service() + settings.update_beta_settings({"transition_plugins_enabled": False}) + try: + with pytest.raises(HTTPException) as exc: + _run(list_transition_plugins()) + assert exc.value.status_code == 404 + assert "beta" in exc.value.detail.lower() + + with pytest.raises(HTTPException) as exc: + _run(preview_transition({"plugin_id": "fake_typewriter", "to_text": "HI"})) + assert exc.value.status_code == 404 + finally: + settings.update_beta_settings({"transition_plugins_enabled": True}) diff --git a/tests/test_transitions_runner.py b/tests/test_transitions_runner.py new file mode 100644 index 000000000..684b673bd --- /dev/null +++ b/tests/test_transitions_runner.py @@ -0,0 +1,465 @@ +"""Tests for TransitionRunner and BoardClient.render() façade.""" + +import threading +import time +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from src.board_client import TRANSITION_PLUGIN_PREFIX, BoardClient +from src.plugins.base import TransitionFrame, TransitionPluginBase +from src.transitions.runner import TransitionRunner + + +@pytest.fixture(autouse=True) +def _enable_transition_plugins_beta(): + """Enable the beta flag for every render() test in this module. + + The defense-in-depth gate in :meth:`BoardClient.render` falls back to + a non-plugin send when ``beta.transition_plugins_enabled`` is False; + tests that exercise the plugin code path need the flag on. + """ + from src.settings.service import get_settings_service + + settings = get_settings_service() + original = settings.get_beta_settings().transition_plugins_enabled + settings.update_beta_settings({"transition_plugins_enabled": True}) + yield + settings.update_beta_settings({"transition_plugins_enabled": original}) + + +# --------------------------------------------------------------------------- +# Test plugins +# --------------------------------------------------------------------------- + + +class _SeqPlugin(TransitionPluginBase): + """Yields a fixed list of frames (delay is the manifest min_interval_ms).""" + + def __init__(self, manifest: dict[str, Any], frames: list[TransitionFrame]): + super().__init__(manifest) + self._frames = frames + + @property + def plugin_id(self) -> str: + return self._manifest.get("id", "seq") + + def generate_frames(self, from_grid, to_grid, device, config): + yield from self._frames + + +class _BareGridPlugin(TransitionPluginBase): + """Yields raw grids without a (grid, delay) tuple wrapping.""" + + def __init__(self, manifest, grids): + super().__init__(manifest) + self._grids = grids + + @property + def plugin_id(self) -> str: + return self._manifest.get("id", "bare") + + def generate_frames(self, from_grid, to_grid, device, config): + yield from self._grids # bare grid, no delay + + +class _RaisingPlugin(TransitionPluginBase): + @property + def plugin_id(self) -> str: + return "boom" + + def generate_frames(self, from_grid, to_grid, device, config): + if False: + yield # mark as generator function + raise RuntimeError("plugin exploded") + + +class _ForeverPlugin(TransitionPluginBase): + """Yields an unbounded stream of frames -- used to test caps + cancel.""" + + @property + def plugin_id(self) -> str: + return "forever" + + def generate_frames(self, from_grid, to_grid, device, config): + n = 0 + while True: + n += 1 + yield to_grid, 5 + + +# --------------------------------------------------------------------------- +# Mock board client +# --------------------------------------------------------------------------- + + +class _FakeBoard: + """Minimal stand-in for BoardClient that records sends.""" + + def __init__(self, cached: list[list[int]] | None = None, read: list[list[int]] | None = None): + self.sent: list[list[list[int]]] = [] + self._last_characters = cached + self._read_result = read + self.fail_after = None # if set, the Nth send raises + + def send_characters(self, characters, strategy=None, force=False, **kwargs): + if self.fail_after is not None and len(self.sent) >= self.fail_after: + raise RuntimeError("send blew up") + self.sent.append([list(r) for r in characters]) + self._last_characters = [list(r) for r in characters] + return (True, True) + + def read_current_message(self): + return self._read_result + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _manifest(plugin_id: str, **transition_settings) -> dict[str, Any]: + return { + "id": plugin_id, + "name": plugin_id, + "version": "1.0.0", + "plugin_type": "transition", + "transition_settings": transition_settings, + } + + +def _grid(value: int) -> list[list[int]]: + """6x22 flagship grid filled with *value*.""" + return [[value] * 22 for _ in range(6)] + + +# --------------------------------------------------------------------------- +# Runner: basic flow +# --------------------------------------------------------------------------- + + +def test_run_drives_frames_and_snaps_to_target(): + target = _grid(1) + frames = [(_grid(0), 0), (_grid(2), 0), (_grid(3), 0)] + plugin = _SeqPlugin(_manifest("seq", min_interval_ms=0), frames) + runner = TransitionRunner(lambda pid: plugin if pid == "seq" else None) + + board = _FakeBoard() + success, was_sent = runner.run( + plugin_id="seq", + to_grid=target, + board_client=board, + cancel_event=threading.Event(), + ) + + assert success + assert was_sent + # 3 plugin frames + 1 final snap to target + assert len(board.sent) == 4 + assert board.sent[-1] == target + + +def test_run_falls_back_to_blank_when_no_cache_or_read(): + """When the board has no cached state and read returns None, from_grid is blank.""" + captured = [] + + class _CapturePlugin(TransitionPluginBase): + @property + def plugin_id(self): + return "cap" + + def generate_frames(self, from_grid, to_grid, device, config): + captured.append(from_grid) + yield to_grid, 0 + + plugin = _CapturePlugin(_manifest("cap", min_interval_ms=0)) + runner = TransitionRunner(lambda pid: plugin) + + board = _FakeBoard(cached=None, read=None) + runner.run(plugin_id="cap", to_grid=_grid(1), board_client=board) + + assert captured and captured[0] == _grid(0) + + +def test_run_uses_cached_grid_as_from(): + captured = [] + + class _CapturePlugin(TransitionPluginBase): + @property + def plugin_id(self): + return "cap" + + def generate_frames(self, from_grid, to_grid, device, config): + captured.append(from_grid) + yield to_grid, 0 + + plugin = _CapturePlugin(_manifest("cap", min_interval_ms=0)) + runner = TransitionRunner(lambda pid: plugin) + board = _FakeBoard(cached=_grid(7)) + runner.run(plugin_id="cap", to_grid=_grid(1), board_client=board) + assert captured[0] == _grid(7) + + +def test_run_does_not_call_read_current_message_when_cache_empty(): + """Runner no longer falls back to a live board read. + + Previously ``_resolve_from_grid`` invoked ``read_current_message`` as a + last-ditch fallback before the blank grid. That added a network + round-trip under the send lock for a value the isinstance check + typically rejected. Now we go straight from missing cache to a + blank grid. + """ + captured = [] + + class _CapturePlugin(TransitionPluginBase): + @property + def plugin_id(self): + return "cap" + + def generate_frames(self, from_grid, to_grid, device, config): + captured.append(from_grid) + yield to_grid, 0 + + plugin = _CapturePlugin(_manifest("cap", min_interval_ms=0)) + runner = TransitionRunner(lambda pid: plugin) + + read_calls = [] + + class _ReadTrackingBoard(_FakeBoard): + def read_current_message(self): + read_calls.append(True) + return _grid(9) + + board = _ReadTrackingBoard(cached=None) + runner.run(plugin_id="cap", to_grid=_grid(1), board_client=board) + assert captured[0] == _grid(0) # blank fallback, not the read result + assert read_calls == [] # read_current_message is never invoked + + +def test_run_unknown_plugin_snaps_to_target(): + runner = TransitionRunner(lambda pid: None) + board = _FakeBoard() + success, was_sent = runner.run(plugin_id="ghost", to_grid=_grid(4), board_client=board) + assert success and was_sent + assert board.sent == [_grid(4)] + + +def test_run_handles_bare_grid_yield(): + plugin = _BareGridPlugin(_manifest("bare", min_interval_ms=0), [_grid(1), _grid(2)]) + runner = TransitionRunner(lambda pid: plugin) + board = _FakeBoard() + runner.run(plugin_id="bare", to_grid=_grid(3), board_client=board) + # 2 bare frames + 1 snap + assert [s[0][0] for s in board.sent] == [1, 2, 3] + + +def test_run_aborts_when_plugin_raises(): + plugin = _RaisingPlugin(_manifest("boom")) + runner = TransitionRunner(lambda pid: plugin) + board = _FakeBoard() + success, was_sent = runner.run(plugin_id="boom", to_grid=_grid(1), board_client=board) + # Snap to target still happens + assert success and was_sent + assert board.sent == [_grid(1)] + + +def test_run_skips_malformed_frame_and_snaps(): + class _Garbage(TransitionPluginBase): + @property + def plugin_id(self): + return "g" + + def generate_frames(self, from_grid, to_grid, device, config): + yield ("not", "a", "tuple") # malformed + + plugin = _Garbage(_manifest("g")) + runner = TransitionRunner(lambda pid: plugin) + board = _FakeBoard() + runner.run(plugin_id="g", to_grid=_grid(5), board_client=board) + # Plugin frames rejected; only snap reaches the board + assert board.sent == [_grid(5)] + + +# --------------------------------------------------------------------------- +# Runner: caps & cancellation +# --------------------------------------------------------------------------- + + +def test_max_frames_cap_aborts_generator(): + plugin = _ForeverPlugin(_manifest("forever", min_interval_ms=0, max_frames=4)) + runner = TransitionRunner(lambda pid: plugin) + board = _FakeBoard() + runner.run(plugin_id="forever", to_grid=_grid(1), board_client=board) + # 4 plugin frames + 1 snap + assert len(board.sent) == 5 + + +def test_max_runtime_seconds_cap_aborts(): + plugin = _ForeverPlugin(_manifest("forever", min_interval_ms=20, max_frames=10000, max_runtime_seconds=1)) + runner = TransitionRunner(lambda pid: plugin) + board = _FakeBoard() + start = time.monotonic() + runner.run(plugin_id="forever", to_grid=_grid(1), board_client=board) + elapsed = time.monotonic() - start + # Should give up close to the 1s cap, not run forever. + assert elapsed < 3.0 + assert len(board.sent) >= 2 # at least one plugin frame plus snap + + +def test_cancel_event_stops_iteration(): + plugin = _ForeverPlugin(_manifest("forever", min_interval_ms=50, max_frames=10000)) + runner = TransitionRunner(lambda pid: plugin) + board = _FakeBoard() + cancel = threading.Event() + + def fire_cancel(): + time.sleep(0.1) + cancel.set() + + t = threading.Thread(target=fire_cancel) + t.start() + runner.run( + plugin_id="forever", + to_grid=_grid(1), + board_client=board, + cancel_event=cancel, + ) + t.join() + # Cancel should fire well before the default 120s cap; sends remain small. + assert len(board.sent) < 50 + + +def test_non_interruptible_ignores_cancel_event(): + plugin = _ForeverPlugin( + _manifest( + "forever", + min_interval_ms=0, + max_frames=3, # rely on cap so the test terminates + interruptible=False, + ) + ) + runner = TransitionRunner(lambda pid: plugin) + board = _FakeBoard() + cancel = threading.Event() + cancel.set() # pre-set; runner should still iterate to the cap + runner.run( + plugin_id="forever", + to_grid=_grid(1), + board_client=board, + cancel_event=cancel, + ) + # 3 plugin frames (cap) + 1 snap + assert len(board.sent) == 4 + + +def test_min_interval_ms_floor_is_enforced(): + plugin = _SeqPlugin( + _manifest("seq", min_interval_ms=50, max_frames=5), + [(_grid(1), 0), (_grid(2), 0)], # plugin yields 0 ms + ) + runner = TransitionRunner(lambda pid: plugin) + board = _FakeBoard() + start = time.monotonic() + runner.run(plugin_id="seq", to_grid=_grid(3), board_client=board) + elapsed = time.monotonic() - start + # Two 50ms sleeps minimum (one after each plugin frame). + assert elapsed >= 0.09 + + +# --------------------------------------------------------------------------- +# BoardClient.render() façade +# --------------------------------------------------------------------------- + + +def _build_board_client() -> BoardClient: + bc = BoardClient(api_key="k", host="h", use_cloud=False) + # Stub the actual HTTP call so we don't need network. + bc.send_characters = MagicMock(return_value=(True, True)) # type: ignore[assignment] + return bc + + +def test_render_passes_built_in_strategy_through(): + bc = _build_board_client() + bc.render(_grid(1), strategy="column", step_interval_ms=200, step_size=2) + bc.send_characters.assert_called_once_with( + _grid(1), + strategy="column", + step_interval_ms=200, + step_size=2, + force=False, + ) + + +def test_render_no_strategy_passes_through(): + bc = _build_board_client() + bc.render(_grid(1)) + bc.send_characters.assert_called_once() + + +def test_render_with_plugin_strategy_and_no_runner_falls_back(): + bc = _build_board_client() + bc.render(_grid(1), strategy=f"{TRANSITION_PLUGIN_PREFIX}missing") + # Without a runner attached, the call snaps to target via send_characters. + bc.send_characters.assert_called_once() + + +def test_render_with_plugin_strategy_invokes_runner(): + bc = _build_board_client() + + captured = {} + + class _Runner: + def run(self, plugin_id, to_grid, board_client, cancel_event, device_type): + captured["plugin_id"] = plugin_id + captured["to_grid"] = to_grid + captured["cancel"] = cancel_event + captured["device_type"] = device_type + return (True, True) + + bc.set_transition_runner(_Runner()) + bc.render( + _grid(2), + strategy=f"{TRANSITION_PLUGIN_PREFIX}typewriter", + device_type="flagship", + ) + assert captured["plugin_id"] == "typewriter" + assert captured["to_grid"] == _grid(2) + assert captured["device_type"] == "flagship" + assert isinstance(captured["cancel"], threading.Event) + + +def test_render_empty_plugin_id_falls_back(): + bc = _build_board_client() + + bc.set_transition_runner(MagicMock()) + bc.render(_grid(1), strategy=f"{TRANSITION_PLUGIN_PREFIX} ") + # Empty id after the prefix: do not call the runner, snap instead. + assert bc._transition_runner.run.call_count == 0 + bc.send_characters.assert_called_once() + + +def test_render_sets_cancel_event_before_handing_off(): + """A second render() while a transition is in flight should signal cancel.""" + bc = _build_board_client() + + events_seen: list[bool] = [] + + class _Runner: + def run(self, plugin_id, to_grid, board_client, cancel_event, device_type): + # The new render call should clear the event before invoking us. + events_seen.append(cancel_event.is_set()) + return (True, True) + + bc.set_transition_runner(_Runner()) + bc.render(_grid(1), strategy=f"{TRANSITION_PLUGIN_PREFIX}t") + bc.render(_grid(2), strategy=f"{TRANSITION_PLUGIN_PREFIX}t") + # Both runner invocations should see a fresh (cleared) event. + assert events_seen == [False, False] + + +def test_set_transition_runner_can_be_cleared(): + bc = _build_board_client() + bc.set_transition_runner(MagicMock()) + bc.set_transition_runner(None) + assert bc._transition_runner is None diff --git a/tests/test_trigger_render_path.py b/tests/test_trigger_render_path.py index d8b51acba..b6a400aff 100644 --- a/tests/test_trigger_render_path.py +++ b/tests/test_trigger_render_path.py @@ -117,6 +117,7 @@ def display_service(): svc = DisplayService() svc.vb_client = Mock() svc.vb_client.send_characters.return_value = (True, True) + svc.vb_client.render.return_value = (True, True) return svc @@ -537,7 +538,7 @@ def test_sends_content_when_different_from_cache(self, display_service): sent = display_service._send_trigger_content("HELLO TRIGGER") assert sent is True - display_service.vb_client.send_characters.assert_called_once() + display_service.vb_client.render.assert_called_once() assert display_service._last_active_page_content == "HELLO TRIGGER" assert display_service._last_active_page_id == "__trigger__" @@ -545,4 +546,4 @@ def test_skips_send_when_content_unchanged(self, display_service): display_service._last_active_page_content = "ALREADY ON BOARD" sent = display_service._send_trigger_content("ALREADY ON BOARD") assert sent is False - display_service.vb_client.send_characters.assert_not_called() + display_service.vb_client.render.assert_not_called() diff --git a/web/src/__tests__/transitions-api.test.ts b/web/src/__tests__/transitions-api.test.ts new file mode 100644 index 000000000..cc4b4904b --- /dev/null +++ b/web/src/__tests__/transitions-api.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for the transition plugin API client functions in @/lib/api. + * + * Mocks the backend with MSW and verifies the client posts the expected + * payloads and parses the responses correctly. The harness page itself + * is exercised manually in the browser; these tests cover the data + * contract that the page relies on. + */ + +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; + +import type { TransitionPluginsResponse, TransitionPreviewResponse } from "@/lib/api"; +import { api } from "@/lib/api"; + +import { server } from "./mocks/server"; + +const API_BASE = "/api"; + +describe("transition API client", () => { + describe("listTransitionPlugins", () => { + it("returns the plugin list as-is", async () => { + const payload: TransitionPluginsResponse = { + plugins: [ + { + id: "typewriter", + name: "Typewriter", + description: "Left-to-right reveal", + icon: "type", + version: "1.0.0", + author: "FiestaBoard", + settings_schema: { + type: "object", + properties: { + chars_per_frame: { type: "integer", default: 1 }, + }, + }, + transition_settings: { + interruptible: true, + min_interval_ms: 50, + max_frames: 200, + max_runtime_seconds: 60, + }, + config: { chars_per_frame: 2 }, + strategy: "plugin:typewriter", + }, + ], + }; + server.use(http.get(`${API_BASE}/transitions/plugins`, () => HttpResponse.json(payload))); + + const result = await api.listTransitionPlugins(); + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0].id).toBe("typewriter"); + expect(result.plugins[0].strategy).toBe("plugin:typewriter"); + expect(result.plugins[0].transition_settings.max_frames).toBe(200); + }); + + it("surfaces backend errors", async () => { + server.use( + http.get(`${API_BASE}/transitions/plugins`, () => new HttpResponse(null, { status: 500, statusText: "boom" })), + ); + await expect(api.listTransitionPlugins()).rejects.toThrow(/500/); + }); + }); + + describe("previewTransition", () => { + it("POSTs the request body and parses the frame array", async () => { + let capturedBody: unknown = null; + const payload: TransitionPreviewResponse = { + plugin_id: "typewriter", + device_type: "flagship", + frames: [ + { grid: Array.from({ length: 6 }, () => Array(22).fill(0)), delay_ms: 50 }, + { grid: Array.from({ length: 6 }, () => Array(22).fill(1)), delay_ms: 50 }, + ], + frame_count: 2, + total_delay_ms: 100, + capped: false, + from_grid: Array.from({ length: 6 }, () => Array(22).fill(0)), + to_grid: Array.from({ length: 6 }, () => Array(22).fill(1)), + }; + server.use( + http.post(`${API_BASE}/transitions/preview`, async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json(payload); + }), + ); + + const result = await api.previewTransition({ + plugin_id: "typewriter", + to_text: "HI", + from_text: "BYE", + device_type: "flagship", + config: { chars_per_frame: 2 }, + }); + + expect(capturedBody).toEqual({ + plugin_id: "typewriter", + to_text: "HI", + from_text: "BYE", + device_type: "flagship", + config: { chars_per_frame: 2 }, + }); + expect(result.frames).toHaveLength(2); + expect(result.frame_count).toBe(2); + expect(result.total_delay_ms).toBe(100); + expect(result.capped).toBe(false); + }); + + it("surfaces 404 from an unknown plugin", async () => { + server.use( + http.post( + `${API_BASE}/transitions/preview`, + () => new HttpResponse(null, { status: 404, statusText: "Not Found" }), + ), + ); + await expect(api.previewTransition({ plugin_id: "ghost", to_text: "X" })).rejects.toThrow(/404/); + }); + + it("surfaces 400 for bad input", async () => { + server.use( + http.post( + `${API_BASE}/transitions/preview`, + () => new HttpResponse(null, { status: 400, statusText: "Bad Request" }), + ), + ); + await expect(api.previewTransition({ plugin_id: "", to_text: "X" })).rejects.toThrow(/400/); + }); + + it("preserves capped flag on overlarge previews", async () => { + server.use( + http.post(`${API_BASE}/transitions/preview`, () => + HttpResponse.json({ + plugin_id: "forever", + device_type: "flagship", + frames: [{ grid: [[0]], delay_ms: 50 }], + frame_count: 1, + total_delay_ms: 50, + capped: true, + from_grid: [[0]], + to_grid: [[0]], + }), + ), + ); + const result = await api.previewTransition({ + plugin_id: "forever", + to_text: "X", + }); + expect(result.capped).toBe(true); + }); + }); +}); diff --git a/web/src/app/transitions/page.tsx b/web/src/app/transitions/page.tsx new file mode 100644 index 000000000..45bccbf6c --- /dev/null +++ b/web/src/app/transitions/page.tsx @@ -0,0 +1,344 @@ +"use client"; + +/** + * Transition plugin test harness. + * + * Authors of transition plugins use this page to preview a frame-by-frame + * animation against arbitrary from/to text without sending anything to a + * real board. The page fetches the list of enabled transition plugins, + * lets the user pick one, type from/to text, optionally edit per-plugin + * config knobs, and then drives the resulting frame array through the + * existing board renderer with play / pause / step / scrub controls. + */ + +import { useQuery } from "@tanstack/react-query"; +import { Pause, Play, RotateCcw, SkipBack, SkipForward } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { PageHeader } from "@/components/page-header"; +import { PageLayout } from "@/components/page-layout"; +import { TransitionGridDisplay } from "@/components/transitions/transition-grid-display"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; +import { Textarea } from "@/components/ui/textarea"; +import type { TransitionPreviewResponse } from "@/lib/api"; +import { api } from "@/lib/api"; + +const DEFAULT_FROM = "HELLO WORLD"; +const DEFAULT_TO = "FIESTABOARD"; + +type DeviceType = "flagship" | "note"; + +export default function TransitionsHarnessPage() { + const [selectedPluginId, setSelectedPluginId] = useState(""); + const [fromText, setFromText] = useState(DEFAULT_FROM); + const [toText, setToText] = useState(DEFAULT_TO); + const [deviceType, setDeviceType] = useState("flagship"); + const [configJson, setConfigJson] = useState("{}"); + const [preview, setPreview] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [previewing, setPreviewing] = useState(false); + + // Playback state. + const [frameIdx, setFrameIdx] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const playTimerRef = useRef | null>(null); + + const pluginsQuery = useQuery({ + queryKey: ["transition-plugins"], + queryFn: () => api.listTransitionPlugins(), + }); + + const plugins = pluginsQuery.data?.plugins ?? []; + + // Pick first plugin once loaded so the page isn't empty. + useEffect(() => { + if (!selectedPluginId && plugins.length > 0) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- initial selection + setSelectedPluginId(plugins[0].id); + } + }, [plugins, selectedPluginId]); + + const selectedPlugin = useMemo( + () => plugins.find((p) => p.id === selectedPluginId) ?? null, + [plugins, selectedPluginId], + ); + + // Seed the config editor with the plugin's current config when the + // selection changes, so authors aren't staring at an empty `{}` when + // a plugin already has defaults. + useEffect(() => { + if (selectedPlugin) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- seed editor from plugin + setConfigJson(JSON.stringify(selectedPlugin.config ?? {}, null, 2)); + } + }, [selectedPlugin]); + + const stopPlayback = useCallback(() => { + if (playTimerRef.current) { + clearTimeout(playTimerRef.current); + playTimerRef.current = null; + } + setIsPlaying(false); + }, []); + + // Drive playback: each frame schedules the next based on its delay_ms. + // setState in callbacks here is fine — it's a setTimeout callback, not a + // synchronous render-time setState — but lint can't distinguish. + useEffect(() => { + if (!isPlaying || !preview) return; + if (frameIdx >= preview.frames.length - 1) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- end of timeline; stop playing + setIsPlaying(false); + return; + } + const currentDelay = preview.frames[frameIdx]?.delay_ms ?? 100; + playTimerRef.current = setTimeout(() => { + setFrameIdx((idx) => Math.min(idx + 1, preview.frames.length - 1)); + }, currentDelay); + return () => { + if (playTimerRef.current) { + clearTimeout(playTimerRef.current); + playTimerRef.current = null; + } + }; + }, [isPlaying, frameIdx, preview]); + + // When a new preview lands, reset playback to the first frame. This is + // a legitimate cascading render: a fresh preview must start at frame 0 + // and the play state must reset. The runPreview callback toggles + // isPlaying back to true afterward when frames are present. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset on new preview + setFrameIdx(0); + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset on new preview + setIsPlaying(false); + }, [preview]); + + const runPreview = useCallback(async () => { + if (!selectedPluginId) return; + setPreviewError(null); + setPreviewing(true); + stopPlayback(); + try { + let parsedConfig: Record = {}; + try { + parsedConfig = configJson.trim() ? JSON.parse(configJson) : {}; + } catch (err) { + setPreviewError(`Config JSON is invalid: ${(err as Error).message}`); + setPreviewing(false); + return; + } + const result = await api.previewTransition({ + plugin_id: selectedPluginId, + from_text: fromText, + to_text: toText, + device_type: deviceType, + config: parsedConfig, + }); + setPreview(result); + if (result.frames.length > 0) { + setIsPlaying(true); + } + } catch (err) { + setPreviewError((err as Error).message); + } finally { + setPreviewing(false); + } + }, [configJson, deviceType, fromText, selectedPluginId, stopPlayback, toText]); + + // Frame to display: current playback index, or the to-grid if there + // are no frames yet. + const displayGrid = useMemo(() => { + if (preview && preview.frames.length > 0) { + return preview.frames[Math.min(frameIdx, preview.frames.length - 1)].grid; + } + if (preview) { + return preview.to_grid; + } + return null; + }, [preview, frameIdx]); + + const totalDuration = preview?.total_delay_ms ?? 0; + const frameCount = preview?.frame_count ?? 0; + + return ( + + + +
+ + + Setup + Pick a transition plugin, then enter the from/to text. + + +
+ + + {selectedPlugin &&

{selectedPlugin.description}

} +
+ +
+ + +
+ +
+ + setFromText(e.target.value)} + placeholder="Starting message" + /> +
+ +
+ + setToText(e.target.value)} + placeholder="Target message" + /> +
+ +
+ +