bambu-printer-mcp 1.1.8 → 1.1.9

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/CONTRIBUTORS.md CHANGED
@@ -6,6 +6,7 @@ Thank you to everyone who builds, tests, reports problems, and shares real print
6
6
 
7
7
  | Contributor | Contribution |
8
8
  | --- | --- |
9
+ | [Sebastian (sebas1986)](https://github.com/sebas1986) | Isolates the multi-filament CLI crash with real BambuStudio bisection, contributes per-slot colours and multi-nozzle prime-tower placement, and verifies X2D identification, status, and slicing in [#18](https://github.com/DMontgomery40/bambu-printer-mcp/pull/18). Direct X2D printing remains deferred pending the native transport. |
9
10
  | [Stenslaen](https://github.com/Stenslaen) | Traces the delayed H2 crash to OTA model detection, documents a multi-day workaround, and reports unexpected state-transition crashes in [#7](https://github.com/DMontgomery40/bambu-printer-mcp/issues/7). |
10
11
  | [Alejandro Oñate (alexol91)](https://github.com/alexol91) | Reports the missing machine-template G-code and multi-filament override problems in [#12](https://github.com/DMontgomery40/bambu-printer-mcp/issues/12), with measured output and a proposed fix in [#13](https://github.com/DMontgomery40/bambu-printer-mcp/pull/13). |
11
12
  | [var-poro](https://github.com/var-poro) | Supplies P2S firmware/MQTT evidence and regression tests in [#15](https://github.com/DMontgomery40/bambu-printer-mcp/pull/15), plus profile-template resolution and inheritance tests in [#16](https://github.com/DMontgomery40/bambu-printer-mcp/pull/16). |
package/README.md CHANGED
@@ -106,9 +106,10 @@ This fork adds a substantial set of printer control tools beyond the upstream `m
106
106
  - Get detailed printer status: temperatures (nozzle, bed, chamber), print progress, current layer, time remaining, and live AMS slot data
107
107
  - Query live AMS inventory with resolved Bambu/Orca filament profile paths via `get_printer_filaments`. Includes per-tray display names, match confidence (`high`/`medium`/`low`/`none`), resolution tier (`exact-model-nozzle`/`model`/`generic`/`unresolved`), and a summary with recommended auto-slice filament. Retries automatically when AMS data hasn't arrived yet (common on first MQTT push from idle printers).
108
108
  - List, upload, and delete files on the printer's SD card via FTPS
109
- - Capture a JPEG snapshot from the chamber camera. Supports A1, A1 mini, P1S, P1P (TCP-on-6000), and X1, X1C, X1E, P2S, H2, H2S, H2D, H2C, H2D Pro (RTSP via ffmpeg). Requires ffmpeg in PATH for the RTSP path.
109
+ - Capture a JPEG snapshot from the chamber camera. Supports A1, A1 mini, P1S, P1P (TCP-on-6000), and X1, X1C, X1E, P2S, H2, H2S, H2D, H2C, H2D Pro, X2D (RTSP via ffmpeg). Requires ffmpeg in PATH for the RTSP path.
110
110
  - Upload and print pre-sliced `.gcode.3mf` files with full plate selection and calibration flag control (recommended path — see [docs/SLICING.md](./docs/SLICING.md))
111
- - Optional single-color auto-slice path via BambuStudio CLI. BBL profile inheritance and include templates resolve automatically before slicing; missing dependencies stop the slice. Standalone custom configurations remain supported. H2C requires a compatible installed Bambu Studio profile tree and `BAMBU_MODEL=h2c`. The previously documented H2D multi-color CLI limitation remains; use a GUI-sliced `.gcode.3mf` for that workflow. See [docs/SLICING.md](./docs/SLICING.md).
111
+ - Slice through BambuStudio CLI with automatic BBL inheritance/include resolution, per-slot filament colours, and fallback prime-tower placement for multi-nozzle printers. Missing dependencies stop the slice; custom settings and saved project tower positions are preserved. Multi-colour slicing is verified by the contributor on BambuStudio 02.08.02.60 for Windows; older CLI versions have separate limitations. See [docs/SLICING.md](./docs/SLICING.md).
112
+ - Recognize X2D status and slice with its own installed BambuStudio preset (`BAMBU_MODEL=x2d`). **Direct X2D printing is not supported yet**: the internal eMMC transport is pending. These print requests stop before slicing, uploading, or issuing printer commands. Print exported projects through a supported slicer instead.
112
113
  - Parse AMS mapping from the 3MF's embedded slicer metadata (`Metadata/plate_<n>.json` + gcode filament header) and send it correctly formatted per the OpenBambuAPI spec, with correct H2S/H2D/H2C `ams_mapping2` parallel array format
113
114
  - **Auto-match AMS slots by RFID** (`auto_match_ams` flag on `print_3mf`). Resolves required `tray_info_idx` from the sliced 3MF against live AMS inventory. Handles same-SKU different-color filaments by matching on `(tray_info_idx, tray_color)` and tracking already-claimed slots. Dry-run with `resolve_3mf_ams_slots` before printing.
114
115
  - Cancel, pause, and resume in-progress print jobs via MQTT
@@ -186,7 +187,7 @@ BAMBU_TOKEN=your_access_token # LAN access token from printer touchscreen
186
187
  # BAMBU_PRINTER_HOST / BAMBU_PRINTER_SERIAL / BAMBU_PRINTER_ACCESS_TOKEN
187
188
 
188
189
  # --- Printer model (CRITICAL for safe operation) ---
189
- BAMBU_MODEL=p1s # Your printer model: p1s, p1p, p2s, x1c, x1e, a1, a1mini, h2d, h2s, h2c
190
+ BAMBU_MODEL=p1s # Your printer model: p1s, p1p, p2s, x1c, x1e, a1, a1mini, h2d, h2s, h2c, x2d
190
191
  # Alias also accepted: BAMBU_PRINTER_MODEL
191
192
  BED_TYPE=textured_plate # Bed plate type: textured_plate, cool_plate, engineering_plate, hot_plate, supertack_plate
192
193
  NOZZLE_DIAMETER=0.4 # Nozzle diameter in mm (default: 0.4)
@@ -227,7 +228,7 @@ BLENDER_MCP_TIMEOUT_MS=120000
227
228
  | `PRINTER_HOST` | `localhost` | Yes | IP address of the Bambu printer. Alias: `BAMBU_PRINTER_HOST` |
228
229
  | `BAMBU_SERIAL` | | Yes | Printer serial number. Alias: `BAMBU_PRINTER_SERIAL` |
229
230
  | `BAMBU_TOKEN` | | Yes | LAN access token. Alias: `BAMBU_PRINTER_ACCESS_TOKEN` |
230
- | `BAMBU_MODEL` | | **Yes** | Printer model: `p1s`, `p1p`, `p2s`, `x1c`, `x1e`, `a1`, `a1mini`, `h2d`, `h2s`, `h2c`. **Required for safe operation** -- determines the correct G-code generation. Alias: `BAMBU_PRINTER_MODEL`. If omitted and the MCP client supports elicitation, the server will ask you interactively. Use `h2c` for H2C; do not use `h2d` as a fallback. |
231
+ | `BAMBU_MODEL` | | **Yes** | Printer model: `p1s`, `p1p`, `p2s`, `x1c`, `x1e`, `a1`, `a1mini`, `h2d`, `h2s`, `h2c`, `x2d`. **Required for safe operation** -- determines the correct G-code generation. Alias: `BAMBU_PRINTER_MODEL`. If omitted and the MCP client supports elicitation, the server will ask you interactively. Use `h2c` for H2C and `x2d` for X2D; do not use `h2d` as a fallback. |
231
232
  | `BED_TYPE` | `textured_plate` | No | Bed plate type: `textured_plate`, `cool_plate`, `engineering_plate`, `hot_plate`, `supertack_plate` |
232
233
  | `NOZZLE_DIAMETER` | `0.4` | No | Nozzle diameter in mm. Used to select the correct BambuStudio machine preset. |
233
234
  | `SLICER_TYPE` | `bambustudio` | No | Slicer to use for slicing operations |
@@ -1246,6 +1247,7 @@ When `slicer_type` is `bambustudio` (the default), these additional parameters a
1246
1247
  | `skip_objects` | string | Object indices to skip, comma-separated (e.g. `"3,5,10"`) |
1247
1248
  | `load_filaments` | string | Filament profile paths, semicolon-separated |
1248
1249
  | `load_filament_ids` | string | Filament-to-object mapping, comma-separated |
1250
+ | `filament_colours` | string | Slot colours, one `#RRGGBB` per filament slot, semicolon-separated. Explicit values take priority, followed by input 3MF colours, each custom profile's colour, then the BambuStudio default. |
1249
1251
  | `enable_timelapse` | boolean | Enable timelapse-aware slicing |
1250
1252
  | `allow_mix_temp` | boolean | Allow mixed-temperature filaments on one plate |
1251
1253
  | `scale` | number | Uniform scale factor |
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { BambuNetworkBridge } from "./bambu-network-bridge.js";
14
14
  import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
15
15
  import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
16
16
  import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
17
- import { BambuImplementation } from "./printers/bambu.js";
17
+ import { BambuImplementation, assertDirectPrintSupported } from "./printers/bambu.js";
18
18
  dotenv.config();
19
19
  const DEFAULT_HOST = process.env.BAMBU_PRINTER_HOST || process.env.PRINTER_HOST || "localhost";
20
20
  const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_PRINTER_SERIAL || process.env.BAMBU_SERIAL || "";
@@ -44,7 +44,7 @@ const DEFAULT_BAMBU_MODEL = process.env.BAMBU_PRINTER_MODEL?.trim().toLowerCase(
44
44
  "";
45
45
  const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
46
46
  const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
47
- const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c"];
47
+ const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c", "x2d"];
48
48
  const H2_BAMBU_MODELS = new Set(["h2d", "h2s", "h2c"]);
49
49
  const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"];
50
50
  const VALID_BAMBUSTUDIO_CLI_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"];
@@ -60,6 +60,7 @@ const BAMBU_MODEL_PRESETS = {
60
60
  h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
61
61
  h2s: (n) => `Bambu Lab H2S ${n} nozzle`,
62
62
  h2c: (n) => `Bambu Lab H2C ${n} nozzle`,
63
+ x2d: (n) => `Bambu Lab X2D ${n} nozzle`,
63
64
  };
64
65
  const FILAMENT_PROFILE_DIR = "/Applications/BambuStudio.app/Contents/Resources/profiles/BBL/filament";
65
66
  const FILAMENT_MODEL_CODES = {
@@ -73,6 +74,7 @@ const FILAMENT_MODEL_CODES = {
73
74
  h2d: "H2D",
74
75
  h2s: "H2S",
75
76
  h2c: "H2C",
77
+ x2d: "X2D",
76
78
  };
77
79
  const COLLAR_CHARM_POLICY = getCollarCharmRolePolicy();
78
80
  let filamentProfileIndexCache = null;
@@ -371,6 +373,19 @@ function extractPrinterDiagnostics(status) {
371
373
  diagnostic_fields: diagnosticFields,
372
374
  };
373
375
  }
376
+ /** Parse "#RRGGBB;#RRGGBB" (leading '#' optional) into positional slot colours. */
377
+ function parseFilamentColours(value) {
378
+ const colours = value.split(";").map((c) => c.trim()).filter(Boolean)
379
+ .map((c) => (c.startsWith("#") ? c : `#${c}`).toUpperCase());
380
+ for (const c of colours) {
381
+ if (!/^#[0-9A-F]{6}([0-9A-F]{2})?$/.test(c)) {
382
+ throw new Error(`Invalid filament colour "${c}"; expected #RRGGBB.`);
383
+ }
384
+ }
385
+ if (colours.length === 0)
386
+ throw new Error("filament_colours must list at least one colour.");
387
+ return colours;
388
+ }
374
389
  function validateBambuModel(model) {
375
390
  const normalized = model.trim().toLowerCase();
376
391
  if (!VALID_BAMBU_MODELS.includes(normalized)) {
@@ -822,6 +837,7 @@ class BambuPrinterMCPServer {
822
837
  { const: "h2d", title: "H2D" },
823
838
  { const: "h2s", title: "H2S" },
824
839
  { const: "h2c", title: "H2C" },
840
+ { const: "x2d", title: "X2D" },
825
841
  ],
826
842
  },
827
843
  },
@@ -1549,6 +1565,7 @@ class BambuPrinterMCPServer {
1549
1565
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
1550
1566
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs." },
1551
1567
  load_filament_ids: { type: "string", description: "Optional filament-to-object mapping string." },
1568
+ filament_colours: { type: "string", description: "Optional slot colours, one #RRGGBB per filament slot in order, separated by ';' (e.g. '#161616;#FFFFFF'). Defaults to the input 3MF's project colours, else the BambuStudio default." },
1552
1569
  ensure_on_bed: { type: "boolean", description: "Lift floating models onto the bed." },
1553
1570
  arrange: { type: "boolean", description: "Auto-arrange objects on the build plate." },
1554
1571
  orient: { type: "boolean", description: "Auto-orient model for optimal printability." },
@@ -1603,6 +1620,7 @@ class BambuPrinterMCPServer {
1603
1620
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs, e.g. 'pla_basic.json;petg_cf.json'." },
1604
1621
  filament_profile: { type: "string", description: "Compatibility alias for load_filaments. Semicolon-separated Orca/Bambu filament profile JSON paths." },
1605
1622
  load_filament_ids: { type: "string", description: "Map filaments to objects/parts. Comma-separated IDs matching load_filaments order, e.g. '1,2,3,1' assigns filament 1 to objects 0 and 3." },
1623
+ filament_colours: { type: "string", description: "Optional slot colours, one #RRGGBB per filament slot in order, separated by ';' (e.g. '#161616;#FFFFFF'). Defaults to the input 3MF's project colours, else the BambuStudio default." },
1606
1624
  enable_timelapse: { type: "boolean", description: "Insert timelapse parking moves into gcode. The toolhead parks at a fixed position each layer for camera capture. Adds ~10% print time." },
1607
1625
  allow_mix_temp: { type: "boolean", description: "Allow filaments with different temperature requirements on the same plate. Required for multi-material prints mixing e.g. PLA and PETG." },
1608
1626
  scale: { type: "number", description: "Uniform scale factor applied to all axes. 1.0 = original size, 2.0 = double, 0.5 = half. Applied before slicing." },
@@ -2107,7 +2125,7 @@ class BambuPrinterMCPServer {
2107
2125
  bambu_model: {
2108
2126
  type: "string",
2109
2127
  enum: [...VALID_BAMBU_MODELS],
2110
- description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths."
2128
+ description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths. X2D direct printing is not supported."
2111
2129
  },
2112
2130
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2113
2131
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -2343,23 +2361,27 @@ class BambuPrinterMCPServer {
2343
2361
  }
2344
2362
  break;
2345
2363
  }
2346
- case "upload_file":
2364
+ case "upload_file": {
2347
2365
  if (!args?.file_path || !args?.filename) {
2348
2366
  throw new Error("Missing required parameters: file_path and filename");
2349
2367
  }
2350
- if (Boolean(args.print ?? false)) {
2351
- await this.resolveBambuModel(args?.bambu_model);
2352
- }
2353
- result = await this.bambu.uploadFile(host, bambuSerial, bambuToken, String(args.file_path), String(args.filename), Boolean(args.print ?? false));
2368
+ const print = Boolean(args.print ?? false);
2369
+ const uploadModel = print ? await this.resolveBambuModel(args?.bambu_model) : undefined;
2370
+ if (print)
2371
+ assertDirectPrintSupported(uploadModel, bambuSerial);
2372
+ result = await this.bambu.uploadFile(host, bambuSerial, bambuToken, String(args.file_path), String(args.filename), print, uploadModel);
2354
2373
  break;
2374
+ }
2355
2375
  case "start_print":
2356
- case "start_print_job":
2376
+ case "start_print_job": {
2357
2377
  if (!args?.filename) {
2358
2378
  throw new Error("Missing required parameter: filename");
2359
2379
  }
2360
- await this.resolveBambuModel(args?.bambu_model);
2361
- result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename));
2380
+ const startModel = await this.resolveBambuModel(args?.bambu_model);
2381
+ assertDirectPrintSupported(startModel, bambuSerial);
2382
+ result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename), startModel);
2362
2383
  break;
2384
+ }
2363
2385
  case "cancel_print":
2364
2386
  result = await this.bambu.cancelJob(host, bambuSerial, bambuToken);
2365
2387
  break;
@@ -2499,6 +2521,8 @@ class BambuPrinterMCPServer {
2499
2521
  }
2500
2522
  if (args?.load_filament_ids !== undefined)
2501
2523
  sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
2524
+ if (args?.filament_colours !== undefined)
2525
+ sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
2502
2526
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
2503
2527
  if (args?.enable_timelapse !== undefined)
2504
2528
  sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
@@ -2576,6 +2600,8 @@ class BambuPrinterMCPServer {
2576
2600
  }
2577
2601
  if (args?.load_filament_ids !== undefined)
2578
2602
  sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
2603
+ if (args?.filament_colours !== undefined)
2604
+ sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
2579
2605
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
2580
2606
  if (args?.enable_timelapse !== undefined)
2581
2607
  sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
@@ -2628,6 +2654,7 @@ class BambuPrinterMCPServer {
2628
2654
  }
2629
2655
  const { slicerType, slicerPath, slicerProfile } = this.resolveSlicerConfigFromArgs(args, "print_3mf");
2630
2656
  const printModel = await this.resolveBambuModel(args?.bambu_model);
2657
+ assertDirectPrintSupported(printModel, bambuSerial);
2631
2658
  const printBedType = resolveBedType(args?.bed_type);
2632
2659
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2633
2660
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(args, slicerProfile || undefined, resolveTemplatePathFromName(["json", "config", "3mf"]) || explicitTemplatePath || undefined, TEMP_DIR);
@@ -2768,6 +2795,7 @@ class BambuPrinterMCPServer {
2768
2795
  }
2769
2796
  const { slicerType, slicerPath, slicerProfile } = this.resolveSlicerConfigFromArgs(args, "print_collar_charm");
2770
2797
  const printModel = await this.resolveBambuModel(args?.bambu_model);
2798
+ assertDirectPrintSupported(printModel, bambuSerial);
2771
2799
  const printBedType = resolveBedType(args?.bed_type);
2772
2800
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2773
2801
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(args, slicerProfile || undefined, resolvedTemplateSourcePath || explicitTemplatePath || undefined, TEMP_DIR);
@@ -1,3 +1,4 @@
1
+ export declare function assertDirectPrintSupported(model: string | undefined, serial?: string): void;
1
2
  interface BambuPrintOptionsInternal {
2
3
  projectName: string;
3
4
  filePath: string;
@@ -67,8 +68,8 @@ export declare class BambuImplementation {
67
68
  name: string;
68
69
  exists: boolean;
69
70
  }>;
70
- uploadFile(host: string, serial: string, token: string, filePath: string, filename: string, print: boolean): Promise<Record<string, unknown>>;
71
- startJob(host: string, serial: string, token: string, filename: string): Promise<{
71
+ uploadFile(host: string, serial: string, token: string, filePath: string, filename: string, print: boolean, bambuModel?: string): Promise<Record<string, unknown>>;
72
+ startJob(host: string, serial: string, token: string, filename: string, bambuModel?: string): Promise<{
72
73
  status: string;
73
74
  message: string;
74
75
  file: string;
@@ -37,6 +37,7 @@ const MODEL_ID_TO_NAME = {
37
37
  O1D: "H2D",
38
38
  O1E: "H2D Pro",
39
39
  O1S: "H2S",
40
+ N6: "X2D",
40
41
  N2S: "A1",
41
42
  A1M: "A1 Mini",
42
43
  C11: "P1P",
@@ -46,6 +47,12 @@ const MODEL_ID_TO_NAME = {
46
47
  C13: "X1E",
47
48
  };
48
49
  const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s"]);
50
+ export function assertDirectPrintSupported(model, serial) {
51
+ if (model?.trim().toLowerCase() === "x2d" || serial?.trim().toUpperCase().startsWith("20P")) {
52
+ throw new Error("X2D direct printing is not supported pending native eMMC transport support. " +
53
+ "X2D status and slicing remain available; use a supported slicer to print.");
54
+ }
55
+ }
49
56
  function isH2ModelName(model) {
50
57
  return H2_MODEL_NAMES.has(String(model ?? "").trim().toLowerCase().replace(/\s+/g, " "));
51
58
  }
@@ -95,6 +102,8 @@ class TolerantBambuClient extends BambuClient {
95
102
  return "H2C";
96
103
  if (sn.startsWith("31B"))
97
104
  return "H2DPRO";
105
+ if (sn.startsWith("20P"))
106
+ return "X2D";
98
107
  if (sn.startsWith("00M"))
99
108
  return "X1C";
100
109
  if (sn.startsWith("00W"))
@@ -457,6 +466,7 @@ export class BambuImplementation {
457
466
  }
458
467
  }
459
468
  async print3mf(host, serial, token, options) {
469
+ assertDirectPrintSupported(options.bambuModel, serial);
460
470
  if (!options.filePath.toLowerCase().endsWith(".3mf")) {
461
471
  throw new Error("print3mf requires a .3mf input file.");
462
472
  }
@@ -929,7 +939,9 @@ export class BambuImplementation {
929
939
  exists,
930
940
  };
931
941
  }
932
- async uploadFile(host, serial, token, filePath, filename, print) {
942
+ async uploadFile(host, serial, token, filePath, filename, print, bambuModel) {
943
+ if (print)
944
+ assertDirectPrintSupported(bambuModel, serial);
933
945
  await fs.access(filePath);
934
946
  const normalizedFileName = filename.replace(/^\/+/, "");
935
947
  const remotePath = normalizedFileName.includes("/")
@@ -945,7 +957,7 @@ export class BambuImplementation {
945
957
  };
946
958
  if (print) {
947
959
  if (remotePath.toLowerCase().endsWith(".gcode")) {
948
- response.startResult = await this.startJob(host, serial, token, remotePath);
960
+ response.startResult = await this.startJob(host, serial, token, remotePath, bambuModel);
949
961
  }
950
962
  else if (remotePath.toLowerCase().endsWith(".3mf")) {
951
963
  response.note =
@@ -957,7 +969,8 @@ export class BambuImplementation {
957
969
  }
958
970
  return response;
959
971
  }
960
- async startJob(host, serial, token, filename) {
972
+ async startJob(host, serial, token, filename, bambuModel) {
973
+ assertDirectPrintSupported(bambuModel, serial);
961
974
  const lower = filename.toLowerCase();
962
975
  if (lower.endsWith(".3mf") && !lower.endsWith(".gcode.3mf")) {
963
976
  throw new Error("Use print_3mf for .3mf project files.");
@@ -1007,7 +1020,7 @@ export class BambuImplementation {
1007
1020
  // P1/A1 series still use the proprietary TCP-on-6000 framed JPEG path
1008
1021
  // (per https://github.com/Doridian/OpenBambuAPI/blob/main/video.md).
1009
1022
  const TCP_CAMERA_MODELS = new Set(["a1", "a1mini", "p1s", "p1p"]);
1010
- // X1, P2S, AND H2 (H2S/H2D/H2C) all use RTSP on port 322. The
1023
+ // X1, P2S, H2 (H2S/H2D/H2C), AND X2D all use RTSP on port 322. The
1011
1024
  // OpenBambuAPI doc only mentions X1/P2S, but the HA bambulab
1012
1025
  // integration's models.py shows the printer reports its own
1013
1026
  // `ipcam.rtsp_url` for these models, and Parker (H2S) rejects the
@@ -1015,7 +1028,7 @@ export class BambuImplementation {
1015
1028
  // confirmed by local H2 camera transport probes).
1016
1029
  const RTSP_MODELS = new Set([
1017
1030
  "x1", "x1c", "x1carbon", "x1e", "p2s",
1018
- "h2", "h2s", "h2d", "h2c", "h2dpro",
1031
+ "h2", "h2s", "h2d", "h2c", "h2dpro", "x2d",
1019
1032
  ]);
1020
1033
  if (!model) {
1021
1034
  throw new Error("camera_snapshot requires bambu_model or BAMBU_MODEL so it can choose the correct Bambu camera protocol.");
@@ -70,7 +70,21 @@ export interface FlattenOptions {
70
70
  process?: Record<string, unknown>;
71
71
  filaments?: (Record<string, unknown> | undefined)[];
72
72
  };
73
+ /**
74
+ * Positional `#RRGGBB` colour per filament slot (e.g. from the input 3MF
75
+ * project or the caller). Missing entries keep the profile's own colour or
76
+ * fall back to DEFAULT_FILAMENT_COLOUR.
77
+ */
78
+ filamentColours?: (string | undefined)[];
79
+ /** Saved per-plate positions, used only when the process sets no position. */
80
+ projectTowerPosition?: {
81
+ wipe_tower_x?: unknown;
82
+ wipe_tower_y?: unknown;
83
+ };
73
84
  }
85
+ /** Best-effort extruder count for fallback nozzle_volume_type sizing. */
86
+ /** BambuStudio's built-in filament_colour default. */
87
+ export declare const DEFAULT_FILAMENT_COLOUR = "#00AE42";
74
88
  /**
75
89
  * Flatten the leaf profiles, post-process for CLI, and write to temp files.
76
90
  *
@@ -219,6 +219,86 @@ function enforceMatchingNozzles(arr) {
219
219
  }
220
220
  }
221
221
  /** Best-effort extruder count for fallback nozzle_volume_type sizing. */
222
+ /** BambuStudio's built-in filament_colour default. */
223
+ export const DEFAULT_FILAMENT_COLOUR = "#00AE42";
224
+ const FILAMENT_COLOUR_RE = /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/;
225
+ /**
226
+ * No BBL filament profile defines `filament_colour`; the GUI fills it from
227
+ * the AMS. Without it the CLI keeps a single-entry colour vector however many
228
+ * filaments are loaded and crashes (access violation) as soon as a slice uses
229
+ * any filament after the first. Give every slot exactly one colour.
230
+ */
231
+ function ensureFilamentColour(flat, colour) {
232
+ if (colour !== undefined) {
233
+ if (!FILAMENT_COLOUR_RE.test(colour)) {
234
+ throw new Error(`Invalid filament colour "${colour}"; expected #RRGGBB.`);
235
+ }
236
+ flat["filament_colour"] = [colour];
237
+ return;
238
+ }
239
+ const own = flat["filament_colour"];
240
+ if (Array.isArray(own) && typeof own[0] === "string" && FILAMENT_COLOUR_RE.test(own[0])) {
241
+ flat["filament_colour"] = [own[0]];
242
+ return;
243
+ }
244
+ flat["filament_colour"] = [DEFAULT_FILAMENT_COLOUR];
245
+ }
246
+ // BambuStudio's defaults when a process sets no prime tower position/width.
247
+ const DEFAULT_WIPE_TOWER_X = 15;
248
+ const DEFAULT_WIPE_TOWER_Y = 220;
249
+ const DEFAULT_PRIME_TOWER_WIDTH = 35;
250
+ // Clearance inside the shared nozzle area for the auto-sized tower brim and
251
+ // the CLI's own safety margin (X2D fails at 5 mm from the edge, passes at 6).
252
+ const WIPE_TOWER_MARGIN = 15;
253
+ function parseArea(area) {
254
+ const points = area.split(",").map((p) => p.trim().split("x").map(Number));
255
+ if (points.length < 3 || points.some((p) => p.length !== 2 || p.some((n) => !Number.isFinite(n)))) {
256
+ throw new Error(`Malformed extruder_printable_area entry "${area}".`);
257
+ }
258
+ const xs = points.map((p) => p[0]);
259
+ const ys = points.map((p) => p[1]);
260
+ return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
261
+ }
262
+ /**
263
+ * On multi-nozzle machines (H2D, X2D) each nozzle reaches only part of the
264
+ * bed (`extruder_printable_area`), and the prime tower must be reachable by
265
+ * every nozzle. The CLI's default tower position (x=15) lies outside the
266
+ * X2D/H2D right-nozzle area, so slicing fails with "G-code outside of the
267
+ * printable area". When the process sets no position and the default does
268
+ * not fit, move the tower inside the area all nozzles share.
269
+ */
270
+ function placePrimeTowerForAllNozzles(machineFlat, processFlat) {
271
+ if (processFlat["wipe_tower_x"] !== undefined || processFlat["wipe_tower_y"] !== undefined)
272
+ return;
273
+ if (["0", "false"].includes(String(processFlat["enable_prime_tower"]).toLowerCase()))
274
+ return;
275
+ const areas = machineFlat["extruder_printable_area"];
276
+ if (!Array.isArray(areas) || areas.length < 2)
277
+ return;
278
+ const rects = areas.map((a) => {
279
+ if (typeof a !== "string")
280
+ throw new Error("Malformed extruder_printable_area in machine profile.");
281
+ return parseArea(a);
282
+ });
283
+ const shared = {
284
+ minX: Math.max(...rects.map((r) => r.minX)) + WIPE_TOWER_MARGIN,
285
+ maxX: Math.min(...rects.map((r) => r.maxX)) - WIPE_TOWER_MARGIN,
286
+ minY: Math.max(...rects.map((r) => r.minY)) + WIPE_TOWER_MARGIN,
287
+ maxY: Math.min(...rects.map((r) => r.maxY)) - WIPE_TOWER_MARGIN,
288
+ };
289
+ const width = Number(processFlat["prime_tower_width"] ?? DEFAULT_PRIME_TOWER_WIDTH);
290
+ const size = Number.isFinite(width) && width > 0 ? width : DEFAULT_PRIME_TOWER_WIDTH;
291
+ if (shared.maxX - shared.minX < size || shared.maxY - shared.minY < size) {
292
+ throw new Error("No bed area is reachable by every nozzle for the prime tower.");
293
+ }
294
+ const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
295
+ const x = clamp(DEFAULT_WIPE_TOWER_X, shared.minX, shared.maxX - size);
296
+ const y = clamp(DEFAULT_WIPE_TOWER_Y, shared.minY, shared.maxY - size);
297
+ if (x === DEFAULT_WIPE_TOWER_X && y === DEFAULT_WIPE_TOWER_Y)
298
+ return;
299
+ processFlat["wipe_tower_x"] = [String(x)];
300
+ processFlat["wipe_tower_y"] = [String(y)];
301
+ }
222
302
  function inferExtruderCount(flat) {
223
303
  for (const key of ["nozzle_diameter", "extruder_type", "extruder_variant_list"]) {
224
304
  const v = flat[key];
@@ -466,7 +546,15 @@ export async function flattenForCli(opts) {
466
546
  normalizeForCli(machineFlat, "machine", opts.machineLeaf);
467
547
  normalizeForCli(processFlat, "process", opts.processLeaf);
468
548
  filamentFlats.forEach((f, i) => normalizeForCli(f, "filament", opts.filamentLeaves[i]));
549
+ if (opts.filamentColours && opts.filamentColours.length !== filamentFlats.length) {
550
+ throw new Error(`${opts.filamentColours.length} filament colours were supplied for ${filamentFlats.length} filament slots.`);
551
+ }
552
+ filamentFlats.forEach((f, i) => ensureFilamentColour(f, opts.filamentColours?.[i]));
469
553
  applyBedType(processFlat, opts.bedType);
554
+ if (processFlat["wipe_tower_x"] === undefined && processFlat["wipe_tower_y"] === undefined) {
555
+ Object.assign(processFlat, opts.projectTowerPosition);
556
+ }
557
+ placePrimeTowerForAllNozzles(machineFlat, processFlat);
470
558
  // Mirror the GUI's auto-extend behavior: when the caller explicitly
471
559
  // chose a process or filament that wasn't pre-declared compatible with
472
560
  // the chosen machine (e.g. "0.20mm Standard @BBL P1P" used on a P1S),
@@ -36,6 +36,7 @@ export interface BambuSliceOptions {
36
36
  skipObjects?: string;
37
37
  loadFilaments?: string;
38
38
  loadFilamentIds?: string;
39
+ filamentColours?: string[];
39
40
  bedType?: string;
40
41
  enableTimelapse?: boolean;
41
42
  allowMixTemp?: boolean;
@@ -382,20 +382,32 @@ export class STLManipulator extends EventEmitter {
382
382
  profilesRoot,
383
383
  tempDir: this.tempDir,
384
384
  bedType: this.resolveBambuStudioBedType(bambuOptions?.bedType),
385
+ filamentColours: bambuOptions?.filamentColours ?? bundle.filamentColours,
386
+ projectTowerPosition: bundle.projectTowerPosition,
385
387
  sourceProfiles: {
386
388
  machine: isBundledFile(machinePath) ? undefined : machine,
387
389
  process: isBundledFile(processPath) ? undefined : processProfile,
388
390
  filaments: filaments.map((profile, i) => isBundledFile(bundle.filamentPaths[i]) ? undefined : profile),
389
391
  },
390
392
  });
391
- // Standalone custom files have no BBL dependencies; preserve their identity
392
- // and content. Other profiles retain user values through sourceProfiles.
393
+ // Standalone files retain their own identity and settings, but still need
394
+ // the CLI colour/tower fields. Do not discard those required overlays when
395
+ // choosing the original profile instead of the normalized BBL output.
396
+ const withCliFields = (filePath, profile, flatPath, keys) => {
397
+ const flattened = readProfile(flatPath);
398
+ const fields = Object.fromEntries(keys.filter(key => flattened[key] !== undefined).map(key => [key, flattened[key]]));
399
+ if (Object.entries(fields).every(([key, value]) => JSON.stringify(profile[key]) === JSON.stringify(value)))
400
+ return filePath;
401
+ return this.writeTempJson(path.basename(filePath, '.json'), 'cli', { ...profile, ...fields });
402
+ };
393
403
  return {
394
404
  settingsArg: [
395
405
  needsResolution(machinePath, machine) ? flat.machinePath : machinePath,
396
- needsResolution(processPath, processProfile) ? flat.processPath : processPath,
406
+ needsResolution(processPath, processProfile) ? flat.processPath
407
+ : withCliFields(processPath, processProfile, flat.processPath, ['wipe_tower_x', 'wipe_tower_y']),
397
408
  ].join(';'),
398
- filamentPaths: filaments.map((profile, i) => needsResolution(bundle.filamentPaths[i], profile) ? flat.filamentPaths[i] : bundle.filamentPaths[i]),
409
+ filamentPaths: filaments.map((profile, i) => needsResolution(bundle.filamentPaths[i], profile) ? flat.filamentPaths[i]
410
+ : withCliFields(bundle.filamentPaths[i], profile, flat.filamentPaths[i], ['filament_colour'])),
399
411
  };
400
412
  }
401
413
  /** --load-filaments is positional; a single override must cover every project slot. */
@@ -417,13 +429,20 @@ export class STLManipulator extends EventEmitter {
417
429
  .map(key => Array.isArray(data[key]) ? data[key].length : 0));
418
430
  if (count === 0)
419
431
  throw new Error('Cannot determine project filament slots from project_settings.config.');
432
+ // Keep the project's slot colours; loaded profiles carry none of their own.
433
+ const colours = Array.isArray(data.filament_colour) && data.filament_colour.length === count &&
434
+ data.filament_colour.every(c => typeof c === 'string' && /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(c))
435
+ ? data.filament_colour
436
+ : undefined;
437
+ const projectTowerPosition = Object.fromEntries(['wipe_tower_x', 'wipe_tower_y']
438
+ .filter(key => data[key] !== undefined).map(key => [key, data[key]]));
420
439
  if (bundle.filamentPaths.length === 1) {
421
- return { ...bundle, filamentPaths: Array(count).fill(bundle.filamentPaths[0]) };
440
+ return { ...bundle, filamentPaths: Array(count).fill(bundle.filamentPaths[0]), filamentColours: colours, projectTowerPosition };
422
441
  }
423
442
  if (bundle.filamentPaths.length !== count) {
424
443
  throw new Error(`Project declares ${count} filament slots, but ${bundle.filamentPaths.length} profiles were supplied. Provide one profile for all slots or one per slot.`);
425
444
  }
426
- return bundle;
445
+ return { ...bundle, filamentColours: colours, projectTowerPosition };
427
446
  }
428
447
  resolveBambuStudioBedType(bedType) {
429
448
  if (!bedType)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.8",
3
+ "version": "1.1.9",
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
@@ -29,7 +29,7 @@ import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
29
29
  import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
30
30
  import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
31
31
  import type { ThreeMFAmsRequirements } from "./types.js";
32
- import { BambuImplementation } from "./printers/bambu.js";
32
+ import { BambuImplementation, assertDirectPrintSupported } from "./printers/bambu.js";
33
33
 
34
34
  dotenv.config();
35
35
 
@@ -64,7 +64,7 @@ const DEFAULT_BAMBU_MODEL =
64
64
  const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
65
65
  const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
66
66
 
67
- const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c"] as const;
67
+ const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c", "x2d"] as const;
68
68
  type BambuModel = typeof VALID_BAMBU_MODELS[number];
69
69
  const H2_BAMBU_MODELS = new Set<string>(["h2d", "h2s", "h2c"]);
70
70
 
@@ -83,6 +83,7 @@ const BAMBU_MODEL_PRESETS: Record<string, (nozzle: string) => string> = {
83
83
  h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
84
84
  h2s: (n) => `Bambu Lab H2S ${n} nozzle`,
85
85
  h2c: (n) => `Bambu Lab H2C ${n} nozzle`,
86
+ x2d: (n) => `Bambu Lab X2D ${n} nozzle`,
86
87
  };
87
88
 
88
89
  const FILAMENT_PROFILE_DIR =
@@ -98,6 +99,7 @@ const FILAMENT_MODEL_CODES: Record<string, string> = {
98
99
  h2d: "H2D",
99
100
  h2s: "H2S",
100
101
  h2c: "H2C",
102
+ x2d: "X2D",
101
103
  };
102
104
 
103
105
  type FilamentProfileIndex = {
@@ -516,6 +518,19 @@ function extractPrinterDiagnostics(status: any): Record<string, unknown> {
516
518
  };
517
519
  }
518
520
 
521
+ /** Parse "#RRGGBB;#RRGGBB" (leading '#' optional) into positional slot colours. */
522
+ function parseFilamentColours(value: string): string[] {
523
+ const colours = value.split(";").map((c) => c.trim()).filter(Boolean)
524
+ .map((c) => (c.startsWith("#") ? c : `#${c}`).toUpperCase());
525
+ for (const c of colours) {
526
+ if (!/^#[0-9A-F]{6}([0-9A-F]{2})?$/.test(c)) {
527
+ throw new Error(`Invalid filament colour "${c}"; expected #RRGGBB.`);
528
+ }
529
+ }
530
+ if (colours.length === 0) throw new Error("filament_colours must list at least one colour.");
531
+ return colours;
532
+ }
533
+
519
534
  function validateBambuModel(model: string): string {
520
535
  const normalized = model.trim().toLowerCase();
521
536
  if (!VALID_BAMBU_MODELS.includes(normalized as BambuModel)) {
@@ -1108,6 +1123,7 @@ class BambuPrinterMCPServer {
1108
1123
  { const: "h2d", title: "H2D" },
1109
1124
  { const: "h2s", title: "H2S" },
1110
1125
  { const: "h2c", title: "H2C" },
1126
+ { const: "x2d", title: "X2D" },
1111
1127
  ],
1112
1128
  },
1113
1129
  },
@@ -2045,6 +2061,7 @@ class BambuPrinterMCPServer {
2045
2061
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
2046
2062
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs." },
2047
2063
  load_filament_ids: { type: "string", description: "Optional filament-to-object mapping string." },
2064
+ filament_colours: { type: "string", description: "Optional slot colours, one #RRGGBB per filament slot in order, separated by ';' (e.g. '#161616;#FFFFFF'). Defaults to the input 3MF's project colours, else the BambuStudio default." },
2048
2065
  ensure_on_bed: { type: "boolean", description: "Lift floating models onto the bed." },
2049
2066
  arrange: { type: "boolean", description: "Auto-arrange objects on the build plate." },
2050
2067
  orient: { type: "boolean", description: "Auto-orient model for optimal printability." },
@@ -2099,6 +2116,7 @@ class BambuPrinterMCPServer {
2099
2116
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs, e.g. 'pla_basic.json;petg_cf.json'." },
2100
2117
  filament_profile: { type: "string", description: "Compatibility alias for load_filaments. Semicolon-separated Orca/Bambu filament profile JSON paths." },
2101
2118
  load_filament_ids: { type: "string", description: "Map filaments to objects/parts. Comma-separated IDs matching load_filaments order, e.g. '1,2,3,1' assigns filament 1 to objects 0 and 3." },
2119
+ filament_colours: { type: "string", description: "Optional slot colours, one #RRGGBB per filament slot in order, separated by ';' (e.g. '#161616;#FFFFFF'). Defaults to the input 3MF's project colours, else the BambuStudio default." },
2102
2120
  enable_timelapse: { type: "boolean", description: "Insert timelapse parking moves into gcode. The toolhead parks at a fixed position each layer for camera capture. Adds ~10% print time." },
2103
2121
  allow_mix_temp: { type: "boolean", description: "Allow filaments with different temperature requirements on the same plate. Required for multi-material prints mixing e.g. PLA and PETG." },
2104
2122
  scale: { type: "number", description: "Uniform scale factor applied to all axes. 1.0 = original size, 2.0 = double, 0.5 = half. Applied before slicing." },
@@ -2603,7 +2621,7 @@ class BambuPrinterMCPServer {
2603
2621
  bambu_model: {
2604
2622
  type: "string",
2605
2623
  enum: [...VALID_BAMBU_MODELS],
2606
- description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths."
2624
+ description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths. X2D direct printing is not supported."
2607
2625
  },
2608
2626
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2609
2627
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -2895,28 +2913,31 @@ class BambuPrinterMCPServer {
2895
2913
  break;
2896
2914
  }
2897
2915
 
2898
- case "upload_file":
2916
+ case "upload_file": {
2899
2917
  if (!args?.file_path || !args?.filename) {
2900
2918
  throw new Error("Missing required parameters: file_path and filename");
2901
2919
  }
2902
- if (Boolean(args.print ?? false)) {
2903
- await this.resolveBambuModel(args?.bambu_model as string | undefined);
2904
- }
2920
+ const print = Boolean(args.print ?? false);
2921
+ const uploadModel = print ? await this.resolveBambuModel(args?.bambu_model as string | undefined) : undefined;
2922
+ if (print) assertDirectPrintSupported(uploadModel, bambuSerial);
2905
2923
  result = await this.bambu.uploadFile(
2906
2924
  host, bambuSerial, bambuToken,
2907
2925
  String(args.file_path), String(args.filename),
2908
- Boolean(args.print ?? false)
2926
+ print, uploadModel
2909
2927
  );
2910
2928
  break;
2929
+ }
2911
2930
 
2912
2931
  case "start_print":
2913
- case "start_print_job":
2932
+ case "start_print_job": {
2914
2933
  if (!args?.filename) {
2915
2934
  throw new Error("Missing required parameter: filename");
2916
2935
  }
2917
- await this.resolveBambuModel(args?.bambu_model as string | undefined);
2918
- result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename));
2936
+ const startModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
2937
+ assertDirectPrintSupported(startModel, bambuSerial);
2938
+ result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename), startModel);
2919
2939
  break;
2940
+ }
2920
2941
 
2921
2942
  case "cancel_print":
2922
2943
  result = await this.bambu.cancelJob(host, bambuSerial, bambuToken);
@@ -3118,6 +3139,7 @@ class BambuPrinterMCPServer {
3118
3139
  sliceBambuOptions.loadFilaments = String(args.filament_profile);
3119
3140
  }
3120
3141
  if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
3142
+ if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3121
3143
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3122
3144
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3123
3145
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
@@ -3203,6 +3225,7 @@ class BambuPrinterMCPServer {
3203
3225
  sliceBambuOptions.loadFilaments = String(args.filament_profile);
3204
3226
  }
3205
3227
  if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
3228
+ if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3206
3229
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3207
3230
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3208
3231
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
@@ -3265,6 +3288,7 @@ class BambuPrinterMCPServer {
3265
3288
  "print_3mf"
3266
3289
  );
3267
3290
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
3291
+ assertDirectPrintSupported(printModel, bambuSerial);
3268
3292
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
3269
3293
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
3270
3294
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(
@@ -3450,6 +3474,7 @@ class BambuPrinterMCPServer {
3450
3474
  "print_collar_charm"
3451
3475
  );
3452
3476
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
3477
+ assertDirectPrintSupported(printModel, bambuSerial);
3453
3478
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
3454
3479
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
3455
3480
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(
@@ -47,6 +47,7 @@ const MODEL_ID_TO_NAME: Record<string, string> = {
47
47
  O1D: "H2D",
48
48
  O1E: "H2D Pro",
49
49
  O1S: "H2S",
50
+ N6: "X2D",
50
51
  N2S: "A1",
51
52
  A1M: "A1 Mini",
52
53
  C11: "P1P",
@@ -58,6 +59,15 @@ const MODEL_ID_TO_NAME: Record<string, string> = {
58
59
 
59
60
  const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s"]);
60
61
 
62
+ export function assertDirectPrintSupported(model: string | undefined, serial?: string): void {
63
+ if (model?.trim().toLowerCase() === "x2d" || serial?.trim().toUpperCase().startsWith("20P")) {
64
+ throw new Error(
65
+ "X2D direct printing is not supported pending native eMMC transport support. " +
66
+ "X2D status and slicing remain available; use a supported slicer to print."
67
+ );
68
+ }
69
+ }
70
+
61
71
  function isH2ModelName(model: unknown): boolean {
62
72
  return H2_MODEL_NAMES.has(String(model ?? "").trim().toLowerCase().replace(/\s+/g, " "));
63
73
  }
@@ -151,6 +161,7 @@ class TolerantBambuClient extends BambuClient {
151
161
  if (sn.startsWith("094")) return "H2D";
152
162
  if (sn.startsWith("239")) return "H2C";
153
163
  if (sn.startsWith("31B")) return "H2DPRO";
164
+ if (sn.startsWith("20P")) return "X2D";
154
165
  if (sn.startsWith("00M")) return "X1C";
155
166
  if (sn.startsWith("00W")) return "X1";
156
167
  if (sn.startsWith("03W")) return "X1E";
@@ -568,6 +579,7 @@ export class BambuImplementation {
568
579
  token: string,
569
580
  options: BambuPrintOptionsInternal
570
581
  ): Promise<any> {
582
+ assertDirectPrintSupported(options.bambuModel, serial);
571
583
  if (!options.filePath.toLowerCase().endsWith(".3mf")) {
572
584
  throw new Error("print3mf requires a .3mf input file.");
573
585
  }
@@ -1159,8 +1171,10 @@ export class BambuImplementation {
1159
1171
  token: string,
1160
1172
  filePath: string,
1161
1173
  filename: string,
1162
- print: boolean
1174
+ print: boolean,
1175
+ bambuModel?: string
1163
1176
  ) {
1177
+ if (print) assertDirectPrintSupported(bambuModel, serial);
1164
1178
  await fs.access(filePath);
1165
1179
 
1166
1180
  const normalizedFileName = filename.replace(/^\/+/, "");
@@ -1180,7 +1194,7 @@ export class BambuImplementation {
1180
1194
 
1181
1195
  if (print) {
1182
1196
  if (remotePath.toLowerCase().endsWith(".gcode")) {
1183
- response.startResult = await this.startJob(host, serial, token, remotePath);
1197
+ response.startResult = await this.startJob(host, serial, token, remotePath, bambuModel);
1184
1198
  } else if (remotePath.toLowerCase().endsWith(".3mf")) {
1185
1199
  response.note =
1186
1200
  "3MF upload complete. Use print_3mf to start a project print with plate and metadata options.";
@@ -1194,7 +1208,8 @@ export class BambuImplementation {
1194
1208
  return response;
1195
1209
  }
1196
1210
 
1197
- async startJob(host: string, serial: string, token: string, filename: string) {
1211
+ async startJob(host: string, serial: string, token: string, filename: string, bambuModel?: string) {
1212
+ assertDirectPrintSupported(bambuModel, serial);
1198
1213
  const lower = filename.toLowerCase();
1199
1214
  if (lower.endsWith(".3mf") && !lower.endsWith(".gcode.3mf")) {
1200
1215
  throw new Error("Use print_3mf for .3mf project files.");
@@ -1281,7 +1296,7 @@ export class BambuImplementation {
1281
1296
  // P1/A1 series still use the proprietary TCP-on-6000 framed JPEG path
1282
1297
  // (per https://github.com/Doridian/OpenBambuAPI/blob/main/video.md).
1283
1298
  const TCP_CAMERA_MODELS = new Set(["a1", "a1mini", "p1s", "p1p"]);
1284
- // X1, P2S, AND H2 (H2S/H2D/H2C) all use RTSP on port 322. The
1299
+ // X1, P2S, H2 (H2S/H2D/H2C), AND X2D all use RTSP on port 322. The
1285
1300
  // OpenBambuAPI doc only mentions X1/P2S, but the HA bambulab
1286
1301
  // integration's models.py shows the printer reports its own
1287
1302
  // `ipcam.rtsp_url` for these models, and Parker (H2S) rejects the
@@ -1289,7 +1304,7 @@ export class BambuImplementation {
1289
1304
  // confirmed by local H2 camera transport probes).
1290
1305
  const RTSP_MODELS = new Set([
1291
1306
  "x1", "x1c", "x1carbon", "x1e", "p2s",
1292
- "h2", "h2s", "h2d", "h2c", "h2dpro",
1307
+ "h2", "h2s", "h2d", "h2c", "h2dpro", "x2d",
1293
1308
  ]);
1294
1309
 
1295
1310
  if (!model) {
@@ -78,6 +78,14 @@ export interface FlattenOptions {
78
78
  process?: Record<string, unknown>;
79
79
  filaments?: (Record<string, unknown> | undefined)[];
80
80
  };
81
+ /**
82
+ * Positional `#RRGGBB` colour per filament slot (e.g. from the input 3MF
83
+ * project or the caller). Missing entries keep the profile's own colour or
84
+ * fall back to DEFAULT_FILAMENT_COLOUR.
85
+ */
86
+ filamentColours?: (string | undefined)[];
87
+ /** Saved per-plate positions, used only when the process sets no position. */
88
+ projectTowerPosition?: { wipe_tower_x?: unknown; wipe_tower_y?: unknown };
81
89
  }
82
90
 
83
91
  interface IndexedProfile {
@@ -308,6 +316,91 @@ function enforceMatchingNozzles(arr: unknown[]): void {
308
316
  }
309
317
 
310
318
  /** Best-effort extruder count for fallback nozzle_volume_type sizing. */
319
+ /** BambuStudio's built-in filament_colour default. */
320
+ export const DEFAULT_FILAMENT_COLOUR = "#00AE42";
321
+ const FILAMENT_COLOUR_RE = /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/;
322
+
323
+ /**
324
+ * No BBL filament profile defines `filament_colour`; the GUI fills it from
325
+ * the AMS. Without it the CLI keeps a single-entry colour vector however many
326
+ * filaments are loaded and crashes (access violation) as soon as a slice uses
327
+ * any filament after the first. Give every slot exactly one colour.
328
+ */
329
+ function ensureFilamentColour(flat: Record<string, unknown>, colour?: string): void {
330
+ if (colour !== undefined) {
331
+ if (!FILAMENT_COLOUR_RE.test(colour)) {
332
+ throw new Error(`Invalid filament colour "${colour}"; expected #RRGGBB.`);
333
+ }
334
+ flat["filament_colour"] = [colour];
335
+ return;
336
+ }
337
+ const own = flat["filament_colour"];
338
+ if (Array.isArray(own) && typeof own[0] === "string" && FILAMENT_COLOUR_RE.test(own[0])) {
339
+ flat["filament_colour"] = [own[0]];
340
+ return;
341
+ }
342
+ flat["filament_colour"] = [DEFAULT_FILAMENT_COLOUR];
343
+ }
344
+
345
+ // BambuStudio's defaults when a process sets no prime tower position/width.
346
+ const DEFAULT_WIPE_TOWER_X = 15;
347
+ const DEFAULT_WIPE_TOWER_Y = 220;
348
+ const DEFAULT_PRIME_TOWER_WIDTH = 35;
349
+ // Clearance inside the shared nozzle area for the auto-sized tower brim and
350
+ // the CLI's own safety margin (X2D fails at 5 mm from the edge, passes at 6).
351
+ const WIPE_TOWER_MARGIN = 15;
352
+
353
+ interface Rect { minX: number; maxX: number; minY: number; maxY: number }
354
+
355
+ function parseArea(area: string): Rect {
356
+ const points = area.split(",").map((p) => p.trim().split("x").map(Number));
357
+ if (points.length < 3 || points.some((p) => p.length !== 2 || p.some((n) => !Number.isFinite(n)))) {
358
+ throw new Error(`Malformed extruder_printable_area entry "${area}".`);
359
+ }
360
+ const xs = points.map((p) => p[0]);
361
+ const ys = points.map((p) => p[1]);
362
+ return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
363
+ }
364
+
365
+ /**
366
+ * On multi-nozzle machines (H2D, X2D) each nozzle reaches only part of the
367
+ * bed (`extruder_printable_area`), and the prime tower must be reachable by
368
+ * every nozzle. The CLI's default tower position (x=15) lies outside the
369
+ * X2D/H2D right-nozzle area, so slicing fails with "G-code outside of the
370
+ * printable area". When the process sets no position and the default does
371
+ * not fit, move the tower inside the area all nozzles share.
372
+ */
373
+ function placePrimeTowerForAllNozzles(
374
+ machineFlat: Record<string, unknown>,
375
+ processFlat: Record<string, unknown>
376
+ ): void {
377
+ if (processFlat["wipe_tower_x"] !== undefined || processFlat["wipe_tower_y"] !== undefined) return;
378
+ if (["0", "false"].includes(String(processFlat["enable_prime_tower"]).toLowerCase())) return;
379
+ const areas = machineFlat["extruder_printable_area"];
380
+ if (!Array.isArray(areas) || areas.length < 2) return;
381
+ const rects = areas.map((a) => {
382
+ if (typeof a !== "string") throw new Error("Malformed extruder_printable_area in machine profile.");
383
+ return parseArea(a);
384
+ });
385
+ const shared: Rect = {
386
+ minX: Math.max(...rects.map((r) => r.minX)) + WIPE_TOWER_MARGIN,
387
+ maxX: Math.min(...rects.map((r) => r.maxX)) - WIPE_TOWER_MARGIN,
388
+ minY: Math.max(...rects.map((r) => r.minY)) + WIPE_TOWER_MARGIN,
389
+ maxY: Math.min(...rects.map((r) => r.maxY)) - WIPE_TOWER_MARGIN,
390
+ };
391
+ const width = Number(processFlat["prime_tower_width"] ?? DEFAULT_PRIME_TOWER_WIDTH);
392
+ const size = Number.isFinite(width) && width > 0 ? width : DEFAULT_PRIME_TOWER_WIDTH;
393
+ if (shared.maxX - shared.minX < size || shared.maxY - shared.minY < size) {
394
+ throw new Error("No bed area is reachable by every nozzle for the prime tower.");
395
+ }
396
+ const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
397
+ const x = clamp(DEFAULT_WIPE_TOWER_X, shared.minX, shared.maxX - size);
398
+ const y = clamp(DEFAULT_WIPE_TOWER_Y, shared.minY, shared.maxY - size);
399
+ if (x === DEFAULT_WIPE_TOWER_X && y === DEFAULT_WIPE_TOWER_Y) return;
400
+ processFlat["wipe_tower_x"] = [String(x)];
401
+ processFlat["wipe_tower_y"] = [String(y)];
402
+ }
403
+
311
404
  function inferExtruderCount(flat: Record<string, unknown>): number {
312
405
  for (const key of ["nozzle_diameter", "extruder_type", "extruder_variant_list"]) {
313
406
  const v = flat[key];
@@ -586,7 +679,17 @@ export async function flattenForCli(opts: FlattenOptions): Promise<FlattenedProf
586
679
  normalizeForCli(machineFlat, "machine", opts.machineLeaf);
587
680
  normalizeForCli(processFlat, "process", opts.processLeaf);
588
681
  filamentFlats.forEach((f, i) => normalizeForCli(f, "filament", opts.filamentLeaves[i]));
682
+ if (opts.filamentColours && opts.filamentColours.length !== filamentFlats.length) {
683
+ throw new Error(
684
+ `${opts.filamentColours.length} filament colours were supplied for ${filamentFlats.length} filament slots.`
685
+ );
686
+ }
687
+ filamentFlats.forEach((f, i) => ensureFilamentColour(f, opts.filamentColours?.[i]));
589
688
  applyBedType(processFlat, opts.bedType);
689
+ if (processFlat["wipe_tower_x"] === undefined && processFlat["wipe_tower_y"] === undefined) {
690
+ Object.assign(processFlat, opts.projectTowerPosition);
691
+ }
692
+ placePrimeTowerForAllNozzles(machineFlat, processFlat);
590
693
 
591
694
  // Mirror the GUI's auto-extend behavior: when the caller explicitly
592
695
  // chose a process or filament that wasn't pre-declared compatible with
@@ -162,6 +162,7 @@ export interface BambuSliceOptions {
162
162
  skipObjects?: string; // --skip-objects "3,5,10": skip specific objects
163
163
  loadFilaments?: string; // --load-filaments "f1.json;f2.json": filament profiles
164
164
  loadFilamentIds?: string; // --load-filament-ids "1,2,3,1": filament-to-object mapping
165
+ filamentColours?: string[]; // positional #RRGGBB per loaded filament slot
165
166
  bedType?: string; // Bambu bed type, e.g. textured_plate or cool_plate
166
167
  enableTimelapse?: boolean; // --enable-timelapse: timelapse-aware slicing
167
168
  allowMixTemp?: boolean; // --allow-mix-temp: allow mixed-temp filaments
@@ -177,6 +178,10 @@ export interface BambuSliceOptions {
177
178
  interface BambuSettingsBundle {
178
179
  settingsArg?: string;
179
180
  filamentPaths: string[];
181
+ /** Positional slot colours declared by the input 3MF project. */
182
+ filamentColours?: string[];
183
+ /** Saved per-plate tower coordinates must survive default process loading. */
184
+ projectTowerPosition?: { wipe_tower_x?: unknown; wipe_tower_y?: unknown };
180
185
  /** Identity provenance before the process is merged into a generated file. */
181
186
  processSource?: { filePath: string; profile: Record<string, unknown> };
182
187
  }
@@ -534,21 +539,32 @@ export class STLManipulator extends EventEmitter {
534
539
  profilesRoot,
535
540
  tempDir: this.tempDir,
536
541
  bedType: this.resolveBambuStudioBedType(bambuOptions?.bedType),
542
+ filamentColours: bambuOptions?.filamentColours ?? bundle.filamentColours,
543
+ projectTowerPosition: bundle.projectTowerPosition,
537
544
  sourceProfiles: {
538
545
  machine: isBundledFile(machinePath) ? undefined : machine,
539
546
  process: isBundledFile(processPath) ? undefined : processProfile,
540
547
  filaments: filaments.map((profile, i) => isBundledFile(bundle.filamentPaths[i]) ? undefined : profile),
541
548
  },
542
549
  });
543
- // Standalone custom files have no BBL dependencies; preserve their identity
544
- // and content. Other profiles retain user values through sourceProfiles.
550
+ // Standalone files retain their own identity and settings, but still need
551
+ // the CLI colour/tower fields. Do not discard those required overlays when
552
+ // choosing the original profile instead of the normalized BBL output.
553
+ const withCliFields = (filePath: string, profile: Record<string, unknown>, flatPath: string, keys: string[]): string => {
554
+ const flattened = readProfile(flatPath);
555
+ const fields = Object.fromEntries(keys.filter(key => flattened[key] !== undefined).map(key => [key, flattened[key]]));
556
+ if (Object.entries(fields).every(([key, value]) => JSON.stringify(profile[key]) === JSON.stringify(value))) return filePath;
557
+ return this.writeTempJson(path.basename(filePath, '.json'), 'cli', { ...profile, ...fields });
558
+ };
545
559
  return {
546
560
  settingsArg: [
547
561
  needsResolution(machinePath, machine) ? flat.machinePath : machinePath,
548
- needsResolution(processPath, processProfile) ? flat.processPath : processPath,
562
+ needsResolution(processPath, processProfile) ? flat.processPath
563
+ : withCliFields(processPath, processProfile, flat.processPath, ['wipe_tower_x', 'wipe_tower_y']),
549
564
  ].join(';'),
550
565
  filamentPaths: filaments.map((profile, i) =>
551
- needsResolution(bundle.filamentPaths[i], profile) ? flat.filamentPaths[i] : bundle.filamentPaths[i]),
566
+ needsResolution(bundle.filamentPaths[i], profile) ? flat.filamentPaths[i]
567
+ : withCliFields(bundle.filamentPaths[i], profile, flat.filamentPaths[i], ['filament_colour'])),
552
568
  };
553
569
  }
554
570
 
@@ -571,13 +587,20 @@ export class STLManipulator extends EventEmitter {
571
587
  const count = Math.max(0, ...['filament_settings_id', 'filament_type', 'filament_colour', 'filament_diameter']
572
588
  .map(key => Array.isArray(data[key]) ? data[key].length : 0));
573
589
  if (count === 0) throw new Error('Cannot determine project filament slots from project_settings.config.');
590
+ // Keep the project's slot colours; loaded profiles carry none of their own.
591
+ const colours = Array.isArray(data.filament_colour) && data.filament_colour.length === count &&
592
+ data.filament_colour.every(c => typeof c === 'string' && /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(c))
593
+ ? data.filament_colour as string[]
594
+ : undefined;
595
+ const projectTowerPosition = Object.fromEntries(['wipe_tower_x', 'wipe_tower_y']
596
+ .filter(key => data[key] !== undefined).map(key => [key, data[key]]));
574
597
  if (bundle.filamentPaths.length === 1) {
575
- return { ...bundle, filamentPaths: Array(count).fill(bundle.filamentPaths[0]) };
598
+ return { ...bundle, filamentPaths: Array(count).fill(bundle.filamentPaths[0]), filamentColours: colours, projectTowerPosition };
576
599
  }
577
600
  if (bundle.filamentPaths.length !== count) {
578
601
  throw new Error(`Project declares ${count} filament slots, but ${bundle.filamentPaths.length} profiles were supplied. Provide one profile for all slots or one per slot.`);
579
602
  }
580
- return bundle;
603
+ return { ...bundle, filamentColours: colours, projectTowerPosition };
581
604
  }
582
605
 
583
606
  private resolveBambuStudioBedType(bedType?: string): string | undefined {