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.
@@ -46,14 +46,14 @@ export declare class BambuImplementation {
46
46
  getStatus(host: string, serial: string, token: string): Promise<any>;
47
47
  print3mf(host: string, serial: string, token: string, options: BambuPrintOptionsInternal): Promise<any>;
48
48
  private print3mfPrepared;
49
- cancelJob(host: string, serial: string, token: string): Promise<any>;
50
- pauseJob(host: string, serial: string, token: string): Promise<any>;
51
- resumeJob(host: string, serial: string, token: string): Promise<any>;
52
- clearHmsErrors(host: string, serial: string, token: string): Promise<any>;
49
+ cancelJob(host: string, serial: string, token: string, dispatch?: () => Promise<any>): Promise<any>;
50
+ pauseJob(host: string, serial: string, token: string, dispatch?: () => Promise<any>): Promise<any>;
51
+ resumeJob(host: string, serial: string, token: string, dispatch?: (assertActive: () => void, beforeDispatch: () => Promise<void>) => Promise<any>): Promise<any>;
52
+ clearHmsErrors(host: string, serial: string, token: string, dispatch?: (assertActive: () => void, beforeDispatch: () => Promise<void>) => Promise<any>): Promise<any>;
53
53
  setPrintSpeed(host: string, serial: string, token: string, speedMode: string | number): Promise<any>;
54
54
  setAirductMode(host: string, serial: string, token: string, mode: string): Promise<any>;
55
55
  rereadAmsRfid(host: string, serial: string, token: string, amsId: number, slotId: number): Promise<any>;
56
- setTemperature(host: string, serial: string, token: string, component: string, temperature: unknown, bambuModel?: string, material?: string, nozzleDiameter?: number): Promise<{
56
+ setTemperature(host: string, serial: string, token: string, component: string, temperature: unknown, bambuModel?: string, material?: string, nozzleDiameter?: number, dispatch?: (heater: "bed" | "nozzle", target: number, assertActive: () => void, beforeDispatch?: () => Promise<void>) => Promise<any>): Promise<{
57
57
  status: string;
58
58
  message: string;
59
59
  command: string;
@@ -84,7 +84,7 @@ export declare class BambuImplementation {
84
84
  name: string;
85
85
  exists: boolean;
86
86
  }>;
87
- uploadFile(host: string, serial: string, token: string, filePath: string, filename: string, print: boolean, bambuModel?: string): Promise<{
87
+ uploadFile(host: string, serial: string, token: string, filePath: string, filename: string, print: boolean, bambuModel?: string, upload?: (snapshot: string, destination: string, assertActive: () => void) => Promise<void>): Promise<{
88
88
  status: string;
89
89
  uploaded: boolean;
90
90
  remotePath: string;
@@ -128,8 +128,8 @@ export declare class BambuImplementation {
128
128
  * [16..16+payloadSize] JPEG (FF D8 ... FF D9)
129
129
  *
130
130
  * Verified models per upstream docs: A1, A1 mini, P1S, P1P. X1/X1C/X1E
131
- * and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C
132
- * are not documented; we fail fast rather than guess at the protocol.
131
+ * and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C/X2D
132
+ * use the same RTSP path.
133
133
  *
134
134
  * Read-only; no confirm gate. Default 8s timeout for cold-start latency.
135
135
  */
@@ -163,18 +163,21 @@ export declare class BambuImplementation {
163
163
  }>;
164
164
  /**
165
165
  * Pull a single JPEG frame from the printer's RTSP/RTSPS stream using
166
- * ffmpeg. Used for X1, P2S, and H2 series.
166
+ * ffmpeg. Used for X1, P2S, H2-family printers, and X2D.
167
167
  *
168
168
  * URL pattern verified against HA bambulab's models.py example:
169
169
  * rtsps://bblp:<access_code>@<host>:322/streaming/live/1
170
170
  *
171
171
  * ffmpeg invocation:
172
- * ffmpeg -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
172
+ * ffmpeg -tls_verify 0 -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
173
173
  *
174
- * -rtsp_transport tcp avoids UDP NAT/firewall issues. -frames:v 1
175
- * makes ffmpeg exit as soon as one frame lands. -y overwrites the temp
176
- * file. The Bambu printer presents a self-signed cert; ffmpeg's TLS
177
- * layer accepts that by default (no host verification).
174
+ * -tls_verify 0 accepts the printer's self-signed certificate. This is
175
+ * consistent with the existing local-device TLS/FTPS paths, which do not
176
+ * have a public CA chain or hostname that ffmpeg can validate. The
177
+ * connection is still encrypted and remains scoped to the configured
178
+ * printer host. -rtsp_transport tcp avoids UDP NAT/firewall issues.
179
+ * -frames:v 1 makes ffmpeg exit as soon as one frame lands. -y overwrites
180
+ * the temp file.
178
181
  */
179
182
  private fetchRtspCameraFrame;
180
183
  /**
@@ -51,11 +51,11 @@ const MODEL_ID_TO_NAME = {
51
51
  "BL-P002": "X1",
52
52
  C13: "X1E",
53
53
  };
54
- const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s"]);
54
+ const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s", "x2d"]);
55
55
  export function assertDirectPrintSupported(model, serial) {
56
56
  if (model?.trim().toLowerCase() === "x2d" || serial?.trim().toUpperCase().startsWith("20P")) {
57
- throw new Error("X2D direct printing is not supported pending native eMMC transport support. " +
58
- "X2D status and slicing remain available; use a supported slicer to print.");
57
+ throw new Error("X2D direct printing is not supported through legacy MQTT/FTPS. Use the macOS native eMMC route via print_3mf. " +
58
+ "X2D status and slicing remain available on all supported platforms.");
59
59
  }
60
60
  }
61
61
  function isH2ModelName(model) {
@@ -196,9 +196,18 @@ class TolerantBambuClient extends BambuClient {
196
196
  return [undefined];
197
197
  }
198
198
  }
199
- /** Build FTPS secureOptions that include the client cert+key when available. */
200
- function ftpsSecureOptions() {
201
- const opts = { rejectUnauthorized: false };
199
+ /**
200
+ * Build FTPS secureOptions that include the client cert+key when available.
201
+ *
202
+ * `host` must be included: basic-ftp opens each data connection by wrapping
203
+ * a plain socket with tls.connect(tlsOptions), and without a host Node can bind
204
+ * the resumable session to "localhost" instead of the printer host. Printers
205
+ * that require TLS session reuse (vsftpd require_ssl_reuse, e.g. X2D) refuse
206
+ * to resume the session and reply "522 SSL connection failed: session reuse
207
+ * required", failing every LIST, STOR and RETR.
208
+ */
209
+ function ftpsSecureOptions(host) {
210
+ const opts = { rejectUnauthorized: false, host };
202
211
  if (CLIENT_CREDS) {
203
212
  opts.cert = CLIENT_CREDS.cert;
204
213
  opts.key = CLIENT_CREDS.key;
@@ -751,12 +760,15 @@ export class BambuImplementation {
751
760
  amsMapping,
752
761
  };
753
762
  }
754
- async cancelJob(host, serial, token) {
763
+ async cancelJob(host, serial, token, dispatch) {
755
764
  cancelPendingPrinterOperations(host, serial);
756
765
  this.checkedJobs.delete(`${host}\n${serial}`);
757
- const printer = await this.getPrinter(host, serial, token);
766
+ const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
758
767
  try {
759
- await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
768
+ if (dispatch)
769
+ return await dispatch();
770
+ else
771
+ await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
760
772
  this.checkedJobs.delete(`${host}\n${serial}`);
761
773
  return { status: "success", message: "Cancel command sent successfully." };
762
774
  }
@@ -764,36 +776,47 @@ export class BambuImplementation {
764
776
  throw new Error(`Failed to cancel print: ${error.message}`);
765
777
  }
766
778
  }
767
- async pauseJob(host, serial, token) {
768
- const printer = await this.getPrinter(host, serial, token);
779
+ async pauseJob(host, serial, token, dispatch) {
780
+ const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
769
781
  try {
770
- await invokeWithoutAck(printer, new UpdateStateCommand({ state: "pause" }));
782
+ if (dispatch)
783
+ return await dispatch();
784
+ else
785
+ await invokeWithoutAck(printer, new UpdateStateCommand({ state: "pause" }));
771
786
  return { status: "success", message: "Pause command sent successfully." };
772
787
  }
773
788
  catch (error) {
774
789
  throw new Error(`Failed to pause print: ${error.message}`);
775
790
  }
776
791
  }
777
- async resumeJob(host, serial, token) {
792
+ async resumeJob(host, serial, token, dispatch) {
778
793
  return withPrinterOperation(host, serial, async (assertActive) => {
779
794
  const checked = this.checkedJobs.get(`${host}\n${serial}`);
780
795
  if (!checked)
781
796
  throw new Error("Resume requires a job inspected and started by this server instance. Verify other jobs on the printer before resuming them there.");
782
- const status = await this.getSafetyStatus(host, serial, token);
783
- validatePrinterState(status, { ...checked.requirements, requireIdle: false });
784
- if (status.raw.gcode_state !== "PAUSE")
785
- throw new Error("Resume requires a freshly reported paused job.");
786
- const stem = (value) => path.posix.basename(value).replace(/(?:\.gcode)?\.3mf$|\.gcode$/i, "");
787
- const names = [status.raw.gcode_file, status.raw.subtask_name].filter(value => typeof value === "string");
788
- if (!names.some(value => stem(value) === stem(checked.remotePath)))
789
- throw new Error("Paused job identity does not match this server's inspected artifact.");
790
- const printer = await this.getPrinter(host, serial, token);
797
+ const beforeDispatch = async () => {
798
+ assertActive();
799
+ const status = await this.getSafetyStatus(host, serial, token);
800
+ validatePrinterState(status, { ...checked.requirements, requireIdle: false });
801
+ if (status.raw.gcode_state !== "PAUSE")
802
+ throw new Error("Resume requires a freshly reported paused job.");
803
+ const stem = (value) => path.posix.basename(value).replace(/(?:\.gcode)?\.3mf$|\.gcode$/i, "");
804
+ const names = [status.raw.gcode_file, status.raw.subtask_name].filter(value => typeof value === "string");
805
+ if (!names.some(value => stem(value) === stem(checked.remotePath)))
806
+ throw new Error("Paused job identity does not match this server's inspected artifact.");
807
+ assertActive();
808
+ };
809
+ await beforeDispatch();
810
+ const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
791
811
  assertActive();
792
- await invokeWithoutAck(printer, new UpdateStateCommand({ state: "resume" }));
812
+ if (dispatch)
813
+ await dispatch(assertActive, beforeDispatch);
814
+ else
815
+ await invokeWithoutAck(printer, new UpdateStateCommand({ state: "resume" }));
793
816
  return { status: "success", message: "Resume command sent successfully." };
794
817
  });
795
818
  }
796
- async clearHmsErrors(host, serial, token) {
819
+ async clearHmsErrors(host, serial, token, dispatch) {
797
820
  return withPrinterOperation(host, serial, async (assertActive) => {
798
821
  const codesFor = (status) => {
799
822
  if (!Array.isArray(status.raw?.hms) || status.raw?.print_error === undefined)
@@ -802,12 +825,20 @@ export class BambuImplementation {
802
825
  };
803
826
  const codes = codesFor(await this.getSafetyStatus(host, serial, token));
804
827
  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);
805
- const current = codesFor(await this.getSafetyStatus(host, serial, token));
806
- if (JSON.stringify(current) !== JSON.stringify(codes))
807
- throw new Error("Hardware errors changed during confirmation. Inspect the new report before retrying.");
808
- const printer = await this.getPrinter(host, serial, token);
828
+ const beforeDispatch = async () => {
829
+ assertActive();
830
+ const current = codesFor(await this.getSafetyStatus(host, serial, token));
831
+ if (JSON.stringify(current) !== JSON.stringify(codes))
832
+ throw new Error("Hardware errors changed before clearing. Inspect the new report before retrying.");
833
+ assertActive();
834
+ };
835
+ await beforeDispatch();
836
+ const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
809
837
  assertActive();
810
- await printer.publish({ print: { command: "clean_print_error", sequence_id: "0" } });
838
+ if (dispatch)
839
+ await dispatch(assertActive, beforeDispatch);
840
+ else
841
+ await printer.publish({ print: { command: "clean_print_error", sequence_id: "0" } });
811
842
  this.clearedErrors.set(serial, codes);
812
843
  await sleep(COMMAND_SETTLE_MS);
813
844
  return { status: "success", message: "Confirmed HMS clear command sent. The next print requires acknowledgment of these cleared codes.", cleared_codes: codes };
@@ -862,15 +893,15 @@ export class BambuImplementation {
862
893
  };
863
894
  }
864
895
  async rereadAmsRfid(host, serial, token, amsId, slotId) {
865
- const printer = await this.getPrinter(host, serial, token);
866
- const normalizedAmsId = Math.trunc(amsId);
867
- const normalizedSlotId = Math.trunc(slotId);
896
+ const normalizedAmsId = amsId;
897
+ const normalizedSlotId = slotId;
868
898
  if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
869
899
  throw new Error("ams_id must be an integer from 0 to 3.");
870
900
  }
871
901
  if (!Number.isInteger(normalizedSlotId) || normalizedSlotId < 0 || normalizedSlotId > 3) {
872
902
  throw new Error("slot_id must be an integer from 0 to 3.");
873
903
  }
904
+ const printer = await this.getPrinter(host, serial, token);
874
905
  await printer.publish({
875
906
  print: {
876
907
  command: "ams_get_rfid",
@@ -887,7 +918,7 @@ export class BambuImplementation {
887
918
  slot_id: normalizedSlotId,
888
919
  };
889
920
  }
890
- async setTemperature(host, serial, token, component, temperature, bambuModel, material, nozzleDiameter = 0.4) {
921
+ async setTemperature(host, serial, token, component, temperature, bambuModel, material, nozzleDiameter = 0.4, dispatch) {
891
922
  const normalizedComponent = component.toLowerCase();
892
923
  const heater = normalizedComponent === "bed" ? "bed" :
893
924
  ["extruder", "nozzle", "tool", "tool0"].includes(normalizedComponent) ? "nozzle" : undefined;
@@ -906,20 +937,28 @@ export class BambuImplementation {
906
937
  const targetTemperature = temperature === 0 ? 0 : validateTemperature(heater, temperature, model, material ? [material] : undefined);
907
938
  const gcode = `${heater === "bed" ? "M140" : "M104"}${heater === "nozzle" && targetTemperature > 0 ? " T0" : ""} S${targetTemperature}`;
908
939
  const send = async (assertActive) => {
940
+ let beforeDispatch;
909
941
  if (targetTemperature > 0) {
910
942
  const status = await this.getSafetyStatus(host, serial, token);
911
- validatePrinterState(status, heater === "nozzle"
912
- ? manualHeatingRequirements(status, model, nozzleDiameter, material)
943
+ const validateHeatingStatus = (current) => validatePrinterState(current, heater === "nozzle"
944
+ ? manualHeatingRequirements(current, model, nozzleDiameter, material)
913
945
  : { model: model, nozzleDiameters: [] });
946
+ validateHeatingStatus(status);
914
947
  await this.confirmHardwareAction(`Heat ${heater} on ${model.toUpperCase()} (${serial}) to ${targetTemperature}°C?${material ? ` Declared material: ${material}. Confirm the physical spool label.` : ""}`);
915
- const confirmedStatus = await this.getSafetyStatus(host, serial, token);
916
- validatePrinterState(confirmedStatus, heater === "nozzle"
917
- ? manualHeatingRequirements(confirmedStatus, model, nozzleDiameter, material)
918
- : { model: model, nozzleDiameters: [] });
948
+ beforeDispatch = async () => {
949
+ assertActive();
950
+ const confirmedStatus = await this.getSafetyStatus(host, serial, token);
951
+ validateHeatingStatus(confirmedStatus);
952
+ assertActive();
953
+ };
954
+ await beforeDispatch();
919
955
  }
920
- const printer = await this.getPrinter(host, serial, token);
956
+ const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
921
957
  assertActive();
922
- await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
958
+ if (dispatch)
959
+ await dispatch(heater, targetTemperature, assertActive, beforeDispatch);
960
+ else
961
+ await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
923
962
  return { status: "success", message: `Temperature command sent for ${normalizedComponent}.`, command: gcode };
924
963
  };
925
964
  if (targetTemperature === 0) {
@@ -976,16 +1015,16 @@ export class BambuImplementation {
976
1015
  };
977
1016
  }
978
1017
  async setAmsDrying(host, serial, token, action, amsId) {
979
- const printer = await this.getPrinter(host, serial, token);
980
1018
  const normalizedAction = action.trim().toLowerCase();
981
1019
  if (normalizedAction !== "start" && normalizedAction !== "stop") {
982
1020
  throw new Error("AMS drying action must be one of: start, stop.");
983
1021
  }
984
- const normalizedAmsId = Math.trunc(amsId);
1022
+ const normalizedAmsId = amsId;
985
1023
  if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
986
1024
  throw new Error("ams_id must be an integer from 0 to 3.");
987
1025
  }
988
1026
  const param = normalizedAction === "start" ? "start_drying" : "stop_drying";
1027
+ const printer = await this.getPrinter(host, serial, token);
989
1028
  await printer.publish({
990
1029
  print: {
991
1030
  command: "ams_control",
@@ -1060,7 +1099,7 @@ export class BambuImplementation {
1060
1099
  exists,
1061
1100
  };
1062
1101
  }
1063
- async uploadFile(host, serial, token, filePath, filename, print, bambuModel) {
1102
+ async uploadFile(host, serial, token, filePath, filename, print, bambuModel, upload) {
1064
1103
  const remotePath = normalizedRemotePath(filename);
1065
1104
  if (!print) {
1066
1105
  const sourceExtension = path.extname(filePath).toLowerCase();
@@ -1104,7 +1143,10 @@ export class BambuImplementation {
1104
1143
  }
1105
1144
  assertActive();
1106
1145
  const destination = path.posix.join(path.posix.dirname(remotePath), uniquePrintName(remotePath));
1107
- await this.ftpUpload(host, token, snapshot, `/${destination}`);
1146
+ if (upload)
1147
+ await upload(snapshot, destination, assertActive);
1148
+ else
1149
+ await this.ftpUpload(host, token, snapshot, `/${destination}`);
1108
1150
  return { status: "success", uploaded: true, remotePath: destination, printRequested: false, inspected: printable };
1109
1151
  }));
1110
1152
  }
@@ -1164,7 +1206,7 @@ export class BambuImplementation {
1164
1206
  async ftpDownload(host, token, remotePath, localPath) {
1165
1207
  const client = new FTPClient(15000);
1166
1208
  try {
1167
- await client.access({ host, port: 990, user: "bblp", password: token, secure: "implicit", secureOptions: ftpsSecureOptions() });
1209
+ await client.access({ host, port: 990, user: "bblp", password: token, secure: "implicit", secureOptions: ftpsSecureOptions(host) });
1168
1210
  await this.waitForTlsSession(client);
1169
1211
  const size = await client.size(`/${remotePath}`);
1170
1212
  if (!Number.isFinite(size) || size <= 0 || size > 512 * 1024 * 1024)
@@ -1197,8 +1239,8 @@ export class BambuImplementation {
1197
1239
  * [16..16+payloadSize] JPEG (FF D8 ... FF D9)
1198
1240
  *
1199
1241
  * Verified models per upstream docs: A1, A1 mini, P1S, P1P. X1/X1C/X1E
1200
- * and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C
1201
- * are not documented; we fail fast rather than guess at the protocol.
1242
+ * and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C/X2D
1243
+ * use the same RTSP path.
1202
1244
  *
1203
1245
  * Read-only; no confirm gate. Default 8s timeout for cold-start latency.
1204
1246
  */
@@ -1257,18 +1299,21 @@ export class BambuImplementation {
1257
1299
  }
1258
1300
  /**
1259
1301
  * Pull a single JPEG frame from the printer's RTSP/RTSPS stream using
1260
- * ffmpeg. Used for X1, P2S, and H2 series.
1302
+ * ffmpeg. Used for X1, P2S, H2-family printers, and X2D.
1261
1303
  *
1262
1304
  * URL pattern verified against HA bambulab's models.py example:
1263
1305
  * rtsps://bblp:<access_code>@<host>:322/streaming/live/1
1264
1306
  *
1265
1307
  * ffmpeg invocation:
1266
- * ffmpeg -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
1308
+ * ffmpeg -tls_verify 0 -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
1267
1309
  *
1268
- * -rtsp_transport tcp avoids UDP NAT/firewall issues. -frames:v 1
1269
- * makes ffmpeg exit as soon as one frame lands. -y overwrites the temp
1270
- * file. The Bambu printer presents a self-signed cert; ffmpeg's TLS
1271
- * layer accepts that by default (no host verification).
1310
+ * -tls_verify 0 accepts the printer's self-signed certificate. This is
1311
+ * consistent with the existing local-device TLS/FTPS paths, which do not
1312
+ * have a public CA chain or hostname that ffmpeg can validate. The
1313
+ * connection is still encrypted and remains scoped to the configured
1314
+ * printer host. -rtsp_transport tcp avoids UDP NAT/firewall issues.
1315
+ * -frames:v 1 makes ffmpeg exit as soon as one frame lands. -y overwrites
1316
+ * the temp file.
1272
1317
  */
1273
1318
  async fetchRtspCameraFrame(host, token, timeoutMs, ffmpegPath) {
1274
1319
  const fsSync = await import("node:fs");
@@ -1283,6 +1328,7 @@ export class BambuImplementation {
1283
1328
  // have to detect ffmpeg version. -rtsp_transport tcp avoids UDP NAT
1284
1329
  // headaches; -frames:v 1 makes ffmpeg exit on first frame.
1285
1330
  const args = [
1331
+ "-tls_verify", "0",
1286
1332
  "-rtsp_transport", "tcp",
1287
1333
  "-i", url,
1288
1334
  "-frames:v", "1",
@@ -1466,7 +1512,7 @@ export class BambuImplementation {
1466
1512
  user: "bblp",
1467
1513
  password: token,
1468
1514
  secure: "implicit",
1469
- secureOptions: ftpsSecureOptions(),
1515
+ secureOptions: ftpsSecureOptions(host),
1470
1516
  });
1471
1517
  await this.waitForTlsSession(client);
1472
1518
  const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
@@ -1491,7 +1537,7 @@ export class BambuImplementation {
1491
1537
  user: "bblp",
1492
1538
  password: token,
1493
1539
  secure: "implicit",
1494
- secureOptions: ftpsSecureOptions(),
1540
+ secureOptions: ftpsSecureOptions(host),
1495
1541
  });
1496
1542
  // With TLS 1.3 the session ticket arrives asynchronously; basic-ftp calls
1497
1543
  // getSession() when opening the data channel and gets undefined if the