bambu-printer-mcp 1.1.26 → 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: {
@@ -2900,6 +2932,23 @@ class BambuPrinterMCPServer {
2900
2932
  required: ["action", "ams_id"]
2901
2933
  }
2902
2934
  },
2935
+ {
2936
+ name: "inspect_print_file",
2937
+ description: "Inspect an exact private snapshot of a prepared sliced 3MF or textual G-code file and selected plate without contacting a printer. Returns versioned file-only evidence; no printer readiness or permission to dispatch is established. Does not auto-slice.",
2938
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2939
+ inputSchema: {
2940
+ type: "object",
2941
+ additionalProperties: false,
2942
+ properties: {
2943
+ file_path: { type: "string", minLength: 1, description: "Local prepared .3mf or textual .gcode artifact to inspect; checksum-only and unsliced projects reject." },
2944
+ bambu_model: { type: "string", enum: [...VALID_BAMBU_MODELS], description: "Explicit file-inspection target model; never inferred from environment or live state." },
2945
+ plate_index: { type: "integer", minimum: 0, description: "Zero-based selected plate; defaults to 0. Raw G-code only supports 0." },
2946
+ nozzle_diameters: { type: "array", minItems: 1, maxItems: 2, items: { type: "number", enum: [0.2, 0.4, 0.6, 0.8] }, description: "Optional requested nozzle diameters to compare with file declarations; this does not observe installed hardware." },
2947
+ bed_type: { type: "string", enum: [...VALID_BED_TYPES], description: "Optional declared bed type to compare with file metadata." },
2948
+ },
2949
+ required: ["file_path", "bambu_model"],
2950
+ },
2951
+ },
2903
2952
  {
2904
2953
  name: "print_3mf",
2905
2954
  description: "Print a 3MF file on a Bambu Lab printer. Auto-slices if the 3MF has no gcode. IMPORTANT: bambu_model must be specified to ensure safe printer operation.",
@@ -2935,6 +2984,11 @@ class BambuPrinterMCPServer {
2935
2984
  enum: [...VALID_NOZZLE_TYPES],
2936
2985
  description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)."
2937
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
+ },
2938
2992
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2939
2993
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2940
2994
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
@@ -3130,9 +3184,43 @@ class BambuPrinterMCPServer {
3130
3184
  const host = String(args?.host || DEFAULT_HOST);
3131
3185
  const bambuSerial = String(args?.bambu_serial || DEFAULT_BAMBU_SERIAL);
3132
3186
  const bambuToken = String(args?.bambu_token || DEFAULT_BAMBU_TOKEN);
3133
- const nativeControls = process.platform === "darwin" &&
3134
- (bambuSerial.trim().toUpperCase().startsWith("20P") ||
3135
- (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
+ };
3136
3224
  const requestedTemplateDir =
3137
3225
  typeof args?.template_dir === "string" && args.template_dir.trim().length > 0
3138
3226
  ? String(args.template_dir)
@@ -3147,6 +3235,34 @@ class BambuPrinterMCPServer {
3147
3235
  let result;
3148
3236
 
3149
3237
  switch (name) {
3238
+ case "inspect_print_file": {
3239
+ const allowed = new Set(["file_path", "bambu_model", "plate_index", "nozzle_diameters", "bed_type"]);
3240
+ if (!args || Object.keys(args).some(key => !allowed.has(key))) throw new Error("inspect_print_file accepts only file/model/plate/nozzle/bed arguments.");
3241
+ if (typeof args.file_path !== "string" || !args.file_path.trim() || args.file_path.includes("\0")) throw new Error("file_path must name a local prepared artifact.");
3242
+ if (typeof args.bambu_model !== "string" || !args.bambu_model.trim()) throw new Error("An explicit bambu_model is required for file inspection.");
3243
+ const model = validateBambuModel(args.bambu_model);
3244
+ const plateIndex = args.plate_index === undefined ? 0 : args.plate_index;
3245
+ if (typeof plateIndex !== "number" || !Number.isSafeInteger(plateIndex) || plateIndex < 0) throw new Error("plate_index must be a nonnegative safe integer.");
3246
+ if (args.nozzle_diameters !== undefined && (!Array.isArray(args.nozzle_diameters) || args.nozzle_diameters.some(value => typeof value !== "number"))) throw new Error("nozzle_diameters must be numeric sizes.");
3247
+ const nozzles = requestedPrintNozzles(args);
3248
+ if (args.bed_type !== undefined && (typeof args.bed_type !== "string" || !args.bed_type.trim())) throw new Error("bed_type must be a supported plate type.");
3249
+ const bedType = args.bed_type === undefined ? undefined : resolveBedType(args.bed_type);
3250
+ if (extra.signal.aborted) throw new Error("File inspection cancelled.");
3251
+ result = await withPrintSnapshot(args.file_path, async snapshot => {
3252
+ const inspection = await inspectPrintFile(snapshot, { model, nozzleDiameters: nozzles, plateIndex, bedType });
3253
+ if (extra.signal.aborted) throw new Error("File inspection cancelled.");
3254
+ const byteLength = (await fs.promises.stat(snapshot)).size;
3255
+ return {
3256
+ receipt_version: "1",
3257
+ evidence: "file-only",
3258
+ dispatch_authorized: false,
3259
+ artifact: { sha256: inspection.sha256, byte_length: byteLength, file_format: path.extname(snapshot).toLowerCase() === ".3mf" ? "3mf" : "gcode" },
3260
+ selection: { bambu_model: model, plate_index: plateIndex, ...(nozzles === undefined ? {} : { nozzle_diameters: nozzles }), ...(bedType === undefined ? {} : { bed_type: bedType }) },
3261
+ inspection,
3262
+ };
3263
+ });
3264
+ break;
3265
+ }
3150
3266
  case "get_printer_status":
3151
3267
  result = await this.bambu.getStatus(host, bambuSerial, bambuToken);
3152
3268
  break;
@@ -3360,15 +3476,17 @@ class BambuPrinterMCPServer {
3360
3476
 
3361
3477
  case "cancel_print":
3362
3478
  result = await this.bambu.cancelJob(host, bambuSerial, bambuToken,
3363
- nativeControls
3364
- ? () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3365
- 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))
3366
3484
  : undefined);
3367
3485
  break;
3368
3486
 
3369
3487
  case "pause_print":
3370
3488
  result = await this.bambu.pauseJob(host, bambuSerial, bambuToken,
3371
- nativeControls
3489
+ nativeControls()
3372
3490
  ? () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3373
3491
  messageJson: JSON.stringify({ print: { command: "pause", param: "", sequence_id: String(Date.now()) } }), qos: 1 })
3374
3492
  : undefined);
@@ -3376,7 +3494,7 @@ class BambuPrinterMCPServer {
3376
3494
 
3377
3495
  case "resume_print":
3378
3496
  result = await this.bambu.resumeJob(host, bambuSerial, bambuToken,
3379
- nativeControls
3497
+ nativeControls()
3380
3498
  ? (assertActive, beforeDispatch) => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3381
3499
  messageJson: JSON.stringify({ print: { command: "resume", param: "", sequence_id: String(Date.now()) } }), qos: 1 }, { signal: extra.signal, assertActive, beforeDispatch })
3382
3500
  : undefined);
@@ -3386,8 +3504,9 @@ class BambuPrinterMCPServer {
3386
3504
  if (!args?.message_json) {
3387
3505
  throw new Error("Missing required parameter: message_json");
3388
3506
  }
3389
- if (!nativeControls) {
3390
- 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());
3391
3510
  }
3392
3511
  const messageJson = validateBambuNativeMetadata(String(args.message_json));
3393
3512
  result = await sendNativeCommand({
@@ -3403,7 +3522,7 @@ class BambuPrinterMCPServer {
3403
3522
 
3404
3523
  case "clear_hms_errors":
3405
3524
  result = await this.bambu.clearHmsErrors(host, bambuSerial, bambuToken,
3406
- nativeControls
3525
+ nativeControls()
3407
3526
  ? (assertActive, beforeDispatch) => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3408
3527
  messageJson: JSON.stringify({ print: { command: "clean_print_error", param: "", sequence_id: String(Date.now()) } }), qos: 1 }, { signal: extra.signal, assertActive, beforeDispatch })
3409
3528
  : undefined);
@@ -3413,7 +3532,7 @@ class BambuPrinterMCPServer {
3413
3532
  if (!args?.mode) {
3414
3533
  throw new Error("Missing required parameter: mode");
3415
3534
  }
3416
- if (nativeControls) {
3535
+ if (nativeControls()) {
3417
3536
  const normalized = String(args.mode).trim().toLowerCase();
3418
3537
  const mode = normalized === "silent" ? 1
3419
3538
  : normalized === "standard" ? 2
@@ -3440,7 +3559,7 @@ class BambuPrinterMCPServer {
3440
3559
  if (!args?.mode) {
3441
3560
  throw new Error("Missing required parameter: mode");
3442
3561
  }
3443
- if (nativeControls) {
3562
+ if (nativeControls()) {
3444
3563
  const mode = String(args.mode).trim().toLowerCase();
3445
3564
  if (mode !== "cooling" && mode !== "heating") {
3446
3565
  throw new Error("Airduct mode must be one of: cooling, heating.");
@@ -3467,7 +3586,7 @@ class BambuPrinterMCPServer {
3467
3586
  if (args?.ams_id === undefined || args?.slot_id === undefined) {
3468
3587
  throw new Error("Missing required parameters: ams_id and slot_id");
3469
3588
  }
3470
- if (nativeControls) {
3589
+ if (nativeControls()) {
3471
3590
  const amsId = Number(args.ams_id);
3472
3591
  const slotId = Number(args.slot_id);
3473
3592
  if ((!Number.isInteger(amsId) || (amsId < 0 || amsId > 3)) && amsId !== 128) {
@@ -3516,9 +3635,14 @@ class BambuPrinterMCPServer {
3516
3635
  heaterModel || undefined,
3517
3636
  args?.material !== undefined ? String(args.material) : undefined,
3518
3637
  Number(args?.nozzle_diameter ?? DEFAULT_NOZZLE_DIAMETER),
3519
- (args.temperature === 0 ? nativeControls : heaterModel === "x2d" && process.platform === "darwin")
3520
- ? (heater, target, assertActive, beforeDispatch) => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3521
- 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
+ }
3522
3646
  : undefined
3523
3647
  );
3524
3648
  break;
@@ -3528,7 +3652,7 @@ class BambuPrinterMCPServer {
3528
3652
  if (!args?.fan || args?.speed === undefined) {
3529
3653
  throw new Error("Missing required parameters: fan and speed");
3530
3654
  }
3531
- if (nativeControls) {
3655
+ if (nativeControls()) {
3532
3656
  const status = await this.bambu.getStatus(host, bambuSerial, bambuToken);
3533
3657
  const printerState = String(status?.raw?.gcode_state ?? status?.raw?.print?.gcode_state ?? status?.gcode_state ?? status?.status ?? "").toUpperCase();
3534
3658
  if (printerState === "RUNNING" && args?.confirm_during_print !== true) {
@@ -3562,7 +3686,7 @@ class BambuPrinterMCPServer {
3562
3686
  if (!args?.light || !args?.mode) {
3563
3687
  throw new Error("Missing required parameters: light and mode");
3564
3688
  }
3565
- if (nativeControls) {
3689
+ if (nativeControls()) {
3566
3690
  const light = String(args.light).trim();
3567
3691
  const mode = String(args.mode).trim().toLowerCase();
3568
3692
  if (light !== "chamber_light" && light !== "chamber_light2") {
@@ -3603,7 +3727,7 @@ class BambuPrinterMCPServer {
3603
3727
  if (!Array.isArray(args?.object_ids)) {
3604
3728
  throw new Error("Missing required parameter: object_ids");
3605
3729
  }
3606
- if (nativeControls) {
3730
+ if (nativeControls()) {
3607
3731
  const objectIds = args.object_ids.map((id: unknown) => Number(id));
3608
3732
  if (objectIds.length === 0 || objectIds.some((id: number) => !Number.isInteger(id) || id < 0)) {
3609
3733
  throw new Error("object_ids must contain one or more non-negative integers.");
@@ -3631,7 +3755,7 @@ class BambuPrinterMCPServer {
3631
3755
  if (!args?.action || args?.ams_id === undefined) {
3632
3756
  throw new Error("Missing required parameters: action and ams_id");
3633
3757
  }
3634
- if (nativeControls) {
3758
+ if (nativeControls()) {
3635
3759
  const action = String(args.action).trim().toLowerCase();
3636
3760
  const amsId = Number(args.ams_id);
3637
3761
  if (action !== "start" && action !== "stop") {
@@ -3763,6 +3887,7 @@ class BambuPrinterMCPServer {
3763
3887
  if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3764
3888
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3765
3889
  sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3890
+ sliceBambuOptions.nozzleVolumeType = resolveNozzleVolumeType(args?.nozzle_volume_type);
3766
3891
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3767
3892
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
3768
3893
  if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
@@ -3850,6 +3975,7 @@ class BambuPrinterMCPServer {
3850
3975
  if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3851
3976
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3852
3977
  sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3978
+ sliceBambuOptions.nozzleVolumeType = resolveNozzleVolumeType(args?.nozzle_volume_type);
3853
3979
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3854
3980
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
3855
3981
  if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
@@ -3978,6 +4104,7 @@ class BambuPrinterMCPServer {
3978
4104
  skipModifiedGcodes: true,
3979
4105
  bedType: printBedType,
3980
4106
  nozzleType: resolveNozzleType(args?.nozzle_type),
4107
+ nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
3981
4108
  };
3982
4109
  if (!explicitSlicerProfile) {
3983
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);