mcp-3d-printer-server 1.2.24 → 1.2.26

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 (96) hide show
  1. package/CHANGELOG.md +71 -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 +200 -0
  7. package/dist/bambu-network-bridge.js +881 -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 +528 -72
  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 +59 -8
  53. package/dist/safety/printer-state.js +174 -39
  54. package/dist/slicer/bambu-cli.d.ts +6 -2
  55. package/dist/slicer/bambu-cli.js +19 -12
  56. package/dist/slicer/layer-pauses.d.ts +47 -0
  57. package/dist/slicer/layer-pauses.js +130 -0
  58. package/dist/slicer/slicer-error.d.ts +11 -1
  59. package/dist/slicer/slicer-error.js +16 -2
  60. package/dist/stl/stl-manipulator.d.ts +27 -1
  61. package/dist/stl/stl-manipulator.js +216 -76
  62. package/dist/studio-print-options.d.ts +57 -0
  63. package/dist/studio-print-options.js +179 -0
  64. package/package.json +25 -4
  65. package/server.json +2 -2
  66. package/src/ams-mapping.ts +132 -0
  67. package/src/bambu-network-bridge.ts +1075 -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 +546 -87
  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 +179 -40
  92. package/src/slicer/bambu-cli.ts +22 -13
  93. package/src/slicer/layer-pauses.ts +143 -0
  94. package/src/slicer/slicer-error.ts +21 -2
  95. package/src/stl/stl-manipulator.ts +254 -84
  96. 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,13 +8,58 @@ 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 } from "../safety/print-file.js";
10
- import { normalizeModel, validateTemperature } from "../safety/limits.js";
11
- import { manualHeatingRequirements, publishBambuCommand, readFreshPrinterStatus, unchippedNozzles, validatePrinterState, } from "../safety/printer-state.js";
11
+ import { isDualNozzleModel, normalizeModel, validateTemperature } from "../safety/limits.js";
12
+ import { describeAmsTrayRange, externalTrayForNozzle } from "../ams-mapping.js";
13
+ import { manualHeatingRequirements, publishBambuCommand, readFreshPrinterStatus, validatePrinterState, } from "../safety/printer-state.js";
12
14
  import { cancelPendingPrinterOperations, normalizedRemotePath, uniquePrintName, withPrinterOperation, withPrintSnapshot, } from "../safety/artifact.js";
13
- import { requireHumanConfirmation } from "../safety/confirmation.js";
14
15
  import { installBambuNodeRejectionGuard } from "./bambu-node-guard.js";
16
+ import { requireHumanConfirmation } from "../safety/confirmation.js";
15
17
  import { assertExpectedPeaks, validateExpectedPeaks } from "../safety/expected-peaks.js";
18
+ import { controlReply, refusedControlMessage } from "../control-reply.js";
16
19
  const COMMAND_SETTLE_MS = 300;
20
+ /**
21
+ * Pause, Resume or Stop exactly as Bambu Studio sends them (MachineObject::command_task_*), with this server's own
22
+ * 32-bit sequence id, so the printer's answer to this one control can be told apart from any other report.
23
+ */
24
+ function studioControl(command) {
25
+ const sequenceId = String(20000 + (Date.now() % 1000000));
26
+ return { command, sequenceId, send: (printer) => printer.publish({ print: { command, param: "", sequence_id: sequenceId } }) };
27
+ }
28
+ /** How long a control on the direct local network waits for the printer's answer (BAMBU_CONTROL_REPLY_MS, 0-30000). */
29
+ function controlReplyMs() {
30
+ const configured = Number(process.env.BAMBU_CONTROL_REPLY_MS);
31
+ return Number.isFinite(configured) && configured >= 0 ? Math.min(configured, 30000) : 5000;
32
+ }
33
+ /**
34
+ * The printer's answer to one control on its report topic (bambu-node re-emits every message as rawMessage).
35
+ * Started before the publish so a fast answer is not missed; undefined when none arrives in time.
36
+ */
37
+ function watchControlReply(printer, command, sequenceId, ms) {
38
+ const emitter = printer;
39
+ if (ms <= 0 || typeof emitter?.on !== "function" || typeof emitter?.off !== "function")
40
+ return Promise.resolve(undefined);
41
+ return new Promise(resolve => {
42
+ let timer;
43
+ const onMessage = (_topic, payload) => {
44
+ const reply = controlReply(String(payload), command, sequenceId);
45
+ if (reply)
46
+ finish(reply);
47
+ };
48
+ const finish = (reply) => { clearTimeout(timer); emitter.off("rawMessage", onMessage); resolve(reply); };
49
+ timer = setTimeout(() => finish(undefined), ms);
50
+ emitter.on("rawMessage", onMessage);
51
+ });
52
+ }
53
+ /** Send one direct control and judge it by the printer's answer; a printer not in Developer Mode refuses unsigned ones. */
54
+ async function sendDirectControl(printer, command) {
55
+ const control = studioControl(command);
56
+ const answered = watchControlReply(printer, command, control.sequenceId, controlReplyMs());
57
+ await control.send(printer);
58
+ const reply = await answered;
59
+ if (reply && !reply.accepted)
60
+ throw new Error(refusedControlMessage(command, "the local network", reply.errCode, true));
61
+ return reply;
62
+ }
17
63
  /** HMS 0500-0500-0001-0007: firmware 01.08.05+ rejected an unsigned MQTT command. */
18
64
  const COMMAND_VERIFICATION_HMS = { attr: 0x05000500, code: 0x00010007 };
19
65
  const COMMAND_REJECTED_MESSAGE = "The printer rejected the print command (HMS 0500-0500-0001-0007, \"MQTT command verification failed\"). " +
@@ -31,16 +77,10 @@ function dispatchCheckMs() {
31
77
  function sleep(ms) {
32
78
  return new Promise((resolve) => setTimeout(resolve, ms));
33
79
  }
34
- function validateTrayValue(value, label) {
35
- if (!Number.isInteger(value) ||
36
- value < -1 ||
37
- (value > 15 && value < 128) ||
38
- value > 254) {
39
- throw new Error(`${label} values must be integers in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${String(value)}`);
40
- }
41
- }
42
80
  class BambuClientStore {
43
- constructor() {
81
+ /** Sees every merged report, so a resume record notices a job ending even when nobody polls. */
82
+ constructor(onReport) {
83
+ this.onReport = onReport;
44
84
  this.printers = new Map();
45
85
  this.initialConnectionPromises = new Map();
46
86
  }
@@ -62,6 +102,14 @@ class BambuClientStore {
62
102
  serialNumber: serial,
63
103
  accessToken: token,
64
104
  });
105
+ printer.on("printer:dataUpdate", (data) => {
106
+ if (!data || !this.onReport)
107
+ return;
108
+ try {
109
+ this.onReport(host, serial, data);
110
+ }
111
+ catch { /* an observer never breaks report handling */ }
112
+ });
65
113
  printer.on("client:connect", () => {
66
114
  this.printers.set(key, printer);
67
115
  this.initialConnectionPromises.delete(key);
@@ -102,29 +150,176 @@ class BambuClientStore {
102
150
  this.initialConnectionPromises.clear();
103
151
  }
104
152
  }
153
+ /**
154
+ * Where each printer's resume record is kept so a restart of this server (a deploy, a crash, a reboot) during a
155
+ * pause can still resume the job it started: BAMBU_RESUME_STATE_DIR, else mcp-3d-printer-server/resume in the
156
+ * config home (bambu-printer-mcp keeps its own). Resume runs every check on a reloaded record: a fresh paused
157
+ * report naming this exact job, the re-read bytes or its unique name, and a person for an unverified file.
158
+ */
159
+ export function defaultResumeStateDir(env = process.env) {
160
+ if (env.BAMBU_RESUME_STATE_DIR)
161
+ return env.BAMBU_RESUME_STATE_DIR;
162
+ const configHome = env.XDG_CONFIG_HOME || path.join(env.HOME || os.homedir(), ".config");
163
+ return path.join(configHome, "mcp-3d-printer-server", "resume");
164
+ }
165
+ function isCheckedJob(value) {
166
+ return Boolean(value) && typeof value === "object" && /^[a-f0-9]{64}$/.test(value.sha256 ?? "") &&
167
+ typeof value.remotePath === "string" && value.remotePath !== "" && typeof value.model === "string" &&
168
+ Boolean(value.requirements) && typeof value.requirements === "object" && Array.isArray(value.materials) &&
169
+ typeof value.seenRunning === "boolean" && typeof value.uploadedHere === "boolean" &&
170
+ [value.maxNozzleTemperature, value.maxBedTemperature, value.maxChamberTemperature].every(Number.isFinite);
171
+ }
172
+ /**
173
+ * The resume record of each printer (keyed host and serial). With a folder, every change is written to that
174
+ * printer's own file (private, replaced atomically) and the file is read the first time the printer is asked
175
+ * about, so a new server process knows the job the previous one started. Without one it lives in memory only.
176
+ */
177
+ class ResumeRecords {
178
+ constructor(dir) {
179
+ this.dir = dir;
180
+ this.jobs = new Map();
181
+ this.read = new Set();
182
+ }
183
+ get(key) {
184
+ if (!this.read.has(key)) {
185
+ this.read.add(key);
186
+ const saved = this.dir ? this.readFile(key) : undefined;
187
+ if (saved)
188
+ this.jobs.set(key, saved);
189
+ }
190
+ return this.jobs.get(key);
191
+ }
192
+ set(key, job) {
193
+ this.read.add(key);
194
+ this.jobs.set(key, job);
195
+ this.save(key);
196
+ }
197
+ delete(key) {
198
+ this.read.add(key);
199
+ const had = this.jobs.delete(key);
200
+ this.save(key);
201
+ return had;
202
+ }
203
+ /** Write this printer's record as it is now (or remove its file). A failed write never stops a print or a control. */
204
+ save(key) {
205
+ if (!this.dir)
206
+ return;
207
+ const file = this.file(key);
208
+ const job = this.jobs.get(key);
209
+ try {
210
+ if (!job) {
211
+ rmSync(file, { force: true });
212
+ return;
213
+ }
214
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
215
+ const temporary = `${file}.${process.pid}.tmp`;
216
+ writeFileSync(temporary, JSON.stringify({ version: 1, key, job }), { mode: 0o600 });
217
+ renameSync(temporary, file);
218
+ }
219
+ catch (error) {
220
+ console.error(`Could not save the resume record for ${key.replace("\n", " ")}: ${error.message}. Resume still works until this server restarts.`);
221
+ }
222
+ }
223
+ file(key) {
224
+ return path.join(this.dir, `${createHash("sha256").update(key).digest("hex").slice(0, 32)}.json`);
225
+ }
226
+ readFile(key) {
227
+ try {
228
+ const saved = JSON.parse(readFileSync(this.file(key), "utf8"));
229
+ return saved?.version === 1 && saved.key === key && isCheckedJob(saved.job) ? saved.job : undefined;
230
+ }
231
+ catch {
232
+ return undefined;
233
+ }
234
+ }
235
+ }
236
+ const ACTIVE_JOB_STATES = new Set(["PREPARE", "SLICING", "RUNNING", "PAUSE"]);
237
+ /** Reports after which a started job can no longer be resumed. */
238
+ const ENDED_JOB_STATES = new Set(["FINISH", "FAILED", "IDLE"]);
239
+ const jobStem = (value) => path.posix.basename(value.replace(/\\/g, "/")).replace(/(?:\.gcode)?\.3mf$|\.gcode$/i, "");
240
+ const reportedJobNames = (raw) => [raw.gcode_file, raw.subtask_name].filter((value) => typeof value === "string" && value.trim() !== "");
241
+ /** Whether a report names the job stored at remotePath (subtask_name for projects, gcode_file for raw G-code). */
242
+ function reportsJob(raw, remotePath) {
243
+ return reportedJobNames(raw).some((value) => jobStem(value) === jobStem(remotePath));
244
+ }
245
+ /** What a resume needs to know about a started file (filePath, the inspected snapshot). */
246
+ export async function checkedFile(inspection, filePath) {
247
+ const used = inspection.usedFilamentPositions.map((position) => inspection.materials[position]).filter(Boolean);
248
+ let plate;
249
+ const plateNumber = /^Metadata\/plate_(\d+)\.gcode$/i.exec(inspection.plateInternalPath ?? "");
250
+ if (plateNumber && /\.3mf$/i.test(filePath)) {
251
+ const entry = (await readSafe3mfArchive(filePath)).zip.file(inspection.plateInternalPath);
252
+ if (entry)
253
+ plate = { index: Number(plateNumber[1]), sha256: createHash("sha256").update(await entry.async("nodebuffer")).digest("hex") };
254
+ }
255
+ return {
256
+ sha256: inspection.sha256, ...(plate ? { plate } : {}), model: inspection.model,
257
+ maxNozzleTemperature: inspection.maxNozzleTemperature, maxBedTemperature: inspection.maxBedTemperature,
258
+ maxChamberTemperature: inspection.maxChamberTemperature,
259
+ materials: [...new Set(used.length ? used : inspection.materials)],
260
+ };
261
+ }
262
+ /**
263
+ * Where a printer keeps the plate G-code it extracted from a project to print
264
+ * it: a P1S removes the uploaded 3MF once it starts and keeps
265
+ * cache/<name>_plate_<n>.gcode, byte-identical to the 3MF's plate G-code.
266
+ */
267
+ function extractedPlatePath(remotePath, index) {
268
+ const directory = path.posix.dirname(remotePath);
269
+ const stem = path.posix.basename(remotePath).replace(/(?:\.gcode)?\.3mf$/i, "");
270
+ return `${directory === "." ? "" : `${directory}/`}${stem}_plate_${index}.gcode`;
271
+ }
272
+ /** The job name the printer reports: subtask_name, else the G-code file's basename. */
273
+ function reportedJobName(raw) {
274
+ const subtask = typeof raw.subtask_name === "string" ? raw.subtask_name.trim() : "";
275
+ if (subtask)
276
+ return subtask;
277
+ return typeof raw.gcode_file === "string" ? path.posix.basename(raw.gcode_file.replace(/\\/g, "/").replace(/^\d+:/, "")) || undefined : undefined;
278
+ }
105
279
  export class BambuImplementation extends PrinterImplementation {
106
- constructor(apiClient, confirm) {
280
+ /** confirm: a person (MCP elicitation), asked only to resume a job whose file could not be re-read. */
281
+ constructor(apiClient, confirm, options = {}) {
107
282
  super(apiClient);
108
283
  this.confirm = confirm;
109
- this.printerStore = new BambuClientStore();
110
- }
111
- async confirmHardwareAction(message, physicalCheck = false) {
112
- await requireHumanConfirmation(this.confirm, message, "bambu", physicalCheck);
113
- }
114
- finishedJobIdentity(status) {
115
- return status.raw.gcode_state === "FINISH"
116
- ? JSON.stringify([status.raw.gcode_file, status.raw.subtask_name, status.raw.task_id, status.raw.subtask_id])
117
- : undefined;
118
- }
119
- async confirmPrintPreflight(serial, status, inspection) {
120
- const finishedJob = this.finishedJobIdentity(status);
121
- const unchipped = unchippedNozzles(status?.raw);
122
- await this.confirmHardwareAction(`Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
123
- `Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
124
- `File SHA-256: ${inspection.sha256}. Confirm the physical spool labels.` +
125
- (unchipped.length ? ` Printer ${unchipped.length === 1 ? `nozzle ${unchipped[0]} reports` : `nozzles ${unchipped.join(" and ")} report`} no identity chip, ` +
126
- `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"}.` : ""));
127
- return finishedJob;
284
+ this.checkedJobs = new ResumeRecords(options.resumeStateDir);
285
+ this.printerStore = new BambuClientStore((host, serial, report) => this.observeReport(host, serial, report));
286
+ }
287
+ /**
288
+ * Internal handoff from a successful inspected transport; never an MCP tool.
289
+ * uploadedHere is false for FULU's BambuNetwork routes, where Bambu's plug-in
290
+ * put the file on the printer at a path this server has not verified: a
291
+ * resume then re-reads it if it can and otherwise asks a person.
292
+ */
293
+ recordCheckedJob(host, serial, remotePath, requirements, file, uploadedHere = true) {
294
+ if (!/^[a-f0-9]{64}$/.test(file?.sha256 ?? ""))
295
+ throw new Error("A started job needs the SHA-256 of its inspected bytes.");
296
+ this.checkedJobs.set(`${host}\n${serial}`, {
297
+ ...structuredClone(file), remotePath, requirements: structuredClone(requirements), seenRunning: false, uploadedHere,
298
+ });
299
+ }
300
+ /** Retire a record that never started (the dispatch was refused or failed). */
301
+ forgetCheckedJob(host, serial, remotePath) {
302
+ const key = `${host}\n${serial}`;
303
+ if (this.checkedJobs.get(key)?.remotePath === remotePath)
304
+ this.checkedJobs.delete(key);
305
+ }
306
+ /** Feed one merged report to the resume record (the client store calls it for every report). */
307
+ observeReport(host, serial, raw) {
308
+ const key = `${host}\n${serial}`;
309
+ const checked = this.checkedJobs.get(key);
310
+ if (!checked || !raw || typeof raw.gcode_state !== "string")
311
+ return;
312
+ const state = raw.gcode_state.toUpperCase();
313
+ if (ACTIVE_JOB_STATES.has(state) && reportsJob(raw, checked.remotePath)) {
314
+ if (!checked.seenRunning) {
315
+ checked.seenRunning = true;
316
+ this.checkedJobs.save(key);
317
+ }
318
+ return;
319
+ }
320
+ if (checked.seenRunning && (ENDED_JOB_STATES.has(state) || (ACTIVE_JOB_STATES.has(state) && reportedJobNames(raw).length > 0))) {
321
+ this.checkedJobs.delete(key);
322
+ }
128
323
  }
129
324
  async getPrinter(host, serial, token) {
130
325
  return this.printerStore.getPrinter(host, serial, token);
@@ -160,10 +355,41 @@ export class BambuImplementation extends PrinterImplementation {
160
355
  throw new Error(`Selected inspected plate ${expectedEntryName} is not present in 3MF. Re-slice that plate.`);
161
356
  }
162
357
  const gcodeBuffer = await selectedEntry.async("nodebuffer");
358
+ // The slicer's project filament list length, from the G-code header
359
+ // (`; filament_colour = #FFFFFF;#FF911A80;...`), scanning only its first 32 KB.
360
+ let projectFilamentCount = 1;
361
+ const head = gcodeBuffer.subarray(0, 32 * 1024).toString("utf8");
362
+ const colourLine = head.match(/;\s*filament_colour\s*=\s*([^\n\r]+)/i);
363
+ if (colourLine) {
364
+ projectFilamentCount = colourLine[1].split(";").filter((entry) => entry.trim()).length;
365
+ }
366
+ else {
367
+ const idsLine = head.match(/;\s*filament_ids\s*=\s*([^\n\r]+)/i);
368
+ if (idsLine)
369
+ projectFilamentCount = idsLine[1].split(";").filter((entry) => entry.trim()).length;
370
+ }
371
+ // The positions the selected plate consumes, from Metadata/plate_<n>.json filament_ids.
372
+ let usedFilamentPositions = [];
373
+ const plateJsonEntry = zip.file(selectedEntry.name.replace(/\.gcode$/i, ".json"));
374
+ if (plateJsonEntry) {
375
+ try {
376
+ const json = JSON.parse(await plateJsonEntry.async("string"));
377
+ if (Array.isArray(json.filament_ids)) {
378
+ usedFilamentPositions = json.filament_ids.filter((n) => Number.isInteger(n));
379
+ }
380
+ }
381
+ catch {
382
+ // A malformed plate_N.json leaves ams_mapping as the way to map trays.
383
+ }
384
+ }
385
+ if (usedFilamentPositions.length === 0)
386
+ usedFilamentPositions = [0];
163
387
  return {
164
388
  plateFileName: path.posix.basename(selectedEntry.name),
165
389
  plateInternalPath: selectedEntry.name,
166
390
  md5: createHash("md5").update(gcodeBuffer).digest("hex"),
391
+ projectFilamentCount,
392
+ usedFilamentPositions,
167
393
  };
168
394
  }
169
395
  async getStatus(host, port, apiKey) {
@@ -244,35 +470,15 @@ export class BambuImplementation extends PrinterImplementation {
244
470
  if (options.md5 !== undefined && options.md5 !== md5) {
245
471
  throw new Error("Provided checksum does not match the inspected plate G-code.");
246
472
  }
247
- for (const value of options.amsMapping ?? [])
248
- validateTrayValue(value, "ams_mapping");
249
- const useAMS = options.useAMS !== false;
250
- let checkedMapping;
251
- if (useAMS) {
252
- checkedMapping = (options.amsMapping ?? []).slice();
253
- }
254
- else {
255
- checkedMapping = Array(inspection.materials.length).fill(-1);
256
- inspection.usedFilamentPositions.forEach((position) => { checkedMapping[position] = 254; });
257
- }
258
- for (const position of inspection.usedFilamentPositions) {
259
- if (checkedMapping[position] === undefined || checkedMapping[position] < 0) {
260
- throw new Error(`Missing physical filament mapping for project filament ${position}. ` +
261
- "Pass ams_mapping with a tray for every used project filament, or use_ams:false for the external spool.");
262
- }
263
- }
264
- // Wire mapping: position = project filament, value = absolute tray. Never
265
- // truncate a checked mapping; pad to the historical five entries.
266
- const amsMapping = options.amsMapping && options.amsMapping.length > 0
267
- ? Array.from({ length: Math.max(5, options.amsMapping.length) }, (_, i) => i < options.amsMapping.length ? options.amsMapping[i] : -1)
268
- : [-1, -1, -1, -1, 0];
473
+ const { baseMapping, amsMapping, useAMS } = this.checkedAmsMapping(model, options, inspection, projectMetadata);
474
+ const checkedMapping = baseMapping;
269
475
  const requirements = { ...inspection, amsMapping: checkedMapping, useAMS };
476
+ const resumeFile = await checkedFile(inspection, options.filePath);
270
477
  const initialStatus = await this.getSafetyStatus(host, serial, token);
271
478
  validatePrinterState(initialStatus, requirements);
272
- await this.confirmPrintPreflight(serial, initialStatus, inspection);
273
- // Recheck after the human response: the printer may have changed meanwhile.
274
- const confirmedStatus = await this.getSafetyStatus(host, serial, token);
275
- validatePrinterState(confirmedStatus, requirements);
479
+ // Recheck before the upload: the printer may have changed meanwhile.
480
+ const recheckedStatus = await this.getSafetyStatus(host, serial, token);
481
+ validatePrinterState(recheckedStatus, requirements);
276
482
  assertActive();
277
483
  const remoteFileName = uniquePrintName(options.filePath);
278
484
  const remoteProjectPath = `cache/${remoteFileName}`;
@@ -282,12 +488,14 @@ export class BambuImplementation extends PrinterImplementation {
282
488
  validatePrinterState(dispatchStatus, requirements);
283
489
  const printer = await this.getPrinter(host, serial, token);
284
490
  assertActive();
491
+ const subtaskName = remoteFileName.replace(/\.3mf$/i, "");
285
492
  const projectFileCmd = {
286
493
  print: {
287
494
  command: "project_file",
288
495
  param: projectMetadata.plateInternalPath,
289
496
  url: `file:///sdcard/${remoteProjectPath}`,
290
- subtask_name: options.projectName,
497
+ // The printer reports this back as subtask_name, which identifies the checked job for resume.
498
+ subtask_name: subtaskName,
291
499
  md5,
292
500
  flow_cali: options.flowCalibration ?? true,
293
501
  layer_inspect: options.layerInspect ?? true,
@@ -306,8 +514,9 @@ export class BambuImplementation extends PrinterImplementation {
306
514
  };
307
515
  const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
308
516
  await printer.publish(projectFileCmd);
517
+ this.recordCheckedJob(host, serial, remoteProjectPath, requirements, resumeFile);
309
518
  await sleep(COMMAND_SETTLE_MS);
310
- const dispatch = await watcher.settled();
519
+ const dispatch = await this.settleRecorded(watcher, host, serial, remoteProjectPath);
311
520
  return {
312
521
  status: "success",
313
522
  dispatch,
@@ -316,6 +525,7 @@ export class BambuImplementation extends PrinterImplementation {
316
525
  : `Uploaded the checked 3MF and sent the print command for ${options.projectName}; the printer has not reported starting yet. ` +
317
526
  "Check get_printer_status before assuming it is printing.",
318
527
  remoteProjectPath,
528
+ subtaskName,
319
529
  plateFile: projectMetadata.plateFileName,
320
530
  platePath: projectMetadata.plateInternalPath,
321
531
  md5,
@@ -328,6 +538,66 @@ export class BambuImplementation extends PrinterImplementation {
328
538
  },
329
539
  };
330
540
  }
541
+ /**
542
+ * The project-level tray mapping for a checked print, built as
543
+ * bambu-printer-mcp builds it: position = project filament, value = absolute
544
+ * tray (0-15 AMS, 128+ AMS-HT, 254 external; 255 the right/main external
545
+ * spool on dual-nozzle printers, -1 unused). ams_mapping is taken as given;
546
+ * ams_slots supplies one tray per used filament and is expanded against the
547
+ * plate's filament_ids; use_ams:false maps every used filament to the
548
+ * external spool of its nozzle. Every used position must end up mapped.
549
+ * The wire form pads to the historical five entries.
550
+ */
551
+ checkedAmsMapping(model, options, inspection, projectMetadata) {
552
+ const dualNozzle = isDualNozzleModel(model);
553
+ const externalTrays = dualNozzle ? [254, 255] : [254];
554
+ const useAMS = options.useAMS !== false;
555
+ const validate = (value, label) => {
556
+ if (!Number.isInteger(value) || value < -1 ||
557
+ (value > 15 && value < 128) || value > (dualNozzle ? 255 : 254)) {
558
+ throw new Error(`${label} values must be integers in ${describeAmsTrayRange({ dualNozzle })}; got ${String(value)}`);
559
+ }
560
+ };
561
+ let baseMapping;
562
+ if (options.amsMapping && options.amsMapping.length > 0) {
563
+ for (const value of options.amsMapping)
564
+ validate(value, "ams_mapping");
565
+ baseMapping = options.amsMapping.slice();
566
+ }
567
+ else if (options.amsSlots && options.amsSlots.length > 0) {
568
+ for (const value of options.amsSlots)
569
+ validate(value, "ams_slots");
570
+ const positions = projectMetadata.usedFilamentPositions;
571
+ if (options.amsSlots.length !== positions.length) {
572
+ throw new Error(`ams_slots length ${options.amsSlots.length} does not match used filament count ${positions.length} ` +
573
+ `(plate uses positions ${JSON.stringify(positions)}). Provide one tray per used filament, or use ams_mapping for a raw project-level array.`);
574
+ }
575
+ baseMapping = Array(Math.max(projectMetadata.projectFilamentCount, ...positions.map((p) => p + 1))).fill(-1);
576
+ positions.forEach((position, index) => { baseMapping[position] = options.amsSlots[index]; });
577
+ }
578
+ else {
579
+ if (useAMS) {
580
+ throw new Error("AMS printing requires ams_slots or ams_mapping for this sliced file. " +
581
+ `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.`);
582
+ }
583
+ const positions = inspection.usedFilamentPositions;
584
+ baseMapping = Array(Math.max(inspection.materials.length, projectMetadata.projectFilamentCount, ...positions.map((p) => p + 1), 1)).fill(-1);
585
+ for (const position of positions) {
586
+ baseMapping[position] = externalTrayForNozzle(inspection.filamentNozzleIndices?.[position], dualNozzle);
587
+ }
588
+ }
589
+ for (const position of inspection.usedFilamentPositions) {
590
+ if (baseMapping[position] === undefined || baseMapping[position] < 0) {
591
+ throw new Error(`Missing physical filament mapping for project filament ${position}. ` +
592
+ "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.");
593
+ }
594
+ if (!useAMS && !externalTrays.includes(baseMapping[position])) {
595
+ throw new Error(`External-spool printing requires external spool mapping (${externalTrays.join(" or ")}).`);
596
+ }
597
+ }
598
+ const amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, index) => (index < baseMapping.length ? baseMapping[index] : -1));
599
+ return { baseMapping, amsMapping, useAMS };
600
+ }
331
601
  /**
332
602
  * A published print command is not proof the printer took it. Listen to the
333
603
  * reports the printer pushes anyway (no extra pushall polling on weak P1
@@ -407,19 +677,156 @@ export class BambuImplementation extends PrinterImplementation {
407
677
  },
408
678
  };
409
679
  }
410
- /** Stop is never gated or queued behind pending checked operations. */
411
- async cancelJob(host, port, apiKey) {
680
+ /** A refused or unconfirmed-then-failed dispatch leaves no job to resume. */
681
+ async settleRecorded(watcher, host, serial, remotePath) {
682
+ try {
683
+ return await watcher.settled();
684
+ }
685
+ catch (error) {
686
+ this.forgetCheckedJob(host, serial, remotePath);
687
+ throw error;
688
+ }
689
+ }
690
+ /**
691
+ * Stop is never gated or queued behind pending checked operations, and it
692
+ * retires the resume record. dispatch sends it over the route that printed
693
+ * (FULU's BambuNetwork); without one it goes over LAN MQTT.
694
+ */
695
+ async cancelJob(host, port, apiKey, dispatch) {
412
696
  const [serial, token] = this.extractBambuCredentials(apiKey);
413
697
  cancelPendingPrinterOperations(host, serial);
414
- const printer = await this.getPrinter(host, serial, token);
698
+ this.checkedJobs.delete(`${host}\n${serial}`);
415
699
  try {
416
- await publishBambuCommand(printer, "print", "stop");
417
- return { status: "success", message: "Cancel command sent successfully." };
700
+ if (dispatch)
701
+ return await dispatch();
702
+ const printer = await this.getPrinter(host, serial, token);
703
+ const reply = await sendDirectControl(printer, "stop");
704
+ return { status: "success", confirmed: Boolean(reply), message: reply ? "The printer accepted Stop." : "Cancel command sent successfully." };
418
705
  }
419
706
  catch (error) {
420
707
  throw new Error(`Failed to cancel print: ${error.message}`);
421
708
  }
422
709
  }
710
+ /** Pause is never gated. */
711
+ async pauseJob(host, port, apiKey, dispatch) {
712
+ const [serial, token] = this.extractBambuCredentials(apiKey);
713
+ try {
714
+ if (dispatch) {
715
+ const result = await dispatch();
716
+ return { status: "success", message: "Pause command sent successfully.", ...result, action: "pause" };
717
+ }
718
+ const printer = await this.getPrinter(host, serial, token);
719
+ const reply = await sendDirectControl(printer, "pause");
720
+ if (!reply)
721
+ await sleep(COMMAND_SETTLE_MS);
722
+ return { status: "success", action: "pause", confirmed: Boolean(reply), message: reply ? "The printer accepted Pause." : "Pause command sent successfully." };
723
+ }
724
+ catch (error) {
725
+ throw new Error(`Failed to pause print: ${error.message}`);
726
+ }
727
+ }
728
+ /**
729
+ * Resume reheats and moves the printer, so it is gated like a print start:
730
+ * only the job this server inspected and started, while a fresh report shows
731
+ * it paused, with its bytes re-read over FTPS and compared. Only a file that
732
+ * could not be re-read, and that this server did not upload itself, asks a
733
+ * person. The record must still be current at every check.
734
+ */
735
+ async resumeJob(host, port, apiKey, dispatch) {
736
+ const [serial, token] = this.extractBambuCredentials(apiKey);
737
+ return withPrinterOperation(host, serial, async (assertActive) => {
738
+ const key = `${host}\n${serial}`;
739
+ const checked = this.checkedJobs.get(key);
740
+ if (!checked)
741
+ throw new Error("Resume requires a job inspected and started by this server instance. Verify other jobs on the printer before resuming them there.");
742
+ let jobName = path.posix.basename(checked.remotePath);
743
+ const pausedJob = async () => {
744
+ assertActive();
745
+ const status = await this.getSafetyStatus(host, serial, token);
746
+ this.observeReport(host, serial, status.raw);
747
+ if (this.checkedJobs.get(key) !== checked) {
748
+ 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.`);
749
+ }
750
+ validatePrinterState(status, { ...checked.requirements, requireIdle: false });
751
+ if (status.raw.gcode_state !== "PAUSE")
752
+ throw new Error("Resume requires a freshly reported paused job.");
753
+ if (!reportsJob(status.raw, checked.remotePath))
754
+ throw new Error("Paused job identity does not match this server's inspected artifact.");
755
+ jobName = reportedJobName(status.raw) ?? jobName;
756
+ assertActive();
757
+ };
758
+ await pausedJob();
759
+ const reread = await this.rereadJob(host, token, checked);
760
+ if ("changed" in reread) {
761
+ if (this.checkedJobs.get(key) === checked)
762
+ this.checkedJobs.delete(key);
763
+ 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.`);
764
+ }
765
+ const unread = "unread" in reread ? reread.unread : undefined;
766
+ if (unread !== undefined && checked.uploadedHere) {
767
+ 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.`);
768
+ }
769
+ const bytesVerified = "verified" in reread;
770
+ assertActive();
771
+ if (!bytesVerified) {
772
+ // 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.
773
+ await requireHumanConfirmation(this.confirm, `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. ` +
774
+ `It reheats to its targets (peak nozzle ${checked.maxNozzleTemperature}°C, bed ${checked.maxBedTemperature}°C, chamber ${checked.maxChamberTemperature}°C) and moves. ` +
775
+ `Materials: ${checked.materials.length ? checked.materials.join(", ") : "none declared"}. Inspected file SHA-256: ${checked.sha256}. ` +
776
+ "Confirm the nozzle path is clear and the filament is still loaded.");
777
+ }
778
+ // The job may have been resumed, stopped or replaced at the printer during the re-read or the prompt.
779
+ await pausedJob();
780
+ if (dispatch) {
781
+ const result = await dispatch(assertActive, pausedJob);
782
+ return { status: "success", message: `Resume command sent for ${jobName}.`, ...result, action: "resume", jobName, bytesVerified };
783
+ }
784
+ const printer = await this.getPrinter(host, serial, token);
785
+ assertActive();
786
+ const reply = await sendDirectControl(printer, "resume");
787
+ if (!reply)
788
+ await sleep(COMMAND_SETTLE_MS);
789
+ return { status: "success", action: "resume", confirmed: Boolean(reply), message: reply ? `The printer accepted Resume for ${jobName}.` : `Resume command sent for ${jobName}.`, jobName, bytesVerified };
790
+ });
791
+ }
792
+ /**
793
+ * Re-read a started job's bytes over FTPS: the uploaded file, else the plate
794
+ * G-code the printer extracted from the project to print it. The first file
795
+ * that can be read decides; a mismatch is reported as changed.
796
+ */
797
+ async rereadJob(host, token, checked) {
798
+ const candidates = [{ remote: checked.remotePath, sha256: checked.sha256 }];
799
+ if (checked.plate)
800
+ candidates.push({ remote: extractedPlatePath(checked.remotePath, checked.plate.index), sha256: checked.plate.sha256 });
801
+ const failures = [];
802
+ for (const candidate of candidates) {
803
+ let sha256;
804
+ try {
805
+ sha256 = await this.remoteSha256(host, token, candidate.remote);
806
+ }
807
+ catch (error) {
808
+ failures.push(`${candidate.remote}: ${error.message}`);
809
+ continue;
810
+ }
811
+ return sha256 === candidate.sha256 ? { verified: candidate.remote } : { changed: candidate.remote };
812
+ }
813
+ return { unread: failures.join("; ") };
814
+ }
815
+ /** SHA-256 of a printer file, streamed from an FTPS download into a private directory. */
816
+ async remoteSha256(host, token, remotePath) {
817
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bambu-resume-check-"));
818
+ try {
819
+ const localPath = path.join(directory, "job");
820
+ await this.ftpDownload(host, token, remotePath.replace(/^\/+/, ""), localPath);
821
+ const hash = createHash("sha256");
822
+ for await (const chunk of createReadStream(localPath))
823
+ hash.update(chunk);
824
+ return hash.digest("hex");
825
+ }
826
+ finally {
827
+ await fs.rm(directory, { recursive: true, force: true });
828
+ }
829
+ }
423
830
  async setTemperature(host, port, apiKey, component, temperature, options = {}) {
424
831
  const normalizedComponent = String(component).toLowerCase();
425
832
  const heater = normalizedComponent === "bed" ? "bed" :
@@ -453,8 +860,6 @@ export class BambuImplementation extends PrinterImplementation {
453
860
  ? manualHeatingRequirements(current, model, nozzleDiameter, material)
454
861
  : { model: model, nozzleDiameters: [] });
455
862
  validateHeatingStatus(await this.getSafetyStatus(host, serial, token));
456
- await this.confirmHardwareAction(`Heat ${heater} on ${model.toUpperCase()} (${serial}) to ${targetTemperature}°C?` +
457
- (material && heater === "nozzle" ? ` Declared material: ${material}. Confirm the physical spool label.` : ""));
458
863
  assertActive();
459
864
  validateHeatingStatus(await this.getSafetyStatus(host, serial, token));
460
865
  }
@@ -545,20 +950,21 @@ export class BambuImplementation extends PrinterImplementation {
545
950
  }
546
951
  const initialStatus = await this.getSafetyStatus(host, serial, token);
547
952
  this.validateLoadedGcodeState(initialStatus, inspection);
548
- await this.confirmPrintPreflight(serial, initialStatus, inspection);
549
- const confirmedStatus = await this.getSafetyStatus(host, serial, token);
550
- this.validateLoadedGcodeState(confirmedStatus, inspection);
953
+ const recheckedStatus = await this.getSafetyStatus(host, serial, token);
954
+ this.validateLoadedGcodeState(recheckedStatus, inspection);
551
955
  assertActive();
552
956
  // Start a uniquely named copy of the inspected bytes, never a name that can change.
553
957
  const remotePath = path.posix.join(path.posix.dirname(filename), uniquePrintName(filename));
554
958
  await this.ftpUpload(host, token, filePath, `/${remotePath}`);
555
959
  const dispatchStatus = await this.getSafetyStatus(host, serial, token);
556
- this.validateLoadedGcodeState(dispatchStatus, inspection);
960
+ const requirements = this.validateLoadedGcodeState(dispatchStatus, inspection);
961
+ const resumeFile = await checkedFile(inspection, filePath);
557
962
  const printer = await this.getPrinter(host, serial, token);
558
963
  assertActive();
559
964
  const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
560
965
  await publishBambuCommand(printer, "print", "gcode_file", { param: remotePath });
561
- const dispatch = await watcher.settled();
966
+ this.recordCheckedJob(host, serial, remotePath, requirements, resumeFile);
967
+ const dispatch = await this.settleRecorded(watcher, host, serial, remotePath);
562
968
  return {
563
969
  status: "success",
564
970
  uploaded: true,