@ecopoesis/homebridge-dmx 0.1.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 (226) hide show
  1. package/CLAUDE.md +176 -0
  2. package/README.md +146 -0
  3. package/config.schema.json +143 -0
  4. package/dist/cli/stick.d.ts +3 -0
  5. package/dist/cli/stick.d.ts.map +1 -0
  6. package/dist/cli/stick.js +176 -0
  7. package/dist/cli/stick.js.map +1 -0
  8. package/dist/color/cct.d.ts +3 -0
  9. package/dist/color/cct.d.ts.map +1 -0
  10. package/dist/color/cct.js +33 -0
  11. package/dist/color/cct.js.map +1 -0
  12. package/dist/color/dimmer.d.ts +3 -0
  13. package/dist/color/dimmer.d.ts.map +1 -0
  14. package/dist/color/dimmer.js +16 -0
  15. package/dist/color/dimmer.js.map +1 -0
  16. package/dist/color/hsvcct.d.ts +3 -0
  17. package/dist/color/hsvcct.d.ts.map +1 -0
  18. package/dist/color/hsvcct.js +52 -0
  19. package/dist/color/hsvcct.js.map +1 -0
  20. package/dist/color/index.d.ts +7 -0
  21. package/dist/color/index.d.ts.map +1 -0
  22. package/dist/color/index.js +28 -0
  23. package/dist/color/index.js.map +1 -0
  24. package/dist/color/parsers.d.ts +20 -0
  25. package/dist/color/parsers.d.ts.map +1 -0
  26. package/dist/color/parsers.js +232 -0
  27. package/dist/color/parsers.js.map +1 -0
  28. package/dist/color/rgb.d.ts +3 -0
  29. package/dist/color/rgb.d.ts.map +1 -0
  30. package/dist/color/rgb.js +32 -0
  31. package/dist/color/rgb.js.map +1 -0
  32. package/dist/color/rgbaw.d.ts +3 -0
  33. package/dist/color/rgbaw.d.ts.map +1 -0
  34. package/dist/color/rgbaw.js +48 -0
  35. package/dist/color/rgbaw.js.map +1 -0
  36. package/dist/color/rgbw.d.ts +3 -0
  37. package/dist/color/rgbw.d.ts.map +1 -0
  38. package/dist/color/rgbw.js +37 -0
  39. package/dist/color/rgbw.js.map +1 -0
  40. package/dist/color/rgbww.d.ts +3 -0
  41. package/dist/color/rgbww.d.ts.map +1 -0
  42. package/dist/color/rgbww.js +54 -0
  43. package/dist/color/rgbww.js.map +1 -0
  44. package/dist/color/types.d.ts +64 -0
  45. package/dist/color/types.d.ts.map +1 -0
  46. package/dist/color/types.js +91 -0
  47. package/dist/color/types.js.map +1 -0
  48. package/dist/config.d.ts +60 -0
  49. package/dist/config.d.ts.map +1 -0
  50. package/dist/config.js +191 -0
  51. package/dist/config.js.map +1 -0
  52. package/dist/controller.d.ts +32 -0
  53. package/dist/controller.d.ts.map +1 -0
  54. package/dist/controller.js +128 -0
  55. package/dist/controller.js.map +1 -0
  56. package/dist/index.d.ts +4 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +7 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/platform.d.ts +17 -0
  61. package/dist/platform.d.ts.map +1 -0
  62. package/dist/platform.js +96 -0
  63. package/dist/platform.js.map +1 -0
  64. package/dist/platformAccessory.d.ts +18 -0
  65. package/dist/platformAccessory.d.ts.map +1 -0
  66. package/dist/platformAccessory.js +81 -0
  67. package/dist/platformAccessory.js.map +1 -0
  68. package/dist/settings.d.ts +14 -0
  69. package/dist/settings.d.ts.map +1 -0
  70. package/dist/settings.js +15 -0
  71. package/dist/settings.js.map +1 -0
  72. package/dist/stick/frame.d.ts +16 -0
  73. package/dist/stick/frame.d.ts.map +1 -0
  74. package/dist/stick/frame.js +51 -0
  75. package/dist/stick/frame.js.map +1 -0
  76. package/dist/stick/kdf.d.ts +23 -0
  77. package/dist/stick/kdf.d.ts.map +1 -0
  78. package/dist/stick/kdf.js +90 -0
  79. package/dist/stick/kdf.js.map +1 -0
  80. package/dist/stick/protocol.d.ts +28 -0
  81. package/dist/stick/protocol.d.ts.map +1 -0
  82. package/dist/stick/protocol.js +78 -0
  83. package/dist/stick/protocol.js.map +1 -0
  84. package/dist/stick/session.d.ts +50 -0
  85. package/dist/stick/session.d.ts.map +1 -0
  86. package/dist/stick/session.js +301 -0
  87. package/dist/stick/session.js.map +1 -0
  88. package/examples/dmx.yaml +65 -0
  89. package/package.json +51 -0
  90. package/src/cli/stick.ts +198 -0
  91. package/src/color/cct.ts +40 -0
  92. package/src/color/dimmer.ts +24 -0
  93. package/src/color/hsvcct.ts +59 -0
  94. package/src/color/index.ts +34 -0
  95. package/src/color/parsers.ts +232 -0
  96. package/src/color/rgb.ts +33 -0
  97. package/src/color/rgbaw.ts +46 -0
  98. package/src/color/rgbw.ts +36 -0
  99. package/src/color/rgbww.ts +56 -0
  100. package/src/color/types.ts +150 -0
  101. package/src/config.ts +248 -0
  102. package/src/controller.ts +136 -0
  103. package/src/index.ts +9 -0
  104. package/src/platform.ts +118 -0
  105. package/src/platformAccessory.ts +102 -0
  106. package/src/settings.ts +18 -0
  107. package/src/stick/frame.ts +70 -0
  108. package/src/stick/kdf.ts +99 -0
  109. package/src/stick/protocol.ts +90 -0
  110. package/src/stick/session.ts +348 -0
  111. package/stick-de3.json +6 -0
  112. package/tools/aes-hook.c +1404 -0
  113. package/tools/analyze-pcap.mjs +143 -0
  114. package/tools/capture-aes-key.lldb +25 -0
  115. package/tools/capture-and-verify.sh +46 -0
  116. package/tools/capture-handshake.sh +59 -0
  117. package/tools/capture-send.sh +39 -0
  118. package/tools/crack-kdf.mjs +121 -0
  119. package/tools/crack-key.mjs +170 -0
  120. package/tools/decode-frame.mjs +110 -0
  121. package/tools/decrypt-011c.mjs +123 -0
  122. package/tools/decrypt-dmx.mjs +130 -0
  123. package/tools/decrypt-ssl2.ts +154 -0
  124. package/tools/derive-dmx-key.mjs +189 -0
  125. package/tools/diff-011c.mjs +127 -0
  126. package/tools/dump-pcap-summary.mjs +54 -0
  127. package/tools/dump-tcp-stream.mjs +123 -0
  128. package/tools/extract-011c.mjs +117 -0
  129. package/tools/extract-frame.mjs +78 -0
  130. package/tools/find-011c-key.mjs +140 -0
  131. package/tools/find-iv.mjs +153 -0
  132. package/tools/ghidra/README.md +136 -0
  133. package/tools/ghidra/__pycache__/dump-011c-callers.cpython-313.pyc +0 -0
  134. package/tools/ghidra/__pycache__/dump-011c-handler.cpython-313.pyc +0 -0
  135. package/tools/ghidra/__pycache__/dump-aes-candidates.cpython-313.pyc +0 -0
  136. package/tools/ghidra/__pycache__/dump-anchors.cpython-313.pyc +0 -0
  137. package/tools/ghidra/__pycache__/dump-auth2.cpython-313.pyc +0 -0
  138. package/tools/ghidra/__pycache__/dump-authenticate.cpython-313.pyc +0 -0
  139. package/tools/ghidra/__pycache__/dump-block-cipher.cpython-313.pyc +0 -0
  140. package/tools/ghidra/__pycache__/dump-cipher.cpython-313.pyc +0 -0
  141. package/tools/ghidra/__pycache__/dump-ctor-chain.cpython-313.pyc +0 -0
  142. package/tools/ghidra/__pycache__/dump-curve.cpython-313.pyc +0 -0
  143. package/tools/ghidra/__pycache__/dump-encompassing.cpython-313.pyc +0 -0
  144. package/tools/ghidra/__pycache__/dump-handshake.cpython-313.pyc +0 -0
  145. package/tools/ghidra/__pycache__/dump-kdf-chain.cpython-313.pyc +0 -0
  146. package/tools/ghidra/__pycache__/dump-kdf-context.cpython-313.pyc +0 -0
  147. package/tools/ghidra/__pycache__/dump-profile.cpython-313.pyc +0 -0
  148. package/tools/ghidra/__pycache__/dump-real-encrypt.cpython-313.pyc +0 -0
  149. package/tools/ghidra/__pycache__/dump-secret.cpython-313.pyc +0 -0
  150. package/tools/ghidra/__pycache__/dump-setsoftware.cpython-313.pyc +0 -0
  151. package/tools/ghidra/__pycache__/dump-stick3-vtable.cpython-313.pyc +0 -0
  152. package/tools/ghidra/__pycache__/dump-swxref.cpython-313.pyc +0 -0
  153. package/tools/ghidra/__pycache__/dump-vt-key-getter.cpython-313.pyc +0 -0
  154. package/tools/ghidra/__pycache__/find-cipher.cpython-313.pyc +0 -0
  155. package/tools/ghidra/__pycache__/find-ctor.cpython-313.pyc +0 -0
  156. package/tools/ghidra/__pycache__/find-md5.cpython-313.pyc +0 -0
  157. package/tools/ghidra/__pycache__/find-secret-users.cpython-313.pyc +0 -0
  158. package/tools/ghidra/__pycache__/find-stick3-crypto.cpython-313.pyc +0 -0
  159. package/tools/ghidra/__pycache__/find-stick3-device.cpython-313.pyc +0 -0
  160. package/tools/ghidra/__pycache__/find-stick3a-ctor.cpython-313.pyc +0 -0
  161. package/tools/ghidra/__pycache__/find-stick3anet-ctor.cpython-313.pyc +0 -0
  162. package/tools/ghidra/__pycache__/find-vtables.cpython-313.pyc +0 -0
  163. package/tools/ghidra/__pycache__/string-walk.cpython-313.pyc +0 -0
  164. package/tools/ghidra/dump-011c-callers.py +140 -0
  165. package/tools/ghidra/dump-011c-handler.py +158 -0
  166. package/tools/ghidra/dump-aes-candidates.py +99 -0
  167. package/tools/ghidra/dump-anchors.py +192 -0
  168. package/tools/ghidra/dump-auth2.py +50 -0
  169. package/tools/ghidra/dump-authenticate.py +71 -0
  170. package/tools/ghidra/dump-block-cipher.py +88 -0
  171. package/tools/ghidra/dump-cipher.py +106 -0
  172. package/tools/ghidra/dump-ctor-chain.py +107 -0
  173. package/tools/ghidra/dump-curve.py +112 -0
  174. package/tools/ghidra/dump-encompassing.py +146 -0
  175. package/tools/ghidra/dump-handshake.py +76 -0
  176. package/tools/ghidra/dump-kdf-chain.py +105 -0
  177. package/tools/ghidra/dump-kdf-context.py +72 -0
  178. package/tools/ghidra/dump-profile.py +59 -0
  179. package/tools/ghidra/dump-real-encrypt.py +90 -0
  180. package/tools/ghidra/dump-secret.py +84 -0
  181. package/tools/ghidra/dump-setsoftware.py +63 -0
  182. package/tools/ghidra/dump-stick3-vtable.py +182 -0
  183. package/tools/ghidra/dump-swxref.py +27 -0
  184. package/tools/ghidra/dump-vt-key-getter.py +170 -0
  185. package/tools/ghidra/find-cipher.py +210 -0
  186. package/tools/ghidra/find-ctor.py +109 -0
  187. package/tools/ghidra/find-md5.py +97 -0
  188. package/tools/ghidra/find-secret-users.py +98 -0
  189. package/tools/ghidra/find-stick3-crypto.py +190 -0
  190. package/tools/ghidra/find-stick3-device.py +111 -0
  191. package/tools/ghidra/find-stick3a-ctor.py +87 -0
  192. package/tools/ghidra/find-stick3anet-ctor.py +76 -0
  193. package/tools/ghidra/find-vtables.py +165 -0
  194. package/tools/ghidra/out-011c-callers-run.log +51 -0
  195. package/tools/ghidra/out-011c-run.log +50 -0
  196. package/tools/ghidra/out-vt-key-run.log +55 -0
  197. package/tools/ghidra/string-walk.py +180 -0
  198. package/tools/hmac-key.sh +51 -0
  199. package/tools/hwm-entitlements.plist +18 -0
  200. package/tools/inspect-plaintext.mjs +87 -0
  201. package/tools/lldb-capture-aes.py +137 -0
  202. package/tools/lldb_hmac_key.py +93 -0
  203. package/tools/lldb_pathA_scan.py +341 -0
  204. package/tools/lldb_pathC_keytrace.py +300 -0
  205. package/tools/lldb_pathD_kdf_watch.py +281 -0
  206. package/tools/pathA-launch-hwm.sh +86 -0
  207. package/tools/pathA-scan.sh +52 -0
  208. package/tools/pathC-keytrace.sh +49 -0
  209. package/tools/pathC-run.sh +71 -0
  210. package/tools/pathD-kdf-watch.sh +51 -0
  211. package/tools/quick-trigger.mjs +86 -0
  212. package/tools/run-hooked-hwmanager.sh +51 -0
  213. package/tools/scan-aes-schedule.mjs +135 -0
  214. package/tools/send_dmx.mjs +480 -0
  215. package/tools/stick-status.mjs +57 -0
  216. package/tools/test-aes-detector.mjs +100 -0
  217. package/tools/test-tcp-write.ts +274 -0
  218. package/tools/test-udp-dmx.ts +324 -0
  219. package/tools/test-x25519.mjs +87 -0
  220. package/tools/try-decrypt.ts +144 -0
  221. package/tools/try-hardcoded-key.mjs +86 -0
  222. package/tools/try-hardcoded-key.ts +103 -0
  223. package/tools/try-key.mjs +103 -0
  224. package/tools/verify-011c-key.mjs +121 -0
  225. package/tools/verify-kdf.mjs +142 -0
  226. package/tsconfig.json +19 -0
@@ -0,0 +1,36 @@
1
+ // rgbw — RGB + single White channel.
2
+ // White component extracted as min(R,G,B); the colored channels get
3
+ // (channel - white) so the total spectrum stays correct.
4
+
5
+ import {
6
+ ChannelDef,
7
+ ColorModel,
8
+ HomeKitLightState,
9
+ hsvToRgb01,
10
+ rgbBytes,
11
+ requireRoles,
12
+ } from './types.js';
13
+
14
+ export const rgbw: ColorModel = {
15
+ id: 'rgbw',
16
+ characteristics: ['On', 'Brightness', 'Hue', 'Saturation'],
17
+ validate(channels) {
18
+ requireRoles(channels, ['red', 'green', 'blue', 'white']);
19
+ },
20
+ render(state: HomeKitLightState, channels: ChannelDef[]): Uint8Array {
21
+ const rgb01 = hsvToRgb01(state.hue ?? 0, state.saturation ?? 0);
22
+ const px = rgbBytes(rgb01, state);
23
+ const w = Math.min(px.r, px.g, px.b);
24
+ const out = new Uint8Array(channels.length);
25
+ for (let i = 0; i < channels.length; i++) {
26
+ if (channels[i].fine) continue;
27
+ switch (channels[i].role) {
28
+ case 'red': out[i] = px.r - w; break;
29
+ case 'green': out[i] = px.g - w; break;
30
+ case 'blue': out[i] = px.b - w; break;
31
+ case 'white': out[i] = w; break;
32
+ }
33
+ }
34
+ return out;
35
+ },
36
+ };
@@ -0,0 +1,56 @@
1
+ // rgbww — RGB + WarmWhite + CoolWhite. The extracted white component
2
+ // is split between WW and CW based on the HomeKit CCT (mireds), falling
3
+ // back to 50/50 when CCT isn't set. WW channel optionally declares K=K
4
+ // (e.g. "WarmWhite 2700") and CW does the same; if absent, defaults
5
+ // 2700K (WW) / 6500K (CW) are used.
6
+
7
+ import {
8
+ ChannelDef,
9
+ ColorModel,
10
+ HomeKitLightState,
11
+ clamp,
12
+ hsvToRgb01,
13
+ rgbBytes,
14
+ requireRoles,
15
+ } from './types.js';
16
+
17
+ const DEFAULT_WW_K = 2700;
18
+ const DEFAULT_CW_K = 6500;
19
+
20
+ export const rgbww: ColorModel = {
21
+ id: 'rgbww',
22
+ characteristics: ['On', 'Brightness', 'Hue', 'Saturation', 'ColorTemperature'],
23
+ validate(channels) {
24
+ requireRoles(channels, ['red', 'green', 'blue', 'warm-white', 'cool-white']);
25
+ },
26
+ render(state: HomeKitLightState, channels: ChannelDef[]): Uint8Array {
27
+ const wwK = channels.find((c) => c.role === 'warm-white')?.kMin ?? DEFAULT_WW_K;
28
+ const cwK = channels.find((c) => c.role === 'cool-white')?.kMin ?? DEFAULT_CW_K;
29
+
30
+ const rgb01 = hsvToRgb01(state.hue ?? 0, state.saturation ?? 0);
31
+ const px = rgbBytes(rgb01, state);
32
+ const w = Math.min(px.r, px.g, px.b);
33
+
34
+ // Mix ratio: when CCT is at WW endpoint -> all WW; at CW endpoint -> all CW.
35
+ let warmFrac = 0.5;
36
+ if (state.colorTemperatureMireds != null && state.colorTemperatureMireds > 0) {
37
+ const k = 1_000_000 / state.colorTemperatureMireds;
38
+ warmFrac = clamp((cwK - k) / (cwK - wwK), 0, 1);
39
+ }
40
+ const ww = Math.round(w * warmFrac);
41
+ const cw = w - ww;
42
+
43
+ const out = new Uint8Array(channels.length);
44
+ for (let i = 0; i < channels.length; i++) {
45
+ if (channels[i].fine) continue;
46
+ switch (channels[i].role) {
47
+ case 'red': out[i] = px.r - w; break;
48
+ case 'green': out[i] = px.g - w; break;
49
+ case 'blue': out[i] = px.b - w; break;
50
+ case 'warm-white': out[i] = ww; break;
51
+ case 'cool-white': out[i] = cw; break;
52
+ }
53
+ }
54
+ return out;
55
+ },
56
+ };
@@ -0,0 +1,150 @@
1
+ // Color-model interfaces.
2
+ //
3
+ // A `ColorModel` translates the canonical HomeKit lightbulb state into the
4
+ // byte values a fixture expects, given that fixture's `channel_order` from
5
+ // the patch config. The optional `parse` is a placeholder for future
6
+ // state-read support; not implemented in v1.
7
+
8
+ export type HKCharacteristic =
9
+ | 'On'
10
+ | 'Brightness'
11
+ | 'Hue'
12
+ | 'Saturation'
13
+ | 'ColorTemperature';
14
+
15
+ /** Canonical HomeKit state. Optional fields are populated only for models
16
+ * that expose the corresponding characteristic. */
17
+ export interface HomeKitLightState {
18
+ on: boolean;
19
+ brightness: number; // 0..100 (percent)
20
+ hue?: number; // 0..360 (degrees)
21
+ saturation?: number; // 0..100 (percent)
22
+ colorTemperatureMireds?: number; // 50..400 (HomeKit mired range)
23
+ }
24
+
25
+ export type ChannelRole =
26
+ | 'intensity'
27
+ | 'cct'
28
+ | 'saturation'
29
+ | 'hue'
30
+ | 'red'
31
+ | 'green'
32
+ | 'blue'
33
+ | 'white'
34
+ | 'warm-white'
35
+ | 'cool-white'
36
+ | 'amber';
37
+
38
+ /** One entry from a profile's `channel_order`. Produced by `parseChannelName`.
39
+ * `fine: true` means the byte at this slot is the low 8 bits of the 16-bit
40
+ * value paired with the immediately preceding non-fine entry of the same role. */
41
+ export interface ChannelDef {
42
+ role: ChannelRole;
43
+ fine?: boolean;
44
+ /** For CCT channels: Kelvin range. Required for CCT to mireds<->byte mapping. */
45
+ kMin?: number;
46
+ kMax?: number;
47
+ }
48
+
49
+ export interface ColorModel {
50
+ /** Lowercase identifier used in YAML/JSON config (`colormodel: hsvcct`). */
51
+ readonly id: string;
52
+ /** HomeKit characteristics this model wants on its fixture. */
53
+ readonly characteristics: HKCharacteristic[];
54
+ /** Throw if `channels` is inconsistent with this model's requirements. */
55
+ validate(channels: ChannelDef[]): void;
56
+ /** Produce a byte per `channels` entry, in order, given the HomeKit state. */
57
+ render(state: HomeKitLightState, channels: ChannelDef[]): Uint8Array;
58
+ /** Optional reverse direction; not implemented in v1. */
59
+ parse?(bytes: Uint8Array, channels: ChannelDef[]): HomeKitLightState;
60
+ }
61
+
62
+ // ── shared helpers ─────────────────────────────────────────────────────────
63
+
64
+ /** Map HomeKit brightness (0..100) to a 16-bit value (0..65535) honoring On. */
65
+ export function brightness16(state: HomeKitLightState): number {
66
+ if (!state.on) return 0;
67
+ const pct = clamp(state.brightness, 0, 100);
68
+ return Math.round((pct / 100) * 65535);
69
+ }
70
+
71
+ /** Map HomeKit brightness (0..100) to an 8-bit value (0..255) honoring On. */
72
+ export function brightness8(state: HomeKitLightState): number {
73
+ if (!state.on) return 0;
74
+ return Math.round((clamp(state.brightness, 0, 100) / 100) * 255);
75
+ }
76
+
77
+ /** Mireds → Kelvin → 0..255 byte across [kMin, kMax]. */
78
+ export function ctByte(mireds: number | undefined, kMin: number, kMax: number): number {
79
+ if (mireds == null || mireds <= 0) return 128;
80
+ const k = 1_000_000 / mireds;
81
+ const t = (k - kMin) / (kMax - kMin);
82
+ return Math.round(clamp(t, 0, 1) * 255);
83
+ }
84
+
85
+ /** HSV → RGB at V=1 (the brightness is applied separately on top — same idiom
86
+ * as unifi-ap-rgb so we don't double-darken). */
87
+ export function hsvToRgb01(hueDeg: number, satPct: number): { r: number; g: number; b: number } {
88
+ const h = ((hueDeg % 360) + 360) % 360;
89
+ const s = clamp(satPct, 0, 100) / 100;
90
+ const c = s; // chroma when V = 1
91
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
92
+ let r1 = 0, g1 = 0, b1 = 0;
93
+ if (h < 60) [r1, g1, b1] = [c, x, 0];
94
+ else if (h < 120) [r1, g1, b1] = [x, c, 0];
95
+ else if (h < 180) [r1, g1, b1] = [0, c, x];
96
+ else if (h < 240) [r1, g1, b1] = [0, x, c];
97
+ else if (h < 300) [r1, g1, b1] = [x, 0, c];
98
+ else [r1, g1, b1] = [c, 0, x];
99
+ const m = 1 - c; // V - c with V = 1
100
+ return { r: r1 + m, g: g1 + m, b: b1 + m };
101
+ }
102
+
103
+ /** Scale 0..1 RGB by brightness (0..100) and on/off, return 0..255 bytes. */
104
+ export function rgbBytes(rgb01: { r: number; g: number; b: number }, state: HomeKitLightState):
105
+ { r: number; g: number; b: number } {
106
+ const m = state.on ? clamp(state.brightness, 0, 100) / 100 : 0;
107
+ return {
108
+ r: Math.round(rgb01.r * m * 255),
109
+ g: Math.round(rgb01.g * m * 255),
110
+ b: Math.round(rgb01.b * m * 255),
111
+ };
112
+ }
113
+
114
+ export function clamp(v: number, lo: number, hi: number): number {
115
+ return v < lo ? lo : v > hi ? hi : v;
116
+ }
117
+
118
+ /** Tag a single-byte channel index plus its fine companion (if any), and
119
+ * emit the right 16-bit split into `out`. Returns the index advanced past
120
+ * any fine companion. */
121
+ export function writeMaybe16(
122
+ out: Uint8Array,
123
+ channels: ChannelDef[],
124
+ i: number,
125
+ value16: number,
126
+ ): number {
127
+ const coarse = (value16 >> 8) & 0xff;
128
+ const fine = value16 & 0xff;
129
+ out[i] = coarse;
130
+ if (channels[i + 1]?.fine && channels[i + 1].role === channels[i].role) {
131
+ out[i + 1] = fine;
132
+ return i + 2;
133
+ }
134
+ return i + 1;
135
+ }
136
+
137
+ /** Validate that `roles` is exactly the set of (coarse) roles in `channels`. */
138
+ export function requireRoles(channels: ChannelDef[], roles: ChannelRole[]): void {
139
+ const present = channels.filter((c) => !c.fine).map((c) => c.role);
140
+ for (const r of roles) {
141
+ if (!present.includes(r)) {
142
+ throw new Error(`channel_order missing required role '${r}'`);
143
+ }
144
+ }
145
+ for (const r of present) {
146
+ if (!roles.includes(r)) {
147
+ throw new Error(`channel_order has unexpected role '${r}' for this model`);
148
+ }
149
+ }
150
+ }
package/src/config.ts ADDED
@@ -0,0 +1,248 @@
1
+ // Config loader + validator.
2
+ //
3
+ // Inputs:
4
+ // - Homebridge config: `platforms[].DMX` block from Homebridge's
5
+ // config.json. Optional `yamlPath` points to an external YAML file
6
+ // whose contents take precedence over the JSON.
7
+ // - YAML override: same shape as the JSON block.
8
+ //
9
+ // Shape:
10
+ // {
11
+ // "platform": "DMX",
12
+ // "name": "DMX",
13
+ // "yamlPath": "/optional/path/to/dmx.yaml",
14
+ // "controllers": [
15
+ // { "id": "main", "type": "StickDE3", "ip": "192.168.96.2" }
16
+ // ],
17
+ // "profiles": [
18
+ // { "name": "WAC", "colormodel": "hsvcct",
19
+ // "channel_order": ["Intensity", "Intensity (Fine)",
20
+ // "ColorTemp 1650-8000", "Saturation", "Hue"] }
21
+ // ],
22
+ // "patch": [
23
+ // { "id": "a_down", "name": "A Down", "type": "WAC",
24
+ // "controller": "main", "universe": "A", "start": 6 }
25
+ // ]
26
+ // }
27
+ //
28
+ // Output: a normalized `LoadedConfig` the platform consumes. Validates:
29
+ // - controllers: unique ids, supported type, ip present
30
+ // - profiles: colormodel recognised, channel_order parses cleanly, model
31
+ // requirements satisfied
32
+ // - patch: unique ids, references a known profile + controller, valid
33
+ // universe ∈ {A,B}, start in [1, 512-nch+1], no slot overlap within a
34
+ // single (controller, universe).
35
+
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import yaml from 'js-yaml';
39
+
40
+ import { ChannelDef, ColorModel } from './color/types.js';
41
+ import { getColorModel } from './color/index.js';
42
+ import { parseChannelName } from './color/parsers.js';
43
+
44
+ export interface ControllerSpec {
45
+ id: string; // unique handle, referenced from patch entries
46
+ type: string; // 'StickDE3' (only one supported today)
47
+ ip: string; // controller's static IP
48
+ }
49
+
50
+ export interface ProfileSpec {
51
+ name: string;
52
+ colormodel: string;
53
+ channel_order: string[];
54
+ }
55
+
56
+ export interface PatchSpec {
57
+ id: string; // unique handle (kebab-case)
58
+ name: string; // human-friendly HomeKit name
59
+ type: string; // profile name (refers to a ProfileSpec.name)
60
+ controller?: string; // controller id; required if >1 controller configured
61
+ universe?: string; // 'A' or 'B'; default 'A'
62
+ start: number; // 1-based DMX start address
63
+ }
64
+
65
+ export interface RawConfig {
66
+ name?: string;
67
+ yamlPath?: string;
68
+ controllers?: ControllerSpec[];
69
+ profiles?: ProfileSpec[];
70
+ patch?: PatchSpec[];
71
+ }
72
+
73
+ export interface Profile {
74
+ name: string;
75
+ model: ColorModel;
76
+ channels: ChannelDef[];
77
+ }
78
+
79
+ export const CONTROLLER_TYPES = ['StickDE3'] as const;
80
+ export type ControllerType = typeof CONTROLLER_TYPES[number];
81
+
82
+ export interface Controller {
83
+ id: string;
84
+ type: ControllerType;
85
+ ip: string;
86
+ }
87
+
88
+ export interface Fixture {
89
+ id: string;
90
+ name: string;
91
+ profile: Profile;
92
+ controller: Controller;
93
+ universe: number; // 0 (A) or 1 (B)
94
+ startCh: number; // 1..512
95
+ nChannels: number; // = channels.length
96
+ }
97
+
98
+ export interface LoadedConfig {
99
+ name: string;
100
+ controllers: Controller[];
101
+ fixtures: Fixture[];
102
+ }
103
+
104
+ export const DEFAULT_PLATFORM_NAME = 'DMX';
105
+
106
+ /** Load + validate the platform config. `rawJson` is the Homebridge
107
+ * `platforms[]` entry; if it specifies `yamlPath`, that YAML file is loaded
108
+ * and treated as a full override of the JSON.
109
+ *
110
+ * If `cwd` is passed, relative yamlPath is resolved against it. */
111
+ export function loadConfig(rawJson: RawConfig, cwd?: string): LoadedConfig {
112
+ let raw: RawConfig = rawJson;
113
+ if (raw.yamlPath) {
114
+ const p = path.isAbsolute(raw.yamlPath) ? raw.yamlPath
115
+ : path.resolve(cwd ?? process.cwd(), raw.yamlPath);
116
+ const text = fs.readFileSync(p, 'utf8');
117
+ const parsed = yaml.load(text) as RawConfig;
118
+ if (!parsed || typeof parsed !== 'object') {
119
+ throw new Error(`yamlPath ${p}: not an object`);
120
+ }
121
+ raw = { ...rawJson, ...parsed };
122
+ }
123
+
124
+ // Controllers
125
+ const controllerSpecs = raw.controllers ?? [];
126
+ if (controllerSpecs.length === 0) {
127
+ throw new Error('config: at least one controller required under "controllers"');
128
+ }
129
+ const controllersById = new Map<string, Controller>();
130
+ for (const cs of controllerSpecs) {
131
+ if (!cs.id) throw new Error('controller: missing id');
132
+ if (controllersById.has(cs.id)) {
133
+ throw new Error(`controller: duplicate id "${cs.id}"`);
134
+ }
135
+ if (!CONTROLLER_TYPES.includes(cs.type as ControllerType)) {
136
+ throw new Error(
137
+ `controller "${cs.id}": unknown type "${cs.type}". ` +
138
+ `Supported: ${CONTROLLER_TYPES.join(', ')}`,
139
+ );
140
+ }
141
+ if (!cs.ip || typeof cs.ip !== 'string') {
142
+ throw new Error(`controller "${cs.id}": required field "ip" missing`);
143
+ }
144
+ controllersById.set(cs.id, { id: cs.id, type: cs.type as ControllerType, ip: cs.ip });
145
+ }
146
+
147
+ // Profiles
148
+ const profileSpecs = raw.profiles ?? [];
149
+ const profilesByName = new Map<string, Profile>();
150
+ for (const ps of profileSpecs) {
151
+ if (!ps.name) throw new Error('profile: missing name');
152
+ if (profilesByName.has(ps.name)) {
153
+ throw new Error(`profile: duplicate name "${ps.name}"`);
154
+ }
155
+ if (!Array.isArray(ps.channel_order) || ps.channel_order.length === 0) {
156
+ throw new Error(`profile "${ps.name}": channel_order must be a non-empty array`);
157
+ }
158
+ let model: ColorModel;
159
+ try { model = getColorModel(ps.colormodel); }
160
+ catch (e) { throw new Error(`profile "${ps.name}": ${(e as Error).message}`); }
161
+ let channels: ChannelDef[];
162
+ try { channels = ps.channel_order.map(parseChannelName); }
163
+ catch (e) { throw new Error(`profile "${ps.name}" channel_order: ${(e as Error).message}`); }
164
+ try { model.validate(channels); }
165
+ catch (e) { throw new Error(`profile "${ps.name}": ${(e as Error).message}`); }
166
+ profilesByName.set(ps.name, { name: ps.name, model, channels });
167
+ }
168
+
169
+ // Patch
170
+ const patchSpecs = raw.patch ?? [];
171
+ const fixtures: Fixture[] = [];
172
+ const seenIds = new Set<string>();
173
+ // Per controller + per universe: 512 channels, tracking which fixture
174
+ // owns each slot. Key is `${controllerId}:${universe}`.
175
+ const occupancy: Map<string, Map<number, string>> = new Map();
176
+ const slots = (cid: string, u: number): Map<number, string> => {
177
+ const key = `${cid}:${u}`;
178
+ let m = occupancy.get(key);
179
+ if (!m) { m = new Map(); occupancy.set(key, m); }
180
+ return m;
181
+ };
182
+
183
+ for (const ps of patchSpecs) {
184
+ if (!ps.id) throw new Error('patch: missing id');
185
+ if (seenIds.has(ps.id)) {
186
+ throw new Error(`patch: duplicate id "${ps.id}"`);
187
+ }
188
+ seenIds.add(ps.id);
189
+ const profile = profilesByName.get(ps.type);
190
+ if (!profile) {
191
+ throw new Error(`patch "${ps.id}": unknown profile "${ps.type}"`);
192
+ }
193
+ // controller reference. If only one controller is configured, default to it.
194
+ let controllerId = ps.controller;
195
+ if (!controllerId) {
196
+ if (controllersById.size === 1) {
197
+ controllerId = controllersById.keys().next().value as string;
198
+ } else {
199
+ throw new Error(
200
+ `patch "${ps.id}": "controller" required when more than one controller is configured`,
201
+ );
202
+ }
203
+ }
204
+ const controller = controllersById.get(controllerId);
205
+ if (!controller) {
206
+ throw new Error(`patch "${ps.id}": unknown controller "${controllerId}"`);
207
+ }
208
+ const universeChar = (ps.universe ?? 'A').toUpperCase();
209
+ if (universeChar !== 'A' && universeChar !== 'B') {
210
+ throw new Error(`patch "${ps.id}": universe must be 'A' or 'B', got "${ps.universe}"`);
211
+ }
212
+ const universe = universeChar === 'A' ? 0 : 1;
213
+ const nch = profile.channels.length;
214
+ const start = Number(ps.start);
215
+ if (!Number.isInteger(start) || start < 1 || start + nch - 1 > 512) {
216
+ throw new Error(
217
+ `patch "${ps.id}": start ${ps.start} + ${nch} channels does not fit in 1..512`,
218
+ );
219
+ }
220
+ const slotMap = slots(controllerId, universe);
221
+ for (let i = 0; i < nch; i++) {
222
+ const slot = start + i;
223
+ const owner = slotMap.get(slot);
224
+ if (owner) {
225
+ throw new Error(
226
+ `patch "${ps.id}": DMX slot ${slot}@${universeChar} ` +
227
+ `(controller "${controllerId}") overlaps with "${owner}"`,
228
+ );
229
+ }
230
+ slotMap.set(slot, ps.id);
231
+ }
232
+ fixtures.push({
233
+ id: ps.id,
234
+ name: ps.name || ps.id,
235
+ profile,
236
+ controller,
237
+ universe,
238
+ startCh: start,
239
+ nChannels: nch,
240
+ });
241
+ }
242
+
243
+ return {
244
+ name: raw.name ?? DEFAULT_PLATFORM_NAME,
245
+ controllers: [...controllersById.values()],
246
+ fixtures,
247
+ };
248
+ }
@@ -0,0 +1,136 @@
1
+ // StickController — single shared session manager for the platform.
2
+ //
3
+ // Behavior:
4
+ // - HomeKit calls setFixture(fixture, state) any number of times
5
+ // - The first call opens a StickSession and starts streaming at 25 Hz
6
+ // - Subsequent calls within DEBOUNCE_MS just update the targets and
7
+ // extend the streaming window
8
+ // - When DEBOUNCE_MS passes with no new calls, RST the session → latch
9
+ // - If a call arrives during teardown, immediately re-open and continue
10
+
11
+ import type { Logger } from 'homebridge';
12
+ import { spawn } from 'node:child_process';
13
+ import { fileURLToPath } from 'node:url';
14
+ import path from 'node:path';
15
+
16
+ import { Fixture } from './config.js';
17
+ import { HomeKitLightState } from './color/types.js';
18
+ import { DEBOUNCE_MS } from './settings.js';
19
+ import { sleep } from './stick/protocol.js';
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+ // dist/controller.js → ../tools/send_dmx.mjs
24
+ const SEND_DMX_PATH = path.resolve(__dirname, '..', 'tools', 'send_dmx.mjs');
25
+
26
+ export class StickController {
27
+ private targets = new Map<number, Uint8Array>();
28
+ private lastChangeAt = 0;
29
+ private streaming = false;
30
+ /** Tracks whether the very first subprocess in this controller's
31
+ * lifetime should set RUN_PROBE=1. After a Stick power-cycle (which
32
+ * we can't detect in-band) the probe is required to (re-)register
33
+ * the client. Subsequent subprocesses can skip it. */
34
+ private needsProbe = true;
35
+
36
+ constructor(
37
+ private readonly ip: string,
38
+ private readonly log: Logger,
39
+ private readonly debounceMs: number = DEBOUNCE_MS,
40
+ ) {}
41
+
42
+ /** Stage a fixture's bytes into the universe target buffer and ensure
43
+ * the streaming loop is running. */
44
+ setFixture(fixture: Fixture, state: HomeKitLightState): void {
45
+ const arr = this.universeBuf(fixture.universe);
46
+ const bytes = fixture.profile.model.render(state, fixture.profile.channels);
47
+ for (let i = 0; i < bytes.length; i++) {
48
+ arr[fixture.startCh - 1 + i] = bytes[i];
49
+ }
50
+ this.lastChangeAt = Date.now();
51
+ const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
52
+ this.log.info(`setFixture ${fixture.id}@DMX${fixture.startCh}=${hex} streaming=${this.streaming}`);
53
+ if (!this.streaming) {
54
+ this.streaming = true;
55
+ this.run().catch((e) => {
56
+ this.log.error('streaming loop crashed:', (e as Error).message);
57
+ }).finally(() => {
58
+ this.streaming = false;
59
+ });
60
+ }
61
+ }
62
+
63
+ private universeBuf(u: number): Uint8Array {
64
+ let arr = this.targets.get(u);
65
+ if (!arr) { arr = new Uint8Array(512); this.targets.set(u, arr); }
66
+ return arr;
67
+ }
68
+
69
+ /** Build the CLI args from current targets. Only non-zero channels are
70
+ * passed — send_dmx initialises the universe to 0 so omission == 0. */
71
+ private buildArgs(): string[] {
72
+ const args: string[] = [SEND_DMX_PATH, this.ip];
73
+ for (const [u, arr] of this.targets) {
74
+ for (let i = 0; i < 512; i++) {
75
+ if (arr[i] !== 0) args.push(`${u},${i + 1}=${arr[i]}`);
76
+ }
77
+ }
78
+ return args;
79
+ }
80
+
81
+ /** Subprocess approach: spawn tools/send_dmx.mjs for each transaction.
82
+ * Fresh node process = no in-process state poisoning that breaks
83
+ * session 2+ in the long-lived plugin. Costs ~1.5-3s per spawn (process
84
+ * startup + handshake + 750ms stream + RST) but is empirically reliable. */
85
+ private async run(): Promise<void> {
86
+ // eslint-disable-next-line no-constant-condition
87
+ while (true) {
88
+ // Wait for the debounce window to clear (no changes for debounceMs).
89
+ while (Date.now() - this.lastChangeAt < this.debounceMs) {
90
+ await sleep(50);
91
+ }
92
+
93
+ const runProbe = this.needsProbe;
94
+ const args = this.buildArgs();
95
+ const env = {
96
+ ...process.env,
97
+ STREAM_MS: '750',
98
+ SECTORS: '0',
99
+ ...(runProbe ? { RUN_PROBE: '1' } : {}),
100
+ };
101
+ this.log.info(`send_dmx subprocess: ${args.length - 2} channels` +
102
+ (runProbe ? ' (with probe)' : ''));
103
+
104
+ const startedAt = Date.now();
105
+ let stderr = '';
106
+ const child = spawn('node', args, { env });
107
+ child.stdout.on('data', (d) => this.log.debug(`[send_dmx] ${d.toString().trimEnd()}`));
108
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
109
+
110
+ const exitCode = await new Promise<number>((res) => {
111
+ child.on('exit', (code) => res(code ?? -1));
112
+ child.on('error', (e) => {
113
+ this.log.error('send_dmx spawn error:', e.message);
114
+ res(-1);
115
+ });
116
+ });
117
+ const elapsedMs = Date.now() - startedAt;
118
+
119
+ if (exitCode === 0) {
120
+ this.needsProbe = false;
121
+ this.log.info(`send_dmx done in ${elapsedMs}ms`);
122
+ } else {
123
+ this.log.error(`send_dmx exited ${exitCode} after ${elapsedMs}ms; stderr: ${stderr.slice(0, 200)}`);
124
+ }
125
+
126
+ // If a change arrived during the subprocess, run another transaction.
127
+ if (Date.now() - this.lastChangeAt < this.debounceMs) continue;
128
+ return;
129
+ }
130
+ }
131
+
132
+ /** Shutdown hook. Subprocess approach has no long-lived state. */
133
+ shutdown(): void {
134
+ /* no-op */
135
+ }
136
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ // Homebridge plugin entry point.
2
+
3
+ import type { API } from 'homebridge';
4
+ import { PLATFORM_NAME } from './settings.js';
5
+ import { DmxPlatform } from './platform.js';
6
+
7
+ export default (api: API): void => {
8
+ api.registerPlatform(PLATFORM_NAME, DmxPlatform);
9
+ };