From 3e711cc40c771bc4bb5d5df44063b7003a4795a0 Mon Sep 17 00:00:00 2001 From: Jeffrey D Johnson Date: Sat, 30 May 2026 23:52:38 -0700 Subject: [PATCH 1/8] feat(transitions): pluggable frame-by-frame board animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new TransitionPlugin type alongside the existing data plugins. Transition plugins produce a sequence of intermediate grids that the runtime sends frame-by-frame, enabling typewriter reveals, slot-machine spins, dissolves, and anything else that requires custom per-frame control beyond Vestaboard's hardware transitions. Highlights - New TransitionPluginBase in src/plugins/base.py with a minimal generate_frames(from, to, device, config) -> Iterator[(grid, ms)] contract. Manifests opt in via plugin_type: "transition" and the new "transition" category. - Host-side TransitionRunner in src/transitions/runner.py drives the iterator, enforces caps (max_frames, max_runtime, min_interval_ms floor), and uses interruptible sleeps for clean cancellation. - BoardClient.render() faΓ§ade routes "plugin:" strategies through the runner and holds a per-board lock so concurrent rotation / trigger / manual sends serialise instead of interleaving frames. - All ten user-facing send_characters call sites in main.py and api_server.py migrated to render(); admin paths (blank/fill/debug) intentionally left raw. - Three first-party plugins to dogfood the SDK: typewriter, simple_dissolve, slot_machine. Each ships with manifest, README, SETUP guide, and tests at >=93% coverage. - /transitions/plugins and /transitions/preview endpoints power a new Transition Lab page in the web UI with a timeline scrubber and per-plugin config form -- authors can preview against arbitrary from/to text without a real board. - Loader hardened to reuse existing sys.modules entries when a plugin module was already imported, eliminating a test-pollution class where dynamic loading clobbered references held by other tests. - External transition plugins live in their own repos via the existing plugin-registry.json mechanism with a fiestaboard-transition-- naming convention. Docs - docs/development/TRANSITION_PLUGIN_DEVELOPMENT.md walks authors through the SDK, manifest fields, caps, the test harness, and publishing externally. - plugins/_template_transition is a copy-and-edit starter scaffold. - README adds a Transition Plugins subsection linking the three first-party transitions and the dev guide. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/scheduled_tasks.lock | 1 + .github/workflows/ci.yml | 2 +- README.md | 13 + .../TRANSITION_PLUGIN_DEVELOPMENT.md | 147 ++++++ plugins/_template_transition/README.md | 35 ++ plugins/_template_transition/__init__.py | 41 ++ plugins/_template_transition/docs/SETUP.md | 32 ++ .../docs/board-display.png | Bin 0 -> 68 bytes plugins/_template_transition/manifest.json | 38 ++ .../_template_transition/tests/__init__.py | 0 .../tests/test_my_transition.py | 9 + plugins/simple_dissolve/README.md | 40 ++ plugins/simple_dissolve/__init__.py | 77 +++ plugins/simple_dissolve/docs/SETUP.md | 37 ++ .../simple_dissolve/docs/board-display.png | Bin 0 -> 68 bytes plugins/simple_dissolve/manifest.json | 51 ++ plugins/simple_dissolve/tests/__init__.py | 0 .../tests/test_simple_dissolve.py | 170 +++++++ plugins/slot_machine/README.md | 42 ++ plugins/slot_machine/__init__.py | 69 +++ plugins/slot_machine/docs/SETUP.md | 38 ++ plugins/slot_machine/docs/board-display.png | Bin 0 -> 68 bytes plugins/slot_machine/manifest.json | 59 +++ plugins/slot_machine/tests/__init__.py | 0 .../slot_machine/tests/test_slot_machine.py | 226 +++++++++ plugins/typewriter/README.md | 37 ++ plugins/typewriter/__init__.py | 48 ++ plugins/typewriter/docs/SETUP.md | 35 ++ plugins/typewriter/docs/board-display.png | Bin 0 -> 68 bytes plugins/typewriter/manifest.json | 45 ++ plugins/typewriter/tests/__init__.py | 0 plugins/typewriter/tests/test_typewriter.py | 138 ++++++ scripts/run_plugin_tests.py | 2 +- src/api_server.py | 166 ++++++- src/board_client.py | 102 ++++ src/main.py | 42 +- src/pages/models.py | 7 +- src/plugins/__init__.py | 10 +- src/plugins/base.py | 173 ++++++- src/plugins/loader.py | 128 +++-- src/plugins/manifest.py | 75 ++- src/plugins/registry.py | 19 + src/plugins/sources.py | 27 +- src/settings/service.py | 36 +- src/transitions/__init__.py | 13 + src/transitions/runner.py | 348 ++++++++++++++ tests/test_api_coverage.py | 12 + tests/test_api_extended.py | 4 + tests/test_debug_endpoints.py | 1 + tests/test_integration_multi_features.py | 3 + tests/test_live_output.py | 7 + tests/test_plugin_validation.py | 14 +- tests/test_silence_mode_display.py | 29 +- tests/test_silence_schedule_polling.py | 15 +- tests/test_transition_plugin_base.py | 403 ++++++++++++++++ tests/test_transitions_api.py | 238 ++++++++++ tests/test_transitions_runner.py | 438 ++++++++++++++++++ web/src/__tests__/transitions-api.test.ts | 164 +++++++ web/src/app/transitions/page.tsx | 385 +++++++++++++++ .../transitions/transition-grid-display.tsx | 137 ++++++ web/src/lib/api.ts | 57 +++ 61 files changed, 4385 insertions(+), 100 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 docs/development/TRANSITION_PLUGIN_DEVELOPMENT.md create mode 100644 plugins/_template_transition/README.md create mode 100644 plugins/_template_transition/__init__.py create mode 100644 plugins/_template_transition/docs/SETUP.md create mode 100644 plugins/_template_transition/docs/board-display.png create mode 100644 plugins/_template_transition/manifest.json create mode 100644 plugins/_template_transition/tests/__init__.py create mode 100644 plugins/_template_transition/tests/test_my_transition.py create mode 100644 plugins/simple_dissolve/README.md create mode 100644 plugins/simple_dissolve/__init__.py create mode 100644 plugins/simple_dissolve/docs/SETUP.md create mode 100644 plugins/simple_dissolve/docs/board-display.png create mode 100644 plugins/simple_dissolve/manifest.json create mode 100644 plugins/simple_dissolve/tests/__init__.py create mode 100644 plugins/simple_dissolve/tests/test_simple_dissolve.py create mode 100644 plugins/slot_machine/README.md create mode 100644 plugins/slot_machine/__init__.py create mode 100644 plugins/slot_machine/docs/SETUP.md create mode 100644 plugins/slot_machine/docs/board-display.png create mode 100644 plugins/slot_machine/manifest.json create mode 100644 plugins/slot_machine/tests/__init__.py create mode 100644 plugins/slot_machine/tests/test_slot_machine.py create mode 100644 plugins/typewriter/README.md create mode 100644 plugins/typewriter/__init__.py create mode 100644 plugins/typewriter/docs/SETUP.md create mode 100644 plugins/typewriter/docs/board-display.png create mode 100644 plugins/typewriter/manifest.json create mode 100644 plugins/typewriter/tests/__init__.py create mode 100644 plugins/typewriter/tests/test_typewriter.py create mode 100644 src/transitions/__init__.py create mode 100644 src/transitions/runner.py create mode 100644 tests/test_transition_plugin_base.py create mode 100644 tests/test_transitions_api.py create mode 100644 tests/test_transitions_runner.py create mode 100644 web/src/__tests__/transitions-api.test.ts create mode 100644 web/src/app/transitions/page.tsx create mode 100644 web/src/components/transitions/transition-grid-display.tsx diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 000000000..2dc82cfe9 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"e12b8a09-365c-4481-8d26-06fe571812c1","pid":67721,"procStart":"Sat May 30 21:47:28 2026","acquiredAt":1780197849630} \ No newline at end of file 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/README.md b/README.md index c59fe4f74..8f046af43 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,19 @@ 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 + +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..d2de14a4e --- /dev/null +++ b/docs/development/TRANSITION_PLUGIN_DEVELOPMENT.md @@ -0,0 +1,147 @@ +# Transition Plugin Development Guide + +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..f8e458302 --- /dev/null +++ b/plugins/_template_transition/__init__.py @@ -0,0 +1,41 @@ +"""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 typing import Any, Dict, Iterator, List, Tuple + +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 0000000000000000000000000000000000000000..909c66db1740b7c1b41eb4db6c414a7ab5bb6a23 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$DG5Lh8v~O;;{|;n Oi^0>?&t;ucLK6U5DhwL{ literal 0 HcmV?d00001 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..b3bf3fb5e --- /dev/null +++ b/plugins/simple_dissolve/__init__.py @@ -0,0 +1,77 @@ +"""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 typing import Any, Dict, Iterator, List, Tuple + +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 0000000000000000000000000000000000000000..909c66db1740b7c1b41eb4db6c414a7ab5bb6a23 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$DG5Lh8v~O;;{|;n Oi^0>?&t;ucLK6U5DhwL{ literal 0 HcmV?d00001 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..ccf0c5ff8 --- /dev/null +++ b/plugins/simple_dissolve/tests/test_simple_dissolve.py @@ -0,0 +1,170 @@ +"""Tests for the simple_dissolve transition plugin.""" + +import json +from pathlib import Path + +import pytest + +from src.devices import DEVICE_DIMENSIONS +from plugins.simple_dissolve import SimpleDissolveTransition + + +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..2b024b377 --- /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 typing import Any, Dict, Iterator, List, Tuple + +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 0000000000000000000000000000000000000000..909c66db1740b7c1b41eb4db6c414a7ab5bb6a23 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$DG5Lh8v~O;;{|;n Oi^0>?&t;ucLK6U5DhwL{ literal 0 HcmV?d00001 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..f3c3b8edc --- /dev/null +++ b/plugins/slot_machine/tests/test_slot_machine.py @@ -0,0 +1,226 @@ +"""Tests for the slot_machine transition plugin.""" + +import json +from pathlib import Path + +import pytest + +from src.devices import DEVICE_DIMENSIONS +from plugins.slot_machine import SlotMachineTransition, _SPIN_CODES + + +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..ec81c80ee --- /dev/null +++ b/plugins/typewriter/__init__.py @@ -0,0 +1,48 @@ +"""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 typing import Any, Dict, Iterator, List, Tuple + +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 0000000000000000000000000000000000000000..909c66db1740b7c1b41eb4db6c414a7ab5bb6a23 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$DG5Lh8v~O;;{|;n Oi^0>?&t;ucLK6U5DhwL{ literal 0 HcmV?d00001 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..59a963615 --- /dev/null +++ b/plugins/typewriter/tests/test_typewriter.py @@ -0,0 +1,138 @@ +"""Tests for the typewriter transition plugin.""" + +import json +from pathlib import Path + +import pytest + +from src.devices import DEVICE_DIMENSIONS +from plugins.typewriter import TypewriterTransition + + +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/src/api_server.py b/src/api_server.py index 731c9da94..82773a0bf 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,153 @@ async def get_transition_settings(): } +@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. + """ + 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): + """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 would exceed max_frames the response is + truncated and ``capped`` is set to true so the UI can display a hint. + """ + 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"]) + + frames = [] + total_delay = 0 + capped = False + try: + for raw_frame in plugin.generate_frames(from_grid, to_grid, device, config): + if len(frames) >= max_frames: + capped = 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: + logger.exception("Transition preview failed for %s", plugin_id) + raise HTTPException(status_code=500, detail=f"Plugin error: {exc}") + + 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 +5252,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: @@ -6437,11 +6580,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..e80328da3 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,103 @@ 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. + # Set externally (by render()) when a new send arrives mid-transition. + self._cancel_transition = 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 + + 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`. + """ + # Built-in strategies and bare sends go straight through. + if not isinstance(strategy, str) or not strategy.startswith(TRANSITION_PLUGIN_PREFIX): + with self._send_lock: + # New non-transition send arriving while a transition is in + # flight cancels it (the runner observes the event between + # frames). Caller is responsible for picking the target. + self._cancel_transition.set() + self._cancel_transition.clear() + 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) + + 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) + + # Signal any in-flight transition to wind down before we grab the lock. + self._cancel_transition.set() + with self._send_lock: + self._cancel_transition.clear() + return runner.run( + plugin_id=plugin_id, + to_grid=characters, + board_client=self, + cancel_event=self._cancel_transition, + 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..1d415fbf7 100644 --- a/src/plugins/base.py +++ b/src/plugins/base.py @@ -1,6 +1,8 @@ """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 @@ -8,6 +10,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any +from collections.abc import Iterator logger = logging.getLogger(__name__) @@ -534,3 +537,171 @@ 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: + """Hook called when the plugin is disabled or unloaded.""" diff --git a/src/plugins/loader.py b/src/plugins/loader.py index 5283d94f3..1379271a9 100644 --- a/src/plugins/loader.py +++ b/src/plugins/loader.py @@ -9,15 +9,18 @@ import re import sys from pathlib import Path -from typing import Any +from typing import Any, Union -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 = Union[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,38 @@ 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 +252,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 +315,28 @@ 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 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. module_name = f"plugins.{plugin_name}" - spec = importlib.util.spec_from_file_location(module_name, init_path) + existing = sys.modules.get(module_name) + if existing is not None and getattr(existing, "__file__", None) == str(init_path): + 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 + 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) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) except Exception as e: errors.append(f"Failed to import plugin module: {e}") @@ -303,10 +344,18 @@ 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} " + f"(manifest plugin_type={expected_type!r})" + ) self._load_errors[plugin_name] = errors return None @@ -336,36 +385,34 @@ 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 +434,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 +506,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 +515,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..b21bb7800 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,8 +374,11 @@ 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 def from_dict(cls, data: dict[str, Any]) -> "PluginManifest": """Create PluginManifest from dictionary. @@ -498,6 +536,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 +560,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 +682,37 @@ 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..c2259a822 100644 --- a/src/settings/service.py +++ b/src/settings/service.py @@ -14,13 +14,40 @@ 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: Optional[str]) -> bool: + """Return True if *strategy* is None, a built-in, or a plugin reference. + + Plugin references are accepted as soon as they have a non-empty id; 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) + and len(strategy) > len(TRANSITION_PLUGIN_PREFIX) + ): + return True + return False + @dataclass class TransitionSettings: @@ -659,8 +686,11 @@ 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 " + f"{VALID_STRATEGIES} or 'plugin:'" + ) self._transition.strategy = strategy if step_interval_ms is not ...: diff --git a/src/transitions/__init__.py b/src/transitions/__init__.py new file mode 100644 index 000000000..69457d80d --- /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 TransitionRunner, TransitionResolver, TransitionRunResult + +__all__ = ["TransitionRunner", "TransitionResolver", "TransitionRunResult"] diff --git a/src/transitions/runner.py b/src/transitions/runner.py new file mode 100644 index 000000000..d7d534df0 --- /dev/null +++ b/src/transitions/runner.py @@ -0,0 +1,348 @@ +"""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 dataclasses import dataclass +from threading import Event +from typing import Any, Callable, List, Optional, Tuple + +from ..devices import DEVICE_DIMENSIONS, DeviceDimensions, get_dimensions +from ..plugins.base import TransitionPluginBase + +logger = logging.getLogger(__name__) + + +TransitionResolver = Callable[[str], Optional[TransitionPluginBase]] + + +@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: Optional[Event] = None, + device_type: Optional[str] = None, + from_grid: Optional[List[List[int]]] = None, + config: Optional[dict] = 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, + ) + + # Always finish on to_grid: guarantees the board lands on the + # exact target even if the plugin is buggy or aborted. + 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: Optional[str], + ) -> 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 dt, dims in DEVICE_DIMENSIONS.items(): + 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: Optional[List[List[int]]], + ) -> List[List[int]]: + """Pick a starting grid for the transition. + + Priority: explicit override β†’ cached ``_last_characters`` β†’ + live ``read_current_message()`` β†’ blank grid sized like ``to_grid``. + """ + 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] + + reader = getattr(board_client, "read_current_message", None) + if callable(reader): + try: + live = reader() + except Exception as exc: # pragma: no cover - read failures logged + logger.warning("TransitionRunner: read_current_message failed: %s", exc) + live = None + if isinstance(live, list) and live: + return [list(row) for row in live] + + 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: Optional[Event], + 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[Optional[List[List[int]]], 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..11510bdca 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,9 +789,10 @@ 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: manifest_path = plugin_dir / "manifest.json" if not manifest_path.exists(): 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..a0eef1db4 --- /dev/null +++ b/tests/test_transition_plugin_base.py @@ -0,0 +1,403 @@ +"""Tests for TransitionPluginBase and its manifest / loader integration.""" + +import json +from pathlib import Path +from typing import Any, Dict, Iterator, List + +import pytest + +from src.plugins.base import ( + DEFAULT_TRANSITION_INTERRUPTIBLE, + DEFAULT_TRANSITION_MAX_FRAMES, + DEFAULT_TRANSITION_MAX_RUNTIME_SECONDS, + DEFAULT_TRANSITION_MIN_INTERVAL_MS, + PluginBase, + PluginResult, + 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..b8703bb11 --- /dev/null +++ b/tests/test_transitions_api.py @@ -0,0 +1,238 @@ +"""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 typing import Any, Dict, Iterator, List, Tuple + +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 +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"]) diff --git a/tests/test_transitions_runner.py b/tests/test_transitions_runner.py new file mode 100644 index 000000000..80ecac983 --- /dev/null +++ b/tests/test_transitions_runner.py @@ -0,0 +1,438 @@ +"""Tests for TransitionRunner and BoardClient.render() faΓ§ade.""" + +import threading +import time +from typing import Any, Dict, Iterator, List +from unittest.mock import MagicMock + +import pytest + +from src.board_client import BoardClient, TRANSITION_PLUGIN_PREFIX +from src.plugins.base import TransitionFrame, TransitionPluginBase +from src.transitions.runner import TransitionRunner + + +# --------------------------------------------------------------------------- +# 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): + for f in self._frames: + yield f + + +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): + for g in self._grids: + yield g # 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): + raise RuntimeError("plugin exploded") + yield # noqa: unreachable + + +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, read: List[List[int]] = 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_falls_back_to_read_current_message_when_cache_empty(): + 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=_grid(9)) + runner.run(plugin_id="cap", to_grid=_grid(1), board_client=board) + assert captured[0] == _grid(9) + + +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/web/src/__tests__/transitions-api.test.ts b/web/src/__tests__/transitions-api.test.ts new file mode 100644 index 000000000..3ce3fe203 --- /dev/null +++ b/web/src/__tests__/transitions-api.test.ts @@ -0,0 +1,164 @@ +/** + * 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 { describe, it, expect } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server } from "./mocks/server"; +import { api } from "@/lib/api"; +import type { + TransitionPluginsResponse, + TransitionPreviewResponse, +} from "@/lib/api"; + +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..eaddda32a --- /dev/null +++ b/web/src/app/transitions/page.tsx @@ -0,0 +1,385 @@ +"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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Play, Pause, SkipBack, SkipForward, RotateCcw } from "lucide-react"; + +import { api } from "@/lib/api"; +import type { TransitionPreviewResponse } from "@/lib/api"; + +import { PageHeader } from "@/components/page-header"; +import { PageLayout } from "@/components/page-layout"; +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 { TransitionGridDisplay } from "@/components/transitions/transition-grid-display"; + +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" + /> +
+ +
+ +