bambu-printer-mcp 1.1.13 → 1.1.14
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 +2 -2
- package/README.md +18 -4
- package/dist/bambu-connect.d.ts +7 -0
- package/dist/bambu-connect.js +77 -0
- package/dist/bambu-native.d.ts +66 -0
- package/dist/bambu-native.js +609 -0
- package/dist/index.js +457 -33
- package/dist/printers/bambu.d.ts +17 -14
- package/dist/printers/bambu.js +102 -56
- package/native/bambu-native-print.cpp +758 -0
- package/package.json +7 -3
- package/scripts/build-bambu-native.zsh +33 -0
- package/src/bambu-connect.ts +100 -0
- package/src/bambu-native.ts +722 -0
- package/src/index.ts +527 -66
- package/src/printers/bambu.ts +89 -53
package/src/printers/bambu.ts
CHANGED
|
@@ -62,13 +62,13 @@ const MODEL_ID_TO_NAME: Record<string, string> = {
|
|
|
62
62
|
C13: "X1E",
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
-
const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s"]);
|
|
65
|
+
const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s", "x2d"]);
|
|
66
66
|
|
|
67
67
|
export function assertDirectPrintSupported(model: string | undefined, serial?: string): void {
|
|
68
68
|
if (model?.trim().toLowerCase() === "x2d" || serial?.trim().toUpperCase().startsWith("20P")) {
|
|
69
69
|
throw new Error(
|
|
70
|
-
"X2D direct printing is not supported
|
|
71
|
-
"X2D status and slicing remain available
|
|
70
|
+
"X2D direct printing is not supported through legacy MQTT/FTPS. Use the macOS native eMMC route via print_3mf. " +
|
|
71
|
+
"X2D status and slicing remain available on all supported platforms."
|
|
72
72
|
);
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -251,9 +251,18 @@ class TolerantBambuClient extends BambuClient {
|
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
/**
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Build FTPS secureOptions that include the client cert+key when available.
|
|
256
|
+
*
|
|
257
|
+
* `host` must be included: basic-ftp opens each data connection by wrapping
|
|
258
|
+
* a plain socket with tls.connect(tlsOptions), and without a host Node can bind
|
|
259
|
+
* the resumable session to "localhost" instead of the printer host. Printers
|
|
260
|
+
* that require TLS session reuse (vsftpd require_ssl_reuse, e.g. X2D) refuse
|
|
261
|
+
* to resume the session and reply "522 SSL connection failed: session reuse
|
|
262
|
+
* required", failing every LIST, STOR and RETR.
|
|
263
|
+
*/
|
|
264
|
+
function ftpsSecureOptions(host: string): Record<string, unknown> {
|
|
265
|
+
const opts: Record<string, unknown> = { rejectUnauthorized: false, host };
|
|
257
266
|
if (CLIENT_CREDS) {
|
|
258
267
|
opts.cert = CLIENT_CREDS.cert;
|
|
259
268
|
opts.key = CLIENT_CREDS.key;
|
|
@@ -882,13 +891,14 @@ export class BambuImplementation {
|
|
|
882
891
|
};
|
|
883
892
|
}
|
|
884
893
|
|
|
885
|
-
async cancelJob(host: string, serial: string, token: string): Promise<any> {
|
|
894
|
+
async cancelJob(host: string, serial: string, token: string, dispatch?: () => Promise<any>): Promise<any> {
|
|
886
895
|
cancelPendingPrinterOperations(host, serial);
|
|
887
896
|
this.checkedJobs.delete(`${host}\n${serial}`);
|
|
888
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
897
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
889
898
|
|
|
890
899
|
try {
|
|
891
|
-
|
|
900
|
+
if (dispatch) return await dispatch();
|
|
901
|
+
else await invokeWithoutAck(printer!, new UpdateStateCommand({ state: "stop" }));
|
|
892
902
|
this.checkedJobs.delete(`${host}\n${serial}`);
|
|
893
903
|
return { status: "success", message: "Cancel command sent successfully." };
|
|
894
904
|
} catch (error) {
|
|
@@ -896,34 +906,41 @@ export class BambuImplementation {
|
|
|
896
906
|
}
|
|
897
907
|
}
|
|
898
908
|
|
|
899
|
-
async pauseJob(host: string, serial: string, token: string): Promise<any> {
|
|
900
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
909
|
+
async pauseJob(host: string, serial: string, token: string, dispatch?: () => Promise<any>): Promise<any> {
|
|
910
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
901
911
|
try {
|
|
902
|
-
|
|
912
|
+
if (dispatch) return await dispatch();
|
|
913
|
+
else await invokeWithoutAck(printer!, new UpdateStateCommand({ state: "pause" }));
|
|
903
914
|
return { status: "success", message: "Pause command sent successfully." };
|
|
904
915
|
} catch (error) {
|
|
905
916
|
throw new Error(`Failed to pause print: ${(error as Error).message}`);
|
|
906
917
|
}
|
|
907
918
|
}
|
|
908
919
|
|
|
909
|
-
async resumeJob(host: string, serial: string, token: string): Promise<any> {
|
|
920
|
+
async resumeJob(host: string, serial: string, token: string, dispatch?: (assertActive: () => void, beforeDispatch: () => Promise<void>) => Promise<any>): Promise<any> {
|
|
910
921
|
return withPrinterOperation(host, serial, async assertActive => {
|
|
911
922
|
const checked = this.checkedJobs.get(`${host}\n${serial}`);
|
|
912
923
|
if (!checked) throw new Error("Resume requires a job inspected and started by this server instance. Verify other jobs on the printer before resuming them there.");
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
924
|
+
const beforeDispatch = async () => {
|
|
925
|
+
assertActive();
|
|
926
|
+
const status = await this.getSafetyStatus(host, serial, token);
|
|
927
|
+
validatePrinterState(status, { ...checked.requirements, requireIdle: false });
|
|
928
|
+
if (status.raw.gcode_state !== "PAUSE") throw new Error("Resume requires a freshly reported paused job.");
|
|
929
|
+
const stem = (value: string) => path.posix.basename(value).replace(/(?:\.gcode)?\.3mf$|\.gcode$/i, "");
|
|
930
|
+
const names = [status.raw.gcode_file, status.raw.subtask_name].filter(value => typeof value === "string");
|
|
931
|
+
if (!names.some(value => stem(value) === stem(checked.remotePath))) throw new Error("Paused job identity does not match this server's inspected artifact.");
|
|
932
|
+
assertActive();
|
|
933
|
+
};
|
|
934
|
+
await beforeDispatch();
|
|
935
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
920
936
|
assertActive();
|
|
921
|
-
|
|
937
|
+
if (dispatch) await dispatch(assertActive, beforeDispatch);
|
|
938
|
+
else await invokeWithoutAck(printer!, new UpdateStateCommand({ state: "resume" }));
|
|
922
939
|
return { status: "success", message: "Resume command sent successfully." };
|
|
923
940
|
});
|
|
924
941
|
}
|
|
925
942
|
|
|
926
|
-
async clearHmsErrors(host: string, serial: string, token: string): Promise<any> {
|
|
943
|
+
async clearHmsErrors(host: string, serial: string, token: string, dispatch?: (assertActive: () => void, beforeDispatch: () => Promise<void>) => Promise<any>): Promise<any> {
|
|
927
944
|
return withPrinterOperation(host, serial, async assertActive => {
|
|
928
945
|
const codesFor = (status: any): string[] => {
|
|
929
946
|
if (!Array.isArray(status.raw?.hms) || status.raw?.print_error === undefined) throw new Error("Fresh error codes are required before clearing hardware errors.");
|
|
@@ -931,11 +948,17 @@ export class BambuImplementation {
|
|
|
931
948
|
};
|
|
932
949
|
const codes = codesFor(await this.getSafetyStatus(host, serial, token));
|
|
933
950
|
await this.confirmHardwareAction(`Clear reported hardware errors on ${serial}: ${codes.join(", ")}? Inspect the printer and resolve the physical cause first. Confirm only after removing obstructions and correcting the fault.`, true);
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
951
|
+
const beforeDispatch = async () => {
|
|
952
|
+
assertActive();
|
|
953
|
+
const current = codesFor(await this.getSafetyStatus(host, serial, token));
|
|
954
|
+
if (JSON.stringify(current) !== JSON.stringify(codes)) throw new Error("Hardware errors changed before clearing. Inspect the new report before retrying.");
|
|
955
|
+
assertActive();
|
|
956
|
+
};
|
|
957
|
+
await beforeDispatch();
|
|
958
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
937
959
|
assertActive();
|
|
938
|
-
|
|
960
|
+
if (dispatch) await dispatch(assertActive, beforeDispatch);
|
|
961
|
+
else await printer!.publish({ print: { command: "clean_print_error", sequence_id: "0" } });
|
|
939
962
|
this.clearedErrors.set(serial, codes);
|
|
940
963
|
await sleep(COMMAND_SETTLE_MS);
|
|
941
964
|
return { status: "success", message: "Confirmed HMS clear command sent. The next print requires acknowledgment of these cleared codes.", cleared_codes: codes };
|
|
@@ -1004,9 +1027,8 @@ export class BambuImplementation {
|
|
|
1004
1027
|
amsId: number,
|
|
1005
1028
|
slotId: number
|
|
1006
1029
|
): Promise<any> {
|
|
1007
|
-
const
|
|
1008
|
-
const
|
|
1009
|
-
const normalizedSlotId = Math.trunc(slotId);
|
|
1030
|
+
const normalizedAmsId = amsId;
|
|
1031
|
+
const normalizedSlotId = slotId;
|
|
1010
1032
|
|
|
1011
1033
|
if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
|
|
1012
1034
|
throw new Error("ams_id must be an integer from 0 to 3.");
|
|
@@ -1015,6 +1037,7 @@ export class BambuImplementation {
|
|
|
1015
1037
|
throw new Error("slot_id must be an integer from 0 to 3.");
|
|
1016
1038
|
}
|
|
1017
1039
|
|
|
1040
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
1018
1041
|
await printer.publish({
|
|
1019
1042
|
print: {
|
|
1020
1043
|
command: "ams_get_rfid",
|
|
@@ -1040,7 +1063,8 @@ export class BambuImplementation {
|
|
|
1040
1063
|
temperature: unknown,
|
|
1041
1064
|
bambuModel?: string,
|
|
1042
1065
|
material?: string,
|
|
1043
|
-
nozzleDiameter = 0.4
|
|
1066
|
+
nozzleDiameter = 0.4,
|
|
1067
|
+
dispatch?: (heater: "bed" | "nozzle", target: number, assertActive: () => void, beforeDispatch?: () => Promise<void>) => Promise<any>
|
|
1044
1068
|
) {
|
|
1045
1069
|
const normalizedComponent = component.toLowerCase();
|
|
1046
1070
|
const heater = normalizedComponent === "bed" ? "bed" :
|
|
@@ -1061,20 +1085,26 @@ export class BambuImplementation {
|
|
|
1061
1085
|
const targetTemperature = temperature === 0 ? 0 : validateTemperature(heater, temperature, model!, material ? [material] : undefined);
|
|
1062
1086
|
const gcode = `${heater === "bed" ? "M140" : "M104"}${heater === "nozzle" && targetTemperature > 0 ? " T0" : ""} S${targetTemperature}`;
|
|
1063
1087
|
const send = async (assertActive: () => void) => {
|
|
1088
|
+
let beforeDispatch: (() => Promise<void>) | undefined;
|
|
1064
1089
|
if (targetTemperature > 0) {
|
|
1065
1090
|
const status = await this.getSafetyStatus(host, serial, token);
|
|
1066
|
-
validatePrinterState(
|
|
1067
|
-
? manualHeatingRequirements(
|
|
1091
|
+
const validateHeatingStatus = (current: any) => validatePrinterState(current, heater === "nozzle"
|
|
1092
|
+
? manualHeatingRequirements(current, model!, nozzleDiameter, material!)
|
|
1068
1093
|
: { model: model!, nozzleDiameters: [] });
|
|
1094
|
+
validateHeatingStatus(status);
|
|
1069
1095
|
await this.confirmHardwareAction(`Heat ${heater} on ${model!.toUpperCase()} (${serial}) to ${targetTemperature}°C?${material ? ` Declared material: ${material}. Confirm the physical spool label.` : ""}`);
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1096
|
+
beforeDispatch = async () => {
|
|
1097
|
+
assertActive();
|
|
1098
|
+
const confirmedStatus = await this.getSafetyStatus(host, serial, token);
|
|
1099
|
+
validateHeatingStatus(confirmedStatus);
|
|
1100
|
+
assertActive();
|
|
1101
|
+
};
|
|
1102
|
+
await beforeDispatch();
|
|
1074
1103
|
}
|
|
1075
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
1104
|
+
const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
|
|
1076
1105
|
assertActive();
|
|
1077
|
-
|
|
1106
|
+
if (dispatch) await dispatch(heater, targetTemperature, assertActive, beforeDispatch);
|
|
1107
|
+
else await invokeWithoutAck(printer!, new GCodeLineCommand({ gcodes: [gcode] }));
|
|
1078
1108
|
return { status: "success", message: `Temperature command sent for ${normalizedComponent}.`, command: gcode };
|
|
1079
1109
|
};
|
|
1080
1110
|
if (targetTemperature === 0) {
|
|
@@ -1164,18 +1194,18 @@ export class BambuImplementation {
|
|
|
1164
1194
|
action: string,
|
|
1165
1195
|
amsId: number
|
|
1166
1196
|
): Promise<any> {
|
|
1167
|
-
const printer = await this.getPrinter(host, serial, token);
|
|
1168
1197
|
const normalizedAction = action.trim().toLowerCase();
|
|
1169
1198
|
if (normalizedAction !== "start" && normalizedAction !== "stop") {
|
|
1170
1199
|
throw new Error("AMS drying action must be one of: start, stop.");
|
|
1171
1200
|
}
|
|
1172
1201
|
|
|
1173
|
-
const normalizedAmsId =
|
|
1202
|
+
const normalizedAmsId = amsId;
|
|
1174
1203
|
if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
|
|
1175
1204
|
throw new Error("ams_id must be an integer from 0 to 3.");
|
|
1176
1205
|
}
|
|
1177
1206
|
|
|
1178
1207
|
const param = normalizedAction === "start" ? "start_drying" : "stop_drying";
|
|
1208
|
+
const printer = await this.getPrinter(host, serial, token);
|
|
1179
1209
|
await printer.publish({
|
|
1180
1210
|
print: {
|
|
1181
1211
|
command: "ams_control",
|
|
@@ -1273,7 +1303,8 @@ export class BambuImplementation {
|
|
|
1273
1303
|
|
|
1274
1304
|
async uploadFile(
|
|
1275
1305
|
host: string, serial: string, token: string, filePath: string,
|
|
1276
|
-
filename: string, print: boolean, bambuModel?: string
|
|
1306
|
+
filename: string, print: boolean, bambuModel?: string,
|
|
1307
|
+
upload?: (snapshot: string, destination: string, assertActive: () => void) => Promise<void>
|
|
1277
1308
|
) {
|
|
1278
1309
|
const remotePath = normalizedRemotePath(filename);
|
|
1279
1310
|
if (!print) {
|
|
@@ -1315,7 +1346,8 @@ export class BambuImplementation {
|
|
|
1315
1346
|
}
|
|
1316
1347
|
assertActive();
|
|
1317
1348
|
const destination = path.posix.join(path.posix.dirname(remotePath), uniquePrintName(remotePath));
|
|
1318
|
-
|
|
1349
|
+
if (upload) await upload(snapshot, destination, assertActive);
|
|
1350
|
+
else await this.ftpUpload(host, token, snapshot, `/${destination}`);
|
|
1319
1351
|
return { status: "success", uploaded: true, remotePath: destination, printRequested: false, inspected: printable };
|
|
1320
1352
|
}));
|
|
1321
1353
|
}
|
|
@@ -1374,7 +1406,7 @@ export class BambuImplementation {
|
|
|
1374
1406
|
private async ftpDownload(host: string, token: string, remotePath: string, localPath: string): Promise<void> {
|
|
1375
1407
|
const client = new FTPClient(15_000);
|
|
1376
1408
|
try {
|
|
1377
|
-
await client.access({ host, port: 990, user: "bblp", password: token, secure: "implicit", secureOptions: ftpsSecureOptions() });
|
|
1409
|
+
await client.access({ host, port: 990, user: "bblp", password: token, secure: "implicit", secureOptions: ftpsSecureOptions(host) });
|
|
1378
1410
|
await this.waitForTlsSession(client);
|
|
1379
1411
|
const size = await client.size(`/${remotePath}`);
|
|
1380
1412
|
if (!Number.isFinite(size) || size <= 0 || size > 512 * 1024 * 1024) throw new Error("Remote print file is empty or exceeds the 512 MiB inspection limit.");
|
|
@@ -1404,8 +1436,8 @@ export class BambuImplementation {
|
|
|
1404
1436
|
* [16..16+payloadSize] JPEG (FF D8 ... FF D9)
|
|
1405
1437
|
*
|
|
1406
1438
|
* Verified models per upstream docs: A1, A1 mini, P1S, P1P. X1/X1C/X1E
|
|
1407
|
-
* and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C
|
|
1408
|
-
*
|
|
1439
|
+
* and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C/X2D
|
|
1440
|
+
* use the same RTSP path.
|
|
1409
1441
|
*
|
|
1410
1442
|
* Read-only; no confirm gate. Default 8s timeout for cold-start latency.
|
|
1411
1443
|
*/
|
|
@@ -1508,18 +1540,21 @@ export class BambuImplementation {
|
|
|
1508
1540
|
|
|
1509
1541
|
/**
|
|
1510
1542
|
* Pull a single JPEG frame from the printer's RTSP/RTSPS stream using
|
|
1511
|
-
* ffmpeg. Used for X1, P2S, and
|
|
1543
|
+
* ffmpeg. Used for X1, P2S, H2-family printers, and X2D.
|
|
1512
1544
|
*
|
|
1513
1545
|
* URL pattern verified against HA bambulab's models.py example:
|
|
1514
1546
|
* rtsps://bblp:<access_code>@<host>:322/streaming/live/1
|
|
1515
1547
|
*
|
|
1516
1548
|
* ffmpeg invocation:
|
|
1517
|
-
* ffmpeg -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
|
|
1549
|
+
* ffmpeg -tls_verify 0 -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
|
|
1518
1550
|
*
|
|
1519
|
-
* -
|
|
1520
|
-
*
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
1551
|
+
* -tls_verify 0 accepts the printer's self-signed certificate. This is
|
|
1552
|
+
* consistent with the existing local-device TLS/FTPS paths, which do not
|
|
1553
|
+
* have a public CA chain or hostname that ffmpeg can validate. The
|
|
1554
|
+
* connection is still encrypted and remains scoped to the configured
|
|
1555
|
+
* printer host. -rtsp_transport tcp avoids UDP NAT/firewall issues.
|
|
1556
|
+
* -frames:v 1 makes ffmpeg exit as soon as one frame lands. -y overwrites
|
|
1557
|
+
* the temp file.
|
|
1523
1558
|
*/
|
|
1524
1559
|
private async fetchRtspCameraFrame(
|
|
1525
1560
|
host: string,
|
|
@@ -1540,6 +1575,7 @@ export class BambuImplementation {
|
|
|
1540
1575
|
// have to detect ffmpeg version. -rtsp_transport tcp avoids UDP NAT
|
|
1541
1576
|
// headaches; -frames:v 1 makes ffmpeg exit on first frame.
|
|
1542
1577
|
const args = [
|
|
1578
|
+
"-tls_verify", "0",
|
|
1543
1579
|
"-rtsp_transport", "tcp",
|
|
1544
1580
|
"-i", url,
|
|
1545
1581
|
"-frames:v", "1",
|
|
@@ -1768,7 +1804,7 @@ export class BambuImplementation {
|
|
|
1768
1804
|
user: "bblp",
|
|
1769
1805
|
password: token,
|
|
1770
1806
|
secure: "implicit",
|
|
1771
|
-
secureOptions: ftpsSecureOptions(),
|
|
1807
|
+
secureOptions: ftpsSecureOptions(host),
|
|
1772
1808
|
});
|
|
1773
1809
|
await this.waitForTlsSession(client);
|
|
1774
1810
|
const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
|
|
@@ -1798,7 +1834,7 @@ export class BambuImplementation {
|
|
|
1798
1834
|
user: "bblp",
|
|
1799
1835
|
password: token,
|
|
1800
1836
|
secure: "implicit",
|
|
1801
|
-
secureOptions: ftpsSecureOptions(),
|
|
1837
|
+
secureOptions: ftpsSecureOptions(host),
|
|
1802
1838
|
});
|
|
1803
1839
|
// With TLS 1.3 the session ticket arrives asynchronously; basic-ftp calls
|
|
1804
1840
|
// getSession() when opening the data channel and gets undefined if the
|