nx_iot_local_magic_home.nx source
↩ module page · 378 lines · 13568 B
1// nx_iot_local_magic_home.nx -- Magic Home / Flux LED LAN protocol.
2//
3// Roadmap: NISHI_IOT_HUB_ROADMAP.md Epoch 2 (SPEAK).
4// S-class invariants: S0 (bit-equal frame encode), S6 (no cloud), S7
5// (sealed-enum verdicts), S8 (kind-isolated — does not import any
6// other nx_iot_local_* primitive).
7//
8// Kind generator (one of N per cardinal
9// feedback-kind-specific-generators-not-broad-noise). Sibling
10// kind generators: nx_iot_local_tuya_v33, nx_iot_local_wiz (next),
11// nx_iot_local_yeelight (queued).
12//
13// Wire format (observed via flux_led / Saberlight RE notes,
14// INDEPENDENT_REDERIVE per iot-hub-research.toml):
15//
16// Transport: TCP/5577 (control), UDP/48899 (discovery)
17// Crypto: none
18// Frame: <cmd_byte> <args...> [<persist=0x0F or no-persist=0x00>] <checksum>
19// checksum = sum of all preceding bytes mod 256
20//
21// Discovery (UDP/48899 broadcast): ASCII "HF-A11ASSISTHREAD"
22// Discovery reply: ASCII "<ip>,<mac>,<model>"
23//
24// Common commands (controller variants v8/v9/v10+):
25// 0x71 0x23 0x0F <chk> Power ON
26// 0x71 0x24 0x0F <chk> Power OFF
27// 0x31 R G B W 0xF0 0x0F <chk> Set RGBW (persist)
28// 0x31 R G B W 0xF0 0x00 <chk> Set RGBW (no persist)
29// 0x31 R G B 0x00 0xF0 0x0F <chk> Set RGB only
30// 0x31 0x00 0x00 0x00 W 0x0F 0x0F <chk> Set warm-white only
31// 0x81 0x8A 0x8B <chk> Query status
32// 0x61 <preset> <speed> 0x0F <chk> Set preset mode
33//
34// Status reply (14 bytes for v8/v9, 16 bytes for v10+):
35// [0] 0x81 (response sentinel)
36// [1] model byte
37// [2] power: 0x23=ON, 0x24=OFF
38// [3] mode (0x61=custom RGB, 0x62=preset, ...)
39// [4] speed (preset only)
40// [5] R
41// [6] G
42// [7] B
43// [8] W (warm-white)
44// [9] version / mode-2
45// [10] CW (cool-white, v10+)
46// [11] 0x0F or 0x04
47// [12] checksum (v8/v9) -- last byte
48// [13] (v10+ reserved)
49//
50// What this primitive does today:
51// - sum-mod-256 checksum helper
52// - frame builders: power, RGBW (persist + transient), RGB-only,
53// warm-white-only, query, preset
54// - status reply parser into a flat IotMhStatus record
55// - discovery payload builders (probe + reply parser)
56// - sealed verdicts for every operation
57//
58// What it doesn't do yet (queued):
59// - timer / schedule commands (Magic Home has on-device cron) --
60// out of scope, we own the clock master
61// - older v7 controllers with different frame layouts -- v2
62// - bulb-specific extensions (RGB-LED-strip vs ceiling fixture
63// vs RGB-controller) -- diff by model byte; v2
64//
65// genealogy_id: iot_hub_research/magic_home_protocol +
66// flux_led_reference (READ ONLY) +
67// saberlight_reference (READ ONLY)
68// license_tier: ORIGINAL (substrate route INDEPENDENT_REDERIVE)
69//
70// nx_capability_claims: (per docs/NISHI_INTELLIGENT_CAPABILITY_LAYER.md)
71// needs: [byte_arithmetic, pointer_arithmetic]
72// provides: [magic_home_power_frame, magic_home_rgbw_frame,
73// magic_home_discovery, magic_home_status_parse]
74// safety: [no_unchecked_deref, no_floating_point, no_syscall,
75// bit_equal_reproducible, kind_isolated]
76// verdict: [sealed_enum_7_state, no_silent_failure]
77// license: ORIGINAL
78// kind: iot_vendor_protocol
79// sss: [S0, S6, S7, S8]
80//
81// nx_safety_envelope: (schema: nishi-library/seeds/safety-critical-standards.toml)
82// intended_use: "Magic Home LAN protocol -- power + RGBW frame
83// encode/decode + status parse"
84// sil_target: SIL2 (lighting control; malfunction can
85// leave devices in unintended bright
86// state, fire risk through prolonged
87// white output -- but no direct
88// life-threatening dependency)
89// asil_target: QM
90// dal_target: NONE
91// iec_62304_class: NONE
92// evidence: [no_syscall, no_floating_point, no_unchecked_deref,
93// bit_equal_reproducible, sealed_enum_complete,
94// kind_isolated_audit, all_12_backends_compile_clean]
95// hazard_register: [bug-tape-spoofed-discovery-response,
96// bug-tape-frame-injection-no-auth,
97// bug-tape-cleartext-LAN-control]
98// residual_risk: "Magic Home protocol has NO authentication.
99// Any LAN attacker can frame-inject. Caller
100// MUST treat LAN as semi-trusted; defense-in-
101// depth (network segmentation) is upstream
102// operator responsibility."
103// verdict: NOT_YET_EVALUATED (awaits nx_safety_critical_grade)
104
105// No syscall imports needed: every function operates on caller-
106// provided buffers. Pure i64 arithmetic + byte ops. Compile-clean
107// across all 12 nxc2 backends (frame-primitive pattern proven).
108
109// ---- Sealed enum: verdict ------------------------------------------
110
111const NX_IOT_MH_VERDICT_UNKNOWN: i64 = 0
112const NX_IOT_MH_VERDICT_OK: i64 = 1
113const NX_IOT_MH_VERDICT_BAD_LEN: i64 = 2
114const NX_IOT_MH_VERDICT_BAD_SENTINEL: i64 = 3
115const NX_IOT_MH_VERDICT_BAD_CHECKSUM: i64 = 4
116const NX_IOT_MH_VERDICT_BUF_TOO_SMALL: i64 = 5
117const NX_IOT_MH_VERDICT_BAD_ARG: i64 = 6
118const NX_IOT_MH_VERDICT_N: i64 = 7
119
120func nx_iot_mh_verdict_is_valid(v: i64) -> i64 {
121 if v < 0 { return 0 }
122 if v >= NX_IOT_MH_VERDICT_N { return 0 }
123 return 1
124}
125
126// ---- Sealed enum: power state --------------------------------------
127
128const NX_IOT_MH_POWER_UNKNOWN: i64 = 0
129const NX_IOT_MH_POWER_ON: i64 = 1
130const NX_IOT_MH_POWER_OFF: i64 = 2
131const NX_IOT_MH_POWER_N: i64 = 3
132
133const NX_IOT_MH_POWER_BYTE_ON: i64 = 0x23
134const NX_IOT_MH_POWER_BYTE_OFF: i64 = 0x24
135
136// ---- Wire constants ------------------------------------------------
137
138const NX_IOT_MH_PORT_CONTROL: i64 = 5577
139const NX_IOT_MH_PORT_DISCOVERY: i64 = 48899
140
141const NX_IOT_MH_RESPONSE_SENTINEL: i64 = 0x81
142const NX_IOT_MH_PERSIST: i64 = 0x0F
143const NX_IOT_MH_NO_PERSIST: i64 = 0x00
144
145const NX_IOT_MH_STATUS_LEN_V9: i64 = 14
146const NX_IOT_MH_STATUS_LEN_V10: i64 = 16
147
148// ---- Status reply record -------------------------------------------
149
150struct IotMhStatus {
151 power: i64, // sealed enum NX_IOT_MH_POWER_*
152 mode: i64, // raw mode byte
153 speed: i64, // preset speed (0..100 typically)
154 r: i64,
155 g: i64,
156 b: i64,
157 ww: i64, // warm-white
158 cw: i64, // cool-white (v10+; 0 for v9)
159 is_v10: i64, // 1 if 16-byte status, 0 if 14-byte
160}
161
162// ---- Checksum ------------------------------------------------------
163
164func nx_iot_mh_checksum(buf: *u8, len: i64) -> i64 {
165 var s: i64 = 0
166 var i: i64 = 0
167 while i < len {
168 s = s + ((buf[i] as i64) & 0xff)
169 i = i + 1
170 }
171 return s & 0xff
172}
173
174// ---- Frame builders ------------------------------------------------
175//
176// Every builder writes the frame at `out` and returns the byte count.
177// out_cap is checked; on overflow returns -1.
178
179func nx_iot_mh_build_power(out: *u8, out_cap: i64, on: i64) -> i64 {
180 if out_cap < 4 { return -1 }
181 out[0] = 0x71
182 if on != 0 { out[1] = NX_IOT_MH_POWER_BYTE_ON }
183 else { out[1] = NX_IOT_MH_POWER_BYTE_OFF }
184 out[2] = 0x0F
185 out[3] = nx_iot_mh_checksum(out, 3)
186 return 4
187}
188
189// Set full RGBW. persist=1 retains across power-cycle, persist=0 is
190// transient.
191func nx_iot_mh_build_rgbw(out: *u8, out_cap: i64,
192 r: i64, g: i64, b: i64, w: i64,
193 persist: i64) -> i64 {
194 if out_cap < 8 { return -1 }
195 out[0] = 0x31
196 out[1] = r & 0xff
197 out[2] = g & 0xff
198 out[3] = b & 0xff
199 out[4] = w & 0xff
200 out[5] = 0xF0
201 if persist != 0 { out[6] = NX_IOT_MH_PERSIST }
202 else { out[6] = NX_IOT_MH_NO_PERSIST }
203 out[7] = nx_iot_mh_checksum(out, 7)
204 return 8
205}
206
207// Set RGB only (no white channel touch). Persist always.
208func nx_iot_mh_build_rgb(out: *u8, out_cap: i64,
209 r: i64, g: i64, b: i64) -> i64 {
210 if out_cap < 8 { return -1 }
211 out[0] = 0x31
212 out[1] = r & 0xff
213 out[2] = g & 0xff
214 out[3] = b & 0xff
215 out[4] = 0x00
216 out[5] = 0xF0
217 out[6] = NX_IOT_MH_PERSIST
218 out[7] = nx_iot_mh_checksum(out, 7)
219 return 8
220}
221
222// Set warm-white only.
223func nx_iot_mh_build_warm_white(out: *u8, out_cap: i64, w: i64) -> i64 {
224 if out_cap < 8 { return -1 }
225 out[0] = 0x31
226 out[1] = 0x00
227 out[2] = 0x00
228 out[3] = 0x00
229 out[4] = w & 0xff
230 out[5] = 0x0F // W-only marker
231 out[6] = NX_IOT_MH_PERSIST
232 out[7] = nx_iot_mh_checksum(out, 7)
233 return 8
234}
235
236// Query status -- 4 bytes.
237func nx_iot_mh_build_query(out: *u8, out_cap: i64) -> i64 {
238 if out_cap < 4 { return -1 }
239 out[0] = 0x81
240 out[1] = 0x8A
241 out[2] = 0x8B
242 out[3] = nx_iot_mh_checksum(out, 3)
243 return 4
244}
245
246// Set preset mode (0x25-0x38 typical; speed 0x01-0x1F).
247func nx_iot_mh_build_preset(out: *u8, out_cap: i64,
248 preset: i64, speed: i64) -> i64 {
249 if out_cap < 5 { return -1 }
250 out[0] = 0x61
251 out[1] = preset & 0xff
252 out[2] = speed & 0xff
253 out[3] = 0x0F
254 out[4] = nx_iot_mh_checksum(out, 4)
255 return 5
256}
257
258// ---- Discovery payload + reply parse -------------------------------
259//
260// Discovery probe is the 17-byte ASCII string "HF-A11ASSISTHREAD".
261// Reply is "<ip>,<mac>,<model>" terminated by either NUL or end-of-
262// datagram. Reply parser returns the offset of the first comma + the
263// offset of the second comma + the model start (or -1s if malformed).
264
265func nx_iot_mh_build_discovery(out: *u8, out_cap: i64) -> i64 {
266 if out_cap < 17 { return -1 }
267 out[0] = 0x48 // H
268 out[1] = 0x46 // F
269 out[2] = 0x2D // -
270 out[3] = 0x41 // A
271 out[4] = 0x31 // 1
272 out[5] = 0x31 // 1
273 out[6] = 0x41 // A
274 out[7] = 0x53 // S
275 out[8] = 0x53 // S
276 out[9] = 0x49 // I
277 out[10] = 0x53 // S
278 out[11] = 0x54 // T
279 out[12] = 0x48 // H
280 out[13] = 0x52 // R
281 out[14] = 0x45 // E
282 out[15] = 0x41 // A
283 out[16] = 0x44 // D
284 return 17
285}
286
287// Find the first occurrence of byte `c` in buf[0..len), or -1.
288func nx_iot_mh_find_byte(buf: *u8, len: i64, c: i64) -> i64 {
289 var i: i64 = 0
290 while i < len {
291 if (buf[i] as i64) == c { return i }
292 i = i + 1
293 }
294 return -1
295}
296
297// Parse discovery reply. Returns 0 on success + writes the two
298// comma offsets via out_comma1 / out_comma2. -1 + verdict on
299// malformed.
300func nx_iot_mh_parse_discovery_reply(buf: *u8, len: i64,
301 out_comma1: *i64,
302 out_comma2: *i64,
303 out_verdict: *i64) -> i64 {
304 if len < 5 {
305 *out_verdict = NX_IOT_MH_VERDICT_BAD_LEN
306 return -1
307 }
308 let c1: i64 = nx_iot_mh_find_byte(buf, len, 0x2C) // ','
309 if c1 < 0 {
310 *out_verdict = NX_IOT_MH_VERDICT_BAD_SENTINEL
311 return -1
312 }
313 // Search for the second comma starting AFTER the first.
314 let rest_addr: i64 = (buf as i64) + c1 + 1
315 let rest: *u8 = rest_addr as *u8
316 let c2_rel: i64 = nx_iot_mh_find_byte(rest, len - c1 - 1, 0x2C)
317 if c2_rel < 0 {
318 *out_verdict = NX_IOT_MH_VERDICT_BAD_SENTINEL
319 return -1
320 }
321 *out_comma1 = c1
322 *out_comma2 = c1 + 1 + c2_rel
323 *out_verdict = NX_IOT_MH_VERDICT_OK
324 return 0
325}
326
327// ---- Status parser -------------------------------------------------
328//
329// Validates the response sentinel + checksum + length. Writes
330// extracted fields into the caller-provided IotMhStatus record.
331
332func nx_iot_mh_parse_status(buf: *u8, len: i64,
333 out: *IotMhStatus,
334 out_verdict: *i64) -> i64 {
335 *out_verdict = NX_IOT_MH_VERDICT_UNKNOWN
336
337 if len != NX_IOT_MH_STATUS_LEN_V9 {
338 if len != NX_IOT_MH_STATUS_LEN_V10 {
339 *out_verdict = NX_IOT_MH_VERDICT_BAD_LEN
340 return -1
341 }
342 }
343
344 if (buf[0] as i64) != NX_IOT_MH_RESPONSE_SENTINEL {
345 *out_verdict = NX_IOT_MH_VERDICT_BAD_SENTINEL
346 return -1
347 }
348
349 // Checksum: last byte == sum-mod-256 of all preceding bytes.
350 let expected_cs: i64 = nx_iot_mh_checksum(buf, len - 1)
351 let actual_cs: i64 = (buf[len - 1] as i64) & 0xff
352 if expected_cs != actual_cs {
353 *out_verdict = NX_IOT_MH_VERDICT_BAD_CHECKSUM
354 return -1
355 }
356
357 // Extract fields. Power byte at offset 2.
358 let pb: i64 = (buf[2] as i64) & 0xff
359 if pb == NX_IOT_MH_POWER_BYTE_ON { out.power = NX_IOT_MH_POWER_ON }
360 else { if pb == NX_IOT_MH_POWER_BYTE_OFF { out.power = NX_IOT_MH_POWER_OFF }
361 else { out.power = NX_IOT_MH_POWER_UNKNOWN } }
362 out.mode = (buf[3] as i64) & 0xff
363 out.speed = (buf[4] as i64) & 0xff
364 out.r = (buf[5] as i64) & 0xff
365 out.g = (buf[6] as i64) & 0xff
366 out.b = (buf[7] as i64) & 0xff
367 out.ww = (buf[8] as i64) & 0xff
368 if len == NX_IOT_MH_STATUS_LEN_V10 {
369 out.cw = (buf[10] as i64) & 0xff
370 out.is_v10 = 1
371 } else {
372 out.cw = 0
373 out.is_v10 = 0
374 }
375
376 *out_verdict = NX_IOT_MH_VERDICT_OK
377 return 0
378}