bambu-printer-mcp 1.1.27 → 1.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTORS.md +7 -1
- package/README.md +29 -10
- package/dist/ams-mapping.d.ts +18 -2
- package/dist/ams-mapping.js +36 -5
- package/dist/bambu-native.d.ts +55 -0
- package/dist/bambu-native.js +212 -13
- package/dist/index.js +154 -71
- package/dist/printers/bambu.d.ts +29 -2
- package/dist/printers/bambu.js +59 -28
- package/dist/safety/hidden-hms.d.ts +32 -0
- package/dist/safety/hidden-hms.js +405 -0
- package/dist/safety/limits.d.ts +6 -0
- package/dist/safety/limits.js +9 -0
- package/dist/safety/print-file.d.ts +2 -0
- package/dist/safety/print-file.js +83 -7
- package/dist/safety/printer-state.d.ts +11 -0
- package/dist/safety/printer-state.js +115 -22
- package/dist/slicer/profile-flatten.js +25 -0
- package/dist/slicer/remote-slicer.d.ts +12 -0
- package/dist/slicer/remote-slicer.js +240 -0
- package/dist/stl/stl-manipulator.d.ts +2 -1
- package/dist/stl/stl-manipulator.js +71 -42
- package/native/bambu-native-print.cpp +100 -26
- package/package.json +7 -5
- package/scripts/build-bambu-native.sh +53 -0
- package/scripts/setup-bambu-native.mjs +372 -0
- package/src/ams-mapping.ts +38 -5
- package/src/bambu-native.ts +229 -14
- package/src/index.ts +156 -67
- package/src/printers/bambu.ts +65 -25
- package/src/safety/hidden-hms.ts +406 -0
- package/src/safety/limits.ts +9 -0
- package/src/safety/print-file.ts +71 -7
- package/src/safety/printer-state.ts +117 -22
- package/src/slicer/profile-flatten.ts +32 -0
- package/src/slicer/remote-slicer.ts +216 -0
- package/src/stl/stl-manipulator.ts +70 -43
- package/scripts/build-bambu-native.zsh +0 -33
package/src/safety/print-file.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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 { MACHINE_LIMITS, isDualNozzleModel, normalizeMaterial, normalizeModel, validateStartupPurgeTemperature, validateTemperature } from './limits.js';
|
|
4
4
|
|
|
5
5
|
export interface PrintFileInspection {
|
|
6
6
|
model: string;
|
|
@@ -16,6 +16,8 @@ export interface PrintFileInspection {
|
|
|
16
16
|
selectsAms: boolean;
|
|
17
17
|
nozzleTypes?: string[];
|
|
18
18
|
nozzleFlows?: string[];
|
|
19
|
+
/** Physical nozzle index for each declared filament, composed from the selected plate headers. */
|
|
20
|
+
filamentNozzleIndices?: number[];
|
|
19
21
|
}
|
|
20
22
|
type Metadata = Record<string, unknown>;
|
|
21
23
|
const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
|
|
@@ -57,6 +59,11 @@ function stripComments(line: string): string {
|
|
|
57
59
|
/** A deliberately narrow parameter lexer for commands affecting heat/material selection.
|
|
58
60
|
* Unknown nonthermal vendor commands do not require a global G-code allowlist.
|
|
59
61
|
*/
|
|
62
|
+
/** True when the parameter is present, finite and within [low, high]. */
|
|
63
|
+
function inRange(args: Map<string,number>, key: string, low: number, high: number): boolean {
|
|
64
|
+
const value=args.get(key);
|
|
65
|
+
return value!==undefined && Number.isFinite(value) && value>=low && value<=high;
|
|
66
|
+
}
|
|
60
67
|
function parameters(text: string, flags = ''): Map<string,number> {
|
|
61
68
|
const result=new Map<string,number>();let rest=text.trim();
|
|
62
69
|
while(rest) {
|
|
@@ -135,6 +142,7 @@ function validateBounds(headers: Metadata[], model: string, plate: Metadata): vo
|
|
|
135
142
|
export async function inspectPrintFile(filePath: string, options: {model:string; nozzleDiameters?:number[]; plateIndex?:number; bedType?:string}): Promise<PrintFileInspection> {
|
|
136
143
|
const model=normalizeModel(options.model);
|
|
137
144
|
if(!model) return fail(`unknown or missing printer model '${options.model}'`);
|
|
145
|
+
const dualNozzle=isDualNozzleModel(model);
|
|
138
146
|
const plateIndex=options.plateIndex ?? 0;
|
|
139
147
|
if(!Number.isInteger(plateIndex)||plateIndex<0) return fail('selected plate index must be a nonnegative integer');
|
|
140
148
|
if(/\.md5$/i.test(filePath)) return fail('a checksum file is not a printable artifact');
|
|
@@ -232,6 +240,22 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
232
240
|
const materials=consistent(metadataValues(metadata,['filament_type']),value=>{
|
|
233
241
|
const values=list(value);return values.length && values.every(v=>!!normalizeMaterial(v))?values.map(v=>normalizeMaterial(v)!):undefined;
|
|
234
242
|
},'filament material');
|
|
243
|
+
// GCode.cpp uses 1-based filament_map logical extruders and then indexes
|
|
244
|
+
// physical_extruder_map to select the physical nozzle. Only this selected
|
|
245
|
+
// plate's headers establish that binding; unsliced project defaults do not.
|
|
246
|
+
const headerMap=(key:string,count:number,low:number,high:number):number[]|undefined=>{
|
|
247
|
+
const values=metadataValues(headers,[key]);
|
|
248
|
+
if(!values.length) return undefined;
|
|
249
|
+
return consistent(values,value=>{
|
|
250
|
+
const entries=list(value);
|
|
251
|
+
return entries.length===count && entries.every(v=>NUMBER.test(v) && Number.isInteger(Number(v)) && Number(v)>=low && Number(v)<=high)
|
|
252
|
+
? entries.map(Number):undefined;
|
|
253
|
+
},key.replace(/_/g,' '));
|
|
254
|
+
};
|
|
255
|
+
const physicalExtruderMap=headerMap('physical_extruder_map',nozzleDiameters.length,0,dualNozzle?Math.min(nozzleDiameters.length,2)-1:nozzleDiameters.length-1);
|
|
256
|
+
if(physicalExtruderMap && new Set(physicalExtruderMap).size!==physicalExtruderMap.length) fail('physical extruder map must be a complete permutation');
|
|
257
|
+
const filamentMap=headerMap('filament_map',materials.length,1,nozzleDiameters.length);
|
|
258
|
+
const filamentNozzleIndices=filamentMap && physicalExtruderMap ? filamentMap.map(logical=>physicalExtruderMap[logical-1]):undefined;
|
|
235
259
|
const bedValues=metadataValues([...metadata,plate],['curr_bed_type','bed_type']);
|
|
236
260
|
const bedType=bedValues.length?consistent(bedValues,normalizeBed,'bed type'):undefined;
|
|
237
261
|
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,7 +297,10 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
273
297
|
try {
|
|
274
298
|
if(isLayerMarker(lines[index]) || /^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(lines[index])) closeStartupWindow();
|
|
275
299
|
let line=stripComments(lines[index]);if(!line||line==='%') continue;
|
|
276
|
-
//
|
|
300
|
+
// Accept only the exact vendor banners in their documented model/scope.
|
|
301
|
+
// X2D emits its filament-change banner throughout a print; H2C's record
|
|
302
|
+
// separator stays inside its explicitly bounded startup block.
|
|
303
|
+
if(line==='======== X2D filament_change gcode ==========' && model==='x2d') continue;
|
|
277
304
|
if(line==='========== record data ==========' && h2cStartup(index)) continue;
|
|
278
305
|
line=line.replace(/^N\d+\s*/i,'').replace(/\*\d+\s*$/,'');
|
|
279
306
|
if(/[{}\[\]#]/.test(line)) fail('unresolved dynamic command syntax');
|
|
@@ -292,6 +319,26 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
292
319
|
if(!command) fail(`unsupported G-code command syntax '${line.slice(0,100)}'`);
|
|
293
320
|
const [base,subcode]=command![2].split('.');
|
|
294
321
|
const code=command![1].toUpperCase()+String(Number(base))+(subcode===undefined?'':'.'+subcode);const argumentsText=line.slice(command![0].length).trim();commandCount++;
|
|
322
|
+
if(code==='M9711') {
|
|
323
|
+
// Official X2D and H2D time-lapse capture (Bambu Studio and OrcaSlicer
|
|
324
|
+
// templates), emitted on every layer: M is the time-lapse mode and E the
|
|
325
|
+
// physical extruder, not a second command; T is a duration, not a heater.
|
|
326
|
+
// It sets no temperature. Positions stay inside the machine volume.
|
|
327
|
+
const invalid=()=>fail('unsupported M9711 time-lapse parameters');
|
|
328
|
+
if(!['x2d','h2d'].includes(model)) invalid();
|
|
329
|
+
let args:Map<string,number>;
|
|
330
|
+
try {args=parameters(argumentsText);} catch {return invalid();}
|
|
331
|
+
const [width,depth,height]=MACHINE_LIMITS[model].volume;
|
|
332
|
+
const position=['X','Y','U','V'].filter(key=>args.has(key)).join('');
|
|
333
|
+
const limit:Record<string,number>={X:width,U:width,Y:depth,V:depth};
|
|
334
|
+
if([...args.keys()].some(key=>!'MEXYUVZSCOT'.includes(key)) ||
|
|
335
|
+
// X2D and H2D have two physical extruders, whatever the file declares.
|
|
336
|
+
![0,1].includes(args.get('M')!) || !Number.isInteger(args.get('E')) || !inRange(args,'E',0,Math.min(nozzleDiameters.length,2)-1) ||
|
|
337
|
+
args.get('S')!==11 || args.get('C')!==10 || args.get('O')!==0 || !inRange(args,'T',0,600000) ||
|
|
338
|
+
!inRange(args,'Z',0,height) ||
|
|
339
|
+
!['','XY','UV'].includes(position) || [...position].some(key=>!inRange(args,key,0,limit[key]))) invalid();
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
295
342
|
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');
|
|
296
343
|
// Coordinated extrusion closes the startup-only allowance even when the
|
|
297
344
|
// source omits layer comments. Retraction also closes it conservatively.
|
|
@@ -330,17 +377,23 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
330
377
|
heat('bed',args.get('D')!,undefined,true);continue;
|
|
331
378
|
}
|
|
332
379
|
const vendorWait=code==='M109' && ['a1','a1mini'].includes(model);
|
|
333
|
-
|
|
380
|
+
// Bambu Studio addresses a physical nozzle on dual-nozzle machines with N<index>
|
|
381
|
+
// (X2D `M104 T1 S25 N0` multi-extruder pre-cooling and wipe-tower reheats).
|
|
382
|
+
// N selects a nozzle, never a target, so it is accepted only on those models,
|
|
383
|
+
// only for nozzle heaters, and only within the declared nozzle count.
|
|
384
|
+
const nozzleIndex=['M104','M109'].includes(code) && dualNozzle;
|
|
385
|
+
for(const key of args.keys()) if(!'SRTA'.includes(key) && !(key==='H' && vendorWait) && !(key==='N' && nozzleIndex)) fail(`unsupported ${code} temperature parameter ${key}`);
|
|
386
|
+
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`);
|
|
334
387
|
if(args.has('H') && (!Number.isFinite(args.get('H')) || args.get('H')!<0 || args.get('H')!>300)) fail(`unsupported ${code} wait parameter H`);
|
|
335
388
|
if(!args.has('S')&&!args.has('R')) fail(`missing ${code} temperature target`);
|
|
336
389
|
const component= ['M104','M109'].includes(code)?'nozzle': ['M140','M190'].includes(code)?'bed':'chamber';
|
|
337
390
|
// Heater T is a physical nozzle, unlike standalone remapped T filament selection.
|
|
338
391
|
// Without a verified per-filament nozzle map, apply all possible materials.
|
|
339
392
|
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');
|
|
340
|
-
const position= args.has('T') ? undefined : active;
|
|
393
|
+
const position= args.has('T') || args.has('N') ? undefined : active;
|
|
341
394
|
const commonFlush=code==='M109' && args.size===1 && args.get('S')===290 && model==='x1e' && !depositionStarted && !commonFlushUsed && boundedX1ePurge(lines,index);
|
|
342
395
|
if(commonFlush) commonFlushUsed=true;
|
|
343
|
-
for(const key of ['S','R']) if(args.has(key)) heat(component,args.get(key)!,position,args.has('T') || args.has('A'),commonFlush);
|
|
396
|
+
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);
|
|
344
397
|
} else if(code==='M620' || code==='M621') {
|
|
345
398
|
const args=parameters(argumentsText,model==='x2d'?'MAB':model==='h2c'?'MAN':'MA');
|
|
346
399
|
if(args.has('N') && (code!=='M620' || !h2cStartup(index) || args.size!==1 || !Number.isNaN(args.get('N')!))) fail('unsupported H2C startup hotend-remap flag');
|
|
@@ -409,7 +462,18 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
409
462
|
const args=parameters(argumentsText);
|
|
410
463
|
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');
|
|
411
464
|
for(const key of args.keys()) if(!'SLT'.includes(key)) fail(`unsupported M620.17 parameter ${key}`);
|
|
412
|
-
|
|
465
|
+
if(args.get('L')===-1 && model==='x2d') {
|
|
466
|
+
// OrcaSlicer's X2D start G-code preheats a nozzle with no assigned
|
|
467
|
+
// filament (L-1) for toolhead-offset calibration; Bambu Studio writes
|
|
468
|
+
// L0 there. No project filament is selected, so check the target
|
|
469
|
+
// against the hardware limit and every declared material, like other
|
|
470
|
+
// heater commands without a verified filament. That is never looser
|
|
471
|
+
// than an accepted L<position> form. Material left in the nozzle from
|
|
472
|
+
// an earlier job is live state that no file can declare. T names one
|
|
473
|
+
// of the two physical nozzles, whatever the file declares.
|
|
474
|
+
if(args.get('T')!>1) fail('unsupported M620.17 nozzle/material temperature mapping');
|
|
475
|
+
heat('nozzle',args.get('S')!,undefined,true);
|
|
476
|
+
} else heat('nozzle',args.get('S')!,requirePosition(args.get('L')!));
|
|
413
477
|
} else if((/^M620\./.test(code) && !['M620.3','M620.6','M620.11'].includes(code)) || /^M621\./.test(code)) {
|
|
414
478
|
fail(`unsupported thermal-affecting filament command ${code}`);
|
|
415
479
|
} else if(code==='M145') {
|
|
@@ -428,5 +492,5 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
428
492
|
}
|
|
429
493
|
if(!commandCount) return fail('selected G-code contains no printable commands');
|
|
430
494
|
if(!used.size) materials.forEach((_,index)=>used.add(index));
|
|
431
|
-
return {model,nozzleDiameters,materials,usedFilamentPositions:[...used].sort((a,b)=>a-b),plateInternalPath,sha256,maxNozzleTemperature,maxBedTemperature,maxChamberTemperature,bedType,selectsAms,nozzleTypes,nozzleFlows};
|
|
495
|
+
return {model,nozzleDiameters,materials,usedFilamentPositions:[...used].sort((a,b)=>a-b),plateInternalPath,sha256,maxNozzleTemperature,maxBedTemperature,maxChamberTemperature,bedType,selectsAms,nozzleTypes,nozzleFlows,filamentNozzleIndices};
|
|
432
496
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
import { HIDDEN_HMS } from "./hidden-hms.js";
|
|
1
2
|
import { BambuClient, GetVersionCommand, PushAllCommand } from "bambu-node";
|
|
2
|
-
import { normalizeMaterial, normalizeModel } from "./limits.js";
|
|
3
|
+
import { isDualNozzleModel, normalizeMaterial, normalizeModel } from "./limits.js";
|
|
3
4
|
|
|
4
5
|
const MAX_REPORT_AGE_MS = 15_000;
|
|
5
6
|
const MODEL_IDS: Record<string, string> = {
|
|
6
7
|
O1C: "H2C", O1C2: "H2C", O1D: "H2D", O1E: "H2D Pro", O1S: "H2S", N6: "X2D",
|
|
7
8
|
N2S: "A1", A1M: "A1 Mini", C11: "P1P", C12: "P1S", C13: "X1E", "BL-P001": "X1C", "BL-P002": "X1",
|
|
8
9
|
};
|
|
9
|
-
const SERIAL_MODELS: Record<string, string> = {
|
|
10
|
+
export const SERIAL_MODELS: Record<string, string> = {
|
|
10
11
|
"093": "H2S", "094": "H2D", "31B": "H2C", "239": "H2D Pro", "20P": "X2D",
|
|
11
12
|
"00M": "X1C", "00W": "X1", "03W": "X1E", "01S": "P1P", "01P": "P1S", "22E": "P2S", "039": "A1", "030": "A1 Mini",
|
|
12
13
|
};
|
|
@@ -102,6 +103,8 @@ export interface PrinterStateRequirements {
|
|
|
102
103
|
nozzleDiameters: number[];
|
|
103
104
|
materials?: string[];
|
|
104
105
|
usedFilamentPositions?: number[];
|
|
106
|
+
/** Verified physical nozzle index for each project filament, composed from the inspected plate mappings. */
|
|
107
|
+
filamentNozzleIndices?: number[];
|
|
105
108
|
amsMapping?: number[];
|
|
106
109
|
useAMS?: boolean;
|
|
107
110
|
requireIdle?: boolean;
|
|
@@ -183,24 +186,95 @@ function reportedNozzles(raw: Record<string,any>): ReportedNozzle[] {
|
|
|
183
186
|
return output;
|
|
184
187
|
}
|
|
185
188
|
|
|
186
|
-
|
|
189
|
+
const hiddenHmsSets = new Map(Object.entries(HIDDEN_HMS).map(([prefix, codes]) => [prefix, new Set(codes)]));
|
|
190
|
+
|
|
191
|
+
type HmsEntry = { attr: number; code: number; severity: number; hex: string };
|
|
192
|
+
function hmsCode(entry: any): HmsEntry | undefined {
|
|
193
|
+
const code = finiteNumber(entry?.code);
|
|
194
|
+
const attr = finiteNumber(entry?.attr);
|
|
195
|
+
const valid = (value: number | undefined): value is number => value !== undefined && Number.isInteger(value) && value >= 0 && value <= 0xffffffff;
|
|
196
|
+
if (!valid(code) || !valid(attr)) return undefined;
|
|
197
|
+
// BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
|
|
198
|
+
return { attr, code, severity: Math.floor(code / 65536), hex: (attr.toString(16).padStart(8, "0") + code.toString(16).padStart(8, "0")).toUpperCase() };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const hmsLabel = (hex: string) => `HMS ${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12)}`;
|
|
202
|
+
const hmsKey = (entry: HmsEntry) => `hms:${entry.attr}:${entry.code}`;
|
|
203
|
+
|
|
204
|
+
/** The one key format for a reported entry, shared by the gate and clear_hms_errors. */
|
|
205
|
+
export function reportedHmsKey(entry: unknown): string {
|
|
206
|
+
const parsed = hmsCode(entry);
|
|
207
|
+
return parsed ? hmsKey(parsed) : `hms:${(entry as any)?.attr}:${(entry as any)?.code}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Bambu's display form of a cleared-code key such as "hms:83887616:131184"; other keys unchanged. */
|
|
211
|
+
export function describeHmsKey(key: string): string {
|
|
212
|
+
const match = /^hms:(\d+):(\d+)$/.exec(key);
|
|
213
|
+
const entry = match ? hmsCode({ attr: Number(match[1]), code: Number(match[2]) }) : undefined;
|
|
214
|
+
return entry ? hmsLabel(entry.hex) : key;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A serious or common entry that Bambu lists for this serial prefix with empty
|
|
219
|
+
* text: Bambu Studio, Bambu Handy and the printer screen never show it, and
|
|
220
|
+
* the printer keeps printing with it (an X2D raises 0500-0600-0002-0070 at
|
|
221
|
+
* every boot). Bambu Studio selects the table by serial prefix, so this does
|
|
222
|
+
* too, but only when that prefix belongs to the verified model: a sparse report
|
|
223
|
+
* without a serial must not let a misconfigured serial pick another model's
|
|
224
|
+
* table. Fatal, invalid-level and unlisted entries never qualify.
|
|
225
|
+
*/
|
|
226
|
+
function isHiddenNotice(serial: string, model: string, entry: HmsEntry): boolean {
|
|
227
|
+
if (entry.severity !== 2 && entry.severity !== 3) return false;
|
|
228
|
+
const prefix = serial.trim().toUpperCase().slice(0, 3);
|
|
229
|
+
const normalized = normalizeModel(model);
|
|
230
|
+
if (!normalized || normalizeModel(SERIAL_MODELS[prefix] ?? "") !== normalized) return false;
|
|
231
|
+
return hiddenHmsSets.get(prefix)?.has(entry.hex) === true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function hiddenHmsEntries(raw: unknown, serial: string, model: string): HmsEntry[] {
|
|
235
|
+
if (!object(raw)) return [];
|
|
236
|
+
const identity = observedIdentity(raw, serial);
|
|
237
|
+
if (!identity.observedSerial || identity.model !== normalizeModel(model)) return [];
|
|
238
|
+
const entries = Array.isArray(raw.hms) ? raw.hms : [];
|
|
239
|
+
const seen = new Set<string>();
|
|
240
|
+
return entries.map(hmsCode).filter((entry): entry is HmsEntry =>
|
|
241
|
+
!!entry && isHiddenNotice(identity.observedSerial, model, entry) && !seen.has(entry.hex) && !!seen.add(entry.hex));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Labels of reported entries that pass only because Bambu hides them for this printer. */
|
|
245
|
+
export function hiddenHmsNotices(raw: unknown, serial: string, model: string): string[] {
|
|
246
|
+
return hiddenHmsEntries(raw, serial, model).map(entry => hmsLabel(entry.hex));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Cleared-code keys of reported hidden entries; clearing never removes them, so they are not re-acknowledged. */
|
|
250
|
+
export function hiddenHmsKeys(raw: unknown, serial: string, model: string): string[] {
|
|
251
|
+
return hiddenHmsEntries(raw, serial, model).map(hmsKey);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const loggedHiddenNotices = new Set<string>();
|
|
255
|
+
/** Name each hidden entry a check passed once per printer on stderr, so headless runs still record it. */
|
|
256
|
+
function logHiddenNotices(raw: Record<string, any>, serial: string, model: string): void {
|
|
257
|
+
for (const entry of hiddenHmsEntries(raw, serial, model)) {
|
|
258
|
+
const key = `${serial}:${entry.hex}`;
|
|
259
|
+
if (loggedHiddenNotices.has(key)) continue;
|
|
260
|
+
loggedHiddenNotices.add(key);
|
|
261
|
+
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.`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function validateErrors(raw: Record<string,any>, requireIdle: boolean, serial: string, model: string) {
|
|
187
266
|
const state = typeof raw.gcode_state === "string" ? raw.gcode_state.toUpperCase() : "UNKNOWN";
|
|
188
267
|
if (requireIdle && !["IDLE","FINISH"].includes(state)) throw new Error(`Printer state ${state} is not safely idle.`);
|
|
189
268
|
if (!requireIdle && !["IDLE","FINISH","RUNNING","PAUSE"].includes(state)) throw new Error(`Printer state ${state} is unknown or failed.`);
|
|
190
269
|
if (finiteNumber(raw.print_error) !== 0) throw new Error("Printer reports a print error or did not report its error state.");
|
|
191
270
|
if (!Array.isArray(raw.hms)) throw new Error("Printer HMS error state is missing or malformed.");
|
|
192
|
-
for (const
|
|
193
|
-
const
|
|
194
|
-
const attr = finiteNumber(entry?.attr);
|
|
195
|
-
// BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
|
|
271
|
+
for (const reported of raw.hms) {
|
|
272
|
+
const entry = hmsCode(reported);
|
|
196
273
|
// Unknown severities fail closed; attr identifies the module and is not a severity bitmask.
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
const label = code !== undefined && attr !== undefined && Number.isInteger(code) && Number.isInteger(attr) && code >= 0 && attr >= 0
|
|
200
|
-
? ` (HMS ${hex(attr).slice(0, 4)}-${hex(attr).slice(4)}-${hex(code).slice(0, 4)}-${hex(code).slice(4)})` : "";
|
|
201
|
-
throw new Error(`Printer reports actionable or unknown HMS errors${label}; inspect and resolve them before proceeding.`);
|
|
202
|
-
}
|
|
274
|
+
if (entry && (entry.severity === 4 || isHiddenNotice(serial, model, entry))) continue;
|
|
275
|
+
throw new Error(`Printer reports actionable or unknown HMS errors${entry ? ` (${hmsLabel(entry.hex)})` : ""}; inspect and resolve them before proceeding.`);
|
|
203
276
|
}
|
|
277
|
+
logHiddenNotices(raw, serial, model);
|
|
204
278
|
}
|
|
205
279
|
function trayRange(value: unknown, label: string): number | undefined {
|
|
206
280
|
if (value === undefined || value === null || value === "" || value === "0" || value === 0) return undefined;
|
|
@@ -208,13 +282,20 @@ function trayRange(value: unknown, label: string): number | undefined {
|
|
|
208
282
|
if (number === undefined || number <= 0) throw new Error(`Reported filament ${label} temperature is malformed.`);
|
|
209
283
|
return number;
|
|
210
284
|
}
|
|
211
|
-
|
|
285
|
+
/** Dual-nozzle printers report one virtual external slot per nozzle, numbered as Bambu
|
|
286
|
+
* Studio does (VIRTUAL_TRAY_MAIN_ID 255 = right/main nozzle, VIRTUAL_TRAY_DEPUTY_ID 254 =
|
|
287
|
+
* left/deputy nozzle), so the requested tray selects its slot. Single-nozzle printers keep
|
|
288
|
+
* requiring exactly one slot 254 report, then the legacy vt_tray field. */
|
|
289
|
+
function externalTray(raw: Record<string,any>, trayIndex: number, model: string): Record<string,any> | undefined {
|
|
212
290
|
const slots = raw.vir_slot;
|
|
291
|
+
const dualNozzle = isDualNozzleModel(model);
|
|
213
292
|
if (Array.isArray(slots)) {
|
|
214
|
-
const selected = slots.filter((slot:any) => Number(slot?.id) ===
|
|
215
|
-
if (
|
|
293
|
+
const selected = slots.filter((slot:any) => Number(slot?.id) === trayIndex);
|
|
294
|
+
if (selected.length === 1 && (dualNozzle || slots.length === 1)) return selected[0];
|
|
295
|
+
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.`);
|
|
216
296
|
if (slots.length > 0) throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
|
|
217
297
|
}
|
|
298
|
+
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.`);
|
|
218
299
|
return object(raw.vt_tray) ? raw.vt_tray : undefined;
|
|
219
300
|
}
|
|
220
301
|
function mappedTray(raw: Record<string,any>, index: number): Record<string,any> {
|
|
@@ -248,7 +329,7 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
|
|
|
248
329
|
const identity = observedIdentity(raw, String(status.serial ?? ""));
|
|
249
330
|
const model = normalizeModel(requirements.model);
|
|
250
331
|
if (!model || !identity.model || model !== identity.model) throw new Error(`Printer model identity mismatch: requested ${requirements.model}, observed ${identity.model ?? "unknown"}.`);
|
|
251
|
-
validateErrors(raw, requirements.requireIdle !== false);
|
|
332
|
+
validateErrors(raw, requirements.requireIdle !== false, identity.observedSerial ?? "", model);
|
|
252
333
|
if (requirements.requireLoadedFilament && (raw.ams?.tray_now !== undefined || Array.isArray(raw.ams?.ams))) {
|
|
253
334
|
const current = finiteNumber(raw.ams?.tray_now);
|
|
254
335
|
if (current === 255) throw new Error("The nozzle is explicitly reported unloaded. Load the declared filament before starting or resuming this G-code job.");
|
|
@@ -260,7 +341,7 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
|
|
|
260
341
|
}
|
|
261
342
|
}
|
|
262
343
|
const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
|
|
263
|
-
if (requirements.nozzleDiameters.length &&
|
|
344
|
+
if (requirements.nozzleDiameters.length && isDualNozzleModel(model) &&
|
|
264
345
|
!Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
|
|
265
346
|
throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
|
|
266
347
|
}
|
|
@@ -288,13 +369,27 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
|
|
|
288
369
|
if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
|
|
289
370
|
const materials = requirements.materials ?? [];
|
|
290
371
|
const positions = requirements.usedFilamentPositions ?? materials.map((_, index) => index);
|
|
372
|
+
// 255 is the right/main nozzle external spool only on dual-nozzle printers.
|
|
373
|
+
const dualNozzle = isDualNozzleModel(model);
|
|
374
|
+
const maxTray = dualNozzle ? 255 : 254;
|
|
291
375
|
if (!positions.length) throw new Error("A declared material is required for each used filament or external spool.");
|
|
292
376
|
for (const position of positions) {
|
|
293
377
|
const material = normalizeMaterial(materials[position]);
|
|
294
378
|
if (!Number.isInteger(position) || position < 0 || !material) throw new Error(`A known declared material is required for filament position ${position}.`);
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
const
|
|
379
|
+
const physicalNozzle = requirements.filamentNozzleIndices?.[position];
|
|
380
|
+
const externalForNozzle = physicalNozzle === 0 ? 255 : physicalNozzle === 1 ? 254 : undefined;
|
|
381
|
+
const trayIndex = requirements.useAMS || requirements.amsMapping !== undefined
|
|
382
|
+
? requirements.amsMapping?.[position] : dualNozzle ? externalForNozzle : 254;
|
|
383
|
+
if (!requirements.useAMS && dualNozzle && requirements.amsMapping === undefined && externalForNozzle === undefined) {
|
|
384
|
+
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.`);
|
|
385
|
+
}
|
|
386
|
+
if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > maxTray) throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
|
|
387
|
+
if (!requirements.useAMS && trayIndex < 254) throw new Error("External-spool printing requires an external spool mapping; AMS trays require use_ams:true.");
|
|
388
|
+
if (dualNozzle && trayIndex >= 254) {
|
|
389
|
+
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.`);
|
|
390
|
+
if (trayIndex !== externalForNozzle) throw new Error(`External spool ${trayIndex} does not feed physical nozzle ${physicalNozzle} for filament position ${position}; use spool ${externalForNozzle}.`);
|
|
391
|
+
}
|
|
392
|
+
const tray = trayIndex >= 254 ? externalTray(raw,trayIndex,model) : mappedTray(raw,trayIndex);
|
|
298
393
|
const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
|
|
299
394
|
if (reportedType && normalizeMaterial(reportedType) !== material) throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
|
|
300
395
|
const nozzleMin = trayRange(tray?.nozzle_temp_min,"minimum");
|
|
@@ -312,7 +407,7 @@ export function manualHeatingRequirements(status: any, model: string, nozzleDiam
|
|
|
312
407
|
if (!normalized) throw new Error("A known printer model is required for manual nozzle heating.");
|
|
313
408
|
const raw = status?.raw;
|
|
314
409
|
if (!object(raw)) throw new Error("A fresh printer report is required for manual nozzle heating.");
|
|
315
|
-
if (
|
|
410
|
+
if (isDualNozzleModel(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
|
|
316
411
|
throw new Error("Manual heating cannot verify the active nozzle and loaded material on this multi-nozzle printer. Use a checked sliced print instead.");
|
|
317
412
|
}
|
|
318
413
|
const requirements: PrinterStateRequirements = {
|
|
@@ -335,6 +335,37 @@ function enforceMatchingNozzles(arr: unknown[]): void {
|
|
|
335
335
|
}
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
+
/**
|
|
339
|
+
* BambuStudio picks each per-variant machine, process, and filament value
|
|
340
|
+
* (e.g. filament_max_volumetric_speed) by "<extruder_type> <nozzle_volume_type>"
|
|
341
|
+
* among the variants each extruder declares in extruder_variant_list. A flow
|
|
342
|
+
* type the preset doesn't declare has no values to pick, so refuse it instead
|
|
343
|
+
* of labelling a job for a nozzle it wasn't sliced for.
|
|
344
|
+
*/
|
|
345
|
+
function assertNozzleVariantDeclared(
|
|
346
|
+
flat: Record<string, unknown>,
|
|
347
|
+
volumeType: string,
|
|
348
|
+
machineLeaf: string
|
|
349
|
+
): void {
|
|
350
|
+
const strings = (key: string): string[] =>
|
|
351
|
+
Array.isArray(flat[key]) ? (flat[key] as unknown[]).map(String) : [];
|
|
352
|
+
const extruderTypes = strings("extruder_type");
|
|
353
|
+
const declaredLists = strings("extruder_variant_list");
|
|
354
|
+
const count = (flat["nozzle_volume_type"] as unknown[]).length;
|
|
355
|
+
for (let i = 0; i < count; i++) {
|
|
356
|
+
const extruderType = extruderTypes[i] ?? extruderTypes[0];
|
|
357
|
+
const declared = (declaredLists[i] ?? declaredLists[0] ?? "").split(",").map((v) => v.trim()).filter(Boolean);
|
|
358
|
+
const matches = (variant: string) =>
|
|
359
|
+
extruderType ? variant === `${extruderType} ${volumeType}` : variant.endsWith(` ${volumeType}`);
|
|
360
|
+
// Presets without variant declarations only have Standard values.
|
|
361
|
+
if (declared.length === 0 ? volumeType === "Standard" : declared.some(matches)) continue;
|
|
362
|
+
throw new Error(
|
|
363
|
+
`Slicing for a ${volumeType} nozzle is refused: "${machineLeaf}" declares no ${volumeType} variant ` +
|
|
364
|
+
`for extruder ${i + 1} (declared: ${declared.join(", ") || "none"}).`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
338
369
|
/** Best-effort extruder count for fallback nozzle_volume_type sizing. */
|
|
339
370
|
/** BambuStudio's built-in filament_colour default. */
|
|
340
371
|
export const DEFAULT_FILAMENT_COLOUR = "#00AE42";
|
|
@@ -687,6 +718,7 @@ export async function flattenForCli(opts: FlattenOptions): Promise<FlattenedProf
|
|
|
687
718
|
|
|
688
719
|
// CLI-specific post-processing on machine profile only.
|
|
689
720
|
deriveNozzleVolumeType(machineFlat, opts.nozzleVolumeType);
|
|
721
|
+
if (opts.nozzleVolumeType) assertNozzleVariantDeclared(machineFlat, opts.nozzleVolumeType, opts.machineLeaf);
|
|
690
722
|
applyMachineModelBedMetadata(machineFlat, machineIndex);
|
|
691
723
|
// A standalone custom machine can accompany process/filament profiles that
|
|
692
724
|
// need BBL resolution; do not impose bundled model config on that machine.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/** AFKFelix/orca-slicer-api contract at a9a28f0a7b0c6f51b9be158509e9e9677532b868. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { inspectPrintFile } from '../safety/print-file.js';
|
|
6
|
+
import { normalizeMaterial, normalizeModel } from '../safety/limits.js';
|
|
7
|
+
import type { BambuSliceOptions } from '../stl/stl-manipulator.js';
|
|
8
|
+
|
|
9
|
+
const MAX_BYTES = 100_000_000;
|
|
10
|
+
const SUPPORTED_OPTIONS = new Set([
|
|
11
|
+
'arrange', 'orient', 'loadFilaments', 'filamentColours', 'bedType',
|
|
12
|
+
'nozzleType', 'nozzleVolumeType', 'slicePlate',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function configuration(): { base: string; headers: Record<string, string>; mode: string; timeout: number } {
|
|
16
|
+
let url: URL;
|
|
17
|
+
try { url = new URL(process.env.SLICER_API_URL ?? ''); }
|
|
18
|
+
catch { throw new Error('Remote slicing requires a valid trusted SLICER_API_URL.'); }
|
|
19
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
20
|
+
throw new Error('SLICER_API_URL must use HTTP(S) without embedded credentials, query or fragment.');
|
|
21
|
+
}
|
|
22
|
+
const mode = process.env.SLICER_API_MODE || 'sync';
|
|
23
|
+
if (!['sync', 'async'].includes(mode)) throw new Error('SLICER_API_MODE must be sync or async.');
|
|
24
|
+
const timeout = Number(process.env.SLICER_API_TIMEOUT_MS ?? 600000);
|
|
25
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3600000) {
|
|
26
|
+
throw new Error('SLICER_API_TIMEOUT_MS must be an integer from 1 to 3600000.');
|
|
27
|
+
}
|
|
28
|
+
const headers: Record<string, string> = {};
|
|
29
|
+
const token = process.env.SLICER_API_TOKEN;
|
|
30
|
+
if (token) {
|
|
31
|
+
const header = process.env.SLICER_API_TOKEN_HEADER || 'Authorization';
|
|
32
|
+
if (!/^[a-zA-Z0-9!#$%&'*+.^_`|~-]+$/.test(header) ||
|
|
33
|
+
['host', 'content-type', 'content-length', 'connection', 'transfer-encoding'].includes(header.toLowerCase()) ||
|
|
34
|
+
/[^\x20-\x7e]/.test(token)) {
|
|
35
|
+
throw new Error('Invalid remote slicing authentication configuration.');
|
|
36
|
+
}
|
|
37
|
+
headers[header] = header.toLowerCase() === 'authorization' ? `Bearer ${token}` : token;
|
|
38
|
+
}
|
|
39
|
+
return { base: url.href.replace(/\/+$/, ''), headers, mode, timeout };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateRemoteSliceOptions(options: BambuSliceOptions = {}): number {
|
|
43
|
+
for (const [key, value] of Object.entries(options)) {
|
|
44
|
+
if (value !== undefined && !SUPPORTED_OPTIONS.has(key)) {
|
|
45
|
+
throw new Error(`Remote slicing option ${key} is unsupported by orca-slicer-api; use a local backend for this workflow.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Upstream evaluates multipart values by truthiness, so the string "false"
|
|
49
|
+
// becomes true. Never silently turn an explicit false into an enabled flag.
|
|
50
|
+
if (options.arrange === false || options.orient === false) {
|
|
51
|
+
throw new Error('Remote arrange=false and orient=false are unsupported by the upstream multipart API.');
|
|
52
|
+
}
|
|
53
|
+
const plate = options.slicePlate ?? 1;
|
|
54
|
+
if (!Number.isInteger(plate) || plate < 1) throw new Error('Remote slicing requires one positive plate number; slicing all plates is unsupported.');
|
|
55
|
+
return plate;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The remote API forwards this display name to Orca's --curr-bed-type. */
|
|
59
|
+
function remoteBedType(value: unknown): string {
|
|
60
|
+
const names: Record<string, string> = {
|
|
61
|
+
textured_plate: 'Textured PEI Plate', textured_pei_plate: 'Textured PEI Plate',
|
|
62
|
+
cool_plate: 'Cool Plate', engineering_plate: 'Engineering Plate',
|
|
63
|
+
hot_plate: 'High Temp Plate', high_temp_plate: 'High Temp Plate',
|
|
64
|
+
};
|
|
65
|
+
const key = typeof value === 'string' ? value.trim().toLowerCase().replace(/[\s-]+/g, '_') : '';
|
|
66
|
+
if (typeof names[key] !== 'string') throw new Error('Unsupported remote bed type; use textured_plate, cool_plate, engineering_plate, or hot_plate.');
|
|
67
|
+
return names[key];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readResponse(response: Response, limit: number): Promise<Buffer> {
|
|
71
|
+
const size = Number(response.headers.get('content-length'));
|
|
72
|
+
if (size > limit) { await response.body?.cancel(); throw new Error('Remote slicing response exceeds the size limit.'); }
|
|
73
|
+
if (!response.body) throw new Error('Remote slicing returned an empty response.');
|
|
74
|
+
const reader = response.body.getReader();
|
|
75
|
+
const chunks: Uint8Array[] = [];
|
|
76
|
+
let length = 0;
|
|
77
|
+
try {
|
|
78
|
+
for (;;) {
|
|
79
|
+
const { value, done } = await reader.read();
|
|
80
|
+
if (done) break;
|
|
81
|
+
length += value.byteLength;
|
|
82
|
+
if (length > limit) throw new Error('Remote slicing response exceeds the size limit.');
|
|
83
|
+
chunks.push(value);
|
|
84
|
+
}
|
|
85
|
+
} finally { await reader.cancel(); }
|
|
86
|
+
return Buffer.concat(chunks);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readJob(response: Response): Promise<Record<string, unknown>> {
|
|
90
|
+
try {
|
|
91
|
+
const job: unknown = JSON.parse((await readResponse(response, 65536)).toString('utf8'));
|
|
92
|
+
if (job && typeof job === 'object' && !Array.isArray(job)) return job as Record<string, unknown>;
|
|
93
|
+
} catch { /* Do not echo an untrusted response, which may contain credentials. */ }
|
|
94
|
+
throw new Error('Remote slicing returned invalid job metadata.');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Upload only locally prepared profiles, then inspect the exact downloaded 3MF. */
|
|
98
|
+
export async function sliceWithOrcaApi(input: {
|
|
99
|
+
inputPath: string;
|
|
100
|
+
machinePath: string;
|
|
101
|
+
processPath: string;
|
|
102
|
+
filamentPaths: string[];
|
|
103
|
+
outputPath: string;
|
|
104
|
+
options?: BambuSliceOptions;
|
|
105
|
+
isCancelled: () => boolean;
|
|
106
|
+
}): Promise<string> {
|
|
107
|
+
const options = input.options ?? {};
|
|
108
|
+
const plate = validateRemoteSliceOptions(options);
|
|
109
|
+
if (input.filamentPaths.length !== 1 || (options.filamentColours && options.filamentColours.length !== 1)) {
|
|
110
|
+
throw new Error('orca-slicer-api supports exactly one filament slot; multi-slot projects require a local slicer.');
|
|
111
|
+
}
|
|
112
|
+
const config = configuration();
|
|
113
|
+
const machine = JSON.parse(await fs.readFile(input.machinePath, 'utf8'));
|
|
114
|
+
const filament = JSON.parse(await fs.readFile(input.filamentPaths[0], 'utf8'));
|
|
115
|
+
const processProfile = JSON.parse(await fs.readFile(input.processPath, 'utf8'));
|
|
116
|
+
const bedType = remoteBedType(options.bedType ?? processProfile.curr_bed_type ?? 'textured_plate');
|
|
117
|
+
const model = normalizeModel(machine.printer_model);
|
|
118
|
+
const nozzles = Array.isArray(machine.nozzle_diameter) ? machine.nozzle_diameter.map(Number) : [];
|
|
119
|
+
const materialValues: unknown[] = Array.isArray(filament.filament_type) ? filament.filament_type : [];
|
|
120
|
+
const materials = materialValues.map(normalizeMaterial);
|
|
121
|
+
if (!model || !nozzles.length || nozzles.some((n: number) => ![0.2, 0.4, 0.6, 0.8].includes(n)) ||
|
|
122
|
+
materials.length !== 1 || !materials[0]) {
|
|
123
|
+
throw new Error('Remote slicing requires explicit model, nozzle and single-material metadata in the prepared profiles.');
|
|
124
|
+
}
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
let timedOut = false;
|
|
127
|
+
const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, config.timeout);
|
|
128
|
+
const cancelCheck = setInterval(() => { if (input.isCancelled()) controller.abort(); }, 100);
|
|
129
|
+
let requestId: string | undefined;
|
|
130
|
+
let terminalJob = false;
|
|
131
|
+
let wroteOutput = false;
|
|
132
|
+
const request = async (route: string, init: RequestInit = {}): Promise<Response> => {
|
|
133
|
+
if (input.isCancelled()) controller.abort();
|
|
134
|
+
// Do not pass multipart data to fetch after cancellation: undici may start
|
|
135
|
+
// its body producer before checking the signal and close it mid-enqueue.
|
|
136
|
+
controller.signal.throwIfAborted();
|
|
137
|
+
let response: Response;
|
|
138
|
+
try {
|
|
139
|
+
response = await fetch(config.base + route, { ...init, headers: config.headers, signal: controller.signal, redirect: 'manual' });
|
|
140
|
+
} catch { throw new Error('Remote slicing HTTP request failed; check the trusted service configuration.'); }
|
|
141
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error(`Remote slicing HTTP ${response.status}.`); }
|
|
142
|
+
return response;
|
|
143
|
+
};
|
|
144
|
+
try {
|
|
145
|
+
const ext = path.extname(input.inputPath).toLowerCase();
|
|
146
|
+
if (!['.stl', '.3mf'].includes(ext)) throw new Error('Remote slicing supports only STL or 3MF input.');
|
|
147
|
+
const bytes = await fs.readFile(input.inputPath);
|
|
148
|
+
if (bytes.length > MAX_BYTES) throw new Error('Remote slicing input exceeds the 100 MB upstream limit.');
|
|
149
|
+
const form = new FormData();
|
|
150
|
+
form.set('file', new Blob([new Uint8Array(bytes)], { type: ext === '.3mf' ? 'model/3mf' : 'model/stl' }), `model${ext}`);
|
|
151
|
+
for (const [field, filePath, type] of [
|
|
152
|
+
['printerProfile', input.machinePath, 'machine'],
|
|
153
|
+
['presetProfile', input.processPath, 'process'],
|
|
154
|
+
['filamentProfile', input.filamentPaths[0], 'filament'],
|
|
155
|
+
]) {
|
|
156
|
+
const profile = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
157
|
+
// The remote validator requires these metadata fields even for standalone
|
|
158
|
+
// user profiles. They do not change the resolved machine/process settings.
|
|
159
|
+
const data = { ...profile, type, from: profile.from || 'User' };
|
|
160
|
+
form.set(field, new Blob([JSON.stringify(data)], { type: 'application/json' }), `${field}.json`);
|
|
161
|
+
}
|
|
162
|
+
form.set('exportType', '3mf');
|
|
163
|
+
form.set('plate', String(plate));
|
|
164
|
+
form.set('bedType', bedType);
|
|
165
|
+
form.set('resolveProfileInheritance', 'false');
|
|
166
|
+
if (options.arrange === true) form.set('arrange', 'true');
|
|
167
|
+
if (options.orient === true) form.set('orient', 'true');
|
|
168
|
+
let response = await request(config.mode === 'async' ? '/slice-async' : '/slice', { method: 'POST', body: form });
|
|
169
|
+
if (config.mode === 'async') {
|
|
170
|
+
const submitted = await readJob(response);
|
|
171
|
+
if (typeof submitted.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(submitted.requestId)) {
|
|
172
|
+
throw new Error('Remote slicing returned an invalid request ID.');
|
|
173
|
+
}
|
|
174
|
+
requestId = submitted.requestId;
|
|
175
|
+
for (;;) {
|
|
176
|
+
// Construct the documented same-service paths. Returned URLs cannot
|
|
177
|
+
// redirect authenticated requests to an arbitrary origin or route.
|
|
178
|
+
const job = await readJob(await request(`/slice-async/${requestId}`));
|
|
179
|
+
if (job.status === 'completed' || job.status === 'failed') {
|
|
180
|
+
terminalJob = true;
|
|
181
|
+
if (job.status === 'failed') throw new Error('Remote slicing job failed; inspect the service logs.');
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
if (job.status !== 'pending' && job.status !== 'processing') throw new Error('Remote slicing returned an unknown job state.');
|
|
185
|
+
await delay(500, undefined, { signal: controller.signal });
|
|
186
|
+
}
|
|
187
|
+
response = await request(`/slice-async/${requestId}/result`);
|
|
188
|
+
}
|
|
189
|
+
const result = await readResponse(response, MAX_BYTES);
|
|
190
|
+
await fs.writeFile(input.outputPath, result, { flag: 'wx', mode: 0o600 });
|
|
191
|
+
wroteOutput = true;
|
|
192
|
+
const inspection = await inspectPrintFile(input.outputPath, {
|
|
193
|
+
model, nozzleDiameters: nozzles, plateIndex: plate - 1, bedType,
|
|
194
|
+
});
|
|
195
|
+
if (JSON.stringify(inspection.materials) !== JSON.stringify(materials)) {
|
|
196
|
+
throw new Error('Remote sliced file materials contradict the prepared filament profile.');
|
|
197
|
+
}
|
|
198
|
+
if (input.isCancelled()) throw new Error('Remote slicing cancelled.');
|
|
199
|
+
return input.outputPath;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (wroteOutput) await fs.rm(input.outputPath, { force: true });
|
|
202
|
+
if (controller.signal.aborted) {
|
|
203
|
+
throw new Error(`Remote slicing ${timedOut ? 'timed out' : 'cancelled'}; the remote job may continue. No retry was sent.`);
|
|
204
|
+
}
|
|
205
|
+
throw error;
|
|
206
|
+
} finally {
|
|
207
|
+
// DELETE only removes finished jobs; upstream has no active-job cancellation.
|
|
208
|
+
// Cleanup never retries submission or masks a valid result / original error.
|
|
209
|
+
if (requestId && terminalJob && !controller.signal.aborted) {
|
|
210
|
+
try { await (await request(`/slice-async/${requestId}`, { method: 'DELETE' })).body?.cancel(); }
|
|
211
|
+
catch { console.error('Remote slicing finished-job cleanup failed; service retention applies.'); }
|
|
212
|
+
}
|
|
213
|
+
clearTimeout(deadline);
|
|
214
|
+
clearInterval(cancelCheck);
|
|
215
|
+
}
|
|
216
|
+
}
|