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.
Files changed (86) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/CONTRIBUTORS.md +56 -0
  3. package/README.md +528 -811
  4. package/dist/3mf_parser.js +8 -8
  5. package/dist/blender-mcp-bridge.d.ts +1 -0
  6. package/dist/blender-mcp-bridge.js +209 -21
  7. package/dist/fulu-orca.js +29 -5
  8. package/dist/index.js +614 -110
  9. package/dist/printers/bambu-node-guard.d.ts +2 -0
  10. package/dist/printers/bambu-node-guard.js +38 -0
  11. package/dist/printers/bambu.d.ts +53 -7
  12. package/dist/printers/bambu.js +420 -126
  13. package/dist/printers/creality.d.ts +15 -6
  14. package/dist/printers/creality.js +42 -9
  15. package/dist/printers/duet.d.ts +11 -6
  16. package/dist/printers/duet.js +57 -34
  17. package/dist/printers/generic-base.d.ts +69 -0
  18. package/dist/printers/generic-base.js +209 -0
  19. package/dist/printers/klipper.d.ts +17 -6
  20. package/dist/printers/klipper.js +63 -16
  21. package/dist/printers/octoprint.d.ts +18 -6
  22. package/dist/printers/octoprint.js +64 -9
  23. package/dist/printers/printer-factory.d.ts +11 -2
  24. package/dist/printers/printer-factory.js +9 -8
  25. package/dist/printers/prusa.d.ts +14 -6
  26. package/dist/printers/prusa.js +56 -9
  27. package/dist/printers/repetier.d.ts +18 -6
  28. package/dist/printers/repetier.js +48 -6
  29. package/dist/safety/archive.d.ts +11 -0
  30. package/dist/safety/archive.js +214 -0
  31. package/dist/safety/artifact.d.ts +6 -0
  32. package/dist/safety/artifact.js +66 -0
  33. package/dist/safety/confirmation.d.ts +22 -0
  34. package/dist/safety/confirmation.js +43 -0
  35. package/dist/safety/expected-peaks.d.ts +24 -0
  36. package/dist/safety/expected-peaks.js +24 -0
  37. package/dist/safety/gcode-thermal.d.ts +64 -0
  38. package/dist/safety/gcode-thermal.js +382 -0
  39. package/dist/safety/limits.d.ts +31 -0
  40. package/dist/safety/limits.js +105 -0
  41. package/dist/safety/print-file.d.ts +25 -0
  42. package/dist/safety/print-file.js +610 -0
  43. package/dist/safety/printer-state.d.ts +51 -0
  44. package/dist/safety/printer-state.js +371 -0
  45. package/dist/slicer/bambu-cli.d.ts +110 -0
  46. package/dist/slicer/bambu-cli.js +547 -0
  47. package/dist/slicer/profile-flatten.d.ts +104 -0
  48. package/dist/slicer/profile-flatten.js +676 -0
  49. package/dist/slicer/run-slicer.d.ts +18 -0
  50. package/dist/slicer/run-slicer.js +102 -0
  51. package/dist/slicer/slicer-error.d.ts +27 -0
  52. package/dist/slicer/slicer-error.js +53 -0
  53. package/dist/slicer/templates.d.ts +126 -0
  54. package/dist/slicer/templates.js +255 -0
  55. package/dist/stl/stl-manipulator.d.ts +25 -3
  56. package/dist/stl/stl-manipulator.js +214 -232
  57. package/package.json +16 -9
  58. package/server.json +56 -0
  59. package/src/3mf_parser.ts +8 -8
  60. package/src/blender-mcp-bridge.ts +200 -19
  61. package/src/fulu-orca.ts +36 -6
  62. package/src/index.ts +686 -137
  63. package/src/printers/bambu-node-guard.ts +44 -0
  64. package/src/printers/bambu.ts +458 -164
  65. package/src/printers/creality.ts +40 -9
  66. package/src/printers/duet.ts +64 -53
  67. package/src/printers/generic-base.ts +286 -0
  68. package/src/printers/klipper.ts +83 -28
  69. package/src/printers/octoprint.ts +74 -21
  70. package/src/printers/printer-factory.ts +21 -11
  71. package/src/printers/prusa.ts +53 -10
  72. package/src/printers/repetier.ts +55 -17
  73. package/src/safety/archive.ts +181 -0
  74. package/src/safety/artifact.ts +58 -0
  75. package/src/safety/confirmation.ts +56 -0
  76. package/src/safety/expected-peaks.ts +47 -0
  77. package/src/safety/gcode-thermal.ts +404 -0
  78. package/src/safety/limits.ts +98 -0
  79. package/src/safety/print-file.ts +429 -0
  80. package/src/safety/printer-state.ts +344 -0
  81. package/src/slicer/bambu-cli.ts +633 -0
  82. package/src/slicer/profile-flatten.ts +830 -0
  83. package/src/slicer/run-slicer.ts +126 -0
  84. package/src/slicer/slicer-error.ts +72 -0
  85. package/src/slicer/templates.ts +288 -0
  86. package/src/stl/stl-manipulator.ts +247 -275
@@ -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
- async uploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean) {
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 startJob(host: string, port: string, apiKey: string, filename: string) {
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 cancelJob(host: string, port: string, apiKey: string) {
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 setTemperature(host: string, port: string, apiKey: string, component: string, temperature: number) {
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
- async uploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean) {
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 startJob(host: string, port: string, apiKey: string, filename: string) {
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 cancelJob(host: string, port: string, apiKey: string) {
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
- async setTemperature(host: string, port: string, apiKey: string, component: string, temperature: number) {
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.startsWith("extruder")) {
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
- constructor() {
16
- this.implementations.set("octoprint", new OctoPrintImplementation(this.apiClient));
17
- this.implementations.set("klipper", new KlipperImplementation(this.apiClient));
18
- this.implementations.set("duet", new DuetImplementation(this.apiClient));
19
- this.implementations.set("repetier", new RepetierImplementation(this.apiClient));
20
- this.implementations.set("bambu", new BambuImplementation(this.apiClient));
21
- this.implementations.set("prusa", new PrusaImplementation(this.apiClient));
22
- this.implementations.set("creality", new CrealityImplementation(this.apiClient));
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): PrinterImplementation {
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
+ }
@@ -1,5 +1,5 @@
1
1
  import { isAxiosError, type AxiosRequestConfig, type AxiosResponse } from "axios";
2
- import { PrinterImplementation } from "../types.js";
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
- export class PrusaImplementation extends PrinterImplementation {
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
- async uploadFile(
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
- if (print) {
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 startJob(host: string, port: string, apiKey: string, filename: string) {
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 cancelJob(host: string, port: string, apiKey: string) {
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
- async setTemperature(
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,
@@ -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
- async uploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean) {
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 startJob(host: string, port: string, apiKey: string, filename: string) {
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 cancelJob(host: string, port: string, apiKey: string) {
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 setTemperature(host: string, port: string, apiKey: string, component: string, temperature: number) {
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
+ }