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