@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,170 @@
|
|
|
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
|
+
|
|
18
|
+
import type {
|
|
19
|
+
CharacteristicValue,
|
|
20
|
+
PlatformAccessory,
|
|
21
|
+
Service,
|
|
22
|
+
} from 'homebridge';
|
|
23
|
+
|
|
24
|
+
import { Fixture, Zone } from './config.js';
|
|
25
|
+
import { HKCharacteristic, HomeKitLightState } from './color/types.js';
|
|
26
|
+
import { StickController } from './controller.js';
|
|
27
|
+
import type { DmxPlatform } from './platform.js';
|
|
28
|
+
import { CHARACTERISTIC_UPDATE_DELAY_MS } from './settings.js';
|
|
29
|
+
import { StateRegistry, pickMajority } from './stateRegistry.js';
|
|
30
|
+
|
|
31
|
+
export class ZoneFixture {
|
|
32
|
+
private pending: NodeJS.Timeout | null = null;
|
|
33
|
+
private refreshPending: NodeJS.Timeout | null = null;
|
|
34
|
+
private bulb: Service;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly platform: DmxPlatform,
|
|
38
|
+
accessory: PlatformAccessory,
|
|
39
|
+
private readonly zone: Zone,
|
|
40
|
+
private readonly controllers: Map<string, StickController>,
|
|
41
|
+
private readonly registry: StateRegistry,
|
|
42
|
+
) {
|
|
43
|
+
const C = platform.Characteristic;
|
|
44
|
+
const info = accessory.getService(platform.Service.AccessoryInformation);
|
|
45
|
+
info?.setCharacteristic(C.Manufacturer, 'DMX')
|
|
46
|
+
?.setCharacteristic(C.Model, `Zone (${zone.members.length} fixtures)`)
|
|
47
|
+
?.setCharacteristic(C.SerialNumber, `zone-${zone.id}`);
|
|
48
|
+
|
|
49
|
+
this.bulb =
|
|
50
|
+
accessory.getService(platform.Service.Lightbulb)
|
|
51
|
+
?? accessory.addService(platform.Service.Lightbulb, zone.name);
|
|
52
|
+
|
|
53
|
+
this.bulb.setCharacteristic(C.Name, zone.name);
|
|
54
|
+
|
|
55
|
+
const has = (k: HKCharacteristic): boolean => zone.characteristics.includes(k);
|
|
56
|
+
|
|
57
|
+
this.bulb.getCharacteristic(C.On)
|
|
58
|
+
.onGet(() => this.majorityState().on)
|
|
59
|
+
.onSet((v) => this.applyToMembers({ on: Boolean(v) }));
|
|
60
|
+
|
|
61
|
+
if (has('Brightness')) {
|
|
62
|
+
this.bulb.getCharacteristic(C.Brightness)
|
|
63
|
+
.onGet(() => this.majorityState().brightness)
|
|
64
|
+
.onSet((v: CharacteristicValue) => this.applyToMembers({ brightness: Number(v) }));
|
|
65
|
+
}
|
|
66
|
+
if (has('Hue')) {
|
|
67
|
+
this.bulb.getCharacteristic(C.Hue)
|
|
68
|
+
.onGet(() => this.majorityState().hue ?? 0)
|
|
69
|
+
.onSet((v) => this.applyToMembers({ hue: Number(v) }));
|
|
70
|
+
}
|
|
71
|
+
if (has('Saturation')) {
|
|
72
|
+
this.bulb.getCharacteristic(C.Saturation)
|
|
73
|
+
.onGet(() => this.majorityState().saturation ?? 0)
|
|
74
|
+
.onSet((v) => this.applyToMembers({ saturation: Number(v) }));
|
|
75
|
+
}
|
|
76
|
+
if (has('ColorTemperature')) {
|
|
77
|
+
this.bulb.getCharacteristic(C.ColorTemperature)
|
|
78
|
+
.onGet(() => this.majorityState().colorTemperatureMireds ?? 200)
|
|
79
|
+
.onSet((v) => this.applyToMembers({
|
|
80
|
+
colorTemperatureMireds: Number(v),
|
|
81
|
+
saturation: 0,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Refresh on any member change.
|
|
86
|
+
const memberIds = new Set(zone.members.map((m) => m.id));
|
|
87
|
+
registry.onChange((id) => {
|
|
88
|
+
if (memberIds.has(id)) this.scheduleRefresh();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Compute majority state across members, per characteristic. */
|
|
93
|
+
private majorityState(): HomeKitLightState {
|
|
94
|
+
const ss = this.zone.members.map((m) => ({ id: m.id, state: this.registry.get(m.id) }));
|
|
95
|
+
return {
|
|
96
|
+
on: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.on }))) ?? false,
|
|
97
|
+
brightness: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.brightness }))) ?? 100,
|
|
98
|
+
hue: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.hue }))),
|
|
99
|
+
saturation: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.saturation }))),
|
|
100
|
+
colorTemperatureMireds: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.colorTemperatureMireds }))),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A HomeKit set on the zone: build the new state from the current
|
|
105
|
+
* majority + the patch the user is applying, write it into every
|
|
106
|
+
* member, and trigger the controller after a small debounce so
|
|
107
|
+
* multi-characteristic dispatch coalesces. */
|
|
108
|
+
private applyToMembers(patch: Partial<HomeKitLightState>): void {
|
|
109
|
+
const next = { ...this.majorityState(), ...patch };
|
|
110
|
+
// Push the next state into every member's registry entry. This fires
|
|
111
|
+
// per-fixture change events so the individual StickFixture accessories
|
|
112
|
+
// refresh their own HomeKit displays.
|
|
113
|
+
for (const m of this.zone.members) {
|
|
114
|
+
this.registry.update(m.id, next);
|
|
115
|
+
}
|
|
116
|
+
this.scheduleDispatch();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Debounced controller dispatch. Sends current registry state for each
|
|
120
|
+
* member to the appropriate StickController. */
|
|
121
|
+
private scheduleDispatch(): void {
|
|
122
|
+
if (this.pending) clearTimeout(this.pending);
|
|
123
|
+
this.pending = setTimeout(() => {
|
|
124
|
+
this.pending = null;
|
|
125
|
+
const s = this.majorityState();
|
|
126
|
+
this.platform.log.info(
|
|
127
|
+
`[zone:${this.zone.id}] HK set: on=${s.on} br=${s.brightness}` +
|
|
128
|
+
(s.hue != null ? ` h=${s.hue}` : '') +
|
|
129
|
+
(s.saturation != null ? ` s=${s.saturation}` : '') +
|
|
130
|
+
(s.colorTemperatureMireds != null ? ` ct=${s.colorTemperatureMireds}` : '') +
|
|
131
|
+
` → ${this.zone.members.length} members`,
|
|
132
|
+
);
|
|
133
|
+
for (const m of this.zone.members) {
|
|
134
|
+
const c = this.controllers.get(m.controller.id);
|
|
135
|
+
if (!c) {
|
|
136
|
+
this.platform.log.error(`zone "${this.zone.id}": no controller for "${m.id}"`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
c.setFixture(m, this.registry.get(m.id));
|
|
140
|
+
}
|
|
141
|
+
}, CHARACTERISTIC_UPDATE_DELAY_MS);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Debounced refresh: when members change, push the new majority to
|
|
145
|
+
* the zone's HomeKit characteristics. */
|
|
146
|
+
private scheduleRefresh(): void {
|
|
147
|
+
if (this.refreshPending) clearTimeout(this.refreshPending);
|
|
148
|
+
this.refreshPending = setTimeout(() => {
|
|
149
|
+
this.refreshPending = null;
|
|
150
|
+
const s = this.majorityState();
|
|
151
|
+
const C = this.platform.Characteristic;
|
|
152
|
+
this.bulb.updateCharacteristic(C.On, s.on);
|
|
153
|
+
if (this.zone.characteristics.includes('Brightness')) {
|
|
154
|
+
this.bulb.updateCharacteristic(C.Brightness, s.brightness);
|
|
155
|
+
}
|
|
156
|
+
if (this.zone.characteristics.includes('Hue') && s.hue != null) {
|
|
157
|
+
this.bulb.updateCharacteristic(C.Hue, s.hue);
|
|
158
|
+
}
|
|
159
|
+
if (this.zone.characteristics.includes('Saturation') && s.saturation != null) {
|
|
160
|
+
this.bulb.updateCharacteristic(C.Saturation, s.saturation);
|
|
161
|
+
}
|
|
162
|
+
if (this.zone.characteristics.includes('ColorTemperature') && s.colorTemperatureMireds != null) {
|
|
163
|
+
this.bulb.updateCharacteristic(C.ColorTemperature, s.colorTemperatureMireds);
|
|
164
|
+
}
|
|
165
|
+
}, CHARACTERISTIC_UPDATE_DELAY_MS);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Silence "Fixture imported but unused" — kept for potential future use.
|
|
170
|
+
export type _MemberRef = Fixture;
|