bambu-printer-mcp 1.1.28 → 1.1.30
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 +4 -2
- package/README.md +5 -3
- package/dist/ams-mapping.d.ts +18 -2
- package/dist/ams-mapping.js +36 -5
- package/dist/index.js +81 -49
- package/dist/printers/bambu.d.ts +3 -2
- package/dist/printers/bambu.js +55 -20
- 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 +78 -47
- package/src/printers/bambu.ts +56 -17
- 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
|
@@ -11,6 +11,7 @@ import * as crypto from 'crypto';
|
|
|
11
11
|
import { execFile } from 'child_process';
|
|
12
12
|
import { flattenForCli, detectProfilesRoot, resolveBblMachineProfile } from '../slicer/profile-flatten.js';
|
|
13
13
|
import { profileJsonFiles } from '../slicer/profile-files.js';
|
|
14
|
+
import { sliceWithOrcaApi, validateRemoteSliceOptions } from '../slicer/remote-slicer.js';
|
|
14
15
|
const readFileAsync = promisify(fs.readFile);
|
|
15
16
|
const writeFileAsync = promisify(fs.writeFile);
|
|
16
17
|
const BAMBU_PROFILE_ROOTS = [
|
|
@@ -27,6 +28,7 @@ export const SLICER_TYPES = [
|
|
|
27
28
|
'bambustudio',
|
|
28
29
|
'orcaslicer',
|
|
29
30
|
'orcaslicer-bambulab',
|
|
31
|
+
'orca-slicer-api',
|
|
30
32
|
'prusaslicer',
|
|
31
33
|
'cura',
|
|
32
34
|
'slic3r',
|
|
@@ -48,6 +50,8 @@ const SLICER_TYPE_ALIASES = {
|
|
|
48
50
|
orcastudio: 'orcaslicer-bambulab',
|
|
49
51
|
'orcaslicer-bmcu': 'orcaslicer-bambulab',
|
|
50
52
|
'orca-bmcu': 'orcaslicer-bambulab',
|
|
53
|
+
'orca-slicer-api': 'orca-slicer-api',
|
|
54
|
+
'orcaslicer-api': 'orca-slicer-api',
|
|
51
55
|
prusaslicer: 'prusaslicer',
|
|
52
56
|
'prusa-slicer': 'prusaslicer',
|
|
53
57
|
cura: 'cura',
|
|
@@ -65,7 +69,8 @@ export function normalizeSlicerType(rawSlicerType) {
|
|
|
65
69
|
export function isBambuCompatibleSlicer(slicerType) {
|
|
66
70
|
return (slicerType === 'bambustudio' ||
|
|
67
71
|
slicerType === 'orcaslicer' ||
|
|
68
|
-
slicerType === 'orcaslicer-bambulab'
|
|
72
|
+
slicerType === 'orcaslicer-bambulab' ||
|
|
73
|
+
slicerType === 'orca-slicer-api');
|
|
69
74
|
}
|
|
70
75
|
function isPathLikeExecutable(slicerPath) {
|
|
71
76
|
return (path.isAbsolute(slicerPath) ||
|
|
@@ -1225,7 +1230,7 @@ export class STLManipulator extends EventEmitter {
|
|
|
1225
1230
|
async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, bambuOptions) {
|
|
1226
1231
|
const operationId = this.generateOperationId();
|
|
1227
1232
|
this.activeOperations.set(operationId, true);
|
|
1228
|
-
if (isPathLikeExecutable(slicerPath) && !fs.existsSync(slicerPath)) {
|
|
1233
|
+
if (slicerType !== 'orca-slicer-api' && isPathLikeExecutable(slicerPath) && !fs.existsSync(slicerPath)) {
|
|
1229
1234
|
throw new Error(`Slicer executable not found at: ${slicerPath}`);
|
|
1230
1235
|
}
|
|
1231
1236
|
if (slicerProfile && !fs.existsSync(slicerProfile)) {
|
|
@@ -1267,6 +1272,7 @@ export class STLManipulator extends EventEmitter {
|
|
|
1267
1272
|
case 'bambustudio':
|
|
1268
1273
|
case 'orcaslicer':
|
|
1269
1274
|
case 'orcaslicer-bambulab':
|
|
1275
|
+
case 'orca-slicer-api':
|
|
1270
1276
|
// Bambu-compatible CLI: slice and export as 3MF with embedded gcode.
|
|
1271
1277
|
// FULU's OrcaSlicer-bambulab fork keeps this Bambu-style path.
|
|
1272
1278
|
// For 3MF input: slice in place and export sliced 3MF
|
|
@@ -1274,10 +1280,19 @@ export class STLManipulator extends EventEmitter {
|
|
|
1274
1280
|
{
|
|
1275
1281
|
const is3mf = stlFilePath.toLowerCase().endsWith('.3mf');
|
|
1276
1282
|
const outputBase = path.basename(stlFilePath, is3mf ? '.3mf' : '.stl');
|
|
1277
|
-
const
|
|
1283
|
+
const remote = slicerType === 'orca-slicer-api';
|
|
1284
|
+
if (remote) {
|
|
1285
|
+
validateRemoteSliceOptions(bambuOptions);
|
|
1286
|
+
if (!process.env.BAMBU_PROFILES_ROOT)
|
|
1287
|
+
throw new Error('Remote slicing requires BAMBU_PROFILES_ROOT pointing to the matching Orca profile tree (a read-only mount is sufficient).');
|
|
1288
|
+
if (slicerProfile && !fs.existsSync(slicerProfile))
|
|
1289
|
+
throw new Error('Remote slicing process profile does not exist.');
|
|
1290
|
+
}
|
|
1291
|
+
const profileSlicerType = remote ? 'orcaslicer' : slicerType;
|
|
1292
|
+
const bambuOutputPath = path.join(this.tempDir, outputBase + (remote ? `_${operationId}` : '') + '_sliced.3mf');
|
|
1278
1293
|
const outputDir = path.dirname(bambuOutputPath);
|
|
1279
|
-
const profilesRoot = detectProfilesRoot(slicerPath,
|
|
1280
|
-
const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase,
|
|
1294
|
+
const profilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
|
|
1295
|
+
const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, profileSlicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot);
|
|
1281
1296
|
// Resolve every BBL dependency before launching the slicer.
|
|
1282
1297
|
const settingsBundle = await this.maybeFlattenBundle(await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
|
|
1283
1298
|
// The machine preset assumes the stock hotend; record the installed one so
|
|
@@ -1297,7 +1312,7 @@ export class STLManipulator extends EventEmitter {
|
|
|
1297
1312
|
// absolute-extrusion normalization after resolving those ancestors.
|
|
1298
1313
|
// The machine owns the extrusion mode and layer G-code once process
|
|
1299
1314
|
// files lose machine keys, so normalize it there as well.
|
|
1300
|
-
if (
|
|
1315
|
+
if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg) {
|
|
1301
1316
|
const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
|
|
1302
1317
|
settingsBundle.settingsArg = [
|
|
1303
1318
|
this.writeTempJson(outputBase, 'machine_orca_resolved', this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
|
|
@@ -1305,6 +1320,17 @@ export class STLManipulator extends EventEmitter {
|
|
|
1305
1320
|
].join(';');
|
|
1306
1321
|
}
|
|
1307
1322
|
const cliBundle = this.isolateMachineSettings(settingsBundle);
|
|
1323
|
+
if (remote) {
|
|
1324
|
+
const [machinePath, processPath] = cliBundle.settingsArg?.split(';') ?? [];
|
|
1325
|
+
if (!machinePath || !processPath)
|
|
1326
|
+
throw new Error('Remote slicing requires complete prepared machine and process profiles.');
|
|
1327
|
+
outputFilePath = await sliceWithOrcaApi({
|
|
1328
|
+
inputPath: stlFilePath, machinePath, processPath,
|
|
1329
|
+
filamentPaths: cliBundle.filamentPaths, outputPath: bambuOutputPath,
|
|
1330
|
+
options: bambuOptions, isCancelled: () => !this.activeOperations.get(operationId),
|
|
1331
|
+
});
|
|
1332
|
+
break;
|
|
1333
|
+
}
|
|
1308
1334
|
args = [
|
|
1309
1335
|
'--slice', String(bambuOptions?.slicePlate ?? 0),
|
|
1310
1336
|
'--outputdir', outputDir,
|
|
@@ -1360,45 +1386,47 @@ export class STLManipulator extends EventEmitter {
|
|
|
1360
1386
|
default:
|
|
1361
1387
|
throw new Error(`Unsupported slicer type: ${slicerType}`);
|
|
1362
1388
|
}
|
|
1363
|
-
if (
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
else {
|
|
1375
|
-
console.error(`Slicer Stdout: ${stdout}`);
|
|
1376
|
-
if (stderr) {
|
|
1377
|
-
console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
|
|
1389
|
+
if (slicerType !== 'orca-slicer-api') {
|
|
1390
|
+
if (progressCallback)
|
|
1391
|
+
progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
|
|
1392
|
+
console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
|
|
1393
|
+
// Execute the slicer
|
|
1394
|
+
await new Promise((resolve, reject) => {
|
|
1395
|
+
const process = execFile(slicerPath, args, (error, stdout, stderr) => {
|
|
1396
|
+
if (error) {
|
|
1397
|
+
console.error(`Slicer Error: ${error.message}`);
|
|
1398
|
+
console.error(`Slicer Stderr: ${stderr}`);
|
|
1399
|
+
reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
|
|
1378
1400
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
// Check for cancellation periodically
|
|
1386
|
-
const checkCancel = setInterval(() => {
|
|
1387
|
-
if (!this.activeOperations.get(operationId)) {
|
|
1388
|
-
clearInterval(checkCancel);
|
|
1389
|
-
try {
|
|
1390
|
-
process.kill(); // Attempt to kill the slicer process
|
|
1391
|
-
reject(new Error("Slicing operation cancelled"));
|
|
1401
|
+
else {
|
|
1402
|
+
console.error(`Slicer Stdout: ${stdout}`);
|
|
1403
|
+
if (stderr) {
|
|
1404
|
+
console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
|
|
1405
|
+
}
|
|
1406
|
+
resolve();
|
|
1392
1407
|
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1408
|
+
});
|
|
1409
|
+
// Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
|
|
1410
|
+
// process.stdout?.on('data', (data) => { console.error(`Slicer stdout: ${data}`); });
|
|
1411
|
+
// process.stderr?.on('data', (data) => { console.error(`Slicer stderr: ${data}`); });
|
|
1412
|
+
// Check for cancellation periodically
|
|
1413
|
+
const checkCancel = setInterval(() => {
|
|
1414
|
+
if (!this.activeOperations.get(operationId)) {
|
|
1415
|
+
clearInterval(checkCancel);
|
|
1416
|
+
try {
|
|
1417
|
+
process.kill(); // Attempt to kill the slicer process
|
|
1418
|
+
reject(new Error("Slicing operation cancelled"));
|
|
1419
|
+
}
|
|
1420
|
+
catch (killError) {
|
|
1421
|
+
console.error("Error attempting to kill slicer process:", killError);
|
|
1422
|
+
reject(new Error("Slicing operation cancelled, but failed to kill process."));
|
|
1423
|
+
}
|
|
1396
1424
|
}
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
}
|
|
1425
|
+
}, 500);
|
|
1426
|
+
process.on('exit', () => clearInterval(checkCancel));
|
|
1427
|
+
process.on('error', () => clearInterval(checkCancel)); // Ensure interval cleared on process error too
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1402
1430
|
if (!this.activeOperations.get(operationId)) {
|
|
1403
1431
|
// This check might be redundant if the promise rejected on kill, but good safety
|
|
1404
1432
|
throw new Error("Slicing operation cancelled after process finished");
|
package/package.json
CHANGED
package/src/ams-mapping.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
/** Tray-value options. `dualNozzle` admits 255, the right/main nozzle external spool that
|
|
2
|
+
* dual-nozzle printers report beside the left/deputy external spool 254 (Bambu Studio
|
|
3
|
+
* VIRTUAL_TRAY_MAIN_ID / VIRTUAL_TRAY_DEPUTY_ID). Single-nozzle printers keep 254 only. */
|
|
4
|
+
export interface AmsTrayValueOptions { dualNozzle?: boolean }
|
|
5
|
+
|
|
6
|
+
export function describeAmsTrayRange(options: AmsTrayValueOptions = {}): string {
|
|
7
|
+
return options.dualNozzle
|
|
8
|
+
? "[-1, 15] (absolute tray), 128-253 (AMS-HT), 254 (left/deputy external) or 255 (right/main external)"
|
|
9
|
+
: "[-1, 15] (absolute tray) or 128-254 (HT/external)";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeBridgeAmsTrayValue(value: unknown, label: string, options: AmsTrayValueOptions = {}): number {
|
|
2
13
|
let numeric: number;
|
|
3
14
|
if (typeof value === "number") {
|
|
4
15
|
numeric = value;
|
|
@@ -12,10 +23,10 @@ export function normalizeBridgeAmsTrayValue(value: unknown, label: string): numb
|
|
|
12
23
|
!Number.isInteger(numeric) ||
|
|
13
24
|
numeric < -1 ||
|
|
14
25
|
(numeric > 15 && numeric < 128) ||
|
|
15
|
-
numeric > 254
|
|
26
|
+
numeric > (options.dualNozzle ? 255 : 254)
|
|
16
27
|
) {
|
|
17
28
|
throw new Error(
|
|
18
|
-
`${label} must be an integer in
|
|
29
|
+
`${label} must be an integer in ${describeAmsTrayRange(options)}; got ${JSON.stringify(value)}`
|
|
19
30
|
);
|
|
20
31
|
}
|
|
21
32
|
|
|
@@ -27,7 +38,7 @@ function isNumericMappingKey(key: string): boolean {
|
|
|
27
38
|
return Number.isInteger(numeric) && numeric >= 0;
|
|
28
39
|
}
|
|
29
40
|
|
|
30
|
-
export function normalizeAmsMappingObject(mapping: Record<string, unknown>, label = "ams_mapping"): number[] {
|
|
41
|
+
export function normalizeAmsMappingObject(mapping: Record<string, unknown>, label = "ams_mapping", options: AmsTrayValueOptions = {}): number[] {
|
|
31
42
|
return Object.entries(mapping)
|
|
32
43
|
.sort(([leftKey], [rightKey]) => {
|
|
33
44
|
const leftNumeric = isNumericMappingKey(leftKey);
|
|
@@ -37,7 +48,7 @@ export function normalizeAmsMappingObject(mapping: Record<string, unknown>, labe
|
|
|
37
48
|
if (rightNumeric) return 1;
|
|
38
49
|
return 0;
|
|
39
50
|
})
|
|
40
|
-
.map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]
|
|
51
|
+
.map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]`, options));
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
export function hasAmsMappingInput(value: unknown): boolean {
|
|
@@ -45,3 +56,25 @@ export function hasAmsMappingInput(value: unknown): boolean {
|
|
|
45
56
|
if (value && typeof value === "object") return Object.keys(value as Record<string, unknown>).length > 0;
|
|
46
57
|
return value !== undefined && value !== null;
|
|
47
58
|
}
|
|
59
|
+
|
|
60
|
+
/** Flat wire entries use -1 for dual-nozzle virtual trays; ams_mapping2 identifies their spool. */
|
|
61
|
+
export function bridgeAmsMappingValue(value: number, dualNozzle = false): number {
|
|
62
|
+
return dualNozzle && (value === 254 || value === 255) ? -1 : value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Select an external spool only from a verified physical nozzle on dual-nozzle printers. */
|
|
66
|
+
export function externalTrayForNozzle(nozzle: number | undefined, dualNozzle = false): number {
|
|
67
|
+
if (!dualNozzle) return 254;
|
|
68
|
+
if (nozzle === 0) return 255;
|
|
69
|
+
if (nozzle === 1) return 254;
|
|
70
|
+
throw new Error("External-spool printing requires inspected filament_map and physical_extruder_map nozzle bindings.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Virtual trays use slot zero on dual-nozzle printers; retain the historical single-nozzle form. */
|
|
74
|
+
export function bridgeAmsMapping2Entry(value: number, dualNozzle = false): { ams_id: number; slot_id: number } {
|
|
75
|
+
if (value < 0) return { ams_id: 255, slot_id: 255 };
|
|
76
|
+
if (value === 255) return dualNozzle ? { ams_id: 255, slot_id: 0 } : { ams_id: 255, slot_id: 255 };
|
|
77
|
+
if (value === 254) return { ams_id: 254, slot_id: dualNozzle ? 0 : 254 };
|
|
78
|
+
if (value >= 128) return { ams_id: 128, slot_id: value - 128 };
|
|
79
|
+
return { ams_id: Math.floor(value / 4), slot_id: value % 4 };
|
|
80
|
+
}
|
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
|
|
|
@@ -84,6 +85,22 @@ const DEFAULT_BAMBU_CONNECTION_MODE =
|
|
|
84
85
|
const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
|
|
85
86
|
const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
|
|
86
87
|
|
|
88
|
+
/** Bound for each Linux X2D emergency MQTT send (BAMBU_EMERGENCY_MQTT_TIMEOUT_MS, 1–60000 ms). */
|
|
89
|
+
function withEmergencyMqttTimeout<T>(send: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
90
|
+
const raw = Number(process.env.BAMBU_EMERGENCY_MQTT_TIMEOUT_MS?.trim() || 15_000);
|
|
91
|
+
const ms = Number.isInteger(raw) && raw >= 1 && raw <= 60_000 ? raw : 15_000;
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
94
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
95
|
+
timer = setTimeout(() => {
|
|
96
|
+
const error = new Error(`no MQTT confirmation within ${ms / 1000} s`);
|
|
97
|
+
controller.abort(error);
|
|
98
|
+
reject(error);
|
|
99
|
+
}, ms);
|
|
100
|
+
});
|
|
101
|
+
return Promise.race([Promise.resolve().then(() => send(controller.signal)), timeout]).finally(() => clearTimeout(timer));
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
function requestedPrintNozzles(args: Record<string, any>): number[] | undefined {
|
|
88
105
|
if (args.nozzle_diameters !== undefined && args.nozzle_diameter !== undefined) {
|
|
89
106
|
throw new Error("Supply nozzle_diameters or nozzle_diameter, not both.");
|
|
@@ -703,7 +720,7 @@ function resolveSlicerConfig(args: Record<string, unknown> | undefined): {
|
|
|
703
720
|
slicerProfile: string;
|
|
704
721
|
} {
|
|
705
722
|
const slicerType = normalizeSlicerType(String(args?.slicer_type || DEFAULT_SLICER_TYPE));
|
|
706
|
-
const slicerPath = String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
|
|
723
|
+
const slicerPath = slicerType === "orca-slicer-api" ? "" : String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
|
|
707
724
|
const slicerProfile = String(args?.slicer_profile || DEFAULT_SLICER_PROFILE);
|
|
708
725
|
return { slicerType, slicerPath, slicerProfile };
|
|
709
726
|
}
|
|
@@ -994,14 +1011,9 @@ function stringifyBridgeJson(value: unknown): string | undefined {
|
|
|
994
1011
|
return JSON.stringify(value);
|
|
995
1012
|
}
|
|
996
1013
|
|
|
997
|
-
function nativeAmsMapping2(mapping: number[] | undefined): string | undefined {
|
|
1014
|
+
function nativeAmsMapping2(mapping: number[] | undefined, dualNozzle = false): string | undefined {
|
|
998
1015
|
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
|
-
}));
|
|
1016
|
+
return JSON.stringify(mapping.map((value) => bridgeAmsMapping2Entry(value, dualNozzle)));
|
|
1005
1017
|
}
|
|
1006
1018
|
|
|
1007
1019
|
function redactPrintParams(params: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -1357,6 +1369,9 @@ class BambuPrinterMCPServer {
|
|
|
1357
1369
|
): ReturnType<typeof resolveSlicerConfig> {
|
|
1358
1370
|
const config = resolveSlicerConfig(args);
|
|
1359
1371
|
if (args?.slicer_path !== undefined) {
|
|
1372
|
+
if (config.slicerType === "orca-slicer-api") {
|
|
1373
|
+
throw new Error(`${toolName}: remote slicing uses trusted SLICER_API_URL and BAMBU_PROFILES_ROOT configuration; slicer_path is unsupported.`);
|
|
1374
|
+
}
|
|
1360
1375
|
const resolvedSlicerPath = this.resolveExecutableSelectorArg(
|
|
1361
1376
|
args.slicer_path,
|
|
1362
1377
|
toolName,
|
|
@@ -1397,10 +1412,10 @@ class BambuPrinterMCPServer {
|
|
|
1397
1412
|
|
|
1398
1413
|
console.error(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
|
|
1399
1414
|
const autoSliceOptions: BambuSliceOptions = {
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1415
|
+
...(slicerType === "orca-slicer-api" ? {} : {
|
|
1416
|
+
uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
|
|
1417
|
+
}),
|
|
1418
|
+
...(slicerType === "orca-slicer-api" ? { slicePlate: Number(args.plate_index ?? 0) + 1 } : {}),
|
|
1404
1419
|
bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
|
|
1405
1420
|
nozzleType: resolveNozzleType(args?.nozzle_type),
|
|
1406
1421
|
nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
|
|
@@ -1426,9 +1441,10 @@ class BambuPrinterMCPServer {
|
|
|
1426
1441
|
): Promise<{ useAMS: boolean; finalAmsMapping?: number[]; finalAmsSlots?: number[] }> {
|
|
1427
1442
|
const parsed3MFData = await parse3MF(threeMFPath);
|
|
1428
1443
|
let parsedAmsMapping: number[] | undefined;
|
|
1444
|
+
const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
|
|
1429
1445
|
|
|
1430
1446
|
if (parsed3MFData.slicerConfig?.ams_mapping) {
|
|
1431
|
-
const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown
|
|
1447
|
+
const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
|
|
1432
1448
|
if (slots.length > 0) {
|
|
1433
1449
|
parsedAmsMapping = slots;
|
|
1434
1450
|
}
|
|
@@ -1444,10 +1460,10 @@ class BambuPrinterMCPServer {
|
|
|
1444
1460
|
let userMappingOverride: number[] | undefined;
|
|
1445
1461
|
if (Array.isArray(args.ams_mapping)) {
|
|
1446
1462
|
userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
|
|
1447
|
-
normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]
|
|
1463
|
+
normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions)
|
|
1448
1464
|
);
|
|
1449
1465
|
} else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
|
|
1450
|
-
userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown
|
|
1466
|
+
userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
|
|
1451
1467
|
}
|
|
1452
1468
|
if (userMappingOverride && userMappingOverride.length > 0) {
|
|
1453
1469
|
finalAmsMapping = userMappingOverride;
|
|
@@ -1458,7 +1474,7 @@ class BambuPrinterMCPServer {
|
|
|
1458
1474
|
|
|
1459
1475
|
if (!hasUserAmsMapping && hasUserAmsSlots) {
|
|
1460
1476
|
const userSlots = args.ams_slots.map((slot: unknown, i: number) =>
|
|
1461
|
-
normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]
|
|
1477
|
+
normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]`, trayOptions)
|
|
1462
1478
|
);
|
|
1463
1479
|
finalAmsSlots = userSlots;
|
|
1464
1480
|
finalAmsMapping = undefined;
|
|
@@ -1487,8 +1503,8 @@ class BambuPrinterMCPServer {
|
|
|
1487
1503
|
}
|
|
1488
1504
|
|
|
1489
1505
|
if (args?.use_ams === false) {
|
|
1490
|
-
finalAmsMapping = undefined;
|
|
1491
|
-
finalAmsSlots = undefined;
|
|
1506
|
+
if (!hasUserAmsMapping) finalAmsMapping = undefined;
|
|
1507
|
+
if (!hasUserAmsSlots) finalAmsSlots = undefined;
|
|
1492
1508
|
useAMS = false;
|
|
1493
1509
|
}
|
|
1494
1510
|
if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
|
|
@@ -1589,8 +1605,16 @@ class BambuPrinterMCPServer {
|
|
|
1589
1605
|
inspection.usedFilamentPositions.forEach((position, index) => { mapping![position] = finalAmsSlots[index]; });
|
|
1590
1606
|
}
|
|
1591
1607
|
if (!useAMS) {
|
|
1592
|
-
mapping
|
|
1593
|
-
|
|
1608
|
+
mapping ??= Array(inspection.materials.length).fill(-1);
|
|
1609
|
+
const dualNozzle = isDualNozzleModel(printModel);
|
|
1610
|
+
for (const position of inspection.usedFilamentPositions) {
|
|
1611
|
+
if (mapping[position] === undefined || mapping[position] < 0) {
|
|
1612
|
+
mapping[position] = externalTrayForNozzle(inspection.filamentNozzleIndices?.[position], dualNozzle);
|
|
1613
|
+
}
|
|
1614
|
+
if (mapping[position] !== 254 && !(dualNozzle && mapping[position] === 255)) {
|
|
1615
|
+
throw new Error("use_ams:false requires an external-spool mapping for every used filament.");
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1594
1618
|
}
|
|
1595
1619
|
const requirements = { ...inspection, amsMapping: mapping, useAMS };
|
|
1596
1620
|
const initialStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
|
|
@@ -1599,7 +1623,7 @@ class BambuPrinterMCPServer {
|
|
|
1599
1623
|
const confirmedStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
|
|
1600
1624
|
validatePrinterState(confirmedStatus, requirements);
|
|
1601
1625
|
this.bambu.assertBedClearance(confirmedStatus, bedClearance);
|
|
1602
|
-
const amsMapping = stringifyBridgeJson(mapping);
|
|
1626
|
+
const amsMapping = stringifyBridgeJson(mapping?.map(value => bridgeAmsMappingValue(value, isDualNozzleModel(printModel))));
|
|
1603
1627
|
const remoteFileName = uniquePrintName(threeMFPath);
|
|
1604
1628
|
const params: Record<string, unknown> = {
|
|
1605
1629
|
dev_id: devId,
|
|
@@ -1616,7 +1640,7 @@ class BambuPrinterMCPServer {
|
|
|
1616
1640
|
ftp_file_md5: String(args?.ftp_file_md5 || ""),
|
|
1617
1641
|
nozzle_mapping: stringifyBridgeJson(args?.nozzle_mapping) || "",
|
|
1618
1642
|
ams_mapping: amsMapping || "",
|
|
1619
|
-
ams_mapping2:
|
|
1643
|
+
ams_mapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)) || "",
|
|
1620
1644
|
ams_mapping_info: stringifyBridgeJson(args?.ams_mapping_info) || "",
|
|
1621
1645
|
nozzles_info: stringifyBridgeJson(args?.nozzles_info) || "",
|
|
1622
1646
|
connection_type: connectionType,
|
|
@@ -1657,7 +1681,7 @@ class BambuPrinterMCPServer {
|
|
|
1657
1681
|
host: devIp, serial: devId, token: password || bambuToken, filePath: snapshot,
|
|
1658
1682
|
projectName: String(params.task_name), presetName, remoteName: remoteFileName,
|
|
1659
1683
|
plateIndex, bedType: printBedType, useAMS,
|
|
1660
|
-
amsMapping, amsMapping2: nativeAmsMapping2(mapping),
|
|
1684
|
+
amsMapping, amsMapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)),
|
|
1661
1685
|
bedLeveling: params.task_bed_leveling as boolean,
|
|
1662
1686
|
flowCalibration: params.task_flow_cali as boolean,
|
|
1663
1687
|
vibrationCalibration: params.task_vibration_cali as boolean,
|
|
@@ -1916,10 +1940,9 @@ class BambuPrinterMCPServer {
|
|
|
1916
1940
|
);
|
|
1917
1941
|
}
|
|
1918
1942
|
const autoSliceOptions: BambuSliceOptions = {
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
skipModifiedGcodes: true,
|
|
1943
|
+
...(slicerType === "orca-slicer-api" ? {} : {
|
|
1944
|
+
uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
|
|
1945
|
+
}),
|
|
1923
1946
|
bedType,
|
|
1924
1947
|
};
|
|
1925
1948
|
|
|
@@ -2470,7 +2493,7 @@ class BambuPrinterMCPServer {
|
|
|
2470
2493
|
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
2494
|
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
2495
|
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
|
|
2496
|
+
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
2497
|
},
|
|
2475
2498
|
required: ["stl_path", "bambu_model"]
|
|
2476
2499
|
}
|
|
@@ -2573,8 +2596,8 @@ class BambuPrinterMCPServer {
|
|
|
2573
2596
|
nozzle_diameter: { type: "string", description: "Nozzle diameter in mm for auto-slicing (default: 0.4)." },
|
|
2574
2597
|
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
2598
|
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" } },
|
|
2599
|
+
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" } },
|
|
2600
|
+
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
2601
|
ams_mapping_bridge: { type: "string", description: "Raw JSON string override for FULU PrintParams ams_mapping when the automatic array is not enough." },
|
|
2579
2602
|
ams_mapping2: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping2, matching OrcaSlicer-bambulab's v1 AMS mapping field." },
|
|
2580
2603
|
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 +3025,12 @@ class BambuPrinterMCPServer {
|
|
|
3002
3025
|
use_ams: { type: "boolean", description: "Whether to use the AMS (default: auto-detect from 3MF)" },
|
|
3003
3026
|
ams_mapping: {
|
|
3004
3027
|
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.",
|
|
3028
|
+
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
3029
|
items: { type: "number" }
|
|
3007
3030
|
},
|
|
3008
3031
|
ams_slots: {
|
|
3009
3032
|
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.",
|
|
3033
|
+
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
3034
|
items: { type: "number" }
|
|
3012
3035
|
},
|
|
3013
3036
|
auto_match_ams: {
|
|
@@ -3201,12 +3224,20 @@ class BambuPrinterMCPServer {
|
|
|
3201
3224
|
// again once every older helper for this printer has exited, and only
|
|
3202
3225
|
// that send counts. Either counted route succeeding is success. macOS
|
|
3203
3226
|
// stays native-only.
|
|
3204
|
-
const linuxEmergency = (native: () => Promise<any>, mqtt: () => Promise<any>) => async () => {
|
|
3227
|
+
const linuxEmergency = (native: () => Promise<any>, mqtt: (signal: AbortSignal) => Promise<any>) => async () => {
|
|
3205
3228
|
if (process.platform !== "linux") return native();
|
|
3229
|
+
// An unreachable printer makes the MQTT connect retry forever; bound each
|
|
3230
|
+
// MQTT send so it cannot hold back a stop the native route already sent.
|
|
3231
|
+
const immediate = withEmergencyMqttTimeout(mqtt);
|
|
3206
3232
|
const [nativeResult, immediateResult, mqttResult] = await Promise.allSettled([
|
|
3207
3233
|
native(),
|
|
3208
|
-
|
|
3209
|
-
|
|
3234
|
+
immediate,
|
|
3235
|
+
// Finish the first attempt before reusing its cached publisher: its
|
|
3236
|
+
// expiry must never close the later, counted send's transport.
|
|
3237
|
+
Promise.allSettled([interruptNativeOperations(bambuSerial), immediate]).then(results => {
|
|
3238
|
+
if (results[0].status === "rejected") throw results[0].reason;
|
|
3239
|
+
return withEmergencyMqttTimeout(mqtt);
|
|
3240
|
+
}),
|
|
3210
3241
|
]);
|
|
3211
3242
|
const reason = (result: PromiseSettledResult<any>) =>
|
|
3212
3243
|
result.status === "rejected" ? (result.reason instanceof Error ? result.reason.message : String(result.reason)) : undefined;
|
|
@@ -3480,7 +3511,7 @@ class BambuPrinterMCPServer {
|
|
|
3480
3511
|
? linuxEmergency(
|
|
3481
3512
|
() => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
|
|
3482
3513
|
messageJson: JSON.stringify({ print: { command: "stop", param: "", sequence_id: String(Date.now()) } }), qos: 1 }),
|
|
3483
|
-
|
|
3514
|
+
signal => this.bambu.sendStopOverMqtt(host, bambuSerial, bambuToken, signal))
|
|
3484
3515
|
: undefined);
|
|
3485
3516
|
break;
|
|
3486
3517
|
|
|
@@ -3640,7 +3671,7 @@ class BambuPrinterMCPServer {
|
|
|
3640
3671
|
const native = () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
|
|
3641
3672
|
messageJson: buildBambuNativeTemperatureCommand(heater, target).messageJson }, { signal: extra.signal, assertActive, beforeDispatch });
|
|
3642
3673
|
return target === 0
|
|
3643
|
-
? linuxEmergency(native,
|
|
3674
|
+
? linuxEmergency(native, signal => this.bambu.sendHeaterOffOverMqtt(host, bambuSerial, bambuToken, heater, signal))()
|
|
3644
3675
|
: native();
|
|
3645
3676
|
}
|
|
3646
3677
|
: undefined
|
|
@@ -4098,10 +4129,9 @@ class BambuPrinterMCPServer {
|
|
|
4098
4129
|
}
|
|
4099
4130
|
console.error(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
|
|
4100
4131
|
const autoSliceOptions: BambuSliceOptions = {
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
skipModifiedGcodes: true,
|
|
4132
|
+
...(slicerType === "orca-slicer-api" ? { slicePlate: plateIndex + 1 } : {
|
|
4133
|
+
uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
|
|
4134
|
+
}),
|
|
4105
4135
|
bedType: printBedType,
|
|
4106
4136
|
nozzleType: resolveNozzleType(args?.nozzle_type),
|
|
4107
4137
|
nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
|
|
@@ -4133,9 +4163,10 @@ class BambuPrinterMCPServer {
|
|
|
4133
4163
|
|
|
4134
4164
|
const parsed3MFData = await parse3MF(threeMFPath);
|
|
4135
4165
|
const isH2Print = H2_BAMBU_MODELS.has(printModel);
|
|
4166
|
+
const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
|
|
4136
4167
|
let parsedAmsMapping: number[] | undefined;
|
|
4137
4168
|
if (!isH2Print && parsed3MFData.slicerConfig?.ams_mapping) {
|
|
4138
|
-
const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown
|
|
4169
|
+
const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
|
|
4139
4170
|
if (slots.length > 0) {
|
|
4140
4171
|
parsedAmsMapping = slots;
|
|
4141
4172
|
}
|
|
@@ -4151,10 +4182,10 @@ class BambuPrinterMCPServer {
|
|
|
4151
4182
|
let userMappingOverride: number[] | undefined;
|
|
4152
4183
|
if (Array.isArray(args.ams_mapping)) {
|
|
4153
4184
|
userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
|
|
4154
|
-
normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]
|
|
4185
|
+
normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions)
|
|
4155
4186
|
);
|
|
4156
4187
|
} else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
|
|
4157
|
-
userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown
|
|
4188
|
+
userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
|
|
4158
4189
|
}
|
|
4159
4190
|
if (userMappingOverride && userMappingOverride.length > 0) {
|
|
4160
4191
|
finalAmsMapping = userMappingOverride;
|
|
@@ -4208,8 +4239,8 @@ class BambuPrinterMCPServer {
|
|
|
4208
4239
|
}
|
|
4209
4240
|
|
|
4210
4241
|
if (args?.use_ams === false) {
|
|
4211
|
-
finalAmsMapping = undefined;
|
|
4212
|
-
finalAmsSlots = undefined;
|
|
4242
|
+
if (!hasUserAmsMapping) finalAmsMapping = undefined;
|
|
4243
|
+
if (!hasUserAmsSlots) finalAmsSlots = undefined;
|
|
4213
4244
|
useAMS = false;
|
|
4214
4245
|
}
|
|
4215
4246
|
if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
|