bambu-printer-mcp 1.1.13 → 1.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTORS.md +2 -2
- package/README.md +18 -4
- package/dist/bambu-connect.d.ts +7 -0
- package/dist/bambu-connect.js +77 -0
- package/dist/bambu-native.d.ts +66 -0
- package/dist/bambu-native.js +609 -0
- package/dist/index.js +457 -33
- package/dist/printers/bambu.d.ts +17 -14
- package/dist/printers/bambu.js +102 -56
- package/native/bambu-native-print.cpp +758 -0
- package/package.json +7 -3
- package/scripts/build-bambu-native.zsh +33 -0
- package/src/bambu-connect.ts +100 -0
- package/src/bambu-native.ts +722 -0
- package/src/index.ts +527 -66
- package/src/printers/bambu.ts +89 -53
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
function firstExistingExecutable(candidates) {
|
|
6
|
+
return candidates.find((candidate) => {
|
|
7
|
+
try {
|
|
8
|
+
return fs.statSync(candidate).isFile() && (fs.statSync(candidate).mode & 0o111) !== 0;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function assertBambuNativeAvailable() {
|
|
16
|
+
if (process.platform !== "darwin")
|
|
17
|
+
throw new Error("Bambu native X2D transport requires macOS and the installed Bambu Studio networking plug-in.");
|
|
18
|
+
resolveNativeHelper();
|
|
19
|
+
}
|
|
20
|
+
function resolveNativeHelper() {
|
|
21
|
+
const configured = process.env.BAMBU_NATIVE_HELPER?.trim();
|
|
22
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const candidates = [
|
|
24
|
+
configured,
|
|
25
|
+
path.resolve(packageRoot, "native/bambu-native-print"),
|
|
26
|
+
].filter((value) => Boolean(value));
|
|
27
|
+
const helper = firstExistingExecutable(candidates);
|
|
28
|
+
if (!helper) {
|
|
29
|
+
throw new Error("Bambu native helper is not installed. Build native/bambu-native-print with clang++ or set BAMBU_NATIVE_HELPER.");
|
|
30
|
+
}
|
|
31
|
+
return helper;
|
|
32
|
+
}
|
|
33
|
+
function boolEnv(value, fallback) {
|
|
34
|
+
return String(value === undefined ? fallback : value);
|
|
35
|
+
}
|
|
36
|
+
function tail(value, maxLength = 4000) {
|
|
37
|
+
return value.length > maxLength ? value.slice(-maxLength) : value;
|
|
38
|
+
}
|
|
39
|
+
const activeHelpers = new Map();
|
|
40
|
+
process.once("exit", () => {
|
|
41
|
+
// Server shutdown can exit before the asynchronous SIGTERM grace expires.
|
|
42
|
+
for (const child of activeHelpers.keys())
|
|
43
|
+
child.kill("SIGKILL");
|
|
44
|
+
});
|
|
45
|
+
function isEmergencyCommand(messageJson) {
|
|
46
|
+
const command = JSON.parse(messageJson).print;
|
|
47
|
+
return command?.command === "stop" ||
|
|
48
|
+
(command?.command === "set_bed_temp" && command.temp === 0) ||
|
|
49
|
+
(command?.command === "set_nozzle_temp" && command.target_temp === 0);
|
|
50
|
+
}
|
|
51
|
+
async function interruptNativeOperations(serial) {
|
|
52
|
+
const pending = [...activeHelpers.values()].filter(helper => helper.serial === serial && !helper.emergency);
|
|
53
|
+
for (const helper of pending)
|
|
54
|
+
helper.interrupt();
|
|
55
|
+
// Send stop/heater-off only after an older helper can no longer send a
|
|
56
|
+
// delayed print/heating command after that emergency control.
|
|
57
|
+
await Promise.all(pending.map(helper => helper.closed));
|
|
58
|
+
}
|
|
59
|
+
function runNativeHelper(mode, env, timeoutMs, onUpdate, execution = {}) {
|
|
60
|
+
execution.signal?.throwIfAborted();
|
|
61
|
+
execution.assertActive?.();
|
|
62
|
+
const needsAuthorization = mode === "--print-authorized" || mode === "--command-authorized";
|
|
63
|
+
if (needsAuthorization && typeof execution.beforeDispatch !== "function") {
|
|
64
|
+
throw new Error("Native dispatch requires a beforeDispatch fresh-state check.");
|
|
65
|
+
}
|
|
66
|
+
assertBambuNativeAvailable();
|
|
67
|
+
const helper = resolveNativeHelper();
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const child = spawn(helper, [mode], {
|
|
70
|
+
env,
|
|
71
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
72
|
+
});
|
|
73
|
+
let markClosed;
|
|
74
|
+
const closed = new Promise(resolve => { markClosed = resolve; });
|
|
75
|
+
const updates = [];
|
|
76
|
+
let stdout = "";
|
|
77
|
+
let stderr = "";
|
|
78
|
+
let settled = false;
|
|
79
|
+
let failure;
|
|
80
|
+
let dispatchAttempt = 0;
|
|
81
|
+
let authorizedAttempts = 0;
|
|
82
|
+
let authorizing = false;
|
|
83
|
+
let killTimer;
|
|
84
|
+
const stop = (error) => {
|
|
85
|
+
if (settled || failure)
|
|
86
|
+
return;
|
|
87
|
+
failure = error;
|
|
88
|
+
child.kill("SIGTERM");
|
|
89
|
+
// Do not release the checked snapshot or operation lock until the process
|
|
90
|
+
// has actually exited, even if the plug-in ignores SIGTERM.
|
|
91
|
+
killTimer = setTimeout(() => { child.kill("SIGKILL"); }, 1000);
|
|
92
|
+
};
|
|
93
|
+
const interrupted = (reason) => new Error(`${reason} The native command may already have reached the printer; verify its state before retrying.`);
|
|
94
|
+
const onAbort = () => stop(interrupted("Native request cancelled."));
|
|
95
|
+
const timer = setTimeout(() => stop(interrupted(`Bambu native helper timed out after ${timeoutMs} ms.`)), timeoutMs);
|
|
96
|
+
const guard = execution.assertActive ? setInterval(() => {
|
|
97
|
+
try {
|
|
98
|
+
execution.assertActive();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
stop(interrupted(error instanceof Error ? error.message : String(error)));
|
|
102
|
+
}
|
|
103
|
+
}, 25) : undefined;
|
|
104
|
+
const cleanup = () => {
|
|
105
|
+
activeHelpers.delete(child);
|
|
106
|
+
markClosed();
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
if (killTimer)
|
|
109
|
+
clearTimeout(killTimer);
|
|
110
|
+
if (guard)
|
|
111
|
+
clearInterval(guard);
|
|
112
|
+
execution.signal?.removeEventListener("abort", onAbort);
|
|
113
|
+
};
|
|
114
|
+
activeHelpers.set(child, {
|
|
115
|
+
serial: env.BAMBU_NATIVE_SERIAL || "",
|
|
116
|
+
emergency: (mode === "--command" || mode === "--command-authorized") && isEmergencyCommand(env.BAMBU_NATIVE_COMMAND_JSON || "{}"),
|
|
117
|
+
closed,
|
|
118
|
+
interrupt: () => stop(interrupted("Native operation cancelled by stop or heater-off.")),
|
|
119
|
+
});
|
|
120
|
+
execution.signal?.addEventListener("abort", onAbort, { once: true });
|
|
121
|
+
if (execution.signal?.aborted)
|
|
122
|
+
onAbort();
|
|
123
|
+
const authorizeDispatch = async (line) => {
|
|
124
|
+
if (failure || settled)
|
|
125
|
+
return;
|
|
126
|
+
const match = /^native_dispatch_request=([1-9]\d*)$/.exec(line);
|
|
127
|
+
const attempt = match ? Number(match[1]) : NaN;
|
|
128
|
+
if (!needsAuthorization || authorizing || !Number.isSafeInteger(attempt) || attempt !== dispatchAttempt + 1) {
|
|
129
|
+
stop(new Error("Invalid native dispatch authorization request."));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
dispatchAttempt = attempt;
|
|
133
|
+
authorizing = true;
|
|
134
|
+
try {
|
|
135
|
+
await execution.beforeDispatch();
|
|
136
|
+
if (failure || settled)
|
|
137
|
+
return;
|
|
138
|
+
execution.signal?.throwIfAborted();
|
|
139
|
+
execution.assertActive?.();
|
|
140
|
+
child.stdin.write(`native_dispatch_authorized=${attempt}\n`);
|
|
141
|
+
authorizedAttempts++;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
stop(error instanceof Error ? error : new Error(String(error)));
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
authorizing = false;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
child.stdin.on("error", (error) => stop(error));
|
|
151
|
+
child.stdout.on("data", (chunk) => {
|
|
152
|
+
stdout += chunk.toString("utf8");
|
|
153
|
+
const lines = stdout.split(/\r?\n/);
|
|
154
|
+
stdout = lines.pop() || "";
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
const trimmed = line.trim();
|
|
157
|
+
if (trimmed) {
|
|
158
|
+
if (trimmed.startsWith("native_dispatch_request=")) {
|
|
159
|
+
void authorizeDispatch(trimmed);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
updates.push(trimmed);
|
|
163
|
+
onUpdate?.(trimmed);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
child.stderr.on("data", (chunk) => {
|
|
168
|
+
stderr = tail(stderr + chunk.toString("utf8"));
|
|
169
|
+
});
|
|
170
|
+
child.on("error", (error) => {
|
|
171
|
+
// Node emits close after a spawn failure too. Keep one exit/cleanup path.
|
|
172
|
+
failure ?? (failure = error);
|
|
173
|
+
});
|
|
174
|
+
child.on("close", (code, signal) => {
|
|
175
|
+
if (settled)
|
|
176
|
+
return;
|
|
177
|
+
settled = true;
|
|
178
|
+
cleanup();
|
|
179
|
+
if (failure) {
|
|
180
|
+
reject(failure);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (needsAuthorization && code === 0 && (authorizing || authorizedAttempts === 0)) {
|
|
184
|
+
reject(new Error("Native helper exited without completed dispatch authorization."));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const trailing = stdout.trim();
|
|
188
|
+
if (trailing) {
|
|
189
|
+
updates.push(trailing);
|
|
190
|
+
onUpdate?.(trailing);
|
|
191
|
+
}
|
|
192
|
+
resolve({ resultCode: code ?? (signal ? 1 : 0), updates, stderr });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const X2D_NATIVE_PRINT_COMMANDS = new Set([
|
|
197
|
+
"ams_change_filament",
|
|
198
|
+
"ams_control",
|
|
199
|
+
"ams_filament_drying",
|
|
200
|
+
"ams_filament_setting",
|
|
201
|
+
"ams_get_rfid",
|
|
202
|
+
"ams_reset",
|
|
203
|
+
"ams_user_setting",
|
|
204
|
+
"auto_stop_ams_dry",
|
|
205
|
+
"back_to_center",
|
|
206
|
+
"buzzer_ctrl",
|
|
207
|
+
"calibration",
|
|
208
|
+
"clean_print_error",
|
|
209
|
+
"close_air_filt",
|
|
210
|
+
"extrusion_cali",
|
|
211
|
+
"extrusion_cali_del",
|
|
212
|
+
"extrusion_cali_get",
|
|
213
|
+
"extrusion_cali_get_result",
|
|
214
|
+
"extrusion_cali_sel",
|
|
215
|
+
"extrusion_cali_set",
|
|
216
|
+
"flowrate_cali",
|
|
217
|
+
"flowrate_get_result",
|
|
218
|
+
"get_auto_nozzle_mapping",
|
|
219
|
+
"holder_nozzle_refresh",
|
|
220
|
+
"idle_ignore",
|
|
221
|
+
"ignore",
|
|
222
|
+
"nozzle_holder_ctrl",
|
|
223
|
+
"nozzle_info_confirm",
|
|
224
|
+
"pause",
|
|
225
|
+
"print_speed",
|
|
226
|
+
"refresh_nozzle",
|
|
227
|
+
"resume",
|
|
228
|
+
"select_extruder",
|
|
229
|
+
"set_against_continued_heating_mode",
|
|
230
|
+
"set_airduct",
|
|
231
|
+
"set_ctt",
|
|
232
|
+
"set_extrusion_length",
|
|
233
|
+
"skip_objects",
|
|
234
|
+
"stop",
|
|
235
|
+
"xyz_ctrl",
|
|
236
|
+
]);
|
|
237
|
+
const X2D_NATIVE_SYSTEM_COMMANDS = new Set(["ledctrl", "print_cache_set", "set_door_stat", "uiop"]);
|
|
238
|
+
const X2D_NATIVE_CAMERA_COMMANDS = new Set([
|
|
239
|
+
"ipcam_cap_pic_set",
|
|
240
|
+
"ipcam_delete_oldest_timelapse",
|
|
241
|
+
"ipcam_get_media_info",
|
|
242
|
+
"ipcam_record_set",
|
|
243
|
+
"ipcam_resolution_set",
|
|
244
|
+
"ipcam_timelapse",
|
|
245
|
+
]);
|
|
246
|
+
const X2D_NATIVE_XCAM_COMMANDS = new Set(["xcam_control_set"]);
|
|
247
|
+
export function buildBambuNativeFanCommand(fan, speed, sequenceId = String(Date.now())) {
|
|
248
|
+
const normalized = String(fan).trim().toLowerCase();
|
|
249
|
+
const fanConfig = normalized === "1" || normalized === "part" || normalized === "part_cooling"
|
|
250
|
+
? { fan: "part", fanIndex: 1 }
|
|
251
|
+
: normalized === "2" || normalized === "aux" || normalized === "auxiliary" || normalized === "left_auxiliary"
|
|
252
|
+
? { fan: "auxiliary", fanIndex: 2 }
|
|
253
|
+
: normalized === "10" || normalized === "right_aux" || normalized === "right_auxiliary"
|
|
254
|
+
? { fan: "right_auxiliary", fanIndex: 10 }
|
|
255
|
+
: normalized === "3" || normalized === "chamber" || normalized === "exhaust"
|
|
256
|
+
? { fan: "chamber", fanIndex: 3 }
|
|
257
|
+
: undefined;
|
|
258
|
+
if (!fanConfig) {
|
|
259
|
+
throw new Error("Unsupported X2D fan. Use part, auxiliary, right_auxiliary, chamber, 1, 2, 3, or 10.");
|
|
260
|
+
}
|
|
261
|
+
if (!Number.isFinite(speed) || speed < 0 || speed > 100) {
|
|
262
|
+
throw new Error("Fan speed must be between 0 and 100 percent.");
|
|
263
|
+
}
|
|
264
|
+
// Preserve the public tool's existing 10% step semantics while sending the
|
|
265
|
+
// command through the signed native network plug-in instead of UI scripting.
|
|
266
|
+
const roundedSpeed = Math.round(speed / 10) * 10;
|
|
267
|
+
return {
|
|
268
|
+
...fanConfig,
|
|
269
|
+
requestedSpeed: speed,
|
|
270
|
+
speed: roundedSpeed,
|
|
271
|
+
messageJson: JSON.stringify({
|
|
272
|
+
print: {
|
|
273
|
+
command: "set_fan",
|
|
274
|
+
sequence_id: sequenceId,
|
|
275
|
+
fan_index: fanConfig.fanIndex,
|
|
276
|
+
speed: roundedSpeed,
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
export function buildBambuNativeTemperatureCommand(component, temperature, sequenceId = String(Date.now())) {
|
|
282
|
+
const normalized = component.trim().toLowerCase();
|
|
283
|
+
const normalizedComponent = normalized === "bed"
|
|
284
|
+
? "bed"
|
|
285
|
+
: ["extruder", "nozzle", "tool", "tool0"].includes(normalized)
|
|
286
|
+
? "nozzle"
|
|
287
|
+
: undefined;
|
|
288
|
+
if (!normalizedComponent) {
|
|
289
|
+
throw new Error("Unsupported X2D temperature component. Use bed, nozzle, extruder, tool, or tool0.");
|
|
290
|
+
}
|
|
291
|
+
const roundedTemperature = Math.round(temperature);
|
|
292
|
+
const maximum = normalizedComponent === "bed" ? 120 : 300;
|
|
293
|
+
if (!Number.isFinite(temperature) || roundedTemperature < 0 || roundedTemperature > maximum) {
|
|
294
|
+
throw new Error(`X2D ${normalizedComponent} temperature must be between 0 and ${maximum}°C.`);
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
component: normalizedComponent,
|
|
298
|
+
requestedTemperature: temperature,
|
|
299
|
+
temperature: roundedTemperature,
|
|
300
|
+
messageJson: JSON.stringify({
|
|
301
|
+
print: normalizedComponent === "bed"
|
|
302
|
+
? {
|
|
303
|
+
command: "set_bed_temp",
|
|
304
|
+
sequence_id: sequenceId,
|
|
305
|
+
temp: roundedTemperature,
|
|
306
|
+
}
|
|
307
|
+
: {
|
|
308
|
+
command: "set_nozzle_temp",
|
|
309
|
+
sequence_id: sequenceId,
|
|
310
|
+
extruder_index: 0,
|
|
311
|
+
target_temp: roundedTemperature,
|
|
312
|
+
},
|
|
313
|
+
}),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
export function validateBambuNativeControlMessage(messageJson) {
|
|
317
|
+
let parsed;
|
|
318
|
+
try {
|
|
319
|
+
parsed = JSON.parse(messageJson);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
throw new Error("X2D native control message_json must be valid JSON.");
|
|
323
|
+
}
|
|
324
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
325
|
+
throw new Error("X2D native control message_json must be a JSON object.");
|
|
326
|
+
}
|
|
327
|
+
const envelope = parsed;
|
|
328
|
+
if (Object.keys(envelope).length !== 1) {
|
|
329
|
+
throw new Error("X2D native control accepts exactly one command envelope.");
|
|
330
|
+
}
|
|
331
|
+
const section = Object.keys(envelope)[0];
|
|
332
|
+
const payload = envelope[section];
|
|
333
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
334
|
+
throw new Error("X2D native control command envelope must contain an object.");
|
|
335
|
+
}
|
|
336
|
+
const print = payload;
|
|
337
|
+
const command = typeof print.command === "string" ? print.command : "";
|
|
338
|
+
if (section === "system" && X2D_NATIVE_SYSTEM_COMMANDS.has(command)) {
|
|
339
|
+
return { messageJson: JSON.stringify(parsed), command };
|
|
340
|
+
}
|
|
341
|
+
if (section === "camera" && X2D_NATIVE_CAMERA_COMMANDS.has(command)) {
|
|
342
|
+
return { messageJson: JSON.stringify(parsed), command };
|
|
343
|
+
}
|
|
344
|
+
if (section === "xcam" && X2D_NATIVE_XCAM_COMMANDS.has(command)) {
|
|
345
|
+
return { messageJson: JSON.stringify(parsed), command };
|
|
346
|
+
}
|
|
347
|
+
if (section !== "print") {
|
|
348
|
+
throw new Error(`X2D native control command is not allowed: ${section}.${command || "<missing>"}.`);
|
|
349
|
+
}
|
|
350
|
+
if (command === "set_bed_temp" || command === "set_nozzle_temp") {
|
|
351
|
+
const allowedKeys = command === "set_bed_temp"
|
|
352
|
+
? new Set(["command", "sequence_id", "temp"])
|
|
353
|
+
: new Set(["command", "sequence_id", "extruder_index", "target_temp"]);
|
|
354
|
+
if (Object.keys(print).some((key) => !allowedKeys.has(key))) {
|
|
355
|
+
throw new Error(`X2D native ${command} contains unsupported fields.`);
|
|
356
|
+
}
|
|
357
|
+
if (typeof print.sequence_id !== "string" || print.sequence_id.length === 0) {
|
|
358
|
+
throw new Error(`X2D native ${command} requires a sequence_id string.`);
|
|
359
|
+
}
|
|
360
|
+
const temperature = Number(command === "set_bed_temp" ? print.temp : print.target_temp);
|
|
361
|
+
const maximum = command === "set_bed_temp" ? 120 : 300;
|
|
362
|
+
if (!Number.isInteger(temperature) || temperature < 0 || temperature > maximum) {
|
|
363
|
+
throw new Error(`X2D native ${command} temperature must be an integer from 0 to ${maximum}°C.`);
|
|
364
|
+
}
|
|
365
|
+
if (command === "set_nozzle_temp" && (!Number.isInteger(print.extruder_index) || Number(print.extruder_index) < 0 || Number(print.extruder_index) > 1)) {
|
|
366
|
+
throw new Error("X2D native set_nozzle_temp extruder_index must be 0 or 1.");
|
|
367
|
+
}
|
|
368
|
+
return { messageJson: JSON.stringify(parsed), command };
|
|
369
|
+
}
|
|
370
|
+
if (command === "gcode_file") {
|
|
371
|
+
if (print.param !== "/usr/etc/print/auto_cali_for_user.gcode") {
|
|
372
|
+
throw new Error("X2D native gcode_file is limited to the built-in user calibration file.");
|
|
373
|
+
}
|
|
374
|
+
return { messageJson: JSON.stringify(parsed), command };
|
|
375
|
+
}
|
|
376
|
+
if (X2D_NATIVE_PRINT_COMMANDS.has(command)) {
|
|
377
|
+
return { messageJson: JSON.stringify(parsed), command };
|
|
378
|
+
}
|
|
379
|
+
if (command === "set_fan") {
|
|
380
|
+
const allowedKeys = new Set(["command", "sequence_id", "fan_index", "speed"]);
|
|
381
|
+
if (Object.keys(print).some((key) => !allowedKeys.has(key))) {
|
|
382
|
+
throw new Error("X2D native set_fan contains unsupported fields.");
|
|
383
|
+
}
|
|
384
|
+
if (typeof print.sequence_id !== "string" || print.sequence_id.length === 0) {
|
|
385
|
+
throw new Error("X2D native set_fan requires a sequence_id string.");
|
|
386
|
+
}
|
|
387
|
+
if (![1, 2, 3, 10].includes(Number(print.fan_index)) || !Number.isInteger(print.fan_index)) {
|
|
388
|
+
throw new Error("X2D native set_fan fan_index must be 1, 2, 3, or 10.");
|
|
389
|
+
}
|
|
390
|
+
if (!Number.isInteger(print.speed) || Number(print.speed) < 0 || Number(print.speed) > 100 || Number(print.speed) % 10 !== 0) {
|
|
391
|
+
throw new Error("X2D native set_fan speed must be an integer from 0 to 100 in 10% steps.");
|
|
392
|
+
}
|
|
393
|
+
return { messageJson: JSON.stringify(parsed), command };
|
|
394
|
+
}
|
|
395
|
+
if (command === "print_option") {
|
|
396
|
+
const optionKeys = [
|
|
397
|
+
"air_print_detect", "air_purification", "auto_recovery", "auto_switch_filament",
|
|
398
|
+
"filament_tangle_detect", "nozzle_blob_detect", "nozzle_blob_detect_v2",
|
|
399
|
+
"option", "sound_enable",
|
|
400
|
+
];
|
|
401
|
+
const allowedKeys = new Set(["command", "sequence_id", ...optionKeys]);
|
|
402
|
+
if (!optionKeys.some((key) => key in print) || Object.keys(print).some((key) => !allowedKeys.has(key))) {
|
|
403
|
+
throw new Error("X2D native print_option contains no supported option or has unsupported fields.");
|
|
404
|
+
}
|
|
405
|
+
return { messageJson: JSON.stringify(parsed), command };
|
|
406
|
+
}
|
|
407
|
+
if (command === "gcode_line") {
|
|
408
|
+
const param = typeof print.param === "string" ? print.param.trim() : "";
|
|
409
|
+
if (!/^M620\s+[CRP]\d+\s*$/i.test(param)) {
|
|
410
|
+
throw new Error("X2D native gcode_line is limited to AMS M620 C/R/P commands.");
|
|
411
|
+
}
|
|
412
|
+
return { messageJson: JSON.stringify(parsed), command };
|
|
413
|
+
}
|
|
414
|
+
throw new Error(`X2D native control command is not allowed: ${command || "<missing>"}.`);
|
|
415
|
+
}
|
|
416
|
+
/** Public raw input is limited to metadata and read-only calibration queries. */
|
|
417
|
+
export function validateBambuNativeMetadata(messageJson) {
|
|
418
|
+
const validated = validateBambuNativeControlMessage(messageJson);
|
|
419
|
+
const parsed = JSON.parse(validated.messageJson);
|
|
420
|
+
const value = parsed.print;
|
|
421
|
+
const fields = {
|
|
422
|
+
ams_filament_setting: ["ams_id", "slot_id", "tray_id", "tray_info_idx", "setting_id", "tray_color", "nozzle_temp_min", "nozzle_temp_max", "tray_type"],
|
|
423
|
+
extrusion_cali_sel: ["ams_id", "slot_id", "tray_id", "cali_idx", "filament_id", "nozzle_diameter"],
|
|
424
|
+
extrusion_cali_get: ["filament_id", "nozzle_diameter"],
|
|
425
|
+
extrusion_cali_get_result: ["filament_id", "nozzle_diameter"],
|
|
426
|
+
flowrate_get_result: ["filament_id", "nozzle_diameter"],
|
|
427
|
+
};
|
|
428
|
+
const allowed = value && fields[value.command];
|
|
429
|
+
if (!allowed)
|
|
430
|
+
throw new Error("Use a dedicated checked tool: raw native control only permits AMS metadata and read-only calibration queries.");
|
|
431
|
+
if (Object.keys(value).some(key => !["command", "sequence_id", ...allowed].includes(key))) {
|
|
432
|
+
throw new Error("Raw native metadata contains unsupported fields.");
|
|
433
|
+
}
|
|
434
|
+
if (typeof value.sequence_id !== "string" || !value.sequence_id.length)
|
|
435
|
+
throw new Error("Metadata sequence_id is required.");
|
|
436
|
+
for (const key of ["ams_id", "slot_id", "tray_id", "cali_idx", "nozzle_temp_min", "nozzle_temp_max"]) {
|
|
437
|
+
if (value[key] === undefined)
|
|
438
|
+
continue;
|
|
439
|
+
const number = value[key];
|
|
440
|
+
if (!Number.isInteger(number) || number < (key === "cali_idx" ? -1 : 0) ||
|
|
441
|
+
(key === "ams_id" && ![0, 1, 2, 3, 128].includes(number)) ||
|
|
442
|
+
(key === "slot_id" && number > 3) ||
|
|
443
|
+
(key.startsWith("nozzle_temp_") && number > 300))
|
|
444
|
+
throw new Error(`Invalid metadata ${key}.`);
|
|
445
|
+
}
|
|
446
|
+
if (value.nozzle_temp_min > value.nozzle_temp_max)
|
|
447
|
+
throw new Error("Metadata temperature range is reversed.");
|
|
448
|
+
for (const key of ["tray_info_idx", "setting_id", "tray_color", "tray_type", "filament_id", "nozzle_diameter"]) {
|
|
449
|
+
if (value[key] !== undefined && (typeof value[key] !== "string" || value[key].length > 256))
|
|
450
|
+
throw new Error(`Invalid metadata ${key}.`);
|
|
451
|
+
}
|
|
452
|
+
if (["ams_filament_setting", "extrusion_cali_sel"].includes(value.command) &&
|
|
453
|
+
(value.ams_id === undefined || value.slot_id === undefined || (value.ams_id === 128 && value.slot_id !== 0))) {
|
|
454
|
+
throw new Error("Metadata requires a valid AMS unit and slot (AMS-HT has only slot 0).");
|
|
455
|
+
}
|
|
456
|
+
if (["ams_filament_setting", "extrusion_cali_sel"].includes(value.command)) {
|
|
457
|
+
// Studio's filament settings use a unit-local tray_id; PA selection uses
|
|
458
|
+
// GetTrayIdByAmsSlotId: absolute AMS index, or the AMS-HT unit's index.
|
|
459
|
+
const trayId = value.command === "ams_filament_setting" ? value.slot_id :
|
|
460
|
+
value.ams_id === 128 ? 128 : value.ams_id * 4 + value.slot_id;
|
|
461
|
+
if (value.tray_id !== undefined && value.tray_id !== trayId)
|
|
462
|
+
throw new Error("Metadata tray_id contradicts the AMS unit/slot.");
|
|
463
|
+
value.tray_id = trayId;
|
|
464
|
+
}
|
|
465
|
+
return JSON.stringify(parsed);
|
|
466
|
+
}
|
|
467
|
+
export async function sendCommandWithBambuNative(options, execution) {
|
|
468
|
+
const validated = validateBambuNativeControlMessage(options.messageJson);
|
|
469
|
+
execution?.signal?.throwIfAborted();
|
|
470
|
+
const print = JSON.parse(validated.messageJson).print;
|
|
471
|
+
const requiresFreshState = print?.command === "resume" || print?.command === "clean_print_error" ||
|
|
472
|
+
(print?.command === "set_bed_temp" && Number(print.temp) > 0) ||
|
|
473
|
+
(print?.command === "set_nozzle_temp" && Number(print.target_temp) > 0);
|
|
474
|
+
if (requiresFreshState && typeof execution?.beforeDispatch !== "function") {
|
|
475
|
+
throw new Error(`Native ${validated.command} requires a beforeDispatch fresh-state check.`);
|
|
476
|
+
}
|
|
477
|
+
if (isEmergencyCommand(validated.messageJson))
|
|
478
|
+
await interruptNativeOperations(options.serial);
|
|
479
|
+
const qos = options.qos === undefined ? 0 : Math.trunc(options.qos);
|
|
480
|
+
const flag = options.flag === undefined ? 0 : Math.trunc(options.flag);
|
|
481
|
+
if (!Number.isFinite(qos) || !Number.isFinite(flag) || qos < 0 || qos > 1 || flag < 0 || flag > 1) {
|
|
482
|
+
throw new Error("X2D native control qos and flag must be 0 or 1.");
|
|
483
|
+
}
|
|
484
|
+
const result = await runNativeHelper(execution?.beforeDispatch ? "--command-authorized" : "--command", {
|
|
485
|
+
...process.env,
|
|
486
|
+
BAMBU_NATIVE_COMMAND_CONFIRM: "1",
|
|
487
|
+
BAMBU_NATIVE_HOST: options.host,
|
|
488
|
+
BAMBU_NATIVE_SERIAL: options.serial,
|
|
489
|
+
BAMBU_NATIVE_ACCESS_CODE: options.token,
|
|
490
|
+
BAMBU_NATIVE_COMMAND_JSON: validated.messageJson,
|
|
491
|
+
BAMBU_NATIVE_COMMAND_QOS: String(qos),
|
|
492
|
+
BAMBU_NATIVE_COMMAND_FLAG: String(flag),
|
|
493
|
+
}, 30000, undefined, execution);
|
|
494
|
+
if (result.resultCode !== 0) {
|
|
495
|
+
const detail = [
|
|
496
|
+
...result.updates,
|
|
497
|
+
...(result.stderr ? [result.stderr.trim()] : []),
|
|
498
|
+
]
|
|
499
|
+
.filter((line) => line.startsWith("native_error=") || line.startsWith("native_command result=") || line.startsWith("local MQTT connection"))
|
|
500
|
+
.join("; ");
|
|
501
|
+
throw new Error(`Bambu native X2D control failed (${result.resultCode})${detail ? `: ${detail}` : "."}`);
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
status: "success",
|
|
505
|
+
route: "bambu:///local",
|
|
506
|
+
command: validated.command,
|
|
507
|
+
updates: result.updates,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
export async function probeBambuNative(host, token) {
|
|
511
|
+
const result = await runNativeHelper("--probe", {
|
|
512
|
+
...process.env,
|
|
513
|
+
BAMBU_NATIVE_HOST: host,
|
|
514
|
+
BAMBU_NATIVE_ACCESS_CODE: token,
|
|
515
|
+
}, 20000);
|
|
516
|
+
if (result.resultCode !== 0) {
|
|
517
|
+
throw new Error(`Bambu native local tunnel probe failed (${result.resultCode}).`);
|
|
518
|
+
}
|
|
519
|
+
return { status: "ok", route: "bambu:///local", updates: result.updates };
|
|
520
|
+
}
|
|
521
|
+
export async function printWithBambuNative(options, onUpdate, execution) {
|
|
522
|
+
if (typeof execution?.beforeDispatch !== "function") {
|
|
523
|
+
throw new Error("Native printing requires a beforeDispatch fresh-state check.");
|
|
524
|
+
}
|
|
525
|
+
const result = await runNativeHelper(
|
|
526
|
+
// Older compiled helpers reject this mode before connecting or printing.
|
|
527
|
+
"--print-authorized", {
|
|
528
|
+
...process.env,
|
|
529
|
+
BAMBU_NATIVE_CONFIRM: "1",
|
|
530
|
+
BAMBU_NATIVE_HOST: options.host,
|
|
531
|
+
BAMBU_NATIVE_SERIAL: options.serial,
|
|
532
|
+
BAMBU_NATIVE_ACCESS_CODE: options.token,
|
|
533
|
+
BAMBU_NATIVE_FILE: options.filePath,
|
|
534
|
+
BAMBU_NATIVE_CONFIG_FILE: options.filePath,
|
|
535
|
+
BAMBU_NATIVE_TASK_NAME: options.projectName,
|
|
536
|
+
BAMBU_NATIVE_DST_FILE: options.remoteName || path.basename(options.filePath),
|
|
537
|
+
BAMBU_NATIVE_PROJECT_NAME: options.projectName,
|
|
538
|
+
BAMBU_NATIVE_PRESET_NAME: options.presetName,
|
|
539
|
+
BAMBU_NATIVE_PLATE_INDEX: String(options.plateIndex + 1),
|
|
540
|
+
BAMBU_NATIVE_BED_TYPE: options.bedType,
|
|
541
|
+
BAMBU_NATIVE_USE_AMS: String(options.useAMS),
|
|
542
|
+
BAMBU_NATIVE_AMS_MAPPING: options.amsMapping || "",
|
|
543
|
+
BAMBU_NATIVE_AMS_MAPPING2: options.amsMapping2 || "",
|
|
544
|
+
BAMBU_NATIVE_AMS_MAPPING_INFO: options.amsMappingInfo || "",
|
|
545
|
+
BAMBU_NATIVE_NOZZLE_MAPPING: options.nozzleMapping || "",
|
|
546
|
+
BAMBU_NATIVE_NOZZLES_INFO: options.nozzlesInfo || "",
|
|
547
|
+
BAMBU_NATIVE_BED_LEVELING: boolEnv(options.bedLeveling, true),
|
|
548
|
+
BAMBU_NATIVE_FLOW_CALIBRATION: boolEnv(options.flowCalibration, true),
|
|
549
|
+
BAMBU_NATIVE_VIBRATION_CALIBRATION: boolEnv(options.vibrationCalibration, true),
|
|
550
|
+
BAMBU_NATIVE_LAYER_INSPECT: boolEnv(options.layerInspect, false),
|
|
551
|
+
BAMBU_NATIVE_TIMELAPSE: boolEnv(options.timelapse, false),
|
|
552
|
+
}, 300000, onUpdate, execution);
|
|
553
|
+
if (result.resultCode !== 0) {
|
|
554
|
+
const detail = [
|
|
555
|
+
...result.updates,
|
|
556
|
+
...(result.stderr ? [result.stderr.trim()] : []),
|
|
557
|
+
]
|
|
558
|
+
.filter((line) => line.startsWith("native_error=") || line.startsWith("native_print result=") || line.startsWith("local MQTT connection"))
|
|
559
|
+
.join("; ");
|
|
560
|
+
throw new Error(`Bambu native local print failed (${result.resultCode})${detail ? `: ${detail}` : "."}`);
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
status: "success",
|
|
564
|
+
route: "bambu:///local",
|
|
565
|
+
updates: result.updates,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
export async function uploadWithBambuNative(options, onUpdate, execution) {
|
|
569
|
+
const result = await runNativeHelper("--upload", {
|
|
570
|
+
...process.env,
|
|
571
|
+
BAMBU_NATIVE_UPLOAD_CONFIRM: "1",
|
|
572
|
+
BAMBU_NATIVE_HOST: options.host,
|
|
573
|
+
BAMBU_NATIVE_SERIAL: options.serial,
|
|
574
|
+
BAMBU_NATIVE_ACCESS_CODE: options.token,
|
|
575
|
+
BAMBU_NATIVE_FILE: options.filePath,
|
|
576
|
+
BAMBU_NATIVE_CONFIG_FILE: options.filePath,
|
|
577
|
+
BAMBU_NATIVE_TASK_NAME: options.projectName,
|
|
578
|
+
BAMBU_NATIVE_DST_FILE: options.remoteName || path.basename(options.filePath),
|
|
579
|
+
BAMBU_NATIVE_PROJECT_NAME: options.projectName,
|
|
580
|
+
BAMBU_NATIVE_PRESET_NAME: options.presetName,
|
|
581
|
+
BAMBU_NATIVE_PLATE_INDEX: String(options.plateIndex + 1),
|
|
582
|
+
BAMBU_NATIVE_BED_TYPE: options.bedType,
|
|
583
|
+
BAMBU_NATIVE_USE_AMS: String(options.useAMS),
|
|
584
|
+
BAMBU_NATIVE_AMS_MAPPING: options.amsMapping || "",
|
|
585
|
+
BAMBU_NATIVE_AMS_MAPPING2: options.amsMapping2 || "",
|
|
586
|
+
BAMBU_NATIVE_AMS_MAPPING_INFO: options.amsMappingInfo || "",
|
|
587
|
+
BAMBU_NATIVE_NOZZLE_MAPPING: options.nozzleMapping || "",
|
|
588
|
+
BAMBU_NATIVE_NOZZLES_INFO: options.nozzlesInfo || "",
|
|
589
|
+
BAMBU_NATIVE_BED_LEVELING: boolEnv(options.bedLeveling, true),
|
|
590
|
+
BAMBU_NATIVE_FLOW_CALIBRATION: boolEnv(options.flowCalibration, true),
|
|
591
|
+
BAMBU_NATIVE_VIBRATION_CALIBRATION: boolEnv(options.vibrationCalibration, true),
|
|
592
|
+
BAMBU_NATIVE_LAYER_INSPECT: boolEnv(options.layerInspect, false),
|
|
593
|
+
BAMBU_NATIVE_TIMELAPSE: boolEnv(options.timelapse, false),
|
|
594
|
+
}, 300000, onUpdate, execution);
|
|
595
|
+
if (result.resultCode !== 0) {
|
|
596
|
+
const detail = [
|
|
597
|
+
...result.updates,
|
|
598
|
+
...(result.stderr ? [result.stderr.trim()] : []),
|
|
599
|
+
]
|
|
600
|
+
.filter((line) => line.startsWith("native_error=") || line.startsWith("native_upload result=") || line.startsWith("local MQTT connection"))
|
|
601
|
+
.join("; ");
|
|
602
|
+
throw new Error(`Bambu native local upload failed (${result.resultCode})${detail ? `: ${detail}` : "."}`);
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
status: "success",
|
|
606
|
+
route: "bambu:///local",
|
|
607
|
+
updates: result.updates,
|
|
608
|
+
};
|
|
609
|
+
}
|