@ecopoesis/homebridge-dmx 0.2.0 → 0.4.0

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 (42) hide show
  1. package/CLAUDE.md +1 -0
  2. package/ENCRYPTION.md +207 -0
  3. package/PROTOCOL.md +277 -0
  4. package/README.md +22 -0
  5. package/config.schema.json +8 -0
  6. package/dist/config.d.ts +2 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +8 -1
  9. package/dist/config.js.map +1 -1
  10. package/dist/controller.d.ts +14 -3
  11. package/dist/controller.d.ts.map +1 -1
  12. package/dist/controller.js +41 -5
  13. package/dist/controller.js.map +1 -1
  14. package/dist/platform.d.ts +1 -0
  15. package/dist/platform.d.ts.map +1 -1
  16. package/dist/platform.js +8 -6
  17. package/dist/platform.js.map +1 -1
  18. package/dist/platformAccessory.d.ts +5 -5
  19. package/dist/platformAccessory.d.ts.map +1 -1
  20. package/dist/platformAccessory.js +58 -29
  21. package/dist/platformAccessory.js.map +1 -1
  22. package/dist/settings.d.ts +9 -0
  23. package/dist/settings.d.ts.map +1 -1
  24. package/dist/settings.js +9 -0
  25. package/dist/settings.js.map +1 -1
  26. package/dist/stateRegistry.d.ts +21 -0
  27. package/dist/stateRegistry.d.ts.map +1 -0
  28. package/dist/stateRegistry.js +65 -0
  29. package/dist/stateRegistry.js.map +1 -0
  30. package/dist/zoneAccessory.d.ts +20 -4
  31. package/dist/zoneAccessory.d.ts.map +1 -1
  32. package/dist/zoneAccessory.js +103 -40
  33. package/dist/zoneAccessory.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/config.ts +10 -1
  36. package/src/controller.ts +39 -5
  37. package/src/platform.ts +8 -6
  38. package/src/platformAccessory.ts +53 -31
  39. package/src/settings.ts +11 -0
  40. package/src/stateRegistry.ts +71 -0
  41. package/src/zoneAccessory.ts +106 -40
  42. package/tools/stick-power-cycle.py +121 -0
@@ -1,14 +1,30 @@
1
1
  import type { PlatformAccessory } from 'homebridge';
2
- import { Zone } from './config.js';
2
+ import { Fixture, Zone } from './config.js';
3
3
  import { StickController } from './controller.js';
4
4
  import type { DmxPlatform } from './platform.js';
5
+ import { StateRegistry } from './stateRegistry.js';
5
6
  export declare class ZoneFixture {
6
7
  private readonly platform;
7
8
  private readonly zone;
8
9
  private readonly controllers;
9
- private state;
10
+ private readonly registry;
10
11
  private pending;
11
- constructor(platform: DmxPlatform, accessory: PlatformAccessory, zone: Zone, controllers: Map<string, StickController>);
12
- private schedule;
12
+ private refreshPending;
13
+ private bulb;
14
+ constructor(platform: DmxPlatform, accessory: PlatformAccessory, zone: Zone, controllers: Map<string, StickController>, registry: StateRegistry);
15
+ /** Compute majority state across members, per characteristic. */
16
+ private majorityState;
17
+ /** A HomeKit set on the zone: build the new state from the current
18
+ * majority + the patch the user is applying, write it into every
19
+ * member, and trigger the controller after a small debounce so
20
+ * multi-characteristic dispatch coalesces. */
21
+ private applyToMembers;
22
+ /** Debounced controller dispatch. Sends current registry state for each
23
+ * member to the appropriate StickController. */
24
+ private scheduleDispatch;
25
+ /** Debounced refresh: when members change, push the new majority to
26
+ * the zone's HomeKit characteristics. */
27
+ private scheduleRefresh;
13
28
  }
29
+ export type _MemberRef = Fixture;
14
30
  //# sourceMappingURL=zoneAccessory.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"zoneAccessory.d.ts","sourceRoot":"","sources":["../src/zoneAccessory.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAEV,iBAAiB,EAElB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAEnC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAGjD,qBAAa,WAAW;IAKpB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAEzB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAP9B,OAAO,CAAC,KAAK,CAAqD;IAClE,OAAO,CAAC,OAAO,CAA+B;gBAG3B,QAAQ,EAAE,WAAW,EACtC,SAAS,EAAE,iBAAiB,EACX,IAAI,EAAE,IAAI,EACV,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC;IAkD5D,OAAO,CAAC,QAAQ;CAuBjB"}
1
+ {"version":3,"file":"zoneAccessory.d.ts","sourceRoot":"","sources":["../src/zoneAccessory.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAEV,iBAAiB,EAElB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAE5C,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD,OAAO,EAAE,aAAa,EAAgB,MAAM,oBAAoB,CAAC;AAEjE,qBAAa,WAAW;IAMpB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAEzB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAT3B,OAAO,CAAC,OAAO,CAA+B;IAC9C,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,IAAI,CAAU;gBAGH,QAAQ,EAAE,WAAW,EACtC,SAAS,EAAE,iBAAiB,EACX,IAAI,EAAE,IAAI,EACV,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,EACzC,QAAQ,EAAE,aAAa;IAmD1C,iEAAiE;IACjE,OAAO,CAAC,aAAa;IAWrB;;;mDAG+C;IAC/C,OAAO,CAAC,cAAc;IAWtB;qDACiD;IACjD,OAAO,CAAC,gBAAgB;IAuBxB;8CAC0C;IAC1C,OAAO,CAAC,eAAe;CAqBxB;AAGD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC"}
@@ -1,83 +1,146 @@
1
- // ZoneFixture — virtual HomeKit Lightbulb that dispatches to a set of
2
- // member fixtures. Setting any characteristic of the zone updates the
3
- // zone's local state, then calls controller.setFixture for each member
4
- // using the zone's current state. The single controller debounce
5
- // coalesces all member updates into one subprocess transaction.
1
+ // ZoneFixture — virtual HomeKit Lightbulb that aggregates a set of member
2
+ // fixtures.
6
3
  //
7
- // Per-member HomeKit accessories are NOT updated automatically. The user
8
- // has accepted this trade-off: last command wins, and partial overrides
9
- // of group commands are fine.
4
+ // Reads: each .onGet returns the MAJORITY value of that characteristic
5
+ // across the zone's members. Ties are broken in favour of the value held
6
+ // by the alphabetically-first member. This gives a deterministic display
7
+ // even when members are in split states.
8
+ //
9
+ // Writes: each .onSet updates every member's state in the registry (which
10
+ // fires per-member change events so the individual fixture accessories
11
+ // refresh their own HomeKit displays), then triggers the controller for
12
+ // each member so the DMX wire reflects the change.
13
+ //
14
+ // When ANY member's state changes (by any path), the zone's HomeKit
15
+ // characteristics push the new majority — so zones containing overlapping
16
+ // members stay coherent with each other.
10
17
  import { CHARACTERISTIC_UPDATE_DELAY_MS } from './settings.js';
18
+ import { pickMajority } from './stateRegistry.js';
11
19
  export class ZoneFixture {
12
20
  platform;
13
21
  zone;
14
22
  controllers;
15
- state = { on: false, brightness: 100 };
23
+ registry;
16
24
  pending = null;
17
- constructor(platform, accessory, zone, controllers) {
25
+ refreshPending = null;
26
+ bulb;
27
+ constructor(platform, accessory, zone, controllers, registry) {
18
28
  this.platform = platform;
19
29
  this.zone = zone;
20
30
  this.controllers = controllers;
31
+ this.registry = registry;
21
32
  const C = platform.Characteristic;
22
33
  const info = accessory.getService(platform.Service.AccessoryInformation);
23
34
  info?.setCharacteristic(C.Manufacturer, 'DMX')
24
35
  ?.setCharacteristic(C.Model, `Zone (${zone.members.length} fixtures)`)
25
36
  ?.setCharacteristic(C.SerialNumber, `zone-${zone.id}`);
26
- const bulb = accessory.getService(platform.Service.Lightbulb)
27
- ?? accessory.addService(platform.Service.Lightbulb, zone.name);
28
- bulb.setCharacteristic(C.Name, zone.name);
29
- // Always expose On.
30
- bulb.getCharacteristic(C.On)
31
- .onGet(() => this.state.on)
32
- .onSet((v) => { this.state.on = Boolean(v); this.schedule(); });
37
+ this.bulb =
38
+ accessory.getService(platform.Service.Lightbulb)
39
+ ?? accessory.addService(platform.Service.Lightbulb, zone.name);
40
+ this.bulb.setCharacteristic(C.Name, zone.name);
33
41
  const has = (k) => zone.characteristics.includes(k);
42
+ this.bulb.getCharacteristic(C.On)
43
+ .onGet(() => this.majorityState().on)
44
+ .onSet((v) => this.applyToMembers({ on: Boolean(v) }));
34
45
  if (has('Brightness')) {
35
- bulb.getCharacteristic(C.Brightness)
36
- .onGet(() => this.state.brightness)
37
- .onSet((v) => {
38
- this.state.brightness = Number(v);
39
- this.schedule();
40
- });
46
+ this.bulb.getCharacteristic(C.Brightness)
47
+ .onGet(() => this.majorityState().brightness)
48
+ .onSet((v) => this.applyToMembers({ brightness: Number(v) }));
41
49
  }
42
50
  if (has('Hue')) {
43
- bulb.getCharacteristic(C.Hue)
44
- .onGet(() => this.state.hue ?? 0)
45
- .onSet((v) => { this.state.hue = Number(v); this.schedule(); });
51
+ this.bulb.getCharacteristic(C.Hue)
52
+ .onGet(() => this.majorityState().hue ?? 0)
53
+ .onSet((v) => this.applyToMembers({ hue: Number(v) }));
46
54
  }
47
55
  if (has('Saturation')) {
48
- bulb.getCharacteristic(C.Saturation)
49
- .onGet(() => this.state.saturation ?? 0)
50
- .onSet((v) => { this.state.saturation = Number(v); this.schedule(); });
56
+ this.bulb.getCharacteristic(C.Saturation)
57
+ .onGet(() => this.majorityState().saturation ?? 0)
58
+ .onSet((v) => this.applyToMembers({ saturation: Number(v) }));
51
59
  }
52
60
  if (has('ColorTemperature')) {
53
- bulb.getCharacteristic(C.ColorTemperature)
54
- .onGet(() => this.state.colorTemperatureMireds ?? 200)
55
- .onSet((v) => {
56
- this.state.colorTemperatureMireds = Number(v);
57
- this.state.saturation = 0;
58
- this.schedule();
59
- });
61
+ this.bulb.getCharacteristic(C.ColorTemperature)
62
+ .onGet(() => this.majorityState().colorTemperatureMireds ?? 200)
63
+ .onSet((v) => this.applyToMembers({
64
+ colorTemperatureMireds: Number(v),
65
+ saturation: 0,
66
+ }));
67
+ }
68
+ // Refresh on any member change.
69
+ const memberIds = new Set(zone.members.map((m) => m.id));
70
+ registry.onChange((id) => {
71
+ if (memberIds.has(id))
72
+ this.scheduleRefresh();
73
+ });
74
+ }
75
+ /** Compute majority state across members, per characteristic. */
76
+ majorityState() {
77
+ const ss = this.zone.members.map((m) => ({ id: m.id, state: this.registry.get(m.id) }));
78
+ return {
79
+ on: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.on }))) ?? false,
80
+ brightness: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.brightness }))) ?? 100,
81
+ hue: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.hue }))),
82
+ saturation: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.saturation }))),
83
+ colorTemperatureMireds: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.colorTemperatureMireds }))),
84
+ };
85
+ }
86
+ /** A HomeKit set on the zone: build the new state from the current
87
+ * majority + the patch the user is applying, write it into every
88
+ * member, and trigger the controller after a small debounce so
89
+ * multi-characteristic dispatch coalesces. */
90
+ applyToMembers(patch) {
91
+ const next = { ...this.majorityState(), ...patch };
92
+ // Push the next state into every member's registry entry. This fires
93
+ // per-fixture change events so the individual StickFixture accessories
94
+ // refresh their own HomeKit displays.
95
+ for (const m of this.zone.members) {
96
+ this.registry.update(m.id, next);
60
97
  }
98
+ this.scheduleDispatch();
61
99
  }
62
- schedule() {
100
+ /** Debounced controller dispatch. Sends current registry state for each
101
+ * member to the appropriate StickController. */
102
+ scheduleDispatch() {
63
103
  if (this.pending)
64
104
  clearTimeout(this.pending);
65
105
  this.pending = setTimeout(() => {
66
106
  this.pending = null;
67
- const s = this.state;
107
+ const s = this.majorityState();
68
108
  this.platform.log.info(`[zone:${this.zone.id}] HK set: on=${s.on} br=${s.brightness}` +
69
109
  (s.hue != null ? ` h=${s.hue}` : '') +
70
110
  (s.saturation != null ? ` s=${s.saturation}` : '') +
71
111
  (s.colorTemperatureMireds != null ? ` ct=${s.colorTemperatureMireds}` : '') +
72
112
  ` → ${this.zone.members.length} members`);
73
- // Dispatch the zone's state to each member via that member's controller.
74
113
  for (const m of this.zone.members) {
75
114
  const c = this.controllers.get(m.controller.id);
76
115
  if (!c) {
77
116
  this.platform.log.error(`zone "${this.zone.id}": no controller for "${m.id}"`);
78
117
  continue;
79
118
  }
80
- c.setFixture(m, this.state);
119
+ c.setFixture(m, this.registry.get(m.id));
120
+ }
121
+ }, CHARACTERISTIC_UPDATE_DELAY_MS);
122
+ }
123
+ /** Debounced refresh: when members change, push the new majority to
124
+ * the zone's HomeKit characteristics. */
125
+ scheduleRefresh() {
126
+ if (this.refreshPending)
127
+ clearTimeout(this.refreshPending);
128
+ this.refreshPending = setTimeout(() => {
129
+ this.refreshPending = null;
130
+ const s = this.majorityState();
131
+ const C = this.platform.Characteristic;
132
+ this.bulb.updateCharacteristic(C.On, s.on);
133
+ if (this.zone.characteristics.includes('Brightness')) {
134
+ this.bulb.updateCharacteristic(C.Brightness, s.brightness);
135
+ }
136
+ if (this.zone.characteristics.includes('Hue') && s.hue != null) {
137
+ this.bulb.updateCharacteristic(C.Hue, s.hue);
138
+ }
139
+ if (this.zone.characteristics.includes('Saturation') && s.saturation != null) {
140
+ this.bulb.updateCharacteristic(C.Saturation, s.saturation);
141
+ }
142
+ if (this.zone.characteristics.includes('ColorTemperature') && s.colorTemperatureMireds != null) {
143
+ this.bulb.updateCharacteristic(C.ColorTemperature, s.colorTemperatureMireds);
81
144
  }
82
145
  }, CHARACTERISTIC_UPDATE_DELAY_MS);
83
146
  }
@@ -1 +1 @@
1
- {"version":3,"file":"zoneAccessory.js","sourceRoot":"","sources":["../src/zoneAccessory.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"zoneAccessory.js","sourceRoot":"","sources":["../src/zoneAccessory.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ecopoesis/homebridge-dmx",
3
3
  "displayName": "DMX",
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "description": "Homebridge platform for DMX controllers (currently Nicolaudie Stick-DE3)",
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",
package/src/config.ts CHANGED
@@ -38,6 +38,7 @@ import path from 'node:path';
38
38
  import yaml from 'js-yaml';
39
39
 
40
40
  import { ChannelDef, ColorModel, HKCharacteristic } from './color/types.js';
41
+ import { REFRESH_MINUTES_DEFAULT } from './settings.js';
41
42
  import { getColorModel } from './color/index.js';
42
43
  import { parseChannelName } from './color/parsers.js';
43
44
 
@@ -45,6 +46,7 @@ export interface ControllerSpec {
45
46
  id: string; // unique handle, referenced from patch entries
46
47
  type: string; // 'StickDE3' (only one supported today)
47
48
  ip: string; // controller's static IP
49
+ refreshMinutes?: number; // idle keepalive: re-send state every N min (0 = off)
48
50
  }
49
51
 
50
52
  export interface ProfileSpec {
@@ -90,6 +92,7 @@ export interface Controller {
90
92
  id: string;
91
93
  type: ControllerType;
92
94
  ip: string;
95
+ refreshMinutes: number;
93
96
  }
94
97
 
95
98
  export interface Fixture {
@@ -159,7 +162,13 @@ export function loadConfig(rawJson: RawConfig, cwd?: string): LoadedConfig {
159
162
  if (!cs.ip || typeof cs.ip !== 'string') {
160
163
  throw new Error(`controller "${cs.id}": required field "ip" missing`);
161
164
  }
162
- controllersById.set(cs.id, { id: cs.id, type: cs.type as ControllerType, ip: cs.ip });
165
+ const refreshMinutes = cs.refreshMinutes ?? REFRESH_MINUTES_DEFAULT;
166
+ if (typeof refreshMinutes !== 'number' || refreshMinutes < 0) {
167
+ throw new Error(`controller "${cs.id}": refreshMinutes must be a number >= 0 (0 disables)`);
168
+ }
169
+ controllersById.set(cs.id, {
170
+ id: cs.id, type: cs.type as ControllerType, ip: cs.ip, refreshMinutes,
171
+ });
163
172
  }
164
173
 
165
174
  // Profiles
package/src/controller.ts CHANGED
@@ -15,7 +15,7 @@ import path from 'node:path';
15
15
 
16
16
  import { Fixture } from './config.js';
17
17
  import { HomeKitLightState } from './color/types.js';
18
- import { DEBOUNCE_MS } from './settings.js';
18
+ import { DEBOUNCE_MS, REFRESH_CHECK_MS } from './settings.js';
19
19
  import { sleep } from './stick/protocol.js';
20
20
 
21
21
  const __filename = fileURLToPath(import.meta.url);
@@ -26,7 +26,9 @@ const SEND_DMX_PATH = path.resolve(__dirname, '..', 'tools', 'send_dmx.mjs');
26
26
  export class StickController {
27
27
  private targets = new Map<number, Uint8Array>();
28
28
  private lastChangeAt = 0;
29
+ private lastTxEndAt = 0;
29
30
  private streaming = false;
31
+ private refreshTimer?: NodeJS.Timeout;
30
32
  /** Tracks whether the very first subprocess in this controller's
31
33
  * lifetime should set RUN_PROBE=1. After a Stick power-cycle (which
32
34
  * we can't detect in-band) the probe is required to (re-)register
@@ -37,7 +39,31 @@ export class StickController {
37
39
  private readonly ip: string,
38
40
  private readonly log: Logger,
39
41
  private readonly debounceMs: number = DEBOUNCE_MS,
40
- ) {}
42
+ private readonly refreshMs: number = 0,
43
+ ) {
44
+ if (refreshMs > 0) {
45
+ this.refreshTimer = setInterval(() => this.maybeRefresh(), REFRESH_CHECK_MS);
46
+ this.refreshTimer.unref?.();
47
+ }
48
+ }
49
+
50
+ /** Idle keepalive: the Stick wedges (sessions succeed, output dark) after
51
+ * hours without traffic. Re-send the current universe state whenever the
52
+ * controller has been idle for refreshMs. Also re-asserts state after an
53
+ * out-of-band power-cycle of the Stick. */
54
+ private maybeRefresh(): void {
55
+ if (this.streaming || this.targets.size === 0) return;
56
+ const idleMs = Date.now() - Math.max(this.lastTxEndAt, this.lastChangeAt);
57
+ if (idleMs < this.refreshMs) return;
58
+ this.log.info(`keepalive refresh after ${Math.round(idleMs / 60_000)}m idle`);
59
+ this.needsProbe = true; // the Stick may have power-cycled while we were idle
60
+ this.streaming = true;
61
+ this.run().catch((e) => {
62
+ this.log.error('keepalive refresh crashed:', (e as Error).message);
63
+ }).finally(() => {
64
+ this.streaming = false;
65
+ });
66
+ }
41
67
 
42
68
  /** Stage a fixture's bytes into the universe target buffer and ensure
43
69
  * the streaming loop is running. */
@@ -67,7 +93,9 @@ export class StickController {
67
93
  }
68
94
 
69
95
  /** Build the CLI args from current targets. Only non-zero channels are
70
- * passed — send_dmx initialises the universe to 0 so omission == 0. */
96
+ * passed — send_dmx initialises the universe to 0 so omission == 0.
97
+ * When everything is 0 (all lights off), send an explicit zero so
98
+ * send_dmx still has at least one assignment to transact. */
71
99
  private buildArgs(): string[] {
72
100
  const args: string[] = [SEND_DMX_PATH, this.ip];
73
101
  for (const [u, arr] of this.targets) {
@@ -75,6 +103,10 @@ export class StickController {
75
103
  if (arr[i] !== 0) args.push(`${u},${i + 1}=${arr[i]}`);
76
104
  }
77
105
  }
106
+ if (args.length === 2 && this.targets.size > 0) {
107
+ const firstU = this.targets.keys().next().value as number;
108
+ args.push(`${firstU},1=0`);
109
+ }
78
110
  return args;
79
111
  }
80
112
 
@@ -115,6 +147,7 @@ export class StickController {
115
147
  });
116
148
  });
117
149
  const elapsedMs = Date.now() - startedAt;
150
+ this.lastTxEndAt = Date.now();
118
151
 
119
152
  if (exitCode === 0) {
120
153
  this.needsProbe = false;
@@ -129,8 +162,9 @@ export class StickController {
129
162
  }
130
163
  }
131
164
 
132
- /** Shutdown hook. Subprocess approach has no long-lived state. */
165
+ /** Shutdown hook. Stop the keepalive timer; the subprocess approach has
166
+ * no other long-lived state. */
133
167
  shutdown(): void {
134
- /* no-op */
168
+ if (this.refreshTimer) clearInterval(this.refreshTimer);
135
169
  }
136
170
  }
package/src/platform.ts CHANGED
@@ -17,8 +17,9 @@ import type {
17
17
  import { Controller, LoadedConfig, loadConfig, RawConfig } from './config.js';
18
18
  import { StickController } from './controller.js';
19
19
  import { StickFixture } from './platformAccessory.js';
20
+ import { StateRegistry } from './stateRegistry.js';
20
21
  import { ZoneFixture } from './zoneAccessory.js';
21
- import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
22
+ import { DEBOUNCE_MS, PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
22
23
 
23
24
  export class DmxPlatform implements DynamicPlatformPlugin {
24
25
  public readonly Service: typeof Service;
@@ -27,6 +28,7 @@ export class DmxPlatform implements DynamicPlatformPlugin {
27
28
 
28
29
  private cfg: LoadedConfig | null = null;
29
30
  private controllers = new Map<string, StickController>();
31
+ private registry = new StateRegistry();
30
32
 
31
33
  constructor(
32
34
  public readonly log: Logger,
@@ -66,7 +68,7 @@ export class DmxPlatform implements DynamicPlatformPlugin {
66
68
  private makeController(c: Controller): StickController {
67
69
  switch (c.type) {
68
70
  case 'StickDE3':
69
- return new StickController(c.ip, this.log);
71
+ return new StickController(c.ip, this.log, DEBOUNCE_MS, c.refreshMinutes * 60_000);
70
72
  default:
71
73
  throw new Error(`unsupported controller type "${(c as Controller).type}"`);
72
74
  }
@@ -96,12 +98,12 @@ export class DmxPlatform implements DynamicPlatformPlugin {
96
98
  if (existing) {
97
99
  this.log.debug(`wiring cached accessory ${fx.id}`);
98
100
  existing.context.fixture = { id: fx.id, controllerId: fx.controller.id };
99
- new StickFixture(this, existing, fx, controller);
101
+ new StickFixture(this, existing, fx, controller, this.registry);
100
102
  } else {
101
103
  this.log.info(`registering new accessory: ${fx.name} (${fx.id})`);
102
104
  const accessory = new this.api.platformAccessory(fx.name, uuid);
103
105
  accessory.context.fixture = { id: fx.id, controllerId: fx.controller.id };
104
- new StickFixture(this, accessory, fx, controller);
106
+ new StickFixture(this, accessory, fx, controller, this.registry);
105
107
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
106
108
  this.accessories.push(accessory);
107
109
  }
@@ -115,12 +117,12 @@ export class DmxPlatform implements DynamicPlatformPlugin {
115
117
  if (existing) {
116
118
  this.log.debug(`wiring cached zone ${zone.id}`);
117
119
  existing.context.zone = { id: zone.id };
118
- new ZoneFixture(this, existing, zone, this.controllers);
120
+ new ZoneFixture(this, existing, zone, this.controllers, this.registry);
119
121
  } else {
120
122
  this.log.info(`registering new zone: ${zone.name} (${zone.id}, ${zone.members.length} members)`);
121
123
  const accessory = new this.api.platformAccessory(zone.name, uuid);
122
124
  accessory.context.zone = { id: zone.id };
123
- new ZoneFixture(this, accessory, zone, this.controllers);
125
+ new ZoneFixture(this, accessory, zone, this.controllers, this.registry);
124
126
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
125
127
  this.accessories.push(accessory);
126
128
  }
@@ -1,4 +1,8 @@
1
1
  // StickFixture — one HomeKit Lightbulb accessory per patched fixture.
2
+ //
3
+ // State is owned by the platform-wide StateRegistry. We read on .onGet,
4
+ // write on .onSet, and subscribe to changes so the HomeKit-displayed
5
+ // values track the registry (e.g. when a zone updates this fixture).
2
6
 
3
7
  import type {
4
8
  CharacteristicValue,
@@ -11,20 +15,18 @@ import { HomeKitLightState } from './color/types.js';
11
15
  import { StickController } from './controller.js';
12
16
  import type { DmxPlatform } from './platform.js';
13
17
  import { CHARACTERISTIC_UPDATE_DELAY_MS } from './settings.js';
18
+ import { StateRegistry } from './stateRegistry.js';
14
19
 
15
20
  export class StickFixture {
16
- /** Current HomeKit state we'll send to the Stick on the next push. */
17
- private state: HomeKitLightState = { on: false, brightness: 100 };
18
-
19
- /** Debounce timer for HomeKit's multi-characteristic dispatch
20
- * (Hue/Saturation/Brightness arrive as 3 separate set calls). */
21
21
  private pending: NodeJS.Timeout | null = null;
22
+ private bulb: Service;
22
23
 
23
24
  constructor(
24
25
  private readonly platform: DmxPlatform,
25
26
  private readonly accessory: PlatformAccessory,
26
27
  private readonly fixture: Fixture,
27
28
  private readonly controller: StickController,
29
+ private readonly registry: StateRegistry,
28
30
  ) {
29
31
  const C = platform.Characteristic;
30
32
  const info = accessory.getService(platform.Service.AccessoryInformation);
@@ -32,71 +34,91 @@ export class StickFixture {
32
34
  ?.setCharacteristic(C.Model, fixture.profile.name)
33
35
  ?.setCharacteristic(C.SerialNumber, fixture.id);
34
36
 
35
- const bulb: Service =
37
+ this.bulb =
36
38
  accessory.getService(platform.Service.Lightbulb)
37
39
  ?? accessory.addService(platform.Service.Lightbulb, fixture.name);
38
40
 
39
- bulb.setCharacteristic(C.Name, fixture.name);
41
+ this.bulb.setCharacteristic(C.Name, fixture.name);
40
42
 
41
43
  const chars = fixture.profile.model.characteristics;
42
44
 
43
- bulb.getCharacteristic(C.On)
44
- .onGet(() => this.state.on)
45
- .onSet((v) => { this.state.on = Boolean(v); this.schedule(); });
45
+ this.bulb.getCharacteristic(C.On)
46
+ .onGet(() => registry.get(fixture.id).on)
47
+ .onSet((v) => {
48
+ registry.update(fixture.id, { on: Boolean(v) });
49
+ this.schedule();
50
+ });
46
51
 
47
52
  if (chars.includes('Brightness')) {
48
- bulb.getCharacteristic(C.Brightness)
49
- .onGet(() => this.state.brightness)
53
+ this.bulb.getCharacteristic(C.Brightness)
54
+ .onGet(() => registry.get(fixture.id).brightness)
50
55
  .onSet((v: CharacteristicValue) => {
51
- this.state.brightness = Number(v);
56
+ registry.update(fixture.id, { brightness: Number(v) });
52
57
  this.schedule();
53
58
  });
54
59
  }
55
-
56
60
  if (chars.includes('Hue')) {
57
- bulb.getCharacteristic(C.Hue)
58
- .onGet(() => this.state.hue ?? 0)
61
+ this.bulb.getCharacteristic(C.Hue)
62
+ .onGet(() => registry.get(fixture.id).hue ?? 0)
59
63
  .onSet((v) => {
60
- this.state.hue = Number(v);
61
- // setting hue implies color mode (sat>0); leave saturation as-is
64
+ registry.update(fixture.id, { hue: Number(v) });
62
65
  this.schedule();
63
66
  });
64
67
  }
65
-
66
68
  if (chars.includes('Saturation')) {
67
- bulb.getCharacteristic(C.Saturation)
68
- .onGet(() => this.state.saturation ?? 0)
69
+ this.bulb.getCharacteristic(C.Saturation)
70
+ .onGet(() => registry.get(fixture.id).saturation ?? 0)
69
71
  .onSet((v) => {
70
- this.state.saturation = Number(v);
72
+ registry.update(fixture.id, { saturation: Number(v) });
71
73
  this.schedule();
72
74
  });
73
75
  }
74
-
75
76
  if (chars.includes('ColorTemperature')) {
76
- bulb.getCharacteristic(C.ColorTemperature)
77
- .onGet(() => this.state.colorTemperatureMireds ?? 200)
77
+ this.bulb.getCharacteristic(C.ColorTemperature)
78
+ .onGet(() => registry.get(fixture.id).colorTemperatureMireds ?? 200)
78
79
  .onSet((v) => {
79
- this.state.colorTemperatureMireds = Number(v);
80
- // CCT mode: HomeKit toggles between color and white modes; we mirror
81
- // by zeroing saturation when CCT is changed.
82
- this.state.saturation = 0;
80
+ // CCT change implies white mode; zero saturation.
81
+ registry.update(fixture.id, {
82
+ colorTemperatureMireds: Number(v),
83
+ saturation: 0,
84
+ });
83
85
  this.schedule();
84
86
  });
85
87
  }
88
+
89
+ // Refresh HomeKit characteristics whenever this fixture's state changes
90
+ // in the registry (e.g. via a zone set, or another channel of itself).
91
+ registry.onChange((changedId) => {
92
+ if (changedId !== fixture.id) return;
93
+ this.pushFromRegistry();
94
+ });
95
+ }
96
+
97
+ private pushFromRegistry(): void {
98
+ const s = this.registry.get(this.fixture.id);
99
+ const C = this.platform.Characteristic;
100
+ const chars = this.fixture.profile.model.characteristics;
101
+ this.bulb.updateCharacteristic(C.On, s.on);
102
+ if (chars.includes('Brightness')) this.bulb.updateCharacteristic(C.Brightness, s.brightness);
103
+ if (chars.includes('Hue') && s.hue != null) this.bulb.updateCharacteristic(C.Hue, s.hue);
104
+ if (chars.includes('Saturation') && s.saturation != null) this.bulb.updateCharacteristic(C.Saturation, s.saturation);
105
+ if (chars.includes('ColorTemperature') && s.colorTemperatureMireds != null) {
106
+ this.bulb.updateCharacteristic(C.ColorTemperature, s.colorTemperatureMireds);
107
+ }
86
108
  }
87
109
 
88
110
  private schedule(): void {
89
111
  if (this.pending) clearTimeout(this.pending);
90
112
  this.pending = setTimeout(() => {
91
113
  this.pending = null;
92
- const s = this.state;
114
+ const s = this.registry.get(this.fixture.id);
93
115
  this.platform.log.info(
94
116
  `[${this.fixture.id}] HK set: on=${s.on} br=${s.brightness}` +
95
117
  (s.hue != null ? ` h=${s.hue}` : '') +
96
118
  (s.saturation != null ? ` s=${s.saturation}` : '') +
97
119
  (s.colorTemperatureMireds != null ? ` ct=${s.colorTemperatureMireds}` : ''),
98
120
  );
99
- this.controller.setFixture(this.fixture, this.state);
121
+ this.controller.setFixture(this.fixture, s);
100
122
  }, CHARACTERISTIC_UPDATE_DELAY_MS);
101
123
  }
102
124
  }
package/src/settings.ts CHANGED
@@ -12,6 +12,17 @@ export const CHARACTERISTIC_UPDATE_DELAY_MS = 50;
12
12
  * slider drag into one transaction. */
13
13
  export const DEBOUNCE_MS = 750;
14
14
 
15
+ /** Idle keepalive. The Stick-DE3 drifts into a wedged state after hours
16
+ * without traffic: sessions and the crypto handshake still succeed but
17
+ * live DMX output goes dark (observed 2026-08-22..25; only a hard power
18
+ * cycle recovers it). Re-sending the last-known state whenever the
19
+ * controller has been idle this long keeps the device exercised and
20
+ * re-asserts state after an out-of-band power-cycle. 0 disables. */
21
+ export const REFRESH_MINUTES_DEFAULT = 30;
22
+
23
+ /** How often the controller checks whether a keepalive refresh is due. */
24
+ export const REFRESH_CHECK_MS = 60_000;
25
+
15
26
  /** Frame interval (40ms = 25Hz, matches HWM exactly). Unused by the
16
27
  * subprocess controller — send_dmx.mjs's own FRAME_HZ env knob is what
17
28
  * matters now. */