code wiki / (root) / nx_iot_watchdog.nx

nx_iot_watchdog.nx source

↩ module page · 316 lines · 12214 B

1// nx_iot_watchdog.nx -- per-device link healer (closes the blink loop). 2// 3// Roadmap: NISHI_IOT_HUB_ROADMAP.md Epoch 5 (HEAL). The rung that turns 4// "the light dropped + blinks + I re-pair it" into a fully automatic 5// detect -> re-provision -> re-adopt cycle, with a MEASURED count of the 6// manual re-pairs it spared the operator. 7// 8// THE PROBLEM THIS CLOSES (operator-reported): 9// The anchor (WHO) gives a fixture one stable identity for life. 10// The provision state machine (nx_iot_provision_softap) auto-pairs a 11// fixture found in SoftAP mode with no manual step. This organ is the 12// LOOP that ties them together over TIME: it watches each device's 13// reachability, declares it down after a bounded run of missed 14// heartbeats, recognises when it has reverted to pairing mode (the 15// blink), kicks off re-provisioning, and marks it healthy again when 16// the anchor re-adopts it. The blink loop is closed without a human. 17// 18// WHAT THIS ORGAN IS (and is NOT): 19// A pure, deterministic per-device HEALTH state machine. It takes a 20// reachability OBSERVATION each tick (seen / missed / found-in-pairing / 21// re-adopted) and returns the ACTION the daemon should take (probe / 22// mark-unreachable / re-provision / mark-current). It does NOT itself 23// touch the network or the devices -- the daemon wires its actions to 24// nx_iot_provision_softap (re-provision) and nx_iot_anchor (soft-delete 25// / revive), keeping each primitive thin (feedback-hub-primitive-thin- 26// per-site-wiring) + single-responsibility (CLAUDE.md #9). 27// 28// MEASURED-EXCEED HOOK (feedback-no-wave-measured-exceed; mirrors the 29// anchor's duplicates_prevented): recover_count is the number of times 30// the watchdog drove a fixture from RECOVERING back to HEALTHY by 31// re-provisioning it -- i.e. the exact count of MANUAL re-pairs the 32// operator did NOT have to do. The gate asserts it. 33// 34// NEVER-BRICK (CLAUDE.md #26): every action this organ emits is brick-safe 35// -- probe is read-only, mark-unreachable/mark-current touch only the 36// anchor REGISTRY (soft-delete/revive, additive #13), and re-provision 37// delegates to nx_iot_provision_softap whose action alphabet is proven 38// firmware-write-free. No watchdog action writes device firmware. 39// 40// genealogy_id: NISHI_IOT_HUB_ROADMAP.md Epoch 5 + project-iot-hub-multi-vendor-kickoff-2026-05-16 41// license_tier: ORIGINAL 42// 43// nx_capability_claims: 44// needs: [pointer_arithmetic] 45// provides: [device_health_state_machine, bounded_miss_threshold, 46// auto_reprovision_on_pairing, measured_recover_count, 47// brick_safe_action_alphabet] 48// safety: [no_unchecked_deref, no_floating_point, no_syscall, 49// bounded_iteration, no_firmware_write_by_construction, 50// additive_registry_only, kind_isolated] 51// verdict: [sealed_enum_health, sealed_enum_action, sealed_enum_obs, 52// no_silent_failure] 53// license: ORIGINAL 54// kind: iot_runtime_primitive 55// sss: [S2 (liveness -- bounded detection), S6 (no cloud), 56// S7 (sealed-enum verdict)] 57 58// No imports. Pure caller-buffer; the daemon maps actions to the anchor 59// + provision organs, so the watchdog stays self-contained + portable. 60 61// ---- Sealed enum: device health state ----------------------------- 62 63const WD_ST_HEALTHY: i64 = 0 // seen recently, reachable 64const WD_ST_MISSED: i64 = 1 // missed >=1 heartbeat (transient) 65const WD_ST_UNREACHABLE: i64 = 2 // missed >= threshold -> declared down 66const WD_ST_RECOVERING: i64 = 3 // found in pairing mode -> re-provisioning 67const WD_ST_N: i64 = 4 68 69func nx_iot_wd_state_is_valid(v: i64) -> i64 { 70 if v < 0 { return 0 } 71 if v >= WD_ST_N { return 0 } 72 return 1 73} 74 75// ---- Sealed enum: observation (what the reachability check reported) - 76 77const WD_OBS_NONE: i64 = 0 78const WD_OBS_SEEN: i64 = 1 // device replied / present on the LAN 79const WD_OBS_MISS: i64 = 2 // no reply this tick 80const WD_OBS_PAIRING: i64 = 3 // device found back in open pairing mode (the blink) 81const WD_OBS_READOPTED: i64 = 4 // provision+anchor re-adopted it (healed) 82const WD_OBS_N: i64 = 5 83 84func nx_iot_wd_obs_is_valid(v: i64) -> i64 { 85 if v < 0 { return 0 } 86 if v >= WD_OBS_N { return 0 } 87 return 1 88} 89 90// ---- Sealed enum: action (what the daemon should do) -------------- 91// 92// Brick-safe alphabet (CLAUDE.md #26): none writes device firmware. 93 94const WD_ACT_NONE: i64 = 0 95const WD_ACT_PROBE: i64 = 1 // actively re-probe the device 96const WD_ACT_MARK_UNREACHABLE: i64 = 2 // anchor soft-delete (additive) 97const WD_ACT_REPROVISION: i64 = 3 // kick nx_iot_provision_softap 98const WD_ACT_MARK_CURRENT: i64 = 4 // anchor revive (re-adopt healthy) 99const WD_ACT_N: i64 = 5 100 101func nx_iot_wd_action_is_valid(v: i64) -> i64 { 102 if v < 0 { return 0 } 103 if v >= WD_ACT_N { return 0 } 104 return 1 105} 106 107// FAIL-SAFE firmware-write classifier (same discipline as provision): 108// 0 only for the explicit safe allow-list, 1 (brick risk) otherwise, so a 109// future un-reviewed action defaults RED at the never-brick gate. 110func nx_iot_wd_action_writes_firmware(act: i64) -> i64 { 111 if act == WD_ACT_NONE { return 0 } 112 if act == WD_ACT_PROBE { return 0 } 113 if act == WD_ACT_MARK_UNREACHABLE { return 0 } // anchor registry, not device 114 if act == WD_ACT_REPROVISION { return 0 } // delegates to never-brick provision 115 if act == WD_ACT_MARK_CURRENT { return 0 } // anchor registry, not device 116 return 1 117} 118 119func nx_iot_wd_action_is_brick_safe(act: i64) -> i64 { 120 if nx_iot_wd_action_is_valid(act) != 1 { return 0 } 121 if nx_iot_wd_action_writes_firmware(act) != 0 { return 0 } 122 return 1 123} 124 125// ---- Miss threshold (data, not a magic number -- CLAUDE.md #11) ---- 126 127const NX_IOT_WD_MISS_THRESHOLD: i64 = 3 128 129// ---- Per-device watchdog record (8 i64 = 64 bytes) ---------------- 130 131struct IotWatchdog { 132 logical_id: i64, // the anchored device this watches 133 health: i64, // sealed health state 134 consec_misses: i64, // consecutive missed heartbeats 135 miss_threshold: i64, // misses before declaring unreachable (data-driven) 136 last_seen_ms: i64, // wall-ms of last reachable observation 137 last_action: i64, // action emitted by the most recent observe 138 recover_count: i64, // MEASURED auto-heals (manual re-pairs spared) 139 tick_count: i64, // total valid observations processed 140} 141 142// ---- Init --------------------------------------------------------- 143// 144// Returns 0 on success, -1 on a null record. miss_threshold <= 0 takes 145// the default. A fresh device starts HEALTHY (it was just anchored). 146 147func nx_iot_wd_init(w: *IotWatchdog, logical_id: i64, 148 miss_threshold: i64, now_ms: i64) -> i64 { 149 if w == (0 as *IotWatchdog) { return -1 } 150 var mt: i64 = miss_threshold 151 if mt <= 0 { mt = NX_IOT_WD_MISS_THRESHOLD } 152 w.logical_id = logical_id 153 w.health = WD_ST_HEALTHY 154 w.consec_misses = 0 155 w.miss_threshold = mt 156 w.last_seen_ms = now_ms 157 w.last_action = WD_ACT_NONE 158 w.recover_count = 0 159 w.tick_count = 0 160 return 0 161} 162 163// ---- Observe: the transition --------------------------------------- 164// 165// Feed the reachability observation for this tick; get back the action. 166// Dispatch is on a snapshot of the health state (h), so a block that 167// advances w.health cannot fall through into a later block. Unhandled 168// (state,observation) pairs are a defensive no-op. 169 170func nx_iot_wd_observe(w: *IotWatchdog, obs: i64, now_ms: i64) -> i64 { 171 if w == (0 as *IotWatchdog) { return WD_ACT_NONE } 172 if nx_iot_wd_obs_is_valid(obs) != 1 { 173 w.last_action = WD_ACT_NONE 174 return WD_ACT_NONE 175 } 176 w.tick_count = w.tick_count + 1 177 178 let h: i64 = w.health 179 var act: i64 = WD_ACT_NONE 180 181 // ---- HEALTHY --------------------------------------------------- 182 if h == WD_ST_HEALTHY { 183 if obs == WD_OBS_SEEN { 184 w.consec_misses = 0 185 w.last_seen_ms = now_ms 186 act = WD_ACT_NONE 187 } 188 if obs == WD_OBS_MISS { 189 w.consec_misses = 1 190 w.health = WD_ST_MISSED 191 act = WD_ACT_PROBE 192 } 193 if obs == WD_OBS_PAIRING { 194 // a healthy fixture suddenly in pairing mode -> it dropped + reverted 195 w.health = WD_ST_RECOVERING 196 act = WD_ACT_REPROVISION 197 } 198 } 199 200 // ---- MISSED (transient) ---------------------------------------- 201 if h == WD_ST_MISSED { 202 if obs == WD_OBS_SEEN { 203 w.consec_misses = 0 204 w.last_seen_ms = now_ms 205 w.health = WD_ST_HEALTHY 206 act = WD_ACT_NONE 207 } 208 if obs == WD_OBS_MISS { 209 w.consec_misses = w.consec_misses + 1 210 if w.consec_misses >= w.miss_threshold { 211 w.health = WD_ST_UNREACHABLE 212 act = WD_ACT_MARK_UNREACHABLE 213 } else { 214 act = WD_ACT_PROBE 215 } 216 } 217 if obs == WD_OBS_PAIRING { 218 w.health = WD_ST_RECOVERING 219 act = WD_ACT_REPROVISION 220 } 221 } 222 223 // ---- UNREACHABLE (declared down) ------------------------------- 224 if h == WD_ST_UNREACHABLE { 225 if obs == WD_OBS_SEEN { 226 // came back on its own (no re-provision needed) -> revive 227 w.consec_misses = 0 228 w.last_seen_ms = now_ms 229 w.health = WD_ST_HEALTHY 230 act = WD_ACT_MARK_CURRENT 231 } 232 if obs == WD_OBS_PAIRING { 233 // the blink: reverted to pairing -> auto-heal 234 w.health = WD_ST_RECOVERING 235 act = WD_ACT_REPROVISION 236 } 237 if obs == WD_OBS_MISS { 238 act = WD_ACT_NONE // already down; nothing new to do 239 } 240 } 241 242 // ---- RECOVERING (re-provisioning in flight) -------------------- 243 if h == WD_ST_RECOVERING { 244 if obs == WD_OBS_READOPTED { 245 // provision_softap re-provisioned + anchor re-adopted = HEALED. 246 // This is the manual re-pair the operator did NOT have to do. 247 w.recover_count = w.recover_count + 1 248 w.consec_misses = 0 249 w.last_seen_ms = now_ms 250 w.health = WD_ST_HEALTHY 251 act = WD_ACT_MARK_CURRENT 252 } 253 if obs == WD_OBS_SEEN { 254 // re-provision brought it back on the LAN 255 w.recover_count = w.recover_count + 1 256 w.consec_misses = 0 257 w.last_seen_ms = now_ms 258 w.health = WD_ST_HEALTHY 259 act = WD_ACT_MARK_CURRENT 260 } 261 if obs == WD_OBS_PAIRING { 262 act = WD_ACT_REPROVISION // still pairing -> keep re-provisioning 263 } 264 if obs == WD_OBS_MISS { 265 act = WD_ACT_NONE // waiting for re-provision to complete 266 } 267 } 268 269 w.last_action = act 270 return act 271} 272 273// ---- Observers (for the daemon / UI / gate) ----------------------- 274 275func nx_iot_wd_state(w: *IotWatchdog) -> i64 { 276 if w == (0 as *IotWatchdog) { return -1 } 277 return w.health 278} 279 280func nx_iot_wd_action(w: *IotWatchdog) -> i64 { 281 if w == (0 as *IotWatchdog) { return -1 } 282 return w.last_action 283} 284 285func nx_iot_wd_consec_misses(w: *IotWatchdog) -> i64 { 286 if w == (0 as *IotWatchdog) { return -1 } 287 return w.consec_misses 288} 289 290func nx_iot_wd_recover_count(w: *IotWatchdog) -> i64 { 291 if w == (0 as *IotWatchdog) { return -1 } 292 return w.recover_count 293} 294 295func nx_iot_wd_tick_count(w: *IotWatchdog) -> i64 { 296 if w == (0 as *IotWatchdog) { return -1 } 297 return w.tick_count 298} 299 300func nx_iot_wd_logical_id(w: *IotWatchdog) -> i64 { 301 if w == (0 as *IotWatchdog) { return -1 } 302 return w.logical_id 303} 304 305func nx_iot_wd_is_healthy(w: *IotWatchdog) -> i64 { 306 if w == (0 as *IotWatchdog) { return -1 } 307 if w.health == WD_ST_HEALTHY { return 1 } 308 return 0 309} 310 311func nx_iot_wd_is_down(w: *IotWatchdog) -> i64 { 312 if w == (0 as *IotWatchdog) { return -1 } 313 if w.health == WD_ST_UNREACHABLE { return 1 } 314 if w.health == WD_ST_RECOVERING { return 1 } 315 return 0 316}