mcp-3d-printer-server 1.2.25 → 1.2.27
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 +67 -0
- package/CONTRIBUTORS.md +5 -1
- package/README.md +64 -29
- package/dist/ams-mapping.d.ts +38 -0
- package/dist/ams-mapping.js +125 -0
- package/dist/bambu-network-bridge.d.ts +211 -0
- package/dist/bambu-network-bridge.js +918 -0
- package/dist/bambu-network-certs.d.ts +13 -0
- package/dist/bambu-network-certs.js +152 -0
- package/dist/control-reply.d.ts +21 -0
- package/dist/control-reply.js +44 -0
- package/dist/counterpart/camera-sources.d.ts +16 -0
- package/dist/counterpart/camera-sources.js +138 -0
- package/dist/counterpart/camera-stream.d.ts +66 -0
- package/dist/counterpart/camera-stream.js +246 -0
- package/dist/counterpart/camera-tools.d.ts +247 -0
- package/dist/counterpart/camera-tools.js +35 -0
- package/dist/counterpart/contract.d.ts +21 -5
- package/dist/counterpart/contract.js +8 -2
- package/dist/counterpart/route.d.ts +31 -0
- package/dist/counterpart/route.js +70 -0
- package/dist/counterpart/service.d.ts +60 -4
- package/dist/counterpart/service.js +142 -16
- package/dist/design-guidance.d.ts +26 -0
- package/dist/design-guidance.js +177 -0
- package/dist/fulu-orca.d.ts +7 -0
- package/dist/fulu-orca.js +18 -2
- package/dist/index.js +514 -70
- package/dist/printers/bambu-filaments.d.ts +68 -0
- package/dist/printers/bambu-filaments.js +262 -0
- package/dist/printers/bambu.d.ts +99 -6
- package/dist/printers/bambu.js +478 -72
- package/dist/printers/creality.d.ts +2 -1
- package/dist/printers/creality.js +5 -1
- package/dist/printers/generic-base.d.ts +11 -6
- package/dist/printers/generic-base.js +21 -25
- package/dist/printers/klipper.d.ts +2 -1
- package/dist/printers/klipper.js +10 -7
- package/dist/printers/octoprint.d.ts +2 -1
- package/dist/printers/octoprint.js +15 -8
- package/dist/printers/printer-factory.d.ts +1 -1
- package/dist/printers/printer-factory.js +3 -3
- package/dist/printers/prusa.js +0 -1
- package/dist/safety/confirmation.d.ts +11 -16
- package/dist/safety/confirmation.js +8 -32
- package/dist/safety/hidden-hms.d.ts +32 -0
- package/dist/safety/hidden-hms.js +405 -0
- package/dist/safety/limits.d.ts +8 -2
- package/dist/safety/limits.js +14 -5
- package/dist/safety/print-file.d.ts +5 -0
- package/dist/safety/print-file.js +156 -19
- package/dist/safety/printer-state.d.ts +68 -8
- package/dist/safety/printer-state.js +187 -40
- package/dist/slicer/bambu-cli.d.ts +2 -0
- package/dist/slicer/bambu-cli.js +7 -5
- package/dist/slicer/layer-pauses.d.ts +47 -0
- package/dist/slicer/layer-pauses.js +130 -0
- package/dist/stl/stl-manipulator.d.ts +1 -0
- package/dist/stl/stl-manipulator.js +40 -0
- package/dist/studio-print-options.d.ts +57 -0
- package/dist/studio-print-options.js +179 -0
- package/dist/tool-annotations.d.ts +25 -0
- package/dist/tool-annotations.js +58 -0
- package/package.json +27 -4
- package/server.json +2 -2
- package/src/ams-mapping.ts +132 -0
- package/src/bambu-network-bridge.ts +1106 -0
- package/src/bambu-network-certs.ts +154 -0
- package/src/control-reply.ts +41 -0
- package/src/counterpart/camera-sources.ts +102 -0
- package/src/counterpart/camera-stream.ts +195 -0
- package/src/counterpart/camera-tools.ts +35 -0
- package/src/counterpart/contract.ts +12 -4
- package/src/counterpart/route.ts +82 -0
- package/src/counterpart/service.ts +150 -16
- package/src/design-guidance.ts +188 -0
- package/src/fulu-orca.ts +18 -2
- package/src/index.ts +528 -83
- package/src/printers/bambu-filaments.ts +346 -0
- package/src/printers/bambu.ts +506 -81
- package/src/printers/creality.ts +8 -3
- package/src/printers/generic-base.ts +33 -40
- package/src/printers/klipper.ts +12 -8
- package/src/printers/octoprint.ts +16 -9
- package/src/printers/printer-factory.ts +3 -3
- package/src/printers/prusa.ts +0 -1
- package/src/safety/confirmation.ts +15 -38
- package/src/safety/hidden-hms.ts +406 -0
- package/src/safety/limits.ts +15 -5
- package/src/safety/print-file.ts +139 -17
- package/src/safety/printer-state.ts +192 -41
- package/src/slicer/bambu-cli.ts +8 -5
- package/src/slicer/layer-pauses.ts +143 -0
- package/src/stl/stl-manipulator.ts +45 -0
- package/src/studio-print-options.ts +201 -0
package/src/printers/bambu.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PrinterImplementation } from "../types.js";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream, readFileSync, mkdirSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
|
3
4
|
import fs from "node:fs/promises";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
@@ -7,12 +8,12 @@ import { Client as FTPClient } from "basic-ftp";
|
|
|
7
8
|
import { BambuClient, PushAllCommand } from "bambu-node";
|
|
8
9
|
import { MAX_PRINT_FILE_BYTES, readSafe3mfArchive } from "../safety/archive.js";
|
|
9
10
|
import { inspectPrintFile, type PrintFileInspection } from "../safety/print-file.js";
|
|
10
|
-
import { normalizeModel, validateTemperature } from "../safety/limits.js";
|
|
11
|
+
import { isDualNozzleModel, normalizeModel, validateTemperature } from "../safety/limits.js";
|
|
12
|
+
import { describeAmsTrayRange, externalTrayForNozzle } from "../ams-mapping.js";
|
|
11
13
|
import {
|
|
12
14
|
manualHeatingRequirements,
|
|
13
15
|
publishBambuCommand,
|
|
14
16
|
readFreshPrinterStatus,
|
|
15
|
-
unchippedNozzles,
|
|
16
17
|
validatePrinterState,
|
|
17
18
|
type PrinterStateRequirements,
|
|
18
19
|
} from "../safety/printer-state.js";
|
|
@@ -23,9 +24,10 @@ import {
|
|
|
23
24
|
withPrinterOperation,
|
|
24
25
|
withPrintSnapshot,
|
|
25
26
|
} from "../safety/artifact.js";
|
|
26
|
-
import { requireHumanConfirmation, type ConfirmHardware } from "../safety/confirmation.js";
|
|
27
27
|
import { installBambuNodeRejectionGuard } from "./bambu-node-guard.js";
|
|
28
|
+
import { requireHumanConfirmation, type ConfirmHardware } from "../safety/confirmation.js";
|
|
28
29
|
import { assertExpectedPeaks, validateExpectedPeaks, type ExpectedPeakTemperatures, type PrintSafetyOptions } from "../safety/expected-peaks.js";
|
|
30
|
+
import { controlReply, refusedControlMessage, type ControlReply } from "../control-reply.js";
|
|
29
31
|
|
|
30
32
|
export interface BambuPrintOptionsInternal {
|
|
31
33
|
projectName: string;
|
|
@@ -43,6 +45,8 @@ export interface BambuPrintOptionsInternal {
|
|
|
43
45
|
timelapse?: boolean;
|
|
44
46
|
/** Positional mapping: project filament position -> absolute AMS tray (254 = external spool). */
|
|
45
47
|
amsMapping?: number[];
|
|
48
|
+
/** One absolute tray per used filament in plate order; expanded to the project-level mapping. */
|
|
49
|
+
amsSlots?: number[];
|
|
46
50
|
md5?: string;
|
|
47
51
|
expectedPeaks?: ExpectedPeakTemperatures;
|
|
48
52
|
}
|
|
@@ -53,9 +57,57 @@ interface ProjectFileMetadata {
|
|
|
53
57
|
plateFileName: string;
|
|
54
58
|
plateInternalPath: string;
|
|
55
59
|
md5: string;
|
|
60
|
+
/** Project filament count from the plate G-code header (filament_colour, else filament_ids). */
|
|
61
|
+
projectFilamentCount: number;
|
|
62
|
+
/** 0-based project filament positions the plate uses, from Metadata/plate_<n>.json filament_ids. */
|
|
63
|
+
usedFilamentPositions: number[];
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
const COMMAND_SETTLE_MS = 300;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Pause, Resume or Stop exactly as Bambu Studio sends them (MachineObject::command_task_*), with this server's own
|
|
70
|
+
* 32-bit sequence id, so the printer's answer to this one control can be told apart from any other report.
|
|
71
|
+
*/
|
|
72
|
+
function studioControl(command: "stop" | "pause" | "resume") {
|
|
73
|
+
const sequenceId = String(20_000 + (Date.now() % 1_000_000));
|
|
74
|
+
return { command, sequenceId, send: (printer: Pick<BambuClient, "publish">) => printer.publish({ print: { command, param: "", sequence_id: sequenceId } }) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** How long a control on the direct local network waits for the printer's answer (BAMBU_CONTROL_REPLY_MS, 0-30000). */
|
|
78
|
+
function controlReplyMs(): number {
|
|
79
|
+
const configured = Number(process.env.BAMBU_CONTROL_REPLY_MS);
|
|
80
|
+
return Number.isFinite(configured) && configured >= 0 ? Math.min(configured, 30_000) : 5_000;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The printer's answer to one control on its report topic (bambu-node re-emits every message as rawMessage).
|
|
85
|
+
* Started before the publish so a fast answer is not missed; undefined when none arrives in time.
|
|
86
|
+
*/
|
|
87
|
+
function watchControlReply(printer: unknown, command: string, sequenceId: string, ms: number): Promise<ControlReply | undefined> {
|
|
88
|
+
const emitter = printer as any;
|
|
89
|
+
if (ms <= 0 || typeof emitter?.on !== "function" || typeof emitter?.off !== "function") return Promise.resolve(undefined);
|
|
90
|
+
return new Promise(resolve => {
|
|
91
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
92
|
+
const onMessage = (_topic: string, payload: Buffer | string) => {
|
|
93
|
+
const reply = controlReply(String(payload), command, sequenceId);
|
|
94
|
+
if (reply) finish(reply);
|
|
95
|
+
};
|
|
96
|
+
const finish = (reply: ControlReply | undefined) => { clearTimeout(timer); emitter.off("rawMessage", onMessage); resolve(reply); };
|
|
97
|
+
timer = setTimeout(() => finish(undefined), ms);
|
|
98
|
+
emitter.on("rawMessage", onMessage);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Send one direct control and judge it by the printer's answer; a printer not in Developer Mode refuses unsigned ones. */
|
|
103
|
+
async function sendDirectControl(printer: BambuClient, command: "stop" | "pause" | "resume"): Promise<ControlReply | undefined> {
|
|
104
|
+
const control = studioControl(command);
|
|
105
|
+
const answered = watchControlReply(printer, command, control.sequenceId, controlReplyMs());
|
|
106
|
+
await control.send(printer);
|
|
107
|
+
const reply = await answered;
|
|
108
|
+
if (reply && !reply.accepted) throw new Error(refusedControlMessage(command, "the local network", reply.errCode, true));
|
|
109
|
+
return reply;
|
|
110
|
+
}
|
|
59
111
|
/** HMS 0500-0500-0001-0007: firmware 01.08.05+ rejected an unsigned MQTT command. */
|
|
60
112
|
const COMMAND_VERIFICATION_HMS = { attr: 0x05000500, code: 0x00010007 };
|
|
61
113
|
const COMMAND_REJECTED_MESSAGE =
|
|
@@ -78,20 +130,10 @@ function sleep(ms: number): Promise<void> {
|
|
|
78
130
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
79
131
|
}
|
|
80
132
|
|
|
81
|
-
function validateTrayValue(value: unknown, label: string): void {
|
|
82
|
-
if (
|
|
83
|
-
!Number.isInteger(value) ||
|
|
84
|
-
(value as number) < -1 ||
|
|
85
|
-
((value as number) > 15 && (value as number) < 128) ||
|
|
86
|
-
(value as number) > 254
|
|
87
|
-
) {
|
|
88
|
-
throw new Error(
|
|
89
|
-
`${label} values must be integers in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${String(value)}`
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
133
|
class BambuClientStore {
|
|
134
|
+
/** Sees every merged report, so a resume record notices a job ending even when nobody polls. */
|
|
135
|
+
constructor(private readonly onReport?: (host: string, serial: string, report: Record<string, any>) => void) {}
|
|
136
|
+
|
|
95
137
|
private printers: Map<string, BambuClient> = new Map();
|
|
96
138
|
private initialConnectionPromises: Map<string, Promise<void>> = new Map();
|
|
97
139
|
|
|
@@ -117,6 +159,11 @@ class BambuClientStore {
|
|
|
117
159
|
accessToken: token,
|
|
118
160
|
});
|
|
119
161
|
|
|
162
|
+
printer.on("printer:dataUpdate", (data: Record<string, any>) => {
|
|
163
|
+
if (!data || !this.onReport) return;
|
|
164
|
+
try { this.onReport(host, serial, data); } catch { /* an observer never breaks report handling */ }
|
|
165
|
+
});
|
|
166
|
+
|
|
120
167
|
printer.on("client:connect", () => {
|
|
121
168
|
this.printers.set(key, printer);
|
|
122
169
|
this.initialConnectionPromises.delete(key);
|
|
@@ -165,38 +212,212 @@ class BambuClientStore {
|
|
|
165
212
|
}
|
|
166
213
|
}
|
|
167
214
|
|
|
215
|
+
/** The inspected bytes and limits of a started job, for the resume byte check and prompt. */
|
|
216
|
+
export type CheckedFile = {
|
|
217
|
+
sha256: string;
|
|
218
|
+
/** A project's printed plate: its number and the SHA-256 of its Metadata/plate_<index>.gcode. */
|
|
219
|
+
plate?: { index: number; sha256: string };
|
|
220
|
+
model: string;
|
|
221
|
+
maxNozzleTemperature: number;
|
|
222
|
+
maxBedTemperature: number;
|
|
223
|
+
maxChamberTemperature: number;
|
|
224
|
+
materials: string[];
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The last job this server inspected and started on a printer. seenRunning: a
|
|
229
|
+
* report showed it preparing, printing or paused, so a later ended report, or
|
|
230
|
+
* another job in its place, is about it rather than about the job before it.
|
|
231
|
+
*/
|
|
232
|
+
type CheckedJob = CheckedFile & {
|
|
233
|
+
remotePath: string;
|
|
234
|
+
requirements: PrinterStateRequirements;
|
|
235
|
+
seenRunning: boolean;
|
|
236
|
+
/** This server uploaded the bytes to remotePath over FTPS, so a failed re-read refuses the resume. */
|
|
237
|
+
uploadedHere: boolean;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** A route's own dispatch for a print control (FULU's BambuNetwork); undefined sends over LAN MQTT. */
|
|
241
|
+
export type ControlDispatch = () => Promise<any>;
|
|
242
|
+
export type ResumeDispatch = (assertActive: () => void, beforeDispatch: () => Promise<void>) => Promise<any>;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Where each printer's resume record is kept so a restart of this server (a deploy, a crash, a reboot) during a
|
|
246
|
+
* pause can still resume the job it started: BAMBU_RESUME_STATE_DIR, else mcp-3d-printer-server/resume in the
|
|
247
|
+
* config home (bambu-printer-mcp keeps its own). Resume runs every check on a reloaded record: a fresh paused
|
|
248
|
+
* report naming this exact job, the re-read bytes or its unique name, and a person for an unverified file.
|
|
249
|
+
*/
|
|
250
|
+
export function defaultResumeStateDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
251
|
+
if (env.BAMBU_RESUME_STATE_DIR) return env.BAMBU_RESUME_STATE_DIR;
|
|
252
|
+
const configHome = env.XDG_CONFIG_HOME || path.join(env.HOME || os.homedir(), ".config");
|
|
253
|
+
return path.join(configHome, "mcp-3d-printer-server", "resume");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isCheckedJob(value: any): value is CheckedJob {
|
|
257
|
+
return Boolean(value) && typeof value === "object" && /^[a-f0-9]{64}$/.test(value.sha256 ?? "") &&
|
|
258
|
+
typeof value.remotePath === "string" && value.remotePath !== "" && typeof value.model === "string" &&
|
|
259
|
+
Boolean(value.requirements) && typeof value.requirements === "object" && Array.isArray(value.materials) &&
|
|
260
|
+
typeof value.seenRunning === "boolean" && typeof value.uploadedHere === "boolean" &&
|
|
261
|
+
[value.maxNozzleTemperature, value.maxBedTemperature, value.maxChamberTemperature].every(Number.isFinite);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The resume record of each printer (keyed host and serial). With a folder, every change is written to that
|
|
266
|
+
* printer's own file (private, replaced atomically) and the file is read the first time the printer is asked
|
|
267
|
+
* about, so a new server process knows the job the previous one started. Without one it lives in memory only.
|
|
268
|
+
*/
|
|
269
|
+
class ResumeRecords {
|
|
270
|
+
private readonly jobs = new Map<string, CheckedJob>();
|
|
271
|
+
private readonly read = new Set<string>();
|
|
272
|
+
constructor(private readonly dir?: string) {}
|
|
273
|
+
|
|
274
|
+
get(key: string): CheckedJob | undefined {
|
|
275
|
+
if (!this.read.has(key)) {
|
|
276
|
+
this.read.add(key);
|
|
277
|
+
const saved = this.dir ? this.readFile(key) : undefined;
|
|
278
|
+
if (saved) this.jobs.set(key, saved);
|
|
279
|
+
}
|
|
280
|
+
return this.jobs.get(key);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
set(key: string, job: CheckedJob): void {
|
|
284
|
+
this.read.add(key);
|
|
285
|
+
this.jobs.set(key, job);
|
|
286
|
+
this.save(key);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
delete(key: string): boolean {
|
|
290
|
+
this.read.add(key);
|
|
291
|
+
const had = this.jobs.delete(key);
|
|
292
|
+
this.save(key);
|
|
293
|
+
return had;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Write this printer's record as it is now (or remove its file). A failed write never stops a print or a control. */
|
|
297
|
+
save(key: string): void {
|
|
298
|
+
if (!this.dir) return;
|
|
299
|
+
const file = this.file(key);
|
|
300
|
+
const job = this.jobs.get(key);
|
|
301
|
+
try {
|
|
302
|
+
if (!job) { rmSync(file, { force: true }); return; }
|
|
303
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
304
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
305
|
+
writeFileSync(temporary, JSON.stringify({ version: 1, key, job }), { mode: 0o600 });
|
|
306
|
+
renameSync(temporary, file);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error(`Could not save the resume record for ${key.replace("\n", " ")}: ${(error as Error).message}. Resume still works until this server restarts.`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private file(key: string): string {
|
|
313
|
+
return path.join(this.dir!, `${createHash("sha256").update(key).digest("hex").slice(0, 32)}.json`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private readFile(key: string): CheckedJob | undefined {
|
|
317
|
+
try {
|
|
318
|
+
const saved = JSON.parse(readFileSync(this.file(key), "utf8"));
|
|
319
|
+
return saved?.version === 1 && saved.key === key && isCheckedJob(saved.job) ? saved.job : undefined;
|
|
320
|
+
} catch { return undefined; }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const ACTIVE_JOB_STATES = new Set(["PREPARE", "SLICING", "RUNNING", "PAUSE"]);
|
|
325
|
+
/** Reports after which a started job can no longer be resumed. */
|
|
326
|
+
const ENDED_JOB_STATES = new Set(["FINISH", "FAILED", "IDLE"]);
|
|
327
|
+
|
|
328
|
+
const jobStem = (value: string) => path.posix.basename(value.replace(/\\/g, "/")).replace(/(?:\.gcode)?\.3mf$|\.gcode$/i, "");
|
|
329
|
+
const reportedJobNames = (raw: Record<string, any>): string[] =>
|
|
330
|
+
[raw.gcode_file, raw.subtask_name].filter((value): value is string => typeof value === "string" && value.trim() !== "");
|
|
331
|
+
/** Whether a report names the job stored at remotePath (subtask_name for projects, gcode_file for raw G-code). */
|
|
332
|
+
function reportsJob(raw: Record<string, any>, remotePath: string): boolean {
|
|
333
|
+
return reportedJobNames(raw).some((value) => jobStem(value) === jobStem(remotePath));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** What a resume needs to know about a started file (filePath, the inspected snapshot). */
|
|
337
|
+
export async function checkedFile(inspection: PrintFileInspection, filePath: string): Promise<CheckedFile> {
|
|
338
|
+
const used = inspection.usedFilamentPositions.map((position) => inspection.materials[position]).filter(Boolean);
|
|
339
|
+
let plate: CheckedFile["plate"];
|
|
340
|
+
const plateNumber = /^Metadata\/plate_(\d+)\.gcode$/i.exec(inspection.plateInternalPath ?? "");
|
|
341
|
+
if (plateNumber && /\.3mf$/i.test(filePath)) {
|
|
342
|
+
const entry = (await readSafe3mfArchive(filePath)).zip.file(inspection.plateInternalPath!);
|
|
343
|
+
if (entry) plate = { index: Number(plateNumber[1]), sha256: createHash("sha256").update(await entry.async("nodebuffer")).digest("hex") };
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
sha256: inspection.sha256, ...(plate ? { plate } : {}), model: inspection.model,
|
|
347
|
+
maxNozzleTemperature: inspection.maxNozzleTemperature, maxBedTemperature: inspection.maxBedTemperature,
|
|
348
|
+
maxChamberTemperature: inspection.maxChamberTemperature,
|
|
349
|
+
materials: [...new Set(used.length ? used : inspection.materials)],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Where a printer keeps the plate G-code it extracted from a project to print
|
|
355
|
+
* it: a P1S removes the uploaded 3MF once it starts and keeps
|
|
356
|
+
* cache/<name>_plate_<n>.gcode, byte-identical to the 3MF's plate G-code.
|
|
357
|
+
*/
|
|
358
|
+
function extractedPlatePath(remotePath: string, index: number): string {
|
|
359
|
+
const directory = path.posix.dirname(remotePath);
|
|
360
|
+
const stem = path.posix.basename(remotePath).replace(/(?:\.gcode)?\.3mf$/i, "");
|
|
361
|
+
return `${directory === "." ? "" : `${directory}/`}${stem}_plate_${index}.gcode`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The job name the printer reports: subtask_name, else the G-code file's basename. */
|
|
365
|
+
function reportedJobName(raw: Record<string, any>): string | undefined {
|
|
366
|
+
const subtask = typeof raw.subtask_name === "string" ? raw.subtask_name.trim() : "";
|
|
367
|
+
if (subtask) return subtask;
|
|
368
|
+
return typeof raw.gcode_file === "string" ? path.posix.basename(raw.gcode_file.replace(/\\/g, "/").replace(/^\d+:/, "")) || undefined : undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
168
371
|
export class BambuImplementation extends PrinterImplementation {
|
|
169
372
|
private printerStore: BambuClientStore;
|
|
373
|
+
/**
|
|
374
|
+
* The job each printer may resume, keyed host and serial. Cancel, a report
|
|
375
|
+
* that it ended after it was seen running, or another job seen in its place
|
|
376
|
+
* retires it, so a later job that reuses the name is never resumed.
|
|
377
|
+
*/
|
|
378
|
+
private readonly checkedJobs: ResumeRecords;
|
|
170
379
|
|
|
171
|
-
|
|
380
|
+
/** confirm: a person (MCP elicitation), asked only to resume a job whose file could not be re-read. */
|
|
381
|
+
constructor(apiClient: any, private readonly confirm?: ConfirmHardware, options: { resumeStateDir?: string } = {}) {
|
|
172
382
|
super(apiClient);
|
|
173
|
-
this.
|
|
383
|
+
this.checkedJobs = new ResumeRecords(options.resumeStateDir);
|
|
384
|
+
this.printerStore = new BambuClientStore((host, serial, report) => this.observeReport(host, serial, report));
|
|
174
385
|
}
|
|
175
386
|
|
|
176
|
-
|
|
177
|
-
|
|
387
|
+
/**
|
|
388
|
+
* Internal handoff from a successful inspected transport; never an MCP tool.
|
|
389
|
+
* uploadedHere is false for FULU's BambuNetwork routes, where Bambu's plug-in
|
|
390
|
+
* put the file on the printer at a path this server has not verified: a
|
|
391
|
+
* resume then re-reads it if it can and otherwise asks a person.
|
|
392
|
+
*/
|
|
393
|
+
recordCheckedJob(host: string, serial: string, remotePath: string, requirements: PrinterStateRequirements, file: CheckedFile, uploadedHere = true): void {
|
|
394
|
+
if (!/^[a-f0-9]{64}$/.test(file?.sha256 ?? "")) throw new Error("A started job needs the SHA-256 of its inspected bytes.");
|
|
395
|
+
this.checkedJobs.set(`${host}\n${serial}`, {
|
|
396
|
+
...structuredClone(file), remotePath, requirements: structuredClone(requirements), seenRunning: false, uploadedHere,
|
|
397
|
+
});
|
|
178
398
|
}
|
|
179
399
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
400
|
+
/** Retire a record that never started (the dispatch was refused or failed). */
|
|
401
|
+
forgetCheckedJob(host: string, serial: string, remotePath: string): void {
|
|
402
|
+
const key = `${host}\n${serial}`;
|
|
403
|
+
if (this.checkedJobs.get(key)?.remotePath === remotePath) this.checkedJobs.delete(key);
|
|
184
404
|
}
|
|
185
405
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
406
|
+
/** Feed one merged report to the resume record (the client store calls it for every report). */
|
|
407
|
+
observeReport(host: string, serial: string, raw: Record<string, any> | undefined): void {
|
|
408
|
+
const key = `${host}\n${serial}`;
|
|
409
|
+
const checked = this.checkedJobs.get(key);
|
|
410
|
+
if (!checked || !raw || typeof raw.gcode_state !== "string") return;
|
|
411
|
+
const state = raw.gcode_state.toUpperCase();
|
|
412
|
+
if (ACTIVE_JOB_STATES.has(state) && reportsJob(raw, checked.remotePath)) {
|
|
413
|
+
if (!checked.seenRunning) { checked.seenRunning = true; this.checkedJobs.save(key); }
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (checked.seenRunning && (ENDED_JOB_STATES.has(state) || (ACTIVE_JOB_STATES.has(state) && reportedJobNames(raw).length > 0))) {
|
|
417
|
+
this.checkedJobs.delete(key);
|
|
418
|
+
}
|
|
197
419
|
}
|
|
198
420
|
|
|
199
|
-
|
|
200
421
|
private async getPrinter(host: string, serial: string, token: string): Promise<BambuClient> {
|
|
201
422
|
return this.printerStore.getPrinter(host, serial, token);
|
|
202
423
|
}
|
|
@@ -238,10 +459,37 @@ export class BambuImplementation extends PrinterImplementation {
|
|
|
238
459
|
throw new Error(`Selected inspected plate ${expectedEntryName} is not present in 3MF. Re-slice that plate.`);
|
|
239
460
|
}
|
|
240
461
|
const gcodeBuffer = await selectedEntry.async("nodebuffer");
|
|
462
|
+
// The slicer's project filament list length, from the G-code header
|
|
463
|
+
// (`; filament_colour = #FFFFFF;#FF911A80;...`), scanning only its first 32 KB.
|
|
464
|
+
let projectFilamentCount = 1;
|
|
465
|
+
const head = gcodeBuffer.subarray(0, 32 * 1024).toString("utf8");
|
|
466
|
+
const colourLine = head.match(/;\s*filament_colour\s*=\s*([^\n\r]+)/i);
|
|
467
|
+
if (colourLine) {
|
|
468
|
+
projectFilamentCount = colourLine[1].split(";").filter((entry) => entry.trim()).length;
|
|
469
|
+
} else {
|
|
470
|
+
const idsLine = head.match(/;\s*filament_ids\s*=\s*([^\n\r]+)/i);
|
|
471
|
+
if (idsLine) projectFilamentCount = idsLine[1].split(";").filter((entry) => entry.trim()).length;
|
|
472
|
+
}
|
|
473
|
+
// The positions the selected plate consumes, from Metadata/plate_<n>.json filament_ids.
|
|
474
|
+
let usedFilamentPositions: number[] = [];
|
|
475
|
+
const plateJsonEntry = zip.file(selectedEntry.name.replace(/\.gcode$/i, ".json"));
|
|
476
|
+
if (plateJsonEntry) {
|
|
477
|
+
try {
|
|
478
|
+
const json = JSON.parse(await plateJsonEntry.async("string"));
|
|
479
|
+
if (Array.isArray(json.filament_ids)) {
|
|
480
|
+
usedFilamentPositions = json.filament_ids.filter((n: unknown) => Number.isInteger(n));
|
|
481
|
+
}
|
|
482
|
+
} catch {
|
|
483
|
+
// A malformed plate_N.json leaves ams_mapping as the way to map trays.
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (usedFilamentPositions.length === 0) usedFilamentPositions = [0];
|
|
241
487
|
return {
|
|
242
488
|
plateFileName: path.posix.basename(selectedEntry.name),
|
|
243
489
|
plateInternalPath: selectedEntry.name,
|
|
244
490
|
md5: createHash("md5").update(gcodeBuffer).digest("hex"),
|
|
491
|
+
projectFilamentCount,
|
|
492
|
+
usedFilamentPositions,
|
|
245
493
|
};
|
|
246
494
|
}
|
|
247
495
|
|
|
@@ -342,38 +590,16 @@ export class BambuImplementation extends PrinterImplementation {
|
|
|
342
590
|
throw new Error("Provided checksum does not match the inspected plate G-code.");
|
|
343
591
|
}
|
|
344
592
|
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
let checkedMapping: number[];
|
|
348
|
-
if (useAMS) {
|
|
349
|
-
checkedMapping = (options.amsMapping ?? []).slice();
|
|
350
|
-
} else {
|
|
351
|
-
checkedMapping = Array<number>(inspection.materials.length).fill(-1);
|
|
352
|
-
inspection.usedFilamentPositions.forEach((position) => { checkedMapping[position] = 254; });
|
|
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];
|
|
593
|
+
const { baseMapping, amsMapping, useAMS } = this.checkedAmsMapping(model, options, inspection, projectMetadata);
|
|
594
|
+
const checkedMapping = baseMapping;
|
|
369
595
|
|
|
370
596
|
const requirements: PrinterStateRequirements = { ...inspection, amsMapping: checkedMapping, useAMS };
|
|
597
|
+
const resumeFile = await checkedFile(inspection, options.filePath);
|
|
371
598
|
const initialStatus = await this.getSafetyStatus(host, serial, token);
|
|
372
599
|
validatePrinterState(initialStatus, requirements);
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
validatePrinterState(confirmedStatus, requirements);
|
|
600
|
+
// Recheck before the upload: the printer may have changed meanwhile.
|
|
601
|
+
const recheckedStatus = await this.getSafetyStatus(host, serial, token);
|
|
602
|
+
validatePrinterState(recheckedStatus, requirements);
|
|
377
603
|
assertActive();
|
|
378
604
|
|
|
379
605
|
const remoteFileName = uniquePrintName(options.filePath);
|
|
@@ -385,12 +611,14 @@ export class BambuImplementation extends PrinterImplementation {
|
|
|
385
611
|
const printer = await this.getPrinter(host, serial, token);
|
|
386
612
|
assertActive();
|
|
387
613
|
|
|
614
|
+
const subtaskName = remoteFileName.replace(/\.3mf$/i, "");
|
|
388
615
|
const projectFileCmd = {
|
|
389
616
|
print: {
|
|
390
617
|
command: "project_file",
|
|
391
618
|
param: projectMetadata.plateInternalPath,
|
|
392
619
|
url: `file:///sdcard/${remoteProjectPath}`,
|
|
393
|
-
subtask_name
|
|
620
|
+
// The printer reports this back as subtask_name, which identifies the checked job for resume.
|
|
621
|
+
subtask_name: subtaskName,
|
|
394
622
|
md5,
|
|
395
623
|
flow_cali: options.flowCalibration ?? true,
|
|
396
624
|
layer_inspect: options.layerInspect ?? true,
|
|
@@ -410,8 +638,9 @@ export class BambuImplementation extends PrinterImplementation {
|
|
|
410
638
|
|
|
411
639
|
const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
|
|
412
640
|
await printer.publish(projectFileCmd);
|
|
641
|
+
this.recordCheckedJob(host, serial, remoteProjectPath, requirements, resumeFile);
|
|
413
642
|
await sleep(COMMAND_SETTLE_MS);
|
|
414
|
-
const dispatch = await
|
|
643
|
+
const dispatch = await this.settleRecorded(watcher, host, serial, remoteProjectPath);
|
|
415
644
|
|
|
416
645
|
return {
|
|
417
646
|
status: "success",
|
|
@@ -421,6 +650,7 @@ export class BambuImplementation extends PrinterImplementation {
|
|
|
421
650
|
: `Uploaded the checked 3MF and sent the print command for ${options.projectName}; the printer has not reported starting yet. ` +
|
|
422
651
|
"Check get_printer_status before assuming it is printing.",
|
|
423
652
|
remoteProjectPath,
|
|
653
|
+
subtaskName,
|
|
424
654
|
plateFile: projectMetadata.plateFileName,
|
|
425
655
|
platePath: projectMetadata.plateInternalPath,
|
|
426
656
|
md5,
|
|
@@ -434,6 +664,77 @@ export class BambuImplementation extends PrinterImplementation {
|
|
|
434
664
|
};
|
|
435
665
|
}
|
|
436
666
|
|
|
667
|
+
/**
|
|
668
|
+
* The project-level tray mapping for a checked print, built as
|
|
669
|
+
* bambu-printer-mcp builds it: position = project filament, value = absolute
|
|
670
|
+
* tray (0-15 AMS, 128+ AMS-HT, 254 external; 255 the right/main external
|
|
671
|
+
* spool on dual-nozzle printers, -1 unused). ams_mapping is taken as given;
|
|
672
|
+
* ams_slots supplies one tray per used filament and is expanded against the
|
|
673
|
+
* plate's filament_ids; use_ams:false maps every used filament to the
|
|
674
|
+
* external spool of its nozzle. Every used position must end up mapped.
|
|
675
|
+
* The wire form pads to the historical five entries.
|
|
676
|
+
*/
|
|
677
|
+
private checkedAmsMapping(
|
|
678
|
+
model: string,
|
|
679
|
+
options: BambuPrintOptionsInternal,
|
|
680
|
+
inspection: PrintFileInspection,
|
|
681
|
+
projectMetadata: ProjectFileMetadata
|
|
682
|
+
): { baseMapping: number[]; amsMapping: number[]; useAMS: boolean } {
|
|
683
|
+
const dualNozzle = isDualNozzleModel(model);
|
|
684
|
+
const externalTrays = dualNozzle ? [254, 255] : [254];
|
|
685
|
+
const useAMS = options.useAMS !== false;
|
|
686
|
+
const validate = (value: unknown, label: string): void => {
|
|
687
|
+
if (!Number.isInteger(value) || (value as number) < -1 ||
|
|
688
|
+
((value as number) > 15 && (value as number) < 128) || (value as number) > (dualNozzle ? 255 : 254)) {
|
|
689
|
+
throw new Error(`${label} values must be integers in ${describeAmsTrayRange({ dualNozzle })}; got ${String(value)}`);
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
let baseMapping: number[];
|
|
693
|
+
if (options.amsMapping && options.amsMapping.length > 0) {
|
|
694
|
+
for (const value of options.amsMapping) validate(value, "ams_mapping");
|
|
695
|
+
baseMapping = options.amsMapping.slice();
|
|
696
|
+
} else if (options.amsSlots && options.amsSlots.length > 0) {
|
|
697
|
+
for (const value of options.amsSlots) validate(value, "ams_slots");
|
|
698
|
+
const positions = projectMetadata.usedFilamentPositions;
|
|
699
|
+
if (options.amsSlots.length !== positions.length) {
|
|
700
|
+
throw new Error(
|
|
701
|
+
`ams_slots length ${options.amsSlots.length} does not match used filament count ${positions.length} ` +
|
|
702
|
+
`(plate uses positions ${JSON.stringify(positions)}). Provide one tray per used filament, or use ams_mapping for a raw project-level array.`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
baseMapping = Array<number>(Math.max(projectMetadata.projectFilamentCount, ...positions.map((p) => p + 1))).fill(-1);
|
|
706
|
+
positions.forEach((position, index) => { baseMapping[position] = options.amsSlots![index]; });
|
|
707
|
+
} else {
|
|
708
|
+
if (useAMS) {
|
|
709
|
+
throw new Error(
|
|
710
|
+
"AMS printing requires ams_slots or ams_mapping for this sliced file. " +
|
|
711
|
+
`Plate uses project filament positions ${JSON.stringify(inspection.usedFilamentPositions)}; pass one tray per used filament (for example ams_slots: [0]), or use_ams:false for the external spool.`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
const positions = inspection.usedFilamentPositions;
|
|
715
|
+
baseMapping = Array<number>(Math.max(inspection.materials.length, projectMetadata.projectFilamentCount, ...positions.map((p) => p + 1), 1)).fill(-1);
|
|
716
|
+
for (const position of positions) {
|
|
717
|
+
baseMapping[position] = externalTrayForNozzle(inspection.filamentNozzleIndices?.[position], dualNozzle);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
for (const position of inspection.usedFilamentPositions) {
|
|
721
|
+
if (baseMapping[position] === undefined || baseMapping[position] < 0) {
|
|
722
|
+
throw new Error(
|
|
723
|
+
`Missing physical filament mapping for project filament ${position}. ` +
|
|
724
|
+
"Pass ams_slots (one tray per used filament) or ams_mapping with a tray for every used project filament, or use_ams:false for the external spool."
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
if (!useAMS && !externalTrays.includes(baseMapping[position])) {
|
|
728
|
+
throw new Error(`External-spool printing requires external spool mapping (${externalTrays.join(" or ")}).`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const amsMapping = Array.from(
|
|
732
|
+
{ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) },
|
|
733
|
+
(_, index) => (index < baseMapping.length ? baseMapping[index] : -1)
|
|
734
|
+
);
|
|
735
|
+
return { baseMapping, amsMapping, useAMS };
|
|
736
|
+
}
|
|
737
|
+
|
|
437
738
|
/**
|
|
438
739
|
* A published print command is not proof the printer took it. Listen to the
|
|
439
740
|
* reports the printer pushes anyway (no extra pushall polling on weak P1
|
|
@@ -488,20 +789,147 @@ export class BambuImplementation extends PrinterImplementation {
|
|
|
488
789
|
};
|
|
489
790
|
}
|
|
490
791
|
|
|
491
|
-
/**
|
|
492
|
-
async
|
|
792
|
+
/** A refused or unconfirmed-then-failed dispatch leaves no job to resume. */
|
|
793
|
+
private async settleRecorded(watcher: { settled: () => Promise<"started" | "unconfirmed"> }, host: string, serial: string, remotePath: string) {
|
|
794
|
+
try {
|
|
795
|
+
return await watcher.settled();
|
|
796
|
+
} catch (error) {
|
|
797
|
+
this.forgetCheckedJob(host, serial, remotePath);
|
|
798
|
+
throw error;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Stop is never gated or queued behind pending checked operations, and it
|
|
804
|
+
* retires the resume record. dispatch sends it over the route that printed
|
|
805
|
+
* (FULU's BambuNetwork); without one it goes over LAN MQTT.
|
|
806
|
+
*/
|
|
807
|
+
async cancelJob(host: string, port: string, apiKey: string, dispatch?: ControlDispatch): Promise<any> {
|
|
493
808
|
const [serial, token] = this.extractBambuCredentials(apiKey);
|
|
494
809
|
cancelPendingPrinterOperations(host, serial);
|
|
495
|
-
|
|
496
|
-
|
|
810
|
+
this.checkedJobs.delete(`${host}\n${serial}`);
|
|
497
811
|
try {
|
|
498
|
-
|
|
499
|
-
|
|
812
|
+
if (dispatch) return await dispatch();
|
|
813
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
814
|
+
const reply = await sendDirectControl(printer, "stop");
|
|
815
|
+
return { status: "success", confirmed: Boolean(reply), message: reply ? "The printer accepted Stop." : "Cancel command sent successfully." };
|
|
500
816
|
} catch (error) {
|
|
501
817
|
throw new Error(`Failed to cancel print: ${(error as Error).message}`);
|
|
502
818
|
}
|
|
503
819
|
}
|
|
504
820
|
|
|
821
|
+
/** Pause is never gated. */
|
|
822
|
+
async pauseJob(host: string, port: string, apiKey: string, dispatch?: ControlDispatch): Promise<{ status: string; action: "pause"; message: string; [key: string]: unknown }> {
|
|
823
|
+
const [serial, token] = this.extractBambuCredentials(apiKey);
|
|
824
|
+
try {
|
|
825
|
+
if (dispatch) {
|
|
826
|
+
const result = await dispatch();
|
|
827
|
+
return { status: "success", message: "Pause command sent successfully.", ...result, action: "pause" };
|
|
828
|
+
}
|
|
829
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
830
|
+
const reply = await sendDirectControl(printer, "pause");
|
|
831
|
+
if (!reply) await sleep(COMMAND_SETTLE_MS);
|
|
832
|
+
return { status: "success", action: "pause", confirmed: Boolean(reply), message: reply ? "The printer accepted Pause." : "Pause command sent successfully." };
|
|
833
|
+
} catch (error) {
|
|
834
|
+
throw new Error(`Failed to pause print: ${(error as Error).message}`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Resume reheats and moves the printer, so it is gated like a print start:
|
|
840
|
+
* only the job this server inspected and started, while a fresh report shows
|
|
841
|
+
* it paused, with its bytes re-read over FTPS and compared. Only a file that
|
|
842
|
+
* could not be re-read, and that this server did not upload itself, asks a
|
|
843
|
+
* person. The record must still be current at every check.
|
|
844
|
+
*/
|
|
845
|
+
async resumeJob(host: string, port: string, apiKey: string, dispatch?: ResumeDispatch): Promise<{ status: string; action: "resume"; message: string; jobName: string; bytesVerified: boolean; [key: string]: unknown }> {
|
|
846
|
+
const [serial, token] = this.extractBambuCredentials(apiKey);
|
|
847
|
+
return withPrinterOperation(host, serial, async (assertActive) => {
|
|
848
|
+
const key = `${host}\n${serial}`;
|
|
849
|
+
const checked = this.checkedJobs.get(key);
|
|
850
|
+
if (!checked) throw new Error("Resume requires a job inspected and started by this server instance. Verify other jobs on the printer before resuming them there.");
|
|
851
|
+
let jobName = path.posix.basename(checked.remotePath);
|
|
852
|
+
const pausedJob = async () => {
|
|
853
|
+
assertActive();
|
|
854
|
+
const status = await this.getSafetyStatus(host, serial, token);
|
|
855
|
+
this.observeReport(host, serial, status.raw);
|
|
856
|
+
if (this.checkedJobs.get(key) !== checked) {
|
|
857
|
+
throw new Error(`${jobName} is no longer the job this server started: it was cancelled, it ended, or another job took its place. Nothing was sent; check the printer and resume it there.`);
|
|
858
|
+
}
|
|
859
|
+
validatePrinterState(status, { ...checked.requirements, requireIdle: false });
|
|
860
|
+
if (status.raw.gcode_state !== "PAUSE") throw new Error("Resume requires a freshly reported paused job.");
|
|
861
|
+
if (!reportsJob(status.raw, checked.remotePath)) throw new Error("Paused job identity does not match this server's inspected artifact.");
|
|
862
|
+
jobName = reportedJobName(status.raw) ?? jobName;
|
|
863
|
+
assertActive();
|
|
864
|
+
};
|
|
865
|
+
await pausedJob();
|
|
866
|
+
const reread = await this.rereadJob(host, token, checked);
|
|
867
|
+
if ("changed" in reread) {
|
|
868
|
+
if (this.checkedJobs.get(key) === checked) this.checkedJobs.delete(key);
|
|
869
|
+
throw new Error(`${jobName} on the printer is no longer the file this server inspected (SHA-256 of ${reread.changed} changed). Nothing was sent, and this job can no longer be resumed from here.`);
|
|
870
|
+
}
|
|
871
|
+
const unread = "unread" in reread ? reread.unread : undefined;
|
|
872
|
+
if (unread !== undefined && checked.uploadedHere) {
|
|
873
|
+
throw new Error(`Could not re-read ${jobName} from the printer over FTPS to confirm it is the inspected file (${unread}). Nothing was sent; resume it at the printer.`);
|
|
874
|
+
}
|
|
875
|
+
const bytesVerified = "verified" in reread;
|
|
876
|
+
assertActive();
|
|
877
|
+
if (!bytesVerified) {
|
|
878
|
+
// Bambu's plug-in placed this file and it cannot be handed back, so only a person can tell it is still the inspected job.
|
|
879
|
+
await requireHumanConfirmation(
|
|
880
|
+
this.confirm,
|
|
881
|
+
`Resume ${jobName} on ${checked.model.toUpperCase()} (${serial})? Bambu's plug-in placed the file and it could not be re-read over FTPS (${unread}), so it is matched by its unique name only. ` +
|
|
882
|
+
`It reheats to its targets (peak nozzle ${checked.maxNozzleTemperature}°C, bed ${checked.maxBedTemperature}°C, chamber ${checked.maxChamberTemperature}°C) and moves. ` +
|
|
883
|
+
`Materials: ${checked.materials.length ? checked.materials.join(", ") : "none declared"}. Inspected file SHA-256: ${checked.sha256}. ` +
|
|
884
|
+
"Confirm the nozzle path is clear and the filament is still loaded."
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
// The job may have been resumed, stopped or replaced at the printer during the re-read or the prompt.
|
|
888
|
+
await pausedJob();
|
|
889
|
+
if (dispatch) {
|
|
890
|
+
const result = await dispatch(assertActive, pausedJob);
|
|
891
|
+
return { status: "success", message: `Resume command sent for ${jobName}.`, ...result, action: "resume", jobName, bytesVerified };
|
|
892
|
+
}
|
|
893
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
894
|
+
assertActive();
|
|
895
|
+
const reply = await sendDirectControl(printer, "resume");
|
|
896
|
+
if (!reply) await sleep(COMMAND_SETTLE_MS);
|
|
897
|
+
return { status: "success", action: "resume", confirmed: Boolean(reply), message: reply ? `The printer accepted Resume for ${jobName}.` : `Resume command sent for ${jobName}.`, jobName, bytesVerified };
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Re-read a started job's bytes over FTPS: the uploaded file, else the plate
|
|
903
|
+
* G-code the printer extracted from the project to print it. The first file
|
|
904
|
+
* that can be read decides; a mismatch is reported as changed.
|
|
905
|
+
*/
|
|
906
|
+
private async rereadJob(host: string, token: string, checked: CheckedJob): Promise<{ verified: string } | { changed: string } | { unread: string }> {
|
|
907
|
+
const candidates = [{ remote: checked.remotePath, sha256: checked.sha256 }];
|
|
908
|
+
if (checked.plate) candidates.push({ remote: extractedPlatePath(checked.remotePath, checked.plate.index), sha256: checked.plate.sha256 });
|
|
909
|
+
const failures: string[] = [];
|
|
910
|
+
for (const candidate of candidates) {
|
|
911
|
+
let sha256: string;
|
|
912
|
+
try { sha256 = await this.remoteSha256(host, token, candidate.remote); }
|
|
913
|
+
catch (error) { failures.push(`${candidate.remote}: ${(error as Error).message}`); continue; }
|
|
914
|
+
return sha256 === candidate.sha256 ? { verified: candidate.remote } : { changed: candidate.remote };
|
|
915
|
+
}
|
|
916
|
+
return { unread: failures.join("; ") };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** SHA-256 of a printer file, streamed from an FTPS download into a private directory. */
|
|
920
|
+
private async remoteSha256(host: string, token: string, remotePath: string): Promise<string> {
|
|
921
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bambu-resume-check-"));
|
|
922
|
+
try {
|
|
923
|
+
const localPath = path.join(directory, "job");
|
|
924
|
+
await this.ftpDownload(host, token, remotePath.replace(/^\/+/, ""), localPath);
|
|
925
|
+
const hash = createHash("sha256");
|
|
926
|
+
for await (const chunk of createReadStream(localPath)) hash.update(chunk as Buffer);
|
|
927
|
+
return hash.digest("hex");
|
|
928
|
+
} finally {
|
|
929
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
505
933
|
async setTemperature(
|
|
506
934
|
host: string,
|
|
507
935
|
port: string,
|
|
@@ -543,10 +971,6 @@ export class BambuImplementation extends PrinterImplementation {
|
|
|
543
971
|
? manualHeatingRequirements(current, model!, nozzleDiameter, material!)
|
|
544
972
|
: { model: model!, nozzleDiameters: [] });
|
|
545
973
|
validateHeatingStatus(await this.getSafetyStatus(host, serial, token));
|
|
546
|
-
await this.confirmHardwareAction(
|
|
547
|
-
`Heat ${heater} on ${model!.toUpperCase()} (${serial}) to ${targetTemperature}°C?` +
|
|
548
|
-
(material && heater === "nozzle" ? ` Declared material: ${material}. Confirm the physical spool label.` : "")
|
|
549
|
-
);
|
|
550
974
|
assertActive();
|
|
551
975
|
validateHeatingStatus(await this.getSafetyStatus(host, serial, token));
|
|
552
976
|
}
|
|
@@ -668,20 +1092,21 @@ export class BambuImplementation extends PrinterImplementation {
|
|
|
668
1092
|
}
|
|
669
1093
|
const initialStatus = await this.getSafetyStatus(host, serial, token);
|
|
670
1094
|
this.validateLoadedGcodeState(initialStatus, inspection);
|
|
671
|
-
await this.
|
|
672
|
-
|
|
673
|
-
this.validateLoadedGcodeState(confirmedStatus, inspection);
|
|
1095
|
+
const recheckedStatus = await this.getSafetyStatus(host, serial, token);
|
|
1096
|
+
this.validateLoadedGcodeState(recheckedStatus, inspection);
|
|
674
1097
|
assertActive();
|
|
675
1098
|
// Start a uniquely named copy of the inspected bytes, never a name that can change.
|
|
676
1099
|
const remotePath = path.posix.join(path.posix.dirname(filename), uniquePrintName(filename));
|
|
677
1100
|
await this.ftpUpload(host, token, filePath, `/${remotePath}`);
|
|
678
1101
|
const dispatchStatus = await this.getSafetyStatus(host, serial, token);
|
|
679
|
-
this.validateLoadedGcodeState(dispatchStatus, inspection);
|
|
1102
|
+
const requirements = this.validateLoadedGcodeState(dispatchStatus, inspection);
|
|
1103
|
+
const resumeFile = await checkedFile(inspection, filePath);
|
|
680
1104
|
const printer = await this.getPrinter(host, serial, token);
|
|
681
1105
|
assertActive();
|
|
682
1106
|
const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
|
|
683
1107
|
await publishBambuCommand(printer, "print", "gcode_file", { param: remotePath });
|
|
684
|
-
|
|
1108
|
+
this.recordCheckedJob(host, serial, remotePath, requirements, resumeFile);
|
|
1109
|
+
const dispatch = await this.settleRecorded(watcher, host, serial, remotePath);
|
|
685
1110
|
return {
|
|
686
1111
|
status: "success",
|
|
687
1112
|
uploaded: true,
|