code wiki / (root) / nx_moonraker_client.nx

nx_moonraker_client.nx source

↩ module page · 277 lines · 10071 B

1// nx_moonraker_client.nx -- request-construction layer for the 2// Klipper Moonraker HTTP API. Composes raw byte buffers that an 3// HTTP client (nx_http_client or nx_https_client) sends over a 4// socket to a Klipper printer's Moonraker server. 5// 6// Per NISHI_3D_PRINT_BASELINE_2026_05_19 ยง3: Moonraker speaks HTTP 7// (file transfer + most endpoints) + JSON-RPC over WebSocket 8// (events). v1 of this primitive handles the HTTP slice: 9// GET /printer/info -- printer state 10// POST /printer/print/start -- start print from named file 11// POST /printer/print/cancel -- abort active print 12// POST /printer/objects/query -- subscribe to object updates 13// 14// File upload (POST /server/files/upload multipart) deferred to v2; 15// for v1 the operator places the .gcode on the printer via scp/sftp 16// then calls /printer/print/start with the filename. 17// 18// WebSocket event subscription deferred to v2; v1 uses HTTP polling 19// of /printer/objects/query. Moonraker explicitly supports both 20// (per https://moonraker.readthedocs.io/external_api/introduction). 21// 22// API key handling: 23// Moonraker uses X-Api-Key header for authenticated requests. 24// v1 accepts an api_key parameter at construction time and adds 25// the header to every request when non-NULL. 26// 27// JSON safety: 28// v1 assumes filenames contain only [a-zA-Z0-9._-/]. Quotes and 29// backslashes in user filenames will break the JSON. v2 will 30// compose nx_json_emit (already shipped) for proper escaping. 31// 32// Per cardinal feedback-engineering-sciences-bits-up-3d-print-first: 33// this primitive is one of the FIRST examples of substrate primitives 34// composing into a real device-control workflow (operator -> substrate 35// -> printer over network). Same pattern will apply to nx_cnc_client 36// (Mach3 / LinuxCNC), nx_voron_klipper (variant), nx_marlin_serial 37// (older firmware), etc. 38// 39// license_tier: ORIGINAL 40 41import "nx_syscalls.nx" 42 43// ===== verdicts =================================================== 44 45const NX_MR_OK: i64 = 0 46const NX_MR_ERR_OVERFLOW: i64 = 1 47const NX_MR_ERR_BAD_INPUT: i64 = 2 48const NX_MR_ERR_BAD_FILENAME: i64 = 3 49const NX_MR_N_VERDICTS: i64 = 4 50 51func nx_mr_verdict_name(v: i64) -> *u8 { 52 if v == NX_MR_OK { return "OK" } 53 if v == NX_MR_ERR_OVERFLOW { return "OVERFLOW" } 54 if v == NX_MR_ERR_BAD_INPUT { return "BAD_INPUT" } 55 if v == NX_MR_ERR_BAD_FILENAME { return "BAD_FILENAME" } 56 return "UNKNOWN" 57} 58 59// ===== buffer write helpers ======================================== 60 61// Write `n` bytes from src to dst[off..]; returns new offset or -1. 62func nx_mr_write_bytes(dst: *u8, off: i64, cap: i64, 63 src: *u8, n: i64) -> i64 { 64 if off + n > cap { return -1 } 65 var i: i64 = 0 66 while i < n { 67 dst[off + i] = src[i] 68 i = i + 1 69 } 70 return off + n 71} 72 73// Write NUL-terminated C string to dst[off..]; returns new offset or -1. 74func nx_mr_write_cstr(dst: *u8, off: i64, cap: i64, s: *u8) -> i64 { 75 var o: i64 = off 76 var i: i64 = 0 77 while s[i] != 0 { 78 if o >= cap { return -1 } 79 dst[o] = s[i] 80 o = o + 1 81 i = i + 1 82 } 83 return o 84} 85 86// Integer to decimal in-place into dst[off..]; returns new offset or -1. 87func nx_mr_write_int(dst: *u8, off: i64, cap: i64, value: i64) -> i64 { 88 var v: i64 = value 89 var neg: i64 = 0 90 if v < 0 { neg = 1; v = -v } 91 var o: i64 = off 92 if neg == 1 { 93 if o >= cap { return -1 } 94 dst[o] = 45 // '-' 95 o = o + 1 96 } 97 let start: i64 = o 98 if v == 0 { 99 if o >= cap { return -1 } 100 dst[o] = 48 // '0' 101 o = o + 1 102 } 103 while v > 0 { 104 if o >= cap { return -1 } 105 let d: i64 = v - (v / 10) * 10 106 dst[o] = (48 + d) & 0xff 107 o = o + 1 108 v = v / 10 109 } 110 // Reverse the digit range [start, o) 111 var i: i64 = start 112 var j: i64 = o - 1 113 while i < j { 114 let t: i64 = dst[i] as i64 115 dst[i] = dst[j] 116 dst[j] = t & 0xff 117 i = i + 1 118 j = j - 1 119 } 120 return o 121} 122 123// ===== filename validation ========================================= 124// 125// v1 accepts: a-z A-Z 0-9 . _ - / (no quotes, no backslashes, no 126// control chars). Returns 1 if safe, 0 otherwise. 127 128func nx_mr_filename_is_safe(fn: *u8, fn_len: i64) -> i64 { 129 if fn_len <= 0 { return 0 } 130 if fn_len > 240 { return 0 } 131 var i: i64 = 0 132 while i < fn_len { 133 let c: i64 = (fn[i] as i64) & 0xff 134 var ok: i64 = 0 135 if c >= 48 { if c <= 57 { ok = 1 } } // '0'-'9' 136 if c >= 65 { if c <= 90 { ok = 1 } } // 'A'-'Z' 137 if c >= 97 { if c <= 122 { ok = 1 } } // 'a'-'z' 138 if c == 46 { ok = 1 } // '.' 139 if c == 95 { ok = 1 } // '_' 140 if c == 45 { ok = 1 } // '-' 141 if c == 47 { ok = 1 } // '/' 142 if ok == 0 { return 0 } 143 i = i + 1 144 } 145 return 1 146} 147 148// ===== URL building ================================================ 149// 150// Concatenates base + path into out_buf. Returns bytes written or 151// -1 on overflow. Base is expected to be the printer root URL like 152// "http://192.168.1.42:7125" (no trailing slash); path is expected 153// to start with '/'. 154 155func nx_mr_build_url(base: *u8, base_len: i64, 156 path: *u8, path_len: i64, 157 out_buf: *u8, out_cap: i64) -> i64 { 158 if base_len < 0 { return -1 } 159 if path_len < 0 { return -1 } 160 if base_len + path_len > out_cap { return -1 } 161 var o: i64 = 0 162 o = nx_mr_write_bytes(out_buf, o, out_cap, base, base_len) 163 if o < 0 { return -1 } 164 o = nx_mr_write_bytes(out_buf, o, out_cap, path, path_len) 165 return o 166} 167 168// ===== JSON body builders ========================================== 169// 170// Emit `{"filename":"<fn>"}` -- the body for POST /printer/print/start. 171// Returns bytes written or -1 on overflow / bad input. 172 173func nx_mr_build_print_start_body(filename: *u8, fn_len: i64, 174 out_buf: *u8, out_cap: i64) -> i64 { 175 if nx_mr_filename_is_safe(filename, fn_len) != 1 { return -1 } 176 var o: i64 = 0 177 o = nx_mr_write_cstr(out_buf, o, out_cap, "{\"filename\":\"") 178 if o < 0 { return -1 } 179 o = nx_mr_write_bytes(out_buf, o, out_cap, filename, fn_len) 180 if o < 0 { return -1 } 181 o = nx_mr_write_cstr(out_buf, o, out_cap, "\"}") 182 return o 183} 184 185// ===== HTTP request building ====================================== 186// 187// Build an HTTP POST request: request line + Host + Content-Type + 188// Content-Length + optional X-Api-Key + body. Returns total bytes 189// written or -1 on overflow. 190// 191// Caller passes the printer's host:port string for the Host header 192// (without scheme), the request path (with leading /), body bytes, 193// optional api_key (NULL if no auth). 194 195func nx_mr_build_post_request(host: *u8, host_len: i64, 196 path: *u8, path_len: i64, 197 body: *u8, body_len: i64, 198 api_key: *u8, api_key_len: i64, 199 out_buf: *u8, out_cap: i64) -> i64 { 200 var o: i64 = 0 201 // Request line: POST <path> HTTP/1.1\r\n 202 o = nx_mr_write_cstr(out_buf, o, out_cap, "POST ") 203 if o < 0 { return -1 } 204 o = nx_mr_write_bytes(out_buf, o, out_cap, path, path_len) 205 if o < 0 { return -1 } 206 o = nx_mr_write_cstr(out_buf, o, out_cap, " HTTP/1.1\r\nHost: ") 207 if o < 0 { return -1 } 208 o = nx_mr_write_bytes(out_buf, o, out_cap, host, host_len) 209 if o < 0 { return -1 } 210 o = nx_mr_write_cstr(out_buf, o, out_cap, 211 "\r\nContent-Type: application/json\r\nContent-Length: ") 212 if o < 0 { return -1 } 213 o = nx_mr_write_int(out_buf, o, out_cap, body_len) 214 if o < 0 { return -1 } 215 if api_key_len > 0 { 216 o = nx_mr_write_cstr(out_buf, o, out_cap, "\r\nX-Api-Key: ") 217 if o < 0 { return -1 } 218 o = nx_mr_write_bytes(out_buf, o, out_cap, api_key, api_key_len) 219 if o < 0 { return -1 } 220 } 221 // End of headers 222 o = nx_mr_write_cstr(out_buf, o, out_cap, "\r\n\r\n") 223 if o < 0 { return -1 } 224 // Body 225 o = nx_mr_write_bytes(out_buf, o, out_cap, body, body_len) 226 return o 227} 228 229// ===== HTTP GET request builder =================================== 230// 231// Build a GET request: same layout as POST but no body. 232 233func nx_mr_build_get_request(host: *u8, host_len: i64, 234 path: *u8, path_len: i64, 235 api_key: *u8, api_key_len: i64, 236 out_buf: *u8, out_cap: i64) -> i64 { 237 var o: i64 = 0 238 o = nx_mr_write_cstr(out_buf, o, out_cap, "GET ") 239 if o < 0 { return -1 } 240 o = nx_mr_write_bytes(out_buf, o, out_cap, path, path_len) 241 if o < 0 { return -1 } 242 o = nx_mr_write_cstr(out_buf, o, out_cap, " HTTP/1.1\r\nHost: ") 243 if o < 0 { return -1 } 244 o = nx_mr_write_bytes(out_buf, o, out_cap, host, host_len) 245 if o < 0 { return -1 } 246 if api_key_len > 0 { 247 o = nx_mr_write_cstr(out_buf, o, out_cap, "\r\nX-Api-Key: ") 248 if o < 0 { return -1 } 249 o = nx_mr_write_bytes(out_buf, o, out_cap, api_key, api_key_len) 250 if o < 0 { return -1 } 251 } 252 o = nx_mr_write_cstr(out_buf, o, out_cap, "\r\nConnection: close\r\n\r\n") 253 return o 254} 255 256// ===== response status-line parser ================================ 257// 258// Given the first bytes of an HTTP response ("HTTP/1.1 200 OK\r\n..."), 259// extract the integer status code. Returns -1 on parse failure. 260 261func nx_mr_parse_status(resp: *u8, resp_len: i64) -> i64 { 262 if resp_len < 12 { return -1 } 263 // Skip "HTTP/1.1 " (9 bytes); parse 3-digit status. 264 var i: i64 = 9 265 var status: i64 = 0 266 var digits: i64 = 0 267 while digits < 3 { 268 if i >= resp_len { return -1 } 269 let c: i64 = (resp[i] as i64) & 0xff 270 if c < 48 { return -1 } 271 if c > 57 { return -1 } 272 status = status * 10 + (c - 48) 273 i = i + 1 274 digits = digits + 1 275 } 276 return status 277}