bambu-printer-mcp 1.1.23 → 1.1.25

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,9 @@ Thank you to everyone who builds, tests, reports problems, and shares real print
6
6
 
7
7
  | Contributor | Contribution |
8
8
  | --- | --- |
9
+ | [Aleksei Popovich (alexpapay)](https://github.com/alexpapay) | Fixes cross-platform AMS profile resolution and packed P2S chamber status in [#43](https://github.com/DMontgomery40/bambu-printer-mcp/pull/43), contributes regressions, and supplies read-only Windows P2S/AMS 2 Pro status and local slicing evidence. No physical print is claimed for this contribution. |
10
+ | [travismcashan](https://github.com/travismcashan) | Reports stock X2D material-switch and runout-purge rejection, misleading unchecked confirmation, and stdout diagnostics in [#44](https://github.com/DMontgomery40/bambu-printer-mcp/issues/44). Supplies exact preset syntax and firmware evidence, then reports a clean physical X2D cube print with a local workaround; maintainer validation remains isolated and mocked. |
11
+ | [nitpreet22](https://github.com/nitpreet22) | Fixes rejection of the stock A1/A1 mini `M109 H` startup waits in [#41](https://github.com/DMontgomery40/bambu-printer-mcp/pull/41), contributes temperature and model regressions, and supplies Windows Bambu Studio profile and real sliced A1 job inspection evidence. Their physical print used Bambu Studio, not this MCP change. |
9
12
  | [Steavie (steavie)](https://github.com/steavie) | Reports the reversed A1/A1 mini serial prefixes in [#39](https://github.com/DMontgomery40/bambu-printer-mcp/issues/39), with A1 status and configuration evidence that identifies the status and fresh-identity regression. |
10
13
  | [JaviOFC](https://github.com/JaviOFC) | Reports the X2D identity rejection in [#36](https://github.com/DMontgomery40/bambu-printer-mcp/issues/36) and supplies current firmware, connection-mode, status-field, serial-prefix, and cloud-job evidence showing that `model_id` can contain a print-job identifier while `model` correctly identifies the printer. |
11
14
  | [Izzy Mansurov (sapoepsilon)](https://github.com/sapoepsilon) | Fixes Bambu Studio 02.08 P2S extruder-variant metadata and airduct-command inspection in [#33](https://github.com/DMontgomery40/bambu-printer-mcp/pull/33). Supplies regression tests and offline inspection evidence from four real P2S 0.6 mm PLA/PETG jobs and unsafe mutated copies; this contribution does not claim a physical print. |
package/README.md CHANGED
@@ -191,7 +191,7 @@ The optional FULU **BambuNetwork bridge** exposes `bambu_network_bridge_status`,
191
191
  ## Features
192
192
 
193
193
  - Get detailed printer status: temperatures (nozzle, bed, chamber), print progress, current layer, time remaining, and live AMS slot data
194
- - 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).
194
+ - Query live AMS inventory with resolved Bambu/Orca filament profile paths via `get_printer_filaments`. Profile suggestions use the selected slicer installation on Windows, macOS and Linux, including an explicit `BAMBU_PROFILES_ROOT`; unavailable profiles leave live inventory readable. 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).
195
195
  - List, upload, and delete files on the printer's SD card via FTPS
196
196
  - 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.
197
197
  - Upload and print pre-sliced `.3mf` projects with checked plate selection and calibration flags. Legacy `.gcode.3mf` routes require a single external-spool-only plate; see the [slicing guide](https://github.com/DMontgomery40/bambu-printer-mcp/blob/main/docs/SLICING.md).
@@ -568,6 +568,8 @@ Retrieve current printer state including temperatures, print progress, layer cou
568
568
  }
569
569
  ```
570
570
 
571
+ Chamber status decodes the current temperature from newer firmware's packed CTC reports, including P2S, while retaining legacy chamber/frame fields.
572
+
571
573
  Returns a structured object with fields including `status` (gcode_state string), `temperatures.nozzle`, `temperatures.bed`, `temperatures.chamber`, `print.progress`, `print.currentLayer`, `print.totalLayers`, `print.timeRemaining`, and `ams` (raw AMS data from the printer).
572
574
 
573
575
  #### get_printer_filaments
@@ -90,7 +90,7 @@ async function parseBambuConfig(zip) {
90
90
  if (file) {
91
91
  configFile = file;
92
92
  configContent = await file.async('string');
93
- console.log(`Found Bambu config file: ${configFile.name}`);
93
+ console.error(`Found Bambu config file: ${configFile.name}`);
94
94
  break; // Use the first one found (project usually has the most)
95
95
  }
96
96
  }
@@ -99,7 +99,7 @@ async function parseBambuConfig(zip) {
99
99
  // Attempt to parse as JSON
100
100
  const jsonData = JSON.parse(configContent);
101
101
  const parsedConfig = parseBambuJSONConfig(jsonData);
102
- console.log('Bambu config parsed successfully as JSON.');
102
+ console.error('Bambu config parsed successfully as JSON.');
103
103
  return parsedConfig;
104
104
  }
105
105
  catch (jsonError) {
@@ -107,7 +107,7 @@ async function parseBambuConfig(zip) {
107
107
  // Fallback: Try parsing as INI if JSON fails (though unlikely based on sample)
108
108
  try {
109
109
  const parsedConfig = parseFallbackINIConfig(configContent); // Keep a simple INI parser as fallback
110
- console.log('Bambu config parsed successfully as INI (fallback).');
110
+ console.error('Bambu config parsed successfully as INI (fallback).');
111
111
  return parsedConfig;
112
112
  }
113
113
  catch (iniError) {
@@ -117,7 +117,7 @@ async function parseBambuConfig(zip) {
117
117
  }
118
118
  }
119
119
  else {
120
- console.log('No Bambu-specific config file found in Metadata directory.');
120
+ console.error('No Bambu-specific config file found in Metadata directory.');
121
121
  return {};
122
122
  }
123
123
  }
@@ -186,7 +186,7 @@ async function parse3DModelConfig(zip) {
186
186
  });
187
187
  });
188
188
  }
189
- console.log('3dmodel.model parsed successfully.');
189
+ console.error('3dmodel.model parsed successfully.');
190
190
  return { metadata, objects, build: { items: buildItems } };
191
191
  }
192
192
  catch (error) {
@@ -195,13 +195,13 @@ async function parse3DModelConfig(zip) {
195
195
  }
196
196
  }
197
197
  export async function parse3MF(filePath) {
198
- console.log(`Parsing 3MF file: ${filePath}`);
198
+ console.error(`Parsing 3MF file: ${filePath}`);
199
199
  try {
200
200
  const data = await fs.readFile(filePath);
201
201
  const zip = await JSZip.loadAsync(data);
202
202
  // List files for debugging
203
203
  // const fileList = Object.keys(zip.files);
204
- // console.log('Files in 3MF:', fileList);
204
+ // console.error('Files in 3MF:', fileList);
205
205
  const { metadata, objects, build } = await parse3DModelConfig(zip);
206
206
  const bambuConfig = await parseBambuConfig(zip);
207
207
  // Combine data into the final structure
@@ -211,7 +211,7 @@ export async function parse3MF(filePath) {
211
211
  build,
212
212
  slicerConfig: bambuConfig
213
213
  };
214
- console.log('3MF parsing completed.');
214
+ console.error('3MF parsing completed.');
215
215
  return combinedData;
216
216
  }
217
217
  catch (error) {
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { BambuImplementation, assertDirectPrintSupported } from "./printers/bamb
20
20
  import { inspectPrintFile } from "./safety/print-file.js";
21
21
  import { validatePrinterState } from "./safety/printer-state.js";
22
22
  import { withPrinterOperation, withPrintSnapshot, uniquePrintName } from "./safety/artifact.js";
23
+ import { detectProfilesRoot } from "./slicer/profile-flatten.js";
23
24
  dotenv.config();
24
25
  const DEFAULT_HOST = process.env.BAMBU_PRINTER_HOST || process.env.PRINTER_HOST || "localhost";
25
26
  const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_PRINTER_SERIAL || process.env.BAMBU_SERIAL || "";
@@ -104,7 +105,6 @@ const BAMBU_MODEL_PRESETS = {
104
105
  h2c: (n) => `Bambu Lab H2C ${n} nozzle`,
105
106
  x2d: (n) => `Bambu Lab X2D ${n} nozzle`,
106
107
  };
107
- const FILAMENT_PROFILE_DIR = "/Applications/BambuStudio.app/Contents/Resources/profiles/BBL/filament";
108
108
  const FILAMENT_MODEL_CODES = {
109
109
  p1s: "P1S",
110
110
  p1p: "P1P",
@@ -123,13 +123,27 @@ let filamentProfileIndexCache = null;
123
123
  function buildFilamentProfileIndex() {
124
124
  const byName = new Map();
125
125
  const baseNameByFilamentId = new Map();
126
- if (!fs.existsSync(FILAMENT_PROFILE_DIR)) {
126
+ let filamentProfileDir;
127
+ try {
128
+ const { slicerType, slicerPath } = resolveSlicerConfig(undefined);
129
+ filamentProfileDir = path.join(detectProfilesRoot(slicerPath, slicerType), "BBL", "filament");
130
+ }
131
+ catch {
132
+ // Profile suggestions are optional; invalid slicer configuration must not hide AMS inventory.
127
133
  return { byName, baseNameByFilamentId };
128
134
  }
129
- for (const entry of fs.readdirSync(FILAMENT_PROFILE_DIR)) {
135
+ let profileEntries;
136
+ try {
137
+ profileEntries = fs.readdirSync(filamentProfileDir);
138
+ }
139
+ catch {
140
+ // Ignore unavailable profile suggestions while keeping live inventory readable.
141
+ return { byName, baseNameByFilamentId };
142
+ }
143
+ for (const entry of profileEntries) {
130
144
  if (!entry.endsWith(".json"))
131
145
  continue;
132
- const filePath = path.join(FILAMENT_PROFILE_DIR, entry);
146
+ const filePath = path.join(filamentProfileDir, entry);
133
147
  try {
134
148
  const raw = fs.readFileSync(filePath, "utf8");
135
149
  const parsed = JSON.parse(raw);
@@ -880,6 +894,9 @@ class BambuPrinterMCPServer {
880
894
  required: ["confirmed"],
881
895
  },
882
896
  }, { timeout: this.confirmationTimeoutMs });
897
+ if (response.action === "accept" && response.content?.confirmed !== true) {
898
+ throw new Error("Hardware confirmation checkbox was not checked. Check it only after inspecting the printer, or cancel. No command was sent.");
899
+ }
883
900
  return response.action === "accept" && response.content?.confirmed === true;
884
901
  }
885
902
  catch (error) {
@@ -1015,7 +1032,7 @@ class BambuPrinterMCPServer {
1015
1032
  if (bedType === "supertack_plate") {
1016
1033
  throw new Error('BambuStudio CLI SuperTack bed type is not verified; use a pre-sliced 3MF for SuperTack or choose textured_plate, cool_plate, engineering_plate, or hot_plate.');
1017
1034
  }
1018
- console.log(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
1035
+ console.error(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
1019
1036
  const autoSliceOptions = {
1020
1037
  uptodate: true,
1021
1038
  ensureOnBed: true,
@@ -1025,7 +1042,7 @@ class BambuPrinterMCPServer {
1025
1042
  nozzleType: resolveNozzleType(args?.nozzle_type),
1026
1043
  };
1027
1044
  threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, slicerProfile || undefined, undefined, printPreset, autoSliceOptions);
1028
- console.log("Auto-sliced to: " + threeMFPath);
1045
+ console.error("Auto-sliced to: " + threeMFPath);
1029
1046
  return { threeMFPath, autoSliced: true };
1030
1047
  }
1031
1048
  async resolveAmsPrintSettings(threeMFPath, args, host, bambuSerial, bambuToken, printModel, printNozzle) {
@@ -3269,7 +3286,7 @@ class BambuPrinterMCPServer {
3269
3286
  if (printBedType === "supertack_plate") {
3270
3287
  throw new Error('BambuStudio CLI SuperTack bed type is not verified; use a pre-sliced 3MF for SuperTack or choose textured_plate, cool_plate, engineering_plate, or hot_plate.');
3271
3288
  }
3272
- console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
3289
+ console.error(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
3273
3290
  const autoSliceOptions = {
3274
3291
  uptodate: true,
3275
3292
  ensureOnBed: true,
@@ -3291,7 +3308,7 @@ class BambuPrinterMCPServer {
3291
3308
  }
3292
3309
  threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, activeSlicerProfile, undefined, // progressCallback
3293
3310
  printPreset, autoSliceOptions);
3294
- console.log("Auto-sliced to: " + threeMFPath);
3311
+ console.error("Auto-sliced to: " + threeMFPath);
3295
3312
  }
3296
3313
  const parsed3MFData = await parse3MF(threeMFPath);
3297
3314
  const isH2Print = H2_BAMBU_MODELS.has(printModel);
@@ -504,6 +504,11 @@ export class BambuImplementation {
504
504
  const data = cachedData && Object.keys(cachedData).length > 0
505
505
  ? cachedData
506
506
  : printer.data;
507
+ const ctcTemperature = data.device?.ctc?.info?.temp;
508
+ // CTC packs the current temperature in the low 16 bits and the target in the high 16 bits.
509
+ const chamberTemperature = typeof ctcTemperature === "number" && Number.isInteger(ctcTemperature) && ctcTemperature >= 0 && ctcTemperature <= 0xffffffff
510
+ ? ctcTemperature & 0xffff
511
+ : data.chamber_temper || data.frame_temper || 0;
507
512
  return {
508
513
  status: data.gcode_state || "UNKNOWN",
509
514
  connected: true,
@@ -516,7 +521,7 @@ export class BambuImplementation {
516
521
  actual: data.bed_temper || 0,
517
522
  target: data.bed_target_temper || 0,
518
523
  },
519
- chamber: data.chamber_temper || data.frame_temper || 0,
524
+ chamber: chamberTemperature,
520
525
  },
521
526
  print: {
522
527
  filename: data.subtask_name || data.gcode_file || "None",
@@ -405,9 +405,15 @@ export async function inspectPrintFile(filePath, options) {
405
405
  }
406
406
  if (['M104', 'M109', 'M140', 'M190', 'M141', 'M191'].includes(code)) {
407
407
  const args = parameters(argumentsText, 'A');
408
+ // Official A1 and A1 mini start G-code waits with M109 S<target> H<limit>
409
+ // (S25 H140, S100 H170, S220 H300). H is not a heater target: S stays the
410
+ // checked target, so H is accepted only within those vendor bounds.
411
+ const vendorWait = code === 'M109' && ['a1', 'a1mini'].includes(model);
408
412
  for (const key of args.keys())
409
- if (!'SRTA'.includes(key))
413
+ if (!'SRTA'.includes(key) && !(key === 'H' && vendorWait))
410
414
  fail(`unsupported ${code} temperature parameter ${key}`);
415
+ if (args.has('H') && (!Number.isFinite(args.get('H')) || args.get('H') < 0 || args.get('H') > 300))
416
+ fail(`unsupported ${code} wait parameter H`);
411
417
  if (!args.has('S') && !args.has('R'))
412
418
  fail(`missing ${code} temperature target`);
413
419
  const component = ['M104', 'M109'].includes(code) ? 'nozzle' : ['M140', 'M190'].includes(code) ? 'bed' : 'chamber';
@@ -424,7 +430,10 @@ export async function inspectPrintFile(filePath, options) {
424
430
  heat(component, args.get(key), position, args.has('T') || args.has('A'), commonFlush);
425
431
  }
426
432
  else if (code === 'M620' || code === 'M621') {
427
- const args = parameters(argumentsText, 'MA');
433
+ const args = parameters(argumentsText, model === 'x2d' ? 'MAB' : 'MA');
434
+ // X2D templates use B as a bare material-switch flag, never a target.
435
+ if (args.has('B') && (model !== 'x2d' || !Number.isNaN(args.get('B'))))
436
+ fail('unsupported material-switch B flag');
428
437
  if (args.has('S')) {
429
438
  const position = args.get('S');
430
439
  if ([254, 255, 65279, 65535].includes(position)) {
@@ -448,6 +457,14 @@ export async function inspectPrintFile(filePath, options) {
448
457
  }
449
458
  }
450
459
  }
460
+ else if (code === 'M620.22') {
461
+ // Official X2D runout auto-purge enables the declared project filament.
462
+ // It supplies no heater target; all explicit heat still uses the shared gate.
463
+ const args = parameters(argumentsText);
464
+ if (model !== 'x2d' || args.size !== 2 || !args.has('I') || args.get('P') !== 1)
465
+ fail('unsupported M620.22 runout purge parameters');
466
+ requirePosition(args.get('I'));
467
+ }
451
468
  else if (code === 'M620.1' || code === 'M620.10') {
452
469
  const args = parameters(argumentsText, 'E');
453
470
  const position = code === 'M620.10' && args.get('A') === 1 ? pending ?? active : code === 'M620.10' && args.get('A') === 0 ? active : undefined;
@@ -1144,7 +1144,7 @@ export class STLManipulator extends EventEmitter {
1144
1144
  try {
1145
1145
  if (progressCallback)
1146
1146
  progressCallback(0, "Starting base extension operation...");
1147
- console.log(`Extending base of ${stlFilePath} by ${extensionInches} inches`);
1147
+ console.error(`Extending base of ${stlFilePath} by ${extensionInches} inches`);
1148
1148
  // Load the STL file
1149
1149
  const { geometry, boundingBox } = await this.loadSTL(stlFilePath, progressCallback);
1150
1150
  if (!this.activeOperations.get(operationId)) {
@@ -1197,7 +1197,7 @@ export class STLManipulator extends EventEmitter {
1197
1197
  success: true,
1198
1198
  output: outputFilePath
1199
1199
  });
1200
- console.log(`Modified STL saved to ${outputFilePath}`);
1200
+ console.error(`Modified STL saved to ${outputFilePath}`);
1201
1201
  return outputFilePath;
1202
1202
  }
1203
1203
  catch (error) {
@@ -1364,7 +1364,7 @@ export class STLManipulator extends EventEmitter {
1364
1364
  }
1365
1365
  if (progressCallback)
1366
1366
  progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
1367
- console.log(`Executing: ${slicerPath} ${args.join(' ')}`);
1367
+ console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1368
1368
  // Execute the slicer
1369
1369
  await new Promise((resolve, reject) => {
1370
1370
  const process = execFile(slicerPath, args, (error, stdout, stderr) => {
@@ -1374,7 +1374,7 @@ export class STLManipulator extends EventEmitter {
1374
1374
  reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
1375
1375
  }
1376
1376
  else {
1377
- console.log(`Slicer Stdout: ${stdout}`);
1377
+ console.error(`Slicer Stdout: ${stdout}`);
1378
1378
  if (stderr) {
1379
1379
  console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
1380
1380
  }
@@ -1382,8 +1382,8 @@ export class STLManipulator extends EventEmitter {
1382
1382
  }
1383
1383
  });
1384
1384
  // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
1385
- // process.stdout?.on('data', (data) => { console.log(`Slicer stdout: ${data}`); });
1386
- // process.stderr?.on('data', (data) => { console.log(`Slicer stderr: ${data}`); });
1385
+ // process.stdout?.on('data', (data) => { console.error(`Slicer stdout: ${data}`); });
1386
+ // process.stderr?.on('data', (data) => { console.error(`Slicer stderr: ${data}`); });
1387
1387
  // Check for cancellation periodically
1388
1388
  const checkCancel = setInterval(() => {
1389
1389
  if (!this.activeOperations.get(operationId)) {
@@ -1534,7 +1534,7 @@ export class STLManipulator extends EventEmitter {
1534
1534
  const originalVertexCount = geometry.attributes.position.count;
1535
1535
  const mergedGeometry = BufferGeometryUtils.mergeVertices(geometry, tolerance);
1536
1536
  const newVertexCount = mergedGeometry.attributes.position.count;
1537
- console.log(`Merged vertices: ${originalVertexCount} -> ${newVertexCount} (Tolerance: ${tolerance}mm)`);
1537
+ console.error(`Merged vertices: ${originalVertexCount} -> ${newVertexCount} (Tolerance: ${tolerance}mm)`);
1538
1538
  if (progressCallback)
1539
1539
  progressCallback(70, `Vertices merged: ${originalVertexCount} -> ${newVertexCount}`);
1540
1540
  if (!this.activeOperations.get(operationId))
@@ -1572,7 +1572,7 @@ export class STLManipulator extends EventEmitter {
1572
1572
  if (center.lengthSq() < 0.0001) { // Already centered (or very close)
1573
1573
  if (progressCallback)
1574
1574
  progressCallback(100, "Model is already centered.");
1575
- console.log("Model already centered. No changes made.");
1575
+ console.error("Model already centered. No changes made.");
1576
1576
  this.emit('operationComplete', { operationId, type: 'centerModel', success: true, output: stlFilePath, message: "Model already centered." });
1577
1577
  return stlFilePath; // Return original path
1578
1578
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.23",
3
+ "version": "1.1.25",
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": {
@@ -46,13 +46,13 @@
46
46
  "author": "David Montgomery",
47
47
  "license": "GPL-2.0",
48
48
  "dependencies": {
49
- "@modelcontextprotocol/sdk": "^1.27.0",
49
+ "@modelcontextprotocol/sdk": "^1.32.1",
50
50
  "bambu-js": "^2.2.2",
51
51
  "bambu-node": "3.22.21",
52
- "basic-ftp": "^5.0.5",
52
+ "basic-ftp": "^6.2.2",
53
53
  "dotenv": "^16.3.1",
54
- "jszip": "^3.10.1",
55
- "mqtt": "^5.15.1",
54
+ "jszip": "^3.10.2",
55
+ "mqtt": "^5.16.0",
56
56
  "patch-package": "8.0.1",
57
57
  "three": "^0.173.0",
58
58
  "xml2js": "^0.6.2"
package/src/3mf_parser.ts CHANGED
@@ -86,7 +86,7 @@ async function parseBambuConfig(zip: JSZip): Promise<Partial<BambuSlicerConfig>>
86
86
  if (file) {
87
87
  configFile = file;
88
88
  configContent = await file.async('string');
89
- console.log(`Found Bambu config file: ${configFile.name}`);
89
+ console.error(`Found Bambu config file: ${configFile.name}`);
90
90
  break; // Use the first one found (project usually has the most)
91
91
  }
92
92
  }
@@ -96,14 +96,14 @@ async function parseBambuConfig(zip: JSZip): Promise<Partial<BambuSlicerConfig>>
96
96
  // Attempt to parse as JSON
97
97
  const jsonData = JSON.parse(configContent);
98
98
  const parsedConfig = parseBambuJSONConfig(jsonData);
99
- console.log('Bambu config parsed successfully as JSON.');
99
+ console.error('Bambu config parsed successfully as JSON.');
100
100
  return parsedConfig;
101
101
  } catch (jsonError: any) {
102
102
  console.warn(`Failed to parse ${configFile.name} as JSON: ${jsonError.message}. Attempting INI parse as fallback...`);
103
103
  // Fallback: Try parsing as INI if JSON fails (though unlikely based on sample)
104
104
  try {
105
105
  const parsedConfig = parseFallbackINIConfig(configContent); // Keep a simple INI parser as fallback
106
- console.log('Bambu config parsed successfully as INI (fallback).');
106
+ console.error('Bambu config parsed successfully as INI (fallback).');
107
107
  return parsedConfig;
108
108
  } catch (iniError: any) {
109
109
  console.error(`Error parsing Bambu config ${configFile.name} as INI (fallback):`, iniError);
@@ -111,7 +111,7 @@ async function parseBambuConfig(zip: JSZip): Promise<Partial<BambuSlicerConfig>>
111
111
  }
112
112
  }
113
113
  } else {
114
- console.log('No Bambu-specific config file found in Metadata directory.');
114
+ console.error('No Bambu-specific config file found in Metadata directory.');
115
115
  return {};
116
116
  }
117
117
  }
@@ -186,7 +186,7 @@ async function parse3DModelConfig(zip: JSZip): Promise<{ metadata: ThreeMFMetada
186
186
  });
187
187
  }
188
188
 
189
- console.log('3dmodel.model parsed successfully.');
189
+ console.error('3dmodel.model parsed successfully.');
190
190
  return { metadata, objects, build: { items: buildItems } };
191
191
 
192
192
  } catch (error: any) {
@@ -198,14 +198,14 @@ async function parse3DModelConfig(zip: JSZip): Promise<{ metadata: ThreeMFMetada
198
198
  export { ThreeMFData };
199
199
 
200
200
  export async function parse3MF(filePath: string): Promise<ThreeMFData> {
201
- console.log(`Parsing 3MF file: ${filePath}`);
201
+ console.error(`Parsing 3MF file: ${filePath}`);
202
202
  try {
203
203
  const data = await fs.readFile(filePath);
204
204
  const zip = await JSZip.loadAsync(data);
205
205
 
206
206
  // List files for debugging
207
207
  // const fileList = Object.keys(zip.files);
208
- // console.log('Files in 3MF:', fileList);
208
+ // console.error('Files in 3MF:', fileList);
209
209
 
210
210
  const { metadata, objects, build } = await parse3DModelConfig(zip);
211
211
  const bambuConfig = await parseBambuConfig(zip);
@@ -218,7 +218,7 @@ export async function parse3MF(filePath: string): Promise<ThreeMFData> {
218
218
  slicerConfig: bambuConfig
219
219
  };
220
220
 
221
- console.log('3MF parsing completed.');
221
+ console.error('3MF parsing completed.');
222
222
  return combinedData;
223
223
 
224
224
  } catch (error: any) {
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ import { BambuImplementation, assertDirectPrintSupported } from "./printers/bamb
44
44
  import { inspectPrintFile } from "./safety/print-file.js";
45
45
  import { validatePrinterState } from "./safety/printer-state.js";
46
46
  import { withPrinterOperation, withPrintSnapshot, uniquePrintName } from "./safety/artifact.js";
47
+ import { detectProfilesRoot } from "./slicer/profile-flatten.js";
47
48
 
48
49
  dotenv.config();
49
50
 
@@ -140,8 +141,6 @@ const BAMBU_MODEL_PRESETS: Record<string, (nozzle: string) => string> = {
140
141
  x2d: (n) => `Bambu Lab X2D ${n} nozzle`,
141
142
  };
142
143
 
143
- const FILAMENT_PROFILE_DIR =
144
- "/Applications/BambuStudio.app/Contents/Resources/profiles/BBL/filament";
145
144
  const FILAMENT_MODEL_CODES: Record<string, string> = {
146
145
  p1s: "P1S",
147
146
  p1p: "P1P",
@@ -212,15 +211,27 @@ let filamentProfileIndexCache: FilamentProfileIndex | null = null;
212
211
  function buildFilamentProfileIndex(): FilamentProfileIndex {
213
212
  const byName = new Map<string, string>();
214
213
  const baseNameByFilamentId = new Map<string, string>();
214
+ let filamentProfileDir: string;
215
+ try {
216
+ const { slicerType, slicerPath } = resolveSlicerConfig(undefined);
217
+ filamentProfileDir = path.join(detectProfilesRoot(slicerPath, slicerType), "BBL", "filament");
218
+ } catch {
219
+ // Profile suggestions are optional; invalid slicer configuration must not hide AMS inventory.
220
+ return { byName, baseNameByFilamentId };
221
+ }
215
222
 
216
- if (!fs.existsSync(FILAMENT_PROFILE_DIR)) {
223
+ let profileEntries: string[];
224
+ try {
225
+ profileEntries = fs.readdirSync(filamentProfileDir);
226
+ } catch {
227
+ // Ignore unavailable profile suggestions while keeping live inventory readable.
217
228
  return { byName, baseNameByFilamentId };
218
229
  }
219
230
 
220
- for (const entry of fs.readdirSync(FILAMENT_PROFILE_DIR)) {
231
+ for (const entry of profileEntries) {
221
232
  if (!entry.endsWith(".json")) continue;
222
233
 
223
- const filePath = path.join(FILAMENT_PROFILE_DIR, entry);
234
+ const filePath = path.join(filamentProfileDir, entry);
224
235
 
225
236
  try {
226
237
  const raw = fs.readFileSync(filePath, "utf8");
@@ -1174,6 +1185,9 @@ class BambuPrinterMCPServer {
1174
1185
  required: ["confirmed"],
1175
1186
  },
1176
1187
  }, { timeout: this.confirmationTimeoutMs });
1188
+ if (response.action === "accept" && response.content?.confirmed !== true) {
1189
+ throw new Error("Hardware confirmation checkbox was not checked. Check it only after inspecting the printer, or cancel. No command was sent.");
1190
+ }
1177
1191
  return response.action === "accept" && response.content?.confirmed === true;
1178
1192
  } catch (error: any) {
1179
1193
  if (isElicitationTimeout(error)) {
@@ -1363,7 +1377,7 @@ class BambuPrinterMCPServer {
1363
1377
  );
1364
1378
  }
1365
1379
 
1366
- console.log(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
1380
+ console.error(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
1367
1381
  const autoSliceOptions: BambuSliceOptions = {
1368
1382
  uptodate: true,
1369
1383
  ensureOnBed: true,
@@ -1378,7 +1392,7 @@ class BambuPrinterMCPServer {
1378
1392
  printPreset,
1379
1393
  autoSliceOptions
1380
1394
  );
1381
- console.log("Auto-sliced to: " + threeMFPath);
1395
+ console.error("Auto-sliced to: " + threeMFPath);
1382
1396
  return { threeMFPath, autoSliced: true };
1383
1397
  }
1384
1398
 
@@ -3956,7 +3970,7 @@ class BambuPrinterMCPServer {
3956
3970
  'BambuStudio CLI SuperTack bed type is not verified; use a pre-sliced 3MF for SuperTack or choose textured_plate, cool_plate, engineering_plate, or hot_plate.'
3957
3971
  );
3958
3972
  }
3959
- console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
3973
+ console.error(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
3960
3974
  const autoSliceOptions: BambuSliceOptions = {
3961
3975
  uptodate: true,
3962
3976
  ensureOnBed: true,
@@ -3987,7 +4001,7 @@ class BambuPrinterMCPServer {
3987
4001
  printPreset,
3988
4002
  autoSliceOptions
3989
4003
  );
3990
- console.log("Auto-sliced to: " + threeMFPath);
4004
+ console.error("Auto-sliced to: " + threeMFPath);
3991
4005
  }
3992
4006
 
3993
4007
  const parsed3MFData = await parse3MF(threeMFPath);
@@ -606,10 +606,16 @@ export class BambuImplementation {
606
606
  }
607
607
 
608
608
  const cachedData = await this.printerStore.waitForInitialReport(host, serial, token);
609
- const data =
609
+ const data: Record<string, any> =
610
610
  cachedData && Object.keys(cachedData).length > 0
611
611
  ? cachedData
612
612
  : printer.data;
613
+ const ctcTemperature = data.device?.ctc?.info?.temp;
614
+ // CTC packs the current temperature in the low 16 bits and the target in the high 16 bits.
615
+ const chamberTemperature =
616
+ typeof ctcTemperature === "number" && Number.isInteger(ctcTemperature) && ctcTemperature >= 0 && ctcTemperature <= 0xffffffff
617
+ ? ctcTemperature & 0xffff
618
+ : data.chamber_temper || data.frame_temper || 0;
613
619
 
614
620
  return {
615
621
  status: data.gcode_state || "UNKNOWN",
@@ -623,7 +629,7 @@ export class BambuImplementation {
623
629
  actual: data.bed_temper || 0,
624
630
  target: data.bed_target_temper || 0,
625
631
  },
626
- chamber: data.chamber_temper || data.frame_temper || 0,
632
+ chamber: chamberTemperature,
627
633
  },
628
634
  print: {
629
635
  filename: data.subtask_name || data.gcode_file || "None",
@@ -296,7 +296,12 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
296
296
  }
297
297
  if(['M104','M109','M140','M190','M141','M191'].includes(code)) {
298
298
  const args=parameters(argumentsText,'A');
299
- for(const key of args.keys()) if(!'SRTA'.includes(key)) fail(`unsupported ${code} temperature parameter ${key}`);
299
+ // Official A1 and A1 mini start G-code waits with M109 S<target> H<limit>
300
+ // (S25 H140, S100 H170, S220 H300). H is not a heater target: S stays the
301
+ // checked target, so H is accepted only within those vendor bounds.
302
+ const vendorWait=code==='M109' && ['a1','a1mini'].includes(model);
303
+ for(const key of args.keys()) if(!'SRTA'.includes(key) && !(key==='H' && vendorWait)) fail(`unsupported ${code} temperature parameter ${key}`);
304
+ if(args.has('H') && (!Number.isFinite(args.get('H')) || args.get('H')!<0 || args.get('H')!>300)) fail(`unsupported ${code} wait parameter H`);
300
305
  if(!args.has('S')&&!args.has('R')) fail(`missing ${code} temperature target`);
301
306
  const component= ['M104','M109'].includes(code)?'nozzle': ['M140','M190'].includes(code)?'bed':'chamber';
302
307
  // Heater T is a physical nozzle, unlike standalone remapped T filament selection.
@@ -307,12 +312,20 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
307
312
  if(commonFlush) commonFlushUsed=true;
308
313
  for(const key of ['S','R']) if(args.has(key)) heat(component,args.get(key)!,position,args.has('T') || args.has('A'),commonFlush);
309
314
  } else if(code==='M620' || code==='M621') {
310
- const args=parameters(argumentsText,'MA');
315
+ const args=parameters(argumentsText,model==='x2d'?'MAB':'MA');
316
+ // X2D templates use B as a bare material-switch flag, never a target.
317
+ if(args.has('B') && (model!=='x2d' || !Number.isNaN(args.get('B')!))) fail('unsupported material-switch B flag');
311
318
  if(args.has('S')) {
312
319
  const position=args.get('S')!;
313
320
  if([254,255,65279,65535].includes(position)) {if(code==='M621') {previous=active;active=undefined;}pending=undefined;}
314
321
  else {selectsAms=true;requirePosition(position);if(code==='M620') pending=position;else {validateTemperature('nozzle',nozzleTarget,model,[materials[position]]);previous=active;active=position;pending=undefined;}}
315
322
  }
323
+ } else if(code==='M620.22') {
324
+ // Official X2D runout auto-purge enables the declared project filament.
325
+ // It supplies no heater target; all explicit heat still uses the shared gate.
326
+ const args=parameters(argumentsText);
327
+ if(model!=='x2d' || args.size!==2 || !args.has('I') || args.get('P')!==1) fail('unsupported M620.22 runout purge parameters');
328
+ requirePosition(args.get('I')!);
316
329
  } else if(code==='M620.1' || code==='M620.10') {
317
330
  const args=parameters(argumentsText,'E');
318
331
  const position=code==='M620.10' && args.get('A')===1 ? pending ?? active : code==='M620.10' && args.get('A')===0 ? active : undefined;
@@ -1477,7 +1477,7 @@ export class STLManipulator extends EventEmitter {
1477
1477
 
1478
1478
  try {
1479
1479
  if (progressCallback) progressCallback(0, "Starting base extension operation...");
1480
- console.log(`Extending base of ${stlFilePath} by ${extensionInches} inches`);
1480
+ console.error(`Extending base of ${stlFilePath} by ${extensionInches} inches`);
1481
1481
 
1482
1482
  // Load the STL file
1483
1483
  const { geometry, boundingBox } = await this.loadSTL(stlFilePath, progressCallback);
@@ -1554,7 +1554,7 @@ export class STLManipulator extends EventEmitter {
1554
1554
  output: outputFilePath
1555
1555
  });
1556
1556
 
1557
- console.log(`Modified STL saved to ${outputFilePath}`);
1557
+ console.error(`Modified STL saved to ${outputFilePath}`);
1558
1558
  return outputFilePath;
1559
1559
  } catch (error) {
1560
1560
  console.error("Error extending STL base:", error);
@@ -1733,7 +1733,7 @@ export class STLManipulator extends EventEmitter {
1733
1733
  }
1734
1734
 
1735
1735
  if (progressCallback) progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
1736
- console.log(`Executing: ${slicerPath} ${args.join(' ')}`);
1736
+ console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1737
1737
 
1738
1738
  // Execute the slicer
1739
1739
  await new Promise<void>((resolve, reject) => {
@@ -1743,7 +1743,7 @@ export class STLManipulator extends EventEmitter {
1743
1743
  console.error(`Slicer Stderr: ${stderr}`);
1744
1744
  reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
1745
1745
  } else {
1746
- console.log(`Slicer Stdout: ${stdout}`);
1746
+ console.error(`Slicer Stdout: ${stdout}`);
1747
1747
  if (stderr) {
1748
1748
  console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
1749
1749
  }
@@ -1752,8 +1752,8 @@ export class STLManipulator extends EventEmitter {
1752
1752
  });
1753
1753
 
1754
1754
  // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
1755
- // process.stdout?.on('data', (data) => { console.log(`Slicer stdout: ${data}`); });
1756
- // process.stderr?.on('data', (data) => { console.log(`Slicer stderr: ${data}`); });
1755
+ // process.stdout?.on('data', (data) => { console.error(`Slicer stdout: ${data}`); });
1756
+ // process.stderr?.on('data', (data) => { console.error(`Slicer stderr: ${data}`); });
1757
1757
 
1758
1758
  // Check for cancellation periodically
1759
1759
  const checkCancel = setInterval(() => {
@@ -1938,7 +1938,7 @@ export class STLManipulator extends EventEmitter {
1938
1938
  const mergedGeometry = BufferGeometryUtils.mergeVertices(geometry, tolerance);
1939
1939
  const newVertexCount = mergedGeometry.attributes.position.count;
1940
1940
 
1941
- console.log(`Merged vertices: ${originalVertexCount} -> ${newVertexCount} (Tolerance: ${tolerance}mm)`);
1941
+ console.error(`Merged vertices: ${originalVertexCount} -> ${newVertexCount} (Tolerance: ${tolerance}mm)`);
1942
1942
  if (progressCallback) progressCallback(70, `Vertices merged: ${originalVertexCount} -> ${newVertexCount}`);
1943
1943
 
1944
1944
  if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
@@ -1982,7 +1982,7 @@ export class STLManipulator extends EventEmitter {
1982
1982
 
1983
1983
  if (center.lengthSq() < 0.0001) { // Already centered (or very close)
1984
1984
  if (progressCallback) progressCallback(100, "Model is already centered.");
1985
- console.log("Model already centered. No changes made.");
1985
+ console.error("Model already centered. No changes made.");
1986
1986
  this.emit('operationComplete', { operationId, type: 'centerModel', success: true, output: stlFilePath, message: "Model already centered." });
1987
1987
  return stlFilePath; // Return original path
1988
1988
  }