code wiki / (root) / nx_iot_provision_softap.nx

nx_iot_provision_softap.nx source

↩ module page · 490 lines · 20900 B

1// nx_iot_provision_softap.nx -- the auto-pair STATE MACHINE. 2// 3// Roadmap: NISHI_IOT_HUB_ROADMAP.md Epoch 3 (PAIR), the rung that turns 4// the anchor (WHO) + the vendor drivers (HOW) + the classifier (PROBE) 5// into the operator's actual outcome: lights that re-join the house on 6// their own, with NO manual re-pair step. 7// 8// THE PROBLEM THIS CLOSES (operator-reported, 2026-05-16 + 2026-06-18): 9// "they disconnect all the time and blink and then I MANUALLY have to 10// re-pair and then they duplicate in the app." 11// The anchor already killed the duplicate (re-adopt -> same logical id). 12// This kills the MANUAL step: when a fixture drops to its open pairing 13// SoftAP (e.g. TP-LINK_HS210_C209), the hub scans for it, joins it on 14// the free Wi-Fi 6E radio, pushes the house creds over the vendor's own 15// pairing path, waits for it to re-join the LAN, verifies it, and 16// anchors it -- no human in the loop, no new app row. 17// 18// WHAT THIS ORGAN IS (and is NOT): 19// This is the pure, deterministic LOGIC -- a sealed-enum state machine. 20// It takes the RESULT of each radio/driver step as an event and returns 21// the NEXT action the runner must perform. The radio calls themselves 22// (scan / join SoftAP / socket-send creds / re-probe LAN) are the 23// Windows-native live step, injected by the caller. Keeping the logic 24// pure + buffer-only makes the whole auto-pair flow gate-provable today, 25// off the LAN, on every nxc2 backend -- and lets the live runner be a 26// thin shim that just performs actions and feeds back events. 27// 28// NEVER-BRICK BY CONSTRUCTION (CLAUDE.md #26 -- ABSOLUTE, brand-critical): 29// Provisioning is the one IoT flow that writes persistent device state 30// (the Wi-Fi credential). The guarantee here is MECHANICAL, not an 31// asserted promise: 32// 1. The action alphabet (PROV_ACT_*) contains NO firmware/OTA/flash 33// action. The machine is structurally incapable of emitting one. 34// 2. The most device-touching action it CAN emit, PUSH_CREDS, carries 35// only a bounded Wi-Fi credential over the vendor's documented, 36// reversible pairing API; a rejected push leaves the device safe in 37// pairing mode (PROV_FAIL_PUSH, recoverable) -- never a half-write. 38// 3. nx_iot_prov_action_writes_firmware is a FAIL-SAFE classifier: 39// it returns 0 only for the explicit allow-list of safe actions and 40// 1 (= writes firmware = brick risk) for anything else, so a future 41// action added without review defaults to RED at the never-brick 42// gate rather than silently shipping. 43// The gate asserts (2)+(3) across the whole alphabet -- proven, not promised. 44// 45// VENDOR-AGNOSTIC: the machine reasons about a generic pairing lifecycle. 46// It calls the shared classifier (nx_iot_classify) once, to decide 47// whether a discovered SoftAP belongs to a vendor we can drive; the 48// per-vendor creds-push payload is the driver's job (HOW), kept thin 49// per feedback-hub-primitive-thin-per-site-wiring. UNKNOWN pairing APs 50// are an honest dead-end here (PROV_FAIL_UNKNOWN_VENDOR) -- they are the 51// signal for autonomous-driver work, not a thing to fake-provision. 52// 53// genealogy_id: NISHI_IOT_HUB_ROADMAP.md Epoch 3 + project-iot-hub-multi-vendor-kickoff-2026-05-16 54// license_tier: ORIGINAL 55// 56// nx_capability_claims: (per docs/NISHI_INTELLIGENT_CAPABILITY_LAYER.md) 57// needs: [pointer_arithmetic] 58// provides: [softap_pairing_state_machine, bounded_retry, 59// terminal_idempotent, never_brick_action_alphabet, 60// failsafe_firmware_write_classifier, pairing_ssid_filter] 61// safety: [no_unchecked_deref, no_floating_point, no_syscall, 62// bounded_iteration, no_firmware_write_by_construction, 63// failsafe_default_red, kind_isolated] 64// verdict: [sealed_enum_state, sealed_enum_action, sealed_enum_event, 65// sealed_enum_fail, no_silent_failure] 66// license: ORIGINAL 67// kind: iot_runtime_primitive 68// sss: [S6 (no cloud -- pairing is LAN/radio only), 69// S7 (sealed-enum verdict on every transition)] 70// 71// nx_safety_envelope: (schema: nishi-library/seeds/safety-critical-standards.toml) 72// intended_use: "Drive a discovered fixture from open-SoftAP 73// pairing mode to anchored-on-LAN, with no manual 74// step; emit only brick-safe actions." 75// sil_target: SIL2 (a mis-provision mis-routes/strands a 76// fixture; bounded blast radius -- lighting) 77// asil_target: QM 78// dal_target: NONE 79// iec_62304_class: NONE 80// evidence: [no_syscall, no_floating_point, bounded_iteration, 81// sealed_enum_complete, no_firmware_action_in_alphabet, 82// failsafe_firmware_classifier_gate, 83// full_path_and_retry_gate, terminal_idempotency_gate] 84// hazard_register: [bug-tape-brick-on-provision (FIRMWARE write) -- CLOSED 85// by construction: no firmware action exists, 86// classifier is fail-safe-RED for unknowns; 87// bug-tape-strand-on-bad-creds -- bounded retry then 88// PROV_FAIL_PUSH leaves device recoverable; 89// bug-tape-runaway-retry -- max_attempts cap per step] 90// residual_risk: "The live runner MUST actually perform only the 91// action returned and feed back a truthful event; 92// this organ cannot police a runner that ignores it. 93// The creds payload bounds + the vendor's reversible 94// pairing path are what make PUSH_CREDS safe -- those 95// live in the driver + the runner, gated separately." 96// verdict: NOT_YET_EVALUATED (awaits nx_safety_critical_grade; 97// no-firmware-action + bounded_iteration pre-proven here) 98 99import "nx_iot_classify.nx" // nx_iot_classify_by_ssid + NX_IOT_VENDOR_* (via nx_iot_anchor) 100 101// ---- Sealed enum: provisioning state ------------------------------ 102// 103// What the machine is currently doing / waiting on. DONE + FAILED are 104// terminal and sticky (idempotent under any further event, CLAUDE.md #10). 105 106const PROV_ST_IDLE: i64 = 0 107const PROV_ST_SCANNING: i64 = 1 // waiting for a scan result 108const PROV_ST_JOINING: i64 = 2 // joining the device SoftAP 109const PROV_ST_PUSHING: i64 = 3 // pushing house creds to the device 110const PROV_ST_AWAIT_REJOIN: i64 = 4 // device rebooting onto the home LAN 111const PROV_ST_VERIFYING: i64 = 5 // confirming reachability on the LAN 112const PROV_ST_ANCHORING: i64 = 6 // anchoring the stable identity 113const PROV_ST_DONE: i64 = 7 // terminal: provisioned + anchored 114const PROV_ST_FAILED: i64 = 8 // terminal: gave up (see fail_reason) 115const PROV_ST_N: i64 = 9 116 117func nx_iot_prov_state_is_valid(v: i64) -> i64 { 118 if v < 0 { return 0 } 119 if v >= PROV_ST_N { return 0 } 120 return 1 121} 122 123// ---- Sealed enum: next action (THE NEVER-BRICK ALPHABET) ---------- 124// 125// What the live runner must do next. CRITICAL INVARIANT (CLAUDE.md #26): 126// there is NO firmware/OTA/flash action in this set. PUSH_CREDS is the 127// most device-touching action and writes only a reversible Wi-Fi 128// credential. Adding any firmware-writing action here without a 129// never-brick guarantee + classifier update must make the gate go RED. 130 131const PROV_ACT_NONE: i64 = 0 132const PROV_ACT_SCAN: i64 = 1 // scan for open pairing SoftAPs 133const PROV_ACT_JOIN_AP: i64 = 2 // join the selected device SoftAP 134const PROV_ACT_PUSH_CREDS: i64 = 3 // send house Wi-Fi creds (reversible) 135const PROV_ACT_REJOIN_HOME: i64 = 4 // rejoin home LAN + probe for device 136const PROV_ACT_VERIFY: i64 = 5 // confirm device reachable on LAN 137const PROV_ACT_ANCHOR: i64 = 6 // anchor stable identity (no dup) 138const PROV_ACT_REPORT_DONE: i64 = 7 // terminal success 139const PROV_ACT_REPORT_FAIL: i64 = 8 // terminal failure 140const PROV_ACT_N: i64 = 9 141 142func nx_iot_prov_action_is_valid(v: i64) -> i64 { 143 if v < 0 { return 0 } 144 if v >= PROV_ACT_N { return 0 } 145 return 1 146} 147 148// ---- Sealed enum: event (the result the runner feeds back) --------- 149 150const PROV_EV_NONE: i64 = 0 151const PROV_EV_START: i64 = 1 // begin provisioning a device 152const PROV_EV_AP_FOUND: i64 = 2 // scan found a pairing AP (ssid given) 153const PROV_EV_AP_NONE: i64 = 3 // scan found no pairing AP 154const PROV_EV_JOIN_OK: i64 = 4 155const PROV_EV_JOIN_FAIL: i64 = 5 156const PROV_EV_PUSH_OK: i64 = 6 157const PROV_EV_PUSH_FAIL: i64 = 7 158const PROV_EV_REJOIN_OK: i64 = 8 // device seen on the home LAN 159const PROV_EV_REJOIN_FAIL: i64 = 9 // device never came back (timeout) 160const PROV_EV_VERIFY_OK: i64 = 10 161const PROV_EV_VERIFY_FAIL: i64 = 11 162const PROV_EV_ANCHOR_OK: i64 = 12 // anchor adopt succeeded (logical_id in arg) 163const PROV_EV_ANCHOR_FAIL: i64 = 13 // anchor rejected (e.g. registry full) 164const PROV_EV_N: i64 = 14 165 166func nx_iot_prov_event_is_valid(v: i64) -> i64 { 167 if v < 0 { return 0 } 168 if v >= PROV_EV_N { return 0 } 169 return 1 170} 171 172// ---- Sealed enum: failure reason ---------------------------------- 173 174const PROV_FAIL_NONE: i64 = 0 175const PROV_FAIL_NO_AP: i64 = 1 // scan found nothing to pair 176const PROV_FAIL_UNKNOWN_VENDOR: i64 = 2 // pairing AP, but no driver for it 177const PROV_FAIL_JOIN: i64 = 3 // could not join the SoftAP (retries gone) 178const PROV_FAIL_PUSH: i64 = 4 // creds push rejected (device left safe) 179const PROV_FAIL_REJOIN_TIMEOUT: i64 = 5 // device never re-joined the LAN 180const PROV_FAIL_VERIFY: i64 = 6 // on LAN but not reachable 181const PROV_FAIL_ANCHOR: i64 = 7 // anchor adopt failed 182const PROV_FAIL_BAD_ARG: i64 = 8 // null session at init 183const PROV_FAIL_N: i64 = 9 184 185func nx_iot_prov_fail_is_valid(v: i64) -> i64 { 186 if v < 0 { return 0 } 187 if v >= PROV_FAIL_N { return 0 } 188 return 1 189} 190 191// ---- Default retry cap (data, not a magic number -- CLAUDE.md #11) - 192// 193// Max transient failures tolerated PER STEP (join / push / rejoin / 194// verify) before the step gives up. Overridable at init. 195 196const NX_IOT_PROV_MAX_ATTEMPTS: i64 = 3 197 198// ---- Never-brick classifiers (CLAUDE.md #26) ---------------------- 199// 200// FAIL-SAFE firmware-write classifier. Returns 0 ONLY for the explicit 201// allow-list of brick-safe actions; returns 1 (= writes firmware = brick 202// risk) for everything else. Therefore a NEW action added to the enum 203// without being classified here defaults to "writes firmware", which the 204// never-brick gate refuses (RED). Safety is the default, not the promise. 205 206func nx_iot_prov_action_writes_firmware(act: i64) -> i64 { 207 if act == PROV_ACT_NONE { return 0 } 208 if act == PROV_ACT_SCAN { return 0 } 209 if act == PROV_ACT_JOIN_AP { return 0 } 210 if act == PROV_ACT_PUSH_CREDS { return 0 } // Wi-Fi credential, reversible -- NOT firmware 211 if act == PROV_ACT_REJOIN_HOME { return 0 } 212 if act == PROV_ACT_VERIFY { return 0 } 213 if act == PROV_ACT_ANCHOR { return 0 } 214 if act == PROV_ACT_REPORT_DONE { return 0 } 215 if act == PROV_ACT_REPORT_FAIL { return 0 } 216 return 1 // unknown / unreviewed action -> treat as a brick risk 217} 218 219// An action is brick-safe iff it is a valid member of the alphabet AND 220// does not write firmware. The gate asserts this holds for every action 221// the machine can ever emit. 222func nx_iot_prov_action_is_brick_safe(act: i64) -> i64 { 223 if nx_iot_prov_action_is_valid(act) != 1 { return 0 } 224 if nx_iot_prov_action_writes_firmware(act) != 0 { return 0 } 225 return 1 226} 227 228// ---- Session (one device being provisioned) ----------------------- 229// 230// 8 i64 = 64 bytes. The caller pre-allocates one per concurrent 231// provisioning flow. 232 233struct IotProvSession { 234 state: i64, // current sealed state 235 vendor: i64, // classified vendor (NX_IOT_VENDOR_*) once AP found 236 fail_reason: i64, // sealed fail reason (PROV_FAIL_NONE until failed) 237 attempts: i64, // transient-failure counter for the current step 238 max_attempts: i64, // cap per step (data-driven) 239 logical_id: i64, // anchor's stable id, filled on ANCHOR_OK (0 before) 240 last_action: i64, // action emitted by the most recent step 241 ev_count: i64, // total VALID events processed (observability) 242} 243 244// ---- Init --------------------------------------------------------- 245// 246// Returns 0 on success, PROV_FAIL_BAD_ARG on a null session. Safe to 247// call twice (resets the flow). max_attempts <= 0 takes the default. 248 249func nx_iot_prov_init(s: *IotProvSession, max_attempts: i64) -> i64 { 250 if s == (0 as *IotProvSession) { return PROV_FAIL_BAD_ARG } 251 var ma: i64 = max_attempts 252 if ma <= 0 { ma = NX_IOT_PROV_MAX_ATTEMPTS } 253 s.state = PROV_ST_IDLE 254 s.vendor = NX_IOT_VENDOR_UNKNOWN 255 s.fail_reason = PROV_FAIL_NONE 256 s.attempts = 0 257 s.max_attempts = ma 258 s.logical_id = 0 259 s.last_action = PROV_ACT_NONE 260 s.ev_count = 0 261 return 0 262} 263 264// ---- Pairing-AP filter -------------------------------------------- 265// 266// 1 if the SoftAP SSID belongs to a vendor we can drive (reuses the 267// shared classifier -- DRY, CLAUDE.md #15), 0 if UNKNOWN. The live 268// scanner uses this to ignore non-pairing / unsupported APs. 269 270func nx_iot_prov_is_pairing_ssid(ssid: *u8, n: i64) -> i64 { 271 if nx_iot_classify_by_ssid(ssid, n) == NX_IOT_VENDOR_UNKNOWN { return 0 } 272 return 1 273} 274 275// ---- Step: the transition function -------------------------------- 276// 277// Feed the event that just happened; get back the action to perform next. 278// ssid / ssid_n : only read on PROV_EV_AP_FOUND (classify the AP). 279// arg : the logical_id on PROV_EV_ANCHOR_OK; ignored otherwise. 280// 281// Dispatch is on a SNAPSHOT of the state (st), so a block that advances 282// s.state cannot also fall through into a later block -- deterministic 283// even without an else-if chain. Unhandled (state,event) pairs are a 284// defensive no-op (action NONE, no state change). Terminal states are 285// sticky. Returns the emitted action (also stored in s.last_action). 286 287func nx_iot_prov_step(s: *IotProvSession, ev: i64, 288 ssid: *u8, ssid_n: i64, arg: i64) -> i64 { 289 if s == (0 as *IotProvSession) { return PROV_ACT_NONE } 290 291 // Reject malformed events without disturbing state (CLAUDE.md #12). 292 if nx_iot_prov_event_is_valid(ev) != 1 { 293 s.last_action = PROV_ACT_NONE 294 return PROV_ACT_NONE 295 } 296 s.ev_count = s.ev_count + 1 297 298 let st: i64 = s.state 299 300 // Terminal states are sticky + idempotent. 301 if st == PROV_ST_DONE { 302 s.last_action = PROV_ACT_NONE 303 return PROV_ACT_NONE 304 } 305 if st == PROV_ST_FAILED { 306 s.last_action = PROV_ACT_NONE 307 return PROV_ACT_NONE 308 } 309 310 var act: i64 = PROV_ACT_NONE 311 312 // ---- IDLE: kick off the scan ----------------------------------- 313 if st == PROV_ST_IDLE { 314 if ev == PROV_EV_START { 315 s.state = PROV_ST_SCANNING 316 s.attempts = 0 317 act = PROV_ACT_SCAN 318 } 319 } 320 321 // ---- SCANNING: classify the discovered AP ---------------------- 322 if st == PROV_ST_SCANNING { 323 if ev == PROV_EV_AP_FOUND { 324 let v: i64 = nx_iot_classify_by_ssid(ssid, ssid_n) 325 if v == NX_IOT_VENDOR_UNKNOWN { 326 // Honest dead-end: a pairing AP we have no driver for. 327 s.state = PROV_ST_FAILED 328 s.fail_reason = PROV_FAIL_UNKNOWN_VENDOR 329 act = PROV_ACT_REPORT_FAIL 330 } else { 331 s.vendor = v 332 s.state = PROV_ST_JOINING 333 s.attempts = 0 334 act = PROV_ACT_JOIN_AP 335 } 336 } 337 if ev == PROV_EV_AP_NONE { 338 s.state = PROV_ST_FAILED 339 s.fail_reason = PROV_FAIL_NO_AP 340 act = PROV_ACT_REPORT_FAIL 341 } 342 } 343 344 // ---- JOINING: joined the SoftAP? ------------------------------- 345 if st == PROV_ST_JOINING { 346 if ev == PROV_EV_JOIN_OK { 347 s.state = PROV_ST_PUSHING 348 s.attempts = 0 349 act = PROV_ACT_PUSH_CREDS 350 } 351 if ev == PROV_EV_JOIN_FAIL { 352 s.attempts = s.attempts + 1 353 if s.attempts < s.max_attempts { 354 act = PROV_ACT_JOIN_AP // bounded retry 355 } else { 356 s.state = PROV_ST_FAILED 357 s.fail_reason = PROV_FAIL_JOIN 358 act = PROV_ACT_REPORT_FAIL 359 } 360 } 361 } 362 363 // ---- PUSHING: creds accepted? ---------------------------------- 364 if st == PROV_ST_PUSHING { 365 if ev == PROV_EV_PUSH_OK { 366 s.state = PROV_ST_AWAIT_REJOIN 367 s.attempts = 0 368 act = PROV_ACT_REJOIN_HOME 369 } 370 if ev == PROV_EV_PUSH_FAIL { 371 // A rejected push leaves the device safe in pairing mode 372 // (never a half-write -- never-brick). Retry the SAME 373 // bounded creds payload, then give up safely. 374 s.attempts = s.attempts + 1 375 if s.attempts < s.max_attempts { 376 act = PROV_ACT_PUSH_CREDS 377 } else { 378 s.state = PROV_ST_FAILED 379 s.fail_reason = PROV_FAIL_PUSH 380 act = PROV_ACT_REPORT_FAIL 381 } 382 } 383 } 384 385 // ---- AWAIT_REJOIN: device back on the LAN? --------------------- 386 if st == PROV_ST_AWAIT_REJOIN { 387 if ev == PROV_EV_REJOIN_OK { 388 s.state = PROV_ST_VERIFYING 389 s.attempts = 0 390 act = PROV_ACT_VERIFY 391 } 392 if ev == PROV_EV_REJOIN_FAIL { 393 s.attempts = s.attempts + 1 394 if s.attempts < s.max_attempts { 395 act = PROV_ACT_REJOIN_HOME // keep waiting / re-probe 396 } else { 397 s.state = PROV_ST_FAILED 398 s.fail_reason = PROV_FAIL_REJOIN_TIMEOUT 399 act = PROV_ACT_REPORT_FAIL 400 } 401 } 402 } 403 404 // ---- VERIFYING: reachable? ------------------------------------- 405 if st == PROV_ST_VERIFYING { 406 if ev == PROV_EV_VERIFY_OK { 407 s.state = PROV_ST_ANCHORING 408 s.attempts = 0 409 act = PROV_ACT_ANCHOR 410 } 411 if ev == PROV_EV_VERIFY_FAIL { 412 s.attempts = s.attempts + 1 413 if s.attempts < s.max_attempts { 414 act = PROV_ACT_VERIFY 415 } else { 416 s.state = PROV_ST_FAILED 417 s.fail_reason = PROV_FAIL_VERIFY 418 act = PROV_ACT_REPORT_FAIL 419 } 420 } 421 } 422 423 // ---- ANCHORING: identity anchored? ----------------------------- 424 if st == PROV_ST_ANCHORING { 425 if ev == PROV_EV_ANCHOR_OK { 426 s.state = PROV_ST_DONE 427 s.logical_id = arg 428 s.fail_reason = PROV_FAIL_NONE 429 act = PROV_ACT_REPORT_DONE 430 } 431 if ev == PROV_EV_ANCHOR_FAIL { 432 s.state = PROV_ST_FAILED 433 s.fail_reason = PROV_FAIL_ANCHOR 434 act = PROV_ACT_REPORT_FAIL 435 } 436 } 437 438 s.last_action = act 439 return act 440} 441 442// ---- Observers (for the daemon / UI / gate) ----------------------- 443 444func nx_iot_prov_state(s: *IotProvSession) -> i64 { 445 if s == (0 as *IotProvSession) { return -1 } 446 return s.state 447} 448 449func nx_iot_prov_action(s: *IotProvSession) -> i64 { 450 if s == (0 as *IotProvSession) { return -1 } 451 return s.last_action 452} 453 454func nx_iot_prov_vendor(s: *IotProvSession) -> i64 { 455 if s == (0 as *IotProvSession) { return -1 } 456 return s.vendor 457} 458 459func nx_iot_prov_fail_reason(s: *IotProvSession) -> i64 { 460 if s == (0 as *IotProvSession) { return -1 } 461 return s.fail_reason 462} 463 464func nx_iot_prov_logical_id(s: *IotProvSession) -> i64 { 465 if s == (0 as *IotProvSession) { return -1 } 466 return s.logical_id 467} 468 469func nx_iot_prov_attempts(s: *IotProvSession) -> i64 { 470 if s == (0 as *IotProvSession) { return -1 } 471 return s.attempts 472} 473 474func nx_iot_prov_ev_count(s: *IotProvSession) -> i64 { 475 if s == (0 as *IotProvSession) { return -1 } 476 return s.ev_count 477} 478 479func nx_iot_prov_is_terminal(s: *IotProvSession) -> i64 { 480 if s == (0 as *IotProvSession) { return -1 } 481 if s.state == PROV_ST_DONE { return 1 } 482 if s.state == PROV_ST_FAILED { return 1 } 483 return 0 484} 485 486func nx_iot_prov_is_success(s: *IotProvSession) -> i64 { 487 if s == (0 as *IotProvSession) { return -1 } 488 if s.state == PROV_ST_DONE { return 1 } 489 return 0 490}