bambu-printer-mcp 1.1.27 → 1.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -27,6 +27,9 @@ import {
27
27
  import { BambuNetworkBridge, type BambuNetworkBridgeOptions } from "./bambu-network-bridge.js";
28
28
  import {
29
29
  assertBambuNativeAvailable,
30
+ bambuNativeControlsAvailable,
31
+ bambuNativeUnavailableReason,
32
+ interruptNativeOperations,
30
33
  validateBambuNativeMetadata,
31
34
  buildBambuNativeFanCommand,
32
35
  buildBambuNativeTemperatureCommand,
@@ -124,6 +127,21 @@ function resolveNozzleType(value: unknown): string | undefined {
124
127
  }
125
128
  return normalized;
126
129
  }
130
+
131
+ /** BambuStudio's nozzle_volume_type value for each tool argument value. */
132
+ const NOZZLE_VOLUME_TYPES = { standard: "Standard", high_flow: "High Flow" } as const;
133
+ const VALID_NOZZLE_VOLUME_TYPES = Object.keys(NOZZLE_VOLUME_TYPES) as (keyof typeof NOZZLE_VOLUME_TYPES)[];
134
+
135
+ /** The installed nozzle's flow type. Bambu machine presets assume Standard; the print gate compares this with the printer. */
136
+ function resolveNozzleVolumeType(value: unknown): "Standard" | "High Flow" | undefined {
137
+ const raw = value === undefined || value === null || value === "" ? process.env.BAMBU_NOZZLE_VOLUME_TYPE?.trim() : value;
138
+ if (raw === undefined || raw === "") return undefined;
139
+ const normalized = String(raw).trim().toLowerCase().replace(/[\s-]+/g, "_");
140
+ if (!(VALID_NOZZLE_VOLUME_TYPES as readonly string[]).includes(normalized)) {
141
+ throw new Error(`Invalid nozzle volume type "${raw}". Valid types: ${VALID_NOZZLE_VOLUME_TYPES.join(", ")}`);
142
+ }
143
+ return NOZZLE_VOLUME_TYPES[normalized as keyof typeof NOZZLE_VOLUME_TYPES];
144
+ }
127
145
  const VALID_BAMBUSTUDIO_CLI_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"] as const;
128
146
 
129
147
  // Map model IDs to BambuStudio --load-machine preset names
@@ -1385,6 +1403,7 @@ class BambuPrinterMCPServer {
1385
1403
  skipModifiedGcodes: true,
1386
1404
  bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
1387
1405
  nozzleType: resolveNozzleType(args?.nozzle_type),
1406
+ nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
1388
1407
  };
1389
1408
  threeMFPath = await this.stlManipulator.sliceSTL(
1390
1409
  threeMFPath, slicerType, slicerPath, slicerProfile || undefined,
@@ -1765,10 +1784,12 @@ class BambuPrinterMCPServer {
1765
1784
  args: Record<string, any>, host: string, bambuSerial: string, bambuToken: string,
1766
1785
  onUpdate?: BambuNativeUpdateCallback, signal?: AbortSignal
1767
1786
  ): Promise<Record<string, unknown>> {
1768
- assertBambuNativeAvailable();
1769
1787
  if (args.print) throw new Error("Native upload never starts printing; use print_3mf.");
1788
+ // Resolve and enforce the model first, as the print path does, so a
1789
+ // non-X2D request is never told to install the native helper.
1770
1790
  const model = await this.resolveBambuModel(args.bambu_model);
1771
1791
  if (model !== "x2d") throw new Error("connection_mode=bambu_native is restricted to X2D.");
1792
+ assertBambuNativeAvailable();
1772
1793
  const unsupported = ["use_ams", "ams_mapping", "ams_mapping2", "ams_mapping_info", "nozzle_mapping", "nozzles_info",
1773
1794
  "bed_leveling", "flow_calibration", "vibration_calibration", "layer_inspect", "timelapse"];
1774
1795
  for (const key of unsupported) {
@@ -2364,6 +2385,11 @@ class BambuPrinterMCPServer {
2364
2385
  enum: [...VALID_NOZZLE_TYPES],
2365
2386
  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."
2366
2387
  },
2388
+ nozzle_volume_type: {
2389
+ type: "string",
2390
+ enum: [...VALID_NOZZLE_VOLUME_TYPES],
2391
+ description: "Bambu-compatible slicing: the flow type of the nozzle installed on the printer (default: BAMBU_NOZZLE_VOLUME_TYPE, else standard). high_flow uses the presets' High Flow values, such as the filament's High Flow max volumetric speed, and is refused if the machine preset has no High Flow variant. Printing compares it with the printer's reported nozzle flow."
2392
+ },
2367
2393
  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." },
2368
2394
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2369
2395
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -2419,6 +2445,11 @@ class BambuPrinterMCPServer {
2419
2445
  enum: [...VALID_NOZZLE_TYPES],
2420
2446
  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."
2421
2447
  },
2448
+ nozzle_volume_type: {
2449
+ type: "string",
2450
+ enum: [...VALID_NOZZLE_VOLUME_TYPES],
2451
+ description: "Bambu-compatible slicing: the flow type of the nozzle installed on the printer (default: BAMBU_NOZZLE_VOLUME_TYPE, else standard). high_flow uses the presets' High Flow values, such as the filament's High Flow max volumetric speed, and is refused if the machine preset has no High Flow variant. Printing compares it with the printer's reported nozzle flow."
2452
+ },
2422
2453
  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." },
2423
2454
  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." },
2424
2455
  repetitions: { type: "number", description: "Print N identical copies of the model. Each copy gets its own plate placement. Example: 3 prints three copies." },
@@ -2525,6 +2556,7 @@ class BambuPrinterMCPServer {
2525
2556
  password: { type: "string", description: "Printer password/access code override for LAN/local bridge methods." },
2526
2557
  bed_type: { type: "string", enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"], description: "Bed plate type currently installed (default: textured_plate)." },
2527
2558
  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)." },
2559
+ nozzle_volume_type: { type: "string", enum: [...VALID_NOZZLE_VOLUME_TYPES], description: "Installed nozzle flow type, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_VOLUME_TYPE, else standard)." },
2528
2560
  plate_index: { type: "number", description: "Zero-based plate index to print from the sliced 3MF; converted to FULU's one-based PrintParams plate_index." },
2529
2561
  project_name: { type: "string", description: "Optional project name sent in FULU PrintParams; defaults to the 3MF filename without extension." },
2530
2562
  preset_name: { type: "string", description: "Optional preset name sent in FULU PrintParams; defaults to project plus one-based plate index." },
@@ -2788,7 +2820,7 @@ class BambuPrinterMCPServer {
2788
2820
  },
2789
2821
  {
2790
2822
  name: "reread_ams_rfid",
2791
- description: "Trigger a Bambu AMS RFID re-read for one AMS slot. X2D on macOS uses the native official plug-in route. This can move AMS filament; use only when the printer is idle and unloaded.",
2823
+ description: "Trigger a Bambu AMS RFID re-read for one AMS slot. X2D on macOS, or Linux with the native helper installed, uses the native official plug-in route. This can move AMS filament; use only when the printer is idle and unloaded.",
2792
2824
  inputSchema: {
2793
2825
  type: "object",
2794
2826
  properties: {
@@ -2821,7 +2853,7 @@ class BambuPrinterMCPServer {
2821
2853
  },
2822
2854
  {
2823
2855
  name: "set_fan_speed",
2824
- description: "Set a Bambu printer fan speed percentage. X2D on macOS uses the installed Bambu networking plug-in without UI automation; other models use MQTT.",
2856
+ description: "Set a Bambu printer fan speed percentage. X2D on macOS, or Linux with the native helper installed, uses Bambu's networking plug-in without UI automation; other models use MQTT.",
2825
2857
  inputSchema: {
2826
2858
  type: "object",
2827
2859
  properties: {
@@ -2880,7 +2912,7 @@ class BambuPrinterMCPServer {
2880
2912
  },
2881
2913
  {
2882
2914
  name: "set_ams_drying",
2883
- description: "Start or stop the AMS filament drying cycle. X2D on macOS uses the native official plug-in route and supports AMS-HT id 128.",
2915
+ description: "Start or stop the AMS filament drying cycle. X2D on macOS, or Linux with the native helper installed, uses the native official plug-in route and supports AMS-HT id 128.",
2884
2916
  inputSchema: {
2885
2917
  type: "object",
2886
2918
  properties: {
@@ -2952,6 +2984,11 @@ class BambuPrinterMCPServer {
2952
2984
  enum: [...VALID_NOZZLE_TYPES],
2953
2985
  description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)."
2954
2986
  },
2987
+ nozzle_volume_type: {
2988
+ type: "string",
2989
+ enum: [...VALID_NOZZLE_VOLUME_TYPES],
2990
+ description: "Installed nozzle flow type, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_VOLUME_TYPE, else standard)."
2991
+ },
2955
2992
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2956
2993
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2957
2994
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
@@ -3147,9 +3184,43 @@ class BambuPrinterMCPServer {
3147
3184
  const host = String(args?.host || DEFAULT_HOST);
3148
3185
  const bambuSerial = String(args?.bambu_serial || DEFAULT_BAMBU_SERIAL);
3149
3186
  const bambuToken = String(args?.bambu_token || DEFAULT_BAMBU_TOKEN);
3150
- const nativeControls = process.platform === "darwin" &&
3151
- (bambuSerial.trim().toUpperCase().startsWith("20P") ||
3152
- (DEFAULT_BAMBU_MODEL === "x2d" && bambuSerial === DEFAULT_BAMBU_SERIAL));
3187
+ const x2dTarget = bambuSerial.trim().toUpperCase().startsWith("20P") ||
3188
+ (DEFAULT_BAMBU_MODEL === "x2d" && bambuSerial === DEFAULT_BAMBU_SERIAL);
3189
+ // Resolved lazily: the Linux availability check reads files, and most
3190
+ // tools (status, slicing, STL) never need it.
3191
+ // One resolution per request; null means native X2D controls are usable.
3192
+ let nativeUnavailable: string | null | undefined;
3193
+ const nativeUnavailableReason = () =>
3194
+ (nativeUnavailable ??= x2dTarget ? bambuNativeUnavailableReason() ?? null : "The selected printer is not an X2D.");
3195
+ const nativeControls = () => nativeUnavailableReason() === null;
3196
+ // Linux sent X2D stop and heater-off over MQTT before the native route
3197
+ // existed. Send them over both routes there: a stuck helper must not
3198
+ // delay an emergency control, and a repeated stop or heater-off is
3199
+ // harmless. The immediate MQTT send can precede the exit of an older
3200
+ // print or heating helper that then still dispatches, so MQTT is sent
3201
+ // again once every older helper for this printer has exited, and only
3202
+ // that send counts. Either counted route succeeding is success. macOS
3203
+ // stays native-only.
3204
+ const linuxEmergency = (native: () => Promise<any>, mqtt: () => Promise<any>) => async () => {
3205
+ if (process.platform !== "linux") return native();
3206
+ const [nativeResult, immediateResult, mqttResult] = await Promise.allSettled([
3207
+ native(),
3208
+ mqtt(),
3209
+ interruptNativeOperations(bambuSerial).then(() => mqtt()),
3210
+ ]);
3211
+ const reason = (result: PromiseSettledResult<any>) =>
3212
+ result.status === "rejected" ? (result.reason instanceof Error ? result.reason.message : String(result.reason)) : undefined;
3213
+ if (nativeResult.status === "rejected" && mqttResult.status === "rejected") {
3214
+ throw new Error(`native route: ${reason(nativeResult)}; MQTT route: ${reason(mqttResult)}`);
3215
+ }
3216
+ const state = (result: PromiseSettledResult<any>) => result.status === "fulfilled" ? "sent" : "failed";
3217
+ return {
3218
+ status: "success",
3219
+ routes: { native: state(nativeResult), mqtt: state(mqttResult), mqtt_immediate: state(immediateResult) },
3220
+ ...(reason(nativeResult) ? { native_error: reason(nativeResult) } : {}),
3221
+ ...(reason(mqttResult) ? { mqtt_error: reason(mqttResult) } : {}),
3222
+ };
3223
+ };
3153
3224
  const requestedTemplateDir =
3154
3225
  typeof args?.template_dir === "string" && args.template_dir.trim().length > 0
3155
3226
  ? String(args.template_dir)
@@ -3405,15 +3476,17 @@ class BambuPrinterMCPServer {
3405
3476
 
3406
3477
  case "cancel_print":
3407
3478
  result = await this.bambu.cancelJob(host, bambuSerial, bambuToken,
3408
- nativeControls
3409
- ? () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3410
- messageJson: JSON.stringify({ print: { command: "stop", param: "", sequence_id: String(Date.now()) } }), qos: 1 })
3479
+ nativeControls()
3480
+ ? linuxEmergency(
3481
+ () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3482
+ messageJson: JSON.stringify({ print: { command: "stop", param: "", sequence_id: String(Date.now()) } }), qos: 1 }),
3483
+ () => this.bambu.sendStopOverMqtt(host, bambuSerial, bambuToken))
3411
3484
  : undefined);
3412
3485
  break;
3413
3486
 
3414
3487
  case "pause_print":
3415
3488
  result = await this.bambu.pauseJob(host, bambuSerial, bambuToken,
3416
- nativeControls
3489
+ nativeControls()
3417
3490
  ? () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3418
3491
  messageJson: JSON.stringify({ print: { command: "pause", param: "", sequence_id: String(Date.now()) } }), qos: 1 })
3419
3492
  : undefined);
@@ -3421,7 +3494,7 @@ class BambuPrinterMCPServer {
3421
3494
 
3422
3495
  case "resume_print":
3423
3496
  result = await this.bambu.resumeJob(host, bambuSerial, bambuToken,
3424
- nativeControls
3497
+ nativeControls()
3425
3498
  ? (assertActive, beforeDispatch) => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3426
3499
  messageJson: JSON.stringify({ print: { command: "resume", param: "", sequence_id: String(Date.now()) } }), qos: 1 }, { signal: extra.signal, assertActive, beforeDispatch })
3427
3500
  : undefined);
@@ -3431,8 +3504,9 @@ class BambuPrinterMCPServer {
3431
3504
  if (!args?.message_json) {
3432
3505
  throw new Error("Missing required parameter: message_json");
3433
3506
  }
3434
- if (!nativeControls) {
3435
- throw new Error("x2d_native_control is restricted to a selected X2D printer on macOS.");
3507
+ if (!nativeControls()) {
3508
+ throw new Error("x2d_native_control is restricted to a selected X2D printer on macOS, or on Linux with the native helper installed. " +
3509
+ nativeUnavailableReason());
3436
3510
  }
3437
3511
  const messageJson = validateBambuNativeMetadata(String(args.message_json));
3438
3512
  result = await sendNativeCommand({
@@ -3448,7 +3522,7 @@ class BambuPrinterMCPServer {
3448
3522
 
3449
3523
  case "clear_hms_errors":
3450
3524
  result = await this.bambu.clearHmsErrors(host, bambuSerial, bambuToken,
3451
- nativeControls
3525
+ nativeControls()
3452
3526
  ? (assertActive, beforeDispatch) => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3453
3527
  messageJson: JSON.stringify({ print: { command: "clean_print_error", param: "", sequence_id: String(Date.now()) } }), qos: 1 }, { signal: extra.signal, assertActive, beforeDispatch })
3454
3528
  : undefined);
@@ -3458,7 +3532,7 @@ class BambuPrinterMCPServer {
3458
3532
  if (!args?.mode) {
3459
3533
  throw new Error("Missing required parameter: mode");
3460
3534
  }
3461
- if (nativeControls) {
3535
+ if (nativeControls()) {
3462
3536
  const normalized = String(args.mode).trim().toLowerCase();
3463
3537
  const mode = normalized === "silent" ? 1
3464
3538
  : normalized === "standard" ? 2
@@ -3485,7 +3559,7 @@ class BambuPrinterMCPServer {
3485
3559
  if (!args?.mode) {
3486
3560
  throw new Error("Missing required parameter: mode");
3487
3561
  }
3488
- if (nativeControls) {
3562
+ if (nativeControls()) {
3489
3563
  const mode = String(args.mode).trim().toLowerCase();
3490
3564
  if (mode !== "cooling" && mode !== "heating") {
3491
3565
  throw new Error("Airduct mode must be one of: cooling, heating.");
@@ -3512,7 +3586,7 @@ class BambuPrinterMCPServer {
3512
3586
  if (args?.ams_id === undefined || args?.slot_id === undefined) {
3513
3587
  throw new Error("Missing required parameters: ams_id and slot_id");
3514
3588
  }
3515
- if (nativeControls) {
3589
+ if (nativeControls()) {
3516
3590
  const amsId = Number(args.ams_id);
3517
3591
  const slotId = Number(args.slot_id);
3518
3592
  if ((!Number.isInteger(amsId) || (amsId < 0 || amsId > 3)) && amsId !== 128) {
@@ -3561,9 +3635,14 @@ class BambuPrinterMCPServer {
3561
3635
  heaterModel || undefined,
3562
3636
  args?.material !== undefined ? String(args.material) : undefined,
3563
3637
  Number(args?.nozzle_diameter ?? DEFAULT_NOZZLE_DIAMETER),
3564
- (args.temperature === 0 ? nativeControls : heaterModel === "x2d" && process.platform === "darwin")
3565
- ? (heater, target, assertActive, beforeDispatch) => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3566
- messageJson: buildBambuNativeTemperatureCommand(heater, target).messageJson }, { signal: extra.signal, assertActive, beforeDispatch })
3638
+ (args.temperature === 0 ? nativeControls() : heaterModel === "x2d" && bambuNativeControlsAvailable())
3639
+ ? (heater, target, assertActive, beforeDispatch) => {
3640
+ const native = () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3641
+ messageJson: buildBambuNativeTemperatureCommand(heater, target).messageJson }, { signal: extra.signal, assertActive, beforeDispatch });
3642
+ return target === 0
3643
+ ? linuxEmergency(native, () => this.bambu.sendHeaterOffOverMqtt(host, bambuSerial, bambuToken, heater))()
3644
+ : native();
3645
+ }
3567
3646
  : undefined
3568
3647
  );
3569
3648
  break;
@@ -3573,7 +3652,7 @@ class BambuPrinterMCPServer {
3573
3652
  if (!args?.fan || args?.speed === undefined) {
3574
3653
  throw new Error("Missing required parameters: fan and speed");
3575
3654
  }
3576
- if (nativeControls) {
3655
+ if (nativeControls()) {
3577
3656
  const status = await this.bambu.getStatus(host, bambuSerial, bambuToken);
3578
3657
  const printerState = String(status?.raw?.gcode_state ?? status?.raw?.print?.gcode_state ?? status?.gcode_state ?? status?.status ?? "").toUpperCase();
3579
3658
  if (printerState === "RUNNING" && args?.confirm_during_print !== true) {
@@ -3607,7 +3686,7 @@ class BambuPrinterMCPServer {
3607
3686
  if (!args?.light || !args?.mode) {
3608
3687
  throw new Error("Missing required parameters: light and mode");
3609
3688
  }
3610
- if (nativeControls) {
3689
+ if (nativeControls()) {
3611
3690
  const light = String(args.light).trim();
3612
3691
  const mode = String(args.mode).trim().toLowerCase();
3613
3692
  if (light !== "chamber_light" && light !== "chamber_light2") {
@@ -3648,7 +3727,7 @@ class BambuPrinterMCPServer {
3648
3727
  if (!Array.isArray(args?.object_ids)) {
3649
3728
  throw new Error("Missing required parameter: object_ids");
3650
3729
  }
3651
- if (nativeControls) {
3730
+ if (nativeControls()) {
3652
3731
  const objectIds = args.object_ids.map((id: unknown) => Number(id));
3653
3732
  if (objectIds.length === 0 || objectIds.some((id: number) => !Number.isInteger(id) || id < 0)) {
3654
3733
  throw new Error("object_ids must contain one or more non-negative integers.");
@@ -3676,7 +3755,7 @@ class BambuPrinterMCPServer {
3676
3755
  if (!args?.action || args?.ams_id === undefined) {
3677
3756
  throw new Error("Missing required parameters: action and ams_id");
3678
3757
  }
3679
- if (nativeControls) {
3758
+ if (nativeControls()) {
3680
3759
  const action = String(args.action).trim().toLowerCase();
3681
3760
  const amsId = Number(args.ams_id);
3682
3761
  if (action !== "start" && action !== "stop") {
@@ -3808,6 +3887,7 @@ class BambuPrinterMCPServer {
3808
3887
  if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3809
3888
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3810
3889
  sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3890
+ sliceBambuOptions.nozzleVolumeType = resolveNozzleVolumeType(args?.nozzle_volume_type);
3811
3891
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3812
3892
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
3813
3893
  if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
@@ -3895,6 +3975,7 @@ class BambuPrinterMCPServer {
3895
3975
  if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3896
3976
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3897
3977
  sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3978
+ sliceBambuOptions.nozzleVolumeType = resolveNozzleVolumeType(args?.nozzle_volume_type);
3898
3979
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3899
3980
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
3900
3981
  if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
@@ -4023,6 +4104,7 @@ class BambuPrinterMCPServer {
4023
4104
  skipModifiedGcodes: true,
4024
4105
  bedType: printBedType,
4025
4106
  nozzleType: resolveNozzleType(args?.nozzle_type),
4107
+ nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
4026
4108
  };
4027
4109
  if (!explicitSlicerProfile) {
4028
4110
  try {
@@ -8,7 +8,7 @@ import { Readable } from "node:stream";
8
8
  import { readSafe3mfArchive } from "../safety/archive.js";
9
9
  import { inspectPrintFile, type PrintFileInspection } from "../safety/print-file.js";
10
10
  import { normalizeModel, validateTemperature } from "../safety/limits.js";
11
- import { validatePrinterState, manualHeatingRequirements, type PrinterStateRequirements } from "../safety/printer-state.js";
11
+ import { describeHmsKey, hiddenHmsKeys, hiddenHmsNotices, reportedHmsKey, validatePrinterState, manualHeatingRequirements, type PrinterStateRequirements } from "../safety/printer-state.js";
12
12
  import { withPrinterOperation, withPrintSnapshot, uniquePrintName, normalizedRemotePath, cancelPendingPrinterOperations } from "../safety/artifact.js";
13
13
  import { Client as FTPClient } from "basic-ftp";
14
14
  import { BambuPrinter } from "bambu-js";
@@ -85,7 +85,7 @@ const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s",
85
85
  export function assertDirectPrintSupported(model: string | undefined, serial?: string): void {
86
86
  if (model?.trim().toLowerCase() === "x2d" || serial?.trim().toUpperCase().startsWith("20P")) {
87
87
  throw new Error(
88
- "X2D direct printing is not supported through legacy MQTT/FTPS. Use the macOS native eMMC route via print_3mf. " +
88
+ "X2D direct printing is not supported through legacy MQTT/FTPS. Use the native eMMC route via print_3mf (macOS, or Linux with the native helper installed). " +
89
89
  "X2D status and slicing remain available on all supported platforms."
90
90
  );
91
91
  }
@@ -461,6 +461,9 @@ class BambuClientStore {
461
461
  }
462
462
  }
463
463
 
464
+ /** What the human confirmed in confirmPrintPreflight, checked again before dispatch. */
465
+ export type PrintPreflight = { finishedJob?: string; serial: string; model: string; notices: string[] };
466
+
464
467
  export class BambuImplementation {
465
468
  private printerStore: BambuClientStore;
466
469
  private checkedJobs = new Map<string, { remotePath: string; requirements: PrinterStateRequirements }>();
@@ -481,25 +484,41 @@ export class BambuImplementation {
481
484
  return status.raw.gcode_state === "FINISH" ? JSON.stringify([status.raw.gcode_file, status.raw.subtask_name, status.raw.task_id, status.raw.subtask_id]) : undefined;
482
485
  }
483
486
 
484
- async confirmPrintPreflight(serial: string, status: any, inspection: Awaited<ReturnType<typeof inspectPrintFile>>): Promise<string | undefined> {
487
+ /**
488
+ * The human print confirmation. Its result goes to assertBedClearance at
489
+ * every later state check of the same print, which stops the print if the
490
+ * situation the human confirmed has changed.
491
+ */
492
+ async confirmPrintPreflight(serial: string, status: any, inspection: Awaited<ReturnType<typeof inspectPrintFile>>): Promise<PrintPreflight> {
485
493
  const finishedJob = this.finishedJobIdentity(status);
486
494
  const cleared = this.clearedErrors.get(serial);
495
+ const notices = hiddenHmsNotices(status?.raw, serial, inspection.model);
487
496
  await this.confirmHardwareAction(
488
497
  `Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
489
498
  `Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
490
499
  `File SHA-256: ${inspection.sha256}. Confirm the physical spool labels and that the build plate is clear.` +
491
500
  (finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : "") +
492
- (cleared ? ` Previously cleared hardware codes: ${cleared.join(", ")}. Confirm their physical causes have been resolved.` : ""),
501
+ (cleared ? ` Previously cleared hardware codes: ${cleared.map(describeHmsKey).join(", ")}. Confirm their physical causes have been resolved.` : "") +
502
+ (notices.length ? ` The printer also reports ${notices.join(", ")}, which Bambu lists for this model without text and never shows as a fault; ` +
503
+ `${notices.length === 1 ? "it does" : "they do"} not block printing.` : ""),
493
504
  finishedJob !== undefined || !!cleared
494
505
  );
495
- return finishedJob;
506
+ return { finishedJob, serial, model: inspection.model, notices };
496
507
  }
497
508
 
498
- assertBedClearance(status: any, confirmedFinishedJob: string | undefined): void {
509
+ /** Stop a print whose confirmed situation changed: a newly finished job, or a hidden HMS notice the preflight did not name. */
510
+ assertBedClearance(status: any, preflight: PrintPreflight): void {
499
511
  const current = this.finishedJobIdentity(status);
500
- if (current !== undefined && current !== confirmedFinishedJob) {
512
+ if (current !== undefined && current !== preflight.finishedJob) {
501
513
  throw new Error("Printer reports a newly finished job. Confirm that its part and debris have been removed before retrying the print.");
502
514
  }
515
+ // An X2D raises its hidden camera notice about 15 seconds after boot, so it
516
+ // can appear after the human confirmed. Never pass a notice nobody was told about.
517
+ const unnamed = hiddenHmsNotices(status?.raw, preflight.serial, preflight.model).filter(notice => !preflight.notices.includes(notice));
518
+ if (unnamed.length) {
519
+ throw new Error(`Printer reported ${unnamed.join(", ")} after the print confirmation. ` +
520
+ "Bambu hides it and it does not block printing, but it was not named when you confirmed; start the print again to review it.");
521
+ }
503
522
  }
504
523
 
505
524
  private async getPrinter(host: string, serial: string, token: string): Promise<BambuClient> {
@@ -944,6 +963,21 @@ export class BambuImplementation {
944
963
  }
945
964
  }
946
965
 
966
+ /** Raw MQTT stop for routes that already ran the shared cancellation steps. */
967
+ async sendStopOverMqtt(host: string, serial: string, token: string): Promise<any> {
968
+ const printer = await this.getPrinter(host, serial, token);
969
+ await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
970
+ return { status: "success", message: "Cancel command sent over MQTT." };
971
+ }
972
+
973
+ /** Raw MQTT heater-off; switching off needs no material or model declaration. */
974
+ async sendHeaterOffOverMqtt(host: string, serial: string, token: string, heater: "bed" | "nozzle"): Promise<any> {
975
+ const printer = await this.getPrinter(host, serial, token);
976
+ const gcode = `${heater === "bed" ? "M140" : "M104"} S0`;
977
+ await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
978
+ return { status: "success", message: `Heater-off sent over MQTT for ${heater}.`, command: gcode };
979
+ }
980
+
947
981
  async pauseJob(host: string, serial: string, token: string, dispatch?: () => Promise<any>): Promise<any> {
948
982
  const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
949
983
  try {
@@ -982,10 +1016,12 @@ export class BambuImplementation {
982
1016
  return withPrinterOperation(host, serial, async assertActive => {
983
1017
  const codesFor = (status: any): string[] => {
984
1018
  if (!Array.isArray(status.raw?.hms) || status.raw?.print_error === undefined) throw new Error("Fresh error codes are required before clearing hardware errors.");
985
- return [`print_error:${status.raw.print_error}`, ...status.raw.hms.map((entry: any) => `hms:${entry.attr}:${entry.code}`)].sort();
1019
+ // Hidden notices never block and survive clean_print_error, so they are not errors to clear or re-acknowledge.
1020
+ const hidden = new Set(hiddenHmsKeys(status.raw, serial, String(status.model ?? "")));
1021
+ return [`print_error:${status.raw.print_error}`, ...status.raw.hms.map(reportedHmsKey).filter((key: string) => !hidden.has(key))].sort();
986
1022
  };
987
1023
  const codes = codesFor(await this.getSafetyStatus(host, serial, token));
988
- await this.confirmHardwareAction(`Clear reported hardware errors on ${serial}: ${codes.join(", ")}? Inspect the printer and resolve the physical cause first. Confirm only after removing obstructions and correcting the fault.`, true);
1024
+ await this.confirmHardwareAction(`Clear reported hardware errors on ${serial}: ${codes.map(describeHmsKey).join(", ")}? Inspect the printer and resolve the physical cause first. Confirm only after removing obstructions and correcting the fault.`, true);
989
1025
  const beforeDispatch = async () => {
990
1026
  assertActive();
991
1027
  const current = codesFor(await this.getSafetyStatus(host, serial, token));
@@ -1141,9 +1177,12 @@ export class BambuImplementation {
1141
1177
  }
1142
1178
  const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
1143
1179
  assertActive();
1144
- if (dispatch) await dispatch(heater, targetTemperature, assertActive, beforeDispatch);
1145
- else await invokeWithoutAck(printer!, new GCodeLineCommand({ gcodes: [gcode] }));
1146
- return { status: "success", message: `Temperature command sent for ${normalizedComponent}.`, command: gcode };
1180
+ const dispatched = dispatch ? await dispatch(heater, targetTemperature, assertActive, beforeDispatch) : undefined;
1181
+ if (!dispatch) await invokeWithoutAck(printer!, new GCodeLineCommand({ gcodes: [gcode] }));
1182
+ // Keep route details from a dual-route Linux heater-off.
1183
+ const routeDetails = dispatched && typeof dispatched === "object" && "routes" in dispatched
1184
+ ? { routes: dispatched.routes, native_error: dispatched.native_error, mqtt_error: dispatched.mqtt_error } : {};
1185
+ return { status: "success", message: `Temperature command sent for ${normalizedComponent}.`, command: gcode, ...routeDetails };
1147
1186
  };
1148
1187
  if (targetTemperature === 0) {
1149
1188
  cancelPendingPrinterOperations(host, serial);