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.
- package/README.md +105 -29
- package/dist/http-server.d.ts +16 -0
- package/dist/http-server.js +268 -0
- package/dist/index.js +462 -37
- package/dist/printers/bambu.d.ts +23 -12
- package/dist/printers/bambu.js +319 -232
- package/dist/printers/printer-factory.d.ts +0 -1
- package/dist/printers/printer-factory.js +6 -4
- package/dist/printers/prusa.d.ts +5 -0
- package/dist/printers/prusa.js +160 -59
- package/dist/sse-server.d.ts +20 -0
- package/dist/sse-server.js +318 -0
- package/dist/stl/stl-manipulator.d.ts +5 -5
- package/dist/stl/stl-manipulator.js +32 -7
- package/dist/types.d.ts +0 -6
- package/dist/types.js +0 -20
- package/package.json +10 -4
- package/src/http-server.ts +328 -0
- package/src/index.ts +582 -50
- package/src/printers/bambu.ts +452 -299
- package/src/printers/printer-factory.ts +7 -4
- package/src/printers/prusa.ts +218 -73
- package/src/sse-server.ts +409 -0
- package/src/stl/stl-manipulator.ts +38 -8
- package/src/test/Tape+Card+55mm+Ring.stl +0 -0
- package/src/types.ts +0 -19
package/src/printers/bambu.ts
CHANGED
|
@@ -1,356 +1,509 @@
|
|
|
1
|
-
import { PrinterImplementation
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
113
|
+
constructor(apiClient: any) {
|
|
60
114
|
super(apiClient);
|
|
61
|
-
this.
|
|
115
|
+
this.printerStore = new BambuClientStore();
|
|
62
116
|
}
|
|
63
117
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
sequence_id: Date.now().toString(),
|
|
254
|
+
const projectFileCmd = {
|
|
255
|
+
print: {
|
|
226
256
|
command: "project_file",
|
|
227
|
-
param:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
vibration_cali: options.vibrationCalibration ??
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
return {
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
name: `${directory}/${baseName}`,
|
|
387
|
+
exists,
|
|
388
|
+
};
|
|
297
389
|
}
|
|
298
390
|
|
|
299
|
-
async uploadFile(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
+
}
|