mcp-3d-printer-server 1.2.11 → 1.2.12

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.12 — 2026-10-13
4
+
5
+ ### Fixed
6
+
7
+ - Correct fresh H2C (`31B`) and H2D Pro (`239`) returned-serial identity without trusting configured identity or suppressing conflicts. Accept a cloud print-job identifier in `model_id` only when another recognized observed model or returned serial identifies the printer; unknown actual model fields still reject. Port reports from Adam (@adamkaplan, [fork #47](https://github.com/DMontgomery40/bambu-printer-mcp/issues/47)) and JaviOFC ([fork #36](https://github.com/DMontgomery40/bambu-printer-mcp/issues/36)).
8
+ - Accept Bambu Studio 02.08 P2S per-variant nozzle tables alongside the selected plate header, and the exact P2S `M145 P0/P1` airduct forms. Preserve selected extruder/variant conflicts and independent thermal checks. Port Izzy Mansurov's (@sapoepsilon) code and regressions from [fork #33](https://github.com/DMontgomery40/bambu-printer-mcp/pull/33), including the subsequent selection-validation corrections.
9
+ - Accept the official H2C startup forms only inside its single bounded executable startup block, with independent base/derived nozzle and bed checks and declared filament positions. Keep configuration `layer_change_gcode` keys from ending startup; actual layer markers and extrusion still close it. Reject unchecked `G383 U` targets outside the allowance. Thanks to Adam for [fork #48](https://github.com/DMontgomery40/bambu-printer-mcp/issues/48) and the exact vendor template evidence.
10
+ - Accept the official X2D bare material-switch `B` flag and exact `M620.22 I<declared filament> P1` runout-purge form while rejecting unsupported variants and preserving heater ceilings. Thanks to Travis Cashman (@travismcashan) for [fork #44](https://github.com/DMontgomery40/bambu-printer-mcp/issues/44). These are file-inspection changes; no native X2D helper is added to this server.
11
+
12
+ ### Dependencies
13
+
14
+ - Update basic-ftp to 6.2.3 for [GHSA-r4jh-29pc-gfqr](https://github.com/patrickjuchli/basic-ftp/security/advisories/GHSA-r4jh-29pc-gfqr): rejected control-character passwords/resume paths no longer cause an uncaught exception or leave the client unusable, and rejected PASS errors omit the password. Keep bambu-node pinned and preserve the parent's direct MQTT wire-shape and rejection guard.
15
+
16
+ Validation uses mocked transports, the pinned official H2C template, unsafe-file mutations, and clean package installations. No physical printer operation is performed or claimed.
17
+
3
18
  ## 1.2.11 — 2026-10-06
4
19
 
5
20
  This release also includes the safety, slicing, Blender, and documentation
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
+ | [Adam (@adamkaplan)](https://github.com/adamkaplan) | Supplies H2C serial/status evidence in [fork #47](https://github.com/DMontgomery40/bambu-printer-mcp/issues/47) and exact Bambu Studio 02.08 startup/template reproductions in [fork #48](https://github.com/DMontgomery40/bambu-printer-mcp/issues/48), ported here with fresh identity and bounded file-inspection regressions. No maintainer physical H2C print is claimed. |
10
+ | [Izzy Mansurov (sapoepsilon)](https://github.com/sapoepsilon) | Implements P2S extruder-variant and airduct inspection in [fork #33](https://github.com/DMontgomery40/bambu-printer-mcp/pull/33). Original code authorship is retained in the port, alongside unsafe-file and selected-header regressions; hardware printing is not claimed. |
11
+ | [JaviOFC](https://github.com/JaviOFC) | Provides X2D firmware/status evidence in [fork #36](https://github.com/DMontgomery40/bambu-printer-mcp/issues/36) demonstrating that a cloud job identifier in model_id is not a printer declaration. The observed-identity correction and conflict regressions are ported here. |
9
12
  | [Jack Manning (EastArctica)](https://github.com/EastArctica) | Sends `X-Api-Key` on every Klipper/Moonraker request, including uploads and remote-file downloads, in [#30](https://github.com/DMontgomery40/mcp-3D-printer-server/pull/30). Supplies Klipper 0.13.0 evidence of authenticated status succeeding after the previous 401 response. |
10
13
  | [nitpreet22](https://github.com/nitpreet22) | Adds A1/A1 mini `M109 H` wait compatibility and safety regressions in the Bambu fork's [#41](https://github.com/DMontgomery40/bambu-printer-mcp/pull/41), ported here with original code authorship. Shares Windows A1 slicing and file-inspection evidence; this is not physical-print verification. |
11
14
  | [Javier Cortejoso (jcortejoso)](https://github.com/jcortejoso) | Reads the FULU bridge command from server configuration by default, with an explicit opt-in for per-call bridge commands, in [#20](https://github.com/DMontgomery40/mcp-3D-printer-server/pull/20). [#21](https://github.com/DMontgomery40/mcp-3D-printer-server/pull/21) extends the same gate to every per-call executable selector. |
@@ -19,7 +22,7 @@ Thank you to everyone who builds, tests, reports problems, and shares real print
19
22
  | Contributor | Contribution |
20
23
  | --- | --- |
21
24
  | [Steavie](https://github.com/Steavie) | Reports reversed A1/A1 mini serial identity in the Bambu fork's [#39](https://github.com/DMontgomery40/bambu-printer-mcp/issues/39). This repository now uses the corrected prefixes for fresh safety identity, with mocked MQTT regressions. |
22
- | [Travis Cashman (travismcashan)](https://github.com/travismcashan) | Reports MCP stdout pollution from model parsing and slicing in the Bambu fork's [#44](https://github.com/DMontgomery40/bambu-printer-mcp/issues/44). The stderr correction and subprocess regressions are also applied here. |
25
+ | [Travis Cashman (travismcashan)](https://github.com/travismcashan) | Reports MCP stdout pollution from model parsing and slicing in the Bambu fork's [#44](https://github.com/DMontgomery40/bambu-printer-mcp/issues/44). The stderr correction and subprocess regressions are also applied here. Their exact stock material-switch and runout-purge evidence informs the X2D file-inspection port; maintainer validation remains offline and mocked. |
23
26
  | [Lickitysplitted](https://github.com/Lickitysplitted) | Diagnoses Bambu FTPS "Premature close" upload failures on an X1C as missing TLS session reuse in [#22](https://github.com/DMontgomery40/mcp-3D-printer-server/issues/22). Release 1.2.9 addresses it; confirmation on that X1C firmware is still outstanding. |
24
27
  | [CrowSoda](https://github.com/CrowSoda) | Tries slicing and printing on a Creality K1 Max through Klipper and Moonraker, and reports invalid OrcaSlicer CLI flags, missing filament-profile support, and unregistered upload and start tools in [#16](https://github.com/DMontgomery40/mcp-3D-printer-server/issues/16). All three are fixed. |
25
28
  | [dongio20](https://github.com/dongio20) | Reports the PrusaLink 0.8.1 status 404, with firmware details and the working endpoint, in [#11](https://github.com/DMontgomery40/mcp-3D-printer-server/issues/11). Fixed in [#15](https://github.com/DMontgomery40/mcp-3D-printer-server/pull/15). |
@@ -44,6 +47,10 @@ Thank you to everyone who builds, tests, reports problems, and shares real print
44
47
 
45
48
  See the [full contribution history](https://github.com/DMontgomery40/mcp-3D-printer-server/graphs/contributors) for commit authorship. Credit here includes bug reports, printer evidence, and reviewed proposals as well as merged code.
46
49
 
50
+ ## Dependency maintenance
51
+
52
+ Thanks to [Patrick Juchli](https://github.com/patrickjuchli) and the basic-ftp maintainers for [6.2.3](https://github.com/patrickjuchli/basic-ftp/releases/tag/v6.2.3), which fixes rejected-command crashes, client recovery, and password exposure in PASS errors. The compatible FTPS security update is validated in this release.
53
+
47
54
  ## Open-source community and interoperability
48
55
 
49
56
  Special thanks to the [FULU Foundation](https://www.fulu.org/), [Louis Rossmann](https://www.youtube.com/@rossmanngroup), and the [OrcaSlicer-bambulab contributors](https://github.com/FULU-Foundation/OrcaSlicer-bambulab) for their work supporting user control and interoperable tools. This is community recognition, separate from code authorship in this repository. The Bambu-only fork, [bambu-printer-mcp](https://github.com/DMontgomery40/bambu-printer-mcp), credits its own contributors in its [CONTRIBUTORS.md](https://github.com/DMontgomery40/bambu-printer-mcp/blob/main/CONTRIBUTORS.md).
@@ -80,7 +80,7 @@ function parameters(text, flags = '') {
80
80
  return result;
81
81
  }
82
82
  // Layer comments only close a startup exception; they can never enable one.
83
- const isLayerMarker = (line) => /^\s*;\s*(?:LAYER_CHANGE|CHANGE_LAYER|LAYER\s*:|layer num\/total_layer_count\s*:)/i.test(line);
83
+ const isLayerMarker = (line) => /^\s*;\s*(?:(?:LAYER_CHANGE|CHANGE_LAYER)(?:\s|$)|LAYER\s*:|layer num\/total_layer_count\s*:)/i.test(line);
84
84
  function boundedX1ePurge(lines, index) {
85
85
  // Official X1E common flush: 50 mm at 200 mm/min, followed immediately by
86
86
  // lowering the target. No dwell, XY extrusion, repeated block or open-ended
@@ -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)
266
288
  return undefined;
267
- const selected = rows.filter(row => variants[row] === `${extruders[index]}_${nozzleFlows[index]}`);
289
+ const variant = `${extruders[index]}_${nozzleFlows[index]}`;
290
+ if (variants.selected && variants.selected[index] !== variant)
291
+ return undefined;
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]]);
@@ -306,6 +331,10 @@ export async function inspectPrintFile(filePath, options) {
306
331
  let previous;
307
332
  let maxNozzleTemperature = 0, maxBedTemperature = 0, maxChamberTemperature = 0, commandCount = 0;
308
333
  let depositionStarted = false, commonFlushUsed = false;
334
+ // Bound the H2C vendor forms to its one explicit executable startup block.
335
+ const startupStarts = lines.flatMap((line, index) => /^\s*;\s*EXECUTABLE_BLOCK_START\s*$/i.test(line) ? [index] : []);
336
+ const startupEnds = lines.flatMap((line, index) => /^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(line) ? [index] : []);
337
+ const h2cStartup = (index) => model === 'h2c' && !depositionStarted && startupStarts.length === 1 && startupEnds.length === 1 && index > startupStarts[0] && index < startupEnds[0];
309
338
  const heat = (component, value, position, allMaterials = false, startupPurge = false) => {
310
339
  const affected = allMaterials || position === undefined ? materials.map((_, index) => index) : [requirePosition(position)];
311
340
  if (component === 'nozzle' && startupPurge)
@@ -331,11 +360,14 @@ export async function inspectPrintFile(filePath, options) {
331
360
  };
332
361
  for (let index = 0; index < lines.length; index++) {
333
362
  try {
334
- if (isLayerMarker(lines[index]))
363
+ if (isLayerMarker(lines[index]) || /^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(lines[index]))
335
364
  closeStartupWindow();
336
365
  let line = stripComments(lines[index]);
337
366
  if (!line || line === '%')
338
367
  continue;
368
+ // The official H2C template emits this literal separator without a semicolon.
369
+ if (line === '========== record data ==========' && h2cStartup(index))
370
+ continue;
339
371
  line = line.replace(/^N\d+\s*/i, '').replace(/\*\d+\s*$/, '');
340
372
  if (/[{}\[\]#]/.test(line))
341
373
  fail('unresolved dynamic command syntax');
@@ -380,6 +412,34 @@ export async function inspectPrintFile(filePath, options) {
380
412
  }
381
413
  if (['M104', 'M109', 'M140', 'M190', 'M141', 'M191'].includes(code)) {
382
414
  const args = parameters(argumentsText, 'A');
415
+ // Official A1 and A1 mini start G-code waits with M109 S<target> H<limit>
416
+ // (S25 H140, S100 H170, S220 H300). H is not a heater target: S stays the
417
+ // checked target, so H is accepted only within those vendor bounds.
418
+ if (args.has('O')) {
419
+ // H2C's exact all-hotend preheat offset is relative to the declared
420
+ // first-layer targets. Check every base and derived target, never use
421
+ // editable maxima or an unchecked offset as thermal evidence.
422
+ if (code !== 'M104' || !h2cStartup(index) || args.size !== 2 || args.get('O') !== -80 || !Number.isNaN(args.get('A')))
423
+ fail('unsupported H2C startup offset parameters');
424
+ const targets = consistent(metadataValues(metadata, ['nozzle_temperature_initial_layer']), value => {
425
+ const values = list(value);
426
+ return values.length === materials.length && values.every(v => NUMBER.test(v) && Number.isFinite(Number(v))) ? values.map(Number) : undefined;
427
+ }, 'first-layer nozzle temperature');
428
+ for (let position = 0; position < targets.length; position++) {
429
+ validateTemperature('nozzle', targets[position], model, [materials[position]]);
430
+ const preheat = Math.max(0, targets[position] - 80);
431
+ validateTemperature('nozzle', preheat, model, [materials[position]]);
432
+ nozzleTarget = Math.max(nozzleTarget, preheat);
433
+ maxNozzleTemperature = Math.max(maxNozzleTemperature, preheat);
434
+ }
435
+ continue;
436
+ }
437
+ if (args.has('D')) {
438
+ if (!['M140', 'M190'].includes(code) || !h2cStartup(index) || args.size !== 1)
439
+ fail('unsupported H2C startup bed parameters');
440
+ heat('bed', args.get('D'), undefined, true);
441
+ continue;
442
+ }
383
443
  const vendorWait = code === 'M109' && ['a1', 'a1mini'].includes(model);
384
444
  for (const key of args.keys())
385
445
  if (!'SRTA'.includes(key) && !(key === 'H' && vendorWait))
@@ -402,7 +462,12 @@ export async function inspectPrintFile(filePath, options) {
402
462
  heat(component, args.get(key), position, args.has('T') || args.has('A'), commonFlush);
403
463
  }
404
464
  else if (code === 'M620' || code === 'M621') {
405
- const args = parameters(argumentsText, 'MA');
465
+ const args = parameters(argumentsText, model === 'x2d' ? 'MAB' : model === 'h2c' ? 'MAN' : 'MA');
466
+ if (args.has('N') && (code !== 'M620' || !h2cStartup(index) || args.size !== 1 || !Number.isNaN(args.get('N'))))
467
+ fail('unsupported H2C startup hotend-remap flag');
468
+ // X2D templates use B as a bare material-switch flag, never a target.
469
+ if (args.has('B') && (model !== 'x2d' || !Number.isNaN(args.get('B'))))
470
+ fail('unsupported material-switch B flag');
406
471
  if (args.has('S')) {
407
472
  const position = args.get('S');
408
473
  if ([254, 255, 65279, 65535].includes(position)) {
@@ -426,6 +491,14 @@ export async function inspectPrintFile(filePath, options) {
426
491
  }
427
492
  }
428
493
  }
494
+ else if (code === 'M620.22') {
495
+ // Official X2D runout auto-purge enables the declared project filament.
496
+ // It supplies no heater target; all explicit heat still uses the shared gate.
497
+ const args = parameters(argumentsText);
498
+ if (model !== 'x2d' || args.size !== 2 || !args.has('I') || args.get('P') !== 1)
499
+ fail('unsupported M620.22 runout purge parameters');
500
+ requirePosition(args.get('I'));
501
+ }
429
502
  else if (code === 'M620.1' || code === 'M620.10') {
430
503
  const args = parameters(argumentsText, 'E');
431
504
  const position = code === 'M620.10' && args.get('A') === 1 ? pending ?? active : code === 'M620.10' && args.get('A') === 0 ? active : undefined;
@@ -444,10 +517,27 @@ export async function inspectPrintFile(filePath, options) {
444
517
  else if (/^G150\./.test(code) && /T/i.test(argumentsText)) {
445
518
  fail(`unsupported thermal-affecting wipe command ${code}`);
446
519
  }
520
+ else if (code === 'M620.14') {
521
+ const args = parameters(argumentsText);
522
+ // H2C tower coordinates include the rear wipe area outside object bounds.
523
+ if (!h2cStartup(index) || args.size !== 2 || !['X', 'Y'].every(key => args.has(key) && Number.isFinite(args.get(key)) && args.get(key) >= 0 && args.get(key) <= 350))
524
+ fail('unsupported H2C startup tower parameters');
525
+ }
526
+ else if (model === 'h2c' && ['G383', 'G383.3', 'G383.7'].includes(code) && /U/i.test(argumentsText)) {
527
+ const args = parameters(argumentsText);
528
+ const expected = code === 'G383.7' ? 'UJ' : code === 'G383.3' ? 'UL' : 'OUL';
529
+ if (!h2cStartup(index) || args.size !== expected.length || [...args.keys()].some(key => !expected.includes(key)) || args.get('U') !== 140 ||
530
+ (code === 'G383.7' && args.get('J') !== 0) || (code === 'G383' && args.get('O') !== 2))
531
+ fail('unsupported H2C startup probing parameters');
532
+ const position = args.has('L') ? requirePosition(args.get('L')) : active;
533
+ heat('nozzle', args.get('U'), position);
534
+ }
447
535
  else if (code === 'G383' || code === 'G383.3') {
448
536
  // Bambu probing routines carry nozzle temperature in T and optionally
449
537
  // the project filament position in L (including the H2D G383.3 form).
450
538
  const args = parameters(argumentsText);
539
+ if (args.has('U'))
540
+ fail('unsupported G383 temperature parameter U outside H2C startup');
451
541
  if (code === 'G383.3' && (!args.has('T') || [...args.keys()].some(key => !'TL'.includes(key))))
452
542
  fail('unsupported G383.3 temperature parameters');
453
543
  const position = args.has('L') ? requirePosition(args.get('L')) : active;
@@ -490,8 +580,11 @@ export async function inspectPrintFile(filePath, options) {
490
580
  fail(`unsupported thermal-affecting filament command ${code}`);
491
581
  }
492
582
  else if (code === 'M145') {
583
+ // Airduct mode switch (P0 cooling, P1 heating) in the official H2-series,
584
+ // X2D and P2S start/end G-code; it sets no heater target. Other forms and
585
+ // models without a switchable airduct stay rejected.
493
586
  const args = parameters(argumentsText);
494
- if (args.size !== 1 || !args.has('P') || ![0, 1].includes(args.get('P')) || !['h2d', 'h2dpro', 'h2c', 'h2s', 'x2d'].includes(model))
587
+ if (args.size !== 1 || !args.has('P') || ![0, 1].includes(args.get('P')) || !['h2d', 'h2dpro', 'h2c', 'h2s', 'x2d', 'p2s'].includes(model))
495
588
  fail('unsupported thermal-affecting M145 parameters');
496
589
  }
497
590
  else if (code === 'M142') {
@@ -17,18 +17,24 @@ const MODEL_IDS = {
17
17
  N2S: "A1", A1M: "A1 Mini", C11: "P1P", C12: "P1S", C13: "X1E", "BL-P001": "X1C", "BL-P002": "X1",
18
18
  };
19
19
  const SERIAL_MODELS = {
20
- "093": "H2S", "094": "H2D", "239": "H2C", "31B": "H2D Pro", "20P": "X2D",
20
+ "093": "H2S", "094": "H2D", "31B": "H2C", "239": "H2D Pro", "20P": "X2D",
21
21
  "00M": "X1C", "00W": "X1", "03W": "X1E", "01S": "P1P", "01P": "P1S", "22E": "P2S", "039": "A1", "030": "A1 Mini",
22
22
  };
23
23
  function object(value) {
24
24
  return !!value && typeof value === "object" && !Array.isArray(value);
25
25
  }
26
26
  function observedIdentity(raw, configuredSerial) {
27
- const declarations = [raw.model_id, raw.model, raw.printer_type, raw.device?.devModel, raw.device?.dev_model]
27
+ const recognizedModelFromModelId = typeof raw.model_id === "string" && raw.model_id.trim()
28
+ ? normalizeModel(MODEL_IDS[raw.model_id.trim().toUpperCase()] ?? raw.model_id)
29
+ : undefined;
30
+ const declarations = [raw.model, raw.printer_type, raw.device?.devModel, raw.device?.dev_model]
28
31
  .filter(value => typeof value === "string" && value.trim());
29
32
  const models = declarations.map(value => normalizeModel(MODEL_IDS[value.trim().toUpperCase()] ?? value));
30
33
  if (models.some(model => !model))
31
34
  throw new Error("Printer reported an unknown model identity; cannot verify this printer.");
35
+ if (recognizedModelFromModelId)
36
+ models.unshift(recognizedModelFromModelId);
37
+ const hasReportedModel = models.length > 0;
32
38
  const serials = [raw.serial, raw.serial_number, raw.sn, raw.device?.sn,
33
39
  ...(Array.isArray(raw.modules) ? raw.modules.filter((module) => module?.name === "ota").map((module) => module.sn) : [])]
34
40
  .filter(value => typeof value === "string" && value.trim()).map(value => value.trim());
@@ -43,7 +49,7 @@ function observedIdentity(raw, configuredSerial) {
43
49
  if (new Set(models).size > 1)
44
50
  throw new Error("Printer returned contradictory model identity information.");
45
51
  const model = models[0];
46
- return { model, observedSerial: serials[0], identitySource: declarations.length ? "report" : "module-serial" };
52
+ return { model, observedSerial: serials[0], identitySource: hasReportedModel ? "report" : "module-serial" };
47
53
  }
48
54
  /** Only raw reports received after this request are evidence. Never read printer.data here. */
49
55
  export function readFreshPrinterStatus(printer, serial, timeoutMs = 5000) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-3d-printer-server",
3
3
  "mcpName": "io.github.DMontgomery40/mcp-3d-printer-server",
4
- "version": "1.2.11",
4
+ "version": "1.2.12",
5
5
  "description": "MCP server for connecting Claude with 3D printer management systems",
6
6
  "main": "dist/index.js",
7
7
  "repository": "https://github.com/DMontgomery40/mcp-3D-printer-server",
@@ -82,7 +82,7 @@
82
82
  "@modelcontextprotocol/sdk": "^1.32.1",
83
83
  "axios": "^1.20.0",
84
84
  "bambu-node": "3.22.21",
85
- "basic-ftp": "^6.2.2",
85
+ "basic-ftp": "^6.2.3",
86
86
  "dotenv": "^16.6.1",
87
87
  "form-data": "^4.0.6",
88
88
  "jszip": "^3.10.2",
package/server.json CHANGED
@@ -7,22 +7,49 @@
7
7
  "url": "https://github.com/DMontgomery40/mcp-3D-printer-server",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.2.11",
10
+ "version": "1.2.12",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "mcp-3d-printer-server",
15
- "version": "1.2.11",
15
+ "version": "1.2.12",
16
16
  "runtimeHint": "npx",
17
- "transport": { "type": "stdio" },
17
+ "transport": {
18
+ "type": "stdio"
19
+ },
18
20
  "environmentVariables": [
19
- { "name": "PRINTER_TYPE", "description": "Printer backend: octoprint, klipper, duet, repetier, bambu, prusa, or creality.", "isRequired": true },
20
- { "name": "PRINTER_HOST", "description": "Printer IP address or hostname.", "isRequired": true },
21
- { "name": "PRINTER_PORT", "description": "Optional backend-specific port." },
22
- { "name": "API_KEY", "description": "API key for printer backends that require authentication.", "isSecret": true },
23
- { "name": "BAMBU_SERIAL", "description": "Printer serial number; required for Bambu Lab." },
24
- { "name": "BAMBU_TOKEN", "description": "LAN access code; required for Bambu Lab.", "isSecret": true },
25
- { "name": "BAMBU_MODEL", "description": "Exact Bambu model; required for Bambu print operations." }
21
+ {
22
+ "name": "PRINTER_TYPE",
23
+ "description": "Printer backend: octoprint, klipper, duet, repetier, bambu, prusa, or creality.",
24
+ "isRequired": true
25
+ },
26
+ {
27
+ "name": "PRINTER_HOST",
28
+ "description": "Printer IP address or hostname.",
29
+ "isRequired": true
30
+ },
31
+ {
32
+ "name": "PRINTER_PORT",
33
+ "description": "Optional backend-specific port."
34
+ },
35
+ {
36
+ "name": "API_KEY",
37
+ "description": "API key for printer backends that require authentication.",
38
+ "isSecret": true
39
+ },
40
+ {
41
+ "name": "BAMBU_SERIAL",
42
+ "description": "Printer serial number; required for Bambu Lab."
43
+ },
44
+ {
45
+ "name": "BAMBU_TOKEN",
46
+ "description": "LAN access code; required for Bambu Lab.",
47
+ "isSecret": true
48
+ },
49
+ {
50
+ "name": "BAMBU_MODEL",
51
+ "description": "Exact Bambu model; required for Bambu print operations."
52
+ }
26
53
  ]
27
54
  }
28
55
  ]
@@ -70,7 +70,7 @@ function parameters(text: string, flags = ''): Map<string,number> {
70
70
  return result;
71
71
  }
72
72
  // Layer comments only close a startup exception; they can never enable one.
73
- const isLayerMarker=(line:string):boolean=>/^\s*;\s*(?:LAYER_CHANGE|CHANGE_LAYER|LAYER\s*:|layer num\/total_layer_count\s*:)/i.test(line);
73
+ const isLayerMarker=(line:string):boolean=>/^\s*;\s*(?:(?:LAYER_CHANGE|CHANGE_LAYER)(?:\s|$)|LAYER\s*:|layer num\/total_layer_count\s*:)/i.test(line);
74
74
  function boundedX1ePurge(lines:string[],index:number):boolean {
75
75
  // Official X1E common flush: 50 mm at 200 mm/min, followed immediately by
76
76
  // lowering the target. No dwell, XY extrusion, repeated block or open-ended
@@ -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
  }
@@ -228,6 +249,10 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
228
249
  let nozzleTarget=0;let selectsAms=false;let pending: number|undefined;let previous: number|undefined;
229
250
  let maxNozzleTemperature=0,maxBedTemperature=0,maxChamberTemperature=0,commandCount=0;
230
251
  let depositionStarted=false,commonFlushUsed=false;
252
+ // Bound the H2C vendor forms to its one explicit executable startup block.
253
+ const startupStarts=lines.flatMap((line,index)=>/^\s*;\s*EXECUTABLE_BLOCK_START\s*$/i.test(line)?[index]:[]);
254
+ const startupEnds=lines.flatMap((line,index)=>/^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(line)?[index]:[]);
255
+ const h2cStartup=(index:number)=>model==='h2c' && !depositionStarted && startupStarts.length===1 && startupEnds.length===1 && index>startupStarts[0] && index<startupEnds[0];
231
256
  const heat=(component:'nozzle'|'bed'|'chamber',value:number,position?:number,allMaterials=false,startupPurge=false)=>{
232
257
  const affected=allMaterials || position===undefined ? materials.map((_,index)=>index) : [requirePosition(position)];
233
258
  if(component==='nozzle' && startupPurge) validateStartupPurgeTemperature(value,model,affected.map(index=>materials[index]));
@@ -246,8 +271,10 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
246
271
  };
247
272
  for(let index=0;index<lines.length;index++) {
248
273
  try {
249
- if(isLayerMarker(lines[index])) closeStartupWindow();
274
+ if(isLayerMarker(lines[index]) || /^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(lines[index])) closeStartupWindow();
250
275
  let line=stripComments(lines[index]);if(!line||line==='%') continue;
276
+ // The official H2C template emits this literal separator without a semicolon.
277
+ if(line==='========== record data ==========' && h2cStartup(index)) continue;
251
278
  line=line.replace(/^N\d+\s*/i,'').replace(/\*\d+\s*$/,'');
252
279
  if(/[{}\[\]#]/.test(line)) fail('unresolved dynamic command syntax');
253
280
  if(/^SYNC(?=$|\s)/i.test(line)) {
@@ -275,6 +302,30 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
275
302
  }
276
303
  if(['M104','M109','M140','M190','M141','M191'].includes(code)) {
277
304
  const args=parameters(argumentsText,'A');
305
+ // Official A1 and A1 mini start G-code waits with M109 S<target> H<limit>
306
+ // (S25 H140, S100 H170, S220 H300). H is not a heater target: S stays the
307
+ // checked target, so H is accepted only within those vendor bounds.
308
+ if(args.has('O')) {
309
+ // H2C's exact all-hotend preheat offset is relative to the declared
310
+ // first-layer targets. Check every base and derived target, never use
311
+ // editable maxima or an unchecked offset as thermal evidence.
312
+ if(code!=='M104' || !h2cStartup(index) || args.size!==2 || args.get('O')!==-80 || !Number.isNaN(args.get('A')!)) fail('unsupported H2C startup offset parameters');
313
+ const targets=consistent(metadataValues(metadata,['nozzle_temperature_initial_layer']),value=>{
314
+ const values=list(value);return values.length===materials.length && values.every(v=>NUMBER.test(v) && Number.isFinite(Number(v)))?values.map(Number):undefined;
315
+ },'first-layer nozzle temperature');
316
+ for(let position=0;position<targets.length;position++) {
317
+ validateTemperature('nozzle',targets[position],model,[materials[position]]);
318
+ const preheat=Math.max(0,targets[position]-80);
319
+ validateTemperature('nozzle',preheat,model,[materials[position]]);
320
+ nozzleTarget=Math.max(nozzleTarget,preheat);
321
+ maxNozzleTemperature=Math.max(maxNozzleTemperature,preheat);
322
+ }
323
+ continue;
324
+ }
325
+ if(args.has('D')) {
326
+ if(!['M140','M190'].includes(code) || !h2cStartup(index) || args.size!==1) fail('unsupported H2C startup bed parameters');
327
+ heat('bed',args.get('D')!,undefined,true);continue;
328
+ }
278
329
  const vendorWait=code==='M109' && ['a1','a1mini'].includes(model);
279
330
  for(const key of args.keys()) if(!'SRTA'.includes(key) && !(key==='H' && vendorWait)) fail(`unsupported ${code} temperature parameter ${key}`);
280
331
  if(args.has('H') && (!Number.isFinite(args.get('H')) || args.get('H')!<0 || args.get('H')!>300)) fail(`unsupported ${code} wait parameter H`);
@@ -288,12 +339,21 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
288
339
  if(commonFlush) commonFlushUsed=true;
289
340
  for(const key of ['S','R']) if(args.has(key)) heat(component,args.get(key)!,position,args.has('T') || args.has('A'),commonFlush);
290
341
  } else if(code==='M620' || code==='M621') {
291
- const args=parameters(argumentsText,'MA');
342
+ const args=parameters(argumentsText,model==='x2d'?'MAB':model==='h2c'?'MAN':'MA');
343
+ if(args.has('N') && (code!=='M620' || !h2cStartup(index) || args.size!==1 || !Number.isNaN(args.get('N')!))) fail('unsupported H2C startup hotend-remap flag');
344
+ // X2D templates use B as a bare material-switch flag, never a target.
345
+ if(args.has('B') && (model!=='x2d' || !Number.isNaN(args.get('B')!))) fail('unsupported material-switch B flag');
292
346
  if(args.has('S')) {
293
347
  const position=args.get('S')!;
294
348
  if([254,255,65279,65535].includes(position)) {if(code==='M621') {previous=active;active=undefined;}pending=undefined;}
295
349
  else {selectsAms=true;requirePosition(position);if(code==='M620') pending=position;else {validateTemperature('nozzle',nozzleTarget,model,[materials[position]]);previous=active;active=position;pending=undefined;}}
296
350
  }
351
+ } else if(code==='M620.22') {
352
+ // Official X2D runout auto-purge enables the declared project filament.
353
+ // It supplies no heater target; all explicit heat still uses the shared gate.
354
+ const args=parameters(argumentsText);
355
+ if(model!=='x2d' || args.size!==2 || !args.has('I') || args.get('P')!==1) fail('unsupported M620.22 runout purge parameters');
356
+ requirePosition(args.get('I')!);
297
357
  } else if(code==='M620.1' || code==='M620.10') {
298
358
  const args=parameters(argumentsText,'E');
299
359
  const position=code==='M620.10' && args.get('A')===1 ? pending ?? active : code==='M620.10' && args.get('A')===0 ? active : undefined;
@@ -306,10 +366,22 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
306
366
  if(args.has('T')) heat('nozzle',args.get('T')!,active);
307
367
  } else if(/^G150\./.test(code) && /T/i.test(argumentsText)) {
308
368
  fail(`unsupported thermal-affecting wipe command ${code}`);
369
+ } else if(code==='M620.14') {
370
+ const args=parameters(argumentsText);
371
+ // H2C tower coordinates include the rear wipe area outside object bounds.
372
+ if(!h2cStartup(index) || args.size!==2 || !['X','Y'].every(key=>args.has(key) && Number.isFinite(args.get(key)) && args.get(key)!>=0 && args.get(key)!<=350)) fail('unsupported H2C startup tower parameters');
373
+ } else if(model==='h2c' && ['G383','G383.3','G383.7'].includes(code) && /U/i.test(argumentsText)) {
374
+ const args=parameters(argumentsText);
375
+ const expected=code==='G383.7'?'UJ':code==='G383.3'?'UL':'OUL';
376
+ if(!h2cStartup(index) || args.size!==expected.length || [...args.keys()].some(key=>!expected.includes(key)) || args.get('U')!==140 ||
377
+ (code==='G383.7' && args.get('J')!==0) || (code==='G383' && args.get('O')!==2)) fail('unsupported H2C startup probing parameters');
378
+ const position=args.has('L')?requirePosition(args.get('L')!):active;
379
+ heat('nozzle',args.get('U')!,position);
309
380
  } else if(code==='G383' || code==='G383.3') {
310
381
  // Bambu probing routines carry nozzle temperature in T and optionally
311
382
  // the project filament position in L (including the H2D G383.3 form).
312
383
  const args=parameters(argumentsText);
384
+ if(args.has('U')) fail('unsupported G383 temperature parameter U outside H2C startup');
313
385
  if(code==='G383.3' && (!args.has('T') || [...args.keys()].some(key=>!'TL'.includes(key)))) fail('unsupported G383.3 temperature parameters');
314
386
  const position=args.has('L')?requirePosition(args.get('L')!):active;
315
387
  if(args.has('T')) heat('nozzle',args.get('T')!,position);
@@ -338,8 +410,11 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
338
410
  } else if((/^M620\./.test(code) && !['M620.3','M620.6','M620.11'].includes(code)) || /^M621\./.test(code)) {
339
411
  fail(`unsupported thermal-affecting filament command ${code}`);
340
412
  } else if(code==='M145') {
413
+ // Airduct mode switch (P0 cooling, P1 heating) in the official H2-series,
414
+ // X2D and P2S start/end G-code; it sets no heater target. Other forms and
415
+ // models without a switchable airduct stay rejected.
341
416
  const args=parameters(argumentsText);
342
- 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');
417
+ 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');
343
418
  } else if(code==='M142') {
344
419
  // Bambu chamber fan regulation thresholds, not an active heater command.
345
420
  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');
@@ -20,7 +20,7 @@ const MODEL_IDS: Record<string, string> = {
20
20
  N2S: "A1", A1M: "A1 Mini", C11: "P1P", C12: "P1S", C13: "X1E", "BL-P001": "X1C", "BL-P002": "X1",
21
21
  };
22
22
  const SERIAL_MODELS: Record<string, string> = {
23
- "093": "H2S", "094": "H2D", "239": "H2C", "31B": "H2D Pro", "20P": "X2D",
23
+ "093": "H2S", "094": "H2D", "31B": "H2C", "239": "H2D Pro", "20P": "X2D",
24
24
  "00M": "X1C", "00W": "X1", "03W": "X1E", "01S": "P1P", "01P": "P1S", "22E": "P2S", "039": "A1", "030": "A1 Mini",
25
25
  };
26
26
 
@@ -28,10 +28,15 @@ function object(value: unknown): value is Record<string, any> {
28
28
  return !!value && typeof value === "object" && !Array.isArray(value);
29
29
  }
30
30
  function observedIdentity(raw: Record<string, any>, configuredSerial: string) {
31
- const declarations = [raw.model_id, raw.model, raw.printer_type, raw.device?.devModel, raw.device?.dev_model]
31
+ const recognizedModelFromModelId = typeof raw.model_id === "string" && raw.model_id.trim()
32
+ ? normalizeModel(MODEL_IDS[raw.model_id.trim().toUpperCase()] ?? raw.model_id)
33
+ : undefined;
34
+ const declarations = [raw.model, raw.printer_type, raw.device?.devModel, raw.device?.dev_model]
32
35
  .filter(value => typeof value === "string" && value.trim());
33
36
  const models = declarations.map(value => normalizeModel(MODEL_IDS[value.trim().toUpperCase()] ?? value));
34
37
  if (models.some(model => !model)) throw new Error("Printer reported an unknown model identity; cannot verify this printer.");
38
+ if (recognizedModelFromModelId) models.unshift(recognizedModelFromModelId);
39
+ const hasReportedModel = models.length > 0;
35
40
  const serials = [raw.serial, raw.serial_number, raw.sn, raw.device?.sn,
36
41
  ...(Array.isArray(raw.modules) ? raw.modules.filter((module: any) => module?.name === "ota").map((module: any) => module.sn) : [])]
37
42
  .filter(value => typeof value === "string" && value.trim()).map(value => value.trim());
@@ -44,7 +49,7 @@ function observedIdentity(raw: Record<string, any>, configuredSerial: string) {
44
49
  }
45
50
  if (new Set(models).size > 1) throw new Error("Printer returned contradictory model identity information.");
46
51
  const model = models[0];
47
- return { model, observedSerial: serials[0], identitySource: declarations.length ? "report" : "module-serial" };
52
+ return { model, observedSerial: serials[0], identitySource: hasReportedModel ? "report" : "module-serial" };
48
53
  }
49
54
 
50
55
  /** Only raw reports received after this request are evidence. Never read printer.data here. */