code wiki / (root) / nx_iot_local_magic_home.nx

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}