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/CONTRIBUTORS.md CHANGED
@@ -11,7 +11,9 @@ Thank you to everyone who builds, tests, reports problems, and shares real print
11
11
  | [Patrick Juchli (@patrickjuchli)](https://github.com/patrickjuchli) | Maintains basic-ftp and supplies the 6.2.3 fixes for rejected-command recovery, asynchronous credential-error crashes, and password redaction used by direct FTPS transfers. |
12
12
  | [Aleksei Popovich (alexpapay)](https://github.com/alexpapay) | Fixes cross-platform AMS profile resolution and packed P2S chamber status in [#43](https://github.com/DMontgomery40/bambu-printer-mcp/pull/43), contributes regressions, and supplies read-only Windows P2S/AMS 2 Pro status and local slicing evidence. No physical print is claimed for this contribution. |
13
13
  | [Adam (@adamkaplan)](https://github.com/adamkaplan) | Identifies the reversed H2C/H2D Pro serial prefixes in [#47](https://github.com/DMontgomery40/bambu-printer-mcp/issues/47), verifies corrected H2C status locally, and isolates the official H2C startup commands and config-block marker failure in [#48](https://github.com/DMontgomery40/bambu-printer-mcp/issues/48). Provides exact Bambu Studio version/template hash and unsafe-file reproductions; maintainer validation is offline and mocked. |
14
- | [travismcashan](https://github.com/travismcashan) | Reports stock X2D material-switch and runout-purge rejection, misleading unchecked confirmation, and stdout diagnostics in [#44](https://github.com/DMontgomery40/bambu-printer-mcp/issues/44). Supplies exact preset syntax and firmware evidence, then reports a clean physical X2D cube print with a local workaround; maintainer validation remains isolated and mocked. Reports the accepted native job and subsequent helper teardown crash in [#46](https://github.com/DMontgomery40/bambu-printer-mcp/issues/46), with completed-print and macOS crash evidence informing result preservation and duplicate-print warnings. |
14
+ | [Pavel Bukhatkin (welderpb)](https://github.com/welderpb) | Requests remote API slicing for containerized deployments and specifies authentication, timeout and async needs in [#50](https://github.com/DMontgomery40/bambu-printer-mcp/issues/50). This informs the optional single-slot Orca API backend; validation uses HTTP fixtures and the genuine API application with a fake CLI, without a physical print. |
15
+ | [AFKFelix](https://github.com/AFKFelix) and [orca-slicer-api contributors](https://github.com/AFKFelix/orca-slicer-api) | Provide the upstream HTTP slicing application and published multipart/job contracts used by the optional remote backend. |
16
+ | [travismcashan](https://github.com/travismcashan) | Reports stock X2D material-switch and runout-purge rejection, misleading unchecked confirmation, and stdout diagnostics in [#44](https://github.com/DMontgomery40/bambu-printer-mcp/issues/44). Supplies exact preset syntax and firmware evidence, then reports a clean physical X2D cube print with a local workaround; maintainer validation remains isolated and mocked. Reports the accepted native job and subsequent helper teardown crash in [#46](https://github.com/DMontgomery40/bambu-printer-mcp/issues/46), with completed-print and macOS crash evidence informing result preservation and duplicate-print warnings. Contributes the X2D dual-nozzle slicer-output and dual-external-spool fix in [#59](https://github.com/DMontgomery40/bambu-printer-mcp/pull/59), with a real five-filament X2D job, live `vir_slot` evidence and the Bambu Studio virtual-tray conventions; the change is validated offline and mocked, and no physical print is claimed for it. |
15
17
  | [nitpreet22](https://github.com/nitpreet22) | Fixes rejection of the stock A1/A1 mini `M109 H` startup waits in [#41](https://github.com/DMontgomery40/bambu-printer-mcp/pull/41), contributes temperature and model regressions, and supplies Windows Bambu Studio profile and real sliced A1 job inspection evidence. Their physical print used Bambu Studio, not this MCP change. |
16
18
  | [Steavie (steavie)](https://github.com/steavie) | Reports the reversed A1/A1 mini serial prefixes in [#39](https://github.com/DMontgomery40/bambu-printer-mcp/issues/39), with A1 status and configuration evidence that identifies the status and fresh-identity regression. |
17
19
  | [JaviOFC](https://github.com/JaviOFC) | Reports the X2D identity rejection in [#36](https://github.com/DMontgomery40/bambu-printer-mcp/issues/36) and supplies current firmware, connection-mode, status-field, serial-prefix, and cloud-job evidence showing that `model_id` can contain a print-job identifier while `model` correctly identifies the printer. |
package/README.md CHANGED
@@ -210,7 +210,7 @@ The optional FULU **BambuNetwork bridge** exposes `bambu_network_bridge_status`,
210
210
  - Start G-code files already stored on the printer
211
211
  - **Collar charm print wrapper** (`print_collar_charm`) — specialized two-color workflow with fixed tray policy for inner (black, AMS 1 slot 1) and outer (white, AMS 2 slot 1) charm parts
212
212
  - STL manipulation: scale, rotate, extend base, merge vertices, center at origin, lay flat, and inspect model info
213
- - Slice STL or 3MF files using an external CLI. BambuStudio, FULU, and Orca require the exact machine preset and resolve BBL dependencies before slicing; see [FULU/Orca CLI setup and validation limits](https://github.com/DMontgomery40/bambu-printer-mcp/blob/main/docs/FULU.md#fulu-and-orca-cli-safety-limit).
213
+ - Slice STL or 3MF files using an external CLI, or use the optional [`orca-slicer-api` backend](https://github.com/DMontgomery40/bambu-printer-mcp/blob/main/docs/SLICING.md) in a separate container without a local slicer executable. Remote slicing requires a matching read-only Orca profile tree and currently supports one filament slot and one selected plate. BambuStudio, FULU, and Orca require the exact machine preset and resolve BBL dependencies before slicing; see [FULU/Orca CLI setup and validation limits](https://github.com/DMontgomery40/bambu-printer-mcp/blob/main/docs/FULU.md#fulu-and-orca-cli-safety-limit).
214
214
  - Inspect slicer settings from a saved 3MF template or extracted profile via `get_slice_settings`
215
215
  - Enumerate saved slicing templates from the local registry via `list_templates`
216
216
  - Save templates into the local registry via `save_template`
@@ -279,6 +279,8 @@ If you are using the direct-feed spool holder (no AMS attached) or want to bypas
279
279
 
280
280
  For H2 projects with declared filaments, also provide the required mapping; `use_ams: false` alone does not remove the firmware's mapping requirement. See [`print_3mf`](#print_3mf).
281
281
 
282
+ Dual-nozzle printers (H2D, H2D Pro, H2C, X2D) have two external spool holders, and the printer reports one virtual slot per nozzle. Use Bambu Studio's ids in `ams_slots`/`ams_mapping`: `254` is the left/deputy nozzle's external spool and `255` the right/main nozzle's. The server composes the selected plate's `filament_map` with `physical_extruder_map` to verify each filament's physical nozzle (`0` = right, `1` = left), and requires the matching fresh `vir_slot` report. An explicit external-spool mapping is preserved with `use_ams: false`; a missing mapping is derived only from these inspected headers. Single-nozzle printers keep `254` as their only external tray.
283
+
282
284
  ### Auto-match AMS by RFID
283
285
 
284
286
  For pre-sliced 3MFs that declare filament types, the `auto_match_ams` flag on `print_3mf` (or the standalone `resolve_3mf_ams_slots` dry-run tool) automatically resolves the required filaments against your live AMS inventory. The matcher works as follows:
@@ -948,7 +950,7 @@ Install Bambu Studio and its networking plug-in, plus the Apple command-line dev
948
950
 
949
951
  The npm package and desktop bundle include `native/bambu-native-print.cpp`, `scripts/build-bambu-native.sh` and the Linux setup script, never a developer's compiled binary. The server finds the built helper relative to its installed package, regardless of the launch directory. A trusted server setting `BAMBU_NATIVE_HELPER` can select an alternate executable.
950
952
 
951
- For X2D, `print_3mf` defaults to `connection_mode: "bambu_native"`; legacy `lan_mqtt_ftps` requests are redirected to it. Supply `ams_slots`, a complete project-level `ams_mapping`, or `auto_match_ams: true`. An external-spool job requires explicit `use_ams: false`. Raw `ams_mapping2` and nozzle/extra-option overrides are rejected before dispatch; the server derives both AMS representations from the checked structured mapping. Model/nozzle/material inspection, fresh printer-state checks, and human preflight apply before the helper receives a private snapshot. The native helper requests fresh shared printer-state authorization immediately before each print submission, including a certificate retry, and before checked heating, resume, or error-clearing commands; custom helpers must support this handshake. Stop and heater-off remain available without preflight. Native upload-only requests use the same all-plate inspection as FTPS uploads. They accept `project_name`, `preset_name`, `bed_type`, and an existing `plate_index`; AMS, nozzle, and calibration print options are rejected. Use `print_3mf` for checked print settings.
953
+ For X2D, `print_3mf` defaults to `connection_mode: "bambu_native"`; legacy `lan_mqtt_ftps` requests are redirected to it. Supply `ams_slots`, a complete project-level `ams_mapping`, or `auto_match_ams: true`. A job that uses only one external spool requires explicit `use_ams: false`; a job mixing AMS trays with the external spools maps them as `254` (left/deputy nozzle) and `255` (right/main nozzle). Raw `ams_mapping2` and nozzle/extra-option overrides are rejected before dispatch; the server derives both AMS representations from the checked structured mapping. Model/nozzle/material inspection, fresh printer-state checks, and human preflight apply before the helper receives a private snapshot. The native helper requests fresh shared printer-state authorization immediately before each print submission, including a certificate retry, and before checked heating, resume, or error-clearing commands; custom helpers must support this handshake. Stop and heater-off remain available without preflight. Native upload-only requests use the same all-plate inspection as FTPS uploads. They accept `project_name`, `preset_name`, `bed_type`, and an existing `plate_index`; AMS, nozzle, and calibration print options are rejected. Use `print_3mf` for checked print settings.
952
954
 
953
955
  The helper waits for connection/certificate exchange and retries only the initial `-4030` send once. Rebuild the helper after upgrading to receive the teardown fix. A successful dispatch receipt ends the helper before plug-in cleanup can turn an accepted job into an error. If an older/custom helper crashes after dispatch, the server reports uncertainty: check the printer state and job name before retrying, because resending can duplicate a print. Previous hardware testing reached `RUNNING` on X2D; this revision is validated with mocked transport regressions and clean installs, not a new physical print. `bambu_connect` is an optional macOS handoff for user review in Bambu Connect and does not start a print. Native task, heater, and error controls retain the common safety checks; raw controls cannot bypass them. Request cancellation, stop, and heater-off interrupt pending native helpers; the server waits for process exit before releasing a checked file. A command already sent to the printer cannot be recalled by cancelling the request, so verify printer state before retrying.
954
956
 
@@ -1,3 +1,19 @@
1
- export declare function normalizeBridgeAmsTrayValue(value: unknown, label: string): number;
2
- export declare function normalizeAmsMappingObject(mapping: Record<string, unknown>, label?: string): number[];
1
+ /** Tray-value options. `dualNozzle` admits 255, the right/main nozzle external spool that
2
+ * dual-nozzle printers report beside the left/deputy external spool 254 (Bambu Studio
3
+ * VIRTUAL_TRAY_MAIN_ID / VIRTUAL_TRAY_DEPUTY_ID). Single-nozzle printers keep 254 only. */
4
+ export interface AmsTrayValueOptions {
5
+ dualNozzle?: boolean;
6
+ }
7
+ export declare function describeAmsTrayRange(options?: AmsTrayValueOptions): string;
8
+ export declare function normalizeBridgeAmsTrayValue(value: unknown, label: string, options?: AmsTrayValueOptions): number;
9
+ export declare function normalizeAmsMappingObject(mapping: Record<string, unknown>, label?: string, options?: AmsTrayValueOptions): number[];
3
10
  export declare function hasAmsMappingInput(value: unknown): boolean;
11
+ /** Flat wire entries use -1 for dual-nozzle virtual trays; ams_mapping2 identifies their spool. */
12
+ export declare function bridgeAmsMappingValue(value: number, dualNozzle?: boolean): number;
13
+ /** Select an external spool only from a verified physical nozzle on dual-nozzle printers. */
14
+ export declare function externalTrayForNozzle(nozzle: number | undefined, dualNozzle?: boolean): number;
15
+ /** Virtual trays use slot zero on dual-nozzle printers; retain the historical single-nozzle form. */
16
+ export declare function bridgeAmsMapping2Entry(value: number, dualNozzle?: boolean): {
17
+ ams_id: number;
18
+ slot_id: number;
19
+ };
@@ -1,4 +1,9 @@
1
- export function normalizeBridgeAmsTrayValue(value, label) {
1
+ export function describeAmsTrayRange(options = {}) {
2
+ return options.dualNozzle
3
+ ? "[-1, 15] (absolute tray), 128-253 (AMS-HT), 254 (left/deputy external) or 255 (right/main external)"
4
+ : "[-1, 15] (absolute tray) or 128-254 (HT/external)";
5
+ }
6
+ export function normalizeBridgeAmsTrayValue(value, label, options = {}) {
2
7
  let numeric;
3
8
  if (typeof value === "number") {
4
9
  numeric = value;
@@ -12,8 +17,8 @@ export function normalizeBridgeAmsTrayValue(value, label) {
12
17
  if (!Number.isInteger(numeric) ||
13
18
  numeric < -1 ||
14
19
  (numeric > 15 && numeric < 128) ||
15
- numeric > 254) {
16
- throw new Error(`${label} must be an integer in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${JSON.stringify(value)}`);
20
+ numeric > (options.dualNozzle ? 255 : 254)) {
21
+ throw new Error(`${label} must be an integer in ${describeAmsTrayRange(options)}; got ${JSON.stringify(value)}`);
17
22
  }
18
23
  return numeric;
19
24
  }
@@ -21,7 +26,7 @@ function isNumericMappingKey(key) {
21
26
  const numeric = Number(key);
22
27
  return Number.isInteger(numeric) && numeric >= 0;
23
28
  }
24
- export function normalizeAmsMappingObject(mapping, label = "ams_mapping") {
29
+ export function normalizeAmsMappingObject(mapping, label = "ams_mapping", options = {}) {
25
30
  return Object.entries(mapping)
26
31
  .sort(([leftKey], [rightKey]) => {
27
32
  const leftNumeric = isNumericMappingKey(leftKey);
@@ -34,7 +39,7 @@ export function normalizeAmsMappingObject(mapping, label = "ams_mapping") {
34
39
  return 1;
35
40
  return 0;
36
41
  })
37
- .map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]`));
42
+ .map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]`, options));
38
43
  }
39
44
  export function hasAmsMappingInput(value) {
40
45
  if (Array.isArray(value))
@@ -43,3 +48,29 @@ export function hasAmsMappingInput(value) {
43
48
  return Object.keys(value).length > 0;
44
49
  return value !== undefined && value !== null;
45
50
  }
51
+ /** Flat wire entries use -1 for dual-nozzle virtual trays; ams_mapping2 identifies their spool. */
52
+ export function bridgeAmsMappingValue(value, dualNozzle = false) {
53
+ return dualNozzle && (value === 254 || value === 255) ? -1 : value;
54
+ }
55
+ /** Select an external spool only from a verified physical nozzle on dual-nozzle printers. */
56
+ export function externalTrayForNozzle(nozzle, dualNozzle = false) {
57
+ if (!dualNozzle)
58
+ return 254;
59
+ if (nozzle === 0)
60
+ return 255;
61
+ if (nozzle === 1)
62
+ return 254;
63
+ throw new Error("External-spool printing requires inspected filament_map and physical_extruder_map nozzle bindings.");
64
+ }
65
+ /** Virtual trays use slot zero on dual-nozzle printers; retain the historical single-nozzle form. */
66
+ export function bridgeAmsMapping2Entry(value, dualNozzle = false) {
67
+ if (value < 0)
68
+ return { ams_id: 255, slot_id: 255 };
69
+ if (value === 255)
70
+ return dualNozzle ? { ams_id: 255, slot_id: 0 } : { ams_id: 255, slot_id: 255 };
71
+ if (value === 254)
72
+ return { ams_id: 254, slot_id: dualNozzle ? 0 : 254 };
73
+ if (value >= 128)
74
+ return { ams_id: 128, slot_id: value - 128 };
75
+ return { ams_id: Math.floor(value / 4), slot_id: value % 4 };
76
+ }
package/dist/index.js CHANGED
@@ -14,11 +14,12 @@ import { BambuNetworkBridge } from "./bambu-network-bridge.js";
14
14
  import { assertBambuNativeAvailable, bambuNativeControlsAvailable, bambuNativeUnavailableReason, interruptNativeOperations, validateBambuNativeMetadata, buildBambuNativeFanCommand, buildBambuNativeTemperatureCommand, printWithBambuNative, sendCommandWithBambuNative, uploadWithBambuNative, } from "./bambu-native.js";
15
15
  import { importFileViaBambuConnect } from "./bambu-connect.js";
16
16
  import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
17
- import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
17
+ import { bridgeAmsMapping2Entry, bridgeAmsMappingValue, externalTrayForNozzle, hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
18
18
  import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
19
19
  import { BambuImplementation, assertDirectPrintSupported } from "./printers/bambu.js";
20
20
  import { inspectPrintFile } from "./safety/print-file.js";
21
21
  import { validatePrinterState } from "./safety/printer-state.js";
22
+ import { isDualNozzleModel } from "./safety/limits.js";
22
23
  import { withPrinterOperation, withPrintSnapshot, uniquePrintName } from "./safety/artifact.js";
23
24
  import { detectProfilesRoot } from "./slicer/profile-flatten.js";
24
25
  dotenv.config();
@@ -523,7 +524,7 @@ function defaultSlicerPathFor(slicerType) {
523
524
  }
524
525
  function resolveSlicerConfig(args) {
525
526
  const slicerType = normalizeSlicerType(String(args?.slicer_type || DEFAULT_SLICER_TYPE));
526
- const slicerPath = String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
527
+ const slicerPath = slicerType === "orca-slicer-api" ? "" : String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
527
528
  const slicerProfile = String(args?.slicer_profile || DEFAULT_SLICER_PROFILE);
528
529
  return { slicerType, slicerPath, slicerProfile };
529
530
  }
@@ -735,18 +736,10 @@ function stringifyBridgeJson(value) {
735
736
  return value;
736
737
  return JSON.stringify(value);
737
738
  }
738
- function nativeAmsMapping2(mapping) {
739
+ function nativeAmsMapping2(mapping, dualNozzle = false) {
739
740
  if (!mapping || mapping.length === 0)
740
741
  return undefined;
741
- return JSON.stringify(mapping.map((value) => {
742
- if (value < 0 || value === 255)
743
- return { ams_id: 255, slot_id: 255 };
744
- if (value === 254)
745
- return { ams_id: 254, slot_id: 254 };
746
- if (value >= 128)
747
- return { ams_id: 128, slot_id: value - 128 };
748
- return { ams_id: Math.floor(value / 4), slot_id: value % 4 };
749
- }));
742
+ return JSON.stringify(mapping.map((value) => bridgeAmsMapping2Entry(value, dualNozzle)));
750
743
  }
751
744
  function redactPrintParams(params) {
752
745
  return {
@@ -1025,6 +1018,9 @@ class BambuPrinterMCPServer {
1025
1018
  resolveSlicerConfigFromArgs(args, toolName) {
1026
1019
  const config = resolveSlicerConfig(args);
1027
1020
  if (args?.slicer_path !== undefined) {
1021
+ if (config.slicerType === "orca-slicer-api") {
1022
+ throw new Error(`${toolName}: remote slicing uses trusted SLICER_API_URL and BAMBU_PROFILES_ROOT configuration; slicer_path is unsupported.`);
1023
+ }
1028
1024
  const resolvedSlicerPath = this.resolveExecutableSelectorArg(args.slicer_path, toolName, "slicer_path");
1029
1025
  if (resolvedSlicerPath !== undefined) {
1030
1026
  config.slicerPath = resolvedSlicerPath;
@@ -1048,10 +1044,10 @@ class BambuPrinterMCPServer {
1048
1044
  }
1049
1045
  console.error(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
1050
1046
  const autoSliceOptions = {
1051
- uptodate: true,
1052
- ensureOnBed: true,
1053
- minSave: true,
1054
- skipModifiedGcodes: true,
1047
+ ...(slicerType === "orca-slicer-api" ? {} : {
1048
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
1049
+ }),
1050
+ ...(slicerType === "orca-slicer-api" ? { slicePlate: Number(args.plate_index ?? 0) + 1 } : {}),
1055
1051
  bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
1056
1052
  nozzleType: resolveNozzleType(args?.nozzle_type),
1057
1053
  nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
@@ -1063,8 +1059,9 @@ class BambuPrinterMCPServer {
1063
1059
  async resolveAmsPrintSettings(threeMFPath, args, host, bambuSerial, bambuToken, printModel, printNozzle) {
1064
1060
  const parsed3MFData = await parse3MF(threeMFPath);
1065
1061
  let parsedAmsMapping;
1062
+ const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
1066
1063
  if (parsed3MFData.slicerConfig?.ams_mapping) {
1067
- const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping);
1064
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping, "ams_mapping", trayOptions);
1068
1065
  if (slots.length > 0) {
1069
1066
  parsedAmsMapping = slots;
1070
1067
  }
@@ -1077,10 +1074,10 @@ class BambuPrinterMCPServer {
1077
1074
  if (hasUserAmsMapping) {
1078
1075
  let userMappingOverride;
1079
1076
  if (Array.isArray(args.ams_mapping)) {
1080
- userMappingOverride = args.ams_mapping.map((v, i) => normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`));
1077
+ userMappingOverride = args.ams_mapping.map((v, i) => normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions));
1081
1078
  }
1082
1079
  else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
1083
- userMappingOverride = normalizeAmsMappingObject(args.ams_mapping);
1080
+ userMappingOverride = normalizeAmsMappingObject(args.ams_mapping, "ams_mapping", trayOptions);
1084
1081
  }
1085
1082
  if (userMappingOverride && userMappingOverride.length > 0) {
1086
1083
  finalAmsMapping = userMappingOverride;
@@ -1089,7 +1086,7 @@ class BambuPrinterMCPServer {
1089
1086
  }
1090
1087
  }
1091
1088
  if (!hasUserAmsMapping && hasUserAmsSlots) {
1092
- const userSlots = args.ams_slots.map((slot, i) => normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]`));
1089
+ const userSlots = args.ams_slots.map((slot, i) => normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]`, trayOptions));
1093
1090
  finalAmsSlots = userSlots;
1094
1091
  finalAmsMapping = undefined;
1095
1092
  useAMS = userSlots.length > 0;
@@ -1106,8 +1103,10 @@ class BambuPrinterMCPServer {
1106
1103
  useAMS = true;
1107
1104
  }
1108
1105
  if (args?.use_ams === false) {
1109
- finalAmsMapping = undefined;
1110
- finalAmsSlots = undefined;
1106
+ if (!hasUserAmsMapping)
1107
+ finalAmsMapping = undefined;
1108
+ if (!hasUserAmsSlots)
1109
+ finalAmsSlots = undefined;
1111
1110
  useAMS = false;
1112
1111
  }
1113
1112
  if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
@@ -1177,8 +1176,16 @@ class BambuPrinterMCPServer {
1177
1176
  inspection.usedFilamentPositions.forEach((position, index) => { mapping[position] = finalAmsSlots[index]; });
1178
1177
  }
1179
1178
  if (!useAMS) {
1180
- mapping = Array(inspection.materials.length).fill(-1);
1181
- inspection.usedFilamentPositions.forEach(position => { mapping[position] = 254; });
1179
+ mapping ?? (mapping = Array(inspection.materials.length).fill(-1));
1180
+ const dualNozzle = isDualNozzleModel(printModel);
1181
+ for (const position of inspection.usedFilamentPositions) {
1182
+ if (mapping[position] === undefined || mapping[position] < 0) {
1183
+ mapping[position] = externalTrayForNozzle(inspection.filamentNozzleIndices?.[position], dualNozzle);
1184
+ }
1185
+ if (mapping[position] !== 254 && !(dualNozzle && mapping[position] === 255)) {
1186
+ throw new Error("use_ams:false requires an external-spool mapping for every used filament.");
1187
+ }
1188
+ }
1182
1189
  }
1183
1190
  const requirements = { ...inspection, amsMapping: mapping, useAMS };
1184
1191
  const initialStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
@@ -1187,7 +1194,7 @@ class BambuPrinterMCPServer {
1187
1194
  const confirmedStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
1188
1195
  validatePrinterState(confirmedStatus, requirements);
1189
1196
  this.bambu.assertBedClearance(confirmedStatus, bedClearance);
1190
- const amsMapping = stringifyBridgeJson(mapping);
1197
+ const amsMapping = stringifyBridgeJson(mapping?.map(value => bridgeAmsMappingValue(value, isDualNozzleModel(printModel))));
1191
1198
  const remoteFileName = uniquePrintName(threeMFPath);
1192
1199
  const params = {
1193
1200
  dev_id: devId,
@@ -1204,7 +1211,7 @@ class BambuPrinterMCPServer {
1204
1211
  ftp_file_md5: String(args?.ftp_file_md5 || ""),
1205
1212
  nozzle_mapping: stringifyBridgeJson(args?.nozzle_mapping) || "",
1206
1213
  ams_mapping: amsMapping || "",
1207
- ams_mapping2: stringifyBridgeJson(args?.ams_mapping2) || "",
1214
+ ams_mapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)) || "",
1208
1215
  ams_mapping_info: stringifyBridgeJson(args?.ams_mapping_info) || "",
1209
1216
  nozzles_info: stringifyBridgeJson(args?.nozzles_info) || "",
1210
1217
  connection_type: connectionType,
@@ -1244,7 +1251,7 @@ class BambuPrinterMCPServer {
1244
1251
  host: devIp, serial: devId, token: password || bambuToken, filePath: snapshot,
1245
1252
  projectName: String(params.task_name), presetName, remoteName: remoteFileName,
1246
1253
  plateIndex, bedType: printBedType, useAMS,
1247
- amsMapping, amsMapping2: nativeAmsMapping2(mapping),
1254
+ amsMapping, amsMapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)),
1248
1255
  bedLeveling: params.task_bed_leveling,
1249
1256
  flowCalibration: params.task_flow_cali,
1250
1257
  vibrationCalibration: params.task_vibration_cali,
@@ -1447,10 +1454,9 @@ class BambuPrinterMCPServer {
1447
1454
  throw new Error('BambuStudio CLI SuperTack bed type is not verified; use a pre-sliced 3MF for SuperTack or choose textured_plate, cool_plate, engineering_plate, or hot_plate.');
1448
1455
  }
1449
1456
  const autoSliceOptions = {
1450
- uptodate: true,
1451
- ensureOnBed: true,
1452
- minSave: true,
1453
- skipModifiedGcodes: true,
1457
+ ...(slicerType === "orca-slicer-api" ? {} : {
1458
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
1459
+ }),
1454
1460
  bedType,
1455
1461
  };
1456
1462
  try {
@@ -1943,7 +1949,7 @@ class BambuPrinterMCPServer {
1943
1949
  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." },
1944
1950
  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." },
1945
1951
  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." },
1946
- 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." }
1952
+ 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." }
1947
1953
  },
1948
1954
  required: ["stl_path", "bambu_model"]
1949
1955
  }
@@ -2046,8 +2052,8 @@ class BambuPrinterMCPServer {
2046
2052
  nozzle_diameter: { type: "string", description: "Nozzle diameter in mm for auto-slicing (default: 0.4)." },
2047
2053
  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." },
2048
2054
  use_ams: { type: "boolean", description: "Whether to use the AMS; defaults to auto-detect from the 3MF mapping." },
2049
- ams_mapping: { type: "array", description: "AMS slot mapping array used by both local MCP printing and FULU PrintParams.", items: { type: "number" } },
2050
- ams_slots: { type: "array", description: "Per-used-filament AMS slot list, matching the local LAN print path.", items: { type: "number" } },
2055
+ 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" } },
2056
+ 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" } },
2051
2057
  ams_mapping_bridge: { type: "string", description: "Raw JSON string override for FULU PrintParams ams_mapping when the automatic array is not enough." },
2052
2058
  ams_mapping2: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping2, matching OrcaSlicer-bambulab's v1 AMS mapping field." },
2053
2059
  ams_mapping_info: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping_info, matching OrcaSlicer-bambulab's detailed AMS mapping field." },
@@ -2475,12 +2481,12 @@ class BambuPrinterMCPServer {
2475
2481
  use_ams: { type: "boolean", description: "Whether to use the AMS (default: auto-detect from 3MF)" },
2476
2482
  ams_mapping: {
2477
2483
  type: "array",
2478
- 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.",
2484
+ 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.",
2479
2485
  items: { type: "number" }
2480
2486
  },
2481
2487
  ams_slots: {
2482
2488
  type: "array",
2483
- 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.",
2489
+ 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.",
2484
2490
  items: { type: "number" }
2485
2491
  },
2486
2492
  auto_match_ams: {
@@ -3415,10 +3421,9 @@ class BambuPrinterMCPServer {
3415
3421
  }
3416
3422
  console.error(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
3417
3423
  const autoSliceOptions = {
3418
- uptodate: true,
3419
- ensureOnBed: true,
3420
- minSave: true,
3421
- skipModifiedGcodes: true,
3424
+ ...(slicerType === "orca-slicer-api" ? { slicePlate: plateIndex + 1 } : {
3425
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
3426
+ }),
3422
3427
  bedType: printBedType,
3423
3428
  nozzleType: resolveNozzleType(args?.nozzle_type),
3424
3429
  nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
@@ -3440,9 +3445,10 @@ class BambuPrinterMCPServer {
3440
3445
  }
3441
3446
  const parsed3MFData = await parse3MF(threeMFPath);
3442
3447
  const isH2Print = H2_BAMBU_MODELS.has(printModel);
3448
+ const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
3443
3449
  let parsedAmsMapping;
3444
3450
  if (!isH2Print && parsed3MFData.slicerConfig?.ams_mapping) {
3445
- const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping);
3451
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping, "ams_mapping", trayOptions);
3446
3452
  if (slots.length > 0) {
3447
3453
  parsedAmsMapping = slots;
3448
3454
  }
@@ -3455,10 +3461,10 @@ class BambuPrinterMCPServer {
3455
3461
  if (hasUserAmsMapping) {
3456
3462
  let userMappingOverride;
3457
3463
  if (Array.isArray(args.ams_mapping)) {
3458
- userMappingOverride = args.ams_mapping.map((v, i) => normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`));
3464
+ userMappingOverride = args.ams_mapping.map((v, i) => normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions));
3459
3465
  }
3460
3466
  else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
3461
- userMappingOverride = normalizeAmsMappingObject(args.ams_mapping);
3467
+ userMappingOverride = normalizeAmsMappingObject(args.ams_mapping, "ams_mapping", trayOptions);
3462
3468
  }
3463
3469
  if (userMappingOverride && userMappingOverride.length > 0) {
3464
3470
  finalAmsMapping = userMappingOverride;
@@ -3499,8 +3505,10 @@ class BambuPrinterMCPServer {
3499
3505
  }
3500
3506
  }
3501
3507
  if (args?.use_ams === false) {
3502
- finalAmsMapping = undefined;
3503
- finalAmsSlots = undefined;
3508
+ if (!hasUserAmsMapping)
3509
+ finalAmsMapping = undefined;
3510
+ if (!hasUserAmsSlots)
3511
+ finalAmsSlots = undefined;
3504
3512
  useAMS = false;
3505
3513
  }
3506
3514
  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 } 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";
@@ -620,12 +621,17 @@ export class BambuImplementation {
620
621
  // `plate_N.json.filament_ids` -- and expand to a full project-level
621
622
  // array here. `amsMapping` is the raw escape hatch (takes precedence
622
623
  // when both are supplied).
624
+ // Dual-nozzle printers address two external spools: 254 feeds the left/deputy
625
+ // nozzle and 255 the right/main nozzle (Bambu Studio VIRTUAL_TRAY_DEPUTY_ID /
626
+ // VIRTUAL_TRAY_MAIN_ID). Single-nozzle printers keep 254 as the only external tray.
627
+ const dualNozzle = isDualNozzleModel(model);
628
+ const externalTrays = dualNozzle ? [254, 255] : [254];
623
629
  const validateTrayValue = (v, label) => {
624
630
  if (!Number.isInteger(v) ||
625
631
  v < -1 ||
626
632
  (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}`);
633
+ v > (dualNozzle ? 255 : 254)) {
634
+ throw new Error(`${label} values must be integers in ${describeAmsTrayRange({ dualNozzle })}; got ${v}`);
629
635
  }
630
636
  };
631
637
  let baseMapping;
@@ -655,7 +661,7 @@ export class BambuImplementation {
655
661
  const projectLen = Math.max(projectMetadata.projectFilamentCount, ...positions.map((p) => p + 1), 1);
656
662
  baseMapping = Array(projectLen).fill(-1);
657
663
  positions.forEach((pos, i) => {
658
- baseMapping[pos] = options.useAMS === false ? 254 : i;
664
+ baseMapping[pos] = options.useAMS === false ? externalTrayForNozzle(inspection.filamentNozzleIndices?.[pos], dualNozzle) : i;
659
665
  });
660
666
  }
661
667
  let amsMapping;
@@ -663,15 +669,7 @@ export class BambuImplementation {
663
669
  if (usesH2ProjectFile) {
664
670
  const projLen = Math.max(projectMetadata.projectFilamentCount, baseMapping.length, 1);
665
671
  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
- });
672
+ amsMapping2 = amsMapping.map((v) => bridgeAmsMapping2Entry(v, dualNozzle));
675
673
  }
676
674
  else {
677
675
  amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
@@ -681,8 +679,8 @@ export class BambuImplementation {
681
679
  if (baseMapping[position] === undefined || baseMapping[position] < 0) {
682
680
  throw new Error(`Missing physical filament mapping for project filament ${position}.`);
683
681
  }
684
- if (options.useAMS === false && baseMapping[position] !== 254) {
685
- throw new Error("External-spool printing requires external spool mapping (254).");
682
+ if (options.useAMS === false && !externalTrays.includes(baseMapping[position])) {
683
+ throw new Error(`External-spool printing requires external spool mapping (${externalTrays.join(" or ")}).`);
686
684
  }
687
685
  }
688
686
  const legacyContainer = options.filePath.toLowerCase().endsWith(".gcode.3mf") && !usesH2ProjectFile && !isA1;
@@ -768,7 +766,7 @@ export class BambuImplementation {
768
766
  project_id: submissionId,
769
767
  subtask_id: submissionId,
770
768
  task_id: submissionId,
771
- ams_mapping: amsMapping,
769
+ ams_mapping: amsMapping.map(value => bridgeAmsMappingValue(value, dualNozzle)),
772
770
  ams_mapping2: amsMapping2,
773
771
  },
774
772
  };
@@ -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
  }