@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
@@ -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
+ }
@@ -1,12 +1,19 @@
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
 
11
18
  import type {
12
19
  CharacteristicValue,
@@ -14,21 +21,24 @@ import type {
14
21
  Service,
15
22
  } from 'homebridge';
16
23
 
17
- import { Zone } from './config.js';
24
+ import { Fixture, Zone } from './config.js';
18
25
  import { HKCharacteristic, HomeKitLightState } from './color/types.js';
19
26
  import { StickController } from './controller.js';
20
27
  import type { DmxPlatform } from './platform.js';
21
28
  import { CHARACTERISTIC_UPDATE_DELAY_MS } from './settings.js';
29
+ import { StateRegistry, pickMajority } from './stateRegistry.js';
22
30
 
23
31
  export class ZoneFixture {
24
- private state: HomeKitLightState = { on: false, brightness: 100 };
25
32
  private pending: NodeJS.Timeout | null = null;
33
+ private refreshPending: NodeJS.Timeout | null = null;
34
+ private bulb: Service;
26
35
 
27
36
  constructor(
28
37
  private readonly platform: DmxPlatform,
29
38
  accessory: PlatformAccessory,
30
39
  private readonly zone: Zone,
31
40
  private readonly controllers: Map<string, StickController>,
41
+ private readonly registry: StateRegistry,
32
42
  ) {
33
43
  const C = platform.Characteristic;
34
44
  const info = accessory.getService(platform.Service.AccessoryInformation);
@@ -36,53 +46,83 @@ export class ZoneFixture {
36
46
  ?.setCharacteristic(C.Model, `Zone (${zone.members.length} fixtures)`)
37
47
  ?.setCharacteristic(C.SerialNumber, `zone-${zone.id}`);
38
48
 
39
- const bulb: Service =
49
+ this.bulb =
40
50
  accessory.getService(platform.Service.Lightbulb)
41
51
  ?? accessory.addService(platform.Service.Lightbulb, zone.name);
42
52
 
43
- bulb.setCharacteristic(C.Name, zone.name);
44
-
45
- // Always expose On.
46
- bulb.getCharacteristic(C.On)
47
- .onGet(() => this.state.on)
48
- .onSet((v) => { this.state.on = Boolean(v); this.schedule(); });
53
+ this.bulb.setCharacteristic(C.Name, zone.name);
49
54
 
50
55
  const has = (k: HKCharacteristic): boolean => zone.characteristics.includes(k);
51
56
 
57
+ this.bulb.getCharacteristic(C.On)
58
+ .onGet(() => this.majorityState().on)
59
+ .onSet((v) => this.applyToMembers({ on: Boolean(v) }));
60
+
52
61
  if (has('Brightness')) {
53
- bulb.getCharacteristic(C.Brightness)
54
- .onGet(() => this.state.brightness)
55
- .onSet((v: CharacteristicValue) => {
56
- this.state.brightness = Number(v);
57
- this.schedule();
58
- });
62
+ this.bulb.getCharacteristic(C.Brightness)
63
+ .onGet(() => this.majorityState().brightness)
64
+ .onSet((v: CharacteristicValue) => this.applyToMembers({ brightness: Number(v) }));
59
65
  }
60
66
  if (has('Hue')) {
61
- bulb.getCharacteristic(C.Hue)
62
- .onGet(() => this.state.hue ?? 0)
63
- .onSet((v) => { this.state.hue = Number(v); this.schedule(); });
67
+ this.bulb.getCharacteristic(C.Hue)
68
+ .onGet(() => this.majorityState().hue ?? 0)
69
+ .onSet((v) => this.applyToMembers({ hue: Number(v) }));
64
70
  }
65
71
  if (has('Saturation')) {
66
- bulb.getCharacteristic(C.Saturation)
67
- .onGet(() => this.state.saturation ?? 0)
68
- .onSet((v) => { this.state.saturation = Number(v); this.schedule(); });
72
+ this.bulb.getCharacteristic(C.Saturation)
73
+ .onGet(() => this.majorityState().saturation ?? 0)
74
+ .onSet((v) => this.applyToMembers({ saturation: Number(v) }));
69
75
  }
70
76
  if (has('ColorTemperature')) {
71
- bulb.getCharacteristic(C.ColorTemperature)
72
- .onGet(() => this.state.colorTemperatureMireds ?? 200)
73
- .onSet((v) => {
74
- this.state.colorTemperatureMireds = Number(v);
75
- this.state.saturation = 0;
76
- this.schedule();
77
- });
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);
78
115
  }
116
+ this.scheduleDispatch();
79
117
  }
80
118
 
81
- private schedule(): void {
119
+ /** Debounced controller dispatch. Sends current registry state for each
120
+ * member to the appropriate StickController. */
121
+ private scheduleDispatch(): void {
82
122
  if (this.pending) clearTimeout(this.pending);
83
123
  this.pending = setTimeout(() => {
84
124
  this.pending = null;
85
- const s = this.state;
125
+ const s = this.majorityState();
86
126
  this.platform.log.info(
87
127
  `[zone:${this.zone.id}] HK set: on=${s.on} br=${s.brightness}` +
88
128
  (s.hue != null ? ` h=${s.hue}` : '') +
@@ -90,15 +130,41 @@ export class ZoneFixture {
90
130
  (s.colorTemperatureMireds != null ? ` ct=${s.colorTemperatureMireds}` : '') +
91
131
  ` → ${this.zone.members.length} members`,
92
132
  );
93
- // Dispatch the zone's state to each member via that member's controller.
94
133
  for (const m of this.zone.members) {
95
134
  const c = this.controllers.get(m.controller.id);
96
135
  if (!c) {
97
136
  this.platform.log.error(`zone "${this.zone.id}": no controller for "${m.id}"`);
98
137
  continue;
99
138
  }
100
- c.setFixture(m, this.state);
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);
101
164
  }
102
165
  }, CHARACTERISTIC_UPDATE_DELAY_MS);
103
166
  }
104
167
  }
168
+
169
+ // Silence "Fixture imported but unused" — kept for potential future use.
170
+ export type _MemberRef = Fixture;
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ # stick-power-cycle — hard power-cycle the Nicolaudie Stick-DE3 by turning
3
+ # PoE off/on on its switch port (sw01 port 15, via PoE splitter).
4
+ #
5
+ # Why: the Stick can wedge into a state where TCP sessions and the crypto
6
+ # handshake succeed but live DMX output is blackout (seen 2026-08-22..24);
7
+ # only a real power removal recovers it. The UniFi UI "Power Cycle" bounce
8
+ # is too short — the splitter rides through it. This holds power off for
9
+ # OFF_SECONDS, then restores, then waits for the Stick to answer ping.
10
+ #
11
+ # Credentials: read at runtime from the homebridge config.json (same local
12
+ # UniFi user the AP-RGB plugin uses). Nothing secret is stored here.
13
+ #
14
+ # Scheduled via systemd user timer (host has no cron):
15
+ # ~/.config/systemd/user/stick-power-cycle.{service,timer} — daily 08:30 UTC
16
+ # (= 4:30am EDT; lights are always off then). Lingering enabled for miker so
17
+ # the timer fires without a login session. Logs: ~/stick-power-cycle.log
18
+ # Manual run: systemctl --user start stick-power-cycle.service
19
+
20
+ import json, socket, ssl, sys, time, urllib.request, http.cookiejar
21
+
22
+ CONTROLLER = 'https://192.168.1.1'
23
+ CONFIG_JSON = '/opt/containers/homebridge/config.json'
24
+ SWITCH_MAC = '94:2a:6f:94:f1:da' # sw01
25
+ PORT_IDX = 15 # Stick-DE3 PoE splitter, "dmx" VLAN 96
26
+ STICK_IP = '192.168.96.2'
27
+ OFF_SECONDS = 30
28
+ BOOT_WAIT_S = 180
29
+
30
+ def log(*a):
31
+ print(time.strftime('%Y-%m-%d %H:%M:%S%z'), *a, flush=True)
32
+
33
+ def load_creds():
34
+ cfg = json.load(open(CONFIG_JSON))
35
+ p = next(x for x in cfg['platforms'] if x.get('platform') == 'UnifiAPLight')
36
+ return p['username'], p['password']
37
+
38
+ class Unifi:
39
+ def __init__(self):
40
+ ctx = ssl.create_default_context()
41
+ ctx.check_hostname = False
42
+ ctx.verify_mode = ssl.CERT_NONE
43
+ self.op = urllib.request.build_opener(
44
+ urllib.request.HTTPSHandler(context=ctx),
45
+ urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
46
+ self.csrf = ''
47
+
48
+ def req(self, method, path, body=None):
49
+ rq = urllib.request.Request(CONTROLLER + path, method=method,
50
+ data=json.dumps(body).encode() if body is not None else None,
51
+ headers={'Content-Type': 'application/json'})
52
+ if self.csrf:
53
+ rq.add_header('x-csrf-token', self.csrf)
54
+ r = self.op.open(rq, timeout=15)
55
+ self.csrf = r.headers.get('x-csrf-token', self.csrf)
56
+ return json.load(r)
57
+
58
+ def login(self, user, pw):
59
+ self.req('POST', '/api/auth/login', {'username': user, 'password': pw})
60
+
61
+ def get_switch(self):
62
+ data = self.req('GET', '/proxy/network/api/s/default/stat/device')['data']
63
+ return next(d for d in data if d.get('mac') == SWITCH_MAC)
64
+
65
+ def set_poe(self, poe_mode):
66
+ # port_overrides is replaced wholesale on PUT — merge, never clobber.
67
+ sw = self.get_switch()
68
+ overrides = sw.get('port_overrides', [])
69
+ entry = next((o for o in overrides if o.get('port_idx') == PORT_IDX), None)
70
+ if entry is None:
71
+ entry = {'port_idx': PORT_IDX}
72
+ overrides.append(entry)
73
+ entry['poe_mode'] = poe_mode
74
+ self.req('PUT', f"/proxy/network/api/s/default/rest/device/{sw['_id']}",
75
+ {'port_overrides': overrides})
76
+ log(f'port {PORT_IDX} poe_mode -> {poe_mode}')
77
+
78
+ def stick_up():
79
+ # TCP probe of the Stick's own control port — proves the device booted,
80
+ # not just that the link is up. (The host has no ping binary anyway.)
81
+ try:
82
+ socket.create_connection((STICK_IP, 2431), timeout=2).close()
83
+ return True
84
+ except OSError:
85
+ return False
86
+
87
+ def main():
88
+ log(f'power-cycling Stick-DE3 (sw {SWITCH_MAC} port {PORT_IDX}, {OFF_SECONDS}s off)')
89
+ api = Unifi()
90
+ api.login(*load_creds())
91
+ api.set_poe('off')
92
+ try:
93
+ time.sleep(OFF_SECONDS)
94
+ finally:
95
+ # restore is sacred: retry hard so the port can never stay dark
96
+ for attempt in range(5):
97
+ try:
98
+ api.set_poe('auto')
99
+ break
100
+ except Exception as e:
101
+ log(f'restore attempt {attempt + 1} failed: {e}')
102
+ time.sleep(5)
103
+ try:
104
+ api.login(*load_creds())
105
+ except Exception:
106
+ pass
107
+ else:
108
+ log('FATAL: could not restore PoE — port may be off!')
109
+ sys.exit(2)
110
+
111
+ deadline = time.time() + BOOT_WAIT_S
112
+ while time.time() < deadline:
113
+ if stick_up():
114
+ log(f'Stick back up ({STICK_IP}:2431 accepting connections)')
115
+ return
116
+ time.sleep(5)
117
+ log(f'WARNING: Stick not reachable within {BOOT_WAIT_S}s of power restore')
118
+ sys.exit(1)
119
+
120
+ if __name__ == '__main__':
121
+ main()