17 Commits

Author SHA1 Message Date
42aa6f16cc Merge pull request 'feat/figment: periodic SVG glyph overlays with CLI flag' (#34) from feat/figment into main
Reviewed-on: #34
2026-03-19 20:42:10 +00:00
a25b80d4a6 feat: Enable and configure figment mode via new CLI flags, update documentation, and improve Cairo library detection on macOS. 2026-03-19 13:41:40 -07:00
3a1aa975d1 docs: update README for figment mode
- Add figment mode to intro paragraph
- New Figment Mode section: enabling, assets, trigger protocol, deps
- Architecture table updated with engine/figment_render.py,
  figment_trigger.py, and effects_plugins/ directory breakdown
- Dev setup: separate uv sync --extras entries (mic vs figment)
- Testing section: mentions figment test coverage and Cairo skip
- Roadmap: adds figment follow-up items (CLI flag, intensity wiring,
  ntfy trigger)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:39:58 -07:00
d5e5f39404 test: add pytest.importorskip for cairosvg-dependent tests
Gracefully skips figment tests when system Cairo library is unavailable
instead of crashing with opaque OSError during test collection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
2bfd3a01da style: apply ruff lint fixes and formatting to figment modules
Fixes: unused imports, import sorting, unused variable, overly broad
exception type in test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
4cf316c280 feat(figment): integrate figment overlay into scroll loop
Wire render_figment_overlay() into stream() between the effects chain
and the ntfy message overlay, with optional cairosvg import guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
79d271c42b feat(figment): add render_figment_overlay() to layers.py
Implements phase-aware (REVEAL/HOLD/DISSOLVE) ANSI cursor-positioning overlay
renderer for figment glyphs, with deterministic shuffle seeding and gradient
coloring via _color_codes_to_ansi(). Includes 6 TDD tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
525af4bc46 feat(figment): add FigmentEffect plugin with state machine and timer
Implements the core figment plugin: timer-driven SVG selection, REVEAL →
HOLD → DISSOLVE state machine, trigger API, and get_figment_state() for
overlay rendering. process() is a deliberate no-op; scroll.py will call
get_figment_state() instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
085f150cb0 feat(figment): add SVG to half-block rasterization pipeline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
0b6e2fae74 feat(figment): add trigger protocol and command types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
6864ad84c6 feat(figment): add test fixture SVG and FIGMENT_TRIGGER event type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
acb42ea140 build: add cairosvg optional dependency for figment mode
Also adds DYLD_LIBRARY_PATH to mise.toml for macOS Cairo discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
7014a9d5cd docs: add figment mode TDD implementation plan
8-task plan covering SVG rasterization, overlay rendering,
FigmentEffect plugin, trigger protocol, and scroll loop integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
2cc8dbfc02 docs: address spec review feedback for figment mode
- Rename FigmentPlugin to FigmentEffect (discovery convention)
- Define FigmentState dataclass and FigmentPhase enum
- Clarify chain exclusion (no-op process, not in chain order)
- Add isinstance() downcast for type-safe scroll.py access
- Use FigmentAction enum instead of string literals
- Add Error Handling section (missing deps, empty dir, resize)
- Add Goals, Out of Scope sections
- Split tests per module (test_figment_render, test_figment_trigger)
- Add FIGMENT_TRIGGER to modified files (events.py)
- Document timing formula (progress += FRAME_DT / phase_duration)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
f1d5162488 docs: add figment mode design spec
Hybrid plugin + overlay architecture for periodic SVG glyph display
with theme-aware coloring and extensible input abstraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
9f61226779 Merge pull request 'docs/update-readme' (#33) from docs/update-readme into main
Reviewed-on: #33
2026-03-19 06:30:18 +00:00
9415e18679 docs: use david's most recent README from feature/vector_display branch 2026-03-18 23:29:02 -07:00
12 changed files with 966 additions and 130 deletions

331
README.md
View File

@@ -2,7 +2,34 @@
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh). **Figment mode** overlays flickery, theme-colored SVG glyphs on the running stream at timed intervals — controllable from any input source via an extensible trigger protocol.
---
## Contents
- [Using](#using)
- [Run](#run)
- [Config](#config)
- [Display Modes](#display-modes)
- [Feeds](#feeds)
- [Fonts](#fonts)
- [ntfy.sh](#ntfysh)
- [Figment Mode](#figment-mode)
- [Command & Control](#command--control-cc)
- [Internals](#internals)
- [How it works](#how-it-works)
- [Architecture](#architecture)
- [Development](#development)
- [Setup](#setup)
- [Tasks](#tasks)
- [Testing](#testing)
- [Linting](#linting)
- [Roadmap](#roadmap)
- [Performance](#performance)
- [Graphics](#graphics)
- [Cyberpunk Vibes](#cyberpunk-vibes)
- [Extensibility](#extensibility)
---
@@ -15,6 +42,10 @@ python3 mainline.py # news stream
python3 mainline.py --poetry # literary consciousness mode
python3 mainline.py -p # same
python3 mainline.py --firehose # dense rapid-fire headline mode
python3 mainline.py --figment # enable periodic SVG glyph overlays
python3 mainline.py --figment-interval 30 # figment every 30 seconds (default: 60)
python3 mainline.py --display websocket # web browser display only
python3 mainline.py --display both # terminal + web browser
python3 mainline.py --refresh # force re-fetch (bypass cache)
python3 mainline.py --no-font-picker # skip interactive font picker
python3 mainline.py --font-file path.otf # use a specific font file
@@ -28,7 +59,20 @@ Or with uv:
uv run mainline.py
```
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache. With uv, run `uv sync` or `uv sync --all-extras` (includes mic support) instead.
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
### Command & Control (C&C)
Control mainline remotely using `cmdline.py`:
```bash
uv run cmdline.py # Interactive TUI
uv run cmdline.py /effects list # List all effects
uv run cmdline.py /effects stats # Show performance stats
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
```
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
### Config
@@ -39,20 +83,33 @@ All constants live in `engine/config.py`:
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream for messages |
| `NTFY_CC_CMD_TOPIC` | klubhaus URL | ntfy.sh topic for C&C commands |
| `NTFY_CC_RESP_TOPIC` | klubhaus URL | ntfy.sh topic for C&C responses |
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after dropped SSE |
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
| `FONT_INDEX` | `0` | Face index within a font collection file |
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
| `FONT_PATH` | first file in `FONT_DIR` | Active display font |
| `FONT_PICKER` | `True` | Show interactive font picker at boot |
| `FONT_SZ` | `60` | Font render size (affects block density) |
| `RENDER_H` | `8` | Terminal rows per headline line |
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
| `SSAA` | `4` | Super-sampling factor |
| `SCROLL_DUR` | `5.625` | Seconds per headline |
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) |
### Display Modes
Mainline supports multiple display backends:
- **Terminal** (`--display terminal`): ANSI terminal output (default)
- **WebSocket** (`--display websocket`): Stream to web browser clients
- **Sixel** (`--display sixel`): Sixel graphics in supported terminals (iTerm2, mintty)
- **Both** (`--display both`): Terminal + WebSocket simultaneously
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
### Feeds
@@ -62,30 +119,15 @@ All constants live in `engine/config.py`:
### Fonts
A `fonts/` directory is bundled with demo faces (AgorTechnoDemo, AlphatronDemo, CSBishopDrawn, CubaTechnologyDemo, CyberformDemo, KATA, Microbots, ModernSpaceDemo, Neoform, Pixel Sparta, RaceHugoDemo, Resond, Robocops, Synthetix, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
A `fonts/` directory is bundled with demo faces. On startup, an interactive picker lists all discovered faces with a live half-block preview.
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select.
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
### Color Schemes
Mainline supports three color themes for the scrolling gradient: **Verdant Green**, **Molten Orange**, and **Violet Purple**. Each theme uses a precise color-opposite palette for ntfy message queue rendering (magenta, blue, and yellow respectively).
On startup, an interactive picker presents all available color schemes:
```
[1] Verdant Green (white-hot → deep green)
[2] Molten Orange (white-hot → deep orange)
[3] Violet Purple (white-hot → deep purple)
```
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selection applies only to the current session; you'll pick a fresh theme each run.
**Note:** The boot UI (title, status lines, font picker menu) uses a hardcoded green accent color for visual continuity. Only the scrolling headlines and incoming messages render in the selected theme gradient.
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`.
### ntfy.sh
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen.
To push a message:
@@ -95,6 +137,39 @@ curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
### Figment Mode
Figment mode periodically overlays a full-screen SVG glyph on the running ticker — flickering through a reveal → hold (strobe) → dissolve cycle, colored with a randomly selected theme gradient.
**Enable it** with the `--figment` flag:
```bash
uv run mainline.py --figment # glyph every 60 seconds (default)
uv run mainline.py --figment --figment-interval 30 # every 30 seconds
```
**Figment assets** live in `figments/` — drop any `.svg` file there and it will be picked up automatically. The bundled set contains Mayan and Aztec glyphs. Figments are selected randomly, avoiding immediate repeats, and rasterized into half-block terminal art at display time.
**Triggering manually** — any object with a `poll() -> FigmentCommand | None` method satisfies the `FigmentTrigger` protocol and can be passed to the plugin:
```python
from engine.figment_trigger import FigmentAction, FigmentCommand
class MyTrigger:
def poll(self):
if some_condition:
return FigmentCommand(action=FigmentAction.TRIGGER)
return None
```
Built-in commands: `TRIGGER`, `SET_INTENSITY`, `SET_INTERVAL`, `SET_COLOR`, `STOP`.
**System dependency:** Figment mode requires the Cairo C library (`brew install cairo` on macOS) in addition to the `figment` extras group:
```bash
uv sync --extra figment # adds cairosvg
```
---
## Internals
@@ -108,93 +183,64 @@ Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
- An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
- An ntfy.sh SSE stream runs in a background thread for messages and C&C commands; incoming messages interrupt the scroll and render full-screen until dismissed or expired
- Figment mode rasterizes SVGs via cairosvg → PIL → greyscale → half-block encode, then overlays them with ANSI cursor-positioning commands between the effect chain and the ntfy message layer
### Architecture
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
```
engine/
__init__.py package marker
app.py main(), font picker TUI, boot sequence, signal handler
config.py constants, CLI flags, glyph tables
sources.py FEEDS, POETRY_SOURCES, language/script maps
terminal.py ANSI codes, tw/th, type_out, boot_ln
filter.py HTML stripping, content filter
translate.py Google Translate wrapper + region detection
render.py OTF → half-block pipeline (SSAA, gradient)
effects.py noise, glitch_bar, fade, firehose
fetch.py RSS/Gutenberg fetching + cache load/save
ntfy.py NtfyPoller — standalone, zero internal deps
mic.py MicMonitor — standalone, graceful fallback
scroll.py stream() frame loop + message rendering
viewport.py terminal dimension tracking (tw/th)
frame.py scroll step calculation, timing
layers.py ticker zone, firehose, message overlay rendering
eventbus.py thread-safe event publishing for decoupled communication
events.py event types and definitions
controller.py coordinates ntfy/mic monitoring and event publishing
emitters.py background emitters for ntfy and mic
types.py type definitions and dataclasses
__init__.py package marker
app.py main(), font picker TUI, boot sequence, C&C poller
config.py constants, CLI flags, glyph tables
sources.py FEEDS, POETRY_SOURCES, language/script maps
terminal.py ANSI codes, tw/th, type_out, boot_ln
filter.py HTML stripping, content filter
translate.py Google Translate wrapper + region detection
render.py OTF → half-block pipeline (SSAA, gradient)
effects/ plugin architecture for visual effects
types.py EffectPlugin ABC, EffectConfig, EffectContext
registry.py effect registration and lookup
chain.py effect pipeline chaining
controller.py handles /effects commands
performance.py performance monitoring
legacy.py legacy functional effects
fetch.py RSS/Gutenberg fetching + cache load/save
ntfy.py NtfyPoller — standalone, zero internal deps
mic.py MicMonitor — standalone, graceful fallback
scroll.py stream() frame loop + message rendering
viewport.py terminal dimension tracking (tw/th)
frame.py scroll step calculation, timing
layers.py ticker zone, firehose, message + figment overlay rendering
figment_render.py SVG → cairosvg → PIL → half-block rasterizer with cache
figment_trigger.py FigmentTrigger protocol, FigmentAction enum, FigmentCommand
eventbus.py thread-safe event publishing for decoupled communication
events.py event types and definitions
controller.py coordinates ntfy/mic monitoring and event publishing
emitters.py background emitters for ntfy and mic
types.py type definitions and dataclasses
themes.py THEME_REGISTRY — gradient color definitions
display/ Display backend system
__init__.py DisplayRegistry, get_monitor
backends/
terminal.py ANSI terminal display
websocket.py WebSocket server for browser clients
sixel.py Sixel graphics (pure Python)
null.py headless display for testing
multi.py forwards to multiple displays
benchmark.py performance benchmarking tool
effects_plugins/
__init__.py plugin discovery (ABC issubclass scan)
noise.py NoiseEffect — random character noise
glitch.py GlitchEffect — horizontal glitch bars
fade.py FadeEffect — edge fade zones
firehose.py FirehoseEffect — dense bottom ticker strip
figment.py FigmentEffect — periodic SVG glyph overlay (state machine)
figments/ SVG assets for figment mode
```
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
---
## Extending
`ntfy.py` and `mic.py` are fully standalone and designed to be reused by any terminal visualizer. `engine.render` is the importable rendering pipeline for non-terminal targets.
### NtfyPoller
```python
from engine.ntfy import NtfyPoller
poller = NtfyPoller("https://ntfy.sh/my_topic/json")
poller.start()
# in your render loop:
msg = poller.get_active_message() # → (title, body, timestamp) or None
if msg:
title, body, ts = msg
render_my_message(title, body) # visualizer-specific
```
Dependencies: `urllib.request`, `json`, `threading`, `time` — stdlib only. The `since=` parameter is managed automatically on reconnect.
### MicMonitor
```python
from engine.mic import MicMonitor
mic = MicMonitor(threshold_db=50)
result = mic.start() # None = sounddevice unavailable; False = stream failed; True = ok
if result:
excess = mic.excess # dB above threshold, clamped to 0
db = mic.db # raw RMS dB level
```
Dependencies: `sounddevice`, `numpy` — both optional; degrades gracefully if unavailable.
### Render pipeline
`engine.render` exposes the OTF → raster pipeline independently of the terminal scroll loop. The planned `serve.py` extension will import it directly to pre-render headlines as 1-bit bitmaps for an ESP32 thin client:
```python
# planned — serve.py does not yet exist
from engine.render import render_line, big_wrap
from engine.fetch import fetch_all
headlines = fetch_all()
for h in headlines:
rows = big_wrap(h.text, font, width=800) # list of half-block rows
# threshold to 1-bit, pack bytes, serve over HTTP
```
See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server + thin client architecture.
---
## Development
@@ -204,35 +250,64 @@ See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server +
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
```bash
uv sync # minimal (no mic)
uv sync --all-extras # with mic support (sounddevice + numpy)
uv sync # minimal (no mic, no figment)
uv sync --extra mic # with mic support (sounddevice + numpy)
uv sync --extra figment # with figment mode (cairosvg + system Cairo)
uv sync --all-extras # all optional features
uv sync --all-extras --group dev # full dev environment
```
Figment mode also requires the Cairo C library: `brew install cairo` (macOS).
### Tasks
With [mise](https://mise.jdx.dev/):
```bash
mise run test # run test suite
mise run test-cov # run with coverage report
mise run lint # ruff check
mise run lint-fix # ruff check --fix
mise run format # ruff format
mise run run # uv run mainline.py
mise run run-poetry # uv run mainline.py --poetry
mise run run-firehose # uv run mainline.py --firehose
mise run test-cov # run with coverage report
mise run lint # ruff check
mise run lint-fix # ruff check --fix
mise run format # ruff format
mise run run # terminal display
mise run run-websocket # web display only
mise run run-sixel # sixel graphics
mise run run-both # terminal + web
mise run run-client # both + open browser
mise run cmd # C&C command interface
mise run cmd-stats # watch effects stats
mise run benchmark # run performance benchmarks
mise run benchmark-json # save as JSON
mise run topics-init # initialize ntfy topics
```
### Testing
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, `terminal`, and the full figment pipeline (`figment_render`, `figment_trigger`, `figment`, `figment_overlay`). Figment tests are automatically skipped if Cairo is not installed.
```bash
uv run pytest
uv run pytest --cov=engine --cov-report=term-missing
# Run with mise
mise run test
mise run test-cov
# Run performance benchmarks
mise run benchmark
mise run benchmark-json
# Run benchmark hook mode (for CI)
uv run python -m engine.benchmark --hook
```
Performance regression tests are in `tests/test_benchmark.py` marked with `@pytest.mark.benchmark`.
### Linting
```bash
@@ -247,17 +322,19 @@ Pre-commit hooks run lint automatically via `hk`.
## Roadmap
### Performance
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
- Concurrent feed fetching with ThreadPoolExecutor
- Background feed refresh daemon
- Translation pre-fetch during boot
### Graphics
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
- Matrix rain katakana underlay
- CRT scanline simulation
- Sixel/iTerm2 inline images
- Parallax secondary column
### Cyberpunk Vibes
- **Figment intensity wiring** — `config.intensity` currently stored but not yet applied to reveal/dissolve speed or strobe frequency
- **ntfy figment trigger** — built-in `NtfyFigmentTrigger` that listens on a dedicated topic to fire figments on demand
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
@@ -271,4 +348,4 @@ Pre-commit hooks run lint automatically via `hk`.
---
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.10+.*
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*

200
effects_plugins/figment.py Normal file
View File

@@ -0,0 +1,200 @@
"""
Figment effect plugin — periodic SVG glyph overlay.
Owns the figment lifecycle: timer, SVG selection, state machine.
Delegates rendering to render_figment_overlay() in engine/layers.py.
Named FigmentEffect (not FigmentPlugin) to match the *Effect discovery
convention in effects_plugins/__init__.py.
NOT added to the EffectChain order — process() is a no-op. The overlay
rendering is handled by scroll.py calling get_figment_state().
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from engine import config
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
from engine.figment_render import rasterize_svg
from engine.figment_trigger import FigmentAction, FigmentCommand, FigmentTrigger
from engine.themes import THEME_REGISTRY
class FigmentPhase(Enum):
REVEAL = auto()
HOLD = auto()
DISSOLVE = auto()
@dataclass
class FigmentState:
phase: FigmentPhase
progress: float
rows: list[str]
gradient: list[int]
center_row: int
center_col: int
class FigmentEffect(EffectPlugin):
name = "figment"
config = EffectConfig(
enabled=False,
intensity=1.0,
params={
"interval_secs": 60,
"display_secs": 4.5,
"figment_dir": "figments",
},
)
def __init__(
self,
figment_dir: str | None = None,
triggers: list[FigmentTrigger] | None = None,
):
self.config = EffectConfig(
enabled=False,
intensity=1.0,
params={
"interval_secs": 60,
"display_secs": 4.5,
"figment_dir": figment_dir or "figments",
},
)
self._triggers = triggers or []
self._phase: FigmentPhase | None = None
self._progress: float = 0.0
self._rows: list[str] = []
self._gradient: list[int] = []
self._center_row: int = 0
self._center_col: int = 0
self._timer: float = 0.0
self._last_svg: str | None = None
self._svg_files: list[str] = []
self._scan_svgs()
def _scan_svgs(self) -> None:
figment_dir = Path(self.config.params["figment_dir"])
if figment_dir.is_dir():
self._svg_files = sorted(str(p) for p in figment_dir.glob("*.svg"))
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
return buf
def configure(self, cfg: EffectConfig) -> None:
# Preserve figment_dir if the new config doesn't supply one
figment_dir = cfg.params.get(
"figment_dir", self.config.params.get("figment_dir", "figments")
)
self.config = cfg
if "figment_dir" not in self.config.params:
self.config.params["figment_dir"] = figment_dir
self._scan_svgs()
def trigger(self, w: int, h: int) -> None:
"""Manually trigger a figment display."""
if not self._svg_files:
return
# Pick a random SVG, avoid repeating
candidates = [s for s in self._svg_files if s != self._last_svg]
if not candidates:
candidates = self._svg_files
svg_path = random.choice(candidates)
self._last_svg = svg_path
# Rasterize
try:
self._rows = rasterize_svg(svg_path, w, h)
except Exception:
return
# Pick random theme gradient
theme_key = random.choice(list(THEME_REGISTRY.keys()))
self._gradient = THEME_REGISTRY[theme_key].main_gradient
# Center in viewport
figment_h = len(self._rows)
figment_w = max((len(r) for r in self._rows), default=0)
self._center_row = max(0, (h - figment_h) // 2)
self._center_col = max(0, (w - figment_w) // 2)
# Start reveal phase
self._phase = FigmentPhase.REVEAL
self._progress = 0.0
def get_figment_state(
self, frame_number: int, w: int, h: int
) -> FigmentState | None:
"""Tick the state machine and return current state, or None if idle."""
if not self.config.enabled:
return None
# Poll triggers
for trig in self._triggers:
cmd = trig.poll()
if cmd is not None:
self._handle_command(cmd, w, h)
# Tick timer when idle
if self._phase is None:
self._timer += config.FRAME_DT
interval = self.config.params.get("interval_secs", 60)
if self._timer >= interval:
self._timer = 0.0
self.trigger(w, h)
# Tick animation — snapshot current phase/progress, then advance
if self._phase is not None:
# Capture the state at the start of this frame
current_phase = self._phase
current_progress = self._progress
# Advance for next frame
display_secs = self.config.params.get("display_secs", 4.5)
phase_duration = display_secs / 3.0
self._progress += config.FRAME_DT / phase_duration
if self._progress >= 1.0:
self._progress = 0.0
if self._phase == FigmentPhase.REVEAL:
self._phase = FigmentPhase.HOLD
elif self._phase == FigmentPhase.HOLD:
self._phase = FigmentPhase.DISSOLVE
elif self._phase == FigmentPhase.DISSOLVE:
self._phase = None
return FigmentState(
phase=current_phase,
progress=current_progress,
rows=self._rows,
gradient=self._gradient,
center_row=self._center_row,
center_col=self._center_col,
)
return None
def _handle_command(self, cmd: FigmentCommand, w: int, h: int) -> None:
if cmd.action == FigmentAction.TRIGGER:
self.trigger(w, h)
elif cmd.action == FigmentAction.SET_INTENSITY and isinstance(
cmd.value, (int, float)
):
self.config.intensity = float(cmd.value)
elif cmd.action == FigmentAction.SET_INTERVAL and isinstance(
cmd.value, (int, float)
):
self.config.params["interval_secs"] = float(cmd.value)
elif cmd.action == FigmentAction.SET_COLOR and isinstance(cmd.value, str):
if cmd.value in THEME_REGISTRY:
self._gradient = THEME_REGISTRY[cmd.value].main_gradient
elif cmd.action == FigmentAction.STOP:
self._phase = None
self._progress = 0.0

View File

@@ -413,6 +413,14 @@ def main():
if config.FIREHOSE:
boot_ln("Firehose", "ENGAGED", True)
if config.FIGMENT:
try:
from effects_plugins.figment import FigmentEffect # noqa: F401
boot_ln("Figment", f"ARMED [{config.FIGMENT_INTERVAL}s interval]", True)
except (ImportError, OSError):
boot_ln("Figment", "UNAVAILABLE — run: brew install cairo", False)
time.sleep(0.4)
slow_print(" > STREAMING...\n")
time.sleep(0.2)

View File

@@ -196,6 +196,8 @@ MODE = (
else "news"
)
FIREHOSE = "--firehose" in sys.argv
FIGMENT = "--figment" in sys.argv
FIGMENT_INTERVAL = _arg_int("--figment-interval", 60) # seconds between appearances
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"

90
engine/figment_render.py Normal file
View File

@@ -0,0 +1,90 @@
"""
SVG to half-block terminal art rasterization.
Pipeline: SVG -> cairosvg -> PIL -> greyscale threshold -> half-block encode.
Follows the same pixel-pair approach as engine/render.py for OTF fonts.
"""
from __future__ import annotations
import os
import sys
from io import BytesIO
# cairocffi (used by cairosvg) calls dlopen() to find the Cairo C library.
# On macOS with Homebrew, Cairo lives in /opt/homebrew/lib (Apple Silicon) or
# /usr/local/lib (Intel), which are not in dyld's default search path.
# Setting DYLD_LIBRARY_PATH before the import directs dlopen() to those paths.
if sys.platform == "darwin" and not os.environ.get("DYLD_LIBRARY_PATH"):
for _brew_lib in ("/opt/homebrew/lib", "/usr/local/lib"):
if os.path.exists(os.path.join(_brew_lib, "libcairo.2.dylib")):
os.environ["DYLD_LIBRARY_PATH"] = _brew_lib
break
import cairosvg
from PIL import Image
_cache: dict[tuple[str, int, int], list[str]] = {}
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
"""Convert SVG file to list of half-block terminal rows (uncolored).
Args:
svg_path: Path to SVG file.
width: Target terminal width in columns.
height: Target terminal height in rows.
Returns:
List of strings, one per terminal row, containing block characters.
"""
cache_key = (svg_path, width, height)
if cache_key in _cache:
return _cache[cache_key]
# SVG -> PNG in memory
png_bytes = cairosvg.svg2png(
url=svg_path,
output_width=width,
output_height=height * 2, # 2 pixel rows per terminal row
)
# PNG -> greyscale PIL image
# Composite RGBA onto white background so transparent areas become white (255)
# and drawn pixels retain their luminance values.
img_rgba = Image.open(BytesIO(png_bytes)).convert("RGBA")
img_rgba = img_rgba.resize((width, height * 2), Image.Resampling.LANCZOS)
background = Image.new("RGBA", img_rgba.size, (255, 255, 255, 255))
background.paste(img_rgba, mask=img_rgba.split()[3])
img = background.convert("L")
data = img.tobytes()
pix_w = width
pix_h = height * 2
# White (255) = empty space, dark (< threshold) = filled pixel
threshold = 128
# Half-block encode: walk pixel pairs
rows: list[str] = []
for y in range(0, pix_h, 2):
row: list[str] = []
for x in range(pix_w):
top = data[y * pix_w + x] < threshold
bot = data[(y + 1) * pix_w + x] < threshold if y + 1 < pix_h else False
if top and bot:
row.append("")
elif top:
row.append("")
elif bot:
row.append("")
else:
row.append(" ")
rows.append("".join(row))
_cache[cache_key] = rows
return rows
def clear_cache() -> None:
"""Clear the rasterization cache (e.g., on terminal resize)."""
_cache.clear()

36
engine/figment_trigger.py Normal file
View File

@@ -0,0 +1,36 @@
"""
Figment trigger protocol and command types.
Defines the extensible input abstraction for triggering figment displays
from any control surface (ntfy, MQTT, serial, etc.).
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
class FigmentAction(Enum):
TRIGGER = "trigger"
SET_INTENSITY = "set_intensity"
SET_INTERVAL = "set_interval"
SET_COLOR = "set_color"
STOP = "stop"
@dataclass
class FigmentCommand:
action: FigmentAction
value: float | str | None = None
class FigmentTrigger(Protocol):
"""Protocol for figment trigger sources.
Any input source (ntfy, MQTT, serial) can implement this
to trigger and control figment displays.
"""
def poll(self) -> FigmentCommand | None: ...

View File

@@ -57,9 +57,7 @@ def render_message_overlay(
else:
msg_rows = msg_cache[1]
msg_rows = msg_gradient(
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
)
msg_rows = msg_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
@@ -258,3 +256,101 @@ def get_effect_chain() -> EffectChain | None:
if _effect_chain is None:
init_effects()
return _effect_chain
def render_figment_overlay(
figment_state,
w: int,
h: int,
) -> list[str]:
"""Render figment overlay as ANSI cursor-positioning commands.
Args:
figment_state: FigmentState with phase, progress, rows, gradient, centering.
w: terminal width
h: terminal height
Returns:
List of ANSI strings to append to display buffer.
"""
from engine.render import _color_codes_to_ansi
rows = figment_state.rows
if not rows:
return []
phase = figment_state.phase
progress = figment_state.progress
gradient = figment_state.gradient
center_row = figment_state.center_row
center_col = figment_state.center_col
cols = _color_codes_to_ansi(gradient)
# Build a list of non-space cell positions
cell_positions = []
for r_idx, row in enumerate(rows):
for c_idx, ch in enumerate(row):
if ch != " ":
cell_positions.append((r_idx, c_idx))
n_cells = len(cell_positions)
if n_cells == 0:
return []
# Use a deterministic seed so the reveal/dissolve pattern is stable per-figment
rng = random.Random(hash(tuple(rows[0][:10])) if rows[0] else 42)
shuffled = list(cell_positions)
rng.shuffle(shuffled)
# Phase-dependent visibility
from effects_plugins.figment import FigmentPhase
if phase == FigmentPhase.REVEAL:
visible_count = int(n_cells * progress)
visible = set(shuffled[:visible_count])
elif phase == FigmentPhase.HOLD:
visible = set(cell_positions)
# Strobe: dim some cells periodically
if int(progress * 20) % 3 == 0:
dim_count = int(n_cells * 0.3)
visible -= set(shuffled[:dim_count])
elif phase == FigmentPhase.DISSOLVE:
remaining_count = int(n_cells * (1.0 - progress))
visible = set(shuffled[:remaining_count])
else:
visible = set(cell_positions)
# Build overlay commands
overlay: list[str] = []
n_cols = len(cols)
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
for r_idx, row in enumerate(rows):
scr_row = center_row + r_idx + 1 # 1-indexed
if scr_row < 1 or scr_row > h:
continue
line_buf: list[str] = []
has_content = False
for c_idx, ch in enumerate(row):
scr_col = center_col + c_idx + 1
if scr_col < 1 or scr_col > w:
continue
if ch != " " and (r_idx, c_idx) in visible:
# Apply gradient color
shifted = (c_idx / max(max_x - 1, 1)) % 1.0
idx = min(round(shifted * (n_cols - 1)), n_cols - 1)
line_buf.append(f"{cols[idx]}{ch}{RST}")
has_content = True
else:
line_buf.append(" ")
if has_content:
line_str = "".join(line_buf).rstrip()
if line_str.strip():
overlay.append(f"\033[{scr_row};{center_col + 1}H{line_str}{RST}")
return overlay

View File

@@ -18,6 +18,7 @@ from engine.frame import calculate_scroll_step
from engine.layers import (
apply_glitch,
process_effects,
render_figment_overlay,
render_firehose,
render_message_overlay,
render_ticker_zone,
@@ -53,6 +54,18 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
msg_cache = (None, None)
frame_number = 0
# Figment overlay (optional — requires cairosvg)
figment = None
if config.FIGMENT:
try:
from effects_plugins.figment import FigmentEffect
figment = FigmentEffect()
figment.config.enabled = True
figment.config.params["interval_secs"] = config.FIGMENT_INTERVAL
except (ImportError, OSError):
pass
while True:
if queued >= config.HEADLINE_LIMIT and not active:
break
@@ -123,6 +136,13 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
firehose_buf = render_firehose(items, w, fh, h)
buf.extend(firehose_buf)
# Figment overlay (between effects and ntfy message)
if figment and figment.config.enabled:
figment_state = figment.get_figment_state(frame_number, w, h)
if figment_state is not None:
figment_buf = render_figment_overlay(figment_state, w, h)
buf.extend(figment_buf)
if msg_overlay:
buf.extend(msg_overlay)

151
tests/test_figment.py Normal file
View File

@@ -0,0 +1,151 @@
"""Tests for the FigmentEffect plugin."""
import os
from enum import Enum
import pytest
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
from effects_plugins.figment import FigmentEffect, FigmentPhase, FigmentState
from engine.effects.types import EffectConfig, EffectContext
FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")
FIGMENTS_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
class TestFigmentPhase:
def test_is_enum(self):
assert issubclass(FigmentPhase, Enum)
def test_has_all_phases(self):
assert hasattr(FigmentPhase, "REVEAL")
assert hasattr(FigmentPhase, "HOLD")
assert hasattr(FigmentPhase, "DISSOLVE")
class TestFigmentState:
def test_creation(self):
state = FigmentState(
phase=FigmentPhase.REVEAL,
progress=0.5,
rows=["█▀▄", ""],
gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
center_row=5,
center_col=10,
)
assert state.phase == FigmentPhase.REVEAL
assert state.progress == 0.5
assert len(state.rows) == 2
class TestFigmentEffectInit:
def test_name(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
assert effect.name == "figment"
def test_default_config(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
assert effect.config.enabled is False
assert effect.config.intensity == 1.0
assert effect.config.params["interval_secs"] == 60
assert effect.config.params["display_secs"] == 4.5
def test_process_is_noop(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
buf = ["line1", "line2"]
ctx = EffectContext(
terminal_width=80,
terminal_height=24,
scroll_cam=0,
ticker_height=20,
)
result = effect.process(buf, ctx)
assert result == buf
assert result is buf
def test_configure(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
new_cfg = EffectConfig(enabled=True, intensity=0.5)
effect.configure(new_cfg)
assert effect.config.enabled is True
assert effect.config.intensity == 0.5
class TestFigmentStateMachine:
def test_idle_initially(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
effect.config.enabled = True
state = effect.get_figment_state(0, 80, 24)
# Timer hasn't fired yet, should be None (idle)
assert state is None
def test_trigger_starts_reveal(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
effect.config.enabled = True
effect.trigger(80, 24)
state = effect.get_figment_state(1, 80, 24)
assert state is not None
assert state.phase == FigmentPhase.REVEAL
def test_full_cycle(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
effect.config.enabled = True
effect.config.params["display_secs"] = 0.15 # 3 phases x 0.05s
effect.trigger(40, 20)
# Advance through reveal (30 frames at 0.05s = 1.5s, but we shrunk it)
# With display_secs=0.15, each phase is 0.05s = 1 frame
state = effect.get_figment_state(1, 40, 20)
assert state is not None
assert state.phase == FigmentPhase.REVEAL
# Advance enough frames to get through all phases
for frame in range(2, 100):
state = effect.get_figment_state(frame, 40, 20)
if state is None:
break
# Should have completed the full cycle back to idle
assert state is None
def test_timer_fires_at_interval(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
effect.config.enabled = True
effect.config.params["interval_secs"] = 0.1 # 2 frames at 20fps
# Frame 0: idle
state = effect.get_figment_state(0, 40, 20)
assert state is None
# Advance past interval (0.1s = 2 frames)
state = effect.get_figment_state(1, 40, 20)
state = effect.get_figment_state(2, 40, 20)
state = effect.get_figment_state(3, 40, 20)
# Timer should have fired by now
assert state is not None
class TestFigmentEdgeCases:
def test_empty_figment_dir(self, tmp_path):
effect = FigmentEffect(figment_dir=str(tmp_path))
effect.config.enabled = True
effect.trigger(40, 20)
state = effect.get_figment_state(1, 40, 20)
# No SVGs available — should stay idle
assert state is None
def test_missing_figment_dir(self):
effect = FigmentEffect(figment_dir="/nonexistent/path")
effect.config.enabled = True
effect.trigger(40, 20)
state = effect.get_figment_state(1, 40, 20)
assert state is None
def test_disabled_ignores_trigger(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
effect.config.enabled = False
effect.trigger(80, 24)
state = effect.get_figment_state(1, 80, 24)
assert state is None

View File

@@ -0,0 +1,64 @@
"""Tests for render_figment_overlay in engine.layers."""
import pytest
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
from effects_plugins.figment import FigmentPhase, FigmentState
from engine.layers import render_figment_overlay
def _make_state(phase=FigmentPhase.HOLD, progress=0.5):
return FigmentState(
phase=phase,
progress=progress,
rows=["█▀▄ █", " ▄█▀ ", "█ █"],
gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
center_row=10,
center_col=37,
)
class TestRenderFigmentOverlay:
def test_returns_list_of_strings(self):
state = _make_state()
result = render_figment_overlay(state, 80, 24)
assert isinstance(result, list)
assert all(isinstance(s, str) for s in result)
def test_contains_ansi_positioning(self):
state = _make_state()
result = render_figment_overlay(state, 80, 24)
# Should contain cursor positioning escape codes
assert any("\033[" in s for s in result)
def test_reveal_phase_partial(self):
state = _make_state(phase=FigmentPhase.REVEAL, progress=0.0)
result = render_figment_overlay(state, 80, 24)
# At progress 0.0, very few cells should be visible
# Result should still be a valid list
assert isinstance(result, list)
def test_hold_phase_full(self):
state = _make_state(phase=FigmentPhase.HOLD, progress=0.5)
result = render_figment_overlay(state, 80, 24)
# During hold, content should be present
assert len(result) > 0
def test_dissolve_phase(self):
state = _make_state(phase=FigmentPhase.DISSOLVE, progress=0.9)
result = render_figment_overlay(state, 80, 24)
# At high dissolve progress, most cells are gone
assert isinstance(result, list)
def test_empty_rows(self):
state = FigmentState(
phase=FigmentPhase.HOLD,
progress=0.5,
rows=[],
gradient=[46] * 12,
center_row=0,
center_col=0,
)
result = render_figment_overlay(state, 80, 24)
assert result == []

View File

@@ -0,0 +1,52 @@
"""Tests for engine.figment_render module."""
import os
import pytest
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
from engine.figment_render import rasterize_svg
FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")
class TestRasterizeSvg:
def test_returns_list_of_strings(self):
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
assert isinstance(rows, list)
assert all(isinstance(r, str) for r in rows)
def test_output_height_matches_terminal_height(self):
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
assert len(rows) == 20
def test_output_contains_block_characters(self):
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
all_chars = "".join(rows)
block_chars = {"", "", ""}
assert any(ch in all_chars for ch in block_chars)
def test_different_sizes_produce_different_output(self):
rows_small = rasterize_svg(FIXTURE_SVG, 20, 10)
rows_large = rasterize_svg(FIXTURE_SVG, 80, 40)
assert len(rows_small) == 10
assert len(rows_large) == 40
def test_nonexistent_file_raises(self):
import pytest
with pytest.raises((FileNotFoundError, OSError)):
rasterize_svg("/nonexistent/file.svg", 40, 20)
class TestRasterizeCache:
def test_cache_returns_same_result(self):
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
rows2 = rasterize_svg(FIXTURE_SVG, 40, 20)
assert rows1 == rows2
def test_cache_invalidated_by_size_change(self):
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
rows2 = rasterize_svg(FIXTURE_SVG, 60, 30)
assert len(rows1) != len(rows2)

View File

@@ -0,0 +1,40 @@
"""Tests for engine.figment_trigger module."""
from enum import Enum
from engine.figment_trigger import FigmentAction, FigmentCommand
class TestFigmentAction:
def test_is_enum(self):
assert issubclass(FigmentAction, Enum)
def test_has_trigger(self):
assert FigmentAction.TRIGGER.value == "trigger"
def test_has_set_intensity(self):
assert FigmentAction.SET_INTENSITY.value == "set_intensity"
def test_has_set_interval(self):
assert FigmentAction.SET_INTERVAL.value == "set_interval"
def test_has_set_color(self):
assert FigmentAction.SET_COLOR.value == "set_color"
def test_has_stop(self):
assert FigmentAction.STOP.value == "stop"
class TestFigmentCommand:
def test_trigger_command(self):
cmd = FigmentCommand(action=FigmentAction.TRIGGER)
assert cmd.action == FigmentAction.TRIGGER
assert cmd.value is None
def test_set_intensity_command(self):
cmd = FigmentCommand(action=FigmentAction.SET_INTENSITY, value=0.8)
assert cmd.value == 0.8
def test_set_color_command(self):
cmd = FigmentCommand(action=FigmentAction.SET_COLOR, value="orange")
assert cmd.value == "orange"