code wiki / (root) / nx_iot_anchor.nx

nx_iot_anchor.nx source

↩ module page · 486 lines · 19460 B

1// nx_iot_anchor.nx -- stable device-identity anchor for the hub. 2// 3// Roadmap: NISHI_IOT_HUB_ROADMAP.md Epoch 3 (PAIR) keystone + 4// the cross-cutting identity substrate every later epoch writes into. 5// 6// THE PROBLEM THIS CLOSES (operator-reported, 2026-05-16 + 2026-06-18): 7// "they disconnect all the time and blink and then I manually have 8// to repair and then they duplicate in the app ... light bulbs 9// aren't wandering electronics, they should stay anchored like 10// sprinklers." 11// 12// Incumbent apps (Smart Life / Kasa / etc.) key a device by a 13// CLOUD-ISSUED registration token. When a fixture drops WiFi, 14// reverts to pairing mode, and is re-paired, the cloud mints a NEW 15// token -> a NEW device row appears, the old one goes stale, and 16// scenes/automations silently point at the dead row. The fixture 17// never moved; its IDENTITY wandered. That is the duplication bug. 18// 19// THE FIX (structural, not heuristic -- per 20// feedback-honest-perf-verdict-no-aspirational-claims): 21// Anchor identity to the device's STABLE HARDWARE id (its MAC, or a 22// vendor gwId) -- NOT to an IP, a session, or a cloud token. A 23// (re)discovery of a known hardware id RE-ADOPTS the existing 24// logical device: same logical_id, same friendly name, same history; 25// only the volatile fields (ip, last_seen, reachability) update. A 26// physical device therefore maps to EXACTLY ONE logical record for 27// its entire life, no matter how many times it blinks, drops, and 28// re-pairs. Duplication is eliminated BY CONSTRUCTION, not by a 29// de-dup pass that can be skipped. 30// 31// HONEST SCOPE (what this primitive does NOT do): 32// - It does not, by itself, stop a fixture from dropping WiFi. That 33// is the LAN-session + watchdog + provision layers (queued: 34// nx_iot_watchdog.nx, nx_iot_provision_softap.nx). 35// - It does not talk to the network. It is pure-buffer: the caller 36// (discovery / provision / daemon) hands it a hardware id + the 37// volatile facts, and it returns the stable logical identity. 38// This is the IDENTITY spine those layers hang on. 39// 40// VENDOR-AGNOSTIC BY DESIGN (per feedback-hub-primitive-thin-per-site- 41// wiring): the anchor keys on an opaque, fixed-width hardware id, so 42// the SAME registry anchors a consumer Kasa switch, a Tuya bulb, a 43// WiZ lamp, AND an enterprise PoE fixture or a sprinkler controller. 44// Per-vendor drivers are thin; this core is shared. 45// 46// MEASURED-EXCEED HOOK (per feedback-exceed-jpl-iec-ieee + the 47// WIN/TIE/LOSE bench discipline): the registry counts every adopt 48// call (`naive_adopts`) alongside the number of distinct logical 49// devices it actually keeps (`count`). A naive, dedup-free 50// controller would have created one row per pairing event = 51// naive_adopts rows. duplicates_prevented = naive_adopts - count is 52// therefore a real, measured number -- the exact count of phantom 53// devices the incumbent app would have accrued. The gate asserts it. 54// 55// genealogy_id: nishi-core/nxc2/docs/NISHI_IOT_HUB_ROADMAP.md (Epoch 3) 56// + project-iot-hub-multi-vendor-kickoff-2026-05-16 57// license_tier: ORIGINAL 58// 59// nx_capability_claims: (per docs/NISHI_INTELLIGENT_CAPABILITY_LAYER.md) 60// needs: [pointer_arithmetic] 61// provides: [stable_identity_anchor, idempotent_adopt, 62// readopt_never_duplicate, soft_delete_additive_history, 63// measured_duplicates_prevented] 64// safety: [no_unchecked_deref, no_floating_point, no_syscall, 65// bounded_iteration, cap_enforced, additive_only, 66// kind_isolated] 67// verdict: [sealed_enum_7_state, no_silent_failure] 68// license: ORIGINAL 69// kind: iot_runtime_primitive 70// sss: [S5 (content/identity-addressed), S6 (no cloud), 71// S7 (sealed-enum verdict)] 72// 73// nx_safety_envelope: (schema: nishi-library/seeds/safety-critical-standards.toml) 74// intended_use: "Stable device-identity registry -- maps a 75// physical fixture to exactly one logical id for 76// its whole life; eliminates duplicate-on-repair." 77// sil_target: SIL2 (a wrong anchor mis-routes a command to 78// the wrong fixture; bounded blast radius 79// -- lighting, not life-safety -- but a 80// mis-actuation is a real hazard) 81// asil_target: QM 82// dal_target: NONE 83// iec_62304_class: NONE 84// evidence: [no_syscall, no_floating_point, bounded_iteration, 85// cap_enforced, additive_only, sealed_enum_complete, 86// readopt_idempotency_gate, duplicates_prevented_KAT] 87// hazard_register: [bug-tape-anchor-collision (two devices, one id), 88// bug-tape-anchor-split (one device, two ids), 89// bug-tape-stale-current (dead row marked live)] 90// residual_risk: "Caller MUST supply a CONSISTENT hardware-id 91// representation per device (recommended: the 92// lowercase MAC string from mac48_format, 17 93// bytes; or the vendor gwId). Mixing raw-6-byte 94// and 17-char forms for the same device would 95// split its identity. The anchor cannot detect 96// that without a syscall-side OUI/ARP read." 97// verdict: NOT_YET_EVALUATED (awaits nx_safety_critical_grade; 98// bounded_iteration + sealed_enum pre-proven here) 99 100// No imports. Pure caller-provided-buffer arithmetic, like the clock 101// master + kv store -- keeps the anchor portable across every nxc2 102// backend and free of the x86_64/rv64 syscall-ABI split. 103 104// ---- Sealed enum: vendor family ----------------------------------- 105// 106// Matches the discovery classifier (nx_iot_announce.nx) + adds KASA, 107// the TP-Link Smart Home family (the live device found on the LAN 108// 2026-06-18: TP-LINK_HS210_C209). UNKNOWN is a first-class value so 109// a device discovered before classification still anchors. 110 111const NX_IOT_VENDOR_UNKNOWN: i64 = 0 112const NX_IOT_VENDOR_TUYA: i64 = 1 113const NX_IOT_VENDOR_MAGICHOME: i64 = 2 114const NX_IOT_VENDOR_WIZ: i64 = 3 115const NX_IOT_VENDOR_YEELIGHT: i64 = 4 116const NX_IOT_VENDOR_KASA: i64 = 5 117const NX_IOT_VENDOR_N: i64 = 6 118 119func nx_iot_vendor_is_valid(v: i64) -> i64 { 120 if v < 0 { return 0 } 121 if v >= NX_IOT_VENDOR_N { return 0 } 122 return 1 123} 124 125// ---- Sealed enum: adopt verdict ----------------------------------- 126 127const NX_IOT_ANCHOR_UNKNOWN: i64 = 0 128const NX_IOT_ANCHOR_NEW: i64 = 1 // first sighting -> new logical device 129const NX_IOT_ANCHOR_READOPTED: i64 = 2 // known hwid -> SAME logical_id (no dup) 130const NX_IOT_ANCHOR_UPDATED: i64 = 3 // metadata-only change (name / reachability) 131const NX_IOT_ANCHOR_FULL: i64 = 4 // registry at capacity 132const NX_IOT_ANCHOR_BAD_ARG: i64 = 5 133const NX_IOT_ANCHOR_NOT_FOUND: i64 = 6 134const NX_IOT_ANCHOR_N: i64 = 7 135 136func nx_iot_anchor_verdict_is_valid(v: i64) -> i64 { 137 if v < 0 { return 0 } 138 if v >= NX_IOT_ANCHOR_N { return 0 } 139 return 1 140} 141 142// ---- Layout constants --------------------------------------------- 143// 144// A device occupies one fixed slot in TWO caller-provided arrays 145// (parallel by slot index): 146// metas[slot] : IotDeviceMeta (8 i64 = 64 bytes) -- the numeric facts 147// blob[slot] : HWID_CAP + NAME_CAP bytes -- hwid + friendly name 148// 149// hwid is zero-padded to a fixed width so equality is a fixed-length 150// compare (no length ambiguity, no partial-prefix collision within a 151// consistent representation). 152 153const NX_IOT_HWID_CAP: i64 = 32 // fits a 17-char MAC string or a gwId 154const NX_IOT_NAME_CAP: i64 = 32 // friendly name, e.g. "Kitchen Switch" 155const NX_IOT_BLOB_STRIDE: i64 = 64 // HWID_CAP + NAME_CAP 156const NX_IOT_DEV_META_BYTES: i64 = 64 // 8 i64 fields 157 158// ---- Registry header (caller pre-allocates one) ------------------- 159 160struct IotAnchorReg { 161 count: i64, // number of distinct logical devices held 162 cap: i64, // max slots in the metas/blob arrays 163 next_logical_id: i64, // monotonic; assigned once per device, never reused 164 naive_adopts: i64, // every adopt call -- the dedup-free baseline 165} 166 167// ---- Per-device record (numeric facts) ---------------------------- 168// 169// logical_id + first_seen are WRITE-ONCE (the anchor). vendor, ip_be, 170// last_seen, adopt_count, is_current are volatile and update on 171// re-adoption. name_len tracks the friendly name stored in blob. 172 173struct IotDeviceMeta { 174 logical_id: i64, // STABLE anchor -- never changes after assignment 175 vendor: i64, // sealed vendor enum 176 ip_be: i64, // last-known IPv4, packed big-endian (volatile) 177 first_seen: i64, // wall-ms of first adoption (write-once) 178 last_seen: i64, // wall-ms of most recent adoption (volatile) 179 adopt_count: i64, // how many pairing events hit this device (the blink tally) 180 is_current: i64, // 1 = reachable, 0 = soft-deleted (additive history) 181 name_len: i64, // friendly-name length in blob (<= NAME_CAP) 182} 183 184// ---- Slot address helpers ----------------------------------------- 185 186func nx_iot_anchor_meta_ptr(metas: *IotDeviceMeta, slot: i64) -> *IotDeviceMeta { 187 let addr: i64 = (metas as i64) + slot * NX_IOT_DEV_META_BYTES 188 return addr as *IotDeviceMeta 189} 190 191func nx_iot_anchor_hwid_base(slot: i64) -> i64 { 192 return slot * NX_IOT_BLOB_STRIDE 193} 194 195func nx_iot_anchor_name_base(slot: i64) -> i64 { 196 return slot * NX_IOT_BLOB_STRIDE + NX_IOT_HWID_CAP 197} 198 199// ---- hwid store + compare (fixed-width, zero-padded) -------------- 200 201func nx_iot_anchor_store_hwid(blob: *u8, slot: i64, 202 hwid: *u8, hwid_n: i64) -> i64 { 203 let base: i64 = nx_iot_anchor_hwid_base(slot) 204 var i: i64 = 0 205 while i < NX_IOT_HWID_CAP { 206 if i < hwid_n { blob[base + i] = hwid[i] } 207 else { blob[base + i] = 0 as u8 } 208 i = i + 1 209 } 210 return 0 211} 212 213// Returns 1 if slot's stored hwid equals the (zero-padded) query. 214// Keep-flag walk (NishiLang has no break). 215func nx_iot_anchor_hwid_eq(blob: *u8, slot: i64, 216 hwid: *u8, hwid_n: i64) -> i64 { 217 let base: i64 = nx_iot_anchor_hwid_base(slot) 218 var i: i64 = 0 219 var eq: i64 = 1 220 var keep: i64 = 1 221 while keep == 1 { 222 if i >= NX_IOT_HWID_CAP { keep = 0 } 223 else { 224 var want: i64 = 0 225 if i < hwid_n { want = hwid[i] as i64 } 226 if (blob[base + i] as i64) != want { 227 eq = 0 228 keep = 0 229 } else { 230 i = i + 1 231 } 232 } 233 } 234 return eq 235} 236 237// ---- Lookups ------------------------------------------------------ 238 239// Slot index of a known hardware id, or -1. 240func nx_iot_anchor_find(reg: *IotAnchorReg, blob: *u8, 241 hwid: *u8, hwid_n: i64) -> i64 { 242 var i: i64 = 0 243 var found: i64 = -1 244 while i < reg.count { 245 if found == -1 { 246 if nx_iot_anchor_hwid_eq(blob, i, hwid, hwid_n) == 1 { found = i } 247 } 248 i = i + 1 249 } 250 return found 251} 252 253// Slot index of a logical id, or -1. 254func nx_iot_anchor_slot_by_logical(reg: *IotAnchorReg, metas: *IotDeviceMeta, 255 logical_id: i64) -> i64 { 256 var i: i64 = 0 257 var found: i64 = -1 258 while i < reg.count { 259 if found == -1 { 260 let m: *IotDeviceMeta = nx_iot_anchor_meta_ptr(metas, i) 261 if m.logical_id == logical_id { found = i } 262 } 263 i = i + 1 264 } 265 return found 266} 267 268// ---- Init --------------------------------------------------------- 269// 270// Returns 0 on success, NX_IOT_ANCHOR_BAD_ARG on bad input. 271 272func nx_iot_anchor_init(reg: *IotAnchorReg, cap: i64) -> i64 { 273 if reg == (0 as *IotAnchorReg) { return NX_IOT_ANCHOR_BAD_ARG } 274 if cap <= 0 { return NX_IOT_ANCHOR_BAD_ARG } 275 reg.count = 0 276 reg.cap = cap 277 reg.next_logical_id = 1 // 0 is reserved = "no device" 278 reg.naive_adopts = 0 279 return 0 280} 281 282// ---- Adopt (the keystone) ----------------------------------------- 283// 284// Idempotent upsert keyed by hardware id. Returns the STABLE 285// logical_id (>= 1) on success, or -1 on failure. Verdict via 286// out_verdict: 287// NEW -> first sighting; a fresh logical_id was assigned 288// READOPTED -> this exact device was already anchored; SAME 289// logical_id returned, volatile fields refreshed, NO new 290// row. (This is the duplicate-on-repair killer.) 291// FULL -> registry at capacity, new device rejected 292// BAD_ARG -> bad pointer / hwid_n out of range / invalid vendor 293 294func nx_iot_anchor_adopt(reg: *IotAnchorReg, metas: *IotDeviceMeta, 295 blob: *u8, hwid: *u8, hwid_n: i64, 296 vendor: i64, ip_be: i64, wall_ms: i64, 297 out_verdict: *i64) -> i64 { 298 *out_verdict = NX_IOT_ANCHOR_UNKNOWN 299 if reg == (0 as *IotAnchorReg) { 300 *out_verdict = NX_IOT_ANCHOR_BAD_ARG 301 return -1 302 } 303 if hwid == (0 as *u8) { 304 *out_verdict = NX_IOT_ANCHOR_BAD_ARG 305 return -1 306 } 307 if hwid_n <= 0 { 308 *out_verdict = NX_IOT_ANCHOR_BAD_ARG 309 return -1 310 } 311 if hwid_n > NX_IOT_HWID_CAP { 312 *out_verdict = NX_IOT_ANCHOR_BAD_ARG 313 return -1 314 } 315 if nx_iot_vendor_is_valid(vendor) != 1 { 316 *out_verdict = NX_IOT_ANCHOR_BAD_ARG 317 return -1 318 } 319 320 // Count the pairing event -- the dedup-free baseline for the 321 // measured-exceed contrast. 322 reg.naive_adopts = reg.naive_adopts + 1 323 324 let slot: i64 = nx_iot_anchor_find(reg, blob, hwid, hwid_n) 325 if slot >= 0 { 326 // RE-ADOPTION: known fixture. Preserve logical_id + first_seen 327 // + name; refresh only the volatile facts. 328 let m: *IotDeviceMeta = nx_iot_anchor_meta_ptr(metas, slot) 329 m.vendor = vendor 330 m.ip_be = ip_be 331 m.last_seen = wall_ms 332 m.adopt_count = m.adopt_count + 1 333 m.is_current = 1 334 *out_verdict = NX_IOT_ANCHOR_READOPTED 335 return m.logical_id 336 } 337 338 // NEW device. 339 if reg.count >= reg.cap { 340 *out_verdict = NX_IOT_ANCHOR_FULL 341 return -1 342 } 343 let ns: i64 = reg.count 344 let nm: *IotDeviceMeta = nx_iot_anchor_meta_ptr(metas, ns) 345 let lid: i64 = reg.next_logical_id 346 nm.logical_id = lid 347 nm.vendor = vendor 348 nm.ip_be = ip_be 349 nm.first_seen = wall_ms 350 nm.last_seen = wall_ms 351 nm.adopt_count = 1 352 nm.is_current = 1 353 nm.name_len = 0 354 nx_iot_anchor_store_hwid(blob, ns, hwid, hwid_n) 355 reg.count = ns + 1 356 reg.next_logical_id = lid + 1 357 *out_verdict = NX_IOT_ANCHOR_NEW 358 return lid 359} 360 361// ---- Friendly name ------------------------------------------------ 362// 363// Names the anchor (survives every re-pair, unlike the cloud apps that 364// lose the name on duplicate). Returns 0 / -1; verdict UPDATED or 365// NOT_FOUND / BAD_ARG. 366 367func nx_iot_anchor_set_name(reg: *IotAnchorReg, metas: *IotDeviceMeta, 368 blob: *u8, logical_id: i64, 369 name: *u8, name_n: i64, out_verdict: *i64) -> i64 { 370 *out_verdict = NX_IOT_ANCHOR_UNKNOWN 371 if name_n < 0 { 372 *out_verdict = NX_IOT_ANCHOR_BAD_ARG 373 return -1 374 } 375 if name_n > NX_IOT_NAME_CAP { 376 *out_verdict = NX_IOT_ANCHOR_BAD_ARG 377 return -1 378 } 379 if name == (0 as *u8) { 380 if name_n > 0 { 381 *out_verdict = NX_IOT_ANCHOR_BAD_ARG 382 return -1 383 } 384 } 385 let slot: i64 = nx_iot_anchor_slot_by_logical(reg, metas, logical_id) 386 if slot < 0 { 387 *out_verdict = NX_IOT_ANCHOR_NOT_FOUND 388 return -1 389 } 390 let base: i64 = nx_iot_anchor_name_base(slot) 391 var i: i64 = 0 392 while i < NX_IOT_NAME_CAP { 393 if i < name_n { blob[base + i] = name[i] } 394 else { blob[base + i] = 0 as u8 } 395 i = i + 1 396 } 397 let m: *IotDeviceMeta = nx_iot_anchor_meta_ptr(metas, slot) 398 m.name_len = name_n 399 *out_verdict = NX_IOT_ANCHOR_UPDATED 400 return 0 401} 402 403// ---- Soft-delete (additive history, never remove) ----------------- 404// 405// Mark a device unreachable. The row is KEPT (is_current = 0) so its 406// logical_id, history, and name survive -- a future re-adoption flips 407// is_current back to 1 with the same anchor. Per CLAUDE.md #13. 408 409func nx_iot_anchor_mark_unreachable(reg: *IotAnchorReg, metas: *IotDeviceMeta, 410 logical_id: i64, out_verdict: *i64) -> i64 { 411 *out_verdict = NX_IOT_ANCHOR_UNKNOWN 412 let slot: i64 = nx_iot_anchor_slot_by_logical(reg, metas, logical_id) 413 if slot < 0 { 414 *out_verdict = NX_IOT_ANCHOR_NOT_FOUND 415 return -1 416 } 417 let m: *IotDeviceMeta = nx_iot_anchor_meta_ptr(metas, slot) 418 m.is_current = 0 419 *out_verdict = NX_IOT_ANCHOR_UPDATED 420 return 0 421} 422 423// ---- Observers / measured-exceed counters ------------------------- 424 425func nx_iot_anchor_count(reg: *IotAnchorReg) -> i64 { 426 if reg == (0 as *IotAnchorReg) { return -1 } 427 return reg.count 428} 429 430func nx_iot_anchor_naive_adopts(reg: *IotAnchorReg) -> i64 { 431 if reg == (0 as *IotAnchorReg) { return -1 } 432 return reg.naive_adopts 433} 434 435// The headline number: phantom devices an incumbent (dedup-free) app 436// would have accrued across the same pairing events. 437func nx_iot_anchor_duplicates_prevented(reg: *IotAnchorReg) -> i64 { 438 if reg == (0 as *IotAnchorReg) { return -1 } 439 return reg.naive_adopts - reg.count 440} 441 442func nx_iot_anchor_count_current(reg: *IotAnchorReg, metas: *IotDeviceMeta) -> i64 { 443 if reg == (0 as *IotAnchorReg) { return -1 } 444 var i: i64 = 0 445 var n: i64 = 0 446 while i < reg.count { 447 let m: *IotDeviceMeta = nx_iot_anchor_meta_ptr(metas, i) 448 if m.is_current == 1 { n = n + 1 } 449 i = i + 1 450 } 451 return n 452} 453 454// ---- Per-device field getters (for the daemon / UI) --------------- 455 456func nx_iot_anchor_logical_adopt_count(reg: *IotAnchorReg, metas: *IotDeviceMeta, 457 logical_id: i64) -> i64 { 458 let slot: i64 = nx_iot_anchor_slot_by_logical(reg, metas, logical_id) 459 if slot < 0 { return -1 } 460 let m: *IotDeviceMeta = nx_iot_anchor_meta_ptr(metas, slot) 461 return m.adopt_count 462} 463 464func nx_iot_anchor_logical_is_current(reg: *IotAnchorReg, metas: *IotDeviceMeta, 465 logical_id: i64) -> i64 { 466 let slot: i64 = nx_iot_anchor_slot_by_logical(reg, metas, logical_id) 467 if slot < 0 { return -1 } 468 let m: *IotDeviceMeta = nx_iot_anchor_meta_ptr(metas, slot) 469 return m.is_current 470} 471 472func nx_iot_anchor_logical_vendor(reg: *IotAnchorReg, metas: *IotDeviceMeta, 473 logical_id: i64) -> i64 { 474 let slot: i64 = nx_iot_anchor_slot_by_logical(reg, metas, logical_id) 475 if slot < 0 { return -1 } 476 let m: *IotDeviceMeta = nx_iot_anchor_meta_ptr(metas, slot) 477 return m.vendor 478} 479 480func nx_iot_anchor_logical_ip(reg: *IotAnchorReg, metas: *IotDeviceMeta, 481 logical_id: i64) -> i64 { 482 let slot: i64 = nx_iot_anchor_slot_by_logical(reg, metas, logical_id) 483 if slot < 0 { return -1 } 484 let m: *IotDeviceMeta = nx_iot_anchor_meta_ptr(metas, slot) 485 return m.ip_be 486}