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}