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}