mcp-3d-printer-server 1.2.5 → 1.2.11
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/CHANGELOG.md +228 -0
- package/CONTRIBUTORS.md +49 -0
- package/README.md +529 -739
- package/dist/3mf_parser.js +8 -8
- package/dist/blender-mcp-bridge.d.ts +11 -0
- package/dist/blender-mcp-bridge.js +661 -0
- package/dist/fulu-orca.js +29 -5
- package/dist/index.js +752 -212
- package/dist/printers/bambu-node-guard.d.ts +2 -0
- package/dist/printers/bambu-node-guard.js +38 -0
- package/dist/printers/bambu.d.ts +53 -7
- package/dist/printers/bambu.js +420 -120
- package/dist/printers/creality.d.ts +15 -6
- package/dist/printers/creality.js +42 -9
- package/dist/printers/duet.d.ts +11 -6
- package/dist/printers/duet.js +57 -34
- package/dist/printers/generic-base.d.ts +69 -0
- package/dist/printers/generic-base.js +209 -0
- package/dist/printers/klipper.d.ts +17 -6
- package/dist/printers/klipper.js +63 -16
- package/dist/printers/octoprint.d.ts +18 -6
- package/dist/printers/octoprint.js +64 -9
- package/dist/printers/printer-factory.d.ts +11 -2
- package/dist/printers/printer-factory.js +9 -8
- package/dist/printers/prusa.d.ts +14 -6
- package/dist/printers/prusa.js +56 -9
- package/dist/printers/repetier.d.ts +18 -6
- package/dist/printers/repetier.js +48 -6
- package/dist/safety/archive.d.ts +11 -0
- package/dist/safety/archive.js +214 -0
- package/dist/safety/artifact.d.ts +6 -0
- package/dist/safety/artifact.js +66 -0
- package/dist/safety/confirmation.d.ts +22 -0
- package/dist/safety/confirmation.js +43 -0
- package/dist/safety/expected-peaks.d.ts +24 -0
- package/dist/safety/expected-peaks.js +24 -0
- package/dist/safety/gcode-thermal.d.ts +64 -0
- package/dist/safety/gcode-thermal.js +382 -0
- package/dist/safety/limits.d.ts +31 -0
- package/dist/safety/limits.js +105 -0
- package/dist/safety/print-file.d.ts +25 -0
- package/dist/safety/print-file.js +517 -0
- package/dist/safety/printer-state.d.ts +51 -0
- package/dist/safety/printer-state.js +365 -0
- package/dist/slicer/bambu-cli.d.ts +110 -0
- package/dist/slicer/bambu-cli.js +547 -0
- package/dist/slicer/profile-flatten.d.ts +104 -0
- package/dist/slicer/profile-flatten.js +676 -0
- package/dist/slicer/run-slicer.d.ts +18 -0
- package/dist/slicer/run-slicer.js +102 -0
- package/dist/slicer/slicer-error.d.ts +27 -0
- package/dist/slicer/slicer-error.js +53 -0
- package/dist/slicer/templates.d.ts +126 -0
- package/dist/slicer/templates.js +255 -0
- package/dist/stl/stl-manipulator.d.ts +25 -3
- package/dist/stl/stl-manipulator.js +214 -232
- package/package.json +30 -14
- package/server.json +29 -0
- package/src/3mf_parser.ts +8 -8
- package/src/blender-mcp-bridge.ts +611 -0
- package/src/fulu-orca.ts +36 -6
- package/src/index.ts +888 -255
- package/src/printers/bambu-node-guard.ts +44 -0
- package/src/printers/bambu.ts +458 -158
- package/src/printers/creality.ts +40 -9
- package/src/printers/duet.ts +64 -53
- package/src/printers/generic-base.ts +286 -0
- package/src/printers/klipper.ts +83 -28
- package/src/printers/octoprint.ts +74 -21
- package/src/printers/printer-factory.ts +21 -11
- package/src/printers/prusa.ts +53 -10
- package/src/printers/repetier.ts +55 -17
- package/src/safety/archive.ts +181 -0
- package/src/safety/artifact.ts +58 -0
- package/src/safety/confirmation.ts +56 -0
- package/src/safety/expected-peaks.ts +47 -0
- package/src/safety/gcode-thermal.ts +404 -0
- package/src/safety/limits.ts +98 -0
- package/src/safety/print-file.ts +354 -0
- package/src/safety/printer-state.ts +339 -0
- package/src/slicer/bambu-cli.ts +633 -0
- package/src/slicer/profile-flatten.ts +830 -0
- package/src/slicer/run-slicer.ts +126 -0
- package/src/slicer/slicer-error.ts +72 -0
- package/src/slicer/templates.ts +288 -0
- package/src/stl/stl-manipulator.ts +247 -275
- package/dist/index.js.map +0 -1
package/src/printers/klipper.ts
CHANGED
|
@@ -1,65 +1,120 @@
|
|
|
1
|
-
import { PrinterImplementation } from "../types.js";
|
|
2
1
|
import fs from "fs";
|
|
3
2
|
import FormData from "form-data";
|
|
3
|
+
import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
|
|
4
|
+
|
|
5
|
+
function encodePath(filename: string): string {
|
|
6
|
+
return filename.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class KlipperImplementation extends GenericPrinterImplementation {
|
|
10
|
+
protected readonly printerLabel = "Klipper/Moonraker";
|
|
11
|
+
|
|
12
|
+
protected authHeaders(apiKey: string): Record<string, unknown> {
|
|
13
|
+
return apiKey ? { headers: { "X-Api-Key": apiKey } } : {};
|
|
14
|
+
}
|
|
4
15
|
|
|
5
|
-
export class KlipperImplementation extends PrinterImplementation {
|
|
6
16
|
async getStatus(host: string, port: string, apiKey: string) {
|
|
7
17
|
const url = `http://${host}:${port}/printer/info`;
|
|
8
|
-
const response = await this.apiClient.get(url);
|
|
18
|
+
const response = await this.apiClient.get(url, this.authHeaders(apiKey));
|
|
9
19
|
return response.data;
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
async getFiles(host: string, port: string, apiKey: string) {
|
|
13
23
|
const url = `http://${host}:${port}/server/files/list`;
|
|
14
|
-
const response = await this.apiClient.get(url);
|
|
24
|
+
const response = await this.apiClient.get(url, this.authHeaders(apiKey));
|
|
15
25
|
return response.data;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
async getFile(host: string, port: string, apiKey: string, filename: string) {
|
|
19
29
|
const url = `http://${host}:${port}/server/files/metadata?filename=${encodeURIComponent(filename)}`;
|
|
20
|
-
const response = await this.apiClient.get(url);
|
|
30
|
+
const response = await this.apiClient.get(url, this.authHeaders(apiKey));
|
|
21
31
|
return response.data;
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Moonraker object query: webhooks.state must be "ready" (not startup,
|
|
36
|
+
* shutdown or error) and print_stats.state standby/complete/cancelled.
|
|
37
|
+
* complete/cancelled can leave a part on the bed, so they need a human.
|
|
38
|
+
*/
|
|
39
|
+
protected async readPrinterState(host: string, port: string, apiKey: string): Promise<GenericPrinterState> {
|
|
40
|
+
let data: any;
|
|
41
|
+
try {
|
|
42
|
+
data = (await this.apiClient.get(
|
|
43
|
+
`http://${host}:${port}/printer/objects/query?webhooks&print_stats`,
|
|
44
|
+
this.authHeaders(apiKey)
|
|
45
|
+
)).data;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new Error(`Cannot read Klipper/Moonraker printer state; nothing was sent: ${(error as Error).message}`);
|
|
48
|
+
}
|
|
49
|
+
const status = data?.result?.status;
|
|
50
|
+
const klippy = status?.webhooks?.state;
|
|
51
|
+
const job = status?.print_stats?.state;
|
|
52
|
+
if (typeof klippy !== "string" || typeof job !== "string") {
|
|
53
|
+
throw new Error("Moonraker did not report webhooks.state and print_stats.state; cannot verify the printer is idle.");
|
|
54
|
+
}
|
|
55
|
+
const state = `klippy ${klippy}, print ${job}`;
|
|
56
|
+
if (klippy !== "ready") {
|
|
57
|
+
return { state, ready: false, reason: status?.webhooks?.state_message ? String(status.webhooks.state_message) : "Klipper is not ready" };
|
|
58
|
+
}
|
|
59
|
+
if (!["standby", "complete", "cancelled"].includes(job)) {
|
|
60
|
+
return { state, ready: false, reason: `print_stats.state is ${job}` };
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
state,
|
|
64
|
+
ready: true,
|
|
65
|
+
finishedJob: job === "standby" ? undefined : JSON.stringify([job, status?.print_stats?.filename ?? null]),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected async downloadRemoteFile(host: string, port: string, apiKey: string, filename: string, destination: string): Promise<void> {
|
|
70
|
+
await this.downloadToFile(
|
|
71
|
+
`http://${host}:${port}/server/files/gcodes/${encodePath(filename)}`,
|
|
72
|
+
apiKey ? { "X-Api-Key": apiKey } : {},
|
|
73
|
+
destination
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Current Moonraker returns {result:{item}}; older releases returned result "success". */
|
|
78
|
+
protected uploadSucceeded(data: any): boolean {
|
|
79
|
+
return data?.result === "success" || !!data?.result?.item || !!data?.item;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protected async rawUploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean) {
|
|
25
83
|
const url = `http://${host}:${port}/server/files/upload`;
|
|
26
|
-
|
|
84
|
+
|
|
27
85
|
const formData = new FormData();
|
|
28
86
|
formData.append("file", fs.createReadStream(filePath));
|
|
29
87
|
formData.append("filename", filename);
|
|
30
|
-
|
|
88
|
+
|
|
31
89
|
const response = await this.apiClient.post(url, formData as any, {
|
|
32
90
|
headers: {
|
|
33
|
-
...formData.getHeaders()
|
|
91
|
+
...formData.getHeaders(),
|
|
92
|
+
...(apiKey ? { "X-Api-Key": apiKey } : {})
|
|
34
93
|
}
|
|
35
94
|
});
|
|
36
|
-
|
|
37
|
-
if (print && response.data.result === "success") {
|
|
38
|
-
await this.startJob(host, port, apiKey, filename);
|
|
39
|
-
}
|
|
40
|
-
|
|
95
|
+
|
|
41
96
|
return response.data;
|
|
42
97
|
}
|
|
43
98
|
|
|
44
|
-
async
|
|
99
|
+
protected async rawStartJob(host: string, port: string, apiKey: string, filename: string) {
|
|
45
100
|
const url = `http://${host}:${port}/printer/print/start`;
|
|
46
|
-
|
|
47
|
-
const response = await this.apiClient.post(url, { filename } as any);
|
|
48
|
-
|
|
101
|
+
|
|
102
|
+
const response = await this.apiClient.post(url, { filename } as any, this.authHeaders(apiKey));
|
|
103
|
+
|
|
49
104
|
return response.data;
|
|
50
105
|
}
|
|
51
106
|
|
|
52
|
-
async
|
|
107
|
+
protected async rawCancelJob(host: string, port: string, apiKey: string) {
|
|
53
108
|
const url = `http://${host}:${port}/printer/print/cancel`;
|
|
54
|
-
|
|
55
|
-
const response = await this.apiClient.post(url, null as any);
|
|
56
|
-
|
|
109
|
+
|
|
110
|
+
const response = await this.apiClient.post(url, null as any, this.authHeaders(apiKey));
|
|
111
|
+
|
|
57
112
|
return response.data;
|
|
58
113
|
}
|
|
59
114
|
|
|
60
|
-
async
|
|
115
|
+
protected async rawSetTemperature(host: string, port: string, apiKey: string, component: string, temperature: number) {
|
|
61
116
|
const url = `http://${host}:${port}/printer/gcode/script`;
|
|
62
|
-
|
|
117
|
+
|
|
63
118
|
let gcode;
|
|
64
119
|
if (component === "bed") {
|
|
65
120
|
gcode = `SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=${temperature}`;
|
|
@@ -68,11 +123,11 @@ export class KlipperImplementation extends PrinterImplementation {
|
|
|
68
123
|
} else {
|
|
69
124
|
throw new Error(`Unsupported component: ${component}`);
|
|
70
125
|
}
|
|
71
|
-
|
|
126
|
+
|
|
72
127
|
const response = await this.apiClient.post(url, {
|
|
73
128
|
script: gcode
|
|
74
|
-
} as any);
|
|
75
|
-
|
|
129
|
+
} as any, this.authHeaders(apiKey));
|
|
130
|
+
|
|
76
131
|
return response.data;
|
|
77
132
|
}
|
|
78
|
-
}
|
|
133
|
+
}
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { PrinterImplementation } from "../types.js";
|
|
2
1
|
import fs from "fs";
|
|
3
2
|
import FormData from "form-data";
|
|
3
|
+
import { isAxiosError } from "axios";
|
|
4
|
+
import { GenericPrinterImplementation, type GenericPrinterState, type HeaterComponent } from "./generic-base.js";
|
|
5
|
+
|
|
6
|
+
const BLOCKING_FLAGS = ["printing", "paused", "pausing", "cancelling", "resuming", "finishing", "error", "closedOrError"];
|
|
7
|
+
|
|
8
|
+
function encodePath(filename: string): string {
|
|
9
|
+
return filename.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class OctoPrintImplementation extends GenericPrinterImplementation {
|
|
13
|
+
protected readonly printerLabel = "OctoPrint";
|
|
14
|
+
// POST /api/files/local with print=true uploads and starts the same bytes.
|
|
15
|
+
protected readonly uploadStartsPrint = true;
|
|
4
16
|
|
|
5
|
-
export class OctoPrintImplementation extends PrinterImplementation {
|
|
6
17
|
async getStatus(host: string, port: string, apiKey: string) {
|
|
7
18
|
const url = `http://${host}:${port}/api/printer`;
|
|
8
19
|
const response = await this.apiClient.get(url, {
|
|
@@ -33,30 +44,63 @@ export class OctoPrintImplementation extends PrinterImplementation {
|
|
|
33
44
|
return response.data;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
|
-
|
|
47
|
+
/**
|
|
48
|
+
* GET /api/printer state.flags (OctoPrint REST API). A 409 means the printer
|
|
49
|
+
* is not operational. Informational flags such as sdReady never block.
|
|
50
|
+
*/
|
|
51
|
+
protected async readPrinterState(host: string, port: string, apiKey: string): Promise<GenericPrinterState> {
|
|
52
|
+
let data: any;
|
|
53
|
+
try {
|
|
54
|
+
data = (await this.apiClient.get(`http://${host}:${port}/api/printer`, { headers: { "X-Api-Key": apiKey } })).data;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (isAxiosError(error) && error.response?.status === 409) {
|
|
57
|
+
return { state: "Offline", ready: false, reason: "OctoPrint reports the printer is not operational" };
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`Cannot read OctoPrint printer state; nothing was sent: ${(error as Error).message}`);
|
|
60
|
+
}
|
|
61
|
+
const flags = data?.state?.flags;
|
|
62
|
+
if (!flags || typeof flags !== "object") {
|
|
63
|
+
throw new Error("OctoPrint returned no printer state flags; cannot verify the printer is idle.");
|
|
64
|
+
}
|
|
65
|
+
const blocking = BLOCKING_FLAGS.filter((flag) => flags[flag] === true);
|
|
66
|
+
if (flags.operational !== true) blocking.unshift("not operational");
|
|
67
|
+
if (flags.ready === false && !blocking.length) blocking.push("not ready");
|
|
68
|
+
return { state: String(data?.state?.text ?? "unknown"), ready: blocking.length === 0, reason: blocking.join(", ") || undefined };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** OctoPrint reports effectivePrint=false when print=true had no effect. */
|
|
72
|
+
protected uploadStartedPrint(data: any): boolean {
|
|
73
|
+
return data?.effectivePrint !== false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
protected async downloadRemoteFile(host: string, port: string, apiKey: string, filename: string, destination: string): Promise<void> {
|
|
77
|
+
await this.downloadToFile(`http://${host}:${port}/downloads/files/local/${encodePath(filename)}`, { "X-Api-Key": apiKey }, destination);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
protected async rawUploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean) {
|
|
37
81
|
const url = `http://${host}:${port}/api/files/local`;
|
|
38
|
-
|
|
82
|
+
|
|
39
83
|
const formData = new FormData();
|
|
40
84
|
formData.append("file", fs.createReadStream(filePath));
|
|
41
85
|
formData.append("filename", filename);
|
|
42
|
-
|
|
86
|
+
|
|
43
87
|
if (print) {
|
|
44
88
|
formData.append("print", "true");
|
|
45
89
|
}
|
|
46
|
-
|
|
90
|
+
|
|
47
91
|
const response = await this.apiClient.post(url, formData as any, {
|
|
48
92
|
headers: {
|
|
49
93
|
"X-Api-Key": apiKey,
|
|
50
94
|
...formData.getHeaders()
|
|
51
95
|
}
|
|
52
96
|
});
|
|
53
|
-
|
|
97
|
+
|
|
54
98
|
return response.data;
|
|
55
99
|
}
|
|
56
100
|
|
|
57
|
-
async
|
|
101
|
+
protected async rawStartJob(host: string, port: string, apiKey: string, filename: string) {
|
|
58
102
|
const url = `http://${host}:${port}/api/files/local/${filename}`;
|
|
59
|
-
|
|
103
|
+
|
|
60
104
|
const response = await this.apiClient.post(url, {
|
|
61
105
|
command: "select",
|
|
62
106
|
print: true
|
|
@@ -66,13 +110,13 @@ export class OctoPrintImplementation extends PrinterImplementation {
|
|
|
66
110
|
"Content-Type": "application/json"
|
|
67
111
|
}
|
|
68
112
|
});
|
|
69
|
-
|
|
113
|
+
|
|
70
114
|
return response.data;
|
|
71
115
|
}
|
|
72
116
|
|
|
73
|
-
async
|
|
117
|
+
protected async rawCancelJob(host: string, port: string, apiKey: string) {
|
|
74
118
|
const url = `http://${host}:${port}/api/job`;
|
|
75
|
-
|
|
119
|
+
|
|
76
120
|
const response = await this.apiClient.post(url, {
|
|
77
121
|
command: "cancel"
|
|
78
122
|
} as any, {
|
|
@@ -81,33 +125,42 @@ export class OctoPrintImplementation extends PrinterImplementation {
|
|
|
81
125
|
"Content-Type": "application/json"
|
|
82
126
|
}
|
|
83
127
|
});
|
|
84
|
-
|
|
128
|
+
|
|
85
129
|
return response.data;
|
|
86
130
|
}
|
|
87
131
|
|
|
88
|
-
|
|
132
|
+
/** OctoPrint tool targets are keyed tool{n} (POST /api/printer/tool, command "target"). */
|
|
133
|
+
protected heaterComponent(component: string): HeaterComponent | undefined {
|
|
134
|
+
const normalized = component.trim().toLowerCase();
|
|
135
|
+
if (normalized === "bed") return { heater: "bed", raw: "bed" };
|
|
136
|
+
if (["extruder", "nozzle", "tool"].includes(normalized)) return { heater: "nozzle", raw: "tool0" };
|
|
137
|
+
const indexed = normalized.match(/^(?:extruder|tool)(\d+)$/);
|
|
138
|
+
if (indexed) return { heater: "nozzle", raw: `tool${Number(indexed[1])}` };
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
protected async rawSetTemperature(host: string, port: string, apiKey: string, component: string, temperature: number) {
|
|
89
143
|
let url = `http://${host}:${port}/api/printer/tool`;
|
|
90
|
-
|
|
144
|
+
|
|
91
145
|
const data: Record<string, any> = {};
|
|
92
146
|
if (component === "bed") {
|
|
93
147
|
data.command = "target";
|
|
94
148
|
data.target = temperature;
|
|
95
149
|
url = `http://${host}:${port}/api/printer/bed`;
|
|
96
|
-
} else if (component
|
|
150
|
+
} else if (/^tool\d+$/.test(component)) {
|
|
97
151
|
data.command = "target";
|
|
98
|
-
data.targets = {};
|
|
99
|
-
data.targets[component] = temperature;
|
|
152
|
+
data.targets = { [component]: temperature };
|
|
100
153
|
} else {
|
|
101
154
|
throw new Error(`Unsupported component: ${component}`);
|
|
102
155
|
}
|
|
103
|
-
|
|
156
|
+
|
|
104
157
|
const response = await this.apiClient.post(url, data as any, {
|
|
105
158
|
headers: {
|
|
106
159
|
"X-Api-Key": apiKey,
|
|
107
160
|
"Content-Type": "application/json"
|
|
108
161
|
}
|
|
109
162
|
});
|
|
110
|
-
|
|
163
|
+
|
|
111
164
|
return response.data;
|
|
112
165
|
}
|
|
113
|
-
}
|
|
166
|
+
}
|
|
@@ -6,28 +6,38 @@ import { RepetierImplementation } from "./repetier.js";
|
|
|
6
6
|
import { BambuImplementation } from "./bambu.js";
|
|
7
7
|
import { PrusaImplementation } from "./prusa.js";
|
|
8
8
|
import { CrealityImplementation } from "./creality.js";
|
|
9
|
+
import type { ConfirmHardware } from "../safety/confirmation.js";
|
|
10
|
+
import type { PrintSafetyOptions } from "../safety/expected-peaks.js";
|
|
9
11
|
import axios from "axios";
|
|
10
12
|
|
|
13
|
+
/** Every adapter accepts trailing safety options on its print and heating mutations. */
|
|
14
|
+
export type GuardedPrinterImplementation = PrinterImplementation & {
|
|
15
|
+
uploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean, options?: PrintSafetyOptions): Promise<any>;
|
|
16
|
+
startJob(host: string, port: string, apiKey: string, filename: string, options?: PrintSafetyOptions): Promise<any>;
|
|
17
|
+
setTemperature(host: string, port: string, apiKey: string, component: string, temperature: unknown, options?: PrintSafetyOptions): Promise<any>;
|
|
18
|
+
};
|
|
19
|
+
|
|
11
20
|
export class PrinterFactory {
|
|
12
21
|
private implementations: Map<string, PrinterImplementation> = new Map();
|
|
13
22
|
private apiClient = axios.create({ timeout: 10000 });
|
|
14
23
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
this.implementations.set("
|
|
18
|
-
this.implementations.set("
|
|
19
|
-
this.implementations.set("
|
|
20
|
-
this.implementations.set("
|
|
21
|
-
this.implementations.set("
|
|
22
|
-
this.implementations.set("
|
|
24
|
+
/** confirm is the human (MCP elicitation) confirmation used before prints and positive heating. */
|
|
25
|
+
constructor(confirm?: ConfirmHardware) {
|
|
26
|
+
this.implementations.set("octoprint", new OctoPrintImplementation(this.apiClient, confirm));
|
|
27
|
+
this.implementations.set("klipper", new KlipperImplementation(this.apiClient, confirm));
|
|
28
|
+
this.implementations.set("duet", new DuetImplementation(this.apiClient, confirm));
|
|
29
|
+
this.implementations.set("repetier", new RepetierImplementation(this.apiClient, confirm));
|
|
30
|
+
this.implementations.set("bambu", new BambuImplementation(this.apiClient, confirm));
|
|
31
|
+
this.implementations.set("prusa", new PrusaImplementation(this.apiClient, confirm));
|
|
32
|
+
this.implementations.set("creality", new CrealityImplementation(this.apiClient, confirm));
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
getImplementation(type: string):
|
|
35
|
+
getImplementation(type: string): GuardedPrinterImplementation {
|
|
26
36
|
const implementation = this.implementations.get(type.toLowerCase());
|
|
27
37
|
if (!implementation) {
|
|
28
38
|
throw new Error(`Unsupported printer type: ${type}`);
|
|
29
39
|
}
|
|
30
|
-
return implementation;
|
|
40
|
+
return implementation as GuardedPrinterImplementation;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
async disconnectAll(): Promise<void> {
|
|
@@ -37,4 +47,4 @@ export class PrinterFactory {
|
|
|
37
47
|
await bambuImpl.disconnectAll();
|
|
38
48
|
}
|
|
39
49
|
}
|
|
40
|
-
}
|
|
50
|
+
}
|
package/src/printers/prusa.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isAxiosError, type AxiosRequestConfig, type AxiosResponse } from "axios";
|
|
2
|
-
import {
|
|
2
|
+
import { GenericPrinterImplementation, type GenericPrinterState, type HeaterComponent } from "./generic-base.js";
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import FormData from "form-data";
|
|
5
5
|
|
|
@@ -8,7 +8,11 @@ type RequestCandidate = {
|
|
|
8
8
|
data?: unknown;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
const PRUSALINK_READY_STATES = new Set(["IDLE", "READY", "FINISHED", "STOPPED"]);
|
|
12
|
+
const OCTOPRINT_BLOCKING_FLAGS = ["printing", "paused", "pausing", "cancelling", "resuming", "finishing", "error", "closedOrError"];
|
|
13
|
+
|
|
14
|
+
export class PrusaImplementation extends GenericPrinterImplementation {
|
|
15
|
+
protected readonly printerLabel = "Prusa";
|
|
12
16
|
private buildAuthHeaders(apiKey: string): Record<string, string> {
|
|
13
17
|
return {
|
|
14
18
|
Accept: "application/json",
|
|
@@ -147,7 +151,40 @@ export class PrusaImplementation extends PrinterImplementation {
|
|
|
147
151
|
return response.data;
|
|
148
152
|
}
|
|
149
153
|
|
|
150
|
-
|
|
154
|
+
/**
|
|
155
|
+
* PrusaLink /api/v1/status printer.state, falling back to the legacy
|
|
156
|
+
* OctoPrint-compatible /api/printer flags. FINISHED and STOPPED are idle but
|
|
157
|
+
* may leave a part on the bed, so they require a human confirmation.
|
|
158
|
+
*/
|
|
159
|
+
protected async readPrinterState(host: string, port: string, apiKey: string): Promise<GenericPrinterState> {
|
|
160
|
+
let response: AxiosResponse;
|
|
161
|
+
try {
|
|
162
|
+
response = await this.getWithFallback(host, port, apiKey, ["/api/v1/status", "/api/printer"]);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw new Error(`Cannot read Prusa printer state; nothing was sent: ${(error as Error).message}`);
|
|
165
|
+
}
|
|
166
|
+
const data: any = response.data;
|
|
167
|
+
const v1State = data?.printer?.state;
|
|
168
|
+
if (typeof v1State === "string" && v1State) {
|
|
169
|
+
const state = v1State.toUpperCase();
|
|
170
|
+
const ready = PRUSALINK_READY_STATES.has(state);
|
|
171
|
+
return {
|
|
172
|
+
state,
|
|
173
|
+
ready,
|
|
174
|
+
reason: ready ? undefined : `printer.state is ${state}`,
|
|
175
|
+
finishedJob: ready && (state === "FINISHED" || state === "STOPPED") ? JSON.stringify([state, data?.job?.id ?? null]) : undefined,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const flags = data?.state?.flags;
|
|
179
|
+
if (flags && typeof flags === "object") {
|
|
180
|
+
const blocking = OCTOPRINT_BLOCKING_FLAGS.filter((flag) => flags[flag] === true);
|
|
181
|
+
if (flags.operational !== true) blocking.unshift("not operational");
|
|
182
|
+
return { state: String(data?.state?.text ?? "unknown"), ready: blocking.length === 0, reason: blocking.join(", ") || undefined };
|
|
183
|
+
}
|
|
184
|
+
throw new Error("Prusa did not report printer.state or state flags; cannot verify the printer is idle.");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
protected async rawUploadFile(
|
|
151
188
|
host: string,
|
|
152
189
|
port: string,
|
|
153
190
|
apiKey: string,
|
|
@@ -174,14 +211,11 @@ export class PrusaImplementation extends PrinterImplementation {
|
|
|
174
211
|
}
|
|
175
212
|
);
|
|
176
213
|
|
|
177
|
-
|
|
178
|
-
await this.startJob(host, port, apiKey, filename);
|
|
179
|
-
}
|
|
180
|
-
|
|
214
|
+
// Printing is started by the shared gate only after a fresh state recheck.
|
|
181
215
|
return response.data;
|
|
182
216
|
}
|
|
183
217
|
|
|
184
|
-
async
|
|
218
|
+
protected async rawStartJob(host: string, port: string, apiKey: string, filename: string) {
|
|
185
219
|
const response = await this.postWithFallback(host, port, apiKey, [
|
|
186
220
|
{
|
|
187
221
|
route: "/api/v1/job",
|
|
@@ -216,7 +250,7 @@ export class PrusaImplementation extends PrinterImplementation {
|
|
|
216
250
|
return response.data;
|
|
217
251
|
}
|
|
218
252
|
|
|
219
|
-
async
|
|
253
|
+
protected async rawCancelJob(host: string, port: string, apiKey: string) {
|
|
220
254
|
const response = await this.postWithFallback(host, port, apiKey, [
|
|
221
255
|
{
|
|
222
256
|
route: "/api/v1/job",
|
|
@@ -235,7 +269,16 @@ export class PrusaImplementation extends PrinterImplementation {
|
|
|
235
269
|
return response.data;
|
|
236
270
|
}
|
|
237
271
|
|
|
238
|
-
|
|
272
|
+
protected heaterComponent(component: string): HeaterComponent | undefined {
|
|
273
|
+
const normalized = component.trim().toLowerCase();
|
|
274
|
+
if (normalized === "bed") return { heater: "bed", raw: "bed" };
|
|
275
|
+
if (normalized.startsWith("extruder") || normalized === "nozzle" || normalized === "tool0" || normalized === "tool") {
|
|
276
|
+
return { heater: "nozzle", raw: "nozzle" };
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
protected async rawSetTemperature(
|
|
239
282
|
host: string,
|
|
240
283
|
port: string,
|
|
241
284
|
apiKey: string,
|
package/src/printers/repetier.ts
CHANGED
|
@@ -1,58 +1,96 @@
|
|
|
1
|
-
import { PrinterImplementation } from "../types.js";
|
|
2
1
|
import fs from "fs";
|
|
3
2
|
import FormData from "form-data";
|
|
3
|
+
import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Note: Repetier-Server documents printer commands at /printer/api/<slug>;
|
|
7
|
+
* this adapter's slug-less routes are unverified. Remote starts are refused
|
|
8
|
+
* because Repetier documents no complete file download for inspection.
|
|
9
|
+
*/
|
|
10
|
+
export class RepetierImplementation extends GenericPrinterImplementation {
|
|
11
|
+
protected readonly printerLabel = "Repetier";
|
|
12
|
+
// The upload form's print=1 flag uploads and starts the same bytes.
|
|
13
|
+
protected readonly uploadStartsPrint = true;
|
|
4
14
|
|
|
5
|
-
export class RepetierImplementation extends PrinterImplementation {
|
|
6
15
|
async getStatus(host: string, port: string, apiKey: string) {
|
|
7
16
|
const url = `http://${host}:${port}/printer/api/?a=getPrinterInfo&apikey=${apiKey}`;
|
|
8
17
|
const response = await this.apiClient.get(url);
|
|
9
18
|
return response.data;
|
|
10
19
|
}
|
|
11
|
-
|
|
20
|
+
|
|
12
21
|
async getFiles(host: string, port: string, apiKey: string) {
|
|
13
22
|
const url = `http://${host}:${port}/printer/api/?a=ls&apikey=${apiKey}`;
|
|
14
23
|
const response = await this.apiClient.get(url);
|
|
15
24
|
return response.data;
|
|
16
25
|
}
|
|
17
|
-
|
|
26
|
+
|
|
18
27
|
async getFile(host: string, port: string, apiKey: string, filename: string) {
|
|
19
28
|
const url = `http://${host}:${port}/printer/api/?a=getFileInfo&apikey=${apiKey}&filename=${encodeURIComponent(filename)}`;
|
|
20
29
|
const response = await this.apiClient.get(url);
|
|
21
30
|
return response.data;
|
|
22
31
|
}
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* listPrinter returns one entry per printer (online 0/1, active, job "none").
|
|
35
|
+
* Without a configured slug, more than one printer is ambiguous.
|
|
36
|
+
*/
|
|
37
|
+
protected async readPrinterState(host: string, port: string, apiKey: string): Promise<GenericPrinterState> {
|
|
38
|
+
let data: any;
|
|
39
|
+
try {
|
|
40
|
+
data = (await this.apiClient.get(`http://${host}:${port}/printer/api/?a=listPrinter&apikey=${encodeURIComponent(apiKey)}`)).data;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new Error(`Cannot read Repetier printer state; nothing was sent: ${(error as Error).message}`);
|
|
43
|
+
}
|
|
44
|
+
const printers = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : undefined;
|
|
45
|
+
if (!printers) throw new Error("Repetier did not return a printer list; cannot verify the printer is idle.");
|
|
46
|
+
if (printers.length !== 1) {
|
|
47
|
+
throw new Error(`Repetier reports ${printers.length} printers; this adapter cannot tell which one would receive the job.`);
|
|
48
|
+
}
|
|
49
|
+
const [printer] = printers;
|
|
50
|
+
const online = printer?.online === 1 || printer?.online === true;
|
|
51
|
+
const job = typeof printer?.job === "string" ? printer.job : undefined;
|
|
52
|
+
if (job === undefined) throw new Error("Repetier did not report the printer's job state; cannot verify it is idle.");
|
|
53
|
+
const reasons = [
|
|
54
|
+
...(online ? [] : ["offline"]),
|
|
55
|
+
...(printer?.active === false ? ["inactive"] : []),
|
|
56
|
+
...(job !== "none" && job !== "" ? [`job ${job} is active`] : []),
|
|
57
|
+
...(printer?.paused === true ? ["paused"] : []),
|
|
58
|
+
];
|
|
59
|
+
return { state: `${online ? "online" : "offline"}, job ${job || "none"}`, ready: reasons.length === 0, reason: reasons.join(", ") || undefined };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
protected async rawUploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean) {
|
|
25
63
|
const url = `http://${host}:${port}/printer/api/`;
|
|
26
|
-
|
|
64
|
+
|
|
27
65
|
const formData = new FormData();
|
|
28
66
|
formData.append("a", "upload");
|
|
29
67
|
formData.append("apikey", apiKey);
|
|
30
68
|
formData.append("filename", filename);
|
|
31
69
|
formData.append("print", print ? "1" : "0");
|
|
32
70
|
formData.append("file", fs.createReadStream(filePath));
|
|
33
|
-
|
|
71
|
+
|
|
34
72
|
const response = await this.apiClient.post(url, formData as any, {
|
|
35
73
|
headers: {
|
|
36
74
|
...formData.getHeaders()
|
|
37
75
|
}
|
|
38
76
|
});
|
|
39
|
-
|
|
77
|
+
|
|
40
78
|
return response.data;
|
|
41
79
|
}
|
|
42
|
-
|
|
43
|
-
async
|
|
80
|
+
|
|
81
|
+
protected async rawStartJob(host: string, port: string, apiKey: string, filename: string) {
|
|
44
82
|
const url = `http://${host}:${port}/printer/api/?a=startJob&apikey=${apiKey}&filename=${encodeURIComponent(filename)}`;
|
|
45
83
|
const response = await this.apiClient.get(url);
|
|
46
84
|
return response.data;
|
|
47
85
|
}
|
|
48
|
-
|
|
49
|
-
async
|
|
86
|
+
|
|
87
|
+
protected async rawCancelJob(host: string, port: string, apiKey: string) {
|
|
50
88
|
const url = `http://${host}:${port}/printer/api/?a=stopJob&apikey=${apiKey}`;
|
|
51
89
|
const response = await this.apiClient.get(url);
|
|
52
90
|
return response.data;
|
|
53
91
|
}
|
|
54
|
-
|
|
55
|
-
async
|
|
92
|
+
|
|
93
|
+
protected async rawSetTemperature(host: string, port: string, apiKey: string, component: string, temperature: number) {
|
|
56
94
|
let url;
|
|
57
95
|
if (component === "bed") {
|
|
58
96
|
url = `http://${host}:${port}/printer/api/?a=setBedTemp&apikey=${apiKey}&temp=${temperature}`;
|
|
@@ -61,8 +99,8 @@ export class RepetierImplementation extends PrinterImplementation {
|
|
|
61
99
|
} else {
|
|
62
100
|
throw new Error(`Unsupported component: ${component}`);
|
|
63
101
|
}
|
|
64
|
-
|
|
102
|
+
|
|
65
103
|
const response = await this.apiClient.get(url);
|
|
66
104
|
return response.data;
|
|
67
105
|
}
|
|
68
|
-
}
|
|
106
|
+
}
|