bambu-printer-mcp 1.1.17 → 1.1.19

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 = {
@@ -1010,6 +1022,7 @@ class BambuPrinterMCPServer {
1010
1022
  minSave: true,
1011
1023
  skipModifiedGcodes: true,
1012
1024
  bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
1025
+ nozzleType: resolveNozzleType(args?.nozzle_type),
1013
1026
  };
1014
1027
  threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, slicerProfile || undefined, undefined, printPreset, autoSliceOptions);
1015
1028
  console.log("Auto-sliced to: " + threeMFPath);
@@ -1806,6 +1819,11 @@ class BambuPrinterMCPServer {
1806
1819
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
1807
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."
1808
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
+ },
1809
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." },
1810
1828
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
1811
1829
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -1856,6 +1874,11 @@ class BambuPrinterMCPServer {
1856
1874
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
1857
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."
1858
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
+ },
1859
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." },
1860
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." },
1861
1884
  repetitions: { type: "number", description: "Print N identical copies of the model. Each copy gets its own plate placement. Example: 3 prints three copies." },
@@ -1961,6 +1984,7 @@ class BambuPrinterMCPServer {
1961
1984
  username: { type: "string", description: "Printer username for LAN/local bridge methods; defaults to bblp." },
1962
1985
  password: { type: "string", description: "Printer password/access code override for LAN/local bridge methods." },
1963
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)." },
1964
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." },
1965
1989
  project_name: { type: "string", description: "Optional project name sent in FULU PrintParams; defaults to the 3MF filename without extension." },
1966
1990
  preset_name: { type: "string", description: "Optional preset name sent in FULU PrintParams; defaults to project plus one-based plate index." },
@@ -2366,6 +2390,11 @@ class BambuPrinterMCPServer {
2366
2390
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
2367
2391
  description: "Bed plate type currently installed (default: textured_plate)"
2368
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
+ },
2369
2398
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2370
2399
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2371
2400
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
@@ -3064,6 +3093,7 @@ class BambuPrinterMCPServer {
3064
3093
  if (args?.filament_colours !== undefined)
3065
3094
  sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3066
3095
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
3096
+ sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3067
3097
  if (args?.enable_timelapse !== undefined)
3068
3098
  sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3069
3099
  if (args?.allow_mix_temp !== undefined)
@@ -3143,6 +3173,7 @@ class BambuPrinterMCPServer {
3143
3173
  if (args?.filament_colours !== undefined)
3144
3174
  sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3145
3175
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
3176
+ sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3146
3177
  if (args?.enable_timelapse !== undefined)
3147
3178
  sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3148
3179
  if (args?.allow_mix_temp !== undefined)
@@ -3245,6 +3276,7 @@ class BambuPrinterMCPServer {
3245
3276
  minSave: true,
3246
3277
  skipModifiedGcodes: true,
3247
3278
  bedType: printBedType,
3279
+ nozzleType: resolveNozzleType(args?.nozzle_type),
3248
3280
  };
3249
3281
  if (!explicitSlicerProfile) {
3250
3282
  try {
@@ -3347,7 +3379,7 @@ class BambuPrinterMCPServer {
3347
3379
  layerInspect: args?.layer_inspect !== undefined ? Boolean(args.layer_inspect) : undefined,
3348
3380
  timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : undefined,
3349
3381
  });
3350
- result = `Print command for ${threeMfFilename} sent successfully.`;
3382
+ // Report what the printer did, not merely that a command was published.
3351
3383
  break;
3352
3384
  }
3353
3385
  case "print_collar_charm": {
@@ -3448,10 +3480,14 @@ class BambuPrinterMCPServer {
3448
3480
  }
3449
3481
  catch (error) {
3450
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);
3451
3485
  const structured = {
3452
3486
  status: "error",
3453
3487
  retryable: false,
3454
- 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}`,
3455
3491
  message,
3456
3492
  tool: name,
3457
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
  }
@@ -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;
@@ -1282,6 +1282,19 @@ export class STLManipulator extends EventEmitter {
1282
1282
  const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, slicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot);
1283
1283
  // Resolve every BBL dependency before launching the slicer.
1284
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
+ }
1285
1298
  // Inherited process G-code is now present; apply Orca's existing
1286
1299
  // absolute-extrusion normalization after resolving those ancestors.
1287
1300
  // The machine owns the extrusion mode and layer G-code once process
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.17",
3
+ "version": "1.1.19",
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
@@ -1358,6 +1370,7 @@ class BambuPrinterMCPServer {
1358
1370
  minSave: true,
1359
1371
  skipModifiedGcodes: true,
1360
1372
  bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
1373
+ nozzleType: resolveNozzleType(args?.nozzle_type),
1361
1374
  };
1362
1375
  threeMFPath = await this.stlManipulator.sliceSTL(
1363
1376
  threeMFPath, slicerType, slicerPath, slicerProfile || undefined,
@@ -2332,6 +2345,11 @@ class BambuPrinterMCPServer {
2332
2345
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
2333
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."
2334
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
+ },
2335
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." },
2336
2354
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2337
2355
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -2382,6 +2400,11 @@ class BambuPrinterMCPServer {
2382
2400
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
2383
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."
2384
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
+ },
2385
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." },
2386
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." },
2387
2410
  repetitions: { type: "number", description: "Print N identical copies of the model. Each copy gets its own plate placement. Example: 3 prints three copies." },
@@ -2487,6 +2510,7 @@ class BambuPrinterMCPServer {
2487
2510
  username: { type: "string", description: "Printer username for LAN/local bridge methods; defaults to bblp." },
2488
2511
  password: { type: "string", description: "Printer password/access code override for LAN/local bridge methods." },
2489
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)." },
2490
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." },
2491
2515
  project_name: { type: "string", description: "Optional project name sent in FULU PrintParams; defaults to the 3MF filename without extension." },
2492
2516
  preset_name: { type: "string", description: "Optional preset name sent in FULU PrintParams; defaults to project plus one-based plate index." },
@@ -2892,6 +2916,11 @@ class BambuPrinterMCPServer {
2892
2916
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
2893
2917
  description: "Bed plate type currently installed (default: textured_plate)"
2894
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
+ },
2895
2924
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2896
2925
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2897
2926
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
@@ -3719,6 +3748,7 @@ class BambuPrinterMCPServer {
3719
3748
  if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
3720
3749
  if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3721
3750
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3751
+ sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3722
3752
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3723
3753
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
3724
3754
  if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
@@ -3805,6 +3835,7 @@ class BambuPrinterMCPServer {
3805
3835
  if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
3806
3836
  if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3807
3837
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3838
+ sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3808
3839
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3809
3840
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
3810
3841
  if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
@@ -3932,6 +3963,7 @@ class BambuPrinterMCPServer {
3932
3963
  minSave: true,
3933
3964
  skipModifiedGcodes: true,
3934
3965
  bedType: printBedType,
3966
+ nozzleType: resolveNozzleType(args?.nozzle_type),
3935
3967
  };
3936
3968
  if (!explicitSlicerProfile) {
3937
3969
  try {
@@ -4062,7 +4094,7 @@ class BambuPrinterMCPServer {
4062
4094
  layerInspect: args?.layer_inspect !== undefined ? Boolean(args.layer_inspect) : undefined,
4063
4095
  timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : undefined,
4064
4096
  });
4065
- result = `Print command for ${threeMfFilename} sent successfully.`;
4097
+ // Report what the printer did, not merely that a command was published.
4066
4098
  break;
4067
4099
  }
4068
4100
 
@@ -4214,10 +4246,14 @@ class BambuPrinterMCPServer {
4214
4246
 
4215
4247
  } catch (error) {
4216
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);
4217
4251
  const structured: StructuredToolError = {
4218
4252
  status: "error",
4219
4253
  retryable: false,
4220
- 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}`,
4221
4257
  message,
4222
4258
  tool: name,
4223
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
  }
@@ -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
@@ -1661,6 +1662,19 @@ export class STLManipulator extends EventEmitter {
1661
1662
  // Resolve every BBL dependency before launching the slicer.
1662
1663
  const settingsBundle = await this.maybeFlattenBundle(
1663
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
+ }
1664
1678
  // Inherited process G-code is now present; apply Orca's existing
1665
1679
  // absolute-extrusion normalization after resolving those ancestors.
1666
1680
  // The machine owns the extrusion mode and layer G-code once process