mcp-3d-printer-server 1.0.3 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,276 +1,509 @@
1
1
  import { PrinterImplementation } from "../types.js";
2
- import {
3
- BambuClient,
4
- UpdateStateCommand,
5
- UpdateTempCommand,
6
- // State, // Removed as using string literal
7
- // TempUpdatePartType // Removed as likely unusable type
8
- } from "bambu-node"; // Use bambu-node library
9
- import * as path from 'path';
10
-
11
- // Define interface for print options matching bambu-node expectations
12
- // Note: Check ProjectFileCommand in bambu-node for exact structure
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
+
13
16
  interface BambuPrintOptionsInternal {
14
- projectName: string;
15
- filePath: string; // Path to the local .3mf file
16
- useAMS?: boolean;
17
- plateIndex?: number;
18
- bedLeveling?: boolean;
19
- flowCalibration?: boolean;
20
- vibrationCalibration?: boolean;
21
- layerInspect?: boolean;
22
- timelapse?: boolean;
23
- amsMapping?: { [originalFilamentIndex: string]: number }; // Map original filament index (string) to AMS slot number
24
- md5?: string; // Optional MD5 hash of the file
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;
29
+ }
30
+
31
+ interface ProjectFileMetadata {
32
+ plateFileName: string;
33
+ plateInternalPath: string;
34
+ md5: string;
25
35
  }
26
36
 
27
- // Store for bambu-node printer instances
28
37
  class BambuClientStore {
29
- private printers: Map<string, BambuClient> = new Map();
30
- private initialConnectionPromises: Map<string, Promise<void>> = new Map();
31
-
32
- async getPrinter(host: string, serial: string, token: string): Promise<BambuClient> {
33
- const key = `${host}-${serial}`;
34
-
35
- // If already connected/connecting, return existing instance or wait for connection
36
- if (this.printers.has(key)) {
37
- console.log(`Returning existing BambuClient instance for ${key}`);
38
- return this.printers.get(key)!;
39
- }
40
- if (this.initialConnectionPromises.has(key)) {
41
- console.log(`Waiting for existing initial connection attempt for ${key}...`);
42
- // Don't await here; if promise exists, listeners will handle map update
43
- // await this.initialConnectionPromises.get(key);
44
- // Throw error immediately if we know a connection is pending but not resolved yet
45
- // Or rely on the promise rejection propagating if it fails
46
- await this.initialConnectionPromises.get(key); // Await necessary to ensure connection completes or fails
47
- if (this.printers.has(key)) {
48
- return this.printers.get(key)!;
49
- } else {
50
- throw new Error(`Existing initial connection attempt for ${key} failed or timed out.`);
51
- }
52
- }
38
+ private printers: Map<string, BambuClient> = new Map();
39
+ private initialConnectionPromises: Map<string, Promise<void>> = new Map();
53
40
 
54
- // Create new instance and attempt connection
55
- console.log(`Creating new BambuClient instance for ${key}`);
56
- const printer = new BambuClient({
57
- host: host,
58
- serialNumber: serial,
59
- accessToken: token,
60
- });
61
-
62
- // Setup event listeners for state management
63
- printer.on('client:connect', () => {
64
- console.log(`BambuClient connected for ${key}`);
65
- this.printers.set(key, printer);
66
- this.initialConnectionPromises.delete(key);
67
- });
68
- printer.on('client:error', (err: Error) => {
69
- console.error(`BambuClient connection error for ${key}:`, err);
70
- this.printers.delete(key);
71
- this.initialConnectionPromises.delete(key);
72
- });
73
- printer.on('client:disconnect', () => {
74
- console.log(`BambuClient connection closed for ${key}`);
75
- this.printers.delete(key);
76
- this.initialConnectionPromises.delete(key);
77
- });
78
- printer.on('printer:dataUpdate', (data) => {
79
- // Optional: log or update internal state based on data
80
- // console.log(`BambuClient data update for ${key}`);
81
- });
82
-
83
- // Store promise and initiate connection
84
- console.log(`Attempting initial connection for BambuClient ${key}...`);
85
- const connectPromise = printer.connect().then(() => {});
86
- this.initialConnectionPromises.set(key, connectPromise);
41
+ async getPrinter(host: string, serial: string, token: string): Promise<BambuClient> {
42
+ const key = `${host}-${serial}`;
87
43
 
88
- try {
89
- await connectPromise;
90
- console.log(`Initial connection successful for ${key}.`);
91
- // Redundant set, already handled by 'client:connect' listener
92
- // this.printers.set(key, printer);
93
- return printer;
94
- } catch (err) {
95
- console.error(`Initial connection failed for ${key}:`, err);
96
- this.initialConnectionPromises.delete(key); // Clean up promise map on failure
97
- throw err; // Rethrow the error
98
- }
44
+ if (this.printers.has(key)) {
45
+ return this.printers.get(key)!;
99
46
  }
100
47
 
101
- async disconnectAll(): Promise<void> {
102
- console.log("Disconnecting all BambuClient instances...");
103
- const disconnectPromises: Promise<void>[] = [];
104
- for (const [key, printer] of this.printers.entries()) {
105
- disconnectPromises.push(
106
- printer.disconnect()
107
- .then(() => console.log(`Disconnected ${key}`))
108
- .catch(err => console.error(`Error disconnecting ${key}:`, err))
109
- );
110
- }
111
- await Promise.allSettled(disconnectPromises);
112
- this.printers.clear();
113
- this.initialConnectionPromises.clear();
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;
114
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
+ }
115
108
  }
116
109
 
117
110
  export class BambuImplementation extends PrinterImplementation {
118
- private printerStore: BambuClientStore; // Use correct store name
111
+ private printerStore: BambuClientStore;
119
112
 
120
- constructor(apiClient: any /* Not used */) {
113
+ constructor(apiClient: any) {
121
114
  super(apiClient);
122
115
  this.printerStore = new BambuClientStore();
123
116
  }
124
117
 
125
- // Helper to get connected printer instance
126
118
  private async getPrinter(host: string, serial: string, token: string): Promise<BambuClient> {
127
- return this.printerStore.getPrinter(host, serial, token);
119
+ return this.printerStore.getPrinter(host, serial, token);
120
+ }
121
+
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
+ }
138
+
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
+ );
152
+ }
153
+
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
+ };
128
165
  }
129
166
 
130
- // --- getStatus ---
131
167
  async getStatus(host: string, port: string, apiKey: string): Promise<any> {
132
168
  const [serial, token] = this.extractBambuCredentials(apiKey);
169
+
133
170
  try {
134
- const printer = await this.getPrinter(host, serial, token);
135
-
136
- // Wait a moment for status data to populate if needed
137
- if (!printer.data || Object.keys(printer.data).length === 0) {
138
- await new Promise(resolve => setTimeout(resolve, 2000));
139
- }
140
-
141
- const data = printer.data;
142
-
143
- return {
144
- status: data.gcode_state || "UNKNOWN",
145
- connected: true,
146
- temperatures: {
147
- nozzle: {
148
- actual: data.nozzle_temper || 0,
149
- target: data.nozzle_target_temper || 0
150
- },
151
- bed: {
152
- actual: data.bed_temper || 0,
153
- target: data.bed_target_temper || 0
154
- },
155
- chamber: data.chamber_temper || data.frame_temper || 0
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,
156
192
  },
157
- print: {
158
- filename: data.subtask_name || "None",
159
- progress: data.mc_percent || 0,
160
- timeRemaining: data.mc_remaining_time || 0,
161
- currentLayer: data.layer_num || 0,
162
- totalLayers: data.total_layer_num || 0
193
+ bed: {
194
+ actual: data.bed_temper || 0,
195
+ target: data.bed_target_temper || 0,
163
196
  },
164
- ams: data.ams || null,
165
- model: data.model || "Unknown",
166
- raw: data // Include raw data for debugging
167
- };
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
+ };
168
211
  } catch (error) {
169
- console.error(`Failed to get BambuClient status for ${serial}:`, error);
170
- return { status: "error", connected: false, 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 };
171
214
  }
172
215
  }
173
-
174
- // --- print3mf ---
175
- async print3mf(host: string, serial: string, token: string, options: BambuPrintOptionsInternal): Promise<any> {
176
- console.error("print3mf error: bambu-node library does not directly support the required FTPS file upload for .3mf files.");
177
- throw new Error("Printing .3mf files is not supported with the current bambu-node library integration due to missing FTPS capability.");
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.");
225
+ }
226
+
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];
252
+ }
253
+
254
+ const projectFileCmd = {
255
+ print: {
256
+ command: "project_file",
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",
266
+ timelapse: options.timelapse ?? false,
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
+ },
275
+ };
276
+
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
+ };
178
289
  }
179
-
180
- // --- cancelJob ---
290
+
181
291
  async cancelJob(host: string, port: string, apiKey: string): Promise<any> {
182
292
  const [serial, token] = this.extractBambuCredentials(apiKey);
183
293
  const printer = await this.getPrinter(host, serial, token);
184
-
185
- console.log(`Attempting to cancel print via bambu-node...`);
294
+
186
295
  try {
187
- // Use UpdateStateCommand with string literal state
188
- const command = new UpdateStateCommand({ state: 'stop' });
189
- await printer.executeCommand(command);
190
- console.log(`Cancel print command successful via bambu-node.`);
191
- return { status: "success", message: "Cancel command sent successfully." };
192
- } catch (cancelError) {
193
- console.error(`Error sending cancel command via bambu-node:`, cancelError);
194
- throw new Error(`Failed to cancel print: ${(cancelError as Error).message}`);
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}`);
195
300
  }
196
301
  }
197
-
198
- // --- setTemperature --- (Commenting out due to type issues)
199
- async setTemperature(host: string, port: string, apiKey: string, component: string, temperature: number) {
200
- // const [serial, token] = this.extractBambuCredentials(apiKey);
201
- // const printer = await this.getPrinter(host, serial, token);
202
- // console.log(`Attempting to set temperature for ${component} to ${temperature}°C via bambu-node...`);
203
- // try {
204
- // let command;
205
- // const partArg = component.toLowerCase();
206
- // if (partArg === 'extruder' || partArg === 'nozzle') {
207
- // // Use string literal for part. Cast temp due to strict lib types.
208
- // console.warn("Casting temperature to 'any' for UpdateTempCommand.");
209
- // command = new UpdateTempCommand({ part: 'nozzle', temperature: temperature as any });
210
- // } else if (partArg === 'bed') {
211
- // // Use string literal for part. Cast temp due to strict lib types.
212
- // console.warn("Casting temperature to 'any' for UpdateTempCommand.");
213
- // command = new UpdateTempCommand({ part: 'bed', temperature: temperature as any });
214
- // } else {
215
- // throw new Error(`Unsupported temperature component: ${component}. Use 'extruder' or 'bed'.`);
216
- // }
217
- // await printer.executeCommand(command);
218
- // console.log(`Set temperature command successful for ${component}.`);
219
- // return { status: "success", message: `Temperature set command for ${component} sent.` };
220
- // } catch (tempError) {
221
- // console.error(`Error sending set temperature command via bambu-node:`, tempError);
222
- // throw new Error(`Failed to set temperature: ${(tempError as Error).message}`);
223
- // }
224
- console.warn("setTemperature is currently disabled due to type inconsistencies in the bambu-node library.");
225
- throw new Error("setTemperature is disabled.");
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
+ };
226
342
  }
227
-
228
- // --- getFiles --- (Library doesn't seem to expose direct file listing)
343
+
229
344
  async getFiles(host: string, port: string, apiKey: string) {
230
- console.warn("File listing is not directly supported by bambu-node. Returning empty list.");
231
- return { files: [], note: "Not supported by bambu-node library" };
345
+ const [serial, token] = this.extractBambuCredentials(apiKey);
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
+ }
358
+ });
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
+ };
232
368
  }
233
369
 
234
- // --- getFile --- (Library doesn't seem to expose direct file metadata/download)
235
370
  async getFile(host: string, port: string, apiKey: string, filename: string) {
236
- console.warn("Getting individual file metadata/content is not directly supported by bambu-node.");
237
- return { name: filename, exists: false, note: "Not supported by bambu-node library" };
371
+ const [serial, token] = this.extractBambuCredentials(apiKey);
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);
383
+ });
384
+
385
+ return {
386
+ name: `${directory}/${baseName}`,
387
+ exists,
388
+ };
238
389
  }
239
390
 
240
- // --- uploadFile (Handled by print method if supported) ---
241
- async uploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean) {
242
- console.warn("Use the 'print_3mf' tool. Direct upload without print is not the primary use case.");
243
- if (print) {
244
- // Cannot directly call print3mf as it's not supported by this library
245
- throw new Error("Cannot initiate print via uploadFile as print_3mf is not supported by bambu-node.");
246
- // const [serial, token] = this.extractBambuCredentials(apiKey);
247
- // await this.print3mf(host, serial, token, {
248
- // filePath: filePath,
249
- // projectName: path.basename(filePath, path.extname(filePath))
250
- // });
251
- // return { status: "success", message: "Upload and print initiated via print_3mf." };
252
- } else {
253
- throw new Error("Uploading without printing is not supported. Use print_3mf (if supported).");
254
- }
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;
255
432
  }
256
433
 
257
- // --- startJob (Use print method via print_3mf if supported) ---
258
434
  async startJob(host: string, port: string, apiKey: string, filename: string) {
259
- console.warn("startJob is deprecated for Bambu. Use the 'print_3mf' tool (if supported).");
260
- throw new Error("startJob is deprecated for Bambu printers. Use print_3mf (if supported).");
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
+ };
454
+ }
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 },
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
+ }
261
487
  }
262
-
263
- // --- Helper to extract Bambu credentials ---
488
+
264
489
  private extractBambuCredentials(apiKey: string): [string, string] {
265
- const parts = apiKey.split(':');
266
- if (parts.length !== 2) {
267
- 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'.");
494
+ }
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'.");
268
501
  }
269
- return [parts[0], parts[1]];
502
+
503
+ return [serial, token];
270
504
  }
271
505
 
272
- // Method required by PrinterFactory, disconnects managed printers
273
506
  async disconnectAll(): Promise<void> {
274
- await this.printerStore.disconnectAll();
507
+ await this.printerStore.disconnectAll();
275
508
  }
276
- }
509
+ }