code wiki / _hdl_build / nx_iot_ctl_lib.nx

nx_iot_ctl_lib.nx source

↩ module page · 618 lines · 26897 B

1// nx_iot_ctl_lib.nx -- sovereign HOME-ELECTRONICS identification LIB (no main). 2// 3// THE MEASURED DEFECT THIS CLOSES (2026-07-25, measured on the real LAN before 4// a line was written): the live `nx_printer_ctl survey` classifies EVERY host 5// on 192.168.8 as "web-device" because its port profile is four hardcoded 6// printer/web ports (7125/10088/80/443). It cannot tell a smart plug from a 7// NAS. Meanwhile a full vendor-routing brain -- nx_iot_classify, built and 8// gate-proven back in June (fused reply > OUI > port > SSID over Kasa / Tuya / 9// WiZ / MagicHome / Yeelight) -- has been sitting STRANDED in the tree, never 10// wired to anything live. This lib is the wiring, not a re-derivation. 11// 12// TWO DESIGN LAWS ARE MECHANISED HERE: 13// 14// 1. DATA-DRIVEN, NOT HARDCODED (rule 11). The service table is a MANIFEST: 15// port / proto / vendor / kind / label rows, loaded from `iot_services.conf` 16// with a compiled-in fail-safe default set (rule 14: a missing/unreadable 17// conf degrades to the defaults, it never leaves the organ blind). Adding a 18// vendor's port is a conf edit, not a rebuild. MANIFEST ORDER IS PRECEDENCE: 19// the first matching row decides a host's kind, so specificity is expressed 20// as data too. 21// 22// 2. AN INSTRUMENT MUST DECLARE WHAT IT CANNOT SEE (the cynical-instruments 23// law: unproven == absent; a synthetic pass is a stub). A TCP connect sweep 24// STRUCTURALLY CANNOT find a UDP-only service -- WiZ bulbs live on UDP 38899 25// and would be silently reported as "not there", which reads identically to 26// "no WiZ bulbs exist". So every emitted scan carries `udp_ports_unswept` 27// plus an explicit note that absence there is not evidence of absence. The 28// manifest tracks proto per row precisely so this stays honest as it grows. 29// 30// NEVER-BRICK (rule 26, ABSOLUTE): this lib is IDENTIFICATION ONLY. Its single 31// active probe is Kasa `get_sysinfo` -- a READ. There is no write verb, no 32// set_stainfo credential push, no firmware/OTA path, no raw command 33// passthrough. A device cannot be altered through any code path in this file; 34// nx_iot_ctl_gate proves it mechanically. 35// 36// license_tier: ORIGINAL No hw writes (Rule 26). 37import "nx_syscalls.nx" 38import "nx_lan_scan.nx" 39import "nx_iot_classify.nx" 40import "nx_iot_local_kasa.nx" 41import "nx_moonraker_io.nx" 42const IOTC_MAGIC_65535: i64 = 65535 43const IOTC_MAGIC_9999: i64 = 9999 44const IOTC_MAGIC_6668: i64 = 6668 45const IOTC_MAGIC_6667: i64 = 6667 46const IOTC_MAGIC_55443: i64 = 55443 47const IOTC_MAGIC_5577: i64 = 5577 48const IOTC_MAGIC_38899: i64 = 38899 49const IOTC_MAGIC_7125: i64 = 7125 50const IOTC_MAGIC_10088: i64 = 10088 51const IOTC_MAGIC_1883: i64 = 1883 52const IOTC_MAGIC_8123: i64 = 8123 53 54// ---- buffers -------------------------------------------------------- 55const IOTC_OUT: i64 = 32768 56const IOTC_RESP: i64 = 8192 57const IOTC_SCRATCH: i64 = 4096 58 59// ---- verb ids (sealed) ---------------------------------------------- 60const IOTC_V_BAD: i64 = 0 61const IOTC_V_SERVICES: i64 = 1 62const IOTC_V_SCAN: i64 = 2 63const IOTC_V_PROBE: i64 = 3 64const IOTC_V_CONTRACT: i64 = 4 65 66// ---- device kind (sealed enum) -------------------------------------- 67const IOTC_KIND_UNKNOWN: i64 = 0 68const IOTC_KIND_PLUG: i64 = 1 69const IOTC_KIND_BULB: i64 = 2 70const IOTC_KIND_PRINTER: i64 = 3 71const IOTC_KIND_HUB: i64 = 4 72const IOTC_KIND_WEB: i64 = 5 73const IOTC_KIND_N: i64 = 6 74 75// ---- transport (sealed enum) ---------------------------------------- 76const IOTC_PROTO_TCP: i64 = 1 77const IOTC_PROTO_UDP: i64 = 2 78 79// ---- manifest layout ------------------------------------------------- 80// header: [0] = row count. rows begin at IOTM_BASE, IOTM_ROW bytes each: 81// +0 port +8 vendor +16 kind +24 proto +32 label (32B, NUL-term) 82const IOTM_BASE: i64 = 8 83const IOTM_ROW: i64 = 64 84const IOTM_LABEL: i64 = 32 85const IOTM_MAXLBL: i64 = 31 86const IOTM_MAX: i64 = 32 87const IOTM_BYTES: i64 = 4096 88 89func iotm_new() -> *u8 { 90 let m: *u8 = sys_mmap(IOTM_BYTES) 91 let hdr: *i64 = m as *i64 92 hdr[0] = 0 93 return m 94} 95func iotm_count(m: *u8) -> i64 { let hdr: *i64 = m as *i64; return hdr[0] } 96func iotm_row(m: *u8, i: i64) -> *u8 { 97 let a: i64 = (m as i64) + IOTM_BASE + (i * IOTM_ROW) 98 return a as *u8 99} 100func iotm_port(m: *u8, i: i64) -> i64 { let r: *i64 = iotm_row(m, i) as *i64; return r[0] } 101func iotm_vendor(m: *u8, i: i64) -> i64 { let r: *i64 = iotm_row(m, i) as *i64; return r[1] } 102func iotm_kind(m: *u8, i: i64) -> i64 { let r: *i64 = iotm_row(m, i) as *i64; return r[2] } 103func iotm_proto(m: *u8, i: i64) -> i64 { let r: *i64 = iotm_row(m, i) as *i64; return r[3] } 104func iotm_label(m: *u8, i: i64) -> *u8 { 105 let a: i64 = (iotm_row(m, i) as i64) + IOTM_LABEL 106 return a as *u8 107} 108 109// Append a row. Returns the new count, or -1 if full / label too long. 110func iotm_add(m: *u8, port: i64, vendor: i64, kind: i64, proto: i64, label: *u8) -> i64 { 111 let hdr: *i64 = m as *i64 112 let n: i64 = hdr[0] 113 if n >= IOTM_MAX { return 0 - 1 } 114 if port < 1 { return 0 - 1 } 115 if port > IOTC_MAGIC_65535 { return 0 - 1 } 116 let r: *i64 = iotm_row(m, n) as *i64 117 r[0] = port 118 r[1] = vendor 119 r[2] = kind 120 r[3] = proto 121 let dst: *u8 = iotm_label(m, n) 122 var i: i64 = 0 123 while i < IOTM_MAXLBL { 124 let c: i64 = (label[i] as i64) & 0xff 125 if c == 0 { 126 dst[i] = 0 as u8 127 i = IOTM_MAXLBL 128 } else { 129 dst[i] = c as u8 130 i = i + 1 131 } 132 } 133 dst[IOTM_MAXLBL] = 0 as u8 134 hdr[0] = n + 1 135 return n + 1 136} 137 138// ---- fail-safe compiled-in defaults --------------------------------- 139// ORDER IS PRECEDENCE: vendor-specific control ports first, generic web last, 140// so a host that answers on both 9999 and 80 is a Kasa plug, not a "web-device" 141// (the exact misclassification the live survey makes today). 142func iotm_defaults(m: *u8) -> i64 { 143 iotm_add(m, IOTC_MAGIC_9999, NX_IOT_VENDOR_KASA, IOTC_KIND_PLUG, IOTC_PROTO_TCP, "kasa-tcp" as *u8) 144 iotm_add(m, IOTC_MAGIC_6668, NX_IOT_VENDOR_TUYA, IOTC_KIND_PLUG, IOTC_PROTO_TCP, "tuya-lan" as *u8) 145 iotm_add(m, IOTC_MAGIC_6667, NX_IOT_VENDOR_TUYA, IOTC_KIND_PLUG, IOTC_PROTO_TCP, "tuya-lan-alt" as *u8) 146 iotm_add(m, IOTC_MAGIC_55443, NX_IOT_VENDOR_YEELIGHT, IOTC_KIND_BULB, IOTC_PROTO_TCP, "yeelight-lan" as *u8) 147 iotm_add(m, IOTC_MAGIC_5577, NX_IOT_VENDOR_MAGICHOME, IOTC_KIND_BULB, IOTC_PROTO_TCP, "magichome-lan" as *u8) 148 iotm_add(m, IOTC_MAGIC_38899, NX_IOT_VENDOR_WIZ, IOTC_KIND_BULB, IOTC_PROTO_UDP, "wiz-udp" as *u8) 149 iotm_add(m, IOTC_MAGIC_7125, NX_IOT_VENDOR_UNKNOWN, IOTC_KIND_PRINTER, IOTC_PROTO_TCP, "moonraker" as *u8) 150 iotm_add(m, IOTC_MAGIC_10088, NX_IOT_VENDOR_UNKNOWN, IOTC_KIND_PRINTER, IOTC_PROTO_TCP, "fluidd" as *u8) 151 iotm_add(m, IOTC_MAGIC_1883, NX_IOT_VENDOR_UNKNOWN, IOTC_KIND_HUB, IOTC_PROTO_TCP, "mqtt" as *u8) 152 iotm_add(m, IOTC_MAGIC_8123, NX_IOT_VENDOR_UNKNOWN, IOTC_KIND_HUB, IOTC_PROTO_TCP, "home-hub" as *u8) 153 iotm_add(m, 80, NX_IOT_VENDOR_UNKNOWN, IOTC_KIND_WEB, IOTC_PROTO_TCP, "http" as *u8) 154 iotm_add(m, 443, NX_IOT_VENDOR_UNKNOWN, IOTC_KIND_WEB, IOTC_PROTO_TCP, "https" as *u8) 155 return iotm_count(m) 156} 157 158// ---- conf parsing ---------------------------------------------------- 159// Row syntax: <port> <proto> <vendor> <kind> <label> (whitespace separated) 160// '#' comments and blank lines ignored. Any malformed row is SKIPPED, not 161// fatal -- a typo must not blind the organ (rule 14). 162// CR counts as whitespace so a CRLF-authored conf parses as-is (the printer 163// lane paid for this lesson once already -- do not make the operator run tr). 164func iotc_is_space(c: i64) -> i64 { 165 if c == 32 { return 1 } 166 if c == 9 { return 1 } 167 if c == 13 { return 1 } 168 return 0 169} 170func iotc_word_eq(buf: *u8, s: i64, e: i64, lit: *u8) -> i64 { 171 var i: i64 = 0 172 var k: i64 = s 173 var ok: i64 = 1 174 var go: i64 = 1 175 while go == 1 { 176 let lc: i64 = (lit[i] as i64) & 0xff 177 if lc == 0 { 178 if k != e { ok = 0 } 179 go = 0 180 } else { 181 if k >= e { ok = 0; go = 0 } 182 else { 183 if ((buf[k] as i64) & 0xff) != lc { ok = 0; go = 0 } 184 else { i = i + 1; k = k + 1 } 185 } 186 } 187 } 188 return ok 189} 190func iotc_word_num(buf: *u8, s: i64, e: i64) -> i64 { 191 if s >= e { return 0 - 1 } 192 var v: i64 = 0 193 var i: i64 = s 194 while i < e { 195 let c: i64 = (buf[i] as i64) & 0xff 196 if c < 48 { return 0 - 1 } 197 if c > 57 { return 0 - 1 } 198 v = v * 10 + (c - 48) 199 i = i + 1 200 } 201 return v 202} 203func iotc_vendor_of(buf: *u8, s: i64, e: i64) -> i64 { 204 if iotc_word_eq(buf, s, e, "kasa" as *u8) == 1 { return NX_IOT_VENDOR_KASA } 205 if iotc_word_eq(buf, s, e, "tuya" as *u8) == 1 { return NX_IOT_VENDOR_TUYA } 206 if iotc_word_eq(buf, s, e, "wiz" as *u8) == 1 { return NX_IOT_VENDOR_WIZ } 207 if iotc_word_eq(buf, s, e, "yeelight" as *u8) == 1 { return NX_IOT_VENDOR_YEELIGHT } 208 if iotc_word_eq(buf, s, e, "magichome" as *u8) == 1 { return NX_IOT_VENDOR_MAGICHOME } 209 return NX_IOT_VENDOR_UNKNOWN 210} 211func iotc_kind_of(buf: *u8, s: i64, e: i64) -> i64 { 212 if iotc_word_eq(buf, s, e, "plug" as *u8) == 1 { return IOTC_KIND_PLUG } 213 if iotc_word_eq(buf, s, e, "bulb" as *u8) == 1 { return IOTC_KIND_BULB } 214 if iotc_word_eq(buf, s, e, "printer" as *u8) == 1 { return IOTC_KIND_PRINTER } 215 if iotc_word_eq(buf, s, e, "hub" as *u8) == 1 { return IOTC_KIND_HUB } 216 if iotc_word_eq(buf, s, e, "web" as *u8) == 1 { return IOTC_KIND_WEB } 217 return IOTC_KIND_UNKNOWN 218} 219 220// Parse a conf image into the manifest. Returns rows accepted (0 = none, caller 221// falls back to defaults). Never partially trusts a bad line. 222func iotm_parse(m: *u8, buf: *u8, n: i64) -> i64 { 223 var added: i64 = 0 224 var i: i64 = 0 225 while i < n { 226 // ---- line bounds [ls, le) ---- 227 let ls: i64 = i 228 var le: i64 = i 229 var scan_line: i64 = 1 230 while scan_line == 1 { 231 if le >= n { scan_line = 0 } 232 else { 233 if ((buf[le] as i64) & 0xff) == 10 { scan_line = 0 } 234 else { le = le + 1 } 235 } 236 } 237 i = le + 1 238 239 // ---- tokenize the line into up to 5 fields ---- 240 var w: i64 = 0 241 var p: i64 = ls 242 var port: i64 = 0 - 1 243 var proto: i64 = 0 244 var vend: i64 = NX_IOT_VENDOR_UNKNOWN 245 var kind: i64 = IOTC_KIND_UNKNOWN 246 var lbl_s: i64 = 0 - 1 247 var lbl_e: i64 = 0 - 1 248 var bad: i64 = 0 249 while p < le { 250 if iotc_is_space((buf[p] as i64) & 0xff) == 1 { p = p + 1 } 251 else { 252 var we: i64 = p 253 var scan_word: i64 = 1 254 while scan_word == 1 { 255 if we >= le { scan_word = 0 } 256 else { 257 if iotc_is_space((buf[we] as i64) & 0xff) == 1 { scan_word = 0 } 258 else { we = we + 1 } 259 } 260 } 261 if w == 0 { 262 // '#' comment: abandon the whole line 263 if ((buf[p] as i64) & 0xff) == 35 { bad = 1; we = le } 264 else { 265 port = iotc_word_num(buf, p, we) 266 if port < 1 { bad = 1 } 267 } 268 } 269 if w == 1 { 270 if iotc_word_eq(buf, p, we, "tcp" as *u8) == 1 { proto = IOTC_PROTO_TCP } 271 if iotc_word_eq(buf, p, we, "udp" as *u8) == 1 { proto = IOTC_PROTO_UDP } 272 if proto == 0 { bad = 1 } 273 } 274 if w == 2 { vend = iotc_vendor_of(buf, p, we) } 275 if w == 3 { kind = iotc_kind_of(buf, p, we) } 276 if w == 4 { lbl_s = p; lbl_e = we } 277 w = w + 1 278 p = we 279 } 280 } 281 282 // ---- accept only a complete, well-formed row (skip, never fatal) ---- 283 if bad == 0 { 284 if w >= 4 { 285 if port > 0 { 286 if proto != 0 { 287 let tmp: *u8 = sys_mmap(IOTM_LABEL) 288 var lz: i64 = 0 289 if lbl_s >= 0 { lz = lbl_e - lbl_s } 290 if lz < 0 { lz = 0 } 291 if lz > IOTM_MAXLBL { lz = IOTM_MAXLBL } 292 var li: i64 = 0 293 while li < lz { tmp[li] = buf[lbl_s + li]; li = li + 1 } 294 tmp[lz] = 0 as u8 295 if iotm_add(m, port, vend, kind, proto, tmp) > 0 { added = added + 1 } 296 } 297 } 298 } 299 } 300 } 301 return added 302} 303 304// Load the manifest: conf if present+parseable, else compiled-in defaults. 305// Returns 1 if the conf supplied the rows, 0 if defaults were used. 306func iotm_load(m: *u8, path: *u8) -> i64 { 307 let lenp: *i64 = sys_mmap(8) as *i64 308 lenp[0] = 0 309 let img: *u8 = sys_read_file(path, lenp) 310 if (img as i64) != 0 { 311 if lenp[0] > 0 { 312 if iotm_parse(m, img, lenp[0]) > 0 { return 1 } 313 } 314 } 315 iotm_defaults(m) 316 return 0 317} 318 319// ---- name tables (sealed-enum -> wire string) ------------------------ 320func iotc_kind_name(k: i64) -> *u8 { 321 if k == IOTC_KIND_PLUG { return "smart-plug" as *u8 } 322 if k == IOTC_KIND_BULB { return "smart-bulb" as *u8 } 323 if k == IOTC_KIND_PRINTER { return "3d-printer" as *u8 } 324 if k == IOTC_KIND_HUB { return "home-hub" as *u8 } 325 if k == IOTC_KIND_WEB { return "web-device" as *u8 } 326 return "device" as *u8 327} 328func iotc_vendor_name(v: i64) -> *u8 { 329 if v == NX_IOT_VENDOR_KASA { return "kasa" as *u8 } 330 if v == NX_IOT_VENDOR_TUYA { return "tuya" as *u8 } 331 if v == NX_IOT_VENDOR_WIZ { return "wiz" as *u8 } 332 if v == NX_IOT_VENDOR_YEELIGHT { return "yeelight" as *u8 } 333 if v == NX_IOT_VENDOR_MAGICHOME { return "magichome" as *u8 } 334 return "unknown" as *u8 335} 336func iotc_proto_name(p: i64) -> *u8 { 337 if p == IOTC_PROTO_UDP { return "udp" as *u8 } 338 return "tcp" as *u8 339} 340 341// ---- verb routing ---------------------------------------------------- 342func iotc_streq(a: *u8, b: *u8) -> i64 { 343 var i: i64 = 0 344 while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 } 345 if b[i] != (0 as u8) { return 0 } 346 return 1 347} 348// NEVER-BRICK: the verb map is the whole reachable surface. There is no write 349// verb here, so no argument can steer this organ onto a device-mutating path. 350func iotc_verb_id(v: *u8) -> i64 { 351 if iotc_streq(v, "services" as *u8) == 1 { return IOTC_V_SERVICES } 352 if iotc_streq(v, "scan" as *u8) == 1 { return IOTC_V_SCAN } 353 if iotc_streq(v, "probe" as *u8) == 1 { return IOTC_V_PROBE } 354 if iotc_streq(v, "contract" as *u8) == 1 { return IOTC_V_CONTRACT } 355 return IOTC_V_BAD 356} 357 358// ---- JSON emit helpers ----------------------------------------------- 359func iotc_cat(d: *u8, o: i64, s: *u8) -> i64 { 360 var i: i64 = 0 361 while s[i] != (0 as u8) { d[o] = s[i]; o = o + 1; i = i + 1 } 362 return o 363} 364func iotc_catn(d: *u8, o: i64, v: i64) -> i64 { 365 let t: *u8 = sys_mmap(28) 366 var m: i64 = v 367 if m < 0 { d[o] = 45 as u8; o = o + 1; m = 0 - m } 368 var k: i64 = 0 369 if m == 0 { t[0] = 48 as u8; k = 1 } 370 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 371 var i: i64 = 0 372 while i < k { d[o] = t[k - 1 - i]; o = o + 1; i = i + 1 } 373 return o 374} 375func iotc_cat_ip(d: *u8, o: i64, ipv4: i64) -> i64 { 376 o = iotc_catn(d, o, (ipv4 >> 24) & 0xff) 377 d[o] = 46 as u8; o = o + 1 378 o = iotc_catn(d, o, (ipv4 >> 16) & 0xff) 379 d[o] = 46 as u8; o = o + 1 380 o = iotc_catn(d, o, (ipv4 >> 8) & 0xff) 381 d[o] = 46 as u8; o = o + 1 382 o = iotc_catn(d, o, ipv4 & 0xff) 383 return o 384} 385 386// ---- services verb --------------------------------------------------- 387func iotc_emit_services(out: *u8, m: *u8, from_conf: i64) -> i64 { 388 var o: i64 = 0 389 o = iotc_cat(out, 0, "{\"verb\":\"services\",\"source\":\"" as *u8) 390 if from_conf == 1 { o = iotc_cat(out, o, "conf" as *u8) } 391 else { o = iotc_cat(out, o, "compiled-defaults" as *u8) } 392 o = iotc_cat(out, o, "\",\"rows\":[" as *u8) 393 let n: i64 = iotm_count(m) 394 var i: i64 = 0 395 while i < n { 396 if i > 0 { o = iotc_cat(out, o, "," as *u8) } 397 o = iotc_cat(out, o, "{\"port\":" as *u8) 398 o = iotc_catn(out, o, iotm_port(m, i)) 399 o = iotc_cat(out, o, ",\"proto\":\"" as *u8) 400 o = iotc_cat(out, o, iotc_proto_name(iotm_proto(m, i))) 401 o = iotc_cat(out, o, "\",\"vendor\":\"" as *u8) 402 o = iotc_cat(out, o, iotc_vendor_name(iotm_vendor(m, i))) 403 o = iotc_cat(out, o, "\",\"kind\":\"" as *u8) 404 o = iotc_cat(out, o, iotc_kind_name(iotm_kind(m, i))) 405 o = iotc_cat(out, o, "\",\"label\":\"" as *u8) 406 o = iotc_cat(out, o, iotm_label(m, i)) 407 o = iotc_cat(out, o, "\",\"swept\":" as *u8) 408 if iotm_proto(m, i) == IOTC_PROTO_TCP { o = iotc_cat(out, o, "1" as *u8) } 409 else { o = iotc_cat(out, o, "0" as *u8) } 410 o = iotc_cat(out, o, "}" as *u8) 411 i = i + 1 412 } 413 o = iotc_cat(out, o, "],\"count\":" as *u8) 414 o = iotc_catn(out, o, n) 415 o = iotc_cat(out, o, ",\"precedence\":\"manifest order: first matching row decides host kind\"" as *u8) 416 o = iotc_cat(out, o, ",\"conf_path\":\"iot_services.conf\"}\n" as *u8) 417 return o 418} 419 420// ---- scan verb ------------------------------------------------------- 421// Sweep every TCP manifest row across the /24, OR-ing a per-host row-mask. 422// Row index i sets bit i, so kind/vendor resolution stays manifest-ordered. 423func iotc_scan(m: *u8, base24: i64, budget_per_port: i64, mask: *i64) -> i64 { 424 let ips: *i64 = sys_mmap(LSCAN_HOSTS * 8) as *i64 425 let n: i64 = iotm_count(m) 426 var swept: i64 = 0 427 var i: i64 = 0 428 while i < n { 429 if iotm_proto(m, i) == IOTC_PROTO_TCP { 430 let port: i64 = iotm_port(m, i) 431 let cnt: i64 = lscan_sweep(base24, port, budget_per_port, ips, LSCAN_HOSTS) 432 var j: i64 = 0 433 while j < cnt { 434 let h: i64 = ips[j] & 0xff 435 mask[h] = mask[h] | (1 << i) 436 j = j + 1 437 } 438 swept = swept + 1 439 } 440 i = i + 1 441 } 442 return swept 443} 444// First matching manifest row wins (order == precedence). 445func iotc_mask_row(m: *u8, bits: i64) -> i64 { 446 let n: i64 = iotm_count(m) 447 var i: i64 = 0 448 while i < n { 449 if (bits & (1 << i)) != 0 { return i } 450 i = i + 1 451 } 452 return 0 - 1 453} 454// Vendor via the SHARED classifier brain: the open port is the port signal, 455// fused through nx_iot_classify_fuse exactly as the discovery path does. No 456// second vendor table lives here. 457func iotc_mask_vendor(m: *u8, bits: i64) -> i64 { 458 let n: i64 = iotm_count(m) 459 var i: i64 = 0 460 var v: i64 = NX_IOT_VENDOR_UNKNOWN 461 while i < n { 462 if v == NX_IOT_VENDOR_UNKNOWN { 463 if (bits & (1 << i)) != 0 { 464 let byport: i64 = nx_iot_classify_by_port(iotm_port(m, i)) 465 v = nx_iot_classify_fuse(NX_IOT_VENDOR_UNKNOWN, NX_IOT_VENDOR_UNKNOWN, 466 byport, NX_IOT_VENDOR_UNKNOWN) 467 } 468 } 469 i = i + 1 470 } 471 return v 472} 473func iotc_emit_scan(out: *u8, m: *u8, prefix: *u8, base24: i64, mask: *i64, from_conf: i64) -> i64 { 474 let n: i64 = iotm_count(m) 475 var o: i64 = 0 476 o = iotc_cat(out, 0, "{\"verb\":\"scan\",\"subnet\":\"" as *u8) 477 o = iotc_cat(out, o, prefix) 478 o = iotc_cat(out, o, "\",\"manifest\":\"" as *u8) 479 if from_conf == 1 { o = iotc_cat(out, o, "conf" as *u8) } 480 else { o = iotc_cat(out, o, "compiled-defaults" as *u8) } 481 o = iotc_cat(out, o, "\",\"devices\":[" as *u8) 482 var count: i64 = 0 483 var h: i64 = 1 484 while h <= LSCAN_HOSTS { 485 let bits: i64 = mask[h] 486 if bits != 0 { 487 if count > 0 { o = iotc_cat(out, o, "," as *u8) } 488 let row: i64 = iotc_mask_row(m, bits) 489 o = iotc_cat(out, o, "{\"ip\":\"" as *u8) 490 o = iotc_cat_ip(out, o, (base24 << 8) | h) 491 o = iotc_cat(out, o, "\",\"kind\":\"" as *u8) 492 if row >= 0 { o = iotc_cat(out, o, iotc_kind_name(iotm_kind(m, row))) } 493 else { o = iotc_cat(out, o, "device" as *u8) } 494 o = iotc_cat(out, o, "\",\"vendor\":\"" as *u8) 495 o = iotc_cat(out, o, iotc_vendor_name(iotc_mask_vendor(m, bits))) 496 o = iotc_cat(out, o, "\",\"ports\":[" as *u8) 497 var pi: i64 = 0 498 var np: i64 = 0 499 while pi < n { 500 if (bits & (1 << pi)) != 0 { 501 if np > 0 { o = iotc_cat(out, o, "," as *u8) } 502 o = iotc_catn(out, o, iotm_port(m, pi)) 503 np = np + 1 504 } 505 pi = pi + 1 506 } 507 o = iotc_cat(out, o, "]}" as *u8) 508 count = count + 1 509 } 510 h = h + 1 511 } 512 o = iotc_cat(out, o, "],\"count\":" as *u8) 513 o = iotc_catn(out, o, count) 514 // HONEST BLIND-SPOT DECLARATION -- a TCP connect sweep structurally cannot 515 // see a UDP-only service, so name them rather than imply they are absent. 516 o = iotc_cat(out, o, ",\"udp_ports_unswept\":[" as *u8) 517 var ui: i64 = 0 518 var un: i64 = 0 519 while ui < n { 520 if iotm_proto(m, ui) == IOTC_PROTO_UDP { 521 if un > 0 { o = iotc_cat(out, o, "," as *u8) } 522 o = iotc_catn(out, o, iotm_port(m, ui)) 523 un = un + 1 524 } 525 ui = ui + 1 526 } 527 o = iotc_cat(out, o, "],\"blind_spot\":\"" as *u8) 528 if un > 0 { 529 o = iotc_cat(out, o, "TCP connect sweep cannot reach UDP-only services; absence of those vendors here is NOT evidence of absence" as *u8) 530 } else { 531 o = iotc_cat(out, o, "none: every manifest row is TCP and was swept" as *u8) 532 } 533 o = iotc_cat(out, o, "\"}\n" as *u8) 534 return o 535} 536 537// ---- probe verb (READ-ONLY active identification) -------------------- 538// Kasa get_sysinfo over TCP 9999: frame + autokey-encrypt the request through 539// the shipped nx_iot_local_kasa primitives, read the reply, deframe+decrypt, 540// and classify by REPLY -- the strongest signal in the fused router (the device 541// literally told us what it is). Returns decrypted byte count, or negative: 542// -1 connect failed -2 send failed -3 no reply -4 deframe failed 543func iotc_kasa_probe(ipv4: i64, port: i64, plain: *u8, plain_cap: i64) -> i64 { 544 let req: *u8 = sys_mmap(IOTC_SCRATCH) 545 let reqn: i64 = nx_iot_kasa_pt_sysinfo(req, IOTC_SCRATCH) 546 if reqn < 0 { return 0 - 2 } 547 let frame: *u8 = sys_mmap(IOTC_SCRATCH) 548 let vp: *i64 = sys_mmap(8) as *i64 549 vp[0] = 0 550 let fn2: i64 = nx_iot_kasa_frame_tcp(req, reqn, frame, IOTC_SCRATCH, vp) 551 if fn2 < 0 { return 0 - 2 } 552 let fd: i64 = nx_mr_tcp_connect_ipv4(ipv4, port) 553 if fd < 0 { return 0 - 1 } 554 let sent: i64 = sys_write(fd, frame, fn2) 555 if sent < 0 { sys_close(fd); return 0 - 2 } 556 let resp: *u8 = sys_mmap(IOTC_RESP) 557 let rn: i64 = sys_read(fd, resp, IOTC_RESP) 558 sys_close(fd) 559 if rn <= 0 { return 0 - 3 } 560 vp[0] = 0 561 let pn: i64 = nx_iot_kasa_deframe_tcp(resp, rn, plain, plain_cap, vp) 562 if pn < 0 { return 0 - 4 } 563 return pn 564} 565func iotc_probe_err(code: i64) -> *u8 { 566 if code == (0 - 1) { return "NETWORK_ERR" as *u8 } 567 if code == (0 - 2) { return "SEND_FAIL" as *u8 } 568 if code == (0 - 3) { return "NO_REPLY" as *u8 } 569 if code == (0 - 4) { return "BAD_FRAME" as *u8 } 570 return "UNKNOWN" as *u8 571} 572func iotc_emit_probe(out: *u8, ipv4: i64, port: i64, pn: i64, plain: *u8) -> i64 { 573 var o: i64 = 0 574 o = iotc_cat(out, 0, "{\"verb\":\"probe\",\"ip\":\"" as *u8) 575 o = iotc_cat_ip(out, o, ipv4) 576 o = iotc_cat(out, o, "\",\"port\":" as *u8) 577 o = iotc_catn(out, o, port) 578 if pn < 0 { 579 o = iotc_cat(out, o, ",\"result\":\"" as *u8) 580 o = iotc_cat(out, o, iotc_probe_err(pn)) 581 o = iotc_cat(out, o, "\",\"vendor\":\"unknown\",\"signal\":\"none\"}\n" as *u8) 582 return o 583 } 584 let byreply: i64 = nx_iot_classify_by_reply(plain, pn) 585 let byport: i64 = nx_iot_classify_by_port(port) 586 let fused: i64 = nx_iot_classify_fuse(byreply, NX_IOT_VENDOR_UNKNOWN, byport, NX_IOT_VENDOR_UNKNOWN) 587 o = iotc_cat(out, o, ",\"result\":\"OK\",\"bytes\":" as *u8) 588 o = iotc_catn(out, o, pn) 589 o = iotc_cat(out, o, ",\"vendor\":\"" as *u8) 590 o = iotc_cat(out, o, iotc_vendor_name(fused)) 591 o = iotc_cat(out, o, "\",\"signal\":\"" as *u8) 592 if byreply != NX_IOT_VENDOR_UNKNOWN { o = iotc_cat(out, o, "reply" as *u8) } 593 else { o = iotc_cat(out, o, "port" as *u8) } 594 o = iotc_cat(out, o, "\",\"sysinfo\":" as *u8) 595 o = iotc_catn(out, o, nx_iot_kasa_looks_like_sysinfo(plain, pn)) 596 o = iotc_cat(out, o, ",\"probe\":\"read-only get_sysinfo; no device state written\"}\n" as *u8) 597 return o 598} 599 600// ---- contract verb (no network) -------------------------------------- 601func iotc_emit_contract(out: *u8, m: *u8, from_conf: i64) -> i64 { 602 var o: i64 = 0 603 o = iotc_cat(out, 0, "{\"verb\":\"contract\",\"capability\":\"nx_iot_ctl\"" as *u8) 604 o = iotc_cat(out, o, ",\"purpose\":\"sovereign home-electronics identification on the isolated IoT LAN\"" as *u8) 605 o = iotc_cat(out, o, ",\"verbs\":[\"services\",\"scan\",\"probe\",\"contract\"]" as *u8) 606 o = iotc_cat(out, o, ",\"never_brick\":\"IDENTIFICATION ONLY -- no write verb exists. The single active probe is Kasa get_sysinfo (a READ). No set_stainfo credential push, no OTA/firmware path, no raw command passthrough: a device cannot be altered through any code path in this organ.\"" as *u8) 607 o = iotc_cat(out, o, ",\"data_driven\":\"port/proto/vendor/kind rows load from iot_services.conf; compiled-in defaults are the fail-safe when it is absent or malformed\"" as *u8) 608 o = iotc_cat(out, o, ",\"manifest_source\":\"" as *u8) 609 if from_conf == 1 { o = iotc_cat(out, o, "conf" as *u8) } 610 else { o = iotc_cat(out, o, "compiled-defaults" as *u8) } 611 o = iotc_cat(out, o, "\",\"manifest_rows\":" as *u8) 612 o = iotc_catn(out, o, iotm_count(m)) 613 o = iotc_cat(out, o, ",\"honest_limits\":\"a TCP connect sweep cannot observe UDP-only services (WiZ 38899); every scan names them in udp_ports_unswept rather than implying absence\"" as *u8) 614 o = iotc_cat(out, o, ",\"shared_scan\":\"lscan_sweep (nx_lan_scan) is the ONE sweep primitive, shared with nx_printer_ctl so both organs agree about the same LAN\"" as *u8) 615 o = iotc_cat(out, o, ",\"classifier\":\"nx_iot_classify fused router (reply > OUI > port > SSID)\"" as *u8) 616 o = iotc_cat(out, o, ",\"zero_external_exposure\":\"BY_CONSTRUCTION: runs on the NAS inside the home LAN; devices never see the WAN; only the cap-gated organ reaches them\"}\n" as *u8) 617 return o 618}