bambu-printer-mcp 1.1.28 → 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 +3 -1
- package/README.md +4 -2
- package/dist/ams-mapping.d.ts +18 -2
- package/dist/ams-mapping.js +36 -5
- package/dist/index.js +53 -45
- package/dist/printers/bambu.js +14 -16
- 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 +36 -6
- package/dist/safety/printer-state.d.ts +2 -0
- package/dist/safety/printer-state.js +35 -9
- package/dist/slicer/remote-slicer.d.ts +12 -0
- package/dist/slicer/remote-slicer.js +240 -0
- package/dist/stl/stl-manipulator.d.ts +1 -1
- package/dist/stl/stl-manipulator.js +70 -42
- package/package.json +1 -1
- package/src/ams-mapping.ts +38 -5
- package/src/index.ts +49 -42
- package/src/printers/bambu.ts +14 -13
- package/src/safety/limits.ts +9 -0
- package/src/safety/print-file.ts +34 -6
- package/src/safety/printer-state.ts +32 -9
- package/src/slicer/remote-slicer.ts +216 -0
- package/src/stl/stl-manipulator.ts +68 -43
package/src/index.ts
CHANGED
|
@@ -40,12 +40,13 @@ import {
|
|
|
40
40
|
} from "./bambu-native.js";
|
|
41
41
|
import { importFileViaBambuConnect } from "./bambu-connect.js";
|
|
42
42
|
import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
|
|
43
|
-
import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
|
|
43
|
+
import { bridgeAmsMapping2Entry, bridgeAmsMappingValue, externalTrayForNozzle, hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
|
|
44
44
|
import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
|
|
45
45
|
import type { ThreeMFAmsRequirements } from "./types.js";
|
|
46
46
|
import { BambuImplementation, assertDirectPrintSupported } from "./printers/bambu.js";
|
|
47
47
|
import { inspectPrintFile } from "./safety/print-file.js";
|
|
48
48
|
import { validatePrinterState } from "./safety/printer-state.js";
|
|
49
|
+
import { isDualNozzleModel } from "./safety/limits.js";
|
|
49
50
|
import { withPrinterOperation, withPrintSnapshot, uniquePrintName } from "./safety/artifact.js";
|
|
50
51
|
import { detectProfilesRoot } from "./slicer/profile-flatten.js";
|
|
51
52
|
|
|
@@ -703,7 +704,7 @@ function resolveSlicerConfig(args: Record<string, unknown> | undefined): {
|
|
|
703
704
|
slicerProfile: string;
|
|
704
705
|
} {
|
|
705
706
|
const slicerType = normalizeSlicerType(String(args?.slicer_type || DEFAULT_SLICER_TYPE));
|
|
706
|
-
const slicerPath = String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
|
|
707
|
+
const slicerPath = slicerType === "orca-slicer-api" ? "" : String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
|
|
707
708
|
const slicerProfile = String(args?.slicer_profile || DEFAULT_SLICER_PROFILE);
|
|
708
709
|
return { slicerType, slicerPath, slicerProfile };
|
|
709
710
|
}
|
|
@@ -994,14 +995,9 @@ function stringifyBridgeJson(value: unknown): string | undefined {
|
|
|
994
995
|
return JSON.stringify(value);
|
|
995
996
|
}
|
|
996
997
|
|
|
997
|
-
function nativeAmsMapping2(mapping: number[] | undefined): string | undefined {
|
|
998
|
+
function nativeAmsMapping2(mapping: number[] | undefined, dualNozzle = false): string | undefined {
|
|
998
999
|
if (!mapping || mapping.length === 0) return undefined;
|
|
999
|
-
return JSON.stringify(mapping.map((value) =>
|
|
1000
|
-
if (value < 0 || value === 255) return { ams_id: 255, slot_id: 255 };
|
|
1001
|
-
if (value === 254) return { ams_id: 254, slot_id: 254 };
|
|
1002
|
-
if (value >= 128) return { ams_id: 128, slot_id: value - 128 };
|
|
1003
|
-
return { ams_id: Math.floor(value / 4), slot_id: value % 4 };
|
|
1004
|
-
}));
|
|
1000
|
+
return JSON.stringify(mapping.map((value) => bridgeAmsMapping2Entry(value, dualNozzle)));
|
|
1005
1001
|
}
|
|
1006
1002
|
|
|
1007
1003
|
function redactPrintParams(params: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -1357,6 +1353,9 @@ class BambuPrinterMCPServer {
|
|
|
1357
1353
|
): ReturnType<typeof resolveSlicerConfig> {
|
|
1358
1354
|
const config = resolveSlicerConfig(args);
|
|
1359
1355
|
if (args?.slicer_path !== undefined) {
|
|
1356
|
+
if (config.slicerType === "orca-slicer-api") {
|
|
1357
|
+
throw new Error(`${toolName}: remote slicing uses trusted SLICER_API_URL and BAMBU_PROFILES_ROOT configuration; slicer_path is unsupported.`);
|
|
1358
|
+
}
|
|
1360
1359
|
const resolvedSlicerPath = this.resolveExecutableSelectorArg(
|
|
1361
1360
|
args.slicer_path,
|
|
1362
1361
|
toolName,
|
|
@@ -1397,10 +1396,10 @@ class BambuPrinterMCPServer {
|
|
|
1397
1396
|
|
|
1398
1397
|
console.error(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
|
|
1399
1398
|
const autoSliceOptions: BambuSliceOptions = {
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1399
|
+
...(slicerType === "orca-slicer-api" ? {} : {
|
|
1400
|
+
uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
|
|
1401
|
+
}),
|
|
1402
|
+
...(slicerType === "orca-slicer-api" ? { slicePlate: Number(args.plate_index ?? 0) + 1 } : {}),
|
|
1404
1403
|
bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
|
|
1405
1404
|
nozzleType: resolveNozzleType(args?.nozzle_type),
|
|
1406
1405
|
nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
|
|
@@ -1426,9 +1425,10 @@ class BambuPrinterMCPServer {
|
|
|
1426
1425
|
): Promise<{ useAMS: boolean; finalAmsMapping?: number[]; finalAmsSlots?: number[] }> {
|
|
1427
1426
|
const parsed3MFData = await parse3MF(threeMFPath);
|
|
1428
1427
|
let parsedAmsMapping: number[] | undefined;
|
|
1428
|
+
const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
|
|
1429
1429
|
|
|
1430
1430
|
if (parsed3MFData.slicerConfig?.ams_mapping) {
|
|
1431
|
-
const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown
|
|
1431
|
+
const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
|
|
1432
1432
|
if (slots.length > 0) {
|
|
1433
1433
|
parsedAmsMapping = slots;
|
|
1434
1434
|
}
|
|
@@ -1444,10 +1444,10 @@ class BambuPrinterMCPServer {
|
|
|
1444
1444
|
let userMappingOverride: number[] | undefined;
|
|
1445
1445
|
if (Array.isArray(args.ams_mapping)) {
|
|
1446
1446
|
userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
|
|
1447
|
-
normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]
|
|
1447
|
+
normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions)
|
|
1448
1448
|
);
|
|
1449
1449
|
} else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
|
|
1450
|
-
userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown
|
|
1450
|
+
userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
|
|
1451
1451
|
}
|
|
1452
1452
|
if (userMappingOverride && userMappingOverride.length > 0) {
|
|
1453
1453
|
finalAmsMapping = userMappingOverride;
|
|
@@ -1458,7 +1458,7 @@ class BambuPrinterMCPServer {
|
|
|
1458
1458
|
|
|
1459
1459
|
if (!hasUserAmsMapping && hasUserAmsSlots) {
|
|
1460
1460
|
const userSlots = args.ams_slots.map((slot: unknown, i: number) =>
|
|
1461
|
-
normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]
|
|
1461
|
+
normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]`, trayOptions)
|
|
1462
1462
|
);
|
|
1463
1463
|
finalAmsSlots = userSlots;
|
|
1464
1464
|
finalAmsMapping = undefined;
|
|
@@ -1487,8 +1487,8 @@ class BambuPrinterMCPServer {
|
|
|
1487
1487
|
}
|
|
1488
1488
|
|
|
1489
1489
|
if (args?.use_ams === false) {
|
|
1490
|
-
finalAmsMapping = undefined;
|
|
1491
|
-
finalAmsSlots = undefined;
|
|
1490
|
+
if (!hasUserAmsMapping) finalAmsMapping = undefined;
|
|
1491
|
+
if (!hasUserAmsSlots) finalAmsSlots = undefined;
|
|
1492
1492
|
useAMS = false;
|
|
1493
1493
|
}
|
|
1494
1494
|
if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
|
|
@@ -1589,8 +1589,16 @@ class BambuPrinterMCPServer {
|
|
|
1589
1589
|
inspection.usedFilamentPositions.forEach((position, index) => { mapping![position] = finalAmsSlots[index]; });
|
|
1590
1590
|
}
|
|
1591
1591
|
if (!useAMS) {
|
|
1592
|
-
mapping
|
|
1593
|
-
|
|
1592
|
+
mapping ??= Array(inspection.materials.length).fill(-1);
|
|
1593
|
+
const dualNozzle = isDualNozzleModel(printModel);
|
|
1594
|
+
for (const position of inspection.usedFilamentPositions) {
|
|
1595
|
+
if (mapping[position] === undefined || mapping[position] < 0) {
|
|
1596
|
+
mapping[position] = externalTrayForNozzle(inspection.filamentNozzleIndices?.[position], dualNozzle);
|
|
1597
|
+
}
|
|
1598
|
+
if (mapping[position] !== 254 && !(dualNozzle && mapping[position] === 255)) {
|
|
1599
|
+
throw new Error("use_ams:false requires an external-spool mapping for every used filament.");
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1594
1602
|
}
|
|
1595
1603
|
const requirements = { ...inspection, amsMapping: mapping, useAMS };
|
|
1596
1604
|
const initialStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
|
|
@@ -1599,7 +1607,7 @@ class BambuPrinterMCPServer {
|
|
|
1599
1607
|
const confirmedStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
|
|
1600
1608
|
validatePrinterState(confirmedStatus, requirements);
|
|
1601
1609
|
this.bambu.assertBedClearance(confirmedStatus, bedClearance);
|
|
1602
|
-
const amsMapping = stringifyBridgeJson(mapping);
|
|
1610
|
+
const amsMapping = stringifyBridgeJson(mapping?.map(value => bridgeAmsMappingValue(value, isDualNozzleModel(printModel))));
|
|
1603
1611
|
const remoteFileName = uniquePrintName(threeMFPath);
|
|
1604
1612
|
const params: Record<string, unknown> = {
|
|
1605
1613
|
dev_id: devId,
|
|
@@ -1616,7 +1624,7 @@ class BambuPrinterMCPServer {
|
|
|
1616
1624
|
ftp_file_md5: String(args?.ftp_file_md5 || ""),
|
|
1617
1625
|
nozzle_mapping: stringifyBridgeJson(args?.nozzle_mapping) || "",
|
|
1618
1626
|
ams_mapping: amsMapping || "",
|
|
1619
|
-
ams_mapping2:
|
|
1627
|
+
ams_mapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)) || "",
|
|
1620
1628
|
ams_mapping_info: stringifyBridgeJson(args?.ams_mapping_info) || "",
|
|
1621
1629
|
nozzles_info: stringifyBridgeJson(args?.nozzles_info) || "",
|
|
1622
1630
|
connection_type: connectionType,
|
|
@@ -1657,7 +1665,7 @@ class BambuPrinterMCPServer {
|
|
|
1657
1665
|
host: devIp, serial: devId, token: password || bambuToken, filePath: snapshot,
|
|
1658
1666
|
projectName: String(params.task_name), presetName, remoteName: remoteFileName,
|
|
1659
1667
|
plateIndex, bedType: printBedType, useAMS,
|
|
1660
|
-
amsMapping, amsMapping2: nativeAmsMapping2(mapping),
|
|
1668
|
+
amsMapping, amsMapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)),
|
|
1661
1669
|
bedLeveling: params.task_bed_leveling as boolean,
|
|
1662
1670
|
flowCalibration: params.task_flow_cali as boolean,
|
|
1663
1671
|
vibrationCalibration: params.task_vibration_cali as boolean,
|
|
@@ -1916,10 +1924,9 @@ class BambuPrinterMCPServer {
|
|
|
1916
1924
|
);
|
|
1917
1925
|
}
|
|
1918
1926
|
const autoSliceOptions: BambuSliceOptions = {
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
skipModifiedGcodes: true,
|
|
1927
|
+
...(slicerType === "orca-slicer-api" ? {} : {
|
|
1928
|
+
uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
|
|
1929
|
+
}),
|
|
1923
1930
|
bedType,
|
|
1924
1931
|
};
|
|
1925
1932
|
|
|
@@ -2470,7 +2477,7 @@ class BambuPrinterMCPServer {
|
|
|
2470
2477
|
rotate_y: { type: "number", description: "Rotate the model around the Y-axis by this many degrees before slicing. Useful for reorienting prints for better layer adhesion." },
|
|
2471
2478
|
min_save: { type: "boolean", description: "Write a smaller output 3MF by omitting non-essential metadata. Reduces file size for faster FTP upload to the printer." },
|
|
2472
2479
|
skip_modified_gcodes: { type: "boolean", description: "Strip custom start/end gcodes embedded in the 3MF. Recommended for downloaded 3MFs since custom gcodes from other users' profiles may be unsafe for your printer." },
|
|
2473
|
-
slice_plate: { type: "number", description: "Which plate index to slice. 0 = all plates
|
|
2480
|
+
slice_plate: { type: "number", description: "Which plate index to slice. 0 = all plates for local slicers; remote slicing defaults to plate 1 and rejects 0. Use 1, 2, etc. to slice only a specific plate in multi-plate 3MF projects." }
|
|
2474
2481
|
},
|
|
2475
2482
|
required: ["stl_path", "bambu_model"]
|
|
2476
2483
|
}
|
|
@@ -2573,8 +2580,8 @@ class BambuPrinterMCPServer {
|
|
|
2573
2580
|
nozzle_diameter: { type: "string", description: "Nozzle diameter in mm for auto-slicing (default: 0.4)." },
|
|
2574
2581
|
nozzle_diameters: { type: "array", minItems: 1, maxItems: 2, items: { type: "number", enum: [0.2, 0.4, 0.6, 0.8] }, description: "Complete per-nozzle diameters for pre-sliced jobs, e.g. [0.4, 0.6]. Omit to verify file metadata against every reported nozzle; cannot combine with nozzle_diameter." },
|
|
2575
2582
|
use_ams: { type: "boolean", description: "Whether to use the AMS; defaults to auto-detect from the 3MF mapping." },
|
|
2576
|
-
ams_mapping: { type: "array", description: "AMS slot mapping array used by both local MCP printing and FULU PrintParams.", items: { type: "number" } },
|
|
2577
|
-
ams_slots: { type: "array", description: "Per-used-filament AMS slot list, matching the local LAN print path.", items: { type: "number" } },
|
|
2583
|
+
ams_mapping: { type: "array", description: "AMS slot mapping array used by both local MCP printing and FULU PrintParams. Same tray values as print_3mf: 254 = external spool; on dual-nozzle printers 254 = left/deputy nozzle external and 255 = right/main nozzle external.", items: { type: "number" } },
|
|
2584
|
+
ams_slots: { type: "array", description: "Per-used-filament AMS slot list, matching the local LAN print path (254/255 external spool rules as in print_3mf).", items: { type: "number" } },
|
|
2578
2585
|
ams_mapping_bridge: { type: "string", description: "Raw JSON string override for FULU PrintParams ams_mapping when the automatic array is not enough." },
|
|
2579
2586
|
ams_mapping2: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping2, matching OrcaSlicer-bambulab's v1 AMS mapping field." },
|
|
2580
2587
|
ams_mapping_info: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping_info, matching OrcaSlicer-bambulab's detailed AMS mapping field." },
|
|
@@ -3002,12 +3009,12 @@ class BambuPrinterMCPServer {
|
|
|
3002
3009
|
use_ams: { type: "boolean", description: "Whether to use the AMS (default: auto-detect from 3MF)" },
|
|
3003
3010
|
ams_mapping: {
|
|
3004
3011
|
type: "array",
|
|
3005
|
-
description: "Project-level AMS mapping array. Position = project filament index, value = absolute AMS tray (0-3=AMS 0, 4-7=AMS 1, 8-11=AMS 2, 128+=AMS-HT, 254=external, -1=unused). Prefer ams_slots unless you know the project-level layout.",
|
|
3012
|
+
description: "Project-level AMS mapping array. Position = project filament index, value = absolute AMS tray (0-3=AMS 0, 4-7=AMS 1, 8-11=AMS 2, 128+=AMS-HT, 254=external, -1=unused). On dual-nozzle printers (H2D, H2D Pro, H2C, X2D) 254 is the LEFT/deputy nozzle external spool and 255 the RIGHT/main nozzle external spool, matching Bambu Studio's virtual tray ids; the gcode header's filament_map (1=left, 2=right) says which nozzle each filament was sliced for. Prefer ams_slots unless you know the project-level layout.",
|
|
3006
3013
|
items: { type: "number" }
|
|
3007
3014
|
},
|
|
3008
3015
|
ams_slots: {
|
|
3009
3016
|
type: "array",
|
|
3010
|
-
description: "Preferred AMS input: one absolute tray index per USED filament in plate order, e.g. [1] for a single-filament print pulling from AMS 0 slot 1. Expanded to project-level ams_mapping automatically from the 3MF's plate_N.json and gcode header.",
|
|
3017
|
+
description: "Preferred AMS input: one absolute tray index per USED filament in plate order, e.g. [1] for a single-filament print pulling from AMS 0 slot 1. Use 254 for the external spool (on dual-nozzle printers: 254 = left/deputy nozzle external, 255 = right/main nozzle external). Expanded to project-level ams_mapping automatically from the 3MF's plate_N.json and gcode header.",
|
|
3011
3018
|
items: { type: "number" }
|
|
3012
3019
|
},
|
|
3013
3020
|
auto_match_ams: {
|
|
@@ -4098,10 +4105,9 @@ class BambuPrinterMCPServer {
|
|
|
4098
4105
|
}
|
|
4099
4106
|
console.error(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
|
|
4100
4107
|
const autoSliceOptions: BambuSliceOptions = {
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
skipModifiedGcodes: true,
|
|
4108
|
+
...(slicerType === "orca-slicer-api" ? { slicePlate: plateIndex + 1 } : {
|
|
4109
|
+
uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
|
|
4110
|
+
}),
|
|
4105
4111
|
bedType: printBedType,
|
|
4106
4112
|
nozzleType: resolveNozzleType(args?.nozzle_type),
|
|
4107
4113
|
nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
|
|
@@ -4133,9 +4139,10 @@ class BambuPrinterMCPServer {
|
|
|
4133
4139
|
|
|
4134
4140
|
const parsed3MFData = await parse3MF(threeMFPath);
|
|
4135
4141
|
const isH2Print = H2_BAMBU_MODELS.has(printModel);
|
|
4142
|
+
const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
|
|
4136
4143
|
let parsedAmsMapping: number[] | undefined;
|
|
4137
4144
|
if (!isH2Print && parsed3MFData.slicerConfig?.ams_mapping) {
|
|
4138
|
-
const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown
|
|
4145
|
+
const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
|
|
4139
4146
|
if (slots.length > 0) {
|
|
4140
4147
|
parsedAmsMapping = slots;
|
|
4141
4148
|
}
|
|
@@ -4151,10 +4158,10 @@ class BambuPrinterMCPServer {
|
|
|
4151
4158
|
let userMappingOverride: number[] | undefined;
|
|
4152
4159
|
if (Array.isArray(args.ams_mapping)) {
|
|
4153
4160
|
userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
|
|
4154
|
-
normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]
|
|
4161
|
+
normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions)
|
|
4155
4162
|
);
|
|
4156
4163
|
} else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
|
|
4157
|
-
userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown
|
|
4164
|
+
userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
|
|
4158
4165
|
}
|
|
4159
4166
|
if (userMappingOverride && userMappingOverride.length > 0) {
|
|
4160
4167
|
finalAmsMapping = userMappingOverride;
|
|
@@ -4208,8 +4215,8 @@ class BambuPrinterMCPServer {
|
|
|
4208
4215
|
}
|
|
4209
4216
|
|
|
4210
4217
|
if (args?.use_ams === false) {
|
|
4211
|
-
finalAmsMapping = undefined;
|
|
4212
|
-
finalAmsSlots = undefined;
|
|
4218
|
+
if (!hasUserAmsMapping) finalAmsMapping = undefined;
|
|
4219
|
+
if (!hasUserAmsSlots) finalAmsSlots = undefined;
|
|
4213
4220
|
useAMS = false;
|
|
4214
4221
|
}
|
|
4215
4222
|
if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
|
package/src/printers/bambu.ts
CHANGED
|
@@ -7,7 +7,8 @@ import os from "node:os";
|
|
|
7
7
|
import { Readable } from "node:stream";
|
|
8
8
|
import { readSafe3mfArchive } from "../safety/archive.js";
|
|
9
9
|
import { inspectPrintFile, type PrintFileInspection } from "../safety/print-file.js";
|
|
10
|
-
import { normalizeModel, validateTemperature } from "../safety/limits.js";
|
|
10
|
+
import { isDualNozzleModel, normalizeModel, validateTemperature } from "../safety/limits.js";
|
|
11
|
+
import { bridgeAmsMapping2Entry, bridgeAmsMappingValue, externalTrayForNozzle, describeAmsTrayRange } from "../ams-mapping.js";
|
|
11
12
|
import { describeHmsKey, hiddenHmsKeys, hiddenHmsNotices, reportedHmsKey, validatePrinterState, manualHeatingRequirements, type PrinterStateRequirements } from "../safety/printer-state.js";
|
|
12
13
|
import { withPrinterOperation, withPrintSnapshot, uniquePrintName, normalizedRemotePath, cancelPendingPrinterOperations } from "../safety/artifact.js";
|
|
13
14
|
import { Client as FTPClient } from "basic-ftp";
|
|
@@ -747,15 +748,20 @@ export class BambuImplementation {
|
|
|
747
748
|
// `plate_N.json.filament_ids` -- and expand to a full project-level
|
|
748
749
|
// array here. `amsMapping` is the raw escape hatch (takes precedence
|
|
749
750
|
// when both are supplied).
|
|
751
|
+
// Dual-nozzle printers address two external spools: 254 feeds the left/deputy
|
|
752
|
+
// nozzle and 255 the right/main nozzle (Bambu Studio VIRTUAL_TRAY_DEPUTY_ID /
|
|
753
|
+
// VIRTUAL_TRAY_MAIN_ID). Single-nozzle printers keep 254 as the only external tray.
|
|
754
|
+
const dualNozzle = isDualNozzleModel(model);
|
|
755
|
+
const externalTrays = dualNozzle ? [254, 255] : [254];
|
|
750
756
|
const validateTrayValue = (v: unknown, label: string): void => {
|
|
751
757
|
if (
|
|
752
758
|
!Number.isInteger(v) ||
|
|
753
759
|
(v as number) < -1 ||
|
|
754
760
|
((v as number) > 15 && (v as number) < 128) ||
|
|
755
|
-
(v as number) > 254
|
|
761
|
+
(v as number) > (dualNozzle ? 255 : 254)
|
|
756
762
|
) {
|
|
757
763
|
throw new Error(
|
|
758
|
-
`${label} values must be integers in
|
|
764
|
+
`${label} values must be integers in ${describeAmsTrayRange({ dualNozzle })}; got ${v}`
|
|
759
765
|
);
|
|
760
766
|
}
|
|
761
767
|
};
|
|
@@ -794,7 +800,7 @@ export class BambuImplementation {
|
|
|
794
800
|
);
|
|
795
801
|
baseMapping = Array<number>(projectLen).fill(-1);
|
|
796
802
|
positions.forEach((pos, i) => {
|
|
797
|
-
baseMapping[pos] = options.useAMS === false ?
|
|
803
|
+
baseMapping[pos] = options.useAMS === false ? externalTrayForNozzle(inspection.filamentNozzleIndices?.[pos], dualNozzle) : i;
|
|
798
804
|
});
|
|
799
805
|
}
|
|
800
806
|
|
|
@@ -805,12 +811,7 @@ export class BambuImplementation {
|
|
|
805
811
|
amsMapping = Array.from({ length: projLen }, (_, i) =>
|
|
806
812
|
i < baseMapping.length ? baseMapping[i] : -1
|
|
807
813
|
);
|
|
808
|
-
amsMapping2 = amsMapping.map((v) =>
|
|
809
|
-
if (v < 0 || v === 255) return { ams_id: 255, slot_id: 255 };
|
|
810
|
-
if (v === 254) return { ams_id: 254, slot_id: 254 };
|
|
811
|
-
if (v >= 128) return { ams_id: 128, slot_id: v - 128 };
|
|
812
|
-
return { ams_id: Math.floor(v / 4), slot_id: v % 4 };
|
|
813
|
-
});
|
|
814
|
+
amsMapping2 = amsMapping.map((v) => bridgeAmsMapping2Entry(v, dualNozzle));
|
|
814
815
|
} else {
|
|
815
816
|
amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) =>
|
|
816
817
|
i < baseMapping.length ? baseMapping[i] : -1
|
|
@@ -822,8 +823,8 @@ export class BambuImplementation {
|
|
|
822
823
|
if (baseMapping[position] === undefined || baseMapping[position] < 0) {
|
|
823
824
|
throw new Error(`Missing physical filament mapping for project filament ${position}.`);
|
|
824
825
|
}
|
|
825
|
-
if (options.useAMS === false && baseMapping[position]
|
|
826
|
-
throw new Error(
|
|
826
|
+
if (options.useAMS === false && !externalTrays.includes(baseMapping[position])) {
|
|
827
|
+
throw new Error(`External-spool printing requires external spool mapping (${externalTrays.join(" or ")}).`);
|
|
827
828
|
}
|
|
828
829
|
}
|
|
829
830
|
const legacyContainer = options.filePath.toLowerCase().endsWith(".gcode.3mf") && !usesH2ProjectFile && !isA1;
|
|
@@ -898,7 +899,7 @@ export class BambuImplementation {
|
|
|
898
899
|
project_id: submissionId,
|
|
899
900
|
subtask_id: submissionId,
|
|
900
901
|
task_id: submissionId,
|
|
901
|
-
ams_mapping: amsMapping,
|
|
902
|
+
ams_mapping: amsMapping.map(value => bridgeAmsMappingValue(value, dualNozzle)),
|
|
902
903
|
ams_mapping2: amsMapping2,
|
|
903
904
|
},
|
|
904
905
|
};
|
package/src/safety/limits.ts
CHANGED
|
@@ -31,6 +31,15 @@ export const MACHINE_LIMITS: Readonly<Record<string, {nozzle: number; bed: numbe
|
|
|
31
31
|
x2d: {nozzle:300,bed:120,chamber:65,volume:[256,256,260]},
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
/** Printers with two physical nozzles. Bambu Studio numbers the right/main nozzle 0
|
|
35
|
+
* (MAIN_EXTRUDER_ID) and the left/deputy nozzle 1 (DEPUTY_EXTRUDER_ID), and reports one
|
|
36
|
+
* virtual external-spool slot per nozzle: 255 (VIRTUAL_TRAY_MAIN_ID) and 254
|
|
37
|
+
* (VIRTUAL_TRAY_DEPUTY_ID). The single-nozzle H2S is deliberately absent. */
|
|
38
|
+
export const DUAL_NOZZLE_MODELS: ReadonlySet<string> = new Set(['h2d','h2dpro','h2c','x2d']);
|
|
39
|
+
export function isDualNozzleModel(model: unknown): boolean {
|
|
40
|
+
const normalized = normalizeModel(model);
|
|
41
|
+
return normalized !== undefined && DUAL_NOZZLE_MODELS.has(normalized);
|
|
42
|
+
}
|
|
34
43
|
export function normalizeModel(value: unknown): string | undefined {
|
|
35
44
|
if (typeof value !== 'string') return undefined;
|
|
36
45
|
let model = value.trim().toLowerCase().replace(/^bambu\s*(?:lab\s*)?/, '').replace(/[\s_-]/g, '');
|
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+)$/;
|
|
@@ -140,6 +142,7 @@ function validateBounds(headers: Metadata[], model: string, plate: Metadata): vo
|
|
|
140
142
|
export async function inspectPrintFile(filePath: string, options: {model:string; nozzleDiameters?:number[]; plateIndex?:number; bedType?:string}): Promise<PrintFileInspection> {
|
|
141
143
|
const model=normalizeModel(options.model);
|
|
142
144
|
if(!model) return fail(`unknown or missing printer model '${options.model}'`);
|
|
145
|
+
const dualNozzle=isDualNozzleModel(model);
|
|
143
146
|
const plateIndex=options.plateIndex ?? 0;
|
|
144
147
|
if(!Number.isInteger(plateIndex)||plateIndex<0) return fail('selected plate index must be a nonnegative integer');
|
|
145
148
|
if(/\.md5$/i.test(filePath)) return fail('a checksum file is not a printable artifact');
|
|
@@ -237,6 +240,22 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
237
240
|
const materials=consistent(metadataValues(metadata,['filament_type']),value=>{
|
|
238
241
|
const values=list(value);return values.length && values.every(v=>!!normalizeMaterial(v))?values.map(v=>normalizeMaterial(v)!):undefined;
|
|
239
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;
|
|
240
259
|
const bedValues=metadataValues([...metadata,plate],['curr_bed_type','bed_type']);
|
|
241
260
|
const bedType=bedValues.length?consistent(bedValues,normalizeBed,'bed type'):undefined;
|
|
242
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');
|
|
@@ -278,7 +297,10 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
278
297
|
try {
|
|
279
298
|
if(isLayerMarker(lines[index]) || /^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(lines[index])) closeStartupWindow();
|
|
280
299
|
let line=stripComments(lines[index]);if(!line||line==='%') continue;
|
|
281
|
-
//
|
|
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;
|
|
282
304
|
if(line==='========== record data ==========' && h2cStartup(index)) continue;
|
|
283
305
|
line=line.replace(/^N\d+\s*/i,'').replace(/\*\d+\s*$/,'');
|
|
284
306
|
if(/[{}\[\]#]/.test(line)) fail('unresolved dynamic command syntax');
|
|
@@ -355,17 +377,23 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
355
377
|
heat('bed',args.get('D')!,undefined,true);continue;
|
|
356
378
|
}
|
|
357
379
|
const vendorWait=code==='M109' && ['a1','a1mini'].includes(model);
|
|
358
|
-
|
|
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`);
|
|
359
387
|
if(args.has('H') && (!Number.isFinite(args.get('H')) || args.get('H')!<0 || args.get('H')!>300)) fail(`unsupported ${code} wait parameter H`);
|
|
360
388
|
if(!args.has('S')&&!args.has('R')) fail(`missing ${code} temperature target`);
|
|
361
389
|
const component= ['M104','M109'].includes(code)?'nozzle': ['M140','M190'].includes(code)?'bed':'chamber';
|
|
362
390
|
// Heater T is a physical nozzle, unlike standalone remapped T filament selection.
|
|
363
391
|
// Without a verified per-filament nozzle map, apply all possible materials.
|
|
364
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');
|
|
365
|
-
const position= args.has('T') ? undefined : active;
|
|
393
|
+
const position= args.has('T') || args.has('N') ? undefined : active;
|
|
366
394
|
const commonFlush=code==='M109' && args.size===1 && args.get('S')===290 && model==='x1e' && !depositionStarted && !commonFlushUsed && boundedX1ePurge(lines,index);
|
|
367
395
|
if(commonFlush) commonFlushUsed=true;
|
|
368
|
-
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);
|
|
369
397
|
} else if(code==='M620' || code==='M621') {
|
|
370
398
|
const args=parameters(argumentsText,model==='x2d'?'MAB':model==='h2c'?'MAN':'MA');
|
|
371
399
|
if(args.has('N') && (code!=='M620' || !h2cStartup(index) || args.size!==1 || !Number.isNaN(args.get('N')!))) fail('unsupported H2C startup hotend-remap flag');
|
|
@@ -464,5 +492,5 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
464
492
|
}
|
|
465
493
|
if(!commandCount) return fail('selected G-code contains no printable commands');
|
|
466
494
|
if(!used.size) materials.forEach((_,index)=>used.add(index));
|
|
467
|
-
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};
|
|
468
496
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { HIDDEN_HMS } from "./hidden-hms.js";
|
|
2
2
|
import { BambuClient, GetVersionCommand, PushAllCommand } from "bambu-node";
|
|
3
|
-
import { normalizeMaterial, normalizeModel } from "./limits.js";
|
|
3
|
+
import { isDualNozzleModel, normalizeMaterial, normalizeModel } from "./limits.js";
|
|
4
4
|
|
|
5
5
|
const MAX_REPORT_AGE_MS = 15_000;
|
|
6
6
|
const MODEL_IDS: Record<string, string> = {
|
|
@@ -103,6 +103,8 @@ export interface PrinterStateRequirements {
|
|
|
103
103
|
nozzleDiameters: number[];
|
|
104
104
|
materials?: string[];
|
|
105
105
|
usedFilamentPositions?: number[];
|
|
106
|
+
/** Verified physical nozzle index for each project filament, composed from the inspected plate mappings. */
|
|
107
|
+
filamentNozzleIndices?: number[];
|
|
106
108
|
amsMapping?: number[];
|
|
107
109
|
useAMS?: boolean;
|
|
108
110
|
requireIdle?: boolean;
|
|
@@ -280,13 +282,20 @@ function trayRange(value: unknown, label: string): number | undefined {
|
|
|
280
282
|
if (number === undefined || number <= 0) throw new Error(`Reported filament ${label} temperature is malformed.`);
|
|
281
283
|
return number;
|
|
282
284
|
}
|
|
283
|
-
|
|
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 {
|
|
284
290
|
const slots = raw.vir_slot;
|
|
291
|
+
const dualNozzle = isDualNozzleModel(model);
|
|
285
292
|
if (Array.isArray(slots)) {
|
|
286
|
-
const selected = slots.filter((slot:any) => Number(slot?.id) ===
|
|
287
|
-
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.`);
|
|
288
296
|
if (slots.length > 0) throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
|
|
289
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.`);
|
|
290
299
|
return object(raw.vt_tray) ? raw.vt_tray : undefined;
|
|
291
300
|
}
|
|
292
301
|
function mappedTray(raw: Record<string,any>, index: number): Record<string,any> {
|
|
@@ -332,7 +341,7 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
|
|
|
332
341
|
}
|
|
333
342
|
}
|
|
334
343
|
const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
|
|
335
|
-
if (requirements.nozzleDiameters.length &&
|
|
344
|
+
if (requirements.nozzleDiameters.length && isDualNozzleModel(model) &&
|
|
336
345
|
!Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
|
|
337
346
|
throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
|
|
338
347
|
}
|
|
@@ -360,13 +369,27 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
|
|
|
360
369
|
if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
|
|
361
370
|
const materials = requirements.materials ?? [];
|
|
362
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;
|
|
363
375
|
if (!positions.length) throw new Error("A declared material is required for each used filament or external spool.");
|
|
364
376
|
for (const position of positions) {
|
|
365
377
|
const material = normalizeMaterial(materials[position]);
|
|
366
378
|
if (!Number.isInteger(position) || position < 0 || !material) throw new Error(`A known declared material is required for filament position ${position}.`);
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
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);
|
|
370
393
|
const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
|
|
371
394
|
if (reportedType && normalizeMaterial(reportedType) !== material) throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
|
|
372
395
|
const nozzleMin = trayRange(tray?.nozzle_temp_min,"minimum");
|
|
@@ -384,7 +407,7 @@ export function manualHeatingRequirements(status: any, model: string, nozzleDiam
|
|
|
384
407
|
if (!normalized) throw new Error("A known printer model is required for manual nozzle heating.");
|
|
385
408
|
const raw = status?.raw;
|
|
386
409
|
if (!object(raw)) throw new Error("A fresh printer report is required for manual nozzle heating.");
|
|
387
|
-
if (
|
|
410
|
+
if (isDualNozzleModel(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
|
|
388
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.");
|
|
389
412
|
}
|
|
390
413
|
const requirements: PrinterStateRequirements = {
|