@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,51 @@
1
+ #!/bin/bash
2
+ # Runs a copy of HardwareManager with our AES key-capture hook injected.
3
+ # The copy has its code signature stripped so DYLD_INSERT_LIBRARIES works.
4
+
5
+ set -e
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
+ HOOK="$SCRIPT_DIR/aes-hook.dylib"
9
+ SRC="/Applications/EsaPro2/HardwareManager/HardwareManager.app"
10
+ COPY="/tmp/HardwareManager-hooked.app"
11
+ LOG="/tmp/stick-aes-keys.log"
12
+
13
+ if [ ! -f "$HOOK" ]; then
14
+ echo "ERROR: aes-hook.dylib not found. Run:"
15
+ echo " clang -arch x86_64 -shared -undefined dynamic_lookup -o tools/aes-hook.dylib tools/aes-hook.c"
16
+ exit 1
17
+ fi
18
+
19
+ echo "=== Stick-DE3 AES Key Capture ==="
20
+ echo ""
21
+
22
+ # Copy the app if needed
23
+ if [ ! -d "$COPY" ]; then
24
+ echo "Copying HardwareManager to $COPY ..."
25
+ cp -R "$SRC" "$COPY"
26
+
27
+ echo "Stripping code signatures..."
28
+ # Strip signature from main binary and all frameworks
29
+ codesign --remove-signature "$COPY/Contents/MacOS/HardwareManager"
30
+ for fw in "$COPY/Contents/Frameworks/"*.dylib; do
31
+ codesign --remove-signature "$fw" 2>/dev/null || true
32
+ done
33
+ for fw in "$COPY/Contents/Frameworks/"*.framework/Versions/*/Qt*; do
34
+ codesign --remove-signature "$fw" 2>/dev/null || true
35
+ done
36
+ echo "Signatures stripped."
37
+ else
38
+ echo "Using existing copy at $COPY"
39
+ fi
40
+
41
+ echo ""
42
+ echo "Starting HardwareManager with AES hook..."
43
+ echo "Key captures will be logged to: $LOG"
44
+ echo ""
45
+ echo ">>> Connect to the Stick-DE3 via the UI, then check $LOG <<<"
46
+ echo ""
47
+
48
+ # Clear old log
49
+ > "$LOG"
50
+
51
+ DYLD_INSERT_LIBRARIES="$HOOK" "$COPY/Contents/MacOS/HardwareManager"
@@ -0,0 +1,135 @@
1
+ // Path A fallback — heap-wide AES-128 key-schedule search.
2
+ //
3
+ // Runs OFFLINE against the memory dump produced by lldb-pathA-scan.py when
4
+ // invoked with STICK_HEAP_DUMP=1. Use this only if the fast vptr-localised
5
+ // scan finds no key (e.g. the cipher object stores round keys outside the
6
+ // 0x800-byte window, or at an unexpected offset).
7
+ //
8
+ // A valid AES-128 forward key schedule is 176 bytes whose word expansion is
9
+ // self-consistent — random memory matches with vanishing probability, so any
10
+ // hit is almost certainly a real round-key array. We recover the 16-byte
11
+ // master key (w[0..3]) from it.
12
+ //
13
+ // Usage:
14
+ // node tools/scan-aes-schedule.mjs /tmp/stick-heap
15
+ //
16
+ // Also exports scheduleKey() / SBOX / RCON for tools/test-aes-detector.mjs.
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ export const SBOX = Buffer.from(
23
+ '637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0' +
24
+ 'b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275' +
25
+ '09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf' +
26
+ 'd0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2' +
27
+ 'cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb' +
28
+ 'e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08' +
29
+ 'ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e' +
30
+ 'e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16',
31
+ 'hex');
32
+ export const RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36];
33
+
34
+ // Full AES-128 schedule validation of buf[o .. o+176).
35
+ // Returns a 16-byte Buffer (the master key) on success, else null.
36
+ export function scheduleKey(buf, o) {
37
+ if (o + 176 > buf.length) return null;
38
+
39
+ // cheap filter: verify word 4 first (one 4-byte compare, kills ~2^-32)
40
+ // temp = SubWord(RotWord(w3)) ^ Rcon1 ; w4 == w0 ^ temp
41
+ const t0 = SBOX[buf[o + 13]] ^ RCON[0];
42
+ const t1 = SBOX[buf[o + 14]];
43
+ const t2 = SBOX[buf[o + 15]];
44
+ const t3 = SBOX[buf[o + 12]];
45
+ if ((buf[o] ^ t0) !== buf[o + 16]) return null;
46
+ if ((buf[o + 1] ^ t1) !== buf[o + 17]) return null;
47
+ if ((buf[o + 2] ^ t2) !== buf[o + 18]) return null;
48
+ if ((buf[o + 3] ^ t3) !== buf[o + 19]) return null;
49
+
50
+ // full check: all 44 words
51
+ const w = new Array(44);
52
+ for (let i = 0; i < 44; i++) w[i] = buf.subarray(o + 4 * i, o + 4 * i + 4);
53
+ for (let i = 4; i < 44; i++) {
54
+ let a, b, c, d;
55
+ const p = w[i - 1];
56
+ if (i % 4 === 0) {
57
+ a = SBOX[p[1]] ^ RCON[i / 4 - 1];
58
+ b = SBOX[p[2]];
59
+ c = SBOX[p[3]];
60
+ d = SBOX[p[0]];
61
+ } else {
62
+ a = p[0]; b = p[1]; c = p[2]; d = p[3];
63
+ }
64
+ const q = w[i - 4];
65
+ if ((q[0] ^ a) !== w[i][0]) return null;
66
+ if ((q[1] ^ b) !== w[i][1]) return null;
67
+ if ((q[2] ^ c) !== w[i][2]) return null;
68
+ if ((q[3] ^ d) !== w[i][3]) return null;
69
+ }
70
+ return Buffer.from(buf.subarray(o, o + 16));
71
+ }
72
+
73
+ function main() {
74
+ const dir = process.argv[2] || '/tmp/stick-heap';
75
+ const manifestPath = path.join(dir, 'manifest.txt');
76
+ if (!fs.existsSync(manifestPath)) {
77
+ console.error(`no manifest at ${manifestPath}`);
78
+ console.error('run the lldb scan with STICK_HEAP_DUMP=1 first');
79
+ process.exit(1);
80
+ }
81
+
82
+ const manifest = fs.readFileSync(manifestPath, 'utf8').trim().split('\n');
83
+ const found = new Map(); // keyhex -> [addr,...]
84
+ let totalBytes = 0;
85
+ const t0 = Date.now();
86
+
87
+ for (const line of manifest) {
88
+ const [baseHex, , fname] = line.trim().split(/\s+/);
89
+ if (!fname) continue;
90
+ const base = BigInt(baseHex);
91
+ const buf = fs.readFileSync(path.join(dir, fname));
92
+ totalBytes += buf.length;
93
+ const limit = buf.length - 176;
94
+ // round keys are word-aligned in practice; step 4 keeps it fast
95
+ for (let o = 0; o <= limit; o += 4) {
96
+ const key = scheduleKey(buf, o);
97
+ if (key) {
98
+ const hex = key.toString('hex');
99
+ const addr = '0x' + (base + BigInt(o)).toString(16);
100
+ if (!found.has(hex)) found.set(hex, []);
101
+ found.get(hex).push(addr);
102
+ }
103
+ }
104
+ }
105
+
106
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
107
+ console.log(`scanned ${(totalBytes / 1048576).toFixed(1)} MiB in ${secs}s`);
108
+ console.log();
109
+
110
+ if (found.size === 0) {
111
+ console.log('no AES-128 key schedule found in the dump.');
112
+ console.log('the impl likely expands the schedule on the fly — fall back to');
113
+ console.log('reading the raw 16-byte key at cipher_obj+0x48 (see scan log).');
114
+ process.exit(3);
115
+ }
116
+
117
+ console.log(`found ${found.size} distinct AES-128 key(s):`);
118
+ let n = 0;
119
+ for (const [hex, addrs] of found) {
120
+ const spaced = hex.match(/../g).join(' ');
121
+ console.log(` [${n}] ${spaced}`);
122
+ console.log(` at ${addrs.slice(0, 6).join(', ')}` +
123
+ `${addrs.length > 6 ? ` (+${addrs.length - 6} more)` : ''}`);
124
+ fs.writeFileSync(`/tmp/stick-heapscan-key-${n}.bin`, Buffer.from(hex, 'hex'));
125
+ n++;
126
+ }
127
+ console.log();
128
+ console.log('verify each against a captured frame:');
129
+ console.log(' node tools/try-key.mjs <frame.bin> <key-hex>');
130
+ }
131
+
132
+ // run main() only when executed directly, not when imported by the test
133
+ const isMain = process.argv[1] &&
134
+ fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1]);
135
+ if (isMain) main();
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env node
2
+ // send_dmx — set DMX channels on a Nicolaudie Stick-DE3, transactionally.
3
+ //
4
+ // node tools/send_dmx.mjs <ip> <universe,channel=value> [more…]
5
+ // e.g. node tools/send_dmx.mjs 192.168.96.2 0,22=8 0,1=255 0,6=128
6
+ //
7
+ // It connects to the Stick (TCP/2431), runs the handshake, derives the
8
+ // per-session AES key via the recovered KDF, sends the encrypted 576-byte
9
+ // DMX frame over UDP, then disconnects cleanly — relying on the Stick's
10
+ // "latch on clean disconnect" behaviour to hold the values.
11
+ //
12
+ // ── what is solid vs. hopeful ───────────────────────────────────────────────
13
+ // SOLID (verified): the KDF, the AES-256-CBC frame cipher, the 576-byte
14
+ // frame layout, the fixed internal header P0. A frame built here is
15
+ // byte-compatible with what Hardware Manager emits.
16
+ // HOPEFUL (untested against hardware): the TCP handshake sequence is modelled
17
+ // on a captured Hardware Manager session. The Stick may want more (or
18
+ // fewer) messages, or may reject a partial handshake. Iterate from the
19
+ // on-wire behaviour. The clean-disconnect latch is per the project
20
+ // notes but only HWM has been observed doing it.
21
+ //
22
+ // channel is 1..512; value is 0..255; universe selects the DMX port field.
23
+
24
+ import net from 'node:net';
25
+ import dgram from 'node:dgram';
26
+ import crypto from 'node:crypto';
27
+ import fs from 'node:fs';
28
+ import { makeEphemeral, deriveDmxKey, pointToWire, wireToPoint } from './derive-dmx-key.mjs';
29
+
30
+ const TCP_PORT = 2431;
31
+ const UDP_DST_PORT = 2431;
32
+ const UDP_SRC_PORT = 2430;
33
+ const MAGIC = Buffer.from('Stick_3A');
34
+ const LSAG = Buffer.from('LSAG_ALL');
35
+ // the 16-byte internal plaintext header — a fixed constant (RE-confirmed).
36
+ // Plaintext layout, empirically verified 2026-05-23 against a working HWM
37
+ // frame: [P0 (16)][header2 (16, observed zero)][512 DMX channels]. NOT
38
+ // [P0][512 channels][trailer] as the decrypt-dmx.mjs zero-ratio test had
39
+ // suggested (channel position is invisible to a zero-ratio metric on a
40
+ // near-empty stream). The DMX channels start at plaintext offset 32.
41
+ const P0 = Buffer.from('5b4e99da9685ad976c432b0a7ff9ffcc', 'hex');
42
+ // HMAC-SHA256 key for the 0x48 TCP-auth handshake — an internal Hardware
43
+ // Manager constant ("#h.6xcKsGD{y}-z"), extracted at runtime via
44
+ // tools/hmac-key.sh and verified against a captured HWM handshake.
45
+ const AUTH_KEY = Buffer.from('23682e3678634b7347447b797d2d7a', 'hex');
46
+ // the Stick's static 0x0F pubkey (constant across every observed session);
47
+ // used as a fallback if the live 0x0F reply can't be parsed.
48
+ const Q_FALLBACK = Buffer.from(
49
+ '87ef58c2660c272b54a74bbc94cb8518108e370b7eed78456bd8d120c6b9ac0a' +
50
+ 'd791e4ce698aea761679f4b92a3ecf2acd12bf9bc308ce0ba8cb9663' + '0871105e', 'hex');
51
+
52
+ // ── args ────────────────────────────────────────────────────────────────────
53
+ const [, , ip, ...assigns] = process.argv;
54
+ if (!ip || assigns.length === 0) {
55
+ console.error('usage: node tools/send_dmx.mjs <ip> <universe,channel=value> […]');
56
+ process.exit(1);
57
+ }
58
+ // universe -> Uint8Array(512) of channel values
59
+ const universes = new Map();
60
+ for (const a of assigns) {
61
+ const m = /^(\d+),(\d+)=(\d+)$/.exec(a.trim());
62
+ if (!m) { console.error(`bad assignment: "${a}" (want universe,channel=value)`); process.exit(1); }
63
+ const [u, ch, val] = [+m[1], +m[2], +m[3]];
64
+ if (ch < 1 || ch > 512) { console.error(`channel ${ch} out of range 1..512`); process.exit(1); }
65
+ if (val < 0 || val > 255) { console.error(`value ${val} out of range 0..255`); process.exit(1); }
66
+ if (!universes.has(u)) universes.set(u, new Uint8Array(512));
67
+ universes.get(u)[ch - 1] = val;
68
+ }
69
+
70
+ // ── tiny helpers ────────────────────────────────────────────────────────────
71
+ let tokenN = 0x80;
72
+ const token = () => { const b = Buffer.alloc(8); b.writeUInt32LE(tokenN++, 0); return b; };
73
+ const msg = (magic, opcode, ...parts) => {
74
+ const op = Buffer.alloc(2); op.writeUInt16LE(opcode, 0);
75
+ return Buffer.concat([magic, op, ...parts]);
76
+ };
77
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
78
+ // Inter-message gap during the handshake chatter. Older note claimed each
79
+ // TCP write needs to land as a SEPARATE segment for the Stick to process it
80
+ // (otherwise no 0xc9 status). 2026-05-23: user-tested down to CHATTER_MS=5
81
+ // — every value latched and the 0xc9 still arrives. Theory was wrong (or
82
+ // the real floor is way below scheduler tick). Default 10ms for margin.
83
+ const CHATTER_MS = Number(process.env.CHATTER_MS || 10);
84
+
85
+ // All received TCP bytes accumulate here for the life of the connection, so a
86
+ // reply already sitting in the buffer is never missed.
87
+ let rxBuf = Buffer.alloc(0);
88
+ const attachReader = (sock) => sock.on('data', (d) => { rxBuf = Buffer.concat([rxBuf, d]); });
89
+
90
+ /** find a Stick_3A/LSAG message with `opcode` and at least `minLen` bytes. */
91
+ function findMsg(opcode, minLen) {
92
+ for (let o = 0; o + 10 <= rxBuf.length; o++) {
93
+ if ((rxBuf.subarray(o, o + 8).equals(MAGIC) || rxBuf.subarray(o, o + 8).equals(LSAG)) &&
94
+ rxBuf.readUInt16LE(o + 8) === opcode && rxBuf.length >= o + minLen) {
95
+ return rxBuf.subarray(o, o + minLen);
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+ /** poll rxBuf until `predicate` returns non-null, or timeout. */
101
+ function waitFor(predicate, timeoutMs) {
102
+ return new Promise((resolve) => {
103
+ const deadline = Date.now() + timeoutMs;
104
+ const tick = () => {
105
+ const r = predicate();
106
+ if (r != null) return resolve(r);
107
+ if (Date.now() > deadline) return resolve(null);
108
+ setTimeout(tick, 20);
109
+ };
110
+ tick();
111
+ });
112
+ }
113
+
114
+ // ── the DMX frame builder (verified format) ─────────────────────────────────
115
+ let seqCtr = 0;
116
+ function buildFrame(key, channels512, port) {
117
+ // fieldA (+0x0a) is the SAME session message counter as the TCP tokens —
118
+ // the UDP DMX frames continue the sequence. A counter that jumps backwards
119
+ // (a separate low-valued counter) makes the Stick drop every frame.
120
+ const fieldA = token();
121
+ const nonce = crypto.randomBytes(8);
122
+ const iv = Buffer.concat([fieldA, nonce]); // 16-byte CBC IV
123
+ // CORRECTED 2026-05-23: channels go at offset 32, NOT offset 16. Putting them
124
+ // at +16 produced an off-by-16 mismatch (our ch 22 lit HWM's ch 6 = bow A-down).
125
+ const plain = Buffer.concat([P0, Buffer.alloc(16), Buffer.from(channels512)]); // 544
126
+ const c = crypto.createCipheriv('aes-256-cbc', key, iv);
127
+ c.setAutoPadding(false);
128
+ const body = Buffer.concat([c.update(plain), c.final()]); // 544
129
+ const hdr = Buffer.alloc(32);
130
+ MAGIC.copy(hdr, 0);
131
+ hdr.writeUInt16LE(0x0019, 8);
132
+ fieldA.copy(hdr, 0x0a);
133
+ hdr.writeUInt16LE(port, 0x12);
134
+ hdr.writeUInt16LE(512, 0x14);
135
+ hdr[0x16] = 100;
136
+ hdr[0x17] = seqCtr++ & 0xff;
137
+ nonce.copy(hdr, 0x18);
138
+ return Buffer.concat([hdr, body]); // 576
139
+ }
140
+
141
+ // ── main ────────────────────────────────────────────────────────────────────
142
+ const log = (...a) => console.log(' ', ...a);
143
+
144
+ // Run the auth step (0x47 + 0x48) on a freshly opened TCP/2431 socket.
145
+ // Returns the 32-byte Stick handshake key (from the 0x47 reply) and logs the
146
+ // 0x48 status. Used both by the probe connection and the go-live connection.
147
+ async function doAuth(sock, tag) {
148
+ // 0x47 — LSAG_ALL hello; the Stick replies with its 32-byte handshake key
149
+ sock.write(msg(LSAG, 0x47, token()));
150
+ const r47 = await waitFor(() => findMsg(0x47, 54), 3000);
151
+ let stickKey = Buffer.alloc(32);
152
+ if (r47 != null) {
153
+ stickKey = Buffer.from(r47.subarray(0x16, 0x36));
154
+ log(`${tag} 0x47 ok — got Stick handshake key`);
155
+ } else log(`${tag} 0x47 — no reply (continuing)`);
156
+
157
+ // 0x48 — authenticated handshake. The message is
158
+ // magic(8) ‖ 0x48 ‖ token(8) ‖ softwareName(32) ‖ stickKey(32)
159
+ // followed by HMAC-SHA256(AUTH_KEY, the 82-byte head). The Stick verifies
160
+ // the HMAC; a bad one ⇒ reply status 100 (PermissionDenied) and the
161
+ // session is never promoted to a live control session.
162
+ const software = Buffer.alloc(32); software.write('software');
163
+ const head48 = msg(LSAG, 0x48, token(), software, stickKey);
164
+ const mac48 = crypto.createHmac('sha256', AUTH_KEY).update(head48).digest();
165
+ sock.write(Buffer.concat([head48, mac48]));
166
+ const r48 = await waitFor(() => findMsg(0x48, 22), 2000);
167
+ if (r48) {
168
+ const st = r48.readUInt32LE(0x12);
169
+ log(`${tag} 0x48 auth status: ${st}` + (st === 0 ? ' (ok)' : ' (REJECTED)'));
170
+ } else log(`${tag} 0x48 — no reply`);
171
+ return stickKey;
172
+ }
173
+
174
+ // HWM-observed pattern: BEFORE the go-live TCP connection, HWM opens a
175
+ // short-lived PROBE connection that does auth → 0x00/0xc9 → 0x011c → 0x07b →
176
+ // clean close. Then a brief gap, then the real go-live connection. Tokens
177
+ // continue across both. The Stick may track "this client warmed up" and only
178
+ // grant live mode after seeing the probe. tools/send_dmx.mjs's earlier
179
+ // behaviour was to open ONE connection for everything, which is the chief
180
+ // remaining wire-level difference vs HWM (see memory note 2026-05-23).
181
+ async function probeHandshake(sock) {
182
+ attachReader(sock);
183
+ await doAuth(sock, '[probe]');
184
+
185
+ // 0x00 → 0xc9 status registration (same body HWM sends)
186
+ sock.write(msg(MAGIC, 0x00, Buffer.from('14000000', 'hex')));
187
+ await waitFor(() => findMsg(0x00c9, 18), 1500);
188
+ if (findMsg(0x00c9, 18)) log('[probe] 0xc9 received — session registered');
189
+
190
+ // 0x011c probe — we now know (2026-05-23) the reply is just AES-encrypted
191
+ // device-info; we discard it. Sending the request still matters because the
192
+ // PROBE-then-go-live two-connection dance is the remaining unverified
193
+ // wire-level theory.
194
+ sock.write(msg(MAGIC, 0x011c, token(), Buffer.from('01001600', 'hex')));
195
+ await waitFor(() => findMsg(0x011c, 22), 2000);
196
+ log('[probe] 0x011c sent (reply discarded)');
197
+
198
+ // 0x07b — license/serial query. Body is just header(18B); the Stick replies
199
+ // with 39B containing what looks like a license tag (`15f0ff182700…`).
200
+ sock.write(msg(MAGIC, 0x007b, token()));
201
+ await waitFor(() => findMsg(0x007b, 22), 1500);
202
+ log('[probe] 0x07b sent (reply discarded)');
203
+
204
+ await sleep(150); // small settle before the clean close
205
+ }
206
+
207
+ async function handshake(sock) {
208
+ attachReader(sock);
209
+ await doAuth(sock, '[live]');
210
+
211
+ // 3. observed pre-DMX chatter — sent ONE AT A TIME. HWM never batches these;
212
+ // a single coalesced TCP segment leaves the Stick without sending its
213
+ // 0xc9 status, i.e. it never registers the session as a live client.
214
+ for (const m of [
215
+ msg(MAGIC, 0x46, Buffer.alloc(4)),
216
+ msg(MAGIC, 0x09, Buffer.from('14000000', 'hex')),
217
+ msg(MAGIC, 0x09, Buffer.from('14000000', 'hex')),
218
+ msg(MAGIC, 0x00, Buffer.from('14000000', 'hex')),
219
+ msg(MAGIC, 0x011c, token(), Buffer.from('01001600', 'hex')),
220
+ msg(MAGIC, 0x05, Buffer.from('0200', 'hex')),
221
+ ]) {
222
+ sock.write(m);
223
+ await sleep(CHATTER_MS);
224
+ }
225
+ if (findMsg(0x00c9, 18)) log('0xc9 status received — Stick registered the session');
226
+
227
+ // 4. 0x10 — crypto-state query. HWM sees state 3 on a fresh device; state 4
228
+ // means a DMX key from a previous session is still latched.
229
+ rxBuf = Buffer.alloc(0);
230
+ sock.write(msg(MAGIC, 0x10, token()));
231
+ const r10 = await waitFor(() => findMsg(0x10, 22), 2000);
232
+ if (r10) log(`0x10 crypto state: ${r10.readUInt32LE(0x12)}`);
233
+ rxBuf = Buffer.alloc(0);
234
+
235
+ // 5. 0x0F — the DMX key exchange: send our P-256 ephemeral pubkey
236
+ const ecdh = makeEphemeral();
237
+ const ourP256 = pointToWire(ecdh.getPublicKey(null, 'uncompressed')); // 64-byte wire form
238
+ sock.write(msg(MAGIC, 0x0f, token(), ourP256));
239
+ const r0f = await waitFor(() => findMsg(0x0f, 86), 3000);
240
+ let Qwire = Q_FALLBACK;
241
+ if (r0f != null) {
242
+ Qwire = r0f.subarray(0x16, 0x56); // Stick DMX pubkey, 64-byte wire form
243
+ log('0x0F ok — got Stick DMX pubkey');
244
+ } else log('0x0F — no reply, using known static Stick key');
245
+
246
+ // 6. derive the DMX session key (KDF: P-256 double-ECDH)
247
+ const key = deriveDmxKey(ecdh, wireToPoint(Qwire));
248
+ log('DMX key derived:', key.toString('hex'));
249
+ // dump key + ephemeral private d so a capture can be decrypted offline and
250
+ // verified — proving whether our own frames are validly encrypted.
251
+ try {
252
+ const dHex = ecdh.getPrivateKey('hex');
253
+ const keyPath = process.env.KEY_DUMP || `${process.env.HOME || '.'}/.send_dmx-key.txt`;
254
+ fs.writeFileSync(keyPath,
255
+ `key=${key.toString('hex')}\nd=${dHex}\nQ=${Qwire.toString('hex')}\n`);
256
+ log(`wrote ${keyPath}`);
257
+ } catch (e) { log('key dump failed:', e.message); }
258
+
259
+ // 6b. device sync — replicated to match a captured HWM session BYTE-FOR-BYTE
260
+ // in both ORDER and CONTENT (verified against tools/analyze-pcap.mjs):
261
+ // 0x10, 0x75, 0x74, 0x71×3, 0x70 download, 0x2e
262
+ // The earlier code did 0x75/0x74 AFTER the download and sent the wrong
263
+ // third 0x71 param — both now corrected.
264
+ sock.write(msg(MAGIC, 0x10, token())); await sleep(CHATTER_MS);
265
+ sock.write(msg(MAGIC, 0x75, token())); await sleep(CHATTER_MS);
266
+ sock.write(msg(MAGIC, 0x74, token())); await sleep(CHATTER_MS);
267
+ // HWM's 0x71 params (from the 2026-05-23 mirror capture of a working
268
+ // session): FOUR reads — 0200000000, 0100000000, 0100000000, 02b37f0000.
269
+ // The 4th was missing in the earlier code (only seen via the port mirror
270
+ // at the Stick) and the 3rd's value varies between HWM sessions, so it
271
+ // looks tolerant — but the 4th is the one opcode pattern HWM emits that
272
+ // we never did, so include it.
273
+ for (const p of ['0200000000', '0100000000', '0100000000', '02b37f0000']) {
274
+ sock.write(msg(MAGIC, 0x71, token(), Buffer.from(p, 'hex')));
275
+ await sleep(CHATTER_MS);
276
+ }
277
+ // 0x70 sector downloads — HWM reads sector 0 + sectors 63..185 (124 reads)
278
+ // to populate its commissioning UI. 2026-05-23: user-tested SECTORS=0 (skip
279
+ // entirely) → still latches. So this is purely HWM UI chatter, not a Stick
280
+ // precondition for go-live. Default OFF (saves ~1.5 s).
281
+ // SECTORS=N — read the first N entries of the HWM list (0 disables)
282
+ // SECTORS=124 — full HWM-exact list
283
+ const N = process.env.SECTORS != null ? Number(process.env.SECTORS) : 0;
284
+ if (N > 0) {
285
+ const fullSectors = [0];
286
+ for (let s = 63; s <= 185; s++) fullSectors.push(s);
287
+ const sectors = fullSectors.slice(0, N);
288
+ log(`0x70 device download (${sectors.length} sectors) …`);
289
+ for (let i = 0; i < sectors.length; i++) {
290
+ const body = Buffer.alloc(5);
291
+ body.writeUInt32LE(sectors[i], 0);
292
+ body[4] = 1;
293
+ sock.write(msg(MAGIC, 0x70, token(), body));
294
+ await sleep(12);
295
+ if ((i & 0x1f) === 0x1f) rxBuf = Buffer.alloc(0);
296
+ }
297
+ await sleep(200);
298
+ }
299
+ rxBuf = Buffer.alloc(0);
300
+
301
+ // 7. enter live mode: 0x2e, then a settle gap, then 0x10/0x11/0x10. HWM
302
+ // waits ~3.7 s between 0x2e and 0x10/0x11 (UI-paced); we use a much
303
+ // shorter settle. SETTLE_2E_MS tunable (default 800).
304
+ sock.write(msg(MAGIC, 0x2e, Buffer.alloc(32))); await sleep(CHATTER_MS);
305
+ await sleep(Number(process.env.SETTLE_2E_MS || 50));
306
+ sock.write(msg(MAGIC, 0x10, token())); await sleep(CHATTER_MS);
307
+ sock.write(msg(MAGIC, 0x11, token())); // "go live"
308
+ const r11 = await waitFor(() => findMsg(0x11, 22), 3000);
309
+ log(r11 ? 'live mode enabled (0x11 ok)' : '0x11 — no reply (streaming anyway)');
310
+ sock.write(msg(MAGIC, 0x10, token())); // HWM does a 0x10 after 0x11
311
+ const r10b = await waitFor(() => findMsg(0x10, 22), 1500);
312
+ if (r10b) log(`0x10 after 0x11 — crypto state ${r10b.readUInt32LE(0x12)} (HWM sees 4)`);
313
+ return key;
314
+ }
315
+
316
+ // HWM emits four 14-byte UDP/2430 broadcasts at startup (LSAG_ALL,
317
+ // Stick_U1, Stick_3A, Siudi_7B), three times in a row, BEFORE any TCP. Each
318
+ // body = magic(8) + 0x0000 + 0x14000000.
319
+ const DISCOVERY_MAGICS = ['LSAG_ALL', 'Stick_U1', 'Stick_3A', 'Siudi_7B'];
320
+ const DISCOVERY_TAIL = Buffer.from('000014000000', 'hex');
321
+ async function discoveryBroadcast(udp) {
322
+ for (let burst = 0; burst < 3; burst++) {
323
+ for (const m of DISCOVERY_MAGICS) {
324
+ const pkt = Buffer.concat([Buffer.from(m, 'ascii'), DISCOVERY_TAIL]);
325
+ await new Promise((r) => udp.send(pkt, 2430, '255.255.255.255', () => r()));
326
+ }
327
+ await sleep(25);
328
+ }
329
+ }
330
+
331
+ // HWM also broadcasts a SEPARATE "I am Hardware Manager" announcement on
332
+ // UDP/24299 (src+dst 24299) with magic "LIGHTINGSOFT_XHL". A 114-byte
333
+ // announce carrying the literal string "Hardware Manager", followed by
334
+ // 46-byte status follow-ups. The 8-byte instance ID is fresh per launch.
335
+ // We have no Stick-side traffic acknowledging these but HWM does them
336
+ // consistently, so they may be what the Stick uses to identify a client as
337
+ // the live controller.
338
+ const XHL_HEADER = Buffer.from(
339
+ '4c49474854494e47534f46545f58484c' + // "LIGHTINGSOFT_XHL"
340
+ '0000000000000000' + // 8B zero padding
341
+ '14000000' + // op/len = 20
342
+ '01000000', // version = 1
343
+ 'hex'); // 32B total
344
+ async function announceHardwareManager(udp24299, instanceId) {
345
+ // 114B "I am Hardware Manager" announce
346
+ const body114 = Buffer.alloc(114 - 32);
347
+ instanceId.copy(body114, 0); // [32:40] instance
348
+ body114.writeUInt32LE(crypto.randomBytes(4).readUInt32LE(0), 8); // [40:44] varying
349
+ body114.writeUInt32LE(11, 12); // [44:48] = 0x0b
350
+ body114.write('Hardware Manager\0', 18, 'ascii'); // [50:67] string
351
+ const pkt114 = Buffer.concat([XHL_HEADER, body114]);
352
+ await new Promise((r) => udp24299.send(pkt114, 24299, '255.255.255.255', () => r()));
353
+ await sleep(25);
354
+ // 2 × 46B follow-ups
355
+ for (let i = 0; i < 2; i++) {
356
+ const tail = Buffer.concat([crypto.randomBytes(4), Buffer.from([0x0a + i * 2, 0])]);
357
+ const pkt46 = Buffer.concat([XHL_HEADER, instanceId, tail]);
358
+ await new Promise((r) => udp24299.send(pkt46, 24299, '255.255.255.255', () => r()));
359
+ await sleep(25);
360
+ }
361
+ }
362
+
363
+ async function main() {
364
+ console.log(`send_dmx → ${ip}`);
365
+ // 0. UDP socket up FIRST — for discovery + the live DMX stream
366
+ const udp = dgram.createSocket('udp4');
367
+ await new Promise((res, rej) => {
368
+ udp.once('error', rej);
369
+ udp.bind(UDP_SRC_PORT, () => { udp.setBroadcast(true); res(); });
370
+ });
371
+ log(`UDP socket bound to ${UDP_SRC_PORT}, broadcast enabled`);
372
+ await discoveryBroadcast(udp);
373
+ log('discovery broadcasts sent (LSAG_ALL/Stick_U1/Stick_3A/Siudi_7B × 3)');
374
+
375
+ // open a 2nd UDP socket bound to port 24299 for the LIGHTINGSOFT_XHL announce
376
+ const udp24299 = dgram.createSocket('udp4');
377
+ await new Promise((res, rej) => {
378
+ udp24299.once('error', rej);
379
+ udp24299.bind(24299, () => { udp24299.setBroadcast(true); res(); });
380
+ });
381
+ const instanceId = crypto.randomBytes(8);
382
+ await announceHardwareManager(udp24299, instanceId);
383
+ log(`LIGHTINGSOFT_XHL "Hardware Manager" announce sent (instance ${instanceId.toString('hex')})`);
384
+ udp24299.close(); // done with it — prevents process hang
385
+ await sleep(200);
386
+
387
+ // ── PROBE CONNECTION (opt-in) ──
388
+ // HWM opens a throwaway TCP/2431 session that does auth + 0x00/0xc9 +
389
+ // 0x011c + 0x07b + clean close, BEFORE the real go-live connection.
390
+ // 2026-05-23: tested SKIP_PROBE=1 vs the probe-included path — identical
391
+ // outcome. The probe is HWM UI chatter, NOT a Stick precondition. Default
392
+ // OFF (saves ~3-4 s per transaction). Enable for wire-faithfulness with
393
+ // RUN_PROBE=1.
394
+ if (process.env.RUN_PROBE === '1') {
395
+ const probe = net.createConnection({ host: ip, port: TCP_PORT });
396
+ probe.on('error', (e) => { console.error('probe TCP error:', e.message); process.exit(1); });
397
+ probe.setTimeout(5000);
398
+ await new Promise((res, rej) => {
399
+ probe.once('connect', res);
400
+ probe.once('error', rej);
401
+ probe.once('timeout', () => rej(new Error(`probe: no response from ${ip}:${TCP_PORT}`)));
402
+ });
403
+ probe.setTimeout(0);
404
+ log('probe TCP connected');
405
+ await probeHandshake(probe);
406
+ await new Promise((res) => probe.end(res)); // clean close — Stick should hold no state
407
+ log('probe disconnected cleanly');
408
+ rxBuf = Buffer.alloc(0); // reset for the next connection
409
+ // HWM's gap between conn-close and the next conn-open is ~1 s (UI-paced).
410
+ // Anything > a few ms should be fine. Configurable via PROBE_GAP_MS.
411
+ await sleep(Number(process.env.PROBE_GAP_MS || 800));
412
+ } else {
413
+ log('probe skipped (set RUN_PROBE=1 to include HWM-style probe connection)');
414
+ }
415
+
416
+ // ── GO-LIVE CONNECTION ──
417
+ const sock = net.createConnection({ host: ip, port: TCP_PORT });
418
+ sock.on('error', (e) => { console.error('TCP error:', e.message); process.exit(1); });
419
+ sock.setTimeout(5000);
420
+ await new Promise((res, rej) => {
421
+ sock.once('connect', res);
422
+ sock.once('error', rej);
423
+ sock.once('timeout', () => rej(new Error(`no response from ${ip}:${TCP_PORT} (timeout)`)));
424
+ });
425
+ sock.setTimeout(0);
426
+ log('go-live TCP connected');
427
+
428
+ const key = await handshake(sock);
429
+
430
+ // (UDP socket already opened at the top of main() for discovery; reused here)
431
+ for (const [u, chans] of universes) {
432
+ const lit = [...chans.entries()].filter(([, v]) => v).map(([i, v]) => `ch${i + 1}=${v}`);
433
+ log(`universe ${u}: ${lit.join(' ') || '(all 0)'}`);
434
+ }
435
+ // Transactional streaming: send the smallest number of DMX frames needed
436
+ // for the Stick to commit the values, then dirty-close so it latches.
437
+ //
438
+ // CORRECTED 2026-05-23: latching happens on DIRTY disconnect (or any close
439
+ // that doesn't send HWM's right-click "goodbye" opcode — we don't know what
440
+ // that opcode is, but everything ELSE — sock.end(), sock.destroy(), process
441
+ // kill, cmd-Q — falls into the latch bucket). A clean disconnect via
442
+ // HWM-right-click DOES NOT latch. The brief blackout-then-return that
443
+ // happens at disconnect is the Stick's exit-live transition; HWM exhibits
444
+ // the same flicker, so it's unavoidable at the protocol layer.
445
+ //
446
+ // Goal: minimise streaming time. The Stick is unusable from elsewhere while
447
+ // we hold the TCP/2431 session, so for a Homebridge plugin doing single-
448
+ // command updates we want to be in-and-out as fast as possible.
449
+ // Empirical (user test 2026-05-23): at 25 Hz the Stick needs ~12 DMX frames
450
+ // after go-live before it'll commit values; 400 ms fails, 500 ms works. If
451
+ // the limit is frame-count (not wall-time), running at 80 Hz lets us hit 12
452
+ // frames in 150 ms instead of 480. FRAME_HZ env var lets us A/B test.
453
+ sock.write(msg(MAGIC, 0x10, token()));
454
+ const FRAME_HZ = Number(process.env.FRAME_HZ || 25); // HWM uses 25
455
+ const STREAM_MS = Number(process.env.STREAM_MS || 750);
456
+ const frameMs = Math.max(1, Math.round(1000 / FRAME_HZ));
457
+ log(`streaming at ${FRAME_HZ} Hz (${frameMs}ms interval) for ${STREAM_MS}ms`);
458
+ let nFrames = 0;
459
+
460
+ const udpTimer = setInterval(() => {
461
+ for (const [u, chans] of universes) {
462
+ udp.send(buildFrame(key, chans, u), UDP_DST_PORT, ip, () => {});
463
+ nFrames++;
464
+ }
465
+ }, frameMs);
466
+
467
+ await sleep(STREAM_MS);
468
+ clearInterval(udpTimer);
469
+ log(`streamed ${nFrames} frames over ${STREAM_MS}ms`);
470
+
471
+ // DIRTY-close: socket.destroy() sends RST (no FIN handshake). This is what
472
+ // produces the latch — the Stick treats it as "client died, hold last
473
+ // values". A FIN-based sock.end() empirically also latches today because we
474
+ // don't send HWM's polite-goodbye opcode, but destroy() removes the ambiguity.
475
+ udp.close();
476
+ sock.destroy();
477
+ log('disconnected (RST) — Stick should hold the last value');
478
+ }
479
+
480
+ main().catch((e) => { console.error('failed:', e.message); process.exit(1); });