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.
Files changed (95) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/CONTRIBUTORS.md +5 -1
  3. package/README.md +64 -29
  4. package/dist/ams-mapping.d.ts +38 -0
  5. package/dist/ams-mapping.js +125 -0
  6. package/dist/bambu-network-bridge.d.ts +211 -0
  7. package/dist/bambu-network-bridge.js +918 -0
  8. package/dist/bambu-network-certs.d.ts +13 -0
  9. package/dist/bambu-network-certs.js +152 -0
  10. package/dist/control-reply.d.ts +21 -0
  11. package/dist/control-reply.js +44 -0
  12. package/dist/counterpart/camera-sources.d.ts +16 -0
  13. package/dist/counterpart/camera-sources.js +138 -0
  14. package/dist/counterpart/camera-stream.d.ts +66 -0
  15. package/dist/counterpart/camera-stream.js +246 -0
  16. package/dist/counterpart/camera-tools.d.ts +247 -0
  17. package/dist/counterpart/camera-tools.js +35 -0
  18. package/dist/counterpart/contract.d.ts +21 -5
  19. package/dist/counterpart/contract.js +8 -2
  20. package/dist/counterpart/route.d.ts +31 -0
  21. package/dist/counterpart/route.js +70 -0
  22. package/dist/counterpart/service.d.ts +60 -4
  23. package/dist/counterpart/service.js +142 -16
  24. package/dist/design-guidance.d.ts +26 -0
  25. package/dist/design-guidance.js +177 -0
  26. package/dist/fulu-orca.d.ts +7 -0
  27. package/dist/fulu-orca.js +18 -2
  28. package/dist/index.js +514 -70
  29. package/dist/printers/bambu-filaments.d.ts +68 -0
  30. package/dist/printers/bambu-filaments.js +262 -0
  31. package/dist/printers/bambu.d.ts +99 -6
  32. package/dist/printers/bambu.js +478 -72
  33. package/dist/printers/creality.d.ts +2 -1
  34. package/dist/printers/creality.js +5 -1
  35. package/dist/printers/generic-base.d.ts +11 -6
  36. package/dist/printers/generic-base.js +21 -25
  37. package/dist/printers/klipper.d.ts +2 -1
  38. package/dist/printers/klipper.js +10 -7
  39. package/dist/printers/octoprint.d.ts +2 -1
  40. package/dist/printers/octoprint.js +15 -8
  41. package/dist/printers/printer-factory.d.ts +1 -1
  42. package/dist/printers/printer-factory.js +3 -3
  43. package/dist/printers/prusa.js +0 -1
  44. package/dist/safety/confirmation.d.ts +11 -16
  45. package/dist/safety/confirmation.js +8 -32
  46. package/dist/safety/hidden-hms.d.ts +32 -0
  47. package/dist/safety/hidden-hms.js +405 -0
  48. package/dist/safety/limits.d.ts +8 -2
  49. package/dist/safety/limits.js +14 -5
  50. package/dist/safety/print-file.d.ts +5 -0
  51. package/dist/safety/print-file.js +156 -19
  52. package/dist/safety/printer-state.d.ts +68 -8
  53. package/dist/safety/printer-state.js +187 -40
  54. package/dist/slicer/bambu-cli.d.ts +2 -0
  55. package/dist/slicer/bambu-cli.js +7 -5
  56. package/dist/slicer/layer-pauses.d.ts +47 -0
  57. package/dist/slicer/layer-pauses.js +130 -0
  58. package/dist/stl/stl-manipulator.d.ts +1 -0
  59. package/dist/stl/stl-manipulator.js +40 -0
  60. package/dist/studio-print-options.d.ts +57 -0
  61. package/dist/studio-print-options.js +179 -0
  62. package/dist/tool-annotations.d.ts +25 -0
  63. package/dist/tool-annotations.js +58 -0
  64. package/package.json +27 -4
  65. package/server.json +2 -2
  66. package/src/ams-mapping.ts +132 -0
  67. package/src/bambu-network-bridge.ts +1106 -0
  68. package/src/bambu-network-certs.ts +154 -0
  69. package/src/control-reply.ts +41 -0
  70. package/src/counterpart/camera-sources.ts +102 -0
  71. package/src/counterpart/camera-stream.ts +195 -0
  72. package/src/counterpart/camera-tools.ts +35 -0
  73. package/src/counterpart/contract.ts +12 -4
  74. package/src/counterpart/route.ts +82 -0
  75. package/src/counterpart/service.ts +150 -16
  76. package/src/design-guidance.ts +188 -0
  77. package/src/fulu-orca.ts +18 -2
  78. package/src/index.ts +528 -83
  79. package/src/printers/bambu-filaments.ts +346 -0
  80. package/src/printers/bambu.ts +506 -81
  81. package/src/printers/creality.ts +8 -3
  82. package/src/printers/generic-base.ts +33 -40
  83. package/src/printers/klipper.ts +12 -8
  84. package/src/printers/octoprint.ts +16 -9
  85. package/src/printers/printer-factory.ts +3 -3
  86. package/src/printers/prusa.ts +0 -1
  87. package/src/safety/confirmation.ts +15 -38
  88. package/src/safety/hidden-hms.ts +406 -0
  89. package/src/safety/limits.ts +15 -5
  90. package/src/safety/print-file.ts +139 -17
  91. package/src/safety/printer-state.ts +192 -41
  92. package/src/slicer/bambu-cli.ts +8 -5
  93. package/src/slicer/layer-pauses.ts +143 -0
  94. package/src/stl/stl-manipulator.ts +45 -0
  95. package/src/studio-print-options.ts +201 -0
@@ -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
- constructor(apiClient: any, private readonly confirm?: ConfirmHardware) {
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.printerStore = new BambuClientStore();
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
- async confirmHardwareAction(message: string, physicalCheck = false): Promise<void> {
177
- await requireHumanConfirmation(this.confirm, message, "bambu", physicalCheck);
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
- private finishedJobIdentity(status: any): string | undefined {
181
- return status.raw.gcode_state === "FINISH"
182
- ? JSON.stringify([status.raw.gcode_file, status.raw.subtask_name, status.raw.task_id, status.raw.subtask_id])
183
- : undefined;
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
- async confirmPrintPreflight(serial: string, status: any, inspection: PrintFileInspection): Promise<string | undefined> {
187
- const finishedJob = this.finishedJobIdentity(status);
188
- const unchipped = unchippedNozzles(status?.raw);
189
- await this.confirmHardwareAction(
190
- `Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
191
- `Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
192
- `File SHA-256: ${inspection.sha256}. Confirm the physical spool labels.` +
193
- (unchipped.length ? ` Printer ${unchipped.length === 1 ? `nozzle ${unchipped[0]} reports` : `nozzles ${unchipped.join(" and ")} report`} no identity chip, ` +
194
- `so ${unchipped.length === 1 ? "its" : "their"} diameter and type are the printer's configured settings; confirm they match the installed ${unchipped.length === 1 ? "hotend" : "hotends"}.` : "")
195
- );
196
- return finishedJob;
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
- 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();
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
- 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);
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: options.projectName,
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 watcher.settled();
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
- /** Stop is never gated or queued behind pending checked operations. */
492
- async cancelJob(host: string, port: string, apiKey: string): Promise<any> {
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
- const printer = await this.getPrinter(host, serial, token);
496
-
810
+ this.checkedJobs.delete(`${host}\n${serial}`);
497
811
  try {
498
- await publishBambuCommand(printer, "print", "stop");
499
- return { status: "success", message: "Cancel command sent successfully." };
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.confirmPrintPreflight(serial, initialStatus, inspection);
672
- const confirmedStatus = await this.getSafetyStatus(host, serial, token);
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
- const dispatch = await watcher.settled();
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,