- name
- daemon-development
- description
- This skill should be used when working on the Hypercolor daemon, REST API, WebSocket protocol, or render pipeline. Triggers on "daemon route", "API endpoint", "WebSocket channel", "event bus", "render pipeline", "device lifecycle", "AppState", "adaptive FPS", "HypercolorBus", "backend manager", "daemon config", "MCP tool", "daemon startup", or any work in crates/hypercolor-daemon/ or crates/hypercolor-core/src/engine/.
# Hypercolor Daemon Development
The daemon (`hypercolor-daemon`) serves the REST API, WebSocket protocol, and orchestrates the render pipeline. Runs on `127.0.0.1:9420`.
## AppState
`AppState` holds 47 fields (many `Arc`-wrapped), shared with Axum handlers via the `State<Arc<AppState>>` extractor. The first field is the one that matters most:
- `domains: DomainContexts` — the complete domain service graph; every mutating handler goes through it
- `event_bus: Arc<HypercolorBus>` — system-wide event bus (broadcast + watch lanes)
- `backend_manager: Arc<Mutex<BackendManager>>` — device output routing
- `config_manager: Option<Arc<ConfigManager>>` — wraps `ArcSwap<HypercolorConfig>` for lock-free reads; `None` in tests
- `device_registry: DeviceRegistry` — device tracking (internally `Arc`-wrapped, cloneable)
- `scene_manager: SceneService` — scene CRUD, priority stack, transitions
- `render_loop: Arc<RwLock<RenderLoop>>` — frame timing and pipeline skeleton
- `spatial_engine: SpatialService` — maps canvas pixels to LED positions; an `ArcSwap` newtype, so reads are lock-free
- `output_power: OutputPower` — the canonical global power and brightness authority
- `scene_transactions: SceneTransactionQueue` — frame-boundary scene changes mirrored into the render thread
- `security_state: SecurityState` — API auth and rate limiting for HTTP and WS command dispatch
- `extensions` / `api_extensions` — typed state and route mounters owned by downstream daemon extensions
`library_store` and `input_manager` are private; reach them through `state.library_store()` and `state.input_manager()`. There is no `effect_engine`, no `effect_registry`, no `credential_store`, and no `power_state` field: the effect catalog lives inside `domains.effects`, and power lives in `output_power`.
**Why Mutex on BackendManager?** It holds `dyn` trait objects that aren't `Sync`. `RwLock` requires `Sync` on the inner type.
## Domain Services
`AppState::domains` (`src/domain/context.rs`) is the composition root for eleven authorities, each owning its own commit ordering, persistence, and event publication:
`runtime_session`, `devices`, `scene`, `layout`, `output`, `platform`, `display`, `diagnostics`, `effects`, `scene_tree`, `scene_library`.
A handler is a thin adapter: convert wire input, call one service function, wrap the outcome. It does not lock a subsystem mutex, and event publication is the service's job in almost every domain, because the service knows when the commit actually landed. The config routes follow the same rule: `ConfigManager` publishes `ConfigChanged` once a save lands, so `api/config.rs` never publishes on its own. Mutations take their provenance beside the command as a `MutationContext` (`MutationContext::api()` for REST, `MutationContext::mcp()` for MCP), never inside the command payload.
```rust
// src/api/output.rs (both handlers in full)
pub async fn get_output(State(state): State<Arc<AppState>>) -> Response {
envelope::ok(domain::output::get_output(&state.domains.output))
}
pub async fn patch_output(
State(state): State<Arc<AppState>>,
Json(request): Json<OutputPatchRequest>,
) -> Response {
match domain::output::patch_output(&state.domains.output, request).await {
Ok(outcome) => envelope::ok(outcome.output),
Err(error) => error.into_response(),
}
}
```
Services return `Result<T, DomainError>` and never mention Axum, `Response`, or an HTTP status. Each transport owns exactly one `DomainError` projection, so the same service backs REST, MCP, WS commands, and the CLI.
## REST API Patterns
All routes under `/api/v1/`. Request and response contracts live in `hypercolor_types::api` (seventeen domain modules plus `envelope`), shared by the daemon, the web UI, the TUI, the CLI, and the generated Python client. Never hand-mirror one of these types in the daemon.
```rust
// hypercolor_types::api::envelope
pub struct ApiResponse<T> {
pub data: T,
pub meta: ResponseMeta,
}
pub struct ResponseMeta {
pub api_version: String, // "1.0"
pub request_id: String, // "req_{uuid_v7}"
pub timestamp: String, // ISO 8601 UTC, e.g. "2026-03-29T12:00:00.000Z"
}
```
Handlers wrap payloads with the free functions in `crate::api::envelope`: `envelope::ok(data)` (200), `envelope::created(data)` (201), `envelope::accepted(data)` (202). They return `Response`, not `Json<...>`.
Most list routes answer `ListResponse<T> { items, total, page }`, where `page: Option<PageInfo>` is present only on routes that genuinely page. Domain aliases name the concrete shape, so `EffectListResponse` is `ListResponse<EffectSummary>`. Five list routes do not: `/displays`, `/capture/monitors`, `/config/schema`, and `/simulators/displays` put a bare JSON array in `data` (they are declared with `OperationDoc::get_vec` rather than `get_list`), and `/control-surfaces` answers `ControlSurfaceListResponse { surfaces }`. Check the route's `OperationDoc` before assuming the envelope shape.
Errors have exactly one REST rendering: the `IntoResponse` impl on `DomainError` in `src/api/error.rs`. The error type itself lives in `src/domain/mod.rs` and stays transport free, because each transport owns its own projection. A handler builds the variant that describes the failure and calls `.into_response()`; a helper returns `Result<T, DomainError>` so the route can `?` it or match on it. Constructors read like the failure: `DomainError::not_found(ResourceKind::Scene, id)`, `validation(msg)`, `validation_field(field, msg)`, `malformed(msg)`, `conflict(msg)`, `unauthorized(msg)`, `forbidden(msg)`, `unsupported_media_type(msg)`. Never hand-build an error body and never hand a `Response` back through a `Result`; `tests/api_error_surface_tests.rs` scans every file under `src/` for both and fails the build.
The error envelope is `{ error: { code, message, details }, meta }`, where `details` is omitted entirely when the variant carries no structured context. See `references/api-patterns.md` for the full variant, code, and status table.
Key route groups (path parameters use `{id}` Axum syntax, not `:id`):
| Prefix | Purpose |
| --------------------------- | ------------------------------------------------------- |
| `/effects` | Effect catalog, detail, apply, install, rescan |
| `/effects/{id}/apply` | Replace one live zone stack with an effect |
| `/scene` | Read or patch the complete live scene document |
| `/scene/zones` | Live zone, member, layout, layer, and control mutations |
| `/devices` | Connected devices, discovery, pairing, segments |
| `/devices/{id}/attachments` | Embedded attachment bindings and validation |
| `/attachments/templates` | Attachment template collection |
| `/scenes` | Scene CRUD + snapshot + `{id}/activate` |
| `/library/favorites` | Favorites CRUD |
| `/library/presets` | User preset CRUD |
| `/library/playlists` | Playlist CRUD + activate/deactivate |
| `/layouts` | Spatial layout CRUD + active + preview + `{id}/apply` |
| `/config` | Show/get/set/reset system config values |
| `/output` | Global output power and brightness get/patch |
| `/system` | Public identity, authorized status, audio devices, sensors |
| `/diagnose` | System diagnostics |
| `/assets` | User media asset CRUD plus `{id}/blob` and `{id}/thumbnail` |
| `/capture` | Screen-recording authorization, monitor list, source selection |
| `/input/authorize` | Input-monitoring permission grant |
| `/media/authorize` | Media Automation permission grant |
| `/control-surfaces` | Control surface catalog, value patch, action invocation |
| `/displays` | Display devices, face assign/composition/controls, frame |
| `/drivers` | Driver module catalog, config, controls |
| `/simulators/displays` | Virtual display simulator CRUD plus `{id}/frame` |
`/health` sits at the root, outside `/api/v1`.
**Route registration.** Routes never register with a bare `.route()`. Each domain owns a sub-router in `src/api/routes/<domain>.rs` exporting `pub(super) fn router() -> OpenApiRouter<Arc<AppState>>`, and every entry goes through `openapi::documented_route(path, method_router, [OperationDoc..])` so the operation lands in the OpenAPI document. `routes::versioned()` merges all eighteen sub-routers. See `references/api-patterns.md` for the full pattern and the gate that enforces it.
**Security.** One `enforce_security` middleware layer (`src/api/security.rs`) wraps the whole router with a `SecurityState` carrying auth config, the macOS session credential, the network access policy, and a rate limiter. Exactly one serving `SecurityState` is minted per process in `api::build_state`; every other `AppState` is a worker projection holding `SecurityState::unserved`. When a UI directory is configured, a `StaticAssetSurface` tells the middleware which prefixes belong to the API so a browser fetching a stylesheet is not asked for a bearer header. CORS origins come from `WebConfig` and apply only while security is enabled.
**Extensions.** Downstream builds layer on without patching the daemon: `AppState::extensions` (an `ExtensionRegistry`) holds their typed state, and each `Arc<dyn ApiExtension>` in `AppState::api_extensions` gets `mount_api_routes(router)` called on the `OpenApiRouter` before it is split, so extension routes are documented like any other. Extensions push their own state changes as `HypercolorEvent::ExtensionStateChanged`, which the WS event relay carries with no daemon changes.
## WebSocket Protocol
Single endpoint at `/api/v1/ws`. All fifteen topics are declared in one `define_ws_topics!` block in `hypercolor-leptos-ext/src/ws/registry.rs`, which is the single definition of the wire format for the daemon, the web UI, and the TUI. Never hand-roll a frame layout.
| Topic | Data | Binary tags | Backpressure |
| --------------------- | ---------------------------------------------------- | ------------------ | --------------- |
| `events` | State changes (effect applied, device connected) | JSON | Lossless |
| `frame_events` | `FrameRendered` events, split off the default channel | JSON | Lossless |
| `input_events` | Host `InputEventReceived` events; control-authorized because they carry keystroke data | JSON | Lossless |
| `metrics` | Performance telemetry (FPS, frame times) | JSON | DropWithNotice |
| `device_metrics` | Per-device metrics snapshots | JSON | DropWithNotice |
| `sensors` | System sensor readings | JSON | LatestWins |
| `frames` | LED color output per device | `0x01` | DropWithNotice |
| `spectrum` | Audio analysis (FFT, beats) | `0x02` | DropWithNotice |
| `canvas` | Render canvas pixels (default 640x480, configurable) | `0x03` | LatestWins |
| `screen_canvas` | Screen-source canvas | `0x05` | LatestWins |
| `web_viewport_canvas` | Web-viewport preview canvas | `0x06` | LatestWins |
| `display_preview` | Per-display preview frames (keyed by device) | `0x07`, `0x12` | LatestWins |
| `zone_preview` | Per-zone preview frames | `0x08`, `0x0c` | LatestWins |
| `screen_zones` | Ambilight screen-zone frames | `0x09`, `0x0e`, `0x11` | LatestWins |
| `interactive_preview` | Interactive preview lane (keyed by preview id) | `0x0a`, `0x0d` | LatestWins |
`0x0b` is the wide passive-preview frame four topics share, `0x0f` and `0x10` are the chunk and cancellation envelopes every preview stream rides, and `0x04` is deliberately unassigned. A single WS message may not exceed `MAX_WS_MESSAGE_BYTES` (1 MiB).
**Subscribe on connect:**
```json
{
"type": "subscribe",
"topics": [{ "topic": "events" }, { "topic": "metrics" }]
}
```
**Client messages** are more than subscribe. The full vocabulary in `src/api/ws/protocol.rs` is `subscribe`, `unsubscribe`, `command` (`{ id, method, path, body }`, REST-equivalent execution over the socket), `zone_layout_preview`, `zone_layout_preview_clear`, `input_inject`, `interactive_preview_claim_authoritative`, and `interactive_preview_release_authoritative`.
**Backpressure**: Slow consumers get dropped frames, not memory growth. The WS handler sends a `Backpressure` server message (JSON) with `dropped_frames`, `topic`, `recommendation: "reduce_fps"`, and `suggested_fps` so the UI can auto-throttle.
**Keyed topics**: `display_preview` (keyed by device) and `interactive_preview` (keyed by the client's preview id) hold one subscription per key, so a selector names both the topic and its key. Subscribing to `interactive_preview` is what opens its render lane.
## Event Bus (HypercolorBus)
Two communication patterns on the bus, all lock-free:
| Pattern | Channel | Use |
| -------------------------- | ------------------------ | -------------------------------------------------------- |
| `broadcast` (256 capacity) | `tokio::sync::broadcast` | Discrete state changes (`HypercolorEvent` variants) |
View on GitHub