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/dist/printers/bambu.js
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 } 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";
|
|
@@ -46,11 +51,11 @@ const MODEL_ID_TO_NAME = {
|
|
|
46
51
|
"BL-P002": "X1",
|
|
47
52
|
C13: "X1E",
|
|
48
53
|
};
|
|
49
|
-
const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s"]);
|
|
54
|
+
const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s", "x2d"]);
|
|
50
55
|
export function assertDirectPrintSupported(model, serial) {
|
|
51
56
|
if (model?.trim().toLowerCase() === "x2d" || serial?.trim().toUpperCase().startsWith("20P")) {
|
|
52
|
-
throw new Error("X2D direct printing is not supported
|
|
53
|
-
"X2D status and slicing remain available
|
|
57
|
+
throw new Error("X2D direct printing is not supported through legacy MQTT/FTPS. Use the macOS native eMMC route via print_3mf. " +
|
|
58
|
+
"X2D status and slicing remain available on all supported platforms.");
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
function isH2ModelName(model) {
|
|
@@ -191,9 +196,18 @@ class TolerantBambuClient extends BambuClient {
|
|
|
191
196
|
return [undefined];
|
|
192
197
|
}
|
|
193
198
|
}
|
|
194
|
-
/**
|
|
195
|
-
|
|
196
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Build FTPS secureOptions that include the client cert+key when available.
|
|
201
|
+
*
|
|
202
|
+
* `host` must be included: basic-ftp opens each data connection by wrapping
|
|
203
|
+
* a plain socket with tls.connect(tlsOptions), and without a host Node can bind
|
|
204
|
+
* the resumable session to "localhost" instead of the printer host. Printers
|
|
205
|
+
* that require TLS session reuse (vsftpd require_ssl_reuse, e.g. X2D) refuse
|
|
206
|
+
* to resume the session and reply "522 SSL connection failed: session reuse
|
|
207
|
+
* required", failing every LIST, STOR and RETR.
|
|
208
|
+
*/
|
|
209
|
+
function ftpsSecureOptions(host) {
|
|
210
|
+
const opts = { rejectUnauthorized: false, host };
|
|
197
211
|
if (CLIENT_CREDS) {
|
|
198
212
|
opts.cert = CLIENT_CREDS.cert;
|
|
199
213
|
opts.key = CLIENT_CREDS.key;
|
|
@@ -347,28 +361,66 @@ class BambuClientStore {
|
|
|
347
361
|
}
|
|
348
362
|
}
|
|
349
363
|
export class BambuImplementation {
|
|
350
|
-
constructor() {
|
|
364
|
+
constructor(confirm) {
|
|
365
|
+
this.confirm = confirm;
|
|
366
|
+
this.checkedJobs = new Map();
|
|
367
|
+
this.clearedErrors = new Map();
|
|
351
368
|
this.printerStore = new BambuClientStore();
|
|
352
369
|
}
|
|
370
|
+
async confirmHardwareAction(message, physicalCheck = false) {
|
|
371
|
+
if (!physicalCheck && process.env.BAMBU_REQUIRE_CONFIRMATION === "0")
|
|
372
|
+
return;
|
|
373
|
+
if (!this.confirm || !(await this.confirm(message))) {
|
|
374
|
+
throw new Error("Hardware safety confirmation was declined or unavailable. Use an MCP client with elicitation support. No command was sent.");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
finishedJobIdentity(status) {
|
|
378
|
+
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;
|
|
379
|
+
}
|
|
380
|
+
async confirmPrintPreflight(serial, status, inspection) {
|
|
381
|
+
const finishedJob = this.finishedJobIdentity(status);
|
|
382
|
+
const cleared = this.clearedErrors.get(serial);
|
|
383
|
+
await this.confirmHardwareAction(`Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
|
|
384
|
+
`Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
|
|
385
|
+
`File SHA-256: ${inspection.sha256}. Confirm the physical spool labels and that the build plate is clear.` +
|
|
386
|
+
(finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : "") +
|
|
387
|
+
(cleared ? ` Previously cleared hardware codes: ${cleared.join(", ")}. Confirm their physical causes have been resolved.` : ""), finishedJob !== undefined || !!cleared);
|
|
388
|
+
return finishedJob;
|
|
389
|
+
}
|
|
390
|
+
assertBedClearance(status, confirmedFinishedJob) {
|
|
391
|
+
const current = this.finishedJobIdentity(status);
|
|
392
|
+
if (current !== undefined && current !== confirmedFinishedJob) {
|
|
393
|
+
throw new Error("Printer reports a newly finished job. Confirm that its part and debris have been removed before retrying the print.");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
353
396
|
async getPrinter(host, serial, token) {
|
|
354
397
|
return this.printerStore.getPrinter(host, serial, token);
|
|
355
398
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
399
|
+
/** Internal handoff from a successful inspected transport; never exposed as an MCP tool. */
|
|
400
|
+
recordCheckedJob(host, serial, remotePath, requirements) {
|
|
401
|
+
this.checkedJobs.set(`${host}\n${serial}`, { remotePath, requirements: structuredClone(requirements) });
|
|
402
|
+
this.clearedErrors.delete(serial);
|
|
403
|
+
}
|
|
404
|
+
validateLoadedGcodeState(status, inspection) {
|
|
405
|
+
const usedMaterials = inspection.usedFilamentPositions.map(position => inspection.materials[position]);
|
|
406
|
+
if (new Set(usedMaterials).size !== 1)
|
|
407
|
+
throw new Error("gcode_file printing cannot verify physical changes between different materials. Use a mapped .3mf project.");
|
|
408
|
+
const loaded = manualHeatingRequirements(status, inspection.model, inspection.nozzleDiameters[0], usedMaterials[0]);
|
|
409
|
+
const mapping = Array(inspection.materials.length).fill(-1);
|
|
410
|
+
inspection.usedFilamentPositions.forEach(position => { mapping[position] = loaded.amsMapping?.[0] ?? 254; });
|
|
411
|
+
const requirements = { ...inspection, useAMS: loaded.useAMS, amsMapping: mapping, usedNozzleIndices: loaded.usedNozzleIndices, requireLoadedFilament: true };
|
|
412
|
+
validatePrinterState(status, requirements);
|
|
413
|
+
return requirements;
|
|
414
|
+
}
|
|
415
|
+
async resolveProjectFileMetadata(localThreeMfPath, plateIndex, inspectedPlatePath) {
|
|
416
|
+
const { zip } = await readSafe3mfArchive(localThreeMfPath);
|
|
417
|
+
const expectedEntryName = `Metadata/plate_${(plateIndex ?? 0) + 1}.gcode`;
|
|
418
|
+
if (inspectedPlatePath !== undefined && inspectedPlatePath !== expectedEntryName) {
|
|
419
|
+
throw new Error("Inspected plate path does not match the requested print plate.");
|
|
420
|
+
}
|
|
421
|
+
const selectedEntry = zip.file(inspectedPlatePath ?? expectedEntryName);
|
|
422
|
+
if (!selectedEntry) {
|
|
423
|
+
throw new Error(`Selected inspected plate ${expectedEntryName} is not present in 3MF. Re-slice that plate.`);
|
|
372
424
|
}
|
|
373
425
|
const gcodeBuffer = await selectedEntry.async("nodebuffer");
|
|
374
426
|
const md5 = createHash("md5").update(gcodeBuffer).digest("hex");
|
|
@@ -420,6 +472,10 @@ export class BambuImplementation {
|
|
|
420
472
|
usedFilamentPositions,
|
|
421
473
|
};
|
|
422
474
|
}
|
|
475
|
+
/** Safety reads never use the display cache or configured-serial model inference. */
|
|
476
|
+
async getSafetyStatus(host, serial, token) {
|
|
477
|
+
return readFreshPrinterStatus(await this.getPrinter(host, serial, token), serial);
|
|
478
|
+
}
|
|
423
479
|
async getStatus(host, serial, token) {
|
|
424
480
|
try {
|
|
425
481
|
const printer = await this.getPrinter(host, serial, token);
|
|
@@ -467,13 +523,19 @@ export class BambuImplementation {
|
|
|
467
523
|
}
|
|
468
524
|
async print3mf(host, serial, token, options) {
|
|
469
525
|
assertDirectPrintSupported(options.bambuModel, serial);
|
|
526
|
+
return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(options.filePath, filePath => this.print3mfPrepared(host, serial, token, { ...options, filePath }, assertActive)));
|
|
527
|
+
}
|
|
528
|
+
async print3mfPrepared(host, serial, token, options, assertActive) {
|
|
529
|
+
const model = normalizeModel(options.bambuModel);
|
|
530
|
+
if (!model)
|
|
531
|
+
throw new Error("A supported bambuModel is required before printing.");
|
|
470
532
|
if (!options.filePath.toLowerCase().endsWith(".3mf")) {
|
|
471
533
|
throw new Error("print3mf requires a .3mf input file.");
|
|
472
534
|
}
|
|
473
535
|
// Normalise remote filename: collapse double-extension artifacts like
|
|
474
536
|
// "Cube.gcode.3mf.gcode.3mf" -> "Cube.gcode.3mf" so firmware can identify
|
|
475
537
|
// the container format from the extension.
|
|
476
|
-
let remoteFileName =
|
|
538
|
+
let remoteFileName = uniquePrintName(options.filePath);
|
|
477
539
|
remoteFileName = remoteFileName.replace(/\.gcode\.3mf\.gcode\.3mf$/i, ".gcode.3mf");
|
|
478
540
|
// H2-series printers land files at the FTP root and reference them via ftp:///<name>.
|
|
479
541
|
// Full-size A1 uses SD root/project_file (reported on firmware 01.08.01.00).
|
|
@@ -493,31 +555,15 @@ export class BambuImplementation {
|
|
|
493
555
|
const projectUrl = usesH2ProjectFile
|
|
494
556
|
? `ftp:///${remoteProjectPath}`
|
|
495
557
|
: `file:///sdcard/${remoteProjectPath}`;
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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);
|
|
558
|
+
const inspection = await inspectPrintFile(options.filePath, {
|
|
559
|
+
model, nozzleDiameters: options.nozzleDiameters, plateIndex: options.plateIndex ?? 0, bedType: options.bedType,
|
|
560
|
+
});
|
|
561
|
+
const projectMetadata = await this.resolveProjectFileMetadata(options.filePath, options.plateIndex, inspection.plateInternalPath);
|
|
517
562
|
// Send project_file command via bambu-node MQTT (bypasses bambu-js
|
|
518
563
|
// hardcoded use_ams=true and missing ams_mapping support)
|
|
519
|
-
const
|
|
520
|
-
|
|
564
|
+
const md5 = projectMetadata.md5;
|
|
565
|
+
if (options.md5 !== undefined && options.md5 !== md5)
|
|
566
|
+
throw new Error("Provided checksum does not match the inspected plate G-code.");
|
|
521
567
|
// Build AMS mapping.
|
|
522
568
|
//
|
|
523
569
|
// Convention: position = project-level filament index, value = absolute
|
|
@@ -566,14 +612,14 @@ export class BambuImplementation {
|
|
|
566
612
|
});
|
|
567
613
|
}
|
|
568
614
|
else {
|
|
569
|
-
if (
|
|
615
|
+
if (options.useAMS !== false && projectMetadata.usedFilamentPositions.length > 0) {
|
|
570
616
|
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
617
|
}
|
|
572
618
|
const positions = projectMetadata.usedFilamentPositions;
|
|
573
619
|
const projectLen = Math.max(projectMetadata.projectFilamentCount, ...positions.map((p) => p + 1), 1);
|
|
574
620
|
baseMapping = Array(projectLen).fill(-1);
|
|
575
621
|
positions.forEach((pos, i) => {
|
|
576
|
-
baseMapping[pos] = i;
|
|
622
|
+
baseMapping[pos] = options.useAMS === false ? 254 : i;
|
|
577
623
|
});
|
|
578
624
|
}
|
|
579
625
|
let amsMapping;
|
|
@@ -595,6 +641,54 @@ export class BambuImplementation {
|
|
|
595
641
|
amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
|
|
596
642
|
amsMapping2 = [];
|
|
597
643
|
}
|
|
644
|
+
for (const position of inspection.usedFilamentPositions) {
|
|
645
|
+
if (baseMapping[position] === undefined || baseMapping[position] < 0) {
|
|
646
|
+
throw new Error(`Missing physical filament mapping for project filament ${position}.`);
|
|
647
|
+
}
|
|
648
|
+
if (options.useAMS === false && baseMapping[position] !== 254) {
|
|
649
|
+
throw new Error("External-spool printing requires external spool mapping (254).");
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const legacyContainer = options.filePath.toLowerCase().endsWith(".gcode.3mf") && !usesH2ProjectFile && !isA1;
|
|
653
|
+
if (legacyContainer) {
|
|
654
|
+
if (options.useAMS !== false || inspection.selectsAms) {
|
|
655
|
+
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.");
|
|
656
|
+
}
|
|
657
|
+
const { zip: archive } = await readSafe3mfArchive(options.filePath);
|
|
658
|
+
const plates = Object.values(archive.files).filter(entry => !entry.dir && /^Metadata\/plate_\d+\.gcode$/i.test(entry.name));
|
|
659
|
+
if (plates.length !== 1 || inspection.plateInternalPath?.toLowerCase() !== "metadata/plate_1.gcode") {
|
|
660
|
+
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.");
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
let requirements = { ...inspection, amsMapping: baseMapping, useAMS: options.useAMS !== false };
|
|
664
|
+
const initialStatus = await this.getSafetyStatus(host, serial, token);
|
|
665
|
+
if (legacyContainer)
|
|
666
|
+
requirements = this.validateLoadedGcodeState(initialStatus, inspection);
|
|
667
|
+
else
|
|
668
|
+
validatePrinterState(initialStatus, requirements);
|
|
669
|
+
const bedClearance = await this.confirmPrintPreflight(serial, initialStatus, inspection);
|
|
670
|
+
const confirmedStatus = await this.getSafetyStatus(host, serial, token);
|
|
671
|
+
if (legacyContainer)
|
|
672
|
+
requirements = this.validateLoadedGcodeState(confirmedStatus, inspection);
|
|
673
|
+
else
|
|
674
|
+
validatePrinterState(confirmedStatus, requirements);
|
|
675
|
+
this.assertBedClearance(confirmedStatus, bedClearance);
|
|
676
|
+
assertActive();
|
|
677
|
+
await this.ftpUpload(host, token, options.filePath, remoteUploadPath);
|
|
678
|
+
// Uploads can be long. Recheck current state before the command is dispatched.
|
|
679
|
+
const dispatchStatus = await this.getSafetyStatus(host, serial, token);
|
|
680
|
+
if (legacyContainer)
|
|
681
|
+
requirements = this.validateLoadedGcodeState(dispatchStatus, inspection);
|
|
682
|
+
else
|
|
683
|
+
validatePrinterState(dispatchStatus, requirements);
|
|
684
|
+
this.assertBedClearance(dispatchStatus, bedClearance);
|
|
685
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
686
|
+
assertActive();
|
|
687
|
+
if (legacyContainer) {
|
|
688
|
+
await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
|
|
689
|
+
this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
|
|
690
|
+
return { status: "success", message: `Uploaded and started gcode.3mf print: ${options.projectName}`, remoteProjectPath };
|
|
691
|
+
}
|
|
598
692
|
const b = (v) => (v ? 1 : 0);
|
|
599
693
|
let projectFileCmd;
|
|
600
694
|
if (usesH2ProjectFile) {
|
|
@@ -603,7 +697,7 @@ export class BambuImplementation {
|
|
|
603
697
|
print: {
|
|
604
698
|
sequence_id: "0",
|
|
605
699
|
command: "project_file",
|
|
606
|
-
param:
|
|
700
|
+
param: projectMetadata.plateInternalPath,
|
|
607
701
|
url: projectUrl,
|
|
608
702
|
file: remoteFileName,
|
|
609
703
|
md5,
|
|
@@ -633,9 +727,9 @@ export class BambuImplementation {
|
|
|
633
727
|
projectFileCmd = {
|
|
634
728
|
print: {
|
|
635
729
|
command: "project_file",
|
|
636
|
-
param:
|
|
730
|
+
param: projectMetadata.plateInternalPath,
|
|
637
731
|
url: projectUrl,
|
|
638
|
-
subtask_name:
|
|
732
|
+
subtask_name: remoteFileName.replace(/\.3mf$/i, ""),
|
|
639
733
|
md5,
|
|
640
734
|
flow_cali: options.flowCalibration ?? true,
|
|
641
735
|
layer_inspect: options.layerInspect ?? true,
|
|
@@ -654,6 +748,7 @@ export class BambuImplementation {
|
|
|
654
748
|
};
|
|
655
749
|
}
|
|
656
750
|
await printer.publish(projectFileCmd);
|
|
751
|
+
this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
|
|
657
752
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
658
753
|
return {
|
|
659
754
|
status: "success",
|
|
@@ -665,46 +760,89 @@ export class BambuImplementation {
|
|
|
665
760
|
amsMapping,
|
|
666
761
|
};
|
|
667
762
|
}
|
|
668
|
-
async cancelJob(host, serial, token) {
|
|
669
|
-
|
|
763
|
+
async cancelJob(host, serial, token, dispatch) {
|
|
764
|
+
cancelPendingPrinterOperations(host, serial);
|
|
765
|
+
this.checkedJobs.delete(`${host}\n${serial}`);
|
|
766
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
670
767
|
try {
|
|
671
|
-
|
|
768
|
+
if (dispatch)
|
|
769
|
+
return await dispatch();
|
|
770
|
+
else
|
|
771
|
+
await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
|
|
772
|
+
this.checkedJobs.delete(`${host}\n${serial}`);
|
|
672
773
|
return { status: "success", message: "Cancel command sent successfully." };
|
|
673
774
|
}
|
|
674
775
|
catch (error) {
|
|
675
776
|
throw new Error(`Failed to cancel print: ${error.message}`);
|
|
676
777
|
}
|
|
677
778
|
}
|
|
678
|
-
async pauseJob(host, serial, token) {
|
|
679
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
779
|
+
async pauseJob(host, serial, token, dispatch) {
|
|
780
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
680
781
|
try {
|
|
681
|
-
|
|
782
|
+
if (dispatch)
|
|
783
|
+
return await dispatch();
|
|
784
|
+
else
|
|
785
|
+
await invokeWithoutAck(printer, new UpdateStateCommand({ state: "pause" }));
|
|
682
786
|
return { status: "success", message: "Pause command sent successfully." };
|
|
683
787
|
}
|
|
684
788
|
catch (error) {
|
|
685
789
|
throw new Error(`Failed to pause print: ${error.message}`);
|
|
686
790
|
}
|
|
687
791
|
}
|
|
688
|
-
async resumeJob(host, serial, token) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
792
|
+
async resumeJob(host, serial, token, dispatch) {
|
|
793
|
+
return withPrinterOperation(host, serial, async (assertActive) => {
|
|
794
|
+
const checked = this.checkedJobs.get(`${host}\n${serial}`);
|
|
795
|
+
if (!checked)
|
|
796
|
+
throw new Error("Resume requires a job inspected and started by this server instance. Verify other jobs on the printer before resuming them there.");
|
|
797
|
+
const beforeDispatch = async () => {
|
|
798
|
+
assertActive();
|
|
799
|
+
const status = await this.getSafetyStatus(host, serial, token);
|
|
800
|
+
validatePrinterState(status, { ...checked.requirements, requireIdle: false });
|
|
801
|
+
if (status.raw.gcode_state !== "PAUSE")
|
|
802
|
+
throw new Error("Resume requires a freshly reported paused job.");
|
|
803
|
+
const stem = (value) => path.posix.basename(value).replace(/(?:\.gcode)?\.3mf$|\.gcode$/i, "");
|
|
804
|
+
const names = [status.raw.gcode_file, status.raw.subtask_name].filter(value => typeof value === "string");
|
|
805
|
+
if (!names.some(value => stem(value) === stem(checked.remotePath)))
|
|
806
|
+
throw new Error("Paused job identity does not match this server's inspected artifact.");
|
|
807
|
+
assertActive();
|
|
808
|
+
};
|
|
809
|
+
await beforeDispatch();
|
|
810
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
811
|
+
assertActive();
|
|
812
|
+
if (dispatch)
|
|
813
|
+
await dispatch(assertActive, beforeDispatch);
|
|
814
|
+
else
|
|
815
|
+
await invokeWithoutAck(printer, new UpdateStateCommand({ state: "resume" }));
|
|
692
816
|
return { status: "success", message: "Resume command sent successfully." };
|
|
693
|
-
}
|
|
694
|
-
catch (error) {
|
|
695
|
-
throw new Error(`Failed to resume print: ${error.message}`);
|
|
696
|
-
}
|
|
817
|
+
});
|
|
697
818
|
}
|
|
698
|
-
async clearHmsErrors(host, serial, token) {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
819
|
+
async clearHmsErrors(host, serial, token, dispatch) {
|
|
820
|
+
return withPrinterOperation(host, serial, async (assertActive) => {
|
|
821
|
+
const codesFor = (status) => {
|
|
822
|
+
if (!Array.isArray(status.raw?.hms) || status.raw?.print_error === undefined)
|
|
823
|
+
throw new Error("Fresh error codes are required before clearing hardware errors.");
|
|
824
|
+
return [`print_error:${status.raw.print_error}`, ...status.raw.hms.map((entry) => `hms:${entry.attr}:${entry.code}`)].sort();
|
|
825
|
+
};
|
|
826
|
+
const codes = codesFor(await this.getSafetyStatus(host, serial, token));
|
|
827
|
+
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);
|
|
828
|
+
const beforeDispatch = async () => {
|
|
829
|
+
assertActive();
|
|
830
|
+
const current = codesFor(await this.getSafetyStatus(host, serial, token));
|
|
831
|
+
if (JSON.stringify(current) !== JSON.stringify(codes))
|
|
832
|
+
throw new Error("Hardware errors changed before clearing. Inspect the new report before retrying.");
|
|
833
|
+
assertActive();
|
|
834
|
+
};
|
|
835
|
+
await beforeDispatch();
|
|
836
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
837
|
+
assertActive();
|
|
838
|
+
if (dispatch)
|
|
839
|
+
await dispatch(assertActive, beforeDispatch);
|
|
840
|
+
else
|
|
841
|
+
await printer.publish({ print: { command: "clean_print_error", sequence_id: "0" } });
|
|
842
|
+
this.clearedErrors.set(serial, codes);
|
|
843
|
+
await sleep(COMMAND_SETTLE_MS);
|
|
844
|
+
return { status: "success", message: "Confirmed HMS clear command sent. The next print requires acknowledgment of these cleared codes.", cleared_codes: codes };
|
|
705
845
|
});
|
|
706
|
-
await sleep(COMMAND_SETTLE_MS);
|
|
707
|
-
return { status: "success", message: "HMS clear command sent." };
|
|
708
846
|
}
|
|
709
847
|
async setPrintSpeed(host, serial, token, speedMode) {
|
|
710
848
|
const printer = await this.getPrinter(host, serial, token);
|
|
@@ -755,15 +893,15 @@ export class BambuImplementation {
|
|
|
755
893
|
};
|
|
756
894
|
}
|
|
757
895
|
async rereadAmsRfid(host, serial, token, amsId, slotId) {
|
|
758
|
-
const
|
|
759
|
-
const
|
|
760
|
-
const normalizedSlotId = Math.trunc(slotId);
|
|
896
|
+
const normalizedAmsId = amsId;
|
|
897
|
+
const normalizedSlotId = slotId;
|
|
761
898
|
if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
|
|
762
899
|
throw new Error("ams_id must be an integer from 0 to 3.");
|
|
763
900
|
}
|
|
764
901
|
if (!Number.isInteger(normalizedSlotId) || normalizedSlotId < 0 || normalizedSlotId > 3) {
|
|
765
902
|
throw new Error("slot_id must be an integer from 0 to 3.");
|
|
766
903
|
}
|
|
904
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
767
905
|
await printer.publish({
|
|
768
906
|
print: {
|
|
769
907
|
command: "ams_get_rfid",
|
|
@@ -780,32 +918,54 @@ export class BambuImplementation {
|
|
|
780
918
|
slot_id: normalizedSlotId,
|
|
781
919
|
};
|
|
782
920
|
}
|
|
783
|
-
async setTemperature(host, serial, token, component, temperature) {
|
|
784
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
921
|
+
async setTemperature(host, serial, token, component, temperature, bambuModel, material, nozzleDiameter = 0.4, dispatch) {
|
|
785
922
|
const normalizedComponent = component.toLowerCase();
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
let gcode;
|
|
791
|
-
if (normalizedComponent === "bed") {
|
|
792
|
-
gcode = `M140 S${targetTemperature}`;
|
|
923
|
+
const heater = normalizedComponent === "bed" ? "bed" :
|
|
924
|
+
["extruder", "nozzle", "tool", "tool0"].includes(normalizedComponent) ? "nozzle" : undefined;
|
|
925
|
+
if (!heater) {
|
|
926
|
+
throw new Error(`Unsupported temperature component: ${component}. Use one of: bed, nozzle, extruder.`);
|
|
793
927
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
normalizedComponent === "tool" ||
|
|
797
|
-
normalizedComponent === "tool0") {
|
|
798
|
-
gcode = `M104 S${targetTemperature}`;
|
|
928
|
+
if (typeof temperature !== "number" || !Number.isFinite(temperature) || temperature < 0) {
|
|
929
|
+
throw new Error("Temperature must be a finite, non-negative number in °C.");
|
|
799
930
|
}
|
|
800
|
-
|
|
801
|
-
|
|
931
|
+
const model = normalizeModel(bambuModel);
|
|
932
|
+
if (temperature > 0 && !model)
|
|
933
|
+
throw new Error("bambu_model is required before heating.");
|
|
934
|
+
if (temperature > 0 && heater === "nozzle" && !material?.trim()) {
|
|
935
|
+
throw new Error("Declare material before nozzle heating, including non-RFID external spools.");
|
|
802
936
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
937
|
+
const targetTemperature = temperature === 0 ? 0 : validateTemperature(heater, temperature, model, material ? [material] : undefined);
|
|
938
|
+
const gcode = `${heater === "bed" ? "M140" : "M104"}${heater === "nozzle" && targetTemperature > 0 ? " T0" : ""} S${targetTemperature}`;
|
|
939
|
+
const send = async (assertActive) => {
|
|
940
|
+
let beforeDispatch;
|
|
941
|
+
if (targetTemperature > 0) {
|
|
942
|
+
const status = await this.getSafetyStatus(host, serial, token);
|
|
943
|
+
const validateHeatingStatus = (current) => validatePrinterState(current, heater === "nozzle"
|
|
944
|
+
? manualHeatingRequirements(current, model, nozzleDiameter, material)
|
|
945
|
+
: { model: model, nozzleDiameters: [] });
|
|
946
|
+
validateHeatingStatus(status);
|
|
947
|
+
await this.confirmHardwareAction(`Heat ${heater} on ${model.toUpperCase()} (${serial}) to ${targetTemperature}°C?${material ? ` Declared material: ${material}. Confirm the physical spool label.` : ""}`);
|
|
948
|
+
beforeDispatch = async () => {
|
|
949
|
+
assertActive();
|
|
950
|
+
const confirmedStatus = await this.getSafetyStatus(host, serial, token);
|
|
951
|
+
validateHeatingStatus(confirmedStatus);
|
|
952
|
+
assertActive();
|
|
953
|
+
};
|
|
954
|
+
await beforeDispatch();
|
|
955
|
+
}
|
|
956
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
957
|
+
assertActive();
|
|
958
|
+
if (dispatch)
|
|
959
|
+
await dispatch(heater, targetTemperature, assertActive, beforeDispatch);
|
|
960
|
+
else
|
|
961
|
+
await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
|
|
962
|
+
return { status: "success", message: `Temperature command sent for ${normalizedComponent}.`, command: gcode };
|
|
808
963
|
};
|
|
964
|
+
if (targetTemperature === 0) {
|
|
965
|
+
cancelPendingPrinterOperations(host, serial);
|
|
966
|
+
return send(() => undefined);
|
|
967
|
+
}
|
|
968
|
+
return withPrinterOperation(host, serial, send);
|
|
809
969
|
}
|
|
810
970
|
async setFanSpeed(host, serial, token, fan, speed) {
|
|
811
971
|
const printer = await this.getPrinter(host, serial, token);
|
|
@@ -855,16 +1015,16 @@ export class BambuImplementation {
|
|
|
855
1015
|
};
|
|
856
1016
|
}
|
|
857
1017
|
async setAmsDrying(host, serial, token, action, amsId) {
|
|
858
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
859
1018
|
const normalizedAction = action.trim().toLowerCase();
|
|
860
1019
|
if (normalizedAction !== "start" && normalizedAction !== "stop") {
|
|
861
1020
|
throw new Error("AMS drying action must be one of: start, stop.");
|
|
862
1021
|
}
|
|
863
|
-
const normalizedAmsId =
|
|
1022
|
+
const normalizedAmsId = amsId;
|
|
864
1023
|
if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
|
|
865
1024
|
throw new Error("ams_id must be an integer from 0 to 3.");
|
|
866
1025
|
}
|
|
867
1026
|
const param = normalizedAction === "start" ? "start_drying" : "stop_drying";
|
|
1027
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
868
1028
|
await printer.publish({
|
|
869
1029
|
print: {
|
|
870
1030
|
command: "ams_control",
|
|
@@ -939,53 +1099,123 @@ export class BambuImplementation {
|
|
|
939
1099
|
exists,
|
|
940
1100
|
};
|
|
941
1101
|
}
|
|
942
|
-
async uploadFile(host, serial, token, filePath, filename, print, bambuModel) {
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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.");
|
|
1102
|
+
async uploadFile(host, serial, token, filePath, filename, print, bambuModel, upload) {
|
|
1103
|
+
const remotePath = normalizedRemotePath(filename);
|
|
1104
|
+
if (!print) {
|
|
1105
|
+
const sourceExtension = path.extname(filePath).toLowerCase();
|
|
1106
|
+
const destinationExtension = path.posix.extname(remotePath).toLowerCase();
|
|
1107
|
+
const printable = [sourceExtension, destinationExtension].some(extension => extension === ".gcode" || extension === ".3mf");
|
|
1108
|
+
const model = normalizeModel(bambuModel);
|
|
1109
|
+
if (printable && !model)
|
|
1110
|
+
throw new Error("A supported bambuModel is required to inspect printable uploads, even when print is false.");
|
|
1111
|
+
if (printable && sourceExtension !== destinationExtension) {
|
|
1112
|
+
throw new Error("Printable uploads require matching .gcode or .3mf source and destination extensions.");
|
|
968
1113
|
}
|
|
1114
|
+
return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(filePath, async (snapshot) => {
|
|
1115
|
+
const inspections = [];
|
|
1116
|
+
if (printable) {
|
|
1117
|
+
if (sourceExtension === ".3mf") {
|
|
1118
|
+
const { zip } = await readSafe3mfArchive(snapshot);
|
|
1119
|
+
const plates = Object.values(zip.files).filter(entry => !entry.dir && /\.gcode$/i.test(entry.name));
|
|
1120
|
+
if (!plates.length)
|
|
1121
|
+
throw new Error("Printable 3MF uploads require a sliced archive containing Metadata/plate_<n>.gcode.");
|
|
1122
|
+
// A later touchscreen start may select any plate in this archive.
|
|
1123
|
+
for (const plate of plates) {
|
|
1124
|
+
const match = plate.name.match(/^Metadata\/plate_([1-9]\d*)\.gcode$/);
|
|
1125
|
+
const plateNumber = match ? Number(match[1]) : NaN;
|
|
1126
|
+
if (!Number.isSafeInteger(plateNumber)) {
|
|
1127
|
+
throw new Error(`Noncanonical printable G-code entry '${plate.name}'. Re-export using exact Metadata/plate_<positive integer>.gcode paths without aliases.`);
|
|
1128
|
+
}
|
|
1129
|
+
const inspection = await inspectPrintFile(snapshot, { model: model, plateIndex: plateNumber - 1 });
|
|
1130
|
+
if (inspection.plateInternalPath !== plate.name) {
|
|
1131
|
+
throw new Error(`Printable G-code entry '${plate.name}' does not match the inspected plate path.`);
|
|
1132
|
+
}
|
|
1133
|
+
inspections.push(inspection);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
else {
|
|
1137
|
+
inspections.push(await inspectPrintFile(snapshot, { model: model }));
|
|
1138
|
+
}
|
|
1139
|
+
const status = await this.getSafetyStatus(host, serial, token);
|
|
1140
|
+
for (const inspection of inspections) {
|
|
1141
|
+
validatePrinterState(status, { ...inspection, verifyMaterials: false, requireIdle: false });
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
assertActive();
|
|
1145
|
+
const destination = path.posix.join(path.posix.dirname(remotePath), uniquePrintName(remotePath));
|
|
1146
|
+
if (upload)
|
|
1147
|
+
await upload(snapshot, destination, assertActive);
|
|
1148
|
+
else
|
|
1149
|
+
await this.ftpUpload(host, token, snapshot, `/${destination}`);
|
|
1150
|
+
return { status: "success", uploaded: true, remotePath: destination, printRequested: false, inspected: printable };
|
|
1151
|
+
}));
|
|
969
1152
|
}
|
|
970
|
-
|
|
1153
|
+
assertDirectPrintSupported(bambuModel, serial);
|
|
1154
|
+
if (!remotePath.toLowerCase().endsWith(".gcode") || !filePath.toLowerCase().endsWith(".gcode")) {
|
|
1155
|
+
throw new Error("Automatic print after upload requires .gcode. Use print_3mf for inspected .3mf project prints.");
|
|
1156
|
+
}
|
|
1157
|
+
return withPrinterOperation(host, serial, assertActive => withPrintSnapshot(filePath, snapshot => this.printRawPrepared(host, serial, token, snapshot, remotePath, bambuModel, assertActive)));
|
|
1158
|
+
}
|
|
1159
|
+
async printRawPrepared(host, serial, token, filePath, filename, bambuModel, assertActive) {
|
|
1160
|
+
const model = normalizeModel(bambuModel);
|
|
1161
|
+
if (!model)
|
|
1162
|
+
throw new Error("A supported bambuModel is required before printing.");
|
|
1163
|
+
const inspection = await inspectPrintFile(filePath, { model });
|
|
1164
|
+
if (inspection.selectsAms)
|
|
1165
|
+
throw new Error("Raw G-code with AMS selection requires a .3mf project export with verified physical slot mappings.");
|
|
1166
|
+
const initialStatus = await this.getSafetyStatus(host, serial, token);
|
|
1167
|
+
let requirements = this.validateLoadedGcodeState(initialStatus, inspection);
|
|
1168
|
+
const bedClearance = await this.confirmPrintPreflight(serial, initialStatus, inspection);
|
|
1169
|
+
const confirmedStatus = await this.getSafetyStatus(host, serial, token);
|
|
1170
|
+
requirements = this.validateLoadedGcodeState(confirmedStatus, inspection);
|
|
1171
|
+
this.assertBedClearance(confirmedStatus, bedClearance);
|
|
1172
|
+
assertActive();
|
|
1173
|
+
const remotePath = path.posix.join(path.posix.dirname(filename), uniquePrintName(filename));
|
|
1174
|
+
await this.ftpUpload(host, token, filePath, `/${remotePath}`);
|
|
1175
|
+
const dispatchStatus = await this.getSafetyStatus(host, serial, token);
|
|
1176
|
+
requirements = this.validateLoadedGcodeState(dispatchStatus, inspection);
|
|
1177
|
+
this.assertBedClearance(dispatchStatus, bedClearance);
|
|
1178
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
1179
|
+
assertActive();
|
|
1180
|
+
await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remotePath }));
|
|
1181
|
+
this.recordCheckedJob(host, serial, remotePath, requirements);
|
|
1182
|
+
return { status: "success", uploaded: true, printRequested: true, remotePath, message: `Checked and started ${remotePath}.` };
|
|
971
1183
|
}
|
|
972
1184
|
async startJob(host, serial, token, filename, bambuModel) {
|
|
973
1185
|
assertDirectPrintSupported(bambuModel, serial);
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1186
|
+
if (!normalizeModel(bambuModel))
|
|
1187
|
+
throw new Error("A supported bambuModel is required before printing.");
|
|
1188
|
+
const remotePath = normalizedRemotePath(filename);
|
|
1189
|
+
if (!remotePath.toLowerCase().endsWith(".gcode")) {
|
|
1190
|
+
throw new Error("Remote starts require inspectable .gcode. Use print_3mf with the local project for .3mf printing.");
|
|
1191
|
+
}
|
|
1192
|
+
return withPrinterOperation(host, serial, async (assertActive) => {
|
|
1193
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bambu-remote-check-"));
|
|
1194
|
+
const localPath = path.join(directory, path.basename(remotePath));
|
|
1195
|
+
try {
|
|
1196
|
+
await this.ftpDownload(host, token, remotePath, localPath);
|
|
1197
|
+
// Send a unique copy of the downloaded, inspected bytes. Starting the
|
|
1198
|
+
// original remote name would allow it to change after inspection.
|
|
1199
|
+
return await withPrintSnapshot(localPath, snapshot => this.printRawPrepared(host, serial, token, snapshot, remotePath, bambuModel, assertActive));
|
|
1200
|
+
}
|
|
1201
|
+
finally {
|
|
1202
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
async ftpDownload(host, token, remotePath, localPath) {
|
|
1207
|
+
const client = new FTPClient(15000);
|
|
1208
|
+
try {
|
|
1209
|
+
await client.access({ host, port: 990, user: "bblp", password: token, secure: "implicit", secureOptions: ftpsSecureOptions(host) });
|
|
1210
|
+
await this.waitForTlsSession(client);
|
|
1211
|
+
const size = await client.size(`/${remotePath}`);
|
|
1212
|
+
if (!Number.isFinite(size) || size <= 0 || size > 512 * 1024 * 1024)
|
|
1213
|
+
throw new Error("Remote print file is empty or exceeds the 512 MiB inspection limit.");
|
|
1214
|
+
await client.downloadTo(localPath, `/${remotePath}`);
|
|
1215
|
+
}
|
|
1216
|
+
finally {
|
|
1217
|
+
client.close();
|
|
977
1218
|
}
|
|
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
1219
|
}
|
|
990
1220
|
/**
|
|
991
1221
|
* Capture a single JPEG frame from the printer's chamber camera.
|
|
@@ -1009,8 +1239,8 @@ export class BambuImplementation {
|
|
|
1009
1239
|
* [16..16+payloadSize] JPEG (FF D8 ... FF D9)
|
|
1010
1240
|
*
|
|
1011
1241
|
* Verified models per upstream docs: A1, A1 mini, P1S, P1P. X1/X1C/X1E
|
|
1012
|
-
* and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C
|
|
1013
|
-
*
|
|
1242
|
+
* and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C/X2D
|
|
1243
|
+
* use the same RTSP path.
|
|
1014
1244
|
*
|
|
1015
1245
|
* Read-only; no confirm gate. Default 8s timeout for cold-start latency.
|
|
1016
1246
|
*/
|
|
@@ -1069,18 +1299,21 @@ export class BambuImplementation {
|
|
|
1069
1299
|
}
|
|
1070
1300
|
/**
|
|
1071
1301
|
* Pull a single JPEG frame from the printer's RTSP/RTSPS stream using
|
|
1072
|
-
* ffmpeg. Used for X1, P2S, and
|
|
1302
|
+
* ffmpeg. Used for X1, P2S, H2-family printers, and X2D.
|
|
1073
1303
|
*
|
|
1074
1304
|
* URL pattern verified against HA bambulab's models.py example:
|
|
1075
1305
|
* rtsps://bblp:<access_code>@<host>:322/streaming/live/1
|
|
1076
1306
|
*
|
|
1077
1307
|
* ffmpeg invocation:
|
|
1078
|
-
* ffmpeg -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
|
|
1308
|
+
* ffmpeg -tls_verify 0 -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
|
|
1079
1309
|
*
|
|
1080
|
-
* -
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1310
|
+
* -tls_verify 0 accepts the printer's self-signed certificate. This is
|
|
1311
|
+
* consistent with the existing local-device TLS/FTPS paths, which do not
|
|
1312
|
+
* have a public CA chain or hostname that ffmpeg can validate. The
|
|
1313
|
+
* connection is still encrypted and remains scoped to the configured
|
|
1314
|
+
* printer host. -rtsp_transport tcp avoids UDP NAT/firewall issues.
|
|
1315
|
+
* -frames:v 1 makes ffmpeg exit as soon as one frame lands. -y overwrites
|
|
1316
|
+
* the temp file.
|
|
1084
1317
|
*/
|
|
1085
1318
|
async fetchRtspCameraFrame(host, token, timeoutMs, ffmpegPath) {
|
|
1086
1319
|
const fsSync = await import("node:fs");
|
|
@@ -1095,6 +1328,7 @@ export class BambuImplementation {
|
|
|
1095
1328
|
// have to detect ffmpeg version. -rtsp_transport tcp avoids UDP NAT
|
|
1096
1329
|
// headaches; -frames:v 1 makes ffmpeg exit on first frame.
|
|
1097
1330
|
const args = [
|
|
1331
|
+
"-tls_verify", "0",
|
|
1098
1332
|
"-rtsp_transport", "tcp",
|
|
1099
1333
|
"-i", url,
|
|
1100
1334
|
"-frames:v", "1",
|
|
@@ -1278,7 +1512,7 @@ export class BambuImplementation {
|
|
|
1278
1512
|
user: "bblp",
|
|
1279
1513
|
password: token,
|
|
1280
1514
|
secure: "implicit",
|
|
1281
|
-
secureOptions: ftpsSecureOptions(),
|
|
1515
|
+
secureOptions: ftpsSecureOptions(host),
|
|
1282
1516
|
});
|
|
1283
1517
|
await this.waitForTlsSession(client);
|
|
1284
1518
|
const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
|
|
@@ -1303,7 +1537,7 @@ export class BambuImplementation {
|
|
|
1303
1537
|
user: "bblp",
|
|
1304
1538
|
password: token,
|
|
1305
1539
|
secure: "implicit",
|
|
1306
|
-
secureOptions: ftpsSecureOptions(),
|
|
1540
|
+
secureOptions: ftpsSecureOptions(host),
|
|
1307
1541
|
});
|
|
1308
1542
|
// With TLS 1.3 the session ticket arrives asynchronously; basic-ftp calls
|
|
1309
1543
|
// getSession() when opening the data channel and gets undefined if the
|
|
@@ -1312,6 +1546,11 @@ export class BambuImplementation {
|
|
|
1312
1546
|
await this.waitForTlsSession(client);
|
|
1313
1547
|
// Use absolute path to avoid CWD side-effects
|
|
1314
1548
|
const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
|
|
1549
|
+
const entries = await client.list(path.posix.dirname(absoluteRemote));
|
|
1550
|
+
const destinationName = path.posix.basename(absoluteRemote).toLowerCase();
|
|
1551
|
+
if (entries.some(entry => entry.name.toLowerCase() === destinationName)) {
|
|
1552
|
+
throw new Error(`Refusing to overwrite existing printer file ${absoluteRemote}. Choose a new filename.`);
|
|
1553
|
+
}
|
|
1315
1554
|
const fileData = await fs.readFile(localPath);
|
|
1316
1555
|
await client.uploadFrom(Readable.from(fileData), absoluteRemote);
|
|
1317
1556
|
}
|