← All docs
Ideas

DMX hardware backends — port spec

> Track A. Read README.md first for context.

TagTable's src/main/dmx.js ships five working transmitters. Port each to XOSC's main process behind a single interface. No design rework on the protocols — they're battle-tested. We are translating, not inventing.

The interface

Single file: electron/dmx/transmitter.ts. Each backend implements:

export interface Transmitter {
  readonly id: string;            // user-facing key, "art-net-1", "serial-com3", etc.
  readonly kind: TransmitterKind;
  readonly universeId: string;    // which universe this transmitter sends
  open(): Promise<void>;          // throws on failure
  setUniverse(channels: Buffer): void;  // 512 bytes; backend buffers internally
  flush(): boolean;               // emits one frame; returns false if dropped
  status(): string;               // human-readable for diagnostics row
  close(): void;
}

export type TransmitterKind =
| "art-net" | "sacn" | "open-dmx" | "enttec-pro" | "slow-break";

The five backends — one file each

Keep these small. Each is ~80–150 lines.

electron/dmx/transmitters/artNet.ts

UDP unicast on port 6454. Sequence number byte rolls 1..255. Header Art-Net\0 + opcode 0x5000 + version 14.

  • Tick: 23 ms (~43 Hz).
  • Packet: 18 B header + 512 B data.
  • Buffer the universe via setUniverse(channels); flush() builds + sends one packet.
Port verbatim from tagtable/src/main/dmx.js:97-167.

electron/dmx/transmitters/sacn.ts

E1.31 over UDP multicast at 239.255.<universe-high>.<universe-low>:5568. Full ACN packet (root + framing + DMP layers). CID generated once per process.

  • Tick: 23 ms.
  • Packet: 638 B (16 B + 110 B + 11 B + 513 B).
  • Source name: "XOSC" (TagTable uses "TagTable DMX").
Port verbatim from tagtable/src/main/dmx.js:170-287.

electron/dmx/transmitters/openDmx.ts

FTDI USB serial chips that support the BREAK line via port.set({ brk: true }). 250 kbaud, 8N2.

  • Tick: 33 ms (~30 Hz). The 513-byte frame at 250 kbaud takes ~23 ms wire time; break + drain adds the rest.
  • Frame: 1 B start code (0x00) + 512 B data.
  • Sequence: brk=true → sleep 1 ms → brk=false → write(513) → drain.
Port verbatim from tagtable/src/main/dmx.js:361-386.

electron/dmx/transmitters/enttecPro.ts

ENTTEC USB DMX Pro framed protocol. 57600 baud, 8N1.

  • Tick: 100 ms (~10 Hz). The framed message at 57600 baud is the bottleneck.
  • Frame: [0x7E][0x06][lenLSB][lenMSB][0x00 + 512 B][0xE7].
Port verbatim from tagtable/src/main/dmx.js:388-408.

electron/dmx/transmitters/slowBreak.ts

CH340 / PL2303 adapters that don't expose a real BREAK line. Trick: switch baud to 90 kbaud, write 0x00 (10-bit frame stays low for ~111 µs > the 88 µs DMX BREAK requirement), then switch back to 250 kbaud and write the data frame.

  • Tick: 50 ms (~20 Hz). Baud switching adds overhead.
Port verbatim from tagtable/src/main/dmx.js:411-437.

The DMX engine

electron/dmx/engine.ts owns:

  • Map<universeId, Buffer> — one 512-byte buffer per universe.
  • Map<transmitterId, Transmitter> — one transmitter per (kind, port, universe).
  • One setInterval per transmitter at its native tick rate. The shared 44 Hz loop TagTable uses doesn't fit when transmitters run at different rates; each transmitter pulls its own.
  • A pull from universeBus.snapshot(universeId) on every tick.

// pseudo
function startTransmitter(t: Transmitter) {
const intervalMs = TICK_INTERVALS[t.kind];
const id = setInterval(() => {
if (!t.connected) return;
t.setUniverse(universeBus.snapshot(t.universeId));
const ok = t.flush();
diag.bump(t.id, ok ? "sent" : "dropped");
}, intervalMs);
intervals.set(t.id, id);
}

TICK_INTERVALS table from TagTable (dmx.js:459-465):

const TICK_INTERVALS: Record<TransmitterKind, number> = {
  "art-net":     23,
  "sacn":        23,
  "open-dmx":    33,
  "enttec-pro": 100,
  "slow-break":  50,
};

Configuration shape

Each transmitter is configured by a small object that lives in the store under settings.dmx.transmitters:

interface TransmitterConfig {
  id: string;                       // user-named, "stage-left-bar"
  kind: TransmitterKind;
  universeId: string;               // logical universe id; many transmitters can share
  // serial
  serialPath?: string;              // "COM3" / "/dev/ttyUSB0"
  // network
  ip?: string;                      // unicast for art-net; multicast auto-derived for sacn unless overridden
  port?: number;                    // defaults: 6454 for art-net, 5568 for sacn
  artnetUniverse?: number;          // 0..32767 (Art-Net's universe number, distinct from our universeId)
  sacnUniverse?: number;            // 1..63999
  // common
  enabled: boolean;
  tickIntervalOverride?: number;    // 0 / undefined = auto from TICK_INTERVALS
}

Diagnostics

engine.ts keeps a per-transmitter counter set: framesSent, framesDropped, errors, lastErrorMsg, lastFrameTime, startTime. Computed fps, dropRate, health (good / warning / critical / no_output). Exposed via dmx:status IPC; renderer polls at 1 Hz for the diagnostics row.

Health rules (from TagTable dmx.js:704-718):

  • dropRate > 30% or fps < 5 → critical.
  • dropRate > 10% or fps < 15 → warning.
  • No output yet → no_output.
  • Else → good.

IPC surface

dmx:list-serial-ports          → string[]
dmx:add-transmitter   { cfg }  → { ok, error? }
dmx:remove-transmitter{ id }   → { ok }
dmx:update-transmitter{ id, cfg } → { ok }
dmx:status                     → { transmitters: TransmitterDiag[], universes: Array<{ id, channelMonitor: number[16] }> }
dmx:test-pattern      { universeId } → starts the R/G/B/W cycle on universe channel 1-3 (best-effort)
dmx:test-pattern-stop          → stops it
dmx:write-channel    { universeId, channel, value }  // bypass for test/debug

dmx:status polled by the diagnostics row. A push channel dmx:event exists for one-shot debug entries (open/close/error) so the renderer's debug log shows them without polling.

What you'll write

electron/dmx/
├── engine.ts                     # DmxEngine class, owns universes + transmitters + diag
├── transmitter.ts                # interface + TICK_INTERVALS
├── transmitters/
│   ├── artNet.ts                 # ~120 lines
│   ├── sacn.ts                   # ~150 lines
│   ├── openDmx.ts                # ~100 lines
│   ├── enttecPro.ts              # ~80 lines
│   └── slowBreak.ts              # ~110 lines
├── universeBus.ts                # the shared bus the contract spec references
└── diag.ts                       # plain counter set per transmitter

electron/main.ts registers registerDmxHandlers(ipc, log, getWin) and disposes via disposeDmx(). Same pattern as peer-discovery.ts and osc-listener.ts.

Don't do

  • Don't write the mixer. That's Track B. Until B lands, universeBus.write is last-write-wins; that is fine for graph-only DMX.
  • Don't write fixture profiles inline in transmitters. That's 02-fixtures.md.
  • Don't change serialport's top-level singleton — share with the existing electron/serial-bridge.ts only if a port truly clashes (DMX adapters live on different COM ports than Arduino-style serial, in practice).
  • Don't import the renderer-side store from main. Configuration travels via IPC config:save only.