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,22 +1,11 @@
1
- import { normalizeMaterial, normalizeModel } from "./limits.js";
2
- let sequence = 0;
3
- /**
4
- * Publish one MQTT command with a string sequence id and no ACK wait.
5
- * Unpatched bambu-node 3.22.21 command classes send numeric sequence ids;
6
- * bambu-printer-mcp patches them to strings for H2-series firmware. This
7
- * server does not install that patch, so safety-relevant commands are
8
- * published here with the patched wire shape: {category:{sequence_id,command,...}}.
9
- */
10
- export function publishBambuCommand(printer, category, command, extra = {}) {
11
- sequence = (sequence + 1) % 1000000000;
12
- return printer.publish({ [category]: { sequence_id: String(sequence), command, ...extra } });
13
- }
1
+ import { HIDDEN_HMS } from "./hidden-hms.js";
2
+ import { isDualNozzleModel, normalizeMaterial, normalizeModel } from "./limits.js";
14
3
  const MAX_REPORT_AGE_MS = 15000;
15
4
  const MODEL_IDS = {
16
5
  O1C: "H2C", O1C2: "H2C", O1D: "H2D", O1E: "H2D Pro", O1S: "H2S", N6: "X2D",
17
6
  N2S: "A1", A1M: "A1 Mini", C11: "P1P", C12: "P1S", C13: "X1E", "BL-P001": "X1C", "BL-P002": "X1",
18
7
  };
19
- const SERIAL_MODELS = {
8
+ export const SERIAL_MODELS = {
20
9
  "093": "H2S", "094": "H2D", "31B": "H2C", "239": "H2D Pro", "20P": "X2D",
21
10
  "00M": "X1C", "00W": "X1", "03W": "X1E", "01S": "P1P", "01P": "P1S", "22E": "P2S", "039": "A1", "030": "A1 Mini",
22
11
  };
@@ -115,17 +104,40 @@ export function readFreshPrinterStatus(printer, serial, timeoutMs = 5000) {
115
104
  printer.on("rawMessage", onRaw);
116
105
  printer.on("client:disconnect", onDisconnect);
117
106
  printer.on("client:error", onError);
118
- // Publish only; an ACK wait would add a dependency that H2 firmware lacks.
107
+ // invoke() publishes only; executeCommand() would add an ACK dependency that H2 firmware lacks.
108
+ // String sequence ids without bambu-printer-mcp's bambu-node patch (H2-series firmware needs them).
119
109
  void Promise.all([publishBambuCommand(printer, "pushing", "pushall"), publishBambuCommand(printer, "info", "get_version")])
120
110
  .then(() => { requestsPublished = true; maybeResolve(); }, fail);
121
111
  });
122
112
  }
123
- function finiteNumber(value) {
113
+ export function finiteNumber(value) {
124
114
  if (typeof value !== "number" && (typeof value !== "string" || !value.trim()))
125
115
  return undefined;
126
116
  const number = Number(value);
127
117
  return Number.isFinite(number) ? number : undefined;
128
118
  }
119
+ /**
120
+ * X2D retains FAILED after a canceled job even with no active print error.
121
+ * Bambu Studio classifies FAILED as a finished job; its v02.08.02.61
122
+ * hms_en_20P table (926a7192574bcb9b3a732e1ec59a46d79cb45466)
123
+ * calls 0300400C "The task was canceled."
124
+ * Only this observed terminal condition qualifies, never an arbitrary failure.
125
+ * Callers still validate HMS, identity, hardware and material configuration,
126
+ * and print dispatch rechecks the identity of the observed job.
127
+ */
128
+ export function isCancelledX2DJob(raw, model) {
129
+ return normalizeModel(model) === "x2d" && raw.gcode_state === "FAILED" &&
130
+ finiteNumber(raw.print_error) === 0 && finiteNumber(raw.fail_reason) === 0x0300400c &&
131
+ [raw.gcode_file, raw.subtask_name].some(value => typeof value === "string" && value.trim().length > 0);
132
+ }
133
+ /** Bambu Studio 2.8.2.61 marks FAILED as finished. Limit P1S recovery to
134
+ * a named prior job explicitly reporting idle, no print error and no HMS.
135
+ * Dispatch rechecks the observed job identity. */
136
+ export function isFinishedP1SJob(raw, model) {
137
+ return normalizeModel(model) === "p1s" && raw.gcode_state === "FAILED" && raw.print_type === "idle" &&
138
+ finiteNumber(raw.print_error) === 0 && Array.isArray(raw.hms) && raw.hms.length === 0 &&
139
+ [raw.gcode_file, raw.subtask_name].some(value => typeof value === "string" && value.trim().length > 0);
140
+ }
129
141
  const token = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/[\s_-]/g, "") : "";
130
142
  function nozzleTypeAndFlow(value) {
131
143
  const text = String(value ?? "");
@@ -159,7 +171,7 @@ export function unchippedNozzles(raw) {
159
171
  const index = finiteNumber(entry?.id);
160
172
  const stat = finiteNumber(entry?.stat);
161
173
  return index !== undefined && Number.isInteger(index) && index >= 0 && index < 16 &&
162
- stat !== undefined && Number.isInteger(stat) && stat >= 0 && unreliableWithoutChip(entry, stat) ? [index] : [];
174
+ stat !== undefined && Number.isInteger(stat) && unreliableWithoutChip(entry, stat) ? [index] : [];
163
175
  });
164
176
  }
165
177
  const loggedUnchippedNozzles = new Set();
@@ -234,28 +246,107 @@ function reportedNozzles(raw) {
234
246
  }
235
247
  return output;
236
248
  }
237
- function validateErrors(raw, requireIdle) {
249
+ const hiddenHmsSets = new Map(Object.entries(HIDDEN_HMS).map(([prefix, codes]) => [prefix, new Set(codes)]));
250
+ export function hmsCode(entry) {
251
+ const code = finiteNumber(entry?.code);
252
+ const attr = finiteNumber(entry?.attr);
253
+ const valid = (value) => value !== undefined && Number.isInteger(value) && value >= 0 && value <= 0xffffffff;
254
+ if (!valid(code) || !valid(attr))
255
+ return undefined;
256
+ // BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
257
+ return { attr, code, severity: Math.floor(code / 65536), hex: (attr.toString(16).padStart(8, "0") + code.toString(16).padStart(8, "0")).toUpperCase() };
258
+ }
259
+ const hmsLabel = (hex) => `HMS ${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12)}`;
260
+ const hmsKey = (entry) => `hms:${entry.attr}:${entry.code}`;
261
+ /** The one key format for a reported entry, shared by the gate and clear_hms_errors. */
262
+ export function reportedHmsKey(entry) {
263
+ const parsed = hmsCode(entry);
264
+ return parsed ? hmsKey(parsed) : `hms:${entry?.attr}:${entry?.code}`;
265
+ }
266
+ /** Bambu's display form of a cleared-code key such as "hms:83887616:131184"; other keys unchanged. */
267
+ export function describeHmsKey(key) {
268
+ const match = /^hms:(\d+):(\d+)$/.exec(key);
269
+ const entry = match ? hmsCode({ attr: Number(match[1]), code: Number(match[2]) }) : undefined;
270
+ return entry ? hmsLabel(entry.hex) : key;
271
+ }
272
+ /**
273
+ * A serious or common entry that Bambu lists for this serial prefix with empty
274
+ * text: Bambu Studio, Bambu Handy and the printer screen never show it, and
275
+ * the printer keeps printing with it (an X2D raises 0500-0600-0002-0070 at
276
+ * every boot). Bambu Studio selects the table by serial prefix, so this does
277
+ * too, but only when that prefix belongs to the verified model: a sparse report
278
+ * without a serial must not let a misconfigured serial pick another model's
279
+ * table. Fatal, invalid-level and unlisted entries never qualify.
280
+ */
281
+ export function isHiddenNotice(serial, model, entry) {
282
+ if (entry.severity !== 2 && entry.severity !== 3)
283
+ return false;
284
+ const prefix = serial.trim().toUpperCase().slice(0, 3);
285
+ const normalized = normalizeModel(model);
286
+ if (!normalized || normalizeModel(SERIAL_MODELS[prefix] ?? "") !== normalized)
287
+ return false;
288
+ return hiddenHmsSets.get(prefix)?.has(entry.hex) === true;
289
+ }
290
+ function hiddenHmsEntries(raw, serial, model) {
291
+ if (!object(raw))
292
+ return [];
293
+ const identity = observedIdentity(raw, serial);
294
+ if (!identity.observedSerial || identity.model !== normalizeModel(model))
295
+ return [];
296
+ const entries = Array.isArray(raw.hms) ? raw.hms : [];
297
+ const seen = new Set();
298
+ return entries.map(hmsCode).filter((entry) => !!entry && isHiddenNotice(identity.observedSerial, model, entry) && !seen.has(entry.hex) && !!seen.add(entry.hex));
299
+ }
300
+ /** Labels of reported entries that pass only because Bambu hides them for this printer. */
301
+ export function hiddenHmsNotices(raw, serial, model) {
302
+ return hiddenHmsEntries(raw, serial, model).map(entry => hmsLabel(entry.hex));
303
+ }
304
+ /** Cleared-code keys of reported hidden entries; clearing never removes them, so they are not re-acknowledged. */
305
+ export function hiddenHmsKeys(raw, serial, model) {
306
+ return hiddenHmsEntries(raw, serial, model).map(hmsKey);
307
+ }
308
+ const loggedHiddenNotices = new Set();
309
+ /** Name each hidden entry a check passed once per printer on stderr, so headless runs still record it. */
310
+ function logHiddenNotices(raw, serial, model) {
311
+ for (const entry of hiddenHmsEntries(raw, serial, model)) {
312
+ const key = `${serial}:${entry.hex}`;
313
+ if (loggedHiddenNotices.has(key))
314
+ continue;
315
+ loggedHiddenNotices.add(key);
316
+ console.error(`Printer ${serial} reports ${hmsLabel(entry.hex)}, which Bambu lists for it without text and never shows as a fault; it does not block operations.`);
317
+ }
318
+ }
319
+ /**
320
+ * 0300_8013, "Printing was paused by the user" / "paused due to the pause command added to the
321
+ * printing file": the printer's report of a deliberate pause (a Pause, or a pause_at_layers stop),
322
+ * which Resume clears. A paused P1S reports it as print_error, so it is not an error for resuming
323
+ * a paused job. requireIdle is false only for Resume here; starting a job still needs no error.
324
+ * Ported from bambu-printer-mcp src/safety/printer-state.ts.
325
+ */
326
+ export const PAUSE_NOTICE = 0x03008013;
327
+ export function isPauseNotice(raw, requireIdle) {
328
+ const state = typeof raw?.gcode_state === "string" ? raw.gcode_state.toUpperCase() : "";
329
+ return !requireIdle && state === "PAUSE" && finiteNumber(raw?.print_error) === PAUSE_NOTICE;
330
+ }
331
+ function validateErrors(raw, requireIdle, serial, model) {
238
332
  const state = typeof raw.gcode_state === "string" ? raw.gcode_state.toUpperCase() : "UNKNOWN";
239
- if (requireIdle && !["IDLE", "FINISH"].includes(state))
333
+ const terminal = isCancelledX2DJob(raw, model) || isFinishedP1SJob(raw, model);
334
+ if (requireIdle && !["IDLE", "FINISH"].includes(state) && !terminal)
240
335
  throw new Error(`Printer state ${state} is not safely idle.`);
241
- if (!requireIdle && !["IDLE", "FINISH", "RUNNING", "PAUSE"].includes(state))
336
+ if (!requireIdle && !["IDLE", "FINISH", "RUNNING", "PAUSE"].includes(state) && !terminal)
242
337
  throw new Error(`Printer state ${state} is unknown or failed.`);
243
- if (finiteNumber(raw.print_error) !== 0)
338
+ if (finiteNumber(raw.print_error) !== 0 && !isPauseNotice(raw, requireIdle))
244
339
  throw new Error("Printer reports a print error or did not report its error state.");
245
340
  if (!Array.isArray(raw.hms))
246
341
  throw new Error("Printer HMS error state is missing or malformed.");
247
- for (const entry of raw.hms) {
248
- const code = finiteNumber(entry?.code);
249
- const attr = finiteNumber(entry?.attr);
250
- // BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
342
+ for (const reported of raw.hms) {
343
+ const entry = hmsCode(reported);
251
344
  // Unknown severities fail closed; attr identifies the module and is not a severity bitmask.
252
- if (code === undefined || attr === undefined || !Number.isInteger(code) || !Number.isInteger(attr) || code < 0 || code > 0xffffffff || attr < 0 || attr > 0xffffffff || Math.floor(code / 65536) !== 4) {
253
- const hex = (value) => value.toString(16).toUpperCase().padStart(8, "0");
254
- const label = code !== undefined && attr !== undefined && Number.isInteger(code) && Number.isInteger(attr) && code >= 0 && attr >= 0
255
- ? ` (HMS ${hex(attr).slice(0, 4)}-${hex(attr).slice(4)}-${hex(code).slice(0, 4)}-${hex(code).slice(4)})` : "";
256
- throw new Error(`Printer reports actionable or unknown HMS errors${label}; inspect and resolve them on the printer before proceeding.`);
257
- }
345
+ if (entry && (entry.severity === 4 || isHiddenNotice(serial, model, entry)))
346
+ continue;
347
+ throw new Error(`Printer reports actionable or unknown HMS errors${entry ? ` (${hmsLabel(entry.hex)})` : ""}; inspect and resolve them before proceeding.`);
258
348
  }
349
+ logHiddenNotices(raw, serial, model);
259
350
  }
260
351
  function trayRange(value, label) {
261
352
  if (value === undefined || value === null || value === "" || value === "0" || value === 0)
@@ -265,15 +356,24 @@ function trayRange(value, label) {
265
356
  throw new Error(`Reported filament ${label} temperature is malformed.`);
266
357
  return number;
267
358
  }
268
- function externalTray(raw) {
359
+ /** Dual-nozzle printers report one virtual external slot per nozzle, numbered as Bambu
360
+ * Studio does (VIRTUAL_TRAY_MAIN_ID 255 = right/main nozzle, VIRTUAL_TRAY_DEPUTY_ID 254 =
361
+ * left/deputy nozzle), so the requested tray selects its slot. Single-nozzle printers keep
362
+ * requiring exactly one slot 254 report, then the legacy vt_tray field. */
363
+ function externalTray(raw, trayIndex, model) {
269
364
  const slots = raw.vir_slot;
365
+ const dualNozzle = isDualNozzleModel(model);
270
366
  if (Array.isArray(slots)) {
271
- const selected = slots.filter((slot) => Number(slot?.id) === 254);
272
- if (slots.length === 1 && selected.length === 1)
367
+ const selected = slots.filter((slot) => Number(slot?.id) === trayIndex);
368
+ if (selected.length === 1 && (dualNozzle || slots.length === 1))
273
369
  return selected[0];
370
+ if (dualNozzle && selected.length === 0 && slots.length > 0)
371
+ throw new Error(`External spool ${trayIndex} is not in the fresh per-nozzle report; cannot verify the selected material.`);
274
372
  if (slots.length > 0)
275
373
  throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
276
374
  }
375
+ if (dualNozzle)
376
+ throw new Error(`Dual-nozzle external spool ${trayIndex} requires a fresh per-nozzle vir_slot report; legacy vt_tray cannot verify the selected nozzle.`);
277
377
  return object(raw.vt_tray) ? raw.vt_tray : undefined;
278
378
  }
279
379
  function mappedTray(raw, index) {
@@ -315,7 +415,7 @@ export function validatePrinterState(status, requirements) {
315
415
  const model = normalizeModel(requirements.model);
316
416
  if (!model || !identity.model || model !== identity.model)
317
417
  throw new Error(`Printer model identity mismatch: requested ${requirements.model}, observed ${identity.model ?? "unknown"}.`);
318
- validateErrors(raw, requirements.requireIdle !== false);
418
+ validateErrors(raw, requirements.requireIdle !== false, identity.observedSerial ?? "", model);
319
419
  if (requirements.requireLoadedFilament && (raw.ams?.tray_now !== undefined || Array.isArray(raw.ams?.ams))) {
320
420
  const current = finiteNumber(raw.ams?.tray_now);
321
421
  if (current === 255)
@@ -328,7 +428,7 @@ export function validatePrinterState(status, requirements) {
328
428
  }
329
429
  }
330
430
  const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
331
- if (requirements.nozzleDiameters.length && ["h2d", "h2dpro", "h2c", "x2d"].includes(model) &&
431
+ if (requirements.nozzleDiameters.length && isDualNozzleModel(model) &&
332
432
  !Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
333
433
  throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
334
434
  }
@@ -357,16 +457,33 @@ export function validatePrinterState(status, requirements) {
357
457
  if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
358
458
  const materials = requirements.materials ?? [];
359
459
  const positions = requirements.usedFilamentPositions ?? materials.map((_, index) => index);
460
+ // 255 is the right/main nozzle external spool only on dual-nozzle printers.
461
+ const dualNozzle = isDualNozzleModel(model);
462
+ const maxTray = dualNozzle ? 255 : 254;
360
463
  if (!positions.length)
361
464
  throw new Error("A declared material is required for each used filament or external spool.");
362
465
  for (const position of positions) {
363
466
  const material = normalizeMaterial(materials[position]);
364
467
  if (!Number.isInteger(position) || position < 0 || !material)
365
468
  throw new Error(`A known declared material is required for filament position ${position}.`);
366
- const trayIndex = requirements.useAMS ? requirements.amsMapping?.[position] : 254;
367
- if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > 254)
469
+ const physicalNozzle = requirements.filamentNozzleIndices?.[position];
470
+ const externalForNozzle = physicalNozzle === 0 ? 255 : physicalNozzle === 1 ? 254 : undefined;
471
+ const trayIndex = requirements.useAMS || requirements.amsMapping !== undefined
472
+ ? requirements.amsMapping?.[position] : dualNozzle ? externalForNozzle : 254;
473
+ if (!requirements.useAMS && dualNozzle && requirements.amsMapping === undefined && externalForNozzle === undefined) {
474
+ throw new Error(`External spool for filament position ${position} is ambiguous; re-slice with complete filament_map and physical_extruder_map nozzle bindings or provide a verified sliced plate.`);
475
+ }
476
+ if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > maxTray)
368
477
  throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
369
- const tray = trayIndex === 254 ? externalTray(raw) : mappedTray(raw, trayIndex);
478
+ if (!requirements.useAMS && trayIndex < 254)
479
+ throw new Error("External-spool printing requires an external spool mapping; AMS trays require use_ams:true.");
480
+ if (dualNozzle && trayIndex >= 254) {
481
+ if (externalForNozzle === undefined)
482
+ throw new Error(`External spool ${trayIndex} needs a verified physical nozzle binding for filament position ${position}; re-slice with complete filament_map and physical_extruder_map metadata.`);
483
+ if (trayIndex !== externalForNozzle)
484
+ throw new Error(`External spool ${trayIndex} does not feed physical nozzle ${physicalNozzle} for filament position ${position}; use spool ${externalForNozzle}.`);
485
+ }
486
+ const tray = trayIndex >= 254 ? externalTray(raw, trayIndex, model) : mappedTray(raw, trayIndex);
370
487
  const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
371
488
  if (reportedType && normalizeMaterial(reportedType) !== material)
372
489
  throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
@@ -389,7 +506,7 @@ export function manualHeatingRequirements(status, model, nozzleDiameter, materia
389
506
  const raw = status?.raw;
390
507
  if (!object(raw))
391
508
  throw new Error("A fresh printer report is required for manual nozzle heating.");
392
- if (["h2d", "h2dpro", "h2c", "x2d"].includes(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
509
+ if (isDualNozzleModel(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
393
510
  throw new Error("Manual heating cannot verify the active nozzle and loaded material on this multi-nozzle printer. Use a checked sliced print instead.");
394
511
  }
395
512
  const requirements = {
@@ -410,3 +527,33 @@ export function manualHeatingRequirements(status, model, nozzleDiameter, materia
410
527
  }
411
528
  return requirements;
412
529
  }
530
+ const EXTERNAL_STORAGE_STATES = ["none", "normal", "abnormal", "readonly"];
531
+ /**
532
+ * The SD card or USB stick state, read as Bambu Studio does: the `sdcard`
533
+ * flag, then `home_flag` bits 8-9, then on firmware that reports cfg/fun/aux/stat
534
+ * the `aux` bits 12-13, each later source overriding the earlier one
535
+ * (DevStorage.cpp, DeviceManager.cpp parse_home_flag and parse_new_info).
536
+ */
537
+ export function externalStorageState(raw) {
538
+ if (!object(raw))
539
+ return "none";
540
+ let state = raw.sdcard === true ? "normal" : "none";
541
+ if (Number.isInteger(raw.home_flag))
542
+ state = EXTERNAL_STORAGE_STATES[(raw.home_flag >> 8) & 3];
543
+ if (["cfg", "fun", "aux", "stat"].every(key => key in raw) && typeof raw.aux === "string" && /^[0-9a-f]+$/i.test(raw.aux)) {
544
+ state = EXTERNAL_STORAGE_STATES[Number((BigInt(`0x${raw.aux}`) >> 12n) & 3n)];
545
+ }
546
+ return state;
547
+ }
548
+ let sequence = 0;
549
+ /**
550
+ * Publish one MQTT command with a string sequence id and no ACK wait.
551
+ * Unpatched bambu-node 3.22.21 command classes send numeric sequence ids;
552
+ * bambu-printer-mcp patches them to strings for H2-series firmware. This
553
+ * server does not install that patch, so safety-relevant commands are
554
+ * published here with the patched wire shape: {category:{sequence_id,command,...}}.
555
+ */
556
+ export function publishBambuCommand(printer, category, command, extra = {}) {
557
+ sequence = (sequence + 1) % 1000000000;
558
+ return printer.publish({ [category]: { sequence_id: String(sequence), command, ...extra } });
559
+ }
@@ -35,6 +35,8 @@ export interface BambuSliceOptions {
35
35
  minSave?: boolean;
36
36
  skipModifiedGcodes?: boolean;
37
37
  slicePlate?: number;
38
+ pauseAtLayers?: number[];
39
+ customGcodesFile?: string;
38
40
  /** Process keys written over the resolved process preset (layer_height, wall_loops, sparse_infill_density). */
39
41
  processOverrides?: Record<string, string>;
40
42
  }
@@ -489,6 +489,9 @@ const PROFILE_METADATA_KEYS = new Set([
489
489
  */
490
490
  export function planBambuCliInput(slicerType, is3mf, options) {
491
491
  const input = { slicerType, is3mf, meshTransformed: false };
492
+ if (!is3mf && options.repetitions !== undefined && options.cloneObjects) {
493
+ throw new Error("Use either repetitions or clone_objects for an STL, not both.");
494
+ }
492
495
  if (slicerType === "bambustudio")
493
496
  return input;
494
497
  const tilts = !!options.rotateX || !!options.rotateY;
@@ -499,9 +502,6 @@ export function planBambuCliInput(slicerType, is3mf, options) {
499
502
  }
500
503
  return input;
501
504
  }
502
- if (options.repetitions !== undefined && options.cloneObjects) {
503
- throw new Error("Use either repetitions or clone_objects for an STL with OrcaSlicer, not both.");
504
- }
505
505
  return { ...input, meshTransformed: options.scale !== undefined || tilts };
506
506
  }
507
507
  /**
@@ -581,8 +581,8 @@ export function buildBambuCliArgs(bundle, outputDir, exportName, options, input)
581
581
  if (options.arrange !== undefined)
582
582
  args.push("--arrange", options.arrange ? "1" : "0");
583
583
  if (options.repetitions !== undefined) {
584
- // OrcaSlicer rejects --repetitions for one STL; it copies the loaded model with --clone-objects.
585
- if (input.slicerType !== "bambustudio" && !input.is3mf)
584
+ // Both Bambu Studio and OrcaSlicer copy standalone STL objects with --clone-objects.
585
+ if (!input.is3mf)
586
586
  args.push("--clone-objects", String(options.repetitions));
587
587
  else
588
588
  args.push("--repetitions", String(options.repetitions));
@@ -607,6 +607,8 @@ export function buildBambuCliArgs(bundle, outputDir, exportName, options, input)
607
607
  }
608
608
  if (options.loadFilamentIds)
609
609
  args.push("--load-filament-ids", options.loadFilamentIds);
610
+ if (options.customGcodesFile)
611
+ args.push("--load-custom-gcodes", path.resolve(options.customGcodesFile));
610
612
  return args;
611
613
  }
612
614
  /**
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Pause-at-layer for Bambu-compatible slicer CLIs ("Add pause" in Bambu Studio
3
+ * and OrcaSlicer). The slicer writes the machine's own machine_pause_gcode
4
+ * (M400 U1 on Bambu printers) before the chosen layer, so the printer pauses
5
+ * by itself there and waits for Resume; no remote command is involved.
6
+ *
7
+ * The CLI takes these as --load-custom-gcodes <json> (CustomGCode::Info). It
8
+ * does not apply the pauses a project 3MF already carries in
9
+ * Metadata/custom_gcode_per_print_z.xml, so those are read here and passed the
10
+ * same way. A pause's print_z must be the exact top of its layer: a height
11
+ * between layers lands on the layer below.
12
+ */
13
+ /** CustomGCode::Type, in the order bbs_3mf.cpp writes it as an integer. */
14
+ declare const CUSTOM_GCODE_TYPES: readonly ["ColorChange", "PausePrint", "ToolChange", "Template", "Custom", "Unknown"];
15
+ export type CustomGcodeType = (typeof CUSTOM_GCODE_TYPES)[number];
16
+ export interface CustomGcodeItem {
17
+ type: CustomGcodeType;
18
+ print_z: number;
19
+ color: string;
20
+ extruder: number;
21
+ extra: string;
22
+ }
23
+ export interface CustomGcodeInfo {
24
+ mode: string;
25
+ gcodes: CustomGcodeItem[];
26
+ }
27
+ /** 1-based layer numbers, as Bambu Studio's and OrcaSlicer's preview slider count them. */
28
+ export declare function parsePauseLayers(value: unknown): number[] | undefined;
29
+ /**
30
+ * The custom G-codes (pauses, colour changes, ...) a project 3MF stores for
31
+ * one plate (1-based), as Bambu Studio and OrcaSlicer save them.
32
+ */
33
+ export declare function readProjectCustomGcodes(threeMfPath: string, plate?: number): Promise<CustomGcodeInfo | undefined>;
34
+ /** The plate's G-code text inside a sliced 3MF. */
35
+ export declare function readPlateGcode(threeMfPath: string, plate?: number): Promise<string | undefined>;
36
+ /** Each layer's top Z in order, from the slicer's "; Z_HEIGHT:" markers. */
37
+ export declare function layerTops(gcode: string): number[];
38
+ /**
39
+ * The 1-based layers whose block holds a pause: the slicer's "; PAUSE_PRINTING"
40
+ * marker or the machine's pause G-code on its own line.
41
+ */
42
+ export declare function pausedLayers(gcode: string, pauseGcode?: string): number[];
43
+ /** The machine_pause_gcode a sliced file was made with (its config block), else Bambu's M400 U1. */
44
+ export declare function machinePauseGcode(gcode: string): string;
45
+ /** Project custom G-codes plus a pause before each requested layer, at that layer's exact top. */
46
+ export declare function customGcodesWithPauses(tops: number[], layers: number[], project?: CustomGcodeInfo): CustomGcodeInfo;
47
+ export {};
@@ -0,0 +1,130 @@
1
+ import fs from "node:fs";
2
+ import JSZip from "jszip";
3
+ /**
4
+ * Pause-at-layer for Bambu-compatible slicer CLIs ("Add pause" in Bambu Studio
5
+ * and OrcaSlicer). The slicer writes the machine's own machine_pause_gcode
6
+ * (M400 U1 on Bambu printers) before the chosen layer, so the printer pauses
7
+ * by itself there and waits for Resume; no remote command is involved.
8
+ *
9
+ * The CLI takes these as --load-custom-gcodes <json> (CustomGCode::Info). It
10
+ * does not apply the pauses a project 3MF already carries in
11
+ * Metadata/custom_gcode_per_print_z.xml, so those are read here and passed the
12
+ * same way. A pause's print_z must be the exact top of its layer: a height
13
+ * between layers lands on the layer below.
14
+ */
15
+ /** CustomGCode::Type, in the order bbs_3mf.cpp writes it as an integer. */
16
+ const CUSTOM_GCODE_TYPES = ["ColorChange", "PausePrint", "ToolChange", "Template", "Custom", "Unknown"];
17
+ /** 1-based layer numbers, as Bambu Studio's and OrcaSlicer's preview slider count them. */
18
+ export function parsePauseLayers(value) {
19
+ if (value === undefined || value === null || value === "")
20
+ return undefined;
21
+ const raw = Array.isArray(value) ? value : String(value).split(/[\s,;]+/).filter(Boolean);
22
+ const layers = raw.map(entry => Number(entry));
23
+ for (const layer of layers) {
24
+ if (!Number.isInteger(layer) || layer < 2) {
25
+ throw new Error(`pause_at_layers takes whole layer numbers of 2 or more (the printer cannot pause before layer 1), not ${JSON.stringify(value)}.`);
26
+ }
27
+ }
28
+ const unique = [...new Set(layers)].sort((a, b) => a - b);
29
+ return unique.length ? unique : undefined;
30
+ }
31
+ function xmlAttr(tag, name) {
32
+ const match = new RegExp(`\\b${name}="([^"]*)"`).exec(tag);
33
+ return match ? match[1].replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&") : undefined;
34
+ }
35
+ /**
36
+ * The custom G-codes (pauses, colour changes, ...) a project 3MF stores for
37
+ * one plate (1-based), as Bambu Studio and OrcaSlicer save them.
38
+ */
39
+ export async function readProjectCustomGcodes(threeMfPath, plate = 1) {
40
+ let zip;
41
+ try {
42
+ zip = await JSZip.loadAsync(await fs.promises.readFile(threeMfPath));
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ const file = zip.file(/^Metadata\/custom_gcode_per_print_z\.xml$/i)[0];
48
+ if (!file)
49
+ return undefined;
50
+ const xml = await file.async("string");
51
+ for (const block of xml.match(/<plate>[\s\S]*?<\/plate>/g) ?? []) {
52
+ const info = /<plate_info\b[^>]*>/.exec(block)?.[0];
53
+ if (Number(info ? xmlAttr(info, "id") : NaN) !== plate)
54
+ continue;
55
+ const gcodes = [];
56
+ for (const tag of block.match(/<layer\b[^>]*>/g) ?? []) {
57
+ const type = CUSTOM_GCODE_TYPES[Number(xmlAttr(tag, "type"))] ?? "Unknown";
58
+ const printZ = Number(xmlAttr(tag, "top_z"));
59
+ if (!Number.isFinite(printZ))
60
+ continue;
61
+ gcodes.push({
62
+ type,
63
+ print_z: printZ,
64
+ color: xmlAttr(tag, "color") ?? "",
65
+ extruder: Number(xmlAttr(tag, "extruder") ?? 1) || 1,
66
+ // Template and Custom keep their G-code text in "extra" (CustomGCode.hpp).
67
+ extra: xmlAttr(tag, "extra") ?? "",
68
+ });
69
+ }
70
+ const modeTag = /<mode\b[^>]*>/.exec(block)?.[0];
71
+ return { mode: (modeTag && xmlAttr(modeTag, "value")) || "SingleExtruder", gcodes };
72
+ }
73
+ return undefined;
74
+ }
75
+ /** The plate's G-code text inside a sliced 3MF. */
76
+ export async function readPlateGcode(threeMfPath, plate = 1) {
77
+ const zip = await JSZip.loadAsync(await fs.promises.readFile(threeMfPath));
78
+ const exact = zip.file(`Metadata/plate_${plate}.gcode`);
79
+ const file = exact ?? zip.file(/^Metadata\/plate_\d+\.gcode$/)[0];
80
+ return file ? file.async("string") : undefined;
81
+ }
82
+ /** Each layer's top Z in order, from the slicer's "; Z_HEIGHT:" markers. */
83
+ export function layerTops(gcode) {
84
+ const tops = [];
85
+ for (const match of gcode.matchAll(/^; Z_HEIGHT: ([0-9.]+)\s*$/gm))
86
+ tops.push(Number(match[1]));
87
+ return tops;
88
+ }
89
+ /**
90
+ * The 1-based layers whose block holds a pause: the slicer's "; PAUSE_PRINTING"
91
+ * marker or the machine's pause G-code on its own line.
92
+ */
93
+ export function pausedLayers(gcode, pauseGcode = "M400 U1") {
94
+ const lines = gcode.split(/\r?\n/);
95
+ const paused = new Set();
96
+ let layer = 0;
97
+ const pauseLine = pauseGcode.trim();
98
+ for (const line of lines) {
99
+ if (line.startsWith("; Z_HEIGHT:")) {
100
+ layer += 1;
101
+ continue;
102
+ }
103
+ if (layer === 0)
104
+ continue;
105
+ const trimmed = line.trim();
106
+ if (trimmed === "; PAUSE_PRINTING" || (pauseLine && trimmed === pauseLine))
107
+ paused.add(layer);
108
+ }
109
+ return [...paused].sort((a, b) => a - b);
110
+ }
111
+ /** The machine_pause_gcode a sliced file was made with (its config block), else Bambu's M400 U1. */
112
+ export function machinePauseGcode(gcode) {
113
+ const match = /^; machine_pause_gcode = (.*)$/m.exec(gcode);
114
+ const value = match?.[1]?.split("\\n")[0]?.trim();
115
+ return value || "M400 U1";
116
+ }
117
+ /** Project custom G-codes plus a pause before each requested layer, at that layer's exact top. */
118
+ export function customGcodesWithPauses(tops, layers, project) {
119
+ const total = tops.length;
120
+ const beyond = layers.filter(layer => layer > total);
121
+ if (beyond.length) {
122
+ throw new Error(`This print has ${total} layers, so it cannot pause before layer ${beyond.join(", ")}.`);
123
+ }
124
+ const pauses = layers.map(layer => ({ type: "PausePrint", print_z: tops[layer - 1], color: "", extruder: 1, extra: "" }));
125
+ const kept = (project?.gcodes ?? []).filter(item => !(item.type === "PausePrint" && pauses.some(p => Math.abs(p.print_z - item.print_z) < 1e-4)));
126
+ return {
127
+ mode: project?.mode || "SingleExtruder",
128
+ gcodes: [...kept, ...pauses].sort((a, b) => a.print_z - b.print_z),
129
+ };
130
+ }
@@ -165,6 +165,7 @@ export declare class STLManipulator extends EventEmitter {
165
165
  * slicer's error_string and plate warnings.
166
166
  */
167
167
  sliceSTLWithResult(stlFilePath: string, slicerType: SlicerType, slicerPath: string, slicerProfile?: string, progressCallback?: ProgressCallback, printerPreset?: string, filamentProfile?: string, bambuOptions?: BambuSliceOptions, processOverrides?: ProcessOverrides): Promise<SliceOutput>;
168
+ private sliceSTLOnce;
168
169
  /**
169
170
  * Enhanced version of confirmTemperatures with better error handling
170
171
  * @param gcodePath Path to the G-code file
@@ -12,6 +12,7 @@ import { flattenOrcaFilament } from '../slicer/filament-profiles.js';
12
12
  import { BambuCliProfilePreparer, assertSlicedProjectOutput, bambuCliFailureReason, readCliResult, buildBambuCliArgs, planBambuCliInput, } from '../slicer/bambu-cli.js';
13
13
  import { runSlicerProcess } from '../slicer/run-slicer.js';
14
14
  import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
15
+ import { customGcodesWithPauses, layerTops, machinePauseGcode, pausedLayers, readPlateGcode, readProjectCustomGcodes } from '../slicer/layer-pauses.js';
15
16
  /** Orca/Bambu Studio process keys; profile JSON values are strings. */
16
17
  export function orcaProcessOverrides(overrides) {
17
18
  const keys = {};
@@ -848,6 +849,45 @@ export class STLManipulator extends EventEmitter {
848
849
  * slicer's error_string and plate warnings.
849
850
  */
850
851
  async sliceSTLWithResult(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions, processOverrides) {
852
+ const once = (options) => this.sliceSTLOnce(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, options, processOverrides);
853
+ const layers = bambuOptions?.pauseAtLayers?.length ? bambuOptions.pauseAtLayers : undefined;
854
+ const cli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);
855
+ const plate = bambuOptions?.slicePlate && bambuOptions.slicePlate > 0 ? bambuOptions.slicePlate : 1;
856
+ const project = cli && /\.3mf$/i.test(stlFilePath) ? await readProjectCustomGcodes(stlFilePath, plate) : undefined;
857
+ if (!layers && !project?.gcodes.length)
858
+ return once(bambuOptions);
859
+ if (layers && !cli)
860
+ throw new Error(`Pausing at a layer needs the Bambu Studio or OrcaSlicer CLI, not ${slicerType}.`);
861
+ const customGcodesFile = path.join(this.tempDir, `custom-gcodes-${crypto.randomUUID()}.json`);
862
+ try {
863
+ let tops = [];
864
+ if (layers) {
865
+ if (progressCallback)
866
+ progressCallback(10, `Finding layer heights for the pause before layer ${layers.join(", ")}`);
867
+ const plain = await once({ ...bambuOptions, pauseAtLayers: undefined, customGcodesFile: undefined });
868
+ const gcode = await readPlateGcode(plain.path, plate);
869
+ if (!gcode)
870
+ throw new Error('The slicer wrote no plate G-code, so the pause layers cannot be placed.');
871
+ tops = layerTops(gcode);
872
+ }
873
+ const info = layers ? customGcodesWithPauses(tops, layers, project) : project;
874
+ await fs.promises.writeFile(customGcodesFile, JSON.stringify(info));
875
+ const result = await once({ ...bambuOptions, customGcodesFile });
876
+ if (layers) {
877
+ const gcode = await readPlateGcode(result.path, plate);
878
+ const placed = gcode ? pausedLayers(gcode, machinePauseGcode(gcode)) : [];
879
+ const missing = layers.filter(layer => !placed.includes(layer));
880
+ if (!gcode || missing.length || layerTops(gcode).length !== tops.length) {
881
+ throw new Error(`The slicer did not place the pause before layer ${(missing.length ? missing : layers).join(", ")}. Nothing was sent to the printer.`);
882
+ }
883
+ }
884
+ return result;
885
+ }
886
+ finally {
887
+ await fs.promises.rm(customGcodesFile, { force: true });
888
+ }
889
+ }
890
+ async sliceSTLOnce(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions, processOverrides) {
851
891
  const operationId = this.generateOperationId();
852
892
  this.activeOperations.set(operationId, true);
853
893
  const bambuCli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);