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, type PrintFileInspection } 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, type PrinterStateRequirements } 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";
@@ -437,6 +438,12 @@ class BambuClientStore {
437
438
  }
438
439
  }
439
440
 
441
+ forgetPrinter(printer: BambuClient): void {
442
+ for (const [key, cached] of this.printers) {
443
+ if (cached === printer) this.clearPrinterState(key);
444
+ }
445
+ }
446
+
440
447
  async disconnectAll(): Promise<void> {
441
448
  const disconnectPromises: Promise<void>[] = [];
442
449
 
@@ -747,15 +754,20 @@ export class BambuImplementation {
747
754
  // `plate_N.json.filament_ids` -- and expand to a full project-level
748
755
  // array here. `amsMapping` is the raw escape hatch (takes precedence
749
756
  // when both are supplied).
757
+ // Dual-nozzle printers address two external spools: 254 feeds the left/deputy
758
+ // nozzle and 255 the right/main nozzle (Bambu Studio VIRTUAL_TRAY_DEPUTY_ID /
759
+ // VIRTUAL_TRAY_MAIN_ID). Single-nozzle printers keep 254 as the only external tray.
760
+ const dualNozzle = isDualNozzleModel(model);
761
+ const externalTrays = dualNozzle ? [254, 255] : [254];
750
762
  const validateTrayValue = (v: unknown, label: string): void => {
751
763
  if (
752
764
  !Number.isInteger(v) ||
753
765
  (v as number) < -1 ||
754
766
  ((v as number) > 15 && (v as number) < 128) ||
755
- (v as number) > 254
767
+ (v as number) > (dualNozzle ? 255 : 254)
756
768
  ) {
757
769
  throw new Error(
758
- `${label} values must be integers in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${v}`
770
+ `${label} values must be integers in ${describeAmsTrayRange({ dualNozzle })}; got ${v}`
759
771
  );
760
772
  }
761
773
  };
@@ -794,7 +806,7 @@ export class BambuImplementation {
794
806
  );
795
807
  baseMapping = Array<number>(projectLen).fill(-1);
796
808
  positions.forEach((pos, i) => {
797
- baseMapping[pos] = options.useAMS === false ? 254 : i;
809
+ baseMapping[pos] = options.useAMS === false ? externalTrayForNozzle(inspection.filamentNozzleIndices?.[pos], dualNozzle) : i;
798
810
  });
799
811
  }
800
812
 
@@ -805,12 +817,7 @@ export class BambuImplementation {
805
817
  amsMapping = Array.from({ length: projLen }, (_, i) =>
806
818
  i < baseMapping.length ? baseMapping[i] : -1
807
819
  );
808
- amsMapping2 = amsMapping.map((v) => {
809
- if (v < 0 || v === 255) return { ams_id: 255, slot_id: 255 };
810
- if (v === 254) return { ams_id: 254, slot_id: 254 };
811
- if (v >= 128) return { ams_id: 128, slot_id: v - 128 };
812
- return { ams_id: Math.floor(v / 4), slot_id: v % 4 };
813
- });
820
+ amsMapping2 = amsMapping.map((v) => bridgeAmsMapping2Entry(v, dualNozzle));
814
821
  } else {
815
822
  amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) =>
816
823
  i < baseMapping.length ? baseMapping[i] : -1
@@ -822,8 +829,8 @@ export class BambuImplementation {
822
829
  if (baseMapping[position] === undefined || baseMapping[position] < 0) {
823
830
  throw new Error(`Missing physical filament mapping for project filament ${position}.`);
824
831
  }
825
- if (options.useAMS === false && baseMapping[position] !== 254) {
826
- throw new Error("External-spool printing requires external spool mapping (254).");
832
+ if (options.useAMS === false && !externalTrays.includes(baseMapping[position])) {
833
+ throw new Error(`External-spool printing requires external spool mapping (${externalTrays.join(" or ")}).`);
827
834
  }
828
835
  }
829
836
  const legacyContainer = options.filePath.toLowerCase().endsWith(".gcode.3mf") && !usesH2ProjectFile && !isA1;
@@ -898,7 +905,7 @@ export class BambuImplementation {
898
905
  project_id: submissionId,
899
906
  subtask_id: submissionId,
900
907
  task_id: submissionId,
901
- ams_mapping: amsMapping,
908
+ ams_mapping: amsMapping.map(value => bridgeAmsMappingValue(value, dualNozzle)),
902
909
  ams_mapping2: amsMapping2,
903
910
  },
904
911
  };
@@ -963,18 +970,50 @@ export class BambuImplementation {
963
970
  }
964
971
  }
965
972
 
973
+ private async invokeEmergencyCommand(
974
+ printer: BambuClient,
975
+ command: { invoke(client: BambuClient): Promise<void> },
976
+ signal?: AbortSignal
977
+ ): Promise<void> {
978
+ if (!signal) return invokeWithoutAck(printer, command);
979
+ let onAbort: () => void;
980
+ try {
981
+ await new Promise<void>((resolve, reject) => {
982
+ onAbort = () => {
983
+ // Force-close the actual transport: a raced publish may be queued
984
+ // inside MQTT.js and otherwise dispatch after a later reconnect.
985
+ this.printerStore.forgetPrinter(printer);
986
+ void printer.disconnect(true).catch(() => {
987
+ console.error("Failed to close an expired emergency MQTT client.");
988
+ });
989
+ reject(signal.reason);
990
+ };
991
+ signal.addEventListener("abort", onAbort, { once: true });
992
+ if (signal.aborted) onAbort();
993
+ else command.invoke(printer).then(resolve, reject);
994
+ });
995
+ } finally {
996
+ signal.removeEventListener("abort", onAbort!);
997
+ }
998
+ await sleep(COMMAND_SETTLE_MS);
999
+ }
1000
+
966
1001
  /** Raw MQTT stop for routes that already ran the shared cancellation steps. */
967
- async sendStopOverMqtt(host: string, serial: string, token: string): Promise<any> {
1002
+ async sendStopOverMqtt(host: string, serial: string, token: string, signal?: AbortSignal): Promise<any> {
1003
+ signal?.throwIfAborted();
968
1004
  const printer = await this.getPrinter(host, serial, token);
969
- await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
1005
+ signal?.throwIfAborted();
1006
+ await this.invokeEmergencyCommand(printer, new UpdateStateCommand({ state: "stop" }), signal);
970
1007
  return { status: "success", message: "Cancel command sent over MQTT." };
971
1008
  }
972
1009
 
973
1010
  /** Raw MQTT heater-off; switching off needs no material or model declaration. */
974
- async sendHeaterOffOverMqtt(host: string, serial: string, token: string, heater: "bed" | "nozzle"): Promise<any> {
1011
+ async sendHeaterOffOverMqtt(host: string, serial: string, token: string, heater: "bed" | "nozzle", signal?: AbortSignal): Promise<any> {
1012
+ signal?.throwIfAborted();
975
1013
  const printer = await this.getPrinter(host, serial, token);
1014
+ signal?.throwIfAborted();
976
1015
  const gcode = `${heater === "bed" ? "M140" : "M104"} S0`;
977
- await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
1016
+ await this.invokeEmergencyCommand(printer, new GCodeLineCommand({ gcodes: [gcode] }), signal);
978
1017
  return { status: "success", message: `Heater-off sent over MQTT for ${heater}.`, command: gcode };
979
1018
  }
980
1019
 
@@ -31,6 +31,15 @@ export const MACHINE_LIMITS: Readonly<Record<string, {nozzle: number; bed: numbe
31
31
  x2d: {nozzle:300,bed:120,chamber:65,volume:[256,256,260]},
32
32
  };
33
33
 
34
+ /** Printers with two physical nozzles. Bambu Studio numbers the right/main nozzle 0
35
+ * (MAIN_EXTRUDER_ID) and the left/deputy nozzle 1 (DEPUTY_EXTRUDER_ID), and reports one
36
+ * virtual external-spool slot per nozzle: 255 (VIRTUAL_TRAY_MAIN_ID) and 254
37
+ * (VIRTUAL_TRAY_DEPUTY_ID). The single-nozzle H2S is deliberately absent. */
38
+ export const DUAL_NOZZLE_MODELS: ReadonlySet<string> = new Set(['h2d','h2dpro','h2c','x2d']);
39
+ export function isDualNozzleModel(model: unknown): boolean {
40
+ const normalized = normalizeModel(model);
41
+ return normalized !== undefined && DUAL_NOZZLE_MODELS.has(normalized);
42
+ }
34
43
  export function normalizeModel(value: unknown): string | undefined {
35
44
  if (typeof value !== 'string') return undefined;
36
45
  let model = value.trim().toLowerCase().replace(/^bambu\s*(?:lab\s*)?/, '').replace(/[\s_-]/g, '');
@@ -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
 
5
5
  export interface PrintFileInspection {
6
6
  model: string;
@@ -16,6 +16,8 @@ export interface PrintFileInspection {
16
16
  selectsAms: boolean;
17
17
  nozzleTypes?: string[];
18
18
  nozzleFlows?: string[];
19
+ /** Physical nozzle index for each declared filament, composed from the selected plate headers. */
20
+ filamentNozzleIndices?: number[];
19
21
  }
20
22
  type Metadata = Record<string, unknown>;
21
23
  const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
@@ -140,6 +142,7 @@ function validateBounds(headers: Metadata[], model: string, plate: Metadata): vo
140
142
  export async function inspectPrintFile(filePath: string, options: {model:string; nozzleDiameters?:number[]; plateIndex?:number; bedType?:string}): Promise<PrintFileInspection> {
141
143
  const model=normalizeModel(options.model);
142
144
  if(!model) return fail(`unknown or missing printer model '${options.model}'`);
145
+ const dualNozzle=isDualNozzleModel(model);
143
146
  const plateIndex=options.plateIndex ?? 0;
144
147
  if(!Number.isInteger(plateIndex)||plateIndex<0) return fail('selected plate index must be a nonnegative integer');
145
148
  if(/\.md5$/i.test(filePath)) return fail('a checksum file is not a printable artifact');
@@ -237,6 +240,22 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
237
240
  const materials=consistent(metadataValues(metadata,['filament_type']),value=>{
238
241
  const values=list(value);return values.length && values.every(v=>!!normalizeMaterial(v))?values.map(v=>normalizeMaterial(v)!):undefined;
239
242
  },'filament material');
243
+ // GCode.cpp uses 1-based filament_map logical extruders and then indexes
244
+ // physical_extruder_map to select the physical nozzle. Only this selected
245
+ // plate's headers establish that binding; unsliced project defaults do not.
246
+ const headerMap=(key:string,count:number,low:number,high:number):number[]|undefined=>{
247
+ const values=metadataValues(headers,[key]);
248
+ if(!values.length) return undefined;
249
+ return consistent(values,value=>{
250
+ const entries=list(value);
251
+ return entries.length===count && entries.every(v=>NUMBER.test(v) && Number.isInteger(Number(v)) && Number(v)>=low && Number(v)<=high)
252
+ ? entries.map(Number):undefined;
253
+ },key.replace(/_/g,' '));
254
+ };
255
+ const physicalExtruderMap=headerMap('physical_extruder_map',nozzleDiameters.length,0,dualNozzle?Math.min(nozzleDiameters.length,2)-1:nozzleDiameters.length-1);
256
+ if(physicalExtruderMap && new Set(physicalExtruderMap).size!==physicalExtruderMap.length) fail('physical extruder map must be a complete permutation');
257
+ const filamentMap=headerMap('filament_map',materials.length,1,nozzleDiameters.length);
258
+ const filamentNozzleIndices=filamentMap && physicalExtruderMap ? filamentMap.map(logical=>physicalExtruderMap[logical-1]):undefined;
240
259
  const bedValues=metadataValues([...metadata,plate],['curr_bed_type','bed_type']);
241
260
  const bedType=bedValues.length?consistent(bedValues,normalizeBed,'bed type'):undefined;
242
261
  if(options.bedType && (!bedType || normalizeBed(options.bedType)!==bedType)) return fail('selected bed type cannot be verified or contradicts the sliced file; re-slice with the intended bed');
@@ -278,7 +297,10 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
278
297
  try {
279
298
  if(isLayerMarker(lines[index]) || /^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(lines[index])) closeStartupWindow();
280
299
  let line=stripComments(lines[index]);if(!line||line==='%') continue;
281
- // The official H2C template emits this literal separator without a semicolon.
300
+ // Accept only the exact vendor banners in their documented model/scope.
301
+ // X2D emits its filament-change banner throughout a print; H2C's record
302
+ // separator stays inside its explicitly bounded startup block.
303
+ if(line==='======== X2D filament_change gcode ==========' && model==='x2d') continue;
282
304
  if(line==='========== record data ==========' && h2cStartup(index)) continue;
283
305
  line=line.replace(/^N\d+\s*/i,'').replace(/\*\d+\s*$/,'');
284
306
  if(/[{}\[\]#]/.test(line)) fail('unresolved dynamic command syntax');
@@ -355,17 +377,23 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
355
377
  heat('bed',args.get('D')!,undefined,true);continue;
356
378
  }
357
379
  const vendorWait=code==='M109' && ['a1','a1mini'].includes(model);
358
- for(const key of args.keys()) if(!'SRTA'.includes(key) && !(key==='H' && vendorWait)) fail(`unsupported ${code} temperature parameter ${key}`);
380
+ // Bambu Studio addresses a physical nozzle on dual-nozzle machines with N<index>
381
+ // (X2D `M104 T1 S25 N0` multi-extruder pre-cooling and wipe-tower reheats).
382
+ // N selects a nozzle, never a target, so it is accepted only on those models,
383
+ // only for nozzle heaters, and only within the declared nozzle count.
384
+ const nozzleIndex=['M104','M109'].includes(code) && dualNozzle;
385
+ for(const key of args.keys()) if(!'SRTA'.includes(key) && !(key==='H' && vendorWait) && !(key==='N' && nozzleIndex)) fail(`unsupported ${code} temperature parameter ${key}`);
386
+ if(args.has('N') && (!Number.isInteger(args.get('N')) || args.get('N')!<0 || args.get('N')!>=Math.min(nozzleDiameters.length,2))) fail(`unsupported ${code} nozzle index N`);
359
387
  if(args.has('H') && (!Number.isFinite(args.get('H')) || args.get('H')!<0 || args.get('H')!>300)) fail(`unsupported ${code} wait parameter H`);
360
388
  if(!args.has('S')&&!args.has('R')) fail(`missing ${code} temperature target`);
361
389
  const component= ['M104','M109'].includes(code)?'nozzle': ['M140','M190'].includes(code)?'bed':'chamber';
362
390
  // Heater T is a physical nozzle, unlike standalone remapped T filament selection.
363
391
  // Without a verified per-filament nozzle map, apply all possible materials.
364
392
  if(args.has('T') && (!Number.isInteger(args.get('T')) || args.get('T')!<0 || args.get('T')!>=nozzleDiameters.length)) fail('physical heater target has no declared nozzle');
365
- const position= args.has('T') ? undefined : active;
393
+ const position= args.has('T') || args.has('N') ? undefined : active;
366
394
  const commonFlush=code==='M109' && args.size===1 && args.get('S')===290 && model==='x1e' && !depositionStarted && !commonFlushUsed && boundedX1ePurge(lines,index);
367
395
  if(commonFlush) commonFlushUsed=true;
368
- for(const key of ['S','R']) if(args.has(key)) heat(component,args.get(key)!,position,args.has('T') || args.has('A'),commonFlush);
396
+ for(const key of ['S','R']) if(args.has(key)) heat(component,args.get(key)!,position,args.has('T') || args.has('N') || args.has('A'),commonFlush);
369
397
  } else if(code==='M620' || code==='M621') {
370
398
  const args=parameters(argumentsText,model==='x2d'?'MAB':model==='h2c'?'MAN':'MA');
371
399
  if(args.has('N') && (code!=='M620' || !h2cStartup(index) || args.size!==1 || !Number.isNaN(args.get('N')!))) fail('unsupported H2C startup hotend-remap flag');
@@ -464,5 +492,5 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
464
492
  }
465
493
  if(!commandCount) return fail('selected G-code contains no printable commands');
466
494
  if(!used.size) materials.forEach((_,index)=>used.add(index));
467
- return {model,nozzleDiameters,materials,usedFilamentPositions:[...used].sort((a,b)=>a-b),plateInternalPath,sha256,maxNozzleTemperature,maxBedTemperature,maxChamberTemperature,bedType,selectsAms,nozzleTypes,nozzleFlows};
495
+ return {model,nozzleDiameters,materials,usedFilamentPositions:[...used].sort((a,b)=>a-b),plateInternalPath,sha256,maxNozzleTemperature,maxBedTemperature,maxChamberTemperature,bedType,selectsAms,nozzleTypes,nozzleFlows,filamentNozzleIndices};
468
496
  }
@@ -1,6 +1,6 @@
1
1
  import { HIDDEN_HMS } from "./hidden-hms.js";
2
2
  import { BambuClient, GetVersionCommand, PushAllCommand } from "bambu-node";
3
- import { normalizeMaterial, normalizeModel } from "./limits.js";
3
+ import { isDualNozzleModel, normalizeMaterial, normalizeModel } from "./limits.js";
4
4
 
5
5
  const MAX_REPORT_AGE_MS = 15_000;
6
6
  const MODEL_IDS: Record<string, string> = {
@@ -103,6 +103,8 @@ export interface PrinterStateRequirements {
103
103
  nozzleDiameters: number[];
104
104
  materials?: string[];
105
105
  usedFilamentPositions?: number[];
106
+ /** Verified physical nozzle index for each project filament, composed from the inspected plate mappings. */
107
+ filamentNozzleIndices?: number[];
106
108
  amsMapping?: number[];
107
109
  useAMS?: boolean;
108
110
  requireIdle?: boolean;
@@ -280,13 +282,20 @@ function trayRange(value: unknown, label: string): number | undefined {
280
282
  if (number === undefined || number <= 0) throw new Error(`Reported filament ${label} temperature is malformed.`);
281
283
  return number;
282
284
  }
283
- function externalTray(raw: Record<string,any>): Record<string,any> | undefined {
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: Record<string,any>, trayIndex: number, model: string): Record<string,any> | undefined {
284
290
  const slots = raw.vir_slot;
291
+ const dualNozzle = isDualNozzleModel(model);
285
292
  if (Array.isArray(slots)) {
286
- const selected = slots.filter((slot:any) => Number(slot?.id) === 254);
287
- if (slots.length === 1 && selected.length === 1) return selected[0];
293
+ const selected = slots.filter((slot:any) => Number(slot?.id) === trayIndex);
294
+ if (selected.length === 1 && (dualNozzle || slots.length === 1)) return selected[0];
295
+ if (dualNozzle && selected.length === 0 && slots.length > 0) throw new Error(`External spool ${trayIndex} is not in the fresh per-nozzle report; cannot verify the selected material.`);
288
296
  if (slots.length > 0) throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
289
297
  }
298
+ if (dualNozzle) throw new Error(`Dual-nozzle external spool ${trayIndex} requires a fresh per-nozzle vir_slot report; legacy vt_tray cannot verify the selected nozzle.`);
290
299
  return object(raw.vt_tray) ? raw.vt_tray : undefined;
291
300
  }
292
301
  function mappedTray(raw: Record<string,any>, index: number): Record<string,any> {
@@ -332,7 +341,7 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
332
341
  }
333
342
  }
334
343
  const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
335
- if (requirements.nozzleDiameters.length && ["h2d","h2dpro","h2c","x2d"].includes(model) &&
344
+ if (requirements.nozzleDiameters.length && isDualNozzleModel(model) &&
336
345
  !Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
337
346
  throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
338
347
  }
@@ -360,13 +369,27 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
360
369
  if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
361
370
  const materials = requirements.materials ?? [];
362
371
  const positions = requirements.usedFilamentPositions ?? materials.map((_, index) => index);
372
+ // 255 is the right/main nozzle external spool only on dual-nozzle printers.
373
+ const dualNozzle = isDualNozzleModel(model);
374
+ const maxTray = dualNozzle ? 255 : 254;
363
375
  if (!positions.length) throw new Error("A declared material is required for each used filament or external spool.");
364
376
  for (const position of positions) {
365
377
  const material = normalizeMaterial(materials[position]);
366
378
  if (!Number.isInteger(position) || position < 0 || !material) throw new Error(`A known declared material is required for filament position ${position}.`);
367
- const trayIndex = requirements.useAMS ? requirements.amsMapping?.[position] : 254;
368
- if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > 254) throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
369
- const tray = trayIndex === 254 ? externalTray(raw) : mappedTray(raw,trayIndex);
379
+ const physicalNozzle = requirements.filamentNozzleIndices?.[position];
380
+ const externalForNozzle = physicalNozzle === 0 ? 255 : physicalNozzle === 1 ? 254 : undefined;
381
+ const trayIndex = requirements.useAMS || requirements.amsMapping !== undefined
382
+ ? requirements.amsMapping?.[position] : dualNozzle ? externalForNozzle : 254;
383
+ if (!requirements.useAMS && dualNozzle && requirements.amsMapping === undefined && externalForNozzle === undefined) {
384
+ 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.`);
385
+ }
386
+ if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > maxTray) throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
387
+ if (!requirements.useAMS && trayIndex < 254) throw new Error("External-spool printing requires an external spool mapping; AMS trays require use_ams:true.");
388
+ if (dualNozzle && trayIndex >= 254) {
389
+ if (externalForNozzle === undefined) 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.`);
390
+ if (trayIndex !== externalForNozzle) throw new Error(`External spool ${trayIndex} does not feed physical nozzle ${physicalNozzle} for filament position ${position}; use spool ${externalForNozzle}.`);
391
+ }
392
+ const tray = trayIndex >= 254 ? externalTray(raw,trayIndex,model) : mappedTray(raw,trayIndex);
370
393
  const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
371
394
  if (reportedType && normalizeMaterial(reportedType) !== material) throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
372
395
  const nozzleMin = trayRange(tray?.nozzle_temp_min,"minimum");
@@ -384,7 +407,7 @@ export function manualHeatingRequirements(status: any, model: string, nozzleDiam
384
407
  if (!normalized) throw new Error("A known printer model is required for manual nozzle heating.");
385
408
  const raw = status?.raw;
386
409
  if (!object(raw)) throw new Error("A fresh printer report is required for manual nozzle heating.");
387
- if (["h2d","h2dpro","h2c","x2d"].includes(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
410
+ if (isDualNozzleModel(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
388
411
  throw new Error("Manual heating cannot verify the active nozzle and loaded material on this multi-nozzle printer. Use a checked sliced print instead.");
389
412
  }
390
413
  const requirements: PrinterStateRequirements = {
@@ -0,0 +1,216 @@
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
+ import type { BambuSliceOptions } from '../stl/stl-manipulator.js';
8
+
9
+ const MAX_BYTES = 100_000_000;
10
+ const SUPPORTED_OPTIONS = new Set([
11
+ 'arrange', 'orient', 'loadFilaments', 'filamentColours', 'bedType',
12
+ 'nozzleType', 'nozzleVolumeType', 'slicePlate',
13
+ ]);
14
+
15
+ function configuration(): { base: string; headers: Record<string, string>; mode: string; timeout: number } {
16
+ let url: URL;
17
+ try { url = new URL(process.env.SLICER_API_URL ?? ''); }
18
+ catch { throw new Error('Remote slicing requires a valid trusted SLICER_API_URL.'); }
19
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
20
+ throw new Error('SLICER_API_URL must use HTTP(S) without embedded credentials, query or fragment.');
21
+ }
22
+ const mode = process.env.SLICER_API_MODE || 'sync';
23
+ if (!['sync', 'async'].includes(mode)) throw new Error('SLICER_API_MODE must be sync or async.');
24
+ const timeout = Number(process.env.SLICER_API_TIMEOUT_MS ?? 600000);
25
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3600000) {
26
+ throw new Error('SLICER_API_TIMEOUT_MS must be an integer from 1 to 3600000.');
27
+ }
28
+ const headers: Record<string, string> = {};
29
+ const token = process.env.SLICER_API_TOKEN;
30
+ if (token) {
31
+ const header = process.env.SLICER_API_TOKEN_HEADER || 'Authorization';
32
+ if (!/^[a-zA-Z0-9!#$%&'*+.^_`|~-]+$/.test(header) ||
33
+ ['host', 'content-type', 'content-length', 'connection', 'transfer-encoding'].includes(header.toLowerCase()) ||
34
+ /[^\x20-\x7e]/.test(token)) {
35
+ throw new Error('Invalid remote slicing authentication configuration.');
36
+ }
37
+ headers[header] = header.toLowerCase() === 'authorization' ? `Bearer ${token}` : token;
38
+ }
39
+ return { base: url.href.replace(/\/+$/, ''), headers, mode, timeout };
40
+ }
41
+
42
+ export function validateRemoteSliceOptions(options: BambuSliceOptions = {}): number {
43
+ for (const [key, value] of Object.entries(options)) {
44
+ if (value !== undefined && !SUPPORTED_OPTIONS.has(key)) {
45
+ throw new Error(`Remote slicing option ${key} is unsupported by orca-slicer-api; use a local backend for this workflow.`);
46
+ }
47
+ }
48
+ // Upstream evaluates multipart values by truthiness, so the string "false"
49
+ // becomes true. Never silently turn an explicit false into an enabled flag.
50
+ if (options.arrange === false || options.orient === false) {
51
+ throw new Error('Remote arrange=false and orient=false are unsupported by the upstream multipart API.');
52
+ }
53
+ const plate = options.slicePlate ?? 1;
54
+ if (!Number.isInteger(plate) || plate < 1) throw new Error('Remote slicing requires one positive plate number; slicing all plates is unsupported.');
55
+ return plate;
56
+ }
57
+
58
+ /** The remote API forwards this display name to Orca's --curr-bed-type. */
59
+ function remoteBedType(value: unknown): string {
60
+ const names: Record<string, string> = {
61
+ textured_plate: 'Textured PEI Plate', textured_pei_plate: 'Textured PEI Plate',
62
+ cool_plate: 'Cool Plate', engineering_plate: 'Engineering Plate',
63
+ hot_plate: 'High Temp Plate', high_temp_plate: 'High Temp Plate',
64
+ };
65
+ const key = typeof value === 'string' ? value.trim().toLowerCase().replace(/[\s-]+/g, '_') : '';
66
+ if (typeof names[key] !== 'string') throw new Error('Unsupported remote bed type; use textured_plate, cool_plate, engineering_plate, or hot_plate.');
67
+ return names[key];
68
+ }
69
+
70
+ async function readResponse(response: Response, limit: number): Promise<Buffer> {
71
+ const size = Number(response.headers.get('content-length'));
72
+ if (size > limit) { await response.body?.cancel(); throw new Error('Remote slicing response exceeds the size limit.'); }
73
+ if (!response.body) throw new Error('Remote slicing returned an empty response.');
74
+ const reader = response.body.getReader();
75
+ const chunks: Uint8Array[] = [];
76
+ let length = 0;
77
+ try {
78
+ for (;;) {
79
+ const { value, done } = await reader.read();
80
+ if (done) break;
81
+ length += value.byteLength;
82
+ if (length > limit) throw new Error('Remote slicing response exceeds the size limit.');
83
+ chunks.push(value);
84
+ }
85
+ } finally { await reader.cancel(); }
86
+ return Buffer.concat(chunks);
87
+ }
88
+
89
+ async function readJob(response: Response): Promise<Record<string, unknown>> {
90
+ try {
91
+ const job: unknown = JSON.parse((await readResponse(response, 65536)).toString('utf8'));
92
+ if (job && typeof job === 'object' && !Array.isArray(job)) return job as Record<string, unknown>;
93
+ } catch { /* Do not echo an untrusted response, which may contain credentials. */ }
94
+ throw new Error('Remote slicing returned invalid job metadata.');
95
+ }
96
+
97
+ /** Upload only locally prepared profiles, then inspect the exact downloaded 3MF. */
98
+ export async function sliceWithOrcaApi(input: {
99
+ inputPath: string;
100
+ machinePath: string;
101
+ processPath: string;
102
+ filamentPaths: string[];
103
+ outputPath: string;
104
+ options?: BambuSliceOptions;
105
+ isCancelled: () => boolean;
106
+ }): Promise<string> {
107
+ const options = input.options ?? {};
108
+ const plate = validateRemoteSliceOptions(options);
109
+ if (input.filamentPaths.length !== 1 || (options.filamentColours && options.filamentColours.length !== 1)) {
110
+ throw new Error('orca-slicer-api supports exactly one filament slot; multi-slot projects require a local slicer.');
111
+ }
112
+ const config = configuration();
113
+ const machine = JSON.parse(await fs.readFile(input.machinePath, 'utf8'));
114
+ const filament = JSON.parse(await fs.readFile(input.filamentPaths[0], 'utf8'));
115
+ const processProfile = JSON.parse(await fs.readFile(input.processPath, 'utf8'));
116
+ const bedType = remoteBedType(options.bedType ?? processProfile.curr_bed_type ?? 'textured_plate');
117
+ const model = normalizeModel(machine.printer_model);
118
+ const nozzles = Array.isArray(machine.nozzle_diameter) ? machine.nozzle_diameter.map(Number) : [];
119
+ const materialValues: unknown[] = Array.isArray(filament.filament_type) ? filament.filament_type : [];
120
+ const materials = materialValues.map(normalizeMaterial);
121
+ if (!model || !nozzles.length || nozzles.some((n: number) => ![0.2, 0.4, 0.6, 0.8].includes(n)) ||
122
+ materials.length !== 1 || !materials[0]) {
123
+ throw new Error('Remote slicing requires explicit model, nozzle and single-material metadata in the prepared profiles.');
124
+ }
125
+ const controller = new AbortController();
126
+ let timedOut = false;
127
+ const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, config.timeout);
128
+ const cancelCheck = setInterval(() => { if (input.isCancelled()) controller.abort(); }, 100);
129
+ let requestId: string | undefined;
130
+ let terminalJob = false;
131
+ let wroteOutput = false;
132
+ const request = async (route: string, init: RequestInit = {}): Promise<Response> => {
133
+ if (input.isCancelled()) controller.abort();
134
+ // Do not pass multipart data to fetch after cancellation: undici may start
135
+ // its body producer before checking the signal and close it mid-enqueue.
136
+ controller.signal.throwIfAborted();
137
+ let response: Response;
138
+ try {
139
+ response = await fetch(config.base + route, { ...init, headers: config.headers, signal: controller.signal, redirect: 'manual' });
140
+ } catch { throw new Error('Remote slicing HTTP request failed; check the trusted service configuration.'); }
141
+ if (!response.ok) { await response.body?.cancel(); throw new Error(`Remote slicing HTTP ${response.status}.`); }
142
+ return response;
143
+ };
144
+ try {
145
+ const ext = path.extname(input.inputPath).toLowerCase();
146
+ if (!['.stl', '.3mf'].includes(ext)) throw new Error('Remote slicing supports only STL or 3MF input.');
147
+ const bytes = await fs.readFile(input.inputPath);
148
+ if (bytes.length > MAX_BYTES) throw new Error('Remote slicing input exceeds the 100 MB upstream limit.');
149
+ const form = new FormData();
150
+ form.set('file', new Blob([new Uint8Array(bytes)], { type: ext === '.3mf' ? 'model/3mf' : 'model/stl' }), `model${ext}`);
151
+ for (const [field, filePath, type] of [
152
+ ['printerProfile', input.machinePath, 'machine'],
153
+ ['presetProfile', input.processPath, 'process'],
154
+ ['filamentProfile', input.filamentPaths[0], 'filament'],
155
+ ]) {
156
+ const profile = JSON.parse(await fs.readFile(filePath, 'utf8'));
157
+ // The remote validator requires these metadata fields even for standalone
158
+ // user profiles. They do not change the resolved machine/process settings.
159
+ const data = { ...profile, type, from: profile.from || 'User' };
160
+ form.set(field, new Blob([JSON.stringify(data)], { type: 'application/json' }), `${field}.json`);
161
+ }
162
+ form.set('exportType', '3mf');
163
+ form.set('plate', String(plate));
164
+ form.set('bedType', bedType);
165
+ form.set('resolveProfileInheritance', 'false');
166
+ if (options.arrange === true) form.set('arrange', 'true');
167
+ if (options.orient === true) form.set('orient', 'true');
168
+ let response = await request(config.mode === 'async' ? '/slice-async' : '/slice', { method: 'POST', body: form });
169
+ if (config.mode === 'async') {
170
+ const submitted = await readJob(response);
171
+ if (typeof submitted.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(submitted.requestId)) {
172
+ throw new Error('Remote slicing returned an invalid request ID.');
173
+ }
174
+ requestId = submitted.requestId;
175
+ for (;;) {
176
+ // Construct the documented same-service paths. Returned URLs cannot
177
+ // redirect authenticated requests to an arbitrary origin or route.
178
+ const job = await readJob(await request(`/slice-async/${requestId}`));
179
+ if (job.status === 'completed' || job.status === 'failed') {
180
+ terminalJob = true;
181
+ if (job.status === 'failed') throw new Error('Remote slicing job failed; inspect the service logs.');
182
+ break;
183
+ }
184
+ if (job.status !== 'pending' && job.status !== 'processing') throw new Error('Remote slicing returned an unknown job state.');
185
+ await delay(500, undefined, { signal: controller.signal });
186
+ }
187
+ response = await request(`/slice-async/${requestId}/result`);
188
+ }
189
+ const result = await readResponse(response, MAX_BYTES);
190
+ await fs.writeFile(input.outputPath, result, { flag: 'wx', mode: 0o600 });
191
+ wroteOutput = true;
192
+ const inspection = await inspectPrintFile(input.outputPath, {
193
+ model, nozzleDiameters: nozzles, plateIndex: plate - 1, bedType,
194
+ });
195
+ if (JSON.stringify(inspection.materials) !== JSON.stringify(materials)) {
196
+ throw new Error('Remote sliced file materials contradict the prepared filament profile.');
197
+ }
198
+ if (input.isCancelled()) throw new Error('Remote slicing cancelled.');
199
+ return input.outputPath;
200
+ } catch (error) {
201
+ if (wroteOutput) await fs.rm(input.outputPath, { force: true });
202
+ if (controller.signal.aborted) {
203
+ throw new Error(`Remote slicing ${timedOut ? 'timed out' : 'cancelled'}; the remote job may continue. No retry was sent.`);
204
+ }
205
+ throw error;
206
+ } finally {
207
+ // DELETE only removes finished jobs; upstream has no active-job cancellation.
208
+ // Cleanup never retries submission or masks a valid result / original error.
209
+ if (requestId && terminalJob && !controller.signal.aborted) {
210
+ try { await (await request(`/slice-async/${requestId}`, { method: 'DELETE' })).body?.cancel(); }
211
+ catch { console.error('Remote slicing finished-job cleanup failed; service retention applies.'); }
212
+ }
213
+ clearTimeout(deadline);
214
+ clearInterval(cancelCheck);
215
+ }
216
+ }