mcp-3d-printer-server 1.2.9 → 1.2.12

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/CONTRIBUTORS.md +56 -0
  3. package/README.md +528 -811
  4. package/dist/3mf_parser.js +8 -8
  5. package/dist/blender-mcp-bridge.d.ts +1 -0
  6. package/dist/blender-mcp-bridge.js +209 -21
  7. package/dist/fulu-orca.js +29 -5
  8. package/dist/index.js +614 -110
  9. package/dist/printers/bambu-node-guard.d.ts +2 -0
  10. package/dist/printers/bambu-node-guard.js +38 -0
  11. package/dist/printers/bambu.d.ts +53 -7
  12. package/dist/printers/bambu.js +420 -126
  13. package/dist/printers/creality.d.ts +15 -6
  14. package/dist/printers/creality.js +42 -9
  15. package/dist/printers/duet.d.ts +11 -6
  16. package/dist/printers/duet.js +57 -34
  17. package/dist/printers/generic-base.d.ts +69 -0
  18. package/dist/printers/generic-base.js +209 -0
  19. package/dist/printers/klipper.d.ts +17 -6
  20. package/dist/printers/klipper.js +63 -16
  21. package/dist/printers/octoprint.d.ts +18 -6
  22. package/dist/printers/octoprint.js +64 -9
  23. package/dist/printers/printer-factory.d.ts +11 -2
  24. package/dist/printers/printer-factory.js +9 -8
  25. package/dist/printers/prusa.d.ts +14 -6
  26. package/dist/printers/prusa.js +56 -9
  27. package/dist/printers/repetier.d.ts +18 -6
  28. package/dist/printers/repetier.js +48 -6
  29. package/dist/safety/archive.d.ts +11 -0
  30. package/dist/safety/archive.js +214 -0
  31. package/dist/safety/artifact.d.ts +6 -0
  32. package/dist/safety/artifact.js +66 -0
  33. package/dist/safety/confirmation.d.ts +22 -0
  34. package/dist/safety/confirmation.js +43 -0
  35. package/dist/safety/expected-peaks.d.ts +24 -0
  36. package/dist/safety/expected-peaks.js +24 -0
  37. package/dist/safety/gcode-thermal.d.ts +64 -0
  38. package/dist/safety/gcode-thermal.js +382 -0
  39. package/dist/safety/limits.d.ts +31 -0
  40. package/dist/safety/limits.js +105 -0
  41. package/dist/safety/print-file.d.ts +25 -0
  42. package/dist/safety/print-file.js +610 -0
  43. package/dist/safety/printer-state.d.ts +51 -0
  44. package/dist/safety/printer-state.js +371 -0
  45. package/dist/slicer/bambu-cli.d.ts +110 -0
  46. package/dist/slicer/bambu-cli.js +547 -0
  47. package/dist/slicer/profile-flatten.d.ts +104 -0
  48. package/dist/slicer/profile-flatten.js +676 -0
  49. package/dist/slicer/run-slicer.d.ts +18 -0
  50. package/dist/slicer/run-slicer.js +102 -0
  51. package/dist/slicer/slicer-error.d.ts +27 -0
  52. package/dist/slicer/slicer-error.js +53 -0
  53. package/dist/slicer/templates.d.ts +126 -0
  54. package/dist/slicer/templates.js +255 -0
  55. package/dist/stl/stl-manipulator.d.ts +25 -3
  56. package/dist/stl/stl-manipulator.js +214 -232
  57. package/package.json +16 -9
  58. package/server.json +56 -0
  59. package/src/3mf_parser.ts +8 -8
  60. package/src/blender-mcp-bridge.ts +200 -19
  61. package/src/fulu-orca.ts +36 -6
  62. package/src/index.ts +686 -137
  63. package/src/printers/bambu-node-guard.ts +44 -0
  64. package/src/printers/bambu.ts +458 -164
  65. package/src/printers/creality.ts +40 -9
  66. package/src/printers/duet.ts +64 -53
  67. package/src/printers/generic-base.ts +286 -0
  68. package/src/printers/klipper.ts +83 -28
  69. package/src/printers/octoprint.ts +74 -21
  70. package/src/printers/printer-factory.ts +21 -11
  71. package/src/printers/prusa.ts +53 -10
  72. package/src/printers/repetier.ts +55 -17
  73. package/src/safety/archive.ts +181 -0
  74. package/src/safety/artifact.ts +58 -0
  75. package/src/safety/confirmation.ts +56 -0
  76. package/src/safety/expected-peaks.ts +47 -0
  77. package/src/safety/gcode-thermal.ts +404 -0
  78. package/src/safety/limits.ts +98 -0
  79. package/src/safety/print-file.ts +429 -0
  80. package/src/safety/printer-state.ts +344 -0
  81. package/src/slicer/bambu-cli.ts +633 -0
  82. package/src/slicer/profile-flatten.ts +830 -0
  83. package/src/slicer/run-slicer.ts +126 -0
  84. package/src/slicer/slicer-error.ts +72 -0
  85. package/src/slicer/templates.ts +288 -0
  86. package/src/stl/stl-manipulator.ts +247 -275
@@ -1,17 +1,51 @@
1
1
  import { PrinterImplementation } from "../types.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
- import JSZip from "jszip";
6
6
  import { Client as FTPClient } from "basic-ftp";
7
- import { BambuPrinter } from "bambu-js";
8
- import { BambuClient, GCodeFileCommand, GCodeLineCommand, PushAllCommand, UpdateStateCommand, } from "bambu-node";
7
+ import { BambuClient, PushAllCommand } from "bambu-node";
8
+ import { MAX_PRINT_FILE_BYTES, readSafe3mfArchive } from "../safety/archive.js";
9
+ import { inspectPrintFile } from "../safety/print-file.js";
10
+ import { normalizeModel, validateTemperature } from "../safety/limits.js";
11
+ import { manualHeatingRequirements, publishBambuCommand, readFreshPrinterStatus, validatePrinterState, } from "../safety/printer-state.js";
12
+ import { cancelPendingPrinterOperations, normalizedRemotePath, uniquePrintName, withPrinterOperation, withPrintSnapshot, } from "../safety/artifact.js";
13
+ import { requireHumanConfirmation } from "../safety/confirmation.js";
14
+ import { installBambuNodeRejectionGuard } from "./bambu-node-guard.js";
15
+ import { assertExpectedPeaks, validateExpectedPeaks } from "../safety/expected-peaks.js";
16
+ const COMMAND_SETTLE_MS = 300;
17
+ /** HMS 0500-0500-0001-0007: firmware 01.08.05+ rejected an unsigned MQTT command. */
18
+ const COMMAND_VERIFICATION_HMS = { attr: 0x05000500, code: 0x00010007 };
19
+ const COMMAND_REJECTED_MESSAGE = "The printer rejected the print command (HMS 0500-0500-0001-0007, \"MQTT command verification failed\"). " +
20
+ "Bambu firmware 01.08.05 and later only accept third-party LAN control with LAN Only Mode and Developer Mode " +
21
+ "enabled (Settings > WLAN on the printer). The printer keeps this error until it is dismissed on its screen, and the " +
22
+ "safety check refuses new prints while it is present. The checked file is on the printer's storage, but nothing is printing.";
23
+ const DISPATCH_CANCELLED_MESSAGE = "The print command was sent, then a stop or heater-off request cancelled it before the printer confirmed the start. " +
24
+ "Check get_printer_status.";
25
+ /** How long to watch fresh reports after a print command (BAMBU_DISPATCH_CHECK_MS, 0 disables). */
26
+ function dispatchCheckMs() {
27
+ const raw = process.env.BAMBU_DISPATCH_CHECK_MS?.trim();
28
+ const value = raw ? Number(raw) : 15000;
29
+ return Number.isInteger(value) && value >= 0 && value <= 60000 ? value : 15000;
30
+ }
31
+ function sleep(ms) {
32
+ return new Promise((resolve) => setTimeout(resolve, ms));
33
+ }
34
+ function validateTrayValue(value, label) {
35
+ if (!Number.isInteger(value) ||
36
+ value < -1 ||
37
+ (value > 15 && value < 128) ||
38
+ value > 254) {
39
+ throw new Error(`${label} values must be integers in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${String(value)}`);
40
+ }
41
+ }
9
42
  class BambuClientStore {
10
43
  constructor() {
11
44
  this.printers = new Map();
12
45
  this.initialConnectionPromises = new Map();
13
46
  }
14
47
  async getPrinter(host, serial, token) {
48
+ installBambuNodeRejectionGuard();
15
49
  const key = `${host}-${serial}`;
16
50
  if (this.printers.has(key)) {
17
51
  return this.printers.get(key);
@@ -69,36 +103,71 @@ class BambuClientStore {
69
103
  }
70
104
  }
71
105
  export class BambuImplementation extends PrinterImplementation {
72
- constructor(apiClient) {
106
+ constructor(apiClient, confirm) {
73
107
  super(apiClient);
108
+ this.confirm = confirm;
74
109
  this.printerStore = new BambuClientStore();
75
110
  }
111
+ async confirmHardwareAction(message, physicalCheck = false) {
112
+ await requireHumanConfirmation(this.confirm, message, "bambu", physicalCheck);
113
+ }
114
+ finishedJobIdentity(status) {
115
+ return status.raw.gcode_state === "FINISH"
116
+ ? JSON.stringify([status.raw.gcode_file, status.raw.subtask_name, status.raw.task_id, status.raw.subtask_id])
117
+ : undefined;
118
+ }
119
+ async confirmPrintPreflight(serial, status, inspection) {
120
+ const finishedJob = this.finishedJobIdentity(status);
121
+ await this.confirmHardwareAction(`Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
122
+ `Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
123
+ `File SHA-256: ${inspection.sha256}. Confirm the physical spool labels and that the build plate is clear.` +
124
+ (finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : ""), finishedJob !== undefined);
125
+ return finishedJob;
126
+ }
127
+ assertBedClearance(status, confirmedFinishedJob) {
128
+ const current = this.finishedJobIdentity(status);
129
+ if (current !== undefined && current !== confirmedFinishedJob) {
130
+ throw new Error("Printer reports a newly finished job. Confirm that its part and debris have been removed before retrying the print.");
131
+ }
132
+ }
76
133
  async getPrinter(host, serial, token) {
77
134
  return this.printerStore.getPrinter(host, serial, token);
78
135
  }
79
- async resolveProjectFileMetadata(localThreeMfPath, plateIndex) {
80
- const archive = await fs.readFile(localThreeMfPath);
81
- const zip = await JSZip.loadAsync(archive);
82
- const plateEntries = Object.values(zip.files).filter((entry) => !entry.dir && /^Metadata\/plate_\d+\.gcode$/i.test(entry.name));
83
- if (plateEntries.length === 0) {
84
- throw new Error("3MF does not contain any Metadata/plate_<n>.gcode entries. Re-slice and export a printable 3MF.");
85
- }
86
- let selectedEntry = plateEntries.sort((a, b) => a.name.localeCompare(b.name))[0];
87
- if (plateIndex !== undefined) {
88
- const expectedEntryName = `Metadata/plate_${plateIndex + 1}.gcode`;
89
- const matchedEntry = plateEntries.find((entry) => entry.name.toLowerCase() === expectedEntryName.toLowerCase());
90
- if (!matchedEntry) {
91
- const available = plateEntries.map((entry) => entry.name).join(", ");
92
- throw new Error(`Requested plateIndex=${plateIndex} (${expectedEntryName}) not present in 3MF. Available: ${available}`);
93
- }
94
- selectedEntry = matchedEntry;
136
+ /** Safety reads never use the display cache or configured-serial model inference. */
137
+ async getSafetyStatus(host, serial, token) {
138
+ return readFreshPrinterStatus(await this.getPrinter(host, serial, token), serial);
139
+ }
140
+ /** Raw gcode_file printing cannot remap spools: verify the material already at the nozzle. */
141
+ validateLoadedGcodeState(status, inspection) {
142
+ const usedMaterials = inspection.usedFilamentPositions.map((position) => inspection.materials[position]);
143
+ if (new Set(usedMaterials).size !== 1) {
144
+ throw new Error("gcode_file printing cannot verify physical changes between different materials. Use a mapped .3mf project.");
145
+ }
146
+ const loaded = manualHeatingRequirements(status, inspection.model, inspection.nozzleDiameters[0], usedMaterials[0]);
147
+ const mapping = Array(inspection.materials.length).fill(-1);
148
+ inspection.usedFilamentPositions.forEach((position) => { mapping[position] = loaded.amsMapping?.[0] ?? 254; });
149
+ const requirements = {
150
+ ...inspection, useAMS: loaded.useAMS, amsMapping: mapping,
151
+ usedNozzleIndices: loaded.usedNozzleIndices, requireLoadedFilament: true,
152
+ };
153
+ validatePrinterState(status, requirements);
154
+ return requirements;
155
+ }
156
+ async resolveProjectFileMetadata(localThreeMfPath, plateIndex, inspectedPlatePath) {
157
+ const { zip } = await readSafe3mfArchive(localThreeMfPath);
158
+ const expectedEntryName = `Metadata/plate_${(plateIndex ?? 0) + 1}.gcode`;
159
+ if (inspectedPlatePath !== undefined && inspectedPlatePath !== expectedEntryName) {
160
+ throw new Error("Inspected plate path does not match the requested print plate.");
161
+ }
162
+ const selectedEntry = zip.file(inspectedPlatePath ?? expectedEntryName);
163
+ if (!selectedEntry) {
164
+ throw new Error(`Selected inspected plate ${expectedEntryName} is not present in 3MF. Re-slice that plate.`);
95
165
  }
96
166
  const gcodeBuffer = await selectedEntry.async("nodebuffer");
97
- const md5 = createHash("md5").update(gcodeBuffer).digest("hex");
98
167
  return {
99
168
  plateFileName: path.posix.basename(selectedEntry.name),
100
169
  plateInternalPath: selectedEntry.name,
101
- md5,
170
+ md5: createHash("md5").update(gcodeBuffer).digest("hex"),
102
171
  };
103
172
  }
104
173
  async getStatus(host, port, apiKey) {
@@ -144,35 +213,81 @@ export class BambuImplementation extends PrinterImplementation {
144
213
  }
145
214
  catch (error) {
146
215
  console.error(`Failed to get Bambu status for ${serial}:`, error);
147
- return { status: "error", connected: false, error: error.message };
216
+ const message = error.message;
217
+ return {
218
+ status: "error", connected: false, error: message,
219
+ ...(/not authori[sz]ed|\b530\b|login incorrect/i.test(message)
220
+ ? { hint: "The printer rejected its LAN access code. Read the current Access Code on the printer (Settings > Network/WLAN), update BAMBU_TOKEN, and check BAMBU_SERIAL." }
221
+ : {}),
222
+ };
148
223
  }
149
224
  }
150
225
  async print3mf(host, serial, token, options) {
226
+ if (!normalizeModel(options.bambuModel)) {
227
+ throw new Error("A supported bambuModel is required before printing.");
228
+ }
151
229
  if (!options.filePath.toLowerCase().endsWith(".3mf")) {
152
230
  throw new Error("print3mf requires a .3mf input file.");
153
231
  }
154
- const projectMetadata = await this.resolveProjectFileMetadata(options.filePath, options.plateIndex);
155
- const remoteFileName = path.basename(options.filePath);
156
- const remoteProjectPath = `cache/${remoteFileName}`;
157
- // Upload via basic-ftp directly (bypasses bambu-js double-path bug)
158
- await this.ftpUpload(host, token, options.filePath, `/cache/${remoteFileName}`);
159
- // Send project_file command via bambu-node MQTT (bypasses bambu-js
160
- // hardcoded use_ams=true and missing ams_mapping support)
161
- const printer = await this.getPrinter(host, serial, token);
162
- const md5 = options.md5 ?? projectMetadata.md5;
163
- // Build AMS mapping per OpenBambuAPI spec: 5-element array
164
- // [-1,-1,-1,-1,0] means slot 0 only; pad unused slots with -1
165
- let amsMapping;
166
- if (options.amsMapping && options.amsMapping.length > 0) {
167
- amsMapping = Array.from({ length: 5 }, (_, i) => i < options.amsMapping.length ? options.amsMapping[i] : -1);
232
+ validateExpectedPeaks(options.expectedPeaks);
233
+ return withPrinterOperation(host, serial, (assertActive) => withPrintSnapshot(options.filePath, (filePath) => this.print3mfPrepared(host, serial, token, { ...options, filePath }, assertActive)));
234
+ }
235
+ async print3mfPrepared(host, serial, token, options, assertActive) {
236
+ const model = normalizeModel(options.bambuModel);
237
+ // Inspect the private snapshot: these exact bytes are uploaded below.
238
+ const inspection = await inspectPrintFile(options.filePath, {
239
+ model, nozzleDiameters: options.nozzleDiameters, plateIndex: options.plateIndex ?? 0, bedType: options.bedType,
240
+ });
241
+ assertExpectedPeaks(options.expectedPeaks, { nozzle: inspection.maxNozzleTemperature, bed: inspection.maxBedTemperature });
242
+ const projectMetadata = await this.resolveProjectFileMetadata(options.filePath, options.plateIndex, inspection.plateInternalPath);
243
+ const md5 = projectMetadata.md5;
244
+ if (options.md5 !== undefined && options.md5 !== md5) {
245
+ throw new Error("Provided checksum does not match the inspected plate G-code.");
246
+ }
247
+ for (const value of options.amsMapping ?? [])
248
+ validateTrayValue(value, "ams_mapping");
249
+ const useAMS = options.useAMS !== false;
250
+ let checkedMapping;
251
+ if (useAMS) {
252
+ checkedMapping = (options.amsMapping ?? []).slice();
168
253
  }
169
254
  else {
170
- amsMapping = [-1, -1, -1, -1, 0];
255
+ checkedMapping = Array(inspection.materials.length).fill(-1);
256
+ inspection.usedFilamentPositions.forEach((position) => { checkedMapping[position] = 254; });
171
257
  }
258
+ for (const position of inspection.usedFilamentPositions) {
259
+ if (checkedMapping[position] === undefined || checkedMapping[position] < 0) {
260
+ throw new Error(`Missing physical filament mapping for project filament ${position}. ` +
261
+ "Pass ams_mapping with a tray for every used project filament, or use_ams:false for the external spool.");
262
+ }
263
+ }
264
+ // Wire mapping: position = project filament, value = absolute tray. Never
265
+ // truncate a checked mapping; pad to the historical five entries.
266
+ const amsMapping = options.amsMapping && options.amsMapping.length > 0
267
+ ? Array.from({ length: Math.max(5, options.amsMapping.length) }, (_, i) => i < options.amsMapping.length ? options.amsMapping[i] : -1)
268
+ : [-1, -1, -1, -1, 0];
269
+ const requirements = { ...inspection, amsMapping: checkedMapping, useAMS };
270
+ const initialStatus = await this.getSafetyStatus(host, serial, token);
271
+ validatePrinterState(initialStatus, requirements);
272
+ const bedClearance = await this.confirmPrintPreflight(serial, initialStatus, inspection);
273
+ // Recheck after the human response: the printer may have changed meanwhile.
274
+ const confirmedStatus = await this.getSafetyStatus(host, serial, token);
275
+ validatePrinterState(confirmedStatus, requirements);
276
+ this.assertBedClearance(confirmedStatus, bedClearance);
277
+ assertActive();
278
+ const remoteFileName = uniquePrintName(options.filePath);
279
+ const remoteProjectPath = `cache/${remoteFileName}`;
280
+ await this.ftpUpload(host, token, options.filePath, `/${remoteProjectPath}`);
281
+ // Uploads can be long. Recheck current state before the command is dispatched.
282
+ const dispatchStatus = await this.getSafetyStatus(host, serial, token);
283
+ validatePrinterState(dispatchStatus, requirements);
284
+ this.assertBedClearance(dispatchStatus, bedClearance);
285
+ const printer = await this.getPrinter(host, serial, token);
286
+ assertActive();
172
287
  const projectFileCmd = {
173
288
  print: {
174
289
  command: "project_file",
175
- param: `Metadata/${projectMetadata.plateFileName}`,
290
+ param: projectMetadata.plateInternalPath,
176
291
  url: `file:///sdcard/${remoteProjectPath}`,
177
292
  subtask_name: options.projectName,
178
293
  md5,
@@ -182,7 +297,7 @@ export class BambuImplementation extends PrinterImplementation {
182
297
  bed_leveling: options.bedLeveling ?? true,
183
298
  bed_type: options.bedType || "textured_plate",
184
299
  timelapse: options.timelapse ?? false,
185
- use_ams: options.useAMS !== false,
300
+ use_ams: useAMS,
186
301
  ams_mapping: amsMapping,
187
302
  profile_id: "0",
188
303
  project_id: "0",
@@ -191,72 +306,190 @@ export class BambuImplementation extends PrinterImplementation {
191
306
  task_id: "0",
192
307
  },
193
308
  };
309
+ const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
194
310
  await printer.publish(projectFileCmd);
195
- await new Promise((resolve) => setTimeout(resolve, 300));
311
+ await sleep(COMMAND_SETTLE_MS);
312
+ const dispatch = await watcher.settled();
196
313
  return {
197
314
  status: "success",
198
- message: `Uploaded and started 3MF print: ${options.projectName}`,
315
+ dispatch,
316
+ message: dispatch === "started"
317
+ ? `The printer accepted and started the checked 3MF print: ${options.projectName}`
318
+ : `Uploaded the checked 3MF and sent the print command for ${options.projectName}; the printer has not reported starting yet. ` +
319
+ "Check get_printer_status before assuming it is printing.",
199
320
  remoteProjectPath,
200
321
  plateFile: projectMetadata.plateFileName,
201
322
  platePath: projectMetadata.plateInternalPath,
202
323
  md5,
324
+ sha256: inspection.sha256,
203
325
  amsMapping,
326
+ peakTemperatures: {
327
+ nozzle: inspection.maxNozzleTemperature,
328
+ bed: inspection.maxBedTemperature,
329
+ chamber: inspection.maxChamberTemperature,
330
+ },
331
+ };
332
+ }
333
+ /**
334
+ * A published print command is not proof the printer took it. Listen to the
335
+ * reports the printer pushes anyway (no extra pushall polling on weak P1
336
+ * boards): a new command-verification HMS means the firmware refused the
337
+ * command; PREPARE/SLICING/RUNNING means it started. Attach before publishing.
338
+ */
339
+ watchDispatch(printer, host, serial, token, before, assertActive) {
340
+ const windowMs = dispatchCheckMs();
341
+ const stillActive = () => { try {
342
+ assertActive();
343
+ }
344
+ catch {
345
+ throw new Error(DISPATCH_CANCELLED_MESSAGE);
346
+ } };
347
+ if (windowMs === 0 || typeof printer?.on !== "function")
348
+ return { settled: async () => { stillActive(); return "unconfirmed"; } };
349
+ const key = (entry) => `${Number(entry?.attr)}:${Number(entry?.code)}:${entry?.timestamp ?? ""}`;
350
+ const known = new Set((Array.isArray(before?.raw?.hms) ? before.raw.hms : []).map(key));
351
+ const judge = (report) => {
352
+ const hms = Array.isArray(report?.hms) ? report.hms : [];
353
+ if (hms.some((entry) => Number(entry?.attr) === COMMAND_VERIFICATION_HMS.attr &&
354
+ Number(entry?.code) === COMMAND_VERIFICATION_HMS.code && !known.has(key(entry))))
355
+ return "rejected";
356
+ if (["PREPARE", "SLICING", "RUNNING"].includes(String(report?.gcode_state ?? "").toUpperCase()))
357
+ return "started";
358
+ return undefined;
359
+ };
360
+ let resolveOutcome;
361
+ const outcome = new Promise((resolve) => { resolveOutcome = resolve; });
362
+ // cancel_print bumps a local generation; notice it without touching the printer.
363
+ const cancelCheck = setInterval(() => { try {
364
+ assertActive();
365
+ }
366
+ catch {
367
+ resolveOutcome("cancelled");
368
+ } }, 500);
369
+ const onRaw = (topic, payload) => {
370
+ if (topic !== `device/${serial}/report`)
371
+ return;
372
+ let parsed;
373
+ try {
374
+ parsed = JSON.parse(payload.toString());
375
+ }
376
+ catch {
377
+ return;
378
+ }
379
+ const verdict = judge(parsed?.print);
380
+ if (verdict)
381
+ resolveOutcome(verdict);
382
+ };
383
+ printer.on("rawMessage", onRaw);
384
+ const timer = setTimeout(() => resolveOutcome(undefined), windowMs);
385
+ return {
386
+ settled: async () => {
387
+ let verdict;
388
+ try {
389
+ verdict = await outcome;
390
+ }
391
+ finally {
392
+ clearTimeout(timer);
393
+ clearInterval(cancelCheck);
394
+ printer.off?.("rawMessage", onRaw);
395
+ }
396
+ if (verdict === "cancelled")
397
+ throw new Error(DISPATCH_CANCELLED_MESSAGE);
398
+ // Nothing pushed during the window: take one full report, not a polling loop.
399
+ if (verdict === undefined) {
400
+ try {
401
+ verdict = judge((await this.getSafetyStatus(host, serial, token))?.raw);
402
+ }
403
+ catch { /* stays unconfirmed */ }
404
+ }
405
+ if (verdict === "rejected")
406
+ throw new Error(COMMAND_REJECTED_MESSAGE);
407
+ stillActive();
408
+ return verdict ?? "unconfirmed";
409
+ },
204
410
  };
205
411
  }
412
+ /** Stop is never gated or queued behind pending checked operations. */
206
413
  async cancelJob(host, port, apiKey) {
207
414
  const [serial, token] = this.extractBambuCredentials(apiKey);
415
+ cancelPendingPrinterOperations(host, serial);
208
416
  const printer = await this.getPrinter(host, serial, token);
209
417
  try {
210
- await printer.executeCommand(new UpdateStateCommand({ state: "stop" }));
418
+ await publishBambuCommand(printer, "print", "stop");
211
419
  return { status: "success", message: "Cancel command sent successfully." };
212
420
  }
213
421
  catch (error) {
214
422
  throw new Error(`Failed to cancel print: ${error.message}`);
215
423
  }
216
424
  }
217
- async setTemperature(host, port, apiKey, component, temperature) {
218
- const [serial, token] = this.extractBambuCredentials(apiKey);
219
- const printer = await this.getPrinter(host, serial, token);
220
- const normalizedComponent = component.toLowerCase();
221
- const targetTemperature = Math.round(temperature);
222
- if (targetTemperature < 0 || targetTemperature > 300) {
223
- throw new Error("Temperature must be between 0 and 300°C.");
425
+ async setTemperature(host, port, apiKey, component, temperature, options = {}) {
426
+ const normalizedComponent = String(component).toLowerCase();
427
+ const heater = normalizedComponent === "bed" ? "bed" :
428
+ ["extruder", "nozzle", "tool", "tool0"].includes(normalizedComponent) ? "nozzle" : undefined;
429
+ if (!heater) {
430
+ throw new Error(`Unsupported temperature component: ${component}. Use one of: bed, nozzle, extruder.`);
224
431
  }
225
- let gcode;
226
- if (normalizedComponent === "bed") {
227
- gcode = `M140 S${targetTemperature}`;
432
+ // Validate before any credential use or connection.
433
+ if (typeof temperature !== "number" || !Number.isFinite(temperature) || temperature < 0) {
434
+ throw new Error("Temperature must be a finite, non-negative number in °C.");
228
435
  }
229
- else if (normalizedComponent === "extruder" ||
230
- normalizedComponent === "nozzle" ||
231
- normalizedComponent === "tool" ||
232
- normalizedComponent === "tool0") {
233
- gcode = `M104 S${targetTemperature}`;
436
+ const [serial, token] = this.extractBambuCredentials(apiKey);
437
+ const model = normalizeModel(options.bambuModel);
438
+ const material = options.material?.trim();
439
+ const nozzleDiameter = options.nozzleDiameter ?? 0.4;
440
+ if (temperature > 0 && !model)
441
+ throw new Error("bambu_model is required before heating.");
442
+ if (temperature > 0 && heater === "nozzle" && !material) {
443
+ throw new Error("Declare material before nozzle heating, including non-RFID external spools.");
234
444
  }
235
- else {
236
- throw new Error(`Unsupported temperature component: ${component}. Use one of: bed, nozzle, extruder.`);
445
+ if (temperature > 0 && heater === "nozzle" && (!Number.isFinite(nozzleDiameter) || nozzleDiameter <= 0)) {
446
+ throw new Error("nozzle_diameter must be a positive number for nozzle heating.");
237
447
  }
238
- await printer.executeCommand(new GCodeLineCommand({ gcodes: [gcode] }));
239
- return {
240
- status: "success",
241
- message: `Temperature command sent for ${normalizedComponent}.`,
242
- command: gcode,
448
+ const targetTemperature = temperature === 0
449
+ ? 0
450
+ : validateTemperature(heater, temperature, model, material ? [material] : undefined);
451
+ const gcode = `${heater === "bed" ? "M140" : "M104"}${heater === "nozzle" && targetTemperature > 0 ? " T0" : ""} S${targetTemperature}`;
452
+ const send = async (assertActive) => {
453
+ if (targetTemperature > 0) {
454
+ const validateHeatingStatus = (current) => validatePrinterState(current, heater === "nozzle"
455
+ ? manualHeatingRequirements(current, model, nozzleDiameter, material)
456
+ : { model: model, nozzleDiameters: [] });
457
+ validateHeatingStatus(await this.getSafetyStatus(host, serial, token));
458
+ await this.confirmHardwareAction(`Heat ${heater} on ${model.toUpperCase()} (${serial}) to ${targetTemperature}°C?` +
459
+ (material && heater === "nozzle" ? ` Declared material: ${material}. Confirm the physical spool label.` : ""));
460
+ assertActive();
461
+ validateHeatingStatus(await this.getSafetyStatus(host, serial, token));
462
+ }
463
+ const printer = await this.getPrinter(host, serial, token);
464
+ assertActive();
465
+ await publishBambuCommand(printer, "print", "gcode_line", { param: `${gcode}\n` });
466
+ return { status: "success", message: `Temperature command sent for ${normalizedComponent}.`, command: gcode };
243
467
  };
468
+ if (targetTemperature === 0) {
469
+ // Heater-off is never gated and cancels pending checked operations.
470
+ cancelPendingPrinterOperations(host, serial);
471
+ return send(() => undefined);
472
+ }
473
+ return withPrinterOperation(host, serial, send);
244
474
  }
245
475
  async getFiles(host, port, apiKey) {
246
- const [serial, token] = this.extractBambuCredentials(apiKey);
247
- const printer = new BambuPrinter(host, serial, token);
476
+ const [, token] = this.extractBambuCredentials(apiKey);
248
477
  const directories = ["cache", "timelapse", "logs"];
249
478
  const filesByDirectory = {};
250
- await printer.manipulateFiles(async (context) => {
479
+ // bambu-js swallowed listing failures as empty folders. List read-only over
480
+ // the shared FTPS options with absolute paths and surface real failures.
481
+ const client = new FTPClient(15000);
482
+ try {
483
+ await client.access(this.ftpsOptions(host, token));
484
+ const root = await client.list("/");
251
485
  for (const directory of directories) {
252
- try {
253
- filesByDirectory[directory] = await context.readDir(directory);
254
- }
255
- catch {
256
- filesByDirectory[directory] = [];
257
- }
486
+ const present = root.some((entry) => entry.name === directory && (entry.isDirectory || entry.isSymbolicLink));
487
+ filesByDirectory[directory] = present ? (await client.list(`/${directory}`)).map((entry) => entry.name) : [];
258
488
  }
259
- });
489
+ }
490
+ finally {
491
+ client.close();
492
+ }
260
493
  const files = Object.entries(filesByDirectory).flatMap(([directory, names]) => names.map((name) => `${directory}/${name}`));
261
494
  return {
262
495
  files,
@@ -264,67 +497,141 @@ export class BambuImplementation extends PrinterImplementation {
264
497
  };
265
498
  }
266
499
  async getFile(host, port, apiKey, filename) {
267
- const [serial, token] = this.extractBambuCredentials(apiKey);
268
- const printer = new BambuPrinter(host, serial, token);
500
+ const [, token] = this.extractBambuCredentials(apiKey);
269
501
  const normalized = filename.replace(/^\/+/, "");
270
502
  const directory = path.posix.dirname(normalized) === "." ? "cache" : path.posix.dirname(normalized);
271
503
  const baseName = path.posix.basename(normalized);
272
504
  let exists = false;
273
- await printer.manipulateFiles(async (context) => {
274
- const entries = await context.readDir(directory);
275
- exists = entries.includes(baseName);
276
- });
505
+ const client = new FTPClient(15000);
506
+ try {
507
+ await client.access(this.ftpsOptions(host, token));
508
+ const entries = await client.list(`/${directory}`);
509
+ exists = entries.some(entry => entry.name === baseName);
510
+ }
511
+ finally {
512
+ client.close();
513
+ }
277
514
  return {
278
515
  name: `${directory}/${baseName}`,
279
516
  exists,
280
517
  };
281
518
  }
282
- async uploadFile(host, port, apiKey, filePath, filename, print) {
519
+ async uploadFile(host, port, apiKey, filePath, filename, print, options = {}) {
283
520
  await fs.access(filePath);
284
521
  const [serial, token] = this.extractBambuCredentials(apiKey);
285
- const normalizedFileName = filename.replace(/^\/+/, "");
286
- const remotePath = normalizedFileName.includes("/")
287
- ? normalizedFileName
288
- : `cache/${normalizedFileName}`;
289
- // Use direct FTP upload (bypasses bambu-js double-path bug)
522
+ const remotePath = normalizedRemotePath(filename);
523
+ if (!print) {
524
+ // Use direct FTP upload (bypasses bambu-js double-path bug)
525
+ await this.ftpUpload(host, token, filePath, `/${remotePath}`);
526
+ return {
527
+ status: "success",
528
+ uploaded: true,
529
+ remotePath,
530
+ printRequested: false,
531
+ };
532
+ }
533
+ if (!remotePath.toLowerCase().endsWith(".gcode") || !filePath.toLowerCase().endsWith(".gcode")) {
534
+ throw new Error("Automatic print after upload requires .gcode. Use print_3mf for inspected .3mf project prints.");
535
+ }
536
+ const model = normalizeModel(options.bambuModel);
537
+ if (!model)
538
+ throw new Error("A supported bambuModel is required before printing.");
539
+ validateExpectedPeaks(options.expectedPeaks);
540
+ return withPrinterOperation(host, serial, (assertActive) => withPrintSnapshot(filePath, (snapshot) => this.printRawPrepared(host, serial, token, snapshot, remotePath, model, assertActive, options.expectedPeaks)));
541
+ }
542
+ async printRawPrepared(host, serial, token, filePath, filename, model, assertActive, expectedPeaks) {
543
+ const inspection = await inspectPrintFile(filePath, { model });
544
+ assertExpectedPeaks(expectedPeaks, { nozzle: inspection.maxNozzleTemperature, bed: inspection.maxBedTemperature });
545
+ if (inspection.selectsAms) {
546
+ throw new Error("Raw G-code with AMS selection requires a .3mf project export with verified physical slot mappings.");
547
+ }
548
+ const initialStatus = await this.getSafetyStatus(host, serial, token);
549
+ this.validateLoadedGcodeState(initialStatus, inspection);
550
+ const bedClearance = await this.confirmPrintPreflight(serial, initialStatus, inspection);
551
+ const confirmedStatus = await this.getSafetyStatus(host, serial, token);
552
+ this.validateLoadedGcodeState(confirmedStatus, inspection);
553
+ this.assertBedClearance(confirmedStatus, bedClearance);
554
+ assertActive();
555
+ // Start a uniquely named copy of the inspected bytes, never a name that can change.
556
+ const remotePath = path.posix.join(path.posix.dirname(filename), uniquePrintName(filename));
290
557
  await this.ftpUpload(host, token, filePath, `/${remotePath}`);
291
- const response = {
558
+ const dispatchStatus = await this.getSafetyStatus(host, serial, token);
559
+ this.validateLoadedGcodeState(dispatchStatus, inspection);
560
+ this.assertBedClearance(dispatchStatus, bedClearance);
561
+ const printer = await this.getPrinter(host, serial, token);
562
+ assertActive();
563
+ const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
564
+ await publishBambuCommand(printer, "print", "gcode_file", { param: remotePath });
565
+ const dispatch = await watcher.settled();
566
+ return {
292
567
  status: "success",
293
568
  uploaded: true,
569
+ printRequested: true,
570
+ dispatch,
294
571
  remotePath,
295
- printRequested: print,
572
+ sha256: inspection.sha256,
573
+ message: dispatch === "started"
574
+ ? `The printer accepted and started ${remotePath}.`
575
+ : `Sent the print command for ${remotePath}; the printer has not reported starting yet. Check get_printer_status before assuming it is printing.`,
296
576
  };
297
- if (print) {
298
- if (remotePath.toLowerCase().endsWith(".gcode")) {
299
- response.startResult = await this.startJob(host, port, apiKey, remotePath);
300
- }
301
- else if (remotePath.toLowerCase().endsWith(".3mf")) {
302
- response.note =
303
- "3MF upload complete. Use print_3mf to start a project print with plate and metadata options.";
304
- }
305
- else {
306
- throw new Error("Automatic print after upload supports .gcode only. Use print_3mf for .3mf project prints.");
307
- }
308
- }
309
- return response;
310
577
  }
311
- async startJob(host, port, apiKey, filename) {
578
+ async startJob(host, port, apiKey, filename, options = {}) {
312
579
  if (filename.toLowerCase().endsWith(".3mf")) {
313
580
  throw new Error("Use print_3mf for .3mf project files.");
314
581
  }
315
582
  const [serial, token] = this.extractBambuCredentials(apiKey);
316
- const printer = await this.getPrinter(host, serial, token);
317
- const normalizedFileName = filename.replace(/^\/+/, "");
318
- const remoteFile = normalizedFileName.includes("/")
319
- ? normalizedFileName
320
- : `cache/${normalizedFileName}`;
321
- await printer.executeCommand(new GCodeFileCommand({ fileName: remoteFile }));
583
+ const model = normalizeModel(options.bambuModel);
584
+ if (!model)
585
+ throw new Error("A supported bambuModel is required before printing.");
586
+ const remotePath = normalizedRemotePath(filename);
587
+ if (!remotePath.toLowerCase().endsWith(".gcode")) {
588
+ throw new Error("Remote starts require inspectable .gcode. Use print_3mf with the local project for .3mf printing.");
589
+ }
590
+ validateExpectedPeaks(options.expectedPeaks);
591
+ return withPrinterOperation(host, serial, async (assertActive) => {
592
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bambu-remote-check-"));
593
+ const localPath = path.join(directory, path.basename(remotePath));
594
+ try {
595
+ await this.ftpDownload(host, token, remotePath, localPath);
596
+ // Send a unique copy of the downloaded, inspected bytes. Starting the
597
+ // original remote name would allow it to change after inspection.
598
+ return await withPrintSnapshot(localPath, (snapshot) => this.printRawPrepared(host, serial, token, snapshot, remotePath, model, assertActive, options.expectedPeaks));
599
+ }
600
+ finally {
601
+ await fs.rm(directory, { recursive: true, force: true });
602
+ }
603
+ });
604
+ }
605
+ ftpsOptions(host, token) {
322
606
  return {
323
- status: "success",
324
- message: `Start command sent for ${remoteFile}.`,
325
- file: remoteFile,
607
+ host,
608
+ port: 990,
609
+ user: "bblp",
610
+ password: token,
611
+ secure: "implicit",
612
+ // basic-ftp passes getSession() to the data socket. Keep both channels
613
+ // on TLS 1.2, where that session is available at handshake completion;
614
+ // TLS 1.3 tickets arrive later and can break Bambu's required reuse.
615
+ // Node also binds resumable sessions to the host identity. basic-ftp
616
+ // retains these options for its wrapped data socket, so explicitly keep
617
+ // the same host there (nodejs/node#64402 affects implicit FTPS).
618
+ secureOptions: { host, rejectUnauthorized: false, minVersion: "TLSv1.2", maxVersion: "TLSv1.2" },
326
619
  };
327
620
  }
621
+ async ftpDownload(host, token, remotePath, localPath) {
622
+ const client = new FTPClient(15000);
623
+ try {
624
+ await client.access(this.ftpsOptions(host, token));
625
+ const size = await client.size(`/${remotePath}`);
626
+ if (!Number.isFinite(size) || size <= 0 || size > MAX_PRINT_FILE_BYTES) {
627
+ throw new Error("Remote print file is empty or exceeds the 256 MiB inspection limit.");
628
+ }
629
+ await client.downloadTo(localPath, `/${remotePath}`);
630
+ }
631
+ finally {
632
+ client.close();
633
+ }
634
+ }
328
635
  /**
329
636
  * Upload a file to the printer via FTP using basic-ftp directly.
330
637
  * Bypasses bambu-js's sendFile which has a double-path bug (ensureDir CDs
@@ -334,20 +641,7 @@ export class BambuImplementation extends PrinterImplementation {
334
641
  async ftpUpload(host, token, localPath, remotePath) {
335
642
  const client = new FTPClient(15000);
336
643
  try {
337
- await client.access({
338
- host,
339
- port: 990,
340
- user: "bblp",
341
- password: token,
342
- secure: "implicit",
343
- // basic-ftp passes getSession() to the data socket. Keep both channels
344
- // on TLS 1.2, where that session is available at handshake completion;
345
- // TLS 1.3 tickets arrive later and can break Bambu's required reuse.
346
- // Node also binds resumable sessions to the host identity. basic-ftp
347
- // retains these options for its wrapped data socket, so explicitly keep
348
- // the same host there (nodejs/node#64402 affects implicit FTPS).
349
- secureOptions: { host, rejectUnauthorized: false, minVersion: "TLSv1.2", maxVersion: "TLSv1.2" },
350
- });
644
+ await client.access(this.ftpsOptions(host, token));
351
645
  // Use absolute path to avoid CWD side-effects
352
646
  const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
353
647
  const remoteDir = path.posix.dirname(absoluteRemote);