bambu-printer-mcp 1.1.12 → 1.1.13

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.
@@ -1,10 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { readFreshPrinterStatus } from "../safety/printer-state.js";
2
3
  import fs from "node:fs/promises";
3
4
  import { readFileSync, existsSync } from "node:fs";
4
5
  import path from "node:path";
5
6
  import os from "node:os";
6
7
  import { Readable } from "node:stream";
7
- import JSZip from "jszip";
8
+ import { readSafe3mfArchive } from "../safety/archive.js";
9
+ import { inspectPrintFile } from "../safety/print-file.js";
10
+ import { normalizeModel, validateTemperature } from "../safety/limits.js";
11
+ import { validatePrinterState, manualHeatingRequirements } from "../safety/printer-state.js";
12
+ import { withPrinterOperation, withPrintSnapshot, uniquePrintName, normalizedRemotePath, cancelPendingPrinterOperations } from "../safety/artifact.js";
8
13
  import { Client as FTPClient } from "basic-ftp";
9
14
  import { BambuPrinter } from "bambu-js";
10
15
  import * as mqtt from "mqtt";
@@ -347,28 +352,66 @@ class BambuClientStore {
347
352
  }
348
353
  }
349
354
  export class BambuImplementation {
350
- constructor() {
355
+ constructor(confirm) {
356
+ this.confirm = confirm;
357
+ this.checkedJobs = new Map();
358
+ this.clearedErrors = new Map();
351
359
  this.printerStore = new BambuClientStore();
352
360
  }
361
+ async confirmHardwareAction(message, physicalCheck = false) {
362
+ if (!physicalCheck && process.env.BAMBU_REQUIRE_CONFIRMATION === "0")
363
+ return;
364
+ if (!this.confirm || !(await this.confirm(message))) {
365
+ throw new Error("Hardware safety confirmation was declined or unavailable. Use an MCP client with elicitation support. No command was sent.");
366
+ }
367
+ }
368
+ finishedJobIdentity(status) {
369
+ return status.raw.gcode_state === "FINISH" ? JSON.stringify([status.raw.gcode_file, status.raw.subtask_name, status.raw.task_id, status.raw.subtask_id]) : undefined;
370
+ }
371
+ async confirmPrintPreflight(serial, status, inspection) {
372
+ const finishedJob = this.finishedJobIdentity(status);
373
+ const cleared = this.clearedErrors.get(serial);
374
+ await this.confirmHardwareAction(`Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
375
+ `Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
376
+ `File SHA-256: ${inspection.sha256}. Confirm the physical spool labels and that the build plate is clear.` +
377
+ (finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : "") +
378
+ (cleared ? ` Previously cleared hardware codes: ${cleared.join(", ")}. Confirm their physical causes have been resolved.` : ""), finishedJob !== undefined || !!cleared);
379
+ return finishedJob;
380
+ }
381
+ assertBedClearance(status, confirmedFinishedJob) {
382
+ const current = this.finishedJobIdentity(status);
383
+ if (current !== undefined && current !== confirmedFinishedJob) {
384
+ throw new Error("Printer reports a newly finished job. Confirm that its part and debris have been removed before retrying the print.");
385
+ }
386
+ }
353
387
  async getPrinter(host, serial, token) {
354
388
  return this.printerStore.getPrinter(host, serial, token);
355
389
  }
356
- async resolveProjectFileMetadata(localThreeMfPath, plateIndex) {
357
- const archive = await fs.readFile(localThreeMfPath);
358
- const zip = await JSZip.loadAsync(archive);
359
- const plateEntries = Object.values(zip.files).filter((entry) => !entry.dir && /^Metadata\/plate_\d+\.gcode$/i.test(entry.name));
360
- if (plateEntries.length === 0) {
361
- throw new Error("3MF does not contain any Metadata/plate_<n>.gcode entries. Re-slice and export a printable 3MF.");
362
- }
363
- let selectedEntry = plateEntries.sort((a, b) => a.name.localeCompare(b.name))[0];
364
- if (plateIndex !== undefined) {
365
- const expectedEntryName = `Metadata/plate_${plateIndex + 1}.gcode`;
366
- const matchedEntry = plateEntries.find((entry) => entry.name.toLowerCase() === expectedEntryName.toLowerCase());
367
- if (!matchedEntry) {
368
- const available = plateEntries.map((entry) => entry.name).join(", ");
369
- throw new Error(`Requested plateIndex=${plateIndex} (${expectedEntryName}) not present in 3MF. Available: ${available}`);
370
- }
371
- selectedEntry = matchedEntry;
390
+ /** Internal handoff from a successful inspected transport; never exposed as an MCP tool. */
391
+ recordCheckedJob(host, serial, remotePath, requirements) {
392
+ this.checkedJobs.set(`${host}\n${serial}`, { remotePath, requirements: structuredClone(requirements) });
393
+ this.clearedErrors.delete(serial);
394
+ }
395
+ validateLoadedGcodeState(status, inspection) {
396
+ const usedMaterials = inspection.usedFilamentPositions.map(position => inspection.materials[position]);
397
+ if (new Set(usedMaterials).size !== 1)
398
+ throw new Error("gcode_file printing cannot verify physical changes between different materials. Use a mapped .3mf project.");
399
+ const loaded = manualHeatingRequirements(status, inspection.model, inspection.nozzleDiameters[0], usedMaterials[0]);
400
+ const mapping = Array(inspection.materials.length).fill(-1);
401
+ inspection.usedFilamentPositions.forEach(position => { mapping[position] = loaded.amsMapping?.[0] ?? 254; });
402
+ const requirements = { ...inspection, useAMS: loaded.useAMS, amsMapping: mapping, usedNozzleIndices: loaded.usedNozzleIndices, requireLoadedFilament: true };
403
+ validatePrinterState(status, requirements);
404
+ return requirements;
405
+ }
406
+ async resolveProjectFileMetadata(localThreeMfPath, plateIndex, inspectedPlatePath) {
407
+ const { zip } = await readSafe3mfArchive(localThreeMfPath);
408
+ const expectedEntryName = `Metadata/plate_${(plateIndex ?? 0) + 1}.gcode`;
409
+ if (inspectedPlatePath !== undefined && inspectedPlatePath !== expectedEntryName) {
410
+ throw new Error("Inspected plate path does not match the requested print plate.");
411
+ }
412
+ const selectedEntry = zip.file(inspectedPlatePath ?? expectedEntryName);
413
+ if (!selectedEntry) {
414
+ throw new Error(`Selected inspected plate ${expectedEntryName} is not present in 3MF. Re-slice that plate.`);
372
415
  }
373
416
  const gcodeBuffer = await selectedEntry.async("nodebuffer");
374
417
  const md5 = createHash("md5").update(gcodeBuffer).digest("hex");
@@ -420,6 +463,10 @@ export class BambuImplementation {
420
463
  usedFilamentPositions,
421
464
  };
422
465
  }
466
+ /** Safety reads never use the display cache or configured-serial model inference. */
467
+ async getSafetyStatus(host, serial, token) {
468
+ return readFreshPrinterStatus(await this.getPrinter(host, serial, token), serial);
469
+ }
423
470
  async getStatus(host, serial, token) {
424
471
  try {
425
472
  const printer = await this.getPrinter(host, serial, token);
@@ -467,13 +514,19 @@ export class BambuImplementation {
467
514
  }
468
515
  async print3mf(host, serial, token, options) {
469
516
  assertDirectPrintSupported(options.bambuModel, serial);
517
+ return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(options.filePath, filePath => this.print3mfPrepared(host, serial, token, { ...options, filePath }, assertActive)));
518
+ }
519
+ async print3mfPrepared(host, serial, token, options, assertActive) {
520
+ const model = normalizeModel(options.bambuModel);
521
+ if (!model)
522
+ throw new Error("A supported bambuModel is required before printing.");
470
523
  if (!options.filePath.toLowerCase().endsWith(".3mf")) {
471
524
  throw new Error("print3mf requires a .3mf input file.");
472
525
  }
473
526
  // Normalise remote filename: collapse double-extension artifacts like
474
527
  // "Cube.gcode.3mf.gcode.3mf" -> "Cube.gcode.3mf" so firmware can identify
475
528
  // the container format from the extension.
476
- let remoteFileName = path.basename(options.filePath);
529
+ let remoteFileName = uniquePrintName(options.filePath);
477
530
  remoteFileName = remoteFileName.replace(/\.gcode\.3mf\.gcode\.3mf$/i, ".gcode.3mf");
478
531
  // H2-series printers land files at the FTP root and reference them via ftp:///<name>.
479
532
  // Full-size A1 uses SD root/project_file (reported on firmware 01.08.01.00).
@@ -493,31 +546,15 @@ export class BambuImplementation {
493
546
  const projectUrl = usesH2ProjectFile
494
547
  ? `ftp:///${remoteProjectPath}`
495
548
  : `file:///sdcard/${remoteProjectPath}`;
496
- // Upload via basic-ftp directly (bypasses bambu-js double-path bug)
497
- await this.ftpUpload(host, token, options.filePath, remoteUploadPath);
498
- // Pre-sliced .gcode.3mf files: routing depends on firmware generation.
499
- // Legacy P1/X1/A1 mini: project_file returns 405004002 for .gcode.3mf (firmware
500
- // doesn't recognise the container), so use gcode_file instead.
501
- // H2-series and P2S: gcode_file is not supported (P2S answers 0500-4002
502
- // "Unsupported file path or name"); project_file works because the
503
- // firmware can open the zip and find Metadata/plate_<n>.gcode directly.
504
- if (options.filePath.toLowerCase().endsWith(".gcode.3mf")) {
505
- if (!usesH2ProjectFile && !isA1) {
506
- const printer = await this.getPrinter(host, serial, token);
507
- await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
508
- return {
509
- status: "success",
510
- message: `Uploaded and started gcode.3mf print: ${options.projectName}`,
511
- remoteProjectPath,
512
- };
513
- }
514
- // H2-series and P2S: fall through to project_file path below
515
- }
516
- const projectMetadata = await this.resolveProjectFileMetadata(options.filePath, options.plateIndex);
549
+ const inspection = await inspectPrintFile(options.filePath, {
550
+ model, nozzleDiameters: options.nozzleDiameters, plateIndex: options.plateIndex ?? 0, bedType: options.bedType,
551
+ });
552
+ const projectMetadata = await this.resolveProjectFileMetadata(options.filePath, options.plateIndex, inspection.plateInternalPath);
517
553
  // Send project_file command via bambu-node MQTT (bypasses bambu-js
518
554
  // hardcoded use_ams=true and missing ams_mapping support)
519
- const printer = await this.getPrinter(host, serial, token);
520
- const md5 = options.md5 ?? projectMetadata.md5;
555
+ const md5 = projectMetadata.md5;
556
+ if (options.md5 !== undefined && options.md5 !== md5)
557
+ throw new Error("Provided checksum does not match the inspected plate G-code.");
521
558
  // Build AMS mapping.
522
559
  //
523
560
  // Convention: position = project-level filament index, value = absolute
@@ -566,14 +603,14 @@ export class BambuImplementation {
566
603
  });
567
604
  }
568
605
  else {
569
- if (isH2 && projectMetadata.usedFilamentPositions.length > 0) {
606
+ if (options.useAMS !== false && projectMetadata.usedFilamentPositions.length > 0) {
570
607
  throw new Error(`H2 project_file requires amsSlots or amsMapping for sliced files with declared filaments. Plate uses project filament positions ${JSON.stringify(projectMetadata.usedFilamentPositions)}.`);
571
608
  }
572
609
  const positions = projectMetadata.usedFilamentPositions;
573
610
  const projectLen = Math.max(projectMetadata.projectFilamentCount, ...positions.map((p) => p + 1), 1);
574
611
  baseMapping = Array(projectLen).fill(-1);
575
612
  positions.forEach((pos, i) => {
576
- baseMapping[pos] = i;
613
+ baseMapping[pos] = options.useAMS === false ? 254 : i;
577
614
  });
578
615
  }
579
616
  let amsMapping;
@@ -595,6 +632,54 @@ export class BambuImplementation {
595
632
  amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
596
633
  amsMapping2 = [];
597
634
  }
635
+ for (const position of inspection.usedFilamentPositions) {
636
+ if (baseMapping[position] === undefined || baseMapping[position] < 0) {
637
+ throw new Error(`Missing physical filament mapping for project filament ${position}.`);
638
+ }
639
+ if (options.useAMS === false && baseMapping[position] !== 254) {
640
+ throw new Error("External-spool printing requires external spool mapping (254).");
641
+ }
642
+ }
643
+ const legacyContainer = options.filePath.toLowerCase().endsWith(".gcode.3mf") && !usesH2ProjectFile && !isA1;
644
+ if (legacyContainer) {
645
+ if (options.useAMS !== false || inspection.selectsAms) {
646
+ throw new Error("Legacy .gcode.3mf transport cannot apply verified AMS mappings. Export a .3mf project for AMS printing, or use an external-spool-only job with use_ams:false.");
647
+ }
648
+ const { zip: archive } = await readSafe3mfArchive(options.filePath);
649
+ const plates = Object.values(archive.files).filter(entry => !entry.dir && /^Metadata\/plate_\d+\.gcode$/i.test(entry.name));
650
+ if (plates.length !== 1 || inspection.plateInternalPath?.toLowerCase() !== "metadata/plate_1.gcode") {
651
+ throw new Error("Legacy gcode_file printing requires a single plate_1.gcode. Export only the selected plate or use a .3mf project_file export.");
652
+ }
653
+ }
654
+ let requirements = { ...inspection, amsMapping: baseMapping, useAMS: options.useAMS !== false };
655
+ const initialStatus = await this.getSafetyStatus(host, serial, token);
656
+ if (legacyContainer)
657
+ requirements = this.validateLoadedGcodeState(initialStatus, inspection);
658
+ else
659
+ validatePrinterState(initialStatus, requirements);
660
+ const bedClearance = await this.confirmPrintPreflight(serial, initialStatus, inspection);
661
+ const confirmedStatus = await this.getSafetyStatus(host, serial, token);
662
+ if (legacyContainer)
663
+ requirements = this.validateLoadedGcodeState(confirmedStatus, inspection);
664
+ else
665
+ validatePrinterState(confirmedStatus, requirements);
666
+ this.assertBedClearance(confirmedStatus, bedClearance);
667
+ assertActive();
668
+ await this.ftpUpload(host, token, options.filePath, remoteUploadPath);
669
+ // Uploads can be long. Recheck current state before the command is dispatched.
670
+ const dispatchStatus = await this.getSafetyStatus(host, serial, token);
671
+ if (legacyContainer)
672
+ requirements = this.validateLoadedGcodeState(dispatchStatus, inspection);
673
+ else
674
+ validatePrinterState(dispatchStatus, requirements);
675
+ this.assertBedClearance(dispatchStatus, bedClearance);
676
+ const printer = await this.getPrinter(host, serial, token);
677
+ assertActive();
678
+ if (legacyContainer) {
679
+ await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
680
+ this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
681
+ return { status: "success", message: `Uploaded and started gcode.3mf print: ${options.projectName}`, remoteProjectPath };
682
+ }
598
683
  const b = (v) => (v ? 1 : 0);
599
684
  let projectFileCmd;
600
685
  if (usesH2ProjectFile) {
@@ -603,7 +688,7 @@ export class BambuImplementation {
603
688
  print: {
604
689
  sequence_id: "0",
605
690
  command: "project_file",
606
- param: `Metadata/${projectMetadata.plateFileName}`,
691
+ param: projectMetadata.plateInternalPath,
607
692
  url: projectUrl,
608
693
  file: remoteFileName,
609
694
  md5,
@@ -633,9 +718,9 @@ export class BambuImplementation {
633
718
  projectFileCmd = {
634
719
  print: {
635
720
  command: "project_file",
636
- param: `Metadata/${projectMetadata.plateFileName}`,
721
+ param: projectMetadata.plateInternalPath,
637
722
  url: projectUrl,
638
- subtask_name: options.projectName,
723
+ subtask_name: remoteFileName.replace(/\.3mf$/i, ""),
639
724
  md5,
640
725
  flow_cali: options.flowCalibration ?? true,
641
726
  layer_inspect: options.layerInspect ?? true,
@@ -654,6 +739,7 @@ export class BambuImplementation {
654
739
  };
655
740
  }
656
741
  await printer.publish(projectFileCmd);
742
+ this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
657
743
  await new Promise((resolve) => setTimeout(resolve, 300));
658
744
  return {
659
745
  status: "success",
@@ -666,9 +752,12 @@ export class BambuImplementation {
666
752
  };
667
753
  }
668
754
  async cancelJob(host, serial, token) {
755
+ cancelPendingPrinterOperations(host, serial);
756
+ this.checkedJobs.delete(`${host}\n${serial}`);
669
757
  const printer = await this.getPrinter(host, serial, token);
670
758
  try {
671
759
  await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
760
+ this.checkedJobs.delete(`${host}\n${serial}`);
672
761
  return { status: "success", message: "Cancel command sent successfully." };
673
762
  }
674
763
  catch (error) {
@@ -686,25 +775,43 @@ export class BambuImplementation {
686
775
  }
687
776
  }
688
777
  async resumeJob(host, serial, token) {
689
- const printer = await this.getPrinter(host, serial, token);
690
- try {
778
+ return withPrinterOperation(host, serial, async (assertActive) => {
779
+ const checked = this.checkedJobs.get(`${host}\n${serial}`);
780
+ if (!checked)
781
+ throw new Error("Resume requires a job inspected and started by this server instance. Verify other jobs on the printer before resuming them there.");
782
+ const status = await this.getSafetyStatus(host, serial, token);
783
+ validatePrinterState(status, { ...checked.requirements, requireIdle: false });
784
+ if (status.raw.gcode_state !== "PAUSE")
785
+ throw new Error("Resume requires a freshly reported paused job.");
786
+ const stem = (value) => path.posix.basename(value).replace(/(?:\.gcode)?\.3mf$|\.gcode$/i, "");
787
+ const names = [status.raw.gcode_file, status.raw.subtask_name].filter(value => typeof value === "string");
788
+ if (!names.some(value => stem(value) === stem(checked.remotePath)))
789
+ throw new Error("Paused job identity does not match this server's inspected artifact.");
790
+ const printer = await this.getPrinter(host, serial, token);
791
+ assertActive();
691
792
  await invokeWithoutAck(printer, new UpdateStateCommand({ state: "resume" }));
692
793
  return { status: "success", message: "Resume command sent successfully." };
693
- }
694
- catch (error) {
695
- throw new Error(`Failed to resume print: ${error.message}`);
696
- }
794
+ });
697
795
  }
698
796
  async clearHmsErrors(host, serial, token) {
699
- const printer = await this.getPrinter(host, serial, token);
700
- await printer.publish({
701
- print: {
702
- command: "clean_print_error",
703
- sequence_id: "0",
704
- },
797
+ return withPrinterOperation(host, serial, async (assertActive) => {
798
+ const codesFor = (status) => {
799
+ if (!Array.isArray(status.raw?.hms) || status.raw?.print_error === undefined)
800
+ throw new Error("Fresh error codes are required before clearing hardware errors.");
801
+ return [`print_error:${status.raw.print_error}`, ...status.raw.hms.map((entry) => `hms:${entry.attr}:${entry.code}`)].sort();
802
+ };
803
+ const codes = codesFor(await this.getSafetyStatus(host, serial, token));
804
+ await this.confirmHardwareAction(`Clear reported hardware errors on ${serial}: ${codes.join(", ")}? Inspect the printer and resolve the physical cause first. Confirm only after removing obstructions and correcting the fault.`, true);
805
+ const current = codesFor(await this.getSafetyStatus(host, serial, token));
806
+ if (JSON.stringify(current) !== JSON.stringify(codes))
807
+ throw new Error("Hardware errors changed during confirmation. Inspect the new report before retrying.");
808
+ const printer = await this.getPrinter(host, serial, token);
809
+ assertActive();
810
+ await printer.publish({ print: { command: "clean_print_error", sequence_id: "0" } });
811
+ this.clearedErrors.set(serial, codes);
812
+ await sleep(COMMAND_SETTLE_MS);
813
+ return { status: "success", message: "Confirmed HMS clear command sent. The next print requires acknowledgment of these cleared codes.", cleared_codes: codes };
705
814
  });
706
- await sleep(COMMAND_SETTLE_MS);
707
- return { status: "success", message: "HMS clear command sent." };
708
815
  }
709
816
  async setPrintSpeed(host, serial, token, speedMode) {
710
817
  const printer = await this.getPrinter(host, serial, token);
@@ -780,32 +887,46 @@ export class BambuImplementation {
780
887
  slot_id: normalizedSlotId,
781
888
  };
782
889
  }
783
- async setTemperature(host, serial, token, component, temperature) {
784
- const printer = await this.getPrinter(host, serial, token);
890
+ async setTemperature(host, serial, token, component, temperature, bambuModel, material, nozzleDiameter = 0.4) {
785
891
  const normalizedComponent = component.toLowerCase();
786
- const targetTemperature = Math.round(temperature);
787
- if (targetTemperature < 0 || targetTemperature > 300) {
788
- throw new Error("Temperature must be between 0 and 300°C.");
892
+ const heater = normalizedComponent === "bed" ? "bed" :
893
+ ["extruder", "nozzle", "tool", "tool0"].includes(normalizedComponent) ? "nozzle" : undefined;
894
+ if (!heater) {
895
+ throw new Error(`Unsupported temperature component: ${component}. Use one of: bed, nozzle, extruder.`);
789
896
  }
790
- let gcode;
791
- if (normalizedComponent === "bed") {
792
- gcode = `M140 S${targetTemperature}`;
897
+ if (typeof temperature !== "number" || !Number.isFinite(temperature) || temperature < 0) {
898
+ throw new Error("Temperature must be a finite, non-negative number in °C.");
793
899
  }
794
- else if (normalizedComponent === "extruder" ||
795
- normalizedComponent === "nozzle" ||
796
- normalizedComponent === "tool" ||
797
- normalizedComponent === "tool0") {
798
- gcode = `M104 S${targetTemperature}`;
900
+ const model = normalizeModel(bambuModel);
901
+ if (temperature > 0 && !model)
902
+ throw new Error("bambu_model is required before heating.");
903
+ if (temperature > 0 && heater === "nozzle" && !material?.trim()) {
904
+ throw new Error("Declare material before nozzle heating, including non-RFID external spools.");
799
905
  }
800
- else {
801
- throw new Error(`Unsupported temperature component: ${component}. Use one of: bed, nozzle, extruder.`);
802
- }
803
- await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
804
- return {
805
- status: "success",
806
- message: `Temperature command sent for ${normalizedComponent}.`,
807
- command: gcode,
906
+ const targetTemperature = temperature === 0 ? 0 : validateTemperature(heater, temperature, model, material ? [material] : undefined);
907
+ const gcode = `${heater === "bed" ? "M140" : "M104"}${heater === "nozzle" && targetTemperature > 0 ? " T0" : ""} S${targetTemperature}`;
908
+ const send = async (assertActive) => {
909
+ if (targetTemperature > 0) {
910
+ const status = await this.getSafetyStatus(host, serial, token);
911
+ validatePrinterState(status, heater === "nozzle"
912
+ ? manualHeatingRequirements(status, model, nozzleDiameter, material)
913
+ : { model: model, nozzleDiameters: [] });
914
+ await this.confirmHardwareAction(`Heat ${heater} on ${model.toUpperCase()} (${serial}) to ${targetTemperature}°C?${material ? ` Declared material: ${material}. Confirm the physical spool label.` : ""}`);
915
+ const confirmedStatus = await this.getSafetyStatus(host, serial, token);
916
+ validatePrinterState(confirmedStatus, heater === "nozzle"
917
+ ? manualHeatingRequirements(confirmedStatus, model, nozzleDiameter, material)
918
+ : { model: model, nozzleDiameters: [] });
919
+ }
920
+ const printer = await this.getPrinter(host, serial, token);
921
+ assertActive();
922
+ await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
923
+ return { status: "success", message: `Temperature command sent for ${normalizedComponent}.`, command: gcode };
808
924
  };
925
+ if (targetTemperature === 0) {
926
+ cancelPendingPrinterOperations(host, serial);
927
+ return send(() => undefined);
928
+ }
929
+ return withPrinterOperation(host, serial, send);
809
930
  }
810
931
  async setFanSpeed(host, serial, token, fan, speed) {
811
932
  const printer = await this.getPrinter(host, serial, token);
@@ -940,52 +1061,119 @@ export class BambuImplementation {
940
1061
  };
941
1062
  }
942
1063
  async uploadFile(host, serial, token, filePath, filename, print, bambuModel) {
943
- if (print)
944
- assertDirectPrintSupported(bambuModel, serial);
945
- await fs.access(filePath);
946
- const normalizedFileName = filename.replace(/^\/+/, "");
947
- const remotePath = normalizedFileName.includes("/")
948
- ? normalizedFileName
949
- : `cache/${normalizedFileName}`;
950
- // Use direct FTP upload (bypasses bambu-js double-path bug)
951
- await this.ftpUpload(host, token, filePath, `/${remotePath}`);
952
- const response = {
953
- status: "success",
954
- uploaded: true,
955
- remotePath,
956
- printRequested: print,
957
- };
958
- if (print) {
959
- if (remotePath.toLowerCase().endsWith(".gcode")) {
960
- response.startResult = await this.startJob(host, serial, token, remotePath, bambuModel);
961
- }
962
- else if (remotePath.toLowerCase().endsWith(".3mf")) {
963
- response.note =
964
- "3MF upload complete. Use print_3mf to start a project print with plate and metadata options.";
965
- }
966
- else {
967
- throw new Error("Automatic print after upload supports .gcode only. Use print_3mf for .3mf project prints.");
1064
+ const remotePath = normalizedRemotePath(filename);
1065
+ if (!print) {
1066
+ const sourceExtension = path.extname(filePath).toLowerCase();
1067
+ const destinationExtension = path.posix.extname(remotePath).toLowerCase();
1068
+ const printable = [sourceExtension, destinationExtension].some(extension => extension === ".gcode" || extension === ".3mf");
1069
+ const model = normalizeModel(bambuModel);
1070
+ if (printable && !model)
1071
+ throw new Error("A supported bambuModel is required to inspect printable uploads, even when print is false.");
1072
+ if (printable && sourceExtension !== destinationExtension) {
1073
+ throw new Error("Printable uploads require matching .gcode or .3mf source and destination extensions.");
968
1074
  }
1075
+ return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(filePath, async (snapshot) => {
1076
+ const inspections = [];
1077
+ if (printable) {
1078
+ if (sourceExtension === ".3mf") {
1079
+ const { zip } = await readSafe3mfArchive(snapshot);
1080
+ const plates = Object.values(zip.files).filter(entry => !entry.dir && /\.gcode$/i.test(entry.name));
1081
+ if (!plates.length)
1082
+ throw new Error("Printable 3MF uploads require a sliced archive containing Metadata/plate_<n>.gcode.");
1083
+ // A later touchscreen start may select any plate in this archive.
1084
+ for (const plate of plates) {
1085
+ const match = plate.name.match(/^Metadata\/plate_([1-9]\d*)\.gcode$/);
1086
+ const plateNumber = match ? Number(match[1]) : NaN;
1087
+ if (!Number.isSafeInteger(plateNumber)) {
1088
+ throw new Error(`Noncanonical printable G-code entry '${plate.name}'. Re-export using exact Metadata/plate_<positive integer>.gcode paths without aliases.`);
1089
+ }
1090
+ const inspection = await inspectPrintFile(snapshot, { model: model, plateIndex: plateNumber - 1 });
1091
+ if (inspection.plateInternalPath !== plate.name) {
1092
+ throw new Error(`Printable G-code entry '${plate.name}' does not match the inspected plate path.`);
1093
+ }
1094
+ inspections.push(inspection);
1095
+ }
1096
+ }
1097
+ else {
1098
+ inspections.push(await inspectPrintFile(snapshot, { model: model }));
1099
+ }
1100
+ const status = await this.getSafetyStatus(host, serial, token);
1101
+ for (const inspection of inspections) {
1102
+ validatePrinterState(status, { ...inspection, verifyMaterials: false, requireIdle: false });
1103
+ }
1104
+ }
1105
+ assertActive();
1106
+ const destination = path.posix.join(path.posix.dirname(remotePath), uniquePrintName(remotePath));
1107
+ await this.ftpUpload(host, token, snapshot, `/${destination}`);
1108
+ return { status: "success", uploaded: true, remotePath: destination, printRequested: false, inspected: printable };
1109
+ }));
1110
+ }
1111
+ assertDirectPrintSupported(bambuModel, serial);
1112
+ if (!remotePath.toLowerCase().endsWith(".gcode") || !filePath.toLowerCase().endsWith(".gcode")) {
1113
+ throw new Error("Automatic print after upload requires .gcode. Use print_3mf for inspected .3mf project prints.");
969
1114
  }
970
- return response;
1115
+ return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(filePath, snapshot => this.printRawPrepared(host, serial, token, snapshot, remotePath, bambuModel, assertActive)));
1116
+ }
1117
+ async printRawPrepared(host, serial, token, filePath, filename, bambuModel, assertActive) {
1118
+ const model = normalizeModel(bambuModel);
1119
+ if (!model)
1120
+ throw new Error("A supported bambuModel is required before printing.");
1121
+ const inspection = await inspectPrintFile(filePath, { model });
1122
+ if (inspection.selectsAms)
1123
+ throw new Error("Raw G-code with AMS selection requires a .3mf project export with verified physical slot mappings.");
1124
+ const initialStatus = await this.getSafetyStatus(host, serial, token);
1125
+ let requirements = this.validateLoadedGcodeState(initialStatus, inspection);
1126
+ const bedClearance = await this.confirmPrintPreflight(serial, initialStatus, inspection);
1127
+ const confirmedStatus = await this.getSafetyStatus(host, serial, token);
1128
+ requirements = this.validateLoadedGcodeState(confirmedStatus, inspection);
1129
+ this.assertBedClearance(confirmedStatus, bedClearance);
1130
+ assertActive();
1131
+ const remotePath = path.posix.join(path.posix.dirname(filename), uniquePrintName(filename));
1132
+ await this.ftpUpload(host, token, filePath, `/${remotePath}`);
1133
+ const dispatchStatus = await this.getSafetyStatus(host, serial, token);
1134
+ requirements = this.validateLoadedGcodeState(dispatchStatus, inspection);
1135
+ this.assertBedClearance(dispatchStatus, bedClearance);
1136
+ const printer = await this.getPrinter(host, serial, token);
1137
+ assertActive();
1138
+ await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remotePath }));
1139
+ this.recordCheckedJob(host, serial, remotePath, requirements);
1140
+ return { status: "success", uploaded: true, printRequested: true, remotePath, message: `Checked and started ${remotePath}.` };
971
1141
  }
972
1142
  async startJob(host, serial, token, filename, bambuModel) {
973
1143
  assertDirectPrintSupported(bambuModel, serial);
974
- const lower = filename.toLowerCase();
975
- if (lower.endsWith(".3mf") && !lower.endsWith(".gcode.3mf")) {
976
- throw new Error("Use print_3mf for .3mf project files.");
1144
+ if (!normalizeModel(bambuModel))
1145
+ throw new Error("A supported bambuModel is required before printing.");
1146
+ const remotePath = normalizedRemotePath(filename);
1147
+ if (!remotePath.toLowerCase().endsWith(".gcode")) {
1148
+ throw new Error("Remote starts require inspectable .gcode. Use print_3mf with the local project for .3mf printing.");
1149
+ }
1150
+ return withPrinterOperation(host, serial, async (assertActive) => {
1151
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bambu-remote-check-"));
1152
+ const localPath = path.join(directory, path.basename(remotePath));
1153
+ try {
1154
+ await this.ftpDownload(host, token, remotePath, localPath);
1155
+ // Send a unique copy of the downloaded, inspected bytes. Starting the
1156
+ // original remote name would allow it to change after inspection.
1157
+ return await withPrintSnapshot(localPath, snapshot => this.printRawPrepared(host, serial, token, snapshot, remotePath, bambuModel, assertActive));
1158
+ }
1159
+ finally {
1160
+ await fs.rm(directory, { recursive: true, force: true });
1161
+ }
1162
+ });
1163
+ }
1164
+ async ftpDownload(host, token, remotePath, localPath) {
1165
+ const client = new FTPClient(15000);
1166
+ try {
1167
+ await client.access({ host, port: 990, user: "bblp", password: token, secure: "implicit", secureOptions: ftpsSecureOptions() });
1168
+ await this.waitForTlsSession(client);
1169
+ const size = await client.size(`/${remotePath}`);
1170
+ if (!Number.isFinite(size) || size <= 0 || size > 512 * 1024 * 1024)
1171
+ throw new Error("Remote print file is empty or exceeds the 512 MiB inspection limit.");
1172
+ await client.downloadTo(localPath, `/${remotePath}`);
1173
+ }
1174
+ finally {
1175
+ client.close();
977
1176
  }
978
- const printer = await this.getPrinter(host, serial, token);
979
- const normalizedFileName = filename.replace(/^\/+/, "");
980
- const remoteFile = normalizedFileName.includes("/")
981
- ? normalizedFileName
982
- : `cache/${normalizedFileName}`;
983
- await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteFile }));
984
- return {
985
- status: "success",
986
- message: `Start command sent for ${remoteFile}.`,
987
- file: remoteFile,
988
- };
989
1177
  }
990
1178
  /**
991
1179
  * Capture a single JPEG frame from the printer's chamber camera.
@@ -1312,6 +1500,11 @@ export class BambuImplementation {
1312
1500
  await this.waitForTlsSession(client);
1313
1501
  // Use absolute path to avoid CWD side-effects
1314
1502
  const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
1503
+ const entries = await client.list(path.posix.dirname(absoluteRemote));
1504
+ const destinationName = path.posix.basename(absoluteRemote).toLowerCase();
1505
+ if (entries.some(entry => entry.name.toLowerCase() === destinationName)) {
1506
+ throw new Error(`Refusing to overwrite existing printer file ${absoluteRemote}. Choose a new filename.`);
1507
+ }
1315
1508
  const fileData = await fs.readFile(localPath);
1316
1509
  await client.uploadFrom(Readable.from(fileData), absoluteRemote);
1317
1510
  }
@@ -0,0 +1,11 @@
1
+ import JSZip from 'jszip';
2
+ export declare const MAX_PRINT_FILE_BYTES: number;
3
+ /** Read no more than the fixed print-artifact limit, even if the file grows. */
4
+ export declare function readBoundedPrintFile(filePath: string): Promise<Buffer>;
5
+ /** Bounded native inflation prevents false size fields from making JSZip allocate
6
+ * unbounded output. Populate JSZip only with the verified, uniquely named bytes. */
7
+ export declare function loadSafe3mfArchive(bytes: Buffer): Promise<JSZip>;
8
+ export declare function readSafe3mfArchive(filePath: string): Promise<{
9
+ bytes: Buffer;
10
+ zip: JSZip;
11
+ }>;