@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.
- package/CLAUDE.md +176 -0
- package/README.md +146 -0
- package/config.schema.json +143 -0
- package/dist/cli/stick.d.ts +3 -0
- package/dist/cli/stick.d.ts.map +1 -0
- package/dist/cli/stick.js +176 -0
- package/dist/cli/stick.js.map +1 -0
- package/dist/color/cct.d.ts +3 -0
- package/dist/color/cct.d.ts.map +1 -0
- package/dist/color/cct.js +33 -0
- package/dist/color/cct.js.map +1 -0
- package/dist/color/dimmer.d.ts +3 -0
- package/dist/color/dimmer.d.ts.map +1 -0
- package/dist/color/dimmer.js +16 -0
- package/dist/color/dimmer.js.map +1 -0
- package/dist/color/hsvcct.d.ts +3 -0
- package/dist/color/hsvcct.d.ts.map +1 -0
- package/dist/color/hsvcct.js +52 -0
- package/dist/color/hsvcct.js.map +1 -0
- package/dist/color/index.d.ts +7 -0
- package/dist/color/index.d.ts.map +1 -0
- package/dist/color/index.js +28 -0
- package/dist/color/index.js.map +1 -0
- package/dist/color/parsers.d.ts +20 -0
- package/dist/color/parsers.d.ts.map +1 -0
- package/dist/color/parsers.js +232 -0
- package/dist/color/parsers.js.map +1 -0
- package/dist/color/rgb.d.ts +3 -0
- package/dist/color/rgb.d.ts.map +1 -0
- package/dist/color/rgb.js +32 -0
- package/dist/color/rgb.js.map +1 -0
- package/dist/color/rgbaw.d.ts +3 -0
- package/dist/color/rgbaw.d.ts.map +1 -0
- package/dist/color/rgbaw.js +48 -0
- package/dist/color/rgbaw.js.map +1 -0
- package/dist/color/rgbw.d.ts +3 -0
- package/dist/color/rgbw.d.ts.map +1 -0
- package/dist/color/rgbw.js +37 -0
- package/dist/color/rgbw.js.map +1 -0
- package/dist/color/rgbww.d.ts +3 -0
- package/dist/color/rgbww.d.ts.map +1 -0
- package/dist/color/rgbww.js +54 -0
- package/dist/color/rgbww.js.map +1 -0
- package/dist/color/types.d.ts +64 -0
- package/dist/color/types.d.ts.map +1 -0
- package/dist/color/types.js +91 -0
- package/dist/color/types.js.map +1 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +191 -0
- package/dist/config.js.map +1 -0
- package/dist/controller.d.ts +32 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +128 -0
- package/dist/controller.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +17 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +96 -0
- package/dist/platform.js.map +1 -0
- package/dist/platformAccessory.d.ts +18 -0
- package/dist/platformAccessory.d.ts.map +1 -0
- package/dist/platformAccessory.js +81 -0
- package/dist/platformAccessory.js.map +1 -0
- package/dist/settings.d.ts +14 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +15 -0
- package/dist/settings.js.map +1 -0
- package/dist/stick/frame.d.ts +16 -0
- package/dist/stick/frame.d.ts.map +1 -0
- package/dist/stick/frame.js +51 -0
- package/dist/stick/frame.js.map +1 -0
- package/dist/stick/kdf.d.ts +23 -0
- package/dist/stick/kdf.d.ts.map +1 -0
- package/dist/stick/kdf.js +90 -0
- package/dist/stick/kdf.js.map +1 -0
- package/dist/stick/protocol.d.ts +28 -0
- package/dist/stick/protocol.d.ts.map +1 -0
- package/dist/stick/protocol.js +78 -0
- package/dist/stick/protocol.js.map +1 -0
- package/dist/stick/session.d.ts +50 -0
- package/dist/stick/session.d.ts.map +1 -0
- package/dist/stick/session.js +301 -0
- package/dist/stick/session.js.map +1 -0
- package/examples/dmx.yaml +65 -0
- package/package.json +51 -0
- package/src/cli/stick.ts +198 -0
- package/src/color/cct.ts +40 -0
- package/src/color/dimmer.ts +24 -0
- package/src/color/hsvcct.ts +59 -0
- package/src/color/index.ts +34 -0
- package/src/color/parsers.ts +232 -0
- package/src/color/rgb.ts +33 -0
- package/src/color/rgbaw.ts +46 -0
- package/src/color/rgbw.ts +36 -0
- package/src/color/rgbww.ts +56 -0
- package/src/color/types.ts +150 -0
- package/src/config.ts +248 -0
- package/src/controller.ts +136 -0
- package/src/index.ts +9 -0
- package/src/platform.ts +118 -0
- package/src/platformAccessory.ts +102 -0
- package/src/settings.ts +18 -0
- package/src/stick/frame.ts +70 -0
- package/src/stick/kdf.ts +99 -0
- package/src/stick/protocol.ts +90 -0
- package/src/stick/session.ts +348 -0
- package/stick-de3.json +6 -0
- package/tools/aes-hook.c +1404 -0
- package/tools/analyze-pcap.mjs +143 -0
- package/tools/capture-aes-key.lldb +25 -0
- package/tools/capture-and-verify.sh +46 -0
- package/tools/capture-handshake.sh +59 -0
- package/tools/capture-send.sh +39 -0
- package/tools/crack-kdf.mjs +121 -0
- package/tools/crack-key.mjs +170 -0
- package/tools/decode-frame.mjs +110 -0
- package/tools/decrypt-011c.mjs +123 -0
- package/tools/decrypt-dmx.mjs +130 -0
- package/tools/decrypt-ssl2.ts +154 -0
- package/tools/derive-dmx-key.mjs +189 -0
- package/tools/diff-011c.mjs +127 -0
- package/tools/dump-pcap-summary.mjs +54 -0
- package/tools/dump-tcp-stream.mjs +123 -0
- package/tools/extract-011c.mjs +117 -0
- package/tools/extract-frame.mjs +78 -0
- package/tools/find-011c-key.mjs +140 -0
- package/tools/find-iv.mjs +153 -0
- package/tools/ghidra/README.md +136 -0
- package/tools/ghidra/__pycache__/dump-011c-callers.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-011c-handler.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-aes-candidates.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-anchors.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-auth2.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-authenticate.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-block-cipher.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-cipher.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-ctor-chain.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-curve.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-encompassing.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-handshake.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-kdf-chain.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-kdf-context.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-profile.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-real-encrypt.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-secret.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-setsoftware.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-stick3-vtable.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-swxref.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-vt-key-getter.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-cipher.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-ctor.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-md5.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-secret-users.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3-crypto.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3-device.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3a-ctor.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3anet-ctor.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-vtables.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/string-walk.cpython-313.pyc +0 -0
- package/tools/ghidra/dump-011c-callers.py +140 -0
- package/tools/ghidra/dump-011c-handler.py +158 -0
- package/tools/ghidra/dump-aes-candidates.py +99 -0
- package/tools/ghidra/dump-anchors.py +192 -0
- package/tools/ghidra/dump-auth2.py +50 -0
- package/tools/ghidra/dump-authenticate.py +71 -0
- package/tools/ghidra/dump-block-cipher.py +88 -0
- package/tools/ghidra/dump-cipher.py +106 -0
- package/tools/ghidra/dump-ctor-chain.py +107 -0
- package/tools/ghidra/dump-curve.py +112 -0
- package/tools/ghidra/dump-encompassing.py +146 -0
- package/tools/ghidra/dump-handshake.py +76 -0
- package/tools/ghidra/dump-kdf-chain.py +105 -0
- package/tools/ghidra/dump-kdf-context.py +72 -0
- package/tools/ghidra/dump-profile.py +59 -0
- package/tools/ghidra/dump-real-encrypt.py +90 -0
- package/tools/ghidra/dump-secret.py +84 -0
- package/tools/ghidra/dump-setsoftware.py +63 -0
- package/tools/ghidra/dump-stick3-vtable.py +182 -0
- package/tools/ghidra/dump-swxref.py +27 -0
- package/tools/ghidra/dump-vt-key-getter.py +170 -0
- package/tools/ghidra/find-cipher.py +210 -0
- package/tools/ghidra/find-ctor.py +109 -0
- package/tools/ghidra/find-md5.py +97 -0
- package/tools/ghidra/find-secret-users.py +98 -0
- package/tools/ghidra/find-stick3-crypto.py +190 -0
- package/tools/ghidra/find-stick3-device.py +111 -0
- package/tools/ghidra/find-stick3a-ctor.py +87 -0
- package/tools/ghidra/find-stick3anet-ctor.py +76 -0
- package/tools/ghidra/find-vtables.py +165 -0
- package/tools/ghidra/out-011c-callers-run.log +51 -0
- package/tools/ghidra/out-011c-run.log +50 -0
- package/tools/ghidra/out-vt-key-run.log +55 -0
- package/tools/ghidra/string-walk.py +180 -0
- package/tools/hmac-key.sh +51 -0
- package/tools/hwm-entitlements.plist +18 -0
- package/tools/inspect-plaintext.mjs +87 -0
- package/tools/lldb-capture-aes.py +137 -0
- package/tools/lldb_hmac_key.py +93 -0
- package/tools/lldb_pathA_scan.py +341 -0
- package/tools/lldb_pathC_keytrace.py +300 -0
- package/tools/lldb_pathD_kdf_watch.py +281 -0
- package/tools/pathA-launch-hwm.sh +86 -0
- package/tools/pathA-scan.sh +52 -0
- package/tools/pathC-keytrace.sh +49 -0
- package/tools/pathC-run.sh +71 -0
- package/tools/pathD-kdf-watch.sh +51 -0
- package/tools/quick-trigger.mjs +86 -0
- package/tools/run-hooked-hwmanager.sh +51 -0
- package/tools/scan-aes-schedule.mjs +135 -0
- package/tools/send_dmx.mjs +480 -0
- package/tools/stick-status.mjs +57 -0
- package/tools/test-aes-detector.mjs +100 -0
- package/tools/test-tcp-write.ts +274 -0
- package/tools/test-udp-dmx.ts +324 -0
- package/tools/test-x25519.mjs +87 -0
- package/tools/try-decrypt.ts +144 -0
- package/tools/try-hardcoded-key.mjs +86 -0
- package/tools/try-hardcoded-key.ts +103 -0
- package/tools/try-key.mjs +103 -0
- package/tools/verify-011c-key.mjs +121 -0
- package/tools/verify-kdf.mjs +142 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// StickSession — TCP/2431 control channel + UDP/2431 encrypted DMX stream.
|
|
2
|
+
//
|
|
3
|
+
// Lifecycle:
|
|
4
|
+
// const session = await StickSession.connect(ip);
|
|
5
|
+
// session.send(channels, universe); // one DMX frame
|
|
6
|
+
// // ...repeat at 25 Hz...
|
|
7
|
+
// session.destroy(); // RST → Stick latches last values
|
|
8
|
+
//
|
|
9
|
+
// Ported from tools/send_dmx.mjs (frozen reference). Every env-var knob that
|
|
10
|
+
// existed in the .mjs is now a SessionOpts field with the same default; the
|
|
11
|
+
// env var remains as fallback so the CLI keeps its existing tuning behaviour.
|
|
12
|
+
|
|
13
|
+
import net from 'node:net';
|
|
14
|
+
import dgram from 'node:dgram';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import { Buffer } from 'node:buffer';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
AUTH_KEY,
|
|
20
|
+
LSAG,
|
|
21
|
+
MAGIC,
|
|
22
|
+
Q_FALLBACK,
|
|
23
|
+
TCP_PORT,
|
|
24
|
+
Token,
|
|
25
|
+
UDP_DST_PORT,
|
|
26
|
+
UDP_SRC_PORT,
|
|
27
|
+
findMsg,
|
|
28
|
+
msg,
|
|
29
|
+
sleep,
|
|
30
|
+
waitFor,
|
|
31
|
+
} from './protocol.js';
|
|
32
|
+
|
|
33
|
+
// HWM-faithful UDP broadcasts. 4 magics × 3 bursts on UDP/2430, plus a
|
|
34
|
+
// "LIGHTINGSOFT_XHL Hardware Manager" announce on UDP/24299. Verified
|
|
35
|
+
// 2026-05-24: not optional — without them, sessions after a Stick
|
|
36
|
+
// power-cycle silently fail to actually drive output even though the
|
|
37
|
+
// TCP handshake reports go-live success.
|
|
38
|
+
const DISCOVERY_MAGICS = ['LSAG_ALL', 'Stick_U1', 'Stick_3A', 'Siudi_7B'];
|
|
39
|
+
const DISCOVERY_TAIL = Buffer.from('000014000000', 'hex');
|
|
40
|
+
|
|
41
|
+
async function discoveryBroadcast(udp: dgram.Socket): Promise<void> {
|
|
42
|
+
for (let burst = 0; burst < 3; burst++) {
|
|
43
|
+
for (const m of DISCOVERY_MAGICS) {
|
|
44
|
+
const pkt = Buffer.concat([Buffer.from(m, 'ascii'), DISCOVERY_TAIL]);
|
|
45
|
+
await new Promise<void>((r) => udp.send(pkt, 2430, '255.255.255.255', () => r()));
|
|
46
|
+
}
|
|
47
|
+
await sleep(25);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const XHL_HEADER = Buffer.from(
|
|
52
|
+
'4c49474854494e47534f46545f58484c' +
|
|
53
|
+
'0000000000000000' +
|
|
54
|
+
'14000000' +
|
|
55
|
+
'01000000',
|
|
56
|
+
'hex',
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
async function announceHardwareManager(
|
|
60
|
+
udp24299: dgram.Socket,
|
|
61
|
+
instanceId: Buffer,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
const body114 = Buffer.alloc(114 - 32);
|
|
64
|
+
instanceId.copy(body114, 0);
|
|
65
|
+
body114.writeUInt32LE(crypto.randomBytes(4).readUInt32LE(0), 8);
|
|
66
|
+
body114.writeUInt32LE(11, 12);
|
|
67
|
+
body114.write('Hardware Manager\0', 18, 'ascii');
|
|
68
|
+
const pkt114 = Buffer.concat([XHL_HEADER, body114]);
|
|
69
|
+
await new Promise<void>((r) => udp24299.send(pkt114, 24299, '255.255.255.255', () => r()));
|
|
70
|
+
await sleep(25);
|
|
71
|
+
for (let i = 0; i < 2; i++) {
|
|
72
|
+
const tail = Buffer.concat([crypto.randomBytes(4), Buffer.from([0x0a + i * 2, 0])]);
|
|
73
|
+
const pkt46 = Buffer.concat([XHL_HEADER, instanceId, tail]);
|
|
74
|
+
await new Promise<void>((r) => udp24299.send(pkt46, 24299, '255.255.255.255', () => r()));
|
|
75
|
+
await sleep(25);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
import { deriveDmxKey, makeEphemeral, pointToWire, wireToPoint } from './kdf.js';
|
|
79
|
+
import { buildFrame } from './frame.js';
|
|
80
|
+
|
|
81
|
+
export interface SessionOpts {
|
|
82
|
+
/** TCP/2431 control-channel target. */
|
|
83
|
+
port?: number;
|
|
84
|
+
/** Inter-message gap during handshake chatter (ms). Default 10. */
|
|
85
|
+
chatterMs?: number;
|
|
86
|
+
/** Settle after 0x2e enter-live before 0x10/0x11 (ms). Default 50. */
|
|
87
|
+
settle2eMs?: number;
|
|
88
|
+
/** Number of 0x70 sector reads (HWM-UI chatter). Default 0 = skip. */
|
|
89
|
+
sectors?: number;
|
|
90
|
+
/** Run HWM-style probe TCP connection before the live one. Default false. */
|
|
91
|
+
runProbe?: boolean;
|
|
92
|
+
/** Gap between probe close and go-live open (ms). Default 800. */
|
|
93
|
+
probeGapMs?: number;
|
|
94
|
+
/** Initial Token counter value. Default 0x80. */
|
|
95
|
+
tokenStart?: number;
|
|
96
|
+
/** Optional logger; default no-op. */
|
|
97
|
+
log?: (...args: unknown[]) => void;
|
|
98
|
+
/** Caller-owned UDP socket (bound to UDP_SRC_PORT 2430). When supplied,
|
|
99
|
+
* the session uses it for DMX sends + does NOT close it on destroy().
|
|
100
|
+
* Used by StickController to avoid the close-then-rebind cycle that
|
|
101
|
+
* empirically poisons the Stick into rejecting subsequent sessions
|
|
102
|
+
* inside a long-lived plugin process. */
|
|
103
|
+
udp?: dgram.Socket;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const env = (k: string): string | undefined => process.env[k];
|
|
107
|
+
const envNum = (k: string, fallback: number): number =>
|
|
108
|
+
env(k) != null ? Number(env(k)) : fallback;
|
|
109
|
+
|
|
110
|
+
export class StickSession {
|
|
111
|
+
private constructor(
|
|
112
|
+
public readonly ip: string,
|
|
113
|
+
private sock: net.Socket,
|
|
114
|
+
private udp: dgram.Socket,
|
|
115
|
+
/** When true, we own the UDP socket and must close it on destroy(). */
|
|
116
|
+
private ownsUdp: boolean,
|
|
117
|
+
private key: Buffer,
|
|
118
|
+
private token: Token,
|
|
119
|
+
private rxBuf: Buffer,
|
|
120
|
+
) {}
|
|
121
|
+
|
|
122
|
+
/** Open the TCP/2431 control channel, run the handshake (auth → pre-DMX
|
|
123
|
+
* chatter → ECDH → optional sector reads → go-live), and return a session
|
|
124
|
+
* ready for `send()`. */
|
|
125
|
+
static async connect(ip: string, opts: SessionOpts = {}): Promise<StickSession> {
|
|
126
|
+
const resolved = {
|
|
127
|
+
port: opts.port ?? TCP_PORT,
|
|
128
|
+
chatterMs: opts.chatterMs ?? envNum('CHATTER_MS', 10),
|
|
129
|
+
settle2eMs: opts.settle2eMs ?? envNum('SETTLE_2E_MS', 50),
|
|
130
|
+
sectors: opts.sectors ?? envNum('SECTORS', 0),
|
|
131
|
+
runProbe: opts.runProbe ?? env('RUN_PROBE') === '1',
|
|
132
|
+
probeGapMs: opts.probeGapMs ?? envNum('PROBE_GAP_MS', 800),
|
|
133
|
+
tokenStart: opts.tokenStart ?? 0x80,
|
|
134
|
+
log: opts.log ?? ((): void => undefined),
|
|
135
|
+
};
|
|
136
|
+
const log = resolved.log;
|
|
137
|
+
const token = new Token(resolved.tokenStart);
|
|
138
|
+
|
|
139
|
+
// UDP socket for the DMX stream + the HWM-faithful broadcasts. If the
|
|
140
|
+
// caller (the controller, when running in a long-lived plugin process)
|
|
141
|
+
// owns one, we reuse it; otherwise we create one for this session and
|
|
142
|
+
// close it on destroy(). See the comment on SessionOpts.udp.
|
|
143
|
+
let udp: dgram.Socket;
|
|
144
|
+
let ownsUdp = false;
|
|
145
|
+
if (opts.udp) {
|
|
146
|
+
udp = opts.udp;
|
|
147
|
+
} else {
|
|
148
|
+
ownsUdp = true;
|
|
149
|
+
udp = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
|
150
|
+
await new Promise<void>((res, rej) => {
|
|
151
|
+
udp.once('error', rej);
|
|
152
|
+
udp.bind(UDP_SRC_PORT, () => { udp.setBroadcast(true); res(); });
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
await discoveryBroadcast(udp);
|
|
156
|
+
const udp24299 = dgram.createSocket('udp4');
|
|
157
|
+
try {
|
|
158
|
+
await new Promise<void>((res, rej) => {
|
|
159
|
+
udp24299.once('error', rej);
|
|
160
|
+
udp24299.bind(24299, () => { udp24299.setBroadcast(true); res(); });
|
|
161
|
+
});
|
|
162
|
+
const instanceId = crypto.randomBytes(8);
|
|
163
|
+
await announceHardwareManager(udp24299, instanceId);
|
|
164
|
+
} finally {
|
|
165
|
+
udp24299.close();
|
|
166
|
+
}
|
|
167
|
+
await sleep(200);
|
|
168
|
+
|
|
169
|
+
if (resolved.runProbe) {
|
|
170
|
+
const probe = await openTcp(ip, resolved.port);
|
|
171
|
+
try {
|
|
172
|
+
const probeBuf = { v: Buffer.alloc(0) };
|
|
173
|
+
probe.on('data', (d) => { probeBuf.v = Buffer.concat([probeBuf.v, d]); });
|
|
174
|
+
await doAuth(probe, () => probeBuf.v, token, '[probe]', log);
|
|
175
|
+
// 0x00 → 0xc9 status registration
|
|
176
|
+
probe.write(msg(MAGIC, 0x00, Buffer.from('14000000', 'hex')));
|
|
177
|
+
await waitFor(() => findMsg(probeBuf.v, 0x00c9, 18), 1500);
|
|
178
|
+
// 0x011c — we know this is just AES-encrypted device-info; discard reply
|
|
179
|
+
probe.write(msg(MAGIC, 0x011c, token.next(), Buffer.from('01001600', 'hex')));
|
|
180
|
+
await waitFor(() => findMsg(probeBuf.v, 0x011c, 22), 2000);
|
|
181
|
+
// 0x007b — license query
|
|
182
|
+
probe.write(msg(MAGIC, 0x007b, token.next()));
|
|
183
|
+
await waitFor(() => findMsg(probeBuf.v, 0x007b, 22), 1500);
|
|
184
|
+
await sleep(150);
|
|
185
|
+
} finally {
|
|
186
|
+
await new Promise<void>((res) => probe.end(() => res()));
|
|
187
|
+
}
|
|
188
|
+
await sleep(resolved.probeGapMs);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sock = await openTcp(ip, resolved.port);
|
|
192
|
+
const rxBuf = { v: Buffer.alloc(0) };
|
|
193
|
+
sock.on('data', (d) => { rxBuf.v = Buffer.concat([rxBuf.v, d]); });
|
|
194
|
+
|
|
195
|
+
await doAuth(sock, () => rxBuf.v, token, '[live]', log);
|
|
196
|
+
|
|
197
|
+
// Pre-DMX chatter — match send_dmx.mjs EXACTLY (which is empirically
|
|
198
|
+
// known to work multi-session from fresh processes). Earlier attempt
|
|
199
|
+
// to wait explicitly for 0xc9 in the middle may have been wrong.
|
|
200
|
+
for (const m of [
|
|
201
|
+
msg(MAGIC, 0x46, Buffer.alloc(4)),
|
|
202
|
+
msg(MAGIC, 0x09, Buffer.from('14000000', 'hex')),
|
|
203
|
+
msg(MAGIC, 0x09, Buffer.from('14000000', 'hex')),
|
|
204
|
+
msg(MAGIC, 0x00, Buffer.from('14000000', 'hex')),
|
|
205
|
+
msg(MAGIC, 0x011c, token.next(), Buffer.from('01001600', 'hex')),
|
|
206
|
+
msg(MAGIC, 0x05, Buffer.from('0200', 'hex')),
|
|
207
|
+
]) {
|
|
208
|
+
sock.write(m);
|
|
209
|
+
await sleep(resolved.chatterMs);
|
|
210
|
+
}
|
|
211
|
+
if (findMsg(rxBuf.v, 0x00c9, 18)) log('0xc9 status received — session registered');
|
|
212
|
+
|
|
213
|
+
// 0x10 — crypto-state query
|
|
214
|
+
rxBuf.v = Buffer.alloc(0);
|
|
215
|
+
sock.write(msg(MAGIC, 0x10, token.next()));
|
|
216
|
+
const r10 = await waitFor(() => findMsg(rxBuf.v, 0x10, 22), 2000);
|
|
217
|
+
if (r10) log(`0x10 crypto state: ${r10.readUInt32LE(0x12)}`);
|
|
218
|
+
rxBuf.v = Buffer.alloc(0);
|
|
219
|
+
|
|
220
|
+
// 0x0F — ECDH pubkey exchange
|
|
221
|
+
const ecdh = makeEphemeral();
|
|
222
|
+
const ourP256 = pointToWire(ecdh.getPublicKey(null, 'uncompressed') as Buffer);
|
|
223
|
+
sock.write(msg(MAGIC, 0x0f, token.next(), ourP256));
|
|
224
|
+
const r0f = await waitFor(() => findMsg(rxBuf.v, 0x0f, 86), 3000);
|
|
225
|
+
let Qwire: Buffer = Q_FALLBACK;
|
|
226
|
+
if (r0f != null) {
|
|
227
|
+
Qwire = r0f.subarray(0x16, 0x56);
|
|
228
|
+
log('0x0F ok — got Stick DMX pubkey');
|
|
229
|
+
} else {
|
|
230
|
+
log('0x0F — no reply, using static Stick key');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const key = deriveDmxKey(ecdh, wireToPoint(Qwire));
|
|
234
|
+
log(`DMX key derived: ${key.toString('hex')}`);
|
|
235
|
+
|
|
236
|
+
// 6b. device sync — replicated byte-for-byte against a captured HWM session
|
|
237
|
+
sock.write(msg(MAGIC, 0x10, token.next())); await sleep(resolved.chatterMs);
|
|
238
|
+
sock.write(msg(MAGIC, 0x75, token.next())); await sleep(resolved.chatterMs);
|
|
239
|
+
sock.write(msg(MAGIC, 0x74, token.next())); await sleep(resolved.chatterMs);
|
|
240
|
+
for (const p of ['0200000000', '0100000000', '0100000000', '02b37f0000']) {
|
|
241
|
+
sock.write(msg(MAGIC, 0x71, token.next(), Buffer.from(p, 'hex')));
|
|
242
|
+
await sleep(resolved.chatterMs);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 0x70 sector reads — HWM commissioning-UI chatter; user-confirmed not
|
|
246
|
+
// required for go-live. Default 0; set SECTORS=N or sectors:N to include.
|
|
247
|
+
if (resolved.sectors > 0) {
|
|
248
|
+
const full = [0];
|
|
249
|
+
for (let s = 63; s <= 185; s++) full.push(s);
|
|
250
|
+
const list = full.slice(0, resolved.sectors);
|
|
251
|
+
log(`0x70 device download (${list.length} sectors)`);
|
|
252
|
+
for (let i = 0; i < list.length; i++) {
|
|
253
|
+
const body = Buffer.alloc(5);
|
|
254
|
+
body.writeUInt32LE(list[i], 0);
|
|
255
|
+
body[4] = 1;
|
|
256
|
+
sock.write(msg(MAGIC, 0x70, token.next(), body));
|
|
257
|
+
await sleep(12);
|
|
258
|
+
if ((i & 0x1f) === 0x1f) rxBuf.v = Buffer.alloc(0);
|
|
259
|
+
}
|
|
260
|
+
await sleep(200);
|
|
261
|
+
}
|
|
262
|
+
rxBuf.v = Buffer.alloc(0);
|
|
263
|
+
|
|
264
|
+
// Enter live mode: 0x2e, settle, 0x10/0x11/0x10
|
|
265
|
+
sock.write(msg(MAGIC, 0x2e, Buffer.alloc(32))); await sleep(resolved.chatterMs);
|
|
266
|
+
await sleep(resolved.settle2eMs);
|
|
267
|
+
sock.write(msg(MAGIC, 0x10, token.next())); await sleep(resolved.chatterMs);
|
|
268
|
+
sock.write(msg(MAGIC, 0x11, token.next()));
|
|
269
|
+
const r11 = await waitFor(() => findMsg(rxBuf.v, 0x11, 22), 3000);
|
|
270
|
+
log(r11 ? 'live mode enabled (0x11 ok)' : '0x11 — no reply');
|
|
271
|
+
sock.write(msg(MAGIC, 0x10, token.next()));
|
|
272
|
+
const r10b = await waitFor(() => findMsg(rxBuf.v, 0x10, 22), 1500);
|
|
273
|
+
if (r10b) log(`0x10 after 0x11 — crypto state ${r10b.readUInt32LE(0x12)}`);
|
|
274
|
+
|
|
275
|
+
return new StickSession(ip, sock, udp, ownsUdp, key, token, rxBuf.v);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Per-frame sequence counter (low byte). */
|
|
279
|
+
private seqCtr = 0;
|
|
280
|
+
|
|
281
|
+
/** Send one encrypted DMX frame for `universe` (port=0 for A).
|
|
282
|
+
* `channels` must be a 512-byte Uint8Array. */
|
|
283
|
+
send(channels: Uint8Array, universe = 0): void {
|
|
284
|
+
const frame = buildFrame({
|
|
285
|
+
key: this.key,
|
|
286
|
+
channels,
|
|
287
|
+
port: universe,
|
|
288
|
+
fieldA: this.token.next(),
|
|
289
|
+
seq: this.seqCtr++,
|
|
290
|
+
});
|
|
291
|
+
this.udp.send(frame, UDP_DST_PORT, this.ip, () => {});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Dirty-close: UDP close + TCP RST. The Stick latches the last DMX values
|
|
295
|
+
* it received (cf. memory note `stick-actually-latches-on-clean-disconnect`).
|
|
296
|
+
* Idempotent. */
|
|
297
|
+
destroy(): void {
|
|
298
|
+
if (this.ownsUdp) {
|
|
299
|
+
try { this.udp.close(); } catch { /* already closed */ }
|
|
300
|
+
}
|
|
301
|
+
try { this.sock.destroy(); } catch { /* already destroyed */ }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
function openTcp(ip: string, port: number): Promise<net.Socket> {
|
|
308
|
+
return new Promise((res, rej) => {
|
|
309
|
+
const s = net.createConnection({ host: ip, port });
|
|
310
|
+
s.setTimeout(5000);
|
|
311
|
+
s.once('connect', () => { s.setTimeout(0); res(s); });
|
|
312
|
+
s.once('error', rej);
|
|
313
|
+
s.once('timeout', () => rej(new Error(`no response from ${ip}:${port}`)));
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function doAuth(
|
|
318
|
+
sock: net.Socket,
|
|
319
|
+
getBuf: () => Buffer,
|
|
320
|
+
token: Token,
|
|
321
|
+
tag: string,
|
|
322
|
+
log: (...a: unknown[]) => void,
|
|
323
|
+
): Promise<Buffer> {
|
|
324
|
+
// 0x47 — LSAG_ALL hello; Stick replies with its 32-byte handshake key
|
|
325
|
+
sock.write(msg(LSAG, 0x47, token.next()));
|
|
326
|
+
const r47 = await waitFor(() => findMsg(getBuf(), 0x47, 54), 3000);
|
|
327
|
+
let stickKey = Buffer.alloc(32);
|
|
328
|
+
if (r47 != null) {
|
|
329
|
+
stickKey = Buffer.from(r47.subarray(0x16, 0x36));
|
|
330
|
+
log(`${tag} 0x47 ok`);
|
|
331
|
+
} else {
|
|
332
|
+
log(`${tag} 0x47 — no reply`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 0x48 — authenticated handshake (HMAC-SHA256 of the 82-byte head)
|
|
336
|
+
const software = Buffer.alloc(32); software.write('software');
|
|
337
|
+
const head48 = msg(LSAG, 0x48, token.next(), software, stickKey);
|
|
338
|
+
const mac48 = crypto.createHmac('sha256', AUTH_KEY).update(head48).digest();
|
|
339
|
+
sock.write(Buffer.concat([head48, mac48]));
|
|
340
|
+
const r48 = await waitFor(() => findMsg(getBuf(), 0x48, 22), 2000);
|
|
341
|
+
if (r48) {
|
|
342
|
+
const st = r48.readUInt32LE(0x12);
|
|
343
|
+
log(`${tag} 0x48 auth status: ${st}${st === 0 ? ' (ok)' : ' (REJECTED)'}`);
|
|
344
|
+
} else {
|
|
345
|
+
log(`${tag} 0x48 — no reply`);
|
|
346
|
+
}
|
|
347
|
+
return stickKey;
|
|
348
|
+
}
|