bambu-printer-mcp 1.1.18 → 1.1.20

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
+ | [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. |
9
10
  | [Boardy (@boardyai)](https://x.com/boardyai) | Raises the nozzle-verification question that informs the added printer safety checks. |
10
11
  | Fable red-team review, shared by David Montgomery | Supplies archive-ambiguity reproductions and hardware-safety review findings used for plate binding, upload checks, temperature policy, and human preflight. Network/security suggestions remain outside this hardware-safety release. |
11
12
  | [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). Also isolates FTPS session-host identity and supplies physical X2D USB upload/size/deletion evidence in [#24](https://github.com/DMontgomery40/bambu-printer-mcp/pull/24); that test does not establish MQTT print dispatch. |
@@ -206,12 +206,13 @@ export async function inspectPrintFile(filePath, options) {
206
206
  if (/[\x00-\x08\x0b\x0c\x0e-\x1f\ufffd]/.test(source))
207
207
  return fail('binary or malformed G-code is not supported');
208
208
  const lines = source.split(/\r\n|\n|\r/);
209
- const metadata = [project];
209
+ const headers = [];
210
210
  for (const line of lines) {
211
211
  const match = line.match(/^\s*;\s*([a-z_][a-z0-9_]*(?:\s+used\s+\[(?:g|mm)\])?)\s*(?:=|:)\s*(.*?)\s*$/i);
212
212
  if (match)
213
- metadata.push({ [match[1].toLowerCase()]: match[2] });
213
+ headers.push({ [match[1].toLowerCase()]: match[2] });
214
214
  }
215
+ const metadata = [project, ...headers];
215
216
  validateFffJob([...metadata, plate]);
216
217
  const declaredModel = consistent(metadataValues(metadata, ['printer_model']), normalizeModel, 'printer model');
217
218
  if (declaredModel !== model)
@@ -234,6 +235,20 @@ export async function inspectPrintFile(filePath, options) {
234
235
  const entries = list(value).map(entry => entry.toLowerCase().replace(/[\s_-]+/g, '_'));
235
236
  return entries.length && entries.every(Boolean) ? entries : undefined;
236
237
  };
238
+ // Bambu Studio writes printer_extruder_id/printer_extruder_variant to
239
+ // project_settings.config as the full per-variant table, but to the plate
240
+ // G-code header as the per-physical-extruder selection it sliced with (for
241
+ // example ["1","1","1"] vs "1" on a single-nozzle P2S). Preserve the source:
242
+ // a plate header can never substitute a full table for its selected values.
243
+ const variantTable = (key, rows, convert) => {
244
+ const field = key.replace(/_/g, ' ');
245
+ const tableValues = metadataValues([project], [key]);
246
+ const selectedValues = metadataValues(headers, [key]);
247
+ if (tableValues.some(value => list(value).length !== rows) || selectedValues.some(value => list(value).length !== nozzleDiameters.length))
248
+ return fail(`unknown or malformed ${field} metadata`);
249
+ const table = consistent(tableValues, convert, field);
250
+ return { table, selected: selectedValues.length ? consistent(selectedValues, convert, `selected ${field}`) : undefined };
251
+ };
237
252
  const nozzleMetadata = (keys, variantRows = false) => {
238
253
  const values = metadataValues(metadata, keys);
239
254
  return values.length ? consistent(values, value => {
@@ -247,24 +262,34 @@ export async function inspectPrintFile(filePath, options) {
247
262
  // Bambu GUI projects may store nozzle types per extruder variant, while
248
263
  // diameters and selected flow types are per physical extruder. Never truncate
249
264
  // that variant table or mistake its rows for filament/nozzle positions.
250
- const ids = consistent(metadataValues(metadata, ['printer_extruder_id']), value => {
265
+ const { table: ids, selected: selectedIds } = variantTable('printer_extruder_id', entries.length, value => {
251
266
  const ids = list(value);
252
- return ids.length === entries.length && ids.every(id => /^[1-9]\d*$/.test(id) && Number(id) <= nozzleDiameters.length) ? ids.map(Number) : undefined;
253
- }, 'printer extruder id');
267
+ return ids.length > 0 && ids.every(id => /^[1-9]\d*$/.test(id) && Number(id) <= nozzleDiameters.length) ? ids.map(Number) : undefined;
268
+ });
269
+ // The selected form names each physical extruder once, in order.
270
+ if (selectedIds && selectedIds.some((id, index) => id !== index + 1))
271
+ return undefined;
272
+ const variants = metadataValues(metadata, ['printer_extruder_variant']).length
273
+ ? variantTable('printer_extruder_variant', entries.length, normalizedEntries) : undefined;
274
+ const extruders = variants ? consistent(metadataValues(metadata, ['extruder_type']), normalizedEntries, 'extruder type') : undefined;
254
275
  const resolved = [];
255
276
  for (let index = 0; index < nozzleDiameters.length; index++) {
256
277
  const rows = ids.flatMap((id, row) => id === index + 1 ? [row] : []);
257
278
  if (!rows.length)
258
279
  return undefined;
259
- if (rows.every(row => entries[row] === entries[rows[0]])) {
280
+ if (!variants) {
281
+ if (!rows.every(row => entries[row] === entries[rows[0]]))
282
+ return undefined;
260
283
  resolved.push(entries[rows[0]]);
261
284
  continue;
262
285
  }
263
- const variants = consistent(metadataValues(metadata, ['printer_extruder_variant']), normalizedEntries, 'printer extruder variant');
264
- const extruders = consistent(metadataValues(metadata, ['extruder_type']), normalizedEntries, 'extruder type');
265
- if (variants.length !== entries.length || extruders.length !== nozzleDiameters.length || !nozzleFlows)
286
+ // Validate declared variants even when all rows use the same material.
287
+ if (!extruders || extruders.length !== nozzleDiameters.length || !nozzleFlows)
288
+ return undefined;
289
+ const variant = `${extruders[index]}_${nozzleFlows[index]}`;
290
+ if (variants.selected && variants.selected[index] !== variant)
266
291
  return undefined;
267
- const selected = rows.filter(row => variants[row] === `${extruders[index]}_${nozzleFlows[index]}`);
292
+ const selected = rows.filter(row => variants.table[row] === variant);
268
293
  if (selected.length !== 1)
269
294
  return undefined;
270
295
  resolved.push(entries[selected[0]]);
@@ -487,8 +512,11 @@ export async function inspectPrintFile(filePath, options) {
487
512
  fail(`unsupported thermal-affecting filament command ${code}`);
488
513
  }
489
514
  else if (code === 'M145') {
515
+ // Airduct mode switch (P0 cooling, P1 heating) in the official H2-series,
516
+ // X2D and P2S start/end G-code; it sets no heater target. Other forms and
517
+ // models without a switchable airduct stay rejected.
490
518
  const args = parameters(argumentsText);
491
- if (args.size !== 1 || !args.has('P') || ![0, 1].includes(args.get('P')) || !['h2d', 'h2dpro', 'h2c', 'h2s', 'x2d'].includes(model))
519
+ if (args.size !== 1 || !args.has('P') || ![0, 1].includes(args.get('P')) || !['h2d', 'h2dpro', 'h2c', 'h2s', 'x2d', 'p2s'].includes(model))
492
520
  fail('unsupported thermal-affecting M145 parameters');
493
521
  }
494
522
  else if (code === 'M142') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.18",
3
+ "version": "1.1.20",
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": {
@@ -154,11 +154,12 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
154
154
  if(plateIndex!==0) return fail('raw G-code has only plate index 0');source=bytes.toString('utf8');
155
155
  } else return fail('unsupported printable artifact; use a sliced .3mf or textual .gcode file');
156
156
  if(/[\x00-\x08\x0b\x0c\x0e-\x1f\ufffd]/.test(source)) return fail('binary or malformed G-code is not supported');
157
- const lines=source.split(/\r\n|\n|\r/);const metadata:Metadata[]=[project];
157
+ const lines=source.split(/\r\n|\n|\r/);const headers:Metadata[]=[];
158
158
  for(const line of lines) {
159
159
  const match=line.match(/^\s*;\s*([a-z_][a-z0-9_]*(?:\s+used\s+\[(?:g|mm)\])?)\s*(?:=|:)\s*(.*?)\s*$/i);
160
- if(match) metadata.push({[match[1].toLowerCase()]:match[2]});
160
+ if(match) headers.push({[match[1].toLowerCase()]:match[2]});
161
161
  }
162
+ const metadata:Metadata[]=[project,...headers];
162
163
  validateFffJob([...metadata,plate]);
163
164
  const declaredModel=consistent(metadataValues(metadata,['printer_model']),normalizeModel,'printer model');
164
165
  if(declaredModel!==model) return fail(`file model ${declaredModel} contradicts requested model ${model}`);
@@ -178,6 +179,17 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
178
179
  const entries=list(value).map(entry=>entry.toLowerCase().replace(/[\s_-]+/g,'_'));
179
180
  return entries.length && entries.every(Boolean)?entries:undefined;
180
181
  };
182
+ // Bambu Studio writes printer_extruder_id/printer_extruder_variant to
183
+ // project_settings.config as the full per-variant table, but to the plate
184
+ // G-code header as the per-physical-extruder selection it sliced with (for
185
+ // example ["1","1","1"] vs "1" on a single-nozzle P2S). Preserve the source:
186
+ // a plate header can never substitute a full table for its selected values.
187
+ const variantTable=<T>(key:string,rows:number,convert:(value:unknown)=>T[]|undefined):{table:T[];selected?:T[]}=>{
188
+ const field=key.replace(/_/g,' ');const tableValues=metadataValues([project],[key]);const selectedValues=metadataValues(headers,[key]);
189
+ if(tableValues.some(value=>list(value).length!==rows) || selectedValues.some(value=>list(value).length!==nozzleDiameters.length)) return fail(`unknown or malformed ${field} metadata`);
190
+ const table=consistent(tableValues,convert,field);
191
+ return {table,selected:selectedValues.length?consistent(selectedValues,convert,`selected ${field}`):undefined};
192
+ };
181
193
  const nozzleMetadata=(keys:string[],variantRows=false):string[]|undefined=>{
182
194
  const values=metadataValues(metadata,keys);
183
195
  return values.length ? consistent(values,value=>{
@@ -188,18 +200,27 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
188
200
  // Bambu GUI projects may store nozzle types per extruder variant, while
189
201
  // diameters and selected flow types are per physical extruder. Never truncate
190
202
  // that variant table or mistake its rows for filament/nozzle positions.
191
- const ids=consistent(metadataValues(metadata,['printer_extruder_id']),value=>{
192
- const ids=list(value);return ids.length===entries.length && ids.every(id=>/^[1-9]\d*$/.test(id) && Number(id)<=nozzleDiameters.length)?ids.map(Number):undefined;
193
- },'printer extruder id');
203
+ const {table:ids,selected:selectedIds}=variantTable('printer_extruder_id',entries.length,value=>{
204
+ const ids=list(value);return ids.length>0 && ids.every(id=>/^[1-9]\d*$/.test(id) && Number(id)<=nozzleDiameters.length)?ids.map(Number):undefined;
205
+ });
206
+ // The selected form names each physical extruder once, in order.
207
+ if(selectedIds && selectedIds.some((id,index)=>id!==index+1)) return undefined;
208
+ const variants=metadataValues(metadata,['printer_extruder_variant']).length
209
+ ? variantTable('printer_extruder_variant',entries.length,normalizedEntries):undefined;
210
+ const extruders=variants ? consistent(metadataValues(metadata,['extruder_type']),normalizedEntries,'extruder type'):undefined;
194
211
  const resolved:string[]=[];
195
212
  for(let index=0;index<nozzleDiameters.length;index++) {
196
213
  const rows=ids.flatMap((id,row)=>id===index+1?[row]:[]);
197
214
  if(!rows.length) return undefined;
198
- if(rows.every(row=>entries[row]===entries[rows[0]])) {resolved.push(entries[rows[0]]);continue;}
199
- const variants=consistent(metadataValues(metadata,['printer_extruder_variant']),normalizedEntries,'printer extruder variant');
200
- const extruders=consistent(metadataValues(metadata,['extruder_type']),normalizedEntries,'extruder type');
201
- if(variants.length!==entries.length || extruders.length!==nozzleDiameters.length || !nozzleFlows) return undefined;
202
- const selected=rows.filter(row=>variants[row]===`${extruders[index]}_${nozzleFlows[index]}`);
215
+ if(!variants) {
216
+ if(!rows.every(row=>entries[row]===entries[rows[0]])) return undefined;
217
+ resolved.push(entries[rows[0]]);continue;
218
+ }
219
+ // Validate declared variants even when all rows use the same material.
220
+ if(!extruders || extruders.length!==nozzleDiameters.length || !nozzleFlows) return undefined;
221
+ const variant=`${extruders[index]}_${nozzleFlows[index]}`;
222
+ if(variants.selected && variants.selected[index]!==variant) return undefined;
223
+ const selected=rows.filter(row=>variants.table[row]===variant);
203
224
  if(selected.length!==1) return undefined;
204
225
  resolved.push(entries[selected[0]]);
205
226
  }
@@ -336,8 +357,11 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
336
357
  } else if((/^M620\./.test(code) && !['M620.3','M620.6','M620.11'].includes(code)) || /^M621\./.test(code)) {
337
358
  fail(`unsupported thermal-affecting filament command ${code}`);
338
359
  } else if(code==='M145') {
360
+ // Airduct mode switch (P0 cooling, P1 heating) in the official H2-series,
361
+ // X2D and P2S start/end G-code; it sets no heater target. Other forms and
362
+ // models without a switchable airduct stay rejected.
339
363
  const args=parameters(argumentsText);
340
- if(args.size!==1 || !args.has('P') || ![0,1].includes(args.get('P')!) || !['h2d','h2dpro','h2c','h2s','x2d'].includes(model)) fail('unsupported thermal-affecting M145 parameters');
364
+ if(args.size!==1 || !args.has('P') || ![0,1].includes(args.get('P')!) || !['h2d','h2dpro','h2c','h2s','x2d','p2s'].includes(model)) fail('unsupported thermal-affecting M145 parameters');
341
365
  } else if(code==='M142') {
342
366
  // Bambu chamber fan regulation thresholds, not an active heater command.
343
367
  const args=parameters(argumentsText);for(const key of ['S','R']) if(args.has(key) && (!Number.isFinite(args.get(key)) || args.get(key)!<0 || args.get(key)!>65)) fail('unsupported chamber fan temperature threshold');