bambu-printer-mcp 1.1.13 → 1.1.15

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.
@@ -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 pending native eMMC transport support. " +
71
- "X2D status and slicing remain available; use a supported slicer to print."
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
- /** Build FTPS secureOptions that include the client cert+key when available. */
255
- function ftpsSecureOptions(): Record<string, unknown> {
256
- const opts: Record<string, unknown> = { rejectUnauthorized: false };
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
- await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
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
- await invokeWithoutAck(printer, new UpdateStateCommand({ state: "pause" }));
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 status = await this.getSafetyStatus(host, serial, token);
914
- validatePrinterState(status, { ...checked.requirements, requireIdle: false });
915
- if (status.raw.gcode_state !== "PAUSE") throw new Error("Resume requires a freshly reported paused job.");
916
- const stem = (value: string) => path.posix.basename(value).replace(/(?:\.gcode)?\.3mf$|\.gcode$/i, "");
917
- const names = [status.raw.gcode_file, status.raw.subtask_name].filter(value => typeof value === "string");
918
- if (!names.some(value => stem(value) === stem(checked.remotePath))) throw new Error("Paused job identity does not match this server's inspected artifact.");
919
- const printer = await this.getPrinter(host, serial, token);
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
- await invokeWithoutAck(printer, new UpdateStateCommand({ state: "resume" }));
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 current = codesFor(await this.getSafetyStatus(host, serial, token));
935
- if (JSON.stringify(current) !== JSON.stringify(codes)) throw new Error("Hardware errors changed during confirmation. Inspect the new report before retrying.");
936
- const printer = await this.getPrinter(host, serial, token);
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
- await printer.publish({ print: { command: "clean_print_error", sequence_id: "0" } });
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 printer = await this.getPrinter(host, serial, token);
1008
- const normalizedAmsId = Math.trunc(amsId);
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(status, heater === "nozzle"
1067
- ? manualHeatingRequirements(status, model!, nozzleDiameter, material!)
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
- const confirmedStatus = await this.getSafetyStatus(host, serial, token);
1071
- validatePrinterState(confirmedStatus, heater === "nozzle"
1072
- ? manualHeatingRequirements(confirmedStatus, model!, nozzleDiameter, material!)
1073
- : { model: model!, nozzleDiameters: [] });
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
- await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
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 = Math.trunc(amsId);
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",
@@ -1227,19 +1257,32 @@ export class BambuImplementation {
1227
1257
  }
1228
1258
 
1229
1259
  async getFiles(host: string, serial: string, token: string) {
1230
- const printer = new BambuPrinter(host, serial, token);
1260
+ const client = new FTPClient(15_000);
1231
1261
  const directories = ["cache", "timelapse", "logs"];
1232
1262
  const filesByDirectory: Record<string, string[]> = {};
1233
1263
 
1234
- await printer.manipulateFiles(async (context) => {
1264
+ try {
1265
+ await client.access({
1266
+ host,
1267
+ port: 990,
1268
+ user: "bblp",
1269
+ password: token,
1270
+ secure: "implicit",
1271
+ secureOptions: ftpsSecureOptions(host),
1272
+ });
1273
+ await this.waitForTlsSession(client);
1274
+ // Discover absent optional directories without creating them or treating
1275
+ // ambiguous FTP 550 responses (including permission errors) as emptiness.
1276
+ const root = await client.list("/");
1235
1277
  for (const directory of directories) {
1236
- try {
1237
- filesByDirectory[directory] = await context.readDir(directory);
1238
- } catch {
1239
- filesByDirectory[directory] = [];
1240
- }
1278
+ const present = root.some(entry => entry.name === directory && (entry.isDirectory || entry.isSymbolicLink));
1279
+ filesByDirectory[directory] = present
1280
+ ? (await client.list(`/${directory}`)).map(entry => entry.name)
1281
+ : [];
1241
1282
  }
1242
- });
1283
+ } finally {
1284
+ client.close();
1285
+ }
1243
1286
 
1244
1287
  const files = Object.entries(filesByDirectory).flatMap(([directory, names]) =>
1245
1288
  names.map((name) => `${directory}/${name}`)
@@ -1273,7 +1316,8 @@ export class BambuImplementation {
1273
1316
 
1274
1317
  async uploadFile(
1275
1318
  host: string, serial: string, token: string, filePath: string,
1276
- filename: string, print: boolean, bambuModel?: string
1319
+ filename: string, print: boolean, bambuModel?: string,
1320
+ upload?: (snapshot: string, destination: string, assertActive: () => void) => Promise<void>
1277
1321
  ) {
1278
1322
  const remotePath = normalizedRemotePath(filename);
1279
1323
  if (!print) {
@@ -1315,7 +1359,8 @@ export class BambuImplementation {
1315
1359
  }
1316
1360
  assertActive();
1317
1361
  const destination = path.posix.join(path.posix.dirname(remotePath), uniquePrintName(remotePath));
1318
- await this.ftpUpload(host, token, snapshot, `/${destination}`);
1362
+ if (upload) await upload(snapshot, destination, assertActive);
1363
+ else await this.ftpUpload(host, token, snapshot, `/${destination}`);
1319
1364
  return { status: "success", uploaded: true, remotePath: destination, printRequested: false, inspected: printable };
1320
1365
  }));
1321
1366
  }
@@ -1374,7 +1419,7 @@ export class BambuImplementation {
1374
1419
  private async ftpDownload(host: string, token: string, remotePath: string, localPath: string): Promise<void> {
1375
1420
  const client = new FTPClient(15_000);
1376
1421
  try {
1377
- await client.access({ host, port: 990, user: "bblp", password: token, secure: "implicit", secureOptions: ftpsSecureOptions() });
1422
+ await client.access({ host, port: 990, user: "bblp", password: token, secure: "implicit", secureOptions: ftpsSecureOptions(host) });
1378
1423
  await this.waitForTlsSession(client);
1379
1424
  const size = await client.size(`/${remotePath}`);
1380
1425
  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 +1449,8 @@ export class BambuImplementation {
1404
1449
  * [16..16+payloadSize] JPEG (FF D8 ... FF D9)
1405
1450
  *
1406
1451
  * 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
- * are not documented; we fail fast rather than guess at the protocol.
1452
+ * and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C/X2D
1453
+ * use the same RTSP path.
1409
1454
  *
1410
1455
  * Read-only; no confirm gate. Default 8s timeout for cold-start latency.
1411
1456
  */
@@ -1508,18 +1553,21 @@ export class BambuImplementation {
1508
1553
 
1509
1554
  /**
1510
1555
  * Pull a single JPEG frame from the printer's RTSP/RTSPS stream using
1511
- * ffmpeg. Used for X1, P2S, and H2 series.
1556
+ * ffmpeg. Used for X1, P2S, H2-family printers, and X2D.
1512
1557
  *
1513
1558
  * URL pattern verified against HA bambulab's models.py example:
1514
1559
  * rtsps://bblp:<access_code>@<host>:322/streaming/live/1
1515
1560
  *
1516
1561
  * ffmpeg invocation:
1517
- * ffmpeg -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
1562
+ * ffmpeg -tls_verify 0 -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
1518
1563
  *
1519
- * -rtsp_transport tcp avoids UDP NAT/firewall issues. -frames:v 1
1520
- * makes ffmpeg exit as soon as one frame lands. -y overwrites the temp
1521
- * file. The Bambu printer presents a self-signed cert; ffmpeg's TLS
1522
- * layer accepts that by default (no host verification).
1564
+ * -tls_verify 0 accepts the printer's self-signed certificate. This is
1565
+ * consistent with the existing local-device TLS/FTPS paths, which do not
1566
+ * have a public CA chain or hostname that ffmpeg can validate. The
1567
+ * connection is still encrypted and remains scoped to the configured
1568
+ * printer host. -rtsp_transport tcp avoids UDP NAT/firewall issues.
1569
+ * -frames:v 1 makes ffmpeg exit as soon as one frame lands. -y overwrites
1570
+ * the temp file.
1523
1571
  */
1524
1572
  private async fetchRtspCameraFrame(
1525
1573
  host: string,
@@ -1540,6 +1588,7 @@ export class BambuImplementation {
1540
1588
  // have to detect ffmpeg version. -rtsp_transport tcp avoids UDP NAT
1541
1589
  // headaches; -frames:v 1 makes ffmpeg exit on first frame.
1542
1590
  const args = [
1591
+ "-tls_verify", "0",
1543
1592
  "-rtsp_transport", "tcp",
1544
1593
  "-i", url,
1545
1594
  "-frames:v", "1",
@@ -1768,7 +1817,7 @@ export class BambuImplementation {
1768
1817
  user: "bblp",
1769
1818
  password: token,
1770
1819
  secure: "implicit",
1771
- secureOptions: ftpsSecureOptions(),
1820
+ secureOptions: ftpsSecureOptions(host),
1772
1821
  });
1773
1822
  await this.waitForTlsSession(client);
1774
1823
  const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
@@ -1798,7 +1847,7 @@ export class BambuImplementation {
1798
1847
  user: "bblp",
1799
1848
  password: token,
1800
1849
  secure: "implicit",
1801
- secureOptions: ftpsSecureOptions(),
1850
+ secureOptions: ftpsSecureOptions(host),
1802
1851
  });
1803
1852
  // With TLS 1.3 the session ticket arrives asynchronously; basic-ftp calls
1804
1853
  // getSession() when opening the data channel and gets undefined if the