@ecopoesis/homebridge-dmx 0.3.0 → 0.5.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 +1 -0
- package/ENCRYPTION.md +207 -0
- package/PROTOCOL.md +277 -0
- package/README.md +25 -2
- package/config.schema.json +53 -5
- package/dist/config.d.ts +5 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +13 -2
- package/dist/config.js.map +1 -1
- package/dist/controller.d.ts +16 -4
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +41 -5
- package/dist/controller.js.map +1 -1
- package/dist/dmxController.d.ts +11 -0
- package/dist/dmxController.d.ts.map +1 -0
- package/dist/dmxController.js +4 -0
- package/dist/dmxController.js.map +1 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +5 -2
- package/dist/platform.js.map +1 -1
- package/dist/platformAccessory.d.ts +2 -2
- package/dist/platformAccessory.d.ts.map +1 -1
- package/dist/platformAccessory.js.map +1 -1
- package/dist/sacn.d.ts +29 -0
- package/dist/sacn.d.ts.map +1 -0
- package/dist/sacn.js +142 -0
- package/dist/sacn.js.map +1 -0
- package/dist/settings.d.ts +23 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +24 -0
- package/dist/settings.js.map +1 -1
- package/dist/zoneAccessory.d.ts +2 -2
- package/dist/zoneAccessory.d.ts.map +1 -1
- package/dist/zoneAccessory.js.map +1 -1
- package/examples/dmx.yaml +13 -4
- package/package.json +1 -1
- package/src/config.ts +18 -3
- package/src/controller.ts +41 -6
- package/src/dmxController.ts +14 -0
- package/src/platform.ts +8 -4
- package/src/platformAccessory.ts +2 -2
- package/src/sacn.ts +149 -0
- package/src/settings.ts +32 -0
- package/src/zoneAccessory.ts +2 -2
- package/tools/stick-power-cycle.py +121 -0
package/CLAUDE.md
CHANGED
|
@@ -11,6 +11,7 @@ Published to npm as **`@ecopoesis/homebridge-dmx`** (scoped because the unscoped
|
|
|
11
11
|
- HomeKit set → render bytes via the fixture's color model → write to universe buffer → debounce (750 ms) → spawn `tools/send_dmx.mjs` as a child process per transaction
|
|
12
12
|
- Subprocess approach: empirical workaround — in-process protocol only works for the FIRST transaction per long-lived plugin process; sessions 2+ silently fail to drive output. Fresh node process per transaction reliably works. Cost: ~1.5-2 s per change.
|
|
13
13
|
- Color models: dimmer, cct, rgb, rgbw, rgbww, rgbaw, hsvcct (the WAC native model)
|
|
14
|
+
- Keepalive refresh (0.4.0): per-controller `refreshMinutes` (default 30, 0 = off) re-sends the last-known state after that much idle time. Reason: the Stick drifts into a wedged state after hours without traffic — sessions + crypto succeed but live output is dark (2026-08-22..25); regular traffic prevents/corrects it and re-asserts state after the nightly 4:30 AM ET PoE power-cycle (systemd user timer on homebridge01 → UniFi API poe_mode off/auto on sw01 port 15; see `tools/stick-power-cycle.py`). The Stick also has a ~3 min post-boot grace period where sessions succeed but output is ignored.
|
|
14
15
|
- `tools/send_dmx.mjs` is now structurally critical (frozen reference shipped in the npm tarball)
|
|
15
16
|
|
|
16
17
|
## Current deployment
|
package/ENCRYPTION.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Stick-DE3 Encryption
|
|
2
|
+
|
|
3
|
+
All of the encryption used by the Stick-DE3 / Hardware Manager protocol,
|
|
4
|
+
recovered by reverse engineering and verified end-to-end against captured
|
|
5
|
+
sessions.
|
|
6
|
+
|
|
7
|
+
Two independent crypto primitives are involved:
|
|
8
|
+
|
|
9
|
+
1. **HMAC-SHA256** — TCP auth handshake (opcode 0x48). Fixed key.
|
|
10
|
+
2. **AES-256-CBC** with a P-256 ECDH-derived per-session key — the DMX UDP
|
|
11
|
+
stream and the 0x011c control reply.
|
|
12
|
+
|
|
13
|
+
For the wire format that uses these, see [PROTOCOL.md](PROTOCOL.md). For the
|
|
14
|
+
working implementation, see [`tools/send_dmx.mjs`](tools/send_dmx.mjs) and
|
|
15
|
+
[`tools/derive-dmx-key.mjs`](tools/derive-dmx-key.mjs).
|
|
16
|
+
|
|
17
|
+
## 1. TCP auth — HMAC-SHA256
|
|
18
|
+
|
|
19
|
+
The opcode 0x48 message authenticates the client to the Stick. The signed
|
|
20
|
+
region is the 82-byte head of the message (everything up to and not
|
|
21
|
+
including the trailing HMAC):
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
HMAC-SHA256(AUTH_KEY, magic(8) ‖ 0x48 ‖ token(8) ‖ softwareName(32) ‖ stickHandshakeKey(32))
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The HMAC is appended (32 bytes) to make the full 0x48 message 114 bytes.
|
|
28
|
+
|
|
29
|
+
### `AUTH_KEY`
|
|
30
|
+
|
|
31
|
+
A 15-byte ASCII string baked into Hardware Manager. The literal string is:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
#h.6xcKsGD{y}-z
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
As bytes (hex):
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
23 68 2e 36 78 63 4b 73 47 44 7b 79 7d 2d 7a
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Embedded as a constant in [`tools/send_dmx.mjs`](tools/send_dmx.mjs#L45) as
|
|
44
|
+
`AUTH_KEY`. Originally extracted at runtime with `tools/hmac-key.sh` /
|
|
45
|
+
`tools/lldb_hmac_key.py`, then verified against a captured HWM handshake.
|
|
46
|
+
|
|
47
|
+
A bad HMAC results in 0x48 reply status 100 (`PermissionDenied`), and the
|
|
48
|
+
session is never promoted to a live control session — no further opcodes will
|
|
49
|
+
work.
|
|
50
|
+
|
|
51
|
+
## 2. DMX stream cipher — AES-256-CBC, P-256-derived key
|
|
52
|
+
|
|
53
|
+
The 544-byte body of every DMX UDP frame is AES-256 in CBC mode. The IV is
|
|
54
|
+
constructed from two fields in the clear 32-byte header
|
|
55
|
+
(`fieldA ‖ nonce`, see [PROTOCOL.md](PROTOCOL.md#aes-iv)). The key is
|
|
56
|
+
derived freshly each session from an ECDH-style handshake on opcode 0x0F.
|
|
57
|
+
|
|
58
|
+
### Mode confirmation
|
|
59
|
+
|
|
60
|
+
The cipher was statically reverse-engineered from the 2024-03-21 Hardware
|
|
61
|
+
Manager binary: custom-inlined AES (S-box at `DAT_1007c0010`), not the
|
|
62
|
+
Gladman library reference. The mode bit in the cipher state structure
|
|
63
|
+
selects CBC or CFB; the live Stick-DE3 path uses **CBC**, confirmed by
|
|
64
|
+
re-encrypting a captured plaintext with the recovered key + IV and
|
|
65
|
+
byte-matching the captured ciphertext.
|
|
66
|
+
|
|
67
|
+
### The KDF (`deriveDmxKey`)
|
|
68
|
+
|
|
69
|
+
Implemented in [`tools/derive-dmx-key.mjs`](tools/derive-dmx-key.mjs).
|
|
70
|
+
Recovered from `FUN_100107650` in the binary. In full:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
1. d = random scalar mod n (client ephemeral private key)
|
|
74
|
+
2. our = d · G (client ephemeral public key)
|
|
75
|
+
3. send `our`, receive Stick pubkey `Q` (opcode 0x0F handshake)
|
|
76
|
+
4. S = decompress(STATIC_POINT_COMPRESSED)
|
|
77
|
+
5. AES-256 key = LE( X(d · S) XOR X(d · Q) )
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
This is a **two-DH construction**: one ECDH against the hardcoded static
|
|
81
|
+
point `S` (authenticates the application — anyone who doesn't know `S`
|
|
82
|
+
can't talk to the Stick), and one against the Stick's per-session ephemeral
|
|
83
|
+
`Q` (provides forward secrecy across sessions).
|
|
84
|
+
|
|
85
|
+
The Stick computes the identical key on its side as
|
|
86
|
+
`X(s · our) XOR X(e · our)`, where `s` and `e` are the Stick's static and
|
|
87
|
+
ephemeral private scalars. The two agree by ECDH symmetry.
|
|
88
|
+
|
|
89
|
+
### Curve parameters
|
|
90
|
+
|
|
91
|
+
NIST P-256 / secp256r1 / prime256v1. Confirmed three ways from the binary —
|
|
92
|
+
prime, order, and base point all byte-match P-256, and the curve `b`
|
|
93
|
+
constant `5ac635d8…27d2604b` is used during point decompression.
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
|
|
97
|
+
n = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
|
|
98
|
+
a = 0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc // -3 mod p
|
|
99
|
+
b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
|
|
100
|
+
Gx = 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296
|
|
101
|
+
Gy = 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### The static point `S`
|
|
105
|
+
|
|
106
|
+
A 33-byte SEC1-compressed P-256 point stored verbatim in the Hardware
|
|
107
|
+
Manager binary at file offset `0x7b0490` (vmaddr `0x1007b0490`). The
|
|
108
|
+
prefix byte `0x03` indicates odd-Y:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
03 28 08 12 90 39 6d 06 3e 51 14 52 6e d9 78 b9
|
|
112
|
+
26 55 8f 6b a1 c9 6a d2 fa cf 76 ff fb 3f fe a0 fe
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Embedded as `STATIC_POINT_COMPRESSED` in
|
|
116
|
+
[`tools/derive-dmx-key.mjs`](tools/derive-dmx-key.mjs#L68).
|
|
117
|
+
|
|
118
|
+
(An earlier RE pass tried XOR-masking these bytes with `0x51, 0x52, …`,
|
|
119
|
+
which produces a different point used elsewhere in the app. That isn't the
|
|
120
|
+
KDF static point — the raw bytes above are the ones that reproduce a real
|
|
121
|
+
session's installed AES key. Confirmed by `tools/verify-kdf.mjs` against an
|
|
122
|
+
lldb-dumped ephemeral `d` plus the Stick pubkey from a captured handshake.)
|
|
123
|
+
|
|
124
|
+
### Endianness
|
|
125
|
+
|
|
126
|
+
The Stick stores every 256-bit coordinate as **little-endian limbs** (byte 0
|
|
127
|
+
is the least-significant byte). Node's `crypto` works in big-endian SEC1
|
|
128
|
+
form, so a byte-reverse happens at every wire boundary:
|
|
129
|
+
|
|
130
|
+
- The 64-byte public key sent on opcode 0x0F is `LE(X) ‖ LE(Y)`. See
|
|
131
|
+
`pointToWire()` / `wireToPoint()` in `derive-dmx-key.mjs`.
|
|
132
|
+
- The installed AES key is the **little-endian form** of `X(d·S) ⊕ X(d·Q)`.
|
|
133
|
+
See `deriveDmxKey()` lines 128-135.
|
|
134
|
+
|
|
135
|
+
Forgetting the byte-reverse on the key was a multi-day debugging detour.
|
|
136
|
+
|
|
137
|
+
### Plaintext header `P0`
|
|
138
|
+
|
|
139
|
+
The first 16 bytes of every frame's plaintext are a fixed constant baked
|
|
140
|
+
into Hardware Manager:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
5b 4e 99 da 96 85 ad 97 6c 43 2b 0a 7f f9 ff cc
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Embedded as `P0` in [`tools/send_dmx.mjs`](tools/send_dmx.mjs#L41). The
|
|
147
|
+
Stick decrypts the body and verifies the first 16 bytes equal `P0` before
|
|
148
|
+
accepting the frame. This is effectively a known-plaintext anchor /
|
|
149
|
+
integrity check — a frame encrypted with the wrong key fails this check
|
|
150
|
+
even though CBC otherwise has no built-in authentication.
|
|
151
|
+
|
|
152
|
+
The 16 bytes immediately after `P0` are zero in every observed frame; we
|
|
153
|
+
send zeros there. The 512 DMX channel bytes follow at plaintext offset 32.
|
|
154
|
+
|
|
155
|
+
## 3. The 0x011c reply
|
|
156
|
+
|
|
157
|
+
The TCP/2431 reply to opcode 0x011c is encrypted device-info using the same
|
|
158
|
+
AES-256 session key. The body is not needed for the DMX control path and
|
|
159
|
+
its plaintext schema is not fully documented here. Tooling that decrypted
|
|
160
|
+
it for analysis:
|
|
161
|
+
|
|
162
|
+
- [`tools/extract-011c.mjs`](tools/extract-011c.mjs)
|
|
163
|
+
- [`tools/find-011c-key.mjs`](tools/find-011c-key.mjs)
|
|
164
|
+
- [`tools/decrypt-011c.mjs`](tools/decrypt-011c.mjs)
|
|
165
|
+
- [`tools/verify-011c-key.mjs`](tools/verify-011c-key.mjs)
|
|
166
|
+
- [`tools/diff-011c.mjs`](tools/diff-011c.mjs)
|
|
167
|
+
|
|
168
|
+
`send_dmx.mjs` sends the 0x011c request (it's part of the wire-faithful
|
|
169
|
+
handshake sequence) and discards the reply.
|
|
170
|
+
|
|
171
|
+
## What's NOT encrypted
|
|
172
|
+
|
|
173
|
+
- **Discovery broadcasts** on UDP/2430 (the four `LSAG_ALL`/`Stick_U1`/
|
|
174
|
+
`Stick_3A`/`Siudi_7B` startup packets) are clear.
|
|
175
|
+
- **UDP/2430 "Quick Trigger"** is clear and unauthenticated by design — see
|
|
176
|
+
[PROTOCOL.md §Quick Trigger](PROTOCOL.md#udp2430--quick-trigger-documented-but-limited).
|
|
177
|
+
Scene-level only; cannot set individual DMX channels.
|
|
178
|
+
- **`LIGHTINGSOFT_XHL`** beacons on UDP/24299 are clear.
|
|
179
|
+
- **The 32-byte UDP/2431 DMX frame header** is clear (this is what carries
|
|
180
|
+
the IV components).
|
|
181
|
+
- **TCP opcodes 0x47, 0x48, and the handshake chatter (0x46, 0x09, 0x00,
|
|
182
|
+
0x05, 0x10, 0x0F, 0x70, 0x71, 0x74, 0x75, 0x2e, 0x11)** are clear. Only
|
|
183
|
+
the 0x011c reply body and the DMX UDP body are AES-encrypted.
|
|
184
|
+
|
|
185
|
+
## Verification harness
|
|
186
|
+
|
|
187
|
+
The KDF self-test runs without hardware:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
node tools/derive-dmx-key.mjs
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
It exercises ECDH symmetry, the wire round-trip, and the real static point.
|
|
194
|
+
End-to-end verification against captured sessions uses
|
|
195
|
+
`tools/verify-kdf.mjs`, `tools/verify-011c-key.mjs`, and the lldb dumps in
|
|
196
|
+
`tools/lldb_*.py`.
|
|
197
|
+
|
|
198
|
+
## Provenance summary
|
|
199
|
+
|
|
200
|
+
| Constant | Where it lives in HWM | Recovery method |
|
|
201
|
+
|----------|----------------------|-----------------|
|
|
202
|
+
| `AUTH_KEY` (15B) | string literal | `tools/hmac-key.sh`, lldb HMAC trace |
|
|
203
|
+
| `STATIC_POINT_COMPRESSED` (33B) | file offset `0x7b0490` | static RE (Ghidra), verified against captured KDF output |
|
|
204
|
+
| `P0` (16B plaintext header) | constant inlined in frame builder | static RE, verified against decrypted captured frame |
|
|
205
|
+
| Curve = P-256 | code constants | byte-match against NIST P-256 |
|
|
206
|
+
| Mode = AES-256-CBC | custom-inlined AES, S-box at `DAT_1007c0010` | static RE + ciphertext byte-match |
|
|
207
|
+
| KDF = `X(d·S) ⊕ X(d·Q)` | `FUN_100107650` | static RE, verified `tools/verify-kdf.mjs` |
|
package/PROTOCOL.md
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# Stick-DE3 Protocol
|
|
2
|
+
|
|
3
|
+
What we know about the Nicolaudie Stick-DE3 wire protocol, focused on the path
|
|
4
|
+
needed to drive per-channel DMX from a third-party client. Everything below
|
|
5
|
+
was reverse-engineered from packet captures of Hardware Manager / ESA Pro 2
|
|
6
|
+
plus static RE of the 2024-03-21 macOS Hardware Manager binary.
|
|
7
|
+
|
|
8
|
+
The authoritative implementation is [`tools/send_dmx.mjs`](tools/send_dmx.mjs).
|
|
9
|
+
This document is the explanation; the code is the spec.
|
|
10
|
+
|
|
11
|
+
For the encryption details (KDF, key material, AES mode), see
|
|
12
|
+
[ENCRYPTION.md](ENCRYPTION.md).
|
|
13
|
+
|
|
14
|
+
## Ports & transports
|
|
15
|
+
|
|
16
|
+
| Port | Transport | Direction | Purpose |
|
|
17
|
+
|------|-----------|-----------|---------|
|
|
18
|
+
| 2431 | TCP | client → Stick | Control + per-session key exchange. Single session, mutually exclusive with HWM. |
|
|
19
|
+
| 2431 | UDP | client → Stick | Live DMX stream (encrypted 576-byte frames). |
|
|
20
|
+
| 2430 | UDP | bidirectional, broadcast | Discovery + the documented "Quick Trigger" scene control. |
|
|
21
|
+
| 24299 | UDP | client broadcast | Hardware Manager presence announce (`LIGHTINGSOFT_XHL`). |
|
|
22
|
+
|
|
23
|
+
The DMX UDP stream uses **source port 2430** and destination port 2431.
|
|
24
|
+
This pairing matters — the Stick filters on it.
|
|
25
|
+
|
|
26
|
+
## Message framing (TCP/2431)
|
|
27
|
+
|
|
28
|
+
Every TCP message starts with an 8-byte ASCII magic, then a 2-byte
|
|
29
|
+
little-endian opcode, then opcode-specific payload:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
+--------+--------+----------------+
|
|
33
|
+
| magic | opcode | payload … |
|
|
34
|
+
| 8B | 2B LE | variable |
|
|
35
|
+
+--------+--------+----------------+
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Two magics are used:
|
|
39
|
+
|
|
40
|
+
- `Stick_3A` — the Stick's identity; used for most messages.
|
|
41
|
+
- `LSAG_ALL` — broadcast/handshake magic; used for the initial auth opcodes
|
|
42
|
+
(0x47, 0x48) and for discovery.
|
|
43
|
+
|
|
44
|
+
Most payloads begin with an 8-byte session token. The token is a single
|
|
45
|
+
monotonically-increasing little-endian uint32 (padded to 8 bytes) that
|
|
46
|
+
**continues across both TCP messages and the UDP DMX frames** within the same
|
|
47
|
+
session. Restarting the counter, or using a separate counter for UDP, causes
|
|
48
|
+
the Stick to drop every frame. Initial value in our implementation is `0x80`
|
|
49
|
+
to mirror HWM.
|
|
50
|
+
|
|
51
|
+
## TCP handshake (go-live)
|
|
52
|
+
|
|
53
|
+
Sequence observed from HWM and replicated by `send_dmx.mjs`. Numbers in
|
|
54
|
+
parens are the opcode in hex.
|
|
55
|
+
|
|
56
|
+
1. **`LSAG_ALL` 0x47 — hello.** Client sends `magic ‖ opcode ‖ token(8)`.
|
|
57
|
+
Stick replies with a 54-byte message; bytes `[0x16:0x36]` are a 32-byte
|
|
58
|
+
per-session "Stick handshake key" used in step 2.
|
|
59
|
+
2. **`LSAG_ALL` 0x48 — authenticated handshake.** Layout:
|
|
60
|
+
```
|
|
61
|
+
magic(8) ‖ 0x48 ‖ token(8) ‖ softwareName(32) ‖ stickHandshakeKey(32) ‖ hmac(32)
|
|
62
|
+
```
|
|
63
|
+
The HMAC is `HMAC-SHA256(AUTH_KEY, first 82 bytes)`. `AUTH_KEY` is a
|
|
64
|
+
15-byte string baked into Hardware Manager (see [ENCRYPTION.md](ENCRYPTION.md)
|
|
65
|
+
for the recovered value). Reply contains a status uint32 at `[0x12]`:
|
|
66
|
+
`0` = ok, `100` = `PermissionDenied`. A rejected HMAC means the session
|
|
67
|
+
never gets promoted to live.
|
|
68
|
+
3. **Pre-DMX chatter** — six small messages, **each sent in its own TCP
|
|
69
|
+
segment** (HWM never coalesces these; coalescing them empirically prevents
|
|
70
|
+
the Stick from emitting its 0xc9 status):
|
|
71
|
+
```
|
|
72
|
+
Stick_3A 0x46 4 zero bytes
|
|
73
|
+
Stick_3A 0x09 "14000000"
|
|
74
|
+
Stick_3A 0x09 "14000000"
|
|
75
|
+
Stick_3A 0x00 "14000000"
|
|
76
|
+
Stick_3A 0x011c token(8) ‖ "01001600"
|
|
77
|
+
Stick_3A 0x05 "0200"
|
|
78
|
+
```
|
|
79
|
+
After this the Stick sends a `0x00c9` status indicating the session is
|
|
80
|
+
registered. Our implementation paces these with `CHATTER_MS` (default 10ms;
|
|
81
|
+
tested down to 5ms with no failures).
|
|
82
|
+
4. **`Stick_3A` 0x10 — crypto-state query.** Reply has a state uint32 at
|
|
83
|
+
`[0x12]`: state 3 on a fresh device, state 4 if a DMX key from a previous
|
|
84
|
+
session is still latched.
|
|
85
|
+
5. **`Stick_3A` 0x0F — DMX key exchange.** Client sends its 64-byte P-256
|
|
86
|
+
ephemeral public key (X ‖ Y, both little-endian limbs — see
|
|
87
|
+
[ENCRYPTION.md](ENCRYPTION.md)). Stick replies with its own 64-byte
|
|
88
|
+
P-256 public key at `[0x16:0x56]`. The Stick's public key has been
|
|
89
|
+
observed identical across every session captured to date; a static
|
|
90
|
+
fallback is hardcoded in `send_dmx.mjs` for resiliency.
|
|
91
|
+
6. **Device sync chatter** — observed from HWM, byte-for-byte:
|
|
92
|
+
```
|
|
93
|
+
Stick_3A 0x10 token
|
|
94
|
+
Stick_3A 0x75 token
|
|
95
|
+
Stick_3A 0x74 token
|
|
96
|
+
Stick_3A 0x71 token ‖ "0200000000"
|
|
97
|
+
Stick_3A 0x71 token ‖ "0100000000"
|
|
98
|
+
Stick_3A 0x71 token ‖ "0100000000"
|
|
99
|
+
Stick_3A 0x71 token ‖ "02b37f0000"
|
|
100
|
+
```
|
|
101
|
+
These appear to be informational reads. The 4th `0x71` was the one HWM
|
|
102
|
+
pattern previously missed by our impl.
|
|
103
|
+
7. **`Stick_3A` 0x70 — sector reads (skippable).** HWM reads sector 0
|
|
104
|
+
followed by sectors 63..185 (124 reads) to populate its commissioning UI.
|
|
105
|
+
Empirically the Stick will latch values even with `SECTORS=0`; this is
|
|
106
|
+
HWM UI chatter, not a Stick precondition. `send_dmx.mjs` defaults to
|
|
107
|
+
skipping it (saves ~1.5s/transaction).
|
|
108
|
+
8. **`Stick_3A` 0x2e — go-live primer.** 32 zero bytes payload. HWM then
|
|
109
|
+
waits ~3.7s (UI-paced); the Stick only needs a much shorter settle. We
|
|
110
|
+
use 50ms via `SETTLE_2E_MS`.
|
|
111
|
+
9. **`Stick_3A` 0x10, 0x11, 0x10** — enters live mode. The 0x11 reply
|
|
112
|
+
confirms live mode is active; a follow-up 0x10 should now report crypto
|
|
113
|
+
state 4.
|
|
114
|
+
|
|
115
|
+
## DMX UDP frames (576 bytes)
|
|
116
|
+
|
|
117
|
+
Once the session is live, DMX values are sent as encrypted 576-byte UDP
|
|
118
|
+
datagrams from src-port 2430 → dst-port 2431.
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
+----------------------------------+-----------------------------------------+
|
|
122
|
+
| 32-byte clear header | 544-byte AES-256-CBC ciphertext |
|
|
123
|
+
+----------------------------------+-----------------------------------------+
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Clear header (32 bytes)
|
|
127
|
+
|
|
128
|
+
| Offset | Size | Field | Value / meaning |
|
|
129
|
+
|--------|------|-------|-----------------|
|
|
130
|
+
| 0x00 | 8 | magic | `Stick_3A` |
|
|
131
|
+
| 0x08 | 2 | opcode | `0x0019` LE |
|
|
132
|
+
| 0x0a | 8 | `fieldA` | session token (same counter as TCP tokens — continues the sequence) |
|
|
133
|
+
| 0x12 | 2 | port / universe | DMX port selector, LE |
|
|
134
|
+
| 0x14 | 2 | channel count | `512` LE |
|
|
135
|
+
| 0x16 | 1 | constant | `100` (HWM's value) |
|
|
136
|
+
| 0x17 | 1 | sequence byte | per-frame counter mod 256 |
|
|
137
|
+
| 0x18 | 8 | nonce | random per frame |
|
|
138
|
+
|
|
139
|
+
### AES IV
|
|
140
|
+
|
|
141
|
+
The 16-byte AES-CBC IV is `fieldA (8) ‖ nonce (8)` — i.e. the two header
|
|
142
|
+
fields at offsets 0x0a and 0x18. The receiver pulls these straight out of
|
|
143
|
+
the clear header to decrypt.
|
|
144
|
+
|
|
145
|
+
### Plaintext layout (544 bytes)
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
+---------------------+---------------------+-------------------------+
|
|
149
|
+
| P0 (16B constant) | header2 (16B zeros) | 512 DMX channel bytes |
|
|
150
|
+
+---------------------+---------------------+-------------------------+
|
|
151
|
+
0 16 32 544
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`P0` is a fixed 16-byte constant baked into HWM (see
|
|
155
|
+
[ENCRYPTION.md](ENCRYPTION.md)). It serves as a known-plaintext anchor —
|
|
156
|
+
when the Stick decrypts a frame, the first 16 bytes of plaintext must
|
|
157
|
+
equal `P0` or the frame is dropped.
|
|
158
|
+
|
|
159
|
+
`header2` has been observed all-zero in every captured frame. It may be
|
|
160
|
+
meaningful in some other path; we send zeros and the Stick accepts it.
|
|
161
|
+
|
|
162
|
+
**Channel data starts at plaintext offset 32**, not 16. This was an
|
|
163
|
+
off-by-16 bug in an earlier impl (we lit HWM's channel 6 when sending to
|
|
164
|
+
ours-channel 22, because 22 - 16 = 6).
|
|
165
|
+
|
|
166
|
+
## Transactional streaming & the latch
|
|
167
|
+
|
|
168
|
+
The Stick holds the last received DMX values **on disconnect** (any kind that
|
|
169
|
+
doesn't send HWM's "polite goodbye" opcode — which we have never observed
|
|
170
|
+
identified). This lets a client be transactional rather than maintaining a
|
|
171
|
+
persistent stream:
|
|
172
|
+
|
|
173
|
+
1. Connect, run the handshake, derive the session key.
|
|
174
|
+
2. Stream encrypted DMX frames at 25Hz for ~750ms (~19 frames). The Stick
|
|
175
|
+
commits values after roughly 12 frames at 25Hz; below ~500ms wall-time
|
|
176
|
+
the commit doesn't happen.
|
|
177
|
+
3. `socket.destroy()` (RST). The Stick latches the last frame's values.
|
|
178
|
+
|
|
179
|
+
Trade-off: each transaction takes ~1.5–2s end-to-end (handshake + stream +
|
|
180
|
+
disconnect), and the wall controller blinks briefly on disconnect — HWM
|
|
181
|
+
exhibits the same blink, so it's at the protocol layer, not avoidable from
|
|
182
|
+
the client side.
|
|
183
|
+
|
|
184
|
+
## UDP/2430 broadcast — discovery
|
|
185
|
+
|
|
186
|
+
HWM emits a startup burst on UDP/2430 (255.255.255.255):
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
LSAG_ALL <8B zeros> 14 00 00 00
|
|
190
|
+
Stick_U1 <8B zeros> 14 00 00 00
|
|
191
|
+
Stick_3A <8B zeros> 14 00 00 00
|
|
192
|
+
Siudi_7B <8B zeros> 14 00 00 00
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
…repeated three times with a 25ms gap. `send_dmx.mjs` does the same before
|
|
196
|
+
connecting; it doesn't appear to be required (the Stick is reachable by IP
|
|
197
|
+
without it) but matches HWM exactly.
|
|
198
|
+
|
|
199
|
+
## UDP/2430 — "Quick Trigger" (documented but limited)
|
|
200
|
+
|
|
201
|
+
Nicolaudie's STICK Remote Protocol spec defines a 24-byte UDP packet on port
|
|
202
|
+
2430 with **no authentication** and **no encryption**. See
|
|
203
|
+
[`tools/quick-trigger.mjs`](tools/quick-trigger.mjs):
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
[0..7] "Stick_3A"
|
|
207
|
+
[8..9] opcode 109 (0x6D 0x00)
|
|
208
|
+
[10..11] Scene number (page*50 + sceneInPage), LE
|
|
209
|
+
[12] ZoneSyncId
|
|
210
|
+
[13] Command (0=scene-off, 1=scene-on, 5=dimmer, 7=color, 8/9=blackout, …)
|
|
211
|
+
[14..15] Dimmer, LE
|
|
212
|
+
[16..17] Speed, LE
|
|
213
|
+
[18..19] unused
|
|
214
|
+
[20..23] R, G, B, 0
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Useful for testing connectivity but **scene-level only**: it cannot set
|
|
218
|
+
individual DMX channels, and on fw3.08 it is gated by "Security for Cloud
|
|
219
|
+
Access" (must be disabled in Hardware Manager for the device to action it).
|
|
220
|
+
For per-fixture HomeKit control, the encrypted UDP/2431 path is the only
|
|
221
|
+
route.
|
|
222
|
+
|
|
223
|
+
## UDP/24299 — `LIGHTINGSOFT_XHL` presence broadcast
|
|
224
|
+
|
|
225
|
+
HWM broadcasts an "I am Hardware Manager" beacon on UDP/24299 (src+dst
|
|
226
|
+
24299) at startup: one 114-byte announce containing the literal string
|
|
227
|
+
`Hardware Manager`, followed by two 46-byte status packets. The 32-byte
|
|
228
|
+
header is:
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
"LIGHTINGSOFT_XHL" 16B
|
|
232
|
+
00 00 00 00 00 00 00 00 8B zeros
|
|
233
|
+
14 00 00 00 op/len = 20
|
|
234
|
+
01 00 00 00 version = 1
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
We have no Stick-side traffic acknowledging these but HWM emits them
|
|
238
|
+
consistently, and `send_dmx.mjs` replicates them. Mode of action unconfirmed.
|
|
239
|
+
|
|
240
|
+
## TCP/2431 — passive status
|
|
241
|
+
|
|
242
|
+
Per the STICK Remote Protocol spec, once a client connects to TCP/2431 the
|
|
243
|
+
Stick emits a status packet every 5s. [`tools/stick-status.mjs`](tools/stick-status.mjs)
|
|
244
|
+
reads it:
|
|
245
|
+
|
|
246
|
+
| Offset | Field |
|
|
247
|
+
|--------|-------|
|
|
248
|
+
| 0..7 | `Stick_3A` |
|
|
249
|
+
| 8 | opcode |
|
|
250
|
+
| 9 | version |
|
|
251
|
+
| 10..11 | scene number |
|
|
252
|
+
| 12..23 | scene name (NUL-terminated) |
|
|
253
|
+
| 24 | zone number |
|
|
254
|
+
| 25..36 | zone name |
|
|
255
|
+
| 37..38 | dimmer |
|
|
256
|
+
| 39..41 | R, G, B |
|
|
257
|
+
| 42..43 | speed |
|
|
258
|
+
| 47 | **RemoteClientsCount** |
|
|
259
|
+
| 48 | **LiveModeIsActivated** (1 = live/remote, 0 = standalone) |
|
|
260
|
+
|
|
261
|
+
On fw2.x/3.x the LSAG/auth challenge must complete before status is emitted;
|
|
262
|
+
without that the client sees only the auth-challenge reply.
|
|
263
|
+
|
|
264
|
+
## Open questions
|
|
265
|
+
|
|
266
|
+
- **The "polite goodbye" opcode** — HWM has a right-click menu option that
|
|
267
|
+
cleanly disconnects and *does not* latch. The opcode that triggers that
|
|
268
|
+
exit path has not been identified. Every other disconnect we've tried
|
|
269
|
+
(FIN, RST, process kill, cmd-Q) latches.
|
|
270
|
+
- **The 0x011c reply body** is AES-encrypted device-info; the full payload
|
|
271
|
+
schema is undocumented. `tools/decrypt-011c.mjs`, `extract-011c.mjs`,
|
|
272
|
+
`find-011c-key.mjs`, `verify-011c-key.mjs`, `diff-011c.mjs` were used to
|
|
273
|
+
recover its key; the contents are not needed for the DMX path.
|
|
274
|
+
- **The 0x10 crypto-state values** — 3 = pre-handshake, 4 = key installed.
|
|
275
|
+
Other values not observed.
|
|
276
|
+
- **`Siudi_7B`** discovery magic — the Stick-DE3 doesn't respond to this
|
|
277
|
+
one; presumably it targets a different product line.
|
package/README.md
CHANGED
|
@@ -8,6 +8,28 @@ stream, reverse-engineered from the ESA2 Hardware Manager).
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/@ecopoesis/homebridge-dmx)
|
|
10
10
|
|
|
11
|
+
## Nicolaudie Stick-DE3
|
|
12
|
+
|
|
13
|
+
Please, under no circumstances, should you buy a Nicolaudie Stick-DE3 because this plugin exists.
|
|
14
|
+
|
|
15
|
+
The Stick-DE3 hardware, and its accompanying software, are some of the worst I have had the misfortune to use.
|
|
16
|
+
|
|
17
|
+
Some of the litany of problems with the Stick-DE3 ecosystem:
|
|
18
|
+
* The Stick does not support power-over-ethernet. It takes power over an Ethernet (8P8C) shaped port, but it's non-standard.
|
|
19
|
+
* The Stick touchscreen is very touchy. And not in a good way. Phantom clicks, slow responsiveness: all the problems from pre-iPhone touchscreens are here. I believe I've clicked buttons by looking at it too hard.
|
|
20
|
+
* Every setting change also changes the MAC address. Hope you didn't want to give it a static address via DHCP.
|
|
21
|
+
* HSV-style color spaces are not supported at all. Except: look at the color-wheel. It's in HSV. So they do support converting from HSV to RGB or various RGB+W derivatives, but can't pass the native values their controller's UI generates straight through to DMX channels.
|
|
22
|
+
* The software suite is ancient and terrible. ESA Pro, ESA Pro 2, and Hardware Manager all only support x86 on MacOS. In 2026. Apple Silicon came out 6 years ago!
|
|
23
|
+
* The software has a UI that would have looked dated in Windows 95.
|
|
24
|
+
* Why is there so much encryption? This is my hardware. Nicolaudie sensibly ships a way to turn off IoT access, making this a pure NoT ("Network of Things") device. Let me talk to it.
|
|
25
|
+
* Why do you make a DMX interface without simple channel control? It'd be great if I could use this as intended, but since it doesn't support HSV-style color, give me an easy escape hatch. You have a (poorly) documented API. Just add the ability for me to send a DMX packet. I shouldn't need to break AES to do that.
|
|
26
|
+
|
|
27
|
+
That said, this plugin does just that. Reusing the encryption scheme used by Hardware Manager (with keys extracted from the binary) lets us send the same type of per-channel control used there. We then cleanly disconnect; the Stick latches the last frame, with a brief wall-controller blink as a side effect.
|
|
28
|
+
|
|
29
|
+
`send_dmx.mjs` is the pieces of the protocol needed to authenticate and start streaming, including the keys extracted from Hardware Manager. Other files in `tools` are the code developed to get to that point.
|
|
30
|
+
|
|
31
|
+
If you, like me, bought a Stick-DE3 based on the assumption that it was competently executed and are now stuck with it, hopefully this repo and the research it contains can help you actually make it useful.
|
|
32
|
+
|
|
11
33
|
## Status
|
|
12
34
|
|
|
13
35
|
| Piece | State |
|
|
@@ -15,8 +37,9 @@ stream, reverse-engineered from the ESA2 Hardware Manager).
|
|
|
15
37
|
| Stick-DE3 control (auth, ECDH, encrypted DMX stream) | ✅ working |
|
|
16
38
|
| Color models: dimmer, cct, rgb, rgbw, rgbww, rgbaw, hsvcct | ✅ |
|
|
17
39
|
| YAML and Homebridge-UI config | ✅ |
|
|
18
|
-
|
|
|
19
|
-
|
|
|
40
|
+
| sACN (E1.31) gateways, e.g. ENTTEC DIN Ethergate Mk2 | ✅ (`sACN` type; unicast, A/B → sACN universes 1/2, 1 Hz keepalive stream) |
|
|
41
|
+
| Multiple controllers in one patch | ✅ (`sACN` and `StickDE3` types) |
|
|
42
|
+
| Universe B on the Stick | 🚧 plumbed but the wire encoding is unknown (works on `sACN`) |
|
|
20
43
|
| Stick-DE3 password / "Cloud Access" auth | 🚧 not implemented |
|
|
21
44
|
| State read-back | 🚧 Homebridge holds the canonical state; local drift accepted |
|
|
22
45
|
|
package/config.schema.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"pluginAlias": "DMX",
|
|
3
3
|
"pluginType": "platform",
|
|
4
4
|
"singular": true,
|
|
5
|
-
"headerDisplay": "Homebridge platform for DMX controllers.
|
|
5
|
+
"headerDisplay": "Homebridge platform for DMX controllers. Supports sACN (E1.31) gateways such as the ENTTEC DIN Ethergate Mk2, and the Nicolaudie Stick-DE3.",
|
|
6
6
|
"footerDisplay": "For larger setups, point **yamlPath** at an external YAML file containing the controllers + profiles + patch. The YAML replaces the inline JSON below entirely.",
|
|
7
7
|
"schema": {
|
|
8
8
|
"type": "object",
|
|
@@ -37,10 +37,12 @@
|
|
|
37
37
|
"title": "Controller type",
|
|
38
38
|
"type": "string",
|
|
39
39
|
"required": true,
|
|
40
|
-
"default": "
|
|
40
|
+
"default": "sACN",
|
|
41
41
|
"oneOf": [
|
|
42
|
+
{ "title": "sACN / E1.31 gateway (e.g. ENTTEC DIN Ethergate Mk2)", "enum": ["sACN"] },
|
|
42
43
|
{ "title": "Nicolaudie Stick-DE3", "enum": ["StickDE3"] }
|
|
43
|
-
]
|
|
44
|
+
],
|
|
45
|
+
"description": "sACN packets are unicast to the gateway's IP; patch universes A/B map to sACN universes 1/2 (configure the gateway's ports to match)."
|
|
44
46
|
},
|
|
45
47
|
"ip": {
|
|
46
48
|
"title": "IP address",
|
|
@@ -48,6 +50,51 @@
|
|
|
48
50
|
"format": "ipv4",
|
|
49
51
|
"required": true,
|
|
50
52
|
"description": "Static IP of the controller on its DMX VLAN (e.g. 192.168.96.2). Discovery is not supported."
|
|
53
|
+
},
|
|
54
|
+
"refreshMinutes": {
|
|
55
|
+
"title": "Keepalive refresh (minutes, StickDE3 only)",
|
|
56
|
+
"type": "integer",
|
|
57
|
+
"required": false,
|
|
58
|
+
"default": 30,
|
|
59
|
+
"minimum": 0,
|
|
60
|
+
"description": "StickDE3: re-send the last-known DMX state after this many idle minutes. Keeps the Stick-DE3 from drifting into a wedged no-output state and re-asserts state after a power-cycle. 0 disables. The sACN controller streams a 1 Hz keepalive instead and ignores this."
|
|
61
|
+
},
|
|
62
|
+
"priority": {
|
|
63
|
+
"title": "sACN priority (sACN only)",
|
|
64
|
+
"type": "integer",
|
|
65
|
+
"required": false,
|
|
66
|
+
"default": 100,
|
|
67
|
+
"minimum": 0,
|
|
68
|
+
"maximum": 200,
|
|
69
|
+
"description": "E1.31 source priority. Only matters if a second sACN source targets the same universe."
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"zones": {
|
|
75
|
+
"title": "Zones",
|
|
76
|
+
"type": "array",
|
|
77
|
+
"required": false,
|
|
78
|
+
"description": "Virtual zones: group fixtures into one multi-member HomeKit accessory.",
|
|
79
|
+
"items": {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"properties": {
|
|
82
|
+
"id": {
|
|
83
|
+
"title": "Zone id",
|
|
84
|
+
"type": "string",
|
|
85
|
+
"required": true,
|
|
86
|
+
"description": "kebab-case identifier."
|
|
87
|
+
},
|
|
88
|
+
"name": {
|
|
89
|
+
"title": "HomeKit name",
|
|
90
|
+
"type": "string",
|
|
91
|
+
"required": true
|
|
92
|
+
},
|
|
93
|
+
"members": {
|
|
94
|
+
"title": "Member fixture ids",
|
|
95
|
+
"type": "array",
|
|
96
|
+
"required": true,
|
|
97
|
+
"items": { "type": "string" }
|
|
51
98
|
}
|
|
52
99
|
}
|
|
53
100
|
}
|
|
@@ -125,8 +172,9 @@
|
|
|
125
172
|
"default": "A",
|
|
126
173
|
"oneOf": [
|
|
127
174
|
{ "title": "A", "enum": ["A"] },
|
|
128
|
-
{ "title": "B
|
|
129
|
-
]
|
|
175
|
+
{ "title": "B", "enum": ["B"] }
|
|
176
|
+
],
|
|
177
|
+
"description": "On sACN controllers, A/B go out as sACN universes 1/2. On the Stick-DE3, B is untested."
|
|
130
178
|
},
|
|
131
179
|
"start": {
|
|
132
180
|
"title": "DMX start channel",
|
package/dist/config.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export interface ControllerSpec {
|
|
|
3
3
|
id: string;
|
|
4
4
|
type: string;
|
|
5
5
|
ip: string;
|
|
6
|
+
refreshMinutes?: number;
|
|
7
|
+
priority?: number;
|
|
6
8
|
}
|
|
7
9
|
export interface ProfileSpec {
|
|
8
10
|
name: string;
|
|
@@ -35,12 +37,14 @@ export interface Profile {
|
|
|
35
37
|
model: ColorModel;
|
|
36
38
|
channels: ChannelDef[];
|
|
37
39
|
}
|
|
38
|
-
export declare const CONTROLLER_TYPES: readonly ["StickDE3"];
|
|
40
|
+
export declare const CONTROLLER_TYPES: readonly ["StickDE3", "sACN"];
|
|
39
41
|
export type ControllerType = typeof CONTROLLER_TYPES[number];
|
|
40
42
|
export interface Controller {
|
|
41
43
|
id: string;
|
|
42
44
|
type: ControllerType;
|
|
43
45
|
ip: string;
|
|
46
|
+
refreshMinutes: number;
|
|
47
|
+
priority: number;
|
|
44
48
|
}
|
|
45
49
|
export interface Fixture {
|
|
46
50
|
id: string;
|
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAuCA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAuCA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAK5E,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,cAAc,EAAE,CAAC;IAC/B,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAC;IACzB,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,QAAQ,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,UAAU,CAAC;IAClB,QAAQ,EAAE,UAAU,EAAE,CAAC;CACxB;AAED,eAAO,MAAM,gBAAgB,+BAAgC,CAAC;AAC9D,MAAM,MAAM,cAAc,GAAG,OAAO,gBAAgB,CAAC,MAAM,CAAC,CAAC;AAE7D,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,cAAc,CAAC;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,UAAU,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;wEAEwE;AACxE,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,EAAE,CAAC;IACnB,eAAe,EAAE,gBAAgB,EAAE,CAAC;CACrC;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,KAAK,EAAE,IAAI,EAAE,CAAC;CACf;AAED,eAAO,MAAM,qBAAqB,QAAQ,CAAC;AAE3C;;;;oEAIoE;AACpE,wBAAgB,UAAU,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,YAAY,CA6LzE"}
|