6.2 KiB
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
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
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
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
FixtureDefinitionSchema
FixtureInstanceSchema
SceneSchema
FadeOptionsSchema
DmxEngineOptionsSchema
CueStepSchema
New additions for genesis
DmxTransportinterface — injectable;NullTransportships for tests (captures send calls).ChaseSequencer— BPM-based step timing; uses injectableClock; emits events via@suaveplan/events.- Fade interpolation — four easing functions; frame-rate controlled by
refreshRateHz. blackout— sets all channels in one or all universes to 0 immediately, bypassing any active fade.- Address-conflict detection —
addFixturethrowsDmxError(DMX_ADDRESS_CONFLICT)if channel range overlaps an existing fixture in the same universe. - OTel instrumentation — counter
dmx.frame_total, histogramdmx.frame_render_ms, counterdmx.scene_apply_total. - Property-based tests — channel value clamping invariant, fade interpolation monotonicity, scene application round-trip.
- Benchmark suite — single-universe 512-channel render loop, 8-fixture scene apply.
File layout
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.