bambu-printer-mcp 1.1.16 → 1.1.18

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/README.md CHANGED
@@ -906,7 +906,7 @@ The primary tool for starting a Bambu print. **Recommended input: a pre-sliced `
906
906
 
907
907
  `bambu_model` is **required** for model-specific routing and preset selection. It does not by itself validate pre-sliced G-code. BambuStudio, FULU, and Orca CLI preparation additionally require the exact model/nozzle machine preset and reject incomplete profiles. Using the wrong model can damage hardware. If `bambu_model` is not provided in the tool call and `BAMBU_MODEL` is not set in the environment, the server will ask you interactively via MCP elicitation (if your client supports it) or return a clear error.
908
908
 
909
- `bed_type` defaults to `textured_plate` if omitted. `ams_slots` is the preferred override input; `ams_mapping` remains the raw escape hatch. On AMS-equipped H2 printers, `use_ams: false` does not suppress mapping lookup if the sliced file declares filaments. If no mapping is provided for an H2 pre-sliced job with declared filaments, the server fails before sending; pass explicit `ams_slots`, raw `ams_mapping`, or `auto_match_ams: true`.
909
+ `bed_type` defaults to `textured_plate` if omitted. `nozzle_type` (`stainless_steel`, `hardened_steel`, `tungsten_carbide`, `brass`; default `BAMBU_NOZZLE_TYPE`) sets the installed nozzle when the project must be auto-sliced; the job's nozzle type must match the printer's report, and stock P1S/P1P/A1 presets assume stainless steel. After an MQTT print command the server listens to the printer's pushed reports for up to 15 seconds (`BAMBU_DISPATCH_CHECK_MS`) and returns `dispatch: "started"` or `"unconfirmed"`; if firmware 01.08.05+ refuses the command (HMS 0500-0500-0001-0007, needs LAN Only Mode and Developer Mode), the call fails and says so; clear that fatal HMS entry with `clear_hms_errors` before printing again. `ams_slots` is the preferred override input; `ams_mapping` remains the raw escape hatch. On AMS-equipped H2 printers, `use_ams: false` does not suppress mapping lookup if the sliced file declares filaments. If no mapping is provided for an H2 pre-sliced job with declared filaments, the server fails before sending; pass explicit `ams_slots`, raw `ams_mapping`, or `auto_match_ams: true`.
910
910
 
911
911
  Set `auto_match_ams: true` to match the sliced 3MF's `tray_info_idx` values against the live AMS inventory and use the matching `ams_slots`. The matcher joins on `(tray_info_idx, tray_color)` and tracks already-claimed slots, so prints with two filaments of the same SKU but different colors (e.g. two GFG02 PETG HF in black and white) resolve correctly. Falls back to SKU-only when the 3MF's filament has no color set or only one tray of that SKU is loaded. Returns a structured `missing` report (`reason: "no_loaded_match" | "color_mismatch" | "exhausted" | "no_sku"`) when a filament can't be resolved. Ignored when you provide `ams_slots` or `ams_mapping` explicitly.
912
912
 
package/dist/index.js CHANGED
@@ -77,6 +77,18 @@ const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "
77
77
  // jobs take the correct branch instead of the legacy 5-slot mapping.
78
78
  const H2_BAMBU_MODELS = new Set(["h2d", "h2s", "h2c", "x2d"]);
79
79
  const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"];
80
+ const VALID_NOZZLE_TYPES = ["stainless_steel", "hardened_steel", "tungsten_carbide", "brass"];
81
+ /** The installed hotend material. Bambu machine presets assume the stock nozzle; the print gate compares this with the printer. */
82
+ function resolveNozzleType(value) {
83
+ const raw = value === undefined || value === null || value === "" ? process.env.BAMBU_NOZZLE_TYPE?.trim() : value;
84
+ if (raw === undefined || raw === "")
85
+ return undefined;
86
+ const normalized = String(raw).trim().toLowerCase().replace(/[\s-]+/g, "_");
87
+ if (!VALID_NOZZLE_TYPES.includes(normalized)) {
88
+ throw new Error(`Invalid nozzle type "${raw}". Valid types: ${VALID_NOZZLE_TYPES.join(", ")}`);
89
+ }
90
+ return normalized;
91
+ }
80
92
  const VALID_BAMBUSTUDIO_CLI_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"];
81
93
  // Map model IDs to BambuStudio --load-machine preset names
82
94
  const BAMBU_MODEL_PRESETS = {
@@ -497,6 +509,30 @@ function parseBooleanEnv(rawValue, fallback) {
497
509
  return false;
498
510
  return fallback;
499
511
  }
512
+ // Someone confirming a hardware action may have to walk to the printer, so the
513
+ // SDK's 60-second request default is too short for human confirmation.
514
+ const DEFAULT_CONFIRMATION_TIMEOUT_MS = 600000;
515
+ const MIN_CONFIRMATION_TIMEOUT_MS = 1000;
516
+ const MAX_CONFIRMATION_TIMEOUT_MS = 3600000;
517
+ /** Out-of-range values fall back to the default; Node clamps huge timer delays to 1 ms. */
518
+ function parseConfirmationTimeout(value) {
519
+ const parsed = Number(value?.trim() || NaN);
520
+ return Number.isInteger(parsed) && parsed >= MIN_CONFIRMATION_TIMEOUT_MS && parsed <= MAX_CONFIRMATION_TIMEOUT_MS
521
+ ? parsed
522
+ : DEFAULT_CONFIRMATION_TIMEOUT_MS;
523
+ }
524
+ function describeDuration(ms) {
525
+ return ms >= 60000 ? `${Math.round(ms / 6000) / 10} minute(s)` : `${Math.round(ms / 100) / 10} second(s)`;
526
+ }
527
+ function isElicitationTimeout(error) {
528
+ return error?.code === ErrorCode.RequestTimeout;
529
+ }
530
+ /** Only a missing method or capability means the client cannot show the prompt. */
531
+ function isElicitationUnsupported(error) {
532
+ const message = String(error?.message ?? error);
533
+ return error?.code === ErrorCode.MethodNotFound || error?.code === ErrorCode.InvalidRequest ||
534
+ /does not support (form |url )?elicitation/i.test(message);
535
+ }
500
536
  function parsePort(value, fallback) {
501
537
  if (!value)
502
538
  return fallback;
@@ -822,6 +858,7 @@ class BambuPrinterMCPServer {
822
858
  constructor() {
823
859
  this.blender = new BlenderMcpBridge();
824
860
  this.shuttingDown = false;
861
+ this.confirmationTimeoutMs = parseConfirmationTimeout(process.env.BAMBU_CONFIRMATION_TIMEOUT_MS);
825
862
  this.runtimeConfig = readRuntimeConfig();
826
863
  this.server = new Server({
827
864
  name: "bambu-printer-mcp",
@@ -842,11 +879,17 @@ class BambuPrinterMCPServer {
842
879
  properties: { confirmed: { type: "boolean", title: "I checked the printer and confirm this operation", default: false } },
843
880
  required: ["confirmed"],
844
881
  },
845
- });
882
+ }, { timeout: this.confirmationTimeoutMs });
846
883
  return response.action === "accept" && response.content?.confirmed === true;
847
884
  }
848
- catch {
849
- throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
885
+ catch (error) {
886
+ if (isElicitationTimeout(error)) {
887
+ throw new Error(`No hardware confirmation within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. Retry when someone can check the printer, or raise BAMBU_CONFIRMATION_TIMEOUT_MS.`);
888
+ }
889
+ if (isElicitationUnsupported(error)) {
890
+ throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
891
+ }
892
+ throw new Error(`Human hardware confirmation failed: ${error?.message ?? error}. Nothing was sent to the printer.`);
850
893
  }
851
894
  });
852
895
  this.bambuNetwork = new BambuNetworkBridge();
@@ -902,17 +945,19 @@ class BambuPrinterMCPServer {
902
945
  },
903
946
  required: ["bambu_model"],
904
947
  },
905
- });
948
+ }, { timeout: this.confirmationTimeoutMs });
906
949
  if (result.action === "accept" && result.content?.bambu_model) {
907
950
  return validateBambuModel(String(result.content.bambu_model));
908
951
  }
909
952
  throw new Error("Printer model selection was cancelled. Cannot proceed without knowing the printer model.");
910
953
  }
911
954
  catch (elicitError) {
955
+ if (isElicitationTimeout(elicitError)) {
956
+ throw new Error(`No printer model was selected within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. ` +
957
+ "Set BAMBU_MODEL or pass bambu_model in the tool call.");
958
+ }
912
959
  // Elicitation not supported by this client — fall back to a clear error
913
- const msg = elicitError?.message || String(elicitError);
914
- if (elicitError?.code === -32601 || elicitError?.code === -32600 ||
915
- msg.includes("does not support") || msg.includes("elicitation")) {
960
+ if (isElicitationUnsupported(elicitError)) {
916
961
  throw new Error("bambu_model is required but your MCP client does not support elicitation. " +
917
962
  `Set the BAMBU_MODEL environment variable or pass bambu_model in the tool call. ` +
918
963
  `Valid models: ${VALID_BAMBU_MODELS.join(", ")}`);
@@ -977,6 +1022,7 @@ class BambuPrinterMCPServer {
977
1022
  minSave: true,
978
1023
  skipModifiedGcodes: true,
979
1024
  bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
1025
+ nozzleType: resolveNozzleType(args?.nozzle_type),
980
1026
  };
981
1027
  threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, slicerProfile || undefined, undefined, printPreset, autoSliceOptions);
982
1028
  console.log("Auto-sliced to: " + threeMFPath);
@@ -1773,6 +1819,11 @@ class BambuPrinterMCPServer {
1773
1819
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
1774
1820
  description: "Bed plate type for slicing (default: textured_plate). SuperTack is accepted only for pre-sliced print jobs until the BambuStudio CLI identifier is verified."
1775
1821
  },
1822
+ nozzle_type: {
1823
+ type: "string",
1824
+ enum: [...VALID_NOZZLE_TYPES],
1825
+ description: "Bambu-compatible slicing: the hotend nozzle material installed on the printer (default: BAMBU_NOZZLE_TYPE, else the model preset's stock nozzle, usually stainless_steel). Printing compares it with the printer's reported nozzle."
1826
+ },
1776
1827
  use_printer_filaments: { type: "boolean", description: "When true, and no explicit slicer profile or load_filaments override is provided, use the printer's current or first loaded AMS filament as the slicer filament profile." },
1777
1828
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
1778
1829
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -1823,6 +1874,11 @@ class BambuPrinterMCPServer {
1823
1874
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
1824
1875
  description: "Bed plate type for slicing (default: textured_plate). SuperTack is accepted only for pre-sliced print jobs until the BambuStudio CLI identifier is verified."
1825
1876
  },
1877
+ nozzle_type: {
1878
+ type: "string",
1879
+ enum: [...VALID_NOZZLE_TYPES],
1880
+ description: "Bambu-compatible slicing: the hotend nozzle material installed on the printer (default: BAMBU_NOZZLE_TYPE, else the model preset's stock nozzle, usually stainless_steel). Printing compares it with the printer's reported nozzle."
1881
+ },
1826
1882
  use_printer_filaments: { type: "boolean", description: "When true, and no explicit slicer profile or load_filaments override is provided, use the printer's current or first loaded AMS filament as the slicer filament profile. Template 3MF process settings can still be used at the same time." },
1827
1883
  uptodate: { type: "boolean", description: "Refresh 3MF preset configs to match the latest BambuStudio version. Use when slicing downloaded or older 3MF files to prevent stale-config failures." },
1828
1884
  repetitions: { type: "number", description: "Print N identical copies of the model. Each copy gets its own plate placement. Example: 3 prints three copies." },
@@ -1928,6 +1984,7 @@ class BambuPrinterMCPServer {
1928
1984
  username: { type: "string", description: "Printer username for LAN/local bridge methods; defaults to bblp." },
1929
1985
  password: { type: "string", description: "Printer password/access code override for LAN/local bridge methods." },
1930
1986
  bed_type: { type: "string", enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"], description: "Bed plate type currently installed (default: textured_plate)." },
1987
+ nozzle_type: { type: "string", enum: [...VALID_NOZZLE_TYPES], description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)." },
1931
1988
  plate_index: { type: "number", description: "Zero-based plate index to print from the sliced 3MF; converted to FULU's one-based PrintParams plate_index." },
1932
1989
  project_name: { type: "string", description: "Optional project name sent in FULU PrintParams; defaults to the 3MF filename without extension." },
1933
1990
  preset_name: { type: "string", description: "Optional preset name sent in FULU PrintParams; defaults to project plus one-based plate index." },
@@ -2333,6 +2390,11 @@ class BambuPrinterMCPServer {
2333
2390
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
2334
2391
  description: "Bed plate type currently installed (default: textured_plate)"
2335
2392
  },
2393
+ nozzle_type: {
2394
+ type: "string",
2395
+ enum: [...VALID_NOZZLE_TYPES],
2396
+ description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)."
2397
+ },
2336
2398
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2337
2399
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2338
2400
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
@@ -3031,6 +3093,7 @@ class BambuPrinterMCPServer {
3031
3093
  if (args?.filament_colours !== undefined)
3032
3094
  sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3033
3095
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
3096
+ sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3034
3097
  if (args?.enable_timelapse !== undefined)
3035
3098
  sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3036
3099
  if (args?.allow_mix_temp !== undefined)
@@ -3110,6 +3173,7 @@ class BambuPrinterMCPServer {
3110
3173
  if (args?.filament_colours !== undefined)
3111
3174
  sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3112
3175
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
3176
+ sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3113
3177
  if (args?.enable_timelapse !== undefined)
3114
3178
  sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3115
3179
  if (args?.allow_mix_temp !== undefined)
@@ -3212,6 +3276,7 @@ class BambuPrinterMCPServer {
3212
3276
  minSave: true,
3213
3277
  skipModifiedGcodes: true,
3214
3278
  bedType: printBedType,
3279
+ nozzleType: resolveNozzleType(args?.nozzle_type),
3215
3280
  };
3216
3281
  if (!explicitSlicerProfile) {
3217
3282
  try {
@@ -3314,7 +3379,7 @@ class BambuPrinterMCPServer {
3314
3379
  layerInspect: args?.layer_inspect !== undefined ? Boolean(args.layer_inspect) : undefined,
3315
3380
  timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : undefined,
3316
3381
  });
3317
- result = `Print command for ${threeMfFilename} sent successfully.`;
3382
+ // Report what the printer did, not merely that a command was published.
3318
3383
  break;
3319
3384
  }
3320
3385
  case "print_collar_charm": {
@@ -3415,10 +3480,14 @@ class BambuPrinterMCPServer {
3415
3480
  }
3416
3481
  catch (error) {
3417
3482
  const message = error instanceof Error ? error.message : String(error);
3483
+ // Refusals and printer rejections repeat unchanged; say what to fix instead of "try again".
3484
+ const refused = /nothing was (?:sent|uploaded)|no command was sent|does not match the job|is ambiguous|is missing or ambiguous|rejected the print command|are refused|is refused|refused because|refused until|refused before/i.test(message);
3418
3485
  const structured = {
3419
3486
  status: "error",
3420
3487
  retryable: false,
3421
- suggestion: `Check parameters and try again. Error: ${message}`,
3488
+ suggestion: refused
3489
+ ? `Retrying unchanged repeats this refusal. Fix the reported cause (file, arguments, printer settings, or printer state) first. Error: ${message}`
3490
+ : `Check parameters and try again. Error: ${message}`,
3422
3491
  message,
3423
3492
  tool: name,
3424
3493
  };
@@ -94,14 +94,23 @@ export declare class BambuImplementation {
94
94
  status: string;
95
95
  uploaded: boolean;
96
96
  printRequested: boolean;
97
+ dispatch: "started" | "unconfirmed";
97
98
  remotePath: string;
98
99
  message: string;
99
100
  }>;
100
101
  private printRawPrepared;
102
+ /**
103
+ * A published print command is not proof the printer took it. Listen to the
104
+ * reports the printer pushes anyway (no extra pushall polling on weak P1
105
+ * boards): a new command-verification HMS means the firmware refused the
106
+ * command; PREPARE/SLICING/RUNNING means it started. Attach before publishing.
107
+ */
108
+ private watchDispatch;
101
109
  startJob(host: string, serial: string, token: string, filename: string, bambuModel?: string): Promise<{
102
110
  status: string;
103
111
  uploaded: boolean;
104
112
  printRequested: boolean;
113
+ dispatch: "started" | "unconfirmed";
105
114
  remotePath: string;
106
115
  message: string;
107
116
  }>;
@@ -36,6 +36,21 @@ function loadClientCreds() {
36
36
  }
37
37
  const CLIENT_CREDS = loadClientCreds();
38
38
  const COMMAND_SETTLE_MS = 300;
39
+ /** HMS 0500-0500-0001-0007: firmware 01.08.05+ rejected an unsigned MQTT command. */
40
+ const COMMAND_VERIFICATION_HMS = { attr: 0x05000500, code: 0x00010007 };
41
+ const COMMAND_REJECTED_MESSAGE = "The printer rejected the print command (HMS 0500-0500-0001-0007, \"MQTT command verification failed\"). " +
42
+ "Bambu firmware 01.08.05 and later only accept third-party LAN control with LAN Only Mode and Developer Mode " +
43
+ "enabled (Settings > WLAN on the printer). The checked file is on the printer's storage, but nothing is printing. " +
44
+ "The rejection stays on the printer as a fatal HMS entry that blocks later prints: after enabling those modes, " +
45
+ "clear it with clear_hms_errors or dismiss it on the printer's screen, then print again.";
46
+ const DISPATCH_CANCELLED_MESSAGE = "A stop or heater-off request arrived after the print command was sent, so this call does not report the print as started. " +
47
+ "Check get_printer_status.";
48
+ /** How long to watch fresh reports after a print command (BAMBU_DISPATCH_CHECK_MS, 0 disables). */
49
+ function dispatchCheckMs() {
50
+ const raw = process.env.BAMBU_DISPATCH_CHECK_MS?.trim();
51
+ const value = raw ? Number(raw) : 15000;
52
+ return Number.isInteger(value) && value >= 0 && value <= 60000 ? value : 15000;
53
+ }
39
54
  const MODEL_ID_TO_NAME = {
40
55
  O1C: "H2C",
41
56
  O1C2: "H2C",
@@ -685,9 +700,23 @@ export class BambuImplementation {
685
700
  const printer = await this.getPrinter(host, serial, token);
686
701
  assertActive();
687
702
  if (legacyContainer) {
688
- await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
703
+ const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
704
+ try {
705
+ await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
706
+ }
707
+ catch (error) {
708
+ watcher.dispose();
709
+ throw error;
710
+ }
689
711
  this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
690
- return { status: "success", message: `Uploaded and started gcode.3mf print: ${options.projectName}`, remoteProjectPath };
712
+ const dispatch = await watcher.settled();
713
+ return {
714
+ status: "success", dispatch, remoteProjectPath,
715
+ message: dispatch === "started"
716
+ ? `The printer accepted and started the checked gcode.3mf print: ${options.projectName}`
717
+ : `Uploaded the checked gcode.3mf and sent the print command for ${options.projectName}; the printer has not reported starting yet. ` +
718
+ "Check get_printer_status before assuming it is printing.",
719
+ };
691
720
  }
692
721
  const b = (v) => (v ? 1 : 0);
693
722
  let projectFileCmd;
@@ -747,12 +776,24 @@ export class BambuImplementation {
747
776
  },
748
777
  };
749
778
  }
750
- await printer.publish(projectFileCmd);
779
+ const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
780
+ try {
781
+ await printer.publish(projectFileCmd);
782
+ }
783
+ catch (error) {
784
+ watcher.dispose();
785
+ throw error;
786
+ }
751
787
  this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
752
788
  await new Promise((resolve) => setTimeout(resolve, 300));
789
+ const dispatch = await watcher.settled();
753
790
  return {
754
791
  status: "success",
755
- message: `Uploaded and started 3MF print: ${options.projectName}`,
792
+ dispatch,
793
+ message: dispatch === "started"
794
+ ? `The printer accepted and started the checked 3MF print: ${options.projectName}`
795
+ : `Uploaded the checked 3MF and sent the print command for ${options.projectName}; the printer has not reported starting yet. ` +
796
+ "Check get_printer_status before assuming it is printing.",
756
797
  remoteProjectPath,
757
798
  plateFile: projectMetadata.plateFileName,
758
799
  platePath: projectMetadata.plateInternalPath,
@@ -1190,9 +1231,105 @@ export class BambuImplementation {
1190
1231
  this.assertBedClearance(dispatchStatus, bedClearance);
1191
1232
  const printer = await this.getPrinter(host, serial, token);
1192
1233
  assertActive();
1193
- await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remotePath }));
1234
+ const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
1235
+ try {
1236
+ await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remotePath }));
1237
+ }
1238
+ catch (error) {
1239
+ watcher.dispose();
1240
+ throw error;
1241
+ }
1194
1242
  this.recordCheckedJob(host, serial, remotePath, requirements);
1195
- return { status: "success", uploaded: true, printRequested: true, remotePath, message: `Checked and started ${remotePath}.` };
1243
+ const dispatch = await watcher.settled();
1244
+ return {
1245
+ status: "success", uploaded: true, printRequested: true, dispatch, remotePath,
1246
+ message: dispatch === "started"
1247
+ ? `The printer accepted and started ${remotePath}.`
1248
+ : `Sent the print command for ${remotePath}; the printer has not reported starting yet. Check get_printer_status before assuming it is printing.`,
1249
+ };
1250
+ }
1251
+ /**
1252
+ * A published print command is not proof the printer took it. Listen to the
1253
+ * reports the printer pushes anyway (no extra pushall polling on weak P1
1254
+ * boards): a new command-verification HMS means the firmware refused the
1255
+ * command; PREPARE/SLICING/RUNNING means it started. Attach before publishing.
1256
+ */
1257
+ watchDispatch(printer, host, serial, token, before, assertActive) {
1258
+ const windowMs = dispatchCheckMs();
1259
+ if (windowMs === 0)
1260
+ return { settled: async () => { assertActive(); return "unconfirmed"; }, dispose: () => { } };
1261
+ const key = (entry) => `${Number(entry?.attr)}:${Number(entry?.code)}:${entry?.timestamp ?? ""}`;
1262
+ const known = new Set((Array.isArray(before?.raw?.hms) ? before.raw.hms : []).map(key));
1263
+ const judge = (report) => {
1264
+ const hms = Array.isArray(report?.hms) ? report.hms : [];
1265
+ if (hms.some((entry) => Number(entry?.attr) === COMMAND_VERIFICATION_HMS.attr &&
1266
+ Number(entry?.code) === COMMAND_VERIFICATION_HMS.code && !known.has(key(entry))))
1267
+ return "rejected";
1268
+ if (["PREPARE", "SLICING", "RUNNING"].includes(String(report?.gcode_state ?? "").toUpperCase()))
1269
+ return "started";
1270
+ return undefined;
1271
+ };
1272
+ let resolveOutcome;
1273
+ const outcome = new Promise((resolve) => { resolveOutcome = resolve; });
1274
+ const onRaw = (topic, payload) => {
1275
+ if (topic !== `device/${serial}/report`)
1276
+ return;
1277
+ let parsed;
1278
+ try {
1279
+ parsed = JSON.parse(payload.toString());
1280
+ }
1281
+ catch {
1282
+ return;
1283
+ }
1284
+ const verdict = judge(parsed?.print);
1285
+ if (verdict)
1286
+ resolveOutcome(verdict);
1287
+ };
1288
+ const cancelled = () => { try {
1289
+ assertActive();
1290
+ return false;
1291
+ }
1292
+ catch {
1293
+ return true;
1294
+ } };
1295
+ printer?.on?.("rawMessage", onRaw);
1296
+ const timer = setTimeout(() => resolveOutcome(undefined), windowMs);
1297
+ // A stop during the window must not end as "accepted and started".
1298
+ const cancelCheck = setInterval(() => { if (cancelled())
1299
+ resolveOutcome("cancelled"); }, 250);
1300
+ const dispose = () => {
1301
+ clearTimeout(timer);
1302
+ clearInterval(cancelCheck);
1303
+ printer?.off?.("rawMessage", onRaw);
1304
+ resolveOutcome(undefined);
1305
+ };
1306
+ return {
1307
+ dispose,
1308
+ settled: async () => {
1309
+ let verdict;
1310
+ try {
1311
+ verdict = await outcome;
1312
+ }
1313
+ finally {
1314
+ dispose();
1315
+ }
1316
+ // Nothing pushed during the window: take one full report, not a polling loop.
1317
+ if (verdict === undefined && !cancelled()) {
1318
+ try {
1319
+ verdict = judge((await this.getSafetyStatus(host, serial, token))?.raw);
1320
+ }
1321
+ catch { /* stays unconfirmed */ }
1322
+ }
1323
+ if (verdict === "rejected") {
1324
+ // Nothing started, so there is no checked job to resume or clear later.
1325
+ this.checkedJobs.delete(`${host}\n${serial}`);
1326
+ throw new Error(COMMAND_REJECTED_MESSAGE);
1327
+ }
1328
+ if (verdict === "cancelled" || cancelled())
1329
+ throw new Error(DISPATCH_CANCELLED_MESSAGE);
1330
+ return verdict ?? "unconfirmed";
1331
+ },
1332
+ };
1196
1333
  }
1197
1334
  async startJob(host, serial, token, filename, bambuModel) {
1198
1335
  assertDirectPrintSupported(bambuModel, serial);
@@ -194,7 +194,10 @@ function validateErrors(raw, requireIdle) {
194
194
  // BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
195
195
  // Unknown severities fail closed; attr identifies the module and is not a severity bitmask.
196
196
  if (code === undefined || attr === undefined || !Number.isInteger(code) || !Number.isInteger(attr) || code < 0 || code > 0xffffffff || attr < 0 || attr > 0xffffffff || Math.floor(code / 65536) !== 4) {
197
- throw new Error("Printer reports actionable or unknown HMS errors; inspect and resolve them before proceeding.");
197
+ const hex = (value) => value.toString(16).toUpperCase().padStart(8, "0");
198
+ const label = code !== undefined && attr !== undefined && Number.isInteger(code) && Number.isInteger(attr) && code >= 0 && attr >= 0
199
+ ? ` (HMS ${hex(attr).slice(0, 4)}-${hex(attr).slice(4)}-${hex(code).slice(0, 4)}-${hex(code).slice(4)})` : "";
200
+ throw new Error(`Printer reports actionable or unknown HMS errors${label}; inspect and resolve them before proceeding.`);
198
201
  }
199
202
  }
200
203
  }
@@ -41,6 +41,7 @@ import crypto from "node:crypto";
41
41
  */
42
42
  async function buildNameIndex(profilesRoot, vendor, userProfileRoots = []) {
43
43
  const index = new Map();
44
+ index.malformed = [];
44
45
  const subdirs = ["machine", "process", "filament"];
45
46
  const directories = [
46
47
  ...subdirs.map(sub => path.join(profilesRoot, vendor, sub)),
@@ -72,11 +73,15 @@ async function buildNameIndex(profilesRoot, vendor, userProfileRoots = []) {
72
73
  data = JSON.parse(raw);
73
74
  }
74
75
  catch {
75
- // Malformed profile -- skip, don't poison the index.
76
+ // Malformed profile -- skip, don't poison the index. A reference to
77
+ // it still fails, and the error names the skipped file.
78
+ index.malformed.push(filePath);
76
79
  continue;
77
80
  }
78
- if (!data || typeof data !== 'object' || Array.isArray(data))
81
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
82
+ index.malformed.push(filePath);
79
83
  continue;
84
+ }
80
85
  const name = data["name"];
81
86
  if (typeof name !== "string" || name.length === 0)
82
87
  continue;
@@ -106,7 +111,7 @@ function flattenByName(leafName, index, visiting = new Set()) {
106
111
  }
107
112
  const entry = index.get(leafName);
108
113
  if (!entry) {
109
- throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.`);
114
+ throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.${describeMalformed(index)}`);
110
115
  }
111
116
  visiting.add(leafName);
112
117
  try {
@@ -116,6 +121,13 @@ function flattenByName(leafName, index, visiting = new Set()) {
116
121
  visiting.delete(leafName);
117
122
  }
118
123
  }
124
+ function describeMalformed(index) {
125
+ const malformed = index.malformed ?? [];
126
+ if (malformed.length === 0)
127
+ return "";
128
+ const shown = malformed.slice(0, 5).map((file) => path.basename(file)).join(", ");
129
+ return ` Skipped ${malformed.length} unreadable or malformed profile file(s): ${shown}${malformed.length > 5 ? ", ..." : ""}.`;
130
+ }
119
131
  function flattenData(data, index, visiting = new Set()) {
120
132
  const parent = data["inherits"];
121
133
  const merged = typeof parent === "string" && parent.length > 0
@@ -157,7 +169,7 @@ function applyIncludes(target, profile, index, visiting) {
157
169
  const entry = index.get(name);
158
170
  if (!entry) {
159
171
  throw new Error(`Profile "${String(profile["name"])}" includes "${name}", which is not in the index. ` +
160
- `Refusing to fall back to inherited defaults (wrong G-code for this printer).`);
172
+ `Refusing to fall back to inherited defaults (wrong G-code for this printer).${describeMalformed(index)}`);
161
173
  }
162
174
  for (const [key, value] of Object.entries(flattenByName(name, index, visiting))) {
163
175
  if (!INCLUDE_METADATA_KEYS.has(key))
@@ -38,6 +38,7 @@ export interface BambuSliceOptions {
38
38
  loadFilamentIds?: string;
39
39
  filamentColours?: string[];
40
40
  bedType?: string;
41
+ nozzleType?: string;
41
42
  enableTimelapse?: boolean;
42
43
  allowMixTemp?: boolean;
43
44
  scale?: number;
@@ -62,10 +63,20 @@ export declare class STLManipulator extends EventEmitter {
62
63
  private readJsonFile;
63
64
  private stripAbsoluteExtruderResets;
64
65
  private sanitizeProcessForOrca;
66
+ /** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
67
+ private useAbsoluteExtrusionForOrca;
65
68
  private writeTempJson;
66
69
  private resolveBambuLikeSettingsBundle;
67
70
  /** Resolve BBL dependencies before invoking the CLI; failures stop the slice. */
68
71
  private maybeFlattenBundle;
72
+ /**
73
+ * The CLI applies every key in every --load-settings/--load-filaments file,
74
+ * so machine settings carried by a process or filament file (for example a
75
+ * template 3MF's project_settings from another printer) would replace the
76
+ * selected preset's start G-code, model, and bed geometry. The machine
77
+ * preset owns those keys; drop them from the other profiles.
78
+ */
79
+ private isolateMachineSettings;
69
80
  /** --load-filaments is positional; a single override must cover every project slot. */
70
81
  private expandProjectFilaments;
71
82
  private resolveBambuStudioBedType;
@@ -100,6 +100,11 @@ function configuredBambuProfileDirs() {
100
100
  ];
101
101
  return roots.flatMap((root) => appNames.map((appName) => path.join(root, appName, 'system', 'BBL')));
102
102
  }
103
+ /** Keys every profile kind carries; they describe the file, not machine hardware. */
104
+ const PROFILE_METADATA_KEYS = new Set([
105
+ 'name', 'type', 'from', 'inherits', 'instantiation', 'setting_id', 'version', 'is_custom_defined',
106
+ 'compatible_printers', 'compatible_printers_condition', 'compatible_prints', 'compatible_prints_condition',
107
+ ]);
103
108
  export class STLManipulator extends EventEmitter {
104
109
  constructor(tempDir = path.join(process.cwd(), 'temp')) {
105
110
  super();
@@ -193,11 +198,16 @@ export class STLManipulator extends EventEmitter {
193
198
  else if (sanitized.prime_tower_brim_width !== undefined) {
194
199
  sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
195
200
  }
196
- sanitized.use_relative_e_distances = '0';
197
- sanitized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(sanitized.before_layer_change_gcode);
198
- sanitized.layer_gcode = this.stripAbsoluteExtruderResets(sanitized.layer_gcode);
199
- sanitized.layer_change_gcode = this.stripAbsoluteExtruderResets(sanitized.layer_change_gcode);
200
- return sanitized;
201
+ return this.useAbsoluteExtrusionForOrca(sanitized);
202
+ }
203
+ /** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
204
+ useAbsoluteExtrusionForOrca(config) {
205
+ const normalized = { ...config };
206
+ normalized.use_relative_e_distances = '0';
207
+ normalized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(normalized.before_layer_change_gcode);
208
+ normalized.layer_gcode = this.stripAbsoluteExtruderResets(normalized.layer_gcode);
209
+ normalized.layer_change_gcode = this.stripAbsoluteExtruderResets(normalized.layer_change_gcode);
210
+ return normalized;
201
211
  }
202
212
  writeTempJson(outputBase, suffix, value) {
203
213
  const serialized = JSON.stringify(value, null, 2);
@@ -426,6 +436,36 @@ export class STLManipulator extends EventEmitter {
426
436
  : withCliFields(bundle.filamentPaths[i], profile, flat.filamentPaths[i], ['filament_colour'])),
427
437
  };
428
438
  }
439
+ /**
440
+ * The CLI applies every key in every --load-settings/--load-filaments file,
441
+ * so machine settings carried by a process or filament file (for example a
442
+ * template 3MF's project_settings from another printer) would replace the
443
+ * selected preset's start G-code, model, and bed geometry. The machine
444
+ * preset owns those keys; drop them from the other profiles.
445
+ */
446
+ isolateMachineSettings(bundle) {
447
+ const [machinePath, processPath] = bundle.settingsArg?.split(';') ?? [];
448
+ if (!machinePath || !processPath)
449
+ return bundle;
450
+ const machineKeys = Object.keys(this.readJsonFile(machinePath)).filter(key => !PROFILE_METADATA_KEYS.has(key));
451
+ const isolate = (filePath, label) => {
452
+ const profile = this.readJsonFile(filePath);
453
+ const foreign = machineKeys.filter(key => Object.prototype.hasOwnProperty.call(profile, key));
454
+ if (foreign.length === 0)
455
+ return filePath;
456
+ const isolated = { ...profile };
457
+ for (const key of foreign)
458
+ delete isolated[key];
459
+ console.error(`Ignoring ${foreign.length} machine setting(s) in ${label} profile ${filePath} ` +
460
+ `(the selected machine preset owns them): ${foreign.slice(0, 8).join(', ')}${foreign.length > 8 ? ', ...' : ''}`);
461
+ return this.writeTempJson(path.basename(filePath, '.json'), `${label}_without_machine`, isolated);
462
+ };
463
+ return {
464
+ ...bundle,
465
+ settingsArg: [machinePath, isolate(processPath, 'process')].join(';'),
466
+ filamentPaths: bundle.filamentPaths.map(filePath => isolate(filePath, 'filament')),
467
+ };
468
+ }
429
469
  /** --load-filaments is positional; a single override must cover every project slot. */
430
470
  async expandProjectFilaments(inputPath, bundle) {
431
471
  if (!inputPath.toLowerCase().endsWith('.3mf') || bundle.filamentPaths.length === 0)
@@ -1242,19 +1282,38 @@ export class STLManipulator extends EventEmitter {
1242
1282
  const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, slicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot);
1243
1283
  // Resolve every BBL dependency before launching the slicer.
1244
1284
  const settingsBundle = await this.maybeFlattenBundle(await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
1285
+ // The machine preset assumes the stock hotend; record the installed one so
1286
+ // the sliced job's nozzle_type matches what the printer reports.
1287
+ if (bambuOptions?.nozzleType && settingsBundle.settingsArg) {
1288
+ const [machinePath, ...rest] = settingsBundle.settingsArg.split(';');
1289
+ const machine = this.readJsonFile(machinePath);
1290
+ const current = machine.nozzle_type;
1291
+ machine.nozzle_type = Array.isArray(current) && current.length > 0
1292
+ ? current.map(() => bambuOptions.nozzleType)
1293
+ : [bambuOptions.nozzleType];
1294
+ settingsBundle.settingsArg = [
1295
+ this.writeTempJson(outputBase, `machine_${bambuOptions.nozzleType}`, machine), ...rest,
1296
+ ].join(';');
1297
+ }
1245
1298
  // Inherited process G-code is now present; apply Orca's existing
1246
1299
  // absolute-extrusion normalization after resolving those ancestors.
1300
+ // The machine owns the extrusion mode and layer G-code once process
1301
+ // files lose machine keys, so normalize it there as well.
1247
1302
  if (slicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1248
1303
  const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
1249
- settingsBundle.settingsArg = [machinePath, this.writeTempJson(outputBase, 'process_orca_resolved', this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset))].join(';');
1304
+ settingsBundle.settingsArg = [
1305
+ this.writeTempJson(outputBase, 'machine_orca_resolved', this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
1306
+ this.writeTempJson(outputBase, 'process_orca_resolved', this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
1307
+ ].join(';');
1250
1308
  }
1309
+ const cliBundle = this.isolateMachineSettings(settingsBundle);
1251
1310
  args = [
1252
1311
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1253
1312
  '--outputdir', outputDir,
1254
1313
  '--export-3mf', path.basename(bambuOutputPath),
1255
1314
  ];
1256
- if (settingsBundle.settingsArg) {
1257
- args.push('--load-settings', settingsBundle.settingsArg);
1315
+ if (cliBundle.settingsArg) {
1316
+ args.push('--load-settings', cliBundle.settingsArg);
1258
1317
  }
1259
1318
  // Always allow newer-version 3MF files (the CLI rejects them by default)
1260
1319
  args.push('--allow-newer-file');
@@ -1289,8 +1348,8 @@ export class STLManipulator extends EventEmitter {
1289
1348
  args.push('--clone-objects', bambuOptions.cloneObjects);
1290
1349
  if (bambuOptions?.skipObjects)
1291
1350
  args.push('--skip-objects', bambuOptions.skipObjects);
1292
- if (settingsBundle.filamentPaths.length > 0) {
1293
- args.push('--load-filaments', settingsBundle.filamentPaths.join(';'));
1351
+ if (cliBundle.filamentPaths.length > 0) {
1352
+ args.push('--load-filaments', cliBundle.filamentPaths.join(';'));
1294
1353
  args.push('--load-defaultfila');
1295
1354
  }
1296
1355
  if (bambuOptions?.loadFilamentIds)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
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": {
package/src/index.ts CHANGED
@@ -111,6 +111,18 @@ type BambuModel = typeof VALID_BAMBU_MODELS[number];
111
111
  const H2_BAMBU_MODELS = new Set<string>(["h2d", "h2s", "h2c", "x2d"]);
112
112
 
113
113
  const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"] as const;
114
+ const VALID_NOZZLE_TYPES = ["stainless_steel", "hardened_steel", "tungsten_carbide", "brass"] as const;
115
+
116
+ /** The installed hotend material. Bambu machine presets assume the stock nozzle; the print gate compares this with the printer. */
117
+ function resolveNozzleType(value: unknown): string | undefined {
118
+ const raw = value === undefined || value === null || value === "" ? process.env.BAMBU_NOZZLE_TYPE?.trim() : value;
119
+ if (raw === undefined || raw === "") return undefined;
120
+ const normalized = String(raw).trim().toLowerCase().replace(/[\s-]+/g, "_");
121
+ if (!(VALID_NOZZLE_TYPES as readonly string[]).includes(normalized)) {
122
+ throw new Error(`Invalid nozzle type "${raw}". Valid types: ${VALID_NOZZLE_TYPES.join(", ")}`);
123
+ }
124
+ return normalized;
125
+ }
114
126
  const VALID_BAMBUSTUDIO_CLI_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"] as const;
115
127
 
116
128
  // Map model IDs to BambuStudio --load-machine preset names
@@ -689,6 +701,35 @@ function parseBooleanEnv(rawValue: string | undefined, fallback: boolean): boole
689
701
  return fallback;
690
702
  }
691
703
 
704
+ // Someone confirming a hardware action may have to walk to the printer, so the
705
+ // SDK's 60-second request default is too short for human confirmation.
706
+ const DEFAULT_CONFIRMATION_TIMEOUT_MS = 600_000;
707
+ const MIN_CONFIRMATION_TIMEOUT_MS = 1_000;
708
+ const MAX_CONFIRMATION_TIMEOUT_MS = 3_600_000;
709
+
710
+ /** Out-of-range values fall back to the default; Node clamps huge timer delays to 1 ms. */
711
+ function parseConfirmationTimeout(value: string | undefined): number {
712
+ const parsed = Number(value?.trim() || NaN);
713
+ return Number.isInteger(parsed) && parsed >= MIN_CONFIRMATION_TIMEOUT_MS && parsed <= MAX_CONFIRMATION_TIMEOUT_MS
714
+ ? parsed
715
+ : DEFAULT_CONFIRMATION_TIMEOUT_MS;
716
+ }
717
+
718
+ function describeDuration(ms: number): string {
719
+ return ms >= 60_000 ? `${Math.round(ms / 6_000) / 10} minute(s)` : `${Math.round(ms / 100) / 10} second(s)`;
720
+ }
721
+
722
+ function isElicitationTimeout(error: any): boolean {
723
+ return error?.code === ErrorCode.RequestTimeout;
724
+ }
725
+
726
+ /** Only a missing method or capability means the client cannot show the prompt. */
727
+ function isElicitationUnsupported(error: any): boolean {
728
+ const message = String(error?.message ?? error);
729
+ return error?.code === ErrorCode.MethodNotFound || error?.code === ErrorCode.InvalidRequest ||
730
+ /does not support (form |url )?elicitation/i.test(message);
731
+ }
732
+
692
733
  function parsePort(value: string | undefined, fallback: number): number {
693
734
  if (!value) return fallback;
694
735
  const parsed = Number(value);
@@ -1105,6 +1146,7 @@ class BambuPrinterMCPServer {
1105
1146
  private readonly runtimeConfig: RuntimeConfig;
1106
1147
  private httpRuntime?: { transport: StreamableHTTPServerTransport; httpServer: HttpServer };
1107
1148
  private shuttingDown = false;
1149
+ private readonly confirmationTimeoutMs = parseConfirmationTimeout(process.env.BAMBU_CONFIRMATION_TIMEOUT_MS);
1108
1150
 
1109
1151
  constructor() {
1110
1152
  this.runtimeConfig = readRuntimeConfig();
@@ -1131,10 +1173,16 @@ class BambuPrinterMCPServer {
1131
1173
  properties: { confirmed: { type: "boolean", title: "I checked the printer and confirm this operation", default: false } },
1132
1174
  required: ["confirmed"],
1133
1175
  },
1134
- });
1176
+ }, { timeout: this.confirmationTimeoutMs });
1135
1177
  return response.action === "accept" && response.content?.confirmed === true;
1136
- } catch {
1137
- throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
1178
+ } catch (error: any) {
1179
+ if (isElicitationTimeout(error)) {
1180
+ throw new Error(`No hardware confirmation within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. Retry when someone can check the printer, or raise BAMBU_CONFIRMATION_TIMEOUT_MS.`);
1181
+ }
1182
+ if (isElicitationUnsupported(error)) {
1183
+ throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
1184
+ }
1185
+ throw new Error(`Human hardware confirmation failed: ${error?.message ?? error}. Nothing was sent to the printer.`);
1138
1186
  }
1139
1187
  });
1140
1188
  this.bambuNetwork = new BambuNetworkBridge();
@@ -1196,7 +1244,7 @@ class BambuPrinterMCPServer {
1196
1244
  },
1197
1245
  required: ["bambu_model"],
1198
1246
  },
1199
- });
1247
+ }, { timeout: this.confirmationTimeoutMs });
1200
1248
 
1201
1249
  if (result.action === "accept" && result.content?.bambu_model) {
1202
1250
  return validateBambuModel(String(result.content.bambu_model));
@@ -1206,12 +1254,14 @@ class BambuPrinterMCPServer {
1206
1254
  "Printer model selection was cancelled. Cannot proceed without knowing the printer model."
1207
1255
  );
1208
1256
  } catch (elicitError: any) {
1257
+ if (isElicitationTimeout(elicitError)) {
1258
+ throw new Error(
1259
+ `No printer model was selected within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. ` +
1260
+ "Set BAMBU_MODEL or pass bambu_model in the tool call."
1261
+ );
1262
+ }
1209
1263
  // Elicitation not supported by this client — fall back to a clear error
1210
- const msg = elicitError?.message || String(elicitError);
1211
- if (
1212
- elicitError?.code === -32601 || elicitError?.code === -32600 ||
1213
- msg.includes("does not support") || msg.includes("elicitation")
1214
- ) {
1264
+ if (isElicitationUnsupported(elicitError)) {
1215
1265
  throw new Error(
1216
1266
  "bambu_model is required but your MCP client does not support elicitation. " +
1217
1267
  `Set the BAMBU_MODEL environment variable or pass bambu_model in the tool call. ` +
@@ -1320,6 +1370,7 @@ class BambuPrinterMCPServer {
1320
1370
  minSave: true,
1321
1371
  skipModifiedGcodes: true,
1322
1372
  bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
1373
+ nozzleType: resolveNozzleType(args?.nozzle_type),
1323
1374
  };
1324
1375
  threeMFPath = await this.stlManipulator.sliceSTL(
1325
1376
  threeMFPath, slicerType, slicerPath, slicerProfile || undefined,
@@ -2294,6 +2345,11 @@ class BambuPrinterMCPServer {
2294
2345
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
2295
2346
  description: "Bed plate type for slicing (default: textured_plate). SuperTack is accepted only for pre-sliced print jobs until the BambuStudio CLI identifier is verified."
2296
2347
  },
2348
+ nozzle_type: {
2349
+ type: "string",
2350
+ enum: [...VALID_NOZZLE_TYPES],
2351
+ description: "Bambu-compatible slicing: the hotend nozzle material installed on the printer (default: BAMBU_NOZZLE_TYPE, else the model preset's stock nozzle, usually stainless_steel). Printing compares it with the printer's reported nozzle."
2352
+ },
2297
2353
  use_printer_filaments: { type: "boolean", description: "When true, and no explicit slicer profile or load_filaments override is provided, use the printer's current or first loaded AMS filament as the slicer filament profile." },
2298
2354
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2299
2355
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -2344,6 +2400,11 @@ class BambuPrinterMCPServer {
2344
2400
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
2345
2401
  description: "Bed plate type for slicing (default: textured_plate). SuperTack is accepted only for pre-sliced print jobs until the BambuStudio CLI identifier is verified."
2346
2402
  },
2403
+ nozzle_type: {
2404
+ type: "string",
2405
+ enum: [...VALID_NOZZLE_TYPES],
2406
+ description: "Bambu-compatible slicing: the hotend nozzle material installed on the printer (default: BAMBU_NOZZLE_TYPE, else the model preset's stock nozzle, usually stainless_steel). Printing compares it with the printer's reported nozzle."
2407
+ },
2347
2408
  use_printer_filaments: { type: "boolean", description: "When true, and no explicit slicer profile or load_filaments override is provided, use the printer's current or first loaded AMS filament as the slicer filament profile. Template 3MF process settings can still be used at the same time." },
2348
2409
  uptodate: { type: "boolean", description: "Refresh 3MF preset configs to match the latest BambuStudio version. Use when slicing downloaded or older 3MF files to prevent stale-config failures." },
2349
2410
  repetitions: { type: "number", description: "Print N identical copies of the model. Each copy gets its own plate placement. Example: 3 prints three copies." },
@@ -2449,6 +2510,7 @@ class BambuPrinterMCPServer {
2449
2510
  username: { type: "string", description: "Printer username for LAN/local bridge methods; defaults to bblp." },
2450
2511
  password: { type: "string", description: "Printer password/access code override for LAN/local bridge methods." },
2451
2512
  bed_type: { type: "string", enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"], description: "Bed plate type currently installed (default: textured_plate)." },
2513
+ nozzle_type: { type: "string", enum: [...VALID_NOZZLE_TYPES], description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)." },
2452
2514
  plate_index: { type: "number", description: "Zero-based plate index to print from the sliced 3MF; converted to FULU's one-based PrintParams plate_index." },
2453
2515
  project_name: { type: "string", description: "Optional project name sent in FULU PrintParams; defaults to the 3MF filename without extension." },
2454
2516
  preset_name: { type: "string", description: "Optional preset name sent in FULU PrintParams; defaults to project plus one-based plate index." },
@@ -2854,6 +2916,11 @@ class BambuPrinterMCPServer {
2854
2916
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
2855
2917
  description: "Bed plate type currently installed (default: textured_plate)"
2856
2918
  },
2919
+ nozzle_type: {
2920
+ type: "string",
2921
+ enum: [...VALID_NOZZLE_TYPES],
2922
+ description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)."
2923
+ },
2857
2924
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2858
2925
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2859
2926
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
@@ -3681,6 +3748,7 @@ class BambuPrinterMCPServer {
3681
3748
  if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
3682
3749
  if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3683
3750
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3751
+ sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3684
3752
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3685
3753
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
3686
3754
  if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
@@ -3767,6 +3835,7 @@ class BambuPrinterMCPServer {
3767
3835
  if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
3768
3836
  if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3769
3837
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3838
+ sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3770
3839
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3771
3840
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
3772
3841
  if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
@@ -3894,6 +3963,7 @@ class BambuPrinterMCPServer {
3894
3963
  minSave: true,
3895
3964
  skipModifiedGcodes: true,
3896
3965
  bedType: printBedType,
3966
+ nozzleType: resolveNozzleType(args?.nozzle_type),
3897
3967
  };
3898
3968
  if (!explicitSlicerProfile) {
3899
3969
  try {
@@ -4024,7 +4094,7 @@ class BambuPrinterMCPServer {
4024
4094
  layerInspect: args?.layer_inspect !== undefined ? Boolean(args.layer_inspect) : undefined,
4025
4095
  timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : undefined,
4026
4096
  });
4027
- result = `Print command for ${threeMfFilename} sent successfully.`;
4097
+ // Report what the printer did, not merely that a command was published.
4028
4098
  break;
4029
4099
  }
4030
4100
 
@@ -4176,10 +4246,14 @@ class BambuPrinterMCPServer {
4176
4246
 
4177
4247
  } catch (error) {
4178
4248
  const message = error instanceof Error ? error.message : String(error);
4249
+ // Refusals and printer rejections repeat unchanged; say what to fix instead of "try again".
4250
+ const refused = /nothing was (?:sent|uploaded)|no command was sent|does not match the job|is ambiguous|is missing or ambiguous|rejected the print command|are refused|is refused|refused because|refused until|refused before/i.test(message);
4179
4251
  const structured: StructuredToolError = {
4180
4252
  status: "error",
4181
4253
  retryable: false,
4182
- suggestion: `Check parameters and try again. Error: ${message}`,
4254
+ suggestion: refused
4255
+ ? `Retrying unchanged repeats this refusal. Fix the reported cause (file, arguments, printer settings, or printer state) first. Error: ${message}`
4256
+ : `Check parameters and try again. Error: ${message}`,
4183
4257
  message,
4184
4258
  tool: name,
4185
4259
  };
@@ -45,6 +45,24 @@ function loadClientCreds(): { cert: Buffer; key: Buffer } | null {
45
45
  const CLIENT_CREDS = loadClientCreds();
46
46
 
47
47
  const COMMAND_SETTLE_MS = 300;
48
+ /** HMS 0500-0500-0001-0007: firmware 01.08.05+ rejected an unsigned MQTT command. */
49
+ const COMMAND_VERIFICATION_HMS = { attr: 0x05000500, code: 0x00010007 };
50
+ const COMMAND_REJECTED_MESSAGE =
51
+ "The printer rejected the print command (HMS 0500-0500-0001-0007, \"MQTT command verification failed\"). " +
52
+ "Bambu firmware 01.08.05 and later only accept third-party LAN control with LAN Only Mode and Developer Mode " +
53
+ "enabled (Settings > WLAN on the printer). The checked file is on the printer's storage, but nothing is printing. " +
54
+ "The rejection stays on the printer as a fatal HMS entry that blocks later prints: after enabling those modes, " +
55
+ "clear it with clear_hms_errors or dismiss it on the printer's screen, then print again.";
56
+ const DISPATCH_CANCELLED_MESSAGE =
57
+ "A stop or heater-off request arrived after the print command was sent, so this call does not report the print as started. " +
58
+ "Check get_printer_status.";
59
+
60
+ /** How long to watch fresh reports after a print command (BAMBU_DISPATCH_CHECK_MS, 0 disables). */
61
+ function dispatchCheckMs(): number {
62
+ const raw = process.env.BAMBU_DISPATCH_CHECK_MS?.trim();
63
+ const value = raw ? Number(raw) : 15_000;
64
+ return Number.isInteger(value) && value >= 0 && value <= 60_000 ? value : 15_000;
65
+ }
48
66
 
49
67
  const MODEL_ID_TO_NAME: Record<string, string> = {
50
68
  O1C: "H2C",
@@ -813,9 +831,17 @@ export class BambuImplementation {
813
831
  const printer = await this.getPrinter(host, serial, token);
814
832
  assertActive();
815
833
  if (legacyContainer) {
816
- await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
834
+ const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
835
+ try { await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath })); } catch (error) { watcher.dispose(); throw error; }
817
836
  this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
818
- return { status: "success", message: `Uploaded and started gcode.3mf print: ${options.projectName}`, remoteProjectPath };
837
+ const dispatch = await watcher.settled();
838
+ return {
839
+ status: "success", dispatch, remoteProjectPath,
840
+ message: dispatch === "started"
841
+ ? `The printer accepted and started the checked gcode.3mf print: ${options.projectName}`
842
+ : `Uploaded the checked gcode.3mf and sent the print command for ${options.projectName}; the printer has not reported starting yet. ` +
843
+ "Check get_printer_status before assuming it is printing.",
844
+ };
819
845
  }
820
846
 
821
847
  const b = (v: any) => (v ? 1 : 0);
@@ -876,13 +902,19 @@ export class BambuImplementation {
876
902
  };
877
903
  }
878
904
 
879
- await printer.publish(projectFileCmd);
905
+ const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
906
+ try { await printer.publish(projectFileCmd); } catch (error) { watcher.dispose(); throw error; }
880
907
  this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
881
908
  await new Promise((resolve) => setTimeout(resolve, 300));
909
+ const dispatch = await watcher.settled();
882
910
 
883
911
  return {
884
912
  status: "success",
885
- message: `Uploaded and started 3MF print: ${options.projectName}`,
913
+ dispatch,
914
+ message: dispatch === "started"
915
+ ? `The printer accepted and started the checked 3MF print: ${options.projectName}`
916
+ : `Uploaded the checked 3MF and sent the print command for ${options.projectName}; the printer has not reported starting yet. ` +
917
+ "Check get_printer_status before assuming it is printing.",
886
918
  remoteProjectPath,
887
919
  plateFile: projectMetadata.plateFileName,
888
920
  platePath: projectMetadata.plateInternalPath,
@@ -1392,9 +1424,76 @@ export class BambuImplementation {
1392
1424
  this.assertBedClearance(dispatchStatus, bedClearance);
1393
1425
  const printer = await this.getPrinter(host, serial, token);
1394
1426
  assertActive();
1395
- await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remotePath }));
1427
+ const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
1428
+ try { await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remotePath })); } catch (error) { watcher.dispose(); throw error; }
1396
1429
  this.recordCheckedJob(host, serial, remotePath, requirements);
1397
- return { status: "success", uploaded: true, printRequested: true, remotePath, message: `Checked and started ${remotePath}.` };
1430
+ const dispatch = await watcher.settled();
1431
+ return {
1432
+ status: "success", uploaded: true, printRequested: true, dispatch, remotePath,
1433
+ message: dispatch === "started"
1434
+ ? `The printer accepted and started ${remotePath}.`
1435
+ : `Sent the print command for ${remotePath}; the printer has not reported starting yet. Check get_printer_status before assuming it is printing.`,
1436
+ };
1437
+ }
1438
+
1439
+ /**
1440
+ * A published print command is not proof the printer took it. Listen to the
1441
+ * reports the printer pushes anyway (no extra pushall polling on weak P1
1442
+ * boards): a new command-verification HMS means the firmware refused the
1443
+ * command; PREPARE/SLICING/RUNNING means it started. Attach before publishing.
1444
+ */
1445
+ private watchDispatch(printer: any, host: string, serial: string, token: string, before: any, assertActive: () => void):
1446
+ { settled: () => Promise<"started" | "unconfirmed">; dispose: () => void } {
1447
+ const windowMs = dispatchCheckMs();
1448
+ if (windowMs === 0) return { settled: async () => { assertActive(); return "unconfirmed"; }, dispose: () => {} };
1449
+ const key = (entry: any) => `${Number(entry?.attr)}:${Number(entry?.code)}:${entry?.timestamp ?? ""}`;
1450
+ const known = new Set((Array.isArray(before?.raw?.hms) ? before.raw.hms : []).map(key));
1451
+ const judge = (report: any): "started" | "rejected" | undefined => {
1452
+ const hms = Array.isArray(report?.hms) ? report.hms : [];
1453
+ if (hms.some((entry: any) => Number(entry?.attr) === COMMAND_VERIFICATION_HMS.attr &&
1454
+ Number(entry?.code) === COMMAND_VERIFICATION_HMS.code && !known.has(key(entry)))) return "rejected";
1455
+ if (["PREPARE", "SLICING", "RUNNING"].includes(String(report?.gcode_state ?? "").toUpperCase())) return "started";
1456
+ return undefined;
1457
+ };
1458
+ type Verdict = "started" | "rejected" | "cancelled" | undefined;
1459
+ let resolveOutcome!: (value: Verdict) => void;
1460
+ const outcome = new Promise<Verdict>((resolve) => { resolveOutcome = resolve; });
1461
+ const onRaw = (topic: string, payload: Buffer) => {
1462
+ if (topic !== `device/${serial}/report`) return;
1463
+ let parsed: any;
1464
+ try { parsed = JSON.parse(payload.toString()); } catch { return; }
1465
+ const verdict = judge(parsed?.print);
1466
+ if (verdict) resolveOutcome(verdict);
1467
+ };
1468
+ const cancelled = () => { try { assertActive(); return false; } catch { return true; } };
1469
+ printer?.on?.("rawMessage", onRaw);
1470
+ const timer = setTimeout(() => resolveOutcome(undefined), windowMs);
1471
+ // A stop during the window must not end as "accepted and started".
1472
+ const cancelCheck = setInterval(() => { if (cancelled()) resolveOutcome("cancelled"); }, 250);
1473
+ const dispose = () => {
1474
+ clearTimeout(timer);
1475
+ clearInterval(cancelCheck);
1476
+ printer?.off?.("rawMessage", onRaw);
1477
+ resolveOutcome(undefined);
1478
+ };
1479
+ return {
1480
+ dispose,
1481
+ settled: async () => {
1482
+ let verdict: Verdict;
1483
+ try { verdict = await outcome; } finally { dispose(); }
1484
+ // Nothing pushed during the window: take one full report, not a polling loop.
1485
+ if (verdict === undefined && !cancelled()) {
1486
+ try { verdict = judge((await this.getSafetyStatus(host, serial, token))?.raw); } catch { /* stays unconfirmed */ }
1487
+ }
1488
+ if (verdict === "rejected") {
1489
+ // Nothing started, so there is no checked job to resume or clear later.
1490
+ this.checkedJobs.delete(`${host}\n${serial}`);
1491
+ throw new Error(COMMAND_REJECTED_MESSAGE);
1492
+ }
1493
+ if (verdict === "cancelled" || cancelled()) throw new Error(DISPATCH_CANCELLED_MESSAGE);
1494
+ return verdict ?? "unconfirmed";
1495
+ },
1496
+ };
1398
1497
  }
1399
1498
 
1400
1499
  async startJob(host: string, serial: string, token: string, filename: string, bambuModel?: string) {
@@ -190,7 +190,10 @@ function validateErrors(raw: Record<string,any>, requireIdle: boolean) {
190
190
  // BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
191
191
  // Unknown severities fail closed; attr identifies the module and is not a severity bitmask.
192
192
  if (code === undefined || attr === undefined || !Number.isInteger(code) || !Number.isInteger(attr) || code < 0 || code > 0xffffffff || attr < 0 || attr > 0xffffffff || Math.floor(code / 65536) !== 4) {
193
- throw new Error("Printer reports actionable or unknown HMS errors; inspect and resolve them before proceeding.");
193
+ const hex = (value: number) => value.toString(16).toUpperCase().padStart(8, "0");
194
+ const label = code !== undefined && attr !== undefined && Number.isInteger(code) && Number.isInteger(attr) && code >= 0 && attr >= 0
195
+ ? ` (HMS ${hex(attr).slice(0, 4)}-${hex(attr).slice(4)}-${hex(code).slice(0, 4)}-${hex(code).slice(4)})` : "";
196
+ throw new Error(`Printer reports actionable or unknown HMS errors${label}; inspect and resolve them before proceeding.`);
194
197
  }
195
198
  }
196
199
  }
@@ -95,7 +95,8 @@ interface IndexedProfile {
95
95
  data: Record<string, unknown>;
96
96
  }
97
97
 
98
- type NameIndex = Map<string, IndexedProfile>;
98
+ /** Name index plus unreadable JSON files, reported when a name is missing. */
99
+ type NameIndex = Map<string, IndexedProfile> & { malformed?: string[] };
99
100
 
100
101
  /* -------------------------------------------------------------------------- */
101
102
  /* Indexing */
@@ -116,6 +117,7 @@ async function buildNameIndex(
116
117
  userProfileRoots: string[] = []
117
118
  ): Promise<NameIndex> {
118
119
  const index: NameIndex = new Map();
120
+ index.malformed = [];
119
121
  const subdirs: ProfileKind[] = ["machine", "process", "filament"];
120
122
 
121
123
  const directories = [
@@ -144,10 +146,15 @@ async function buildNameIndex(
144
146
  try {
145
147
  data = JSON.parse(raw) as Record<string, unknown>;
146
148
  } catch {
147
- // Malformed profile -- skip, don't poison the index.
149
+ // Malformed profile -- skip, don't poison the index. A reference to
150
+ // it still fails, and the error names the skipped file.
151
+ index.malformed!.push(filePath);
152
+ continue;
153
+ }
154
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
155
+ index.malformed!.push(filePath);
148
156
  continue;
149
157
  }
150
- if (!data || typeof data !== 'object' || Array.isArray(data)) continue;
151
158
  const name = data["name"];
152
159
  if (typeof name !== "string" || name.length === 0) continue;
153
160
  // First-write wins. BBL doesn't have name collisions in practice;
@@ -183,7 +190,7 @@ function flattenByName(
183
190
  }
184
191
  const entry = index.get(leafName);
185
192
  if (!entry) {
186
- throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.`);
193
+ throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.${describeMalformed(index)}`);
187
194
  }
188
195
  visiting.add(leafName);
189
196
  try {
@@ -193,6 +200,13 @@ function flattenByName(
193
200
  }
194
201
  }
195
202
 
203
+ function describeMalformed(index: NameIndex): string {
204
+ const malformed = index.malformed ?? [];
205
+ if (malformed.length === 0) return "";
206
+ const shown = malformed.slice(0, 5).map((file) => path.basename(file)).join(", ");
207
+ return ` Skipped ${malformed.length} unreadable or malformed profile file(s): ${shown}${malformed.length > 5 ? ", ..." : ""}.`;
208
+ }
209
+
196
210
  function flattenData(
197
211
  data: Record<string, unknown>,
198
212
  index: NameIndex,
@@ -249,7 +263,7 @@ function applyIncludes(
249
263
  if (!entry) {
250
264
  throw new Error(
251
265
  `Profile "${String(profile["name"])}" includes "${name}", which is not in the index. ` +
252
- `Refusing to fall back to inherited defaults (wrong G-code for this printer).`
266
+ `Refusing to fall back to inherited defaults (wrong G-code for this printer).${describeMalformed(index)}`
253
267
  );
254
268
  }
255
269
  for (const [key, value] of Object.entries(flattenByName(name, index, visiting))) {
@@ -164,6 +164,7 @@ export interface BambuSliceOptions {
164
164
  loadFilamentIds?: string; // --load-filament-ids "1,2,3,1": filament-to-object mapping
165
165
  filamentColours?: string[]; // positional #RRGGBB per loaded filament slot
166
166
  bedType?: string; // Bambu bed type, e.g. textured_plate or cool_plate
167
+ nozzleType?: string; // installed hotend material written into the machine preset
167
168
  enableTimelapse?: boolean; // --enable-timelapse: timelapse-aware slicing
168
169
  allowMixTemp?: boolean; // --allow-mix-temp: allow mixed-temp filaments
169
170
  scale?: number; // --scale factor: uniform scale
@@ -186,6 +187,12 @@ interface BambuSettingsBundle {
186
187
  processSource?: { filePath: string; profile: Record<string, unknown> };
187
188
  }
188
189
 
190
+ /** Keys every profile kind carries; they describe the file, not machine hardware. */
191
+ const PROFILE_METADATA_KEYS = new Set([
192
+ 'name', 'type', 'from', 'inherits', 'instantiation', 'setting_id', 'version', 'is_custom_defined',
193
+ 'compatible_printers', 'compatible_printers_condition', 'compatible_prints', 'compatible_prints_condition',
194
+ ]);
195
+
189
196
  export class STLManipulator extends EventEmitter {
190
197
  private tempDir: string;
191
198
  private activeOperations: Map<string, boolean> = new Map();
@@ -304,16 +311,22 @@ export class STLManipulator extends EventEmitter {
304
311
  sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
305
312
  }
306
313
 
307
- sanitized.use_relative_e_distances = '0';
308
- sanitized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(
309
- sanitized.before_layer_change_gcode
314
+ return this.useAbsoluteExtrusionForOrca(sanitized);
315
+ }
316
+
317
+ /** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
318
+ private useAbsoluteExtrusionForOrca(config: any): any {
319
+ const normalized = { ...config };
320
+ normalized.use_relative_e_distances = '0';
321
+ normalized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(
322
+ normalized.before_layer_change_gcode
310
323
  );
311
- sanitized.layer_gcode = this.stripAbsoluteExtruderResets(sanitized.layer_gcode);
312
- sanitized.layer_change_gcode = this.stripAbsoluteExtruderResets(
313
- sanitized.layer_change_gcode
324
+ normalized.layer_gcode = this.stripAbsoluteExtruderResets(normalized.layer_gcode);
325
+ normalized.layer_change_gcode = this.stripAbsoluteExtruderResets(
326
+ normalized.layer_change_gcode
314
327
  );
315
328
 
316
- return sanitized;
329
+ return normalized;
317
330
  }
318
331
 
319
332
  private writeTempJson(outputBase: string, suffix: string, value: any): string {
@@ -584,6 +597,36 @@ export class STLManipulator extends EventEmitter {
584
597
  };
585
598
  }
586
599
 
600
+ /**
601
+ * The CLI applies every key in every --load-settings/--load-filaments file,
602
+ * so machine settings carried by a process or filament file (for example a
603
+ * template 3MF's project_settings from another printer) would replace the
604
+ * selected preset's start G-code, model, and bed geometry. The machine
605
+ * preset owns those keys; drop them from the other profiles.
606
+ */
607
+ private isolateMachineSettings(bundle: BambuSettingsBundle): BambuSettingsBundle {
608
+ const [machinePath, processPath] = bundle.settingsArg?.split(';') ?? [];
609
+ if (!machinePath || !processPath) return bundle;
610
+ const machineKeys = Object.keys(this.readJsonFile(machinePath)).filter(key => !PROFILE_METADATA_KEYS.has(key));
611
+ const isolate = (filePath: string, label: string): string => {
612
+ const profile = this.readJsonFile(filePath);
613
+ const foreign = machineKeys.filter(key => Object.prototype.hasOwnProperty.call(profile, key));
614
+ if (foreign.length === 0) return filePath;
615
+ const isolated = { ...profile };
616
+ for (const key of foreign) delete isolated[key];
617
+ console.error(
618
+ `Ignoring ${foreign.length} machine setting(s) in ${label} profile ${filePath} ` +
619
+ `(the selected machine preset owns them): ${foreign.slice(0, 8).join(', ')}${foreign.length > 8 ? ', ...' : ''}`
620
+ );
621
+ return this.writeTempJson(path.basename(filePath, '.json'), `${label}_without_machine`, isolated);
622
+ };
623
+ return {
624
+ ...bundle,
625
+ settingsArg: [machinePath, isolate(processPath, 'process')].join(';'),
626
+ filamentPaths: bundle.filamentPaths.map(filePath => isolate(filePath, 'filament')),
627
+ };
628
+ }
629
+
587
630
  /** --load-filaments is positional; a single override must cover every project slot. */
588
631
  private async expandProjectFilaments(
589
632
  inputPath: string,
@@ -1619,22 +1662,40 @@ export class STLManipulator extends EventEmitter {
1619
1662
  // Resolve every BBL dependency before launching the slicer.
1620
1663
  const settingsBundle = await this.maybeFlattenBundle(
1621
1664
  await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
1665
+ // The machine preset assumes the stock hotend; record the installed one so
1666
+ // the sliced job's nozzle_type matches what the printer reports.
1667
+ if (bambuOptions?.nozzleType && settingsBundle.settingsArg) {
1668
+ const [machinePath, ...rest] = settingsBundle.settingsArg.split(';');
1669
+ const machine = this.readJsonFile(machinePath);
1670
+ const current = machine.nozzle_type;
1671
+ machine.nozzle_type = Array.isArray(current) && current.length > 0
1672
+ ? current.map(() => bambuOptions.nozzleType)
1673
+ : [bambuOptions.nozzleType];
1674
+ settingsBundle.settingsArg = [
1675
+ this.writeTempJson(outputBase, `machine_${bambuOptions.nozzleType}`, machine), ...rest,
1676
+ ].join(';');
1677
+ }
1622
1678
  // Inherited process G-code is now present; apply Orca's existing
1623
1679
  // absolute-extrusion normalization after resolving those ancestors.
1680
+ // The machine owns the extrusion mode and layer G-code once process
1681
+ // files lose machine keys, so normalize it there as well.
1624
1682
  if (slicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1625
1683
  const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
1626
- settingsBundle.settingsArg = [machinePath, this.writeTempJson(
1627
- outputBase, 'process_orca_resolved',
1628
- this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)
1629
- )].join(';');
1684
+ settingsBundle.settingsArg = [
1685
+ this.writeTempJson(outputBase, 'machine_orca_resolved',
1686
+ this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
1687
+ this.writeTempJson(outputBase, 'process_orca_resolved',
1688
+ this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
1689
+ ].join(';');
1630
1690
  }
1691
+ const cliBundle = this.isolateMachineSettings(settingsBundle);
1631
1692
  args = [
1632
1693
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1633
1694
  '--outputdir', outputDir,
1634
1695
  '--export-3mf', path.basename(bambuOutputPath),
1635
1696
  ];
1636
- if (settingsBundle.settingsArg) {
1637
- args.push('--load-settings', settingsBundle.settingsArg);
1697
+ if (cliBundle.settingsArg) {
1698
+ args.push('--load-settings', cliBundle.settingsArg);
1638
1699
  }
1639
1700
  // Always allow newer-version 3MF files (the CLI rejects them by default)
1640
1701
  args.push('--allow-newer-file');
@@ -1655,8 +1716,8 @@ export class STLManipulator extends EventEmitter {
1655
1716
  if (bambuOptions?.rotateY !== undefined) args.push('--rotate-y', String(bambuOptions.rotateY));
1656
1717
  if (bambuOptions?.cloneObjects) args.push('--clone-objects', bambuOptions.cloneObjects);
1657
1718
  if (bambuOptions?.skipObjects) args.push('--skip-objects', bambuOptions.skipObjects);
1658
- if (settingsBundle.filamentPaths.length > 0) {
1659
- args.push('--load-filaments', settingsBundle.filamentPaths.join(';'));
1719
+ if (cliBundle.filamentPaths.length > 0) {
1720
+ args.push('--load-filaments', cliBundle.filamentPaths.join(';'));
1660
1721
  args.push('--load-defaultfila');
1661
1722
  }
1662
1723
  if (bambuOptions?.loadFilamentIds) args.push('--load-filament-ids', bambuOptions.loadFilamentIds);