mcp-3d-printer-server 1.2.5 → 1.2.11

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