bambu-printer-mcp 1.1.28 → 1.1.30

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.
@@ -7,7 +7,8 @@ import os from "node:os";
7
7
  import { Readable } from "node:stream";
8
8
  import { readSafe3mfArchive } from "../safety/archive.js";
9
9
  import { inspectPrintFile } from "../safety/print-file.js";
10
- import { normalizeModel, validateTemperature } from "../safety/limits.js";
10
+ import { isDualNozzleModel, normalizeModel, validateTemperature } from "../safety/limits.js";
11
+ import { bridgeAmsMapping2Entry, bridgeAmsMappingValue, externalTrayForNozzle, describeAmsTrayRange } from "../ams-mapping.js";
11
12
  import { describeHmsKey, hiddenHmsKeys, hiddenHmsNotices, reportedHmsKey, validatePrinterState, manualHeatingRequirements } from "../safety/printer-state.js";
12
13
  import { withPrinterOperation, withPrintSnapshot, uniquePrintName, normalizedRemotePath, cancelPendingPrinterOperations } from "../safety/artifact.js";
13
14
  import { Client as FTPClient } from "basic-ftp";
@@ -355,6 +356,12 @@ class BambuClientStore {
355
356
  throw error;
356
357
  }
357
358
  }
359
+ forgetPrinter(printer) {
360
+ for (const [key, cached] of this.printers) {
361
+ if (cached === printer)
362
+ this.clearPrinterState(key);
363
+ }
364
+ }
358
365
  async disconnectAll() {
359
366
  const disconnectPromises = [];
360
367
  for (const printer of this.printers.values()) {
@@ -620,12 +627,17 @@ export class BambuImplementation {
620
627
  // `plate_N.json.filament_ids` -- and expand to a full project-level
621
628
  // array here. `amsMapping` is the raw escape hatch (takes precedence
622
629
  // when both are supplied).
630
+ // Dual-nozzle printers address two external spools: 254 feeds the left/deputy
631
+ // nozzle and 255 the right/main nozzle (Bambu Studio VIRTUAL_TRAY_DEPUTY_ID /
632
+ // VIRTUAL_TRAY_MAIN_ID). Single-nozzle printers keep 254 as the only external tray.
633
+ const dualNozzle = isDualNozzleModel(model);
634
+ const externalTrays = dualNozzle ? [254, 255] : [254];
623
635
  const validateTrayValue = (v, label) => {
624
636
  if (!Number.isInteger(v) ||
625
637
  v < -1 ||
626
638
  (v > 15 && v < 128) ||
627
- v > 254) {
628
- throw new Error(`${label} values must be integers in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${v}`);
639
+ v > (dualNozzle ? 255 : 254)) {
640
+ throw new Error(`${label} values must be integers in ${describeAmsTrayRange({ dualNozzle })}; got ${v}`);
629
641
  }
630
642
  };
631
643
  let baseMapping;
@@ -655,7 +667,7 @@ export class BambuImplementation {
655
667
  const projectLen = Math.max(projectMetadata.projectFilamentCount, ...positions.map((p) => p + 1), 1);
656
668
  baseMapping = Array(projectLen).fill(-1);
657
669
  positions.forEach((pos, i) => {
658
- baseMapping[pos] = options.useAMS === false ? 254 : i;
670
+ baseMapping[pos] = options.useAMS === false ? externalTrayForNozzle(inspection.filamentNozzleIndices?.[pos], dualNozzle) : i;
659
671
  });
660
672
  }
661
673
  let amsMapping;
@@ -663,15 +675,7 @@ export class BambuImplementation {
663
675
  if (usesH2ProjectFile) {
664
676
  const projLen = Math.max(projectMetadata.projectFilamentCount, baseMapping.length, 1);
665
677
  amsMapping = Array.from({ length: projLen }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
666
- amsMapping2 = amsMapping.map((v) => {
667
- if (v < 0 || v === 255)
668
- return { ams_id: 255, slot_id: 255 };
669
- if (v === 254)
670
- return { ams_id: 254, slot_id: 254 };
671
- if (v >= 128)
672
- return { ams_id: 128, slot_id: v - 128 };
673
- return { ams_id: Math.floor(v / 4), slot_id: v % 4 };
674
- });
678
+ amsMapping2 = amsMapping.map((v) => bridgeAmsMapping2Entry(v, dualNozzle));
675
679
  }
676
680
  else {
677
681
  amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
@@ -681,8 +685,8 @@ export class BambuImplementation {
681
685
  if (baseMapping[position] === undefined || baseMapping[position] < 0) {
682
686
  throw new Error(`Missing physical filament mapping for project filament ${position}.`);
683
687
  }
684
- if (options.useAMS === false && baseMapping[position] !== 254) {
685
- throw new Error("External-spool printing requires external spool mapping (254).");
688
+ if (options.useAMS === false && !externalTrays.includes(baseMapping[position])) {
689
+ throw new Error(`External-spool printing requires external spool mapping (${externalTrays.join(" or ")}).`);
686
690
  }
687
691
  }
688
692
  const legacyContainer = options.filePath.toLowerCase().endsWith(".gcode.3mf") && !usesH2ProjectFile && !isA1;
@@ -768,7 +772,7 @@ export class BambuImplementation {
768
772
  project_id: submissionId,
769
773
  subtask_id: submissionId,
770
774
  task_id: submissionId,
771
- ams_mapping: amsMapping,
775
+ ams_mapping: amsMapping.map(value => bridgeAmsMappingValue(value, dualNozzle)),
772
776
  ams_mapping2: amsMapping2,
773
777
  },
774
778
  };
@@ -838,17 +842,48 @@ export class BambuImplementation {
838
842
  throw new Error(`Failed to cancel print: ${error.message}`);
839
843
  }
840
844
  }
845
+ async invokeEmergencyCommand(printer, command, signal) {
846
+ if (!signal)
847
+ return invokeWithoutAck(printer, command);
848
+ let onAbort;
849
+ try {
850
+ await new Promise((resolve, reject) => {
851
+ onAbort = () => {
852
+ // Force-close the actual transport: a raced publish may be queued
853
+ // inside MQTT.js and otherwise dispatch after a later reconnect.
854
+ this.printerStore.forgetPrinter(printer);
855
+ void printer.disconnect(true).catch(() => {
856
+ console.error("Failed to close an expired emergency MQTT client.");
857
+ });
858
+ reject(signal.reason);
859
+ };
860
+ signal.addEventListener("abort", onAbort, { once: true });
861
+ if (signal.aborted)
862
+ onAbort();
863
+ else
864
+ command.invoke(printer).then(resolve, reject);
865
+ });
866
+ }
867
+ finally {
868
+ signal.removeEventListener("abort", onAbort);
869
+ }
870
+ await sleep(COMMAND_SETTLE_MS);
871
+ }
841
872
  /** Raw MQTT stop for routes that already ran the shared cancellation steps. */
842
- async sendStopOverMqtt(host, serial, token) {
873
+ async sendStopOverMqtt(host, serial, token, signal) {
874
+ signal?.throwIfAborted();
843
875
  const printer = await this.getPrinter(host, serial, token);
844
- await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
876
+ signal?.throwIfAborted();
877
+ await this.invokeEmergencyCommand(printer, new UpdateStateCommand({ state: "stop" }), signal);
845
878
  return { status: "success", message: "Cancel command sent over MQTT." };
846
879
  }
847
880
  /** Raw MQTT heater-off; switching off needs no material or model declaration. */
848
- async sendHeaterOffOverMqtt(host, serial, token, heater) {
881
+ async sendHeaterOffOverMqtt(host, serial, token, heater, signal) {
882
+ signal?.throwIfAborted();
849
883
  const printer = await this.getPrinter(host, serial, token);
884
+ signal?.throwIfAborted();
850
885
  const gcode = `${heater === "bed" ? "M140" : "M104"} S0`;
851
- await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
886
+ await this.invokeEmergencyCommand(printer, new GCodeLineCommand({ gcodes: [gcode] }), signal);
852
887
  return { status: "success", message: `Heater-off sent over MQTT for ${heater}.`, command: gcode };
853
888
  }
854
889
  async pauseJob(host, serial, token, dispatch) {
@@ -21,6 +21,12 @@ export declare const MACHINE_LIMITS: Readonly<Record<string, {
21
21
  chamber: number;
22
22
  volume: readonly number[];
23
23
  }>>;
24
+ /** Printers with two physical nozzles. Bambu Studio numbers the right/main nozzle 0
25
+ * (MAIN_EXTRUDER_ID) and the left/deputy nozzle 1 (DEPUTY_EXTRUDER_ID), and reports one
26
+ * virtual external-spool slot per nozzle: 255 (VIRTUAL_TRAY_MAIN_ID) and 254
27
+ * (VIRTUAL_TRAY_DEPUTY_ID). The single-nozzle H2S is deliberately absent. */
28
+ export declare const DUAL_NOZZLE_MODELS: ReadonlySet<string>;
29
+ export declare function isDualNozzleModel(model: unknown): boolean;
24
30
  export declare function normalizeModel(value: unknown): string | undefined;
25
31
  export declare function normalizeMaterial(value: unknown): string | undefined;
26
32
  export declare function validateTemperature(component: 'nozzle' | 'bed' | 'chamber', value: unknown, model: string, materials?: string[]): number;
@@ -30,6 +30,15 @@ export const MACHINE_LIMITS = {
30
30
  h2s: { nozzle: 350, bed: 120, chamber: 65, volume: [340, 320, 340] },
31
31
  x2d: { nozzle: 300, bed: 120, chamber: 65, volume: [256, 256, 260] },
32
32
  };
33
+ /** Printers with two physical nozzles. Bambu Studio numbers the right/main nozzle 0
34
+ * (MAIN_EXTRUDER_ID) and the left/deputy nozzle 1 (DEPUTY_EXTRUDER_ID), and reports one
35
+ * virtual external-spool slot per nozzle: 255 (VIRTUAL_TRAY_MAIN_ID) and 254
36
+ * (VIRTUAL_TRAY_DEPUTY_ID). The single-nozzle H2S is deliberately absent. */
37
+ export const DUAL_NOZZLE_MODELS = new Set(['h2d', 'h2dpro', 'h2c', 'x2d']);
38
+ export function isDualNozzleModel(model) {
39
+ const normalized = normalizeModel(model);
40
+ return normalized !== undefined && DUAL_NOZZLE_MODELS.has(normalized);
41
+ }
33
42
  export function normalizeModel(value) {
34
43
  if (typeof value !== 'string')
35
44
  return undefined;
@@ -12,6 +12,8 @@ export interface PrintFileInspection {
12
12
  selectsAms: boolean;
13
13
  nozzleTypes?: string[];
14
14
  nozzleFlows?: string[];
15
+ /** Physical nozzle index for each declared filament, composed from the selected plate headers. */
16
+ filamentNozzleIndices?: number[];
15
17
  }
16
18
  /** Inspect the exact selected plate. This does not simulate firmware or arbitrary motion:
17
19
  * only declared object bounds are checked, never purge/homing/wipe travel coordinates.
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readBoundedPrintFile, loadSafe3mfArchive } from './archive.js';
3
- import { MACHINE_LIMITS, normalizeMaterial, normalizeModel, validateStartupPurgeTemperature, validateTemperature } from './limits.js';
3
+ import { MACHINE_LIMITS, isDualNozzleModel, normalizeMaterial, normalizeModel, validateStartupPurgeTemperature, validateTemperature } from './limits.js';
4
4
  const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
5
5
  const fail = (message) => { throw new Error(`Print safety: ${message}`); };
6
6
  function list(value) {
@@ -168,6 +168,7 @@ export async function inspectPrintFile(filePath, options) {
168
168
  const model = normalizeModel(options.model);
169
169
  if (!model)
170
170
  return fail(`unknown or missing printer model '${options.model}'`);
171
+ const dualNozzle = isDualNozzleModel(model);
171
172
  const plateIndex = options.plateIndex ?? 0;
172
173
  if (!Number.isInteger(plateIndex) || plateIndex < 0)
173
174
  return fail('selected plate index must be a nonnegative integer');
@@ -308,6 +309,24 @@ export async function inspectPrintFile(filePath, options) {
308
309
  const values = list(value);
309
310
  return values.length && values.every(v => !!normalizeMaterial(v)) ? values.map(v => normalizeMaterial(v)) : undefined;
310
311
  }, 'filament material');
312
+ // GCode.cpp uses 1-based filament_map logical extruders and then indexes
313
+ // physical_extruder_map to select the physical nozzle. Only this selected
314
+ // plate's headers establish that binding; unsliced project defaults do not.
315
+ const headerMap = (key, count, low, high) => {
316
+ const values = metadataValues(headers, [key]);
317
+ if (!values.length)
318
+ return undefined;
319
+ return consistent(values, value => {
320
+ const entries = list(value);
321
+ return entries.length === count && entries.every(v => NUMBER.test(v) && Number.isInteger(Number(v)) && Number(v) >= low && Number(v) <= high)
322
+ ? entries.map(Number) : undefined;
323
+ }, key.replace(/_/g, ' '));
324
+ };
325
+ const physicalExtruderMap = headerMap('physical_extruder_map', nozzleDiameters.length, 0, dualNozzle ? Math.min(nozzleDiameters.length, 2) - 1 : nozzleDiameters.length - 1);
326
+ if (physicalExtruderMap && new Set(physicalExtruderMap).size !== physicalExtruderMap.length)
327
+ fail('physical extruder map must be a complete permutation');
328
+ const filamentMap = headerMap('filament_map', materials.length, 1, nozzleDiameters.length);
329
+ const filamentNozzleIndices = filamentMap && physicalExtruderMap ? filamentMap.map(logical => physicalExtruderMap[logical - 1]) : undefined;
311
330
  const bedValues = metadataValues([...metadata, plate], ['curr_bed_type', 'bed_type']);
312
331
  const bedType = bedValues.length ? consistent(bedValues, normalizeBed, 'bed type') : undefined;
313
332
  if (options.bedType && (!bedType || normalizeBed(options.bedType) !== bedType))
@@ -370,7 +389,11 @@ export async function inspectPrintFile(filePath, options) {
370
389
  let line = stripComments(lines[index]);
371
390
  if (!line || line === '%')
372
391
  continue;
373
- // The official H2C template emits this literal separator without a semicolon.
392
+ // Accept only the exact vendor banners in their documented model/scope.
393
+ // X2D emits its filament-change banner throughout a print; H2C's record
394
+ // separator stays inside its explicitly bounded startup block.
395
+ if (line === '======== X2D filament_change gcode ==========' && model === 'x2d')
396
+ continue;
374
397
  if (line === '========== record data ==========' && h2cStartup(index))
375
398
  continue;
376
399
  line = line.replace(/^N\d+\s*/i, '').replace(/\*\d+\s*$/, '');
@@ -477,9 +500,16 @@ export async function inspectPrintFile(filePath, options) {
477
500
  continue;
478
501
  }
479
502
  const vendorWait = code === 'M109' && ['a1', 'a1mini'].includes(model);
503
+ // Bambu Studio addresses a physical nozzle on dual-nozzle machines with N<index>
504
+ // (X2D `M104 T1 S25 N0` multi-extruder pre-cooling and wipe-tower reheats).
505
+ // N selects a nozzle, never a target, so it is accepted only on those models,
506
+ // only for nozzle heaters, and only within the declared nozzle count.
507
+ const nozzleIndex = ['M104', 'M109'].includes(code) && dualNozzle;
480
508
  for (const key of args.keys())
481
- if (!'SRTA'.includes(key) && !(key === 'H' && vendorWait))
509
+ if (!'SRTA'.includes(key) && !(key === 'H' && vendorWait) && !(key === 'N' && nozzleIndex))
482
510
  fail(`unsupported ${code} temperature parameter ${key}`);
511
+ if (args.has('N') && (!Number.isInteger(args.get('N')) || args.get('N') < 0 || args.get('N') >= Math.min(nozzleDiameters.length, 2)))
512
+ fail(`unsupported ${code} nozzle index N`);
483
513
  if (args.has('H') && (!Number.isFinite(args.get('H')) || args.get('H') < 0 || args.get('H') > 300))
484
514
  fail(`unsupported ${code} wait parameter H`);
485
515
  if (!args.has('S') && !args.has('R'))
@@ -489,13 +519,13 @@ export async function inspectPrintFile(filePath, options) {
489
519
  // Without a verified per-filament nozzle map, apply all possible materials.
490
520
  if (args.has('T') && (!Number.isInteger(args.get('T')) || args.get('T') < 0 || args.get('T') >= nozzleDiameters.length))
491
521
  fail('physical heater target has no declared nozzle');
492
- const position = args.has('T') ? undefined : active;
522
+ const position = args.has('T') || args.has('N') ? undefined : active;
493
523
  const commonFlush = code === 'M109' && args.size === 1 && args.get('S') === 290 && model === 'x1e' && !depositionStarted && !commonFlushUsed && boundedX1ePurge(lines, index);
494
524
  if (commonFlush)
495
525
  commonFlushUsed = true;
496
526
  for (const key of ['S', 'R'])
497
527
  if (args.has(key))
498
- heat(component, args.get(key), position, args.has('T') || args.has('A'), commonFlush);
528
+ heat(component, args.get(key), position, args.has('T') || args.has('N') || args.has('A'), commonFlush);
499
529
  }
500
530
  else if (code === 'M620' || code === 'M621') {
501
531
  const args = parameters(argumentsText, model === 'x2d' ? 'MAB' : model === 'h2c' ? 'MAN' : 'MA');
@@ -656,5 +686,5 @@ export async function inspectPrintFile(filePath, options) {
656
686
  return fail('selected G-code contains no printable commands');
657
687
  if (!used.size)
658
688
  materials.forEach((_, index) => used.add(index));
659
- return { model, nozzleDiameters, materials, usedFilamentPositions: [...used].sort((a, b) => a - b), plateInternalPath, sha256, maxNozzleTemperature, maxBedTemperature, maxChamberTemperature, bedType, selectsAms, nozzleTypes, nozzleFlows };
689
+ return { model, nozzleDiameters, materials, usedFilamentPositions: [...used].sort((a, b) => a - b), plateInternalPath, sha256, maxNozzleTemperature, maxBedTemperature, maxChamberTemperature, bedType, selectsAms, nozzleTypes, nozzleFlows, filamentNozzleIndices };
660
690
  }
@@ -7,6 +7,8 @@ export interface PrinterStateRequirements {
7
7
  nozzleDiameters: number[];
8
8
  materials?: string[];
9
9
  usedFilamentPositions?: number[];
10
+ /** Verified physical nozzle index for each project filament, composed from the inspected plate mappings. */
11
+ filamentNozzleIndices?: number[];
10
12
  amsMapping?: number[];
11
13
  useAMS?: boolean;
12
14
  requireIdle?: boolean;
@@ -1,6 +1,6 @@
1
1
  import { HIDDEN_HMS } from "./hidden-hms.js";
2
2
  import { GetVersionCommand, PushAllCommand } from "bambu-node";
3
- import { normalizeMaterial, normalizeModel } from "./limits.js";
3
+ import { isDualNozzleModel, normalizeMaterial, normalizeModel } from "./limits.js";
4
4
  const MAX_REPORT_AGE_MS = 15000;
5
5
  const MODEL_IDS = {
6
6
  O1C: "H2C", O1C2: "H2C", O1D: "H2D", O1E: "H2D Pro", O1S: "H2S", N6: "X2D",
@@ -282,15 +282,24 @@ function trayRange(value, label) {
282
282
  throw new Error(`Reported filament ${label} temperature is malformed.`);
283
283
  return number;
284
284
  }
285
- function externalTray(raw) {
285
+ /** Dual-nozzle printers report one virtual external slot per nozzle, numbered as Bambu
286
+ * Studio does (VIRTUAL_TRAY_MAIN_ID 255 = right/main nozzle, VIRTUAL_TRAY_DEPUTY_ID 254 =
287
+ * left/deputy nozzle), so the requested tray selects its slot. Single-nozzle printers keep
288
+ * requiring exactly one slot 254 report, then the legacy vt_tray field. */
289
+ function externalTray(raw, trayIndex, model) {
286
290
  const slots = raw.vir_slot;
291
+ const dualNozzle = isDualNozzleModel(model);
287
292
  if (Array.isArray(slots)) {
288
- const selected = slots.filter((slot) => Number(slot?.id) === 254);
289
- if (slots.length === 1 && selected.length === 1)
293
+ const selected = slots.filter((slot) => Number(slot?.id) === trayIndex);
294
+ if (selected.length === 1 && (dualNozzle || slots.length === 1))
290
295
  return selected[0];
296
+ if (dualNozzle && selected.length === 0 && slots.length > 0)
297
+ throw new Error(`External spool ${trayIndex} is not in the fresh per-nozzle report; cannot verify the selected material.`);
291
298
  if (slots.length > 0)
292
299
  throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
293
300
  }
301
+ if (dualNozzle)
302
+ throw new Error(`Dual-nozzle external spool ${trayIndex} requires a fresh per-nozzle vir_slot report; legacy vt_tray cannot verify the selected nozzle.`);
294
303
  return object(raw.vt_tray) ? raw.vt_tray : undefined;
295
304
  }
296
305
  function mappedTray(raw, index) {
@@ -345,7 +354,7 @@ export function validatePrinterState(status, requirements) {
345
354
  }
346
355
  }
347
356
  const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
348
- if (requirements.nozzleDiameters.length && ["h2d", "h2dpro", "h2c", "x2d"].includes(model) &&
357
+ if (requirements.nozzleDiameters.length && isDualNozzleModel(model) &&
349
358
  !Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
350
359
  throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
351
360
  }
@@ -374,16 +383,33 @@ export function validatePrinterState(status, requirements) {
374
383
  if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
375
384
  const materials = requirements.materials ?? [];
376
385
  const positions = requirements.usedFilamentPositions ?? materials.map((_, index) => index);
386
+ // 255 is the right/main nozzle external spool only on dual-nozzle printers.
387
+ const dualNozzle = isDualNozzleModel(model);
388
+ const maxTray = dualNozzle ? 255 : 254;
377
389
  if (!positions.length)
378
390
  throw new Error("A declared material is required for each used filament or external spool.");
379
391
  for (const position of positions) {
380
392
  const material = normalizeMaterial(materials[position]);
381
393
  if (!Number.isInteger(position) || position < 0 || !material)
382
394
  throw new Error(`A known declared material is required for filament position ${position}.`);
383
- const trayIndex = requirements.useAMS ? requirements.amsMapping?.[position] : 254;
384
- if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > 254)
395
+ const physicalNozzle = requirements.filamentNozzleIndices?.[position];
396
+ const externalForNozzle = physicalNozzle === 0 ? 255 : physicalNozzle === 1 ? 254 : undefined;
397
+ const trayIndex = requirements.useAMS || requirements.amsMapping !== undefined
398
+ ? requirements.amsMapping?.[position] : dualNozzle ? externalForNozzle : 254;
399
+ if (!requirements.useAMS && dualNozzle && requirements.amsMapping === undefined && externalForNozzle === undefined) {
400
+ 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.`);
401
+ }
402
+ if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > maxTray)
385
403
  throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
386
- const tray = trayIndex === 254 ? externalTray(raw) : mappedTray(raw, trayIndex);
404
+ if (!requirements.useAMS && trayIndex < 254)
405
+ throw new Error("External-spool printing requires an external spool mapping; AMS trays require use_ams:true.");
406
+ if (dualNozzle && trayIndex >= 254) {
407
+ if (externalForNozzle === undefined)
408
+ 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.`);
409
+ if (trayIndex !== externalForNozzle)
410
+ throw new Error(`External spool ${trayIndex} does not feed physical nozzle ${physicalNozzle} for filament position ${position}; use spool ${externalForNozzle}.`);
411
+ }
412
+ const tray = trayIndex >= 254 ? externalTray(raw, trayIndex, model) : mappedTray(raw, trayIndex);
387
413
  const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
388
414
  if (reportedType && normalizeMaterial(reportedType) !== material)
389
415
  throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
@@ -404,7 +430,7 @@ export function manualHeatingRequirements(status, model, nozzleDiameter, materia
404
430
  const raw = status?.raw;
405
431
  if (!object(raw))
406
432
  throw new Error("A fresh printer report is required for manual nozzle heating.");
407
- if (["h2d", "h2dpro", "h2c", "x2d"].includes(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
433
+ if (isDualNozzleModel(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
408
434
  throw new Error("Manual heating cannot verify the active nozzle and loaded material on this multi-nozzle printer. Use a checked sliced print instead.");
409
435
  }
410
436
  const requirements = {
@@ -0,0 +1,12 @@
1
+ import type { BambuSliceOptions } from '../stl/stl-manipulator.js';
2
+ export declare function validateRemoteSliceOptions(options?: BambuSliceOptions): number;
3
+ /** Upload only locally prepared profiles, then inspect the exact downloaded 3MF. */
4
+ export declare function sliceWithOrcaApi(input: {
5
+ inputPath: string;
6
+ machinePath: string;
7
+ processPath: string;
8
+ filamentPaths: string[];
9
+ outputPath: string;
10
+ options?: BambuSliceOptions;
11
+ isCancelled: () => boolean;
12
+ }): Promise<string>;
@@ -0,0 +1,240 @@
1
+ /** AFKFelix/orca-slicer-api contract at a9a28f0a7b0c6f51b9be158509e9e9677532b868. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { inspectPrintFile } from '../safety/print-file.js';
6
+ import { normalizeMaterial, normalizeModel } from '../safety/limits.js';
7
+ const MAX_BYTES = 100000000;
8
+ const SUPPORTED_OPTIONS = new Set([
9
+ 'arrange', 'orient', 'loadFilaments', 'filamentColours', 'bedType',
10
+ 'nozzleType', 'nozzleVolumeType', 'slicePlate',
11
+ ]);
12
+ function configuration() {
13
+ let url;
14
+ try {
15
+ url = new URL(process.env.SLICER_API_URL ?? '');
16
+ }
17
+ catch {
18
+ throw new Error('Remote slicing requires a valid trusted SLICER_API_URL.');
19
+ }
20
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
21
+ throw new Error('SLICER_API_URL must use HTTP(S) without embedded credentials, query or fragment.');
22
+ }
23
+ const mode = process.env.SLICER_API_MODE || 'sync';
24
+ if (!['sync', 'async'].includes(mode))
25
+ throw new Error('SLICER_API_MODE must be sync or async.');
26
+ const timeout = Number(process.env.SLICER_API_TIMEOUT_MS ?? 600000);
27
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3600000) {
28
+ throw new Error('SLICER_API_TIMEOUT_MS must be an integer from 1 to 3600000.');
29
+ }
30
+ const headers = {};
31
+ const token = process.env.SLICER_API_TOKEN;
32
+ if (token) {
33
+ const header = process.env.SLICER_API_TOKEN_HEADER || 'Authorization';
34
+ if (!/^[a-zA-Z0-9!#$%&'*+.^_`|~-]+$/.test(header) ||
35
+ ['host', 'content-type', 'content-length', 'connection', 'transfer-encoding'].includes(header.toLowerCase()) ||
36
+ /[^\x20-\x7e]/.test(token)) {
37
+ throw new Error('Invalid remote slicing authentication configuration.');
38
+ }
39
+ headers[header] = header.toLowerCase() === 'authorization' ? `Bearer ${token}` : token;
40
+ }
41
+ return { base: url.href.replace(/\/+$/, ''), headers, mode, timeout };
42
+ }
43
+ export function validateRemoteSliceOptions(options = {}) {
44
+ for (const [key, value] of Object.entries(options)) {
45
+ if (value !== undefined && !SUPPORTED_OPTIONS.has(key)) {
46
+ throw new Error(`Remote slicing option ${key} is unsupported by orca-slicer-api; use a local backend for this workflow.`);
47
+ }
48
+ }
49
+ // Upstream evaluates multipart values by truthiness, so the string "false"
50
+ // becomes true. Never silently turn an explicit false into an enabled flag.
51
+ if (options.arrange === false || options.orient === false) {
52
+ throw new Error('Remote arrange=false and orient=false are unsupported by the upstream multipart API.');
53
+ }
54
+ const plate = options.slicePlate ?? 1;
55
+ if (!Number.isInteger(plate) || plate < 1)
56
+ throw new Error('Remote slicing requires one positive plate number; slicing all plates is unsupported.');
57
+ return plate;
58
+ }
59
+ /** The remote API forwards this display name to Orca's --curr-bed-type. */
60
+ function remoteBedType(value) {
61
+ const names = {
62
+ textured_plate: 'Textured PEI Plate', textured_pei_plate: 'Textured PEI Plate',
63
+ cool_plate: 'Cool Plate', engineering_plate: 'Engineering Plate',
64
+ hot_plate: 'High Temp Plate', high_temp_plate: 'High Temp Plate',
65
+ };
66
+ const key = typeof value === 'string' ? value.trim().toLowerCase().replace(/[\s-]+/g, '_') : '';
67
+ if (typeof names[key] !== 'string')
68
+ throw new Error('Unsupported remote bed type; use textured_plate, cool_plate, engineering_plate, or hot_plate.');
69
+ return names[key];
70
+ }
71
+ async function readResponse(response, limit) {
72
+ const size = Number(response.headers.get('content-length'));
73
+ if (size > limit) {
74
+ await response.body?.cancel();
75
+ throw new Error('Remote slicing response exceeds the size limit.');
76
+ }
77
+ if (!response.body)
78
+ throw new Error('Remote slicing returned an empty response.');
79
+ const reader = response.body.getReader();
80
+ const chunks = [];
81
+ let length = 0;
82
+ try {
83
+ for (;;) {
84
+ const { value, done } = await reader.read();
85
+ if (done)
86
+ break;
87
+ length += value.byteLength;
88
+ if (length > limit)
89
+ throw new Error('Remote slicing response exceeds the size limit.');
90
+ chunks.push(value);
91
+ }
92
+ }
93
+ finally {
94
+ await reader.cancel();
95
+ }
96
+ return Buffer.concat(chunks);
97
+ }
98
+ async function readJob(response) {
99
+ try {
100
+ const job = JSON.parse((await readResponse(response, 65536)).toString('utf8'));
101
+ if (job && typeof job === 'object' && !Array.isArray(job))
102
+ return job;
103
+ }
104
+ catch { /* Do not echo an untrusted response, which may contain credentials. */ }
105
+ throw new Error('Remote slicing returned invalid job metadata.');
106
+ }
107
+ /** Upload only locally prepared profiles, then inspect the exact downloaded 3MF. */
108
+ export async function sliceWithOrcaApi(input) {
109
+ const options = input.options ?? {};
110
+ const plate = validateRemoteSliceOptions(options);
111
+ if (input.filamentPaths.length !== 1 || (options.filamentColours && options.filamentColours.length !== 1)) {
112
+ throw new Error('orca-slicer-api supports exactly one filament slot; multi-slot projects require a local slicer.');
113
+ }
114
+ const config = configuration();
115
+ const machine = JSON.parse(await fs.readFile(input.machinePath, 'utf8'));
116
+ const filament = JSON.parse(await fs.readFile(input.filamentPaths[0], 'utf8'));
117
+ const processProfile = JSON.parse(await fs.readFile(input.processPath, 'utf8'));
118
+ const bedType = remoteBedType(options.bedType ?? processProfile.curr_bed_type ?? 'textured_plate');
119
+ const model = normalizeModel(machine.printer_model);
120
+ const nozzles = Array.isArray(machine.nozzle_diameter) ? machine.nozzle_diameter.map(Number) : [];
121
+ const materialValues = Array.isArray(filament.filament_type) ? filament.filament_type : [];
122
+ const materials = materialValues.map(normalizeMaterial);
123
+ if (!model || !nozzles.length || nozzles.some((n) => ![0.2, 0.4, 0.6, 0.8].includes(n)) ||
124
+ materials.length !== 1 || !materials[0]) {
125
+ throw new Error('Remote slicing requires explicit model, nozzle and single-material metadata in the prepared profiles.');
126
+ }
127
+ const controller = new AbortController();
128
+ let timedOut = false;
129
+ const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, config.timeout);
130
+ const cancelCheck = setInterval(() => { if (input.isCancelled())
131
+ controller.abort(); }, 100);
132
+ let requestId;
133
+ let terminalJob = false;
134
+ let wroteOutput = false;
135
+ const request = async (route, init = {}) => {
136
+ if (input.isCancelled())
137
+ controller.abort();
138
+ // Do not pass multipart data to fetch after cancellation: undici may start
139
+ // its body producer before checking the signal and close it mid-enqueue.
140
+ controller.signal.throwIfAborted();
141
+ let response;
142
+ try {
143
+ response = await fetch(config.base + route, { ...init, headers: config.headers, signal: controller.signal, redirect: 'manual' });
144
+ }
145
+ catch {
146
+ throw new Error('Remote slicing HTTP request failed; check the trusted service configuration.');
147
+ }
148
+ if (!response.ok) {
149
+ await response.body?.cancel();
150
+ throw new Error(`Remote slicing HTTP ${response.status}.`);
151
+ }
152
+ return response;
153
+ };
154
+ try {
155
+ const ext = path.extname(input.inputPath).toLowerCase();
156
+ if (!['.stl', '.3mf'].includes(ext))
157
+ throw new Error('Remote slicing supports only STL or 3MF input.');
158
+ const bytes = await fs.readFile(input.inputPath);
159
+ if (bytes.length > MAX_BYTES)
160
+ throw new Error('Remote slicing input exceeds the 100 MB upstream limit.');
161
+ const form = new FormData();
162
+ form.set('file', new Blob([new Uint8Array(bytes)], { type: ext === '.3mf' ? 'model/3mf' : 'model/stl' }), `model${ext}`);
163
+ for (const [field, filePath, type] of [
164
+ ['printerProfile', input.machinePath, 'machine'],
165
+ ['presetProfile', input.processPath, 'process'],
166
+ ['filamentProfile', input.filamentPaths[0], 'filament'],
167
+ ]) {
168
+ const profile = JSON.parse(await fs.readFile(filePath, 'utf8'));
169
+ // The remote validator requires these metadata fields even for standalone
170
+ // user profiles. They do not change the resolved machine/process settings.
171
+ const data = { ...profile, type, from: profile.from || 'User' };
172
+ form.set(field, new Blob([JSON.stringify(data)], { type: 'application/json' }), `${field}.json`);
173
+ }
174
+ form.set('exportType', '3mf');
175
+ form.set('plate', String(plate));
176
+ form.set('bedType', bedType);
177
+ form.set('resolveProfileInheritance', 'false');
178
+ if (options.arrange === true)
179
+ form.set('arrange', 'true');
180
+ if (options.orient === true)
181
+ form.set('orient', 'true');
182
+ let response = await request(config.mode === 'async' ? '/slice-async' : '/slice', { method: 'POST', body: form });
183
+ if (config.mode === 'async') {
184
+ const submitted = await readJob(response);
185
+ if (typeof submitted.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(submitted.requestId)) {
186
+ throw new Error('Remote slicing returned an invalid request ID.');
187
+ }
188
+ requestId = submitted.requestId;
189
+ for (;;) {
190
+ // Construct the documented same-service paths. Returned URLs cannot
191
+ // redirect authenticated requests to an arbitrary origin or route.
192
+ const job = await readJob(await request(`/slice-async/${requestId}`));
193
+ if (job.status === 'completed' || job.status === 'failed') {
194
+ terminalJob = true;
195
+ if (job.status === 'failed')
196
+ throw new Error('Remote slicing job failed; inspect the service logs.');
197
+ break;
198
+ }
199
+ if (job.status !== 'pending' && job.status !== 'processing')
200
+ throw new Error('Remote slicing returned an unknown job state.');
201
+ await delay(500, undefined, { signal: controller.signal });
202
+ }
203
+ response = await request(`/slice-async/${requestId}/result`);
204
+ }
205
+ const result = await readResponse(response, MAX_BYTES);
206
+ await fs.writeFile(input.outputPath, result, { flag: 'wx', mode: 0o600 });
207
+ wroteOutput = true;
208
+ const inspection = await inspectPrintFile(input.outputPath, {
209
+ model, nozzleDiameters: nozzles, plateIndex: plate - 1, bedType,
210
+ });
211
+ if (JSON.stringify(inspection.materials) !== JSON.stringify(materials)) {
212
+ throw new Error('Remote sliced file materials contradict the prepared filament profile.');
213
+ }
214
+ if (input.isCancelled())
215
+ throw new Error('Remote slicing cancelled.');
216
+ return input.outputPath;
217
+ }
218
+ catch (error) {
219
+ if (wroteOutput)
220
+ await fs.rm(input.outputPath, { force: true });
221
+ if (controller.signal.aborted) {
222
+ throw new Error(`Remote slicing ${timedOut ? 'timed out' : 'cancelled'}; the remote job may continue. No retry was sent.`);
223
+ }
224
+ throw error;
225
+ }
226
+ finally {
227
+ // DELETE only removes finished jobs; upstream has no active-job cancellation.
228
+ // Cleanup never retries submission or masks a valid result / original error.
229
+ if (requestId && terminalJob && !controller.signal.aborted) {
230
+ try {
231
+ await (await request(`/slice-async/${requestId}`, { method: 'DELETE' })).body?.cancel();
232
+ }
233
+ catch {
234
+ console.error('Remote slicing finished-job cleanup failed; service retention applies.');
235
+ }
236
+ }
237
+ clearTimeout(deadline);
238
+ clearInterval(cancelCheck);
239
+ }
240
+ }
@@ -22,7 +22,7 @@ export type TransformationParams = {
22
22
  relative?: boolean;
23
23
  selectionBounds?: THREE.Box3;
24
24
  };
25
- export declare const SLICER_TYPES: readonly ["bambustudio", "orcaslicer", "orcaslicer-bambulab", "prusaslicer", "cura", "slic3r"];
25
+ export declare const SLICER_TYPES: readonly ["bambustudio", "orcaslicer", "orcaslicer-bambulab", "orca-slicer-api", "prusaslicer", "cura", "slic3r"];
26
26
  export type SlicerType = typeof SLICER_TYPES[number];
27
27
  export declare function normalizeSlicerType(rawSlicerType: string): SlicerType;
28
28
  export declare function isBambuCompatibleSlicer(slicerType: SlicerType): boolean;