bambu-printer-mcp 1.1.16 → 1.1.18
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/README.md +1 -1
- package/dist/index.js +78 -9
- package/dist/printers/bambu.d.ts +9 -0
- package/dist/printers/bambu.js +143 -6
- package/dist/safety/printer-state.js +4 -1
- package/dist/slicer/profile-flatten.js +16 -4
- package/dist/stl/stl-manipulator.d.ts +11 -0
- package/dist/stl/stl-manipulator.js +69 -10
- package/package.json +1 -1
- package/src/index.ts +85 -11
- package/src/printers/bambu.ts +105 -6
- package/src/safety/printer-state.ts +4 -1
- package/src/slicer/profile-flatten.ts +19 -5
- package/src/stl/stl-manipulator.ts +76 -15
package/README.md
CHANGED
|
@@ -906,7 +906,7 @@ The primary tool for starting a Bambu print. **Recommended input: a pre-sliced `
|
|
|
906
906
|
|
|
907
907
|
`bambu_model` is **required** for model-specific routing and preset selection. It does not by itself validate pre-sliced G-code. BambuStudio, FULU, and Orca CLI preparation additionally require the exact model/nozzle machine preset and reject incomplete profiles. Using the wrong model can damage hardware. If `bambu_model` is not provided in the tool call and `BAMBU_MODEL` is not set in the environment, the server will ask you interactively via MCP elicitation (if your client supports it) or return a clear error.
|
|
908
908
|
|
|
909
|
-
`bed_type` defaults to `textured_plate` if omitted. `ams_slots` is the preferred override input; `ams_mapping` remains the raw escape hatch. On AMS-equipped H2 printers, `use_ams: false` does not suppress mapping lookup if the sliced file declares filaments. If no mapping is provided for an H2 pre-sliced job with declared filaments, the server fails before sending; pass explicit `ams_slots`, raw `ams_mapping`, or `auto_match_ams: true`.
|
|
909
|
+
`bed_type` defaults to `textured_plate` if omitted. `nozzle_type` (`stainless_steel`, `hardened_steel`, `tungsten_carbide`, `brass`; default `BAMBU_NOZZLE_TYPE`) sets the installed nozzle when the project must be auto-sliced; the job's nozzle type must match the printer's report, and stock P1S/P1P/A1 presets assume stainless steel. After an MQTT print command the server listens to the printer's pushed reports for up to 15 seconds (`BAMBU_DISPATCH_CHECK_MS`) and returns `dispatch: "started"` or `"unconfirmed"`; if firmware 01.08.05+ refuses the command (HMS 0500-0500-0001-0007, needs LAN Only Mode and Developer Mode), the call fails and says so; clear that fatal HMS entry with `clear_hms_errors` before printing again. `ams_slots` is the preferred override input; `ams_mapping` remains the raw escape hatch. On AMS-equipped H2 printers, `use_ams: false` does not suppress mapping lookup if the sliced file declares filaments. If no mapping is provided for an H2 pre-sliced job with declared filaments, the server fails before sending; pass explicit `ams_slots`, raw `ams_mapping`, or `auto_match_ams: true`.
|
|
910
910
|
|
|
911
911
|
Set `auto_match_ams: true` to match the sliced 3MF's `tray_info_idx` values against the live AMS inventory and use the matching `ams_slots`. The matcher joins on `(tray_info_idx, tray_color)` and tracks already-claimed slots, so prints with two filaments of the same SKU but different colors (e.g. two GFG02 PETG HF in black and white) resolve correctly. Falls back to SKU-only when the 3MF's filament has no color set or only one tray of that SKU is loaded. Returns a structured `missing` report (`reason: "no_loaded_match" | "color_mismatch" | "exhausted" | "no_sku"`) when a filament can't be resolved. Ignored when you provide `ams_slots` or `ams_mapping` explicitly.
|
|
912
912
|
|
package/dist/index.js
CHANGED
|
@@ -77,6 +77,18 @@ const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "
|
|
|
77
77
|
// jobs take the correct branch instead of the legacy 5-slot mapping.
|
|
78
78
|
const H2_BAMBU_MODELS = new Set(["h2d", "h2s", "h2c", "x2d"]);
|
|
79
79
|
const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"];
|
|
80
|
+
const VALID_NOZZLE_TYPES = ["stainless_steel", "hardened_steel", "tungsten_carbide", "brass"];
|
|
81
|
+
/** The installed hotend material. Bambu machine presets assume the stock nozzle; the print gate compares this with the printer. */
|
|
82
|
+
function resolveNozzleType(value) {
|
|
83
|
+
const raw = value === undefined || value === null || value === "" ? process.env.BAMBU_NOZZLE_TYPE?.trim() : value;
|
|
84
|
+
if (raw === undefined || raw === "")
|
|
85
|
+
return undefined;
|
|
86
|
+
const normalized = String(raw).trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
87
|
+
if (!VALID_NOZZLE_TYPES.includes(normalized)) {
|
|
88
|
+
throw new Error(`Invalid nozzle type "${raw}". Valid types: ${VALID_NOZZLE_TYPES.join(", ")}`);
|
|
89
|
+
}
|
|
90
|
+
return normalized;
|
|
91
|
+
}
|
|
80
92
|
const VALID_BAMBUSTUDIO_CLI_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"];
|
|
81
93
|
// Map model IDs to BambuStudio --load-machine preset names
|
|
82
94
|
const BAMBU_MODEL_PRESETS = {
|
|
@@ -497,6 +509,30 @@ function parseBooleanEnv(rawValue, fallback) {
|
|
|
497
509
|
return false;
|
|
498
510
|
return fallback;
|
|
499
511
|
}
|
|
512
|
+
// Someone confirming a hardware action may have to walk to the printer, so the
|
|
513
|
+
// SDK's 60-second request default is too short for human confirmation.
|
|
514
|
+
const DEFAULT_CONFIRMATION_TIMEOUT_MS = 600000;
|
|
515
|
+
const MIN_CONFIRMATION_TIMEOUT_MS = 1000;
|
|
516
|
+
const MAX_CONFIRMATION_TIMEOUT_MS = 3600000;
|
|
517
|
+
/** Out-of-range values fall back to the default; Node clamps huge timer delays to 1 ms. */
|
|
518
|
+
function parseConfirmationTimeout(value) {
|
|
519
|
+
const parsed = Number(value?.trim() || NaN);
|
|
520
|
+
return Number.isInteger(parsed) && parsed >= MIN_CONFIRMATION_TIMEOUT_MS && parsed <= MAX_CONFIRMATION_TIMEOUT_MS
|
|
521
|
+
? parsed
|
|
522
|
+
: DEFAULT_CONFIRMATION_TIMEOUT_MS;
|
|
523
|
+
}
|
|
524
|
+
function describeDuration(ms) {
|
|
525
|
+
return ms >= 60000 ? `${Math.round(ms / 6000) / 10} minute(s)` : `${Math.round(ms / 100) / 10} second(s)`;
|
|
526
|
+
}
|
|
527
|
+
function isElicitationTimeout(error) {
|
|
528
|
+
return error?.code === ErrorCode.RequestTimeout;
|
|
529
|
+
}
|
|
530
|
+
/** Only a missing method or capability means the client cannot show the prompt. */
|
|
531
|
+
function isElicitationUnsupported(error) {
|
|
532
|
+
const message = String(error?.message ?? error);
|
|
533
|
+
return error?.code === ErrorCode.MethodNotFound || error?.code === ErrorCode.InvalidRequest ||
|
|
534
|
+
/does not support (form |url )?elicitation/i.test(message);
|
|
535
|
+
}
|
|
500
536
|
function parsePort(value, fallback) {
|
|
501
537
|
if (!value)
|
|
502
538
|
return fallback;
|
|
@@ -822,6 +858,7 @@ class BambuPrinterMCPServer {
|
|
|
822
858
|
constructor() {
|
|
823
859
|
this.blender = new BlenderMcpBridge();
|
|
824
860
|
this.shuttingDown = false;
|
|
861
|
+
this.confirmationTimeoutMs = parseConfirmationTimeout(process.env.BAMBU_CONFIRMATION_TIMEOUT_MS);
|
|
825
862
|
this.runtimeConfig = readRuntimeConfig();
|
|
826
863
|
this.server = new Server({
|
|
827
864
|
name: "bambu-printer-mcp",
|
|
@@ -842,11 +879,17 @@ class BambuPrinterMCPServer {
|
|
|
842
879
|
properties: { confirmed: { type: "boolean", title: "I checked the printer and confirm this operation", default: false } },
|
|
843
880
|
required: ["confirmed"],
|
|
844
881
|
},
|
|
845
|
-
});
|
|
882
|
+
}, { timeout: this.confirmationTimeoutMs });
|
|
846
883
|
return response.action === "accept" && response.content?.confirmed === true;
|
|
847
884
|
}
|
|
848
|
-
catch {
|
|
849
|
-
|
|
885
|
+
catch (error) {
|
|
886
|
+
if (isElicitationTimeout(error)) {
|
|
887
|
+
throw new Error(`No hardware confirmation within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. Retry when someone can check the printer, or raise BAMBU_CONFIRMATION_TIMEOUT_MS.`);
|
|
888
|
+
}
|
|
889
|
+
if (isElicitationUnsupported(error)) {
|
|
890
|
+
throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
|
|
891
|
+
}
|
|
892
|
+
throw new Error(`Human hardware confirmation failed: ${error?.message ?? error}. Nothing was sent to the printer.`);
|
|
850
893
|
}
|
|
851
894
|
});
|
|
852
895
|
this.bambuNetwork = new BambuNetworkBridge();
|
|
@@ -902,17 +945,19 @@ class BambuPrinterMCPServer {
|
|
|
902
945
|
},
|
|
903
946
|
required: ["bambu_model"],
|
|
904
947
|
},
|
|
905
|
-
});
|
|
948
|
+
}, { timeout: this.confirmationTimeoutMs });
|
|
906
949
|
if (result.action === "accept" && result.content?.bambu_model) {
|
|
907
950
|
return validateBambuModel(String(result.content.bambu_model));
|
|
908
951
|
}
|
|
909
952
|
throw new Error("Printer model selection was cancelled. Cannot proceed without knowing the printer model.");
|
|
910
953
|
}
|
|
911
954
|
catch (elicitError) {
|
|
955
|
+
if (isElicitationTimeout(elicitError)) {
|
|
956
|
+
throw new Error(`No printer model was selected within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. ` +
|
|
957
|
+
"Set BAMBU_MODEL or pass bambu_model in the tool call.");
|
|
958
|
+
}
|
|
912
959
|
// Elicitation not supported by this client — fall back to a clear error
|
|
913
|
-
|
|
914
|
-
if (elicitError?.code === -32601 || elicitError?.code === -32600 ||
|
|
915
|
-
msg.includes("does not support") || msg.includes("elicitation")) {
|
|
960
|
+
if (isElicitationUnsupported(elicitError)) {
|
|
916
961
|
throw new Error("bambu_model is required but your MCP client does not support elicitation. " +
|
|
917
962
|
`Set the BAMBU_MODEL environment variable or pass bambu_model in the tool call. ` +
|
|
918
963
|
`Valid models: ${VALID_BAMBU_MODELS.join(", ")}`);
|
|
@@ -977,6 +1022,7 @@ class BambuPrinterMCPServer {
|
|
|
977
1022
|
minSave: true,
|
|
978
1023
|
skipModifiedGcodes: true,
|
|
979
1024
|
bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
|
|
1025
|
+
nozzleType: resolveNozzleType(args?.nozzle_type),
|
|
980
1026
|
};
|
|
981
1027
|
threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, slicerProfile || undefined, undefined, printPreset, autoSliceOptions);
|
|
982
1028
|
console.log("Auto-sliced to: " + threeMFPath);
|
|
@@ -1773,6 +1819,11 @@ class BambuPrinterMCPServer {
|
|
|
1773
1819
|
enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
|
|
1774
1820
|
description: "Bed plate type for slicing (default: textured_plate). SuperTack is accepted only for pre-sliced print jobs until the BambuStudio CLI identifier is verified."
|
|
1775
1821
|
},
|
|
1822
|
+
nozzle_type: {
|
|
1823
|
+
type: "string",
|
|
1824
|
+
enum: [...VALID_NOZZLE_TYPES],
|
|
1825
|
+
description: "Bambu-compatible slicing: the hotend nozzle material installed on the printer (default: BAMBU_NOZZLE_TYPE, else the model preset's stock nozzle, usually stainless_steel). Printing compares it with the printer's reported nozzle."
|
|
1826
|
+
},
|
|
1776
1827
|
use_printer_filaments: { type: "boolean", description: "When true, and no explicit slicer profile or load_filaments override is provided, use the printer's current or first loaded AMS filament as the slicer filament profile." },
|
|
1777
1828
|
host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
|
|
1778
1829
|
bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
|
|
@@ -1823,6 +1874,11 @@ class BambuPrinterMCPServer {
|
|
|
1823
1874
|
enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
|
|
1824
1875
|
description: "Bed plate type for slicing (default: textured_plate). SuperTack is accepted only for pre-sliced print jobs until the BambuStudio CLI identifier is verified."
|
|
1825
1876
|
},
|
|
1877
|
+
nozzle_type: {
|
|
1878
|
+
type: "string",
|
|
1879
|
+
enum: [...VALID_NOZZLE_TYPES],
|
|
1880
|
+
description: "Bambu-compatible slicing: the hotend nozzle material installed on the printer (default: BAMBU_NOZZLE_TYPE, else the model preset's stock nozzle, usually stainless_steel). Printing compares it with the printer's reported nozzle."
|
|
1881
|
+
},
|
|
1826
1882
|
use_printer_filaments: { type: "boolean", description: "When true, and no explicit slicer profile or load_filaments override is provided, use the printer's current or first loaded AMS filament as the slicer filament profile. Template 3MF process settings can still be used at the same time." },
|
|
1827
1883
|
uptodate: { type: "boolean", description: "Refresh 3MF preset configs to match the latest BambuStudio version. Use when slicing downloaded or older 3MF files to prevent stale-config failures." },
|
|
1828
1884
|
repetitions: { type: "number", description: "Print N identical copies of the model. Each copy gets its own plate placement. Example: 3 prints three copies." },
|
|
@@ -1928,6 +1984,7 @@ class BambuPrinterMCPServer {
|
|
|
1928
1984
|
username: { type: "string", description: "Printer username for LAN/local bridge methods; defaults to bblp." },
|
|
1929
1985
|
password: { type: "string", description: "Printer password/access code override for LAN/local bridge methods." },
|
|
1930
1986
|
bed_type: { type: "string", enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"], description: "Bed plate type currently installed (default: textured_plate)." },
|
|
1987
|
+
nozzle_type: { type: "string", enum: [...VALID_NOZZLE_TYPES], description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)." },
|
|
1931
1988
|
plate_index: { type: "number", description: "Zero-based plate index to print from the sliced 3MF; converted to FULU's one-based PrintParams plate_index." },
|
|
1932
1989
|
project_name: { type: "string", description: "Optional project name sent in FULU PrintParams; defaults to the 3MF filename without extension." },
|
|
1933
1990
|
preset_name: { type: "string", description: "Optional preset name sent in FULU PrintParams; defaults to project plus one-based plate index." },
|
|
@@ -2333,6 +2390,11 @@ class BambuPrinterMCPServer {
|
|
|
2333
2390
|
enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
|
|
2334
2391
|
description: "Bed plate type currently installed (default: textured_plate)"
|
|
2335
2392
|
},
|
|
2393
|
+
nozzle_type: {
|
|
2394
|
+
type: "string",
|
|
2395
|
+
enum: [...VALID_NOZZLE_TYPES],
|
|
2396
|
+
description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)."
|
|
2397
|
+
},
|
|
2336
2398
|
host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
|
|
2337
2399
|
bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
|
|
2338
2400
|
bambu_token: { type: "string", description: "Access token (default: value from env)" },
|
|
@@ -3031,6 +3093,7 @@ class BambuPrinterMCPServer {
|
|
|
3031
3093
|
if (args?.filament_colours !== undefined)
|
|
3032
3094
|
sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
|
|
3033
3095
|
sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
|
|
3096
|
+
sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
|
|
3034
3097
|
if (args?.enable_timelapse !== undefined)
|
|
3035
3098
|
sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
|
|
3036
3099
|
if (args?.allow_mix_temp !== undefined)
|
|
@@ -3110,6 +3173,7 @@ class BambuPrinterMCPServer {
|
|
|
3110
3173
|
if (args?.filament_colours !== undefined)
|
|
3111
3174
|
sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
|
|
3112
3175
|
sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
|
|
3176
|
+
sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
|
|
3113
3177
|
if (args?.enable_timelapse !== undefined)
|
|
3114
3178
|
sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
|
|
3115
3179
|
if (args?.allow_mix_temp !== undefined)
|
|
@@ -3212,6 +3276,7 @@ class BambuPrinterMCPServer {
|
|
|
3212
3276
|
minSave: true,
|
|
3213
3277
|
skipModifiedGcodes: true,
|
|
3214
3278
|
bedType: printBedType,
|
|
3279
|
+
nozzleType: resolveNozzleType(args?.nozzle_type),
|
|
3215
3280
|
};
|
|
3216
3281
|
if (!explicitSlicerProfile) {
|
|
3217
3282
|
try {
|
|
@@ -3314,7 +3379,7 @@ class BambuPrinterMCPServer {
|
|
|
3314
3379
|
layerInspect: args?.layer_inspect !== undefined ? Boolean(args.layer_inspect) : undefined,
|
|
3315
3380
|
timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : undefined,
|
|
3316
3381
|
});
|
|
3317
|
-
|
|
3382
|
+
// Report what the printer did, not merely that a command was published.
|
|
3318
3383
|
break;
|
|
3319
3384
|
}
|
|
3320
3385
|
case "print_collar_charm": {
|
|
@@ -3415,10 +3480,14 @@ class BambuPrinterMCPServer {
|
|
|
3415
3480
|
}
|
|
3416
3481
|
catch (error) {
|
|
3417
3482
|
const message = error instanceof Error ? error.message : String(error);
|
|
3483
|
+
// Refusals and printer rejections repeat unchanged; say what to fix instead of "try again".
|
|
3484
|
+
const refused = /nothing was (?:sent|uploaded)|no command was sent|does not match the job|is ambiguous|is missing or ambiguous|rejected the print command|are refused|is refused|refused because|refused until|refused before/i.test(message);
|
|
3418
3485
|
const structured = {
|
|
3419
3486
|
status: "error",
|
|
3420
3487
|
retryable: false,
|
|
3421
|
-
suggestion:
|
|
3488
|
+
suggestion: refused
|
|
3489
|
+
? `Retrying unchanged repeats this refusal. Fix the reported cause (file, arguments, printer settings, or printer state) first. Error: ${message}`
|
|
3490
|
+
: `Check parameters and try again. Error: ${message}`,
|
|
3422
3491
|
message,
|
|
3423
3492
|
tool: name,
|
|
3424
3493
|
};
|
package/dist/printers/bambu.d.ts
CHANGED
|
@@ -94,14 +94,23 @@ export declare class BambuImplementation {
|
|
|
94
94
|
status: string;
|
|
95
95
|
uploaded: boolean;
|
|
96
96
|
printRequested: boolean;
|
|
97
|
+
dispatch: "started" | "unconfirmed";
|
|
97
98
|
remotePath: string;
|
|
98
99
|
message: string;
|
|
99
100
|
}>;
|
|
100
101
|
private printRawPrepared;
|
|
102
|
+
/**
|
|
103
|
+
* A published print command is not proof the printer took it. Listen to the
|
|
104
|
+
* reports the printer pushes anyway (no extra pushall polling on weak P1
|
|
105
|
+
* boards): a new command-verification HMS means the firmware refused the
|
|
106
|
+
* command; PREPARE/SLICING/RUNNING means it started. Attach before publishing.
|
|
107
|
+
*/
|
|
108
|
+
private watchDispatch;
|
|
101
109
|
startJob(host: string, serial: string, token: string, filename: string, bambuModel?: string): Promise<{
|
|
102
110
|
status: string;
|
|
103
111
|
uploaded: boolean;
|
|
104
112
|
printRequested: boolean;
|
|
113
|
+
dispatch: "started" | "unconfirmed";
|
|
105
114
|
remotePath: string;
|
|
106
115
|
message: string;
|
|
107
116
|
}>;
|
package/dist/printers/bambu.js
CHANGED
|
@@ -36,6 +36,21 @@ function loadClientCreds() {
|
|
|
36
36
|
}
|
|
37
37
|
const CLIENT_CREDS = loadClientCreds();
|
|
38
38
|
const COMMAND_SETTLE_MS = 300;
|
|
39
|
+
/** HMS 0500-0500-0001-0007: firmware 01.08.05+ rejected an unsigned MQTT command. */
|
|
40
|
+
const COMMAND_VERIFICATION_HMS = { attr: 0x05000500, code: 0x00010007 };
|
|
41
|
+
const COMMAND_REJECTED_MESSAGE = "The printer rejected the print command (HMS 0500-0500-0001-0007, \"MQTT command verification failed\"). " +
|
|
42
|
+
"Bambu firmware 01.08.05 and later only accept third-party LAN control with LAN Only Mode and Developer Mode " +
|
|
43
|
+
"enabled (Settings > WLAN on the printer). The checked file is on the printer's storage, but nothing is printing. " +
|
|
44
|
+
"The rejection stays on the printer as a fatal HMS entry that blocks later prints: after enabling those modes, " +
|
|
45
|
+
"clear it with clear_hms_errors or dismiss it on the printer's screen, then print again.";
|
|
46
|
+
const DISPATCH_CANCELLED_MESSAGE = "A stop or heater-off request arrived after the print command was sent, so this call does not report the print as started. " +
|
|
47
|
+
"Check get_printer_status.";
|
|
48
|
+
/** How long to watch fresh reports after a print command (BAMBU_DISPATCH_CHECK_MS, 0 disables). */
|
|
49
|
+
function dispatchCheckMs() {
|
|
50
|
+
const raw = process.env.BAMBU_DISPATCH_CHECK_MS?.trim();
|
|
51
|
+
const value = raw ? Number(raw) : 15000;
|
|
52
|
+
return Number.isInteger(value) && value >= 0 && value <= 60000 ? value : 15000;
|
|
53
|
+
}
|
|
39
54
|
const MODEL_ID_TO_NAME = {
|
|
40
55
|
O1C: "H2C",
|
|
41
56
|
O1C2: "H2C",
|
|
@@ -685,9 +700,23 @@ export class BambuImplementation {
|
|
|
685
700
|
const printer = await this.getPrinter(host, serial, token);
|
|
686
701
|
assertActive();
|
|
687
702
|
if (legacyContainer) {
|
|
688
|
-
|
|
703
|
+
const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
|
|
704
|
+
try {
|
|
705
|
+
await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
watcher.dispose();
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
689
711
|
this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
|
|
690
|
-
|
|
712
|
+
const dispatch = await watcher.settled();
|
|
713
|
+
return {
|
|
714
|
+
status: "success", dispatch, remoteProjectPath,
|
|
715
|
+
message: dispatch === "started"
|
|
716
|
+
? `The printer accepted and started the checked gcode.3mf print: ${options.projectName}`
|
|
717
|
+
: `Uploaded the checked gcode.3mf and sent the print command for ${options.projectName}; the printer has not reported starting yet. ` +
|
|
718
|
+
"Check get_printer_status before assuming it is printing.",
|
|
719
|
+
};
|
|
691
720
|
}
|
|
692
721
|
const b = (v) => (v ? 1 : 0);
|
|
693
722
|
let projectFileCmd;
|
|
@@ -747,12 +776,24 @@ export class BambuImplementation {
|
|
|
747
776
|
},
|
|
748
777
|
};
|
|
749
778
|
}
|
|
750
|
-
|
|
779
|
+
const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
|
|
780
|
+
try {
|
|
781
|
+
await printer.publish(projectFileCmd);
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
watcher.dispose();
|
|
785
|
+
throw error;
|
|
786
|
+
}
|
|
751
787
|
this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
|
|
752
788
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
789
|
+
const dispatch = await watcher.settled();
|
|
753
790
|
return {
|
|
754
791
|
status: "success",
|
|
755
|
-
|
|
792
|
+
dispatch,
|
|
793
|
+
message: dispatch === "started"
|
|
794
|
+
? `The printer accepted and started the checked 3MF print: ${options.projectName}`
|
|
795
|
+
: `Uploaded the checked 3MF and sent the print command for ${options.projectName}; the printer has not reported starting yet. ` +
|
|
796
|
+
"Check get_printer_status before assuming it is printing.",
|
|
756
797
|
remoteProjectPath,
|
|
757
798
|
plateFile: projectMetadata.plateFileName,
|
|
758
799
|
platePath: projectMetadata.plateInternalPath,
|
|
@@ -1190,9 +1231,105 @@ export class BambuImplementation {
|
|
|
1190
1231
|
this.assertBedClearance(dispatchStatus, bedClearance);
|
|
1191
1232
|
const printer = await this.getPrinter(host, serial, token);
|
|
1192
1233
|
assertActive();
|
|
1193
|
-
|
|
1234
|
+
const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
|
|
1235
|
+
try {
|
|
1236
|
+
await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remotePath }));
|
|
1237
|
+
}
|
|
1238
|
+
catch (error) {
|
|
1239
|
+
watcher.dispose();
|
|
1240
|
+
throw error;
|
|
1241
|
+
}
|
|
1194
1242
|
this.recordCheckedJob(host, serial, remotePath, requirements);
|
|
1195
|
-
|
|
1243
|
+
const dispatch = await watcher.settled();
|
|
1244
|
+
return {
|
|
1245
|
+
status: "success", uploaded: true, printRequested: true, dispatch, remotePath,
|
|
1246
|
+
message: dispatch === "started"
|
|
1247
|
+
? `The printer accepted and started ${remotePath}.`
|
|
1248
|
+
: `Sent the print command for ${remotePath}; the printer has not reported starting yet. Check get_printer_status before assuming it is printing.`,
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* A published print command is not proof the printer took it. Listen to the
|
|
1253
|
+
* reports the printer pushes anyway (no extra pushall polling on weak P1
|
|
1254
|
+
* boards): a new command-verification HMS means the firmware refused the
|
|
1255
|
+
* command; PREPARE/SLICING/RUNNING means it started. Attach before publishing.
|
|
1256
|
+
*/
|
|
1257
|
+
watchDispatch(printer, host, serial, token, before, assertActive) {
|
|
1258
|
+
const windowMs = dispatchCheckMs();
|
|
1259
|
+
if (windowMs === 0)
|
|
1260
|
+
return { settled: async () => { assertActive(); return "unconfirmed"; }, dispose: () => { } };
|
|
1261
|
+
const key = (entry) => `${Number(entry?.attr)}:${Number(entry?.code)}:${entry?.timestamp ?? ""}`;
|
|
1262
|
+
const known = new Set((Array.isArray(before?.raw?.hms) ? before.raw.hms : []).map(key));
|
|
1263
|
+
const judge = (report) => {
|
|
1264
|
+
const hms = Array.isArray(report?.hms) ? report.hms : [];
|
|
1265
|
+
if (hms.some((entry) => Number(entry?.attr) === COMMAND_VERIFICATION_HMS.attr &&
|
|
1266
|
+
Number(entry?.code) === COMMAND_VERIFICATION_HMS.code && !known.has(key(entry))))
|
|
1267
|
+
return "rejected";
|
|
1268
|
+
if (["PREPARE", "SLICING", "RUNNING"].includes(String(report?.gcode_state ?? "").toUpperCase()))
|
|
1269
|
+
return "started";
|
|
1270
|
+
return undefined;
|
|
1271
|
+
};
|
|
1272
|
+
let resolveOutcome;
|
|
1273
|
+
const outcome = new Promise((resolve) => { resolveOutcome = resolve; });
|
|
1274
|
+
const onRaw = (topic, payload) => {
|
|
1275
|
+
if (topic !== `device/${serial}/report`)
|
|
1276
|
+
return;
|
|
1277
|
+
let parsed;
|
|
1278
|
+
try {
|
|
1279
|
+
parsed = JSON.parse(payload.toString());
|
|
1280
|
+
}
|
|
1281
|
+
catch {
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
const verdict = judge(parsed?.print);
|
|
1285
|
+
if (verdict)
|
|
1286
|
+
resolveOutcome(verdict);
|
|
1287
|
+
};
|
|
1288
|
+
const cancelled = () => { try {
|
|
1289
|
+
assertActive();
|
|
1290
|
+
return false;
|
|
1291
|
+
}
|
|
1292
|
+
catch {
|
|
1293
|
+
return true;
|
|
1294
|
+
} };
|
|
1295
|
+
printer?.on?.("rawMessage", onRaw);
|
|
1296
|
+
const timer = setTimeout(() => resolveOutcome(undefined), windowMs);
|
|
1297
|
+
// A stop during the window must not end as "accepted and started".
|
|
1298
|
+
const cancelCheck = setInterval(() => { if (cancelled())
|
|
1299
|
+
resolveOutcome("cancelled"); }, 250);
|
|
1300
|
+
const dispose = () => {
|
|
1301
|
+
clearTimeout(timer);
|
|
1302
|
+
clearInterval(cancelCheck);
|
|
1303
|
+
printer?.off?.("rawMessage", onRaw);
|
|
1304
|
+
resolveOutcome(undefined);
|
|
1305
|
+
};
|
|
1306
|
+
return {
|
|
1307
|
+
dispose,
|
|
1308
|
+
settled: async () => {
|
|
1309
|
+
let verdict;
|
|
1310
|
+
try {
|
|
1311
|
+
verdict = await outcome;
|
|
1312
|
+
}
|
|
1313
|
+
finally {
|
|
1314
|
+
dispose();
|
|
1315
|
+
}
|
|
1316
|
+
// Nothing pushed during the window: take one full report, not a polling loop.
|
|
1317
|
+
if (verdict === undefined && !cancelled()) {
|
|
1318
|
+
try {
|
|
1319
|
+
verdict = judge((await this.getSafetyStatus(host, serial, token))?.raw);
|
|
1320
|
+
}
|
|
1321
|
+
catch { /* stays unconfirmed */ }
|
|
1322
|
+
}
|
|
1323
|
+
if (verdict === "rejected") {
|
|
1324
|
+
// Nothing started, so there is no checked job to resume or clear later.
|
|
1325
|
+
this.checkedJobs.delete(`${host}\n${serial}`);
|
|
1326
|
+
throw new Error(COMMAND_REJECTED_MESSAGE);
|
|
1327
|
+
}
|
|
1328
|
+
if (verdict === "cancelled" || cancelled())
|
|
1329
|
+
throw new Error(DISPATCH_CANCELLED_MESSAGE);
|
|
1330
|
+
return verdict ?? "unconfirmed";
|
|
1331
|
+
},
|
|
1332
|
+
};
|
|
1196
1333
|
}
|
|
1197
1334
|
async startJob(host, serial, token, filename, bambuModel) {
|
|
1198
1335
|
assertDirectPrintSupported(bambuModel, serial);
|
|
@@ -194,7 +194,10 @@ function validateErrors(raw, requireIdle) {
|
|
|
194
194
|
// BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
|
|
195
195
|
// Unknown severities fail closed; attr identifies the module and is not a severity bitmask.
|
|
196
196
|
if (code === undefined || attr === undefined || !Number.isInteger(code) || !Number.isInteger(attr) || code < 0 || code > 0xffffffff || attr < 0 || attr > 0xffffffff || Math.floor(code / 65536) !== 4) {
|
|
197
|
-
|
|
197
|
+
const hex = (value) => value.toString(16).toUpperCase().padStart(8, "0");
|
|
198
|
+
const label = code !== undefined && attr !== undefined && Number.isInteger(code) && Number.isInteger(attr) && code >= 0 && attr >= 0
|
|
199
|
+
? ` (HMS ${hex(attr).slice(0, 4)}-${hex(attr).slice(4)}-${hex(code).slice(0, 4)}-${hex(code).slice(4)})` : "";
|
|
200
|
+
throw new Error(`Printer reports actionable or unknown HMS errors${label}; inspect and resolve them before proceeding.`);
|
|
198
201
|
}
|
|
199
202
|
}
|
|
200
203
|
}
|
|
@@ -41,6 +41,7 @@ import crypto from "node:crypto";
|
|
|
41
41
|
*/
|
|
42
42
|
async function buildNameIndex(profilesRoot, vendor, userProfileRoots = []) {
|
|
43
43
|
const index = new Map();
|
|
44
|
+
index.malformed = [];
|
|
44
45
|
const subdirs = ["machine", "process", "filament"];
|
|
45
46
|
const directories = [
|
|
46
47
|
...subdirs.map(sub => path.join(profilesRoot, vendor, sub)),
|
|
@@ -72,11 +73,15 @@ async function buildNameIndex(profilesRoot, vendor, userProfileRoots = []) {
|
|
|
72
73
|
data = JSON.parse(raw);
|
|
73
74
|
}
|
|
74
75
|
catch {
|
|
75
|
-
// Malformed profile -- skip, don't poison the index.
|
|
76
|
+
// Malformed profile -- skip, don't poison the index. A reference to
|
|
77
|
+
// it still fails, and the error names the skipped file.
|
|
78
|
+
index.malformed.push(filePath);
|
|
76
79
|
continue;
|
|
77
80
|
}
|
|
78
|
-
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
81
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
82
|
+
index.malformed.push(filePath);
|
|
79
83
|
continue;
|
|
84
|
+
}
|
|
80
85
|
const name = data["name"];
|
|
81
86
|
if (typeof name !== "string" || name.length === 0)
|
|
82
87
|
continue;
|
|
@@ -106,7 +111,7 @@ function flattenByName(leafName, index, visiting = new Set()) {
|
|
|
106
111
|
}
|
|
107
112
|
const entry = index.get(leafName);
|
|
108
113
|
if (!entry) {
|
|
109
|
-
throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled
|
|
114
|
+
throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.${describeMalformed(index)}`);
|
|
110
115
|
}
|
|
111
116
|
visiting.add(leafName);
|
|
112
117
|
try {
|
|
@@ -116,6 +121,13 @@ function flattenByName(leafName, index, visiting = new Set()) {
|
|
|
116
121
|
visiting.delete(leafName);
|
|
117
122
|
}
|
|
118
123
|
}
|
|
124
|
+
function describeMalformed(index) {
|
|
125
|
+
const malformed = index.malformed ?? [];
|
|
126
|
+
if (malformed.length === 0)
|
|
127
|
+
return "";
|
|
128
|
+
const shown = malformed.slice(0, 5).map((file) => path.basename(file)).join(", ");
|
|
129
|
+
return ` Skipped ${malformed.length} unreadable or malformed profile file(s): ${shown}${malformed.length > 5 ? ", ..." : ""}.`;
|
|
130
|
+
}
|
|
119
131
|
function flattenData(data, index, visiting = new Set()) {
|
|
120
132
|
const parent = data["inherits"];
|
|
121
133
|
const merged = typeof parent === "string" && parent.length > 0
|
|
@@ -157,7 +169,7 @@ function applyIncludes(target, profile, index, visiting) {
|
|
|
157
169
|
const entry = index.get(name);
|
|
158
170
|
if (!entry) {
|
|
159
171
|
throw new Error(`Profile "${String(profile["name"])}" includes "${name}", which is not in the index. ` +
|
|
160
|
-
`Refusing to fall back to inherited defaults (wrong G-code for this printer)
|
|
172
|
+
`Refusing to fall back to inherited defaults (wrong G-code for this printer).${describeMalformed(index)}`);
|
|
161
173
|
}
|
|
162
174
|
for (const [key, value] of Object.entries(flattenByName(name, index, visiting))) {
|
|
163
175
|
if (!INCLUDE_METADATA_KEYS.has(key))
|
|
@@ -38,6 +38,7 @@ export interface BambuSliceOptions {
|
|
|
38
38
|
loadFilamentIds?: string;
|
|
39
39
|
filamentColours?: string[];
|
|
40
40
|
bedType?: string;
|
|
41
|
+
nozzleType?: string;
|
|
41
42
|
enableTimelapse?: boolean;
|
|
42
43
|
allowMixTemp?: boolean;
|
|
43
44
|
scale?: number;
|
|
@@ -62,10 +63,20 @@ export declare class STLManipulator extends EventEmitter {
|
|
|
62
63
|
private readJsonFile;
|
|
63
64
|
private stripAbsoluteExtruderResets;
|
|
64
65
|
private sanitizeProcessForOrca;
|
|
66
|
+
/** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
|
|
67
|
+
private useAbsoluteExtrusionForOrca;
|
|
65
68
|
private writeTempJson;
|
|
66
69
|
private resolveBambuLikeSettingsBundle;
|
|
67
70
|
/** Resolve BBL dependencies before invoking the CLI; failures stop the slice. */
|
|
68
71
|
private maybeFlattenBundle;
|
|
72
|
+
/**
|
|
73
|
+
* The CLI applies every key in every --load-settings/--load-filaments file,
|
|
74
|
+
* so machine settings carried by a process or filament file (for example a
|
|
75
|
+
* template 3MF's project_settings from another printer) would replace the
|
|
76
|
+
* selected preset's start G-code, model, and bed geometry. The machine
|
|
77
|
+
* preset owns those keys; drop them from the other profiles.
|
|
78
|
+
*/
|
|
79
|
+
private isolateMachineSettings;
|
|
69
80
|
/** --load-filaments is positional; a single override must cover every project slot. */
|
|
70
81
|
private expandProjectFilaments;
|
|
71
82
|
private resolveBambuStudioBedType;
|
|
@@ -100,6 +100,11 @@ function configuredBambuProfileDirs() {
|
|
|
100
100
|
];
|
|
101
101
|
return roots.flatMap((root) => appNames.map((appName) => path.join(root, appName, 'system', 'BBL')));
|
|
102
102
|
}
|
|
103
|
+
/** Keys every profile kind carries; they describe the file, not machine hardware. */
|
|
104
|
+
const PROFILE_METADATA_KEYS = new Set([
|
|
105
|
+
'name', 'type', 'from', 'inherits', 'instantiation', 'setting_id', 'version', 'is_custom_defined',
|
|
106
|
+
'compatible_printers', 'compatible_printers_condition', 'compatible_prints', 'compatible_prints_condition',
|
|
107
|
+
]);
|
|
103
108
|
export class STLManipulator extends EventEmitter {
|
|
104
109
|
constructor(tempDir = path.join(process.cwd(), 'temp')) {
|
|
105
110
|
super();
|
|
@@ -193,11 +198,16 @@ export class STLManipulator extends EventEmitter {
|
|
|
193
198
|
else if (sanitized.prime_tower_brim_width !== undefined) {
|
|
194
199
|
sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
|
|
195
200
|
}
|
|
196
|
-
sanitized
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
+
return this.useAbsoluteExtrusionForOrca(sanitized);
|
|
202
|
+
}
|
|
203
|
+
/** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
|
|
204
|
+
useAbsoluteExtrusionForOrca(config) {
|
|
205
|
+
const normalized = { ...config };
|
|
206
|
+
normalized.use_relative_e_distances = '0';
|
|
207
|
+
normalized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(normalized.before_layer_change_gcode);
|
|
208
|
+
normalized.layer_gcode = this.stripAbsoluteExtruderResets(normalized.layer_gcode);
|
|
209
|
+
normalized.layer_change_gcode = this.stripAbsoluteExtruderResets(normalized.layer_change_gcode);
|
|
210
|
+
return normalized;
|
|
201
211
|
}
|
|
202
212
|
writeTempJson(outputBase, suffix, value) {
|
|
203
213
|
const serialized = JSON.stringify(value, null, 2);
|
|
@@ -426,6 +436,36 @@ export class STLManipulator extends EventEmitter {
|
|
|
426
436
|
: withCliFields(bundle.filamentPaths[i], profile, flat.filamentPaths[i], ['filament_colour'])),
|
|
427
437
|
};
|
|
428
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* The CLI applies every key in every --load-settings/--load-filaments file,
|
|
441
|
+
* so machine settings carried by a process or filament file (for example a
|
|
442
|
+
* template 3MF's project_settings from another printer) would replace the
|
|
443
|
+
* selected preset's start G-code, model, and bed geometry. The machine
|
|
444
|
+
* preset owns those keys; drop them from the other profiles.
|
|
445
|
+
*/
|
|
446
|
+
isolateMachineSettings(bundle) {
|
|
447
|
+
const [machinePath, processPath] = bundle.settingsArg?.split(';') ?? [];
|
|
448
|
+
if (!machinePath || !processPath)
|
|
449
|
+
return bundle;
|
|
450
|
+
const machineKeys = Object.keys(this.readJsonFile(machinePath)).filter(key => !PROFILE_METADATA_KEYS.has(key));
|
|
451
|
+
const isolate = (filePath, label) => {
|
|
452
|
+
const profile = this.readJsonFile(filePath);
|
|
453
|
+
const foreign = machineKeys.filter(key => Object.prototype.hasOwnProperty.call(profile, key));
|
|
454
|
+
if (foreign.length === 0)
|
|
455
|
+
return filePath;
|
|
456
|
+
const isolated = { ...profile };
|
|
457
|
+
for (const key of foreign)
|
|
458
|
+
delete isolated[key];
|
|
459
|
+
console.error(`Ignoring ${foreign.length} machine setting(s) in ${label} profile ${filePath} ` +
|
|
460
|
+
`(the selected machine preset owns them): ${foreign.slice(0, 8).join(', ')}${foreign.length > 8 ? ', ...' : ''}`);
|
|
461
|
+
return this.writeTempJson(path.basename(filePath, '.json'), `${label}_without_machine`, isolated);
|
|
462
|
+
};
|
|
463
|
+
return {
|
|
464
|
+
...bundle,
|
|
465
|
+
settingsArg: [machinePath, isolate(processPath, 'process')].join(';'),
|
|
466
|
+
filamentPaths: bundle.filamentPaths.map(filePath => isolate(filePath, 'filament')),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
429
469
|
/** --load-filaments is positional; a single override must cover every project slot. */
|
|
430
470
|
async expandProjectFilaments(inputPath, bundle) {
|
|
431
471
|
if (!inputPath.toLowerCase().endsWith('.3mf') || bundle.filamentPaths.length === 0)
|
|
@@ -1242,19 +1282,38 @@ export class STLManipulator extends EventEmitter {
|
|
|
1242
1282
|
const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, slicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot);
|
|
1243
1283
|
// Resolve every BBL dependency before launching the slicer.
|
|
1244
1284
|
const settingsBundle = await this.maybeFlattenBundle(await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
|
|
1285
|
+
// The machine preset assumes the stock hotend; record the installed one so
|
|
1286
|
+
// the sliced job's nozzle_type matches what the printer reports.
|
|
1287
|
+
if (bambuOptions?.nozzleType && settingsBundle.settingsArg) {
|
|
1288
|
+
const [machinePath, ...rest] = settingsBundle.settingsArg.split(';');
|
|
1289
|
+
const machine = this.readJsonFile(machinePath);
|
|
1290
|
+
const current = machine.nozzle_type;
|
|
1291
|
+
machine.nozzle_type = Array.isArray(current) && current.length > 0
|
|
1292
|
+
? current.map(() => bambuOptions.nozzleType)
|
|
1293
|
+
: [bambuOptions.nozzleType];
|
|
1294
|
+
settingsBundle.settingsArg = [
|
|
1295
|
+
this.writeTempJson(outputBase, `machine_${bambuOptions.nozzleType}`, machine), ...rest,
|
|
1296
|
+
].join(';');
|
|
1297
|
+
}
|
|
1245
1298
|
// Inherited process G-code is now present; apply Orca's existing
|
|
1246
1299
|
// absolute-extrusion normalization after resolving those ancestors.
|
|
1300
|
+
// The machine owns the extrusion mode and layer G-code once process
|
|
1301
|
+
// files lose machine keys, so normalize it there as well.
|
|
1247
1302
|
if (slicerType === 'orcaslicer' && settingsBundle.settingsArg) {
|
|
1248
1303
|
const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
|
|
1249
|
-
settingsBundle.settingsArg = [
|
|
1304
|
+
settingsBundle.settingsArg = [
|
|
1305
|
+
this.writeTempJson(outputBase, 'machine_orca_resolved', this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
|
|
1306
|
+
this.writeTempJson(outputBase, 'process_orca_resolved', this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
|
|
1307
|
+
].join(';');
|
|
1250
1308
|
}
|
|
1309
|
+
const cliBundle = this.isolateMachineSettings(settingsBundle);
|
|
1251
1310
|
args = [
|
|
1252
1311
|
'--slice', String(bambuOptions?.slicePlate ?? 0),
|
|
1253
1312
|
'--outputdir', outputDir,
|
|
1254
1313
|
'--export-3mf', path.basename(bambuOutputPath),
|
|
1255
1314
|
];
|
|
1256
|
-
if (
|
|
1257
|
-
args.push('--load-settings',
|
|
1315
|
+
if (cliBundle.settingsArg) {
|
|
1316
|
+
args.push('--load-settings', cliBundle.settingsArg);
|
|
1258
1317
|
}
|
|
1259
1318
|
// Always allow newer-version 3MF files (the CLI rejects them by default)
|
|
1260
1319
|
args.push('--allow-newer-file');
|
|
@@ -1289,8 +1348,8 @@ export class STLManipulator extends EventEmitter {
|
|
|
1289
1348
|
args.push('--clone-objects', bambuOptions.cloneObjects);
|
|
1290
1349
|
if (bambuOptions?.skipObjects)
|
|
1291
1350
|
args.push('--skip-objects', bambuOptions.skipObjects);
|
|
1292
|
-
if (
|
|
1293
|
-
args.push('--load-filaments',
|
|
1351
|
+
if (cliBundle.filamentPaths.length > 0) {
|
|
1352
|
+
args.push('--load-filaments', cliBundle.filamentPaths.join(';'));
|
|
1294
1353
|
args.push('--load-defaultfila');
|
|
1295
1354
|
}
|
|
1296
1355
|
if (bambuOptions?.loadFilamentIds)
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -111,6 +111,18 @@ type BambuModel = typeof VALID_BAMBU_MODELS[number];
|
|
|
111
111
|
const H2_BAMBU_MODELS = new Set<string>(["h2d", "h2s", "h2c", "x2d"]);
|
|
112
112
|
|
|
113
113
|
const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"] as const;
|
|
114
|
+
const VALID_NOZZLE_TYPES = ["stainless_steel", "hardened_steel", "tungsten_carbide", "brass"] as const;
|
|
115
|
+
|
|
116
|
+
/** The installed hotend material. Bambu machine presets assume the stock nozzle; the print gate compares this with the printer. */
|
|
117
|
+
function resolveNozzleType(value: unknown): string | undefined {
|
|
118
|
+
const raw = value === undefined || value === null || value === "" ? process.env.BAMBU_NOZZLE_TYPE?.trim() : value;
|
|
119
|
+
if (raw === undefined || raw === "") return undefined;
|
|
120
|
+
const normalized = String(raw).trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
121
|
+
if (!(VALID_NOZZLE_TYPES as readonly string[]).includes(normalized)) {
|
|
122
|
+
throw new Error(`Invalid nozzle type "${raw}". Valid types: ${VALID_NOZZLE_TYPES.join(", ")}`);
|
|
123
|
+
}
|
|
124
|
+
return normalized;
|
|
125
|
+
}
|
|
114
126
|
const VALID_BAMBUSTUDIO_CLI_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"] as const;
|
|
115
127
|
|
|
116
128
|
// Map model IDs to BambuStudio --load-machine preset names
|
|
@@ -689,6 +701,35 @@ function parseBooleanEnv(rawValue: string | undefined, fallback: boolean): boole
|
|
|
689
701
|
return fallback;
|
|
690
702
|
}
|
|
691
703
|
|
|
704
|
+
// Someone confirming a hardware action may have to walk to the printer, so the
|
|
705
|
+
// SDK's 60-second request default is too short for human confirmation.
|
|
706
|
+
const DEFAULT_CONFIRMATION_TIMEOUT_MS = 600_000;
|
|
707
|
+
const MIN_CONFIRMATION_TIMEOUT_MS = 1_000;
|
|
708
|
+
const MAX_CONFIRMATION_TIMEOUT_MS = 3_600_000;
|
|
709
|
+
|
|
710
|
+
/** Out-of-range values fall back to the default; Node clamps huge timer delays to 1 ms. */
|
|
711
|
+
function parseConfirmationTimeout(value: string | undefined): number {
|
|
712
|
+
const parsed = Number(value?.trim() || NaN);
|
|
713
|
+
return Number.isInteger(parsed) && parsed >= MIN_CONFIRMATION_TIMEOUT_MS && parsed <= MAX_CONFIRMATION_TIMEOUT_MS
|
|
714
|
+
? parsed
|
|
715
|
+
: DEFAULT_CONFIRMATION_TIMEOUT_MS;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function describeDuration(ms: number): string {
|
|
719
|
+
return ms >= 60_000 ? `${Math.round(ms / 6_000) / 10} minute(s)` : `${Math.round(ms / 100) / 10} second(s)`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function isElicitationTimeout(error: any): boolean {
|
|
723
|
+
return error?.code === ErrorCode.RequestTimeout;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Only a missing method or capability means the client cannot show the prompt. */
|
|
727
|
+
function isElicitationUnsupported(error: any): boolean {
|
|
728
|
+
const message = String(error?.message ?? error);
|
|
729
|
+
return error?.code === ErrorCode.MethodNotFound || error?.code === ErrorCode.InvalidRequest ||
|
|
730
|
+
/does not support (form |url )?elicitation/i.test(message);
|
|
731
|
+
}
|
|
732
|
+
|
|
692
733
|
function parsePort(value: string | undefined, fallback: number): number {
|
|
693
734
|
if (!value) return fallback;
|
|
694
735
|
const parsed = Number(value);
|
|
@@ -1105,6 +1146,7 @@ class BambuPrinterMCPServer {
|
|
|
1105
1146
|
private readonly runtimeConfig: RuntimeConfig;
|
|
1106
1147
|
private httpRuntime?: { transport: StreamableHTTPServerTransport; httpServer: HttpServer };
|
|
1107
1148
|
private shuttingDown = false;
|
|
1149
|
+
private readonly confirmationTimeoutMs = parseConfirmationTimeout(process.env.BAMBU_CONFIRMATION_TIMEOUT_MS);
|
|
1108
1150
|
|
|
1109
1151
|
constructor() {
|
|
1110
1152
|
this.runtimeConfig = readRuntimeConfig();
|
|
@@ -1131,10 +1173,16 @@ class BambuPrinterMCPServer {
|
|
|
1131
1173
|
properties: { confirmed: { type: "boolean", title: "I checked the printer and confirm this operation", default: false } },
|
|
1132
1174
|
required: ["confirmed"],
|
|
1133
1175
|
},
|
|
1134
|
-
});
|
|
1176
|
+
}, { timeout: this.confirmationTimeoutMs });
|
|
1135
1177
|
return response.action === "accept" && response.content?.confirmed === true;
|
|
1136
|
-
} catch {
|
|
1137
|
-
|
|
1178
|
+
} catch (error: any) {
|
|
1179
|
+
if (isElicitationTimeout(error)) {
|
|
1180
|
+
throw new Error(`No hardware confirmation within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. Retry when someone can check the printer, or raise BAMBU_CONFIRMATION_TIMEOUT_MS.`);
|
|
1181
|
+
}
|
|
1182
|
+
if (isElicitationUnsupported(error)) {
|
|
1183
|
+
throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
|
|
1184
|
+
}
|
|
1185
|
+
throw new Error(`Human hardware confirmation failed: ${error?.message ?? error}. Nothing was sent to the printer.`);
|
|
1138
1186
|
}
|
|
1139
1187
|
});
|
|
1140
1188
|
this.bambuNetwork = new BambuNetworkBridge();
|
|
@@ -1196,7 +1244,7 @@ class BambuPrinterMCPServer {
|
|
|
1196
1244
|
},
|
|
1197
1245
|
required: ["bambu_model"],
|
|
1198
1246
|
},
|
|
1199
|
-
});
|
|
1247
|
+
}, { timeout: this.confirmationTimeoutMs });
|
|
1200
1248
|
|
|
1201
1249
|
if (result.action === "accept" && result.content?.bambu_model) {
|
|
1202
1250
|
return validateBambuModel(String(result.content.bambu_model));
|
|
@@ -1206,12 +1254,14 @@ class BambuPrinterMCPServer {
|
|
|
1206
1254
|
"Printer model selection was cancelled. Cannot proceed without knowing the printer model."
|
|
1207
1255
|
);
|
|
1208
1256
|
} catch (elicitError: any) {
|
|
1257
|
+
if (isElicitationTimeout(elicitError)) {
|
|
1258
|
+
throw new Error(
|
|
1259
|
+
`No printer model was selected within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. ` +
|
|
1260
|
+
"Set BAMBU_MODEL or pass bambu_model in the tool call."
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1209
1263
|
// Elicitation not supported by this client — fall back to a clear error
|
|
1210
|
-
|
|
1211
|
-
if (
|
|
1212
|
-
elicitError?.code === -32601 || elicitError?.code === -32600 ||
|
|
1213
|
-
msg.includes("does not support") || msg.includes("elicitation")
|
|
1214
|
-
) {
|
|
1264
|
+
if (isElicitationUnsupported(elicitError)) {
|
|
1215
1265
|
throw new Error(
|
|
1216
1266
|
"bambu_model is required but your MCP client does not support elicitation. " +
|
|
1217
1267
|
`Set the BAMBU_MODEL environment variable or pass bambu_model in the tool call. ` +
|
|
@@ -1320,6 +1370,7 @@ class BambuPrinterMCPServer {
|
|
|
1320
1370
|
minSave: true,
|
|
1321
1371
|
skipModifiedGcodes: true,
|
|
1322
1372
|
bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
|
|
1373
|
+
nozzleType: resolveNozzleType(args?.nozzle_type),
|
|
1323
1374
|
};
|
|
1324
1375
|
threeMFPath = await this.stlManipulator.sliceSTL(
|
|
1325
1376
|
threeMFPath, slicerType, slicerPath, slicerProfile || undefined,
|
|
@@ -2294,6 +2345,11 @@ class BambuPrinterMCPServer {
|
|
|
2294
2345
|
enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
|
|
2295
2346
|
description: "Bed plate type for slicing (default: textured_plate). SuperTack is accepted only for pre-sliced print jobs until the BambuStudio CLI identifier is verified."
|
|
2296
2347
|
},
|
|
2348
|
+
nozzle_type: {
|
|
2349
|
+
type: "string",
|
|
2350
|
+
enum: [...VALID_NOZZLE_TYPES],
|
|
2351
|
+
description: "Bambu-compatible slicing: the hotend nozzle material installed on the printer (default: BAMBU_NOZZLE_TYPE, else the model preset's stock nozzle, usually stainless_steel). Printing compares it with the printer's reported nozzle."
|
|
2352
|
+
},
|
|
2297
2353
|
use_printer_filaments: { type: "boolean", description: "When true, and no explicit slicer profile or load_filaments override is provided, use the printer's current or first loaded AMS filament as the slicer filament profile." },
|
|
2298
2354
|
host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
|
|
2299
2355
|
bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
|
|
@@ -2344,6 +2400,11 @@ class BambuPrinterMCPServer {
|
|
|
2344
2400
|
enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
|
|
2345
2401
|
description: "Bed plate type for slicing (default: textured_plate). SuperTack is accepted only for pre-sliced print jobs until the BambuStudio CLI identifier is verified."
|
|
2346
2402
|
},
|
|
2403
|
+
nozzle_type: {
|
|
2404
|
+
type: "string",
|
|
2405
|
+
enum: [...VALID_NOZZLE_TYPES],
|
|
2406
|
+
description: "Bambu-compatible slicing: the hotend nozzle material installed on the printer (default: BAMBU_NOZZLE_TYPE, else the model preset's stock nozzle, usually stainless_steel). Printing compares it with the printer's reported nozzle."
|
|
2407
|
+
},
|
|
2347
2408
|
use_printer_filaments: { type: "boolean", description: "When true, and no explicit slicer profile or load_filaments override is provided, use the printer's current or first loaded AMS filament as the slicer filament profile. Template 3MF process settings can still be used at the same time." },
|
|
2348
2409
|
uptodate: { type: "boolean", description: "Refresh 3MF preset configs to match the latest BambuStudio version. Use when slicing downloaded or older 3MF files to prevent stale-config failures." },
|
|
2349
2410
|
repetitions: { type: "number", description: "Print N identical copies of the model. Each copy gets its own plate placement. Example: 3 prints three copies." },
|
|
@@ -2449,6 +2510,7 @@ class BambuPrinterMCPServer {
|
|
|
2449
2510
|
username: { type: "string", description: "Printer username for LAN/local bridge methods; defaults to bblp." },
|
|
2450
2511
|
password: { type: "string", description: "Printer password/access code override for LAN/local bridge methods." },
|
|
2451
2512
|
bed_type: { type: "string", enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"], description: "Bed plate type currently installed (default: textured_plate)." },
|
|
2513
|
+
nozzle_type: { type: "string", enum: [...VALID_NOZZLE_TYPES], description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)." },
|
|
2452
2514
|
plate_index: { type: "number", description: "Zero-based plate index to print from the sliced 3MF; converted to FULU's one-based PrintParams plate_index." },
|
|
2453
2515
|
project_name: { type: "string", description: "Optional project name sent in FULU PrintParams; defaults to the 3MF filename without extension." },
|
|
2454
2516
|
preset_name: { type: "string", description: "Optional preset name sent in FULU PrintParams; defaults to project plus one-based plate index." },
|
|
@@ -2854,6 +2916,11 @@ class BambuPrinterMCPServer {
|
|
|
2854
2916
|
enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
|
|
2855
2917
|
description: "Bed plate type currently installed (default: textured_plate)"
|
|
2856
2918
|
},
|
|
2919
|
+
nozzle_type: {
|
|
2920
|
+
type: "string",
|
|
2921
|
+
enum: [...VALID_NOZZLE_TYPES],
|
|
2922
|
+
description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)."
|
|
2923
|
+
},
|
|
2857
2924
|
host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
|
|
2858
2925
|
bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
|
|
2859
2926
|
bambu_token: { type: "string", description: "Access token (default: value from env)" },
|
|
@@ -3681,6 +3748,7 @@ class BambuPrinterMCPServer {
|
|
|
3681
3748
|
if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
|
|
3682
3749
|
if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
|
|
3683
3750
|
sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
|
|
3751
|
+
sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
|
|
3684
3752
|
if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
|
|
3685
3753
|
if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
|
|
3686
3754
|
if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
|
|
@@ -3767,6 +3835,7 @@ class BambuPrinterMCPServer {
|
|
|
3767
3835
|
if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
|
|
3768
3836
|
if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
|
|
3769
3837
|
sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
|
|
3838
|
+
sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
|
|
3770
3839
|
if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
|
|
3771
3840
|
if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
|
|
3772
3841
|
if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
|
|
@@ -3894,6 +3963,7 @@ class BambuPrinterMCPServer {
|
|
|
3894
3963
|
minSave: true,
|
|
3895
3964
|
skipModifiedGcodes: true,
|
|
3896
3965
|
bedType: printBedType,
|
|
3966
|
+
nozzleType: resolveNozzleType(args?.nozzle_type),
|
|
3897
3967
|
};
|
|
3898
3968
|
if (!explicitSlicerProfile) {
|
|
3899
3969
|
try {
|
|
@@ -4024,7 +4094,7 @@ class BambuPrinterMCPServer {
|
|
|
4024
4094
|
layerInspect: args?.layer_inspect !== undefined ? Boolean(args.layer_inspect) : undefined,
|
|
4025
4095
|
timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : undefined,
|
|
4026
4096
|
});
|
|
4027
|
-
|
|
4097
|
+
// Report what the printer did, not merely that a command was published.
|
|
4028
4098
|
break;
|
|
4029
4099
|
}
|
|
4030
4100
|
|
|
@@ -4176,10 +4246,14 @@ class BambuPrinterMCPServer {
|
|
|
4176
4246
|
|
|
4177
4247
|
} catch (error) {
|
|
4178
4248
|
const message = error instanceof Error ? error.message : String(error);
|
|
4249
|
+
// Refusals and printer rejections repeat unchanged; say what to fix instead of "try again".
|
|
4250
|
+
const refused = /nothing was (?:sent|uploaded)|no command was sent|does not match the job|is ambiguous|is missing or ambiguous|rejected the print command|are refused|is refused|refused because|refused until|refused before/i.test(message);
|
|
4179
4251
|
const structured: StructuredToolError = {
|
|
4180
4252
|
status: "error",
|
|
4181
4253
|
retryable: false,
|
|
4182
|
-
suggestion:
|
|
4254
|
+
suggestion: refused
|
|
4255
|
+
? `Retrying unchanged repeats this refusal. Fix the reported cause (file, arguments, printer settings, or printer state) first. Error: ${message}`
|
|
4256
|
+
: `Check parameters and try again. Error: ${message}`,
|
|
4183
4257
|
message,
|
|
4184
4258
|
tool: name,
|
|
4185
4259
|
};
|
package/src/printers/bambu.ts
CHANGED
|
@@ -45,6 +45,24 @@ function loadClientCreds(): { cert: Buffer; key: Buffer } | null {
|
|
|
45
45
|
const CLIENT_CREDS = loadClientCreds();
|
|
46
46
|
|
|
47
47
|
const COMMAND_SETTLE_MS = 300;
|
|
48
|
+
/** HMS 0500-0500-0001-0007: firmware 01.08.05+ rejected an unsigned MQTT command. */
|
|
49
|
+
const COMMAND_VERIFICATION_HMS = { attr: 0x05000500, code: 0x00010007 };
|
|
50
|
+
const COMMAND_REJECTED_MESSAGE =
|
|
51
|
+
"The printer rejected the print command (HMS 0500-0500-0001-0007, \"MQTT command verification failed\"). " +
|
|
52
|
+
"Bambu firmware 01.08.05 and later only accept third-party LAN control with LAN Only Mode and Developer Mode " +
|
|
53
|
+
"enabled (Settings > WLAN on the printer). The checked file is on the printer's storage, but nothing is printing. " +
|
|
54
|
+
"The rejection stays on the printer as a fatal HMS entry that blocks later prints: after enabling those modes, " +
|
|
55
|
+
"clear it with clear_hms_errors or dismiss it on the printer's screen, then print again.";
|
|
56
|
+
const DISPATCH_CANCELLED_MESSAGE =
|
|
57
|
+
"A stop or heater-off request arrived after the print command was sent, so this call does not report the print as started. " +
|
|
58
|
+
"Check get_printer_status.";
|
|
59
|
+
|
|
60
|
+
/** How long to watch fresh reports after a print command (BAMBU_DISPATCH_CHECK_MS, 0 disables). */
|
|
61
|
+
function dispatchCheckMs(): number {
|
|
62
|
+
const raw = process.env.BAMBU_DISPATCH_CHECK_MS?.trim();
|
|
63
|
+
const value = raw ? Number(raw) : 15_000;
|
|
64
|
+
return Number.isInteger(value) && value >= 0 && value <= 60_000 ? value : 15_000;
|
|
65
|
+
}
|
|
48
66
|
|
|
49
67
|
const MODEL_ID_TO_NAME: Record<string, string> = {
|
|
50
68
|
O1C: "H2C",
|
|
@@ -813,9 +831,17 @@ export class BambuImplementation {
|
|
|
813
831
|
const printer = await this.getPrinter(host, serial, token);
|
|
814
832
|
assertActive();
|
|
815
833
|
if (legacyContainer) {
|
|
816
|
-
|
|
834
|
+
const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
|
|
835
|
+
try { await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath })); } catch (error) { watcher.dispose(); throw error; }
|
|
817
836
|
this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
|
|
818
|
-
|
|
837
|
+
const dispatch = await watcher.settled();
|
|
838
|
+
return {
|
|
839
|
+
status: "success", dispatch, remoteProjectPath,
|
|
840
|
+
message: dispatch === "started"
|
|
841
|
+
? `The printer accepted and started the checked gcode.3mf print: ${options.projectName}`
|
|
842
|
+
: `Uploaded the checked gcode.3mf and sent the print command for ${options.projectName}; the printer has not reported starting yet. ` +
|
|
843
|
+
"Check get_printer_status before assuming it is printing.",
|
|
844
|
+
};
|
|
819
845
|
}
|
|
820
846
|
|
|
821
847
|
const b = (v: any) => (v ? 1 : 0);
|
|
@@ -876,13 +902,19 @@ export class BambuImplementation {
|
|
|
876
902
|
};
|
|
877
903
|
}
|
|
878
904
|
|
|
879
|
-
|
|
905
|
+
const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
|
|
906
|
+
try { await printer.publish(projectFileCmd); } catch (error) { watcher.dispose(); throw error; }
|
|
880
907
|
this.recordCheckedJob(host, serial, remoteProjectPath, requirements);
|
|
881
908
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
909
|
+
const dispatch = await watcher.settled();
|
|
882
910
|
|
|
883
911
|
return {
|
|
884
912
|
status: "success",
|
|
885
|
-
|
|
913
|
+
dispatch,
|
|
914
|
+
message: dispatch === "started"
|
|
915
|
+
? `The printer accepted and started the checked 3MF print: ${options.projectName}`
|
|
916
|
+
: `Uploaded the checked 3MF and sent the print command for ${options.projectName}; the printer has not reported starting yet. ` +
|
|
917
|
+
"Check get_printer_status before assuming it is printing.",
|
|
886
918
|
remoteProjectPath,
|
|
887
919
|
plateFile: projectMetadata.plateFileName,
|
|
888
920
|
platePath: projectMetadata.plateInternalPath,
|
|
@@ -1392,9 +1424,76 @@ export class BambuImplementation {
|
|
|
1392
1424
|
this.assertBedClearance(dispatchStatus, bedClearance);
|
|
1393
1425
|
const printer = await this.getPrinter(host, serial, token);
|
|
1394
1426
|
assertActive();
|
|
1395
|
-
|
|
1427
|
+
const watcher = this.watchDispatch(printer, host, serial, token, dispatchStatus, assertActive);
|
|
1428
|
+
try { await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remotePath })); } catch (error) { watcher.dispose(); throw error; }
|
|
1396
1429
|
this.recordCheckedJob(host, serial, remotePath, requirements);
|
|
1397
|
-
|
|
1430
|
+
const dispatch = await watcher.settled();
|
|
1431
|
+
return {
|
|
1432
|
+
status: "success", uploaded: true, printRequested: true, dispatch, remotePath,
|
|
1433
|
+
message: dispatch === "started"
|
|
1434
|
+
? `The printer accepted and started ${remotePath}.`
|
|
1435
|
+
: `Sent the print command for ${remotePath}; the printer has not reported starting yet. Check get_printer_status before assuming it is printing.`,
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* A published print command is not proof the printer took it. Listen to the
|
|
1441
|
+
* reports the printer pushes anyway (no extra pushall polling on weak P1
|
|
1442
|
+
* boards): a new command-verification HMS means the firmware refused the
|
|
1443
|
+
* command; PREPARE/SLICING/RUNNING means it started. Attach before publishing.
|
|
1444
|
+
*/
|
|
1445
|
+
private watchDispatch(printer: any, host: string, serial: string, token: string, before: any, assertActive: () => void):
|
|
1446
|
+
{ settled: () => Promise<"started" | "unconfirmed">; dispose: () => void } {
|
|
1447
|
+
const windowMs = dispatchCheckMs();
|
|
1448
|
+
if (windowMs === 0) return { settled: async () => { assertActive(); return "unconfirmed"; }, dispose: () => {} };
|
|
1449
|
+
const key = (entry: any) => `${Number(entry?.attr)}:${Number(entry?.code)}:${entry?.timestamp ?? ""}`;
|
|
1450
|
+
const known = new Set((Array.isArray(before?.raw?.hms) ? before.raw.hms : []).map(key));
|
|
1451
|
+
const judge = (report: any): "started" | "rejected" | undefined => {
|
|
1452
|
+
const hms = Array.isArray(report?.hms) ? report.hms : [];
|
|
1453
|
+
if (hms.some((entry: any) => Number(entry?.attr) === COMMAND_VERIFICATION_HMS.attr &&
|
|
1454
|
+
Number(entry?.code) === COMMAND_VERIFICATION_HMS.code && !known.has(key(entry)))) return "rejected";
|
|
1455
|
+
if (["PREPARE", "SLICING", "RUNNING"].includes(String(report?.gcode_state ?? "").toUpperCase())) return "started";
|
|
1456
|
+
return undefined;
|
|
1457
|
+
};
|
|
1458
|
+
type Verdict = "started" | "rejected" | "cancelled" | undefined;
|
|
1459
|
+
let resolveOutcome!: (value: Verdict) => void;
|
|
1460
|
+
const outcome = new Promise<Verdict>((resolve) => { resolveOutcome = resolve; });
|
|
1461
|
+
const onRaw = (topic: string, payload: Buffer) => {
|
|
1462
|
+
if (topic !== `device/${serial}/report`) return;
|
|
1463
|
+
let parsed: any;
|
|
1464
|
+
try { parsed = JSON.parse(payload.toString()); } catch { return; }
|
|
1465
|
+
const verdict = judge(parsed?.print);
|
|
1466
|
+
if (verdict) resolveOutcome(verdict);
|
|
1467
|
+
};
|
|
1468
|
+
const cancelled = () => { try { assertActive(); return false; } catch { return true; } };
|
|
1469
|
+
printer?.on?.("rawMessage", onRaw);
|
|
1470
|
+
const timer = setTimeout(() => resolveOutcome(undefined), windowMs);
|
|
1471
|
+
// A stop during the window must not end as "accepted and started".
|
|
1472
|
+
const cancelCheck = setInterval(() => { if (cancelled()) resolveOutcome("cancelled"); }, 250);
|
|
1473
|
+
const dispose = () => {
|
|
1474
|
+
clearTimeout(timer);
|
|
1475
|
+
clearInterval(cancelCheck);
|
|
1476
|
+
printer?.off?.("rawMessage", onRaw);
|
|
1477
|
+
resolveOutcome(undefined);
|
|
1478
|
+
};
|
|
1479
|
+
return {
|
|
1480
|
+
dispose,
|
|
1481
|
+
settled: async () => {
|
|
1482
|
+
let verdict: Verdict;
|
|
1483
|
+
try { verdict = await outcome; } finally { dispose(); }
|
|
1484
|
+
// Nothing pushed during the window: take one full report, not a polling loop.
|
|
1485
|
+
if (verdict === undefined && !cancelled()) {
|
|
1486
|
+
try { verdict = judge((await this.getSafetyStatus(host, serial, token))?.raw); } catch { /* stays unconfirmed */ }
|
|
1487
|
+
}
|
|
1488
|
+
if (verdict === "rejected") {
|
|
1489
|
+
// Nothing started, so there is no checked job to resume or clear later.
|
|
1490
|
+
this.checkedJobs.delete(`${host}\n${serial}`);
|
|
1491
|
+
throw new Error(COMMAND_REJECTED_MESSAGE);
|
|
1492
|
+
}
|
|
1493
|
+
if (verdict === "cancelled" || cancelled()) throw new Error(DISPATCH_CANCELLED_MESSAGE);
|
|
1494
|
+
return verdict ?? "unconfirmed";
|
|
1495
|
+
},
|
|
1496
|
+
};
|
|
1398
1497
|
}
|
|
1399
1498
|
|
|
1400
1499
|
async startJob(host: string, serial: string, token: string, filename: string, bambuModel?: string) {
|
|
@@ -190,7 +190,10 @@ function validateErrors(raw: Record<string,any>, requireIdle: boolean) {
|
|
|
190
190
|
// BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
|
|
191
191
|
// Unknown severities fail closed; attr identifies the module and is not a severity bitmask.
|
|
192
192
|
if (code === undefined || attr === undefined || !Number.isInteger(code) || !Number.isInteger(attr) || code < 0 || code > 0xffffffff || attr < 0 || attr > 0xffffffff || Math.floor(code / 65536) !== 4) {
|
|
193
|
-
|
|
193
|
+
const hex = (value: number) => value.toString(16).toUpperCase().padStart(8, "0");
|
|
194
|
+
const label = code !== undefined && attr !== undefined && Number.isInteger(code) && Number.isInteger(attr) && code >= 0 && attr >= 0
|
|
195
|
+
? ` (HMS ${hex(attr).slice(0, 4)}-${hex(attr).slice(4)}-${hex(code).slice(0, 4)}-${hex(code).slice(4)})` : "";
|
|
196
|
+
throw new Error(`Printer reports actionable or unknown HMS errors${label}; inspect and resolve them before proceeding.`);
|
|
194
197
|
}
|
|
195
198
|
}
|
|
196
199
|
}
|
|
@@ -95,7 +95,8 @@ interface IndexedProfile {
|
|
|
95
95
|
data: Record<string, unknown>;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
/** Name index plus unreadable JSON files, reported when a name is missing. */
|
|
99
|
+
type NameIndex = Map<string, IndexedProfile> & { malformed?: string[] };
|
|
99
100
|
|
|
100
101
|
/* -------------------------------------------------------------------------- */
|
|
101
102
|
/* Indexing */
|
|
@@ -116,6 +117,7 @@ async function buildNameIndex(
|
|
|
116
117
|
userProfileRoots: string[] = []
|
|
117
118
|
): Promise<NameIndex> {
|
|
118
119
|
const index: NameIndex = new Map();
|
|
120
|
+
index.malformed = [];
|
|
119
121
|
const subdirs: ProfileKind[] = ["machine", "process", "filament"];
|
|
120
122
|
|
|
121
123
|
const directories = [
|
|
@@ -144,10 +146,15 @@ async function buildNameIndex(
|
|
|
144
146
|
try {
|
|
145
147
|
data = JSON.parse(raw) as Record<string, unknown>;
|
|
146
148
|
} catch {
|
|
147
|
-
// Malformed profile -- skip, don't poison the index.
|
|
149
|
+
// Malformed profile -- skip, don't poison the index. A reference to
|
|
150
|
+
// it still fails, and the error names the skipped file.
|
|
151
|
+
index.malformed!.push(filePath);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
155
|
+
index.malformed!.push(filePath);
|
|
148
156
|
continue;
|
|
149
157
|
}
|
|
150
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) continue;
|
|
151
158
|
const name = data["name"];
|
|
152
159
|
if (typeof name !== "string" || name.length === 0) continue;
|
|
153
160
|
// First-write wins. BBL doesn't have name collisions in practice;
|
|
@@ -183,7 +190,7 @@ function flattenByName(
|
|
|
183
190
|
}
|
|
184
191
|
const entry = index.get(leafName);
|
|
185
192
|
if (!entry) {
|
|
186
|
-
throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled
|
|
193
|
+
throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.${describeMalformed(index)}`);
|
|
187
194
|
}
|
|
188
195
|
visiting.add(leafName);
|
|
189
196
|
try {
|
|
@@ -193,6 +200,13 @@ function flattenByName(
|
|
|
193
200
|
}
|
|
194
201
|
}
|
|
195
202
|
|
|
203
|
+
function describeMalformed(index: NameIndex): string {
|
|
204
|
+
const malformed = index.malformed ?? [];
|
|
205
|
+
if (malformed.length === 0) return "";
|
|
206
|
+
const shown = malformed.slice(0, 5).map((file) => path.basename(file)).join(", ");
|
|
207
|
+
return ` Skipped ${malformed.length} unreadable or malformed profile file(s): ${shown}${malformed.length > 5 ? ", ..." : ""}.`;
|
|
208
|
+
}
|
|
209
|
+
|
|
196
210
|
function flattenData(
|
|
197
211
|
data: Record<string, unknown>,
|
|
198
212
|
index: NameIndex,
|
|
@@ -249,7 +263,7 @@ function applyIncludes(
|
|
|
249
263
|
if (!entry) {
|
|
250
264
|
throw new Error(
|
|
251
265
|
`Profile "${String(profile["name"])}" includes "${name}", which is not in the index. ` +
|
|
252
|
-
`Refusing to fall back to inherited defaults (wrong G-code for this printer)
|
|
266
|
+
`Refusing to fall back to inherited defaults (wrong G-code for this printer).${describeMalformed(index)}`
|
|
253
267
|
);
|
|
254
268
|
}
|
|
255
269
|
for (const [key, value] of Object.entries(flattenByName(name, index, visiting))) {
|
|
@@ -164,6 +164,7 @@ export interface BambuSliceOptions {
|
|
|
164
164
|
loadFilamentIds?: string; // --load-filament-ids "1,2,3,1": filament-to-object mapping
|
|
165
165
|
filamentColours?: string[]; // positional #RRGGBB per loaded filament slot
|
|
166
166
|
bedType?: string; // Bambu bed type, e.g. textured_plate or cool_plate
|
|
167
|
+
nozzleType?: string; // installed hotend material written into the machine preset
|
|
167
168
|
enableTimelapse?: boolean; // --enable-timelapse: timelapse-aware slicing
|
|
168
169
|
allowMixTemp?: boolean; // --allow-mix-temp: allow mixed-temp filaments
|
|
169
170
|
scale?: number; // --scale factor: uniform scale
|
|
@@ -186,6 +187,12 @@ interface BambuSettingsBundle {
|
|
|
186
187
|
processSource?: { filePath: string; profile: Record<string, unknown> };
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
/** Keys every profile kind carries; they describe the file, not machine hardware. */
|
|
191
|
+
const PROFILE_METADATA_KEYS = new Set([
|
|
192
|
+
'name', 'type', 'from', 'inherits', 'instantiation', 'setting_id', 'version', 'is_custom_defined',
|
|
193
|
+
'compatible_printers', 'compatible_printers_condition', 'compatible_prints', 'compatible_prints_condition',
|
|
194
|
+
]);
|
|
195
|
+
|
|
189
196
|
export class STLManipulator extends EventEmitter {
|
|
190
197
|
private tempDir: string;
|
|
191
198
|
private activeOperations: Map<string, boolean> = new Map();
|
|
@@ -304,16 +311,22 @@ export class STLManipulator extends EventEmitter {
|
|
|
304
311
|
sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
|
|
305
312
|
}
|
|
306
313
|
|
|
307
|
-
sanitized
|
|
308
|
-
|
|
309
|
-
|
|
314
|
+
return this.useAbsoluteExtrusionForOrca(sanitized);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
|
|
318
|
+
private useAbsoluteExtrusionForOrca(config: any): any {
|
|
319
|
+
const normalized = { ...config };
|
|
320
|
+
normalized.use_relative_e_distances = '0';
|
|
321
|
+
normalized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(
|
|
322
|
+
normalized.before_layer_change_gcode
|
|
310
323
|
);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
324
|
+
normalized.layer_gcode = this.stripAbsoluteExtruderResets(normalized.layer_gcode);
|
|
325
|
+
normalized.layer_change_gcode = this.stripAbsoluteExtruderResets(
|
|
326
|
+
normalized.layer_change_gcode
|
|
314
327
|
);
|
|
315
328
|
|
|
316
|
-
return
|
|
329
|
+
return normalized;
|
|
317
330
|
}
|
|
318
331
|
|
|
319
332
|
private writeTempJson(outputBase: string, suffix: string, value: any): string {
|
|
@@ -584,6 +597,36 @@ export class STLManipulator extends EventEmitter {
|
|
|
584
597
|
};
|
|
585
598
|
}
|
|
586
599
|
|
|
600
|
+
/**
|
|
601
|
+
* The CLI applies every key in every --load-settings/--load-filaments file,
|
|
602
|
+
* so machine settings carried by a process or filament file (for example a
|
|
603
|
+
* template 3MF's project_settings from another printer) would replace the
|
|
604
|
+
* selected preset's start G-code, model, and bed geometry. The machine
|
|
605
|
+
* preset owns those keys; drop them from the other profiles.
|
|
606
|
+
*/
|
|
607
|
+
private isolateMachineSettings(bundle: BambuSettingsBundle): BambuSettingsBundle {
|
|
608
|
+
const [machinePath, processPath] = bundle.settingsArg?.split(';') ?? [];
|
|
609
|
+
if (!machinePath || !processPath) return bundle;
|
|
610
|
+
const machineKeys = Object.keys(this.readJsonFile(machinePath)).filter(key => !PROFILE_METADATA_KEYS.has(key));
|
|
611
|
+
const isolate = (filePath: string, label: string): string => {
|
|
612
|
+
const profile = this.readJsonFile(filePath);
|
|
613
|
+
const foreign = machineKeys.filter(key => Object.prototype.hasOwnProperty.call(profile, key));
|
|
614
|
+
if (foreign.length === 0) return filePath;
|
|
615
|
+
const isolated = { ...profile };
|
|
616
|
+
for (const key of foreign) delete isolated[key];
|
|
617
|
+
console.error(
|
|
618
|
+
`Ignoring ${foreign.length} machine setting(s) in ${label} profile ${filePath} ` +
|
|
619
|
+
`(the selected machine preset owns them): ${foreign.slice(0, 8).join(', ')}${foreign.length > 8 ? ', ...' : ''}`
|
|
620
|
+
);
|
|
621
|
+
return this.writeTempJson(path.basename(filePath, '.json'), `${label}_without_machine`, isolated);
|
|
622
|
+
};
|
|
623
|
+
return {
|
|
624
|
+
...bundle,
|
|
625
|
+
settingsArg: [machinePath, isolate(processPath, 'process')].join(';'),
|
|
626
|
+
filamentPaths: bundle.filamentPaths.map(filePath => isolate(filePath, 'filament')),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
587
630
|
/** --load-filaments is positional; a single override must cover every project slot. */
|
|
588
631
|
private async expandProjectFilaments(
|
|
589
632
|
inputPath: string,
|
|
@@ -1619,22 +1662,40 @@ export class STLManipulator extends EventEmitter {
|
|
|
1619
1662
|
// Resolve every BBL dependency before launching the slicer.
|
|
1620
1663
|
const settingsBundle = await this.maybeFlattenBundle(
|
|
1621
1664
|
await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
|
|
1665
|
+
// The machine preset assumes the stock hotend; record the installed one so
|
|
1666
|
+
// the sliced job's nozzle_type matches what the printer reports.
|
|
1667
|
+
if (bambuOptions?.nozzleType && settingsBundle.settingsArg) {
|
|
1668
|
+
const [machinePath, ...rest] = settingsBundle.settingsArg.split(';');
|
|
1669
|
+
const machine = this.readJsonFile(machinePath);
|
|
1670
|
+
const current = machine.nozzle_type;
|
|
1671
|
+
machine.nozzle_type = Array.isArray(current) && current.length > 0
|
|
1672
|
+
? current.map(() => bambuOptions.nozzleType)
|
|
1673
|
+
: [bambuOptions.nozzleType];
|
|
1674
|
+
settingsBundle.settingsArg = [
|
|
1675
|
+
this.writeTempJson(outputBase, `machine_${bambuOptions.nozzleType}`, machine), ...rest,
|
|
1676
|
+
].join(';');
|
|
1677
|
+
}
|
|
1622
1678
|
// Inherited process G-code is now present; apply Orca's existing
|
|
1623
1679
|
// absolute-extrusion normalization after resolving those ancestors.
|
|
1680
|
+
// The machine owns the extrusion mode and layer G-code once process
|
|
1681
|
+
// files lose machine keys, so normalize it there as well.
|
|
1624
1682
|
if (slicerType === 'orcaslicer' && settingsBundle.settingsArg) {
|
|
1625
1683
|
const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
|
|
1626
|
-
settingsBundle.settingsArg = [
|
|
1627
|
-
outputBase, '
|
|
1628
|
-
|
|
1629
|
-
|
|
1684
|
+
settingsBundle.settingsArg = [
|
|
1685
|
+
this.writeTempJson(outputBase, 'machine_orca_resolved',
|
|
1686
|
+
this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
|
|
1687
|
+
this.writeTempJson(outputBase, 'process_orca_resolved',
|
|
1688
|
+
this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
|
|
1689
|
+
].join(';');
|
|
1630
1690
|
}
|
|
1691
|
+
const cliBundle = this.isolateMachineSettings(settingsBundle);
|
|
1631
1692
|
args = [
|
|
1632
1693
|
'--slice', String(bambuOptions?.slicePlate ?? 0),
|
|
1633
1694
|
'--outputdir', outputDir,
|
|
1634
1695
|
'--export-3mf', path.basename(bambuOutputPath),
|
|
1635
1696
|
];
|
|
1636
|
-
if (
|
|
1637
|
-
args.push('--load-settings',
|
|
1697
|
+
if (cliBundle.settingsArg) {
|
|
1698
|
+
args.push('--load-settings', cliBundle.settingsArg);
|
|
1638
1699
|
}
|
|
1639
1700
|
// Always allow newer-version 3MF files (the CLI rejects them by default)
|
|
1640
1701
|
args.push('--allow-newer-file');
|
|
@@ -1655,8 +1716,8 @@ export class STLManipulator extends EventEmitter {
|
|
|
1655
1716
|
if (bambuOptions?.rotateY !== undefined) args.push('--rotate-y', String(bambuOptions.rotateY));
|
|
1656
1717
|
if (bambuOptions?.cloneObjects) args.push('--clone-objects', bambuOptions.cloneObjects);
|
|
1657
1718
|
if (bambuOptions?.skipObjects) args.push('--skip-objects', bambuOptions.skipObjects);
|
|
1658
|
-
if (
|
|
1659
|
-
args.push('--load-filaments',
|
|
1719
|
+
if (cliBundle.filamentPaths.length > 0) {
|
|
1720
|
+
args.push('--load-filaments', cliBundle.filamentPaths.join(';'));
|
|
1660
1721
|
args.push('--load-defaultfila');
|
|
1661
1722
|
}
|
|
1662
1723
|
if (bambuOptions?.loadFilamentIds) args.push('--load-filament-ids', bambuOptions.loadFilamentIds);
|