bambu-printer-mcp 1.1.12 → 1.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTORS.md +5 -3
- package/README.md +28 -8
- package/dist/bambu-connect.d.ts +7 -0
- package/dist/bambu-connect.js +77 -0
- package/dist/bambu-native.d.ts +66 -0
- package/dist/bambu-native.js +609 -0
- package/dist/bambu-network-bridge.d.ts +4 -0
- package/dist/bambu-network-bridge.js +3 -0
- package/dist/index.js +644 -109
- package/dist/printers/bambu.d.ts +51 -16
- package/dist/printers/bambu.js +392 -153
- 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/native/bambu-native-print.cpp +758 -0
- package/package.json +7 -3
- package/scripts/build-bambu-native.zsh +33 -0
- package/src/bambu-connect.ts +100 -0
- package/src/bambu-native.ts +722 -0
- package/src/bambu-network-bridge.ts +7 -0
- package/src/index.ts +720 -149
- package/src/printers/bambu.ts +374 -182
- 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";
|
|
@@ -57,13 +62,13 @@ const MODEL_ID_TO_NAME: Record<string, string> = {
|
|
|
57
62
|
C13: "X1E",
|
|
58
63
|
};
|
|
59
64
|
|
|
60
|
-
const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s"]);
|
|
65
|
+
const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s", "x2d"]);
|
|
61
66
|
|
|
62
67
|
export function assertDirectPrintSupported(model: string | undefined, serial?: string): void {
|
|
63
68
|
if (model?.trim().toLowerCase() === "x2d" || serial?.trim().toUpperCase().startsWith("20P")) {
|
|
64
69
|
throw new Error(
|
|
65
|
-
"X2D direct printing is not supported
|
|
66
|
-
"X2D status and slicing remain available
|
|
70
|
+
"X2D direct printing is not supported through legacy MQTT/FTPS. Use the macOS native eMMC route via print_3mf. " +
|
|
71
|
+
"X2D status and slicing remain available on all supported platforms."
|
|
67
72
|
);
|
|
68
73
|
}
|
|
69
74
|
}
|
|
@@ -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;
|
|
@@ -245,9 +251,18 @@ class TolerantBambuClient extends BambuClient {
|
|
|
245
251
|
}
|
|
246
252
|
}
|
|
247
253
|
|
|
248
|
-
/**
|
|
249
|
-
|
|
250
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Build FTPS secureOptions that include the client cert+key when available.
|
|
256
|
+
*
|
|
257
|
+
* `host` must be included: basic-ftp opens each data connection by wrapping
|
|
258
|
+
* a plain socket with tls.connect(tlsOptions), and without a host Node can bind
|
|
259
|
+
* the resumable session to "localhost" instead of the printer host. Printers
|
|
260
|
+
* that require TLS session reuse (vsftpd require_ssl_reuse, e.g. X2D) refuse
|
|
261
|
+
* to resume the session and reply "522 SSL connection failed: session reuse
|
|
262
|
+
* required", failing every LIST, STOR and RETR.
|
|
263
|
+
*/
|
|
264
|
+
function ftpsSecureOptions(host: string): Record<string, unknown> {
|
|
265
|
+
const opts: Record<string, unknown> = { rejectUnauthorized: false, host };
|
|
251
266
|
if (CLIENT_CREDS) {
|
|
252
267
|
opts.cert = CLIENT_CREDS.cert;
|
|
253
268
|
opts.key = CLIENT_CREDS.key;
|
|
@@ -430,48 +445,80 @@ class BambuClientStore {
|
|
|
430
445
|
|
|
431
446
|
export class BambuImplementation {
|
|
432
447
|
private printerStore: BambuClientStore;
|
|
448
|
+
private checkedJobs = new Map<string, { remotePath: string; requirements: PrinterStateRequirements }>();
|
|
449
|
+
private clearedErrors = new Map<string, string[]>();
|
|
433
450
|
|
|
434
|
-
constructor() {
|
|
451
|
+
constructor(private readonly confirm?: (message: string) => Promise<boolean>) {
|
|
435
452
|
this.printerStore = new BambuClientStore();
|
|
436
453
|
}
|
|
437
454
|
|
|
438
|
-
|
|
439
|
-
|
|
455
|
+
async confirmHardwareAction(message: string, physicalCheck = false): Promise<void> {
|
|
456
|
+
if (!physicalCheck && process.env.BAMBU_REQUIRE_CONFIRMATION === "0") return;
|
|
457
|
+
if (!this.confirm || !(await this.confirm(message))) {
|
|
458
|
+
throw new Error("Hardware safety confirmation was declined or unavailable. Use an MCP client with elicitation support. No command was sent.");
|
|
459
|
+
}
|
|
440
460
|
}
|
|
441
461
|
|
|
442
|
-
private
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
): Promise<ProjectFileMetadata> {
|
|
446
|
-
const archive = await fs.readFile(localThreeMfPath);
|
|
447
|
-
const zip = await JSZip.loadAsync(archive);
|
|
462
|
+
private finishedJobIdentity(status: any): string | undefined {
|
|
463
|
+
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;
|
|
464
|
+
}
|
|
448
465
|
|
|
449
|
-
|
|
450
|
-
|
|
466
|
+
async confirmPrintPreflight(serial: string, status: any, inspection: Awaited<ReturnType<typeof inspectPrintFile>>): Promise<string | undefined> {
|
|
467
|
+
const finishedJob = this.finishedJobIdentity(status);
|
|
468
|
+
const cleared = this.clearedErrors.get(serial);
|
|
469
|
+
await this.confirmHardwareAction(
|
|
470
|
+
`Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
|
|
471
|
+
`Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
|
|
472
|
+
`File SHA-256: ${inspection.sha256}. Confirm the physical spool labels and that the build plate is clear.` +
|
|
473
|
+
(finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : "") +
|
|
474
|
+
(cleared ? ` Previously cleared hardware codes: ${cleared.join(", ")}. Confirm their physical causes have been resolved.` : ""),
|
|
475
|
+
finishedJob !== undefined || !!cleared
|
|
451
476
|
);
|
|
477
|
+
return finishedJob;
|
|
478
|
+
}
|
|
452
479
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
);
|
|
480
|
+
assertBedClearance(status: any, confirmedFinishedJob: string | undefined): void {
|
|
481
|
+
const current = this.finishedJobIdentity(status);
|
|
482
|
+
if (current !== undefined && current !== confirmedFinishedJob) {
|
|
483
|
+
throw new Error("Printer reports a newly finished job. Confirm that its part and debris have been removed before retrying the print.");
|
|
457
484
|
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private async getPrinter(host: string, serial: string, token: string): Promise<BambuClient> {
|
|
488
|
+
return this.printerStore.getPrinter(host, serial, token);
|
|
489
|
+
}
|
|
458
490
|
|
|
459
|
-
|
|
491
|
+
/** Internal handoff from a successful inspected transport; never exposed as an MCP tool. */
|
|
492
|
+
recordCheckedJob(host: string, serial: string, remotePath: string, requirements: PrinterStateRequirements): void {
|
|
493
|
+
this.checkedJobs.set(`${host}\n${serial}`, { remotePath, requirements: structuredClone(requirements) });
|
|
494
|
+
this.clearedErrors.delete(serial);
|
|
495
|
+
}
|
|
460
496
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
497
|
+
private validateLoadedGcodeState(status: any, inspection: Awaited<ReturnType<typeof inspectPrintFile>>): PrinterStateRequirements {
|
|
498
|
+
const usedMaterials = inspection.usedFilamentPositions.map(position => inspection.materials[position]);
|
|
499
|
+
if (new Set(usedMaterials).size !== 1) throw new Error("gcode_file printing cannot verify physical changes between different materials. Use a mapped .3mf project.");
|
|
500
|
+
const loaded = manualHeatingRequirements(status, inspection.model, inspection.nozzleDiameters[0], usedMaterials[0]);
|
|
501
|
+
const mapping = Array<number>(inspection.materials.length).fill(-1);
|
|
502
|
+
inspection.usedFilamentPositions.forEach(position => { mapping[position] = loaded.amsMapping?.[0] ?? 254; });
|
|
503
|
+
const requirements = { ...inspection, useAMS: loaded.useAMS, amsMapping: mapping, usedNozzleIndices: loaded.usedNozzleIndices, requireLoadedFilament: true };
|
|
504
|
+
validatePrinterState(status, requirements);
|
|
505
|
+
return requirements;
|
|
506
|
+
}
|
|
466
507
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
508
|
+
private async resolveProjectFileMetadata(
|
|
509
|
+
localThreeMfPath: string,
|
|
510
|
+
plateIndex?: number,
|
|
511
|
+
inspectedPlatePath?: string
|
|
512
|
+
): Promise<ProjectFileMetadata> {
|
|
513
|
+
const { zip } = await readSafe3mfArchive(localThreeMfPath);
|
|
473
514
|
|
|
474
|
-
|
|
515
|
+
const expectedEntryName = `Metadata/plate_${(plateIndex ?? 0) + 1}.gcode`;
|
|
516
|
+
if (inspectedPlatePath !== undefined && inspectedPlatePath !== expectedEntryName) {
|
|
517
|
+
throw new Error("Inspected plate path does not match the requested print plate.");
|
|
518
|
+
}
|
|
519
|
+
const selectedEntry = zip.file(inspectedPlatePath ?? expectedEntryName);
|
|
520
|
+
if (!selectedEntry) {
|
|
521
|
+
throw new Error(`Selected inspected plate ${expectedEntryName} is not present in 3MF. Re-slice that plate.`);
|
|
475
522
|
}
|
|
476
523
|
|
|
477
524
|
const gcodeBuffer = await selectedEntry.async("nodebuffer");
|
|
@@ -525,6 +572,11 @@ export class BambuImplementation {
|
|
|
525
572
|
};
|
|
526
573
|
}
|
|
527
574
|
|
|
575
|
+
/** Safety reads never use the display cache or configured-serial model inference. */
|
|
576
|
+
async getSafetyStatus(host: string, serial: string, token: string): Promise<any> {
|
|
577
|
+
return readFreshPrinterStatus(await this.getPrinter(host, serial, token), serial);
|
|
578
|
+
}
|
|
579
|
+
|
|
528
580
|
async getStatus(host: string, serial: string, token: string): Promise<any> {
|
|
529
581
|
try {
|
|
530
582
|
const printer = await this.getPrinter(host, serial, token);
|
|
@@ -580,6 +632,14 @@ export class BambuImplementation {
|
|
|
580
632
|
options: BambuPrintOptionsInternal
|
|
581
633
|
): Promise<any> {
|
|
582
634
|
assertDirectPrintSupported(options.bambuModel, serial);
|
|
635
|
+
return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(options.filePath, filePath =>
|
|
636
|
+
this.print3mfPrepared(host, serial, token, { ...options, filePath }, assertActive)
|
|
637
|
+
));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private async print3mfPrepared(host: string, serial: string, token: string, options: BambuPrintOptionsInternal, assertActive: () => void): Promise<any> {
|
|
641
|
+
const model = normalizeModel(options.bambuModel);
|
|
642
|
+
if (!model) throw new Error("A supported bambuModel is required before printing.");
|
|
583
643
|
if (!options.filePath.toLowerCase().endsWith(".3mf")) {
|
|
584
644
|
throw new Error("print3mf requires a .3mf input file.");
|
|
585
645
|
}
|
|
@@ -587,7 +647,7 @@ export class BambuImplementation {
|
|
|
587
647
|
// Normalise remote filename: collapse double-extension artifacts like
|
|
588
648
|
// "Cube.gcode.3mf.gcode.3mf" -> "Cube.gcode.3mf" so firmware can identify
|
|
589
649
|
// the container format from the extension.
|
|
590
|
-
let remoteFileName =
|
|
650
|
+
let remoteFileName = uniquePrintName(options.filePath);
|
|
591
651
|
remoteFileName = remoteFileName.replace(/\.gcode\.3mf\.gcode\.3mf$/i, ".gcode.3mf");
|
|
592
652
|
|
|
593
653
|
// H2-series printers land files at the FTP root and reference them via ftp:///<name>.
|
|
@@ -610,37 +670,19 @@ export class BambuImplementation {
|
|
|
610
670
|
? `ftp:///${remoteProjectPath}`
|
|
611
671
|
: `file:///sdcard/${remoteProjectPath}`;
|
|
612
672
|
|
|
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
|
-
|
|
673
|
+
const inspection = await inspectPrintFile(options.filePath, {
|
|
674
|
+
model, nozzleDiameters: options.nozzleDiameters, plateIndex: options.plateIndex ?? 0, bedType: options.bedType,
|
|
675
|
+
});
|
|
635
676
|
const projectMetadata = await this.resolveProjectFileMetadata(
|
|
636
677
|
options.filePath,
|
|
637
|
-
options.plateIndex
|
|
678
|
+
options.plateIndex,
|
|
679
|
+
inspection.plateInternalPath
|
|
638
680
|
);
|
|
639
681
|
|
|
640
682
|
// Send project_file command via bambu-node MQTT (bypasses bambu-js
|
|
641
683
|
// hardcoded use_ams=true and missing ams_mapping support)
|
|
642
|
-
const
|
|
643
|
-
|
|
684
|
+
const md5 = projectMetadata.md5;
|
|
685
|
+
if (options.md5 !== undefined && options.md5 !== md5) throw new Error("Provided checksum does not match the inspected plate G-code.");
|
|
644
686
|
|
|
645
687
|
// Build AMS mapping.
|
|
646
688
|
//
|
|
@@ -696,7 +738,7 @@ export class BambuImplementation {
|
|
|
696
738
|
baseMapping[pos] = options.amsSlots![i];
|
|
697
739
|
});
|
|
698
740
|
} else {
|
|
699
|
-
if (
|
|
741
|
+
if (options.useAMS !== false && projectMetadata.usedFilamentPositions.length > 0) {
|
|
700
742
|
throw new Error(
|
|
701
743
|
`H2 project_file requires amsSlots or amsMapping for sliced files with declared filaments. Plate uses project filament positions ${JSON.stringify(projectMetadata.usedFilamentPositions)}.`
|
|
702
744
|
);
|
|
@@ -709,7 +751,7 @@ export class BambuImplementation {
|
|
|
709
751
|
);
|
|
710
752
|
baseMapping = Array<number>(projectLen).fill(-1);
|
|
711
753
|
positions.forEach((pos, i) => {
|
|
712
|
-
baseMapping[pos] = i;
|
|
754
|
+
baseMapping[pos] = options.useAMS === false ? 254 : i;
|
|
713
755
|
});
|
|
714
756
|
}
|
|
715
757
|
|
|
@@ -733,6 +775,49 @@ export class BambuImplementation {
|
|
|
733
775
|
amsMapping2 = [];
|
|
734
776
|
}
|
|
735
777
|
|
|
778
|
+
for (const position of inspection.usedFilamentPositions) {
|
|
779
|
+
if (baseMapping[position] === undefined || baseMapping[position] < 0) {
|
|
780
|
+
throw new Error(`Missing physical filament mapping for project filament ${position}.`);
|
|
781
|
+
}
|
|
782
|
+
if (options.useAMS === false && baseMapping[position] !== 254) {
|
|
783
|
+
throw new Error("External-spool printing requires external spool mapping (254).");
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const legacyContainer = options.filePath.toLowerCase().endsWith(".gcode.3mf") && !usesH2ProjectFile && !isA1;
|
|
787
|
+
if (legacyContainer) {
|
|
788
|
+
if (options.useAMS !== false || inspection.selectsAms) {
|
|
789
|
+
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.");
|
|
790
|
+
}
|
|
791
|
+
const { zip: archive } = await readSafe3mfArchive(options.filePath);
|
|
792
|
+
const plates = Object.values(archive.files).filter(entry => !entry.dir && /^Metadata\/plate_\d+\.gcode$/i.test(entry.name));
|
|
793
|
+
if (plates.length !== 1 || inspection.plateInternalPath?.toLowerCase() !== "metadata/plate_1.gcode") {
|
|
794
|
+
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.");
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
let requirements: PrinterStateRequirements = { ...inspection, amsMapping: baseMapping, useAMS: options.useAMS !== false };
|
|
798
|
+
const initialStatus = await this.getSafetyStatus(host, serial, token);
|
|
799
|
+
if (legacyContainer) requirements = this.validateLoadedGcodeState(initialStatus, inspection);
|
|
800
|
+
else validatePrinterState(initialStatus, requirements);
|
|
801
|
+
const bedClearance = await this.confirmPrintPreflight(serial, initialStatus, inspection);
|
|
802
|
+
const confirmedStatus = await this.getSafetyStatus(host, serial, token);
|
|
803
|
+
if (legacyContainer) requirements = this.validateLoadedGcodeState(confirmedStatus, inspection);
|
|
804
|
+
else validatePrinterState(confirmedStatus, requirements);
|
|
805
|
+
this.assertBedClearance(confirmedStatus, bedClearance);
|
|
806
|
+
assertActive();
|
|
807
|
+
await this.ftpUpload(host, token, options.filePath, remoteUploadPath);
|
|
808
|
+
// Uploads can be long. Recheck current state before the command is dispatched.
|
|
809
|
+
const dispatchStatus = await this.getSafetyStatus(host, serial, token);
|
|
810
|
+
if (legacyContainer) requirements = this.validateLoadedGcodeState(dispatchStatus, inspection);
|
|
811
|
+
else validatePrinterState(dispatchStatus, requirements);
|
|
812
|
+
this.assertBedClearance(dispatchStatus, bedClearance);
|
|
813
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
814
|
+
assertActive();
|
|
815
|
+
if (legacyContainer) {
|
|
816
|
+
await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
|
|
817
|
+
this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
|
|
818
|
+
return { status: "success", message: `Uploaded and started gcode.3mf print: ${options.projectName}`, remoteProjectPath };
|
|
819
|
+
}
|
|
820
|
+
|
|
736
821
|
const b = (v: any) => (v ? 1 : 0);
|
|
737
822
|
let projectFileCmd: Record<string, any>;
|
|
738
823
|
if (usesH2ProjectFile) {
|
|
@@ -741,7 +826,7 @@ export class BambuImplementation {
|
|
|
741
826
|
print: {
|
|
742
827
|
sequence_id: "0",
|
|
743
828
|
command: "project_file",
|
|
744
|
-
param:
|
|
829
|
+
param: projectMetadata.plateInternalPath,
|
|
745
830
|
url: projectUrl,
|
|
746
831
|
file: remoteFileName,
|
|
747
832
|
md5,
|
|
@@ -770,9 +855,9 @@ export class BambuImplementation {
|
|
|
770
855
|
projectFileCmd = {
|
|
771
856
|
print: {
|
|
772
857
|
command: "project_file",
|
|
773
|
-
param:
|
|
858
|
+
param: projectMetadata.plateInternalPath,
|
|
774
859
|
url: projectUrl,
|
|
775
|
-
subtask_name:
|
|
860
|
+
subtask_name: remoteFileName.replace(/\.3mf$/i, ""),
|
|
776
861
|
md5,
|
|
777
862
|
flow_cali: options.flowCalibration ?? true,
|
|
778
863
|
layer_inspect: options.layerInspect ?? true,
|
|
@@ -792,6 +877,7 @@ export class BambuImplementation {
|
|
|
792
877
|
}
|
|
793
878
|
|
|
794
879
|
await printer.publish(projectFileCmd);
|
|
880
|
+
this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
|
|
795
881
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
796
882
|
|
|
797
883
|
return {
|
|
@@ -805,47 +891,78 @@ export class BambuImplementation {
|
|
|
805
891
|
};
|
|
806
892
|
}
|
|
807
893
|
|
|
808
|
-
async cancelJob(host: string, serial: string, token: string): Promise<any> {
|
|
809
|
-
|
|
894
|
+
async cancelJob(host: string, serial: string, token: string, dispatch?: () => Promise<any>): Promise<any> {
|
|
895
|
+
cancelPendingPrinterOperations(host, serial);
|
|
896
|
+
this.checkedJobs.delete(`${host}\n${serial}`);
|
|
897
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
810
898
|
|
|
811
899
|
try {
|
|
812
|
-
|
|
900
|
+
if (dispatch) return await dispatch();
|
|
901
|
+
else await invokeWithoutAck(printer!, new UpdateStateCommand({ state: "stop" }));
|
|
902
|
+
this.checkedJobs.delete(`${host}\n${serial}`);
|
|
813
903
|
return { status: "success", message: "Cancel command sent successfully." };
|
|
814
904
|
} catch (error) {
|
|
815
905
|
throw new Error(`Failed to cancel print: ${(error as Error).message}`);
|
|
816
906
|
}
|
|
817
907
|
}
|
|
818
908
|
|
|
819
|
-
async pauseJob(host: string, serial: string, token: string): Promise<any> {
|
|
820
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
909
|
+
async pauseJob(host: string, serial: string, token: string, dispatch?: () => Promise<any>): Promise<any> {
|
|
910
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
821
911
|
try {
|
|
822
|
-
|
|
912
|
+
if (dispatch) return await dispatch();
|
|
913
|
+
else await invokeWithoutAck(printer!, new UpdateStateCommand({ state: "pause" }));
|
|
823
914
|
return { status: "success", message: "Pause command sent successfully." };
|
|
824
915
|
} catch (error) {
|
|
825
916
|
throw new Error(`Failed to pause print: ${(error as Error).message}`);
|
|
826
917
|
}
|
|
827
918
|
}
|
|
828
919
|
|
|
829
|
-
async resumeJob(host: string, serial: string, token: string): Promise<any> {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
920
|
+
async resumeJob(host: string, serial: string, token: string, dispatch?: (assertActive: () => void, beforeDispatch: () => Promise<void>) => Promise<any>): Promise<any> {
|
|
921
|
+
return withPrinterOperation(host, serial, async assertActive => {
|
|
922
|
+
const checked = this.checkedJobs.get(`${host}\n${serial}`);
|
|
923
|
+
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.");
|
|
924
|
+
const beforeDispatch = async () => {
|
|
925
|
+
assertActive();
|
|
926
|
+
const status = await this.getSafetyStatus(host, serial, token);
|
|
927
|
+
validatePrinterState(status, { ...checked.requirements, requireIdle: false });
|
|
928
|
+
if (status.raw.gcode_state !== "PAUSE") throw new Error("Resume requires a freshly reported paused job.");
|
|
929
|
+
const stem = (value: string) => path.posix.basename(value).replace(/(?:\.gcode)?\.3mf$|\.gcode$/i, "");
|
|
930
|
+
const names = [status.raw.gcode_file, status.raw.subtask_name].filter(value => typeof value === "string");
|
|
931
|
+
if (!names.some(value => stem(value) === stem(checked.remotePath))) throw new Error("Paused job identity does not match this server's inspected artifact.");
|
|
932
|
+
assertActive();
|
|
933
|
+
};
|
|
934
|
+
await beforeDispatch();
|
|
935
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
936
|
+
assertActive();
|
|
937
|
+
if (dispatch) await dispatch(assertActive, beforeDispatch);
|
|
938
|
+
else await invokeWithoutAck(printer!, new UpdateStateCommand({ state: "resume" }));
|
|
833
939
|
return { status: "success", message: "Resume command sent successfully." };
|
|
834
|
-
}
|
|
835
|
-
throw new Error(`Failed to resume print: ${(error as Error).message}`);
|
|
836
|
-
}
|
|
940
|
+
});
|
|
837
941
|
}
|
|
838
942
|
|
|
839
|
-
async clearHmsErrors(host: string, serial: string, token: string): Promise<any> {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
943
|
+
async clearHmsErrors(host: string, serial: string, token: string, dispatch?: (assertActive: () => void, beforeDispatch: () => Promise<void>) => Promise<any>): Promise<any> {
|
|
944
|
+
return withPrinterOperation(host, serial, async assertActive => {
|
|
945
|
+
const codesFor = (status: any): string[] => {
|
|
946
|
+
if (!Array.isArray(status.raw?.hms) || status.raw?.print_error === undefined) throw new Error("Fresh error codes are required before clearing hardware errors.");
|
|
947
|
+
return [`print_error:${status.raw.print_error}`, ...status.raw.hms.map((entry: any) => `hms:${entry.attr}:${entry.code}`)].sort();
|
|
948
|
+
};
|
|
949
|
+
const codes = codesFor(await this.getSafetyStatus(host, serial, token));
|
|
950
|
+
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);
|
|
951
|
+
const beforeDispatch = async () => {
|
|
952
|
+
assertActive();
|
|
953
|
+
const current = codesFor(await this.getSafetyStatus(host, serial, token));
|
|
954
|
+
if (JSON.stringify(current) !== JSON.stringify(codes)) throw new Error("Hardware errors changed before clearing. Inspect the new report before retrying.");
|
|
955
|
+
assertActive();
|
|
956
|
+
};
|
|
957
|
+
await beforeDispatch();
|
|
958
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
959
|
+
assertActive();
|
|
960
|
+
if (dispatch) await dispatch(assertActive, beforeDispatch);
|
|
961
|
+
else await printer!.publish({ print: { command: "clean_print_error", sequence_id: "0" } });
|
|
962
|
+
this.clearedErrors.set(serial, codes);
|
|
963
|
+
await sleep(COMMAND_SETTLE_MS);
|
|
964
|
+
return { status: "success", message: "Confirmed HMS clear command sent. The next print requires acknowledgment of these cleared codes.", cleared_codes: codes };
|
|
846
965
|
});
|
|
847
|
-
await sleep(COMMAND_SETTLE_MS);
|
|
848
|
-
return { status: "success", message: "HMS clear command sent." };
|
|
849
966
|
}
|
|
850
967
|
|
|
851
968
|
async setPrintSpeed(host: string, serial: string, token: string, speedMode: string | number): Promise<any> {
|
|
@@ -910,9 +1027,8 @@ export class BambuImplementation {
|
|
|
910
1027
|
amsId: number,
|
|
911
1028
|
slotId: number
|
|
912
1029
|
): Promise<any> {
|
|
913
|
-
const
|
|
914
|
-
const
|
|
915
|
-
const normalizedSlotId = Math.trunc(slotId);
|
|
1030
|
+
const normalizedAmsId = amsId;
|
|
1031
|
+
const normalizedSlotId = slotId;
|
|
916
1032
|
|
|
917
1033
|
if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
|
|
918
1034
|
throw new Error("ams_id must be an integer from 0 to 3.");
|
|
@@ -921,6 +1037,7 @@ export class BambuImplementation {
|
|
|
921
1037
|
throw new Error("slot_id must be an integer from 0 to 3.");
|
|
922
1038
|
}
|
|
923
1039
|
|
|
1040
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
924
1041
|
await printer.publish({
|
|
925
1042
|
print: {
|
|
926
1043
|
command: "ams_get_rfid",
|
|
@@ -943,39 +1060,58 @@ export class BambuImplementation {
|
|
|
943
1060
|
serial: string,
|
|
944
1061
|
token: string,
|
|
945
1062
|
component: string,
|
|
946
|
-
temperature:
|
|
1063
|
+
temperature: unknown,
|
|
1064
|
+
bambuModel?: string,
|
|
1065
|
+
material?: string,
|
|
1066
|
+
nozzleDiameter = 0.4,
|
|
1067
|
+
dispatch?: (heater: "bed" | "nozzle", target: number, assertActive: () => void, beforeDispatch?: () => Promise<void>) => Promise<any>
|
|
947
1068
|
) {
|
|
948
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
949
|
-
|
|
950
1069
|
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 {
|
|
1070
|
+
const heater = normalizedComponent === "bed" ? "bed" :
|
|
1071
|
+
["extruder", "nozzle", "tool", "tool0"].includes(normalizedComponent) ? "nozzle" : undefined;
|
|
1072
|
+
if (!heater) {
|
|
968
1073
|
throw new Error(
|
|
969
1074
|
`Unsupported temperature component: ${component}. Use one of: bed, nozzle, extruder.`
|
|
970
1075
|
);
|
|
971
1076
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1077
|
+
if (typeof temperature !== "number" || !Number.isFinite(temperature) || temperature < 0) {
|
|
1078
|
+
throw new Error("Temperature must be a finite, non-negative number in °C.");
|
|
1079
|
+
}
|
|
1080
|
+
const model = normalizeModel(bambuModel);
|
|
1081
|
+
if (temperature > 0 && !model) throw new Error("bambu_model is required before heating.");
|
|
1082
|
+
if (temperature > 0 && heater === "nozzle" && !material?.trim()) {
|
|
1083
|
+
throw new Error("Declare material before nozzle heating, including non-RFID external spools.");
|
|
1084
|
+
}
|
|
1085
|
+
const targetTemperature = temperature === 0 ? 0 : validateTemperature(heater, temperature, model!, material ? [material] : undefined);
|
|
1086
|
+
const gcode = `${heater === "bed" ? "M140" : "M104"}${heater === "nozzle" && targetTemperature > 0 ? " T0" : ""} S${targetTemperature}`;
|
|
1087
|
+
const send = async (assertActive: () => void) => {
|
|
1088
|
+
let beforeDispatch: (() => Promise<void>) | undefined;
|
|
1089
|
+
if (targetTemperature > 0) {
|
|
1090
|
+
const status = await this.getSafetyStatus(host, serial, token);
|
|
1091
|
+
const validateHeatingStatus = (current: any) => validatePrinterState(current, heater === "nozzle"
|
|
1092
|
+
? manualHeatingRequirements(current, model!, nozzleDiameter, material!)
|
|
1093
|
+
: { model: model!, nozzleDiameters: [] });
|
|
1094
|
+
validateHeatingStatus(status);
|
|
1095
|
+
await this.confirmHardwareAction(`Heat ${heater} on ${model!.toUpperCase()} (${serial}) to ${targetTemperature}°C?${material ? ` Declared material: ${material}. Confirm the physical spool label.` : ""}`);
|
|
1096
|
+
beforeDispatch = async () => {
|
|
1097
|
+
assertActive();
|
|
1098
|
+
const confirmedStatus = await this.getSafetyStatus(host, serial, token);
|
|
1099
|
+
validateHeatingStatus(confirmedStatus);
|
|
1100
|
+
assertActive();
|
|
1101
|
+
};
|
|
1102
|
+
await beforeDispatch();
|
|
1103
|
+
}
|
|
1104
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
1105
|
+
assertActive();
|
|
1106
|
+
if (dispatch) await dispatch(heater, targetTemperature, assertActive, beforeDispatch);
|
|
1107
|
+
else await invokeWithoutAck(printer!, new GCodeLineCommand({ gcodes: [gcode] }));
|
|
1108
|
+
return { status: "success", message: `Temperature command sent for ${normalizedComponent}.`, command: gcode };
|
|
978
1109
|
};
|
|
1110
|
+
if (targetTemperature === 0) {
|
|
1111
|
+
cancelPendingPrinterOperations(host, serial);
|
|
1112
|
+
return send(() => undefined);
|
|
1113
|
+
}
|
|
1114
|
+
return withPrinterOperation(host, serial, send);
|
|
979
1115
|
}
|
|
980
1116
|
|
|
981
1117
|
async setFanSpeed(
|
|
@@ -1058,18 +1194,18 @@ export class BambuImplementation {
|
|
|
1058
1194
|
action: string,
|
|
1059
1195
|
amsId: number
|
|
1060
1196
|
): Promise<any> {
|
|
1061
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
1062
1197
|
const normalizedAction = action.trim().toLowerCase();
|
|
1063
1198
|
if (normalizedAction !== "start" && normalizedAction !== "stop") {
|
|
1064
1199
|
throw new Error("AMS drying action must be one of: start, stop.");
|
|
1065
1200
|
}
|
|
1066
1201
|
|
|
1067
|
-
const normalizedAmsId =
|
|
1202
|
+
const normalizedAmsId = amsId;
|
|
1068
1203
|
if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
|
|
1069
1204
|
throw new Error("ams_id must be an integer from 0 to 3.");
|
|
1070
1205
|
}
|
|
1071
1206
|
|
|
1072
1207
|
const param = normalizedAction === "start" ? "start_drying" : "stop_drying";
|
|
1208
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
1073
1209
|
await printer.publish({
|
|
1074
1210
|
print: {
|
|
1075
1211
|
command: "ams_control",
|
|
@@ -1166,69 +1302,116 @@ export class BambuImplementation {
|
|
|
1166
1302
|
}
|
|
1167
1303
|
|
|
1168
1304
|
async uploadFile(
|
|
1169
|
-
host: string,
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
filePath: string,
|
|
1173
|
-
filename: string,
|
|
1174
|
-
print: boolean,
|
|
1175
|
-
bambuModel?: string
|
|
1305
|
+
host: string, serial: string, token: string, filePath: string,
|
|
1306
|
+
filename: string, print: boolean, bambuModel?: string,
|
|
1307
|
+
upload?: (snapshot: string, destination: string, assertActive: () => void) => Promise<void>
|
|
1176
1308
|
) {
|
|
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
|
-
);
|
|
1309
|
+
const remotePath = normalizedRemotePath(filename);
|
|
1310
|
+
if (!print) {
|
|
1311
|
+
const sourceExtension = path.extname(filePath).toLowerCase();
|
|
1312
|
+
const destinationExtension = path.posix.extname(remotePath).toLowerCase();
|
|
1313
|
+
const printable = [sourceExtension, destinationExtension].some(extension => extension === ".gcode" || extension === ".3mf");
|
|
1314
|
+
const model = normalizeModel(bambuModel);
|
|
1315
|
+
if (printable && !model) throw new Error("A supported bambuModel is required to inspect printable uploads, even when print is false.");
|
|
1316
|
+
if (printable && sourceExtension !== destinationExtension) {
|
|
1317
|
+
throw new Error("Printable uploads require matching .gcode or .3mf source and destination extensions.");
|
|
1205
1318
|
}
|
|
1319
|
+
return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(filePath, async snapshot => {
|
|
1320
|
+
const inspections: PrintFileInspection[] = [];
|
|
1321
|
+
if (printable) {
|
|
1322
|
+
if (sourceExtension === ".3mf") {
|
|
1323
|
+
const { zip } = await readSafe3mfArchive(snapshot);
|
|
1324
|
+
const plates = Object.values(zip.files).filter(entry => !entry.dir && /\.gcode$/i.test(entry.name));
|
|
1325
|
+
if (!plates.length) throw new Error("Printable 3MF uploads require a sliced archive containing Metadata/plate_<n>.gcode.");
|
|
1326
|
+
// A later touchscreen start may select any plate in this archive.
|
|
1327
|
+
for (const plate of plates) {
|
|
1328
|
+
const match = plate.name.match(/^Metadata\/plate_([1-9]\d*)\.gcode$/);
|
|
1329
|
+
const plateNumber = match ? Number(match[1]) : NaN;
|
|
1330
|
+
if (!Number.isSafeInteger(plateNumber)) {
|
|
1331
|
+
throw new Error(`Noncanonical printable G-code entry '${plate.name}'. Re-export using exact Metadata/plate_<positive integer>.gcode paths without aliases.`);
|
|
1332
|
+
}
|
|
1333
|
+
const inspection = await inspectPrintFile(snapshot, { model: model!, plateIndex: plateNumber - 1 });
|
|
1334
|
+
if (inspection.plateInternalPath !== plate.name) {
|
|
1335
|
+
throw new Error(`Printable G-code entry '${plate.name}' does not match the inspected plate path.`);
|
|
1336
|
+
}
|
|
1337
|
+
inspections.push(inspection);
|
|
1338
|
+
}
|
|
1339
|
+
} else {
|
|
1340
|
+
inspections.push(await inspectPrintFile(snapshot, { model: model! }));
|
|
1341
|
+
}
|
|
1342
|
+
const status = await this.getSafetyStatus(host, serial, token);
|
|
1343
|
+
for (const inspection of inspections) {
|
|
1344
|
+
validatePrinterState(status, { ...inspection, verifyMaterials: false, requireIdle: false });
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
assertActive();
|
|
1348
|
+
const destination = path.posix.join(path.posix.dirname(remotePath), uniquePrintName(remotePath));
|
|
1349
|
+
if (upload) await upload(snapshot, destination, assertActive);
|
|
1350
|
+
else await this.ftpUpload(host, token, snapshot, `/${destination}`);
|
|
1351
|
+
return { status: "success", uploaded: true, remotePath: destination, printRequested: false, inspected: printable };
|
|
1352
|
+
}));
|
|
1353
|
+
}
|
|
1354
|
+
assertDirectPrintSupported(bambuModel, serial);
|
|
1355
|
+
if (!remotePath.toLowerCase().endsWith(".gcode") || !filePath.toLowerCase().endsWith(".gcode")) {
|
|
1356
|
+
throw new Error("Automatic print after upload requires .gcode. Use print_3mf for inspected .3mf project prints.");
|
|
1206
1357
|
}
|
|
1358
|
+
return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(filePath, snapshot =>
|
|
1359
|
+
this.printRawPrepared(host, serial, token, snapshot, remotePath, bambuModel, assertActive)
|
|
1360
|
+
));
|
|
1361
|
+
}
|
|
1207
1362
|
|
|
1208
|
-
|
|
1363
|
+
private async printRawPrepared(host: string, serial: string, token: string, filePath: string, filename: string, bambuModel: string | undefined, assertActive: () => void) {
|
|
1364
|
+
const model = normalizeModel(bambuModel);
|
|
1365
|
+
if (!model) throw new Error("A supported bambuModel is required before printing.");
|
|
1366
|
+
const inspection = await inspectPrintFile(filePath, { model });
|
|
1367
|
+
if (inspection.selectsAms) throw new Error("Raw G-code with AMS selection requires a .3mf project export with verified physical slot mappings.");
|
|
1368
|
+
const initialStatus = await this.getSafetyStatus(host, serial, token);
|
|
1369
|
+
let requirements = this.validateLoadedGcodeState(initialStatus, inspection);
|
|
1370
|
+
const bedClearance = await this.confirmPrintPreflight(serial, initialStatus, inspection);
|
|
1371
|
+
const confirmedStatus = await this.getSafetyStatus(host, serial, token);
|
|
1372
|
+
requirements = this.validateLoadedGcodeState(confirmedStatus, inspection);
|
|
1373
|
+
this.assertBedClearance(confirmedStatus, bedClearance);
|
|
1374
|
+
assertActive();
|
|
1375
|
+
const remotePath = path.posix.join(path.posix.dirname(filename), uniquePrintName(filename));
|
|
1376
|
+
await this.ftpUpload(host, token, filePath, `/${remotePath}`);
|
|
1377
|
+
const dispatchStatus = await this.getSafetyStatus(host, serial, token);
|
|
1378
|
+
requirements = this.validateLoadedGcodeState(dispatchStatus, inspection);
|
|
1379
|
+
this.assertBedClearance(dispatchStatus, bedClearance);
|
|
1380
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
1381
|
+
assertActive();
|
|
1382
|
+
await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remotePath }));
|
|
1383
|
+
this.recordCheckedJob(host, serial, remotePath, requirements);
|
|
1384
|
+
return { status: "success", uploaded: true, printRequested: true, remotePath, message: `Checked and started ${remotePath}.` };
|
|
1209
1385
|
}
|
|
1210
1386
|
|
|
1211
1387
|
async startJob(host: string, serial: string, token: string, filename: string, bambuModel?: string) {
|
|
1212
1388
|
assertDirectPrintSupported(bambuModel, serial);
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1389
|
+
if (!normalizeModel(bambuModel)) throw new Error("A supported bambuModel is required before printing.");
|
|
1390
|
+
const remotePath = normalizedRemotePath(filename);
|
|
1391
|
+
if (!remotePath.toLowerCase().endsWith(".gcode")) {
|
|
1392
|
+
throw new Error("Remote starts require inspectable .gcode. Use print_3mf with the local project for .3mf printing.");
|
|
1216
1393
|
}
|
|
1394
|
+
return withPrinterOperation(host, serial, async assertActive => {
|
|
1395
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bambu-remote-check-"));
|
|
1396
|
+
const localPath = path.join(directory, path.basename(remotePath));
|
|
1397
|
+
try {
|
|
1398
|
+
await this.ftpDownload(host, token, remotePath, localPath);
|
|
1399
|
+
// Send a unique copy of the downloaded, inspected bytes. Starting the
|
|
1400
|
+
// original remote name would allow it to change after inspection.
|
|
1401
|
+
return await withPrintSnapshot(localPath, snapshot => this.printRawPrepared(host, serial, token, snapshot, remotePath, bambuModel, assertActive));
|
|
1402
|
+
} finally { await fs.rm(directory, { recursive: true, force: true }); }
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1217
1405
|
|
|
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
|
-
};
|
|
1406
|
+
private async ftpDownload(host: string, token: string, remotePath: string, localPath: string): Promise<void> {
|
|
1407
|
+
const client = new FTPClient(15_000);
|
|
1408
|
+
try {
|
|
1409
|
+
await client.access({ host, port: 990, user: "bblp", password: token, secure: "implicit", secureOptions: ftpsSecureOptions(host) });
|
|
1410
|
+
await this.waitForTlsSession(client);
|
|
1411
|
+
const size = await client.size(`/${remotePath}`);
|
|
1412
|
+
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.");
|
|
1413
|
+
await client.downloadTo(localPath, `/${remotePath}`);
|
|
1414
|
+
} finally { client.close(); }
|
|
1232
1415
|
}
|
|
1233
1416
|
|
|
1234
1417
|
/**
|
|
@@ -1253,8 +1436,8 @@ export class BambuImplementation {
|
|
|
1253
1436
|
* [16..16+payloadSize] JPEG (FF D8 ... FF D9)
|
|
1254
1437
|
*
|
|
1255
1438
|
* Verified models per upstream docs: A1, A1 mini, P1S, P1P. X1/X1C/X1E
|
|
1256
|
-
* and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C
|
|
1257
|
-
*
|
|
1439
|
+
* and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C/X2D
|
|
1440
|
+
* use the same RTSP path.
|
|
1258
1441
|
*
|
|
1259
1442
|
* Read-only; no confirm gate. Default 8s timeout for cold-start latency.
|
|
1260
1443
|
*/
|
|
@@ -1357,18 +1540,21 @@ export class BambuImplementation {
|
|
|
1357
1540
|
|
|
1358
1541
|
/**
|
|
1359
1542
|
* Pull a single JPEG frame from the printer's RTSP/RTSPS stream using
|
|
1360
|
-
* ffmpeg. Used for X1, P2S, and
|
|
1543
|
+
* ffmpeg. Used for X1, P2S, H2-family printers, and X2D.
|
|
1361
1544
|
*
|
|
1362
1545
|
* URL pattern verified against HA bambulab's models.py example:
|
|
1363
1546
|
* rtsps://bblp:<access_code>@<host>:322/streaming/live/1
|
|
1364
1547
|
*
|
|
1365
1548
|
* ffmpeg invocation:
|
|
1366
|
-
* ffmpeg -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
|
|
1549
|
+
* ffmpeg -tls_verify 0 -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
|
|
1367
1550
|
*
|
|
1368
|
-
* -
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1551
|
+
* -tls_verify 0 accepts the printer's self-signed certificate. This is
|
|
1552
|
+
* consistent with the existing local-device TLS/FTPS paths, which do not
|
|
1553
|
+
* have a public CA chain or hostname that ffmpeg can validate. The
|
|
1554
|
+
* connection is still encrypted and remains scoped to the configured
|
|
1555
|
+
* printer host. -rtsp_transport tcp avoids UDP NAT/firewall issues.
|
|
1556
|
+
* -frames:v 1 makes ffmpeg exit as soon as one frame lands. -y overwrites
|
|
1557
|
+
* the temp file.
|
|
1372
1558
|
*/
|
|
1373
1559
|
private async fetchRtspCameraFrame(
|
|
1374
1560
|
host: string,
|
|
@@ -1389,6 +1575,7 @@ export class BambuImplementation {
|
|
|
1389
1575
|
// have to detect ffmpeg version. -rtsp_transport tcp avoids UDP NAT
|
|
1390
1576
|
// headaches; -frames:v 1 makes ffmpeg exit on first frame.
|
|
1391
1577
|
const args = [
|
|
1578
|
+
"-tls_verify", "0",
|
|
1392
1579
|
"-rtsp_transport", "tcp",
|
|
1393
1580
|
"-i", url,
|
|
1394
1581
|
"-frames:v", "1",
|
|
@@ -1617,7 +1804,7 @@ export class BambuImplementation {
|
|
|
1617
1804
|
user: "bblp",
|
|
1618
1805
|
password: token,
|
|
1619
1806
|
secure: "implicit",
|
|
1620
|
-
secureOptions: ftpsSecureOptions(),
|
|
1807
|
+
secureOptions: ftpsSecureOptions(host),
|
|
1621
1808
|
});
|
|
1622
1809
|
await this.waitForTlsSession(client);
|
|
1623
1810
|
const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
|
|
@@ -1647,7 +1834,7 @@ export class BambuImplementation {
|
|
|
1647
1834
|
user: "bblp",
|
|
1648
1835
|
password: token,
|
|
1649
1836
|
secure: "implicit",
|
|
1650
|
-
secureOptions: ftpsSecureOptions(),
|
|
1837
|
+
secureOptions: ftpsSecureOptions(host),
|
|
1651
1838
|
});
|
|
1652
1839
|
// With TLS 1.3 the session ticket arrives asynchronously; basic-ftp calls
|
|
1653
1840
|
// getSession() when opening the data channel and gets undefined if the
|
|
@@ -1656,6 +1843,11 @@ export class BambuImplementation {
|
|
|
1656
1843
|
await this.waitForTlsSession(client);
|
|
1657
1844
|
// Use absolute path to avoid CWD side-effects
|
|
1658
1845
|
const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
|
|
1846
|
+
const entries = await client.list(path.posix.dirname(absoluteRemote));
|
|
1847
|
+
const destinationName = path.posix.basename(absoluteRemote).toLowerCase();
|
|
1848
|
+
if (entries.some(entry => entry.name.toLowerCase() === destinationName)) {
|
|
1849
|
+
throw new Error(`Refusing to overwrite existing printer file ${absoluteRemote}. Choose a new filename.`);
|
|
1850
|
+
}
|
|
1659
1851
|
const fileData = await fs.readFile(localPath);
|
|
1660
1852
|
await client.uploadFrom(Readable.from(fileData), absoluteRemote);
|
|
1661
1853
|
} finally {
|