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
|
@@ -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
|
const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
|
|
5
5
|
const fail = (message) => { throw new Error(`Print safety: ${message}`); };
|
|
6
6
|
function list(value) {
|
|
@@ -62,6 +62,11 @@ function stripComments(line) {
|
|
|
62
62
|
/** A deliberately narrow parameter lexer for commands affecting heat/material selection.
|
|
63
63
|
* Unknown nonthermal vendor commands do not require a global G-code allowlist.
|
|
64
64
|
*/
|
|
65
|
+
/** True when the parameter is present, finite and within [low, high]. */
|
|
66
|
+
function inRange(args, key, low, high) {
|
|
67
|
+
const value = args.get(key);
|
|
68
|
+
return value !== undefined && Number.isFinite(value) && value >= low && value <= high;
|
|
69
|
+
}
|
|
65
70
|
function parameters(text, flags = '') {
|
|
66
71
|
const result = new Map();
|
|
67
72
|
let rest = text.trim();
|
|
@@ -163,6 +168,7 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
163
168
|
const model = normalizeModel(options.model);
|
|
164
169
|
if (!model)
|
|
165
170
|
return fail(`unknown or missing printer model '${options.model}'`);
|
|
171
|
+
const dualNozzle = isDualNozzleModel(model);
|
|
166
172
|
const plateIndex = options.plateIndex ?? 0;
|
|
167
173
|
if (!Number.isInteger(plateIndex) || plateIndex < 0)
|
|
168
174
|
return fail('selected plate index must be a nonnegative integer');
|
|
@@ -303,6 +309,24 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
303
309
|
const values = list(value);
|
|
304
310
|
return values.length && values.every(v => !!normalizeMaterial(v)) ? values.map(v => normalizeMaterial(v)) : undefined;
|
|
305
311
|
}, 'filament material');
|
|
312
|
+
// GCode.cpp uses 1-based filament_map logical extruders and then indexes
|
|
313
|
+
// physical_extruder_map to select the physical nozzle. Only this selected
|
|
314
|
+
// plate's headers establish that binding; unsliced project defaults do not.
|
|
315
|
+
const headerMap = (key, count, low, high) => {
|
|
316
|
+
const values = metadataValues(headers, [key]);
|
|
317
|
+
if (!values.length)
|
|
318
|
+
return undefined;
|
|
319
|
+
return consistent(values, value => {
|
|
320
|
+
const entries = list(value);
|
|
321
|
+
return entries.length === count && entries.every(v => NUMBER.test(v) && Number.isInteger(Number(v)) && Number(v) >= low && Number(v) <= high)
|
|
322
|
+
? entries.map(Number) : undefined;
|
|
323
|
+
}, key.replace(/_/g, ' '));
|
|
324
|
+
};
|
|
325
|
+
const physicalExtruderMap = headerMap('physical_extruder_map', nozzleDiameters.length, 0, dualNozzle ? Math.min(nozzleDiameters.length, 2) - 1 : nozzleDiameters.length - 1);
|
|
326
|
+
if (physicalExtruderMap && new Set(physicalExtruderMap).size !== physicalExtruderMap.length)
|
|
327
|
+
fail('physical extruder map must be a complete permutation');
|
|
328
|
+
const filamentMap = headerMap('filament_map', materials.length, 1, nozzleDiameters.length);
|
|
329
|
+
const filamentNozzleIndices = filamentMap && physicalExtruderMap ? filamentMap.map(logical => physicalExtruderMap[logical - 1]) : undefined;
|
|
306
330
|
const bedValues = metadataValues([...metadata, plate], ['curr_bed_type', 'bed_type']);
|
|
307
331
|
const bedType = bedValues.length ? consistent(bedValues, normalizeBed, 'bed type') : undefined;
|
|
308
332
|
if (options.bedType && (!bedType || normalizeBed(options.bedType) !== bedType))
|
|
@@ -365,7 +389,11 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
365
389
|
let line = stripComments(lines[index]);
|
|
366
390
|
if (!line || line === '%')
|
|
367
391
|
continue;
|
|
368
|
-
//
|
|
392
|
+
// Accept only the exact vendor banners in their documented model/scope.
|
|
393
|
+
// X2D emits its filament-change banner throughout a print; H2C's record
|
|
394
|
+
// separator stays inside its explicitly bounded startup block.
|
|
395
|
+
if (line === '======== X2D filament_change gcode ==========' && model === 'x2d')
|
|
396
|
+
continue;
|
|
369
397
|
if (line === '========== record data ==========' && h2cStartup(index))
|
|
370
398
|
continue;
|
|
371
399
|
line = line.replace(/^N\d+\s*/i, '').replace(/\*\d+\s*$/, '');
|
|
@@ -392,6 +420,33 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
392
420
|
const code = command[1].toUpperCase() + String(Number(base)) + (subcode === undefined ? '' : '.' + subcode);
|
|
393
421
|
const argumentsText = line.slice(command[0].length).trim();
|
|
394
422
|
commandCount++;
|
|
423
|
+
if (code === 'M9711') {
|
|
424
|
+
// Official X2D and H2D time-lapse capture (Bambu Studio and OrcaSlicer
|
|
425
|
+
// templates), emitted on every layer: M is the time-lapse mode and E the
|
|
426
|
+
// physical extruder, not a second command; T is a duration, not a heater.
|
|
427
|
+
// It sets no temperature. Positions stay inside the machine volume.
|
|
428
|
+
const invalid = () => fail('unsupported M9711 time-lapse parameters');
|
|
429
|
+
if (!['x2d', 'h2d'].includes(model))
|
|
430
|
+
invalid();
|
|
431
|
+
let args;
|
|
432
|
+
try {
|
|
433
|
+
args = parameters(argumentsText);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return invalid();
|
|
437
|
+
}
|
|
438
|
+
const [width, depth, height] = MACHINE_LIMITS[model].volume;
|
|
439
|
+
const position = ['X', 'Y', 'U', 'V'].filter(key => args.has(key)).join('');
|
|
440
|
+
const limit = { X: width, U: width, Y: depth, V: depth };
|
|
441
|
+
if ([...args.keys()].some(key => !'MEXYUVZSCOT'.includes(key)) ||
|
|
442
|
+
// X2D and H2D have two physical extruders, whatever the file declares.
|
|
443
|
+
![0, 1].includes(args.get('M')) || !Number.isInteger(args.get('E')) || !inRange(args, 'E', 0, Math.min(nozzleDiameters.length, 2) - 1) ||
|
|
444
|
+
args.get('S') !== 11 || args.get('C') !== 10 || args.get('O') !== 0 || !inRange(args, 'T', 0, 600000) ||
|
|
445
|
+
!inRange(args, 'Z', 0, height) ||
|
|
446
|
+
!['', 'XY', 'UV'].includes(position) || [...position].some(key => !inRange(args, key, 0, limit[key])))
|
|
447
|
+
invalid();
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
395
450
|
if (!['M117', 'M118', 'M1002', 'M1006', 'M900', 'M970', 'M983.1', 'G383'].includes(code) && /[GM]\s*\d/i.test(argumentsText))
|
|
396
451
|
fail('multiple commands on one line are unsupported');
|
|
397
452
|
// Coordinated extrusion closes the startup-only allowance even when the
|
|
@@ -445,9 +500,16 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
445
500
|
continue;
|
|
446
501
|
}
|
|
447
502
|
const vendorWait = code === 'M109' && ['a1', 'a1mini'].includes(model);
|
|
503
|
+
// Bambu Studio addresses a physical nozzle on dual-nozzle machines with N<index>
|
|
504
|
+
// (X2D `M104 T1 S25 N0` multi-extruder pre-cooling and wipe-tower reheats).
|
|
505
|
+
// N selects a nozzle, never a target, so it is accepted only on those models,
|
|
506
|
+
// only for nozzle heaters, and only within the declared nozzle count.
|
|
507
|
+
const nozzleIndex = ['M104', 'M109'].includes(code) && dualNozzle;
|
|
448
508
|
for (const key of args.keys())
|
|
449
|
-
if (!'SRTA'.includes(key) && !(key === 'H' && vendorWait))
|
|
509
|
+
if (!'SRTA'.includes(key) && !(key === 'H' && vendorWait) && !(key === 'N' && nozzleIndex))
|
|
450
510
|
fail(`unsupported ${code} temperature parameter ${key}`);
|
|
511
|
+
if (args.has('N') && (!Number.isInteger(args.get('N')) || args.get('N') < 0 || args.get('N') >= Math.min(nozzleDiameters.length, 2)))
|
|
512
|
+
fail(`unsupported ${code} nozzle index N`);
|
|
451
513
|
if (args.has('H') && (!Number.isFinite(args.get('H')) || args.get('H') < 0 || args.get('H') > 300))
|
|
452
514
|
fail(`unsupported ${code} wait parameter H`);
|
|
453
515
|
if (!args.has('S') && !args.has('R'))
|
|
@@ -457,13 +519,13 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
457
519
|
// Without a verified per-filament nozzle map, apply all possible materials.
|
|
458
520
|
if (args.has('T') && (!Number.isInteger(args.get('T')) || args.get('T') < 0 || args.get('T') >= nozzleDiameters.length))
|
|
459
521
|
fail('physical heater target has no declared nozzle');
|
|
460
|
-
const position = args.has('T') ? undefined : active;
|
|
522
|
+
const position = args.has('T') || args.has('N') ? undefined : active;
|
|
461
523
|
const commonFlush = code === 'M109' && args.size === 1 && args.get('S') === 290 && model === 'x1e' && !depositionStarted && !commonFlushUsed && boundedX1ePurge(lines, index);
|
|
462
524
|
if (commonFlush)
|
|
463
525
|
commonFlushUsed = true;
|
|
464
526
|
for (const key of ['S', 'R'])
|
|
465
527
|
if (args.has(key))
|
|
466
|
-
heat(component, args.get(key), position, args.has('T') || args.has('A'), commonFlush);
|
|
528
|
+
heat(component, args.get(key), position, args.has('T') || args.has('N') || args.has('A'), commonFlush);
|
|
467
529
|
}
|
|
468
530
|
else if (code === 'M620' || code === 'M621') {
|
|
469
531
|
const args = parameters(argumentsText, model === 'x2d' ? 'MAB' : model === 'h2c' ? 'MAN' : 'MA');
|
|
@@ -578,7 +640,21 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
578
640
|
for (const key of args.keys())
|
|
579
641
|
if (!'SLT'.includes(key))
|
|
580
642
|
fail(`unsupported M620.17 parameter ${key}`);
|
|
581
|
-
|
|
643
|
+
if (args.get('L') === -1 && model === 'x2d') {
|
|
644
|
+
// OrcaSlicer's X2D start G-code preheats a nozzle with no assigned
|
|
645
|
+
// filament (L-1) for toolhead-offset calibration; Bambu Studio writes
|
|
646
|
+
// L0 there. No project filament is selected, so check the target
|
|
647
|
+
// against the hardware limit and every declared material, like other
|
|
648
|
+
// heater commands without a verified filament. That is never looser
|
|
649
|
+
// than an accepted L<position> form. Material left in the nozzle from
|
|
650
|
+
// an earlier job is live state that no file can declare. T names one
|
|
651
|
+
// of the two physical nozzles, whatever the file declares.
|
|
652
|
+
if (args.get('T') > 1)
|
|
653
|
+
fail('unsupported M620.17 nozzle/material temperature mapping');
|
|
654
|
+
heat('nozzle', args.get('S'), undefined, true);
|
|
655
|
+
}
|
|
656
|
+
else
|
|
657
|
+
heat('nozzle', args.get('S'), requirePosition(args.get('L')));
|
|
582
658
|
}
|
|
583
659
|
else if ((/^M620\./.test(code) && !['M620.3', 'M620.6', 'M620.11'].includes(code)) || /^M621\./.test(code)) {
|
|
584
660
|
fail(`unsupported thermal-affecting filament command ${code}`);
|
|
@@ -610,5 +686,5 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
610
686
|
return fail('selected G-code contains no printable commands');
|
|
611
687
|
if (!used.size)
|
|
612
688
|
materials.forEach((_, index) => used.add(index));
|
|
613
|
-
return { model, nozzleDiameters, materials, usedFilamentPositions: [...used].sort((a, b) => a - b), plateInternalPath, sha256, maxNozzleTemperature, maxBedTemperature, maxChamberTemperature, bedType, selectsAms, nozzleTypes, nozzleFlows };
|
|
689
|
+
return { model, nozzleDiameters, materials, usedFilamentPositions: [...used].sort((a, b) => a - b), plateInternalPath, sha256, maxNozzleTemperature, maxBedTemperature, maxChamberTemperature, bedType, selectsAms, nozzleTypes, nozzleFlows, filamentNozzleIndices };
|
|
614
690
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BambuClient } from "bambu-node";
|
|
2
|
+
export declare const SERIAL_MODELS: Record<string, string>;
|
|
2
3
|
/** Only raw reports received after this request are evidence. Never read printer.data here. */
|
|
3
4
|
export declare function readFreshPrinterStatus(printer: BambuClient, serial: string, timeoutMs?: number): Promise<any>;
|
|
4
5
|
export interface PrinterStateRequirements {
|
|
@@ -6,6 +7,8 @@ export interface PrinterStateRequirements {
|
|
|
6
7
|
nozzleDiameters: number[];
|
|
7
8
|
materials?: string[];
|
|
8
9
|
usedFilamentPositions?: number[];
|
|
10
|
+
/** Verified physical nozzle index for each project filament, composed from the inspected plate mappings. */
|
|
11
|
+
filamentNozzleIndices?: number[];
|
|
9
12
|
amsMapping?: number[];
|
|
10
13
|
useAMS?: boolean;
|
|
11
14
|
requireIdle?: boolean;
|
|
@@ -36,6 +39,14 @@ export interface ValidatedPrinterState {
|
|
|
36
39
|
reportedNozzles: ReportedNozzle[];
|
|
37
40
|
filaments: ReportedFilament[];
|
|
38
41
|
}
|
|
42
|
+
/** The one key format for a reported entry, shared by the gate and clear_hms_errors. */
|
|
43
|
+
export declare function reportedHmsKey(entry: unknown): string;
|
|
44
|
+
/** Bambu's display form of a cleared-code key such as "hms:83887616:131184"; other keys unchanged. */
|
|
45
|
+
export declare function describeHmsKey(key: string): string;
|
|
46
|
+
/** Labels of reported entries that pass only because Bambu hides them for this printer. */
|
|
47
|
+
export declare function hiddenHmsNotices(raw: unknown, serial: string, model: string): string[];
|
|
48
|
+
/** Cleared-code keys of reported hidden entries; clearing never removes them, so they are not re-acknowledged. */
|
|
49
|
+
export declare function hiddenHmsKeys(raw: unknown, serial: string, model: string): string[];
|
|
39
50
|
/** Verifies reported configuration, not a physical nozzle/spool inspection. */
|
|
40
51
|
export declare function validatePrinterState(status: any, requirements: PrinterStateRequirements): ValidatedPrinterState;
|
|
41
52
|
/** Manual M104 cannot remap filament slots. Validate the material already at the nozzle. */
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { HIDDEN_HMS } from "./hidden-hms.js";
|
|
1
2
|
import { GetVersionCommand, PushAllCommand } from "bambu-node";
|
|
2
|
-
import { normalizeMaterial, normalizeModel } from "./limits.js";
|
|
3
|
+
import { isDualNozzleModel, normalizeMaterial, normalizeModel } from "./limits.js";
|
|
3
4
|
const MAX_REPORT_AGE_MS = 15000;
|
|
4
5
|
const MODEL_IDS = {
|
|
5
6
|
O1C: "H2C", O1C2: "H2C", O1D: "H2D", O1E: "H2D Pro", O1S: "H2S", N6: "X2D",
|
|
6
7
|
N2S: "A1", A1M: "A1 Mini", C11: "P1P", C12: "P1S", C13: "X1E", "BL-P001": "X1C", "BL-P002": "X1",
|
|
7
8
|
};
|
|
8
|
-
const SERIAL_MODELS = {
|
|
9
|
+
export const SERIAL_MODELS = {
|
|
9
10
|
"093": "H2S", "094": "H2D", "31B": "H2C", "239": "H2D Pro", "20P": "X2D",
|
|
10
11
|
"00M": "X1C", "00W": "X1", "03W": "X1E", "01S": "P1P", "01P": "P1S", "22E": "P2S", "039": "A1", "030": "A1 Mini",
|
|
11
12
|
};
|
|
@@ -184,7 +185,77 @@ function reportedNozzles(raw) {
|
|
|
184
185
|
}
|
|
185
186
|
return output;
|
|
186
187
|
}
|
|
187
|
-
|
|
188
|
+
const hiddenHmsSets = new Map(Object.entries(HIDDEN_HMS).map(([prefix, codes]) => [prefix, new Set(codes)]));
|
|
189
|
+
function hmsCode(entry) {
|
|
190
|
+
const code = finiteNumber(entry?.code);
|
|
191
|
+
const attr = finiteNumber(entry?.attr);
|
|
192
|
+
const valid = (value) => value !== undefined && Number.isInteger(value) && value >= 0 && value <= 0xffffffff;
|
|
193
|
+
if (!valid(code) || !valid(attr))
|
|
194
|
+
return undefined;
|
|
195
|
+
// BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
|
|
196
|
+
return { attr, code, severity: Math.floor(code / 65536), hex: (attr.toString(16).padStart(8, "0") + code.toString(16).padStart(8, "0")).toUpperCase() };
|
|
197
|
+
}
|
|
198
|
+
const hmsLabel = (hex) => `HMS ${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12)}`;
|
|
199
|
+
const hmsKey = (entry) => `hms:${entry.attr}:${entry.code}`;
|
|
200
|
+
/** The one key format for a reported entry, shared by the gate and clear_hms_errors. */
|
|
201
|
+
export function reportedHmsKey(entry) {
|
|
202
|
+
const parsed = hmsCode(entry);
|
|
203
|
+
return parsed ? hmsKey(parsed) : `hms:${entry?.attr}:${entry?.code}`;
|
|
204
|
+
}
|
|
205
|
+
/** Bambu's display form of a cleared-code key such as "hms:83887616:131184"; other keys unchanged. */
|
|
206
|
+
export function describeHmsKey(key) {
|
|
207
|
+
const match = /^hms:(\d+):(\d+)$/.exec(key);
|
|
208
|
+
const entry = match ? hmsCode({ attr: Number(match[1]), code: Number(match[2]) }) : undefined;
|
|
209
|
+
return entry ? hmsLabel(entry.hex) : key;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* A serious or common entry that Bambu lists for this serial prefix with empty
|
|
213
|
+
* text: Bambu Studio, Bambu Handy and the printer screen never show it, and
|
|
214
|
+
* the printer keeps printing with it (an X2D raises 0500-0600-0002-0070 at
|
|
215
|
+
* every boot). Bambu Studio selects the table by serial prefix, so this does
|
|
216
|
+
* too, but only when that prefix belongs to the verified model: a sparse report
|
|
217
|
+
* without a serial must not let a misconfigured serial pick another model's
|
|
218
|
+
* table. Fatal, invalid-level and unlisted entries never qualify.
|
|
219
|
+
*/
|
|
220
|
+
function isHiddenNotice(serial, model, entry) {
|
|
221
|
+
if (entry.severity !== 2 && entry.severity !== 3)
|
|
222
|
+
return false;
|
|
223
|
+
const prefix = serial.trim().toUpperCase().slice(0, 3);
|
|
224
|
+
const normalized = normalizeModel(model);
|
|
225
|
+
if (!normalized || normalizeModel(SERIAL_MODELS[prefix] ?? "") !== normalized)
|
|
226
|
+
return false;
|
|
227
|
+
return hiddenHmsSets.get(prefix)?.has(entry.hex) === true;
|
|
228
|
+
}
|
|
229
|
+
function hiddenHmsEntries(raw, serial, model) {
|
|
230
|
+
if (!object(raw))
|
|
231
|
+
return [];
|
|
232
|
+
const identity = observedIdentity(raw, serial);
|
|
233
|
+
if (!identity.observedSerial || identity.model !== normalizeModel(model))
|
|
234
|
+
return [];
|
|
235
|
+
const entries = Array.isArray(raw.hms) ? raw.hms : [];
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
return entries.map(hmsCode).filter((entry) => !!entry && isHiddenNotice(identity.observedSerial, model, entry) && !seen.has(entry.hex) && !!seen.add(entry.hex));
|
|
238
|
+
}
|
|
239
|
+
/** Labels of reported entries that pass only because Bambu hides them for this printer. */
|
|
240
|
+
export function hiddenHmsNotices(raw, serial, model) {
|
|
241
|
+
return hiddenHmsEntries(raw, serial, model).map(entry => hmsLabel(entry.hex));
|
|
242
|
+
}
|
|
243
|
+
/** Cleared-code keys of reported hidden entries; clearing never removes them, so they are not re-acknowledged. */
|
|
244
|
+
export function hiddenHmsKeys(raw, serial, model) {
|
|
245
|
+
return hiddenHmsEntries(raw, serial, model).map(hmsKey);
|
|
246
|
+
}
|
|
247
|
+
const loggedHiddenNotices = new Set();
|
|
248
|
+
/** Name each hidden entry a check passed once per printer on stderr, so headless runs still record it. */
|
|
249
|
+
function logHiddenNotices(raw, serial, model) {
|
|
250
|
+
for (const entry of hiddenHmsEntries(raw, serial, model)) {
|
|
251
|
+
const key = `${serial}:${entry.hex}`;
|
|
252
|
+
if (loggedHiddenNotices.has(key))
|
|
253
|
+
continue;
|
|
254
|
+
loggedHiddenNotices.add(key);
|
|
255
|
+
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.`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function validateErrors(raw, requireIdle, serial, model) {
|
|
188
259
|
const state = typeof raw.gcode_state === "string" ? raw.gcode_state.toUpperCase() : "UNKNOWN";
|
|
189
260
|
if (requireIdle && !["IDLE", "FINISH"].includes(state))
|
|
190
261
|
throw new Error(`Printer state ${state} is not safely idle.`);
|
|
@@ -194,18 +265,14 @@ function validateErrors(raw, requireIdle) {
|
|
|
194
265
|
throw new Error("Printer reports a print error or did not report its error state.");
|
|
195
266
|
if (!Array.isArray(raw.hms))
|
|
196
267
|
throw new Error("Printer HMS error state is missing or malformed.");
|
|
197
|
-
for (const
|
|
198
|
-
const
|
|
199
|
-
const attr = finiteNumber(entry?.attr);
|
|
200
|
-
// BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
|
|
268
|
+
for (const reported of raw.hms) {
|
|
269
|
+
const entry = hmsCode(reported);
|
|
201
270
|
// Unknown severities fail closed; attr identifies the module and is not a severity bitmask.
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
? ` (HMS ${hex(attr).slice(0, 4)}-${hex(attr).slice(4)}-${hex(code).slice(0, 4)}-${hex(code).slice(4)})` : "";
|
|
206
|
-
throw new Error(`Printer reports actionable or unknown HMS errors${label}; inspect and resolve them before proceeding.`);
|
|
207
|
-
}
|
|
271
|
+
if (entry && (entry.severity === 4 || isHiddenNotice(serial, model, entry)))
|
|
272
|
+
continue;
|
|
273
|
+
throw new Error(`Printer reports actionable or unknown HMS errors${entry ? ` (${hmsLabel(entry.hex)})` : ""}; inspect and resolve them before proceeding.`);
|
|
208
274
|
}
|
|
275
|
+
logHiddenNotices(raw, serial, model);
|
|
209
276
|
}
|
|
210
277
|
function trayRange(value, label) {
|
|
211
278
|
if (value === undefined || value === null || value === "" || value === "0" || value === 0)
|
|
@@ -215,15 +282,24 @@ function trayRange(value, label) {
|
|
|
215
282
|
throw new Error(`Reported filament ${label} temperature is malformed.`);
|
|
216
283
|
return number;
|
|
217
284
|
}
|
|
218
|
-
|
|
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, trayIndex, model) {
|
|
219
290
|
const slots = raw.vir_slot;
|
|
291
|
+
const dualNozzle = isDualNozzleModel(model);
|
|
220
292
|
if (Array.isArray(slots)) {
|
|
221
|
-
const selected = slots.filter((slot) => Number(slot?.id) ===
|
|
222
|
-
if (
|
|
293
|
+
const selected = slots.filter((slot) => Number(slot?.id) === trayIndex);
|
|
294
|
+
if (selected.length === 1 && (dualNozzle || slots.length === 1))
|
|
223
295
|
return selected[0];
|
|
296
|
+
if (dualNozzle && selected.length === 0 && slots.length > 0)
|
|
297
|
+
throw new Error(`External spool ${trayIndex} is not in the fresh per-nozzle report; cannot verify the selected material.`);
|
|
224
298
|
if (slots.length > 0)
|
|
225
299
|
throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
|
|
226
300
|
}
|
|
301
|
+
if (dualNozzle)
|
|
302
|
+
throw new Error(`Dual-nozzle external spool ${trayIndex} requires a fresh per-nozzle vir_slot report; legacy vt_tray cannot verify the selected nozzle.`);
|
|
227
303
|
return object(raw.vt_tray) ? raw.vt_tray : undefined;
|
|
228
304
|
}
|
|
229
305
|
function mappedTray(raw, index) {
|
|
@@ -265,7 +341,7 @@ export function validatePrinterState(status, requirements) {
|
|
|
265
341
|
const model = normalizeModel(requirements.model);
|
|
266
342
|
if (!model || !identity.model || model !== identity.model)
|
|
267
343
|
throw new Error(`Printer model identity mismatch: requested ${requirements.model}, observed ${identity.model ?? "unknown"}.`);
|
|
268
|
-
validateErrors(raw, requirements.requireIdle !== false);
|
|
344
|
+
validateErrors(raw, requirements.requireIdle !== false, identity.observedSerial ?? "", model);
|
|
269
345
|
if (requirements.requireLoadedFilament && (raw.ams?.tray_now !== undefined || Array.isArray(raw.ams?.ams))) {
|
|
270
346
|
const current = finiteNumber(raw.ams?.tray_now);
|
|
271
347
|
if (current === 255)
|
|
@@ -278,7 +354,7 @@ export function validatePrinterState(status, requirements) {
|
|
|
278
354
|
}
|
|
279
355
|
}
|
|
280
356
|
const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
|
|
281
|
-
if (requirements.nozzleDiameters.length &&
|
|
357
|
+
if (requirements.nozzleDiameters.length && isDualNozzleModel(model) &&
|
|
282
358
|
!Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
|
|
283
359
|
throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
|
|
284
360
|
}
|
|
@@ -307,16 +383,33 @@ export function validatePrinterState(status, requirements) {
|
|
|
307
383
|
if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
|
|
308
384
|
const materials = requirements.materials ?? [];
|
|
309
385
|
const positions = requirements.usedFilamentPositions ?? materials.map((_, index) => index);
|
|
386
|
+
// 255 is the right/main nozzle external spool only on dual-nozzle printers.
|
|
387
|
+
const dualNozzle = isDualNozzleModel(model);
|
|
388
|
+
const maxTray = dualNozzle ? 255 : 254;
|
|
310
389
|
if (!positions.length)
|
|
311
390
|
throw new Error("A declared material is required for each used filament or external spool.");
|
|
312
391
|
for (const position of positions) {
|
|
313
392
|
const material = normalizeMaterial(materials[position]);
|
|
314
393
|
if (!Number.isInteger(position) || position < 0 || !material)
|
|
315
394
|
throw new Error(`A known declared material is required for filament position ${position}.`);
|
|
316
|
-
const
|
|
317
|
-
|
|
395
|
+
const physicalNozzle = requirements.filamentNozzleIndices?.[position];
|
|
396
|
+
const externalForNozzle = physicalNozzle === 0 ? 255 : physicalNozzle === 1 ? 254 : undefined;
|
|
397
|
+
const trayIndex = requirements.useAMS || requirements.amsMapping !== undefined
|
|
398
|
+
? requirements.amsMapping?.[position] : dualNozzle ? externalForNozzle : 254;
|
|
399
|
+
if (!requirements.useAMS && dualNozzle && requirements.amsMapping === undefined && externalForNozzle === undefined) {
|
|
400
|
+
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.`);
|
|
401
|
+
}
|
|
402
|
+
if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > maxTray)
|
|
318
403
|
throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
|
|
319
|
-
|
|
404
|
+
if (!requirements.useAMS && trayIndex < 254)
|
|
405
|
+
throw new Error("External-spool printing requires an external spool mapping; AMS trays require use_ams:true.");
|
|
406
|
+
if (dualNozzle && trayIndex >= 254) {
|
|
407
|
+
if (externalForNozzle === undefined)
|
|
408
|
+
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.`);
|
|
409
|
+
if (trayIndex !== externalForNozzle)
|
|
410
|
+
throw new Error(`External spool ${trayIndex} does not feed physical nozzle ${physicalNozzle} for filament position ${position}; use spool ${externalForNozzle}.`);
|
|
411
|
+
}
|
|
412
|
+
const tray = trayIndex >= 254 ? externalTray(raw, trayIndex, model) : mappedTray(raw, trayIndex);
|
|
320
413
|
const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
|
|
321
414
|
if (reportedType && normalizeMaterial(reportedType) !== material)
|
|
322
415
|
throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
|
|
@@ -337,7 +430,7 @@ export function manualHeatingRequirements(status, model, nozzleDiameter, materia
|
|
|
337
430
|
const raw = status?.raw;
|
|
338
431
|
if (!object(raw))
|
|
339
432
|
throw new Error("A fresh printer report is required for manual nozzle heating.");
|
|
340
|
-
if (
|
|
433
|
+
if (isDualNozzleModel(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
|
|
341
434
|
throw new Error("Manual heating cannot verify the active nozzle and loaded material on this multi-nozzle printer. Use a checked sliced print instead.");
|
|
342
435
|
}
|
|
343
436
|
const requirements = {
|
|
@@ -228,6 +228,29 @@ function enforceMatchingNozzles(arr) {
|
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* BambuStudio picks each per-variant machine, process, and filament value
|
|
233
|
+
* (e.g. filament_max_volumetric_speed) by "<extruder_type> <nozzle_volume_type>"
|
|
234
|
+
* among the variants each extruder declares in extruder_variant_list. A flow
|
|
235
|
+
* type the preset doesn't declare has no values to pick, so refuse it instead
|
|
236
|
+
* of labelling a job for a nozzle it wasn't sliced for.
|
|
237
|
+
*/
|
|
238
|
+
function assertNozzleVariantDeclared(flat, volumeType, machineLeaf) {
|
|
239
|
+
const strings = (key) => Array.isArray(flat[key]) ? flat[key].map(String) : [];
|
|
240
|
+
const extruderTypes = strings("extruder_type");
|
|
241
|
+
const declaredLists = strings("extruder_variant_list");
|
|
242
|
+
const count = flat["nozzle_volume_type"].length;
|
|
243
|
+
for (let i = 0; i < count; i++) {
|
|
244
|
+
const extruderType = extruderTypes[i] ?? extruderTypes[0];
|
|
245
|
+
const declared = (declaredLists[i] ?? declaredLists[0] ?? "").split(",").map((v) => v.trim()).filter(Boolean);
|
|
246
|
+
const matches = (variant) => extruderType ? variant === `${extruderType} ${volumeType}` : variant.endsWith(` ${volumeType}`);
|
|
247
|
+
// Presets without variant declarations only have Standard values.
|
|
248
|
+
if (declared.length === 0 ? volumeType === "Standard" : declared.some(matches))
|
|
249
|
+
continue;
|
|
250
|
+
throw new Error(`Slicing for a ${volumeType} nozzle is refused: "${machineLeaf}" declares no ${volumeType} variant ` +
|
|
251
|
+
`for extruder ${i + 1} (declared: ${declared.join(", ") || "none"}).`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
231
254
|
/** Best-effort extruder count for fallback nozzle_volume_type sizing. */
|
|
232
255
|
/** BambuStudio's built-in filament_colour default. */
|
|
233
256
|
export const DEFAULT_FILAMENT_COLOUR = "#00AE42";
|
|
@@ -545,6 +568,8 @@ export async function flattenForCli(opts) {
|
|
|
545
568
|
});
|
|
546
569
|
// CLI-specific post-processing on machine profile only.
|
|
547
570
|
deriveNozzleVolumeType(machineFlat, opts.nozzleVolumeType);
|
|
571
|
+
if (opts.nozzleVolumeType)
|
|
572
|
+
assertNozzleVariantDeclared(machineFlat, opts.nozzleVolumeType, opts.machineLeaf);
|
|
548
573
|
applyMachineModelBedMetadata(machineFlat, machineIndex);
|
|
549
574
|
// A standalone custom machine can accompany process/filament profiles that
|
|
550
575
|
// need BBL resolution; do not impose bundled model config on that machine.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BambuSliceOptions } from '../stl/stl-manipulator.js';
|
|
2
|
+
export declare function validateRemoteSliceOptions(options?: BambuSliceOptions): number;
|
|
3
|
+
/** Upload only locally prepared profiles, then inspect the exact downloaded 3MF. */
|
|
4
|
+
export declare function sliceWithOrcaApi(input: {
|
|
5
|
+
inputPath: string;
|
|
6
|
+
machinePath: string;
|
|
7
|
+
processPath: string;
|
|
8
|
+
filamentPaths: string[];
|
|
9
|
+
outputPath: string;
|
|
10
|
+
options?: BambuSliceOptions;
|
|
11
|
+
isCancelled: () => boolean;
|
|
12
|
+
}): Promise<string>;
|