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.
- package/README.md +95 -51
- 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 -9
- package/dist/printers/bambu.js +260 -124
- 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/package.json +8 -4
- package/src/http-server.ts +328 -0
- package/src/index.ts +582 -50
- package/src/printers/bambu.ts +456 -223
- 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/dist/printers/bambu.d.ts
CHANGED
|
@@ -4,35 +4,49 @@ interface BambuPrintOptionsInternal {
|
|
|
4
4
|
filePath: string;
|
|
5
5
|
useAMS?: boolean;
|
|
6
6
|
plateIndex?: number;
|
|
7
|
+
bedType?: string;
|
|
7
8
|
bedLeveling?: boolean;
|
|
8
9
|
flowCalibration?: boolean;
|
|
9
10
|
vibrationCalibration?: boolean;
|
|
10
11
|
layerInspect?: boolean;
|
|
11
12
|
timelapse?: boolean;
|
|
12
|
-
amsMapping?:
|
|
13
|
-
[originalFilamentIndex: string]: number;
|
|
14
|
-
};
|
|
13
|
+
amsMapping?: number[];
|
|
15
14
|
md5?: string;
|
|
16
15
|
}
|
|
17
16
|
export declare class BambuImplementation extends PrinterImplementation {
|
|
18
17
|
private printerStore;
|
|
19
18
|
constructor(apiClient: any);
|
|
20
19
|
private getPrinter;
|
|
20
|
+
private resolveProjectFileMetadata;
|
|
21
21
|
getStatus(host: string, port: string, apiKey: string): Promise<any>;
|
|
22
22
|
print3mf(host: string, serial: string, token: string, options: BambuPrintOptionsInternal): Promise<any>;
|
|
23
23
|
cancelJob(host: string, port: string, apiKey: string): Promise<any>;
|
|
24
|
-
setTemperature(host: string, port: string, apiKey: string, component: string, temperature: number): Promise<
|
|
24
|
+
setTemperature(host: string, port: string, apiKey: string, component: string, temperature: number): Promise<{
|
|
25
|
+
status: string;
|
|
26
|
+
message: string;
|
|
27
|
+
command: string;
|
|
28
|
+
}>;
|
|
25
29
|
getFiles(host: string, port: string, apiKey: string): Promise<{
|
|
26
|
-
files:
|
|
27
|
-
|
|
30
|
+
files: string[];
|
|
31
|
+
directories: Record<string, string[]>;
|
|
28
32
|
}>;
|
|
29
33
|
getFile(host: string, port: string, apiKey: string, filename: string): Promise<{
|
|
30
34
|
name: string;
|
|
31
35
|
exists: boolean;
|
|
32
|
-
note: string;
|
|
33
36
|
}>;
|
|
34
|
-
uploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean): Promise<
|
|
35
|
-
startJob(host: string, port: string, apiKey: string, filename: string): Promise<
|
|
37
|
+
uploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean): Promise<Record<string, unknown>>;
|
|
38
|
+
startJob(host: string, port: string, apiKey: string, filename: string): Promise<{
|
|
39
|
+
status: string;
|
|
40
|
+
message: string;
|
|
41
|
+
file: string;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Upload a file to the printer via FTP using basic-ftp directly.
|
|
45
|
+
* Bypasses bambu-js's sendFile which has a double-path bug (ensureDir CDs
|
|
46
|
+
* into the target directory, then uploadFrom uses the full relative path
|
|
47
|
+
* again, resulting in e.g. /cache/cache/file.3mf).
|
|
48
|
+
*/
|
|
49
|
+
private ftpUpload;
|
|
36
50
|
private extractBambuCredentials;
|
|
37
51
|
disconnectAll(): Promise<void>;
|
|
38
52
|
}
|
package/dist/printers/bambu.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { PrinterImplementation } from "../types.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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";
|
|
7
9
|
class BambuClientStore {
|
|
8
10
|
constructor() {
|
|
9
11
|
this.printers = new Map();
|
|
@@ -11,76 +13,55 @@ class BambuClientStore {
|
|
|
11
13
|
}
|
|
12
14
|
async getPrinter(host, serial, token) {
|
|
13
15
|
const key = `${host}-${serial}`;
|
|
14
|
-
// If already connected/connecting, return existing instance or wait for connection
|
|
15
16
|
if (this.printers.has(key)) {
|
|
16
|
-
console.log(`Returning existing BambuClient instance for ${key}`);
|
|
17
17
|
return this.printers.get(key);
|
|
18
18
|
}
|
|
19
19
|
if (this.initialConnectionPromises.has(key)) {
|
|
20
|
-
|
|
21
|
-
// Don't await here; if promise exists, listeners will handle map update
|
|
22
|
-
// await this.initialConnectionPromises.get(key);
|
|
23
|
-
// Throw error immediately if we know a connection is pending but not resolved yet
|
|
24
|
-
// Or rely on the promise rejection propagating if it fails
|
|
25
|
-
await this.initialConnectionPromises.get(key); // Await necessary to ensure connection completes or fails
|
|
20
|
+
await this.initialConnectionPromises.get(key);
|
|
26
21
|
if (this.printers.has(key)) {
|
|
27
22
|
return this.printers.get(key);
|
|
28
23
|
}
|
|
29
|
-
|
|
30
|
-
throw new Error(`Existing initial connection attempt for ${key} failed or timed out.`);
|
|
31
|
-
}
|
|
24
|
+
throw new Error(`Existing Bambu client connection for ${key} failed.`);
|
|
32
25
|
}
|
|
33
|
-
// Create new instance and attempt connection
|
|
34
|
-
console.log(`Creating new BambuClient instance for ${key}`);
|
|
35
26
|
const printer = new BambuClient({
|
|
36
|
-
host
|
|
27
|
+
host,
|
|
37
28
|
serialNumber: serial,
|
|
38
29
|
accessToken: token,
|
|
39
30
|
});
|
|
40
|
-
|
|
41
|
-
printer.on('client:connect', () => {
|
|
42
|
-
console.log(`BambuClient connected for ${key}`);
|
|
31
|
+
printer.on("client:connect", () => {
|
|
43
32
|
this.printers.set(key, printer);
|
|
44
33
|
this.initialConnectionPromises.delete(key);
|
|
45
34
|
});
|
|
46
|
-
printer.on(
|
|
47
|
-
console.error(`BambuClient connection error for ${key}:`, err);
|
|
35
|
+
printer.on("client:error", () => {
|
|
48
36
|
this.printers.delete(key);
|
|
49
37
|
this.initialConnectionPromises.delete(key);
|
|
50
38
|
});
|
|
51
|
-
printer.on(
|
|
52
|
-
console.log(`BambuClient connection closed for ${key}`);
|
|
39
|
+
printer.on("client:disconnect", () => {
|
|
53
40
|
this.printers.delete(key);
|
|
54
41
|
this.initialConnectionPromises.delete(key);
|
|
55
42
|
});
|
|
56
|
-
printer.on('printer:dataUpdate', (data) => {
|
|
57
|
-
// Optional: log or update internal state based on data
|
|
58
|
-
// console.log(`BambuClient data update for ${key}`);
|
|
59
|
-
});
|
|
60
|
-
// Store promise and initiate connection
|
|
61
|
-
console.log(`Attempting initial connection for BambuClient ${key}...`);
|
|
62
43
|
const connectPromise = printer.connect().then(() => { });
|
|
63
44
|
this.initialConnectionPromises.set(key, connectPromise);
|
|
64
45
|
try {
|
|
65
46
|
await connectPromise;
|
|
66
|
-
console.log(`Initial connection successful for ${key}.`);
|
|
67
|
-
// Redundant set, already handled by 'client:connect' listener
|
|
68
|
-
// this.printers.set(key, printer);
|
|
69
47
|
return printer;
|
|
70
48
|
}
|
|
71
|
-
catch (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
throw err; // Rethrow the error
|
|
49
|
+
catch (error) {
|
|
50
|
+
this.initialConnectionPromises.delete(key);
|
|
51
|
+
throw error;
|
|
75
52
|
}
|
|
76
53
|
}
|
|
77
54
|
async disconnectAll() {
|
|
78
|
-
console.log("Disconnecting all BambuClient instances...");
|
|
79
55
|
const disconnectPromises = [];
|
|
80
|
-
for (const
|
|
81
|
-
disconnectPromises.push(
|
|
82
|
-
|
|
83
|
-
|
|
56
|
+
for (const printer of this.printers.values()) {
|
|
57
|
+
disconnectPromises.push((async () => {
|
|
58
|
+
try {
|
|
59
|
+
await printer.disconnect();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
console.error("Failed to disconnect Bambu client", error);
|
|
63
|
+
}
|
|
64
|
+
})());
|
|
84
65
|
}
|
|
85
66
|
await Promise.allSettled(disconnectPromises);
|
|
86
67
|
this.printers.clear();
|
|
@@ -88,22 +69,50 @@ class BambuClientStore {
|
|
|
88
69
|
}
|
|
89
70
|
}
|
|
90
71
|
export class BambuImplementation extends PrinterImplementation {
|
|
91
|
-
constructor(apiClient
|
|
72
|
+
constructor(apiClient) {
|
|
92
73
|
super(apiClient);
|
|
93
74
|
this.printerStore = new BambuClientStore();
|
|
94
75
|
}
|
|
95
|
-
// Helper to get connected printer instance
|
|
96
76
|
async getPrinter(host, serial, token) {
|
|
97
77
|
return this.printerStore.getPrinter(host, serial, token);
|
|
98
78
|
}
|
|
99
|
-
|
|
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}`);
|
|
93
|
+
}
|
|
94
|
+
selectedEntry = matchedEntry;
|
|
95
|
+
}
|
|
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
|
+
}
|
|
100
104
|
async getStatus(host, port, apiKey) {
|
|
101
105
|
const [serial, token] = this.extractBambuCredentials(apiKey);
|
|
102
106
|
try {
|
|
103
107
|
const printer = await this.getPrinter(host, serial, token);
|
|
104
|
-
|
|
108
|
+
try {
|
|
109
|
+
await printer.executeCommand(new PushAllCommand());
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
console.warn("PushAllCommand failed, continuing with cached status", error);
|
|
113
|
+
}
|
|
105
114
|
if (!printer.data || Object.keys(printer.data).length === 0) {
|
|
106
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
107
116
|
}
|
|
108
117
|
const data = printer.data;
|
|
109
118
|
return {
|
|
@@ -112,123 +121,250 @@ export class BambuImplementation extends PrinterImplementation {
|
|
|
112
121
|
temperatures: {
|
|
113
122
|
nozzle: {
|
|
114
123
|
actual: data.nozzle_temper || 0,
|
|
115
|
-
target: data.nozzle_target_temper || 0
|
|
124
|
+
target: data.nozzle_target_temper || 0,
|
|
116
125
|
},
|
|
117
126
|
bed: {
|
|
118
127
|
actual: data.bed_temper || 0,
|
|
119
|
-
target: data.bed_target_temper || 0
|
|
128
|
+
target: data.bed_target_temper || 0,
|
|
120
129
|
},
|
|
121
|
-
chamber: data.chamber_temper || data.frame_temper || 0
|
|
130
|
+
chamber: data.chamber_temper || data.frame_temper || 0,
|
|
122
131
|
},
|
|
123
132
|
print: {
|
|
124
|
-
filename: data.subtask_name || "None",
|
|
133
|
+
filename: data.subtask_name || data.gcode_file || "None",
|
|
125
134
|
progress: data.mc_percent || 0,
|
|
126
135
|
timeRemaining: data.mc_remaining_time || 0,
|
|
127
136
|
currentLayer: data.layer_num || 0,
|
|
128
|
-
totalLayers: data.total_layer_num || 0
|
|
137
|
+
totalLayers: data.total_layer_num || 0,
|
|
129
138
|
},
|
|
130
139
|
ams: data.ams || null,
|
|
131
140
|
model: data.model || "Unknown",
|
|
132
|
-
|
|
141
|
+
serial,
|
|
142
|
+
raw: data,
|
|
133
143
|
};
|
|
134
144
|
}
|
|
135
145
|
catch (error) {
|
|
136
|
-
console.error(`Failed to get
|
|
146
|
+
console.error(`Failed to get Bambu status for ${serial}:`, error);
|
|
137
147
|
return { status: "error", connected: false, error: error.message };
|
|
138
148
|
}
|
|
139
149
|
}
|
|
140
|
-
// --- print3mf ---
|
|
141
150
|
async print3mf(host, serial, token, options) {
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
if (!options.filePath.toLowerCase().endsWith(".3mf")) {
|
|
152
|
+
throw new Error("print3mf requires a .3mf input file.");
|
|
153
|
+
}
|
|
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);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
amsMapping = [-1, -1, -1, -1, 0];
|
|
171
|
+
}
|
|
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,
|
|
204
|
+
};
|
|
144
205
|
}
|
|
145
|
-
// --- cancelJob ---
|
|
146
206
|
async cancelJob(host, port, apiKey) {
|
|
147
207
|
const [serial, token] = this.extractBambuCredentials(apiKey);
|
|
148
208
|
const printer = await this.getPrinter(host, serial, token);
|
|
149
|
-
console.log(`Attempting to cancel print via bambu-node...`);
|
|
150
209
|
try {
|
|
151
|
-
|
|
152
|
-
const command = new UpdateStateCommand({ state: 'stop' });
|
|
153
|
-
await printer.executeCommand(command);
|
|
154
|
-
console.log(`Cancel print command successful via bambu-node.`);
|
|
210
|
+
await printer.executeCommand(new UpdateStateCommand({ state: "stop" }));
|
|
155
211
|
return { status: "success", message: "Cancel command sent successfully." };
|
|
156
212
|
}
|
|
157
|
-
catch (
|
|
158
|
-
|
|
159
|
-
throw new Error(`Failed to cancel print: ${cancelError.message}`);
|
|
213
|
+
catch (error) {
|
|
214
|
+
throw new Error(`Failed to cancel print: ${error.message}`);
|
|
160
215
|
}
|
|
161
216
|
}
|
|
162
|
-
// --- setTemperature --- (Commenting out due to type issues)
|
|
163
217
|
async setTemperature(host, port, apiKey, component, temperature) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
// --- getFiles --- (Library doesn't seem to expose direct file listing)
|
|
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
|
+
};
|
|
244
|
+
}
|
|
192
245
|
async getFiles(host, port, apiKey) {
|
|
193
|
-
|
|
194
|
-
|
|
246
|
+
const [serial, token] = this.extractBambuCredentials(apiKey);
|
|
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
|
+
}
|
|
259
|
+
});
|
|
260
|
+
const files = Object.entries(filesByDirectory).flatMap(([directory, names]) => names.map((name) => `${directory}/${name}`));
|
|
261
|
+
return {
|
|
262
|
+
files,
|
|
263
|
+
directories: filesByDirectory,
|
|
264
|
+
};
|
|
195
265
|
}
|
|
196
|
-
// --- getFile --- (Library doesn't seem to expose direct file metadata/download)
|
|
197
266
|
async getFile(host, port, apiKey, filename) {
|
|
198
|
-
|
|
199
|
-
|
|
267
|
+
const [serial, token] = this.extractBambuCredentials(apiKey);
|
|
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);
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
name: `${directory}/${baseName}`,
|
|
279
|
+
exists,
|
|
280
|
+
};
|
|
200
281
|
}
|
|
201
|
-
// --- uploadFile (Handled by print method if supported) ---
|
|
202
282
|
async uploadFile(host, port, apiKey, filePath, filename, print) {
|
|
203
|
-
|
|
283
|
+
await fs.access(filePath);
|
|
284
|
+
const [serial, token] = this.extractBambuCredentials(apiKey);
|
|
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
|
+
};
|
|
204
297
|
if (print) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
throw new Error("Uploading without printing is not supported. Use print_3mf (if supported).");
|
|
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.");
|
|
307
|
+
}
|
|
216
308
|
}
|
|
309
|
+
return response;
|
|
217
310
|
}
|
|
218
|
-
// --- startJob (Use print method via print_3mf if supported) ---
|
|
219
311
|
async startJob(host, port, apiKey, filename) {
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
};
|
|
327
|
+
}
|
|
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 },
|
|
344
|
+
});
|
|
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
|
+
}
|
|
222
355
|
}
|
|
223
|
-
// --- Helper to extract Bambu credentials ---
|
|
224
356
|
extractBambuCredentials(apiKey) {
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
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'.");
|
|
360
|
+
}
|
|
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'.");
|
|
228
365
|
}
|
|
229
|
-
return [
|
|
366
|
+
return [serial, token];
|
|
230
367
|
}
|
|
231
|
-
// Method required by PrinterFactory, disconnects managed printers
|
|
232
368
|
async disconnectAll() {
|
|
233
369
|
await this.printerStore.disconnectAll();
|
|
234
370
|
}
|
package/dist/printers/prusa.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { PrinterImplementation } from "../types.js";
|
|
2
2
|
export declare class PrusaImplementation extends PrinterImplementation {
|
|
3
|
+
private buildAuthHeaders;
|
|
4
|
+
private buildBaseUrl;
|
|
5
|
+
private isFallbackStatus;
|
|
6
|
+
private getWithFallback;
|
|
7
|
+
private postWithFallback;
|
|
3
8
|
getStatus(host: string, port: string, apiKey: string): Promise<any>;
|
|
4
9
|
getFiles(host: string, port: string, apiKey: string): Promise<any>;
|
|
5
10
|
getFile(host: string, port: string, apiKey: string, filename: string): Promise<any>;
|