@ecopoesis/homebridge-dmx 0.1.0 → 0.3.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 +18 -3
- package/dist/config.d.ts +17 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +42 -0
- package/dist/config.js.map +1 -1
- package/dist/platform.d.ts +1 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +26 -3
- 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/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 +30 -0
- package/dist/zoneAccessory.d.ts.map +1 -0
- package/dist/zoneAccessory.js +148 -0
- package/dist/zoneAccessory.js.map +1 -0
- package/package.json +1 -1
- package/src/config.ts +61 -1
- package/src/platform.ts +26 -3
- package/src/platformAccessory.ts +53 -31
- package/src/stateRegistry.ts +71 -0
- package/src/zoneAccessory.ts +170 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// ZoneFixture — virtual HomeKit Lightbulb that aggregates a set of member
|
|
2
|
+
// fixtures.
|
|
3
|
+
//
|
|
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.
|
|
17
|
+
import { CHARACTERISTIC_UPDATE_DELAY_MS } from './settings.js';
|
|
18
|
+
import { pickMajority } from './stateRegistry.js';
|
|
19
|
+
export class ZoneFixture {
|
|
20
|
+
platform;
|
|
21
|
+
zone;
|
|
22
|
+
controllers;
|
|
23
|
+
registry;
|
|
24
|
+
pending = null;
|
|
25
|
+
refreshPending = null;
|
|
26
|
+
bulb;
|
|
27
|
+
constructor(platform, accessory, zone, controllers, registry) {
|
|
28
|
+
this.platform = platform;
|
|
29
|
+
this.zone = zone;
|
|
30
|
+
this.controllers = controllers;
|
|
31
|
+
this.registry = registry;
|
|
32
|
+
const C = platform.Characteristic;
|
|
33
|
+
const info = accessory.getService(platform.Service.AccessoryInformation);
|
|
34
|
+
info?.setCharacteristic(C.Manufacturer, 'DMX')
|
|
35
|
+
?.setCharacteristic(C.Model, `Zone (${zone.members.length} fixtures)`)
|
|
36
|
+
?.setCharacteristic(C.SerialNumber, `zone-${zone.id}`);
|
|
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);
|
|
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) }));
|
|
45
|
+
if (has('Brightness')) {
|
|
46
|
+
this.bulb.getCharacteristic(C.Brightness)
|
|
47
|
+
.onGet(() => this.majorityState().brightness)
|
|
48
|
+
.onSet((v) => this.applyToMembers({ brightness: Number(v) }));
|
|
49
|
+
}
|
|
50
|
+
if (has('Hue')) {
|
|
51
|
+
this.bulb.getCharacteristic(C.Hue)
|
|
52
|
+
.onGet(() => this.majorityState().hue ?? 0)
|
|
53
|
+
.onSet((v) => this.applyToMembers({ hue: Number(v) }));
|
|
54
|
+
}
|
|
55
|
+
if (has('Saturation')) {
|
|
56
|
+
this.bulb.getCharacteristic(C.Saturation)
|
|
57
|
+
.onGet(() => this.majorityState().saturation ?? 0)
|
|
58
|
+
.onSet((v) => this.applyToMembers({ saturation: Number(v) }));
|
|
59
|
+
}
|
|
60
|
+
if (has('ColorTemperature')) {
|
|
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);
|
|
97
|
+
}
|
|
98
|
+
this.scheduleDispatch();
|
|
99
|
+
}
|
|
100
|
+
/** Debounced controller dispatch. Sends current registry state for each
|
|
101
|
+
* member to the appropriate StickController. */
|
|
102
|
+
scheduleDispatch() {
|
|
103
|
+
if (this.pending)
|
|
104
|
+
clearTimeout(this.pending);
|
|
105
|
+
this.pending = setTimeout(() => {
|
|
106
|
+
this.pending = null;
|
|
107
|
+
const s = this.majorityState();
|
|
108
|
+
this.platform.log.info(`[zone:${this.zone.id}] HK set: on=${s.on} br=${s.brightness}` +
|
|
109
|
+
(s.hue != null ? ` h=${s.hue}` : '') +
|
|
110
|
+
(s.saturation != null ? ` s=${s.saturation}` : '') +
|
|
111
|
+
(s.colorTemperatureMireds != null ? ` ct=${s.colorTemperatureMireds}` : '') +
|
|
112
|
+
` → ${this.zone.members.length} members`);
|
|
113
|
+
for (const m of this.zone.members) {
|
|
114
|
+
const c = this.controllers.get(m.controller.id);
|
|
115
|
+
if (!c) {
|
|
116
|
+
this.platform.log.error(`zone "${this.zone.id}": no controller for "${m.id}"`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
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);
|
|
144
|
+
}
|
|
145
|
+
}, CHARACTERISTIC_UPDATE_DELAY_MS);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=zoneAccessory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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
|
@@ -37,7 +37,7 @@ import fs from 'node:fs';
|
|
|
37
37
|
import path from 'node:path';
|
|
38
38
|
import yaml from 'js-yaml';
|
|
39
39
|
|
|
40
|
-
import { ChannelDef, ColorModel } from './color/types.js';
|
|
40
|
+
import { ChannelDef, ColorModel, HKCharacteristic } from './color/types.js';
|
|
41
41
|
import { getColorModel } from './color/index.js';
|
|
42
42
|
import { parseChannelName } from './color/parsers.js';
|
|
43
43
|
|
|
@@ -62,12 +62,19 @@ export interface PatchSpec {
|
|
|
62
62
|
start: number; // 1-based DMX start address
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
export interface ZoneSpec {
|
|
66
|
+
id: string; // unique handle (kebab-case)
|
|
67
|
+
name: string; // human-friendly HomeKit name
|
|
68
|
+
members: string[]; // fixture ids included in this zone
|
|
69
|
+
}
|
|
70
|
+
|
|
65
71
|
export interface RawConfig {
|
|
66
72
|
name?: string;
|
|
67
73
|
yamlPath?: string;
|
|
68
74
|
controllers?: ControllerSpec[];
|
|
69
75
|
profiles?: ProfileSpec[];
|
|
70
76
|
patch?: PatchSpec[];
|
|
77
|
+
zones?: ZoneSpec[];
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
export interface Profile {
|
|
@@ -95,10 +102,21 @@ export interface Fixture {
|
|
|
95
102
|
nChannels: number; // = channels.length
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
/** A virtual "zone" Lightbulb: appears as a single accessory in HomeKit;
|
|
106
|
+
* setting its state dispatches to every member fixture. The intersection
|
|
107
|
+
* of the members' color-model characteristics is what HomeKit sees. */
|
|
108
|
+
export interface Zone {
|
|
109
|
+
id: string;
|
|
110
|
+
name: string;
|
|
111
|
+
members: Fixture[];
|
|
112
|
+
characteristics: HKCharacteristic[]; // intersection of member characteristics
|
|
113
|
+
}
|
|
114
|
+
|
|
98
115
|
export interface LoadedConfig {
|
|
99
116
|
name: string;
|
|
100
117
|
controllers: Controller[];
|
|
101
118
|
fixtures: Fixture[];
|
|
119
|
+
zones: Zone[];
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
export const DEFAULT_PLATFORM_NAME = 'DMX';
|
|
@@ -240,9 +258,51 @@ export function loadConfig(rawJson: RawConfig, cwd?: string): LoadedConfig {
|
|
|
240
258
|
});
|
|
241
259
|
}
|
|
242
260
|
|
|
261
|
+
// Zones — virtual Lightbulb accessories. Each zone's members must be
|
|
262
|
+
// existing fixture ids. Zones never reference other zones (no recursion).
|
|
263
|
+
const fixturesById = new Map(fixtures.map((f) => [f.id, f]));
|
|
264
|
+
const zoneSpecs = raw.zones ?? [];
|
|
265
|
+
const zones: Zone[] = [];
|
|
266
|
+
const seenZoneIds = new Set<string>();
|
|
267
|
+
for (const zs of zoneSpecs) {
|
|
268
|
+
if (!zs.id) throw new Error('zone: missing id');
|
|
269
|
+
if (seenZoneIds.has(zs.id)) {
|
|
270
|
+
throw new Error(`zone: duplicate id "${zs.id}"`);
|
|
271
|
+
}
|
|
272
|
+
if (fixturesById.has(zs.id)) {
|
|
273
|
+
throw new Error(`zone id "${zs.id}" collides with fixture id`);
|
|
274
|
+
}
|
|
275
|
+
seenZoneIds.add(zs.id);
|
|
276
|
+
if (!Array.isArray(zs.members) || zs.members.length === 0) {
|
|
277
|
+
throw new Error(`zone "${zs.id}": members must be a non-empty array of fixture ids`);
|
|
278
|
+
}
|
|
279
|
+
const members: Fixture[] = [];
|
|
280
|
+
for (const m of zs.members) {
|
|
281
|
+
const fx = fixturesById.get(m);
|
|
282
|
+
if (!fx) {
|
|
283
|
+
throw new Error(`zone "${zs.id}": unknown fixture "${m}"`);
|
|
284
|
+
}
|
|
285
|
+
members.push(fx);
|
|
286
|
+
}
|
|
287
|
+
// Intersection of member characteristics — HomeKit only exposes what
|
|
288
|
+
// ALL members can do.
|
|
289
|
+
let chars = new Set<HKCharacteristic>(members[0].profile.model.characteristics);
|
|
290
|
+
for (let i = 1; i < members.length; i++) {
|
|
291
|
+
const mc = new Set<HKCharacteristic>(members[i].profile.model.characteristics);
|
|
292
|
+
chars = new Set([...chars].filter((c) => mc.has(c)));
|
|
293
|
+
}
|
|
294
|
+
zones.push({
|
|
295
|
+
id: zs.id,
|
|
296
|
+
name: zs.name || zs.id,
|
|
297
|
+
members,
|
|
298
|
+
characteristics: [...chars],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
243
302
|
return {
|
|
244
303
|
name: raw.name ?? DEFAULT_PLATFORM_NAME,
|
|
245
304
|
controllers: [...controllersById.values()],
|
|
246
305
|
fixtures,
|
|
306
|
+
zones,
|
|
247
307
|
};
|
|
248
308
|
}
|
package/src/platform.ts
CHANGED
|
@@ -17,6 +17,8 @@ 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';
|
|
21
|
+
import { ZoneFixture } from './zoneAccessory.js';
|
|
20
22
|
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
|
|
21
23
|
|
|
22
24
|
export class DmxPlatform implements DynamicPlatformPlugin {
|
|
@@ -26,6 +28,7 @@ export class DmxPlatform implements DynamicPlatformPlugin {
|
|
|
26
28
|
|
|
27
29
|
private cfg: LoadedConfig | null = null;
|
|
28
30
|
private controllers = new Map<string, StickController>();
|
|
31
|
+
private registry = new StateRegistry();
|
|
29
32
|
|
|
30
33
|
constructor(
|
|
31
34
|
public readonly log: Logger,
|
|
@@ -52,7 +55,8 @@ export class DmxPlatform implements DynamicPlatformPlugin {
|
|
|
52
55
|
this.log.info(
|
|
53
56
|
`DMX platform configured: ${this.cfg.controllers.length} controller` +
|
|
54
57
|
`${this.cfg.controllers.length === 1 ? '' : 's'}, ` +
|
|
55
|
-
`${this.cfg.fixtures.length} fixture${this.cfg.fixtures.length === 1 ? '' : 's'}
|
|
58
|
+
`${this.cfg.fixtures.length} fixture${this.cfg.fixtures.length === 1 ? '' : 's'}, ` +
|
|
59
|
+
`${this.cfg.zones.length} zone${this.cfg.zones.length === 1 ? '' : 's'}`,
|
|
56
60
|
);
|
|
57
61
|
|
|
58
62
|
this.api.on('didFinishLaunching', () => this.discoverDevices());
|
|
@@ -94,12 +98,31 @@ export class DmxPlatform implements DynamicPlatformPlugin {
|
|
|
94
98
|
if (existing) {
|
|
95
99
|
this.log.debug(`wiring cached accessory ${fx.id}`);
|
|
96
100
|
existing.context.fixture = { id: fx.id, controllerId: fx.controller.id };
|
|
97
|
-
new StickFixture(this, existing, fx, controller);
|
|
101
|
+
new StickFixture(this, existing, fx, controller, this.registry);
|
|
98
102
|
} else {
|
|
99
103
|
this.log.info(`registering new accessory: ${fx.name} (${fx.id})`);
|
|
100
104
|
const accessory = new this.api.platformAccessory(fx.name, uuid);
|
|
101
105
|
accessory.context.fixture = { id: fx.id, controllerId: fx.controller.id };
|
|
102
|
-
new StickFixture(this, accessory, fx, controller);
|
|
106
|
+
new StickFixture(this, accessory, fx, controller, this.registry);
|
|
107
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
108
|
+
this.accessories.push(accessory);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Zones — virtual accessories that dispatch to their member fixtures.
|
|
113
|
+
for (const zone of this.cfg.zones) {
|
|
114
|
+
const uuid = this.api.hap.uuid.generate(`dmx-zone:${zone.id}`);
|
|
115
|
+
wantUuids.add(uuid);
|
|
116
|
+
const existing = this.accessories.find((a) => a.UUID === uuid);
|
|
117
|
+
if (existing) {
|
|
118
|
+
this.log.debug(`wiring cached zone ${zone.id}`);
|
|
119
|
+
existing.context.zone = { id: zone.id };
|
|
120
|
+
new ZoneFixture(this, existing, zone, this.controllers, this.registry);
|
|
121
|
+
} else {
|
|
122
|
+
this.log.info(`registering new zone: ${zone.name} (${zone.id}, ${zone.members.length} members)`);
|
|
123
|
+
const accessory = new this.api.platformAccessory(zone.name, uuid);
|
|
124
|
+
accessory.context.zone = { id: zone.id };
|
|
125
|
+
new ZoneFixture(this, accessory, zone, this.controllers, this.registry);
|
|
103
126
|
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
104
127
|
this.accessories.push(accessory);
|
|
105
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
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// StateRegistry — central store of every fixture's HomeKit-visible state.
|
|
2
|
+
//
|
|
3
|
+
// Every StickFixture and ZoneFixture reads/writes through here. Whenever a
|
|
4
|
+
// fixture's state changes, listeners fire so zones can refresh their own
|
|
5
|
+
// HomeKit characteristics (and vice versa: setting a zone updates each
|
|
6
|
+
// member's state in the registry, which fires events, which prompt the
|
|
7
|
+
// per-fixture accessories to update their HomeKit-displayed state).
|
|
8
|
+
//
|
|
9
|
+
// Majority rule for zone state: see pickMajority() — count occurrences of
|
|
10
|
+
// each value across (alphabetically-sorted) members; largest count wins;
|
|
11
|
+
// ties are broken in favour of the value held by the alphabetically-first
|
|
12
|
+
// member.
|
|
13
|
+
|
|
14
|
+
import { HomeKitLightState } from './color/types.js';
|
|
15
|
+
|
|
16
|
+
export type StateChangeListener = (fixtureId: string) => void;
|
|
17
|
+
|
|
18
|
+
const DEFAULT_STATE: HomeKitLightState = { on: false, brightness: 100 };
|
|
19
|
+
|
|
20
|
+
export class StateRegistry {
|
|
21
|
+
private states = new Map<string, HomeKitLightState>();
|
|
22
|
+
private listeners = new Set<StateChangeListener>();
|
|
23
|
+
|
|
24
|
+
get(id: string): HomeKitLightState {
|
|
25
|
+
return this.states.get(id) ?? { ...DEFAULT_STATE };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Replace a fixture's state and fire change listeners. */
|
|
29
|
+
set(id: string, state: HomeKitLightState): void {
|
|
30
|
+
this.states.set(id, { ...state });
|
|
31
|
+
for (const l of this.listeners) l(id);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Merge a partial update into a fixture's state, returning the new full
|
|
35
|
+
* state. Fires listeners. */
|
|
36
|
+
update(id: string, patch: Partial<HomeKitLightState>): HomeKitLightState {
|
|
37
|
+
const next: HomeKitLightState = { ...this.get(id), ...patch };
|
|
38
|
+
this.set(id, next);
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
onChange(cb: StateChangeListener): () => void {
|
|
43
|
+
this.listeners.add(cb);
|
|
44
|
+
return () => this.listeners.delete(cb);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Bucket members' values; return the value with the largest bucket. Ties
|
|
49
|
+
* break in favour of the value held by the first member in
|
|
50
|
+
* alphabetical-id order. */
|
|
51
|
+
export function pickMajority<T>(items: Array<{ id: string; v: T }>): T | undefined {
|
|
52
|
+
if (items.length === 0) return undefined;
|
|
53
|
+
const sorted = items.slice().sort((a, b) => a.id.localeCompare(b.id));
|
|
54
|
+
const counts = new Map<T, { count: number; firstIdx: number }>();
|
|
55
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
56
|
+
const v = sorted[i].v;
|
|
57
|
+
const rec = counts.get(v);
|
|
58
|
+
if (rec) rec.count++;
|
|
59
|
+
else counts.set(v, { count: 1, firstIdx: i });
|
|
60
|
+
}
|
|
61
|
+
let bestV: T = sorted[0].v;
|
|
62
|
+
let bestRec = counts.get(bestV)!;
|
|
63
|
+
for (const [v, rec] of counts) {
|
|
64
|
+
if (rec.count > bestRec.count ||
|
|
65
|
+
(rec.count === bestRec.count && rec.firstIdx < bestRec.firstIdx)) {
|
|
66
|
+
bestV = v;
|
|
67
|
+
bestRec = rec;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return bestV;
|
|
71
|
+
}
|