232 lines
6.2 KiB
Markdown
232 lines
6.2 KiB
Markdown
# OpenSpec Change: enterprise-dmx
|
||
|
||
- **Change ID**: enterprise-dmx
|
||
- **Package**: `@suaveplan/dmx`
|
||
- **Category**: universal
|
||
- **Target path**: `universal/media/dmx`
|
||
- **Status**: PROPOSED
|
||
- **Date**: 2026-04-24
|
||
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/dmx.md`
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
Deliver `@suaveplan/dmx` on the genesis branch from scratch at enterprise quality. The package is a DMX512 lighting control engine: universe management (512 channels per universe), fixture definition and addressing, scene/chase/cue building, fade interpolation with injectable clock, transport-agnostic output (serial, WebSocket, UDP/Art-Net), and a chase sequencer with BPM clock sync. Pure TypeScript with no platform hard deps — transport is an injectable adapter.
|
||
|
||
---
|
||
|
||
## Current-state audit (genesis)
|
||
|
||
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data. The main-branch package had 99.28% coverage.
|
||
|
||
---
|
||
|
||
## Available workspace deps on genesis
|
||
|
||
| Package | Role |
|
||
|---|---|
|
||
| `@suaveplan/error` (`workspace:^`) | Base class for `DmxError`; `DMX.*` codes |
|
||
| `@suaveplan/types` (`workspace:^`) | `LoggerLike`, `Clock` injectable clock |
|
||
| `@suaveplan/events` (`workspace:^`) | Event bus for universe-update notifications |
|
||
| `zod` (`catalog:forms`) | Zod schemas for all public option types |
|
||
| `@opentelemetry/api` (`catalog:observability`) | Spans + counters |
|
||
|
||
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
|
||
|
||
---
|
||
|
||
## Public API
|
||
|
||
### Core types
|
||
|
||
```ts
|
||
class DmxError extends SuaveplanError
|
||
|
||
const DMX_UNIVERSE_SIZE = 512
|
||
const DMX_MIN_VALUE = 0
|
||
const DMX_MAX_VALUE = 255
|
||
|
||
type DmxChannel = number // 1–512
|
||
type DmxValue = number // 0–255
|
||
type DmxUniverse = Uint8Array // length 512
|
||
|
||
interface FixtureDefinition {
|
||
name: string
|
||
channelCount: number
|
||
channels: FixtureChannel[]
|
||
}
|
||
|
||
interface FixtureChannel {
|
||
offset: number // 0-based offset within fixture
|
||
name: string
|
||
defaultValue?: DmxValue
|
||
}
|
||
|
||
interface FixtureInstance {
|
||
id: string
|
||
definition: FixtureDefinition
|
||
startChannel: DmxChannel
|
||
universeId: string
|
||
}
|
||
|
||
interface Scene {
|
||
id: string
|
||
values: Map<string, DmxValue> // fixtureId:channelName → value
|
||
}
|
||
|
||
interface FadeOptions {
|
||
durationMs: number
|
||
easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out'
|
||
}
|
||
|
||
interface DmxTransport {
|
||
send(universe: DmxUniverse, universeId: string): Promise<void>
|
||
connect?(): Promise<void>
|
||
disconnect?(): Promise<void>
|
||
}
|
||
|
||
interface DmxEngineOptions {
|
||
transport: DmxTransport
|
||
clock?: Clock
|
||
logger?: LoggerLike
|
||
refreshRateHz?: number // default: 40 (25ms frame)
|
||
}
|
||
|
||
class DmxEngine {
|
||
constructor(options: DmxEngineOptions)
|
||
addUniverse(id: string): void
|
||
removeUniverse(id: string): void
|
||
addFixture(instance: FixtureInstance): void
|
||
removeFixture(id: string): void
|
||
setChannel(universeId: string, channel: DmxChannel, value: DmxValue): void
|
||
getChannel(universeId: string, channel: DmxChannel): DmxValue
|
||
getUniverse(id: string): Readonly<DmxUniverse>
|
||
applyScene(scene: Scene, fade?: FadeOptions): Promise<void>
|
||
blackout(universeId?: string): void
|
||
start(): void
|
||
stop(): void
|
||
}
|
||
|
||
function createDmxEngine(options: DmxEngineOptions): DmxEngine
|
||
function buildScene(id: string, values: Record<string, DmxValue>): Scene
|
||
function interpolate(from: DmxValue, to: DmxValue, t: number): DmxValue
|
||
```
|
||
|
||
### Chase sequencer
|
||
|
||
```ts
|
||
interface CueStep {
|
||
scene: Scene
|
||
holdMs: number
|
||
fade?: FadeOptions
|
||
}
|
||
|
||
class ChaseSequencer {
|
||
constructor(engine: DmxEngine, clock: Clock)
|
||
addStep(step: CueStep): void
|
||
start(looping?: boolean): void
|
||
stop(): void
|
||
jumpToStep(index: number): void
|
||
readonly currentStep: number
|
||
readonly running: boolean
|
||
}
|
||
```
|
||
|
||
### Error codes
|
||
|
||
```ts
|
||
type DmxErrorCode =
|
||
| 'DMX_UNIVERSE_NOT_FOUND'
|
||
| 'DMX_FIXTURE_NOT_FOUND'
|
||
| 'DMX_CHANNEL_OUT_OF_RANGE'
|
||
| 'DMX_VALUE_OUT_OF_RANGE'
|
||
| 'DMX_TRANSPORT_FAILED'
|
||
| 'DMX_ADDRESS_CONFLICT'
|
||
|
||
const DMX_ERROR_METADATA: Readonly<Record<DmxErrorCode, CodeMetadata>>
|
||
function registerDmxErrorCodes(): void
|
||
```
|
||
|
||
### Zod schemas
|
||
|
||
```ts
|
||
FixtureDefinitionSchema
|
||
FixtureInstanceSchema
|
||
SceneSchema
|
||
FadeOptionsSchema
|
||
DmxEngineOptionsSchema
|
||
CueStepSchema
|
||
```
|
||
|
||
---
|
||
|
||
## New additions for genesis
|
||
|
||
1. **`DmxTransport` interface** — injectable; `NullTransport` ships for tests (captures send calls).
|
||
2. **`ChaseSequencer`** — BPM-based step timing; uses injectable `Clock`; emits events via `@suaveplan/events`.
|
||
3. **Fade interpolation** — four easing functions; frame-rate controlled by `refreshRateHz`.
|
||
4. **`blackout`** — sets all channels in one or all universes to 0 immediately, bypassing any active fade.
|
||
5. **Address-conflict detection** — `addFixture` throws `DmxError(DMX_ADDRESS_CONFLICT)` if channel range overlaps an existing fixture in the same universe.
|
||
6. **OTel instrumentation** — counter `dmx.frame_total`, histogram `dmx.frame_render_ms`, counter `dmx.scene_apply_total`.
|
||
7. **Property-based tests** — channel value clamping invariant, fade interpolation monotonicity, scene application round-trip.
|
||
8. **Benchmark suite** — single-universe 512-channel render loop, 8-fixture scene apply.
|
||
|
||
---
|
||
|
||
## File layout
|
||
|
||
```text
|
||
universal/media/dmx/
|
||
package.json
|
||
tsconfig.json
|
||
vitest.config.ts
|
||
src/
|
||
index.ts
|
||
error-codes.ts
|
||
constants/
|
||
index.ts
|
||
types/
|
||
common.ts
|
||
common.md
|
||
core/
|
||
dmx-engine/
|
||
dmx-engine.ts
|
||
dmx-engine.md
|
||
dmx-engine.types.ts
|
||
dmx-engine.test.ts
|
||
index.ts
|
||
chase-sequencer/
|
||
chase-sequencer.ts
|
||
chase-sequencer.md
|
||
chase-sequencer.types.ts
|
||
chase-sequencer.test.ts
|
||
index.ts
|
||
transports/
|
||
null-transport/
|
||
null-transport.ts
|
||
null-transport.md
|
||
null-transport.test.ts
|
||
index.ts
|
||
utils/
|
||
interpolate.ts
|
||
interpolate.md
|
||
interpolate.test.ts
|
||
scene.ts
|
||
scene.md
|
||
scene.test.ts
|
||
__benchmarks__/
|
||
dmx.bench.ts
|
||
tests/
|
||
integration.test.ts
|
||
property.test.ts
|
||
```
|
||
|
||
---
|
||
|
||
## Impact
|
||
|
||
**Downstream**: `@suaveplan/r3f-dmx` (React layer), any live-event lighting integrations.
|
||
**Breaking changes**: none — fresh build.
|
||
**Prerequisites**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events` published to Verdaccio.
|