bambu-printer-mcp 1.0.5 → 1.1.2

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,17 +1,59 @@
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
9
  import { BambuPrinter } from "bambu-js";
10
+ import * as mqtt from "mqtt";
7
11
  import {
8
12
  BambuClient,
9
13
  GCodeFileCommand,
10
14
  GCodeLineCommand,
11
15
  PushAllCommand,
16
+ UpdateFanCommand,
17
+ UpdateLightCommand,
12
18
  UpdateStateCommand,
13
19
  } from "bambu-node";
14
20
 
21
+ /**
22
+ * Post-Jan-2025 H2D firmware requires mTLS with a Bambu-issued client cert.
23
+ * Loads cert+key once from:
24
+ * - BAMBU_CLIENT_CERT / BAMBU_CLIENT_KEY env vars (paths), or
25
+ * - ~/Desktop/bambu certs/embedded-cert.pem + embedded-key.pem (default)
26
+ * Returns null if files missing — caller falls back to no-cert TLS.
27
+ */
28
+ function loadClientCreds(): { cert: Buffer; key: Buffer } | null {
29
+ const defaultDir = path.join(os.homedir(), "Desktop", "bambu certs");
30
+ const certPath = process.env.BAMBU_CLIENT_CERT || path.join(defaultDir, "embedded-cert.pem");
31
+ const keyPath = process.env.BAMBU_CLIENT_KEY || path.join(defaultDir, "embedded-key.pem");
32
+ try {
33
+ if (!existsSync(certPath) || !existsSync(keyPath)) return null;
34
+ return { cert: readFileSync(certPath), key: readFileSync(keyPath) };
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ const CLIENT_CREDS = loadClientCreds();
41
+
42
+ const COMMAND_SETTLE_MS = 300;
43
+
44
+ const MODEL_ID_TO_NAME: Record<string, string> = {
45
+ O1D: "H2D",
46
+ O1E: "H2D Pro",
47
+ O1S: "H2S",
48
+ N2S: "A1",
49
+ A1M: "A1 Mini",
50
+ C11: "P1P",
51
+ C12: "P1S",
52
+ "BL-P001": "X1C",
53
+ "BL-P002": "X1",
54
+ C13: "X1E",
55
+ };
56
+
15
57
  interface BambuPrintOptionsInternal {
16
58
  projectName: string;
17
59
  filePath: string;
@@ -24,6 +66,15 @@ interface BambuPrintOptionsInternal {
24
66
  layerInspect?: boolean;
25
67
  timelapse?: boolean;
26
68
  amsMapping?: number[];
69
+ /**
70
+ * Per-used-filament absolute tray index, one entry per position in the
71
+ * selected plate's `filament_ids`. Example: plate uses project filament 1
72
+ * only, and you want to pull from AMS 0 tray 1 -> pass `[1]`. The server
73
+ * expands this into the project-level `ams_mapping` array at the right
74
+ * position automatically (H2-series). Preferred over `amsMapping` for
75
+ * ergonomic callers; `amsMapping` takes precedence if both are set.
76
+ */
77
+ amsSlots?: number[];
27
78
  md5?: string;
28
79
  }
29
80
 
@@ -31,14 +82,240 @@ interface ProjectFileMetadata {
31
82
  plateFileName: string;
32
83
  plateInternalPath: string;
33
84
  md5: string;
85
+ /** Number of filament slots declared at project level (length the H2
86
+ * firmware expects for `ams_mapping`). Parsed from the gcode header
87
+ * `; filament_colour = ...` list. */
88
+ projectFilamentCount: number;
89
+ /** 0-based positions in the project filament list that the selected plate
90
+ * actually uses, from `Metadata/plate_<n>.json.filament_ids`. For a
91
+ * single-filament cube that pulls only project filament 1, this is `[1]`. */
92
+ usedFilamentPositions: number[];
93
+ }
94
+
95
+ function sleep(ms: number): Promise<void> {
96
+ return new Promise((resolve) => setTimeout(resolve, ms));
97
+ }
98
+
99
+ function resolveModelName(data: Record<string, any>): string {
100
+ const modelId = `${data?.model_id ?? ""}`.toUpperCase();
101
+ return (
102
+ MODEL_ID_TO_NAME[modelId] ||
103
+ data?.model ||
104
+ data?.device?.devModel ||
105
+ data?.device?.dev_model ||
106
+ "Unknown"
107
+ );
108
+ }
109
+
110
+ async function invokeWithoutAck(printer: BambuClient, command: { invoke(client: BambuClient): Promise<void> }) {
111
+ await command.invoke(printer);
112
+ await sleep(COMMAND_SETTLE_MS);
113
+ }
114
+
115
+ function getPrinterKey(host: string, serial: string, token: string): string {
116
+ return `${host}-${serial}-${token}`;
117
+ }
118
+
119
+ class TolerantBambuClient extends BambuClient {
120
+ /**
121
+ * H2D-class firmware streams push status immediately after subscribe, but
122
+ * never answers bambu-node's initial get_version round-trip. Avoid treating
123
+ * that missing ACK as a failed connection.
124
+ */
125
+ protected override async onConnect(): Promise<void> {
126
+ const subscribe = (this as any).subscribe.bind(this);
127
+ await subscribe(`device/${this.config.serialNumber}/report`);
128
+ }
129
+
130
+ /**
131
+ * H2S/H2D printers don't respond to get_version with module info.
132
+ * Infer model from serial number prefix so downstream code (Job, status
133
+ * parsing, etc.) has a valid printerModel instead of undefined.
134
+ */
135
+ private inferModelFromSerial(): string | undefined {
136
+ const sn = this.config.serialNumber;
137
+ if (sn.startsWith("093")) return "H2S";
138
+ if (sn.startsWith("094")) return "H2D";
139
+ if (sn.startsWith("00M")) return "X1C";
140
+ if (sn.startsWith("00W")) return "X1";
141
+ if (sn.startsWith("03W")) return "X1E";
142
+ if (sn.startsWith("01S")) return "P1P";
143
+ if (sn.startsWith("01P")) return "P1S";
144
+ if (sn.startsWith("030")) return "A1";
145
+ if (sn.startsWith("039")) return "A1M";
146
+ return undefined;
147
+ }
148
+
149
+ /**
150
+ * Override bambu-node's MQTT connect to pass a client cert+key for mTLS.
151
+ * Post-Jan-2025 H2D firmware rejects TLS handshakes without a valid Bambu-
152
+ * issued client certificate. Options mirror the upstream implementation
153
+ * plus `cert`/`key` when creds are available.
154
+ */
155
+ override async connect(): Promise<[void]> {
156
+ await new Promise<void>((resolve, reject) => {
157
+ const self: any = this;
158
+ if (self.mqttClient) {
159
+ throw new Error("Can't establish a new connection while running another one!");
160
+ }
161
+ const tlsOpts: Record<string, unknown> = {
162
+ username: "bblp",
163
+ password: self.config.accessToken,
164
+ reconnectPeriod: self.clientOptions.reconnectInterval,
165
+ connectTimeout: self.clientOptions.connectTimeout,
166
+ keepalive: self.clientOptions.keepAlive,
167
+ resubscribe: true,
168
+ rejectUnauthorized: false,
169
+ };
170
+ if (CLIENT_CREDS) {
171
+ tlsOpts.cert = CLIENT_CREDS.cert;
172
+ tlsOpts.key = CLIENT_CREDS.key;
173
+ }
174
+ const client = mqtt.connect(
175
+ `mqtts://${self.config.host}:${self.config.port}`,
176
+ tlsOpts as any
177
+ );
178
+ self.mqttClient = client;
179
+ client.on("connect", async (...args: any[]) => {
180
+ try {
181
+ await self.onConnect(...args);
182
+ self.emit("client:connect");
183
+ resolve();
184
+ } catch (e) {
185
+ reject(e);
186
+ }
187
+ });
188
+ client.on("disconnect", () => {
189
+ self.emit("client:disconnect", false);
190
+ self.emit("printer:statusUpdate", self._printerStatus, "OFFLINE");
191
+ self._printerStatus = "OFFLINE";
192
+ if (self.currentJob) self.emit("job:pause", self.currentJob, true);
193
+ });
194
+ client.on("offline", () => {
195
+ self.emit("client:disconnect", true);
196
+ self.emit("printer:statusUpdate", self._printerStatus, "OFFLINE");
197
+ self._printerStatus = "OFFLINE";
198
+ if (self.currentJob) self.emit("job:pause", self.currentJob, true);
199
+ });
200
+ client.on("message", (topic: string, payload: Buffer) =>
201
+ self.emit("rawMessage", topic, payload)
202
+ );
203
+ client.on("error", (err: Error) => {
204
+ self.emit("client:error", err);
205
+ reject(err);
206
+ });
207
+ });
208
+ // H2S/H2D printers don't respond to get_version with module info.
209
+ // Infer model from serial number prefix so downstream code works.
210
+ if (!this.data.model) {
211
+ const inferred = this.inferModelFromSerial();
212
+ if (inferred) {
213
+ (this.data as any).model = inferred;
214
+ this.emit("printer:dataUpdate", this.data, { model: inferred } as any);
215
+ }
216
+ }
217
+ return [undefined as unknown as void];
218
+ }
219
+ }
220
+
221
+ /** Build FTPS secureOptions that include the client cert+key when available. */
222
+ function ftpsSecureOptions(): Record<string, unknown> {
223
+ const opts: Record<string, unknown> = { rejectUnauthorized: false };
224
+ if (CLIENT_CREDS) {
225
+ opts.cert = CLIENT_CREDS.cert;
226
+ opts.key = CLIENT_CREDS.key;
227
+ }
228
+ return opts;
34
229
  }
35
230
 
36
231
  class BambuClientStore {
37
232
  private printers: Map<string, BambuClient> = new Map();
38
233
  private initialConnectionPromises: Map<string, Promise<void>> = new Map();
234
+ private reportSnapshots: Map<string, Record<string, any>> = new Map();
235
+ private initialReportPromises: Map<string, Promise<void>> = new Map();
236
+ private initialReportResolvers: Map<string, () => void> = new Map();
237
+
238
+ private ensureInitialReportPromise(key: string): Promise<void> {
239
+ const existing = this.initialReportPromises.get(key);
240
+ if (existing) {
241
+ return existing;
242
+ }
243
+
244
+ const promise = new Promise<void>((resolve) => {
245
+ this.initialReportResolvers.set(key, resolve);
246
+ });
247
+ this.initialReportPromises.set(key, promise);
248
+ return promise;
249
+ }
250
+
251
+ private resolveInitialReport(key: string): void {
252
+ const resolve = this.initialReportResolvers.get(key);
253
+ if (!resolve) {
254
+ return;
255
+ }
256
+
257
+ this.initialReportResolvers.delete(key);
258
+ resolve();
259
+ }
260
+
261
+ private updateReportSnapshot(key: string, update: Record<string, any>): void {
262
+ if (!update || Object.keys(update).length === 0) {
263
+ return;
264
+ }
265
+
266
+ const previous = this.reportSnapshots.get(key) || {};
267
+ this.reportSnapshots.set(key, { ...previous, ...update });
268
+ this.resolveInitialReport(key);
269
+ }
270
+
271
+ private clearPrinterState(key: string): void {
272
+ this.printers.delete(key);
273
+ this.initialConnectionPromises.delete(key);
274
+ this.reportSnapshots.delete(key);
275
+ this.initialReportPromises.delete(key);
276
+ this.initialReportResolvers.delete(key);
277
+ }
278
+
279
+ getCachedReport(host: string, serial: string, token: string): Record<string, any> | null {
280
+ return this.reportSnapshots.get(getPrinterKey(host, serial, token)) || null;
281
+ }
282
+
283
+ async waitForInitialReport(
284
+ host: string,
285
+ serial: string,
286
+ token: string,
287
+ timeoutMs = 4000
288
+ ): Promise<Record<string, any> | null> {
289
+ const key = getPrinterKey(host, serial, token);
290
+ const existing = this.reportSnapshots.get(key);
291
+
292
+ if (!existing || Object.keys(existing).length === 0) {
293
+ // No MQTT data yet — wait for the first push.
294
+ const reportPromise = this.ensureInitialReportPromise(key);
295
+ try {
296
+ await Promise.race([
297
+ reportPromise,
298
+ sleep(timeoutMs).then(() => {
299
+ throw new Error(`Timed out waiting for initial printer report after ${timeoutMs}ms.`);
300
+ }),
301
+ ]);
302
+ } catch (error) {
303
+ console.warn(`No initial printer report received for ${serial}:`, error);
304
+ }
305
+ }
306
+
307
+ // Short settle: the first MQTT push from the printer is a sparse "hello"
308
+ // with only model/modules. A second push carrying the full status
309
+ // (gcode_state, ams, hms, temperatures, fans, etc.) arrives afterward
310
+ // and gets merged into reportSnapshots via incremental update.
311
+ // This settle runs regardless of whether data arrived before or during
312
+ // the promise race above, so the merge has time to complete.
313
+ await sleep(500);
314
+ return this.reportSnapshots.get(key) || null;
315
+ }
39
316
 
40
317
  async getPrinter(host: string, serial: string, token: string): Promise<BambuClient> {
41
- const key = `${host}-${serial}`;
318
+ const key = getPrinterKey(host, serial, token);
42
319
 
43
320
  if (this.printers.has(key)) {
44
321
  return this.printers.get(key)!;
@@ -52,11 +329,28 @@ class BambuClientStore {
52
329
  throw new Error(`Existing Bambu client connection for ${key} failed.`);
53
330
  }
54
331
 
55
- const printer = new BambuClient({
332
+ const printer = new TolerantBambuClient({
56
333
  host,
57
334
  serialNumber: serial,
58
335
  accessToken: token,
59
336
  });
337
+ this.ensureInitialReportPromise(key);
338
+
339
+ printer.on("rawMessage", (_topic, payload) => {
340
+ try {
341
+ const parsed = JSON.parse(payload.toString());
342
+ const printMessage = parsed?.print;
343
+ if (printMessage && typeof printMessage === "object") {
344
+ this.updateReportSnapshot(key, printMessage);
345
+ }
346
+ } catch {
347
+ // Ignore unrelated payloads.
348
+ }
349
+ });
350
+
351
+ printer.on("printer:dataUpdate", (data) => {
352
+ this.updateReportSnapshot(key, data);
353
+ });
60
354
 
61
355
  printer.on("client:connect", () => {
62
356
  this.printers.set(key, printer);
@@ -64,13 +358,11 @@ class BambuClientStore {
64
358
  });
65
359
 
66
360
  printer.on("client:error", () => {
67
- this.printers.delete(key);
68
- this.initialConnectionPromises.delete(key);
361
+ this.clearPrinterState(key);
69
362
  });
70
363
 
71
364
  printer.on("client:disconnect", () => {
72
- this.printers.delete(key);
73
- this.initialConnectionPromises.delete(key);
365
+ this.clearPrinterState(key);
74
366
  });
75
367
 
76
368
  const connectPromise = printer.connect().then(() => {});
@@ -80,7 +372,7 @@ class BambuClientStore {
80
372
  await connectPromise;
81
373
  return printer;
82
374
  } catch (error) {
83
- this.initialConnectionPromises.delete(key);
375
+ this.clearPrinterState(key);
84
376
  throw error;
85
377
  }
86
378
  }
@@ -103,6 +395,9 @@ class BambuClientStore {
103
395
  await Promise.allSettled(disconnectPromises);
104
396
  this.printers.clear();
105
397
  this.initialConnectionPromises.clear();
398
+ this.reportSnapshots.clear();
399
+ this.initialReportPromises.clear();
400
+ this.initialReportResolvers.clear();
106
401
  }
107
402
  }
108
403
 
@@ -155,10 +450,51 @@ export class BambuImplementation {
155
450
  const gcodeBuffer = await selectedEntry.async("nodebuffer");
156
451
  const md5 = createHash("md5").update(gcodeBuffer).digest("hex");
157
452
 
453
+ // Project filament count: parse the gcode header line
454
+ // `; filament_colour = #FFFFFF;#FF911A80;#DCF478;#DCF478`
455
+ // This is the authoritative source -- it always reflects the slicer's
456
+ // project filament list length. We only scan the first ~32KB of the
457
+ // gcode to keep this cheap even on large plates.
458
+ let projectFilamentCount = 1;
459
+ const head = gcodeBuffer.slice(0, 32 * 1024).toString("utf8");
460
+ const colourLine = head.match(/;\s*filament_colour\s*=\s*([^\n\r]+)/i);
461
+ if (colourLine) {
462
+ projectFilamentCount = colourLine[1].split(";").filter((s) => s.trim()).length;
463
+ } else {
464
+ // Fallback: count filament_ids header entries.
465
+ const idsLine = head.match(/;\s*filament_ids\s*=\s*([^\n\r]+)/i);
466
+ if (idsLine) {
467
+ projectFilamentCount = idsLine[1].split(";").filter((s) => s.trim()).length;
468
+ }
469
+ }
470
+
471
+ // Used filament positions: from Metadata/plate_<n>.json.filament_ids.
472
+ // These are 0-based positions into the project filament list that the
473
+ // selected plate actually consumes.
474
+ let usedFilamentPositions: number[] = [];
475
+ const plateJsonName = selectedEntry.name.replace(/\.gcode$/i, ".json");
476
+ const plateJsonEntry = zip.file(plateJsonName);
477
+ if (plateJsonEntry) {
478
+ try {
479
+ const raw = await plateJsonEntry.async("string");
480
+ const json = JSON.parse(raw);
481
+ if (Array.isArray(json.filament_ids)) {
482
+ usedFilamentPositions = json.filament_ids
483
+ .filter((n: unknown) => Number.isInteger(n))
484
+ .map((n: number) => n);
485
+ }
486
+ } catch {
487
+ // tolerate malformed plate_N.json -- caller can pass amsMapping directly
488
+ }
489
+ }
490
+ if (usedFilamentPositions.length === 0) usedFilamentPositions = [0];
491
+
158
492
  return {
159
493
  plateFileName: path.posix.basename(selectedEntry.name),
160
494
  plateInternalPath: selectedEntry.name,
161
495
  md5,
496
+ projectFilamentCount,
497
+ usedFilamentPositions,
162
498
  };
163
499
  }
164
500
 
@@ -167,16 +503,16 @@ export class BambuImplementation {
167
503
  const printer = await this.getPrinter(host, serial, token);
168
504
 
169
505
  try {
170
- await printer.executeCommand(new PushAllCommand());
506
+ await invokeWithoutAck(printer, new PushAllCommand());
171
507
  } catch (error) {
172
508
  console.warn("PushAllCommand failed, continuing with cached status", error);
173
509
  }
174
510
 
175
- if (!printer.data || Object.keys(printer.data).length === 0) {
176
- await new Promise((resolve) => setTimeout(resolve, 1500));
177
- }
178
-
179
- const data = printer.data;
511
+ const cachedData = await this.printerStore.waitForInitialReport(host, serial, token);
512
+ const data =
513
+ cachedData && Object.keys(cachedData).length > 0
514
+ ? cachedData
515
+ : printer.data;
180
516
 
181
517
  return {
182
518
  status: data.gcode_state || "UNKNOWN",
@@ -200,7 +536,7 @@ export class BambuImplementation {
200
536
  totalLayers: data.total_layer_num || 0,
201
537
  },
202
538
  ams: data.ams || null,
203
- model: data.model || "Unknown",
539
+ model: resolveModelName(data),
204
540
  serial,
205
541
  raw: data,
206
542
  };
@@ -220,55 +556,200 @@ export class BambuImplementation {
220
556
  throw new Error("print3mf requires a .3mf input file.");
221
557
  }
222
558
 
559
+ // Normalise remote filename: collapse double-extension artifacts like
560
+ // "Cube.gcode.3mf.gcode.3mf" -> "Cube.gcode.3mf" so firmware can identify
561
+ // the container format from the extension.
562
+ let remoteFileName = path.basename(options.filePath);
563
+ remoteFileName = remoteFileName.replace(/\.gcode\.3mf\.gcode\.3mf$/i, ".gcode.3mf");
564
+
565
+ // H2S/H2D land files at the FTP root and reference them via ftp:///<name>.
566
+ // P1/A1/X1 use /cache/<name> and file:///sdcard/cache/<name>.
567
+ const isH2 = serial.startsWith("093") || serial.startsWith("094");
568
+ const remoteProjectPath = isH2 ? remoteFileName : `cache/${remoteFileName}`;
569
+ const remoteUploadPath = isH2 ? `/${remoteFileName}` : `/cache/${remoteFileName}`;
570
+ const projectUrl = isH2
571
+ ? `ftp:///${remoteFileName}`
572
+ : `file:///sdcard/${remoteProjectPath}`;
573
+
574
+ // Upload via basic-ftp directly (bypasses bambu-js double-path bug)
575
+ await this.ftpUpload(host, token, options.filePath, remoteUploadPath);
576
+
577
+ // Pre-sliced .gcode.3mf files: routing depends on firmware generation.
578
+ // P1/A1/X1 series: project_file returns 405004002 for .gcode.3mf (firmware
579
+ // doesn't recognise the container), so use gcode_file instead.
580
+ // H2S/H2D: gcode_file is not supported; project_file works because the
581
+ // firmware can open the zip and find Metadata/plate_<n>.gcode directly.
582
+ if (options.filePath.toLowerCase().endsWith(".gcode.3mf")) {
583
+ if (!isH2) {
584
+ const printer = await this.getPrinter(host, serial, token);
585
+ await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
586
+ return {
587
+ status: "success",
588
+ message: `Uploaded and started gcode.3mf print: ${options.projectName}`,
589
+ remoteProjectPath,
590
+ };
591
+ }
592
+ // H2S/H2D: fall through to project_file path below
593
+ }
594
+
223
595
  const projectMetadata = await this.resolveProjectFileMetadata(
224
596
  options.filePath,
225
597
  options.plateIndex
226
598
  );
227
599
 
228
- const remoteFileName = path.basename(options.filePath);
229
- const remoteProjectPath = `cache/${remoteFileName}`;
230
-
231
- // Upload via basic-ftp directly (bypasses bambu-js double-path bug)
232
- await this.ftpUpload(host, token, options.filePath, `/cache/${remoteFileName}`);
233
-
234
600
  // Send project_file command via bambu-node MQTT (bypasses bambu-js
235
601
  // hardcoded use_ams=true and missing ams_mapping support)
236
602
  const printer = await this.getPrinter(host, serial, token);
237
603
  const md5 = options.md5 ?? projectMetadata.md5;
238
604
 
239
- // Build AMS mapping per OpenBambuAPI spec: 5-element array
240
- // [-1,-1,-1,-1,0] means slot 0 only; pad unused slots with -1
241
- let amsMapping: number[];
605
+ // Build AMS mapping.
606
+ //
607
+ // Convention: position = project-level filament index, value = absolute
608
+ // tray index (0-3 = AMS 0 trays, 4-7 = AMS 1, 8-11 = AMS 2, 128+ = AMS-HT,
609
+ // 254 = external spool, -1 = unused). Required on AMS-equipped printers
610
+ // even when you think "no AMS" -- firmware looks up the mapping table
611
+ // whenever the 3MF declares filaments, and a missing/invalid mapping
612
+ // fails with 0700-8012-032015 "Failed to get AMS mapping table".
613
+ //
614
+ // For H2-series the array length MUST equal the project-level filament
615
+ // count declared by the slicer (parsed from the gcode header's
616
+ // `filament_colour` list). For P1/A1/X1 we pad to length 5 per the
617
+ // historical bambu-js behavior.
618
+ //
619
+ // Caller ergonomics: callers typically know only "I want to pull this
620
+ // print's filaments from these AMS slots" in the order the plate uses
621
+ // them. We expose `amsSlots` for that -- one entry per position in
622
+ // `plate_N.json.filament_ids` -- and expand to a full project-level
623
+ // array here. `amsMapping` is the raw escape hatch (takes precedence
624
+ // when both are supplied).
625
+ const validateTrayValue = (v: unknown, label: string): void => {
626
+ if (
627
+ !Number.isInteger(v) ||
628
+ (v as number) < -1 ||
629
+ ((v as number) > 15 && (v as number) < 128) ||
630
+ (v as number) > 254
631
+ ) {
632
+ throw new Error(
633
+ `${label} values must be integers in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${v}`
634
+ );
635
+ }
636
+ };
637
+
638
+ let baseMapping: number[];
242
639
  if (options.amsMapping && options.amsMapping.length > 0) {
243
- amsMapping = Array.from({ length: 5 }, (_, i) =>
244
- i < options.amsMapping!.length ? options.amsMapping![i] : -1
640
+ for (const v of options.amsMapping) validateTrayValue(v, "ams_mapping");
641
+ baseMapping = options.amsMapping.slice();
642
+ } else if (options.amsSlots && options.amsSlots.length > 0) {
643
+ for (const v of options.amsSlots) validateTrayValue(v, "amsSlots");
644
+ const positions = projectMetadata.usedFilamentPositions;
645
+ if (options.amsSlots.length !== positions.length) {
646
+ throw new Error(
647
+ `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.`
648
+ );
649
+ }
650
+ const projectLen = Math.max(
651
+ projectMetadata.projectFilamentCount,
652
+ ...positions.map((p) => p + 1)
245
653
  );
654
+ baseMapping = Array<number>(projectLen).fill(-1);
655
+ positions.forEach((pos, i) => {
656
+ baseMapping[pos] = options.amsSlots![i];
657
+ });
246
658
  } else {
247
- amsMapping = [-1, -1, -1, -1, 0];
659
+ if (isH2 && projectMetadata.usedFilamentPositions.length > 0) {
660
+ throw new Error(
661
+ `H2 project_file requires amsSlots or amsMapping for sliced files with declared filaments. Plate uses project filament positions ${JSON.stringify(projectMetadata.usedFilamentPositions)}.`
662
+ );
663
+ }
664
+ const positions = projectMetadata.usedFilamentPositions;
665
+ const projectLen = Math.max(
666
+ projectMetadata.projectFilamentCount,
667
+ ...positions.map((p) => p + 1),
668
+ 1
669
+ );
670
+ baseMapping = Array<number>(projectLen).fill(-1);
671
+ positions.forEach((pos, i) => {
672
+ baseMapping[pos] = i;
673
+ });
248
674
  }
249
675
 
250
- const projectFileCmd = {
251
- print: {
252
- command: "project_file",
253
- param: `Metadata/${projectMetadata.plateFileName}`,
254
- url: `file:///sdcard/${remoteProjectPath}`,
255
- subtask_name: options.projectName,
256
- md5,
257
- flow_cali: options.flowCalibration ?? true,
258
- layer_inspect: options.layerInspect ?? true,
259
- vibration_cali: options.vibrationCalibration ?? true,
260
- bed_leveling: options.bedLeveling ?? true,
261
- bed_type: options.bedType || "textured_plate",
262
- timelapse: options.timelapse ?? false,
263
- use_ams: options.useAMS !== false,
264
- ams_mapping: amsMapping,
265
- profile_id: "0",
266
- project_id: "0",
267
- sequence_id: "0",
268
- subtask_id: "0",
269
- task_id: "0",
270
- },
271
- };
676
+ let amsMapping: number[];
677
+ let amsMapping2: Array<{ ams_id: number; slot_id: number }>;
678
+ if (isH2) {
679
+ const projLen = Math.max(projectMetadata.projectFilamentCount, baseMapping.length, 1);
680
+ amsMapping = Array.from({ length: projLen }, (_, i) =>
681
+ i < baseMapping.length ? baseMapping[i] : -1
682
+ );
683
+ amsMapping2 = amsMapping.map((v) => {
684
+ if (v < 0 || v === 255) return { ams_id: 255, slot_id: 255 };
685
+ if (v === 254) return { ams_id: 254, slot_id: 254 };
686
+ if (v >= 128) return { ams_id: 128, slot_id: v - 128 };
687
+ return { ams_id: Math.floor(v / 4), slot_id: v % 4 };
688
+ });
689
+ } else {
690
+ amsMapping = Array.from({ length: 5 }, (_, i) =>
691
+ i < baseMapping.length ? baseMapping[i] : -1
692
+ );
693
+ amsMapping2 = [];
694
+ }
695
+
696
+ const b = (v: any) => (v ? 1 : 0);
697
+ let projectFileCmd: Record<string, any>;
698
+ if (isH2) {
699
+ const submissionId = String(Date.now() & 0x7fffffff);
700
+ projectFileCmd = {
701
+ print: {
702
+ sequence_id: "0",
703
+ command: "project_file",
704
+ param: `Metadata/${projectMetadata.plateFileName}`,
705
+ url: projectUrl,
706
+ file: remoteFileName,
707
+ md5,
708
+ bed_type: options.bedType || "auto",
709
+ timelapse: b(options.timelapse),
710
+ bed_leveling: b(options.bedLeveling ?? true),
711
+ auto_bed_leveling: 1,
712
+ flow_cali: b(options.flowCalibration ?? false),
713
+ vibration_cali: b(options.vibrationCalibration ?? true),
714
+ layer_inspect: b(options.layerInspect ?? false),
715
+ use_ams: options.useAMS !== false,
716
+ cfg: "0",
717
+ extrude_cali_flag: 0,
718
+ extrude_cali_manual_mode: 0,
719
+ nozzle_offset_cali: 2,
720
+ subtask_name: remoteFileName.replace(/\.3mf$/i, ""),
721
+ profile_id: "0",
722
+ project_id: submissionId,
723
+ subtask_id: submissionId,
724
+ task_id: submissionId,
725
+ ams_mapping: amsMapping,
726
+ ams_mapping2: amsMapping2,
727
+ },
728
+ };
729
+ } else {
730
+ projectFileCmd = {
731
+ print: {
732
+ command: "project_file",
733
+ param: `Metadata/${projectMetadata.plateFileName}`,
734
+ url: projectUrl,
735
+ subtask_name: options.projectName,
736
+ md5,
737
+ flow_cali: options.flowCalibration ?? true,
738
+ layer_inspect: options.layerInspect ?? true,
739
+ vibration_cali: options.vibrationCalibration ?? true,
740
+ bed_leveling: options.bedLeveling ?? true,
741
+ bed_type: options.bedType || "textured_plate",
742
+ timelapse: options.timelapse ?? false,
743
+ use_ams: options.useAMS !== false,
744
+ ams_mapping: amsMapping,
745
+ profile_id: "0",
746
+ project_id: "0",
747
+ sequence_id: "0",
748
+ subtask_id: "0",
749
+ task_id: "0",
750
+ },
751
+ };
752
+ }
272
753
 
273
754
  await printer.publish(projectFileCmd);
274
755
  await new Promise((resolve) => setTimeout(resolve, 300));
@@ -288,13 +769,135 @@ export class BambuImplementation {
288
769
  const printer = await this.getPrinter(host, serial, token);
289
770
 
290
771
  try {
291
- await printer.executeCommand(new UpdateStateCommand({ state: "stop" }));
772
+ await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
292
773
  return { status: "success", message: "Cancel command sent successfully." };
293
774
  } catch (error) {
294
775
  throw new Error(`Failed to cancel print: ${(error as Error).message}`);
295
776
  }
296
777
  }
297
778
 
779
+ async pauseJob(host: string, serial: string, token: string): Promise<any> {
780
+ const printer = await this.getPrinter(host, serial, token);
781
+ try {
782
+ await invokeWithoutAck(printer, new UpdateStateCommand({ state: "pause" }));
783
+ return { status: "success", message: "Pause command sent successfully." };
784
+ } catch (error) {
785
+ throw new Error(`Failed to pause print: ${(error as Error).message}`);
786
+ }
787
+ }
788
+
789
+ async resumeJob(host: string, serial: string, token: string): Promise<any> {
790
+ const printer = await this.getPrinter(host, serial, token);
791
+ try {
792
+ await invokeWithoutAck(printer, new UpdateStateCommand({ state: "resume" }));
793
+ return { status: "success", message: "Resume command sent successfully." };
794
+ } catch (error) {
795
+ throw new Error(`Failed to resume print: ${(error as Error).message}`);
796
+ }
797
+ }
798
+
799
+ async clearHmsErrors(host: string, serial: string, token: string): Promise<any> {
800
+ const printer = await this.getPrinter(host, serial, token);
801
+ await printer.publish({
802
+ print: {
803
+ command: "clean_print_error",
804
+ sequence_id: "0",
805
+ },
806
+ });
807
+ await sleep(COMMAND_SETTLE_MS);
808
+ return { status: "success", message: "HMS clear command sent." };
809
+ }
810
+
811
+ async setPrintSpeed(host: string, serial: string, token: string, speedMode: string | number): Promise<any> {
812
+ const printer = await this.getPrinter(host, serial, token);
813
+ const normalized =
814
+ typeof speedMode === "number" ? String(Math.trunc(speedMode)) : speedMode.trim().toLowerCase();
815
+ const mode =
816
+ normalized === "silent" ? 1 :
817
+ normalized === "standard" ? 2 :
818
+ normalized === "sport" ? 3 :
819
+ normalized === "ludicrous" ? 4 :
820
+ Number(normalized);
821
+
822
+ if (!Number.isInteger(mode) || mode < 1 || mode > 4) {
823
+ throw new Error("Print speed mode must be one of: silent, standard, sport, ludicrous, 1, 2, 3, 4.");
824
+ }
825
+
826
+ await printer.publish({
827
+ print: {
828
+ command: "print_speed",
829
+ param: String(mode),
830
+ sequence_id: "0",
831
+ },
832
+ });
833
+ await sleep(COMMAND_SETTLE_MS);
834
+ const names = ["", "silent", "standard", "sport", "ludicrous"];
835
+ return {
836
+ status: "success",
837
+ message: `Print speed command sent for ${names[mode]}.`,
838
+ mode,
839
+ label: names[mode],
840
+ };
841
+ }
842
+
843
+ async setAirductMode(host: string, serial: string, token: string, mode: string): Promise<any> {
844
+ const printer = await this.getPrinter(host, serial, token);
845
+ const normalizedMode = mode.trim().toLowerCase();
846
+ if (normalizedMode !== "cooling" && normalizedMode !== "heating") {
847
+ throw new Error("Airduct mode must be one of: cooling, heating.");
848
+ }
849
+
850
+ await printer.publish({
851
+ print: {
852
+ command: "set_airduct",
853
+ modeId: normalizedMode === "cooling" ? 0 : 1,
854
+ submode: -1,
855
+ sequence_id: "0",
856
+ },
857
+ });
858
+ await sleep(COMMAND_SETTLE_MS);
859
+ return {
860
+ status: "success",
861
+ message: `Airduct mode command sent for ${normalizedMode}.`,
862
+ mode: normalizedMode,
863
+ };
864
+ }
865
+
866
+ async rereadAmsRfid(
867
+ host: string,
868
+ serial: string,
869
+ token: string,
870
+ amsId: number,
871
+ slotId: number
872
+ ): Promise<any> {
873
+ const printer = await this.getPrinter(host, serial, token);
874
+ const normalizedAmsId = Math.trunc(amsId);
875
+ const normalizedSlotId = Math.trunc(slotId);
876
+
877
+ if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
878
+ throw new Error("ams_id must be an integer from 0 to 3.");
879
+ }
880
+ if (!Number.isInteger(normalizedSlotId) || normalizedSlotId < 0 || normalizedSlotId > 3) {
881
+ throw new Error("slot_id must be an integer from 0 to 3.");
882
+ }
883
+
884
+ await printer.publish({
885
+ print: {
886
+ command: "ams_get_rfid",
887
+ ams_id: normalizedAmsId,
888
+ slot_id: normalizedSlotId,
889
+ sequence_id: "0",
890
+ },
891
+ });
892
+ await sleep(COMMAND_SETTLE_MS);
893
+ return {
894
+ status: "success",
895
+ message: `AMS RFID re-read command sent for AMS ${normalizedAmsId} slot ${normalizedSlotId}.`,
896
+ ams_id: normalizedAmsId,
897
+ slot_id: normalizedSlotId,
898
+ };
899
+ }
900
+
298
901
  async setTemperature(
299
902
  host: string,
300
903
  serial: string,
@@ -327,7 +930,7 @@ export class BambuImplementation {
327
930
  );
328
931
  }
329
932
 
330
- await printer.executeCommand(new GCodeLineCommand({ gcodes: [gcode] }));
933
+ await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
331
934
  return {
332
935
  status: "success",
333
936
  message: `Temperature command sent for ${normalizedComponent}.`,
@@ -335,6 +938,148 @@ export class BambuImplementation {
335
938
  };
336
939
  }
337
940
 
941
+ async setFanSpeed(
942
+ host: string,
943
+ serial: string,
944
+ token: string,
945
+ fan: string | number,
946
+ speed: number
947
+ ) {
948
+ const printer = await this.getPrinter(host, serial, token);
949
+ const normalizedFan = typeof fan === "number" ? fan : fan.trim().toLowerCase();
950
+ const fanId =
951
+ normalizedFan === 1 || normalizedFan === "1" || normalizedFan === "part" || normalizedFan === "part_cooling"
952
+ ? 1
953
+ : normalizedFan === 2 || normalizedFan === "2" || normalizedFan === "aux" || normalizedFan === "auxiliary"
954
+ ? 2
955
+ : normalizedFan === 3 || normalizedFan === "3" || normalizedFan === "chamber"
956
+ ? 3
957
+ : null;
958
+
959
+ if (fanId === null) {
960
+ throw new Error("Unsupported fan. Use one of: part, auxiliary, chamber, 1, 2, 3.");
961
+ }
962
+
963
+ const targetSpeed = Math.round(speed);
964
+ if (targetSpeed < 0 || targetSpeed > 100) {
965
+ throw new Error("Fan speed must be between 0 and 100 percent.");
966
+ }
967
+
968
+ await invokeWithoutAck(
969
+ printer,
970
+ new UpdateFanCommand({ fan: fanId as any, speed: targetSpeed as any })
971
+ );
972
+ return {
973
+ status: "success",
974
+ message: `Fan speed command sent for fan ${fanId}.`,
975
+ fan: fanId,
976
+ speed: targetSpeed,
977
+ };
978
+ }
979
+
980
+ async setLight(
981
+ host: string,
982
+ serial: string,
983
+ token: string,
984
+ light: string,
985
+ mode: string
986
+ ) {
987
+ const printer = await this.getPrinter(host, serial, token);
988
+ const normalizedLight = light.trim();
989
+ const normalizedMode = mode.trim().toLowerCase();
990
+ const validModes = new Set(["on", "off", "flashing"]);
991
+
992
+ if (!normalizedLight) {
993
+ throw new Error("Light node is required, for example: chamber_light.");
994
+ }
995
+ if (!validModes.has(normalizedMode)) {
996
+ throw new Error("Light mode must be one of: on, off, flashing.");
997
+ }
998
+
999
+ await invokeWithoutAck(
1000
+ printer,
1001
+ new UpdateLightCommand({
1002
+ light: normalizedLight as any,
1003
+ mode: normalizedMode as any,
1004
+ })
1005
+ );
1006
+ return {
1007
+ status: "success",
1008
+ message: `Light command sent for ${normalizedLight}.`,
1009
+ light: normalizedLight,
1010
+ mode: normalizedMode,
1011
+ };
1012
+ }
1013
+
1014
+ async setAmsDrying(
1015
+ host: string,
1016
+ serial: string,
1017
+ token: string,
1018
+ action: string,
1019
+ amsId: number
1020
+ ): Promise<any> {
1021
+ const printer = await this.getPrinter(host, serial, token);
1022
+ const normalizedAction = action.trim().toLowerCase();
1023
+ if (normalizedAction !== "start" && normalizedAction !== "stop") {
1024
+ throw new Error("AMS drying action must be one of: start, stop.");
1025
+ }
1026
+
1027
+ const normalizedAmsId = Math.trunc(amsId);
1028
+ if (!Number.isInteger(normalizedAmsId) || normalizedAmsId < 0 || normalizedAmsId > 3) {
1029
+ throw new Error("ams_id must be an integer from 0 to 3.");
1030
+ }
1031
+
1032
+ const param = normalizedAction === "start" ? "start_drying" : "stop_drying";
1033
+ await printer.publish({
1034
+ print: {
1035
+ command: "ams_control",
1036
+ ams_id: normalizedAmsId,
1037
+ param,
1038
+ sequence_id: "0",
1039
+ },
1040
+ });
1041
+ await sleep(COMMAND_SETTLE_MS);
1042
+
1043
+ const label = normalizedAction === "start" ? "started" : "stopped";
1044
+ return {
1045
+ status: "success",
1046
+ message: `AMS drying ${label} for AMS ${normalizedAmsId}.`,
1047
+ action: normalizedAction,
1048
+ ams_id: normalizedAmsId,
1049
+ };
1050
+ }
1051
+
1052
+ async skipObjects(
1053
+ host: string,
1054
+ serial: string,
1055
+ token: string,
1056
+ objectIds: number[]
1057
+ ) {
1058
+ const printer = await this.getPrinter(host, serial, token);
1059
+ const normalizedObjectIds = objectIds
1060
+ .map((id) => Math.trunc(id))
1061
+ .filter((id) => Number.isInteger(id) && id >= 0);
1062
+
1063
+ if (normalizedObjectIds.length === 0) {
1064
+ throw new Error("At least one non-negative object id is required.");
1065
+ }
1066
+
1067
+ await printer.publish({
1068
+ print: {
1069
+ sequence_id: "0",
1070
+ command: "skip_objects",
1071
+ obj_list: normalizedObjectIds,
1072
+ },
1073
+ });
1074
+ await sleep(COMMAND_SETTLE_MS);
1075
+
1076
+ return {
1077
+ status: "success",
1078
+ message: "Skip objects command sent.",
1079
+ object_ids: normalizedObjectIds,
1080
+ };
1081
+ }
1082
+
338
1083
  async getFiles(host: string, serial: string, token: string) {
339
1084
  const printer = new BambuPrinter(host, serial, token);
340
1085
  const directories = ["cache", "timelapse", "logs"];
@@ -422,7 +1167,8 @@ export class BambuImplementation {
422
1167
  }
423
1168
 
424
1169
  async startJob(host: string, serial: string, token: string, filename: string) {
425
- if (filename.toLowerCase().endsWith(".3mf")) {
1170
+ const lower = filename.toLowerCase();
1171
+ if (lower.endsWith(".3mf") && !lower.endsWith(".gcode.3mf")) {
426
1172
  throw new Error("Use print_3mf for .3mf project files.");
427
1173
  }
428
1174
 
@@ -433,7 +1179,7 @@ export class BambuImplementation {
433
1179
  ? normalizedFileName
434
1180
  : `cache/${normalizedFileName}`;
435
1181
 
436
- await printer.executeCommand(new GCodeFileCommand({ fileName: remoteFile }));
1182
+ await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteFile }));
437
1183
 
438
1184
  return {
439
1185
  status: "success",
@@ -442,6 +1188,402 @@ export class BambuImplementation {
442
1188
  };
443
1189
  }
444
1190
 
1191
+ /**
1192
+ * Capture a single JPEG frame from the printer's chamber camera.
1193
+ *
1194
+ * Protocol per https://github.com/Doridian/OpenBambuAPI/blob/main/video.md
1195
+ *
1196
+ * Connect TLS to <host>:6000 (self-signed cert -- skip verification).
1197
+ * Send an 80-byte auth packet:
1198
+ * [0..4] uint32 LE payload size = 0x40 (64)
1199
+ * [4..8] uint32 LE type = 0x3000
1200
+ * [8..12] uint32 LE flags = 0
1201
+ * [12..16] uint32 LE 0
1202
+ * [16..48] "bblp" + null padding to 32 bytes
1203
+ * [48..80] access token + null padding to 32 bytes
1204
+ *
1205
+ * The server then streams frames as repeating:
1206
+ * [0..4] uint32 LE payload size
1207
+ * [4..8] uint32 LE itrack (0)
1208
+ * [8..12] uint32 LE flags (1)
1209
+ * [12..16] uint32 LE 0
1210
+ * [16..16+payloadSize] JPEG (FF D8 ... FF D9)
1211
+ *
1212
+ * Verified models per upstream docs: A1, A1 mini, P1S, P1P. X1/X1C/X1E
1213
+ * and P2S use RTSP on port 322 instead -- not implemented yet. H2/H2S/H2D
1214
+ * are not documented; we fail fast rather than guess at the protocol.
1215
+ *
1216
+ * Read-only; no confirm gate. Default 8s timeout for cold-start latency.
1217
+ */
1218
+ async cameraSnapshot(
1219
+ host: string,
1220
+ _serial: string,
1221
+ token: string,
1222
+ options: {
1223
+ savePath?: string;
1224
+ timeoutMs?: number;
1225
+ bambuModel?: string;
1226
+ /**
1227
+ * Reserved. Earlier this flag let callers probe the H2 series via
1228
+ * the A1/P1 TCP-on-6000 path. Diagnostics confirmed the printer
1229
+ * does not speak that protocol; H2 uses RTSP, same as X1. The
1230
+ * flag is now ignored. Kept on the type to avoid breaking
1231
+ * existing callers.
1232
+ */
1233
+ experimental?: boolean;
1234
+ /**
1235
+ * Optional override for the ffmpeg binary path used by the RTSP
1236
+ * path. Defaults to `ffmpeg` (relies on $PATH).
1237
+ */
1238
+ ffmpegPath?: string;
1239
+ } = {}
1240
+ ): Promise<{
1241
+ status: string;
1242
+ format: string;
1243
+ sizeBytes: number;
1244
+ base64: string;
1245
+ savedTo?: string;
1246
+ width?: number;
1247
+ height?: number;
1248
+ note?: string;
1249
+ transport?: "tcp-6000" | "rtsps-322";
1250
+ }> {
1251
+ const timeoutMs = options.timeoutMs ?? 8_000;
1252
+ const model = (options.bambuModel ?? "").toLowerCase();
1253
+ // P1/A1 series still use the proprietary TCP-on-6000 framed JPEG path
1254
+ // (per https://github.com/Doridian/OpenBambuAPI/blob/main/video.md).
1255
+ const TCP_CAMERA_MODELS = new Set(["a1", "a1mini", "p1s", "p1p"]);
1256
+ // X1, P2S, AND H2 (H2S/H2D/H2C) all use RTSP on port 322. The
1257
+ // OpenBambuAPI doc only mentions X1/P2S, but the HA bambulab
1258
+ // integration's models.py shows the printer reports its own
1259
+ // `ipcam.rtsp_url` for these models, and Parker (H2S) rejects the
1260
+ // A1/P1 80-byte auth packet on port 6000 (verified 2026-04-27 --
1261
+ // see PROGRESS.md "H2 probe results").
1262
+ const RTSP_MODELS = new Set([
1263
+ "x1", "x1c", "x1carbon", "x1e", "p2s",
1264
+ "h2", "h2s", "h2d", "h2c", "h2dpro",
1265
+ ]);
1266
+
1267
+ if (!model) {
1268
+ throw new Error(
1269
+ "camera_snapshot requires bambu_model or BAMBU_MODEL so it can choose the correct Bambu camera protocol."
1270
+ );
1271
+ }
1272
+
1273
+ if (RTSP_MODELS.has(model)) {
1274
+ const jpeg = await this.fetchRtspCameraFrame(host, token, timeoutMs, options.ffmpegPath);
1275
+ const result: any = {
1276
+ status: "success",
1277
+ format: "image/jpeg",
1278
+ sizeBytes: jpeg.length,
1279
+ base64: jpeg.toString("base64"),
1280
+ transport: "rtsps-322",
1281
+ };
1282
+ if (options.savePath) {
1283
+ const fsSync = await import("node:fs");
1284
+ fsSync.writeFileSync(options.savePath, jpeg);
1285
+ result.savedTo = options.savePath;
1286
+ }
1287
+ return result;
1288
+ }
1289
+
1290
+ if (model && !TCP_CAMERA_MODELS.has(model)) {
1291
+ throw new Error(
1292
+ `camera_snapshot: model "${model}" is not a known Bambu Lab printer model. Supported: ${[...TCP_CAMERA_MODELS, ...RTSP_MODELS].sort().join(", ")}`
1293
+ );
1294
+ }
1295
+
1296
+ const jpeg = await this.fetchTcpCameraFrame(host, token, timeoutMs);
1297
+
1298
+ const result: any = {
1299
+ status: "success",
1300
+ format: "image/jpeg",
1301
+ sizeBytes: jpeg.length,
1302
+ base64: jpeg.toString("base64"),
1303
+ transport: "tcp-6000",
1304
+ };
1305
+
1306
+ if (options.savePath) {
1307
+ const fsSync = await import("node:fs");
1308
+ fsSync.writeFileSync(options.savePath, jpeg);
1309
+ result.savedTo = options.savePath;
1310
+ }
1311
+
1312
+ return result;
1313
+ }
1314
+
1315
+ /**
1316
+ * Pull a single JPEG frame from the printer's RTSP/RTSPS stream using
1317
+ * ffmpeg. Used for X1, P2S, and H2 series.
1318
+ *
1319
+ * URL pattern verified against HA bambulab's models.py example:
1320
+ * rtsps://bblp:<access_code>@<host>:322/streaming/live/1
1321
+ *
1322
+ * ffmpeg invocation:
1323
+ * ffmpeg -rtsp_transport tcp -i <url> -frames:v 1 -f image2 -c:v mjpeg -y <out>
1324
+ *
1325
+ * -rtsp_transport tcp avoids UDP NAT/firewall issues. -frames:v 1
1326
+ * makes ffmpeg exit as soon as one frame lands. -y overwrites the temp
1327
+ * file. The Bambu printer presents a self-signed cert; ffmpeg's TLS
1328
+ * layer accepts that by default (no host verification).
1329
+ */
1330
+ private async fetchRtspCameraFrame(
1331
+ host: string,
1332
+ token: string,
1333
+ timeoutMs: number,
1334
+ ffmpegPath?: string
1335
+ ): Promise<Buffer> {
1336
+ const fsSync = await import("node:fs");
1337
+ const os = await import("node:os");
1338
+ const pathMod = await import("node:path");
1339
+ const { spawn } = await import("node:child_process");
1340
+
1341
+ const tmpOut = pathMod.join(os.tmpdir(), `bambu-snap-${Date.now()}-${Math.random().toString(16).slice(2)}.jpg`);
1342
+ const url = `rtsps://bblp:${encodeURIComponent(token)}@${host}:322/streaming/live/1`;
1343
+ const bin = ffmpegPath ?? "ffmpeg";
1344
+ // Note: ffmpeg's `-stimeout` was removed in 8.0 and renamed across the
1345
+ // 5.x/6.x line; we rely on the outer kill timer instead so we don't
1346
+ // have to detect ffmpeg version. -rtsp_transport tcp avoids UDP NAT
1347
+ // headaches; -frames:v 1 makes ffmpeg exit on first frame.
1348
+ const args = [
1349
+ "-rtsp_transport", "tcp",
1350
+ "-i", url,
1351
+ "-frames:v", "1",
1352
+ "-f", "image2",
1353
+ "-c:v", "mjpeg",
1354
+ "-y",
1355
+ "-loglevel", "error",
1356
+ tmpOut,
1357
+ ];
1358
+
1359
+ return new Promise((resolve, reject) => {
1360
+ let stderr = "";
1361
+ const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
1362
+ const killTimer = setTimeout(() => {
1363
+ try { proc.kill("SIGKILL"); } catch { /* ignore */ }
1364
+ reject(new Error(`camera_snapshot: ffmpeg timed out after ${timeoutMs}ms`));
1365
+ }, timeoutMs + 1000);
1366
+
1367
+ proc.stderr.on("data", (d) => { stderr += d.toString(); });
1368
+ proc.on("error", (err) => {
1369
+ clearTimeout(killTimer);
1370
+ if ((err as any).code === "ENOENT") {
1371
+ reject(new Error(
1372
+ `camera_snapshot: ffmpeg binary not found at "${bin}". Install with \`brew install ffmpeg\` or pass ffmpegPath.`
1373
+ ));
1374
+ } else {
1375
+ reject(err);
1376
+ }
1377
+ });
1378
+ proc.on("close", (code) => {
1379
+ clearTimeout(killTimer);
1380
+ if (code !== 0) {
1381
+ // Strip access code from error messages so we don't leak credentials.
1382
+ const safeStderr = stderr.split(token).join("<token-redacted>").trim();
1383
+ reject(new Error(
1384
+ `camera_snapshot: ffmpeg exited ${code}. stderr: ${safeStderr.slice(-1000)}`
1385
+ ));
1386
+ try { fsSync.unlinkSync(tmpOut); } catch { /* ignore */ }
1387
+ return;
1388
+ }
1389
+ try {
1390
+ const jpeg = fsSync.readFileSync(tmpOut);
1391
+ fsSync.unlinkSync(tmpOut);
1392
+ if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) {
1393
+ reject(new Error("camera_snapshot: ffmpeg produced output that does not start with JPEG SOI"));
1394
+ return;
1395
+ }
1396
+ resolve(jpeg);
1397
+ } catch (err) {
1398
+ reject(err);
1399
+ }
1400
+ });
1401
+ });
1402
+ }
1403
+
1404
+ /**
1405
+ * Open the TLS-on-6000 socket, send the 80-byte auth packet, and read
1406
+ * a single complete JPEG frame. Returns the JPEG bytes.
1407
+ */
1408
+ private async fetchTcpCameraFrame(
1409
+ host: string,
1410
+ token: string,
1411
+ timeoutMs: number
1412
+ ): Promise<Buffer> {
1413
+ const tls = await import("node:tls");
1414
+
1415
+ const auth = Buffer.alloc(80, 0);
1416
+ auth.writeUInt32LE(0x40, 0); // payload size
1417
+ auth.writeUInt32LE(0x3000, 4); // type
1418
+ // flags=0, reserved=0 are already zero from Buffer.alloc.
1419
+ auth.write("bblp", 16, 4, "ascii");
1420
+ auth.write(token, 48, Math.min(32, Buffer.byteLength(token, "ascii")), "ascii");
1421
+
1422
+ return new Promise((resolve, reject) => {
1423
+ const chunks: Buffer[] = [];
1424
+ let totalLen = 0;
1425
+ const FRAME_HEADER_BYTES = 16;
1426
+ let payloadSize: number | null = null;
1427
+ let settled = false;
1428
+
1429
+ const finish = (err: Error | null, jpeg?: Buffer) => {
1430
+ if (settled) return;
1431
+ settled = true;
1432
+ clearTimeout(timer);
1433
+ socket.destroy();
1434
+ if (err) reject(err);
1435
+ else if (jpeg) resolve(jpeg);
1436
+ else reject(new Error("camera_snapshot: ended without jpeg payload"));
1437
+ };
1438
+
1439
+ const timer = setTimeout(
1440
+ () => finish(new Error(`camera_snapshot: timed out after ${timeoutMs}ms`)),
1441
+ timeoutMs
1442
+ );
1443
+
1444
+ const socket = tls.connect(
1445
+ {
1446
+ host,
1447
+ port: 6000,
1448
+ rejectUnauthorized: false,
1449
+ // The printer uses TLS for confidentiality but presents a self-signed cert.
1450
+ // Same trust posture as the FTPS path (basic-ftp with rejectUnauthorized: false).
1451
+ },
1452
+ () => {
1453
+ socket.write(auth);
1454
+ }
1455
+ );
1456
+
1457
+ socket.on("data", (data: Buffer) => {
1458
+ chunks.push(data);
1459
+ totalLen += data.length;
1460
+
1461
+ if (payloadSize === null && totalLen >= FRAME_HEADER_BYTES) {
1462
+ const merged = Buffer.concat(chunks, totalLen);
1463
+ payloadSize = merged.readUInt32LE(0);
1464
+ if (payloadSize <= 0 || payloadSize > 5_000_000) {
1465
+ finish(
1466
+ new Error(
1467
+ `camera_snapshot: unreasonable payload size ${payloadSize} from header; auth likely failed.`
1468
+ )
1469
+ );
1470
+ return;
1471
+ }
1472
+ // Reset chunk list to remaining bytes after the header.
1473
+ const remainder = merged.subarray(FRAME_HEADER_BYTES);
1474
+ chunks.length = 0;
1475
+ chunks.push(remainder);
1476
+ totalLen = remainder.length;
1477
+ }
1478
+
1479
+ if (payloadSize !== null && totalLen >= payloadSize) {
1480
+ const merged = Buffer.concat(chunks, totalLen);
1481
+ const jpeg = merged.subarray(0, payloadSize);
1482
+ if (jpeg[0] !== 0xff || jpeg[1] !== 0xd8) {
1483
+ finish(
1484
+ new Error(
1485
+ `camera_snapshot: payload does not start with JPEG SOI (FF D8); got ${jpeg[0].toString(16)} ${jpeg[1].toString(16)}.`
1486
+ )
1487
+ );
1488
+ return;
1489
+ }
1490
+ finish(null, jpeg);
1491
+ }
1492
+ });
1493
+
1494
+ socket.on("error", (err) => finish(err));
1495
+ socket.on("end", () =>
1496
+ finish(
1497
+ new Error("camera_snapshot: connection ended before a full JPEG frame arrived")
1498
+ )
1499
+ );
1500
+ });
1501
+ }
1502
+
1503
+ /**
1504
+ * Delete a single file from the printer's SD card via FTPS.
1505
+ *
1506
+ * Destructive. Caller MUST set confirm=true; otherwise we return without
1507
+ * touching the printer. Path is normalized the same way uploadFile()
1508
+ * normalizes -- if the caller passes a bare filename, we look in cache/.
1509
+ * Path traversal (`..`) is rejected.
1510
+ *
1511
+ * Only the printer-managed directories (cache/, timelapse/, logs/) are
1512
+ * accepted as parents to avoid letting an agent wander further into the
1513
+ * filesystem than expected.
1514
+ */
1515
+ async deleteFile(
1516
+ host: string,
1517
+ _serial: string,
1518
+ token: string,
1519
+ filename: string,
1520
+ confirm: unknown
1521
+ ): Promise<{ status: string; deleted: boolean; remotePath: string; message?: string }> {
1522
+ if (confirm !== true) {
1523
+ return {
1524
+ status: "skipped",
1525
+ deleted: false,
1526
+ remotePath: filename,
1527
+ message:
1528
+ "delete_printer_file requires confirm:true. No FTP request was made.",
1529
+ };
1530
+ }
1531
+
1532
+ const normalizedFileName = filename.replace(/^\/+/, "");
1533
+ if (normalizedFileName.length === 0) {
1534
+ throw new Error("delete_printer_file: filename is required.");
1535
+ }
1536
+ if (
1537
+ normalizedFileName.split("/").some((seg) => seg === "..")
1538
+ ) {
1539
+ throw new Error(
1540
+ `delete_printer_file: path traversal segments are not allowed (got "${filename}").`
1541
+ );
1542
+ }
1543
+
1544
+ const remotePath = normalizedFileName.includes("/")
1545
+ ? normalizedFileName
1546
+ : `cache/${normalizedFileName}`;
1547
+ const topDir = remotePath.split("/")[0];
1548
+ const ALLOWED_DIRS = new Set(["cache", "timelapse", "logs"]);
1549
+ if (!ALLOWED_DIRS.has(topDir)) {
1550
+ throw new Error(
1551
+ `delete_printer_file: refusing to delete outside cache/, timelapse/, logs/. Got "${remotePath}".`
1552
+ );
1553
+ }
1554
+
1555
+ await this.ftpDelete(host, token, `/${remotePath}`);
1556
+
1557
+ return {
1558
+ status: "success",
1559
+ deleted: true,
1560
+ remotePath,
1561
+ };
1562
+ }
1563
+
1564
+ /**
1565
+ * Delete a single remote file via FTPS, using basic-ftp directly so we
1566
+ * get the same TLS-session-ticket handshake as ftpUpload().
1567
+ */
1568
+ private async ftpDelete(host: string, token: string, remotePath: string): Promise<void> {
1569
+ const client = new FTPClient(15_000);
1570
+ try {
1571
+ await client.access({
1572
+ host,
1573
+ port: 990,
1574
+ user: "bblp",
1575
+ password: token,
1576
+ secure: "implicit",
1577
+ secureOptions: ftpsSecureOptions(),
1578
+ });
1579
+ await this.waitForTlsSession(client);
1580
+ const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
1581
+ await client.remove(absoluteRemote);
1582
+ } finally {
1583
+ client.close();
1584
+ }
1585
+ }
1586
+
445
1587
  /**
446
1588
  * Upload a file to the printer via FTP using basic-ftp directly.
447
1589
  * Bypasses bambu-js's sendFile which has a double-path bug (ensureDir CDs
@@ -462,19 +1604,35 @@ export class BambuImplementation {
462
1604
  user: "bblp",
463
1605
  password: token,
464
1606
  secure: "implicit",
465
- secureOptions: { rejectUnauthorized: false },
1607
+ secureOptions: ftpsSecureOptions(),
466
1608
  });
1609
+ // With TLS 1.3 the session ticket arrives asynchronously; basic-ftp calls
1610
+ // getSession() when opening the data channel and gets undefined if the
1611
+ // ticket hasn't arrived yet, causing a fresh TLS negotiation that Bambu
1612
+ // printers reject. Wait for the session ticket before proceeding.
1613
+ await this.waitForTlsSession(client);
467
1614
  // Use absolute path to avoid CWD side-effects
468
1615
  const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
469
- const remoteDir = path.posix.dirname(absoluteRemote);
470
- await client.ensureDir(remoteDir);
471
- // uploadFrom with just the basename since we're already in the right dir
472
- await client.uploadFrom(localPath, path.posix.basename(absoluteRemote));
1616
+ const fileData = await fs.readFile(localPath);
1617
+ await client.uploadFrom(Readable.from(fileData), absoluteRemote);
473
1618
  } finally {
474
1619
  client.close();
475
1620
  }
476
1621
  }
477
1622
 
1623
+ private async waitForTlsSession(ftpClient: FTPClient): Promise<void> {
1624
+ const socket = (ftpClient as any).ftp?.socket;
1625
+ if (!socket || typeof socket.getSession !== "function") return;
1626
+ if (socket.getSession()) return;
1627
+ await new Promise<void>((resolve) => {
1628
+ const timeout = setTimeout(resolve, 1000);
1629
+ socket.once("session", () => {
1630
+ clearTimeout(timeout);
1631
+ resolve();
1632
+ });
1633
+ });
1634
+ }
1635
+
478
1636
  async disconnectAll(): Promise<void> {
479
1637
  await this.printerStore.disconnectAll();
480
1638
  }