@ecopoesis/homebridge-dmx 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +4 -3
  2. package/config.schema.json +55 -7
  3. package/dist/color/hsvcct.d.ts.map +1 -1
  4. package/dist/color/hsvcct.js +42 -0
  5. package/dist/color/hsvcct.js.map +1 -1
  6. package/dist/config.d.ts +5 -1
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +12 -3
  9. package/dist/config.js.map +1 -1
  10. package/dist/controller.d.ts +2 -1
  11. package/dist/controller.d.ts.map +1 -1
  12. package/dist/controller.js.map +1 -1
  13. package/dist/dmxController.d.ts +11 -0
  14. package/dist/dmxController.d.ts.map +1 -0
  15. package/dist/dmxController.js +4 -0
  16. package/dist/dmxController.js.map +1 -0
  17. package/dist/ethergate.d.ts +5 -0
  18. package/dist/ethergate.d.ts.map +1 -0
  19. package/dist/ethergate.js +50 -0
  20. package/dist/ethergate.js.map +1 -0
  21. package/dist/platform.d.ts +8 -0
  22. package/dist/platform.d.ts.map +1 -1
  23. package/dist/platform.js +63 -1
  24. package/dist/platform.js.map +1 -1
  25. package/dist/platformAccessory.d.ts +2 -2
  26. package/dist/platformAccessory.d.ts.map +1 -1
  27. package/dist/platformAccessory.js.map +1 -1
  28. package/dist/sacn.d.ts +33 -0
  29. package/dist/sacn.d.ts.map +1 -0
  30. package/dist/sacn.js +147 -0
  31. package/dist/sacn.js.map +1 -0
  32. package/dist/settings.d.ts +14 -0
  33. package/dist/settings.d.ts.map +1 -1
  34. package/dist/settings.js +15 -0
  35. package/dist/settings.js.map +1 -1
  36. package/dist/zoneAccessory.d.ts +2 -2
  37. package/dist/zoneAccessory.d.ts.map +1 -1
  38. package/dist/zoneAccessory.js.map +1 -1
  39. package/examples/dmx.yaml +15 -4
  40. package/package.json +1 -1
  41. package/src/color/hsvcct.ts +36 -0
  42. package/src/config.ts +18 -5
  43. package/src/controller.ts +2 -1
  44. package/src/dmxController.ts +14 -0
  45. package/src/ethergate.ts +51 -0
  46. package/src/platform.ts +66 -4
  47. package/src/platformAccessory.ts +2 -2
  48. package/src/sacn.ts +154 -0
  49. package/src/settings.ts +21 -0
  50. package/src/zoneAccessory.ts +2 -2
package/src/platform.ts CHANGED
@@ -14,8 +14,11 @@ import type {
14
14
  Service,
15
15
  } from 'homebridge';
16
16
 
17
- import { Controller, LoadedConfig, loadConfig, RawConfig } from './config.js';
17
+ import { Controller, Fixture, LoadedConfig, loadConfig, RawConfig } from './config.js';
18
18
  import { StickController } from './controller.js';
19
+ import { DmxController } from './dmxController.js';
20
+ import { readEthergateUniverse } from './ethergate.js';
21
+ import { SacnController } from './sacn.js';
19
22
  import { StickFixture } from './platformAccessory.js';
20
23
  import { StateRegistry } from './stateRegistry.js';
21
24
  import { ZoneFixture } from './zoneAccessory.js';
@@ -27,8 +30,9 @@ export class DmxPlatform implements DynamicPlatformPlugin {
27
30
  public readonly accessories: PlatformAccessory[] = [];
28
31
 
29
32
  private cfg: LoadedConfig | null = null;
30
- private controllers = new Map<string, StickController>();
33
+ private controllers = new Map<string, DmxController>();
31
34
  private registry = new StateRegistry();
35
+ private statePollTimers: NodeJS.Timeout[] = [];
32
36
 
33
37
  constructor(
34
38
  public readonly log: Logger,
@@ -59,16 +63,74 @@ export class DmxPlatform implements DynamicPlatformPlugin {
59
63
  `${this.cfg.zones.length} zone${this.cfg.zones.length === 1 ? '' : 's'}`,
60
64
  );
61
65
 
62
- this.api.on('didFinishLaunching', () => this.discoverDevices());
66
+ this.api.on('didFinishLaunching', () => {
67
+ this.discoverDevices();
68
+ this.startStateSync();
69
+ });
63
70
  this.api.on('shutdown', () => {
71
+ for (const t of this.statePollTimers) clearInterval(t);
64
72
  for (const c of this.controllers.values()) c.shutdown();
65
73
  });
66
74
  }
67
75
 
68
- private makeController(c: Controller): StickController {
76
+ /** State read-back (sACN/Ethergate only): the gateway's live output
77
+ * buffers are ground truth for what's on the wire, so seed the registry
78
+ * from them at startup — a Homebridge restart inherits reality instead
79
+ * of assuming darkness — and optionally re-poll every statePollSeconds
80
+ * to track changes made by other senders. */
81
+ private startStateSync(): void {
82
+ if (!this.cfg) return;
83
+ for (const spec of this.cfg.controllers) {
84
+ if (spec.type !== 'sACN') continue;
85
+ const ctrl = this.controllers.get(spec.id);
86
+ if (!(ctrl instanceof SacnController)) continue;
87
+ const fixtures = this.cfg.fixtures.filter(
88
+ (f) => f.controller.id === spec.id && f.profile.model.parse);
89
+ if (fixtures.length === 0) continue;
90
+
91
+ const sync = async (): Promise<void> => {
92
+ // Never clobber an in-flight HomeKit change with a read that may
93
+ // pre-date it.
94
+ if (Date.now() - ctrl.lastChangeAt < 5_000) return;
95
+ const universes = [...new Set(fixtures.map((f) => f.universe))];
96
+ for (const u of universes) {
97
+ const slots = await readEthergateUniverse(spec.ip, u);
98
+ if (!slots) {
99
+ this.log.debug(`state sync: universe ${u + 1} read failed (non-Ethergate gateway?)`);
100
+ continue;
101
+ }
102
+ for (const fx of fixtures) {
103
+ if (fx.universe !== u) continue;
104
+ this.applyReadBack(fx, slots);
105
+ }
106
+ }
107
+ };
108
+
109
+ sync().then(() => this.log.info(
110
+ `state sync: seeded ${fixtures.length} fixtures from ${spec.ip}`));
111
+ if (spec.statePollSeconds > 0) {
112
+ const t = setInterval(() => void sync(), spec.statePollSeconds * 1000);
113
+ t.unref?.();
114
+ this.statePollTimers.push(t);
115
+ }
116
+ }
117
+ }
118
+
119
+ private applyReadBack(fx: Fixture, slots: Uint8Array): void {
120
+ const bytes = slots.slice(fx.startCh - 1, fx.startCh - 1 + fx.nChannels);
121
+ const state = fx.profile.model.parse!(bytes, fx.profile.channels);
122
+ const cur = this.registry.get(fx.id);
123
+ if (JSON.stringify(cur) !== JSON.stringify(state)) {
124
+ this.registry.set(fx.id, state);
125
+ }
126
+ }
127
+
128
+ private makeController(c: Controller): DmxController {
69
129
  switch (c.type) {
70
130
  case 'StickDE3':
71
131
  return new StickController(c.ip, this.log, DEBOUNCE_MS, c.refreshMinutes * 60_000);
132
+ case 'sACN':
133
+ return new SacnController(c.ip, this.log, c.priority);
72
134
  default:
73
135
  throw new Error(`unsupported controller type "${(c as Controller).type}"`);
74
136
  }
@@ -12,7 +12,7 @@ import type {
12
12
 
13
13
  import { Fixture } from './config.js';
14
14
  import { HomeKitLightState } from './color/types.js';
15
- import { StickController } from './controller.js';
15
+ import { DmxController } from './dmxController.js';
16
16
  import type { DmxPlatform } from './platform.js';
17
17
  import { CHARACTERISTIC_UPDATE_DELAY_MS } from './settings.js';
18
18
  import { StateRegistry } from './stateRegistry.js';
@@ -25,7 +25,7 @@ export class StickFixture {
25
25
  private readonly platform: DmxPlatform,
26
26
  private readonly accessory: PlatformAccessory,
27
27
  private readonly fixture: Fixture,
28
- private readonly controller: StickController,
28
+ private readonly controller: DmxController,
29
29
  private readonly registry: StateRegistry,
30
30
  ) {
31
31
  const C = platform.Characteristic;
package/src/sacn.ts ADDED
@@ -0,0 +1,154 @@
1
+ // SacnController — streams DMX to a network gateway over sACN (E1.31).
2
+ //
3
+ // Built for the ENTTEC DIN Ethergate Mk2 (PoE) but speaks plain
4
+ // ANSI E1.31, so any sACN node works. Unlike the Stick there is no
5
+ // session, no crypto, and sends are ~free, so the model is simple:
6
+ //
7
+ // - setFixture() renders into a per-universe 512-byte buffer and marks
8
+ // the universe dirty; a short coalescing timer batches the burst of
9
+ // per-member calls a zone set produces into one packet per universe
10
+ // - a 1 Hz keepalive re-sends every universe we have state for: E1.31
11
+ // receivers drop a source after 2.5 s of silence, and the steady
12
+ // trickle keeps the gateway holding our look
13
+ // - shutdown() sends the spec's stream-terminated packets so the
14
+ // gateway drops the source cleanly (its DMX output holds last values)
15
+ //
16
+ // Universe numbering: patch universes A/B (internal 0/1) map to sACN
17
+ // universes 1/2 — sACN has no universe 0. Set the Ethergate ports to
18
+ // sACN universes 1 and 2. Packets are unicast to the configured ip.
19
+
20
+ import type { Logger } from 'homebridge';
21
+ import dgram from 'node:dgram';
22
+ import { randomBytes } from 'node:crypto';
23
+
24
+ import { Fixture } from './config.js';
25
+ import { HomeKitLightState } from './color/types.js';
26
+ import { DmxController } from './dmxController.js';
27
+ import {
28
+ SACN_COALESCE_MS,
29
+ SACN_KEEPALIVE_MS,
30
+ SACN_PORT,
31
+ SACN_PRIORITY_DEFAULT,
32
+ SACN_SOURCE_NAME,
33
+ } from './settings.js';
34
+
35
+ const PACKET_LEN = 638; // full 512-slot E1.31 data packet
36
+ const OPT_STREAM_TERMINATED = 0x40;
37
+
38
+ export class SacnController implements DmxController {
39
+ private universes = new Map<number, Uint8Array>(); // internal idx (0=A,1=B) → slots
40
+ private seq = new Map<number, number>();
41
+ private dirty = new Set<number>();
42
+ private coalesceTimer: NodeJS.Timeout | null = null;
43
+ private keepaliveTimer: NodeJS.Timeout | null = null;
44
+ /** When setFixture last staged a change (keepalive re-sends excluded).
45
+ * Read by the platform's state-poll guard so a read-back never clobbers
46
+ * an in-flight HomeKit change. */
47
+ public lastChangeAt = 0;
48
+ private readonly socket: dgram.Socket;
49
+ private readonly cid = randomBytes(16);
50
+ private closed = false;
51
+
52
+ constructor(
53
+ private readonly ip: string,
54
+ private readonly log: Logger,
55
+ private readonly priority: number = SACN_PRIORITY_DEFAULT,
56
+ ) {
57
+ this.socket = dgram.createSocket('udp4');
58
+ this.socket.on('error', (e) => this.log.error('sACN socket error:', e.message));
59
+ }
60
+
61
+ setFixture(fixture: Fixture, state: HomeKitLightState): void {
62
+ const arr = this.universeBuf(fixture.universe);
63
+ const bytes = fixture.profile.model.render(state, fixture.profile.channels);
64
+ for (let i = 0; i < bytes.length; i++) {
65
+ arr[fixture.startCh - 1 + i] = bytes[i];
66
+ }
67
+ this.dirty.add(fixture.universe);
68
+ this.lastChangeAt = Date.now();
69
+ const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
70
+ this.log.info(`setFixture ${fixture.id}@DMX${fixture.startCh}/U${fixture.universe + 1}=${hex}`);
71
+ this.scheduleSend();
72
+ if (!this.keepaliveTimer) {
73
+ this.keepaliveTimer = setInterval(() => this.sendAll(), SACN_KEEPALIVE_MS);
74
+ this.keepaliveTimer.unref?.();
75
+ }
76
+ }
77
+
78
+ private universeBuf(u: number): Uint8Array {
79
+ let arr = this.universes.get(u);
80
+ if (!arr) { arr = new Uint8Array(512); this.universes.set(u, arr); }
81
+ return arr;
82
+ }
83
+
84
+ /** Coalesce the burst of setFixture calls a zone set produces into one
85
+ * packet per universe. */
86
+ private scheduleSend(): void {
87
+ if (this.coalesceTimer) return;
88
+ this.coalesceTimer = setTimeout(() => {
89
+ this.coalesceTimer = null;
90
+ for (const u of this.dirty) this.sendUniverse(u);
91
+ this.dirty.clear();
92
+ }, SACN_COALESCE_MS);
93
+ }
94
+
95
+ private sendAll(): void {
96
+ for (const u of this.universes.keys()) this.sendUniverse(u);
97
+ }
98
+
99
+ private sendUniverse(u: number, options = 0): void {
100
+ if (this.closed) return;
101
+ const slots = this.universes.get(u);
102
+ if (!slots) return;
103
+ const seq = (this.seq.get(u) ?? 0) & 0xff;
104
+ this.seq.set(u, seq + 1);
105
+ const pkt = this.buildPacket(u + 1, seq, slots, options);
106
+ this.socket.send(pkt, SACN_PORT, this.ip, (e) => {
107
+ if (e) this.log.error(`sACN send U${u + 1} failed:`, e.message);
108
+ });
109
+ }
110
+
111
+ /** ANSI E1.31 data packet: root layer + framing layer + DMP layer. */
112
+ private buildPacket(sacnUniverse: number, seq: number, slots: Uint8Array, options: number): Buffer {
113
+ const buf = Buffer.alloc(PACKET_LEN);
114
+ // Root layer
115
+ buf.writeUInt16BE(0x0010, 0); // RLP preamble size
116
+ buf.writeUInt16BE(0x0000, 2); // RLP postamble size
117
+ buf.write('ASC-E1.17', 4, 'ascii'); // ACN packet identifier (12 bytes, zero-padded)
118
+ buf.writeUInt16BE(0x7000 | (PACKET_LEN - 16), 16); // flags + length
119
+ buf.writeUInt32BE(0x00000004, 18); // VECTOR_ROOT_E131_DATA
120
+ this.cid.copy(buf, 22);
121
+ // Framing layer
122
+ buf.writeUInt16BE(0x7000 | (PACKET_LEN - 38), 38);
123
+ buf.writeUInt32BE(0x00000002, 40); // VECTOR_E131_DATA_PACKET
124
+ buf.write(SACN_SOURCE_NAME.slice(0, 63), 44, 'utf8');
125
+ buf.writeUInt8(this.priority, 108);
126
+ buf.writeUInt16BE(0x0000, 109); // sync address (none)
127
+ buf.writeUInt8(seq, 111);
128
+ buf.writeUInt8(options, 112);
129
+ buf.writeUInt16BE(sacnUniverse, 113);
130
+ // DMP layer
131
+ buf.writeUInt16BE(0x7000 | (PACKET_LEN - 115), 115);
132
+ buf.writeUInt8(0x02, 117); // VECTOR_DMP_SET_PROPERTY
133
+ buf.writeUInt8(0xa1, 118); // address & data type
134
+ buf.writeUInt16BE(0x0000, 119); // first property address
135
+ buf.writeUInt16BE(0x0001, 121); // address increment
136
+ buf.writeUInt16BE(1 + 512, 123); // property value count
137
+ buf.writeUInt8(0x00, 125); // DMX start code
138
+ buf.set(slots, 126);
139
+ return buf;
140
+ }
141
+
142
+ shutdown(): void {
143
+ if (this.coalesceTimer) clearTimeout(this.coalesceTimer);
144
+ if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
145
+ // Spec-polite goodbye: three stream-terminated packets per universe.
146
+ // The gateway drops the source immediately instead of waiting out the
147
+ // 2.5 s network-loss timeout; its DMX output holds the last values.
148
+ for (const u of this.universes.keys()) {
149
+ for (let i = 0; i < 3; i++) this.sendUniverse(u, OPT_STREAM_TERMINATED);
150
+ }
151
+ this.closed = true;
152
+ setTimeout(() => this.socket.close(), 100).unref?.();
153
+ }
154
+ }
package/src/settings.ts CHANGED
@@ -27,3 +27,24 @@ export const REFRESH_CHECK_MS = 60_000;
27
27
  * subprocess controller — send_dmx.mjs's own FRAME_HZ env knob is what
28
28
  * matters now. */
29
29
  export const FRAME_INTERVAL_MS = 40;
30
+
31
+ // ── sACN (E1.31) controller ────────────────────────────────────────────────
32
+
33
+ /** UDP port sACN receivers listen on (ANSI E1.31). */
34
+ export const SACN_PORT = 5568;
35
+
36
+ /** Coalescing window for the burst of setFixture calls a zone set
37
+ * produces: one packet per universe per burst. */
38
+ export const SACN_COALESCE_MS = 25;
39
+
40
+ /** Keepalive stream rate. E1.31 receivers drop a source after 2.5 s of
41
+ * silence; a 1 Hz re-send keeps the gateway holding our look and
42
+ * re-asserts state cheaply. */
43
+ export const SACN_KEEPALIVE_MS = 1000;
44
+
45
+ /** Default E1.31 priority (spec range 0-200, 100 = default). Only matters
46
+ * if a second source ever targets the same universe. */
47
+ export const SACN_PRIORITY_DEFAULT = 100;
48
+
49
+ /** Source name advertised in every E1.31 framing layer (max 63 chars). */
50
+ export const SACN_SOURCE_NAME = 'homebridge-dmx';
@@ -23,7 +23,7 @@ import type {
23
23
 
24
24
  import { Fixture, Zone } from './config.js';
25
25
  import { HKCharacteristic, HomeKitLightState } from './color/types.js';
26
- import { StickController } from './controller.js';
26
+ import { DmxController } from './dmxController.js';
27
27
  import type { DmxPlatform } from './platform.js';
28
28
  import { CHARACTERISTIC_UPDATE_DELAY_MS } from './settings.js';
29
29
  import { StateRegistry, pickMajority } from './stateRegistry.js';
@@ -37,7 +37,7 @@ export class ZoneFixture {
37
37
  private readonly platform: DmxPlatform,
38
38
  accessory: PlatformAccessory,
39
39
  private readonly zone: Zone,
40
- private readonly controllers: Map<string, StickController>,
40
+ private readonly controllers: Map<string, DmxController>,
41
41
  private readonly registry: StateRegistry,
42
42
  ) {
43
43
  const C = platform.Characteristic;