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