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
@@ -3,6 +3,14 @@ import { PrinterImplementation } from "../types.js";
3
3
  import { type ConfirmHardware } from "../safety/confirmation.js";
4
4
  import { type PrintSafetyOptions } from "../safety/expected-peaks.js";
5
5
  import { type Heater } from "../safety/gcode-thermal.js";
6
+ import type { CounterpartProgress, FilamentSlot, PrinterIdentity } from "../counterpart/contract.js";
7
+ /** A camera snapshot URL as the firmware configures it, before resolution against the printer host. */
8
+ export interface SnapshotCandidate {
9
+ url: string;
10
+ source: string;
11
+ /** True when the firmware's own settings name this URL; false for a conventional fallback. */
12
+ configured: boolean;
13
+ }
6
14
  /** Normalized adapter state used by the print and heating preflight. */
7
15
  export interface GenericPrinterState {
8
16
  /** Adapter-reported state text. */
@@ -21,18 +29,56 @@ export interface HeaterComponent {
21
29
  }
22
30
  /** Download timeout for remote-start inspection; the shared client times out at 10 s. */
23
31
  export declare const REMOTE_DOWNLOAD_TIMEOUT_MS = 120000;
24
- /**
25
- * Shared print and heating gate for non-Bambu adapters. Public mutations
26
- * validate every input before connecting, inspect the exact bytes that are
27
- * uploaded, check fresh adapter state, ask a human (MCP elicitation), recheck
28
- * state and only then dispatch. Heater-off and cancel are never gated.
29
- */
30
32
  export declare abstract class GenericPrinterImplementation extends PrinterImplementation {
31
33
  private readonly confirm?;
32
34
  protected abstract readonly printerLabel: string;
33
35
  /** True when rawUploadFile(print=true) starts the uploaded bytes atomically. */
34
36
  protected readonly uploadStartsPrint: boolean;
37
+ /**
38
+ * The last job this server instance inspected and started, per printer.
39
+ * Cleared by cancel and by a report that the job ended, so a later job
40
+ * reusing the name is never resumed on this record.
41
+ */
42
+ private readonly checkedJobs;
35
43
  constructor(apiClient: AxiosInstance, confirm?: ConfirmHardware | undefined);
44
+ /** Normalized progress for counterpart_print_progress; throws when the printer cannot be read. */
45
+ abstract counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress>;
46
+ /** Progress that also retires the resume record once the printer reports the job ended. */
47
+ observedProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress>;
48
+ /** Optional identity fields; failures are ignored by the caller. */
49
+ counterpartIdentity(_host: string, _port: string, _apiKey: string): Promise<PrinterIdentity>;
50
+ /** Snapshot URLs from the firmware's own settings, in preference order. */
51
+ snapshotCandidates(_host: string, _port: string, _apiKey: string): Promise<SnapshotCandidate[]>;
52
+ /** The single extruder slot; adapters with a spool manager override this. */
53
+ counterpartFilaments(_host: string, _port: string, _apiKey: string): Promise<FilamentSlot[]>;
54
+ /** Whether progress reports carry layer numbers on this firmware. */
55
+ reportsLayers(_host: string, _port: string, _apiKey: string): Promise<boolean>;
56
+ supportsPauseResume(_host: string, _port: string, _apiKey: string): Promise<boolean>;
57
+ protected rawPauseJob?(host: string, port: string, apiKey: string): Promise<unknown>;
58
+ protected rawResumeJob?(host: string, port: string, apiKey: string): Promise<unknown>;
59
+ /** Pause is never gated, like cancel and heater-off. */
60
+ pauseJob(host: string, port: string, apiKey: string): Promise<{
61
+ status: string;
62
+ action: "pause";
63
+ message: string;
64
+ }>;
65
+ /**
66
+ * Resume reheats and moves the printer, so it is gated like a print start:
67
+ * only the job this server instance inspected and started, only while the
68
+ * printer freshly reports that job paused, with its bytes re-read and
69
+ * compared where the firmware can download them, and after human
70
+ * confirmation (unless the server opted out). A stop or heater-off request
71
+ * in the meantime cancels it.
72
+ */
73
+ resumeJob(host: string, port: string, apiKey: string): Promise<{
74
+ status: string;
75
+ action: "resume";
76
+ message: string;
77
+ jobName: string;
78
+ bytesVerified: boolean;
79
+ }>;
80
+ private assertPausedJob;
81
+ private remoteSha256;
36
82
  protected abstract rawUploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean): Promise<any>;
37
83
  protected abstract rawStartJob(host: string, port: string, apiKey: string, filename: string): Promise<any>;
38
84
  protected abstract rawCancelJob(host: string, port: string, apiKey: string): Promise<any>;
@@ -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 { PrinterImplementation } from "../types.js";
6
6
  import { cancelPendingPrinterOperations, withPrinterOperation } from "../safety/artifact.js";
7
7
  import { MAX_PRINT_FILE_BYTES } from "../safety/archive.js";
@@ -10,18 +10,140 @@ import { assertExpectedPeaks, validateExpectedPeaks } from "../safety/expected-p
10
10
  import { assertInspectableGcodeName, finiteTemperature, genericCeilings, inspectGcodeFile, validateGenericTemperature, } from "../safety/gcode-thermal.js";
11
11
  /** Download timeout for remote-start inspection; the shared client times out at 10 s. */
12
12
  export const REMOTE_DOWNLOAD_TIMEOUT_MS = 120000;
13
- /**
14
- * Shared print and heating gate for non-Bambu adapters. Public mutations
15
- * validate every input before connecting, inspect the exact bytes that are
16
- * uploaded, check fresh adapter state, ask a human (MCP elicitation), recheck
17
- * state and only then dispatch. Heater-off and cancel are never gated.
18
- */
13
+ const checkedJob = (name, inspection) => ({ name, sha256: inspection.sha256, peaks: inspection.peaks, materials: inspection.materials, seenRunning: false });
14
+ const RUNNING_STATES = new Set(["preparing", "printing", "paused"]);
15
+ /** Reports after which the checked job can no longer be resumed. */
16
+ const ENDED_STATES = new Set(["idle", "finished", "cancelled", "failed"]);
19
17
  export class GenericPrinterImplementation extends PrinterImplementation {
20
18
  constructor(apiClient, confirm) {
21
19
  super(apiClient);
22
20
  this.confirm = confirm;
23
21
  /** True when rawUploadFile(print=true) starts the uploaded bytes atomically. */
24
22
  this.uploadStartsPrint = false;
23
+ /**
24
+ * The last job this server instance inspected and started, per printer.
25
+ * Cleared by cancel and by a report that the job ended, so a later job
26
+ * reusing the name is never resumed on this record.
27
+ */
28
+ this.checkedJobs = new Map();
29
+ }
30
+ /** Progress that also retires the resume record once the printer reports the job ended. */
31
+ async observedProgress(host, port, apiKey) {
32
+ const progress = await this.counterpartProgress(host, port, apiKey);
33
+ const key = this.operationKey(host, port);
34
+ const checked = this.checkedJobs.get(key);
35
+ if (checked && RUNNING_STATES.has(progress.state) && progress.job?.name === checked.name)
36
+ checked.seenRunning = true;
37
+ else if (checked?.seenRunning && (ENDED_STATES.has(progress.state) || (RUNNING_STATES.has(progress.state) && progress.job?.name !== undefined))) {
38
+ this.checkedJobs.delete(key);
39
+ }
40
+ return progress;
41
+ }
42
+ /** Optional identity fields; failures are ignored by the caller. */
43
+ async counterpartIdentity(_host, _port, _apiKey) {
44
+ return {};
45
+ }
46
+ /** Snapshot URLs from the firmware's own settings, in preference order. */
47
+ async snapshotCandidates(_host, _port, _apiKey) {
48
+ return [];
49
+ }
50
+ /** The single extruder slot; adapters with a spool manager override this. */
51
+ async counterpartFilaments(_host, _port, _apiKey) {
52
+ return [{ id: "ext", source: "unknown" }];
53
+ }
54
+ /** Whether progress reports carry layer numbers on this firmware. */
55
+ async reportsLayers(_host, _port, _apiKey) {
56
+ return false;
57
+ }
58
+ async supportsPauseResume(_host, _port, _apiKey) {
59
+ return this.rawPauseJob !== undefined && this.rawResumeJob !== undefined;
60
+ }
61
+ /** Pause is never gated, like cancel and heater-off. */
62
+ async pauseJob(host, port, apiKey) {
63
+ if (!(await this.supportsPauseResume(host, port, apiKey)) || !this.rawPauseJob) {
64
+ throw new Error(`Unsupported: ${this.printerLabel} has no verified pause route in this server.`);
65
+ }
66
+ await this.rawPauseJob(host, port, apiKey);
67
+ return { status: "success", action: "pause", message: `Pause command sent to ${this.printerLabel} (${host}:${port}).` };
68
+ }
69
+ /**
70
+ * Resume reheats and moves the printer, so it is gated like a print start:
71
+ * only the job this server instance inspected and started, only while the
72
+ * printer freshly reports that job paused, with its bytes re-read and
73
+ * compared where the firmware can download them, and after human
74
+ * confirmation (unless the server opted out). A stop or heater-off request
75
+ * in the meantime cancels it.
76
+ */
77
+ async resumeJob(host, port, apiKey) {
78
+ if (!(await this.supportsPauseResume(host, port, apiKey)) || !this.rawResumeJob) {
79
+ throw new Error(`Unsupported: ${this.printerLabel} has no verified resume route in this server.`);
80
+ }
81
+ const key = this.operationKey(host, port);
82
+ return withPrinterOperation(host, key, async (assertActive) => {
83
+ const checked = this.checkedJobs.get(key);
84
+ if (!checked) {
85
+ throw new Error(`Print safety: resume_print only resumes a job this server instance inspected and started on ${this.printerLabel}; nothing was sent. ` +
86
+ "Check the paused job at the printer and resume it there.");
87
+ }
88
+ await this.assertPausedJob(host, port, apiKey, checked.name);
89
+ let bytesVerified = false;
90
+ if (this.downloadRemoteFile) {
91
+ const sha256 = await this.remoteSha256(host, port, apiKey, checked.name).catch(() => {
92
+ 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.`);
93
+ });
94
+ if (sha256 !== checked.sha256) {
95
+ this.checkedJobs.delete(key);
96
+ throw new Error(`Print safety: ${checked.name} on ${this.printerLabel} is no longer the file this server inspected (SHA-256 changed); nothing was sent.`);
97
+ }
98
+ bytesVerified = true;
99
+ }
100
+ assertActive();
101
+ await requireHumanConfirmation(this.confirm, `Resume ${checked.name} on ${this.printerLabel} (${host}:${port})? It reheats to its targets (peak nozzle ${checked.peaks.nozzle}°C, ` +
102
+ `bed ${checked.peaks.bed}°C, chamber ${checked.peaks.chamber}°C) and moves. Materials: ${checked.materials.length ? checked.materials.join(", ") : "none declared"}. ` +
103
+ `File SHA-256: ${checked.sha256}${bytesVerified ? " (re-read from the printer)" : " (this firmware cannot re-read the file; matched by name)"}. ` +
104
+ "Confirm the nozzle path is clear and the filament is still loaded.", "generic");
105
+ assertActive();
106
+ // Someone may have resumed or stopped it at the printer while the prompt was open.
107
+ await this.assertPausedJob(host, port, apiKey, checked.name);
108
+ // A progress read during the prompt may have retired this record (the job ended, and a new one
109
+ // could now be paused under the same name); only the record that was confirmed may be resumed.
110
+ if (this.checkedJobs.get(key) !== checked) {
111
+ throw new Error(`Print safety: ${checked.name} ended on ${this.printerLabel} while the confirmation was open; nothing was sent.`);
112
+ }
113
+ assertActive();
114
+ await this.rawResumeJob(host, port, apiKey);
115
+ return {
116
+ status: "success",
117
+ action: "resume",
118
+ message: `Resume command sent to ${this.printerLabel} (${host}:${port}) for ${checked.name}.`,
119
+ jobName: checked.name,
120
+ bytesVerified,
121
+ };
122
+ });
123
+ }
124
+ async assertPausedJob(host, port, apiKey, name) {
125
+ const progress = await this.observedProgress(host, port, apiKey);
126
+ if (progress.state === "paused" && progress.job?.stage === "Pausing") {
127
+ // OctoPrint accepts a resume while still pausing and then drops it.
128
+ throw new Error(`Print safety: ${this.printerLabel} is still pausing; nothing was sent. Resume once it reports paused.`);
129
+ }
130
+ if (progress.state !== "paused") {
131
+ throw new Error(`Print safety: ${this.printerLabel} reports '${progress.state}', not a paused job; nothing was sent.`);
132
+ }
133
+ if (progress.job?.name !== name) {
134
+ throw new Error(`Print safety: the paused job (${progress.job?.name ?? "unknown"}) does not match the job this server started (${name}); nothing was sent.`);
135
+ }
136
+ }
137
+ async remoteSha256(host, port, apiKey, name) {
138
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "printer-resume-"));
139
+ try {
140
+ const destination = path.join(directory, "job.gcode");
141
+ await this.downloadRemoteFile(host, port, apiKey, name, destination);
142
+ return createHash("sha256").update(await fs.readFile(destination)).digest("hex");
143
+ }
144
+ finally {
145
+ await fs.rm(directory, { recursive: true, force: true });
146
+ }
25
147
  }
26
148
  /** Adapter-specific upload acknowledgement for the upload-then-start route. */
27
149
  uploadSucceeded(_data) {
@@ -45,6 +167,8 @@ export class GenericPrinterImplementation extends PrinterImplementation {
45
167
  }
46
168
  async cancelJob(host, port, apiKey) {
47
169
  cancelPendingPrinterOperations(host, this.operationKey(host, port));
170
+ // A cancelled job is never resumed, even if a later job reuses its name.
171
+ this.checkedJobs.delete(this.operationKey(host, port));
48
172
  return this.rawCancelJob(host, port, apiKey);
49
173
  }
50
174
  async setTemperature(host, port, apiKey, component, temperature, options = {}) {
@@ -166,6 +290,7 @@ export class GenericPrinterImplementation extends PrinterImplementation {
166
290
  status: "success",
167
291
  printRequested: true,
168
292
  remotePath: remoteName,
293
+ jobName: remoteName,
169
294
  sha256: inspection.sha256,
170
295
  materials: inspection.materials,
171
296
  peakTemperatures: inspection.peaks,
@@ -184,6 +309,7 @@ export class GenericPrinterImplementation extends PrinterImplementation {
184
309
  if (!this.uploadStartedPrint(response)) {
185
310
  throw new Error(`${this.printerLabel} stored ${remoteName} but reports that it did not start printing it.`);
186
311
  }
312
+ this.checkedJobs.set(this.operationKey(host, port), checkedJob(remoteName, inspection));
187
313
  return { ...summary, response };
188
314
  }
189
315
  const upload = await this.rawUploadFile(host, port, apiKey, snapshot, remoteName, false);
@@ -196,6 +322,7 @@ export class GenericPrinterImplementation extends PrinterImplementation {
196
322
  this.assertBedClearance(dispatch, initial.finishedJob);
197
323
  assertActive();
198
324
  const response = await this.rawStartJob(host, port, apiKey, remoteName);
325
+ this.checkedJobs.set(this.operationKey(host, port), checkedJob(remoteName, inspection));
199
326
  return { ...summary, upload, response };
200
327
  }
201
328
  printPrompt(host, port, remoteName, inspection, state) {
@@ -1,6 +1,37 @@
1
- import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
1
+ import { GenericPrinterImplementation, type GenericPrinterState, type SnapshotCandidate } from "./generic-base.js";
2
+ import type { CounterpartProgress, FilamentSlot, PrinterIdentity } from "../counterpart/contract.js";
3
+ export declare const MACRO_TIMEOUT_MS = 60000;
2
4
  export declare class KlipperImplementation extends GenericPrinterImplementation {
3
- protected readonly printerLabel = "Klipper/Moonraker";
5
+ protected readonly printerLabel: string;
6
+ /** Slicer estimates by host:port:filename, so a poll does not re-read file metadata. */
7
+ private readonly estimates;
8
+ /**
9
+ * One object query; remaining time is the file's slicer estimate minus
10
+ * print_duration. https://moonraker.readthedocs.io/en/latest/external_api/printer/
11
+ * and .../external_api/file_manager/ (estimated_time, seconds).
12
+ */
13
+ counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress>;
14
+ private estimatedTime;
15
+ reportsLayers(): Promise<boolean>;
16
+ /**
17
+ * GET /server/webcams/list: enabled webcams' snapshot_url, which may be a
18
+ * path relative to the host. https://moonraker.readthedocs.io/en/latest/external_api/webcams/
19
+ */
20
+ snapshotCandidates(host: string, port: string, apiKey: string): Promise<SnapshotCandidate[]>;
21
+ /**
22
+ * Spoolman through Moonraker: GET /server/spoolman/spool_id, then the proxy
23
+ * for /v1/spool/<id>. https://moonraker.readthedocs.io/en/latest/external_api/integrations/
24
+ * Spoolman color_hex has no '#'. Without Spoolman (404, null id) the slot is unknown.
25
+ */
26
+ counterpartFilaments(host: string, port: string, apiKey: string): Promise<FilamentSlot[]>;
27
+ counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity>;
28
+ /**
29
+ * POST /printer/print/pause and /printer/print/resume (Moonraker printer API).
30
+ * Moonraker answers after the PAUSE/RESUME macro finishes (parking, retract),
31
+ * which can exceed the shared 10 s client timeout.
32
+ */
33
+ protected rawPauseJob(host: string, port: string, apiKey: string): Promise<any>;
34
+ protected rawResumeJob(host: string, port: string, apiKey: string): Promise<any>;
4
35
  protected authHeaders(apiKey: string): Record<string, unknown>;
5
36
  getStatus(host: string, port: string, apiKey: string): Promise<any>;
6
37
  getFiles(host: string, port: string, apiKey: string): Promise<any>;
@@ -1,6 +1,12 @@
1
1
  import fs from "fs";
2
2
  import FormData from "form-data";
3
+ import { isAxiosError } from "axios";
3
4
  import { GenericPrinterImplementation } from "./generic-base.js";
5
+ import { finiteNumber, nonEmptyText } from "../counterpart/contract.js";
6
+ import { mapMoonraker } from "../counterpart/progress.js";
7
+ const PROGRESS_QUERY = "print_stats&virtual_sdcard&display_status&extruder&heater_bed&webhooks";
8
+ const ESTIMATE_TTL_MS = 10 * 60000;
9
+ export const MACRO_TIMEOUT_MS = 60000;
4
10
  function encodePath(filename) {
5
11
  return filename.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
6
12
  }
@@ -8,6 +14,106 @@ export class KlipperImplementation extends GenericPrinterImplementation {
8
14
  constructor() {
9
15
  super(...arguments);
10
16
  this.printerLabel = "Klipper/Moonraker";
17
+ /** Slicer estimates by host:port:filename, so a poll does not re-read file metadata. */
18
+ this.estimates = new Map();
19
+ }
20
+ /**
21
+ * One object query; remaining time is the file's slicer estimate minus
22
+ * print_duration. https://moonraker.readthedocs.io/en/latest/external_api/printer/
23
+ * and .../external_api/file_manager/ (estimated_time, seconds).
24
+ */
25
+ async counterpartProgress(host, port, apiKey) {
26
+ const data = (await this.apiClient.get(`http://${host}:${port}/printer/objects/query?${PROGRESS_QUERY}`, this.authHeaders(apiKey))).data;
27
+ const observedAt = new Date().toISOString();
28
+ const status = data?.result?.status;
29
+ if (!status || typeof status !== "object")
30
+ throw new Error("Moonraker returned no printer object status.");
31
+ const stats = status.print_stats ?? {};
32
+ const filename = nonEmptyText(stats.filename);
33
+ const estimate = filename && (stats.state === "printing" || stats.state === "paused")
34
+ ? await this.estimatedTime(host, port, apiKey, filename)
35
+ : undefined;
36
+ return mapMoonraker(status, estimate, observedAt);
37
+ }
38
+ async estimatedTime(host, port, apiKey, filename) {
39
+ const key = `${host}:${port}:${filename}`;
40
+ const cached = this.estimates.get(key);
41
+ if (cached && Date.now() - cached.at < ESTIMATE_TTL_MS)
42
+ return cached.value;
43
+ let value;
44
+ try {
45
+ const metadata = (await this.apiClient.get(`http://${host}:${port}/server/files/metadata?filename=${encodeURIComponent(filename)}`, this.authHeaders(apiKey))).data;
46
+ const estimate = finiteNumber(metadata?.result?.estimated_time ?? metadata?.estimated_time);
47
+ value = estimate !== undefined && estimate > 0 ? estimate : undefined;
48
+ }
49
+ catch {
50
+ value = undefined;
51
+ }
52
+ if (this.estimates.size > 64)
53
+ this.estimates.clear();
54
+ this.estimates.set(key, { value, at: Date.now() });
55
+ return value;
56
+ }
57
+ async reportsLayers() {
58
+ return true;
59
+ }
60
+ /**
61
+ * GET /server/webcams/list: enabled webcams' snapshot_url, which may be a
62
+ * path relative to the host. https://moonraker.readthedocs.io/en/latest/external_api/webcams/
63
+ */
64
+ async snapshotCandidates(host, port, apiKey) {
65
+ const data = (await this.apiClient.get(`http://${host}:${port}/server/webcams/list`, this.authHeaders(apiKey))).data;
66
+ const webcams = Array.isArray(data?.result?.webcams) ? data.result.webcams : [];
67
+ return webcams
68
+ .filter((webcam) => webcam?.enabled !== false && nonEmptyText(webcam?.snapshot_url))
69
+ .map((webcam) => ({ url: String(webcam.snapshot_url).trim(), source: "moonraker-webcam", configured: true }));
70
+ }
71
+ /**
72
+ * Spoolman through Moonraker: GET /server/spoolman/spool_id, then the proxy
73
+ * for /v1/spool/<id>. https://moonraker.readthedocs.io/en/latest/external_api/integrations/
74
+ * Spoolman color_hex has no '#'. Without Spoolman (404, null id) the slot is unknown.
75
+ */
76
+ async counterpartFilaments(host, port, apiKey) {
77
+ const unknown = [{ id: "ext", source: "unknown" }];
78
+ let spoolId;
79
+ try {
80
+ const active = (await this.apiClient.get(`http://${host}:${port}/server/spoolman/spool_id`, this.authHeaders(apiKey))).data;
81
+ spoolId = finiteNumber(active?.result?.spool_id);
82
+ }
83
+ catch (error) {
84
+ if (isAxiosError(error) && error.response)
85
+ return unknown;
86
+ throw error;
87
+ }
88
+ if (spoolId === undefined)
89
+ return unknown;
90
+ const proxied = (await this.apiClient.post(`http://${host}:${port}/server/spoolman/proxy`, { request_method: "GET", path: `/v1/spool/${spoolId}` }, this.authHeaders(apiKey))).data;
91
+ // v1 responses return the spool directly; use_v2_response wraps it in {response, error}.
92
+ const spool = proxied?.result?.response ?? proxied?.result;
93
+ const filament = spool?.filament ?? {};
94
+ const hex = nonEmptyText(filament.color_hex)?.replace(/^#/, "");
95
+ return [{
96
+ id: "ext",
97
+ material: nonEmptyText(filament.material),
98
+ color: hex && /^[0-9a-f]{6}$/i.test(hex) ? `#${hex.toUpperCase()}` : undefined,
99
+ name: nonEmptyText(filament.name),
100
+ source: "spoolman",
101
+ }];
102
+ }
103
+ async counterpartIdentity(host, port, apiKey) {
104
+ const info = (await this.apiClient.get(`http://${host}:${port}/printer/info`, this.authHeaders(apiKey))).data;
105
+ return { name: nonEmptyText(info?.result?.hostname) };
106
+ }
107
+ /**
108
+ * POST /printer/print/pause and /printer/print/resume (Moonraker printer API).
109
+ * Moonraker answers after the PAUSE/RESUME macro finishes (parking, retract),
110
+ * which can exceed the shared 10 s client timeout.
111
+ */
112
+ async rawPauseJob(host, port, apiKey) {
113
+ return (await this.apiClient.post(`http://${host}:${port}/printer/print/pause`, null, { ...this.authHeaders(apiKey), timeout: MACRO_TIMEOUT_MS })).data;
114
+ }
115
+ async rawResumeJob(host, port, apiKey) {
116
+ return (await this.apiClient.post(`http://${host}:${port}/printer/print/resume`, null, { ...this.authHeaders(apiKey), timeout: MACRO_TIMEOUT_MS })).data;
11
117
  }
12
118
  authHeaders(apiKey) {
13
119
  return apiKey ? { headers: { "X-Api-Key": apiKey } } : {};
@@ -1,7 +1,29 @@
1
- import { GenericPrinterImplementation, type GenericPrinterState, type HeaterComponent } from "./generic-base.js";
1
+ import { GenericPrinterImplementation, type GenericPrinterState, type HeaterComponent, type SnapshotCandidate } from "./generic-base.js";
2
+ import type { CounterpartProgress, PrinterIdentity } from "../counterpart/contract.js";
2
3
  export declare class OctoPrintImplementation extends GenericPrinterImplementation {
3
4
  protected readonly printerLabel = "OctoPrint";
4
5
  protected readonly uploadStartsPrint = true;
6
+ /**
7
+ * GET /api/job plus GET /api/printer (temperatures; 409 when not
8
+ * operational). An Operational job with no progress reads the file's print
9
+ * history so a cancelled job is reported as cancelled.
10
+ * https://docs.octoprint.org/en/master/api/job.html
11
+ */
12
+ counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress>;
13
+ /**
14
+ * Snapshot URL from GET /api/settings: the bundled Classic Webcam plugin's
15
+ * plugins.classicwebcam.snapshot on 1.9+, webcam.snapshotUrl on 1.8 and
16
+ * earlier (1.9+ still fills it through its compatibility layer), then the
17
+ * OctoPi convention http://host/webcam/?action=snapshot.
18
+ * Source: OctoPrint src/octoprint/server/api/settings.py and
19
+ * src/octoprint/plugins/classicwebcam/__init__.py; checked on 1.8.7 and 1.11.2.
20
+ */
21
+ snapshotCandidates(host: string, port: string, apiKey: string): Promise<SnapshotCandidate[]>;
22
+ counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity>;
23
+ /** POST /api/job {command:"pause", action:"pause"|"resume"}; 204 on success, 409 without an active job. */
24
+ protected rawPauseJob(host: string, port: string, apiKey: string): Promise<any>;
25
+ protected rawResumeJob(host: string, port: string, apiKey: string): Promise<any>;
26
+ private jobCommand;
5
27
  getStatus(host: string, port: string, apiKey: string): Promise<any>;
6
28
  getFiles(host: string, port: string, apiKey: string): Promise<any>;
7
29
  getFile(host: string, port: string, apiKey: string, filename: string): Promise<any>;
@@ -2,6 +2,8 @@ import fs from "fs";
2
2
  import FormData from "form-data";
3
3
  import { isAxiosError } from "axios";
4
4
  import { GenericPrinterImplementation } from "./generic-base.js";
5
+ import { nonEmptyText } from "../counterpart/contract.js";
6
+ import { mapOctoPrint } from "../counterpart/progress.js";
5
7
  const BLOCKING_FLAGS = ["printing", "paused", "pausing", "cancelling", "resuming", "finishing", "error", "closedOrError"];
6
8
  function encodePath(filename) {
7
9
  return filename.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
@@ -13,6 +15,85 @@ export class OctoPrintImplementation extends GenericPrinterImplementation {
13
15
  // POST /api/files/local with print=true uploads and starts the same bytes.
14
16
  this.uploadStartsPrint = true;
15
17
  }
18
+ /**
19
+ * GET /api/job plus GET /api/printer (temperatures; 409 when not
20
+ * operational). An Operational job with no progress reads the file's print
21
+ * history so a cancelled job is reported as cancelled.
22
+ * https://docs.octoprint.org/en/master/api/job.html
23
+ */
24
+ async counterpartProgress(host, port, apiKey) {
25
+ const base = `http://${host}:${port}`;
26
+ const headers = { "X-Api-Key": apiKey };
27
+ const job = (await this.apiClient.get(`${base}/api/job`, { headers })).data;
28
+ let printer;
29
+ try {
30
+ printer = (await this.apiClient.get(`${base}/api/printer`, { headers })).data;
31
+ }
32
+ catch (error) {
33
+ if (!(isAxiosError(error) && error.response?.status === 409))
34
+ throw error;
35
+ }
36
+ const observedAt = new Date().toISOString();
37
+ let lastPrint;
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
+ * Snapshot URL from GET /api/settings: the bundled Classic Webcam plugin's
50
+ * plugins.classicwebcam.snapshot on 1.9+, webcam.snapshotUrl on 1.8 and
51
+ * earlier (1.9+ still fills it through its compatibility layer), then the
52
+ * OctoPi convention http://host/webcam/?action=snapshot.
53
+ * Source: OctoPrint src/octoprint/server/api/settings.py and
54
+ * src/octoprint/plugins/classicwebcam/__init__.py; checked on 1.8.7 and 1.11.2.
55
+ */
56
+ async snapshotCandidates(host, port, apiKey) {
57
+ const settings = (await this.apiClient.get(`http://${host}:${port}/api/settings`, { headers: { "X-Api-Key": apiKey } })).data;
58
+ const candidates = [];
59
+ if (settings?.webcam?.webcamEnabled !== false) {
60
+ const configured = [
61
+ settings?.plugins?.classicwebcam?.snapshot,
62
+ settings?.webcam?.snapshotUrl,
63
+ ...(Array.isArray(settings?.webcam?.webcams) ? settings.webcam.webcams.map((webcam) => webcam?.compat?.snapshot) : []),
64
+ ];
65
+ for (const url of configured) {
66
+ const text = nonEmptyText(url);
67
+ if (text)
68
+ 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
+ async counterpartIdentity(host, port, apiKey) {
75
+ const headers = { "X-Api-Key": apiKey };
76
+ const [profiles, settings] = await Promise.all([
77
+ this.apiClient.get(`http://${host}:${port}/api/printerprofiles`, { headers }).then((r) => r.data).catch(() => undefined),
78
+ this.apiClient.get(`http://${host}:${port}/api/settings`, { headers }).then((r) => r.data).catch(() => undefined),
79
+ ]);
80
+ const all = Object.values(profiles?.profiles ?? {});
81
+ const profile = all.find((entry) => entry?.current) ?? all.find((entry) => entry?.default);
82
+ return { model: nonEmptyText(profile?.model), name: nonEmptyText(settings?.appearance?.name) ?? nonEmptyText(profile?.name) };
83
+ }
84
+ /** POST /api/job {command:"pause", action:"pause"|"resume"}; 204 on success, 409 without an active job. */
85
+ async rawPauseJob(host, port, apiKey) {
86
+ return this.jobCommand(host, port, apiKey, { command: "pause", action: "pause" });
87
+ }
88
+ async rawResumeJob(host, port, apiKey) {
89
+ return this.jobCommand(host, port, apiKey, { command: "pause", action: "resume" });
90
+ }
91
+ async jobCommand(host, port, apiKey, body) {
92
+ const response = await this.apiClient.post(`http://${host}:${port}/api/job`, body, {
93
+ headers: { "X-Api-Key": apiKey, "Content-Type": "application/json" },
94
+ });
95
+ return response.data;
96
+ }
16
97
  async getStatus(host, port, apiKey) {
17
98
  const url = `http://${host}:${port}/api/printer`;
18
99
  const response = await this.apiClient.get(url, {
@@ -1,4 +1,5 @@
1
1
  import { GenericPrinterImplementation, type GenericPrinterState, type HeaterComponent } from "./generic-base.js";
2
+ import type { CounterpartProgress, PrinterIdentity } from "../counterpart/contract.js";
2
3
  export declare class PrusaImplementation extends GenericPrinterImplementation {
3
4
  protected readonly printerLabel = "Prusa";
4
5
  private buildAuthHeaders;
@@ -9,6 +10,18 @@ export declare class PrusaImplementation extends GenericPrinterImplementation {
9
10
  private isFallbackStatus;
10
11
  private getWithFallback;
11
12
  private postWithFallback;
13
+ /**
14
+ * PrusaLink GET /api/v1/status, plus GET /api/v1/job for the file name
15
+ * (204 when there is no job). Older firmware without /api/v1 uses the
16
+ * OctoPrint-compatible /api/job and /api/printer.
17
+ * https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi.yaml
18
+ */
19
+ counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress>;
20
+ counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity>;
21
+ /** PUT /api/v1/job/{id}/pause; the OctoPrint-compatible POST /api/job on older firmware. */
22
+ protected rawPauseJob(host: string, port: string, apiKey: string): Promise<any>;
23
+ protected rawResumeJob(host: string, port: string, apiKey: string): Promise<any>;
24
+ private jobControl;
12
25
  getStatus(host: string, port: string, apiKey: string): Promise<any>;
13
26
  getFiles(host: string, port: string, apiKey: string): Promise<any>;
14
27
  getFile(host: string, port: string, apiKey: string, filename: string): Promise<any>;
@@ -1,5 +1,7 @@
1
1
  import { isAxiosError } from "axios";
2
2
  import { GenericPrinterImplementation } from "./generic-base.js";
3
+ import { finiteNumber, nonEmptyText } from "../counterpart/contract.js";
4
+ import { mapOctoPrint, mapPrusaLink } from "../counterpart/progress.js";
3
5
  import fs from "fs";
4
6
  import FormData from "form-data";
5
7
  const PRUSALINK_READY_STATES = new Set(["IDLE", "READY", "FINISHED", "STOPPED"]);
@@ -96,6 +98,63 @@ export class PrusaImplementation extends GenericPrinterImplementation {
96
98
  }
97
99
  throw lastError ?? new Error("No compatible Prusa POST endpoint found.");
98
100
  }
101
+ /**
102
+ * PrusaLink GET /api/v1/status, plus GET /api/v1/job for the file name
103
+ * (204 when there is no job). Older firmware without /api/v1 uses the
104
+ * OctoPrint-compatible /api/job and /api/printer.
105
+ * https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi.yaml
106
+ */
107
+ async counterpartProgress(host, port, apiKey) {
108
+ const baseUrl = this.buildBaseUrl(host, port);
109
+ const headers = this.buildAuthHeaders(apiKey);
110
+ let status;
111
+ try {
112
+ status = (await this.apiClient.get(`${baseUrl}/api/v1/status`, { headers })).data;
113
+ }
114
+ catch (error) {
115
+ if (!this.isFallbackStatus(error))
116
+ throw error;
117
+ const job = (await this.apiClient.get(`${baseUrl}/api/job`, { headers })).data;
118
+ const printer = await this.apiClient.get(`${baseUrl}/api/printer`, { headers }).then((r) => r.data).catch(() => undefined);
119
+ return mapOctoPrint({ job, printer }, new Date().toISOString());
120
+ }
121
+ const observedAt = new Date().toISOString();
122
+ let job;
123
+ if (status?.job && finiteNumber(status.job.id) !== undefined) {
124
+ const response = await this.apiClient.get(`${baseUrl}/api/v1/job`, { headers }).catch(() => undefined);
125
+ job = response?.status === 200 ? response.data : undefined;
126
+ }
127
+ return mapPrusaLink(status, job, observedAt);
128
+ }
129
+ async counterpartIdentity(host, port, apiKey) {
130
+ const info = (await this.apiClient.get(`${this.buildBaseUrl(host, port)}/api/v1/info`, { headers: this.buildAuthHeaders(apiKey) })).data;
131
+ return { name: nonEmptyText(info?.name) ?? nonEmptyText(info?.hostname), serial: nonEmptyText(info?.serial) };
132
+ }
133
+ /** PUT /api/v1/job/{id}/pause; the OctoPrint-compatible POST /api/job on older firmware. */
134
+ async rawPauseJob(host, port, apiKey) {
135
+ return this.jobControl(host, port, apiKey, "pause");
136
+ }
137
+ async rawResumeJob(host, port, apiKey) {
138
+ return this.jobControl(host, port, apiKey, "resume");
139
+ }
140
+ async jobControl(host, port, apiKey, action) {
141
+ const baseUrl = this.buildBaseUrl(host, port);
142
+ const headers = this.buildAuthHeaders(apiKey);
143
+ let id;
144
+ try {
145
+ id = finiteNumber((await this.apiClient.get(`${baseUrl}/api/v1/status`, { headers })).data?.job?.id);
146
+ }
147
+ catch (error) {
148
+ if (!this.isFallbackStatus(error))
149
+ throw error;
150
+ const response = await this.apiClient.post(`${baseUrl}/api/job`, { command: "pause", action }, { headers });
151
+ return response.data;
152
+ }
153
+ if (id === undefined)
154
+ throw new Error(`PrusaLink reports no current job to ${action}; nothing was sent.`);
155
+ const response = await this.apiClient.put(`${baseUrl}/api/v1/job/${id}/${action}`, undefined, { headers });
156
+ return response.data;
157
+ }
99
158
  async getStatus(host, port, apiKey) {
100
159
  const response = await this.getWithFallback(host, port, apiKey, [
101
160
  "/api/v1/status",