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