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}