@ecopoesis/homebridge-dmx 0.3.0 → 0.4.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 +22 -0
- package/config.schema.json +8 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/controller.d.ts +14 -3
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +41 -5
- package/dist/controller.js.map +1 -1
- package/dist/platform.js +2 -2
- package/dist/platform.js.map +1 -1
- package/dist/settings.d.ts +9 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +9 -0
- package/dist/settings.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +10 -1
- package/src/controller.ts +39 -5
- package/src/platform.ts +2 -2
- package/src/settings.ts +11 -0
- 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 |
|
package/config.schema.json
CHANGED
|
@@ -48,6 +48,14 @@
|
|
|
48
48
|
"format": "ipv4",
|
|
49
49
|
"required": true,
|
|
50
50
|
"description": "Static IP of the controller on its DMX VLAN (e.g. 192.168.96.2). Discovery is not supported."
|
|
51
|
+
},
|
|
52
|
+
"refreshMinutes": {
|
|
53
|
+
"title": "Keepalive refresh (minutes)",
|
|
54
|
+
"type": "integer",
|
|
55
|
+
"required": false,
|
|
56
|
+
"default": 30,
|
|
57
|
+
"minimum": 0,
|
|
58
|
+
"description": "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."
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
61
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export interface ControllerSpec {
|
|
|
3
3
|
id: string;
|
|
4
4
|
type: string;
|
|
5
5
|
ip: string;
|
|
6
|
+
refreshMinutes?: number;
|
|
6
7
|
}
|
|
7
8
|
export interface ProfileSpec {
|
|
8
9
|
name: string;
|
|
@@ -41,6 +42,7 @@ export interface Controller {
|
|
|
41
42
|
id: string;
|
|
42
43
|
type: ControllerType;
|
|
43
44
|
ip: string;
|
|
45
|
+
refreshMinutes: number;
|
|
44
46
|
}
|
|
45
47
|
export interface Fixture {
|
|
46
48
|
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;CACzB;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,uBAAwB,CAAC;AACtD,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;CACxB;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,CAyLzE"}
|
package/dist/config.js
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
import fs from 'node:fs';
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
import yaml from 'js-yaml';
|
|
38
|
+
import { REFRESH_MINUTES_DEFAULT } from './settings.js';
|
|
38
39
|
import { getColorModel } from './color/index.js';
|
|
39
40
|
import { parseChannelName } from './color/parsers.js';
|
|
40
41
|
export const CONTROLLER_TYPES = ['StickDE3'];
|
|
@@ -75,7 +76,13 @@ export function loadConfig(rawJson, cwd) {
|
|
|
75
76
|
if (!cs.ip || typeof cs.ip !== 'string') {
|
|
76
77
|
throw new Error(`controller "${cs.id}": required field "ip" missing`);
|
|
77
78
|
}
|
|
78
|
-
|
|
79
|
+
const refreshMinutes = cs.refreshMinutes ?? REFRESH_MINUTES_DEFAULT;
|
|
80
|
+
if (typeof refreshMinutes !== 'number' || refreshMinutes < 0) {
|
|
81
|
+
throw new Error(`controller "${cs.id}": refreshMinutes must be a number >= 0 (0 disables)`);
|
|
82
|
+
}
|
|
83
|
+
controllersById.set(cs.id, {
|
|
84
|
+
id: cs.id, type: cs.type, ip: cs.ip, refreshMinutes,
|
|
85
|
+
});
|
|
79
86
|
}
|
|
80
87
|
// Profiles
|
|
81
88
|
const profileSpecs = raw.profiles ?? [];
|
package/dist/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,6BAA6B;AAC7B,EAAE;AACF,UAAU;AACV,mEAAmE;AACnE,uEAAuE;AACvE,oDAAoD;AACpD,mDAAmD;AACnD,EAAE;AACF,SAAS;AACT,MAAM;AACN,yBAAyB;AACzB,qBAAqB;AACrB,gDAAgD;AAChD,uBAAuB;AACvB,mEAAmE;AACnE,SAAS;AACT,oBAAoB;AACpB,iDAAiD;AACjD,6DAA6D;AAC7D,0EAA0E;AAC1E,SAAS;AACT,iBAAiB;AACjB,2DAA2D;AAC3D,8DAA8D;AAC9D,QAAQ;AACR,MAAM;AACN,EAAE;AACF,wEAAwE;AACxE,0DAA0D;AAC1D,2EAA2E;AAC3E,6BAA6B;AAC7B,wEAAwE;AACxE,0EAA0E;AAC1E,qCAAqC;AAErC,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,IAAI,MAAM,SAAS,CAAC;AAG3B,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/controller.d.ts
CHANGED
|
@@ -5,28 +5,39 @@ export declare class StickController {
|
|
|
5
5
|
private readonly ip;
|
|
6
6
|
private readonly log;
|
|
7
7
|
private readonly debounceMs;
|
|
8
|
+
private readonly refreshMs;
|
|
8
9
|
private targets;
|
|
9
10
|
private lastChangeAt;
|
|
11
|
+
private lastTxEndAt;
|
|
10
12
|
private streaming;
|
|
13
|
+
private refreshTimer?;
|
|
11
14
|
/** Tracks whether the very first subprocess in this controller's
|
|
12
15
|
* lifetime should set RUN_PROBE=1. After a Stick power-cycle (which
|
|
13
16
|
* we can't detect in-band) the probe is required to (re-)register
|
|
14
17
|
* the client. Subsequent subprocesses can skip it. */
|
|
15
18
|
private needsProbe;
|
|
16
|
-
constructor(ip: string, log: Logger, debounceMs?: number);
|
|
19
|
+
constructor(ip: string, log: Logger, debounceMs?: number, refreshMs?: number);
|
|
20
|
+
/** Idle keepalive: the Stick wedges (sessions succeed, output dark) after
|
|
21
|
+
* hours without traffic. Re-send the current universe state whenever the
|
|
22
|
+
* controller has been idle for refreshMs. Also re-asserts state after an
|
|
23
|
+
* out-of-band power-cycle of the Stick. */
|
|
24
|
+
private maybeRefresh;
|
|
17
25
|
/** Stage a fixture's bytes into the universe target buffer and ensure
|
|
18
26
|
* the streaming loop is running. */
|
|
19
27
|
setFixture(fixture: Fixture, state: HomeKitLightState): void;
|
|
20
28
|
private universeBuf;
|
|
21
29
|
/** Build the CLI args from current targets. Only non-zero channels are
|
|
22
|
-
* passed — send_dmx initialises the universe to 0 so omission == 0.
|
|
30
|
+
* passed — send_dmx initialises the universe to 0 so omission == 0.
|
|
31
|
+
* When everything is 0 (all lights off), send an explicit zero so
|
|
32
|
+
* send_dmx still has at least one assignment to transact. */
|
|
23
33
|
private buildArgs;
|
|
24
34
|
/** Subprocess approach: spawn tools/send_dmx.mjs for each transaction.
|
|
25
35
|
* Fresh node process = no in-process state poisoning that breaks
|
|
26
36
|
* session 2+ in the long-lived plugin. Costs ~1.5-3s per spawn (process
|
|
27
37
|
* startup + handshake + 750ms stream + RST) but is empirically reliable. */
|
|
28
38
|
private run;
|
|
29
|
-
/** Shutdown hook.
|
|
39
|
+
/** Shutdown hook. Stop the keepalive timer; the subprocess approach has
|
|
40
|
+
* no other long-lived state. */
|
|
30
41
|
shutdown(): void;
|
|
31
42
|
}
|
|
32
43
|
//# sourceMappingURL=controller.d.ts.map
|
package/dist/controller.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAKzC,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AASrD,qBAAa,eAAe;
|
|
1
|
+
{"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAKzC,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AASrD,qBAAa,eAAe;IAaxB,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAf5B,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,YAAY,CAAC,CAAiB;IACtC;;;2DAGuD;IACvD,OAAO,CAAC,UAAU,CAAQ;gBAGP,EAAE,EAAE,MAAM,EACV,GAAG,EAAE,MAAM,EACX,UAAU,GAAE,MAAoB,EAChC,SAAS,GAAE,MAAU;IAQxC;;;gDAG4C;IAC5C,OAAO,CAAC,YAAY;IAcpB;yCACqC;IACrC,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,iBAAiB,GAAG,IAAI;IAmB5D,OAAO,CAAC,WAAW;IAMnB;;;kEAG8D;IAC9D,OAAO,CAAC,SAAS;IAcjB;;;iFAG6E;YAC/D,GAAG;IAgDjB;qCACiC;IACjC,QAAQ,IAAI,IAAI;CAGjB"}
|
package/dist/controller.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
-
import { DEBOUNCE_MS } from './settings.js';
|
|
13
|
+
import { DEBOUNCE_MS, REFRESH_CHECK_MS } from './settings.js';
|
|
14
14
|
import { sleep } from './stick/protocol.js';
|
|
15
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
16
|
const __dirname = path.dirname(__filename);
|
|
@@ -20,18 +20,45 @@ export class StickController {
|
|
|
20
20
|
ip;
|
|
21
21
|
log;
|
|
22
22
|
debounceMs;
|
|
23
|
+
refreshMs;
|
|
23
24
|
targets = new Map();
|
|
24
25
|
lastChangeAt = 0;
|
|
26
|
+
lastTxEndAt = 0;
|
|
25
27
|
streaming = false;
|
|
28
|
+
refreshTimer;
|
|
26
29
|
/** Tracks whether the very first subprocess in this controller's
|
|
27
30
|
* lifetime should set RUN_PROBE=1. After a Stick power-cycle (which
|
|
28
31
|
* we can't detect in-band) the probe is required to (re-)register
|
|
29
32
|
* the client. Subsequent subprocesses can skip it. */
|
|
30
33
|
needsProbe = true;
|
|
31
|
-
constructor(ip, log, debounceMs = DEBOUNCE_MS) {
|
|
34
|
+
constructor(ip, log, debounceMs = DEBOUNCE_MS, refreshMs = 0) {
|
|
32
35
|
this.ip = ip;
|
|
33
36
|
this.log = log;
|
|
34
37
|
this.debounceMs = debounceMs;
|
|
38
|
+
this.refreshMs = refreshMs;
|
|
39
|
+
if (refreshMs > 0) {
|
|
40
|
+
this.refreshTimer = setInterval(() => this.maybeRefresh(), REFRESH_CHECK_MS);
|
|
41
|
+
this.refreshTimer.unref?.();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Idle keepalive: the Stick wedges (sessions succeed, output dark) after
|
|
45
|
+
* hours without traffic. Re-send the current universe state whenever the
|
|
46
|
+
* controller has been idle for refreshMs. Also re-asserts state after an
|
|
47
|
+
* out-of-band power-cycle of the Stick. */
|
|
48
|
+
maybeRefresh() {
|
|
49
|
+
if (this.streaming || this.targets.size === 0)
|
|
50
|
+
return;
|
|
51
|
+
const idleMs = Date.now() - Math.max(this.lastTxEndAt, this.lastChangeAt);
|
|
52
|
+
if (idleMs < this.refreshMs)
|
|
53
|
+
return;
|
|
54
|
+
this.log.info(`keepalive refresh after ${Math.round(idleMs / 60_000)}m idle`);
|
|
55
|
+
this.needsProbe = true; // the Stick may have power-cycled while we were idle
|
|
56
|
+
this.streaming = true;
|
|
57
|
+
this.run().catch((e) => {
|
|
58
|
+
this.log.error('keepalive refresh crashed:', e.message);
|
|
59
|
+
}).finally(() => {
|
|
60
|
+
this.streaming = false;
|
|
61
|
+
});
|
|
35
62
|
}
|
|
36
63
|
/** Stage a fixture's bytes into the universe target buffer and ensure
|
|
37
64
|
* the streaming loop is running. */
|
|
@@ -62,7 +89,9 @@ export class StickController {
|
|
|
62
89
|
return arr;
|
|
63
90
|
}
|
|
64
91
|
/** Build the CLI args from current targets. Only non-zero channels are
|
|
65
|
-
* passed — send_dmx initialises the universe to 0 so omission == 0.
|
|
92
|
+
* passed — send_dmx initialises the universe to 0 so omission == 0.
|
|
93
|
+
* When everything is 0 (all lights off), send an explicit zero so
|
|
94
|
+
* send_dmx still has at least one assignment to transact. */
|
|
66
95
|
buildArgs() {
|
|
67
96
|
const args = [SEND_DMX_PATH, this.ip];
|
|
68
97
|
for (const [u, arr] of this.targets) {
|
|
@@ -71,6 +100,10 @@ export class StickController {
|
|
|
71
100
|
args.push(`${u},${i + 1}=${arr[i]}`);
|
|
72
101
|
}
|
|
73
102
|
}
|
|
103
|
+
if (args.length === 2 && this.targets.size > 0) {
|
|
104
|
+
const firstU = this.targets.keys().next().value;
|
|
105
|
+
args.push(`${firstU},1=0`);
|
|
106
|
+
}
|
|
74
107
|
return args;
|
|
75
108
|
}
|
|
76
109
|
/** Subprocess approach: spawn tools/send_dmx.mjs for each transaction.
|
|
@@ -107,6 +140,7 @@ export class StickController {
|
|
|
107
140
|
});
|
|
108
141
|
});
|
|
109
142
|
const elapsedMs = Date.now() - startedAt;
|
|
143
|
+
this.lastTxEndAt = Date.now();
|
|
110
144
|
if (exitCode === 0) {
|
|
111
145
|
this.needsProbe = false;
|
|
112
146
|
this.log.info(`send_dmx done in ${elapsedMs}ms`);
|
|
@@ -120,9 +154,11 @@ export class StickController {
|
|
|
120
154
|
return;
|
|
121
155
|
}
|
|
122
156
|
}
|
|
123
|
-
/** Shutdown hook.
|
|
157
|
+
/** Shutdown hook. Stop the keepalive timer; the subprocess approach has
|
|
158
|
+
* no other long-lived state. */
|
|
124
159
|
shutdown() {
|
|
125
|
-
|
|
160
|
+
if (this.refreshTimer)
|
|
161
|
+
clearInterval(this.refreshTimer);
|
|
126
162
|
}
|
|
127
163
|
}
|
|
128
164
|
//# sourceMappingURL=controller.js.map
|
package/dist/controller.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"controller.js","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"AAAA,oEAAoE;AACpE,EAAE;AACF,YAAY;AACZ,mEAAmE;AACnE,wEAAwE;AACxE,sEAAsE;AACtE,kCAAkC;AAClC,yEAAyE;AACzE,0EAA0E;AAG1E,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,IAAI,MAAM,WAAW,CAAC;AAI7B,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"controller.js","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"AAAA,oEAAoE;AACpE,EAAE;AACF,YAAY;AACZ,mEAAmE;AACnE,wEAAwE;AACxE,sEAAsE;AACtE,kCAAkC;AAClC,yEAAyE;AACzE,0EAA0E;AAG1E,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,IAAI,MAAM,WAAW,CAAC;AAI7B,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAE5C,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClD,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;AAC3C,6CAA6C;AAC7C,MAAM,aAAa,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC;AAE7E,MAAM,OAAO,eAAe;IAaP;IACA;IACA;IACA;IAfX,OAAO,GAAG,IAAI,GAAG,EAAsB,CAAC;IACxC,YAAY,GAAG,CAAC,CAAC;IACjB,WAAW,GAAG,CAAC,CAAC;IAChB,SAAS,GAAG,KAAK,CAAC;IAClB,YAAY,CAAkB;IACtC;;;2DAGuD;IAC/C,UAAU,GAAG,IAAI,CAAC;IAE1B,YACmB,EAAU,EACV,GAAW,EACX,aAAqB,WAAW,EAChC,YAAoB,CAAC;QAHrB,OAAE,GAAF,EAAE,CAAQ;QACV,QAAG,GAAH,GAAG,CAAQ;QACX,eAAU,GAAV,UAAU,CAAsB;QAChC,cAAS,GAAT,SAAS,CAAY;QAEtC,IAAI,SAAS,GAAG,CAAC,EAAE,CAAC;YAClB,IAAI,CAAC,YAAY,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,YAAY,EAAE,EAAE,gBAAgB,CAAC,CAAC;YAC7E,IAAI,CAAC,YAAY,CAAC,KAAK,EAAE,EAAE,CAAC;QAC9B,CAAC;IACH,CAAC;IAED;;;gDAG4C;IACpC,YAAY;QAClB,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC;YAAE,OAAO;QACtD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;QAC1E,IAAI,MAAM,GAAG,IAAI,CAAC,SAAS;YAAE,OAAO;QACpC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,2BAA2B,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC9E,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC,qDAAqD;QAC7E,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QACtB,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE;YACrB,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,4BAA4B,EAAG,CAAW,CAAC,OAAO,CAAC,CAAC;QACrE,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE;YACd,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;QACzB,CAAC,CAAC,CAAC;IACL,CAAC;IAED;yCACqC;IACrC,UAAU,CAAC,OAAgB,EAAE,KAAwB;QACnD,MAAM,GAAG,GAAG,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QAC/C,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QAC5E,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACtC,GAAG,CAAC,OAAO,CAAC,OAAO,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QAC1C,CAAC;QACD,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAC/B,MAAM,GAAG,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACnF,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,cAAc,OAAO,CAAC,EAAE,OAAO,OAAO,CAAC,OAAO,IAAI,GAAG,cAAc,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;QACnG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;YACpB,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;YACtB,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE;gBACrB,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,yBAAyB,EAAG,CAAW,CAAC,OAAO,CAAC,CAAC;YAClE,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE;gBACd,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;YACzB,CAAC,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAEO,WAAW,CAAC,CAAS;QAC3B,IAAI,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QAC9B,IAAI,CAAC,GAAG,EAAE,CAAC;YAAC,GAAG,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,CAAC;YAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;QAAC,CAAC;QAClE,OAAO,GAAG,CAAC;IACb,CAAC;IAED;;;kEAG8D;IACtD,SAAS;QACf,MAAM,IAAI,GAAa,CAAC,aAAa,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC;QAChD,KAAK,MAAM,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YACpC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;gBAC7B,IAAI,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC;oBAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YACzD,CAAC;QACH,CAAC;QACD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,GAAG,CAAC,EAAE,CAAC;YAC/C,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAe,CAAC;YAC1D,IAAI,CAAC,IAAI,CAAC,GAAG,MAAM,MAAM,CAAC,CAAC;QAC7B,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAED;;;iFAG6E;IACrE,KAAK,CAAC,GAAG;QACf,iDAAiD;QACjD,OAAO,IAAI,EAAE,CAAC;YACZ,qEAAqE;YACrE,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,UAAU,EAAE,CAAC;gBACxD,MAAM,KAAK,CAAC,EAAE,CAAC,CAAC;YAClB,CAAC;YAED,MAAM,QAAQ,GAAG,IAAI,CAAC,UAAU,CAAC;YACjC,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;YAC9B,MAAM,GAAG,GAAG;gBACV,GAAG,OAAO,CAAC,GAAG;gBACd,SAAS,EAAE,KAAK;gBAChB,OAAO,EAAE,GAAG;gBACZ,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aACxC,CAAC;YACF,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,wBAAwB,IAAI,CAAC,MAAM,GAAG,CAAC,WAAW;gBAClD,CAAC,QAAQ,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YAEjD,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YAC7B,IAAI,MAAM,GAAG,EAAE,CAAC;YAChB,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;YAC3C,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;YACvF,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE,GAAG,MAAM,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;YAE5D,MAAM,QAAQ,GAAG,MAAM,IAAI,OAAO,CAAS,CAAC,GAAG,EAAE,EAAE;gBACjD,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;gBAC5C,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE;oBACtB,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,uBAAuB,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;oBACnD,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;gBACV,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YACH,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC;YACzC,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YAE9B,IAAI,QAAQ,KAAK,CAAC,EAAE,CAAC;gBACnB,IAAI,CAAC,UAAU,GAAG,KAAK,CAAC;gBACxB,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,oBAAoB,SAAS,IAAI,CAAC,CAAC;YACnD,CAAC;iBAAM,CAAC;gBACN,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,mBAAmB,QAAQ,UAAU,SAAS,eAAe,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;YACtG,CAAC;YAED,sEAAsE;YACtE,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,UAAU;gBAAE,SAAS;YAC/D,OAAO;QACT,CAAC;IACH,CAAC;IAED;qCACiC;IACjC,QAAQ;QACN,IAAI,IAAI,CAAC,YAAY;YAAE,aAAa,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;IAC1D,CAAC;CACF"}
|
package/dist/platform.js
CHANGED
|
@@ -8,7 +8,7 @@ import { StickController } from './controller.js';
|
|
|
8
8
|
import { StickFixture } from './platformAccessory.js';
|
|
9
9
|
import { StateRegistry } from './stateRegistry.js';
|
|
10
10
|
import { ZoneFixture } from './zoneAccessory.js';
|
|
11
|
-
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
|
|
11
|
+
import { DEBOUNCE_MS, PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
|
|
12
12
|
export class DmxPlatform {
|
|
13
13
|
log;
|
|
14
14
|
config;
|
|
@@ -48,7 +48,7 @@ export class DmxPlatform {
|
|
|
48
48
|
makeController(c) {
|
|
49
49
|
switch (c.type) {
|
|
50
50
|
case 'StickDE3':
|
|
51
|
-
return new StickController(c.ip, this.log);
|
|
51
|
+
return new StickController(c.ip, this.log, DEBOUNCE_MS, c.refreshMinutes * 60_000);
|
|
52
52
|
default:
|
|
53
53
|
throw new Error(`unsupported controller type "${c.type}"`);
|
|
54
54
|
}
|
package/dist/platform.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platform.js","sourceRoot":"","sources":["../src/platform.ts"],"names":[],"mappings":"AAAA,wDAAwD;AACxD,EAAE;AACF,0EAA0E;AAC1E,2EAA2E;AAC3E,kBAAkB;AAYlB,OAAO,EAA4B,UAAU,EAAa,MAAM,aAAa,CAAC;AAC9E,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"platform.js","sourceRoot":"","sources":["../src/platform.ts"],"names":[],"mappings":"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"}
|
package/dist/settings.d.ts
CHANGED
|
@@ -7,6 +7,15 @@ export declare const CHARACTERISTIC_UPDATE_DELAY_MS = 50;
|
|
|
7
7
|
* this long before spawning the send_dmx subprocess. Coalesces a fast
|
|
8
8
|
* slider drag into one transaction. */
|
|
9
9
|
export declare const DEBOUNCE_MS = 750;
|
|
10
|
+
/** Idle keepalive. The Stick-DE3 drifts into a wedged state after hours
|
|
11
|
+
* without traffic: sessions and the crypto handshake still succeed but
|
|
12
|
+
* live DMX output goes dark (observed 2026-08-22..25; only a hard power
|
|
13
|
+
* cycle recovers it). Re-sending the last-known state whenever the
|
|
14
|
+
* controller has been idle this long keeps the device exercised and
|
|
15
|
+
* re-asserts state after an out-of-band power-cycle. 0 disables. */
|
|
16
|
+
export declare const REFRESH_MINUTES_DEFAULT = 30;
|
|
17
|
+
/** How often the controller checks whether a keepalive refresh is due. */
|
|
18
|
+
export declare const REFRESH_CHECK_MS = 60000;
|
|
10
19
|
/** Frame interval (40ms = 25Hz, matches HWM exactly). Unused by the
|
|
11
20
|
* subprocess controller — send_dmx.mjs's own FRAME_HZ env knob is what
|
|
12
21
|
* matters now. */
|
package/dist/settings.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../src/settings.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,aAAa,QAAQ,CAAC;AACnC,eAAO,MAAM,WAAW,8BAA8B,CAAC;AAEvD;6EAC6E;AAC7E,eAAO,MAAM,8BAA8B,KAAK,CAAC;AAEjD;;wCAEwC;AACxC,eAAO,MAAM,WAAW,MAAM,CAAC;AAE/B;;mBAEmB;AACnB,eAAO,MAAM,iBAAiB,KAAK,CAAC"}
|
|
1
|
+
{"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../src/settings.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,aAAa,QAAQ,CAAC;AACnC,eAAO,MAAM,WAAW,8BAA8B,CAAC;AAEvD;6EAC6E;AAC7E,eAAO,MAAM,8BAA8B,KAAK,CAAC;AAEjD;;wCAEwC;AACxC,eAAO,MAAM,WAAW,MAAM,CAAC;AAE/B;;;;;qEAKqE;AACrE,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAE1C,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,QAAS,CAAC;AAEvC;;mBAEmB;AACnB,eAAO,MAAM,iBAAiB,KAAK,CAAC"}
|
package/dist/settings.js
CHANGED
|
@@ -8,6 +8,15 @@ export const CHARACTERISTIC_UPDATE_DELAY_MS = 50;
|
|
|
8
8
|
* this long before spawning the send_dmx subprocess. Coalesces a fast
|
|
9
9
|
* slider drag into one transaction. */
|
|
10
10
|
export const DEBOUNCE_MS = 750;
|
|
11
|
+
/** Idle keepalive. The Stick-DE3 drifts into a wedged state after hours
|
|
12
|
+
* without traffic: sessions and the crypto handshake still succeed but
|
|
13
|
+
* live DMX output goes dark (observed 2026-08-22..25; only a hard power
|
|
14
|
+
* cycle recovers it). Re-sending the last-known state whenever the
|
|
15
|
+
* controller has been idle this long keeps the device exercised and
|
|
16
|
+
* re-asserts state after an out-of-band power-cycle. 0 disables. */
|
|
17
|
+
export const REFRESH_MINUTES_DEFAULT = 30;
|
|
18
|
+
/** How often the controller checks whether a keepalive refresh is due. */
|
|
19
|
+
export const REFRESH_CHECK_MS = 60_000;
|
|
11
20
|
/** Frame interval (40ms = 25Hz, matches HWM exactly). Unused by the
|
|
12
21
|
* subprocess controller — send_dmx.mjs's own FRAME_HZ env knob is what
|
|
13
22
|
* matters now. */
|
package/dist/settings.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settings.js","sourceRoot":"","sources":["../src/settings.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAE/B,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,CAAC;AACnC,MAAM,CAAC,MAAM,WAAW,GAAG,2BAA2B,CAAC;AAEvD;6EAC6E;AAC7E,MAAM,CAAC,MAAM,8BAA8B,GAAG,EAAE,CAAC;AAEjD;;wCAEwC;AACxC,MAAM,CAAC,MAAM,WAAW,GAAG,GAAG,CAAC;AAE/B;;mBAEmB;AACnB,MAAM,CAAC,MAAM,iBAAiB,GAAG,EAAE,CAAC"}
|
|
1
|
+
{"version":3,"file":"settings.js","sourceRoot":"","sources":["../src/settings.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAE/B,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,CAAC;AACnC,MAAM,CAAC,MAAM,WAAW,GAAG,2BAA2B,CAAC;AAEvD;6EAC6E;AAC7E,MAAM,CAAC,MAAM,8BAA8B,GAAG,EAAE,CAAC;AAEjD;;wCAEwC;AACxC,MAAM,CAAC,MAAM,WAAW,GAAG,GAAG,CAAC;AAE/B;;;;;qEAKqE;AACrE,MAAM,CAAC,MAAM,uBAAuB,GAAG,EAAE,CAAC;AAE1C,0EAA0E;AAC1E,MAAM,CAAC,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAEvC;;mBAEmB;AACnB,MAAM,CAAC,MAAM,iBAAiB,GAAG,EAAE,CAAC"}
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -38,6 +38,7 @@ import path from 'node:path';
|
|
|
38
38
|
import yaml from 'js-yaml';
|
|
39
39
|
|
|
40
40
|
import { ChannelDef, ColorModel, HKCharacteristic } from './color/types.js';
|
|
41
|
+
import { REFRESH_MINUTES_DEFAULT } from './settings.js';
|
|
41
42
|
import { getColorModel } from './color/index.js';
|
|
42
43
|
import { parseChannelName } from './color/parsers.js';
|
|
43
44
|
|
|
@@ -45,6 +46,7 @@ export interface ControllerSpec {
|
|
|
45
46
|
id: string; // unique handle, referenced from patch entries
|
|
46
47
|
type: string; // 'StickDE3' (only one supported today)
|
|
47
48
|
ip: string; // controller's static IP
|
|
49
|
+
refreshMinutes?: number; // idle keepalive: re-send state every N min (0 = off)
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export interface ProfileSpec {
|
|
@@ -90,6 +92,7 @@ export interface Controller {
|
|
|
90
92
|
id: string;
|
|
91
93
|
type: ControllerType;
|
|
92
94
|
ip: string;
|
|
95
|
+
refreshMinutes: number;
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
export interface Fixture {
|
|
@@ -159,7 +162,13 @@ export function loadConfig(rawJson: RawConfig, cwd?: string): LoadedConfig {
|
|
|
159
162
|
if (!cs.ip || typeof cs.ip !== 'string') {
|
|
160
163
|
throw new Error(`controller "${cs.id}": required field "ip" missing`);
|
|
161
164
|
}
|
|
162
|
-
|
|
165
|
+
const refreshMinutes = cs.refreshMinutes ?? REFRESH_MINUTES_DEFAULT;
|
|
166
|
+
if (typeof refreshMinutes !== 'number' || refreshMinutes < 0) {
|
|
167
|
+
throw new Error(`controller "${cs.id}": refreshMinutes must be a number >= 0 (0 disables)`);
|
|
168
|
+
}
|
|
169
|
+
controllersById.set(cs.id, {
|
|
170
|
+
id: cs.id, type: cs.type as ControllerType, ip: cs.ip, refreshMinutes,
|
|
171
|
+
});
|
|
163
172
|
}
|
|
164
173
|
|
|
165
174
|
// Profiles
|
package/src/controller.ts
CHANGED
|
@@ -15,7 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
|
|
16
16
|
import { Fixture } from './config.js';
|
|
17
17
|
import { HomeKitLightState } from './color/types.js';
|
|
18
|
-
import { DEBOUNCE_MS } from './settings.js';
|
|
18
|
+
import { DEBOUNCE_MS, REFRESH_CHECK_MS } from './settings.js';
|
|
19
19
|
import { sleep } from './stick/protocol.js';
|
|
20
20
|
|
|
21
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -26,7 +26,9 @@ const SEND_DMX_PATH = path.resolve(__dirname, '..', 'tools', 'send_dmx.mjs');
|
|
|
26
26
|
export class StickController {
|
|
27
27
|
private targets = new Map<number, Uint8Array>();
|
|
28
28
|
private lastChangeAt = 0;
|
|
29
|
+
private lastTxEndAt = 0;
|
|
29
30
|
private streaming = false;
|
|
31
|
+
private refreshTimer?: NodeJS.Timeout;
|
|
30
32
|
/** Tracks whether the very first subprocess in this controller's
|
|
31
33
|
* lifetime should set RUN_PROBE=1. After a Stick power-cycle (which
|
|
32
34
|
* we can't detect in-band) the probe is required to (re-)register
|
|
@@ -37,7 +39,31 @@ export class StickController {
|
|
|
37
39
|
private readonly ip: string,
|
|
38
40
|
private readonly log: Logger,
|
|
39
41
|
private readonly debounceMs: number = DEBOUNCE_MS,
|
|
40
|
-
|
|
42
|
+
private readonly refreshMs: number = 0,
|
|
43
|
+
) {
|
|
44
|
+
if (refreshMs > 0) {
|
|
45
|
+
this.refreshTimer = setInterval(() => this.maybeRefresh(), REFRESH_CHECK_MS);
|
|
46
|
+
this.refreshTimer.unref?.();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Idle keepalive: the Stick wedges (sessions succeed, output dark) after
|
|
51
|
+
* hours without traffic. Re-send the current universe state whenever the
|
|
52
|
+
* controller has been idle for refreshMs. Also re-asserts state after an
|
|
53
|
+
* out-of-band power-cycle of the Stick. */
|
|
54
|
+
private maybeRefresh(): void {
|
|
55
|
+
if (this.streaming || this.targets.size === 0) return;
|
|
56
|
+
const idleMs = Date.now() - Math.max(this.lastTxEndAt, this.lastChangeAt);
|
|
57
|
+
if (idleMs < this.refreshMs) return;
|
|
58
|
+
this.log.info(`keepalive refresh after ${Math.round(idleMs / 60_000)}m idle`);
|
|
59
|
+
this.needsProbe = true; // the Stick may have power-cycled while we were idle
|
|
60
|
+
this.streaming = true;
|
|
61
|
+
this.run().catch((e) => {
|
|
62
|
+
this.log.error('keepalive refresh crashed:', (e as Error).message);
|
|
63
|
+
}).finally(() => {
|
|
64
|
+
this.streaming = false;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
41
67
|
|
|
42
68
|
/** Stage a fixture's bytes into the universe target buffer and ensure
|
|
43
69
|
* the streaming loop is running. */
|
|
@@ -67,7 +93,9 @@ export class StickController {
|
|
|
67
93
|
}
|
|
68
94
|
|
|
69
95
|
/** Build the CLI args from current targets. Only non-zero channels are
|
|
70
|
-
* passed — send_dmx initialises the universe to 0 so omission == 0.
|
|
96
|
+
* passed — send_dmx initialises the universe to 0 so omission == 0.
|
|
97
|
+
* When everything is 0 (all lights off), send an explicit zero so
|
|
98
|
+
* send_dmx still has at least one assignment to transact. */
|
|
71
99
|
private buildArgs(): string[] {
|
|
72
100
|
const args: string[] = [SEND_DMX_PATH, this.ip];
|
|
73
101
|
for (const [u, arr] of this.targets) {
|
|
@@ -75,6 +103,10 @@ export class StickController {
|
|
|
75
103
|
if (arr[i] !== 0) args.push(`${u},${i + 1}=${arr[i]}`);
|
|
76
104
|
}
|
|
77
105
|
}
|
|
106
|
+
if (args.length === 2 && this.targets.size > 0) {
|
|
107
|
+
const firstU = this.targets.keys().next().value as number;
|
|
108
|
+
args.push(`${firstU},1=0`);
|
|
109
|
+
}
|
|
78
110
|
return args;
|
|
79
111
|
}
|
|
80
112
|
|
|
@@ -115,6 +147,7 @@ export class StickController {
|
|
|
115
147
|
});
|
|
116
148
|
});
|
|
117
149
|
const elapsedMs = Date.now() - startedAt;
|
|
150
|
+
this.lastTxEndAt = Date.now();
|
|
118
151
|
|
|
119
152
|
if (exitCode === 0) {
|
|
120
153
|
this.needsProbe = false;
|
|
@@ -129,8 +162,9 @@ export class StickController {
|
|
|
129
162
|
}
|
|
130
163
|
}
|
|
131
164
|
|
|
132
|
-
/** Shutdown hook.
|
|
165
|
+
/** Shutdown hook. Stop the keepalive timer; the subprocess approach has
|
|
166
|
+
* no other long-lived state. */
|
|
133
167
|
shutdown(): void {
|
|
134
|
-
|
|
168
|
+
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
135
169
|
}
|
|
136
170
|
}
|
package/src/platform.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { StickController } from './controller.js';
|
|
|
19
19
|
import { StickFixture } from './platformAccessory.js';
|
|
20
20
|
import { StateRegistry } from './stateRegistry.js';
|
|
21
21
|
import { ZoneFixture } from './zoneAccessory.js';
|
|
22
|
-
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
|
|
22
|
+
import { DEBOUNCE_MS, PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
|
|
23
23
|
|
|
24
24
|
export class DmxPlatform implements DynamicPlatformPlugin {
|
|
25
25
|
public readonly Service: typeof Service;
|
|
@@ -68,7 +68,7 @@ export class DmxPlatform implements DynamicPlatformPlugin {
|
|
|
68
68
|
private makeController(c: Controller): StickController {
|
|
69
69
|
switch (c.type) {
|
|
70
70
|
case 'StickDE3':
|
|
71
|
-
return new StickController(c.ip, this.log);
|
|
71
|
+
return new StickController(c.ip, this.log, DEBOUNCE_MS, c.refreshMinutes * 60_000);
|
|
72
72
|
default:
|
|
73
73
|
throw new Error(`unsupported controller type "${(c as Controller).type}"`);
|
|
74
74
|
}
|
package/src/settings.ts
CHANGED
|
@@ -12,6 +12,17 @@ export const CHARACTERISTIC_UPDATE_DELAY_MS = 50;
|
|
|
12
12
|
* slider drag into one transaction. */
|
|
13
13
|
export const DEBOUNCE_MS = 750;
|
|
14
14
|
|
|
15
|
+
/** Idle keepalive. The Stick-DE3 drifts into a wedged state after hours
|
|
16
|
+
* without traffic: sessions and the crypto handshake still succeed but
|
|
17
|
+
* live DMX output goes dark (observed 2026-08-22..25; only a hard power
|
|
18
|
+
* cycle recovers it). Re-sending the last-known state whenever the
|
|
19
|
+
* controller has been idle this long keeps the device exercised and
|
|
20
|
+
* re-asserts state after an out-of-band power-cycle. 0 disables. */
|
|
21
|
+
export const REFRESH_MINUTES_DEFAULT = 30;
|
|
22
|
+
|
|
23
|
+
/** How often the controller checks whether a keepalive refresh is due. */
|
|
24
|
+
export const REFRESH_CHECK_MS = 60_000;
|
|
25
|
+
|
|
15
26
|
/** Frame interval (40ms = 25Hz, matches HWM exactly). Unused by the
|
|
16
27
|
* subprocess controller — send_dmx.mjs's own FRAME_HZ env knob is what
|
|
17
28
|
* matters now. */
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# stick-power-cycle — hard power-cycle the Nicolaudie Stick-DE3 by turning
|
|
3
|
+
# PoE off/on on its switch port (sw01 port 15, via PoE splitter).
|
|
4
|
+
#
|
|
5
|
+
# Why: the Stick can wedge into a state where TCP sessions and the crypto
|
|
6
|
+
# handshake succeed but live DMX output is blackout (seen 2026-08-22..24);
|
|
7
|
+
# only a real power removal recovers it. The UniFi UI "Power Cycle" bounce
|
|
8
|
+
# is too short — the splitter rides through it. This holds power off for
|
|
9
|
+
# OFF_SECONDS, then restores, then waits for the Stick to answer ping.
|
|
10
|
+
#
|
|
11
|
+
# Credentials: read at runtime from the homebridge config.json (same local
|
|
12
|
+
# UniFi user the AP-RGB plugin uses). Nothing secret is stored here.
|
|
13
|
+
#
|
|
14
|
+
# Scheduled via systemd user timer (host has no cron):
|
|
15
|
+
# ~/.config/systemd/user/stick-power-cycle.{service,timer} — daily 08:30 UTC
|
|
16
|
+
# (= 4:30am EDT; lights are always off then). Lingering enabled for miker so
|
|
17
|
+
# the timer fires without a login session. Logs: ~/stick-power-cycle.log
|
|
18
|
+
# Manual run: systemctl --user start stick-power-cycle.service
|
|
19
|
+
|
|
20
|
+
import json, socket, ssl, sys, time, urllib.request, http.cookiejar
|
|
21
|
+
|
|
22
|
+
CONTROLLER = 'https://192.168.1.1'
|
|
23
|
+
CONFIG_JSON = '/opt/containers/homebridge/config.json'
|
|
24
|
+
SWITCH_MAC = '94:2a:6f:94:f1:da' # sw01
|
|
25
|
+
PORT_IDX = 15 # Stick-DE3 PoE splitter, "dmx" VLAN 96
|
|
26
|
+
STICK_IP = '192.168.96.2'
|
|
27
|
+
OFF_SECONDS = 30
|
|
28
|
+
BOOT_WAIT_S = 180
|
|
29
|
+
|
|
30
|
+
def log(*a):
|
|
31
|
+
print(time.strftime('%Y-%m-%d %H:%M:%S%z'), *a, flush=True)
|
|
32
|
+
|
|
33
|
+
def load_creds():
|
|
34
|
+
cfg = json.load(open(CONFIG_JSON))
|
|
35
|
+
p = next(x for x in cfg['platforms'] if x.get('platform') == 'UnifiAPLight')
|
|
36
|
+
return p['username'], p['password']
|
|
37
|
+
|
|
38
|
+
class Unifi:
|
|
39
|
+
def __init__(self):
|
|
40
|
+
ctx = ssl.create_default_context()
|
|
41
|
+
ctx.check_hostname = False
|
|
42
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
43
|
+
self.op = urllib.request.build_opener(
|
|
44
|
+
urllib.request.HTTPSHandler(context=ctx),
|
|
45
|
+
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
|
46
|
+
self.csrf = ''
|
|
47
|
+
|
|
48
|
+
def req(self, method, path, body=None):
|
|
49
|
+
rq = urllib.request.Request(CONTROLLER + path, method=method,
|
|
50
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
51
|
+
headers={'Content-Type': 'application/json'})
|
|
52
|
+
if self.csrf:
|
|
53
|
+
rq.add_header('x-csrf-token', self.csrf)
|
|
54
|
+
r = self.op.open(rq, timeout=15)
|
|
55
|
+
self.csrf = r.headers.get('x-csrf-token', self.csrf)
|
|
56
|
+
return json.load(r)
|
|
57
|
+
|
|
58
|
+
def login(self, user, pw):
|
|
59
|
+
self.req('POST', '/api/auth/login', {'username': user, 'password': pw})
|
|
60
|
+
|
|
61
|
+
def get_switch(self):
|
|
62
|
+
data = self.req('GET', '/proxy/network/api/s/default/stat/device')['data']
|
|
63
|
+
return next(d for d in data if d.get('mac') == SWITCH_MAC)
|
|
64
|
+
|
|
65
|
+
def set_poe(self, poe_mode):
|
|
66
|
+
# port_overrides is replaced wholesale on PUT — merge, never clobber.
|
|
67
|
+
sw = self.get_switch()
|
|
68
|
+
overrides = sw.get('port_overrides', [])
|
|
69
|
+
entry = next((o for o in overrides if o.get('port_idx') == PORT_IDX), None)
|
|
70
|
+
if entry is None:
|
|
71
|
+
entry = {'port_idx': PORT_IDX}
|
|
72
|
+
overrides.append(entry)
|
|
73
|
+
entry['poe_mode'] = poe_mode
|
|
74
|
+
self.req('PUT', f"/proxy/network/api/s/default/rest/device/{sw['_id']}",
|
|
75
|
+
{'port_overrides': overrides})
|
|
76
|
+
log(f'port {PORT_IDX} poe_mode -> {poe_mode}')
|
|
77
|
+
|
|
78
|
+
def stick_up():
|
|
79
|
+
# TCP probe of the Stick's own control port — proves the device booted,
|
|
80
|
+
# not just that the link is up. (The host has no ping binary anyway.)
|
|
81
|
+
try:
|
|
82
|
+
socket.create_connection((STICK_IP, 2431), timeout=2).close()
|
|
83
|
+
return True
|
|
84
|
+
except OSError:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def main():
|
|
88
|
+
log(f'power-cycling Stick-DE3 (sw {SWITCH_MAC} port {PORT_IDX}, {OFF_SECONDS}s off)')
|
|
89
|
+
api = Unifi()
|
|
90
|
+
api.login(*load_creds())
|
|
91
|
+
api.set_poe('off')
|
|
92
|
+
try:
|
|
93
|
+
time.sleep(OFF_SECONDS)
|
|
94
|
+
finally:
|
|
95
|
+
# restore is sacred: retry hard so the port can never stay dark
|
|
96
|
+
for attempt in range(5):
|
|
97
|
+
try:
|
|
98
|
+
api.set_poe('auto')
|
|
99
|
+
break
|
|
100
|
+
except Exception as e:
|
|
101
|
+
log(f'restore attempt {attempt + 1} failed: {e}')
|
|
102
|
+
time.sleep(5)
|
|
103
|
+
try:
|
|
104
|
+
api.login(*load_creds())
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
else:
|
|
108
|
+
log('FATAL: could not restore PoE — port may be off!')
|
|
109
|
+
sys.exit(2)
|
|
110
|
+
|
|
111
|
+
deadline = time.time() + BOOT_WAIT_S
|
|
112
|
+
while time.time() < deadline:
|
|
113
|
+
if stick_up():
|
|
114
|
+
log(f'Stick back up ({STICK_IP}:2431 accepting connections)')
|
|
115
|
+
return
|
|
116
|
+
time.sleep(5)
|
|
117
|
+
log(f'WARNING: Stick not reachable within {BOOT_WAIT_S}s of power restore')
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
if __name__ == '__main__':
|
|
121
|
+
main()
|