mcp-3d-printer-server 1.2.25 → 1.2.26

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/CONTRIBUTORS.md +5 -1
  3. package/README.md +64 -29
  4. package/dist/ams-mapping.d.ts +38 -0
  5. package/dist/ams-mapping.js +125 -0
  6. package/dist/bambu-network-bridge.d.ts +200 -0
  7. package/dist/bambu-network-bridge.js +881 -0
  8. package/dist/bambu-network-certs.d.ts +13 -0
  9. package/dist/bambu-network-certs.js +152 -0
  10. package/dist/control-reply.d.ts +21 -0
  11. package/dist/control-reply.js +44 -0
  12. package/dist/counterpart/camera-sources.d.ts +16 -0
  13. package/dist/counterpart/camera-sources.js +138 -0
  14. package/dist/counterpart/camera-stream.d.ts +66 -0
  15. package/dist/counterpart/camera-stream.js +246 -0
  16. package/dist/counterpart/camera-tools.d.ts +247 -0
  17. package/dist/counterpart/camera-tools.js +35 -0
  18. package/dist/counterpart/contract.d.ts +21 -5
  19. package/dist/counterpart/contract.js +8 -2
  20. package/dist/counterpart/route.d.ts +31 -0
  21. package/dist/counterpart/route.js +70 -0
  22. package/dist/counterpart/service.d.ts +60 -4
  23. package/dist/counterpart/service.js +142 -16
  24. package/dist/design-guidance.d.ts +26 -0
  25. package/dist/design-guidance.js +177 -0
  26. package/dist/fulu-orca.d.ts +7 -0
  27. package/dist/fulu-orca.js +18 -2
  28. package/dist/index.js +512 -62
  29. package/dist/printers/bambu-filaments.d.ts +68 -0
  30. package/dist/printers/bambu-filaments.js +262 -0
  31. package/dist/printers/bambu.d.ts +99 -6
  32. package/dist/printers/bambu.js +478 -72
  33. package/dist/printers/creality.d.ts +2 -1
  34. package/dist/printers/creality.js +5 -1
  35. package/dist/printers/generic-base.d.ts +11 -6
  36. package/dist/printers/generic-base.js +21 -25
  37. package/dist/printers/klipper.d.ts +2 -1
  38. package/dist/printers/klipper.js +10 -7
  39. package/dist/printers/octoprint.d.ts +2 -1
  40. package/dist/printers/octoprint.js +15 -8
  41. package/dist/printers/printer-factory.d.ts +1 -1
  42. package/dist/printers/printer-factory.js +3 -3
  43. package/dist/printers/prusa.js +0 -1
  44. package/dist/safety/confirmation.d.ts +11 -16
  45. package/dist/safety/confirmation.js +8 -32
  46. package/dist/safety/hidden-hms.d.ts +32 -0
  47. package/dist/safety/hidden-hms.js +405 -0
  48. package/dist/safety/limits.d.ts +8 -2
  49. package/dist/safety/limits.js +14 -5
  50. package/dist/safety/print-file.d.ts +5 -0
  51. package/dist/safety/print-file.js +156 -19
  52. package/dist/safety/printer-state.d.ts +59 -8
  53. package/dist/safety/printer-state.js +174 -39
  54. package/dist/slicer/bambu-cli.d.ts +2 -0
  55. package/dist/slicer/bambu-cli.js +7 -5
  56. package/dist/slicer/layer-pauses.d.ts +47 -0
  57. package/dist/slicer/layer-pauses.js +130 -0
  58. package/dist/stl/stl-manipulator.d.ts +1 -0
  59. package/dist/stl/stl-manipulator.js +40 -0
  60. package/dist/studio-print-options.d.ts +57 -0
  61. package/dist/studio-print-options.js +179 -0
  62. package/package.json +25 -4
  63. package/server.json +2 -2
  64. package/src/ams-mapping.ts +132 -0
  65. package/src/bambu-network-bridge.ts +1075 -0
  66. package/src/bambu-network-certs.ts +154 -0
  67. package/src/control-reply.ts +41 -0
  68. package/src/counterpart/camera-sources.ts +102 -0
  69. package/src/counterpart/camera-stream.ts +195 -0
  70. package/src/counterpart/camera-tools.ts +35 -0
  71. package/src/counterpart/contract.ts +12 -4
  72. package/src/counterpart/route.ts +82 -0
  73. package/src/counterpart/service.ts +150 -16
  74. package/src/design-guidance.ts +188 -0
  75. package/src/fulu-orca.ts +18 -2
  76. package/src/index.ts +526 -75
  77. package/src/printers/bambu-filaments.ts +346 -0
  78. package/src/printers/bambu.ts +506 -81
  79. package/src/printers/creality.ts +8 -3
  80. package/src/printers/generic-base.ts +33 -40
  81. package/src/printers/klipper.ts +12 -8
  82. package/src/printers/octoprint.ts +16 -9
  83. package/src/printers/printer-factory.ts +3 -3
  84. package/src/printers/prusa.ts +0 -1
  85. package/src/safety/confirmation.ts +15 -38
  86. package/src/safety/hidden-hms.ts +406 -0
  87. package/src/safety/limits.ts +15 -5
  88. package/src/safety/print-file.ts +139 -17
  89. package/src/safety/printer-state.ts +179 -40
  90. package/src/slicer/bambu-cli.ts +8 -5
  91. package/src/slicer/layer-pauses.ts +143 -0
  92. package/src/stl/stl-manipulator.ts +45 -0
  93. package/src/studio-print-options.ts +201 -0
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readBoundedPrintFile, loadSafe3mfArchive } from './archive.js';
3
- import { MACHINE_LIMITS, normalizeMaterial, normalizeModel, validateStartupPurgeTemperature, validateTemperature } from './limits.js';
3
+ import { projectFilaments, type ProjectFilament } from "../ams-mapping.js";
4
+ import { MACHINE_LIMITS, isDualNozzleModel, normalizeMaterial, normalizeModel, validateStartupPurgeTemperature, validateTemperature } from './limits.js';
4
5
 
5
6
  export interface PrintFileInspection {
6
7
  model: string;
@@ -16,6 +17,10 @@ export interface PrintFileInspection {
16
17
  selectsAms: boolean;
17
18
  nozzleTypes?: string[];
18
19
  nozzleFlows?: string[];
20
+ /** Physical nozzle index for each declared filament, composed from the selected plate headers. */
21
+ filamentNozzleIndices?: number[];
22
+ /** Every project filament as project_settings.config declares it, for PrintParams.ams_mapping_info. */
23
+ projectFilaments?: ProjectFilament[];
19
24
  }
20
25
  type Metadata = Record<string, unknown>;
21
26
  const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
@@ -43,6 +48,60 @@ function consistent<T>(values: unknown[], convert: (v: unknown)=>T|undefined, fi
43
48
  if (converted.some(value=>JSON.stringify(value)!==JSON.stringify(converted[0]))) return fail(`contradictory ${field} metadata`);
44
49
  return converted[0]!;
45
50
  }
51
+ /** Bambu Studio keeps filament options per variant in the project, but writes
52
+ * one selected row per filament in the plate header (Print::full_print_config).
53
+ * Match the declared 1-based self index and variant, never truncate the table
54
+ * or borrow the slicer's row-zero fallback for a missing selection.
55
+ */
56
+ function firstLayerTemperatures(project:Metadata, headers:Metadata[], count:number):number[] {
57
+ const key='nozzle_temperature_initial_layer';
58
+ const field='first-layer nozzle temperature';
59
+ const temperatures=(value:unknown):number[]|undefined=>{
60
+ const entries=list(value);
61
+ return entries.length && entries.every(v=>NUMBER.test(v) && Number.isFinite(Number(v)))?entries.map(Number):undefined;
62
+ };
63
+ const perFilament=(value:unknown)=>{
64
+ const values=temperatures(value);return values?.length===count?values:undefined;
65
+ };
66
+ const selectedValues=metadataValues(headers,[key]);
67
+ if(project[key]===undefined) return consistent(selectedValues,perFilament,field);
68
+ if(project.filament_extruder_variant===undefined && project.filament_self_index===undefined)
69
+ return consistent([project[key],...selectedValues],perFilament,field);
70
+
71
+ const table=temperatures(project[key]);
72
+ const variants=list(project.filament_extruder_variant);
73
+ const ids=list(project.filament_self_index);
74
+ const validVariant=(value:string)=>/^(Direct Drive|Bowden) (Standard|High Flow|Hybrid|TPU High Flow|E3D High Flow)$/.test(value);
75
+ if(!table || variants.length!==table.length || ids.length!==table.length ||
76
+ variants.some(v=>!validVariant(v)) || ids.some(id=>!/^[1-9]\d*$/.test(id) || Number(id)>count))
77
+ return fail(`unknown or malformed ${field} variant table`);
78
+ const selectedVariants=consistent(metadataValues(headers,['filament_extruder_variant']),value=>{
79
+ const values=list(value);return values.length===count && values.every(validVariant)?values:undefined;
80
+ },'selected filament extruder variant');
81
+ const selectedIds=consistent(metadataValues(headers,['filament_self_index']),value=>{
82
+ const values=list(value);return values.length===count && values.every((id,index)=>id===String(index+1))?values:undefined;
83
+ },'selected filament self index');
84
+ // These are logical extruders: physical_extruder_map is used for spool
85
+ // routing, not this variant lookup. Hybrid selects the Standard variant.
86
+ const mappingKeys=['filament_map','extruder_type','filament_volume_map'];
87
+ if(mappingKeys.every(key=>metadataValues(headers,[key]).length)) {
88
+ const selection=(key:string)=>consistent(metadataValues(headers,[key]),value=>{
89
+ const entries=list(value);return entries.length?entries:undefined;
90
+ },`selected ${key.replace(/_/g,' ')}`);
91
+ const map=selection('filament_map');const types=selection('extruder_type');const volumes=selection('filament_volume_map');
92
+ const flows:Record<string,string>={'0':'Standard','1':'High Flow','2':'Standard','3':'TPU High Flow','5':'E3D High Flow'};
93
+ if(map.length!==count || volumes.length!==count || map.some(id=>!/^[1-9]\d*$/.test(id) || Number(id)>types.length) ||
94
+ volumes.some(v=>!Object.prototype.hasOwnProperty.call(flows,v)) || types.some(v=>!['Direct Drive','Bowden'].includes(v)) ||
95
+ selectedVariants.some((variant,i)=>variant!==`${types[Number(map[i])-1]} ${flows[volumes[i]]}`))
96
+ return fail('contradictory selected filament extruder variant mapping');
97
+ }
98
+ const resolved=selectedIds.map((id,index)=>{
99
+ const rows=ids.flatMap((candidate,row)=>candidate===id && variants[row]===selectedVariants[index]?[row]:[]);
100
+ if(rows.length!==1) return fail(`ambiguous or missing ${field} variant for filament ${id}`);
101
+ return table[rows[0]];
102
+ });
103
+ return consistent([resolved,...selectedValues],perFilament,field);
104
+ }
46
105
  function stripComments(line: string): string {
47
106
  let clean=''; let depth=0;
48
107
  for(const char of line) {
@@ -57,6 +116,11 @@ function stripComments(line: string): string {
57
116
  /** A deliberately narrow parameter lexer for commands affecting heat/material selection.
58
117
  * Unknown nonthermal vendor commands do not require a global G-code allowlist.
59
118
  */
119
+ /** True when the parameter is present, finite and within [low, high]. */
120
+ function inRange(args: Map<string,number>, key: string, low: number, high: number): boolean {
121
+ const value=args.get(key);
122
+ return value!==undefined && Number.isFinite(value) && value>=low && value<=high;
123
+ }
60
124
  function parameters(text: string, flags = ''): Map<string,number> {
61
125
  const result=new Map<string,number>();let rest=text.trim();
62
126
  while(rest) {
@@ -135,6 +199,7 @@ function validateBounds(headers: Metadata[], model: string, plate: Metadata): vo
135
199
  export async function inspectPrintFile(filePath: string, options: {model:string; nozzleDiameters?:number[]; plateIndex?:number; bedType?:string}): Promise<PrintFileInspection> {
136
200
  const model=normalizeModel(options.model);
137
201
  if(!model) return fail(`unknown or missing printer model '${options.model}'`);
202
+ const dualNozzle=isDualNozzleModel(model);
138
203
  const plateIndex=options.plateIndex ?? 0;
139
204
  if(!Number.isInteger(plateIndex)||plateIndex<0) return fail('selected plate index must be a nonnegative integer');
140
205
  if(/\.md5$/i.test(filePath)) return fail('a checksum file is not a printable artifact');
@@ -232,6 +297,22 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
232
297
  const materials=consistent(metadataValues(metadata,['filament_type']),value=>{
233
298
  const values=list(value);return values.length && values.every(v=>!!normalizeMaterial(v))?values.map(v=>normalizeMaterial(v)!):undefined;
234
299
  },'filament material');
300
+ // GCode.cpp uses 1-based filament_map logical extruders and then indexes
301
+ // physical_extruder_map to select the physical nozzle. Only this selected
302
+ // plate's headers establish that binding; unsliced project defaults do not.
303
+ const headerMap=(key:string,count:number,low:number,high:number):number[]|undefined=>{
304
+ const values=metadataValues(headers,[key]);
305
+ if(!values.length) return undefined;
306
+ return consistent(values,value=>{
307
+ const entries=list(value);
308
+ return entries.length===count && entries.every(v=>NUMBER.test(v) && Number.isInteger(Number(v)) && Number(v)>=low && Number(v)<=high)
309
+ ? entries.map(Number):undefined;
310
+ },key.replace(/_/g,' '));
311
+ };
312
+ const physicalExtruderMap=headerMap('physical_extruder_map',nozzleDiameters.length,0,dualNozzle?Math.min(nozzleDiameters.length,2)-1:nozzleDiameters.length-1);
313
+ if(physicalExtruderMap && new Set(physicalExtruderMap).size!==physicalExtruderMap.length) fail('physical extruder map must be a complete permutation');
314
+ const filamentMap=headerMap('filament_map',materials.length,1,nozzleDiameters.length);
315
+ const filamentNozzleIndices=filamentMap && physicalExtruderMap ? filamentMap.map(logical=>physicalExtruderMap[logical-1]):undefined;
235
316
  const bedValues=metadataValues([...metadata,plate],['curr_bed_type','bed_type']);
236
317
  const bedType=bedValues.length?consistent(bedValues,normalizeBed,'bed type'):undefined;
237
318
  if(options.bedType && (!bedType || normalizeBed(options.bedType)!==bedType)) return fail('selected bed type cannot be verified or contradicts the sliced file; re-slice with the intended bed');
@@ -273,15 +354,21 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
273
354
  try {
274
355
  if(isLayerMarker(lines[index]) || /^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(lines[index])) closeStartupWindow();
275
356
  let line=stripComments(lines[index]);if(!line||line==='%') continue;
276
- // The official H2C template emits this literal separator without a semicolon.
357
+ // Accept only the exact vendor banners in their documented model/scope.
358
+ // X2D emits its filament-change banner throughout a print; H2C's record
359
+ // separator stays inside its explicitly bounded startup block.
360
+ if(line==='======== X2D filament_change gcode ==========' && model==='x2d') continue;
277
361
  if(line==='========== record data ==========' && h2cStartup(index)) continue;
278
362
  line=line.replace(/^N\d+\s*/i,'').replace(/\*\d+\s*$/,'');
279
363
  if(/[{}\[\]#]/.test(line)) fail('unresolved dynamic command syntax');
280
364
  if(/^SYNC(?=$|\s)/i.test(line)) {
281
- // H2D change_filament_gcode emits SYNC T{ceil(flush_length / 125) * 5}.
282
- // T is a synchronization duration here, not a nozzle temperature. The
283
- // vendor template establishes no independent maximum duration.
365
+ // Vendor SYNC T is a duration, not a heater target. X2D and P2S
366
+ // startup templates additionally emit R0; no other R form is supported.
284
367
  const args=parameters(line.slice(4));
368
+ if(args.has('R')) {
369
+ if(!['x2d','p2s'].includes(model) || args.get('R')!==0) fail('unsupported SYNC reset parameters');
370
+ args.delete('R');
371
+ }
285
372
  if(args.size!==1 || !args.has('T') || !Number.isFinite(args.get('T')) || args.get('T')!<0) fail('unsupported SYNC duration parameters');
286
373
  commandCount++;continue;
287
374
  }
@@ -289,6 +376,26 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
289
376
  if(!command) fail(`unsupported G-code command syntax '${line.slice(0,100)}'`);
290
377
  const [base,subcode]=command![2].split('.');
291
378
  const code=command![1].toUpperCase()+String(Number(base))+(subcode===undefined?'':'.'+subcode);const argumentsText=line.slice(command![0].length).trim();commandCount++;
379
+ if(code==='M9711') {
380
+ // Official X2D, H2D and H2C time-lapse capture (Bambu Studio and OrcaSlicer
381
+ // templates), emitted on every layer: M is the time-lapse mode and E the
382
+ // physical extruder, not a second command; T is a duration, not a heater.
383
+ // It sets no temperature. Positions stay inside the machine volume.
384
+ const invalid=()=>fail('unsupported M9711 time-lapse parameters');
385
+ if(!['x2d','h2d','h2c'].includes(model)) invalid();
386
+ let args:Map<string,number>;
387
+ try {args=parameters(argumentsText);} catch {return invalid();}
388
+ const [width,depth,height]=MACHINE_LIMITS[model].volume;
389
+ const position=['X','Y','U','V'].filter(key=>args.has(key)).join('');
390
+ const limit:Record<string,number>={X:width,U:width,Y:depth,V:depth};
391
+ if([...args.keys()].some(key=>!'MEXYUVZSCOT'.includes(key)) ||
392
+ // These models have two physical extruders, whatever the file declares.
393
+ ![0,1].includes(args.get('M')!) || !Number.isInteger(args.get('E')) || !inRange(args,'E',0,Math.min(nozzleDiameters.length,2)-1) ||
394
+ args.get('S')!==11 || args.get('C')!==10 || args.get('O')!==0 || !inRange(args,'T',0,600000) ||
395
+ !inRange(args,'Z',0,height) ||
396
+ !['','XY','UV'].includes(position) || [...position].some(key=>!inRange(args,key,0,limit[key]))) invalid();
397
+ continue;
398
+ }
292
399
  if(!['M117','M118','M1002','M1006','M900','M970','M983.1','G383'].includes(code) && /[GM]\s*\d/i.test(argumentsText)) fail('multiple commands on one line are unsupported');
293
400
  // Coordinated extrusion closes the startup-only allowance even when the
294
401
  // source omits layer comments. Retraction also closes it conservatively.
@@ -301,7 +408,14 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
301
408
  continue;
302
409
  }
303
410
  if(['M104','M109','M140','M190','M141','M191'].includes(code)) {
304
- const args=parameters(argumentsText,'A');
411
+ const args=parameters(argumentsText,code==='M104'?'AM':'A');
412
+ // Studio 2.9's GCodeWriter marks layer-temperature changes with bare M.
413
+ // Accept only its exact S-target / optional T form; targets and tool
414
+ // selectors still pass through the independent thermal checks below.
415
+ if(args.has('M')) {
416
+ if(!Number.isNaN(args.get('M')!) || !args.has('S') || [...args.keys()].some(key=>!'MST'.includes(key))) fail('unsupported M104 layer-change temperature parameters');
417
+ args.delete('M');
418
+ }
305
419
  // Official A1 and A1 mini start G-code waits with M109 S<target> H<limit>
306
420
  // (S25 H140, S100 H170, S220 H300). H is not a heater target: S stays the
307
421
  // checked target, so H is accepted only within those vendor bounds.
@@ -310,9 +424,7 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
310
424
  // first-layer targets. Check every base and derived target, never use
311
425
  // editable maxima or an unchecked offset as thermal evidence.
312
426
  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');
427
+ const targets=firstLayerTemperatures(project,headers,materials.length);
316
428
  for(let position=0;position<targets.length;position++) {
317
429
  validateTemperature('nozzle',targets[position],model,[materials[position]]);
318
430
  const preheat=Math.max(0,targets[position]-80);
@@ -327,17 +439,23 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
327
439
  heat('bed',args.get('D')!,undefined,true);continue;
328
440
  }
329
441
  const vendorWait=code==='M109' && ['a1','a1mini'].includes(model);
330
- for(const key of args.keys()) if(!'SRTA'.includes(key) && !(key==='H' && vendorWait)) fail(`unsupported ${code} temperature parameter ${key}`);
442
+ // Bambu Studio addresses a physical nozzle on dual-nozzle machines with N<index>
443
+ // (X2D `M104 T1 S25 N0` multi-extruder pre-cooling and wipe-tower reheats).
444
+ // N selects a nozzle, never a target, so it is accepted only on those models,
445
+ // only for nozzle heaters, and only within the declared nozzle count.
446
+ const nozzleIndex=['M104','M109'].includes(code) && dualNozzle;
447
+ for(const key of args.keys()) if(!'SRTA'.includes(key) && !(key==='H' && vendorWait) && !(key==='N' && nozzleIndex)) fail(`unsupported ${code} temperature parameter ${key}`);
448
+ if(args.has('N') && (!Number.isInteger(args.get('N')) || args.get('N')!<0 || args.get('N')!>=Math.min(nozzleDiameters.length,2))) fail(`unsupported ${code} nozzle index N`);
331
449
  if(args.has('H') && (!Number.isFinite(args.get('H')) || args.get('H')!<0 || args.get('H')!>300)) fail(`unsupported ${code} wait parameter H`);
332
450
  if(!args.has('S')&&!args.has('R')) fail(`missing ${code} temperature target`);
333
451
  const component= ['M104','M109'].includes(code)?'nozzle': ['M140','M190'].includes(code)?'bed':'chamber';
334
452
  // Heater T is a physical nozzle, unlike standalone remapped T filament selection.
335
453
  // Without a verified per-filament nozzle map, apply all possible materials.
336
454
  if(args.has('T') && (!Number.isInteger(args.get('T')) || args.get('T')!<0 || args.get('T')!>=nozzleDiameters.length)) fail('physical heater target has no declared nozzle');
337
- const position= args.has('T') ? undefined : active;
455
+ const position= args.has('T') || args.has('N') ? undefined : active;
338
456
  const commonFlush=code==='M109' && args.size===1 && args.get('S')===290 && model==='x1e' && !depositionStarted && !commonFlushUsed && boundedX1ePurge(lines,index);
339
457
  if(commonFlush) commonFlushUsed=true;
340
- for(const key of ['S','R']) if(args.has(key)) heat(component,args.get(key)!,position,args.has('T') || args.has('A'),commonFlush);
458
+ for(const key of ['S','R']) if(args.has(key)) heat(component,args.get(key)!,position,args.has('T') || args.has('N') || args.has('A'),commonFlush);
341
459
  } else if(code==='M620' || code==='M621') {
342
460
  const args=parameters(argumentsText,model==='x2d'?'MAB':model==='h2c'?'MAN':'MA');
343
461
  if(args.has('N') && (code!=='M620' || !h2cStartup(index) || args.size!==1 || !Number.isNaN(args.get('N')!))) fail('unsupported H2C startup hotend-remap flag');
@@ -368,8 +486,9 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
368
486
  fail(`unsupported thermal-affecting wipe command ${code}`);
369
487
  } else if(code==='M620.14') {
370
488
  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');
489
+ // H2C uses these coordinates during startup and a selected filament change.
490
+ // The rear wipe area extends outside object bounds (official fallback Y336).
491
+ if(!(h2cStartup(index) || (model==='h2c' && pending!==undefined)) || 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 tower parameters');
373
492
  } else if(model==='h2c' && ['G383','G383.3','G383.7'].includes(code) && /U/i.test(argumentsText)) {
374
493
  const args=parameters(argumentsText);
375
494
  const expected=code==='G383.7'?'UJ':code==='G383.3'?'UL':'OUL';
@@ -400,8 +519,11 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
400
519
  // H2D change_filament_gcode supplies the incoming filament's cooling
401
520
  // temperature as C{new_filament_temp - filament_cooling_before_tower}.
402
521
  const args=parameters(argumentsText);
403
- if(args.size!==1 || !args.has('C')) fail('unsupported M620.15 cooling temperature parameters');
404
- heat('nozzle',args.get('C')!,pending ?? active);
522
+ // H2C also supplies P{filament_pre_cooling_temperature_nc[next_filament_id]}
523
+ // before switching hotends. P and C both constrain the incoming material.
524
+ const target=model==='h2c' && args.has('P')?'P':'C';
525
+ if(args.size!==1 || !args.has(target)) fail('unsupported M620.15 cooling temperature parameters');
526
+ heat('nozzle',args.get(target)!,pending ?? active);
405
527
  } else if(code==='M620.17') {
406
528
  const args=parameters(argumentsText);
407
529
  if(!args.has('S') || !args.has('L') || !args.has('T') || !Number.isInteger(args.get('T')) || args.get('T')!<0 || args.get('T')!>=nozzleDiameters.length) fail('unsupported M620.17 nozzle/material temperature mapping');
@@ -436,5 +558,5 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
436
558
  }
437
559
  if(!commandCount) return fail('selected G-code contains no printable commands');
438
560
  if(!used.size) materials.forEach((_,index)=>used.add(index));
439
- return {model,nozzleDiameters,materials,usedFilamentPositions:[...used].sort((a,b)=>a-b),plateInternalPath,sha256,maxNozzleTemperature,maxBedTemperature,maxChamberTemperature,bedType,selectsAms,nozzleTypes,nozzleFlows};
561
+ return {model,nozzleDiameters,materials,usedFilamentPositions:[...used].sort((a,b)=>a-b),plateInternalPath,sha256,maxNozzleTemperature,maxBedTemperature,maxChamberTemperature,bedType,selectsAms,nozzleTypes,nozzleFlows,filamentNozzleIndices,...(Array.isArray(project.filament_type)?{projectFilaments:projectFilaments(project)}:{})};
440
562
  }
@@ -1,25 +1,13 @@
1
+ import { HIDDEN_HMS } from "./hidden-hms.js";
1
2
  import type { BambuClient } from "bambu-node";
2
- import { normalizeMaterial, normalizeModel } from "./limits.js";
3
-
4
- let sequence = 0;
5
- /**
6
- * Publish one MQTT command with a string sequence id and no ACK wait.
7
- * Unpatched bambu-node 3.22.21 command classes send numeric sequence ids;
8
- * bambu-printer-mcp patches them to strings for H2-series firmware. This
9
- * server does not install that patch, so safety-relevant commands are
10
- * published here with the patched wire shape: {category:{sequence_id,command,...}}.
11
- */
12
- export function publishBambuCommand(printer: Pick<BambuClient, "publish">, category: "print" | "pushing" | "info", command: string, extra: Record<string, unknown> = {}): Promise<void> {
13
- sequence = (sequence + 1) % 1_000_000_000;
14
- return printer.publish({ [category]: { sequence_id: String(sequence), command, ...extra } });
15
- }
3
+ import { isDualNozzleModel, normalizeMaterial, normalizeModel } from "./limits.js";
16
4
 
17
5
  const MAX_REPORT_AGE_MS = 15_000;
18
6
  const MODEL_IDS: Record<string, string> = {
19
7
  O1C: "H2C", O1C2: "H2C", O1D: "H2D", O1E: "H2D Pro", O1S: "H2S", N6: "X2D",
20
8
  N2S: "A1", A1M: "A1 Mini", C11: "P1P", C12: "P1S", C13: "X1E", "BL-P001": "X1C", "BL-P002": "X1",
21
9
  };
22
- const SERIAL_MODELS: Record<string, string> = {
10
+ export const SERIAL_MODELS: Record<string, string> = {
23
11
  "093": "H2S", "094": "H2D", "31B": "H2C", "239": "H2D Pro", "20P": "X2D",
24
12
  "00M": "X1C", "00W": "X1", "03W": "X1E", "01S": "P1P", "01P": "P1S", "22E": "P2S", "039": "A1", "030": "A1 Mini",
25
13
  };
@@ -104,7 +92,8 @@ export function readFreshPrinterStatus(printer: BambuClient, serial: string, tim
104
92
  printer.on("rawMessage", onRaw);
105
93
  printer.on("client:disconnect", onDisconnect);
106
94
  printer.on("client:error", onError);
107
- // Publish only; an ACK wait would add a dependency that H2 firmware lacks.
95
+ // invoke() publishes only; executeCommand() would add an ACK dependency that H2 firmware lacks.
96
+ // String sequence ids without bambu-printer-mcp's bambu-node patch (H2-series firmware needs them).
108
97
  void Promise.all([publishBambuCommand(printer, "pushing", "pushall"), publishBambuCommand(printer, "info", "get_version")])
109
98
  .then(() => { requestsPublished = true; maybeResolve(); }, fail);
110
99
  });
@@ -115,6 +104,8 @@ export interface PrinterStateRequirements {
115
104
  nozzleDiameters: number[];
116
105
  materials?: string[];
117
106
  usedFilamentPositions?: number[];
107
+ /** Verified physical nozzle index for each project filament, composed from the inspected plate mappings. */
108
+ filamentNozzleIndices?: number[];
118
109
  amsMapping?: number[];
119
110
  useAMS?: boolean;
120
111
  requireIdle?: boolean;
@@ -131,11 +122,34 @@ interface ReportedNozzle { index: number; diameter: number; type?: string; flow?
131
122
  interface ReportedFilament { position: number; material: string; trayIndex: number; nozzleMin?: number; nozzleMax?: number }
132
123
  export interface ValidatedPrinterState { model: string; reportedNozzles: ReportedNozzle[]; filaments: ReportedFilament[] }
133
124
 
134
- function finiteNumber(value: unknown): number | undefined {
125
+ export function finiteNumber(value: unknown): number | undefined {
135
126
  if (typeof value !== "number" && (typeof value !== "string" || !value.trim())) return undefined;
136
127
  const number = Number(value);
137
128
  return Number.isFinite(number) ? number : undefined;
138
129
  }
130
+
131
+ /**
132
+ * X2D retains FAILED after a canceled job even with no active print error.
133
+ * Bambu Studio classifies FAILED as a finished job; its v02.08.02.61
134
+ * hms_en_20P table (926a7192574bcb9b3a732e1ec59a46d79cb45466)
135
+ * calls 0300400C "The task was canceled."
136
+ * Only this observed terminal condition qualifies, never an arbitrary failure.
137
+ * Callers still validate HMS, identity, hardware and material configuration,
138
+ * and print dispatch rechecks the identity of the observed job.
139
+ */
140
+ export function isCancelledX2DJob(raw: Record<string, any>, model: string): boolean {
141
+ return normalizeModel(model) === "x2d" && raw.gcode_state === "FAILED" &&
142
+ finiteNumber(raw.print_error) === 0 && finiteNumber(raw.fail_reason) === 0x0300400c &&
143
+ [raw.gcode_file, raw.subtask_name].some(value => typeof value === "string" && value.trim().length > 0);
144
+ }
145
+ /** Bambu Studio 2.8.2.61 marks FAILED as finished. Limit P1S recovery to
146
+ * a named prior job explicitly reporting idle, no print error and no HMS.
147
+ * Dispatch rechecks the observed job identity. */
148
+ export function isFinishedP1SJob(raw: Record<string, any>, model: string): boolean {
149
+ return normalizeModel(model) === "p1s" && raw.gcode_state === "FAILED" && raw.print_type === "idle" &&
150
+ finiteNumber(raw.print_error) === 0 && Array.isArray(raw.hms) && raw.hms.length === 0 &&
151
+ [raw.gcode_file, raw.subtask_name].some(value => typeof value === "string" && value.trim().length > 0);
152
+ }
139
153
  const token = (value: unknown) => typeof value === "string" ? value.trim().toLowerCase().replace(/[\s_-]/g, "") : "";
140
154
  function nozzleTypeAndFlow(value: unknown): {type?: string; flow?: string} {
141
155
  const text = String(value ?? "");
@@ -170,7 +184,7 @@ export function unchippedNozzles(raw: unknown): number[] {
170
184
  const index = finiteNumber(entry?.id);
171
185
  const stat = finiteNumber(entry?.stat);
172
186
  return index !== undefined && Number.isInteger(index) && index >= 0 && index < 16 &&
173
- stat !== undefined && Number.isInteger(stat) && stat >= 0 && unreliableWithoutChip(entry, stat) ? [index] : [];
187
+ stat !== undefined && Number.isInteger(stat) && unreliableWithoutChip(entry, stat) ? [index] : [];
174
188
  });
175
189
  }
176
190
 
@@ -236,24 +250,96 @@ function reportedNozzles(raw: Record<string,any>): ReportedNozzle[] {
236
250
  return output;
237
251
  }
238
252
 
239
- function validateErrors(raw: Record<string,any>, requireIdle: boolean) {
253
+ const hiddenHmsSets = new Map(Object.entries(HIDDEN_HMS).map(([prefix, codes]) => [prefix, new Set(codes)]));
254
+
255
+ export type HmsEntry = { attr: number; code: number; severity: number; hex: string };
256
+ export function hmsCode(entry: any): HmsEntry | undefined {
257
+ const code = finiteNumber(entry?.code);
258
+ const attr = finiteNumber(entry?.attr);
259
+ const valid = (value: number | undefined): value is number => value !== undefined && Number.isInteger(value) && value >= 0 && value <= 0xffffffff;
260
+ if (!valid(code) || !valid(attr)) return undefined;
261
+ // BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
262
+ return { attr, code, severity: Math.floor(code / 65536), hex: (attr.toString(16).padStart(8, "0") + code.toString(16).padStart(8, "0")).toUpperCase() };
263
+ }
264
+
265
+ const hmsLabel = (hex: string) => `HMS ${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12)}`;
266
+ const hmsKey = (entry: HmsEntry) => `hms:${entry.attr}:${entry.code}`;
267
+
268
+ /** The one key format for a reported entry, shared by the gate and clear_hms_errors. */
269
+ export function reportedHmsKey(entry: unknown): string {
270
+ const parsed = hmsCode(entry);
271
+ return parsed ? hmsKey(parsed) : `hms:${(entry as any)?.attr}:${(entry as any)?.code}`;
272
+ }
273
+
274
+ /** Bambu's display form of a cleared-code key such as "hms:83887616:131184"; other keys unchanged. */
275
+ export function describeHmsKey(key: string): string {
276
+ const match = /^hms:(\d+):(\d+)$/.exec(key);
277
+ const entry = match ? hmsCode({ attr: Number(match[1]), code: Number(match[2]) }) : undefined;
278
+ return entry ? hmsLabel(entry.hex) : key;
279
+ }
280
+
281
+ /**
282
+ * A serious or common entry that Bambu lists for this serial prefix with empty
283
+ * text: Bambu Studio, Bambu Handy and the printer screen never show it, and
284
+ * the printer keeps printing with it (an X2D raises 0500-0600-0002-0070 at
285
+ * every boot). Bambu Studio selects the table by serial prefix, so this does
286
+ * too, but only when that prefix belongs to the verified model: a sparse report
287
+ * without a serial must not let a misconfigured serial pick another model's
288
+ * table. Fatal, invalid-level and unlisted entries never qualify.
289
+ */
290
+ export function isHiddenNotice(serial: string, model: string, entry: HmsEntry): boolean {
291
+ if (entry.severity !== 2 && entry.severity !== 3) return false;
292
+ const prefix = serial.trim().toUpperCase().slice(0, 3);
293
+ const normalized = normalizeModel(model);
294
+ if (!normalized || normalizeModel(SERIAL_MODELS[prefix] ?? "") !== normalized) return false;
295
+ return hiddenHmsSets.get(prefix)?.has(entry.hex) === true;
296
+ }
297
+
298
+ function hiddenHmsEntries(raw: unknown, serial: string, model: string): HmsEntry[] {
299
+ if (!object(raw)) return [];
300
+ const identity = observedIdentity(raw, serial);
301
+ if (!identity.observedSerial || identity.model !== normalizeModel(model)) return [];
302
+ const entries = Array.isArray(raw.hms) ? raw.hms : [];
303
+ const seen = new Set<string>();
304
+ return entries.map(hmsCode).filter((entry): entry is HmsEntry =>
305
+ !!entry && isHiddenNotice(identity.observedSerial, model, entry) && !seen.has(entry.hex) && !!seen.add(entry.hex));
306
+ }
307
+
308
+ /** Labels of reported entries that pass only because Bambu hides them for this printer. */
309
+ export function hiddenHmsNotices(raw: unknown, serial: string, model: string): string[] {
310
+ return hiddenHmsEntries(raw, serial, model).map(entry => hmsLabel(entry.hex));
311
+ }
312
+
313
+ /** Cleared-code keys of reported hidden entries; clearing never removes them, so they are not re-acknowledged. */
314
+ export function hiddenHmsKeys(raw: unknown, serial: string, model: string): string[] {
315
+ return hiddenHmsEntries(raw, serial, model).map(hmsKey);
316
+ }
317
+
318
+ const loggedHiddenNotices = new Set<string>();
319
+ /** Name each hidden entry a check passed once per printer on stderr, so headless runs still record it. */
320
+ function logHiddenNotices(raw: Record<string, any>, serial: string, model: string): void {
321
+ for (const entry of hiddenHmsEntries(raw, serial, model)) {
322
+ const key = `${serial}:${entry.hex}`;
323
+ if (loggedHiddenNotices.has(key)) continue;
324
+ loggedHiddenNotices.add(key);
325
+ console.error(`Printer ${serial} reports ${hmsLabel(entry.hex)}, which Bambu lists for it without text and never shows as a fault; it does not block operations.`);
326
+ }
327
+ }
328
+
329
+ function validateErrors(raw: Record<string,any>, requireIdle: boolean, serial: string, model: string) {
240
330
  const state = typeof raw.gcode_state === "string" ? raw.gcode_state.toUpperCase() : "UNKNOWN";
241
- if (requireIdle && !["IDLE","FINISH"].includes(state)) throw new Error(`Printer state ${state} is not safely idle.`);
242
- if (!requireIdle && !["IDLE","FINISH","RUNNING","PAUSE"].includes(state)) throw new Error(`Printer state ${state} is unknown or failed.`);
331
+ const terminal = isCancelledX2DJob(raw, model) || isFinishedP1SJob(raw, model);
332
+ if (requireIdle && !["IDLE","FINISH"].includes(state) && !terminal) throw new Error(`Printer state ${state} is not safely idle.`);
333
+ if (!requireIdle && !["IDLE","FINISH","RUNNING","PAUSE"].includes(state) && !terminal) throw new Error(`Printer state ${state} is unknown or failed.`);
243
334
  if (finiteNumber(raw.print_error) !== 0) throw new Error("Printer reports a print error or did not report its error state.");
244
335
  if (!Array.isArray(raw.hms)) throw new Error("Printer HMS error state is missing or malformed.");
245
- for (const entry of raw.hms) {
246
- const code = finiteNumber(entry?.code);
247
- const attr = finiteNumber(entry?.attr);
248
- // BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
336
+ for (const reported of raw.hms) {
337
+ const entry = hmsCode(reported);
249
338
  // Unknown severities fail closed; attr identifies the module and is not a severity bitmask.
250
- if (code === undefined || attr === undefined || !Number.isInteger(code) || !Number.isInteger(attr) || code < 0 || code > 0xffffffff || attr < 0 || attr > 0xffffffff || Math.floor(code / 65536) !== 4) {
251
- const hex = (value: number) => value.toString(16).toUpperCase().padStart(8, "0");
252
- const label = code !== undefined && attr !== undefined && Number.isInteger(code) && Number.isInteger(attr) && code >= 0 && attr >= 0
253
- ? ` (HMS ${hex(attr).slice(0, 4)}-${hex(attr).slice(4)}-${hex(code).slice(0, 4)}-${hex(code).slice(4)})` : "";
254
- throw new Error(`Printer reports actionable or unknown HMS errors${label}; inspect and resolve them on the printer before proceeding.`);
255
- }
339
+ if (entry && (entry.severity === 4 || isHiddenNotice(serial, model, entry))) continue;
340
+ throw new Error(`Printer reports actionable or unknown HMS errors${entry ? ` (${hmsLabel(entry.hex)})` : ""}; inspect and resolve them before proceeding.`);
256
341
  }
342
+ logHiddenNotices(raw, serial, model);
257
343
  }
258
344
  function trayRange(value: unknown, label: string): number | undefined {
259
345
  if (value === undefined || value === null || value === "" || value === "0" || value === 0) return undefined;
@@ -261,13 +347,20 @@ function trayRange(value: unknown, label: string): number | undefined {
261
347
  if (number === undefined || number <= 0) throw new Error(`Reported filament ${label} temperature is malformed.`);
262
348
  return number;
263
349
  }
264
- function externalTray(raw: Record<string,any>): Record<string,any> | undefined {
350
+ /** Dual-nozzle printers report one virtual external slot per nozzle, numbered as Bambu
351
+ * Studio does (VIRTUAL_TRAY_MAIN_ID 255 = right/main nozzle, VIRTUAL_TRAY_DEPUTY_ID 254 =
352
+ * left/deputy nozzle), so the requested tray selects its slot. Single-nozzle printers keep
353
+ * requiring exactly one slot 254 report, then the legacy vt_tray field. */
354
+ function externalTray(raw: Record<string,any>, trayIndex: number, model: string): Record<string,any> | undefined {
265
355
  const slots = raw.vir_slot;
356
+ const dualNozzle = isDualNozzleModel(model);
266
357
  if (Array.isArray(slots)) {
267
- const selected = slots.filter((slot:any) => Number(slot?.id) === 254);
268
- if (slots.length === 1 && selected.length === 1) return selected[0];
358
+ const selected = slots.filter((slot:any) => Number(slot?.id) === trayIndex);
359
+ if (selected.length === 1 && (dualNozzle || slots.length === 1)) return selected[0];
360
+ if (dualNozzle && selected.length === 0 && slots.length > 0) throw new Error(`External spool ${trayIndex} is not in the fresh per-nozzle report; cannot verify the selected material.`);
269
361
  if (slots.length > 0) throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
270
362
  }
363
+ if (dualNozzle) throw new Error(`Dual-nozzle external spool ${trayIndex} requires a fresh per-nozzle vir_slot report; legacy vt_tray cannot verify the selected nozzle.`);
271
364
  return object(raw.vt_tray) ? raw.vt_tray : undefined;
272
365
  }
273
366
  function mappedTray(raw: Record<string,any>, index: number): Record<string,any> {
@@ -301,7 +394,7 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
301
394
  const identity = observedIdentity(raw, String(status.serial ?? ""));
302
395
  const model = normalizeModel(requirements.model);
303
396
  if (!model || !identity.model || model !== identity.model) throw new Error(`Printer model identity mismatch: requested ${requirements.model}, observed ${identity.model ?? "unknown"}.`);
304
- validateErrors(raw, requirements.requireIdle !== false);
397
+ validateErrors(raw, requirements.requireIdle !== false, identity.observedSerial ?? "", model);
305
398
  if (requirements.requireLoadedFilament && (raw.ams?.tray_now !== undefined || Array.isArray(raw.ams?.ams))) {
306
399
  const current = finiteNumber(raw.ams?.tray_now);
307
400
  if (current === 255) throw new Error("The nozzle is explicitly reported unloaded. Load the declared filament before starting or resuming this G-code job.");
@@ -313,7 +406,7 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
313
406
  }
314
407
  }
315
408
  const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
316
- if (requirements.nozzleDiameters.length && ["h2d","h2dpro","h2c","x2d"].includes(model) &&
409
+ if (requirements.nozzleDiameters.length && isDualNozzleModel(model) &&
317
410
  !Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
318
411
  throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
319
412
  }
@@ -341,13 +434,27 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
341
434
  if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
342
435
  const materials = requirements.materials ?? [];
343
436
  const positions = requirements.usedFilamentPositions ?? materials.map((_, index) => index);
437
+ // 255 is the right/main nozzle external spool only on dual-nozzle printers.
438
+ const dualNozzle = isDualNozzleModel(model);
439
+ const maxTray = dualNozzle ? 255 : 254;
344
440
  if (!positions.length) throw new Error("A declared material is required for each used filament or external spool.");
345
441
  for (const position of positions) {
346
442
  const material = normalizeMaterial(materials[position]);
347
443
  if (!Number.isInteger(position) || position < 0 || !material) throw new Error(`A known declared material is required for filament position ${position}.`);
348
- const trayIndex = requirements.useAMS ? requirements.amsMapping?.[position] : 254;
349
- if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > 254) throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
350
- const tray = trayIndex === 254 ? externalTray(raw) : mappedTray(raw,trayIndex);
444
+ const physicalNozzle = requirements.filamentNozzleIndices?.[position];
445
+ const externalForNozzle = physicalNozzle === 0 ? 255 : physicalNozzle === 1 ? 254 : undefined;
446
+ const trayIndex = requirements.useAMS || requirements.amsMapping !== undefined
447
+ ? requirements.amsMapping?.[position] : dualNozzle ? externalForNozzle : 254;
448
+ if (!requirements.useAMS && dualNozzle && requirements.amsMapping === undefined && externalForNozzle === undefined) {
449
+ throw new Error(`External spool for filament position ${position} is ambiguous; re-slice with complete filament_map and physical_extruder_map nozzle bindings or provide a verified sliced plate.`);
450
+ }
451
+ if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > maxTray) throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
452
+ if (!requirements.useAMS && trayIndex < 254) throw new Error("External-spool printing requires an external spool mapping; AMS trays require use_ams:true.");
453
+ if (dualNozzle && trayIndex >= 254) {
454
+ if (externalForNozzle === undefined) throw new Error(`External spool ${trayIndex} needs a verified physical nozzle binding for filament position ${position}; re-slice with complete filament_map and physical_extruder_map metadata.`);
455
+ if (trayIndex !== externalForNozzle) throw new Error(`External spool ${trayIndex} does not feed physical nozzle ${physicalNozzle} for filament position ${position}; use spool ${externalForNozzle}.`);
456
+ }
457
+ const tray = trayIndex >= 254 ? externalTray(raw,trayIndex,model) : mappedTray(raw,trayIndex);
351
458
  const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
352
459
  if (reportedType && normalizeMaterial(reportedType) !== material) throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
353
460
  const nozzleMin = trayRange(tray?.nozzle_temp_min,"minimum");
@@ -366,7 +473,7 @@ export function manualHeatingRequirements(status: any, model: string, nozzleDiam
366
473
  if (!normalized) throw new Error("A known printer model is required for manual nozzle heating.");
367
474
  const raw = status?.raw;
368
475
  if (!object(raw)) throw new Error("A fresh printer report is required for manual nozzle heating.");
369
- if (["h2d","h2dpro","h2c","x2d"].includes(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
476
+ if (isDualNozzleModel(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
370
477
  throw new Error("Manual heating cannot verify the active nozzle and loaded material on this multi-nozzle printer. Use a checked sliced print instead.");
371
478
  }
372
479
  const requirements: PrinterStateRequirements = {
@@ -383,3 +490,35 @@ export function manualHeatingRequirements(status: any, model: string, nozzleDiam
383
490
  if (tray < 254) { requirements.useAMS = true; requirements.amsMapping = [tray]; }
384
491
  return requirements;
385
492
  }
493
+
494
+ export type ExternalStorageState = "none" | "normal" | "abnormal" | "readonly";
495
+ const EXTERNAL_STORAGE_STATES: readonly ExternalStorageState[] = ["none", "normal", "abnormal", "readonly"];
496
+
497
+ /**
498
+ * The SD card or USB stick state, read as Bambu Studio does: the `sdcard`
499
+ * flag, then `home_flag` bits 8-9, then on firmware that reports cfg/fun/aux/stat
500
+ * the `aux` bits 12-13, each later source overriding the earlier one
501
+ * (DevStorage.cpp, DeviceManager.cpp parse_home_flag and parse_new_info).
502
+ */
503
+ export function externalStorageState(raw: unknown): ExternalStorageState {
504
+ if (!object(raw)) return "none";
505
+ let state: ExternalStorageState = raw.sdcard === true ? "normal" : "none";
506
+ if (Number.isInteger(raw.home_flag)) state = EXTERNAL_STORAGE_STATES[(raw.home_flag >> 8) & 3];
507
+ if (["cfg", "fun", "aux", "stat"].every(key => key in raw) && typeof raw.aux === "string" && /^[0-9a-f]+$/i.test(raw.aux)) {
508
+ state = EXTERNAL_STORAGE_STATES[Number((BigInt(`0x${raw.aux}`) >> 12n) & 3n)];
509
+ }
510
+ return state;
511
+ }
512
+
513
+ let sequence = 0;
514
+ /**
515
+ * Publish one MQTT command with a string sequence id and no ACK wait.
516
+ * Unpatched bambu-node 3.22.21 command classes send numeric sequence ids;
517
+ * bambu-printer-mcp patches them to strings for H2-series firmware. This
518
+ * server does not install that patch, so safety-relevant commands are
519
+ * published here with the patched wire shape: {category:{sequence_id,command,...}}.
520
+ */
521
+ export function publishBambuCommand(printer: Pick<BambuClient, "publish">, category: "print" | "pushing" | "info", command: string, extra: Record<string, unknown> = {}): Promise<void> {
522
+ sequence = (sequence + 1) % 1_000_000_000;
523
+ return printer.publish({ [category]: { sequence_id: String(sequence), command, ...extra } });
524
+ }