bambu-printer-mcp 1.1.28 → 1.1.29

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.
package/src/index.ts CHANGED
@@ -40,12 +40,13 @@ import {
40
40
  } from "./bambu-native.js";
41
41
  import { importFileViaBambuConnect } from "./bambu-connect.js";
42
42
  import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
43
- import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
43
+ import { bridgeAmsMapping2Entry, bridgeAmsMappingValue, externalTrayForNozzle, hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
44
44
  import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
45
45
  import type { ThreeMFAmsRequirements } from "./types.js";
46
46
  import { BambuImplementation, assertDirectPrintSupported } from "./printers/bambu.js";
47
47
  import { inspectPrintFile } from "./safety/print-file.js";
48
48
  import { validatePrinterState } from "./safety/printer-state.js";
49
+ import { isDualNozzleModel } from "./safety/limits.js";
49
50
  import { withPrinterOperation, withPrintSnapshot, uniquePrintName } from "./safety/artifact.js";
50
51
  import { detectProfilesRoot } from "./slicer/profile-flatten.js";
51
52
 
@@ -703,7 +704,7 @@ function resolveSlicerConfig(args: Record<string, unknown> | undefined): {
703
704
  slicerProfile: string;
704
705
  } {
705
706
  const slicerType = normalizeSlicerType(String(args?.slicer_type || DEFAULT_SLICER_TYPE));
706
- const slicerPath = String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
707
+ const slicerPath = slicerType === "orca-slicer-api" ? "" : String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
707
708
  const slicerProfile = String(args?.slicer_profile || DEFAULT_SLICER_PROFILE);
708
709
  return { slicerType, slicerPath, slicerProfile };
709
710
  }
@@ -994,14 +995,9 @@ function stringifyBridgeJson(value: unknown): string | undefined {
994
995
  return JSON.stringify(value);
995
996
  }
996
997
 
997
- function nativeAmsMapping2(mapping: number[] | undefined): string | undefined {
998
+ function nativeAmsMapping2(mapping: number[] | undefined, dualNozzle = false): string | undefined {
998
999
  if (!mapping || mapping.length === 0) return undefined;
999
- return JSON.stringify(mapping.map((value) => {
1000
- if (value < 0 || value === 255) return { ams_id: 255, slot_id: 255 };
1001
- if (value === 254) return { ams_id: 254, slot_id: 254 };
1002
- if (value >= 128) return { ams_id: 128, slot_id: value - 128 };
1003
- return { ams_id: Math.floor(value / 4), slot_id: value % 4 };
1004
- }));
1000
+ return JSON.stringify(mapping.map((value) => bridgeAmsMapping2Entry(value, dualNozzle)));
1005
1001
  }
1006
1002
 
1007
1003
  function redactPrintParams(params: Record<string, unknown>): Record<string, unknown> {
@@ -1357,6 +1353,9 @@ class BambuPrinterMCPServer {
1357
1353
  ): ReturnType<typeof resolveSlicerConfig> {
1358
1354
  const config = resolveSlicerConfig(args);
1359
1355
  if (args?.slicer_path !== undefined) {
1356
+ if (config.slicerType === "orca-slicer-api") {
1357
+ throw new Error(`${toolName}: remote slicing uses trusted SLICER_API_URL and BAMBU_PROFILES_ROOT configuration; slicer_path is unsupported.`);
1358
+ }
1360
1359
  const resolvedSlicerPath = this.resolveExecutableSelectorArg(
1361
1360
  args.slicer_path,
1362
1361
  toolName,
@@ -1397,10 +1396,10 @@ class BambuPrinterMCPServer {
1397
1396
 
1398
1397
  console.error(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
1399
1398
  const autoSliceOptions: BambuSliceOptions = {
1400
- uptodate: true,
1401
- ensureOnBed: true,
1402
- minSave: true,
1403
- skipModifiedGcodes: true,
1399
+ ...(slicerType === "orca-slicer-api" ? {} : {
1400
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
1401
+ }),
1402
+ ...(slicerType === "orca-slicer-api" ? { slicePlate: Number(args.plate_index ?? 0) + 1 } : {}),
1404
1403
  bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
1405
1404
  nozzleType: resolveNozzleType(args?.nozzle_type),
1406
1405
  nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
@@ -1426,9 +1425,10 @@ class BambuPrinterMCPServer {
1426
1425
  ): Promise<{ useAMS: boolean; finalAmsMapping?: number[]; finalAmsSlots?: number[] }> {
1427
1426
  const parsed3MFData = await parse3MF(threeMFPath);
1428
1427
  let parsedAmsMapping: number[] | undefined;
1428
+ const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
1429
1429
 
1430
1430
  if (parsed3MFData.slicerConfig?.ams_mapping) {
1431
- const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>);
1431
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
1432
1432
  if (slots.length > 0) {
1433
1433
  parsedAmsMapping = slots;
1434
1434
  }
@@ -1444,10 +1444,10 @@ class BambuPrinterMCPServer {
1444
1444
  let userMappingOverride: number[] | undefined;
1445
1445
  if (Array.isArray(args.ams_mapping)) {
1446
1446
  userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
1447
- normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`)
1447
+ normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions)
1448
1448
  );
1449
1449
  } else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
1450
- userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>);
1450
+ userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
1451
1451
  }
1452
1452
  if (userMappingOverride && userMappingOverride.length > 0) {
1453
1453
  finalAmsMapping = userMappingOverride;
@@ -1458,7 +1458,7 @@ class BambuPrinterMCPServer {
1458
1458
 
1459
1459
  if (!hasUserAmsMapping && hasUserAmsSlots) {
1460
1460
  const userSlots = args.ams_slots.map((slot: unknown, i: number) =>
1461
- normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]`)
1461
+ normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]`, trayOptions)
1462
1462
  );
1463
1463
  finalAmsSlots = userSlots;
1464
1464
  finalAmsMapping = undefined;
@@ -1487,8 +1487,8 @@ class BambuPrinterMCPServer {
1487
1487
  }
1488
1488
 
1489
1489
  if (args?.use_ams === false) {
1490
- finalAmsMapping = undefined;
1491
- finalAmsSlots = undefined;
1490
+ if (!hasUserAmsMapping) finalAmsMapping = undefined;
1491
+ if (!hasUserAmsSlots) finalAmsSlots = undefined;
1492
1492
  useAMS = false;
1493
1493
  }
1494
1494
  if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
@@ -1589,8 +1589,16 @@ class BambuPrinterMCPServer {
1589
1589
  inspection.usedFilamentPositions.forEach((position, index) => { mapping![position] = finalAmsSlots[index]; });
1590
1590
  }
1591
1591
  if (!useAMS) {
1592
- mapping = Array(inspection.materials.length).fill(-1);
1593
- inspection.usedFilamentPositions.forEach(position => { mapping![position] = 254; });
1592
+ mapping ??= Array(inspection.materials.length).fill(-1);
1593
+ const dualNozzle = isDualNozzleModel(printModel);
1594
+ for (const position of inspection.usedFilamentPositions) {
1595
+ if (mapping[position] === undefined || mapping[position] < 0) {
1596
+ mapping[position] = externalTrayForNozzle(inspection.filamentNozzleIndices?.[position], dualNozzle);
1597
+ }
1598
+ if (mapping[position] !== 254 && !(dualNozzle && mapping[position] === 255)) {
1599
+ throw new Error("use_ams:false requires an external-spool mapping for every used filament.");
1600
+ }
1601
+ }
1594
1602
  }
1595
1603
  const requirements = { ...inspection, amsMapping: mapping, useAMS };
1596
1604
  const initialStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
@@ -1599,7 +1607,7 @@ class BambuPrinterMCPServer {
1599
1607
  const confirmedStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
1600
1608
  validatePrinterState(confirmedStatus, requirements);
1601
1609
  this.bambu.assertBedClearance(confirmedStatus, bedClearance);
1602
- const amsMapping = stringifyBridgeJson(mapping);
1610
+ const amsMapping = stringifyBridgeJson(mapping?.map(value => bridgeAmsMappingValue(value, isDualNozzleModel(printModel))));
1603
1611
  const remoteFileName = uniquePrintName(threeMFPath);
1604
1612
  const params: Record<string, unknown> = {
1605
1613
  dev_id: devId,
@@ -1616,7 +1624,7 @@ class BambuPrinterMCPServer {
1616
1624
  ftp_file_md5: String(args?.ftp_file_md5 || ""),
1617
1625
  nozzle_mapping: stringifyBridgeJson(args?.nozzle_mapping) || "",
1618
1626
  ams_mapping: amsMapping || "",
1619
- ams_mapping2: stringifyBridgeJson(args?.ams_mapping2) || "",
1627
+ ams_mapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)) || "",
1620
1628
  ams_mapping_info: stringifyBridgeJson(args?.ams_mapping_info) || "",
1621
1629
  nozzles_info: stringifyBridgeJson(args?.nozzles_info) || "",
1622
1630
  connection_type: connectionType,
@@ -1657,7 +1665,7 @@ class BambuPrinterMCPServer {
1657
1665
  host: devIp, serial: devId, token: password || bambuToken, filePath: snapshot,
1658
1666
  projectName: String(params.task_name), presetName, remoteName: remoteFileName,
1659
1667
  plateIndex, bedType: printBedType, useAMS,
1660
- amsMapping, amsMapping2: nativeAmsMapping2(mapping),
1668
+ amsMapping, amsMapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)),
1661
1669
  bedLeveling: params.task_bed_leveling as boolean,
1662
1670
  flowCalibration: params.task_flow_cali as boolean,
1663
1671
  vibrationCalibration: params.task_vibration_cali as boolean,
@@ -1916,10 +1924,9 @@ class BambuPrinterMCPServer {
1916
1924
  );
1917
1925
  }
1918
1926
  const autoSliceOptions: BambuSliceOptions = {
1919
- uptodate: true,
1920
- ensureOnBed: true,
1921
- minSave: true,
1922
- skipModifiedGcodes: true,
1927
+ ...(slicerType === "orca-slicer-api" ? {} : {
1928
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
1929
+ }),
1923
1930
  bedType,
1924
1931
  };
1925
1932
 
@@ -2470,7 +2477,7 @@ class BambuPrinterMCPServer {
2470
2477
  rotate_y: { type: "number", description: "Rotate the model around the Y-axis by this many degrees before slicing. Useful for reorienting prints for better layer adhesion." },
2471
2478
  min_save: { type: "boolean", description: "Write a smaller output 3MF by omitting non-essential metadata. Reduces file size for faster FTP upload to the printer." },
2472
2479
  skip_modified_gcodes: { type: "boolean", description: "Strip custom start/end gcodes embedded in the 3MF. Recommended for downloaded 3MFs since custom gcodes from other users' profiles may be unsafe for your printer." },
2473
- slice_plate: { type: "number", description: "Which plate index to slice. 0 = all plates (default). Use 1, 2, etc. to slice only a specific plate in multi-plate 3MF projects." }
2480
+ slice_plate: { type: "number", description: "Which plate index to slice. 0 = all plates for local slicers; remote slicing defaults to plate 1 and rejects 0. Use 1, 2, etc. to slice only a specific plate in multi-plate 3MF projects." }
2474
2481
  },
2475
2482
  required: ["stl_path", "bambu_model"]
2476
2483
  }
@@ -2573,8 +2580,8 @@ class BambuPrinterMCPServer {
2573
2580
  nozzle_diameter: { type: "string", description: "Nozzle diameter in mm for auto-slicing (default: 0.4)." },
2574
2581
  nozzle_diameters: { type: "array", minItems: 1, maxItems: 2, items: { type: "number", enum: [0.2, 0.4, 0.6, 0.8] }, description: "Complete per-nozzle diameters for pre-sliced jobs, e.g. [0.4, 0.6]. Omit to verify file metadata against every reported nozzle; cannot combine with nozzle_diameter." },
2575
2582
  use_ams: { type: "boolean", description: "Whether to use the AMS; defaults to auto-detect from the 3MF mapping." },
2576
- ams_mapping: { type: "array", description: "AMS slot mapping array used by both local MCP printing and FULU PrintParams.", items: { type: "number" } },
2577
- ams_slots: { type: "array", description: "Per-used-filament AMS slot list, matching the local LAN print path.", items: { type: "number" } },
2583
+ ams_mapping: { type: "array", description: "AMS slot mapping array used by both local MCP printing and FULU PrintParams. Same tray values as print_3mf: 254 = external spool; on dual-nozzle printers 254 = left/deputy nozzle external and 255 = right/main nozzle external.", items: { type: "number" } },
2584
+ ams_slots: { type: "array", description: "Per-used-filament AMS slot list, matching the local LAN print path (254/255 external spool rules as in print_3mf).", items: { type: "number" } },
2578
2585
  ams_mapping_bridge: { type: "string", description: "Raw JSON string override for FULU PrintParams ams_mapping when the automatic array is not enough." },
2579
2586
  ams_mapping2: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping2, matching OrcaSlicer-bambulab's v1 AMS mapping field." },
2580
2587
  ams_mapping_info: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping_info, matching OrcaSlicer-bambulab's detailed AMS mapping field." },
@@ -3002,12 +3009,12 @@ class BambuPrinterMCPServer {
3002
3009
  use_ams: { type: "boolean", description: "Whether to use the AMS (default: auto-detect from 3MF)" },
3003
3010
  ams_mapping: {
3004
3011
  type: "array",
3005
- description: "Project-level AMS mapping array. Position = project filament index, value = absolute AMS tray (0-3=AMS 0, 4-7=AMS 1, 8-11=AMS 2, 128+=AMS-HT, 254=external, -1=unused). Prefer ams_slots unless you know the project-level layout.",
3012
+ description: "Project-level AMS mapping array. Position = project filament index, value = absolute AMS tray (0-3=AMS 0, 4-7=AMS 1, 8-11=AMS 2, 128+=AMS-HT, 254=external, -1=unused). On dual-nozzle printers (H2D, H2D Pro, H2C, X2D) 254 is the LEFT/deputy nozzle external spool and 255 the RIGHT/main nozzle external spool, matching Bambu Studio's virtual tray ids; the gcode header's filament_map (1=left, 2=right) says which nozzle each filament was sliced for. Prefer ams_slots unless you know the project-level layout.",
3006
3013
  items: { type: "number" }
3007
3014
  },
3008
3015
  ams_slots: {
3009
3016
  type: "array",
3010
- description: "Preferred AMS input: one absolute tray index per USED filament in plate order, e.g. [1] for a single-filament print pulling from AMS 0 slot 1. Expanded to project-level ams_mapping automatically from the 3MF's plate_N.json and gcode header.",
3017
+ description: "Preferred AMS input: one absolute tray index per USED filament in plate order, e.g. [1] for a single-filament print pulling from AMS 0 slot 1. Use 254 for the external spool (on dual-nozzle printers: 254 = left/deputy nozzle external, 255 = right/main nozzle external). Expanded to project-level ams_mapping automatically from the 3MF's plate_N.json and gcode header.",
3011
3018
  items: { type: "number" }
3012
3019
  },
3013
3020
  auto_match_ams: {
@@ -4098,10 +4105,9 @@ class BambuPrinterMCPServer {
4098
4105
  }
4099
4106
  console.error(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
4100
4107
  const autoSliceOptions: BambuSliceOptions = {
4101
- uptodate: true,
4102
- ensureOnBed: true,
4103
- minSave: true,
4104
- skipModifiedGcodes: true,
4108
+ ...(slicerType === "orca-slicer-api" ? { slicePlate: plateIndex + 1 } : {
4109
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
4110
+ }),
4105
4111
  bedType: printBedType,
4106
4112
  nozzleType: resolveNozzleType(args?.nozzle_type),
4107
4113
  nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
@@ -4133,9 +4139,10 @@ class BambuPrinterMCPServer {
4133
4139
 
4134
4140
  const parsed3MFData = await parse3MF(threeMFPath);
4135
4141
  const isH2Print = H2_BAMBU_MODELS.has(printModel);
4142
+ const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
4136
4143
  let parsedAmsMapping: number[] | undefined;
4137
4144
  if (!isH2Print && parsed3MFData.slicerConfig?.ams_mapping) {
4138
- const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>);
4145
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
4139
4146
  if (slots.length > 0) {
4140
4147
  parsedAmsMapping = slots;
4141
4148
  }
@@ -4151,10 +4158,10 @@ class BambuPrinterMCPServer {
4151
4158
  let userMappingOverride: number[] | undefined;
4152
4159
  if (Array.isArray(args.ams_mapping)) {
4153
4160
  userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
4154
- normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`)
4161
+ normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions)
4155
4162
  );
4156
4163
  } else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
4157
- userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>);
4164
+ userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
4158
4165
  }
4159
4166
  if (userMappingOverride && userMappingOverride.length > 0) {
4160
4167
  finalAmsMapping = userMappingOverride;
@@ -4208,8 +4215,8 @@ class BambuPrinterMCPServer {
4208
4215
  }
4209
4216
 
4210
4217
  if (args?.use_ams === false) {
4211
- finalAmsMapping = undefined;
4212
- finalAmsSlots = undefined;
4218
+ if (!hasUserAmsMapping) finalAmsMapping = undefined;
4219
+ if (!hasUserAmsSlots) finalAmsSlots = undefined;
4213
4220
  useAMS = false;
4214
4221
  }
4215
4222
  if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
@@ -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";
@@ -747,15 +748,20 @@ export class BambuImplementation {
747
748
  // `plate_N.json.filament_ids` -- and expand to a full project-level
748
749
  // array here. `amsMapping` is the raw escape hatch (takes precedence
749
750
  // when both are supplied).
751
+ // Dual-nozzle printers address two external spools: 254 feeds the left/deputy
752
+ // nozzle and 255 the right/main nozzle (Bambu Studio VIRTUAL_TRAY_DEPUTY_ID /
753
+ // VIRTUAL_TRAY_MAIN_ID). Single-nozzle printers keep 254 as the only external tray.
754
+ const dualNozzle = isDualNozzleModel(model);
755
+ const externalTrays = dualNozzle ? [254, 255] : [254];
750
756
  const validateTrayValue = (v: unknown, label: string): void => {
751
757
  if (
752
758
  !Number.isInteger(v) ||
753
759
  (v as number) < -1 ||
754
760
  ((v as number) > 15 && (v as number) < 128) ||
755
- (v as number) > 254
761
+ (v as number) > (dualNozzle ? 255 : 254)
756
762
  ) {
757
763
  throw new Error(
758
- `${label} values must be integers in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${v}`
764
+ `${label} values must be integers in ${describeAmsTrayRange({ dualNozzle })}; got ${v}`
759
765
  );
760
766
  }
761
767
  };
@@ -794,7 +800,7 @@ export class BambuImplementation {
794
800
  );
795
801
  baseMapping = Array<number>(projectLen).fill(-1);
796
802
  positions.forEach((pos, i) => {
797
- baseMapping[pos] = options.useAMS === false ? 254 : i;
803
+ baseMapping[pos] = options.useAMS === false ? externalTrayForNozzle(inspection.filamentNozzleIndices?.[pos], dualNozzle) : i;
798
804
  });
799
805
  }
800
806
 
@@ -805,12 +811,7 @@ export class BambuImplementation {
805
811
  amsMapping = Array.from({ length: projLen }, (_, i) =>
806
812
  i < baseMapping.length ? baseMapping[i] : -1
807
813
  );
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
- });
814
+ amsMapping2 = amsMapping.map((v) => bridgeAmsMapping2Entry(v, dualNozzle));
814
815
  } else {
815
816
  amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) =>
816
817
  i < baseMapping.length ? baseMapping[i] : -1
@@ -822,8 +823,8 @@ export class BambuImplementation {
822
823
  if (baseMapping[position] === undefined || baseMapping[position] < 0) {
823
824
  throw new Error(`Missing physical filament mapping for project filament ${position}.`);
824
825
  }
825
- if (options.useAMS === false && baseMapping[position] !== 254) {
826
- throw new Error("External-spool printing requires external spool mapping (254).");
826
+ if (options.useAMS === false && !externalTrays.includes(baseMapping[position])) {
827
+ throw new Error(`External-spool printing requires external spool mapping (${externalTrays.join(" or ")}).`);
827
828
  }
828
829
  }
829
830
  const legacyContainer = options.filePath.toLowerCase().endsWith(".gcode.3mf") && !usesH2ProjectFile && !isA1;
@@ -898,7 +899,7 @@ export class BambuImplementation {
898
899
  project_id: submissionId,
899
900
  subtask_id: submissionId,
900
901
  task_id: submissionId,
901
- ams_mapping: amsMapping,
902
+ ams_mapping: amsMapping.map(value => bridgeAmsMappingValue(value, dualNozzle)),
902
903
  ams_mapping2: amsMapping2,
903
904
  },
904
905
  };
@@ -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 = {