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
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
|
|
2
|
+
/**
|
|
3
|
+
* Note: this adapter's /api/device, /api/storage and /api/job routes are not a
|
|
4
|
+
* documented Creality API. Readiness fails closed unless the response carries
|
|
5
|
+
* a recognized idle state; remote starts are refused because no download
|
|
6
|
+
* route is verified.
|
|
7
|
+
*/
|
|
8
|
+
export declare class CrealityImplementation extends GenericPrinterImplementation {
|
|
9
|
+
protected readonly printerLabel = "Creality";
|
|
10
|
+
protected readPrinterState(host: string, port: string, apiKey: string): Promise<GenericPrinterState>;
|
|
3
11
|
getStatus(host: string, port: string, apiKey: string): Promise<any>;
|
|
4
12
|
getFiles(host: string, port: string, apiKey: string): Promise<any>;
|
|
5
13
|
getFile(host: string, port: string, apiKey: string, filename: string): Promise<any>;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
14
|
+
protected uploadSucceeded(data: any): boolean;
|
|
15
|
+
protected rawUploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean): Promise<any>;
|
|
16
|
+
protected rawStartJob(host: string, port: string, apiKey: string, filename: string): Promise<any>;
|
|
17
|
+
protected rawCancelJob(host: string, port: string, apiKey: string): Promise<any>;
|
|
18
|
+
protected rawSetTemperature(host: string, port: string, apiKey: string, component: string, temperature: number): Promise<any>;
|
|
10
19
|
}
|
|
@@ -1,7 +1,39 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { GenericPrinterImplementation } from "./generic-base.js";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import FormData from "form-data";
|
|
4
|
-
|
|
4
|
+
const IDLE_STATES = new Set(["idle", "ready", "standby", "operational"]);
|
|
5
|
+
const FINISHED_STATES = new Set(["complete", "completed", "finished", "finish", "cancelled", "canceled", "stopped"]);
|
|
6
|
+
/**
|
|
7
|
+
* Note: this adapter's /api/device, /api/storage and /api/job routes are not a
|
|
8
|
+
* documented Creality API. Readiness fails closed unless the response carries
|
|
9
|
+
* a recognized idle state; remote starts are refused because no download
|
|
10
|
+
* route is verified.
|
|
11
|
+
*/
|
|
12
|
+
export class CrealityImplementation extends GenericPrinterImplementation {
|
|
13
|
+
constructor() {
|
|
14
|
+
super(...arguments);
|
|
15
|
+
this.printerLabel = "Creality";
|
|
16
|
+
}
|
|
17
|
+
async readPrinterState(host, port, apiKey) {
|
|
18
|
+
let data;
|
|
19
|
+
try {
|
|
20
|
+
data = (await this.apiClient.get(`http://${host}:${port}/api/device/status`, { headers: { "Authorization": `Bearer ${apiKey}` } })).data;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
throw new Error(`Cannot read Creality printer state; nothing was sent: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
const candidates = [data?.state, data?.status, data?.printStatus, data?.print_state, data?.data?.state, data?.data?.status];
|
|
26
|
+
const raw = candidates.find((value) => typeof value === "string" && value.trim());
|
|
27
|
+
if (typeof raw !== "string") {
|
|
28
|
+
throw new Error("Creality did not report a recognizable printer state; cannot verify the printer is idle.");
|
|
29
|
+
}
|
|
30
|
+
const state = raw.trim().toLowerCase();
|
|
31
|
+
if (IDLE_STATES.has(state))
|
|
32
|
+
return { state, ready: true };
|
|
33
|
+
if (FINISHED_STATES.has(state))
|
|
34
|
+
return { state, ready: true, finishedJob: JSON.stringify([state]) };
|
|
35
|
+
return { state, ready: false, reason: `state is ${state}` };
|
|
36
|
+
}
|
|
5
37
|
async getStatus(host, port, apiKey) {
|
|
6
38
|
const url = `http://${host}:${port}/api/device/status`;
|
|
7
39
|
const response = await this.apiClient.get(url, {
|
|
@@ -29,7 +61,10 @@ export class CrealityImplementation extends PrinterImplementation {
|
|
|
29
61
|
});
|
|
30
62
|
return response.data;
|
|
31
63
|
}
|
|
32
|
-
|
|
64
|
+
uploadSucceeded(data) {
|
|
65
|
+
return !!data?.success;
|
|
66
|
+
}
|
|
67
|
+
async rawUploadFile(host, port, apiKey, filePath, filename, print) {
|
|
33
68
|
const url = `http://${host}:${port}/api/storage/upload`;
|
|
34
69
|
const formData = new FormData();
|
|
35
70
|
formData.append("file", fs.createReadStream(filePath));
|
|
@@ -40,12 +75,10 @@ export class CrealityImplementation extends PrinterImplementation {
|
|
|
40
75
|
...formData.getHeaders()
|
|
41
76
|
}
|
|
42
77
|
});
|
|
43
|
-
|
|
44
|
-
await this.startJob(host, port, apiKey, filename);
|
|
45
|
-
}
|
|
78
|
+
// Printing is started by the shared gate only after a fresh state recheck.
|
|
46
79
|
return response.data;
|
|
47
80
|
}
|
|
48
|
-
async
|
|
81
|
+
async rawStartJob(host, port, apiKey, filename) {
|
|
49
82
|
const url = `http://${host}:${port}/api/job/start`;
|
|
50
83
|
const response = await this.apiClient.post(url, {
|
|
51
84
|
filename: filename
|
|
@@ -57,7 +90,7 @@ export class CrealityImplementation extends PrinterImplementation {
|
|
|
57
90
|
});
|
|
58
91
|
return response.data;
|
|
59
92
|
}
|
|
60
|
-
async
|
|
93
|
+
async rawCancelJob(host, port, apiKey) {
|
|
61
94
|
const url = `http://${host}:${port}/api/job/cancel`;
|
|
62
95
|
const response = await this.apiClient.post(url, {}, {
|
|
63
96
|
headers: {
|
|
@@ -67,7 +100,7 @@ export class CrealityImplementation extends PrinterImplementation {
|
|
|
67
100
|
});
|
|
68
101
|
return response.data;
|
|
69
102
|
}
|
|
70
|
-
async
|
|
103
|
+
async rawSetTemperature(host, port, apiKey, component, temperature) {
|
|
71
104
|
const url = `http://${host}:${port}/api/printer/temperature`;
|
|
72
105
|
let data = {};
|
|
73
106
|
if (component === "bed") {
|
package/dist/printers/duet.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export declare class DuetImplementation extends
|
|
1
|
+
import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
|
|
2
|
+
export declare class DuetImplementation extends GenericPrinterImplementation {
|
|
3
|
+
protected readonly printerLabel = "Duet";
|
|
4
|
+
private code;
|
|
3
5
|
getStatus(host: string, port: string, apiKey: string): Promise<any>;
|
|
4
6
|
getFiles(host: string, port: string, apiKey: string): Promise<any>;
|
|
5
7
|
getFile(host: string, port: string, apiKey: string, filename: string): Promise<any>;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/** Object model state.status: only "idle" can start a job or heat. */
|
|
9
|
+
protected readPrinterState(host: string, port: string, apiKey: string): Promise<GenericPrinterState>;
|
|
10
|
+
protected downloadRemoteFile(host: string, port: string, apiKey: string, filename: string, destination: string): Promise<void>;
|
|
11
|
+
protected rawUploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean): Promise<any>;
|
|
12
|
+
protected rawStartJob(host: string, port: string, apiKey: string, filename: string): Promise<any>;
|
|
13
|
+
protected rawCancelJob(host: string, port: string, apiKey: string): Promise<any>;
|
|
14
|
+
protected rawSetTemperature(host: string, port: string, apiKey: string, component: string, temperature: number): Promise<any>;
|
|
10
15
|
}
|
package/dist/printers/duet.js
CHANGED
|
@@ -1,14 +1,38 @@
|
|
|
1
|
-
import { PrinterImplementation } from "../types.js";
|
|
2
1
|
import fs from "fs";
|
|
3
|
-
import
|
|
4
|
-
|
|
2
|
+
import { GenericPrinterImplementation } from "./generic-base.js";
|
|
3
|
+
/**
|
|
4
|
+
* Duet Software Framework (DuetWebServer) REST routes, verified against
|
|
5
|
+
* DuetSoftwareFramework src/DuetWebServer/Controllers/MachineController.cs:
|
|
6
|
+
* GET status, POST code (raw text body), GET/PUT file/{*filename},
|
|
7
|
+
* GET directory/{*directory}. Standalone RepRapFirmware rr_* routes are not
|
|
8
|
+
* supported by this adapter.
|
|
9
|
+
*/
|
|
10
|
+
function duetPath(filename) {
|
|
11
|
+
const name = filename.trim();
|
|
12
|
+
if (/^\d+:\//.test(name))
|
|
13
|
+
return name;
|
|
14
|
+
if (name.startsWith("/"))
|
|
15
|
+
return `0:${name}`;
|
|
16
|
+
return `0:/gcodes/${name}`;
|
|
17
|
+
}
|
|
18
|
+
export class DuetImplementation extends GenericPrinterImplementation {
|
|
19
|
+
constructor() {
|
|
20
|
+
super(...arguments);
|
|
21
|
+
this.printerLabel = "Duet";
|
|
22
|
+
}
|
|
23
|
+
async code(host, port, code) {
|
|
24
|
+
const response = await this.apiClient.post(`http://${host}:${port}/machine/code`, code, {
|
|
25
|
+
headers: { "Content-Type": "text/plain" },
|
|
26
|
+
});
|
|
27
|
+
return response.data;
|
|
28
|
+
}
|
|
5
29
|
async getStatus(host, port, apiKey) {
|
|
6
30
|
const url = `http://${host}:${port}/machine/status`;
|
|
7
31
|
const response = await this.apiClient.get(url);
|
|
8
32
|
return response.data;
|
|
9
33
|
}
|
|
10
34
|
async getFiles(host, port, apiKey) {
|
|
11
|
-
const url = `http://${host}:${port}/machine/
|
|
35
|
+
const url = `http://${host}:${port}/machine/directory/${encodeURIComponent("0:/gcodes")}`;
|
|
12
36
|
const response = await this.apiClient.get(url);
|
|
13
37
|
return response.data;
|
|
14
38
|
}
|
|
@@ -17,37 +41,39 @@ export class DuetImplementation extends PrinterImplementation {
|
|
|
17
41
|
const response = await this.apiClient.get(url);
|
|
18
42
|
return response.data;
|
|
19
43
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const response = await this.apiClient.post(url, formData, {
|
|
26
|
-
headers: {
|
|
27
|
-
...formData.getHeaders()
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
if (print && response.data.err === 0) {
|
|
31
|
-
await this.startJob(host, port, apiKey, filename);
|
|
44
|
+
/** Object model state.status: only "idle" can start a job or heat. */
|
|
45
|
+
async readPrinterState(host, port, apiKey) {
|
|
46
|
+
let data;
|
|
47
|
+
try {
|
|
48
|
+
data = (await this.apiClient.get(`http://${host}:${port}/machine/status`)).data;
|
|
32
49
|
}
|
|
33
|
-
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new Error(`Cannot read Duet object model state; nothing was sent: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
const status = data?.state?.status ?? data?.result?.state?.status;
|
|
54
|
+
if (typeof status !== "string" || !status) {
|
|
55
|
+
throw new Error("Duet did not report state.status; cannot verify the printer is idle.");
|
|
56
|
+
}
|
|
57
|
+
return { state: status, ready: status === "idle", reason: status === "idle" ? undefined : `state.status is ${status}` };
|
|
34
58
|
}
|
|
35
|
-
async
|
|
36
|
-
|
|
37
|
-
const response = await this.apiClient.post(url, {
|
|
38
|
-
code: `M32 "${filename}"`
|
|
39
|
-
});
|
|
40
|
-
return response.data;
|
|
59
|
+
async downloadRemoteFile(host, port, apiKey, filename, destination) {
|
|
60
|
+
await this.downloadToFile(`http://${host}:${port}/machine/file/${encodeURIComponent(duetPath(filename))}`, {}, destination);
|
|
41
61
|
}
|
|
42
|
-
async
|
|
43
|
-
const url = `http://${host}:${port}/machine/
|
|
44
|
-
const response = await this.apiClient.
|
|
45
|
-
|
|
62
|
+
async rawUploadFile(host, port, apiKey, filePath, filename, print) {
|
|
63
|
+
const url = `http://${host}:${port}/machine/file/${encodeURIComponent(duetPath(filename))}`;
|
|
64
|
+
const response = await this.apiClient.put(url, fs.createReadStream(filePath), {
|
|
65
|
+
headers: { "Content-Type": "application/octet-stream", "Content-Length": String((await fs.promises.stat(filePath)).size) },
|
|
46
66
|
});
|
|
47
|
-
|
|
67
|
+
// Printing is started by the shared gate only after a fresh state recheck.
|
|
68
|
+
return response.data ?? { uploaded: true };
|
|
48
69
|
}
|
|
49
|
-
async
|
|
50
|
-
|
|
70
|
+
async rawStartJob(host, port, apiKey, filename) {
|
|
71
|
+
return this.code(host, port, `M32 "${duetPath(filename)}"`);
|
|
72
|
+
}
|
|
73
|
+
async rawCancelJob(host, port, apiKey) {
|
|
74
|
+
return this.code(host, port, "M0");
|
|
75
|
+
}
|
|
76
|
+
async rawSetTemperature(host, port, apiKey, component, temperature) {
|
|
51
77
|
let gcode;
|
|
52
78
|
if (component === "bed") {
|
|
53
79
|
gcode = `M140 S${temperature}`;
|
|
@@ -58,9 +84,6 @@ export class DuetImplementation extends PrinterImplementation {
|
|
|
58
84
|
else {
|
|
59
85
|
throw new Error(`Unsupported component: ${component}`);
|
|
60
86
|
}
|
|
61
|
-
|
|
62
|
-
code: gcode
|
|
63
|
-
});
|
|
64
|
-
return response.data;
|
|
87
|
+
return this.code(host, port, gcode);
|
|
65
88
|
}
|
|
66
89
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { AxiosInstance } from "axios";
|
|
2
|
+
import { PrinterImplementation } from "../types.js";
|
|
3
|
+
import { type ConfirmHardware } from "../safety/confirmation.js";
|
|
4
|
+
import { type PrintSafetyOptions } from "../safety/expected-peaks.js";
|
|
5
|
+
import { type Heater } from "../safety/gcode-thermal.js";
|
|
6
|
+
/** Normalized adapter state used by the print and heating preflight. */
|
|
7
|
+
export interface GenericPrinterState {
|
|
8
|
+
/** Adapter-reported state text. */
|
|
9
|
+
state: string;
|
|
10
|
+
/** Idle enough to start a job or heat: not printing, paused, busy, errored or offline. */
|
|
11
|
+
ready: boolean;
|
|
12
|
+
/** Why the printer is not ready. */
|
|
13
|
+
reason?: string;
|
|
14
|
+
/** Set when the printer explicitly reports a finished or cancelled job that may still be on the bed. */
|
|
15
|
+
finishedJob?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface HeaterComponent {
|
|
18
|
+
heater: Heater;
|
|
19
|
+
/** Component name passed to the adapter's raw transport. */
|
|
20
|
+
raw: string;
|
|
21
|
+
}
|
|
22
|
+
/** Download timeout for remote-start inspection; the shared client times out at 10 s. */
|
|
23
|
+
export declare const REMOTE_DOWNLOAD_TIMEOUT_MS = 120000;
|
|
24
|
+
/**
|
|
25
|
+
* Shared print and heating gate for non-Bambu adapters. Public mutations
|
|
26
|
+
* validate every input before connecting, inspect the exact bytes that are
|
|
27
|
+
* uploaded, check fresh adapter state, ask a human (MCP elicitation), recheck
|
|
28
|
+
* state and only then dispatch. Heater-off and cancel are never gated.
|
|
29
|
+
*/
|
|
30
|
+
export declare abstract class GenericPrinterImplementation extends PrinterImplementation {
|
|
31
|
+
private readonly confirm?;
|
|
32
|
+
protected abstract readonly printerLabel: string;
|
|
33
|
+
/** True when rawUploadFile(print=true) starts the uploaded bytes atomically. */
|
|
34
|
+
protected readonly uploadStartsPrint: boolean;
|
|
35
|
+
constructor(apiClient: AxiosInstance, confirm?: ConfirmHardware | undefined);
|
|
36
|
+
protected abstract rawUploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean): Promise<any>;
|
|
37
|
+
protected abstract rawStartJob(host: string, port: string, apiKey: string, filename: string): Promise<any>;
|
|
38
|
+
protected abstract rawCancelJob(host: string, port: string, apiKey: string): Promise<any>;
|
|
39
|
+
protected abstract rawSetTemperature(host: string, port: string, apiKey: string, component: string, temperature: number): Promise<any>;
|
|
40
|
+
/** Throw when the state cannot be read; never guess. */
|
|
41
|
+
protected abstract readPrinterState(host: string, port: string, apiKey: string): Promise<GenericPrinterState>;
|
|
42
|
+
/**
|
|
43
|
+
* Download a printer-side file for inspection. Adapters without a verified
|
|
44
|
+
* download route leave this undefined and remote starts are refused.
|
|
45
|
+
*/
|
|
46
|
+
protected downloadRemoteFile?(host: string, port: string, apiKey: string, filename: string, destination: string): Promise<void>;
|
|
47
|
+
/** Adapter-specific upload acknowledgement for the upload-then-start route. */
|
|
48
|
+
protected uploadSucceeded(_data: unknown): boolean;
|
|
49
|
+
/** Adapter-specific acknowledgement that an atomic upload-and-print started. */
|
|
50
|
+
protected uploadStartedPrint(_data: unknown): boolean;
|
|
51
|
+
/** Map caller component names to a heater and the raw adapter name. */
|
|
52
|
+
protected heaterComponent(component: string): HeaterComponent | undefined;
|
|
53
|
+
private operationKey;
|
|
54
|
+
cancelJob(host: string, port: string, apiKey: string): Promise<any>;
|
|
55
|
+
setTemperature(host: string, port: string, apiKey: string, component: string, temperature: unknown, options?: PrintSafetyOptions): Promise<any>;
|
|
56
|
+
uploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean, options?: PrintSafetyOptions): Promise<any>;
|
|
57
|
+
startJob(host: string, port: string, apiKey: string, filename: string, options?: PrintSafetyOptions): Promise<any>;
|
|
58
|
+
/** Download with a bounded size and its own timeout. */
|
|
59
|
+
protected downloadToFile(url: string, headers: Record<string, string>, destination: string): Promise<void>;
|
|
60
|
+
private validateOffline;
|
|
61
|
+
/** The started name must be the uploaded name: no directories. */
|
|
62
|
+
private printableRemoteName;
|
|
63
|
+
/** A private read-only copy named like the remote file; multipart adapters use its basename. */
|
|
64
|
+
private withNamedSnapshot;
|
|
65
|
+
private assertReady;
|
|
66
|
+
private assertBedClearance;
|
|
67
|
+
private printChecked;
|
|
68
|
+
private printPrompt;
|
|
69
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { PrinterImplementation } from "../types.js";
|
|
6
|
+
import { cancelPendingPrinterOperations, withPrinterOperation } from "../safety/artifact.js";
|
|
7
|
+
import { MAX_PRINT_FILE_BYTES } from "../safety/archive.js";
|
|
8
|
+
import { requireHumanConfirmation } from "../safety/confirmation.js";
|
|
9
|
+
import { assertExpectedPeaks, validateExpectedPeaks } from "../safety/expected-peaks.js";
|
|
10
|
+
import { assertInspectableGcodeName, finiteTemperature, genericCeilings, inspectGcodeFile, validateGenericTemperature, } from "../safety/gcode-thermal.js";
|
|
11
|
+
/** Download timeout for remote-start inspection; the shared client times out at 10 s. */
|
|
12
|
+
export const REMOTE_DOWNLOAD_TIMEOUT_MS = 120000;
|
|
13
|
+
/**
|
|
14
|
+
* Shared print and heating gate for non-Bambu adapters. Public mutations
|
|
15
|
+
* validate every input before connecting, inspect the exact bytes that are
|
|
16
|
+
* uploaded, check fresh adapter state, ask a human (MCP elicitation), recheck
|
|
17
|
+
* state and only then dispatch. Heater-off and cancel are never gated.
|
|
18
|
+
*/
|
|
19
|
+
export class GenericPrinterImplementation extends PrinterImplementation {
|
|
20
|
+
constructor(apiClient, confirm) {
|
|
21
|
+
super(apiClient);
|
|
22
|
+
this.confirm = confirm;
|
|
23
|
+
/** True when rawUploadFile(print=true) starts the uploaded bytes atomically. */
|
|
24
|
+
this.uploadStartsPrint = false;
|
|
25
|
+
}
|
|
26
|
+
/** Adapter-specific upload acknowledgement for the upload-then-start route. */
|
|
27
|
+
uploadSucceeded(_data) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
/** Adapter-specific acknowledgement that an atomic upload-and-print started. */
|
|
31
|
+
uploadStartedPrint(_data) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
/** Map caller component names to a heater and the raw adapter name. */
|
|
35
|
+
heaterComponent(component) {
|
|
36
|
+
const normalized = component.trim().toLowerCase();
|
|
37
|
+
if (normalized === "bed")
|
|
38
|
+
return { heater: "bed", raw: "bed" };
|
|
39
|
+
if (["extruder", "nozzle", "tool", "tool0"].includes(normalized))
|
|
40
|
+
return { heater: "nozzle", raw: "extruder" };
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
operationKey(host, port) {
|
|
44
|
+
return `${this.printerLabel}:${host}:${port}`;
|
|
45
|
+
}
|
|
46
|
+
async cancelJob(host, port, apiKey) {
|
|
47
|
+
cancelPendingPrinterOperations(host, this.operationKey(host, port));
|
|
48
|
+
return this.rawCancelJob(host, port, apiKey);
|
|
49
|
+
}
|
|
50
|
+
async setTemperature(host, port, apiKey, component, temperature, options = {}) {
|
|
51
|
+
const target = this.heaterComponent(String(component));
|
|
52
|
+
if (!target)
|
|
53
|
+
throw new Error(`Unsupported component: ${component}`);
|
|
54
|
+
const value = finiteTemperature(temperature);
|
|
55
|
+
const key = this.operationKey(host, port);
|
|
56
|
+
if (value === 0) {
|
|
57
|
+
// Heater-off is never gated and cancels pending checked operations.
|
|
58
|
+
cancelPendingPrinterOperations(host, key);
|
|
59
|
+
return this.rawSetTemperature(host, port, apiKey, target.raw, 0);
|
|
60
|
+
}
|
|
61
|
+
const material = options.material?.trim();
|
|
62
|
+
validateGenericTemperature(target.heater, value, material ? [material] : undefined);
|
|
63
|
+
return withPrinterOperation(host, key, async (assertActive) => {
|
|
64
|
+
this.assertReady(await this.readPrinterState(host, port, apiKey), "heat");
|
|
65
|
+
await requireHumanConfirmation(this.confirm, `Heat ${target.heater} on ${this.printerLabel} (${host}:${port}) to ${value}°C?` +
|
|
66
|
+
(material && target.heater === "nozzle" ? ` Declared material: ${material}. Confirm the loaded filament matches.` : ""), "generic");
|
|
67
|
+
assertActive();
|
|
68
|
+
this.assertReady(await this.readPrinterState(host, port, apiKey), "heat");
|
|
69
|
+
assertActive();
|
|
70
|
+
return this.rawSetTemperature(host, port, apiKey, target.raw, value);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
async uploadFile(host, port, apiKey, filePath, filename, print, options = {}) {
|
|
74
|
+
if (!print)
|
|
75
|
+
return this.rawUploadFile(host, port, apiKey, filePath, filename, false);
|
|
76
|
+
const remoteName = this.printableRemoteName(filename);
|
|
77
|
+
this.validateOffline(options);
|
|
78
|
+
return withPrinterOperation(host, this.operationKey(host, port), (assertActive) => this.withNamedSnapshot(filePath, remoteName, (snapshot) => this.printChecked(host, port, apiKey, snapshot, remoteName, options, assertActive)));
|
|
79
|
+
}
|
|
80
|
+
async startJob(host, port, apiKey, filename, options = {}) {
|
|
81
|
+
this.validateOffline(options);
|
|
82
|
+
if (!this.downloadRemoteFile) {
|
|
83
|
+
throw new Error(`Print safety: start_print cannot inspect files already stored on ${this.printerLabel}; this adapter has no verified download route. ` +
|
|
84
|
+
"Uninspected remote jobs are refused. Use upload_gcode with print=true and the local G-code so the exact bytes are inspected.");
|
|
85
|
+
}
|
|
86
|
+
assertInspectableGcodeName(filename);
|
|
87
|
+
const checkedName = `checked-${randomUUID()}-${path.posix.basename(filename.replace(/\\/g, "/"))}`;
|
|
88
|
+
return withPrinterOperation(host, this.operationKey(host, port), async (assertActive) => {
|
|
89
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "printer-remote-check-"));
|
|
90
|
+
const downloaded = path.join(directory, "download");
|
|
91
|
+
try {
|
|
92
|
+
await this.downloadRemoteFile(host, port, apiKey, filename, downloaded);
|
|
93
|
+
// Start a uniquely named copy of the inspected download; the original
|
|
94
|
+
// remote name could change between inspection and start.
|
|
95
|
+
return await this.withNamedSnapshot(downloaded, checkedName, (snapshot) => this.printChecked(host, port, apiKey, snapshot, checkedName, options, assertActive));
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** Download with a bounded size and its own timeout. */
|
|
103
|
+
async downloadToFile(url, headers, destination) {
|
|
104
|
+
const response = await this.apiClient.get(url, {
|
|
105
|
+
headers,
|
|
106
|
+
responseType: "arraybuffer",
|
|
107
|
+
timeout: REMOTE_DOWNLOAD_TIMEOUT_MS,
|
|
108
|
+
maxContentLength: MAX_PRINT_FILE_BYTES,
|
|
109
|
+
maxBodyLength: MAX_PRINT_FILE_BYTES,
|
|
110
|
+
});
|
|
111
|
+
const bytes = Buffer.from(response.data);
|
|
112
|
+
if (!bytes.length || bytes.length > MAX_PRINT_FILE_BYTES) {
|
|
113
|
+
throw new Error("Remote print file is empty or exceeds the 256 MiB inspection limit.");
|
|
114
|
+
}
|
|
115
|
+
await fs.writeFile(destination, bytes);
|
|
116
|
+
}
|
|
117
|
+
validateOffline(options) {
|
|
118
|
+
validateExpectedPeaks(options.expectedPeaks);
|
|
119
|
+
genericCeilings();
|
|
120
|
+
}
|
|
121
|
+
/** The started name must be the uploaded name: no directories. */
|
|
122
|
+
printableRemoteName(filename) {
|
|
123
|
+
const name = String(filename);
|
|
124
|
+
if (!name || name !== path.posix.basename(name) || name.includes("\\") || name === "." || name === ".." || /[\r\n\0]/.test(name)) {
|
|
125
|
+
throw new Error("Print safety: printing after upload needs a plain filename without directories, so the started file is exactly the uploaded file.");
|
|
126
|
+
}
|
|
127
|
+
assertInspectableGcodeName(name);
|
|
128
|
+
return name;
|
|
129
|
+
}
|
|
130
|
+
/** A private read-only copy named like the remote file; multipart adapters use its basename. */
|
|
131
|
+
async withNamedSnapshot(source, name, operation) {
|
|
132
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "printer-print-"));
|
|
133
|
+
const snapshot = path.join(directory, name);
|
|
134
|
+
try {
|
|
135
|
+
await fs.copyFile(source, snapshot);
|
|
136
|
+
await fs.chmod(snapshot, 0o400);
|
|
137
|
+
return await operation(snapshot);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
assertReady(state, action) {
|
|
144
|
+
if (!state.ready) {
|
|
145
|
+
throw new Error(`Print safety: ${this.printerLabel} reports '${state.state}'${state.reason ? ` (${state.reason})` : ""}; ` +
|
|
146
|
+
`it is not safely idle, so the ${action === "print" ? "print was not started" : "heater was not changed"}.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
assertBedClearance(state, confirmedFinishedJob) {
|
|
150
|
+
if (state.finishedJob !== undefined && state.finishedJob !== confirmedFinishedJob) {
|
|
151
|
+
throw new Error("Print safety: the printer reports a newly finished job. Confirm its part and debris were removed before retrying.");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async printChecked(host, port, apiKey, snapshot, remoteName, options, assertActive) {
|
|
155
|
+
const inspection = await inspectGcodeFile(snapshot, { material: options.material });
|
|
156
|
+
assertExpectedPeaks(options.expectedPeaks, { nozzle: inspection.peaks.nozzle, bed: inspection.peaks.bed });
|
|
157
|
+
const initial = await this.readPrinterState(host, port, apiKey);
|
|
158
|
+
this.assertReady(initial, "print");
|
|
159
|
+
await requireHumanConfirmation(this.confirm, this.printPrompt(host, port, remoteName, inspection, initial), "generic", initial.finishedJob !== undefined);
|
|
160
|
+
assertActive();
|
|
161
|
+
const confirmed = await this.readPrinterState(host, port, apiKey);
|
|
162
|
+
this.assertReady(confirmed, "print");
|
|
163
|
+
this.assertBedClearance(confirmed, initial.finishedJob);
|
|
164
|
+
assertActive();
|
|
165
|
+
const summary = {
|
|
166
|
+
status: "success",
|
|
167
|
+
printRequested: true,
|
|
168
|
+
remotePath: remoteName,
|
|
169
|
+
sha256: inspection.sha256,
|
|
170
|
+
materials: inspection.materials,
|
|
171
|
+
peakTemperatures: inspection.peaks,
|
|
172
|
+
};
|
|
173
|
+
if (this.uploadStartsPrint) {
|
|
174
|
+
const response = await this.rawUploadFile(host, port, apiKey, snapshot, remoteName, true);
|
|
175
|
+
// A stop sent while the upload was in flight reached the printer before
|
|
176
|
+
// this upload started the job, so stop again: the cancellation must win.
|
|
177
|
+
try {
|
|
178
|
+
assertActive();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
await this.rawCancelJob(host, port, apiKey).catch(() => undefined);
|
|
182
|
+
throw new Error(`A stop request arrived while ${remoteName} was uploading; the print it started was stopped again. Check the printer before retrying.`);
|
|
183
|
+
}
|
|
184
|
+
if (!this.uploadStartedPrint(response)) {
|
|
185
|
+
throw new Error(`${this.printerLabel} stored ${remoteName} but reports that it did not start printing it.`);
|
|
186
|
+
}
|
|
187
|
+
return { ...summary, response };
|
|
188
|
+
}
|
|
189
|
+
const upload = await this.rawUploadFile(host, port, apiKey, snapshot, remoteName, false);
|
|
190
|
+
if (!this.uploadSucceeded(upload)) {
|
|
191
|
+
throw new Error(`${this.printerLabel} did not confirm the upload of ${remoteName}; the print was not started.`);
|
|
192
|
+
}
|
|
193
|
+
// Uploads can be long. Recheck state before the start command.
|
|
194
|
+
const dispatch = await this.readPrinterState(host, port, apiKey);
|
|
195
|
+
this.assertReady(dispatch, "print");
|
|
196
|
+
this.assertBedClearance(dispatch, initial.finishedJob);
|
|
197
|
+
assertActive();
|
|
198
|
+
const response = await this.rawStartJob(host, port, apiKey, remoteName);
|
|
199
|
+
return { ...summary, upload, response };
|
|
200
|
+
}
|
|
201
|
+
printPrompt(host, port, remoteName, inspection, state) {
|
|
202
|
+
const materials = inspection.materials.length ? inspection.materials.join(", ") : "none declared (no positive nozzle target)";
|
|
203
|
+
return (`Start a checked print of ${remoteName} on ${this.printerLabel} (${host}:${port})? ` +
|
|
204
|
+
`Materials: ${materials} (${inspection.materialSource}). Peak targets: nozzle ${inspection.peaks.nozzle}°C, bed ${inspection.peaks.bed}°C, chamber ${inspection.peaks.chamber}°C ` +
|
|
205
|
+
`(server ceilings ${inspection.ceilings.nozzle}/${inspection.ceilings.bed}/${inspection.ceilings.chamber}°C). File SHA-256: ${inspection.sha256}. ` +
|
|
206
|
+
"Confirm the loaded filament matches and that the build plate is clear." +
|
|
207
|
+
(state.finishedJob !== undefined ? ` The printer reports '${state.state}': remove the previous part and debris before confirming.` : ""));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export declare class KlipperImplementation extends
|
|
1
|
+
import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
|
|
2
|
+
export declare class KlipperImplementation extends GenericPrinterImplementation {
|
|
3
|
+
protected readonly printerLabel = "Klipper/Moonraker";
|
|
4
|
+
protected authHeaders(apiKey: string): Record<string, unknown>;
|
|
3
5
|
getStatus(host: string, port: string, apiKey: string): Promise<any>;
|
|
4
6
|
getFiles(host: string, port: string, apiKey: string): Promise<any>;
|
|
5
7
|
getFile(host: string, port: string, apiKey: string, filename: string): Promise<any>;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Moonraker object query: webhooks.state must be "ready" (not startup,
|
|
10
|
+
* shutdown or error) and print_stats.state standby/complete/cancelled.
|
|
11
|
+
* complete/cancelled can leave a part on the bed, so they need a human.
|
|
12
|
+
*/
|
|
13
|
+
protected readPrinterState(host: string, port: string, apiKey: string): Promise<GenericPrinterState>;
|
|
14
|
+
protected downloadRemoteFile(host: string, port: string, apiKey: string, filename: string, destination: string): Promise<void>;
|
|
15
|
+
/** Current Moonraker returns {result:{item}}; older releases returned result "success". */
|
|
16
|
+
protected uploadSucceeded(data: any): boolean;
|
|
17
|
+
protected rawUploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean): Promise<any>;
|
|
18
|
+
protected rawStartJob(host: string, port: string, apiKey: string, filename: string): Promise<any>;
|
|
19
|
+
protected rawCancelJob(host: string, port: string, apiKey: string): Promise<any>;
|
|
20
|
+
protected rawSetTemperature(host: string, port: string, apiKey: string, component: string, temperature: number): Promise<any>;
|
|
10
21
|
}
|