bambu-printer-mcp 1.0.8 → 1.1.4

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.
@@ -1,18 +1,261 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
+ import { readFileSync, existsSync } from "node:fs";
3
4
  import path from "node:path";
5
+ import os from "node:os";
6
+ import { Readable } from "node:stream";
4
7
  import JSZip from "jszip";
5
8
  import { Client as FTPClient } from "basic-ftp";
6
- import { Readable } from "node:stream";
7
9
  import { BambuPrinter } from "bambu-js";
8
- import { BambuClient, GCodeFileCommand, GCodeLineCommand, PushAllCommand, UpdateStateCommand, } from "bambu-node";
10
+ import * as mqtt from "mqtt";
11
+ import { BambuClient, GCodeFileCommand, GCodeLineCommand, PushAllCommand, UpdateFanCommand, UpdateLightCommand, UpdateStateCommand, } from "bambu-node";
12
+ /**
13
+ * Post-Jan-2025 H2D firmware requires mTLS with a Bambu-issued client cert.
14
+ * Loads cert+key once from:
15
+ * - BAMBU_CLIENT_CERT / BAMBU_CLIENT_KEY env vars (paths), or
16
+ * - ~/Desktop/bambu certs/embedded-cert.pem + embedded-key.pem (default)
17
+ * Returns null if files missing — caller falls back to no-cert TLS.
18
+ */
19
+ function loadClientCreds() {
20
+ const defaultDir = path.join(os.homedir(), "Desktop", "bambu certs");
21
+ const certPath = process.env.BAMBU_CLIENT_CERT || path.join(defaultDir, "embedded-cert.pem");
22
+ const keyPath = process.env.BAMBU_CLIENT_KEY || path.join(defaultDir, "embedded-key.pem");
23
+ try {
24
+ if (!existsSync(certPath) || !existsSync(keyPath))
25
+ return null;
26
+ return { cert: readFileSync(certPath), key: readFileSync(keyPath) };
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ const CLIENT_CREDS = loadClientCreds();
33
+ const COMMAND_SETTLE_MS = 300;
34
+ const MODEL_ID_TO_NAME = {
35
+ O1C: "H2C",
36
+ O1C2: "H2C",
37
+ O1D: "H2D",
38
+ O1E: "H2D Pro",
39
+ O1S: "H2S",
40
+ N2S: "A1",
41
+ A1M: "A1 Mini",
42
+ C11: "P1P",
43
+ C12: "P1S",
44
+ "BL-P001": "X1C",
45
+ "BL-P002": "X1",
46
+ C13: "X1E",
47
+ };
48
+ const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s"]);
49
+ function isH2ModelName(model) {
50
+ return H2_MODEL_NAMES.has(String(model ?? "").trim().toLowerCase().replace(/\s+/g, " "));
51
+ }
52
+ function sleep(ms) {
53
+ return new Promise((resolve) => setTimeout(resolve, ms));
54
+ }
55
+ function resolveModelName(data) {
56
+ const modelId = `${data?.model_id ?? ""}`.toUpperCase();
57
+ return (MODEL_ID_TO_NAME[modelId] ||
58
+ data?.model ||
59
+ data?.device?.devModel ||
60
+ data?.device?.dev_model ||
61
+ "Unknown");
62
+ }
63
+ async function invokeWithoutAck(printer, command) {
64
+ await command.invoke(printer);
65
+ await sleep(COMMAND_SETTLE_MS);
66
+ }
67
+ function getPrinterKey(host, serial, token) {
68
+ return `${host}-${serial}-${token}`;
69
+ }
70
+ class TolerantBambuClient extends BambuClient {
71
+ /**
72
+ * H2D-class firmware streams push status immediately after subscribe, but
73
+ * never answers bambu-node's initial get_version round-trip. Avoid treating
74
+ * that missing ACK as a failed connection.
75
+ */
76
+ async onConnect() {
77
+ const subscribe = this.subscribe.bind(this);
78
+ await subscribe(`device/${this.config.serialNumber}/report`);
79
+ }
80
+ /**
81
+ * H2-series printers don't respond to get_version with module info.
82
+ * Infer model from serial number prefix so downstream code (Job, status
83
+ * parsing, etc.) has a valid printerModel instead of undefined.
84
+ */
85
+ inferModelFromSerial() {
86
+ const sn = this.config.serialNumber;
87
+ if (sn.startsWith("093"))
88
+ return "H2S";
89
+ if (sn.startsWith("094"))
90
+ return "H2D";
91
+ if (sn.startsWith("00M"))
92
+ return "X1C";
93
+ if (sn.startsWith("00W"))
94
+ return "X1";
95
+ if (sn.startsWith("03W"))
96
+ return "X1E";
97
+ if (sn.startsWith("01S"))
98
+ return "P1P";
99
+ if (sn.startsWith("01P"))
100
+ return "P1S";
101
+ if (sn.startsWith("030"))
102
+ return "A1";
103
+ if (sn.startsWith("039"))
104
+ return "A1M";
105
+ return undefined;
106
+ }
107
+ /**
108
+ * Override bambu-node's MQTT connect to pass a client cert+key for mTLS.
109
+ * Post-Jan-2025 H2D firmware rejects TLS handshakes without a valid Bambu-
110
+ * issued client certificate. Options mirror the upstream implementation
111
+ * plus `cert`/`key` when creds are available.
112
+ */
113
+ async connect() {
114
+ await new Promise((resolve, reject) => {
115
+ const self = this;
116
+ if (self.mqttClient) {
117
+ throw new Error("Can't establish a new connection while running another one!");
118
+ }
119
+ const tlsOpts = {
120
+ username: "bblp",
121
+ password: self.config.accessToken,
122
+ reconnectPeriod: self.clientOptions.reconnectInterval,
123
+ connectTimeout: self.clientOptions.connectTimeout,
124
+ keepalive: self.clientOptions.keepAlive,
125
+ resubscribe: true,
126
+ rejectUnauthorized: false,
127
+ };
128
+ if (CLIENT_CREDS) {
129
+ tlsOpts.cert = CLIENT_CREDS.cert;
130
+ tlsOpts.key = CLIENT_CREDS.key;
131
+ }
132
+ const client = mqtt.connect(`mqtts://${self.config.host}:${self.config.port}`, tlsOpts);
133
+ self.mqttClient = client;
134
+ client.on("connect", async (...args) => {
135
+ try {
136
+ await self.onConnect(...args);
137
+ self.emit("client:connect");
138
+ resolve();
139
+ }
140
+ catch (e) {
141
+ reject(e);
142
+ }
143
+ });
144
+ client.on("disconnect", () => {
145
+ self.emit("client:disconnect", false);
146
+ self.emit("printer:statusUpdate", self._printerStatus, "OFFLINE");
147
+ self._printerStatus = "OFFLINE";
148
+ if (self.currentJob)
149
+ self.emit("job:pause", self.currentJob, true);
150
+ });
151
+ client.on("offline", () => {
152
+ self.emit("client:disconnect", true);
153
+ self.emit("printer:statusUpdate", self._printerStatus, "OFFLINE");
154
+ self._printerStatus = "OFFLINE";
155
+ if (self.currentJob)
156
+ self.emit("job:pause", self.currentJob, true);
157
+ });
158
+ client.on("message", (topic, payload) => self.emit("rawMessage", topic, payload));
159
+ client.on("error", (err) => {
160
+ self.emit("client:error", err);
161
+ reject(err);
162
+ });
163
+ });
164
+ // H2-series printers don't respond to get_version with module info.
165
+ // Infer model from serial number prefix so downstream code works.
166
+ if (!this.data.model) {
167
+ const inferred = this.inferModelFromSerial();
168
+ if (inferred) {
169
+ this.data.model = inferred;
170
+ this.emit("printer:dataUpdate", this.data, { model: inferred });
171
+ }
172
+ }
173
+ return [undefined];
174
+ }
175
+ }
176
+ /** Build FTPS secureOptions that include the client cert+key when available. */
177
+ function ftpsSecureOptions() {
178
+ const opts = { rejectUnauthorized: false };
179
+ if (CLIENT_CREDS) {
180
+ opts.cert = CLIENT_CREDS.cert;
181
+ opts.key = CLIENT_CREDS.key;
182
+ }
183
+ return opts;
184
+ }
9
185
  class BambuClientStore {
10
186
  constructor() {
11
187
  this.printers = new Map();
12
188
  this.initialConnectionPromises = new Map();
189
+ this.reportSnapshots = new Map();
190
+ this.initialReportPromises = new Map();
191
+ this.initialReportResolvers = new Map();
192
+ }
193
+ ensureInitialReportPromise(key) {
194
+ const existing = this.initialReportPromises.get(key);
195
+ if (existing) {
196
+ return existing;
197
+ }
198
+ const promise = new Promise((resolve) => {
199
+ this.initialReportResolvers.set(key, resolve);
200
+ });
201
+ this.initialReportPromises.set(key, promise);
202
+ return promise;
203
+ }
204
+ resolveInitialReport(key) {
205
+ const resolve = this.initialReportResolvers.get(key);
206
+ if (!resolve) {
207
+ return;
208
+ }
209
+ this.initialReportResolvers.delete(key);
210
+ resolve();
211
+ }
212
+ updateReportSnapshot(key, update) {
213
+ if (!update || Object.keys(update).length === 0) {
214
+ return;
215
+ }
216
+ const previous = this.reportSnapshots.get(key) || {};
217
+ this.reportSnapshots.set(key, { ...previous, ...update });
218
+ this.resolveInitialReport(key);
219
+ }
220
+ clearPrinterState(key) {
221
+ this.printers.delete(key);
222
+ this.initialConnectionPromises.delete(key);
223
+ this.reportSnapshots.delete(key);
224
+ this.initialReportPromises.delete(key);
225
+ this.initialReportResolvers.delete(key);
226
+ }
227
+ getCachedReport(host, serial, token) {
228
+ return this.reportSnapshots.get(getPrinterKey(host, serial, token)) || null;
229
+ }
230
+ async waitForInitialReport(host, serial, token, timeoutMs = 4000) {
231
+ const key = getPrinterKey(host, serial, token);
232
+ const existing = this.reportSnapshots.get(key);
233
+ if (!existing || Object.keys(existing).length === 0) {
234
+ // No MQTT data yet — wait for the first push.
235
+ const reportPromise = this.ensureInitialReportPromise(key);
236
+ try {
237
+ await Promise.race([
238
+ reportPromise,
239
+ sleep(timeoutMs).then(() => {
240
+ throw new Error(`Timed out waiting for initial printer report after ${timeoutMs}ms.`);
241
+ }),
242
+ ]);
243
+ }
244
+ catch (error) {
245
+ console.warn(`No initial printer report received for ${serial}:`, error);
246
+ }
247
+ }
248
+ // Short settle: the first MQTT push from the printer is a sparse "hello"
249
+ // with only model/modules. A second push carrying the full status
250
+ // (gcode_state, ams, hms, temperatures, fans, etc.) arrives afterward
251
+ // and gets merged into reportSnapshots via incremental update.
252
+ // This settle runs regardless of whether data arrived before or during
253
+ // the promise race above, so the merge has time to complete.
254
+ await sleep(500);
255
+ return this.reportSnapshots.get(key) || null;
13
256
  }
14
257
  async getPrinter(host, serial, token) {
15
- const key = `${host}-${serial}`;
258
+ const key = getPrinterKey(host, serial, token);
16
259
  if (this.printers.has(key)) {
17
260
  return this.printers.get(key);
18
261
  }
@@ -23,22 +266,36 @@ class BambuClientStore {
23
266
  }
24
267
  throw new Error(`Existing Bambu client connection for ${key} failed.`);
25
268
  }
26
- const printer = new BambuClient({
269
+ const printer = new TolerantBambuClient({
27
270
  host,
28
271
  serialNumber: serial,
29
272
  accessToken: token,
30
273
  });
274
+ this.ensureInitialReportPromise(key);
275
+ printer.on("rawMessage", (_topic, payload) => {
276
+ try {
277
+ const parsed = JSON.parse(payload.toString());
278
+ const printMessage = parsed?.print;
279
+ if (printMessage && typeof printMessage === "object") {
280
+ this.updateReportSnapshot(key, printMessage);
281
+ }
282
+ }
283
+ catch {
284
+ // Ignore unrelated payloads.
285
+ }
286
+ });
287
+ printer.on("printer:dataUpdate", (data) => {
288
+ this.updateReportSnapshot(key, data);
289
+ });
31
290
  printer.on("client:connect", () => {
32
291
  this.printers.set(key, printer);
33
292
  this.initialConnectionPromises.delete(key);
34
293
  });
35
294
  printer.on("client:error", () => {
36
- this.printers.delete(key);
37
- this.initialConnectionPromises.delete(key);
295
+ this.clearPrinterState(key);
38
296
  });
39
297
  printer.on("client:disconnect", () => {
40
- this.printers.delete(key);
41
- this.initialConnectionPromises.delete(key);
298
+ this.clearPrinterState(key);
42
299
  });
43
300
  const connectPromise = printer.connect().then(() => { });
44
301
  this.initialConnectionPromises.set(key, connectPromise);
@@ -47,7 +304,7 @@ class BambuClientStore {
47
304
  return printer;
48
305
  }
49
306
  catch (error) {
50
- this.initialConnectionPromises.delete(key);
307
+ this.clearPrinterState(key);
51
308
  throw error;
52
309
  }
53
310
  }
@@ -66,6 +323,9 @@ class BambuClientStore {
66
323
  await Promise.allSettled(disconnectPromises);
67
324
  this.printers.clear();
68
325
  this.initialConnectionPromises.clear();
326
+ this.reportSnapshots.clear();
327
+ this.initialReportPromises.clear();
328
+ this.initialReportResolvers.clear();
69
329
  }
70
330
  }
71
331
  export class BambuImplementation {
@@ -94,25 +354,67 @@ export class BambuImplementation {
94
354
  }
95
355
  const gcodeBuffer = await selectedEntry.async("nodebuffer");
96
356
  const md5 = createHash("md5").update(gcodeBuffer).digest("hex");
357
+ // Project filament count: parse the gcode header line
358
+ // `; filament_colour = #FFFFFF;#FF911A80;#DCF478;#DCF478`
359
+ // This is the authoritative source -- it always reflects the slicer's
360
+ // project filament list length. We only scan the first ~32KB of the
361
+ // gcode to keep this cheap even on large plates.
362
+ let projectFilamentCount = 1;
363
+ const head = gcodeBuffer.slice(0, 32 * 1024).toString("utf8");
364
+ const colourLine = head.match(/;\s*filament_colour\s*=\s*([^\n\r]+)/i);
365
+ if (colourLine) {
366
+ projectFilamentCount = colourLine[1].split(";").filter((s) => s.trim()).length;
367
+ }
368
+ else {
369
+ // Fallback: count filament_ids header entries.
370
+ const idsLine = head.match(/;\s*filament_ids\s*=\s*([^\n\r]+)/i);
371
+ if (idsLine) {
372
+ projectFilamentCount = idsLine[1].split(";").filter((s) => s.trim()).length;
373
+ }
374
+ }
375
+ // Used filament positions: from Metadata/plate_<n>.json.filament_ids.
376
+ // These are 0-based positions into the project filament list that the
377
+ // selected plate actually consumes.
378
+ let usedFilamentPositions = [];
379
+ const plateJsonName = selectedEntry.name.replace(/\.gcode$/i, ".json");
380
+ const plateJsonEntry = zip.file(plateJsonName);
381
+ if (plateJsonEntry) {
382
+ try {
383
+ const raw = await plateJsonEntry.async("string");
384
+ const json = JSON.parse(raw);
385
+ if (Array.isArray(json.filament_ids)) {
386
+ usedFilamentPositions = json.filament_ids
387
+ .filter((n) => Number.isInteger(n))
388
+ .map((n) => n);
389
+ }
390
+ }
391
+ catch {
392
+ // tolerate malformed plate_N.json -- caller can pass amsMapping directly
393
+ }
394
+ }
395
+ if (usedFilamentPositions.length === 0)
396
+ usedFilamentPositions = [0];
97
397
  return {
98
398
  plateFileName: path.posix.basename(selectedEntry.name),
99
399
  plateInternalPath: selectedEntry.name,
100
400
  md5,
401
+ projectFilamentCount,
402
+ usedFilamentPositions,
101
403
  };
102
404
  }
103
405
  async getStatus(host, serial, token) {
104
406
  try {
105
407
  const printer = await this.getPrinter(host, serial, token);
106
408
  try {
107
- await printer.executeCommand(new PushAllCommand());
409
+ await invokeWithoutAck(printer, new PushAllCommand());
108
410
  }
109
411
  catch (error) {
110
412
  console.warn("PushAllCommand failed, continuing with cached status", error);
111
413
  }
112
- if (!printer.data || Object.keys(printer.data).length === 0) {
113
- await new Promise((resolve) => setTimeout(resolve, 1500));
114
- }
115
- const data = printer.data;
414
+ const cachedData = await this.printerStore.waitForInitialReport(host, serial, token);
415
+ const data = cachedData && Object.keys(cachedData).length > 0
416
+ ? cachedData
417
+ : printer.data;
116
418
  return {
117
419
  status: data.gcode_state || "UNKNOWN",
118
420
  connected: true,
@@ -135,7 +437,7 @@ export class BambuImplementation {
135
437
  totalLayers: data.total_layer_num || 0,
136
438
  },
137
439
  ams: data.ams || null,
138
- model: data.model || "Unknown",
440
+ model: resolveModelName(data),
139
441
  serial,
140
442
  raw: data,
141
443
  };
@@ -149,51 +451,185 @@ export class BambuImplementation {
149
451
  if (!options.filePath.toLowerCase().endsWith(".3mf")) {
150
452
  throw new Error("print3mf requires a .3mf input file.");
151
453
  }
152
- const projectMetadata = await this.resolveProjectFileMetadata(options.filePath, options.plateIndex);
153
- const remoteFileName = path.basename(options.filePath);
154
- const remoteProjectPath = `cache/${remoteFileName}`;
454
+ // Normalise remote filename: collapse double-extension artifacts like
455
+ // "Cube.gcode.3mf.gcode.3mf" -> "Cube.gcode.3mf" so firmware can identify
456
+ // the container format from the extension.
457
+ let remoteFileName = path.basename(options.filePath);
458
+ remoteFileName = remoteFileName.replace(/\.gcode\.3mf\.gcode\.3mf$/i, ".gcode.3mf");
459
+ // H2-series printers land files at the FTP root and reference them via ftp:///<name>.
460
+ // P1/A1/X1 use /cache/<name> and file:///sdcard/cache/<name>.
461
+ const isH2 = serial.startsWith("093") ||
462
+ serial.startsWith("094") ||
463
+ isH2ModelName(options.bambuModel);
464
+ const remoteProjectPath = isH2 ? remoteFileName : `cache/${remoteFileName}`;
465
+ const remoteUploadPath = isH2 ? `/${remoteFileName}` : `/cache/${remoteFileName}`;
466
+ const projectUrl = isH2
467
+ ? `ftp:///${remoteFileName}`
468
+ : `file:///sdcard/${remoteProjectPath}`;
155
469
  // Upload via basic-ftp directly (bypasses bambu-js double-path bug)
156
- await this.ftpUpload(host, token, options.filePath, `/cache/${remoteFileName}`);
470
+ await this.ftpUpload(host, token, options.filePath, remoteUploadPath);
471
+ // Pre-sliced .gcode.3mf files: routing depends on firmware generation.
472
+ // P1/A1/X1 series: project_file returns 405004002 for .gcode.3mf (firmware
473
+ // doesn't recognise the container), so use gcode_file instead.
474
+ // H2-series: gcode_file is not supported; project_file works because the
475
+ // firmware can open the zip and find Metadata/plate_<n>.gcode directly.
476
+ if (options.filePath.toLowerCase().endsWith(".gcode.3mf")) {
477
+ if (!isH2) {
478
+ const printer = await this.getPrinter(host, serial, token);
479
+ await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
480
+ return {
481
+ status: "success",
482
+ message: `Uploaded and started gcode.3mf print: ${options.projectName}`,
483
+ remoteProjectPath,
484
+ };
485
+ }
486
+ // H2-series: fall through to project_file path below
487
+ }
488
+ const projectMetadata = await this.resolveProjectFileMetadata(options.filePath, options.plateIndex);
157
489
  // Send project_file command via bambu-node MQTT (bypasses bambu-js
158
490
  // hardcoded use_ams=true and missing ams_mapping support)
159
491
  const printer = await this.getPrinter(host, serial, token);
160
492
  const md5 = options.md5 ?? projectMetadata.md5;
161
- // Build AMS mapping per OpenBambuAPI spec: 5-element array
162
- // [-1,-1,-1,-1,0] means slot 0 only; pad unused slots with -1
163
- let amsMapping;
493
+ // Build AMS mapping.
494
+ //
495
+ // Convention: position = project-level filament index, value = absolute
496
+ // tray index (0-3 = AMS 0 trays, 4-7 = AMS 1, 8-11 = AMS 2, 128+ = AMS-HT,
497
+ // 254 = external spool, -1 = unused). Required on AMS-equipped printers
498
+ // even when you think "no AMS" -- firmware looks up the mapping table
499
+ // whenever the 3MF declares filaments, and a missing/invalid mapping
500
+ // fails with 0700-8012-032015 "Failed to get AMS mapping table".
501
+ //
502
+ // For H2-series the array length MUST equal the project-level filament
503
+ // count declared by the slicer (parsed from the gcode header's
504
+ // `filament_colour` list). For P1/A1/X1 we pad to length 5 per the
505
+ // historical bambu-js behavior.
506
+ //
507
+ // Caller ergonomics: callers typically know only "I want to pull this
508
+ // print's filaments from these AMS slots" in the order the plate uses
509
+ // them. We expose `amsSlots` for that -- one entry per position in
510
+ // `plate_N.json.filament_ids` -- and expand to a full project-level
511
+ // array here. `amsMapping` is the raw escape hatch (takes precedence
512
+ // when both are supplied).
513
+ const validateTrayValue = (v, label) => {
514
+ if (!Number.isInteger(v) ||
515
+ v < -1 ||
516
+ (v > 15 && v < 128) ||
517
+ v > 254) {
518
+ throw new Error(`${label} values must be integers in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${v}`);
519
+ }
520
+ };
521
+ let baseMapping;
164
522
  if (options.amsMapping && options.amsMapping.length > 0) {
165
- amsMapping = Array.from({ length: 5 }, (_, i) => i < options.amsMapping.length ? options.amsMapping[i] : -1);
523
+ for (const v of options.amsMapping)
524
+ validateTrayValue(v, "ams_mapping");
525
+ baseMapping = options.amsMapping.slice();
526
+ }
527
+ else if (options.amsSlots && options.amsSlots.length > 0) {
528
+ for (const v of options.amsSlots)
529
+ validateTrayValue(v, "amsSlots");
530
+ const positions = projectMetadata.usedFilamentPositions;
531
+ if (options.amsSlots.length !== positions.length) {
532
+ throw new Error(`amsSlots length ${options.amsSlots.length} does not match used filament count ${positions.length} (plate uses positions ${JSON.stringify(positions)}). Provide one tray per used filament, or use amsMapping for a raw project-level array.`);
533
+ }
534
+ const projectLen = Math.max(projectMetadata.projectFilamentCount, ...positions.map((p) => p + 1));
535
+ baseMapping = Array(projectLen).fill(-1);
536
+ positions.forEach((pos, i) => {
537
+ baseMapping[pos] = options.amsSlots[i];
538
+ });
539
+ }
540
+ else {
541
+ if (isH2 && projectMetadata.usedFilamentPositions.length > 0) {
542
+ throw new Error(`H2 project_file requires amsSlots or amsMapping for sliced files with declared filaments. Plate uses project filament positions ${JSON.stringify(projectMetadata.usedFilamentPositions)}.`);
543
+ }
544
+ const positions = projectMetadata.usedFilamentPositions;
545
+ const projectLen = Math.max(projectMetadata.projectFilamentCount, ...positions.map((p) => p + 1), 1);
546
+ baseMapping = Array(projectLen).fill(-1);
547
+ positions.forEach((pos, i) => {
548
+ baseMapping[pos] = i;
549
+ });
550
+ }
551
+ let amsMapping;
552
+ let amsMapping2;
553
+ if (isH2) {
554
+ const projLen = Math.max(projectMetadata.projectFilamentCount, baseMapping.length, 1);
555
+ amsMapping = Array.from({ length: projLen }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
556
+ amsMapping2 = amsMapping.map((v) => {
557
+ if (v < 0 || v === 255)
558
+ return { ams_id: 255, slot_id: 255 };
559
+ if (v === 254)
560
+ return { ams_id: 254, slot_id: 254 };
561
+ if (v >= 128)
562
+ return { ams_id: 128, slot_id: v - 128 };
563
+ return { ams_id: Math.floor(v / 4), slot_id: v % 4 };
564
+ });
166
565
  }
167
566
  else {
168
- amsMapping = [-1, -1, -1, -1, 0];
567
+ amsMapping = Array.from({ length: 5 }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
568
+ amsMapping2 = [];
569
+ }
570
+ const b = (v) => (v ? 1 : 0);
571
+ let projectFileCmd;
572
+ if (isH2) {
573
+ const submissionId = String(Date.now() & 0x7fffffff);
574
+ projectFileCmd = {
575
+ print: {
576
+ sequence_id: "0",
577
+ command: "project_file",
578
+ param: `Metadata/${projectMetadata.plateFileName}`,
579
+ url: projectUrl,
580
+ file: remoteFileName,
581
+ md5,
582
+ bed_type: options.bedType || "auto",
583
+ timelapse: b(options.timelapse),
584
+ bed_leveling: b(options.bedLeveling ?? true),
585
+ auto_bed_leveling: 1,
586
+ flow_cali: b(options.flowCalibration ?? false),
587
+ vibration_cali: b(options.vibrationCalibration ?? true),
588
+ layer_inspect: b(options.layerInspect ?? false),
589
+ use_ams: options.useAMS !== false,
590
+ cfg: "0",
591
+ extrude_cali_flag: 0,
592
+ extrude_cali_manual_mode: 0,
593
+ nozzle_offset_cali: 2,
594
+ subtask_name: remoteFileName.replace(/\.3mf$/i, ""),
595
+ profile_id: "0",
596
+ project_id: submissionId,
597
+ subtask_id: submissionId,
598
+ task_id: submissionId,
599
+ ams_mapping: amsMapping,
600
+ ams_mapping2: amsMapping2,
601
+ },
602
+ };
603
+ }
604
+ else {
605
+ projectFileCmd = {
606
+ print: {
607
+ command: "project_file",
608
+ param: `Metadata/${projectMetadata.plateFileName}`,
609
+ url: projectUrl,
610
+ subtask_name: options.projectName,
611
+ md5,
612
+ flow_cali: options.flowCalibration ?? true,
613
+ layer_inspect: options.layerInspect ?? true,
614
+ vibration_cali: options.vibrationCalibration ?? true,
615
+ bed_leveling: options.bedLeveling ?? true,
616
+ bed_type: options.bedType || "textured_plate",
617
+ timelapse: options.timelapse ?? false,
618
+ use_ams: options.useAMS !== false,
619
+ ams_mapping: amsMapping,
620
+ profile_id: "0",
621
+ project_id: "0",
622
+ sequence_id: "0",
623
+ subtask_id: "0",
624
+ task_id: "0",
625
+ },
626
+ };
169
627
  }
170
- const projectFileCmd = {
171
- print: {
172
- command: "project_file",
173
- param: `Metadata/${projectMetadata.plateFileName}`,
174
- url: `ftp://${remoteFileName}`,
175
- subtask_name: options.projectName,
176
- md5,
177
- flow_cali: options.flowCalibration ?? true,
178
- layer_inspect: options.layerInspect ?? true,
179
- vibration_cali: options.vibrationCalibration ?? true,
180
- bed_leveling: options.bedLeveling ?? true,
181
- bed_type: options.bedType || "textured_plate",
182
- timelapse: options.timelapse ?? false,
183
- use_ams: options.useAMS !== false,
184
- ams_mapping: amsMapping,
185
- profile_id: "0",
186
- project_id: "0",
187
- sequence_id: "0",
188
- subtask_id: "0",
189
- task_id: "0",
190
- },
191
- };
192
628
  await printer.publish(projectFileCmd);
193
629
  await new Promise((resolve) => setTimeout(resolve, 300));
194
630
  return {
195
631
  status: "success",
196
- message: `Uploaded 3MF and sent project_file print command: ${options.projectName}`,
632
+ message: `Uploaded and started 3MF print: ${options.projectName}`,
197
633
  remoteProjectPath,
198
634
  plateFile: projectMetadata.plateFileName,
199
635
  platePath: projectMetadata.plateInternalPath,
@@ -204,13 +640,118 @@ export class BambuImplementation {
204
640
  async cancelJob(host, serial, token) {
205
641
  const printer = await this.getPrinter(host, serial, token);
206
642
  try {
207
- await printer.executeCommand(new UpdateStateCommand({ state: "stop" }));
643
+ await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
208
644
  return { status: "success", message: "Cancel command sent successfully." };
209
645
  }
210
646
  catch (error) {
211
647
  throw new Error(`Failed to cancel print: ${error.message}`);
212
648
  }
213
649
  }
650
+ async pauseJob(host, serial, token) {
651
+ const printer = await this.getPrinter(host, serial, token);
652
+ try {
653
+ await invokeWithoutAck(printer, new UpdateStateCommand({ state: "pause" }));
654
+ return { status: "success", message: "Pause command sent successfully." };
655
+ }
656
+ catch (error) {
657
+ throw new Error(`Failed to pause print: ${error.message}`);
658
+ }
659
+ }
660
+ async resumeJob(host, serial, token) {
661
+ const printer = await this.getPrinter(host, serial, token);
662
+ try {
663
+ await invokeWithoutAck(printer, new UpdateStateCommand({ state: "resume" }));
664
+ return { status: "success", message: "Resume command sent successfully." };
665
+ }
666
+ catch (error) {
667
+ throw new Error(`Failed to resume print: ${error.message}`);
668
+ }
669
+ }
670
+ async clearHmsErrors(host, serial, token) {
671
+ const printer = await this.getPrinter(host, serial, token);
672
+ await printer.publish({
673
+ print: {
674
+ command: "clean_print_error",
675
+ sequence_id: "0",
676
+ },
677
+ });
678
+ await sleep(COMMAND_SETTLE_MS);
679
+ return { status: "success", message: "HMS clear command sent." };
680
+ }
681
+ async setPrintSpeed(host, serial, token, speedMode) {
682
+ const printer = await this.getPrinter(host, serial, token);
683
+ const normalized = typeof speedMode === "number" ? String(Math.trunc(speedMode)) : speedMode.trim().toLowerCase();
684
+ const mode = normalized === "silent" ? 1 :
685
+ normalized === "standard" ? 2 :
686
+ normalized === "sport" ? 3 :
687
+ normalized === "ludicrous" ? 4 :
688
+ Number(normalized);
689
+ if (!Number.isInteger(mode) || mode < 1 || mode > 4) {
690
+ throw new Error("Print speed mode must be one of: silent, standard, sport, ludicrous, 1, 2, 3, 4.");
691
+ }
692
+ await printer.publish({
693
+ print: {
694
+ command: "print_speed",
695
+ param: String(mode),
696
+ sequence_id: "0",
697
+ },
698
+ });
699
+ await sleep(COMMAND_SETTLE_MS);
700
+ const names = ["", "silent", "standard", "sport", "ludicrous"];
701
+ return {
702
+ status: "success",
703
+ message: `Print speed command sent for ${names[mode]}.`,
704
+ mode,
705
+ label: names[mode],
706
+ };
707
+ }
708
+ async setAirductMode(host, serial, token, mode) {
709
+ const printer = await this.getPrinter(host, serial, token);
710
+ const normalizedMode = mode.trim().toLowerCase();
711
+ if (normalizedMode !== "cooling" && normalizedMode !== "heating") {
712
+ throw new Error("Airduct mode must be one of: cooling, heating.");
713
+ }
714
+ await printer.publish({
715
+ print: {
716
+ command: "set_airduct",
717
+ modeId: normalizedMode === "cooling" ? 0 : 1,
718
+ submode: -1,
719
+ sequence_id: "0",
720
+ },
721
+ });
722
+ await sleep(COMMAND_SETTLE_MS);
723
+ return {
724
+ status: "success",
725
+ message: `Airduct mode command sent for ${normalizedMode}.`,
726
+ mode: normalizedMode,
727
+ };
728
+ }
729
+ async rereadAmsRfid(host, serial, token, amsId, slotId) {
730
+ const printer = await this.getPrinter(host, serial, token);
731
+ const normalizedAmsId = Math.trunc(amsId);
732
+ const normalizedSlotId = Math.trunc(slotId);
733
+ if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
734
+ throw new Error("ams_id must be an integer from 0 to 3.");
735
+ }
736
+ if (!Number.isInteger(normalizedSlotId) || normalizedSlotId < 0 || normalizedSlotId > 3) {
737
+ throw new Error("slot_id must be an integer from 0 to 3.");
738
+ }
739
+ await printer.publish({
740
+ print: {
741
+ command: "ams_get_rfid",
742
+ ams_id: normalizedAmsId,
743
+ slot_id: normalizedSlotId,
744
+ sequence_id: "0",
745
+ },
746
+ });
747
+ await sleep(COMMAND_SETTLE_MS);
748
+ return {
749
+ status: "success",
750
+ message: `AMS RFID re-read command sent for AMS ${normalizedAmsId} slot ${normalizedSlotId}.`,
751
+ ams_id: normalizedAmsId,
752
+ slot_id: normalizedSlotId,
753
+ };
754
+ }
214
755
  async setTemperature(host, serial, token, component, temperature) {
215
756
  const printer = await this.getPrinter(host, serial, token);
216
757
  const normalizedComponent = component.toLowerCase();
@@ -231,13 +772,110 @@ export class BambuImplementation {
231
772
  else {
232
773
  throw new Error(`Unsupported temperature component: ${component}. Use one of: bed, nozzle, extruder.`);
233
774
  }
234
- await printer.executeCommand(new GCodeLineCommand({ gcodes: [gcode] }));
775
+ await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
235
776
  return {
236
777
  status: "success",
237
778
  message: `Temperature command sent for ${normalizedComponent}.`,
238
779
  command: gcode,
239
780
  };
240
781
  }
782
+ async setFanSpeed(host, serial, token, fan, speed) {
783
+ const printer = await this.getPrinter(host, serial, token);
784
+ const normalizedFan = typeof fan === "number" ? fan : fan.trim().toLowerCase();
785
+ const fanId = normalizedFan === 1 || normalizedFan === "1" || normalizedFan === "part" || normalizedFan === "part_cooling"
786
+ ? 1
787
+ : normalizedFan === 2 || normalizedFan === "2" || normalizedFan === "aux" || normalizedFan === "auxiliary"
788
+ ? 2
789
+ : normalizedFan === 3 || normalizedFan === "3" || normalizedFan === "chamber"
790
+ ? 3
791
+ : null;
792
+ if (fanId === null) {
793
+ throw new Error("Unsupported fan. Use one of: part, auxiliary, chamber, 1, 2, 3.");
794
+ }
795
+ const targetSpeed = Math.round(speed);
796
+ if (targetSpeed < 0 || targetSpeed > 100) {
797
+ throw new Error("Fan speed must be between 0 and 100 percent.");
798
+ }
799
+ await invokeWithoutAck(printer, new UpdateFanCommand({ fan: fanId, speed: targetSpeed }));
800
+ return {
801
+ status: "success",
802
+ message: `Fan speed command sent for fan ${fanId}.`,
803
+ fan: fanId,
804
+ speed: targetSpeed,
805
+ };
806
+ }
807
+ async setLight(host, serial, token, light, mode) {
808
+ const printer = await this.getPrinter(host, serial, token);
809
+ const normalizedLight = light.trim();
810
+ const normalizedMode = mode.trim().toLowerCase();
811
+ const validModes = new Set(["on", "off", "flashing"]);
812
+ if (!normalizedLight) {
813
+ throw new Error("Light node is required, for example: chamber_light.");
814
+ }
815
+ if (!validModes.has(normalizedMode)) {
816
+ throw new Error("Light mode must be one of: on, off, flashing.");
817
+ }
818
+ await invokeWithoutAck(printer, new UpdateLightCommand({
819
+ light: normalizedLight,
820
+ mode: normalizedMode,
821
+ }));
822
+ return {
823
+ status: "success",
824
+ message: `Light command sent for ${normalizedLight}.`,
825
+ light: normalizedLight,
826
+ mode: normalizedMode,
827
+ };
828
+ }
829
+ async setAmsDrying(host, serial, token, action, amsId) {
830
+ const printer = await this.getPrinter(host, serial, token);
831
+ const normalizedAction = action.trim().toLowerCase();
832
+ if (normalizedAction !== "start" && normalizedAction !== "stop") {
833
+ throw new Error("AMS drying action must be one of: start, stop.");
834
+ }
835
+ const normalizedAmsId = Math.trunc(amsId);
836
+ if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
837
+ throw new Error("ams_id must be an integer from 0 to 3.");
838
+ }
839
+ const param = normalizedAction === "start" ? "start_drying" : "stop_drying";
840
+ await printer.publish({
841
+ print: {
842
+ command: "ams_control",
843
+ ams_id: normalizedAmsId,
844
+ param,
845
+ sequence_id: "0",
846
+ },
847
+ });
848
+ await sleep(COMMAND_SETTLE_MS);
849
+ const label = normalizedAction === "start" ? "started" : "stopped";
850
+ return {
851
+ status: "success",
852
+ message: `AMS drying ${label} for AMS ${normalizedAmsId}.`,
853
+ action: normalizedAction,
854
+ ams_id: normalizedAmsId,
855
+ };
856
+ }
857
+ async skipObjects(host, serial, token, objectIds) {
858
+ const printer = await this.getPrinter(host, serial, token);
859
+ const normalizedObjectIds = objectIds
860
+ .map((id) => Math.trunc(id))
861
+ .filter((id) => Number.isInteger(id) && id >= 0);
862
+ if (normalizedObjectIds.length === 0) {
863
+ throw new Error("At least one non-negative object id is required.");
864
+ }
865
+ await printer.publish({
866
+ print: {
867
+ sequence_id: "0",
868
+ command: "skip_objects",
869
+ obj_list: normalizedObjectIds,
870
+ },
871
+ });
872
+ await sleep(COMMAND_SETTLE_MS);
873
+ return {
874
+ status: "success",
875
+ message: "Skip objects command sent.",
876
+ object_ids: normalizedObjectIds,
877
+ };
878
+ }
241
879
  async getFiles(host, serial, token) {
242
880
  const printer = new BambuPrinter(host, serial, token);
243
881
  const directories = ["cache", "timelapse", "logs"];
@@ -302,7 +940,8 @@ export class BambuImplementation {
302
940
  return response;
303
941
  }
304
942
  async startJob(host, serial, token, filename) {
305
- if (filename.toLowerCase().endsWith(".3mf")) {
943
+ const lower = filename.toLowerCase();
944
+ if (lower.endsWith(".3mf") && !lower.endsWith(".gcode.3mf")) {
306
945
  throw new Error("Use print_3mf for .3mf project files.");
307
946
  }
308
947
  const printer = await this.getPrinter(host, serial, token);
@@ -310,13 +949,314 @@ export class BambuImplementation {
310
949
  const remoteFile = normalizedFileName.includes("/")
311
950
  ? normalizedFileName
312
951
  : `cache/${normalizedFileName}`;
313
- await printer.executeCommand(new GCodeFileCommand({ fileName: remoteFile }));
952
+ await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteFile }));
314
953
  return {
315
954
  status: "success",
316
955
  message: `Start command sent for ${remoteFile}.`,
317
956
  file: remoteFile,
318
957
  };
319
958
  }
959
+ /**
960
+ * Capture a single JPEG frame from the printer's chamber camera.
961
+ *
962
+ * Protocol per https://github.com/Doridian/OpenBambuAPI/blob/main/video.md
963
+ *
964
+ * Connect TLS to <host>:6000 (self-signed cert -- skip verification).
965
+ * Send an 80-byte auth packet:
966
+ * [0..4] uint32 LE payload size = 0x40 (64)
967
+ * [4..8] uint32 LE type = 0x3000
968
+ * [8..12] uint32 LE flags = 0
969
+ * [12..16] uint32 LE 0
970
+ * [16..48] "bblp" + null padding to 32 bytes
971
+ * [48..80] access token + null padding to 32 bytes
972
+ *
973
+ * The server then streams frames as repeating:
974
+ * [0..4] uint32 LE payload size
975
+ * [4..8] uint32 LE itrack (0)
976
+ * [8..12] uint32 LE flags (1)
977
+ * [12..16] uint32 LE 0
978
+ * [16..16+payloadSize] JPEG (FF D8 ... FF D9)
979
+ *
980
+ * Verified models per upstream docs: A1, A1 mini, P1S, P1P. X1/X1C/X1E
981
+ * and P2S use RTSP on port 322 instead. H2/H2S/H2D/H2C
982
+ * are not documented; we fail fast rather than guess at the protocol.
983
+ *
984
+ * Read-only; no confirm gate. Default 8s timeout for cold-start latency.
985
+ */
986
+ async cameraSnapshot(host, _serial, token, options = {}) {
987
+ const timeoutMs = options.timeoutMs ?? 8000;
988
+ const model = (options.bambuModel ?? "").toLowerCase();
989
+ // P1/A1 series still use the proprietary TCP-on-6000 framed JPEG path
990
+ // (per https://github.com/Doridian/OpenBambuAPI/blob/main/video.md).
991
+ const TCP_CAMERA_MODELS = new Set(["a1", "a1mini", "p1s", "p1p"]);
992
+ // X1, P2S, AND H2 (H2S/H2D/H2C) all use RTSP on port 322. The
993
+ // OpenBambuAPI doc only mentions X1/P2S, but the HA bambulab
994
+ // integration's models.py shows the printer reports its own
995
+ // `ipcam.rtsp_url` for these models, and Parker (H2S) rejects the
996
+ // A1/P1 80-byte auth packet on port 6000 (verified 2026-04-27 --
997
+ // see PROGRESS.md "H2 probe results").
998
+ const RTSP_MODELS = new Set([
999
+ "x1", "x1c", "x1carbon", "x1e", "p2s",
1000
+ "h2", "h2s", "h2d", "h2c", "h2dpro",
1001
+ ]);
1002
+ if (!model) {
1003
+ throw new Error("camera_snapshot requires bambu_model or BAMBU_MODEL so it can choose the correct Bambu camera protocol.");
1004
+ }
1005
+ if (RTSP_MODELS.has(model)) {
1006
+ const jpeg = await this.fetchRtspCameraFrame(host, token, timeoutMs, options.ffmpegPath);
1007
+ const result = {
1008
+ status: "success",
1009
+ format: "image/jpeg",
1010
+ sizeBytes: jpeg.length,
1011
+ base64: jpeg.toString("base64"),
1012
+ transport: "rtsps-322",
1013
+ };
1014
+ if (options.savePath) {
1015
+ const fsSync = await import("node:fs");
1016
+ fsSync.writeFileSync(options.savePath, jpeg);
1017
+ result.savedTo = options.savePath;
1018
+ }
1019
+ return result;
1020
+ }
1021
+ if (model && !TCP_CAMERA_MODELS.has(model)) {
1022
+ throw new Error(`camera_snapshot: model "${model}" is not a known Bambu Lab printer model. Supported: ${[...TCP_CAMERA_MODELS, ...RTSP_MODELS].sort().join(", ")}`);
1023
+ }
1024
+ const jpeg = await this.fetchTcpCameraFrame(host, token, timeoutMs);
1025
+ const result = {
1026
+ status: "success",
1027
+ format: "image/jpeg",
1028
+ sizeBytes: jpeg.length,
1029
+ base64: jpeg.toString("base64"),
1030
+ transport: "tcp-6000",
1031
+ };
1032
+ if (options.savePath) {
1033
+ const fsSync = await import("node:fs");
1034
+ fsSync.writeFileSync(options.savePath, jpeg);
1035
+ result.savedTo = options.savePath;
1036
+ }
1037
+ return result;
1038
+ }
1039
+ /**
1040
+ * Pull a single JPEG frame from the printer's RTSP/RTSPS stream using
1041
+ * ffmpeg. Used for X1, P2S, and H2 series.
1042
+ *
1043
+ * URL pattern verified against HA bambulab's models.py example:
1044
+ * rtsps://bblp:<access_code>@<host>:322/streaming/live/1
1045
+ *
1046
+ * ffmpeg invocation:
1047
+ * ffmpeg -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
1048
+ *
1049
+ * -rtsp_transport tcp avoids UDP NAT/firewall issues. -frames:v 1
1050
+ * makes ffmpeg exit as soon as one frame lands. -y overwrites the temp
1051
+ * file. The Bambu printer presents a self-signed cert; ffmpeg's TLS
1052
+ * layer accepts that by default (no host verification).
1053
+ */
1054
+ async fetchRtspCameraFrame(host, token, timeoutMs, ffmpegPath) {
1055
+ const fsSync = await import("node:fs");
1056
+ const os = await import("node:os");
1057
+ const pathMod = await import("node:path");
1058
+ const { spawn } = await import("node:child_process");
1059
+ const tmpOut = pathMod.join(os.tmpdir(), `bambu-snap-${Date.now()}-${Math.random().toString(16).slice(2)}.jpg`);
1060
+ const url = `rtsps://bblp:${encodeURIComponent(token)}@${host}:322/streaming/live/1`;
1061
+ const bin = ffmpegPath ?? "ffmpeg";
1062
+ // Note: ffmpeg's `-stimeout` was removed in 8.0 and renamed across the
1063
+ // 5.x/6.x line; we rely on the outer kill timer instead so we don't
1064
+ // have to detect ffmpeg version. -rtsp_transport tcp avoids UDP NAT
1065
+ // headaches; -frames:v 1 makes ffmpeg exit on first frame.
1066
+ const args = [
1067
+ "-rtsp_transport", "tcp",
1068
+ "-i", url,
1069
+ "-frames:v", "1",
1070
+ "-f", "image2",
1071
+ "-c:v", "mjpeg",
1072
+ "-y",
1073
+ "-loglevel", "error",
1074
+ tmpOut,
1075
+ ];
1076
+ return new Promise((resolve, reject) => {
1077
+ let stderr = "";
1078
+ const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
1079
+ const killTimer = setTimeout(() => {
1080
+ try {
1081
+ proc.kill("SIGKILL");
1082
+ }
1083
+ catch { /* ignore */ }
1084
+ reject(new Error(`camera_snapshot: ffmpeg timed out after ${timeoutMs}ms`));
1085
+ }, timeoutMs + 1000);
1086
+ proc.stderr.on("data", (d) => { stderr += d.toString(); });
1087
+ proc.on("error", (err) => {
1088
+ clearTimeout(killTimer);
1089
+ if (err.code === "ENOENT") {
1090
+ reject(new Error(`camera_snapshot: ffmpeg binary not found at "${bin}". Install with \`brew install ffmpeg\` or pass ffmpegPath.`));
1091
+ }
1092
+ else {
1093
+ reject(err);
1094
+ }
1095
+ });
1096
+ proc.on("close", (code) => {
1097
+ clearTimeout(killTimer);
1098
+ if (code !== 0) {
1099
+ // Strip access code from error messages so we don't leak credentials.
1100
+ const safeStderr = stderr.split(token).join("<token-redacted>").trim();
1101
+ reject(new Error(`camera_snapshot: ffmpeg exited ${code}. stderr: ${safeStderr.slice(-1000)}`));
1102
+ try {
1103
+ fsSync.unlinkSync(tmpOut);
1104
+ }
1105
+ catch { /* ignore */ }
1106
+ return;
1107
+ }
1108
+ try {
1109
+ const jpeg = fsSync.readFileSync(tmpOut);
1110
+ fsSync.unlinkSync(tmpOut);
1111
+ if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) {
1112
+ reject(new Error("camera_snapshot: ffmpeg produced output that does not start with JPEG SOI"));
1113
+ return;
1114
+ }
1115
+ resolve(jpeg);
1116
+ }
1117
+ catch (err) {
1118
+ reject(err);
1119
+ }
1120
+ });
1121
+ });
1122
+ }
1123
+ /**
1124
+ * Open the TLS-on-6000 socket, send the 80-byte auth packet, and read
1125
+ * a single complete JPEG frame. Returns the JPEG bytes.
1126
+ */
1127
+ async fetchTcpCameraFrame(host, token, timeoutMs) {
1128
+ const tls = await import("node:tls");
1129
+ const auth = Buffer.alloc(80, 0);
1130
+ auth.writeUInt32LE(0x40, 0); // payload size
1131
+ auth.writeUInt32LE(0x3000, 4); // type
1132
+ // flags=0, reserved=0 are already zero from Buffer.alloc.
1133
+ auth.write("bblp", 16, 4, "ascii");
1134
+ auth.write(token, 48, Math.min(32, Buffer.byteLength(token, "ascii")), "ascii");
1135
+ return new Promise((resolve, reject) => {
1136
+ const chunks = [];
1137
+ let totalLen = 0;
1138
+ const FRAME_HEADER_BYTES = 16;
1139
+ let payloadSize = null;
1140
+ let settled = false;
1141
+ const finish = (err, jpeg) => {
1142
+ if (settled)
1143
+ return;
1144
+ settled = true;
1145
+ clearTimeout(timer);
1146
+ socket.destroy();
1147
+ if (err)
1148
+ reject(err);
1149
+ else if (jpeg)
1150
+ resolve(jpeg);
1151
+ else
1152
+ reject(new Error("camera_snapshot: ended without jpeg payload"));
1153
+ };
1154
+ const timer = setTimeout(() => finish(new Error(`camera_snapshot: timed out after ${timeoutMs}ms`)), timeoutMs);
1155
+ const socket = tls.connect({
1156
+ host,
1157
+ port: 6000,
1158
+ rejectUnauthorized: false,
1159
+ // The printer uses TLS for confidentiality but presents a self-signed cert.
1160
+ // Same trust posture as the FTPS path (basic-ftp with rejectUnauthorized: false).
1161
+ }, () => {
1162
+ socket.write(auth);
1163
+ });
1164
+ socket.on("data", (data) => {
1165
+ chunks.push(data);
1166
+ totalLen += data.length;
1167
+ if (payloadSize === null && totalLen >= FRAME_HEADER_BYTES) {
1168
+ const merged = Buffer.concat(chunks, totalLen);
1169
+ payloadSize = merged.readUInt32LE(0);
1170
+ if (payloadSize <= 0 || payloadSize > 5000000) {
1171
+ finish(new Error(`camera_snapshot: unreasonable payload size ${payloadSize} from header; auth likely failed.`));
1172
+ return;
1173
+ }
1174
+ // Reset chunk list to remaining bytes after the header.
1175
+ const remainder = merged.subarray(FRAME_HEADER_BYTES);
1176
+ chunks.length = 0;
1177
+ chunks.push(remainder);
1178
+ totalLen = remainder.length;
1179
+ }
1180
+ if (payloadSize !== null && totalLen >= payloadSize) {
1181
+ const merged = Buffer.concat(chunks, totalLen);
1182
+ const jpeg = merged.subarray(0, payloadSize);
1183
+ if (jpeg[0] !== 0xff || jpeg[1] !== 0xd8) {
1184
+ finish(new Error(`camera_snapshot: payload does not start with JPEG SOI (FF D8); got ${jpeg[0].toString(16)} ${jpeg[1].toString(16)}.`));
1185
+ return;
1186
+ }
1187
+ finish(null, jpeg);
1188
+ }
1189
+ });
1190
+ socket.on("error", (err) => finish(err));
1191
+ socket.on("end", () => finish(new Error("camera_snapshot: connection ended before a full JPEG frame arrived")));
1192
+ });
1193
+ }
1194
+ /**
1195
+ * Delete a single file from the printer's SD card via FTPS.
1196
+ *
1197
+ * Destructive. Caller MUST set confirm=true; otherwise we return without
1198
+ * touching the printer. Path is normalized the same way uploadFile()
1199
+ * normalizes -- if the caller passes a bare filename, we look in cache/.
1200
+ * Path traversal (`..`) is rejected.
1201
+ *
1202
+ * Only the printer-managed directories (cache/, timelapse/, logs/) are
1203
+ * accepted as parents to avoid letting an agent wander further into the
1204
+ * filesystem than expected.
1205
+ */
1206
+ async deleteFile(host, _serial, token, filename, confirm) {
1207
+ if (confirm !== true) {
1208
+ return {
1209
+ status: "skipped",
1210
+ deleted: false,
1211
+ remotePath: filename,
1212
+ message: "delete_printer_file requires confirm:true. No FTP request was made.",
1213
+ };
1214
+ }
1215
+ const normalizedFileName = filename.replace(/^\/+/, "");
1216
+ if (normalizedFileName.length === 0) {
1217
+ throw new Error("delete_printer_file: filename is required.");
1218
+ }
1219
+ if (normalizedFileName.split("/").some((seg) => seg === "..")) {
1220
+ throw new Error(`delete_printer_file: path traversal segments are not allowed (got "${filename}").`);
1221
+ }
1222
+ const remotePath = normalizedFileName.includes("/")
1223
+ ? normalizedFileName
1224
+ : `cache/${normalizedFileName}`;
1225
+ const topDir = remotePath.split("/")[0];
1226
+ const ALLOWED_DIRS = new Set(["cache", "timelapse", "logs"]);
1227
+ if (!ALLOWED_DIRS.has(topDir)) {
1228
+ throw new Error(`delete_printer_file: refusing to delete outside cache/, timelapse/, logs/. Got "${remotePath}".`);
1229
+ }
1230
+ await this.ftpDelete(host, token, `/${remotePath}`);
1231
+ return {
1232
+ status: "success",
1233
+ deleted: true,
1234
+ remotePath,
1235
+ };
1236
+ }
1237
+ /**
1238
+ * Delete a single remote file via FTPS, using basic-ftp directly so we
1239
+ * get the same TLS-session-ticket handshake as ftpUpload().
1240
+ */
1241
+ async ftpDelete(host, token, remotePath) {
1242
+ const client = new FTPClient(15000);
1243
+ try {
1244
+ await client.access({
1245
+ host,
1246
+ port: 990,
1247
+ user: "bblp",
1248
+ password: token,
1249
+ secure: "implicit",
1250
+ secureOptions: ftpsSecureOptions(),
1251
+ });
1252
+ await this.waitForTlsSession(client);
1253
+ const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
1254
+ await client.remove(absoluteRemote);
1255
+ }
1256
+ finally {
1257
+ client.close();
1258
+ }
1259
+ }
320
1260
  /**
321
1261
  * Upload a file to the printer via FTP using basic-ftp directly.
322
1262
  * Bypasses bambu-js's sendFile which has a double-path bug (ensureDir CDs
@@ -324,7 +1264,7 @@ export class BambuImplementation {
324
1264
  * again, resulting in e.g. /cache/cache/file.3mf).
325
1265
  */
326
1266
  async ftpUpload(host, token, localPath, remotePath) {
327
- const client = new FTPClient(30000);
1267
+ const client = new FTPClient(15000);
328
1268
  try {
329
1269
  await client.access({
330
1270
  host,
@@ -332,8 +1272,14 @@ export class BambuImplementation {
332
1272
  user: "bblp",
333
1273
  password: token,
334
1274
  secure: "implicit",
335
- secureOptions: { rejectUnauthorized: false },
1275
+ secureOptions: ftpsSecureOptions(),
336
1276
  });
1277
+ // With TLS 1.3 the session ticket arrives asynchronously; basic-ftp calls
1278
+ // getSession() when opening the data channel and gets undefined if the
1279
+ // ticket hasn't arrived yet, causing a fresh TLS negotiation that Bambu
1280
+ // printers reject. Wait for the session ticket before proceeding.
1281
+ await this.waitForTlsSession(client);
1282
+ // Use absolute path to avoid CWD side-effects
337
1283
  const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
338
1284
  const fileData = await fs.readFile(localPath);
339
1285
  await client.uploadFrom(Readable.from(fileData), absoluteRemote);
@@ -342,6 +1288,20 @@ export class BambuImplementation {
342
1288
  client.close();
343
1289
  }
344
1290
  }
1291
+ async waitForTlsSession(ftpClient) {
1292
+ const socket = ftpClient.ftp?.socket;
1293
+ if (!socket || typeof socket.getSession !== "function")
1294
+ return;
1295
+ if (socket.getSession())
1296
+ return;
1297
+ await new Promise((resolve) => {
1298
+ const timeout = setTimeout(resolve, 1000);
1299
+ socket.once("session", () => {
1300
+ clearTimeout(timeout);
1301
+ resolve();
1302
+ });
1303
+ });
1304
+ }
345
1305
  async disconnectAll() {
346
1306
  await this.printerStore.disconnectAll();
347
1307
  }