mcp-3d-printer-server 1.2.20 → 1.2.21

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 (51) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +112 -9
  3. package/dist/blender-mcp-bridge.d.ts +21 -0
  4. package/dist/blender-mcp-bridge.js +3 -3
  5. package/dist/counterpart/contract.d.ts +395 -0
  6. package/dist/counterpart/contract.js +220 -0
  7. package/dist/counterpart/progress.d.ts +32 -0
  8. package/dist/counterpart/progress.js +443 -0
  9. package/dist/counterpart/service.d.ts +56 -0
  10. package/dist/counterpart/service.js +224 -0
  11. package/dist/fusion-mcp-bridge.d.ts +166 -0
  12. package/dist/fusion-mcp-bridge.js +529 -0
  13. package/dist/index.js +272 -8
  14. package/dist/printers/creality.d.ts +26 -1
  15. package/dist/printers/creality.js +75 -2
  16. package/dist/printers/duet.d.ts +16 -0
  17. package/dist/printers/duet.js +64 -0
  18. package/dist/printers/generic-base.d.ts +52 -6
  19. package/dist/printers/generic-base.js +134 -7
  20. package/dist/printers/klipper.d.ts +33 -2
  21. package/dist/printers/klipper.js +106 -0
  22. package/dist/printers/octoprint.d.ts +23 -1
  23. package/dist/printers/octoprint.js +81 -0
  24. package/dist/printers/prusa.d.ts +13 -0
  25. package/dist/printers/prusa.js +59 -0
  26. package/dist/printers/repetier.d.ts +17 -1
  27. package/dist/printers/repetier.js +55 -0
  28. package/dist/slicer/bambu-cli.d.ts +2 -0
  29. package/dist/slicer/bambu-cli.js +11 -0
  30. package/dist/slicer/filament-profiles.d.ts +25 -0
  31. package/dist/slicer/filament-profiles.js +249 -0
  32. package/dist/stl/stl-manipulator.d.ts +21 -1
  33. package/dist/stl/stl-manipulator.js +86 -6
  34. package/package.json +7 -2
  35. package/server.json +2 -2
  36. package/src/blender-mcp-bridge.ts +3 -3
  37. package/src/counterpart/contract.ts +326 -0
  38. package/src/counterpart/progress.ts +470 -0
  39. package/src/counterpart/service.ts +247 -0
  40. package/src/fusion-mcp-bridge.ts +512 -0
  41. package/src/index.ts +314 -10
  42. package/src/printers/creality.ts +84 -1
  43. package/src/printers/duet.ts +67 -0
  44. package/src/printers/generic-base.ts +172 -1
  45. package/src/printers/klipper.ts +118 -2
  46. package/src/printers/octoprint.ts +86 -1
  47. package/src/printers/prusa.ts +60 -0
  48. package/src/printers/repetier.ts +63 -1
  49. package/src/slicer/bambu-cli.ts +13 -0
  50. package/src/slicer/filament-profiles.ts +249 -0
  51. package/src/stl/stl-manipulator.ts +100 -6
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { randomUUID } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
5
  import type { AxiosInstance } from "axios";
6
6
  import { PrinterImplementation } from "../types.js";
7
7
  import { cancelPendingPrinterOperations, withPrinterOperation } from "../safety/artifact.js";
@@ -17,6 +17,15 @@ import {
17
17
  type GenericPrintInspection,
18
18
  type Heater,
19
19
  } from "../safety/gcode-thermal.js";
20
+ import type { CounterpartProgress, FilamentSlot, PrinterIdentity } from "../counterpart/contract.js";
21
+
22
+ /** A camera snapshot URL as the firmware configures it, before resolution against the printer host. */
23
+ export interface SnapshotCandidate {
24
+ url: string;
25
+ source: string;
26
+ /** True when the firmware's own settings name this URL; false for a conventional fallback. */
27
+ configured: boolean;
28
+ }
20
29
 
21
30
  /** Normalized adapter state used by the print and heating preflight. */
22
31
  export interface GenericPrinterState {
@@ -45,15 +54,172 @@ export const REMOTE_DOWNLOAD_TIMEOUT_MS = 120_000;
45
54
  * uploaded, check fresh adapter state, ask a human (MCP elicitation), recheck
46
55
  * state and only then dispatch. Heater-off and cancel are never gated.
47
56
  */
57
+ /** seenRunning: a report showed this job printing or paused, so a later ended report is about it, not about the previous job. */
58
+ type CheckedJob = { name: string; sha256: string; peaks: GenericPrintInspection["peaks"]; materials: string[]; seenRunning: boolean };
59
+ const checkedJob = (name: string, inspection: GenericPrintInspection): CheckedJob =>
60
+ ({ name, sha256: inspection.sha256, peaks: inspection.peaks, materials: inspection.materials, seenRunning: false });
61
+ const RUNNING_STATES = new Set<string>(["preparing", "printing", "paused"]);
62
+ /** Reports after which the checked job can no longer be resumed. */
63
+ const ENDED_STATES = new Set<string>(["idle", "finished", "cancelled", "failed"]);
64
+
48
65
  export abstract class GenericPrinterImplementation extends PrinterImplementation {
49
66
  protected abstract readonly printerLabel: string;
50
67
  /** True when rawUploadFile(print=true) starts the uploaded bytes atomically. */
51
68
  protected readonly uploadStartsPrint: boolean = false;
52
69
 
70
+ /**
71
+ * The last job this server instance inspected and started, per printer.
72
+ * Cleared by cancel and by a report that the job ended, so a later job
73
+ * reusing the name is never resumed on this record.
74
+ */
75
+ private readonly checkedJobs = new Map<string, CheckedJob>();
76
+
53
77
  constructor(apiClient: AxiosInstance, private readonly confirm?: ConfirmHardware) {
54
78
  super(apiClient);
55
79
  }
56
80
 
81
+ // ---- Counterpart contract (counterpart/1) ----
82
+
83
+ /** Normalized progress for counterpart_print_progress; throws when the printer cannot be read. */
84
+ abstract counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress>;
85
+
86
+ /** Progress that also retires the resume record once the printer reports the job ended. */
87
+ async observedProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
88
+ const progress = await this.counterpartProgress(host, port, apiKey);
89
+ const key = this.operationKey(host, port);
90
+ const checked = this.checkedJobs.get(key);
91
+ if (checked && RUNNING_STATES.has(progress.state) && progress.job?.name === checked.name) checked.seenRunning = true;
92
+ else if (checked?.seenRunning && (ENDED_STATES.has(progress.state) || (RUNNING_STATES.has(progress.state) && progress.job?.name !== undefined))) {
93
+ this.checkedJobs.delete(key);
94
+ }
95
+ return progress;
96
+ }
97
+
98
+ /** Optional identity fields; failures are ignored by the caller. */
99
+ async counterpartIdentity(_host: string, _port: string, _apiKey: string): Promise<PrinterIdentity> {
100
+ return {};
101
+ }
102
+
103
+ /** Snapshot URLs from the firmware's own settings, in preference order. */
104
+ async snapshotCandidates(_host: string, _port: string, _apiKey: string): Promise<SnapshotCandidate[]> {
105
+ return [];
106
+ }
107
+
108
+ /** The single extruder slot; adapters with a spool manager override this. */
109
+ async counterpartFilaments(_host: string, _port: string, _apiKey: string): Promise<FilamentSlot[]> {
110
+ return [{ id: "ext", source: "unknown" }];
111
+ }
112
+
113
+ /** Whether progress reports carry layer numbers on this firmware. */
114
+ async reportsLayers(_host: string, _port: string, _apiKey: string): Promise<boolean> {
115
+ return false;
116
+ }
117
+
118
+ async supportsPauseResume(_host: string, _port: string, _apiKey: string): Promise<boolean> {
119
+ return this.rawPauseJob !== undefined && this.rawResumeJob !== undefined;
120
+ }
121
+
122
+ protected rawPauseJob?(host: string, port: string, apiKey: string): Promise<unknown>;
123
+ protected rawResumeJob?(host: string, port: string, apiKey: string): Promise<unknown>;
124
+
125
+ /** Pause is never gated, like cancel and heater-off. */
126
+ async pauseJob(host: string, port: string, apiKey: string): Promise<{ status: string; action: "pause"; message: string }> {
127
+ if (!(await this.supportsPauseResume(host, port, apiKey)) || !this.rawPauseJob) {
128
+ throw new Error(`Unsupported: ${this.printerLabel} has no verified pause route in this server.`);
129
+ }
130
+ await this.rawPauseJob(host, port, apiKey);
131
+ return { status: "success", action: "pause", message: `Pause command sent to ${this.printerLabel} (${host}:${port}).` };
132
+ }
133
+
134
+ /**
135
+ * Resume reheats and moves the printer, so it is gated like a print start:
136
+ * only the job this server instance inspected and started, only while the
137
+ * printer freshly reports that job paused, with its bytes re-read and
138
+ * compared where the firmware can download them, and after human
139
+ * confirmation (unless the server opted out). A stop or heater-off request
140
+ * in the meantime cancels it.
141
+ */
142
+ async resumeJob(host: string, port: string, apiKey: string): Promise<{ status: string; action: "resume"; message: string; jobName: string; bytesVerified: boolean }> {
143
+ if (!(await this.supportsPauseResume(host, port, apiKey)) || !this.rawResumeJob) {
144
+ throw new Error(`Unsupported: ${this.printerLabel} has no verified resume route in this server.`);
145
+ }
146
+ const key = this.operationKey(host, port);
147
+ return withPrinterOperation(host, key, async (assertActive) => {
148
+ const checked = this.checkedJobs.get(key);
149
+ if (!checked) {
150
+ throw new Error(
151
+ `Print safety: resume_print only resumes a job this server instance inspected and started on ${this.printerLabel}; nothing was sent. ` +
152
+ "Check the paused job at the printer and resume it there."
153
+ );
154
+ }
155
+ await this.assertPausedJob(host, port, apiKey, checked.name);
156
+ let bytesVerified = false;
157
+ if (this.downloadRemoteFile) {
158
+ const sha256 = await this.remoteSha256(host, port, apiKey, checked.name).catch(() => {
159
+ throw new Error(`Print safety: could not re-read ${checked.name} from ${this.printerLabel} to confirm it is the inspected file; nothing was sent. Resume it at the printer.`);
160
+ });
161
+ if (sha256 !== checked.sha256) {
162
+ this.checkedJobs.delete(key);
163
+ throw new Error(`Print safety: ${checked.name} on ${this.printerLabel} is no longer the file this server inspected (SHA-256 changed); nothing was sent.`);
164
+ }
165
+ bytesVerified = true;
166
+ }
167
+ assertActive();
168
+ await requireHumanConfirmation(
169
+ this.confirm,
170
+ `Resume ${checked.name} on ${this.printerLabel} (${host}:${port})? It reheats to its targets (peak nozzle ${checked.peaks.nozzle}°C, ` +
171
+ `bed ${checked.peaks.bed}°C, chamber ${checked.peaks.chamber}°C) and moves. Materials: ${checked.materials.length ? checked.materials.join(", ") : "none declared"}. ` +
172
+ `File SHA-256: ${checked.sha256}${bytesVerified ? " (re-read from the printer)" : " (this firmware cannot re-read the file; matched by name)"}. ` +
173
+ "Confirm the nozzle path is clear and the filament is still loaded.",
174
+ "generic"
175
+ );
176
+ assertActive();
177
+ // Someone may have resumed or stopped it at the printer while the prompt was open.
178
+ await this.assertPausedJob(host, port, apiKey, checked.name);
179
+ // A progress read during the prompt may have retired this record (the job ended, and a new one
180
+ // could now be paused under the same name); only the record that was confirmed may be resumed.
181
+ if (this.checkedJobs.get(key) !== checked) {
182
+ throw new Error(`Print safety: ${checked.name} ended on ${this.printerLabel} while the confirmation was open; nothing was sent.`);
183
+ }
184
+ assertActive();
185
+ await this.rawResumeJob!(host, port, apiKey);
186
+ return {
187
+ status: "success",
188
+ action: "resume",
189
+ message: `Resume command sent to ${this.printerLabel} (${host}:${port}) for ${checked.name}.`,
190
+ jobName: checked.name,
191
+ bytesVerified,
192
+ };
193
+ });
194
+ }
195
+
196
+ private async assertPausedJob(host: string, port: string, apiKey: string, name: string): Promise<void> {
197
+ const progress = await this.observedProgress(host, port, apiKey);
198
+ if (progress.state === "paused" && progress.job?.stage === "Pausing") {
199
+ // OctoPrint accepts a resume while still pausing and then drops it.
200
+ throw new Error(`Print safety: ${this.printerLabel} is still pausing; nothing was sent. Resume once it reports paused.`);
201
+ }
202
+ if (progress.state !== "paused") {
203
+ throw new Error(`Print safety: ${this.printerLabel} reports '${progress.state}', not a paused job; nothing was sent.`);
204
+ }
205
+ if (progress.job?.name !== name) {
206
+ throw new Error(
207
+ `Print safety: the paused job (${progress.job?.name ?? "unknown"}) does not match the job this server started (${name}); nothing was sent.`
208
+ );
209
+ }
210
+ }
211
+
212
+ private async remoteSha256(host: string, port: string, apiKey: string, name: string): Promise<string> {
213
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "printer-resume-"));
214
+ try {
215
+ const destination = path.join(directory, "job.gcode");
216
+ await this.downloadRemoteFile!(host, port, apiKey, name, destination);
217
+ return createHash("sha256").update(await fs.readFile(destination)).digest("hex");
218
+ } finally {
219
+ await fs.rm(directory, { recursive: true, force: true });
220
+ }
221
+ }
222
+
57
223
  protected abstract rawUploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean): Promise<any>;
58
224
  protected abstract rawStartJob(host: string, port: string, apiKey: string, filename: string): Promise<any>;
59
225
  protected abstract rawCancelJob(host: string, port: string, apiKey: string): Promise<any>;
@@ -91,6 +257,8 @@ export abstract class GenericPrinterImplementation extends PrinterImplementation
91
257
 
92
258
  async cancelJob(host: string, port: string, apiKey: string): Promise<any> {
93
259
  cancelPendingPrinterOperations(host, this.operationKey(host, port));
260
+ // A cancelled job is never resumed, even if a later job reuses its name.
261
+ this.checkedJobs.delete(this.operationKey(host, port));
94
262
  return this.rawCancelJob(host, port, apiKey);
95
263
  }
96
264
 
@@ -241,6 +409,7 @@ export abstract class GenericPrinterImplementation extends PrinterImplementation
241
409
  status: "success",
242
410
  printRequested: true,
243
411
  remotePath: remoteName,
412
+ jobName: remoteName,
244
413
  sha256: inspection.sha256,
245
414
  materials: inspection.materials,
246
415
  peakTemperatures: inspection.peaks,
@@ -258,6 +427,7 @@ export abstract class GenericPrinterImplementation extends PrinterImplementation
258
427
  if (!this.uploadStartedPrint(response)) {
259
428
  throw new Error(`${this.printerLabel} stored ${remoteName} but reports that it did not start printing it.`);
260
429
  }
430
+ this.checkedJobs.set(this.operationKey(host, port), checkedJob(remoteName, inspection));
261
431
  return { ...summary, response };
262
432
  }
263
433
  const upload = await this.rawUploadFile(host, port, apiKey, snapshot, remoteName, false);
@@ -270,6 +440,7 @@ export abstract class GenericPrinterImplementation extends PrinterImplementation
270
440
  this.assertBedClearance(dispatch, initial.finishedJob);
271
441
  assertActive();
272
442
  const response = await this.rawStartJob(host, port, apiKey, remoteName);
443
+ this.checkedJobs.set(this.operationKey(host, port), checkedJob(remoteName, inspection));
273
444
  return { ...summary, upload, response };
274
445
  }
275
446
 
@@ -1,13 +1,129 @@
1
1
  import fs from "fs";
2
2
  import FormData from "form-data";
3
- import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
3
+ import { isAxiosError } from "axios";
4
+ import { GenericPrinterImplementation, type GenericPrinterState, type SnapshotCandidate } from "./generic-base.js";
5
+ import type { CounterpartProgress, FilamentSlot, PrinterIdentity } from "../counterpart/contract.js";
6
+ import { finiteNumber, nonEmptyText } from "../counterpart/contract.js";
7
+ import { mapMoonraker } from "../counterpart/progress.js";
8
+
9
+ const PROGRESS_QUERY = "print_stats&virtual_sdcard&display_status&extruder&heater_bed&webhooks";
10
+ const ESTIMATE_TTL_MS = 10 * 60_000;
11
+ export const MACRO_TIMEOUT_MS = 60_000;
4
12
 
5
13
  function encodePath(filename: string): string {
6
14
  return filename.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
7
15
  }
8
16
 
9
17
  export class KlipperImplementation extends GenericPrinterImplementation {
10
- protected readonly printerLabel = "Klipper/Moonraker";
18
+ protected readonly printerLabel: string = "Klipper/Moonraker";
19
+ /** Slicer estimates by host:port:filename, so a poll does not re-read file metadata. */
20
+ private readonly estimates = new Map<string, { value?: number; at: number }>();
21
+
22
+ /**
23
+ * One object query; remaining time is the file's slicer estimate minus
24
+ * print_duration. https://moonraker.readthedocs.io/en/latest/external_api/printer/
25
+ * and .../external_api/file_manager/ (estimated_time, seconds).
26
+ */
27
+ async counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
28
+ const data = (await this.apiClient.get(`http://${host}:${port}/printer/objects/query?${PROGRESS_QUERY}`, this.authHeaders(apiKey))).data;
29
+ const observedAt = new Date().toISOString();
30
+ const status = data?.result?.status;
31
+ if (!status || typeof status !== "object") throw new Error("Moonraker returned no printer object status.");
32
+ const stats = status.print_stats ?? {};
33
+ const filename = nonEmptyText(stats.filename);
34
+ const estimate = filename && (stats.state === "printing" || stats.state === "paused")
35
+ ? await this.estimatedTime(host, port, apiKey, filename)
36
+ : undefined;
37
+ return mapMoonraker(status, estimate, observedAt);
38
+ }
39
+
40
+ private async estimatedTime(host: string, port: string, apiKey: string, filename: string): Promise<number | undefined> {
41
+ const key = `${host}:${port}:${filename}`;
42
+ const cached = this.estimates.get(key);
43
+ if (cached && Date.now() - cached.at < ESTIMATE_TTL_MS) return cached.value;
44
+ let value: number | undefined;
45
+ try {
46
+ const metadata = (await this.apiClient.get(
47
+ `http://${host}:${port}/server/files/metadata?filename=${encodeURIComponent(filename)}`,
48
+ this.authHeaders(apiKey)
49
+ )).data;
50
+ const estimate = finiteNumber(metadata?.result?.estimated_time ?? metadata?.estimated_time);
51
+ value = estimate !== undefined && estimate > 0 ? estimate : undefined;
52
+ } catch {
53
+ value = undefined;
54
+ }
55
+ if (this.estimates.size > 64) this.estimates.clear();
56
+ this.estimates.set(key, { value, at: Date.now() });
57
+ return value;
58
+ }
59
+
60
+ async reportsLayers(): Promise<boolean> {
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * GET /server/webcams/list: enabled webcams' snapshot_url, which may be a
66
+ * path relative to the host. https://moonraker.readthedocs.io/en/latest/external_api/webcams/
67
+ */
68
+ async snapshotCandidates(host: string, port: string, apiKey: string): Promise<SnapshotCandidate[]> {
69
+ const data = (await this.apiClient.get(`http://${host}:${port}/server/webcams/list`, this.authHeaders(apiKey))).data;
70
+ const webcams = Array.isArray(data?.result?.webcams) ? data.result.webcams : [];
71
+ return webcams
72
+ .filter((webcam: any) => webcam?.enabled !== false && nonEmptyText(webcam?.snapshot_url))
73
+ .map((webcam: any) => ({ url: String(webcam.snapshot_url).trim(), source: "moonraker-webcam", configured: true }));
74
+ }
75
+
76
+ /**
77
+ * Spoolman through Moonraker: GET /server/spoolman/spool_id, then the proxy
78
+ * for /v1/spool/<id>. https://moonraker.readthedocs.io/en/latest/external_api/integrations/
79
+ * Spoolman color_hex has no '#'. Without Spoolman (404, null id) the slot is unknown.
80
+ */
81
+ async counterpartFilaments(host: string, port: string, apiKey: string): Promise<FilamentSlot[]> {
82
+ const unknown: FilamentSlot[] = [{ id: "ext", source: "unknown" }];
83
+ let spoolId: number | undefined;
84
+ try {
85
+ const active = (await this.apiClient.get(`http://${host}:${port}/server/spoolman/spool_id`, this.authHeaders(apiKey))).data;
86
+ spoolId = finiteNumber(active?.result?.spool_id);
87
+ } catch (error) {
88
+ if (isAxiosError(error) && error.response) return unknown;
89
+ throw error;
90
+ }
91
+ if (spoolId === undefined) return unknown;
92
+ const proxied = (await this.apiClient.post(
93
+ `http://${host}:${port}/server/spoolman/proxy`,
94
+ { request_method: "GET", path: `/v1/spool/${spoolId}` } as any,
95
+ this.authHeaders(apiKey)
96
+ )).data;
97
+ // v1 responses return the spool directly; use_v2_response wraps it in {response, error}.
98
+ const spool = proxied?.result?.response ?? proxied?.result;
99
+ const filament = spool?.filament ?? {};
100
+ const hex = nonEmptyText(filament.color_hex)?.replace(/^#/, "");
101
+ return [{
102
+ id: "ext",
103
+ material: nonEmptyText(filament.material),
104
+ color: hex && /^[0-9a-f]{6}$/i.test(hex) ? `#${hex.toUpperCase()}` : undefined,
105
+ name: nonEmptyText(filament.name),
106
+ source: "spoolman",
107
+ }];
108
+ }
109
+
110
+ async counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity> {
111
+ const info = (await this.apiClient.get(`http://${host}:${port}/printer/info`, this.authHeaders(apiKey))).data;
112
+ return { name: nonEmptyText(info?.result?.hostname) };
113
+ }
114
+
115
+ /**
116
+ * POST /printer/print/pause and /printer/print/resume (Moonraker printer API).
117
+ * Moonraker answers after the PAUSE/RESUME macro finishes (parking, retract),
118
+ * which can exceed the shared 10 s client timeout.
119
+ */
120
+ protected async rawPauseJob(host: string, port: string, apiKey: string) {
121
+ return (await this.apiClient.post(`http://${host}:${port}/printer/print/pause`, null as any, { ...this.authHeaders(apiKey), timeout: MACRO_TIMEOUT_MS })).data;
122
+ }
123
+
124
+ protected async rawResumeJob(host: string, port: string, apiKey: string) {
125
+ return (await this.apiClient.post(`http://${host}:${port}/printer/print/resume`, null as any, { ...this.authHeaders(apiKey), timeout: MACRO_TIMEOUT_MS })).data;
126
+ }
11
127
 
12
128
  protected authHeaders(apiKey: string): Record<string, unknown> {
13
129
  return apiKey ? { headers: { "X-Api-Key": apiKey } } : {};
@@ -1,7 +1,10 @@
1
1
  import fs from "fs";
2
2
  import FormData from "form-data";
3
3
  import { isAxiosError } from "axios";
4
- import { GenericPrinterImplementation, type GenericPrinterState, type HeaterComponent } from "./generic-base.js";
4
+ import { GenericPrinterImplementation, type GenericPrinterState, type HeaterComponent, type SnapshotCandidate } from "./generic-base.js";
5
+ import type { CounterpartProgress, PrinterIdentity } from "../counterpart/contract.js";
6
+ import { nonEmptyText } from "../counterpart/contract.js";
7
+ import { mapOctoPrint } from "../counterpart/progress.js";
5
8
 
6
9
  const BLOCKING_FLAGS = ["printing", "paused", "pausing", "cancelling", "resuming", "finishing", "error", "closedOrError"];
7
10
 
@@ -14,6 +17,88 @@ export class OctoPrintImplementation extends GenericPrinterImplementation {
14
17
  // POST /api/files/local with print=true uploads and starts the same bytes.
15
18
  protected readonly uploadStartsPrint = true;
16
19
 
20
+ /**
21
+ * GET /api/job plus GET /api/printer (temperatures; 409 when not
22
+ * operational). An Operational job with no progress reads the file's print
23
+ * history so a cancelled job is reported as cancelled.
24
+ * https://docs.octoprint.org/en/master/api/job.html
25
+ */
26
+ async counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
27
+ const base = `http://${host}:${port}`;
28
+ const headers = { "X-Api-Key": apiKey };
29
+ const job = (await this.apiClient.get(`${base}/api/job`, { headers })).data;
30
+ let printer: any;
31
+ try {
32
+ printer = (await this.apiClient.get(`${base}/api/printer`, { headers })).data;
33
+ } catch (error) {
34
+ if (!(isAxiosError(error) && error.response?.status === 409)) throw error;
35
+ }
36
+ const observedAt = new Date().toISOString();
37
+ let lastPrint: { success?: unknown } | undefined;
38
+ const file = job?.job?.file;
39
+ if (String(job?.state ?? "").toLowerCase() === "operational" && typeof file?.path === "string" && file.path &&
40
+ (job?.progress?.completion === null || job?.progress?.completion === undefined)) {
41
+ const origin = file.origin === "sdcard" ? "sdcard" : "local";
42
+ lastPrint = await this.apiClient.get(`${base}/api/files/${origin}/${encodePath(file.path)}`, { headers })
43
+ .then((response) => response.data?.prints?.last)
44
+ .catch(() => undefined);
45
+ }
46
+ return mapOctoPrint({ job, printer, lastPrint }, observedAt);
47
+ }
48
+
49
+ /**
50
+ * Snapshot URL from GET /api/settings: the bundled Classic Webcam plugin's
51
+ * plugins.classicwebcam.snapshot on 1.9+, webcam.snapshotUrl on 1.8 and
52
+ * earlier (1.9+ still fills it through its compatibility layer), then the
53
+ * OctoPi convention http://host/webcam/?action=snapshot.
54
+ * Source: OctoPrint src/octoprint/server/api/settings.py and
55
+ * src/octoprint/plugins/classicwebcam/__init__.py; checked on 1.8.7 and 1.11.2.
56
+ */
57
+ async snapshotCandidates(host: string, port: string, apiKey: string): Promise<SnapshotCandidate[]> {
58
+ const settings = (await this.apiClient.get(`http://${host}:${port}/api/settings`, { headers: { "X-Api-Key": apiKey } })).data;
59
+ const candidates: SnapshotCandidate[] = [];
60
+ if (settings?.webcam?.webcamEnabled !== false) {
61
+ const configured = [
62
+ settings?.plugins?.classicwebcam?.snapshot,
63
+ settings?.webcam?.snapshotUrl,
64
+ ...(Array.isArray(settings?.webcam?.webcams) ? settings.webcam.webcams.map((webcam: any) => webcam?.compat?.snapshot) : []),
65
+ ];
66
+ for (const url of configured) {
67
+ const text = nonEmptyText(url);
68
+ if (text) candidates.push({ url: text, source: "octoprint-settings", configured: true });
69
+ }
70
+ }
71
+ candidates.push({ url: `http://${host}/webcam/?action=snapshot`, source: "octoprint-fallback", configured: false });
72
+ return candidates;
73
+ }
74
+
75
+ async counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity> {
76
+ const headers = { "X-Api-Key": apiKey };
77
+ const [profiles, settings] = await Promise.all([
78
+ this.apiClient.get(`http://${host}:${port}/api/printerprofiles`, { headers }).then((r) => r.data).catch(() => undefined),
79
+ this.apiClient.get(`http://${host}:${port}/api/settings`, { headers }).then((r) => r.data).catch(() => undefined),
80
+ ]);
81
+ const all = Object.values(profiles?.profiles ?? {}) as any[];
82
+ const profile = all.find((entry) => entry?.current) ?? all.find((entry) => entry?.default);
83
+ return { model: nonEmptyText(profile?.model), name: nonEmptyText(settings?.appearance?.name) ?? nonEmptyText(profile?.name) };
84
+ }
85
+
86
+ /** POST /api/job {command:"pause", action:"pause"|"resume"}; 204 on success, 409 without an active job. */
87
+ protected async rawPauseJob(host: string, port: string, apiKey: string) {
88
+ return this.jobCommand(host, port, apiKey, { command: "pause", action: "pause" });
89
+ }
90
+
91
+ protected async rawResumeJob(host: string, port: string, apiKey: string) {
92
+ return this.jobCommand(host, port, apiKey, { command: "pause", action: "resume" });
93
+ }
94
+
95
+ private async jobCommand(host: string, port: string, apiKey: string, body: Record<string, string>) {
96
+ const response = await this.apiClient.post(`http://${host}:${port}/api/job`, body as any, {
97
+ headers: { "X-Api-Key": apiKey, "Content-Type": "application/json" },
98
+ });
99
+ return response.data;
100
+ }
101
+
17
102
  async getStatus(host: string, port: string, apiKey: string) {
18
103
  const url = `http://${host}:${port}/api/printer`;
19
104
  const response = await this.apiClient.get(url, {
@@ -1,5 +1,8 @@
1
1
  import { isAxiosError, type AxiosRequestConfig, type AxiosResponse } from "axios";
2
2
  import { GenericPrinterImplementation, type GenericPrinterState, type HeaterComponent } from "./generic-base.js";
3
+ import type { CounterpartProgress, PrinterIdentity } from "../counterpart/contract.js";
4
+ import { finiteNumber, nonEmptyText } from "../counterpart/contract.js";
5
+ import { mapOctoPrint, mapPrusaLink } from "../counterpart/progress.js";
3
6
  import fs from "fs";
4
7
  import FormData from "form-data";
5
8
 
@@ -124,6 +127,63 @@ export class PrusaImplementation extends GenericPrinterImplementation {
124
127
  throw lastError ?? new Error("No compatible Prusa POST endpoint found.");
125
128
  }
126
129
 
130
+ /**
131
+ * PrusaLink GET /api/v1/status, plus GET /api/v1/job for the file name
132
+ * (204 when there is no job). Older firmware without /api/v1 uses the
133
+ * OctoPrint-compatible /api/job and /api/printer.
134
+ * https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi.yaml
135
+ */
136
+ async counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
137
+ const baseUrl = this.buildBaseUrl(host, port);
138
+ const headers = this.buildAuthHeaders(apiKey);
139
+ let status: any;
140
+ try {
141
+ status = (await this.apiClient.get(`${baseUrl}/api/v1/status`, { headers })).data;
142
+ } catch (error) {
143
+ if (!this.isFallbackStatus(error)) throw error;
144
+ const job = (await this.apiClient.get(`${baseUrl}/api/job`, { headers })).data;
145
+ const printer = await this.apiClient.get(`${baseUrl}/api/printer`, { headers }).then((r) => r.data).catch(() => undefined);
146
+ return mapOctoPrint({ job, printer }, new Date().toISOString());
147
+ }
148
+ const observedAt = new Date().toISOString();
149
+ let job: any;
150
+ if (status?.job && finiteNumber(status.job.id) !== undefined) {
151
+ const response = await this.apiClient.get(`${baseUrl}/api/v1/job`, { headers }).catch(() => undefined);
152
+ job = response?.status === 200 ? response.data : undefined;
153
+ }
154
+ return mapPrusaLink(status, job, observedAt);
155
+ }
156
+
157
+ async counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity> {
158
+ const info = (await this.apiClient.get(`${this.buildBaseUrl(host, port)}/api/v1/info`, { headers: this.buildAuthHeaders(apiKey) })).data;
159
+ return { name: nonEmptyText(info?.name) ?? nonEmptyText(info?.hostname), serial: nonEmptyText(info?.serial) };
160
+ }
161
+
162
+ /** PUT /api/v1/job/{id}/pause; the OctoPrint-compatible POST /api/job on older firmware. */
163
+ protected async rawPauseJob(host: string, port: string, apiKey: string) {
164
+ return this.jobControl(host, port, apiKey, "pause");
165
+ }
166
+
167
+ protected async rawResumeJob(host: string, port: string, apiKey: string) {
168
+ return this.jobControl(host, port, apiKey, "resume");
169
+ }
170
+
171
+ private async jobControl(host: string, port: string, apiKey: string, action: "pause" | "resume") {
172
+ const baseUrl = this.buildBaseUrl(host, port);
173
+ const headers = this.buildAuthHeaders(apiKey);
174
+ let id: number | undefined;
175
+ try {
176
+ id = finiteNumber((await this.apiClient.get(`${baseUrl}/api/v1/status`, { headers })).data?.job?.id);
177
+ } catch (error) {
178
+ if (!this.isFallbackStatus(error)) throw error;
179
+ const response = await this.apiClient.post(`${baseUrl}/api/job`, { command: "pause", action } as any, { headers });
180
+ return response.data;
181
+ }
182
+ if (id === undefined) throw new Error(`PrusaLink reports no current job to ${action}; nothing was sent.`);
183
+ const response = await this.apiClient.put(`${baseUrl}/api/v1/job/${id}/${action}`, undefined, { headers });
184
+ return response.data;
185
+ }
186
+
127
187
  async getStatus(host: string, port: string, apiKey: string) {
128
188
  const response = await this.getWithFallback(host, port, apiKey, [
129
189
  "/api/v1/status",
@@ -1,6 +1,9 @@
1
1
  import fs from "fs";
2
2
  import FormData from "form-data";
3
- import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
3
+ import { GenericPrinterImplementation, type GenericPrinterState, type SnapshotCandidate } from "./generic-base.js";
4
+ import type { CounterpartProgress, PrinterIdentity } from "../counterpart/contract.js";
5
+ import { nonEmptyText } from "../counterpart/contract.js";
6
+ import { mapRepetier } from "../counterpart/progress.js";
4
7
 
5
8
  /**
6
9
  * Note: Repetier-Server documents printer commands at /printer/api/<slug>;
@@ -12,6 +15,65 @@ export class RepetierImplementation extends GenericPrinterImplementation {
12
15
  // The upload form's print=1 flag uploads and starts the same bytes.
13
16
  protected readonly uploadStartsPrint = true;
14
17
 
18
+ /**
19
+ * The printer the counterpart tools address: REPETIER_SLUG when set,
20
+ * otherwise the only printer listPrinter reports.
21
+ * REST form: /printer/api/<slug>?a=<command>&data=<json>&apikey=<key>
22
+ * (https://www.repetier-server.com/manuals/programming/API/index.html).
23
+ */
24
+ private async selectPrinter(host: string, port: string, apiKey: string): Promise<{ slug: string; entry: any }> {
25
+ const wanted = process.env.REPETIER_SLUG?.trim();
26
+ const route = wanted ? encodeURIComponent(wanted) : "";
27
+ const data = (await this.apiClient.get(`http://${host}:${port}/printer/api/${route}?a=listPrinter&apikey=${encodeURIComponent(apiKey)}`)).data;
28
+ const printers: any[] = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
29
+ if (wanted) {
30
+ const entry = printers.find((printer) => printer?.slug === wanted);
31
+ if (!entry) throw new Error(`Repetier lists no printer with slug "${wanted}" (REPETIER_SLUG).`);
32
+ return { slug: wanted, entry };
33
+ }
34
+ if (printers.length !== 1 || typeof printers[0]?.slug !== "string") {
35
+ throw new Error(`Repetier reports ${printers.length} printers; set REPETIER_SLUG to the printer's slug.`);
36
+ }
37
+ return { slug: printers[0].slug, entry: printers[0] };
38
+ }
39
+
40
+ private printerUrl(host: string, port: string, apiKey: string, slug: string, action: string, data?: unknown): string {
41
+ const query = `a=${action}${data === undefined ? "" : `&data=${encodeURIComponent(JSON.stringify(data))}`}&apikey=${encodeURIComponent(apiKey)}`;
42
+ return `http://${host}:${port}/printer/api/${encodeURIComponent(slug)}?${query}`;
43
+ }
44
+
45
+ async counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
46
+ const { entry } = await this.selectPrinter(host, port, apiKey);
47
+ return mapRepetier(entry, new Date().toISOString());
48
+ }
49
+
50
+ async counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity> {
51
+ const { entry, slug } = await this.selectPrinter(host, port, apiKey);
52
+ return { name: nonEmptyText(entry?.name) ?? slug };
53
+ }
54
+
55
+ /** getPrinterConfig webcams[].staticUrl (older servers: a single webcam object). */
56
+ async snapshotCandidates(host: string, port: string, apiKey: string): Promise<SnapshotCandidate[]> {
57
+ const { slug } = await this.selectPrinter(host, port, apiKey);
58
+ const config = (await this.apiClient.get(this.printerUrl(host, port, apiKey, slug, "getPrinterConfig"))).data;
59
+ const webcams = Array.isArray(config?.webcams) ? config.webcams : config?.webcam ? [config.webcam] : [];
60
+ return webcams
61
+ .map((webcam: any) => nonEmptyText(webcam?.staticUrl))
62
+ .filter((url: string | undefined): url is string => url !== undefined)
63
+ .map((url: string) => ({ url, source: "repetier-webcam", configured: true }));
64
+ }
65
+
66
+ /** Pause sends the @pause host command; resume is the documented continueJob. */
67
+ protected async rawPauseJob(host: string, port: string, apiKey: string) {
68
+ const { slug } = await this.selectPrinter(host, port, apiKey);
69
+ return (await this.apiClient.get(this.printerUrl(host, port, apiKey, slug, "send", { cmd: "@pause" }))).data;
70
+ }
71
+
72
+ protected async rawResumeJob(host: string, port: string, apiKey: string) {
73
+ const { slug } = await this.selectPrinter(host, port, apiKey);
74
+ return (await this.apiClient.get(this.printerUrl(host, port, apiKey, slug, "continueJob"))).data;
75
+ }
76
+
15
77
  async getStatus(host: string, port: string, apiKey: string) {
16
78
  const url = `http://${host}:${port}/printer/api/?a=getPrinterInfo&apikey=${apiKey}`;
17
79
  const response = await this.apiClient.get(url);
@@ -49,6 +49,8 @@ export interface BambuSliceOptions {
49
49
  minSave?: boolean; // --min-save
50
50
  skipModifiedGcodes?: boolean;// --skip-modified-gcodes
51
51
  slicePlate?: number; // --slice N (0 = all plates)
52
+ /** Process keys written over the resolved process preset (layer_height, wall_loops, sparse_infill_density). */
53
+ processOverrides?: Record<string, string>;
52
54
  }
53
55
 
54
56
  export interface BambuSettingsBundle {
@@ -527,6 +529,17 @@ export class BambuCliProfilePreparer {
527
529
  this.writeTempJson(outputBase, "process_orca_resolved", this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
528
530
  ].join(";");
529
531
  }
532
+ // Print-sheet quality/strength choices win over the preset and any template.
533
+ if (options.processOverrides && Object.keys(options.processOverrides).length && bundle.settingsArg) {
534
+ const [machinePath, processPath, ...rest] = bundle.settingsArg.split(";");
535
+ if (processPath) {
536
+ bundle.settingsArg = [
537
+ machinePath,
538
+ this.writeTempJson(outputBase, "process_overrides", { ...this.readJsonFile(processPath), ...options.processOverrides }),
539
+ ...rest,
540
+ ].join(";");
541
+ }
542
+ }
530
543
  return this.isolateMachineSettings(bundle);
531
544
  }
532
545