mcp-3d-printer-server 1.0.2 → 1.2.1

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,356 +1,509 @@
1
- import { PrinterImplementation, BambuPrinterStore, BambuFTP } from "../types.js";
2
- import { BambuPrinter } from "bambu-js"; // Keep for FTP manipulation
3
- import * as mqtt from 'mqtt'; // Import MQTT library
4
- import * as fs from 'fs/promises';
5
- import * as path from 'path';
6
- import * as tls from 'tls'; // Needed for TLS connection
7
-
8
- // Define interface for MQTT client store if needed
9
- interface MqttClientStore {
10
- [key: string]: mqtt.MqttClient;
1
+ import { PrinterImplementation } from "../types.js";
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import JSZip from "jszip";
6
+ import { Client as FTPClient } from "basic-ftp";
7
+ import { BambuPrinter } from "bambu-js";
8
+ import {
9
+ BambuClient,
10
+ GCodeFileCommand,
11
+ GCodeLineCommand,
12
+ PushAllCommand,
13
+ UpdateStateCommand,
14
+ } from "bambu-node";
15
+
16
+ interface BambuPrintOptionsInternal {
17
+ projectName: string;
18
+ filePath: string;
19
+ useAMS?: boolean;
20
+ plateIndex?: number;
21
+ bedType?: string;
22
+ bedLeveling?: boolean;
23
+ flowCalibration?: boolean;
24
+ vibrationCalibration?: boolean;
25
+ layerInspect?: boolean;
26
+ timelapse?: boolean;
27
+ amsMapping?: number[];
28
+ md5?: string;
11
29
  }
12
30
 
13
- // Define interface for print options matching OpenBambuAPI spec
14
- interface BambuPrintCommandOptions {
15
- sequence_id: string;
16
- command: "project_file";
17
- param: string; // Path on printer, e.g., gcodes/filename.3mf
18
- project_name: string; // Name of the project (often derived from filename)
19
- project_id?: string; // Default "0" for local
20
- profile_id?: string; // Default "0"
21
- task_id?: string; // Default "0"
22
- subtask_id?: string; // Default "0"
23
- subtask_name?: string; // Default ""
24
- url?: string; // Optional: file:///mnt/sdcard/gcodes/filename.3mf (can override param?)
25
- md5?: string; // MD5 hash of the file (optional but recommended)
26
- timelapse?: boolean;
27
- bed_type?: string; // Default "auto"
28
- bed_levelling?: boolean;
29
- flow_cali?: boolean;
30
- vibration_cali?: boolean;
31
- layer_inspect?: boolean;
32
- ams_mapping?: number[]; // Array of AMS slot indices (e.g., [0, 1, 2, 3]) - Needs verification how this maps to filaments
33
- use_ams?: boolean;
34
- plate_idx?: number; // Add missing property
31
+ interface ProjectFileMetadata {
32
+ plateFileName: string;
33
+ plateInternalPath: string;
34
+ md5: string;
35
35
  }
36
36
 
37
- // Define interface for print options including overrides passed from index.ts
38
- interface BambuPrintOptionsInternal {
39
- projectName: string;
40
- filePath: string; // Path to the local .3mf file
41
- // Base options from OpenBambuAPI spec
42
- useAMS?: boolean;
43
- plateIndex?: number;
44
- bedLeveling?: boolean;
45
- flowCalibration?: boolean;
46
- vibrationCalibration?: boolean;
47
- layerInspect?: boolean;
48
- timelapse?: boolean;
49
- // Overrides / parsed data
50
- amsMapping?: number[]; // Explicit mapping (from user override or parsed 3MF)
51
- md5?: string; // Optional MD5 hash of the file
37
+ class BambuClientStore {
38
+ private printers: Map<string, BambuClient> = new Map();
39
+ private initialConnectionPromises: Map<string, Promise<void>> = new Map();
40
+
41
+ async getPrinter(host: string, serial: string, token: string): Promise<BambuClient> {
42
+ const key = `${host}-${serial}`;
43
+
44
+ if (this.printers.has(key)) {
45
+ return this.printers.get(key)!;
46
+ }
47
+
48
+ if (this.initialConnectionPromises.has(key)) {
49
+ await this.initialConnectionPromises.get(key);
50
+ if (this.printers.has(key)) {
51
+ return this.printers.get(key)!;
52
+ }
53
+ throw new Error(`Existing Bambu client connection for ${key} failed.`);
54
+ }
55
+
56
+ const printer = new BambuClient({
57
+ host,
58
+ serialNumber: serial,
59
+ accessToken: token,
60
+ });
61
+
62
+ printer.on("client:connect", () => {
63
+ this.printers.set(key, printer);
64
+ this.initialConnectionPromises.delete(key);
65
+ });
66
+
67
+ printer.on("client:error", () => {
68
+ this.printers.delete(key);
69
+ this.initialConnectionPromises.delete(key);
70
+ });
71
+
72
+ printer.on("client:disconnect", () => {
73
+ this.printers.delete(key);
74
+ this.initialConnectionPromises.delete(key);
75
+ });
76
+
77
+ const connectPromise = printer.connect().then(() => {});
78
+ this.initialConnectionPromises.set(key, connectPromise);
79
+
80
+ try {
81
+ await connectPromise;
82
+ return printer;
83
+ } catch (error) {
84
+ this.initialConnectionPromises.delete(key);
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ async disconnectAll(): Promise<void> {
90
+ const disconnectPromises: Promise<void>[] = [];
91
+
92
+ for (const printer of this.printers.values()) {
93
+ disconnectPromises.push(
94
+ (async () => {
95
+ try {
96
+ await printer.disconnect();
97
+ } catch (error) {
98
+ console.error("Failed to disconnect Bambu client", error);
99
+ }
100
+ })()
101
+ );
102
+ }
103
+
104
+ await Promise.allSettled(disconnectPromises);
105
+ this.printers.clear();
106
+ this.initialConnectionPromises.clear();
107
+ }
52
108
  }
53
109
 
54
110
  export class BambuImplementation extends PrinterImplementation {
55
- private bambuPrinterStore: BambuPrinterStore;
56
- private mqttClients: MqttClientStore = {}; // Store connected MQTT clients
57
- private mqttConnectionPromises: { [key: string]: Promise<mqtt.MqttClient> } = {}; // Track connection attempts
111
+ private printerStore: BambuClientStore;
58
112
 
59
- constructor(apiClient: any, bambuPrinterStore: BambuPrinterStore) {
113
+ constructor(apiClient: any) {
60
114
  super(apiClient);
61
- this.bambuPrinterStore = bambuPrinterStore;
115
+ this.printerStore = new BambuClientStore();
62
116
  }
63
117
 
64
- // Get BambuPrinter instance for FTP
65
- private getBambuPrinterForFTP(host: string, serial: string, token: string): InstanceType<typeof BambuPrinter> {
66
- // This still uses bambu-js just for the FTP part
67
- return this.bambuPrinterStore.get(host, serial, token);
118
+ private async getPrinter(host: string, serial: string, token: string): Promise<BambuClient> {
119
+ return this.printerStore.getPrinter(host, serial, token);
68
120
  }
69
121
 
70
- // Get or establish MQTT connection
71
- private async getMqttClient(host: string, serial: string, token: string): Promise<mqtt.MqttClient> {
72
- const clientKey = `${host}-${serial}`;
73
-
74
- if (this.mqttClients[clientKey] && this.mqttClients[clientKey].connected) {
75
- return this.mqttClients[clientKey];
76
- }
122
+ private async resolveProjectFileMetadata(
123
+ localThreeMfPath: string,
124
+ plateIndex?: number
125
+ ): Promise<ProjectFileMetadata> {
126
+ const archive = await fs.readFile(localThreeMfPath);
127
+ const zip = await JSZip.loadAsync(archive);
128
+
129
+ const plateEntries = Object.values(zip.files).filter(
130
+ (entry) => !entry.dir && /^Metadata\/plate_\d+\.gcode$/i.test(entry.name)
131
+ );
132
+
133
+ if (plateEntries.length === 0) {
134
+ throw new Error(
135
+ "3MF does not contain any Metadata/plate_<n>.gcode entries. Re-slice and export a printable 3MF."
136
+ );
137
+ }
77
138
 
78
- // If a connection attempt is already in progress, wait for it
79
- if (this.mqttConnectionPromises[clientKey] !== undefined) {
80
- console.log(`Waiting for existing MQTT connection attempt to ${clientKey}...`);
81
- return this.mqttConnectionPromises[clientKey];
139
+ let selectedEntry = plateEntries.sort((a, b) => a.name.localeCompare(b.name))[0];
140
+
141
+ if (plateIndex !== undefined) {
142
+ const expectedEntryName = `Metadata/plate_${plateIndex + 1}.gcode`;
143
+ const matchedEntry = plateEntries.find(
144
+ (entry) => entry.name.toLowerCase() === expectedEntryName.toLowerCase()
145
+ );
146
+
147
+ if (!matchedEntry) {
148
+ const available = plateEntries.map((entry) => entry.name).join(", ");
149
+ throw new Error(
150
+ `Requested plateIndex=${plateIndex} (${expectedEntryName}) not present in 3MF. Available: ${available}`
151
+ );
82
152
  }
83
153
 
84
- console.log(`Attempting to establish new MQTT connection to ${host} for ${serial}...`);
85
- const connectPromise = new Promise<mqtt.MqttClient>((resolve, reject) => {
86
- // Use port 8883 and TLS
87
- const mqttOptions: mqtt.IClientOptions = {
88
- port: 8883,
89
- host: host,
90
- protocol: 'mqtts',
91
- username: 'bblp', // As per OpenBambuAPI docs
92
- password: token,
93
- clientId: `mcp_client_${serial}_${Date.now()}`,
94
- rejectUnauthorized: false, // Might be needed for self-signed certs? Test carefully.
95
- // ca: [fs.readFileSync('path/to/ca_cert.pem')], // Add CA cert if needed
96
- connectTimeout: 10000, // 10 seconds
97
- reconnectPeriod: 5000 // Try reconnecting every 5 seconds
98
- };
99
-
100
- const client = mqtt.connect(mqttOptions);
101
-
102
- client.on('connect', () => {
103
- console.log(`MQTT connected successfully to ${host} for ${serial}`);
104
- this.mqttClients[clientKey] = client;
105
- // Subscribe to necessary topics (e.g., responses/status)
106
- client.subscribe(`device/${serial}/report`, (err) => {
107
- if (err) {
108
- console.error(`Failed to subscribe to report topic for ${serial}:`, err);
109
- }
110
- });
111
- delete this.mqttConnectionPromises[clientKey]; // Clear promise tracker on success
112
- resolve(client);
113
- });
114
-
115
- client.on('error', (err) => {
116
- console.error(`MQTT connection error for ${serial} on ${host}:`, err);
117
- delete this.mqttClients[clientKey]; // Remove potentially bad client
118
- delete this.mqttConnectionPromises[clientKey]; // Clear promise tracker on error
119
- reject(err);
120
- });
121
-
122
- client.on('close', () => {
123
- console.log(`MQTT connection closed for ${serial} on ${host}`);
124
- delete this.mqttClients[clientKey]; // Remove client on close
125
- });
126
-
127
- // Handle status messages from the report topic
128
- client.on('message', (topic, message) => {
129
- try {
130
- const data = JSON.parse(message.toString());
131
- // Processing logic for status updates would go here
132
- // For now, we just acknowledge receiving it (optional logging)
133
- // console.log(`MQTT message received on ${topic}:`, data?.print?.subtask_name ?? data?.print?.command ?? 'Unknown message type');
134
- } catch(e) {
135
- console.error('Failed to parse MQTT message:', e);
136
- }
137
- });
138
- });
139
-
140
- this.mqttConnectionPromises[clientKey] = connectPromise; // Store the promise
141
- return connectPromise;
154
+ selectedEntry = matchedEntry;
155
+ }
156
+
157
+ const gcodeBuffer = await selectedEntry.async("nodebuffer");
158
+ const md5 = createHash("md5").update(gcodeBuffer).digest("hex");
159
+
160
+ return {
161
+ plateFileName: path.posix.basename(selectedEntry.name),
162
+ plateInternalPath: selectedEntry.name,
163
+ md5,
164
+ };
142
165
  }
143
166
 
144
- // Disconnect MQTT clients on shutdown
145
- async disconnectAllMqtt(): Promise<void> {
146
- console.log("Disconnecting all MQTT clients...");
147
- for (const key in this.mqttClients) {
148
- const client = this.mqttClients[key];
149
- if (client) {
150
- await new Promise<void>((resolve, reject) => {
151
- client.end(false, {}, (err) => {
152
- if (err) {
153
- console.error(`Error ending MQTT client ${key}:`, err);
154
- reject(err);
155
- } else {
156
- console.log(`MQTT client ${key} disconnected.`);
157
- resolve();
158
- }
159
- });
160
- });
161
- }
162
- }
163
- this.mqttClients = {};
164
- this.mqttConnectionPromises = {};
165
- }
166
-
167
- // --- getStatus (Limited Implementation) ---
168
167
  async getStatus(host: string, port: string, apiKey: string): Promise<any> {
169
168
  const [serial, token] = this.extractBambuCredentials(apiKey);
169
+
170
170
  try {
171
- // Ensure MQTT connection is attempted/established
172
- const client = await this.getMqttClient(host, serial, token);
173
- // Return minimal status indicating connection success
174
- // Full status requires implementing state storage based on /report topic messages
175
- return {
176
- status: client.connected ? "connected" : "connecting",
177
- serial: serial,
178
- note: "Full status details require MQTT state tracking implementation."
179
- };
171
+ const printer = await this.getPrinter(host, serial, token);
172
+
173
+ try {
174
+ await printer.executeCommand(new PushAllCommand());
175
+ } catch (error) {
176
+ console.warn("PushAllCommand failed, continuing with cached status", error);
177
+ }
178
+
179
+ if (!printer.data || Object.keys(printer.data).length === 0) {
180
+ await new Promise((resolve) => setTimeout(resolve, 1500));
181
+ }
182
+
183
+ const data = printer.data;
184
+
185
+ return {
186
+ status: data.gcode_state || "UNKNOWN",
187
+ connected: true,
188
+ temperatures: {
189
+ nozzle: {
190
+ actual: data.nozzle_temper || 0,
191
+ target: data.nozzle_target_temper || 0,
192
+ },
193
+ bed: {
194
+ actual: data.bed_temper || 0,
195
+ target: data.bed_target_temper || 0,
196
+ },
197
+ chamber: data.chamber_temper || data.frame_temper || 0,
198
+ },
199
+ print: {
200
+ filename: data.subtask_name || data.gcode_file || "None",
201
+ progress: data.mc_percent || 0,
202
+ timeRemaining: data.mc_remaining_time || 0,
203
+ currentLayer: data.layer_num || 0,
204
+ totalLayers: data.total_layer_num || 0,
205
+ },
206
+ ams: data.ams || null,
207
+ model: data.model || "Unknown",
208
+ serial,
209
+ raw: data,
210
+ };
180
211
  } catch (error) {
181
- console.error(`Failed to get MQTT client/status for ${serial}:`, error);
182
- return { status: "error", error: (error as Error).message };
212
+ console.error(`Failed to get Bambu status for ${serial}:`, error);
213
+ return { status: "error", connected: false, error: (error as Error).message };
183
214
  }
184
215
  }
185
-
186
- // --- Refactored print3mf using direct MQTT ---
187
- async print3mf(host: string, serial: string, token: string, options: BambuPrintOptionsInternal): Promise<any> {
188
-
189
- // Still use bambu-js for FTP upload
190
- const printerFTP = this.getBambuPrinterForFTP(host, serial, token);
191
- const remoteFilename = path.basename(options.filePath);
192
- // Ensure path uses forward slashes for printer consistency
193
- const remotePath = `gcodes/${remoteFilename}`.replace(/\\/g, '/');
194
-
195
- // 1. Connect FTP if necessary (via bambu-js helper)
196
- if (!printerFTP.isConnected) {
197
- console.log(`Connecting Bambu FTP ${host} (${serial}) on port 990 (assumed) before uploading...`);
198
- try {
199
- await printerFTP.connect(); // Connects MQTT and FTP
200
- console.log(`Connected Bambu FTP successfully.`);
201
- } catch (ftpConnectError) {
202
- console.error(`Bambu FTP Connection Error:`, ftpConnectError);
203
- throw new Error(`Failed to connect to Bambu FTP: ${(ftpConnectError as Error).message}`);
204
- }
205
- // await printerFTP.awaitInitialState(10000); // Might not be needed just for FTP
216
+
217
+ async print3mf(
218
+ host: string,
219
+ serial: string,
220
+ token: string,
221
+ options: BambuPrintOptionsInternal
222
+ ): Promise<any> {
223
+ if (!options.filePath.toLowerCase().endsWith(".3mf")) {
224
+ throw new Error("print3mf requires a .3mf input file.");
206
225
  }
207
226
 
208
- // 2. Upload the file via FTP
209
- console.log(`Uploading ${options.filePath} to ${remotePath} via FTP...`);
210
- try {
211
- await printerFTP.manipulateFiles(async (context: BambuFTP) => {
212
- await context.sendFile(options.filePath, remotePath);
213
- });
214
- console.log(`File ${remoteFilename} uploaded successfully.`);
215
- } catch (uploadError) {
216
- console.error(`FTP Upload Error:`, uploadError);
217
- throw new Error(`Failed to upload 3MF file via FTP: ${(uploadError as Error).message}`);
227
+ const projectMetadata = await this.resolveProjectFileMetadata(
228
+ options.filePath,
229
+ options.plateIndex
230
+ );
231
+
232
+ const remoteFileName = path.basename(options.filePath);
233
+ const remoteProjectPath = `cache/${remoteFileName}`;
234
+
235
+ // Upload via basic-ftp directly (bypasses bambu-js double-path bug)
236
+ await this.ftpUpload(host, token, options.filePath, `/cache/${remoteFileName}`);
237
+
238
+ // Send project_file command via bambu-node MQTT (bypasses bambu-js
239
+ // hardcoded use_ams=true and missing ams_mapping support)
240
+ const printer = await this.getPrinter(host, serial, token);
241
+ const md5 = options.md5 ?? projectMetadata.md5;
242
+
243
+ // Build AMS mapping per OpenBambuAPI spec: 5-element array
244
+ // [-1,-1,-1,-1,0] means slot 0 only; pad unused slots with -1
245
+ let amsMapping: number[];
246
+ if (options.amsMapping && options.amsMapping.length > 0) {
247
+ amsMapping = Array.from({ length: 5 }, (_, i) =>
248
+ i < options.amsMapping!.length ? options.amsMapping![i] : -1
249
+ );
250
+ } else {
251
+ amsMapping = [-1, -1, -1, -1, 0];
218
252
  }
219
-
220
- // 3. Get MQTT Client
221
- const mqttClient = await this.getMqttClient(host, serial, token);
222
253
 
223
- // 4. Construct the MQTT Print Command Payload based on OpenBambuAPI
224
- const commandPayload: BambuPrintCommandOptions = {
225
- sequence_id: Date.now().toString(),
254
+ const projectFileCmd = {
255
+ print: {
226
256
  command: "project_file",
227
- param: remotePath, // Path on SD card
228
- project_name: options.projectName,
229
- use_ams: options.amsMapping && options.amsMapping.length > 0 ? true : (options.useAMS ?? false),
230
- plate_idx: options.plateIndex ?? 0,
231
- bed_levelling: options.bedLeveling ?? true,
232
- flow_cali: options.flowCalibration ?? false,
233
- vibration_cali: options.vibrationCalibration ?? false,
234
- layer_inspect: options.layerInspect ?? false,
257
+ param: `Metadata/${projectMetadata.plateFileName}`,
258
+ url: `file:///sdcard/${remoteProjectPath}`,
259
+ subtask_name: options.projectName,
260
+ md5,
261
+ flow_cali: options.flowCalibration ?? true,
262
+ layer_inspect: options.layerInspect ?? true,
263
+ vibration_cali: options.vibrationCalibration ?? true,
264
+ bed_leveling: options.bedLeveling ?? true,
265
+ bed_type: options.bedType || "textured_plate",
235
266
  timelapse: options.timelapse ?? false,
236
- ams_mapping: options.amsMapping,
237
- md5: options.md5
267
+ use_ams: options.useAMS !== false,
268
+ ams_mapping: amsMapping,
269
+ profile_id: "0",
270
+ project_id: "0",
271
+ sequence_id: "0",
272
+ subtask_id: "0",
273
+ task_id: "0",
274
+ },
238
275
  };
239
-
240
- // Remove ams_mapping if not provided or empty
241
- if (!commandPayload.ams_mapping || commandPayload.ams_mapping.length === 0) {
242
- delete commandPayload.ams_mapping;
243
- // Also ensure use_ams is false if no mapping exists
244
- commandPayload.use_ams = false;
245
- }
246
- // Remove md5 if not provided
247
- if (!commandPayload.md5) {
248
- delete commandPayload.md5;
249
- }
250
276
 
251
- // 5. Publish the command
252
- return this.publishMqttCommand(mqttClient, serial, 'print', commandPayload);
277
+ await printer.publish(projectFileCmd);
278
+ await new Promise((resolve) => setTimeout(resolve, 300));
279
+
280
+ return {
281
+ status: "success",
282
+ message: `Uploaded and started 3MF print: ${options.projectName}`,
283
+ remoteProjectPath,
284
+ plateFile: projectMetadata.plateFileName,
285
+ platePath: projectMetadata.plateInternalPath,
286
+ md5,
287
+ amsMapping,
288
+ };
253
289
  }
254
-
255
- // --- Refactored cancelJob using MQTT ---
290
+
256
291
  async cancelJob(host: string, port: string, apiKey: string): Promise<any> {
257
292
  const [serial, token] = this.extractBambuCredentials(apiKey);
258
- const mqttClient = await this.getMqttClient(host, serial, token);
259
- // Command structure from OpenBambuAPI (assuming 'stop_print' is correct)
260
- const payload = { sequence_id: Date.now().toString(), command: "stop_print" };
261
- return this.publishMqttCommand(mqttClient, serial, 'print', payload);
293
+ const printer = await this.getPrinter(host, serial, token);
294
+
295
+ try {
296
+ await printer.executeCommand(new UpdateStateCommand({ state: "stop" }));
297
+ return { status: "success", message: "Cancel command sent successfully." };
298
+ } catch (error) {
299
+ throw new Error(`Failed to cancel print: ${(error as Error).message}`);
300
+ }
262
301
  }
263
-
264
- // --- setTemperature (still not directly possible via MQTT command) ---
265
- async setTemperature(host: string, port: string, apiKey: string, component: string, temperature: number) {
266
- console.warn("Setting temperatures directly is not supported via known Bambu MQTT commands. Use G-code.");
267
- // We could potentially implement a tool to send custom G-code?
268
- throw new Error("Direct temperature setting via MQTT is not supported.");
302
+
303
+ async setTemperature(
304
+ host: string,
305
+ port: string,
306
+ apiKey: string,
307
+ component: string,
308
+ temperature: number
309
+ ) {
310
+ const [serial, token] = this.extractBambuCredentials(apiKey);
311
+ const printer = await this.getPrinter(host, serial, token);
312
+
313
+ const normalizedComponent = component.toLowerCase();
314
+ const targetTemperature = Math.round(temperature);
315
+
316
+ if (targetTemperature < 0 || targetTemperature > 300) {
317
+ throw new Error("Temperature must be between 0 and 300°C.");
318
+ }
319
+
320
+ let gcode: string;
321
+ if (normalizedComponent === "bed") {
322
+ gcode = `M140 S${targetTemperature}`;
323
+ } else if (
324
+ normalizedComponent === "extruder" ||
325
+ normalizedComponent === "nozzle" ||
326
+ normalizedComponent === "tool" ||
327
+ normalizedComponent === "tool0"
328
+ ) {
329
+ gcode = `M104 S${targetTemperature}`;
330
+ } else {
331
+ throw new Error(
332
+ `Unsupported temperature component: ${component}. Use one of: bed, nozzle, extruder.`
333
+ );
334
+ }
335
+
336
+ await printer.executeCommand(new GCodeLineCommand({ gcodes: [gcode] }));
337
+ return {
338
+ status: "success",
339
+ message: `Temperature command sent for ${normalizedComponent}.`,
340
+ command: gcode,
341
+ };
269
342
  }
270
-
271
- // --- Other methods (getFiles, getFile, uploadFile, startJob) need review/refactoring ---
272
- // For now, keep using bambu-js FTP for file ops, but MQTT for commands
343
+
273
344
  async getFiles(host: string, port: string, apiKey: string) {
274
345
  const [serial, token] = this.extractBambuCredentials(apiKey);
275
- const printerFTP = this.getBambuPrinterForFTP(host, serial, token);
276
- if (!printerFTP.isConnected) await printerFTP.connect();
277
- const fileList: string[] = [];
278
- await printerFTP.manipulateFiles(async (context: BambuFTP) => {
279
- const files = await context.readDir("gcodes");
280
- fileList.push(...files);
346
+ const printer = new BambuPrinter(host, serial, token);
347
+ const directories = ["cache", "timelapse", "logs"];
348
+ const filesByDirectory: Record<string, string[]> = {};
349
+
350
+ await printer.manipulateFiles(async (context) => {
351
+ for (const directory of directories) {
352
+ try {
353
+ filesByDirectory[directory] = await context.readDir(directory);
354
+ } catch {
355
+ filesByDirectory[directory] = [];
356
+ }
357
+ }
281
358
  });
282
- return { files: fileList };
359
+
360
+ const files = Object.entries(filesByDirectory).flatMap(([directory, names]) =>
361
+ names.map((name) => `${directory}/${name}`)
362
+ );
363
+
364
+ return {
365
+ files,
366
+ directories: filesByDirectory,
367
+ };
283
368
  }
284
369
 
285
370
  async getFile(host: string, port: string, apiKey: string, filename: string) {
286
- // Still relies on checking existence via FTP
287
371
  const [serial, token] = this.extractBambuCredentials(apiKey);
288
- const printerFTP = this.getBambuPrinterForFTP(host, serial, token);
289
- if (!printerFTP.isConnected) await printerFTP.connect();
290
- let fileExists = false;
291
- await printerFTP.manipulateFiles(async (context: BambuFTP) => {
292
- const files = await context.readDir("gcodes");
293
- fileExists = files.includes(filename);
372
+ const printer = new BambuPrinter(host, serial, token);
373
+
374
+ const normalized = filename.replace(/^\/+/, "");
375
+ const directory = path.posix.dirname(normalized) === "." ? "cache" : path.posix.dirname(normalized);
376
+ const baseName = path.posix.basename(normalized);
377
+
378
+ let exists = false;
379
+
380
+ await printer.manipulateFiles(async (context) => {
381
+ const entries = await context.readDir(directory);
382
+ exists = entries.includes(baseName);
294
383
  });
295
- if (!fileExists) throw new Error(`File not found: ${filename}`);
296
- return { name: filename, exists: true };
384
+
385
+ return {
386
+ name: `${directory}/${baseName}`,
387
+ exists,
388
+ };
297
389
  }
298
390
 
299
- async uploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean) {
300
- // This method just uploads now, doesn't handle printing
301
- const [serial, token] = this.extractBambuCredentials(apiKey);
302
- const printerFTP = this.getBambuPrinterForFTP(host, serial, token);
303
- const remotePath = `gcodes/${filename}`.replace(/\\/g, '/');
304
- if (!printerFTP.isConnected) await printerFTP.connect();
305
- console.log(`Uploading ${filePath} to ${remotePath} via FTP...`);
306
- try {
307
- await printerFTP.manipulateFiles(async (context: BambuFTP) => {
308
- await context.sendFile(filePath, remotePath);
309
- });
310
- console.log(`File ${filename} uploaded successfully.`);
311
- if (print) {
312
- console.warn("'print=true' ignored in uploadFile for Bambu. Use print_3mf tool instead.");
313
- }
314
- return { status: "success", message: `File ${filename} uploaded successfully` };
315
- } catch (uploadError) {
316
- console.error(`FTP Upload Error:`, uploadError);
317
- throw new Error(`Failed to upload file via FTP: ${(uploadError as Error).message}`);
318
- }
391
+ async uploadFile(
392
+ host: string,
393
+ port: string,
394
+ apiKey: string,
395
+ filePath: string,
396
+ filename: string,
397
+ print: boolean
398
+ ) {
399
+ await fs.access(filePath);
400
+
401
+ const [serial, token] = this.extractBambuCredentials(apiKey);
402
+
403
+ const normalizedFileName = filename.replace(/^\/+/, "");
404
+ const remotePath = normalizedFileName.includes("/")
405
+ ? normalizedFileName
406
+ : `cache/${normalizedFileName}`;
407
+
408
+ // Use direct FTP upload (bypasses bambu-js double-path bug)
409
+ await this.ftpUpload(host, token, filePath, `/${remotePath}`);
410
+
411
+ const response: Record<string, unknown> = {
412
+ status: "success",
413
+ uploaded: true,
414
+ remotePath,
415
+ printRequested: print,
416
+ };
417
+
418
+ if (print) {
419
+ if (remotePath.toLowerCase().endsWith(".gcode")) {
420
+ response.startResult = await this.startJob(host, port, apiKey, remotePath);
421
+ } else if (remotePath.toLowerCase().endsWith(".3mf")) {
422
+ response.note =
423
+ "3MF upload complete. Use print_3mf to start a project print with plate and metadata options.";
424
+ } else {
425
+ throw new Error(
426
+ "Automatic print after upload supports .gcode only. Use print_3mf for .3mf project prints."
427
+ );
428
+ }
429
+ }
430
+
431
+ return response;
319
432
  }
320
433
 
321
434
  async startJob(host: string, port: string, apiKey: string, filename: string) {
322
- // This should likely be deprecated or only work for G-code files via a different MQTT command?
323
- console.warn("startJob is not recommended for Bambu .3mf files. Use print_3mf tool.");
324
- throw new Error("startJob is not implemented for Bambu printers using MQTT commands yet.");
435
+ if (filename.toLowerCase().endsWith(".3mf")) {
436
+ throw new Error("Use print_3mf for .3mf project files.");
437
+ }
438
+
439
+ const [serial, token] = this.extractBambuCredentials(apiKey);
440
+ const printer = await this.getPrinter(host, serial, token);
441
+
442
+ const normalizedFileName = filename.replace(/^\/+/, "");
443
+ const remoteFile = normalizedFileName.includes("/")
444
+ ? normalizedFileName
445
+ : `cache/${normalizedFileName}`;
446
+
447
+ await printer.executeCommand(new GCodeFileCommand({ fileName: remoteFile }));
448
+
449
+ return {
450
+ status: "success",
451
+ message: `Start command sent for ${remoteFile}.`,
452
+ file: remoteFile,
453
+ };
325
454
  }
326
-
327
- // --- Helper to publish MQTT commands ---
328
- private async publishMqttCommand(client: mqtt.MqttClient, serial: string, commandType: string, payload: any): Promise<any> {
329
- const topic = `device/${serial}/request`;
330
- const message = JSON.stringify({ [commandType]: payload });
331
-
332
- console.log(`Publishing MQTT to ${topic}: ${message}`);
333
-
334
- return new Promise((resolve, reject) => {
335
- client.publish(topic, message, { qos: 1 }, (err) => {
336
- if (err) {
337
- console.error(`MQTT Publish Error to ${topic}:`, err);
338
- reject(new Error(`Failed to publish MQTT command: ${err.message}`));
339
- } else {
340
- console.log(`MQTT command published successfully to ${topic}`);
341
- // Note: We don't wait for a response here. Status updates come via /report topic.
342
- resolve({ status: "success", message: "Command sent successfully via MQTT." });
343
- }
344
- });
455
+
456
+ /**
457
+ * Upload a file to the printer via FTP using basic-ftp directly.
458
+ * Bypasses bambu-js's sendFile which has a double-path bug (ensureDir CDs
459
+ * into the target directory, then uploadFrom uses the full relative path
460
+ * again, resulting in e.g. /cache/cache/file.3mf).
461
+ */
462
+ private async ftpUpload(
463
+ host: string,
464
+ token: string,
465
+ localPath: string,
466
+ remotePath: string
467
+ ): Promise<void> {
468
+ const client = new FTPClient(15_000);
469
+ try {
470
+ await client.access({
471
+ host,
472
+ port: 990,
473
+ user: "bblp",
474
+ password: token,
475
+ secure: "implicit",
476
+ secureOptions: { rejectUnauthorized: false },
345
477
  });
478
+ // Use absolute path to avoid CWD side-effects
479
+ const absoluteRemote = remotePath.startsWith("/") ? remotePath : `/${remotePath}`;
480
+ const remoteDir = path.posix.dirname(absoluteRemote);
481
+ await client.ensureDir(remoteDir);
482
+ // uploadFrom with just the basename since we're already in the right dir
483
+ await client.uploadFrom(localPath, path.posix.basename(absoluteRemote));
484
+ } finally {
485
+ client.close();
486
+ }
346
487
  }
347
488
 
348
- // Helper method to extract Bambu-specific credentials from apiKey
349
489
  private extractBambuCredentials(apiKey: string): [string, string] {
350
- const parts = apiKey.split(':');
351
- if (parts.length !== 2) {
352
- throw new Error("Invalid Bambu credentials format. Expected 'serial:token'");
490
+ const separatorIndex = apiKey.indexOf(":");
491
+
492
+ if (separatorIndex <= 0 || separatorIndex === apiKey.length - 1) {
493
+ throw new Error("Invalid Bambu credentials format. Expected 'serial:token'.");
353
494
  }
354
- return [parts[0], parts[1]];
495
+
496
+ const serial = apiKey.slice(0, separatorIndex).trim();
497
+ const token = apiKey.slice(separatorIndex + 1).trim();
498
+
499
+ if (!serial || !token) {
500
+ throw new Error("Invalid Bambu credentials format. Expected 'serial:token'.");
501
+ }
502
+
503
+ return [serial, token];
355
504
  }
356
- }
505
+
506
+ async disconnectAll(): Promise<void> {
507
+ await this.printerStore.disconnectAll();
508
+ }
509
+ }