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,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,95 @@ 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
+ function validateErrors(raw, requireIdle, serial, model) {
238
320
  const state = typeof raw.gcode_state === "string" ? raw.gcode_state.toUpperCase() : "UNKNOWN";
239
- if (requireIdle && !["IDLE", "FINISH"].includes(state))
321
+ const terminal = isCancelledX2DJob(raw, model) || isFinishedP1SJob(raw, model);
322
+ if (requireIdle && !["IDLE", "FINISH"].includes(state) && !terminal)
240
323
  throw new Error(`Printer state ${state} is not safely idle.`);
241
- if (!requireIdle && !["IDLE", "FINISH", "RUNNING", "PAUSE"].includes(state))
324
+ if (!requireIdle && !["IDLE", "FINISH", "RUNNING", "PAUSE"].includes(state) && !terminal)
242
325
  throw new Error(`Printer state ${state} is unknown or failed.`);
243
326
  if (finiteNumber(raw.print_error) !== 0)
244
327
  throw new Error("Printer reports a print error or did not report its error state.");
245
328
  if (!Array.isArray(raw.hms))
246
329
  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.
330
+ for (const reported of raw.hms) {
331
+ const entry = hmsCode(reported);
251
332
  // 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
- }
333
+ if (entry && (entry.severity === 4 || isHiddenNotice(serial, model, entry)))
334
+ continue;
335
+ throw new Error(`Printer reports actionable or unknown HMS errors${entry ? ` (${hmsLabel(entry.hex)})` : ""}; inspect and resolve them before proceeding.`);
258
336
  }
337
+ logHiddenNotices(raw, serial, model);
259
338
  }
260
339
  function trayRange(value, label) {
261
340
  if (value === undefined || value === null || value === "" || value === "0" || value === 0)
@@ -265,15 +344,24 @@ function trayRange(value, label) {
265
344
  throw new Error(`Reported filament ${label} temperature is malformed.`);
266
345
  return number;
267
346
  }
268
- function externalTray(raw) {
347
+ /** Dual-nozzle printers report one virtual external slot per nozzle, numbered as Bambu
348
+ * Studio does (VIRTUAL_TRAY_MAIN_ID 255 = right/main nozzle, VIRTUAL_TRAY_DEPUTY_ID 254 =
349
+ * left/deputy nozzle), so the requested tray selects its slot. Single-nozzle printers keep
350
+ * requiring exactly one slot 254 report, then the legacy vt_tray field. */
351
+ function externalTray(raw, trayIndex, model) {
269
352
  const slots = raw.vir_slot;
353
+ const dualNozzle = isDualNozzleModel(model);
270
354
  if (Array.isArray(slots)) {
271
- const selected = slots.filter((slot) => Number(slot?.id) === 254);
272
- if (slots.length === 1 && selected.length === 1)
355
+ const selected = slots.filter((slot) => Number(slot?.id) === trayIndex);
356
+ if (selected.length === 1 && (dualNozzle || slots.length === 1))
273
357
  return selected[0];
358
+ if (dualNozzle && selected.length === 0 && slots.length > 0)
359
+ throw new Error(`External spool ${trayIndex} is not in the fresh per-nozzle report; cannot verify the selected material.`);
274
360
  if (slots.length > 0)
275
361
  throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
276
362
  }
363
+ if (dualNozzle)
364
+ 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
365
  return object(raw.vt_tray) ? raw.vt_tray : undefined;
278
366
  }
279
367
  function mappedTray(raw, index) {
@@ -315,7 +403,7 @@ export function validatePrinterState(status, requirements) {
315
403
  const model = normalizeModel(requirements.model);
316
404
  if (!model || !identity.model || model !== identity.model)
317
405
  throw new Error(`Printer model identity mismatch: requested ${requirements.model}, observed ${identity.model ?? "unknown"}.`);
318
- validateErrors(raw, requirements.requireIdle !== false);
406
+ validateErrors(raw, requirements.requireIdle !== false, identity.observedSerial ?? "", model);
319
407
  if (requirements.requireLoadedFilament && (raw.ams?.tray_now !== undefined || Array.isArray(raw.ams?.ams))) {
320
408
  const current = finiteNumber(raw.ams?.tray_now);
321
409
  if (current === 255)
@@ -328,7 +416,7 @@ export function validatePrinterState(status, requirements) {
328
416
  }
329
417
  }
330
418
  const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
331
- if (requirements.nozzleDiameters.length && ["h2d", "h2dpro", "h2c", "x2d"].includes(model) &&
419
+ if (requirements.nozzleDiameters.length && isDualNozzleModel(model) &&
332
420
  !Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
333
421
  throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
334
422
  }
@@ -357,16 +445,33 @@ export function validatePrinterState(status, requirements) {
357
445
  if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
358
446
  const materials = requirements.materials ?? [];
359
447
  const positions = requirements.usedFilamentPositions ?? materials.map((_, index) => index);
448
+ // 255 is the right/main nozzle external spool only on dual-nozzle printers.
449
+ const dualNozzle = isDualNozzleModel(model);
450
+ const maxTray = dualNozzle ? 255 : 254;
360
451
  if (!positions.length)
361
452
  throw new Error("A declared material is required for each used filament or external spool.");
362
453
  for (const position of positions) {
363
454
  const material = normalizeMaterial(materials[position]);
364
455
  if (!Number.isInteger(position) || position < 0 || !material)
365
456
  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)
457
+ const physicalNozzle = requirements.filamentNozzleIndices?.[position];
458
+ const externalForNozzle = physicalNozzle === 0 ? 255 : physicalNozzle === 1 ? 254 : undefined;
459
+ const trayIndex = requirements.useAMS || requirements.amsMapping !== undefined
460
+ ? requirements.amsMapping?.[position] : dualNozzle ? externalForNozzle : 254;
461
+ if (!requirements.useAMS && dualNozzle && requirements.amsMapping === undefined && externalForNozzle === undefined) {
462
+ 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.`);
463
+ }
464
+ if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > maxTray)
368
465
  throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
369
- const tray = trayIndex === 254 ? externalTray(raw) : mappedTray(raw, trayIndex);
466
+ if (!requirements.useAMS && trayIndex < 254)
467
+ throw new Error("External-spool printing requires an external spool mapping; AMS trays require use_ams:true.");
468
+ if (dualNozzle && trayIndex >= 254) {
469
+ if (externalForNozzle === undefined)
470
+ 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.`);
471
+ if (trayIndex !== externalForNozzle)
472
+ throw new Error(`External spool ${trayIndex} does not feed physical nozzle ${physicalNozzle} for filament position ${position}; use spool ${externalForNozzle}.`);
473
+ }
474
+ const tray = trayIndex >= 254 ? externalTray(raw, trayIndex, model) : mappedTray(raw, trayIndex);
370
475
  const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
371
476
  if (reportedType && normalizeMaterial(reportedType) !== material)
372
477
  throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
@@ -389,7 +494,7 @@ export function manualHeatingRequirements(status, model, nozzleDiameter, materia
389
494
  const raw = status?.raw;
390
495
  if (!object(raw))
391
496
  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)) {
497
+ if (isDualNozzleModel(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
393
498
  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
499
  }
395
500
  const requirements = {
@@ -410,3 +515,33 @@ export function manualHeatingRequirements(status, model, nozzleDiameter, materia
410
515
  }
411
516
  return requirements;
412
517
  }
518
+ const EXTERNAL_STORAGE_STATES = ["none", "normal", "abnormal", "readonly"];
519
+ /**
520
+ * The SD card or USB stick state, read as Bambu Studio does: the `sdcard`
521
+ * flag, then `home_flag` bits 8-9, then on firmware that reports cfg/fun/aux/stat
522
+ * the `aux` bits 12-13, each later source overriding the earlier one
523
+ * (DevStorage.cpp, DeviceManager.cpp parse_home_flag and parse_new_info).
524
+ */
525
+ export function externalStorageState(raw) {
526
+ if (!object(raw))
527
+ return "none";
528
+ let state = raw.sdcard === true ? "normal" : "none";
529
+ if (Number.isInteger(raw.home_flag))
530
+ state = EXTERNAL_STORAGE_STATES[(raw.home_flag >> 8) & 3];
531
+ if (["cfg", "fun", "aux", "stat"].every(key => key in raw) && typeof raw.aux === "string" && /^[0-9a-f]+$/i.test(raw.aux)) {
532
+ state = EXTERNAL_STORAGE_STATES[Number((BigInt(`0x${raw.aux}`) >> 12n) & 3n)];
533
+ }
534
+ return state;
535
+ }
536
+ let sequence = 0;
537
+ /**
538
+ * Publish one MQTT command with a string sequence id and no ACK wait.
539
+ * Unpatched bambu-node 3.22.21 command classes send numeric sequence ids;
540
+ * bambu-printer-mcp patches them to strings for H2-series firmware. This
541
+ * server does not install that patch, so safety-relevant commands are
542
+ * published here with the patched wire shape: {category:{sequence_id,command,...}}.
543
+ */
544
+ export function publishBambuCommand(printer, category, command, extra = {}) {
545
+ sequence = (sequence + 1) % 1000000000;
546
+ return printer.publish({ [category]: { sequence_id: String(sequence), command, ...extra } });
547
+ }
@@ -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
  }
@@ -118,9 +120,11 @@ export interface BambuCliInput {
118
120
  */
119
121
  export declare function planBambuCliInput(slicerType: BambuCliSlicerType, is3mf: boolean, options: BambuSliceOptions): BambuCliInput;
120
122
  /**
121
- * The CLI's own reason for a failed run: result.json in the run's fresh output
122
- * directory (written on Linux for handled errors), else the exit-code table.
123
+ * The result.json in the run's fresh output directory, parsed verbatim. Bambu
124
+ * Studio 02.08.02.61 writes it on macOS for successes and failures alike.
123
125
  */
126
+ export declare function readCliResult(outputDir: string): Record<string, unknown> | undefined;
127
+ /** The CLI's own reason for a failed run: its result.json, else the exit-code table. */
124
128
  export declare function bambuCliFailureReason(exitCode: number | null, outputDir: string): string | undefined;
125
129
  /** CLI flags for the prepared bundle and caller options (input path appended by the caller). */
126
130
  export declare function buildBambuCliArgs(bundle: BambuSettingsBundle, outputDir: string, exportName: string, options: BambuSliceOptions, input: BambuCliInput): string[];
@@ -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
  /**
@@ -530,18 +530,23 @@ const BAMBU_CLI_ERRORS = {
530
530
  [-102]: "G-code in an unprintable area",
531
531
  };
532
532
  /**
533
- * The CLI's own reason for a failed run: result.json in the run's fresh output
534
- * directory (written on Linux for handled errors), else the exit-code table.
533
+ * The result.json in the run's fresh output directory, parsed verbatim. Bambu
534
+ * Studio 02.08.02.61 writes it on macOS for successes and failures alike.
535
535
  */
536
- export function bambuCliFailureReason(exitCode, outputDir) {
536
+ export function readCliResult(outputDir) {
537
537
  try {
538
538
  const result = JSON.parse(fs.readFileSync(path.join(outputDir, "result.json"), "utf8"));
539
- if (typeof result?.error_string === "string" && result.error_string.trim()) {
540
- return `${result.error_string.trim()}${result.return_code !== undefined ? ` [${result.return_code}]` : ""}`;
541
- }
539
+ return result && typeof result === "object" && !Array.isArray(result) ? result : undefined;
542
540
  }
543
541
  catch {
544
- // result.json is written only on Linux and only for handled errors.
542
+ return undefined;
543
+ }
544
+ }
545
+ /** The CLI's own reason for a failed run: its result.json, else the exit-code table. */
546
+ export function bambuCliFailureReason(exitCode, outputDir) {
547
+ const result = readCliResult(outputDir);
548
+ if (typeof result?.error_string === "string" && result.error_string.trim()) {
549
+ return `${result.error_string.trim()}${result.return_code !== undefined ? ` [${result.return_code}]` : ""}`;
545
550
  }
546
551
  if (exitCode === null)
547
552
  return undefined;
@@ -576,8 +581,8 @@ export function buildBambuCliArgs(bundle, outputDir, exportName, options, input)
576
581
  if (options.arrange !== undefined)
577
582
  args.push("--arrange", options.arrange ? "1" : "0");
578
583
  if (options.repetitions !== undefined) {
579
- // OrcaSlicer rejects --repetitions for one STL; it copies the loaded model with --clone-objects.
580
- if (input.slicerType !== "bambustudio" && !input.is3mf)
584
+ // Both Bambu Studio and OrcaSlicer copy standalone STL objects with --clone-objects.
585
+ if (!input.is3mf)
581
586
  args.push("--clone-objects", String(options.repetitions));
582
587
  else
583
588
  args.push("--repetitions", String(options.repetitions));
@@ -602,6 +607,8 @@ export function buildBambuCliArgs(bundle, outputDir, exportName, options, input)
602
607
  }
603
608
  if (options.loadFilamentIds)
604
609
  args.push("--load-filament-ids", options.loadFilamentIds);
610
+ if (options.customGcodesFile)
611
+ args.push("--load-custom-gcodes", path.resolve(options.customGcodesFile));
605
612
  return args;
606
613
  }
607
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
+ }
@@ -15,11 +15,21 @@ export interface SlicerFailureDetails {
15
15
  stderrTail?: string;
16
16
  }
17
17
  export declare function slicerSuggestion(phase: SlicerFailurePhase, slicerType?: string): string;
18
+ /** Bambu Studio's or OrcaSlicer's own result.json, parsed verbatim. */
19
+ export type SlicerResult = Record<string, unknown>;
20
+ /**
21
+ * The slicer's own words: its error_string when it reports a failing
22
+ * return_code (not "Success."), then each plate's warning_message.
23
+ */
24
+ export declare function slicerResultText(result: SlicerResult): string;
18
25
  export declare class SlicerError extends Error {
26
+ readonly slicerResult?: SlicerResult | undefined;
19
27
  readonly phase: SlicerFailurePhase;
20
28
  readonly suggestion: string;
21
29
  readonly details: SlicerFailureDetails;
22
- constructor(phase: SlicerFailurePhase, message: string, details?: SlicerFailureDetails);
30
+ /** The slicer's own words from `slicerResult`; the message starts with them. */
31
+ readonly slicerText: string;
32
+ constructor(phase: SlicerFailurePhase, message: string, details?: SlicerFailureDetails, slicerResult?: SlicerResult | undefined);
23
33
  }
24
34
  /** Keep the last lines of slicer output; the actionable line is usually at the end. */
25
35
  export declare function tailText(text: string, maxLines?: number, maxChars?: number): string;
@@ -25,12 +25,26 @@ export function slicerSuggestion(phase, slicerType) {
25
25
  "uploaded or printed.";
26
26
  }
27
27
  }
28
+ /**
29
+ * The slicer's own words: its error_string when it reports a failing
30
+ * return_code (not "Success."), then each plate's warning_message.
31
+ */
32
+ export function slicerResultText(result) {
33
+ const plates = Array.isArray(result.sliced_plates) ? result.sliced_plates : [];
34
+ return [
35
+ result.return_code === 0 ? undefined : result.error_string,
36
+ ...plates.map((plate) => plate && typeof plate === "object" ? plate.warning_message : undefined),
37
+ ].filter((text) => typeof text === "string" && text.trim() !== "").map((text) => text.trim()).join("\n");
38
+ }
28
39
  export class SlicerError extends Error {
29
- constructor(phase, message, details = {}) {
30
- super(message);
40
+ constructor(phase, message, details = {}, slicerResult) {
41
+ const slicerText = slicerResult ? slicerResultText(slicerResult) : "";
42
+ super(slicerText ? `${slicerText}\n${message}` : message);
43
+ this.slicerResult = slicerResult;
31
44
  this.name = "SlicerError";
32
45
  this.phase = phase;
33
46
  this.details = details;
47
+ this.slicerText = slicerText;
34
48
  this.suggestion = slicerSuggestion(phase, details.slicerType);
35
49
  }
36
50
  }