mcp-3d-printer-server 1.2.25 → 1.2.27
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 +67 -0
- package/CONTRIBUTORS.md +5 -1
- package/README.md +64 -29
- package/dist/ams-mapping.d.ts +38 -0
- package/dist/ams-mapping.js +125 -0
- package/dist/bambu-network-bridge.d.ts +211 -0
- package/dist/bambu-network-bridge.js +918 -0
- package/dist/bambu-network-certs.d.ts +13 -0
- package/dist/bambu-network-certs.js +152 -0
- package/dist/control-reply.d.ts +21 -0
- package/dist/control-reply.js +44 -0
- package/dist/counterpart/camera-sources.d.ts +16 -0
- package/dist/counterpart/camera-sources.js +138 -0
- package/dist/counterpart/camera-stream.d.ts +66 -0
- package/dist/counterpart/camera-stream.js +246 -0
- package/dist/counterpart/camera-tools.d.ts +247 -0
- package/dist/counterpart/camera-tools.js +35 -0
- package/dist/counterpart/contract.d.ts +21 -5
- package/dist/counterpart/contract.js +8 -2
- package/dist/counterpart/route.d.ts +31 -0
- package/dist/counterpart/route.js +70 -0
- package/dist/counterpart/service.d.ts +60 -4
- package/dist/counterpart/service.js +142 -16
- package/dist/design-guidance.d.ts +26 -0
- package/dist/design-guidance.js +177 -0
- package/dist/fulu-orca.d.ts +7 -0
- package/dist/fulu-orca.js +18 -2
- package/dist/index.js +514 -70
- package/dist/printers/bambu-filaments.d.ts +68 -0
- package/dist/printers/bambu-filaments.js +262 -0
- package/dist/printers/bambu.d.ts +99 -6
- package/dist/printers/bambu.js +478 -72
- package/dist/printers/creality.d.ts +2 -1
- package/dist/printers/creality.js +5 -1
- package/dist/printers/generic-base.d.ts +11 -6
- package/dist/printers/generic-base.js +21 -25
- package/dist/printers/klipper.d.ts +2 -1
- package/dist/printers/klipper.js +10 -7
- package/dist/printers/octoprint.d.ts +2 -1
- package/dist/printers/octoprint.js +15 -8
- package/dist/printers/printer-factory.d.ts +1 -1
- package/dist/printers/printer-factory.js +3 -3
- package/dist/printers/prusa.js +0 -1
- package/dist/safety/confirmation.d.ts +11 -16
- package/dist/safety/confirmation.js +8 -32
- package/dist/safety/hidden-hms.d.ts +32 -0
- package/dist/safety/hidden-hms.js +405 -0
- package/dist/safety/limits.d.ts +8 -2
- package/dist/safety/limits.js +14 -5
- package/dist/safety/print-file.d.ts +5 -0
- package/dist/safety/print-file.js +156 -19
- package/dist/safety/printer-state.d.ts +68 -8
- package/dist/safety/printer-state.js +187 -40
- package/dist/slicer/bambu-cli.d.ts +2 -0
- package/dist/slicer/bambu-cli.js +7 -5
- package/dist/slicer/layer-pauses.d.ts +47 -0
- package/dist/slicer/layer-pauses.js +130 -0
- package/dist/stl/stl-manipulator.d.ts +1 -0
- package/dist/stl/stl-manipulator.js +40 -0
- package/dist/studio-print-options.d.ts +57 -0
- package/dist/studio-print-options.js +179 -0
- package/dist/tool-annotations.d.ts +25 -0
- package/dist/tool-annotations.js +58 -0
- package/package.json +27 -4
- package/server.json +2 -2
- package/src/ams-mapping.ts +132 -0
- package/src/bambu-network-bridge.ts +1106 -0
- package/src/bambu-network-certs.ts +154 -0
- package/src/control-reply.ts +41 -0
- package/src/counterpart/camera-sources.ts +102 -0
- package/src/counterpart/camera-stream.ts +195 -0
- package/src/counterpart/camera-tools.ts +35 -0
- package/src/counterpart/contract.ts +12 -4
- package/src/counterpart/route.ts +82 -0
- package/src/counterpart/service.ts +150 -16
- package/src/design-guidance.ts +188 -0
- package/src/fulu-orca.ts +18 -2
- package/src/index.ts +528 -83
- package/src/printers/bambu-filaments.ts +346 -0
- package/src/printers/bambu.ts +506 -81
- package/src/printers/creality.ts +8 -3
- package/src/printers/generic-base.ts +33 -40
- package/src/printers/klipper.ts +12 -8
- package/src/printers/octoprint.ts +16 -9
- package/src/printers/printer-factory.ts +3 -3
- package/src/printers/prusa.ts +0 -1
- package/src/safety/confirmation.ts +15 -38
- package/src/safety/hidden-hms.ts +406 -0
- package/src/safety/limits.ts +15 -5
- package/src/safety/print-file.ts +139 -17
- package/src/safety/printer-state.ts +192 -41
- package/src/slicer/bambu-cli.ts +8 -5
- package/src/slicer/layer-pauses.ts +143 -0
- package/src/stl/stl-manipulator.ts +45 -0
- package/src/studio-print-options.ts +201 -0
package/src/safety/print-file.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { readBoundedPrintFile, loadSafe3mfArchive } from './archive.js';
|
|
3
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
282
|
-
//
|
|
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=
|
|
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
|
-
|
|
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
|
|
372
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
//
|
|
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) &&
|
|
187
|
+
stat !== undefined && Number.isInteger(stat) && unreliableWithoutChip(entry, stat) ? [index] : [];
|
|
174
188
|
});
|
|
175
189
|
}
|
|
176
190
|
|
|
@@ -236,24 +250,108 @@ function reportedNozzles(raw: Record<string,any>): ReportedNozzle[] {
|
|
|
236
250
|
return output;
|
|
237
251
|
}
|
|
238
252
|
|
|
239
|
-
|
|
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
|
+
/**
|
|
330
|
+
* 0300_8013, "Printing was paused by the user" / "paused due to the pause command added to the
|
|
331
|
+
* printing file": the printer's report of a deliberate pause (a Pause, or a pause_at_layers stop),
|
|
332
|
+
* which Resume clears. A paused P1S reports it as print_error, so it is not an error for resuming
|
|
333
|
+
* a paused job. requireIdle is false only for Resume here; starting a job still needs no error.
|
|
334
|
+
* Ported from bambu-printer-mcp src/safety/printer-state.ts.
|
|
335
|
+
*/
|
|
336
|
+
export const PAUSE_NOTICE = 0x03008013;
|
|
337
|
+
export function isPauseNotice(raw: Record<string, any>, requireIdle: boolean): boolean {
|
|
338
|
+
const state = typeof raw?.gcode_state === "string" ? raw.gcode_state.toUpperCase() : "";
|
|
339
|
+
return !requireIdle && state === "PAUSE" && finiteNumber(raw?.print_error) === PAUSE_NOTICE;
|
|
340
|
+
}
|
|
341
|
+
function validateErrors(raw: Record<string,any>, requireIdle: boolean, serial: string, model: string) {
|
|
240
342
|
const state = typeof raw.gcode_state === "string" ? raw.gcode_state.toUpperCase() : "UNKNOWN";
|
|
241
|
-
|
|
242
|
-
if (
|
|
243
|
-
if (
|
|
343
|
+
const terminal = isCancelledX2DJob(raw, model) || isFinishedP1SJob(raw, model);
|
|
344
|
+
if (requireIdle && !["IDLE","FINISH"].includes(state) && !terminal) throw new Error(`Printer state ${state} is not safely idle.`);
|
|
345
|
+
if (!requireIdle && !["IDLE","FINISH","RUNNING","PAUSE"].includes(state) && !terminal) throw new Error(`Printer state ${state} is unknown or failed.`);
|
|
346
|
+
if (finiteNumber(raw.print_error) !== 0 && !isPauseNotice(raw, requireIdle)) throw new Error("Printer reports a print error or did not report its error state.");
|
|
244
347
|
if (!Array.isArray(raw.hms)) throw new Error("Printer HMS error state is missing or malformed.");
|
|
245
|
-
for (const
|
|
246
|
-
const
|
|
247
|
-
const attr = finiteNumber(entry?.attr);
|
|
248
|
-
// BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
|
|
348
|
+
for (const reported of raw.hms) {
|
|
349
|
+
const entry = hmsCode(reported);
|
|
249
350
|
// Unknown severities fail closed; attr identifies the module and is not a severity bitmask.
|
|
250
|
-
if (
|
|
251
|
-
|
|
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
|
-
}
|
|
351
|
+
if (entry && (entry.severity === 4 || isHiddenNotice(serial, model, entry))) continue;
|
|
352
|
+
throw new Error(`Printer reports actionable or unknown HMS errors${entry ? ` (${hmsLabel(entry.hex)})` : ""}; inspect and resolve them before proceeding.`);
|
|
256
353
|
}
|
|
354
|
+
logHiddenNotices(raw, serial, model);
|
|
257
355
|
}
|
|
258
356
|
function trayRange(value: unknown, label: string): number | undefined {
|
|
259
357
|
if (value === undefined || value === null || value === "" || value === "0" || value === 0) return undefined;
|
|
@@ -261,13 +359,20 @@ function trayRange(value: unknown, label: string): number | undefined {
|
|
|
261
359
|
if (number === undefined || number <= 0) throw new Error(`Reported filament ${label} temperature is malformed.`);
|
|
262
360
|
return number;
|
|
263
361
|
}
|
|
264
|
-
|
|
362
|
+
/** Dual-nozzle printers report one virtual external slot per nozzle, numbered as Bambu
|
|
363
|
+
* Studio does (VIRTUAL_TRAY_MAIN_ID 255 = right/main nozzle, VIRTUAL_TRAY_DEPUTY_ID 254 =
|
|
364
|
+
* left/deputy nozzle), so the requested tray selects its slot. Single-nozzle printers keep
|
|
365
|
+
* requiring exactly one slot 254 report, then the legacy vt_tray field. */
|
|
366
|
+
function externalTray(raw: Record<string,any>, trayIndex: number, model: string): Record<string,any> | undefined {
|
|
265
367
|
const slots = raw.vir_slot;
|
|
368
|
+
const dualNozzle = isDualNozzleModel(model);
|
|
266
369
|
if (Array.isArray(slots)) {
|
|
267
|
-
const selected = slots.filter((slot:any) => Number(slot?.id) ===
|
|
268
|
-
if (
|
|
370
|
+
const selected = slots.filter((slot:any) => Number(slot?.id) === trayIndex);
|
|
371
|
+
if (selected.length === 1 && (dualNozzle || slots.length === 1)) return selected[0];
|
|
372
|
+
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
373
|
if (slots.length > 0) throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
|
|
270
374
|
}
|
|
375
|
+
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
376
|
return object(raw.vt_tray) ? raw.vt_tray : undefined;
|
|
272
377
|
}
|
|
273
378
|
function mappedTray(raw: Record<string,any>, index: number): Record<string,any> {
|
|
@@ -301,7 +406,7 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
|
|
|
301
406
|
const identity = observedIdentity(raw, String(status.serial ?? ""));
|
|
302
407
|
const model = normalizeModel(requirements.model);
|
|
303
408
|
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);
|
|
409
|
+
validateErrors(raw, requirements.requireIdle !== false, identity.observedSerial ?? "", model);
|
|
305
410
|
if (requirements.requireLoadedFilament && (raw.ams?.tray_now !== undefined || Array.isArray(raw.ams?.ams))) {
|
|
306
411
|
const current = finiteNumber(raw.ams?.tray_now);
|
|
307
412
|
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 +418,7 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
|
|
|
313
418
|
}
|
|
314
419
|
}
|
|
315
420
|
const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
|
|
316
|
-
if (requirements.nozzleDiameters.length &&
|
|
421
|
+
if (requirements.nozzleDiameters.length && isDualNozzleModel(model) &&
|
|
317
422
|
!Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
|
|
318
423
|
throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
|
|
319
424
|
}
|
|
@@ -341,13 +446,27 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
|
|
|
341
446
|
if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
|
|
342
447
|
const materials = requirements.materials ?? [];
|
|
343
448
|
const positions = requirements.usedFilamentPositions ?? materials.map((_, index) => index);
|
|
449
|
+
// 255 is the right/main nozzle external spool only on dual-nozzle printers.
|
|
450
|
+
const dualNozzle = isDualNozzleModel(model);
|
|
451
|
+
const maxTray = dualNozzle ? 255 : 254;
|
|
344
452
|
if (!positions.length) throw new Error("A declared material is required for each used filament or external spool.");
|
|
345
453
|
for (const position of positions) {
|
|
346
454
|
const material = normalizeMaterial(materials[position]);
|
|
347
455
|
if (!Number.isInteger(position) || position < 0 || !material) throw new Error(`A known declared material is required for filament position ${position}.`);
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
const
|
|
456
|
+
const physicalNozzle = requirements.filamentNozzleIndices?.[position];
|
|
457
|
+
const externalForNozzle = physicalNozzle === 0 ? 255 : physicalNozzle === 1 ? 254 : undefined;
|
|
458
|
+
const trayIndex = requirements.useAMS || requirements.amsMapping !== undefined
|
|
459
|
+
? requirements.amsMapping?.[position] : dualNozzle ? externalForNozzle : 254;
|
|
460
|
+
if (!requirements.useAMS && dualNozzle && requirements.amsMapping === undefined && externalForNozzle === undefined) {
|
|
461
|
+
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.`);
|
|
462
|
+
}
|
|
463
|
+
if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > maxTray) throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
|
|
464
|
+
if (!requirements.useAMS && trayIndex < 254) throw new Error("External-spool printing requires an external spool mapping; AMS trays require use_ams:true.");
|
|
465
|
+
if (dualNozzle && trayIndex >= 254) {
|
|
466
|
+
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.`);
|
|
467
|
+
if (trayIndex !== externalForNozzle) throw new Error(`External spool ${trayIndex} does not feed physical nozzle ${physicalNozzle} for filament position ${position}; use spool ${externalForNozzle}.`);
|
|
468
|
+
}
|
|
469
|
+
const tray = trayIndex >= 254 ? externalTray(raw,trayIndex,model) : mappedTray(raw,trayIndex);
|
|
351
470
|
const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
|
|
352
471
|
if (reportedType && normalizeMaterial(reportedType) !== material) throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
|
|
353
472
|
const nozzleMin = trayRange(tray?.nozzle_temp_min,"minimum");
|
|
@@ -366,7 +485,7 @@ export function manualHeatingRequirements(status: any, model: string, nozzleDiam
|
|
|
366
485
|
if (!normalized) throw new Error("A known printer model is required for manual nozzle heating.");
|
|
367
486
|
const raw = status?.raw;
|
|
368
487
|
if (!object(raw)) throw new Error("A fresh printer report is required for manual nozzle heating.");
|
|
369
|
-
if (
|
|
488
|
+
if (isDualNozzleModel(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
|
|
370
489
|
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
490
|
}
|
|
372
491
|
const requirements: PrinterStateRequirements = {
|
|
@@ -383,3 +502,35 @@ export function manualHeatingRequirements(status: any, model: string, nozzleDiam
|
|
|
383
502
|
if (tray < 254) { requirements.useAMS = true; requirements.amsMapping = [tray]; }
|
|
384
503
|
return requirements;
|
|
385
504
|
}
|
|
505
|
+
|
|
506
|
+
export type ExternalStorageState = "none" | "normal" | "abnormal" | "readonly";
|
|
507
|
+
const EXTERNAL_STORAGE_STATES: readonly ExternalStorageState[] = ["none", "normal", "abnormal", "readonly"];
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The SD card or USB stick state, read as Bambu Studio does: the `sdcard`
|
|
511
|
+
* flag, then `home_flag` bits 8-9, then on firmware that reports cfg/fun/aux/stat
|
|
512
|
+
* the `aux` bits 12-13, each later source overriding the earlier one
|
|
513
|
+
* (DevStorage.cpp, DeviceManager.cpp parse_home_flag and parse_new_info).
|
|
514
|
+
*/
|
|
515
|
+
export function externalStorageState(raw: unknown): ExternalStorageState {
|
|
516
|
+
if (!object(raw)) return "none";
|
|
517
|
+
let state: ExternalStorageState = raw.sdcard === true ? "normal" : "none";
|
|
518
|
+
if (Number.isInteger(raw.home_flag)) state = EXTERNAL_STORAGE_STATES[(raw.home_flag >> 8) & 3];
|
|
519
|
+
if (["cfg", "fun", "aux", "stat"].every(key => key in raw) && typeof raw.aux === "string" && /^[0-9a-f]+$/i.test(raw.aux)) {
|
|
520
|
+
state = EXTERNAL_STORAGE_STATES[Number((BigInt(`0x${raw.aux}`) >> 12n) & 3n)];
|
|
521
|
+
}
|
|
522
|
+
return state;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
let sequence = 0;
|
|
526
|
+
/**
|
|
527
|
+
* Publish one MQTT command with a string sequence id and no ACK wait.
|
|
528
|
+
* Unpatched bambu-node 3.22.21 command classes send numeric sequence ids;
|
|
529
|
+
* bambu-printer-mcp patches them to strings for H2-series firmware. This
|
|
530
|
+
* server does not install that patch, so safety-relevant commands are
|
|
531
|
+
* published here with the patched wire shape: {category:{sequence_id,command,...}}.
|
|
532
|
+
*/
|
|
533
|
+
export function publishBambuCommand(printer: Pick<BambuClient, "publish">, category: "print" | "pushing" | "info", command: string, extra: Record<string, unknown> = {}): Promise<void> {
|
|
534
|
+
sequence = (sequence + 1) % 1_000_000_000;
|
|
535
|
+
return printer.publish({ [category]: { sequence_id: String(sequence), command, ...extra } });
|
|
536
|
+
}
|