@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.
@@ -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;