@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.
- package/CLAUDE.md +1 -0
- package/ENCRYPTION.md +207 -0
- package/PROTOCOL.md +277 -0
- package/README.md +22 -0
- package/config.schema.json +8 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/controller.d.ts +14 -3
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +41 -5
- package/dist/controller.js.map +1 -1
- package/dist/platform.d.ts +1 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +8 -6
- package/dist/platform.js.map +1 -1
- package/dist/platformAccessory.d.ts +5 -5
- package/dist/platformAccessory.d.ts.map +1 -1
- package/dist/platformAccessory.js +58 -29
- package/dist/platformAccessory.js.map +1 -1
- package/dist/settings.d.ts +9 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +9 -0
- package/dist/settings.js.map +1 -1
- package/dist/stateRegistry.d.ts +21 -0
- package/dist/stateRegistry.d.ts.map +1 -0
- package/dist/stateRegistry.js +65 -0
- package/dist/stateRegistry.js.map +1 -0
- package/dist/zoneAccessory.d.ts +20 -4
- package/dist/zoneAccessory.d.ts.map +1 -1
- package/dist/zoneAccessory.js +103 -40
- package/dist/zoneAccessory.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +10 -1
- package/src/controller.ts +39 -5
- package/src/platform.ts +8 -6
- package/src/platformAccessory.ts +53 -31
- package/src/settings.ts +11 -0
- package/src/stateRegistry.ts +71 -0
- package/src/zoneAccessory.ts +106 -40
- package/tools/stick-power-cycle.py +121 -0
package/dist/zoneAccessory.d.ts
CHANGED
|
@@ -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
|
|
10
|
+
private readonly registry;
|
|
10
11
|
private pending;
|
|
11
|
-
|
|
12
|
-
private
|
|
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":"
|
|
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"}
|
package/dist/zoneAccessory.js
CHANGED
|
@@ -1,83 +1,146 @@
|
|
|
1
|
-
// ZoneFixture — virtual HomeKit Lightbulb that
|
|
2
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
23
|
+
registry;
|
|
16
24
|
pending = null;
|
|
17
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
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.
|
|
45
|
-
.onSet((v) => {
|
|
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.
|
|
50
|
-
.onSet((v) => {
|
|
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.
|
|
55
|
-
.onSet((v) => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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":"AAAA,
|
|
1
|
+
{"version":3,"file":"zoneAccessory.js","sourceRoot":"","sources":["../src/zoneAccessory.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
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
|
-
|
|
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.
|
|
165
|
+
/** Shutdown hook. Stop the keepalive timer; the subprocess approach has
|
|
166
|
+
* no other long-lived state. */
|
|
133
167
|
shutdown(): void {
|
|
134
|
-
|
|
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
|
}
|
package/src/platformAccessory.ts
CHANGED
|
@@ -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
|
-
|
|
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(() =>
|
|
45
|
-
.onSet((v) => {
|
|
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(() =>
|
|
53
|
+
this.bulb.getCharacteristic(C.Brightness)
|
|
54
|
+
.onGet(() => registry.get(fixture.id).brightness)
|
|
50
55
|
.onSet((v: CharacteristicValue) => {
|
|
51
|
-
|
|
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(() =>
|
|
61
|
+
this.bulb.getCharacteristic(C.Hue)
|
|
62
|
+
.onGet(() => registry.get(fixture.id).hue ?? 0)
|
|
59
63
|
.onSet((v) => {
|
|
60
|
-
|
|
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(() =>
|
|
69
|
+
this.bulb.getCharacteristic(C.Saturation)
|
|
70
|
+
.onGet(() => registry.get(fixture.id).saturation ?? 0)
|
|
69
71
|
.onSet((v) => {
|
|
70
|
-
|
|
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(() =>
|
|
77
|
+
this.bulb.getCharacteristic(C.ColorTemperature)
|
|
78
|
+
.onGet(() => registry.get(fixture.id).colorTemperatureMireds ?? 200)
|
|
78
79
|
.onSet((v) => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
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,
|
|
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. */
|