code wiki / _hdl_build / nx_printer_ctl_lib.nx
nx_printer_ctl_lib.nx source
↩ module page · 836 lines · 36013 B
1// nx_printer_ctl_lib.nx -- sovereign 3D-printer / home-IoT control LIB (no main).
2//
3// The reusable core behind nx_printer_ctl (CLI organ) + nx_printer_ctl_gate.
4// Composes the shipped Moonraker primitives (nx_moonraker_client request
5// builders + nx_moonraker_io arbitrary-IPv4 roundtrip) into a cap-gated,
6// JSON-emitting device-control surface that the sovereign edge reaches over
7// the LAN.
8//
9// ARCHITECTURE (the operator's browser->edge->mTLS-home->printer design,
10// realized sovereignly): the sovereign edge (sites.elf :8443) already does
11// OPAQUE cap-token auth + reverse-proxy. This organ is the "home gateway"
12// leg -- BUT the NAS that runs the edge is ITSELF on the home LAN, so the
13// organ reaches the printer at <lan-ip>:7125 directly. The printer never
14// sees the WAN; only the cap-gated organ ever touches it => "zero external
15// exposure" holds BY CONSTRUCTION, no Node/nginx/WAN-mTLS-tunnel required.
16//
17// NEVER-BRICK (CLAUDE rule 26, ABSOLUTE): every control verb maps to a FIXED
18// allowlisted Moonraker endpoint (info / emergency_stop / print pause|resume
19// |cancel). There is NO raw-G-code passthrough, so no path can ever carry a
20// firmware-flash / EEPROM-write G-code (M500/M502/etc.). emergency_stop is a
21// Klipper RUNTIME MCU halt (recovered by a soft FIRMWARE_RESTART, RAM-only) --
22// it writes NO persistent hardware state. The safe-endpoint allowlist is
23// proven mechanically by nx_printer_ctl_gate (a control path outside the
24// allowlist => gate RED).
25//
26// license_tier: ORIGINAL No hw writes (Rule 26).
27import "nx_syscalls.nx"
28import "nx_moonraker_client.nx"
29import "nx_moonraker_io.nx"
30import "nx_lan_scan.nx"
31
32const PCTL_SCRATCH: i64 = 8192
33const PCTL_RESP: i64 = 16384
34const PCTL_OUT: i64 = 32768
35
36// verb ids
37const PCTL_V_INFO: i64 = 1
38const PCTL_V_ESTOP: i64 = 2
39const PCTL_V_PAUSE: i64 = 3
40const PCTL_V_RESUME: i64 = 4
41const PCTL_V_CANCEL: i64 = 5
42const PCTL_V_CONTRACT: i64 = 6
43const PCTL_V_STATUS: i64 = 7
44const PCTL_V_READY: i64 = 8
45const PCTL_V_DISCOVER: i64 = 9
46const PCTL_V_SURVEY: i64 = 10
47const PCTL_V_MONITOR: i64 = 11
48const PCTL_V_FILES: i64 = 12
49const PCTL_V_PROGRESS: i64 = 13
50const PCTL_V_PRINTSTART: i64 = 14
51const PCTL_V_UPLOAD: i64 = 15
52const PCTL_V_BAD: i64 = 0
53const PCTL_TEMP_NONE: i64 = 0 - 32768
54const PCTL_SOCK_NONBLOCK: i64 = 2048 // SOCK_NONBLOCK
55const PCTL_POLLOUT: i64 = 4
56const PCTL_POLLERR: i64 = 8
57const PCTL_POLLHUP: i64 = 16
58// home-device survey port profile (curated; each port scanned across the /24).
59// Port index -> bit in the per-host mask; classification derives from the mask.
60const PCTL_SURVEY_NPORTS: i64 = 4
61
62// ===== small utils =================================================
63func pctl_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
64func pctl_streq(a: *u8, b: *u8) -> i64 {
65 var i: i64 = 0
66 while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 }
67 if b[i] != (0 as u8) { return 0 }
68 return 1
69}
70func pctl_cat(d: *u8, o: i64, s: *u8) -> i64 { var i: i64 = 0; while s[i] != (0 as u8) { d[o] = s[i]; o = o + 1; i = i + 1 } return o }
71func pctl_catn(d: *u8, o: i64, v: i64) -> i64 {
72 let t: *u8 = sys_mmap(28)
73 var m: i64 = v
74 if m < 0 { d[o] = 45 as u8; o = o + 1; m = 0 - m }
75 var k: i64 = 0
76 if m == 0 { t[0] = 48 as u8; k = 1 }
77 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
78 var i: i64 = 0
79 while i < k { d[o] = t[k-1-i]; o = o + 1; i = i + 1 }
80 return o
81}
82// escape body bytes [s,e) into a JSON string value: " -> ', \ -> /, ctrl -> space
83func pctl_cat_esc(d: *u8, o: i64, q: *u8, s: i64, e: i64) -> i64 {
84 var i: i64 = s
85 while i < e {
86 var c: i64 = q[i] as i64
87 c = c & 0xff
88 if c == 34 { c = 39 }
89 if c == 92 { c = 47 }
90 if c < 32 { c = 32 }
91 d[o] = c as u8
92 o = o + 1
93 i = i + 1
94 }
95 return o
96}
97
98// ===== dotted-IPv4 parse -> packed i64 (matches nx_mr_pack_ipv4) ====
99// "192.168.1.42" -> (192<<24)|(168<<16)|(1<<8)|42 ; returns -1 on malformed.
100func pctl_parse_ip(s: *u8) -> i64 {
101 var val: i64 = 0
102 var ndig: i64 = 0
103 var oct: i64 = 0
104 var packed: i64 = 0
105 var i: i64 = 0
106 var done: i64 = 0
107 while done == 0 {
108 let c: i64 = s[i] & 0xff
109 if c == 0 {
110 done = 1
111 } else {
112 if c == 46 {
113 if ndig == 0 { return 0 - 1 }
114 if val > 255 { return 0 - 1 }
115 packed = (packed << 8) | val
116 oct = oct + 1
117 val = 0
118 ndig = 0
119 i = i + 1
120 } else {
121 if c < 48 { return 0 - 1 }
122 if c > 57 { return 0 - 1 }
123 val = val * 10 + (c - 48)
124 ndig = ndig + 1
125 i = i + 1
126 }
127 }
128 }
129 if ndig == 0 { return 0 - 1 }
130 if val > 255 { return 0 - 1 }
131 packed = (packed << 8) | val
132 oct = oct + 1
133 if oct != 4 { return 0 - 1 }
134 return packed
135}
136
137// parse a positive decimal string -> value, or -1 on any non-digit / empty
138func pctl_parse_port(s: *u8) -> i64 {
139 var v: i64 = 0
140 var i: i64 = 0
141 if s[0] == (0 as u8) { return 0 - 1 }
142 while s[i] != (0 as u8) {
143 let c: i64 = s[i] & 0xff
144 if c < 48 { return 0 - 1 }
145 if c > 57 { return 0 - 1 }
146 v = v * 10 + (c - 48)
147 i = i + 1
148 }
149 if v < 1 { return 0 - 1 }
150 if v > 65535 { return 0 - 1 }
151 return v
152}
153
154// build the "a.b.c.d:port" Host header value into host buf; returns length
155func pctl_build_host(ip: *u8, port: i64, host: *u8) -> i64 {
156 var o: i64 = 0
157 o = pctl_cat(host, o, ip)
158 host[o] = 58 as u8
159 o = o + 1
160 o = pctl_catn(host, o, port)
161 host[o] = 0 as u8
162 return o
163}
164
165// ===== verb name -> id + safe-endpoint allowlist (NEVER-BRICK) =====
166// Returns the FIXED Moonraker path for a control verb. Any verb not in this
167// map returns NULL -- there is no way to reach an arbitrary path, so no
168// firmware-write G-code can ever be routed. This IS the never-brick guarantee.
169func pctl_verb_id(v: *u8) -> i64 {
170 if pctl_streq(v, "info" as *u8) == 1 { return PCTL_V_INFO }
171 if pctl_streq(v, "estop" as *u8) == 1 { return PCTL_V_ESTOP }
172 if pctl_streq(v, "pause" as *u8) == 1 { return PCTL_V_PAUSE }
173 if pctl_streq(v, "resume" as *u8) == 1 { return PCTL_V_RESUME }
174 if pctl_streq(v, "cancel" as *u8) == 1 { return PCTL_V_CANCEL }
175 if pctl_streq(v, "contract" as *u8) == 1 { return PCTL_V_CONTRACT }
176 if pctl_streq(v, "status" as *u8) == 1 { return PCTL_V_STATUS }
177 if pctl_streq(v, "ready" as *u8) == 1 { return PCTL_V_READY }
178 if pctl_streq(v, "discover" as *u8) == 1 { return PCTL_V_DISCOVER }
179 if pctl_streq(v, "survey" as *u8) == 1 { return PCTL_V_SURVEY }
180 if pctl_streq(v, "monitor" as *u8) == 1 { return PCTL_V_MONITOR }
181 if pctl_streq(v, "files" as *u8) == 1 { return PCTL_V_FILES }
182 if pctl_streq(v, "progress" as *u8) == 1 { return PCTL_V_PROGRESS }
183 if pctl_streq(v, "printstart" as *u8) == 1 { return PCTL_V_PRINTSTART }
184 if pctl_streq(v, "upload" as *u8) == 1 { return PCTL_V_UPLOAD }
185 return PCTL_V_BAD
186}
187func pctl_verb_is_get(id: i64) -> i64 {
188 if id == PCTL_V_INFO { return 1 }
189 if id == PCTL_V_STATUS { return 1 }
190 if id == PCTL_V_READY { return 1 }
191 if id == PCTL_V_FILES { return 1 }
192 if id == PCTL_V_PROGRESS { return 1 }
193 return 0
194}
195// the ONLY paths this organ can ever emit (safe control surface allowlist)
196func pctl_verb_path(id: i64) -> *u8 {
197 if id == PCTL_V_INFO { return "/printer/info" as *u8 }
198 if id == PCTL_V_ESTOP { return "/printer/emergency_stop" as *u8 }
199 if id == PCTL_V_PAUSE { return "/printer/print/pause" as *u8 }
200 if id == PCTL_V_RESUME { return "/printer/print/resume" as *u8 }
201 if id == PCTL_V_CANCEL { return "/printer/print/cancel" as *u8 }
202 if id == PCTL_V_STATUS { return "/printer/objects/query?extruder=temperature,target&heater_bed=temperature,target" as *u8 }
203 if id == PCTL_V_READY { return "/printer/info" as *u8 }
204 if id == PCTL_V_FILES { return "/server/files/list" as *u8 }
205 if id == PCTL_V_PROGRESS { return "/printer/objects/query?print_stats&virtual_sdcard&display_status" as *u8 }
206 if id == PCTL_V_PRINTSTART { return "/printer/print/start" as *u8 }
207 return "\x00" as *u8
208}
209func pctl_verb_name(id: i64) -> *u8 {
210 if id == PCTL_V_INFO { return "info" as *u8 }
211 if id == PCTL_V_ESTOP { return "estop" as *u8 }
212 if id == PCTL_V_PAUSE { return "pause" as *u8 }
213 if id == PCTL_V_RESUME { return "resume" as *u8 }
214 if id == PCTL_V_CANCEL { return "cancel" as *u8 }
215 if id == PCTL_V_CONTRACT { return "contract" as *u8 }
216 if id == PCTL_V_STATUS { return "status" as *u8 }
217 if id == PCTL_V_READY { return "ready" as *u8 }
218 if id == PCTL_V_DISCOVER { return "discover" as *u8 }
219 if id == PCTL_V_SURVEY { return "survey" as *u8 }
220 if id == PCTL_V_MONITOR { return "monitor" as *u8 }
221 if id == PCTL_V_FILES { return "files" as *u8 }
222 if id == PCTL_V_PROGRESS { return "progress" as *u8 }
223 if id == PCTL_V_PRINTSTART { return "printstart" as *u8 }
224 if id == PCTL_V_UPLOAD { return "upload" as *u8 }
225 return "bad" as *u8
226}
227
228// ===== response body-start (after "\r\n\r\n") ======================
229func pctl_body_start(resp: *u8, n: i64) -> i64 {
230 var i: i64 = 0
231 while i + 3 < n {
232 if resp[i] == (13 as u8) { if resp[i+1] == (10 as u8) { if resp[i+2] == (13 as u8) { if resp[i+3] == (10 as u8) { return i + 4 } } } }
233 i = i + 1
234 }
235 return n
236}
237
238// ===== the composed network call (SAME path CLI + gate both use) ===
239// Performs the verb's Moonraker roundtrip against ip:port. Returns the HTTP
240// status code (>=100), or a negative nx_moonraker_io verdict on transport
241// failure. Fills resp[0..out] with the raw response; caller reads n via *rn.
242func pctl_call(id: i64, ipv4: i64, port: i64,
243 host: *u8, host_len: i64,
244 api_key: *u8, api_key_len: i64,
245 scratch: *u8, resp: *u8, rn: *i64) -> i64 {
246 let path: *u8 = pctl_verb_path(id)
247 let plen: i64 = pctl_slen(path)
248 var n: i64 = 0
249 if pctl_verb_is_get(id) == 1 {
250 n = nx_mr_get_ipv4(ipv4, port, host, host_len, path, plen, api_key, api_key_len, scratch, PCTL_SCRATCH, resp, PCTL_RESP)
251 } else {
252 let empty: *u8 = "\x00" as *u8
253 n = nx_mr_post_ipv4(ipv4, port, host, host_len, path, plen, empty, 0, api_key, api_key_len, scratch, PCTL_SCRATCH, resp, PCTL_RESP)
254 }
255 rn[0] = n
256 if n <= 0 { return n }
257 let st: i64 = nx_mr_parse_status(resp, n)
258 return st
259}
260
261// ===== JSON result emit for a network verb =========================
262// {"verb":"..","target":"ip:port","http_status":N,"result":"..","body_excerpt":".."}
263func pctl_emit_result(out: *u8, id: i64, host: *u8, status: i64, resp: *u8, n: i64) -> i64 {
264 var o: i64 = 0
265 o = pctl_cat(out, o, "{\"verb\":\"" as *u8)
266 o = pctl_cat(out, o, pctl_verb_name(id))
267 o = pctl_cat(out, o, "\",\"target\":\"" as *u8)
268 o = pctl_cat(out, o, host)
269 o = pctl_cat(out, o, "\",\"http_status\":" as *u8)
270 o = pctl_catn(out, o, status)
271 o = pctl_cat(out, o, ",\"result\":\"" as *u8)
272 if status >= 200 { if status < 300 { o = pctl_cat(out, o, "OK" as *u8) } }
273 if status >= 300 { o = pctl_cat(out, o, "HTTP_ERROR" as *u8) }
274 if status < 200 { if status >= 100 { o = pctl_cat(out, o, "HTTP_INFO" as *u8) } }
275 if status <= 0 { o = pctl_cat(out, o, "NETWORK_ERR" as *u8) }
276 if status > 0 { if status < 100 { o = pctl_cat(out, o, "BAD_RESPONSE" as *u8) } }
277 o = pctl_cat(out, o, "\",\"body_excerpt\":\"" as *u8)
278 if n > 0 {
279 let bs: i64 = pctl_body_start(resp, n)
280 var be: i64 = bs + 240
281 if be > n { be = n }
282 o = pctl_cat_esc(out, o, resp, bs, be)
283 }
284 o = pctl_cat(out, o, "\"}\n" as *u8)
285 return o
286}
287
288// ===== lean telemetry parse (mirrors nx_moonraker_telemetry_parse; self-contained =====
289// so the build closure stays minimal for a robust NAS build) ========================
290func pctl_find(body: *u8, n: i64, start: i64, needle: *u8) -> i64 {
291 var nl: i64 = 0
292 while needle[nl] != (0 as u8) { nl = nl + 1 }
293 if nl == 0 { return start }
294 if start < 0 { return 0 - 1 }
295 var i: i64 = start
296 let stop: i64 = n - nl
297 while i <= stop {
298 var j: i64 = 0
299 var m: i64 = 1
300 while j < nl {
301 if body[i + j] != needle[j] { m = 0; j = nl } else { j = j + 1 }
302 }
303 if m == 1 { return i }
304 i = i + 1
305 }
306 return 0 - 1
307}
308// skip ws, optional '-', digits; truncate at first non-digit; PCTL_TEMP_NONE if none
309func pctl_parse_int_deg(body: *u8, off: i64, n: i64) -> i64 {
310 var q: i64 = off
311 var sk: i64 = 1
312 while sk == 1 {
313 if q >= n { sk = 0 } else {
314 let c: i64 = body[q] & 0xff
315 if c == 32 { q = q + 1 } else {
316 if c == 9 { q = q + 1 } else {
317 if c == 10 { q = q + 1 } else {
318 if c == 13 { q = q + 1 } else { sk = 0 }
319 }
320 }
321 }
322 }
323 }
324 if q >= n { return PCTL_TEMP_NONE }
325 var neg: i64 = 0
326 if body[q] == (45 as u8) { neg = 1; q = q + 1 }
327 var have: i64 = 0
328 var v: i64 = 0
329 var rd: i64 = 1
330 while rd == 1 {
331 if q >= n { rd = 0 } else {
332 let d: i64 = body[q] & 0xff
333 if d < 48 { rd = 0 } else {
334 if d > 57 { rd = 0 } else {
335 v = v * 10 + (d - 48)
336 have = 1
337 q = q + 1
338 }
339 }
340 }
341 }
342 if have == 0 { return PCTL_TEMP_NONE }
343 if neg == 1 { return 0 - v }
344 return v
345}
346// find object key (e.g. "\"extruder\":") then field (e.g. "\"temperature\":") after it -> int
347func pctl_temp_in(body: *u8, n: i64, objkey: *u8, field: *u8) -> i64 {
348 let os: i64 = pctl_find(body, n, 0, objkey)
349 if os < 0 { return PCTL_TEMP_NONE }
350 let fs: i64 = pctl_find(body, n, os, field)
351 if fs < 0 { return PCTL_TEMP_NONE }
352 let fl: i64 = pctl_slen(field)
353 return pctl_parse_int_deg(body, fs + fl, n)
354}
355func pctl_cat_num_or_null(d: *u8, o: i64, v: i64) -> i64 {
356 if v == PCTL_TEMP_NONE { return pctl_cat(d, o, "null" as *u8) }
357 return pctl_catn(d, o, v)
358}
359func pctl_ready_flag(body: *u8, n: i64) -> i64 {
360 if pctl_find(body, n, 0, "\"state\":\"ready\"" as *u8) >= 0 { return 1 }
361 if pctl_find(body, n, 0, "\"state\": \"ready\"" as *u8) >= 0 { return 1 }
362 return 0
363}
364func pctl_state_of(body: *u8, n: i64, rdy: i64) -> *u8 {
365 if rdy == 1 { return "ready" as *u8 }
366 if pctl_find(body, n, 0, "shutdown" as *u8) >= 0 { return "shutdown" as *u8 }
367 if pctl_find(body, n, 0, "error" as *u8) >= 0 { return "error" as *u8 }
368 if pctl_find(body, n, 0, "startup" as *u8) >= 0 { return "startup" as *u8 }
369 return "unknown" as *u8
370}
371func pctl_cat_netresult(d: *u8, o: i64, status: i64) -> i64 {
372 if status <= 0 { return pctl_cat(d, o, "NETWORK_ERR" as *u8) }
373 if status >= 200 { if status < 300 { return pctl_cat(d, o, "OK" as *u8) } }
374 return pctl_cat(d, o, "HTTP_ERROR" as *u8)
375}
376// ===== status verb: parsed hotend/bed temps + targets ==============
377func pctl_emit_status(out: *u8, host: *u8, status: i64, resp: *u8, n: i64) -> i64 {
378 var o: i64 = 0
379 o = pctl_cat(out, o, "{\"verb\":\"status\",\"target\":\"" as *u8)
380 o = pctl_cat(out, o, host)
381 o = pctl_cat(out, o, "\",\"http_status\":" as *u8)
382 o = pctl_catn(out, o, status)
383 var ha: i64 = PCTL_TEMP_NONE
384 var ht: i64 = PCTL_TEMP_NONE
385 var ba: i64 = PCTL_TEMP_NONE
386 var bt: i64 = PCTL_TEMP_NONE
387 if n > 0 { if status >= 200 { if status < 300 {
388 let bs: i64 = pctl_body_start(resp, n)
389 let bp: *u8 = ((resp as i64) + bs) as *u8
390 let bn: i64 = n - bs
391 ha = pctl_temp_in(bp, bn, "\"extruder\":" as *u8, "\"temperature\":" as *u8)
392 ht = pctl_temp_in(bp, bn, "\"extruder\":" as *u8, "\"target\":" as *u8)
393 ba = pctl_temp_in(bp, bn, "\"heater_bed\":" as *u8, "\"temperature\":" as *u8)
394 bt = pctl_temp_in(bp, bn, "\"heater_bed\":" as *u8, "\"target\":" as *u8)
395 } } }
396 o = pctl_cat(out, o, ",\"hotend\":{\"actual_c\":" as *u8)
397 o = pctl_cat_num_or_null(out, o, ha)
398 o = pctl_cat(out, o, ",\"target_c\":" as *u8)
399 o = pctl_cat_num_or_null(out, o, ht)
400 o = pctl_cat(out, o, "},\"bed\":{\"actual_c\":" as *u8)
401 o = pctl_cat_num_or_null(out, o, ba)
402 o = pctl_cat(out, o, ",\"target_c\":" as *u8)
403 o = pctl_cat_num_or_null(out, o, bt)
404 o = pctl_cat(out, o, "},\"result\":\"" as *u8)
405 o = pctl_cat_netresult(out, o, status)
406 o = pctl_cat(out, o, "\"}\n" as *u8)
407 return o
408}
409// ===== ready verb: preflight klippy state ==========================
410func pctl_emit_ready(out: *u8, host: *u8, status: i64, resp: *u8, n: i64) -> i64 {
411 var o: i64 = 0
412 o = pctl_cat(out, o, "{\"verb\":\"ready\",\"target\":\"" as *u8)
413 o = pctl_cat(out, o, host)
414 o = pctl_cat(out, o, "\",\"http_status\":" as *u8)
415 o = pctl_catn(out, o, status)
416 var rdy: i64 = 0
417 var state: *u8 = "unknown" as *u8
418 if n > 0 { if status >= 200 { if status < 300 {
419 let bs: i64 = pctl_body_start(resp, n)
420 let bp: *u8 = ((resp as i64) + bs) as *u8
421 let bn: i64 = n - bs
422 rdy = pctl_ready_flag(bp, bn)
423 state = pctl_state_of(bp, bn, rdy)
424 } } }
425 o = pctl_cat(out, o, ",\"ready\":" as *u8)
426 o = pctl_catn(out, o, rdy)
427 o = pctl_cat(out, o, ",\"klippy_state\":\"" as *u8)
428 o = pctl_cat(out, o, state)
429 o = pctl_cat(out, o, "\",\"result\":\"" as *u8)
430 o = pctl_cat_netresult(out, o, status)
431 o = pctl_cat(out, o, "\"}\n" as *u8)
432 return o
433}
434
435// ===== discover: sovereign LAN sweep for Moonraker printers ========
436//
437// DRY (rule 15): the sweep itself now lives in nx_lan_scan and is SHARED with
438// nx_iot_ctl. It moved because the two organs must AGREE about the same LAN --
439// with two copies, one gets a fix and the other silently does not, and they
440// then answer "is host H open on port P" differently while both look healthy.
441// That is not hypothetical: on 2026-07-25 the printer survey reported 5 hosts
442// on 192.168.8 while the new IoT scan reported 6, minutes apart.
443//
444// These remain as thin wrappers so every existing caller and the 14-tooth
445// nx_printer_ctl_gate keep working unchanged (rule 19: never break a contract).
446func pctl_parse_prefix(s: *u8) -> i64 { return lscan_parse_prefix(s) }
447// non-blocking TCP connect probe: 1 if <ipv4>:<port> accepts, else 0
448func pctl_probe_host(ipv4: i64, port: i64, timeout_ms: i64) -> i64 {
449 return lscan_probe_host(ipv4, port, timeout_ms)
450}
451// scan base24.1 .. base24.254 on <port>; fill out_ips with packed found; return count.
452// BATCHED (scale-law): fire all 254 non-blocking connects, then ONE poll over the whole
453// set -> O(one timeout) not O(N x timeout), so a full /24 sweep fits an MCP call window.
454func pctl_discover_scan(base24: i64, port: i64, timeout_ms: i64, out_ips: *i64, max: i64) -> i64 {
455 return lscan_sweep(base24, port, timeout_ms, out_ips, max)
456}
457func pctl_cat_ip(d: *u8, o: i64, ipv4: i64) -> i64 {
458 o = pctl_catn(d, o, (ipv4 >> 24) & 0xff)
459 d[o] = 46 as u8
460 o = o + 1
461 o = pctl_catn(d, o, (ipv4 >> 16) & 0xff)
462 d[o] = 46 as u8
463 o = o + 1
464 o = pctl_catn(d, o, (ipv4 >> 8) & 0xff)
465 d[o] = 46 as u8
466 o = o + 1
467 o = pctl_catn(d, o, ipv4 & 0xff)
468 return o
469}
470func pctl_emit_discover(out: *u8, prefix: *u8, port: i64, ips: *i64, count: i64) -> i64 {
471 var o: i64 = 0
472 o = pctl_cat(out, o, "{\"verb\":\"discover\",\"subnet\":\"" as *u8)
473 o = pctl_cat(out, o, prefix)
474 o = pctl_cat(out, o, "\",\"port\":" as *u8)
475 o = pctl_catn(out, o, port)
476 o = pctl_cat(out, o, ",\"found\":[" as *u8)
477 var i: i64 = 0
478 while i < count {
479 if i > 0 { o = pctl_cat(out, o, "," as *u8) }
480 o = pctl_cat(out, o, "\"" as *u8)
481 o = pctl_cat_ip(out, o, ips[i])
482 o = pctl_cat(out, o, "\"" as *u8)
483 i = i + 1
484 }
485 o = pctl_cat(out, o, "],\"count\":" as *u8)
486 o = pctl_catn(out, o, count)
487 o = pctl_cat(out, o, "}\n" as *u8)
488 return o
489}
490
491// ===== survey: home-device inventory (multi-port classify) =========
492// bit0=7125 Moonraker, bit1=10088 Fluidd, bit2=80 HTTP, bit3=443 HTTPS
493func pctl_survey_port(pi: i64) -> i64 {
494 if pi == 0 { return 7125 }
495 if pi == 1 { return 10088 }
496 if pi == 2 { return 80 }
497 if pi == 3 { return 443 }
498 return 0
499}
500// scan each survey port across the /24, OR-ing a per-host port-mask.
501// Reuses the proven batched poll-until-resolved scan (each port = <=254 sockets, fd-safe).
502func pctl_survey_scan(base24: i64, budget_per_port: i64, mask: *i64) -> i64 {
503 let ips: *i64 = sys_mmap(254 * 8) as *i64
504 var pi: i64 = 0
505 while pi < PCTL_SURVEY_NPORTS {
506 let port: i64 = pctl_survey_port(pi)
507 let cnt: i64 = pctl_discover_scan(base24, port, budget_per_port, ips, 254)
508 var j: i64 = 0
509 while j < cnt {
510 let h: i64 = ips[j] & 0xff
511 let m: i64 = mask[h]
512 mask[h] = m | (1 << pi)
513 j = j + 1
514 }
515 pi = pi + 1
516 }
517 return 0
518}
519func pctl_survey_kind(bits: i64) -> *u8 {
520 if (bits & 1) != 0 { return "3d-printer" as *u8 }
521 if (bits & 2) != 0 { return "3d-printer" as *u8 }
522 if (bits & 4) != 0 { return "web-device" as *u8 }
523 if (bits & 8) != 0 { return "web-device" as *u8 }
524 return "device" as *u8
525}
526func pctl_emit_survey(out: *u8, prefix: *u8, base24: i64, mask: *i64) -> i64 {
527 var o: i64 = 0
528 o = pctl_cat(out, o, "{\"verb\":\"survey\",\"subnet\":\"" as *u8)
529 o = pctl_cat(out, o, prefix)
530 o = pctl_cat(out, o, "\",\"devices\":[" as *u8)
531 var count: i64 = 0
532 var h: i64 = 1
533 while h <= 254 {
534 let bits: i64 = mask[h]
535 if bits != 0 {
536 if count > 0 { o = pctl_cat(out, o, "," as *u8) }
537 o = pctl_cat(out, o, "{\"ip\":\"" as *u8)
538 o = pctl_cat_ip(out, o, (base24 << 8) | h)
539 o = pctl_cat(out, o, "\",\"kind\":\"" as *u8)
540 o = pctl_cat(out, o, pctl_survey_kind(bits))
541 o = pctl_cat(out, o, "\",\"ports\":[" as *u8)
542 var pi: i64 = 0
543 var np: i64 = 0
544 while pi < PCTL_SURVEY_NPORTS {
545 if (bits & (1 << pi)) != 0 {
546 if np > 0 { o = pctl_cat(out, o, "," as *u8) }
547 o = pctl_catn(out, o, pctl_survey_port(pi))
548 np = np + 1
549 }
550 pi = pi + 1
551 }
552 o = pctl_cat(out, o, "]}" as *u8)
553 count = count + 1
554 }
555 h = h + 1
556 }
557 o = pctl_cat(out, o, "],\"count\":" as *u8)
558 o = pctl_catn(out, o, count)
559 o = pctl_cat(out, o, "}\n" as *u8)
560 return o
561}
562
563// ===== monitor: ONE sovereign call = the whole dashboard JSON =======
564// Replaces the shell beat's grep/date logic (no coreutils). discover -> if online
565// status+ready -> survey -> epoch (sovereign clock syscall) -> aggregated JSON.
566func pctl_ip_to_str(pip: i64, buf: *u8) -> i64 {
567 var o: i64 = 0
568 o = pctl_catn(buf, o, (pip >> 24) & 0xff)
569 buf[o] = 46 as u8
570 o = o + 1
571 o = pctl_catn(buf, o, (pip >> 16) & 0xff)
572 buf[o] = 46 as u8
573 o = o + 1
574 o = pctl_catn(buf, o, (pip >> 8) & 0xff)
575 buf[o] = 46 as u8
576 o = o + 1
577 o = pctl_catn(buf, o, pip & 0xff)
578 buf[o] = 0 as u8
579 return o
580}
581func pctl_build_monitor(out: *u8, subnet: *u8, base24: i64) -> i64 {
582 let ts: *i64 = sys_mmap(16) as *i64
583 sys_clock_gettime_real(ts)
584 let epoch: i64 = ts[0]
585 let ips: *i64 = sys_mmap(254 * 8) as *i64
586 let dcount: i64 = pctl_discover_scan(base24, 7125, 250, ips, 254)
587 var online: i64 = 0
588 var pip: i64 = 0
589 if dcount > 0 { online = 1; pip = ips[0] }
590 let mask: *i64 = sys_mmap(256 * 8) as *i64
591 pctl_survey_scan(base24, LSCAN_BUDGET_MS, mask)
592 var o: i64 = 0
593 o = pctl_cat(out, o, "{\"updated_epoch\":" as *u8)
594 o = pctl_catn(out, o, epoch)
595 o = pctl_cat(out, o, ",\"subnet\":\"" as *u8)
596 o = pctl_cat(out, o, subnet)
597 o = pctl_cat(out, o, "\",\"printer_ip\":\"" as *u8)
598 let ipstr: *u8 = sys_mmap(24)
599 if online == 1 {
600 pctl_ip_to_str(pip, ipstr)
601 o = pctl_cat(out, o, ipstr)
602 }
603 o = pctl_cat(out, o, "\",\"printer_online\":" as *u8)
604 if online == 1 { o = pctl_cat(out, o, "true" as *u8) } else { o = pctl_cat(out, o, "false" as *u8) }
605 o = pctl_cat(out, o, ",\"status\":" as *u8)
606 if online == 1 {
607 let host: *u8 = sys_mmap(64)
608 let hl: i64 = pctl_build_host(ipstr, 7125, host)
609 let scratch: *u8 = sys_mmap(PCTL_SCRATCH)
610 let resp: *u8 = sys_mmap(PCTL_RESP)
611 let rn: *i64 = sys_mmap(8) as *i64
612 let empty: *u8 = "\x00" as *u8
613 let sc: i64 = pctl_call(PCTL_V_STATUS, pip, 7125, host, hl, empty, 0, scratch, resp, rn)
614 let tmp: *u8 = sys_mmap(PCTL_OUT)
615 let tl: i64 = pctl_emit_status(tmp, host, sc, resp, rn[0])
616 if tl > 0 { if tmp[tl-1] == (10 as u8) { tmp[tl-1] = 0 as u8 } else { tmp[tl] = 0 as u8 } }
617 o = pctl_cat(out, o, tmp)
618 o = pctl_cat(out, o, ",\"ready\":" as *u8)
619 let resp2: *u8 = sys_mmap(PCTL_RESP)
620 let rn2: *i64 = sys_mmap(8) as *i64
621 let rc: i64 = pctl_call(PCTL_V_READY, pip, 7125, host, hl, empty, 0, scratch, resp2, rn2)
622 let tmp2: *u8 = sys_mmap(PCTL_OUT)
623 let tl2: i64 = pctl_emit_ready(tmp2, host, rc, resp2, rn2[0])
624 if tl2 > 0 { if tmp2[tl2-1] == (10 as u8) { tmp2[tl2-1] = 0 as u8 } else { tmp2[tl2] = 0 as u8 } }
625 o = pctl_cat(out, o, tmp2)
626 } else {
627 o = pctl_cat(out, o, "null,\"ready\":null" as *u8)
628 }
629 o = pctl_cat(out, o, ",\"survey\":" as *u8)
630 let sv: *u8 = sys_mmap(PCTL_OUT)
631 let svl: i64 = pctl_emit_survey(sv, subnet, base24, mask)
632 if svl > 0 { if sv[svl-1] == (10 as u8) { sv[svl-1] = 0 as u8 } else { sv[svl] = 0 as u8 } }
633 o = pctl_cat(out, o, sv)
634 o = pctl_cat(out, o, "}\n" as *u8)
635 return o
636}
637
638// ===== print job workflow: progress parse (print_stats + virtual_sdcard) ====
639// copy a JSON string value: find <anchor> then <keyq> (e.g. "\"state\":\"") after it,
640// copy chars until the closing quote (escaping \ and ctrl for our own JSON).
641func pctl_cat_jsonstr_after(out: *u8, o: i64, body: *u8, n: i64, anchor: *u8, keyq: *u8) -> i64 {
642 let ao: i64 = pctl_find(body, n, 0, anchor)
643 if ao < 0 { return o }
644 let ko: i64 = pctl_find(body, n, ao, keyq)
645 if ko < 0 { return o }
646 var i: i64 = ko + pctl_slen(keyq)
647 var go: i64 = 1
648 while go == 1 {
649 if i >= n { go = 0 } else {
650 var c: i64 = body[i] & 0xff
651 if c == 34 { go = 0 } else {
652 if c == 92 { c = 47 }
653 if c < 32 { c = 32 }
654 out[o] = c as u8
655 o = o + 1
656 i = i + 1
657 }
658 }
659 }
660 return o
661}
662// parse a Moonraker 0.0-1.0 progress float after <anchor>/<keyq> -> integer percent 0-100
663func pctl_pct_after(body: *u8, n: i64, anchor: *u8, keyq: *u8) -> i64 {
664 let ao: i64 = pctl_find(body, n, 0, anchor)
665 if ao < 0 { return 0 }
666 let ko: i64 = pctl_find(body, n, ao, keyq)
667 if ko < 0 { return 0 }
668 var i: i64 = ko + pctl_slen(keyq)
669 var sk: i64 = 1
670 while sk == 1 { if i >= n { sk = 0 } else { let c: i64 = body[i] & 0xff; if c == 32 { i = i + 1 } else { sk = 0 } } }
671 var intp: i64 = 0
672 var rd: i64 = 1
673 while rd == 1 { if i >= n { rd = 0 } else { let d: i64 = body[i] & 0xff; if d < 48 { rd = 0 } else { if d > 57 { rd = 0 } else { intp = intp * 10 + (d - 48); i = i + 1 } } } }
674 if intp >= 1 { return 100 }
675 var f: i64 = 0
676 if i < n {
677 if body[i] == (46 as u8) {
678 i = i + 1
679 if i < n {
680 let d1: i64 = body[i] & 0xff
681 if d1 >= 48 { if d1 <= 57 {
682 f = (d1 - 48) * 10
683 i = i + 1
684 if i < n { let d2: i64 = body[i] & 0xff; if d2 >= 48 { if d2 <= 57 { f = f + (d2 - 48) } } }
685 } }
686 }
687 }
688 }
689 return f
690}
691func pctl_emit_progress(out: *u8, host: *u8, status: i64, resp: *u8, n: i64) -> i64 {
692 var o: i64 = 0
693 o = pctl_cat(out, o, "{\"verb\":\"progress\",\"target\":\"" as *u8)
694 o = pctl_cat(out, o, host)
695 o = pctl_cat(out, o, "\",\"http_status\":" as *u8)
696 o = pctl_catn(out, o, status)
697 var pct: i64 = 0
698 o = pctl_cat(out, o, ",\"state\":\"" as *u8)
699 if n > 0 { if status >= 200 { if status < 300 {
700 let bs: i64 = pctl_body_start(resp, n)
701 let bp: *u8 = ((resp as i64) + bs) as *u8
702 let bn: i64 = n - bs
703 o = pctl_cat_jsonstr_after(out, o, bp, bn, "\"print_stats\"" as *u8, "\"state\":\"" as *u8)
704 pct = pctl_pct_after(bp, bn, "\"virtual_sdcard\"" as *u8, "\"progress\":" as *u8)
705 } } }
706 o = pctl_cat(out, o, "\",\"filename\":\"" as *u8)
707 if n > 0 { if status >= 200 { if status < 300 {
708 let bs2: i64 = pctl_body_start(resp, n)
709 let bp2: *u8 = ((resp as i64) + bs2) as *u8
710 let bn2: i64 = n - bs2
711 o = pctl_cat_jsonstr_after(out, o, bp2, bn2, "\"print_stats\"" as *u8, "\"filename\":\"" as *u8)
712 } } }
713 o = pctl_cat(out, o, "\",\"progress_pct\":" as *u8)
714 o = pctl_catn(out, o, pct)
715 o = pctl_cat(out, o, ",\"result\":\"" as *u8)
716 o = pctl_cat_netresult(out, o, status)
717 o = pctl_cat(out, o, "\"}\n" as *u8)
718 return o
719}
720
721// ===== printstart: start a print of a NAMED, pre-sliced file =======
722// Validated filename (safe chars only, via nx_mr_build_print_start_body); we NEVER inject
723// raw G-code -> never-brick holds. Deeper hardening rung: fetch the file + nx_klipper_gcode
724// _validator before start (reject M500/M502/firmware writes).
725func pctl_printstart(out: *u8, ipv4: i64, port: i64, host: *u8, hl: i64, fn: *u8, fnlen: i64) -> i64 {
726 let body: *u8 = sys_mmap(512)
727 let blen: i64 = nx_mr_build_print_start_body(fn, fnlen, body, 512)
728 var o: i64 = 0
729 if blen <= 0 {
730 o = pctl_cat(out, o, "{\"verb\":\"printstart\",\"result\":\"BAD_FILENAME\",\"note\":\"filename must be safe chars a-zA-Z0-9._-/\"}\n" as *u8)
731 return o
732 }
733 let scratch: *u8 = sys_mmap(PCTL_SCRATCH)
734 let resp: *u8 = sys_mmap(PCTL_RESP)
735 let empty: *u8 = "\x00" as *u8
736 let n: i64 = nx_mr_post_ipv4(ipv4, port, host, hl, "/printer/print/start" as *u8, 20, body, blen, empty, 0, scratch, PCTL_SCRATCH, resp, PCTL_RESP)
737 var status: i64 = n
738 if n > 0 { status = nx_mr_parse_status(resp, n) }
739 o = pctl_cat(out, o, "{\"verb\":\"printstart\",\"target\":\"" as *u8)
740 o = pctl_cat(out, o, host)
741 o = pctl_cat(out, o, "\",\"filename\":\"" as *u8)
742 o = pctl_cat(out, o, fn)
743 o = pctl_cat(out, o, "\",\"http_status\":" as *u8)
744 o = pctl_catn(out, o, status)
745 o = pctl_cat(out, o, ",\"result\":\"" as *u8)
746 o = pctl_cat_netresult(out, o, status)
747 o = pctl_cat(out, o, "\"}\n" as *u8)
748 return o
749}
750
751// ===== upload: sovereign multipart POST /server/files/upload =======
752// Uploads a local G-code file to the printer (stores it; does NOT run it -- printstart
753// runs a named file). Builds the multipart/form-data body ourselves (no 3rd-party HTTP lib).
754func pctl_build_upload_req(req: *u8, host: *u8, filename: *u8, gcode: *u8, gcode_len: i64) -> i64 {
755 let bnd: *u8 = "----nxbprinterctlB0undary" as *u8
756 // part1: --bnd\r\nContent-Disposition...filename\r\nContent-Type...\r\n\r\n
757 let p1: *u8 = sys_mmap(1024)
758 var a: i64 = 0
759 a = pctl_cat(p1, a, "--" as *u8)
760 a = pctl_cat(p1, a, bnd)
761 a = pctl_cat(p1, a, "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"" as *u8)
762 a = pctl_cat(p1, a, filename)
763 a = pctl_cat(p1, a, "\"\r\nContent-Type: application/octet-stream\r\n\r\n" as *u8)
764 // part3: \r\n--bnd--\r\n
765 let p3: *u8 = sys_mmap(64)
766 var b: i64 = 0
767 b = pctl_cat(p3, b, "\r\n--" as *u8)
768 b = pctl_cat(p3, b, bnd)
769 b = pctl_cat(p3, b, "--\r\n" as *u8)
770 let body_len: i64 = a + gcode_len + b
771 // headers
772 var o: i64 = 0
773 o = pctl_cat(req, o, "POST /server/files/upload HTTP/1.1\r\nHost: " as *u8)
774 o = pctl_cat(req, o, host)
775 o = pctl_cat(req, o, "\r\nContent-Type: multipart/form-data; boundary=" as *u8)
776 o = pctl_cat(req, o, bnd)
777 o = pctl_cat(req, o, "\r\nContent-Length: " as *u8)
778 o = pctl_catn(req, o, body_len)
779 o = pctl_cat(req, o, "\r\nConnection: close\r\n\r\n" as *u8)
780 // body: part1 + gcode + part3
781 var i: i64 = 0
782 while i < a { req[o] = p1[i]; o = o + 1; i = i + 1 }
783 i = 0
784 while i < gcode_len { req[o] = gcode[i]; o = o + 1; i = i + 1 }
785 i = 0
786 while i < b { req[o] = p3[i]; o = o + 1; i = i + 1 }
787 return o
788}
789func pctl_upload_send(out: *u8, ipv4: i64, port: i64, host: *u8, filename: *u8, gcode: *u8, gcode_len: i64) -> i64 {
790 let req: *u8 = sys_mmap(gcode_len + 16384)
791 let rlen: i64 = pctl_build_upload_req(req, host, filename, gcode, gcode_len)
792 let fd: i64 = nx_mr_tcp_connect_ipv4(ipv4, port)
793 var status: i64 = 0 - 2
794 var n: i64 = 0
795 if fd >= 0 {
796 var sent: i64 = 0
797 while sent < rlen {
798 let w: i64 = sys_write(fd, ((req as i64) + sent) as *u8, rlen - sent)
799 if w <= 0 { sent = rlen } else { sent = sent + w }
800 }
801 let resp: *u8 = sys_mmap(PCTL_RESP)
802 n = sys_read(fd, resp, PCTL_RESP)
803 sys_close(fd)
804 if n > 0 { status = nx_mr_parse_status(resp, n) } else { status = 0 - 4 }
805 }
806 var o: i64 = 0
807 o = pctl_cat(out, o, "{\"verb\":\"upload\",\"target\":\"" as *u8)
808 o = pctl_cat(out, o, host)
809 o = pctl_cat(out, o, "\",\"filename\":\"" as *u8)
810 o = pctl_cat(out, o, filename)
811 o = pctl_cat(out, o, "\",\"bytes\":" as *u8)
812 o = pctl_catn(out, o, gcode_len)
813 o = pctl_cat(out, o, ",\"http_status\":" as *u8)
814 o = pctl_catn(out, o, status)
815 o = pctl_cat(out, o, ",\"result\":\"" as *u8)
816 o = pctl_cat_netresult(out, o, status)
817 o = pctl_cat(out, o, "\"}\n" as *u8)
818 return o
819}
820
821// ===== the sovereign architecture contract (no network; agent-facing) =====
822func pctl_emit_contract(out: *u8) -> i64 {
823 var o: i64 = 0
824 o = pctl_cat(out, o, "{\"verb\":\"contract\",\"capability\":\"nx_printer_ctl\",\"maps_operator_design\":\"browser->edge->home-gateway->printer\"," as *u8)
825 o = pctl_cat(out, o, "\"flow\":[" as *u8)
826 o = pctl_cat(out, o, "{\"hop\":1,\"node\":\"browser\",\"presents\":\"opaque X-Nishi-Cap token (cookie/bearer)\"}," as *u8)
827 o = pctl_cat(out, o, "{\"hop\":2,\"node\":\"sovereign edge sites.elf :8443\",\"does\":\"validate opaque cap-token, reverse-proxy /printers route\",\"tls\":\"sovereign TLS-1.3 own trust store\"}," as *u8)
828 o = pctl_cat(out, o, "{\"hop\":3,\"node\":\"nx_printer_ctl organ (on-NAS, on-LAN)\",\"does\":\"cap-gated fixed-endpoint control\",\"auth\":\"least-authority cap\"}," as *u8)
829 o = pctl_cat(out, o, "{\"hop\":4,\"node\":\"QIDI/Moonraker :7125 (Fluidd :10088)\",\"reach\":\"LAN HTTP only\"}]," as *u8)
830 o = pctl_cat(out, o, "\"zero_external_exposure\":\"BY_CONSTRUCTION: NAS edge is on the home LAN; printer never sees WAN; only the cap-gated organ reaches it\"," as *u8)
831 o = pctl_cat(out, o, "\"never_brick\":\"fixed safe-endpoint allowlist (info/emergency_stop/pause/resume/cancel); NO raw-gcode passthrough => no firmware/EEPROM write path; estop = runtime MCU halt, RAM-only\"," as *u8)
832 o = pctl_cat(out, o, "\"safe_endpoints\":[\"/printer/info\",\"/printer/objects/query\",\"/server/files/list\",\"/server/files/upload\",\"/printer/emergency_stop\",\"/printer/print/start\",\"/printer/print/pause\",\"/printer/print/resume\",\"/printer/print/cancel\"]," as *u8)
833 o = pctl_cat(out, o, "\"print_workflow\":\"files->printstart(validated filename, never raw-gcode)->progress; slice via sovereign nx_print_session (mesh->gcode); G-code validated by nx_klipper_gcode_validator\"," as *u8)
834 o = pctl_cat(out, o, "\"no_third_party\":\"sovereign organs only; no Node/Express/nginx/http-proxy-middleware\"}\n" as *u8)
835 return o
836}