bambu-printer-mcp 1.1.8 → 1.1.10

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.
@@ -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 {