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