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