@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,57 @@
1
+ // Stick-DE3 status reader — objectively check if the device is in REMOTE/LIVE
2
+ // mode vs standalone. Per the STICK Remote Protocol spec, once connected to
3
+ // TCP/2431 the Stick sends a status packet every 5s. Key fields:
4
+ // offset 47 = Remote Clients Count
5
+ // offset 48 = Live Mode Is Activated (1 = live/remote, 0 = standalone)
6
+ // (Documented status layout starts: ID[8], OpCode, Ver, SceneNr[2],
7
+ // SceneName[12], ZoneNr, ZoneName[12], Dimmer[2], R,G,B, Speed[2],
8
+ // 3x icon flags, RemoteClients, LiveMode, Screen, Led, ZoneCount,
9
+ // SceneState, ImgDisplayed, ImgFullscreen.)
10
+ //
11
+ // NOTE: on fw2.x/3.x TCP requires auth. If "Security for Cloud Access" is
12
+ // still enabled this may only get the LSAG/auth-challenge reply, not a clean
13
+ // status packet — which itself is the finding (device never enters remote
14
+ // mode for us). Raw hex is always dumped so we can see exactly what comes.
15
+ //
16
+ // Usage: node tools/stick-status.mjs (watches for ~30s)
17
+ // STICK_IP=192.168.96.2 node tools/stick-status.mjs
18
+
19
+ import net from 'node:net';
20
+
21
+ const STICK_IP = process.env.STICK_IP || '192.168.96.2';
22
+ const STICK_PORT = Number(process.env.STICK_TCP_PORT || 2431);
23
+ const WATCH_MS = Number(process.env.WATCH_MS || 30000);
24
+ const GREET = Buffer.from('4c5341475f414c4c000015000000', 'hex'); // observed LSAG_ALL client greeting
25
+
26
+ const ts = () => new Date().toISOString();
27
+ const sock = net.connect({ host: STICK_IP, port: STICK_PORT });
28
+ sock.setNoDelay(true);
29
+
30
+ sock.on('connect', () => {
31
+ console.log(`${ts()} connected ${STICK_IP}:${STICK_PORT} — sending LSAG_ALL greeting`);
32
+ sock.write(GREET);
33
+ });
34
+
35
+ sock.on('data', (b) => {
36
+ const isStatus = b.length >= 49 && b.subarray(0, 8).toString('latin1') === 'Stick_3A';
37
+ console.log(`\n${ts()} <-- ${b.length}B`);
38
+ console.log(' hex: ' + b.subarray(0, Math.min(b.length, 64)).toString('hex').replace(/(..)/g, '$1 ').trim());
39
+ if (isStatus) {
40
+ const sceneName = b.subarray(12, 24).toString('latin1').replace(/\0.*$/, '');
41
+ const zoneName = b.subarray(25, 37).toString('latin1').replace(/\0.*$/, '');
42
+ const remoteClients = b.length > 47 ? b[47] : '?';
43
+ const liveMode = b.length > 48 ? b[48] : '?';
44
+ console.log(` ID=Stick_3A OpCode=${b[8]} Ver=${b[9]}`);
45
+ console.log(` sceneName=${JSON.stringify(sceneName)} zoneName=${JSON.stringify(zoneName)}`);
46
+ console.log(` >>> RemoteClientsCount[off47]=${remoteClients} LiveModeIsActivated[off48]=${liveMode} <<<`);
47
+ if (liveMode === 0) console.log(' ** Live mode NOT active — device is in STANDALONE (this is the suspected root cause) **');
48
+ if (liveMode === 1) console.log(' ** Live mode ACTIVE — device is in REMOTE mode **');
49
+ } else {
50
+ console.log(' (not a documented status packet — likely the LSAG/auth-challenge reply; fw3 TCP auth not satisfied)');
51
+ }
52
+ });
53
+
54
+ sock.on('error', (e) => console.log(`${ts()} error: ${e.message}`));
55
+ sock.on('close', () => console.log(`${ts()} closed`));
56
+
57
+ setTimeout(() => { console.log(`\n${ts()} done (${WATCH_MS}ms)`); sock.destroy(); process.exit(0); }, WATCH_MS);
@@ -0,0 +1,100 @@
1
+ // Self-test for the AES-128 key-schedule detector used by Path A.
2
+ //
3
+ // Validates the detector in BOTH scan-aes-schedule.mjs (JS) and
4
+ // lldb-pathA-scan.py (Python) against the FIPS-197 Appendix A.1 known
5
+ // answer, plus an independent key-expansion generator and a negative case.
6
+ //
7
+ // node tools/test-aes-detector.mjs
8
+
9
+ import { execFileSync } from 'node:child_process';
10
+ import { scheduleKey, SBOX, RCON } from './scan-aes-schedule.mjs';
11
+
12
+ let pass = 0, fail = 0;
13
+ function check(name, cond) {
14
+ if (cond) { pass++; console.log(` ok ${name}`); }
15
+ else { fail++; console.log(` FAIL ${name}`); }
16
+ }
17
+
18
+ // FIPS-197 Appendix A.1: key expansion for this 128-bit key.
19
+ const KEY = '2b7e151628aed2a6abf7158809cf4f3c';
20
+ // The full 176-byte forward key schedule (w[0]..w[43]) from FIPS-197 A.1.
21
+ const FIPS_SCHEDULE =
22
+ '2b7e151628aed2a6abf7158809cf4f3c' + 'a0fafe1788542cb123a339392a6c7605' +
23
+ 'f2c295f27a96b9435935807a7359f67f' + '3d80477d4716fe3e1e237e446d7a883b' +
24
+ 'ef44a541a8525b7fb671253bdb0bad00' + 'd4d1c6f87c839d87caf2b8bc11f915bc' +
25
+ '6d88a37a110b3efddbf98641ca0093fd' + '4e54f70e5f5fc9f384a64fb24ea6dc4f' +
26
+ 'ead27321b58dbad2312bf5607f8d292f' + 'ac7766f319fadc2128d12941575c006e' +
27
+ 'd014f9a8c9ee2589e13f0cc8b6630ca6';
28
+
29
+ // --- independent AES-128 key-expansion generator -------------------------
30
+ function expandKey128(key) {
31
+ const out = Buffer.alloc(176);
32
+ key.copy(out, 0);
33
+ for (let i = 4; i < 44; i++) {
34
+ const prev = out.subarray(4 * (i - 1), 4 * i);
35
+ let t = [prev[0], prev[1], prev[2], prev[3]];
36
+ if (i % 4 === 0) {
37
+ t = [t[1], t[2], t[3], t[0]]; // RotWord
38
+ t = [SBOX[t[0]], SBOX[t[1]], SBOX[t[2]], SBOX[t[3]]]; // SubWord
39
+ t[0] ^= RCON[i / 4 - 1]; // Rcon
40
+ }
41
+ for (let k = 0; k < 4; k++)
42
+ out[4 * i + k] = out[4 * (i - 4) + k] ^ t[k];
43
+ }
44
+ return out;
45
+ }
46
+
47
+ const keyBuf = Buffer.from(KEY, 'hex');
48
+ const fipsBuf = Buffer.from(FIPS_SCHEDULE, 'hex');
49
+
50
+ console.log('FIPS-197 length check:');
51
+ check('176-byte schedule vector', fipsBuf.length === 176);
52
+
53
+ console.log('\nindependent generator vs FIPS-197 known answer:');
54
+ const gen = expandKey128(keyBuf);
55
+ check('expandKey128(key) === FIPS-197 schedule', gen.equals(fipsBuf));
56
+
57
+ console.log('\nJS detector (scheduleKey):');
58
+ const k1 = scheduleKey(fipsBuf, 0);
59
+ check('recovers key from FIPS-197 schedule @ offset 0',
60
+ k1 != null && k1.toString('hex') === KEY);
61
+
62
+ // embedded at a non-zero offset inside a larger buffer of noise
63
+ const wrap = Buffer.concat([Buffer.alloc(32, 0xAB), fipsBuf, Buffer.alloc(48, 0xCD)]);
64
+ let foundOff = -1, foundKey = null;
65
+ for (let o = 0; o + 176 <= wrap.length; o++) {
66
+ const kk = scheduleKey(wrap, o);
67
+ if (kk) { foundOff = o; foundKey = kk; }
68
+ }
69
+ check('finds schedule embedded at offset 32',
70
+ foundOff === 32 && foundKey && foundKey.toString('hex') === KEY);
71
+
72
+ console.log('\nnegative case (random data must NOT match):');
73
+ const noise = Buffer.alloc(4096);
74
+ for (let i = 0; i < noise.length; i++) noise[i] = (i * 2654435761) & 0xff;
75
+ let falsePos = 0;
76
+ for (let o = 0; o + 176 <= noise.length; o++) if (scheduleKey(noise, o)) falsePos++;
77
+ check('zero false positives on 4 KiB of pseudo-random data', falsePos === 0);
78
+
79
+ console.log('\nPython detector (lldb-pathA-scan.py: aes128_schedule_check):');
80
+ try {
81
+ const pyScript = `
82
+ import importlib.util
83
+ spec = importlib.util.spec_from_file_location("p", "tools/lldb_pathA_scan.py")
84
+ m = importlib.util.module_from_spec(spec)
85
+ spec.loader.exec_module(m)
86
+ k = m.aes128_schedule_check(bytes.fromhex("${FIPS_SCHEDULE}"))
87
+ print(k.hex() if k else "NONE")
88
+ bad = m.aes128_schedule_check(bytes([(i*7)&0xff for i in range(176)]))
89
+ print("NONE" if bad is None else bad.hex())
90
+ `;
91
+ const out = execFileSync('python3', ['-c', pyScript], { encoding: 'utf8' }).trim().split('\n');
92
+ check('Python recovers key from FIPS-197 schedule', out[0] === KEY);
93
+ check('Python rejects non-schedule data', out[1] === 'NONE');
94
+ } catch (e) {
95
+ fail++;
96
+ console.log(` FAIL python parity test: ${e.message}`);
97
+ }
98
+
99
+ console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed`);
100
+ process.exit(fail === 0 ? 0 : 1);
@@ -0,0 +1,274 @@
1
+ import * as net from 'net';
2
+ import * as crypto from 'crypto';
3
+
4
+ const STICK_HOST = '192.168.96.2';
5
+ const STICK_PORT = 2431;
6
+ const MAGIC_CLIENT = Buffer.from('LSAG_ALL');
7
+ const MAGIC_STICK = Buffer.from('Stick_3A');
8
+
9
+ function hexDump(label: string, buf: Buffer, maxLen = 128) {
10
+ console.log(`\n--- ${label} (${buf.length} bytes) ---`);
11
+ const len = Math.min(buf.length, maxLen);
12
+ for (let i = 0; i < len; i += 16) {
13
+ const hex = [];
14
+ const ascii = [];
15
+ for (let j = 0; j < 16; j++) {
16
+ if (i + j < len) {
17
+ hex.push(buf[i + j].toString(16).padStart(2, '0'));
18
+ const c = buf[i + j];
19
+ ascii.push(c >= 32 && c < 127 ? String.fromCharCode(c) : '.');
20
+ } else {
21
+ hex.push(' ');
22
+ }
23
+ }
24
+ console.log(` ${i.toString(16).padStart(4, '0')} ${hex.join(' ')} ${ascii.join('')}`);
25
+ }
26
+ if (buf.length > maxLen) console.log(` ... (${buf.length - maxLen} more bytes)`);
27
+ }
28
+
29
+ class StickConnection {
30
+ private sock: net.Socket;
31
+ private recvBuf = Buffer.alloc(0);
32
+ private resolveRead: ((buf: Buffer) => void) | null = null;
33
+ private readTarget = 0;
34
+
35
+ constructor() {
36
+ this.sock = new net.Socket();
37
+ this.sock.on('data', (data) => {
38
+ this.recvBuf = Buffer.concat([this.recvBuf, data]);
39
+ this.checkRead();
40
+ });
41
+ }
42
+
43
+ private checkRead() {
44
+ if (this.resolveRead && this.recvBuf.length >= this.readTarget) {
45
+ const result = this.recvBuf.subarray(0, this.readTarget);
46
+ this.recvBuf = this.recvBuf.subarray(this.readTarget);
47
+ const resolve = this.resolveRead;
48
+ this.resolveRead = null;
49
+ resolve(result);
50
+ }
51
+ }
52
+
53
+ read(n: number, timeout = 5000): Promise<Buffer> {
54
+ if (this.recvBuf.length >= n) {
55
+ const result = this.recvBuf.subarray(0, n);
56
+ this.recvBuf = this.recvBuf.subarray(n);
57
+ return Promise.resolve(result);
58
+ }
59
+ return new Promise((resolve, reject) => {
60
+ this.readTarget = n;
61
+ this.resolveRead = resolve;
62
+ const timer = setTimeout(() => {
63
+ this.resolveRead = null;
64
+ reject(new Error(`timeout reading ${n} bytes, have ${this.recvBuf.length}`));
65
+ }, timeout);
66
+ const origResolve = this.resolveRead;
67
+ this.resolveRead = (buf) => {
68
+ clearTimeout(timer);
69
+ resolve(buf);
70
+ };
71
+ });
72
+ }
73
+
74
+ async readMessage(): Promise<{ opcode: number; seq: number; payload: Buffer }> {
75
+ const header = await this.read(10);
76
+ const magic = header.subarray(0, 8).toString();
77
+ const opcode = header.readUInt8(8) | (header.readUInt8(9) << 8);
78
+
79
+ // Different opcodes have different response sizes
80
+ // Read the seq+reserved block (6 bytes minimum)
81
+ if (opcode === 0xc9) {
82
+ // Device info: 48 bytes after header
83
+ const data = await this.read(48);
84
+ return { opcode, seq: 0, payload: data };
85
+ } else if (opcode === 0x25) {
86
+ // Capability mask - variable, read remaining
87
+ const data = await this.read(30);
88
+ return { opcode, seq: 0, payload: data };
89
+ } else if (opcode === 0x47) {
90
+ // G hello response: 8 bytes ack + 36 bytes (4 + 32 pubkey)
91
+ const data = await this.read(44);
92
+ return { opcode, seq: data.readUInt16LE(0), payload: data.subarray(8) };
93
+ } else if (opcode === 0x48) {
94
+ // H key exchange ack: 8 bytes + 4 bytes
95
+ const data = await this.read(12);
96
+ return { opcode, seq: data.readUInt16LE(0), payload: data.subarray(8) };
97
+ } else if (opcode === 0x011c) {
98
+ // Session setup: 12 bytes ack then variable
99
+ const ack = await this.read(12);
100
+ const payloadLen = ack.readUInt16LE(8);
101
+ if (payloadLen > 0) {
102
+ const data = await this.read(payloadLen);
103
+ return { opcode, seq: ack.readUInt16LE(0), payload: data };
104
+ }
105
+ return { opcode, seq: ack.readUInt16LE(0), payload: Buffer.alloc(0) };
106
+ } else if (opcode === 0x70) {
107
+ // DMX response: 12 bytes ack + 512 bytes state
108
+ const ack = await this.read(12);
109
+ const state = await this.read(512);
110
+ return { opcode, seq: ack.readUInt16LE(0), payload: state };
111
+ } else {
112
+ // Unknown - try reading 12 bytes
113
+ const data = await this.read(12).catch(() => Buffer.alloc(0));
114
+ return { opcode, seq: 0, payload: data };
115
+ }
116
+ }
117
+
118
+ send(buf: Buffer) {
119
+ this.sock.write(buf);
120
+ }
121
+
122
+ async connect(): Promise<void> {
123
+ return new Promise((resolve, reject) => {
124
+ this.sock.connect(STICK_PORT, STICK_HOST, resolve);
125
+ this.sock.on('error', reject);
126
+ });
127
+ }
128
+
129
+ close() {
130
+ this.sock.destroy();
131
+ }
132
+
133
+ get buffered() { return this.recvBuf.length; }
134
+ }
135
+
136
+ // Build a protocol packet: magic(8) + opcode(2) + seq(2) + reserved(6) + payload
137
+ function buildPacket(magic: Buffer, opcode: number, seq: number, payload: Buffer): Buffer {
138
+ const header = Buffer.alloc(18);
139
+ magic.copy(header, 0);
140
+ header.writeUInt8(opcode & 0xff, 8);
141
+ header.writeUInt8((opcode >> 8) & 0xff, 9);
142
+ header.writeUInt16LE(seq, 10);
143
+ // bytes 12-17 are zero (reserved)
144
+ return Buffer.concat([header, payload]);
145
+ }
146
+
147
+ async function main() {
148
+ const conn = new StickConnection();
149
+ await conn.connect();
150
+ console.log('Connected to Stick');
151
+
152
+ // Step 1: ECDH Hello (opcode G = 0x47)
153
+ const helloPacket = buildPacket(MAGIC_CLIENT, 0x47, 6, Buffer.alloc(0));
154
+ hexDump('SEND hello (G)', helloPacket);
155
+ conn.send(helloPacket);
156
+
157
+ let helloResp = await conn.readMessage();
158
+ console.log(`RECV opcode=0x${helloResp.opcode.toString(16)} seq=${helloResp.seq}`);
159
+ hexDump('hello payload', helloResp.payload);
160
+
161
+ // Drain unsolicited 0x25 capability messages until we get the 0x47 hello
162
+ while (helloResp.opcode === 0x25) {
163
+ console.log('Got capability mask, reading next message...');
164
+ helloResp = await conn.readMessage();
165
+ console.log(`RECV opcode=0x${helloResp.opcode.toString(16)}`);
166
+ hexDump('payload', helloResp.payload);
167
+ }
168
+
169
+ if (helloResp.opcode !== 0x47) {
170
+ console.error(`Expected hello (0x47), got 0x${helloResp.opcode.toString(16)}`);
171
+ conn.close();
172
+ return;
173
+ }
174
+
175
+ // Extract Stick's ECDH public key (32 bytes, offset 4 in payload)
176
+ const stickPubKey = helloResp.payload.subarray(4, 36);
177
+ console.log(`Stick pubkey: ${stickPubKey.toString('hex')}`);
178
+
179
+ // Step 2: Generate X25519 key pair
180
+ const clientKeys = crypto.generateKeyPairSync('x25519');
181
+ const clientPubRaw = clientKeys.publicKey.export({ type: 'spki', format: 'der' });
182
+ const clientPubKey = clientPubRaw.subarray(clientPubRaw.length - 32);
183
+ console.log(`Client pubkey: ${clientPubKey.toString('hex')}`);
184
+
185
+ // Derive shared secret
186
+ const stickPubKeyObj = crypto.createPublicKey({
187
+ key: Buffer.concat([
188
+ Buffer.from('302a300506032b656e032100', 'hex'),
189
+ stickPubKey,
190
+ ]),
191
+ format: 'der',
192
+ type: 'spki',
193
+ });
194
+ const sharedSecret = crypto.diffieHellman({
195
+ privateKey: clientKeys.privateKey,
196
+ publicKey: stickPubKeyObj,
197
+ });
198
+ console.log(`Shared secret: ${sharedSecret.toString('hex')}`);
199
+
200
+ // Step 3: Key exchange (opcode H = 0x48)
201
+ const softwareName = Buffer.alloc(32);
202
+ Buffer.from('software').copy(softwareName);
203
+ const keyExPayload = Buffer.concat([softwareName, stickPubKey, clientPubKey]);
204
+ const keyExPacket = buildPacket(MAGIC_CLIENT, 0x48, 7, keyExPayload);
205
+ hexDump('SEND key exchange (H)', keyExPacket);
206
+ conn.send(keyExPacket);
207
+
208
+ const keyExResp = await conn.readMessage();
209
+ console.log(`RECV opcode=0x${keyExResp.opcode.toString(16)}`);
210
+ hexDump('key exchange response', keyExResp.payload);
211
+
212
+ // Drain any unsolicited messages
213
+ if (conn.buffered > 0) {
214
+ console.log(`${conn.buffered} bytes buffered, reading...`);
215
+ const extra = await conn.readMessage().catch(() => null);
216
+ if (extra) console.log(`Extra message: opcode=0x${extra.opcode.toString(16)}`);
217
+ }
218
+
219
+ // Step 4: Init (opcode F = 0x46)
220
+ const initPacket = Buffer.from([...MAGIC_STICK, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00]);
221
+ hexDump('SEND init (F)', initPacket);
222
+ conn.send(initPacket);
223
+
224
+ // Step 5: Device query
225
+ const queryPacket = Buffer.from([...MAGIC_STICK, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00]);
226
+ hexDump('SEND device query', queryPacket);
227
+ conn.send(queryPacket);
228
+
229
+ const devResp = await conn.readMessage();
230
+ console.log(`RECV opcode=0x${devResp.opcode.toString(16)} (device info)`);
231
+ hexDump('device info', devResp.payload, 48);
232
+
233
+ // Step 6: Session setup (opcode 0x011C)
234
+ const sessionPayload = Buffer.from([0x15, 0x00, 0x16, 0x00]);
235
+ const sessionPacket = buildPacket(MAGIC_STICK, 0x011c, 8, sessionPayload);
236
+ hexDump('SEND session setup', sessionPacket);
237
+ conn.send(sessionPacket);
238
+
239
+ const sessResp = await conn.readMessage();
240
+ console.log(`RECV opcode=0x${sessResp.opcode.toString(16)} (session)`);
241
+
242
+ // Drain any extra messages
243
+ await new Promise(r => setTimeout(r, 500));
244
+ while (conn.buffered > 0) {
245
+ const msg = await conn.readMessage().catch(() => null);
246
+ if (msg) console.log(`Extra: opcode=0x${msg.opcode.toString(16)}, payload=${msg.payload.length} bytes`);
247
+ else break;
248
+ }
249
+
250
+ // Step 7: Make fixture 2 RED
251
+ // DMX addr 6 = intensity 255, addr 9 = saturation 255, addr 10 = hue 0 (red)
252
+ console.log('\n=== Making fixture 2 RED ===');
253
+ const writes: [number, number, string][] = [
254
+ [6, 255, 'intensity'],
255
+ [9, 255, 'saturation'],
256
+ [10, 0, 'hue (red=0)'],
257
+ ];
258
+
259
+ let dmxSeq = 0x15;
260
+ for (const [channel, value, label] of writes) {
261
+ const payload = Buffer.from([value, 0x00, 0x00, 0x00, channel]);
262
+ const packet = buildPacket(MAGIC_STICK, 0x70, dmxSeq++, payload);
263
+ console.log(` ch${channel} = ${value} (${label})`);
264
+ conn.send(packet);
265
+ await new Promise(r => setTimeout(r, 100));
266
+ }
267
+ console.log('All writes sent. Waiting 2s for Stick to process...');
268
+ await new Promise(r => setTimeout(r, 2000));
269
+
270
+ console.log('\nDone.');
271
+ conn.close();
272
+ }
273
+
274
+ main().catch(console.error);