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.
@@ -11,6 +11,7 @@ import * as crypto from 'crypto';
11
11
  import { execFile } from 'child_process';
12
12
  import { flattenForCli, detectProfilesRoot, resolveBblMachineProfile } from '../slicer/profile-flatten.js';
13
13
  import { profileJsonFiles } from '../slicer/profile-files.js';
14
+ import { sliceWithOrcaApi, validateRemoteSliceOptions } from '../slicer/remote-slicer.js';
14
15
  const readFileAsync = promisify(fs.readFile);
15
16
  const writeFileAsync = promisify(fs.writeFile);
16
17
  const BAMBU_PROFILE_ROOTS = [
@@ -27,6 +28,7 @@ export const SLICER_TYPES = [
27
28
  'bambustudio',
28
29
  'orcaslicer',
29
30
  'orcaslicer-bambulab',
31
+ 'orca-slicer-api',
30
32
  'prusaslicer',
31
33
  'cura',
32
34
  'slic3r',
@@ -48,6 +50,8 @@ const SLICER_TYPE_ALIASES = {
48
50
  orcastudio: 'orcaslicer-bambulab',
49
51
  'orcaslicer-bmcu': 'orcaslicer-bambulab',
50
52
  'orca-bmcu': 'orcaslicer-bambulab',
53
+ 'orca-slicer-api': 'orca-slicer-api',
54
+ 'orcaslicer-api': 'orca-slicer-api',
51
55
  prusaslicer: 'prusaslicer',
52
56
  'prusa-slicer': 'prusaslicer',
53
57
  cura: 'cura',
@@ -65,7 +69,8 @@ export function normalizeSlicerType(rawSlicerType) {
65
69
  export function isBambuCompatibleSlicer(slicerType) {
66
70
  return (slicerType === 'bambustudio' ||
67
71
  slicerType === 'orcaslicer' ||
68
- slicerType === 'orcaslicer-bambulab');
72
+ slicerType === 'orcaslicer-bambulab' ||
73
+ slicerType === 'orca-slicer-api');
69
74
  }
70
75
  function isPathLikeExecutable(slicerPath) {
71
76
  return (path.isAbsolute(slicerPath) ||
@@ -1225,7 +1230,7 @@ export class STLManipulator extends EventEmitter {
1225
1230
  async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, bambuOptions) {
1226
1231
  const operationId = this.generateOperationId();
1227
1232
  this.activeOperations.set(operationId, true);
1228
- if (isPathLikeExecutable(slicerPath) && !fs.existsSync(slicerPath)) {
1233
+ if (slicerType !== 'orca-slicer-api' && isPathLikeExecutable(slicerPath) && !fs.existsSync(slicerPath)) {
1229
1234
  throw new Error(`Slicer executable not found at: ${slicerPath}`);
1230
1235
  }
1231
1236
  if (slicerProfile && !fs.existsSync(slicerProfile)) {
@@ -1267,6 +1272,7 @@ export class STLManipulator extends EventEmitter {
1267
1272
  case 'bambustudio':
1268
1273
  case 'orcaslicer':
1269
1274
  case 'orcaslicer-bambulab':
1275
+ case 'orca-slicer-api':
1270
1276
  // Bambu-compatible CLI: slice and export as 3MF with embedded gcode.
1271
1277
  // FULU's OrcaSlicer-bambulab fork keeps this Bambu-style path.
1272
1278
  // For 3MF input: slice in place and export sliced 3MF
@@ -1274,10 +1280,19 @@ export class STLManipulator extends EventEmitter {
1274
1280
  {
1275
1281
  const is3mf = stlFilePath.toLowerCase().endsWith('.3mf');
1276
1282
  const outputBase = path.basename(stlFilePath, is3mf ? '.3mf' : '.stl');
1277
- const bambuOutputPath = path.join(this.tempDir, outputBase + '_sliced.3mf');
1283
+ const remote = slicerType === 'orca-slicer-api';
1284
+ if (remote) {
1285
+ validateRemoteSliceOptions(bambuOptions);
1286
+ if (!process.env.BAMBU_PROFILES_ROOT)
1287
+ throw new Error('Remote slicing requires BAMBU_PROFILES_ROOT pointing to the matching Orca profile tree (a read-only mount is sufficient).');
1288
+ if (slicerProfile && !fs.existsSync(slicerProfile))
1289
+ throw new Error('Remote slicing process profile does not exist.');
1290
+ }
1291
+ const profileSlicerType = remote ? 'orcaslicer' : slicerType;
1292
+ const bambuOutputPath = path.join(this.tempDir, outputBase + (remote ? `_${operationId}` : '') + '_sliced.3mf');
1278
1293
  const outputDir = path.dirname(bambuOutputPath);
1279
- const profilesRoot = detectProfilesRoot(slicerPath, slicerType);
1280
- const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, slicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot);
1294
+ const profilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
1295
+ const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, profileSlicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot);
1281
1296
  // Resolve every BBL dependency before launching the slicer.
1282
1297
  const settingsBundle = await this.maybeFlattenBundle(await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
1283
1298
  // The machine preset assumes the stock hotend; record the installed one so
@@ -1297,7 +1312,7 @@ export class STLManipulator extends EventEmitter {
1297
1312
  // absolute-extrusion normalization after resolving those ancestors.
1298
1313
  // The machine owns the extrusion mode and layer G-code once process
1299
1314
  // files lose machine keys, so normalize it there as well.
1300
- if (slicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1315
+ if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1301
1316
  const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
1302
1317
  settingsBundle.settingsArg = [
1303
1318
  this.writeTempJson(outputBase, 'machine_orca_resolved', this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
@@ -1305,6 +1320,17 @@ export class STLManipulator extends EventEmitter {
1305
1320
  ].join(';');
1306
1321
  }
1307
1322
  const cliBundle = this.isolateMachineSettings(settingsBundle);
1323
+ if (remote) {
1324
+ const [machinePath, processPath] = cliBundle.settingsArg?.split(';') ?? [];
1325
+ if (!machinePath || !processPath)
1326
+ throw new Error('Remote slicing requires complete prepared machine and process profiles.');
1327
+ outputFilePath = await sliceWithOrcaApi({
1328
+ inputPath: stlFilePath, machinePath, processPath,
1329
+ filamentPaths: cliBundle.filamentPaths, outputPath: bambuOutputPath,
1330
+ options: bambuOptions, isCancelled: () => !this.activeOperations.get(operationId),
1331
+ });
1332
+ break;
1333
+ }
1308
1334
  args = [
1309
1335
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1310
1336
  '--outputdir', outputDir,
@@ -1360,45 +1386,47 @@ export class STLManipulator extends EventEmitter {
1360
1386
  default:
1361
1387
  throw new Error(`Unsupported slicer type: ${slicerType}`);
1362
1388
  }
1363
- if (progressCallback)
1364
- progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
1365
- console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1366
- // Execute the slicer
1367
- await new Promise((resolve, reject) => {
1368
- const process = execFile(slicerPath, args, (error, stdout, stderr) => {
1369
- if (error) {
1370
- console.error(`Slicer Error: ${error.message}`);
1371
- console.error(`Slicer Stderr: ${stderr}`);
1372
- reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
1373
- }
1374
- else {
1375
- console.error(`Slicer Stdout: ${stdout}`);
1376
- if (stderr) {
1377
- console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
1389
+ if (slicerType !== 'orca-slicer-api') {
1390
+ if (progressCallback)
1391
+ progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
1392
+ console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1393
+ // Execute the slicer
1394
+ await new Promise((resolve, reject) => {
1395
+ const process = execFile(slicerPath, args, (error, stdout, stderr) => {
1396
+ if (error) {
1397
+ console.error(`Slicer Error: ${error.message}`);
1398
+ console.error(`Slicer Stderr: ${stderr}`);
1399
+ reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
1378
1400
  }
1379
- resolve();
1380
- }
1381
- });
1382
- // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
1383
- // process.stdout?.on('data', (data) => { console.error(`Slicer stdout: ${data}`); });
1384
- // process.stderr?.on('data', (data) => { console.error(`Slicer stderr: ${data}`); });
1385
- // Check for cancellation periodically
1386
- const checkCancel = setInterval(() => {
1387
- if (!this.activeOperations.get(operationId)) {
1388
- clearInterval(checkCancel);
1389
- try {
1390
- process.kill(); // Attempt to kill the slicer process
1391
- reject(new Error("Slicing operation cancelled"));
1401
+ else {
1402
+ console.error(`Slicer Stdout: ${stdout}`);
1403
+ if (stderr) {
1404
+ console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
1405
+ }
1406
+ resolve();
1392
1407
  }
1393
- catch (killError) {
1394
- console.error("Error attempting to kill slicer process:", killError);
1395
- reject(new Error("Slicing operation cancelled, but failed to kill process."));
1408
+ });
1409
+ // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
1410
+ // process.stdout?.on('data', (data) => { console.error(`Slicer stdout: ${data}`); });
1411
+ // process.stderr?.on('data', (data) => { console.error(`Slicer stderr: ${data}`); });
1412
+ // Check for cancellation periodically
1413
+ const checkCancel = setInterval(() => {
1414
+ if (!this.activeOperations.get(operationId)) {
1415
+ clearInterval(checkCancel);
1416
+ try {
1417
+ process.kill(); // Attempt to kill the slicer process
1418
+ reject(new Error("Slicing operation cancelled"));
1419
+ }
1420
+ catch (killError) {
1421
+ console.error("Error attempting to kill slicer process:", killError);
1422
+ reject(new Error("Slicing operation cancelled, but failed to kill process."));
1423
+ }
1396
1424
  }
1397
- }
1398
- }, 500);
1399
- process.on('exit', () => clearInterval(checkCancel));
1400
- process.on('error', () => clearInterval(checkCancel)); // Ensure interval cleared on process error too
1401
- });
1425
+ }, 500);
1426
+ process.on('exit', () => clearInterval(checkCancel));
1427
+ process.on('error', () => clearInterval(checkCancel)); // Ensure interval cleared on process error too
1428
+ });
1429
+ }
1402
1430
  if (!this.activeOperations.get(operationId)) {
1403
1431
  // This check might be redundant if the promise rejected on kill, but good safety
1404
1432
  throw new Error("Slicing operation cancelled after process finished");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.28",
3
+ "version": "1.1.30",
4
4
  "description": "MCP server for Bambu Lab 3D printers with STL manipulation, slicing, and print management",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
@@ -1,4 +1,15 @@
1
- export function normalizeBridgeAmsTrayValue(value: 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 { dualNozzle?: boolean }
5
+
6
+ export function describeAmsTrayRange(options: AmsTrayValueOptions = {}): string {
7
+ return options.dualNozzle
8
+ ? "[-1, 15] (absolute tray), 128-253 (AMS-HT), 254 (left/deputy external) or 255 (right/main external)"
9
+ : "[-1, 15] (absolute tray) or 128-254 (HT/external)";
10
+ }
11
+
12
+ export function normalizeBridgeAmsTrayValue(value: unknown, label: string, options: AmsTrayValueOptions = {}): number {
2
13
  let numeric: number;
3
14
  if (typeof value === "number") {
4
15
  numeric = value;
@@ -12,10 +23,10 @@ export function normalizeBridgeAmsTrayValue(value: unknown, label: string): numb
12
23
  !Number.isInteger(numeric) ||
13
24
  numeric < -1 ||
14
25
  (numeric > 15 && numeric < 128) ||
15
- numeric > 254
26
+ numeric > (options.dualNozzle ? 255 : 254)
16
27
  ) {
17
28
  throw new Error(
18
- `${label} must be an integer in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${JSON.stringify(value)}`
29
+ `${label} must be an integer in ${describeAmsTrayRange(options)}; got ${JSON.stringify(value)}`
19
30
  );
20
31
  }
21
32
 
@@ -27,7 +38,7 @@ function isNumericMappingKey(key: string): boolean {
27
38
  return Number.isInteger(numeric) && numeric >= 0;
28
39
  }
29
40
 
30
- export function normalizeAmsMappingObject(mapping: Record<string, unknown>, label = "ams_mapping"): number[] {
41
+ export function normalizeAmsMappingObject(mapping: Record<string, unknown>, label = "ams_mapping", options: AmsTrayValueOptions = {}): number[] {
31
42
  return Object.entries(mapping)
32
43
  .sort(([leftKey], [rightKey]) => {
33
44
  const leftNumeric = isNumericMappingKey(leftKey);
@@ -37,7 +48,7 @@ export function normalizeAmsMappingObject(mapping: Record<string, unknown>, labe
37
48
  if (rightNumeric) return 1;
38
49
  return 0;
39
50
  })
40
- .map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]`));
51
+ .map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]`, options));
41
52
  }
42
53
 
43
54
  export function hasAmsMappingInput(value: unknown): boolean {
@@ -45,3 +56,25 @@ export function hasAmsMappingInput(value: unknown): boolean {
45
56
  if (value && typeof value === "object") return Object.keys(value as Record<string, unknown>).length > 0;
46
57
  return value !== undefined && value !== null;
47
58
  }
59
+
60
+ /** Flat wire entries use -1 for dual-nozzle virtual trays; ams_mapping2 identifies their spool. */
61
+ export function bridgeAmsMappingValue(value: number, dualNozzle = false): number {
62
+ return dualNozzle && (value === 254 || value === 255) ? -1 : value;
63
+ }
64
+
65
+ /** Select an external spool only from a verified physical nozzle on dual-nozzle printers. */
66
+ export function externalTrayForNozzle(nozzle: number | undefined, dualNozzle = false): number {
67
+ if (!dualNozzle) return 254;
68
+ if (nozzle === 0) return 255;
69
+ if (nozzle === 1) return 254;
70
+ throw new Error("External-spool printing requires inspected filament_map and physical_extruder_map nozzle bindings.");
71
+ }
72
+
73
+ /** Virtual trays use slot zero on dual-nozzle printers; retain the historical single-nozzle form. */
74
+ export function bridgeAmsMapping2Entry(value: number, dualNozzle = false): { ams_id: number; slot_id: number } {
75
+ if (value < 0) return { ams_id: 255, slot_id: 255 };
76
+ if (value === 255) return dualNozzle ? { ams_id: 255, slot_id: 0 } : { ams_id: 255, slot_id: 255 };
77
+ if (value === 254) return { ams_id: 254, slot_id: dualNozzle ? 0 : 254 };
78
+ if (value >= 128) return { ams_id: 128, slot_id: value - 128 };
79
+ return { ams_id: Math.floor(value / 4), slot_id: value % 4 };
80
+ }
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
 
@@ -84,6 +85,22 @@ const DEFAULT_BAMBU_CONNECTION_MODE =
84
85
  const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
85
86
  const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
86
87
 
88
+ /** Bound for each Linux X2D emergency MQTT send (BAMBU_EMERGENCY_MQTT_TIMEOUT_MS, 1–60000 ms). */
89
+ function withEmergencyMqttTimeout<T>(send: (signal: AbortSignal) => Promise<T>): Promise<T> {
90
+ const raw = Number(process.env.BAMBU_EMERGENCY_MQTT_TIMEOUT_MS?.trim() || 15_000);
91
+ const ms = Number.isInteger(raw) && raw >= 1 && raw <= 60_000 ? raw : 15_000;
92
+ const controller = new AbortController();
93
+ let timer: ReturnType<typeof setTimeout> | undefined;
94
+ const timeout = new Promise<never>((_, reject) => {
95
+ timer = setTimeout(() => {
96
+ const error = new Error(`no MQTT confirmation within ${ms / 1000} s`);
97
+ controller.abort(error);
98
+ reject(error);
99
+ }, ms);
100
+ });
101
+ return Promise.race([Promise.resolve().then(() => send(controller.signal)), timeout]).finally(() => clearTimeout(timer));
102
+ }
103
+
87
104
  function requestedPrintNozzles(args: Record<string, any>): number[] | undefined {
88
105
  if (args.nozzle_diameters !== undefined && args.nozzle_diameter !== undefined) {
89
106
  throw new Error("Supply nozzle_diameters or nozzle_diameter, not both.");
@@ -703,7 +720,7 @@ function resolveSlicerConfig(args: Record<string, unknown> | undefined): {
703
720
  slicerProfile: string;
704
721
  } {
705
722
  const slicerType = normalizeSlicerType(String(args?.slicer_type || DEFAULT_SLICER_TYPE));
706
- const slicerPath = String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
723
+ const slicerPath = slicerType === "orca-slicer-api" ? "" : String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
707
724
  const slicerProfile = String(args?.slicer_profile || DEFAULT_SLICER_PROFILE);
708
725
  return { slicerType, slicerPath, slicerProfile };
709
726
  }
@@ -994,14 +1011,9 @@ function stringifyBridgeJson(value: unknown): string | undefined {
994
1011
  return JSON.stringify(value);
995
1012
  }
996
1013
 
997
- function nativeAmsMapping2(mapping: number[] | undefined): string | undefined {
1014
+ function nativeAmsMapping2(mapping: number[] | undefined, dualNozzle = false): string | undefined {
998
1015
  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
- }));
1016
+ return JSON.stringify(mapping.map((value) => bridgeAmsMapping2Entry(value, dualNozzle)));
1005
1017
  }
1006
1018
 
1007
1019
  function redactPrintParams(params: Record<string, unknown>): Record<string, unknown> {
@@ -1357,6 +1369,9 @@ class BambuPrinterMCPServer {
1357
1369
  ): ReturnType<typeof resolveSlicerConfig> {
1358
1370
  const config = resolveSlicerConfig(args);
1359
1371
  if (args?.slicer_path !== undefined) {
1372
+ if (config.slicerType === "orca-slicer-api") {
1373
+ throw new Error(`${toolName}: remote slicing uses trusted SLICER_API_URL and BAMBU_PROFILES_ROOT configuration; slicer_path is unsupported.`);
1374
+ }
1360
1375
  const resolvedSlicerPath = this.resolveExecutableSelectorArg(
1361
1376
  args.slicer_path,
1362
1377
  toolName,
@@ -1397,10 +1412,10 @@ class BambuPrinterMCPServer {
1397
1412
 
1398
1413
  console.error(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
1399
1414
  const autoSliceOptions: BambuSliceOptions = {
1400
- uptodate: true,
1401
- ensureOnBed: true,
1402
- minSave: true,
1403
- skipModifiedGcodes: true,
1415
+ ...(slicerType === "orca-slicer-api" ? {} : {
1416
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
1417
+ }),
1418
+ ...(slicerType === "orca-slicer-api" ? { slicePlate: Number(args.plate_index ?? 0) + 1 } : {}),
1404
1419
  bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
1405
1420
  nozzleType: resolveNozzleType(args?.nozzle_type),
1406
1421
  nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
@@ -1426,9 +1441,10 @@ class BambuPrinterMCPServer {
1426
1441
  ): Promise<{ useAMS: boolean; finalAmsMapping?: number[]; finalAmsSlots?: number[] }> {
1427
1442
  const parsed3MFData = await parse3MF(threeMFPath);
1428
1443
  let parsedAmsMapping: number[] | undefined;
1444
+ const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
1429
1445
 
1430
1446
  if (parsed3MFData.slicerConfig?.ams_mapping) {
1431
- const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>);
1447
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
1432
1448
  if (slots.length > 0) {
1433
1449
  parsedAmsMapping = slots;
1434
1450
  }
@@ -1444,10 +1460,10 @@ class BambuPrinterMCPServer {
1444
1460
  let userMappingOverride: number[] | undefined;
1445
1461
  if (Array.isArray(args.ams_mapping)) {
1446
1462
  userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
1447
- normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`)
1463
+ normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions)
1448
1464
  );
1449
1465
  } else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
1450
- userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>);
1466
+ userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
1451
1467
  }
1452
1468
  if (userMappingOverride && userMappingOverride.length > 0) {
1453
1469
  finalAmsMapping = userMappingOverride;
@@ -1458,7 +1474,7 @@ class BambuPrinterMCPServer {
1458
1474
 
1459
1475
  if (!hasUserAmsMapping && hasUserAmsSlots) {
1460
1476
  const userSlots = args.ams_slots.map((slot: unknown, i: number) =>
1461
- normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]`)
1477
+ normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]`, trayOptions)
1462
1478
  );
1463
1479
  finalAmsSlots = userSlots;
1464
1480
  finalAmsMapping = undefined;
@@ -1487,8 +1503,8 @@ class BambuPrinterMCPServer {
1487
1503
  }
1488
1504
 
1489
1505
  if (args?.use_ams === false) {
1490
- finalAmsMapping = undefined;
1491
- finalAmsSlots = undefined;
1506
+ if (!hasUserAmsMapping) finalAmsMapping = undefined;
1507
+ if (!hasUserAmsSlots) finalAmsSlots = undefined;
1492
1508
  useAMS = false;
1493
1509
  }
1494
1510
  if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
@@ -1589,8 +1605,16 @@ class BambuPrinterMCPServer {
1589
1605
  inspection.usedFilamentPositions.forEach((position, index) => { mapping![position] = finalAmsSlots[index]; });
1590
1606
  }
1591
1607
  if (!useAMS) {
1592
- mapping = Array(inspection.materials.length).fill(-1);
1593
- inspection.usedFilamentPositions.forEach(position => { mapping![position] = 254; });
1608
+ mapping ??= Array(inspection.materials.length).fill(-1);
1609
+ const dualNozzle = isDualNozzleModel(printModel);
1610
+ for (const position of inspection.usedFilamentPositions) {
1611
+ if (mapping[position] === undefined || mapping[position] < 0) {
1612
+ mapping[position] = externalTrayForNozzle(inspection.filamentNozzleIndices?.[position], dualNozzle);
1613
+ }
1614
+ if (mapping[position] !== 254 && !(dualNozzle && mapping[position] === 255)) {
1615
+ throw new Error("use_ams:false requires an external-spool mapping for every used filament.");
1616
+ }
1617
+ }
1594
1618
  }
1595
1619
  const requirements = { ...inspection, amsMapping: mapping, useAMS };
1596
1620
  const initialStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
@@ -1599,7 +1623,7 @@ class BambuPrinterMCPServer {
1599
1623
  const confirmedStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
1600
1624
  validatePrinterState(confirmedStatus, requirements);
1601
1625
  this.bambu.assertBedClearance(confirmedStatus, bedClearance);
1602
- const amsMapping = stringifyBridgeJson(mapping);
1626
+ const amsMapping = stringifyBridgeJson(mapping?.map(value => bridgeAmsMappingValue(value, isDualNozzleModel(printModel))));
1603
1627
  const remoteFileName = uniquePrintName(threeMFPath);
1604
1628
  const params: Record<string, unknown> = {
1605
1629
  dev_id: devId,
@@ -1616,7 +1640,7 @@ class BambuPrinterMCPServer {
1616
1640
  ftp_file_md5: String(args?.ftp_file_md5 || ""),
1617
1641
  nozzle_mapping: stringifyBridgeJson(args?.nozzle_mapping) || "",
1618
1642
  ams_mapping: amsMapping || "",
1619
- ams_mapping2: stringifyBridgeJson(args?.ams_mapping2) || "",
1643
+ ams_mapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)) || "",
1620
1644
  ams_mapping_info: stringifyBridgeJson(args?.ams_mapping_info) || "",
1621
1645
  nozzles_info: stringifyBridgeJson(args?.nozzles_info) || "",
1622
1646
  connection_type: connectionType,
@@ -1657,7 +1681,7 @@ class BambuPrinterMCPServer {
1657
1681
  host: devIp, serial: devId, token: password || bambuToken, filePath: snapshot,
1658
1682
  projectName: String(params.task_name), presetName, remoteName: remoteFileName,
1659
1683
  plateIndex, bedType: printBedType, useAMS,
1660
- amsMapping, amsMapping2: nativeAmsMapping2(mapping),
1684
+ amsMapping, amsMapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)),
1661
1685
  bedLeveling: params.task_bed_leveling as boolean,
1662
1686
  flowCalibration: params.task_flow_cali as boolean,
1663
1687
  vibrationCalibration: params.task_vibration_cali as boolean,
@@ -1916,10 +1940,9 @@ class BambuPrinterMCPServer {
1916
1940
  );
1917
1941
  }
1918
1942
  const autoSliceOptions: BambuSliceOptions = {
1919
- uptodate: true,
1920
- ensureOnBed: true,
1921
- minSave: true,
1922
- skipModifiedGcodes: true,
1943
+ ...(slicerType === "orca-slicer-api" ? {} : {
1944
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
1945
+ }),
1923
1946
  bedType,
1924
1947
  };
1925
1948
 
@@ -2470,7 +2493,7 @@ class BambuPrinterMCPServer {
2470
2493
  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
2494
  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
2495
  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." }
2496
+ 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
2497
  },
2475
2498
  required: ["stl_path", "bambu_model"]
2476
2499
  }
@@ -2573,8 +2596,8 @@ class BambuPrinterMCPServer {
2573
2596
  nozzle_diameter: { type: "string", description: "Nozzle diameter in mm for auto-slicing (default: 0.4)." },
2574
2597
  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
2598
  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" } },
2599
+ 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" } },
2600
+ 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
2601
  ams_mapping_bridge: { type: "string", description: "Raw JSON string override for FULU PrintParams ams_mapping when the automatic array is not enough." },
2579
2602
  ams_mapping2: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping2, matching OrcaSlicer-bambulab's v1 AMS mapping field." },
2580
2603
  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 +3025,12 @@ class BambuPrinterMCPServer {
3002
3025
  use_ams: { type: "boolean", description: "Whether to use the AMS (default: auto-detect from 3MF)" },
3003
3026
  ams_mapping: {
3004
3027
  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.",
3028
+ 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
3029
  items: { type: "number" }
3007
3030
  },
3008
3031
  ams_slots: {
3009
3032
  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.",
3033
+ 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
3034
  items: { type: "number" }
3012
3035
  },
3013
3036
  auto_match_ams: {
@@ -3201,12 +3224,20 @@ class BambuPrinterMCPServer {
3201
3224
  // again once every older helper for this printer has exited, and only
3202
3225
  // that send counts. Either counted route succeeding is success. macOS
3203
3226
  // stays native-only.
3204
- const linuxEmergency = (native: () => Promise<any>, mqtt: () => Promise<any>) => async () => {
3227
+ const linuxEmergency = (native: () => Promise<any>, mqtt: (signal: AbortSignal) => Promise<any>) => async () => {
3205
3228
  if (process.platform !== "linux") return native();
3229
+ // An unreachable printer makes the MQTT connect retry forever; bound each
3230
+ // MQTT send so it cannot hold back a stop the native route already sent.
3231
+ const immediate = withEmergencyMqttTimeout(mqtt);
3206
3232
  const [nativeResult, immediateResult, mqttResult] = await Promise.allSettled([
3207
3233
  native(),
3208
- mqtt(),
3209
- interruptNativeOperations(bambuSerial).then(() => mqtt()),
3234
+ immediate,
3235
+ // Finish the first attempt before reusing its cached publisher: its
3236
+ // expiry must never close the later, counted send's transport.
3237
+ Promise.allSettled([interruptNativeOperations(bambuSerial), immediate]).then(results => {
3238
+ if (results[0].status === "rejected") throw results[0].reason;
3239
+ return withEmergencyMqttTimeout(mqtt);
3240
+ }),
3210
3241
  ]);
3211
3242
  const reason = (result: PromiseSettledResult<any>) =>
3212
3243
  result.status === "rejected" ? (result.reason instanceof Error ? result.reason.message : String(result.reason)) : undefined;
@@ -3480,7 +3511,7 @@ class BambuPrinterMCPServer {
3480
3511
  ? linuxEmergency(
3481
3512
  () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3482
3513
  messageJson: JSON.stringify({ print: { command: "stop", param: "", sequence_id: String(Date.now()) } }), qos: 1 }),
3483
- () => this.bambu.sendStopOverMqtt(host, bambuSerial, bambuToken))
3514
+ signal => this.bambu.sendStopOverMqtt(host, bambuSerial, bambuToken, signal))
3484
3515
  : undefined);
3485
3516
  break;
3486
3517
 
@@ -3640,7 +3671,7 @@ class BambuPrinterMCPServer {
3640
3671
  const native = () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3641
3672
  messageJson: buildBambuNativeTemperatureCommand(heater, target).messageJson }, { signal: extra.signal, assertActive, beforeDispatch });
3642
3673
  return target === 0
3643
- ? linuxEmergency(native, () => this.bambu.sendHeaterOffOverMqtt(host, bambuSerial, bambuToken, heater))()
3674
+ ? linuxEmergency(native, signal => this.bambu.sendHeaterOffOverMqtt(host, bambuSerial, bambuToken, heater, signal))()
3644
3675
  : native();
3645
3676
  }
3646
3677
  : undefined
@@ -4098,10 +4129,9 @@ class BambuPrinterMCPServer {
4098
4129
  }
4099
4130
  console.error(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
4100
4131
  const autoSliceOptions: BambuSliceOptions = {
4101
- uptodate: true,
4102
- ensureOnBed: true,
4103
- minSave: true,
4104
- skipModifiedGcodes: true,
4132
+ ...(slicerType === "orca-slicer-api" ? { slicePlate: plateIndex + 1 } : {
4133
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
4134
+ }),
4105
4135
  bedType: printBedType,
4106
4136
  nozzleType: resolveNozzleType(args?.nozzle_type),
4107
4137
  nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
@@ -4133,9 +4163,10 @@ class BambuPrinterMCPServer {
4133
4163
 
4134
4164
  const parsed3MFData = await parse3MF(threeMFPath);
4135
4165
  const isH2Print = H2_BAMBU_MODELS.has(printModel);
4166
+ const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
4136
4167
  let parsedAmsMapping: number[] | undefined;
4137
4168
  if (!isH2Print && parsed3MFData.slicerConfig?.ams_mapping) {
4138
- const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>);
4169
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
4139
4170
  if (slots.length > 0) {
4140
4171
  parsedAmsMapping = slots;
4141
4172
  }
@@ -4151,10 +4182,10 @@ class BambuPrinterMCPServer {
4151
4182
  let userMappingOverride: number[] | undefined;
4152
4183
  if (Array.isArray(args.ams_mapping)) {
4153
4184
  userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
4154
- normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`)
4185
+ normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions)
4155
4186
  );
4156
4187
  } else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
4157
- userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>);
4188
+ userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
4158
4189
  }
4159
4190
  if (userMappingOverride && userMappingOverride.length > 0) {
4160
4191
  finalAmsMapping = userMappingOverride;
@@ -4208,8 +4239,8 @@ class BambuPrinterMCPServer {
4208
4239
  }
4209
4240
 
4210
4241
  if (args?.use_ams === false) {
4211
- finalAmsMapping = undefined;
4212
- finalAmsSlots = undefined;
4242
+ if (!hasUserAmsMapping) finalAmsMapping = undefined;
4243
+ if (!hasUserAmsSlots) finalAmsSlots = undefined;
4213
4244
  useAMS = false;
4214
4245
  }
4215
4246
  if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {