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.
- package/CONTRIBUTORS.md +3 -1
- package/README.md +10 -4
- package/dist/bambu-network-bridge.d.ts +4 -0
- package/dist/bambu-network-bridge.js +3 -0
- package/dist/index.js +196 -85
- package/dist/printers/bambu.d.ts +36 -4
- package/dist/printers/bambu.js +315 -122
- package/dist/safety/archive.d.ts +11 -0
- package/dist/safety/archive.js +214 -0
- package/dist/safety/artifact.d.ts +6 -0
- package/dist/safety/artifact.js +66 -0
- package/dist/safety/limits.d.ts +29 -0
- package/dist/safety/limits.js +100 -0
- package/dist/safety/print-file.d.ts +25 -0
- package/dist/safety/print-file.js +514 -0
- package/dist/safety/printer-state.d.ts +43 -0
- package/dist/safety/printer-state.js +351 -0
- package/package.json +1 -1
- package/src/bambu-network-bridge.ts +7 -0
- package/src/index.ts +203 -93
- package/src/printers/bambu.ts +308 -152
- package/src/safety/archive.ts +181 -0
- package/src/safety/artifact.ts +58 -0
- package/src/safety/limits.ts +93 -0
- package/src/safety/print-file.ts +352 -0
- package/src/safety/printer-state.ts +323 -0
package/src/printers/bambu.ts
CHANGED
|
@@ -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
|
|
8
|
+
import { readSafe3mfArchive } from "../safety/archive.js";
|
|
9
|
+
import { inspectPrintFile, type PrintFileInspection } from "../safety/print-file.js";
|
|
10
|
+
import { normalizeModel, validateTemperature } from "../safety/limits.js";
|
|
11
|
+
import { validatePrinterState, manualHeatingRequirements, type PrinterStateRequirements } 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";
|
|
@@ -80,6 +85,7 @@ interface BambuPrintOptionsInternal {
|
|
|
80
85
|
projectName: string;
|
|
81
86
|
filePath: string;
|
|
82
87
|
bambuModel?: string;
|
|
88
|
+
nozzleDiameters?: number[];
|
|
83
89
|
useAMS?: boolean;
|
|
84
90
|
plateIndex?: number;
|
|
85
91
|
bedType?: string;
|
|
@@ -430,48 +436,80 @@ class BambuClientStore {
|
|
|
430
436
|
|
|
431
437
|
export class BambuImplementation {
|
|
432
438
|
private printerStore: BambuClientStore;
|
|
439
|
+
private checkedJobs = new Map<string, { remotePath: string; requirements: PrinterStateRequirements }>();
|
|
440
|
+
private clearedErrors = new Map<string, string[]>();
|
|
433
441
|
|
|
434
|
-
constructor() {
|
|
442
|
+
constructor(private readonly confirm?: (message: string) => Promise<boolean>) {
|
|
435
443
|
this.printerStore = new BambuClientStore();
|
|
436
444
|
}
|
|
437
445
|
|
|
438
|
-
|
|
439
|
-
|
|
446
|
+
async confirmHardwareAction(message: string, physicalCheck = false): Promise<void> {
|
|
447
|
+
if (!physicalCheck && process.env.BAMBU_REQUIRE_CONFIRMATION === "0") return;
|
|
448
|
+
if (!this.confirm || !(await this.confirm(message))) {
|
|
449
|
+
throw new Error("Hardware safety confirmation was declined or unavailable. Use an MCP client with elicitation support. No command was sent.");
|
|
450
|
+
}
|
|
440
451
|
}
|
|
441
452
|
|
|
442
|
-
private
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
): Promise<ProjectFileMetadata> {
|
|
446
|
-
const archive = await fs.readFile(localThreeMfPath);
|
|
447
|
-
const zip = await JSZip.loadAsync(archive);
|
|
453
|
+
private finishedJobIdentity(status: any): string | undefined {
|
|
454
|
+
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;
|
|
455
|
+
}
|
|
448
456
|
|
|
449
|
-
|
|
450
|
-
|
|
457
|
+
async confirmPrintPreflight(serial: string, status: any, inspection: Awaited<ReturnType<typeof inspectPrintFile>>): Promise<string | undefined> {
|
|
458
|
+
const finishedJob = this.finishedJobIdentity(status);
|
|
459
|
+
const cleared = this.clearedErrors.get(serial);
|
|
460
|
+
await this.confirmHardwareAction(
|
|
461
|
+
`Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
|
|
462
|
+
`Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
|
|
463
|
+
`File SHA-256: ${inspection.sha256}. Confirm the physical spool labels and that the build plate is clear.` +
|
|
464
|
+
(finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : "") +
|
|
465
|
+
(cleared ? ` Previously cleared hardware codes: ${cleared.join(", ")}. Confirm their physical causes have been resolved.` : ""),
|
|
466
|
+
finishedJob !== undefined || !!cleared
|
|
451
467
|
);
|
|
468
|
+
return finishedJob;
|
|
469
|
+
}
|
|
452
470
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
);
|
|
471
|
+
assertBedClearance(status: any, confirmedFinishedJob: string | undefined): void {
|
|
472
|
+
const current = this.finishedJobIdentity(status);
|
|
473
|
+
if (current !== undefined && current !== confirmedFinishedJob) {
|
|
474
|
+
throw new Error("Printer reports a newly finished job. Confirm that its part and debris have been removed before retrying the print.");
|
|
457
475
|
}
|
|
476
|
+
}
|
|
458
477
|
|
|
459
|
-
|
|
478
|
+
private async getPrinter(host: string, serial: string, token: string): Promise<BambuClient> {
|
|
479
|
+
return this.printerStore.getPrinter(host, serial, token);
|
|
480
|
+
}
|
|
460
481
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
482
|
+
/** Internal handoff from a successful inspected transport; never exposed as an MCP tool. */
|
|
483
|
+
recordCheckedJob(host: string, serial: string, remotePath: string, requirements: PrinterStateRequirements): void {
|
|
484
|
+
this.checkedJobs.set(`${host}\n${serial}`, { remotePath, requirements: structuredClone(requirements) });
|
|
485
|
+
this.clearedErrors.delete(serial);
|
|
486
|
+
}
|
|
466
487
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
488
|
+
private validateLoadedGcodeState(status: any, inspection: Awaited<ReturnType<typeof inspectPrintFile>>): PrinterStateRequirements {
|
|
489
|
+
const usedMaterials = inspection.usedFilamentPositions.map(position => inspection.materials[position]);
|
|
490
|
+
if (new Set(usedMaterials).size !== 1) throw new Error("gcode_file printing cannot verify physical changes between different materials. Use a mapped .3mf project.");
|
|
491
|
+
const loaded = manualHeatingRequirements(status, inspection.model, inspection.nozzleDiameters[0], usedMaterials[0]);
|
|
492
|
+
const mapping = Array<number>(inspection.materials.length).fill(-1);
|
|
493
|
+
inspection.usedFilamentPositions.forEach(position => { mapping[position] = loaded.amsMapping?.[0] ?? 254; });
|
|
494
|
+
const requirements = { ...inspection, useAMS: loaded.useAMS, amsMapping: mapping, usedNozzleIndices: loaded.usedNozzleIndices, requireLoadedFilament: true };
|
|
495
|
+
validatePrinterState(status, requirements);
|
|
496
|
+
return requirements;
|
|
497
|
+
}
|
|
473
498
|
|
|
474
|
-
|
|
499
|
+
private async resolveProjectFileMetadata(
|
|
500
|
+
localThreeMfPath: string,
|
|
501
|
+
plateIndex?: number,
|
|
502
|
+
inspectedPlatePath?: string
|
|
503
|
+
): Promise<ProjectFileMetadata> {
|
|
504
|
+
const { zip } = await readSafe3mfArchive(localThreeMfPath);
|
|
505
|
+
|
|
506
|
+
const expectedEntryName = `Metadata/plate_${(plateIndex ?? 0) + 1}.gcode`;
|
|
507
|
+
if (inspectedPlatePath !== undefined && inspectedPlatePath !== expectedEntryName) {
|
|
508
|
+
throw new Error("Inspected plate path does not match the requested print plate.");
|
|
509
|
+
}
|
|
510
|
+
const selectedEntry = zip.file(inspectedPlatePath ?? expectedEntryName);
|
|
511
|
+
if (!selectedEntry) {
|
|
512
|
+
throw new Error(`Selected inspected plate ${expectedEntryName} is not present in 3MF. Re-slice that plate.`);
|
|
475
513
|
}
|
|
476
514
|
|
|
477
515
|
const gcodeBuffer = await selectedEntry.async("nodebuffer");
|
|
@@ -525,6 +563,11 @@ export class BambuImplementation {
|
|
|
525
563
|
};
|
|
526
564
|
}
|
|
527
565
|
|
|
566
|
+
/** Safety reads never use the display cache or configured-serial model inference. */
|
|
567
|
+
async getSafetyStatus(host: string, serial: string, token: string): Promise<any> {
|
|
568
|
+
return readFreshPrinterStatus(await this.getPrinter(host, serial, token), serial);
|
|
569
|
+
}
|
|
570
|
+
|
|
528
571
|
async getStatus(host: string, serial: string, token: string): Promise<any> {
|
|
529
572
|
try {
|
|
530
573
|
const printer = await this.getPrinter(host, serial, token);
|
|
@@ -580,6 +623,14 @@ export class BambuImplementation {
|
|
|
580
623
|
options: BambuPrintOptionsInternal
|
|
581
624
|
): Promise<any> {
|
|
582
625
|
assertDirectPrintSupported(options.bambuModel, serial);
|
|
626
|
+
return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(options.filePath, filePath =>
|
|
627
|
+
this.print3mfPrepared(host, serial, token, { ...options, filePath }, assertActive)
|
|
628
|
+
));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private async print3mfPrepared(host: string, serial: string, token: string, options: BambuPrintOptionsInternal, assertActive: () => void): Promise<any> {
|
|
632
|
+
const model = normalizeModel(options.bambuModel);
|
|
633
|
+
if (!model) throw new Error("A supported bambuModel is required before printing.");
|
|
583
634
|
if (!options.filePath.toLowerCase().endsWith(".3mf")) {
|
|
584
635
|
throw new Error("print3mf requires a .3mf input file.");
|
|
585
636
|
}
|
|
@@ -587,7 +638,7 @@ export class BambuImplementation {
|
|
|
587
638
|
// Normalise remote filename: collapse double-extension artifacts like
|
|
588
639
|
// "Cube.gcode.3mf.gcode.3mf" -> "Cube.gcode.3mf" so firmware can identify
|
|
589
640
|
// the container format from the extension.
|
|
590
|
-
let remoteFileName =
|
|
641
|
+
let remoteFileName = uniquePrintName(options.filePath);
|
|
591
642
|
remoteFileName = remoteFileName.replace(/\.gcode\.3mf\.gcode\.3mf$/i, ".gcode.3mf");
|
|
592
643
|
|
|
593
644
|
// H2-series printers land files at the FTP root and reference them via ftp:///<name>.
|
|
@@ -610,37 +661,19 @@ export class BambuImplementation {
|
|
|
610
661
|
? `ftp:///${remoteProjectPath}`
|
|
611
662
|
: `file:///sdcard/${remoteProjectPath}`;
|
|
612
663
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
// Pre-sliced .gcode.3mf files: routing depends on firmware generation.
|
|
617
|
-
// Legacy P1/X1/A1 mini: project_file returns 405004002 for .gcode.3mf (firmware
|
|
618
|
-
// doesn't recognise the container), so use gcode_file instead.
|
|
619
|
-
// H2-series and P2S: gcode_file is not supported (P2S answers 0500-4002
|
|
620
|
-
// "Unsupported file path or name"); project_file works because the
|
|
621
|
-
// firmware can open the zip and find Metadata/plate_<n>.gcode directly.
|
|
622
|
-
if (options.filePath.toLowerCase().endsWith(".gcode.3mf")) {
|
|
623
|
-
if (!usesH2ProjectFile && !isA1) {
|
|
624
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
625
|
-
await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
|
|
626
|
-
return {
|
|
627
|
-
status: "success",
|
|
628
|
-
message: `Uploaded and started gcode.3mf print: ${options.projectName}`,
|
|
629
|
-
remoteProjectPath,
|
|
630
|
-
};
|
|
631
|
-
}
|
|
632
|
-
// H2-series and P2S: fall through to project_file path below
|
|
633
|
-
}
|
|
634
|
-
|
|
664
|
+
const inspection = await inspectPrintFile(options.filePath, {
|
|
665
|
+
model, nozzleDiameters: options.nozzleDiameters, plateIndex: options.plateIndex ?? 0, bedType: options.bedType,
|
|
666
|
+
});
|
|
635
667
|
const projectMetadata = await this.resolveProjectFileMetadata(
|
|
636
668
|
options.filePath,
|
|
637
|
-
options.plateIndex
|
|
669
|
+
options.plateIndex,
|
|
670
|
+
inspection.plateInternalPath
|
|
638
671
|
);
|
|
639
672
|
|
|
640
673
|
// Send project_file command via bambu-node MQTT (bypasses bambu-js
|
|
641
674
|
// hardcoded use_ams=true and missing ams_mapping support)
|
|
642
|
-
const
|
|
643
|
-
|
|
675
|
+
const md5 = projectMetadata.md5;
|
|
676
|
+
if (options.md5 !== undefined && options.md5 !== md5) throw new Error("Provided checksum does not match the inspected plate G-code.");
|
|
644
677
|
|
|
645
678
|
// Build AMS mapping.
|
|
646
679
|
//
|
|
@@ -696,7 +729,7 @@ export class BambuImplementation {
|
|
|
696
729
|
baseMapping[pos] = options.amsSlots![i];
|
|
697
730
|
});
|
|
698
731
|
} else {
|
|
699
|
-
if (
|
|
732
|
+
if (options.useAMS !== false && projectMetadata.usedFilamentPositions.length > 0) {
|
|
700
733
|
throw new Error(
|
|
701
734
|
`H2 project_file requires amsSlots or amsMapping for sliced files with declared filaments. Plate uses project filament positions ${JSON.stringify(projectMetadata.usedFilamentPositions)}.`
|
|
702
735
|
);
|
|
@@ -709,7 +742,7 @@ export class BambuImplementation {
|
|
|
709
742
|
);
|
|
710
743
|
baseMapping = Array<number>(projectLen).fill(-1);
|
|
711
744
|
positions.forEach((pos, i) => {
|
|
712
|
-
baseMapping[pos] = i;
|
|
745
|
+
baseMapping[pos] = options.useAMS === false ? 254 : i;
|
|
713
746
|
});
|
|
714
747
|
}
|
|
715
748
|
|
|
@@ -733,6 +766,49 @@ export class BambuImplementation {
|
|
|
733
766
|
amsMapping2 = [];
|
|
734
767
|
}
|
|
735
768
|
|
|
769
|
+
for (const position of inspection.usedFilamentPositions) {
|
|
770
|
+
if (baseMapping[position] === undefined || baseMapping[position] < 0) {
|
|
771
|
+
throw new Error(`Missing physical filament mapping for project filament ${position}.`);
|
|
772
|
+
}
|
|
773
|
+
if (options.useAMS === false && baseMapping[position] !== 254) {
|
|
774
|
+
throw new Error("External-spool printing requires external spool mapping (254).");
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const legacyContainer = options.filePath.toLowerCase().endsWith(".gcode.3mf") && !usesH2ProjectFile && !isA1;
|
|
778
|
+
if (legacyContainer) {
|
|
779
|
+
if (options.useAMS !== false || inspection.selectsAms) {
|
|
780
|
+
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.");
|
|
781
|
+
}
|
|
782
|
+
const { zip: archive } = await readSafe3mfArchive(options.filePath);
|
|
783
|
+
const plates = Object.values(archive.files).filter(entry => !entry.dir && /^Metadata\/plate_\d+\.gcode$/i.test(entry.name));
|
|
784
|
+
if (plates.length !== 1 || inspection.plateInternalPath?.toLowerCase() !== "metadata/plate_1.gcode") {
|
|
785
|
+
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.");
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
let requirements: PrinterStateRequirements = { ...inspection, amsMapping: baseMapping, useAMS: options.useAMS !== false };
|
|
789
|
+
const initialStatus = await this.getSafetyStatus(host, serial, token);
|
|
790
|
+
if (legacyContainer) requirements = this.validateLoadedGcodeState(initialStatus, inspection);
|
|
791
|
+
else validatePrinterState(initialStatus, requirements);
|
|
792
|
+
const bedClearance = await this.confirmPrintPreflight(serial, initialStatus, inspection);
|
|
793
|
+
const confirmedStatus = await this.getSafetyStatus(host, serial, token);
|
|
794
|
+
if (legacyContainer) requirements = this.validateLoadedGcodeState(confirmedStatus, inspection);
|
|
795
|
+
else validatePrinterState(confirmedStatus, requirements);
|
|
796
|
+
this.assertBedClearance(confirmedStatus, bedClearance);
|
|
797
|
+
assertActive();
|
|
798
|
+
await this.ftpUpload(host, token, options.filePath, remoteUploadPath);
|
|
799
|
+
// Uploads can be long. Recheck current state before the command is dispatched.
|
|
800
|
+
const dispatchStatus = await this.getSafetyStatus(host, serial, token);
|
|
801
|
+
if (legacyContainer) requirements = this.validateLoadedGcodeState(dispatchStatus, inspection);
|
|
802
|
+
else validatePrinterState(dispatchStatus, requirements);
|
|
803
|
+
this.assertBedClearance(dispatchStatus, bedClearance);
|
|
804
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
805
|
+
assertActive();
|
|
806
|
+
if (legacyContainer) {
|
|
807
|
+
await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
|
|
808
|
+
this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
|
|
809
|
+
return { status: "success", message: `Uploaded and started gcode.3mf print: ${options.projectName}`, remoteProjectPath };
|
|
810
|
+
}
|
|
811
|
+
|
|
736
812
|
const b = (v: any) => (v ? 1 : 0);
|
|
737
813
|
let projectFileCmd: Record<string, any>;
|
|
738
814
|
if (usesH2ProjectFile) {
|
|
@@ -741,7 +817,7 @@ export class BambuImplementation {
|
|
|
741
817
|
print: {
|
|
742
818
|
sequence_id: "0",
|
|
743
819
|
command: "project_file",
|
|
744
|
-
param:
|
|
820
|
+
param: projectMetadata.plateInternalPath,
|
|
745
821
|
url: projectUrl,
|
|
746
822
|
file: remoteFileName,
|
|
747
823
|
md5,
|
|
@@ -770,9 +846,9 @@ export class BambuImplementation {
|
|
|
770
846
|
projectFileCmd = {
|
|
771
847
|
print: {
|
|
772
848
|
command: "project_file",
|
|
773
|
-
param:
|
|
849
|
+
param: projectMetadata.plateInternalPath,
|
|
774
850
|
url: projectUrl,
|
|
775
|
-
subtask_name:
|
|
851
|
+
subtask_name: remoteFileName.replace(/\.3mf$/i, ""),
|
|
776
852
|
md5,
|
|
777
853
|
flow_cali: options.flowCalibration ?? true,
|
|
778
854
|
layer_inspect: options.layerInspect ?? true,
|
|
@@ -792,6 +868,7 @@ export class BambuImplementation {
|
|
|
792
868
|
}
|
|
793
869
|
|
|
794
870
|
await printer.publish(projectFileCmd);
|
|
871
|
+
this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
|
|
795
872
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
796
873
|
|
|
797
874
|
return {
|
|
@@ -806,10 +883,13 @@ export class BambuImplementation {
|
|
|
806
883
|
}
|
|
807
884
|
|
|
808
885
|
async cancelJob(host: string, serial: string, token: string): Promise<any> {
|
|
886
|
+
cancelPendingPrinterOperations(host, serial);
|
|
887
|
+
this.checkedJobs.delete(`${host}\n${serial}`);
|
|
809
888
|
const printer = await this.getPrinter(host, serial, token);
|
|
810
889
|
|
|
811
890
|
try {
|
|
812
891
|
await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
|
|
892
|
+
this.checkedJobs.delete(`${host}\n${serial}`);
|
|
813
893
|
return { status: "success", message: "Cancel command sent successfully." };
|
|
814
894
|
} catch (error) {
|
|
815
895
|
throw new Error(`Failed to cancel print: ${(error as Error).message}`);
|
|
@@ -827,25 +907,39 @@ export class BambuImplementation {
|
|
|
827
907
|
}
|
|
828
908
|
|
|
829
909
|
async resumeJob(host: string, serial: string, token: string): Promise<any> {
|
|
830
|
-
|
|
831
|
-
|
|
910
|
+
return withPrinterOperation(host, serial, async assertActive => {
|
|
911
|
+
const checked = this.checkedJobs.get(`${host}\n${serial}`);
|
|
912
|
+
if (!checked) throw new Error("Resume requires a job inspected and started by this server instance. Verify other jobs on the printer before resuming them there.");
|
|
913
|
+
const status = await this.getSafetyStatus(host, serial, token);
|
|
914
|
+
validatePrinterState(status, { ...checked.requirements, requireIdle: false });
|
|
915
|
+
if (status.raw.gcode_state !== "PAUSE") throw new Error("Resume requires a freshly reported paused job.");
|
|
916
|
+
const stem = (value: string) => path.posix.basename(value).replace(/(?:\.gcode)?\.3mf$|\.gcode$/i, "");
|
|
917
|
+
const names = [status.raw.gcode_file, status.raw.subtask_name].filter(value => typeof value === "string");
|
|
918
|
+
if (!names.some(value => stem(value) === stem(checked.remotePath))) throw new Error("Paused job identity does not match this server's inspected artifact.");
|
|
919
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
920
|
+
assertActive();
|
|
832
921
|
await invokeWithoutAck(printer, new UpdateStateCommand({ state: "resume" }));
|
|
833
922
|
return { status: "success", message: "Resume command sent successfully." };
|
|
834
|
-
}
|
|
835
|
-
throw new Error(`Failed to resume print: ${(error as Error).message}`);
|
|
836
|
-
}
|
|
923
|
+
});
|
|
837
924
|
}
|
|
838
925
|
|
|
839
926
|
async clearHmsErrors(host: string, serial: string, token: string): Promise<any> {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
927
|
+
return withPrinterOperation(host, serial, async assertActive => {
|
|
928
|
+
const codesFor = (status: any): string[] => {
|
|
929
|
+
if (!Array.isArray(status.raw?.hms) || status.raw?.print_error === undefined) throw new Error("Fresh error codes are required before clearing hardware errors.");
|
|
930
|
+
return [`print_error:${status.raw.print_error}`, ...status.raw.hms.map((entry: any) => `hms:${entry.attr}:${entry.code}`)].sort();
|
|
931
|
+
};
|
|
932
|
+
const codes = codesFor(await this.getSafetyStatus(host, serial, token));
|
|
933
|
+
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);
|
|
934
|
+
const current = codesFor(await this.getSafetyStatus(host, serial, token));
|
|
935
|
+
if (JSON.stringify(current) !== JSON.stringify(codes)) throw new Error("Hardware errors changed during confirmation. Inspect the new report before retrying.");
|
|
936
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
937
|
+
assertActive();
|
|
938
|
+
await printer.publish({ print: { command: "clean_print_error", sequence_id: "0" } });
|
|
939
|
+
this.clearedErrors.set(serial, codes);
|
|
940
|
+
await sleep(COMMAND_SETTLE_MS);
|
|
941
|
+
return { status: "success", message: "Confirmed HMS clear command sent. The next print requires acknowledgment of these cleared codes.", cleared_codes: codes };
|
|
846
942
|
});
|
|
847
|
-
await sleep(COMMAND_SETTLE_MS);
|
|
848
|
-
return { status: "success", message: "HMS clear command sent." };
|
|
849
943
|
}
|
|
850
944
|
|
|
851
945
|
async setPrintSpeed(host: string, serial: string, token: string, speedMode: string | number): Promise<any> {
|
|
@@ -943,39 +1037,51 @@ export class BambuImplementation {
|
|
|
943
1037
|
serial: string,
|
|
944
1038
|
token: string,
|
|
945
1039
|
component: string,
|
|
946
|
-
temperature:
|
|
1040
|
+
temperature: unknown,
|
|
1041
|
+
bambuModel?: string,
|
|
1042
|
+
material?: string,
|
|
1043
|
+
nozzleDiameter = 0.4
|
|
947
1044
|
) {
|
|
948
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
949
|
-
|
|
950
1045
|
const normalizedComponent = component.toLowerCase();
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
if (
|
|
954
|
-
throw new Error("Temperature must be between 0 and 300°C.");
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
let gcode: string;
|
|
958
|
-
if (normalizedComponent === "bed") {
|
|
959
|
-
gcode = `M140 S${targetTemperature}`;
|
|
960
|
-
} else if (
|
|
961
|
-
normalizedComponent === "extruder" ||
|
|
962
|
-
normalizedComponent === "nozzle" ||
|
|
963
|
-
normalizedComponent === "tool" ||
|
|
964
|
-
normalizedComponent === "tool0"
|
|
965
|
-
) {
|
|
966
|
-
gcode = `M104 S${targetTemperature}`;
|
|
967
|
-
} else {
|
|
1046
|
+
const heater = normalizedComponent === "bed" ? "bed" :
|
|
1047
|
+
["extruder", "nozzle", "tool", "tool0"].includes(normalizedComponent) ? "nozzle" : undefined;
|
|
1048
|
+
if (!heater) {
|
|
968
1049
|
throw new Error(
|
|
969
1050
|
`Unsupported temperature component: ${component}. Use one of: bed, nozzle, extruder.`
|
|
970
1051
|
);
|
|
971
1052
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1053
|
+
if (typeof temperature !== "number" || !Number.isFinite(temperature) || temperature < 0) {
|
|
1054
|
+
throw new Error("Temperature must be a finite, non-negative number in °C.");
|
|
1055
|
+
}
|
|
1056
|
+
const model = normalizeModel(bambuModel);
|
|
1057
|
+
if (temperature > 0 && !model) throw new Error("bambu_model is required before heating.");
|
|
1058
|
+
if (temperature > 0 && heater === "nozzle" && !material?.trim()) {
|
|
1059
|
+
throw new Error("Declare material before nozzle heating, including non-RFID external spools.");
|
|
1060
|
+
}
|
|
1061
|
+
const targetTemperature = temperature === 0 ? 0 : validateTemperature(heater, temperature, model!, material ? [material] : undefined);
|
|
1062
|
+
const gcode = `${heater === "bed" ? "M140" : "M104"}${heater === "nozzle" && targetTemperature > 0 ? " T0" : ""} S${targetTemperature}`;
|
|
1063
|
+
const send = async (assertActive: () => void) => {
|
|
1064
|
+
if (targetTemperature > 0) {
|
|
1065
|
+
const status = await this.getSafetyStatus(host, serial, token);
|
|
1066
|
+
validatePrinterState(status, heater === "nozzle"
|
|
1067
|
+
? manualHeatingRequirements(status, model!, nozzleDiameter, material!)
|
|
1068
|
+
: { model: model!, nozzleDiameters: [] });
|
|
1069
|
+
await this.confirmHardwareAction(`Heat ${heater} on ${model!.toUpperCase()} (${serial}) to ${targetTemperature}°C?${material ? ` Declared material: ${material}. Confirm the physical spool label.` : ""}`);
|
|
1070
|
+
const confirmedStatus = await this.getSafetyStatus(host, serial, token);
|
|
1071
|
+
validatePrinterState(confirmedStatus, heater === "nozzle"
|
|
1072
|
+
? manualHeatingRequirements(confirmedStatus, model!, nozzleDiameter, material!)
|
|
1073
|
+
: { model: model!, nozzleDiameters: [] });
|
|
1074
|
+
}
|
|
1075
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
1076
|
+
assertActive();
|
|
1077
|
+
await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
|
|
1078
|
+
return { status: "success", message: `Temperature command sent for ${normalizedComponent}.`, command: gcode };
|
|
978
1079
|
};
|
|
1080
|
+
if (targetTemperature === 0) {
|
|
1081
|
+
cancelPendingPrinterOperations(host, serial);
|
|
1082
|
+
return send(() => undefined);
|
|
1083
|
+
}
|
|
1084
|
+
return withPrinterOperation(host, serial, send);
|
|
979
1085
|
}
|
|
980
1086
|
|
|
981
1087
|
async setFanSpeed(
|
|
@@ -1166,69 +1272,114 @@ export class BambuImplementation {
|
|
|
1166
1272
|
}
|
|
1167
1273
|
|
|
1168
1274
|
async uploadFile(
|
|
1169
|
-
host: string,
|
|
1170
|
-
|
|
1171
|
-
token: string,
|
|
1172
|
-
filePath: string,
|
|
1173
|
-
filename: string,
|
|
1174
|
-
print: boolean,
|
|
1175
|
-
bambuModel?: string
|
|
1275
|
+
host: string, serial: string, token: string, filePath: string,
|
|
1276
|
+
filename: string, print: boolean, bambuModel?: string
|
|
1176
1277
|
) {
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
await this.ftpUpload(host, token, filePath, `/${remotePath}`);
|
|
1187
|
-
|
|
1188
|
-
const response: Record<string, unknown> = {
|
|
1189
|
-
status: "success",
|
|
1190
|
-
uploaded: true,
|
|
1191
|
-
remotePath,
|
|
1192
|
-
printRequested: print,
|
|
1193
|
-
};
|
|
1194
|
-
|
|
1195
|
-
if (print) {
|
|
1196
|
-
if (remotePath.toLowerCase().endsWith(".gcode")) {
|
|
1197
|
-
response.startResult = await this.startJob(host, serial, token, remotePath, bambuModel);
|
|
1198
|
-
} else if (remotePath.toLowerCase().endsWith(".3mf")) {
|
|
1199
|
-
response.note =
|
|
1200
|
-
"3MF upload complete. Use print_3mf to start a project print with plate and metadata options.";
|
|
1201
|
-
} else {
|
|
1202
|
-
throw new Error(
|
|
1203
|
-
"Automatic print after upload supports .gcode only. Use print_3mf for .3mf project prints."
|
|
1204
|
-
);
|
|
1278
|
+
const remotePath = normalizedRemotePath(filename);
|
|
1279
|
+
if (!print) {
|
|
1280
|
+
const sourceExtension = path.extname(filePath).toLowerCase();
|
|
1281
|
+
const destinationExtension = path.posix.extname(remotePath).toLowerCase();
|
|
1282
|
+
const printable = [sourceExtension, destinationExtension].some(extension => extension === ".gcode" || extension === ".3mf");
|
|
1283
|
+
const model = normalizeModel(bambuModel);
|
|
1284
|
+
if (printable && !model) throw new Error("A supported bambuModel is required to inspect printable uploads, even when print is false.");
|
|
1285
|
+
if (printable && sourceExtension !== destinationExtension) {
|
|
1286
|
+
throw new Error("Printable uploads require matching .gcode or .3mf source and destination extensions.");
|
|
1205
1287
|
}
|
|
1288
|
+
return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(filePath, async snapshot => {
|
|
1289
|
+
const inspections: PrintFileInspection[] = [];
|
|
1290
|
+
if (printable) {
|
|
1291
|
+
if (sourceExtension === ".3mf") {
|
|
1292
|
+
const { zip } = await readSafe3mfArchive(snapshot);
|
|
1293
|
+
const plates = Object.values(zip.files).filter(entry => !entry.dir && /\.gcode$/i.test(entry.name));
|
|
1294
|
+
if (!plates.length) throw new Error("Printable 3MF uploads require a sliced archive containing Metadata/plate_<n>.gcode.");
|
|
1295
|
+
// A later touchscreen start may select any plate in this archive.
|
|
1296
|
+
for (const plate of plates) {
|
|
1297
|
+
const match = plate.name.match(/^Metadata\/plate_([1-9]\d*)\.gcode$/);
|
|
1298
|
+
const plateNumber = match ? Number(match[1]) : NaN;
|
|
1299
|
+
if (!Number.isSafeInteger(plateNumber)) {
|
|
1300
|
+
throw new Error(`Noncanonical printable G-code entry '${plate.name}'. Re-export using exact Metadata/plate_<positive integer>.gcode paths without aliases.`);
|
|
1301
|
+
}
|
|
1302
|
+
const inspection = await inspectPrintFile(snapshot, { model: model!, plateIndex: plateNumber - 1 });
|
|
1303
|
+
if (inspection.plateInternalPath !== plate.name) {
|
|
1304
|
+
throw new Error(`Printable G-code entry '${plate.name}' does not match the inspected plate path.`);
|
|
1305
|
+
}
|
|
1306
|
+
inspections.push(inspection);
|
|
1307
|
+
}
|
|
1308
|
+
} else {
|
|
1309
|
+
inspections.push(await inspectPrintFile(snapshot, { model: model! }));
|
|
1310
|
+
}
|
|
1311
|
+
const status = await this.getSafetyStatus(host, serial, token);
|
|
1312
|
+
for (const inspection of inspections) {
|
|
1313
|
+
validatePrinterState(status, { ...inspection, verifyMaterials: false, requireIdle: false });
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
assertActive();
|
|
1317
|
+
const destination = path.posix.join(path.posix.dirname(remotePath), uniquePrintName(remotePath));
|
|
1318
|
+
await this.ftpUpload(host, token, snapshot, `/${destination}`);
|
|
1319
|
+
return { status: "success", uploaded: true, remotePath: destination, printRequested: false, inspected: printable };
|
|
1320
|
+
}));
|
|
1206
1321
|
}
|
|
1322
|
+
assertDirectPrintSupported(bambuModel, serial);
|
|
1323
|
+
if (!remotePath.toLowerCase().endsWith(".gcode") || !filePath.toLowerCase().endsWith(".gcode")) {
|
|
1324
|
+
throw new Error("Automatic print after upload requires .gcode. Use print_3mf for inspected .3mf project prints.");
|
|
1325
|
+
}
|
|
1326
|
+
return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(filePath, snapshot =>
|
|
1327
|
+
this.printRawPrepared(host, serial, token, snapshot, remotePath, bambuModel, assertActive)
|
|
1328
|
+
));
|
|
1329
|
+
}
|
|
1207
1330
|
|
|
1208
|
-
|
|
1331
|
+
private async printRawPrepared(host: string, serial: string, token: string, filePath: string, filename: string, bambuModel: string | undefined, assertActive: () => void) {
|
|
1332
|
+
const model = normalizeModel(bambuModel);
|
|
1333
|
+
if (!model) throw new Error("A supported bambuModel is required before printing.");
|
|
1334
|
+
const inspection = await inspectPrintFile(filePath, { model });
|
|
1335
|
+
if (inspection.selectsAms) throw new Error("Raw G-code with AMS selection requires a .3mf project export with verified physical slot mappings.");
|
|
1336
|
+
const initialStatus = await this.getSafetyStatus(host, serial, token);
|
|
1337
|
+
let requirements = this.validateLoadedGcodeState(initialStatus, inspection);
|
|
1338
|
+
const bedClearance = await this.confirmPrintPreflight(serial, initialStatus, inspection);
|
|
1339
|
+
const confirmedStatus = await this.getSafetyStatus(host, serial, token);
|
|
1340
|
+
requirements = this.validateLoadedGcodeState(confirmedStatus, inspection);
|
|
1341
|
+
this.assertBedClearance(confirmedStatus, bedClearance);
|
|
1342
|
+
assertActive();
|
|
1343
|
+
const remotePath = path.posix.join(path.posix.dirname(filename), uniquePrintName(filename));
|
|
1344
|
+
await this.ftpUpload(host, token, filePath, `/${remotePath}`);
|
|
1345
|
+
const dispatchStatus = await this.getSafetyStatus(host, serial, token);
|
|
1346
|
+
requirements = this.validateLoadedGcodeState(dispatchStatus, inspection);
|
|
1347
|
+
this.assertBedClearance(dispatchStatus, bedClearance);
|
|
1348
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
1349
|
+
assertActive();
|
|
1350
|
+
await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remotePath }));
|
|
1351
|
+
this.recordCheckedJob(host, serial, remotePath, requirements);
|
|
1352
|
+
return { status: "success", uploaded: true, printRequested: true, remotePath, message: `Checked and started ${remotePath}.` };
|
|
1209
1353
|
}
|
|
1210
1354
|
|
|
1211
1355
|
async startJob(host: string, serial: string, token: string, filename: string, bambuModel?: string) {
|
|
1212
1356
|
assertDirectPrintSupported(bambuModel, serial);
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1357
|
+
if (!normalizeModel(bambuModel)) throw new Error("A supported bambuModel is required before printing.");
|
|
1358
|
+
const remotePath = normalizedRemotePath(filename);
|
|
1359
|
+
if (!remotePath.toLowerCase().endsWith(".gcode")) {
|
|
1360
|
+
throw new Error("Remote starts require inspectable .gcode. Use print_3mf with the local project for .3mf printing.");
|
|
1216
1361
|
}
|
|
1362
|
+
return withPrinterOperation(host, serial, async assertActive => {
|
|
1363
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bambu-remote-check-"));
|
|
1364
|
+
const localPath = path.join(directory, path.basename(remotePath));
|
|
1365
|
+
try {
|
|
1366
|
+
await this.ftpDownload(host, token, remotePath, localPath);
|
|
1367
|
+
// Send a unique copy of the downloaded, inspected bytes. Starting the
|
|
1368
|
+
// original remote name would allow it to change after inspection.
|
|
1369
|
+
return await withPrintSnapshot(localPath, snapshot => this.printRawPrepared(host, serial, token, snapshot, remotePath, bambuModel, assertActive));
|
|
1370
|
+
} finally { await fs.rm(directory, { recursive: true, force: true }); }
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1217
1373
|
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
return {
|
|
1228
|
-
status: "success",
|
|
1229
|
-
message: `Start command sent for ${remoteFile}.`,
|
|
1230
|
-
file: remoteFile,
|
|
1231
|
-
};
|
|
1374
|
+
private async ftpDownload(host: string, token: string, remotePath: string, localPath: string): Promise<void> {
|
|
1375
|
+
const client = new FTPClient(15_000);
|
|
1376
|
+
try {
|
|
1377
|
+
await client.access({ host, port: 990, user: "bblp", password: token, secure: "implicit", secureOptions: ftpsSecureOptions() });
|
|
1378
|
+
await this.waitForTlsSession(client);
|
|
1379
|
+
const size = await client.size(`/${remotePath}`);
|
|
1380
|
+
if (!Number.isFinite(size) || size <= 0 || size > 512 * 1024 * 1024) throw new Error("Remote print file is empty or exceeds the 512 MiB inspection limit.");
|
|
1381
|
+
await client.downloadTo(localPath, `/${remotePath}`);
|
|
1382
|
+
} finally { client.close(); }
|
|
1232
1383
|
}
|
|
1233
1384
|
|
|
1234
1385
|
/**
|
|
@@ -1656,6 +1807,11 @@ export class BambuImplementation {
|
|
|
1656
1807
|
await this.waitForTlsSession(client);
|
|
1657
1808
|
// Use absolute path to avoid CWD side-effects
|
|
1658
1809
|
const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
|
|
1810
|
+
const entries = await client.list(path.posix.dirname(absoluteRemote));
|
|
1811
|
+
const destinationName = path.posix.basename(absoluteRemote).toLowerCase();
|
|
1812
|
+
if (entries.some(entry => entry.name.toLowerCase() === destinationName)) {
|
|
1813
|
+
throw new Error(`Refusing to overwrite existing printer file ${absoluteRemote}. Choose a new filename.`);
|
|
1814
|
+
}
|
|
1659
1815
|
const fileData = await fs.readFile(localPath);
|
|
1660
1816
|
await client.uploadFrom(Readable.from(fileData), absoluteRemote);
|
|
1661
1817
|
} finally {
|