mcp-3d-printer-server 1.2.20 → 1.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +112 -9
- package/dist/blender-mcp-bridge.d.ts +21 -0
- package/dist/blender-mcp-bridge.js +3 -3
- package/dist/counterpart/contract.d.ts +395 -0
- package/dist/counterpart/contract.js +220 -0
- package/dist/counterpart/progress.d.ts +32 -0
- package/dist/counterpart/progress.js +443 -0
- package/dist/counterpart/service.d.ts +56 -0
- package/dist/counterpart/service.js +232 -0
- package/dist/fusion-mcp-bridge.d.ts +166 -0
- package/dist/fusion-mcp-bridge.js +529 -0
- package/dist/index.js +272 -8
- package/dist/printers/creality.d.ts +26 -1
- package/dist/printers/creality.js +75 -2
- package/dist/printers/duet.d.ts +16 -0
- package/dist/printers/duet.js +64 -0
- package/dist/printers/generic-base.d.ts +52 -6
- package/dist/printers/generic-base.js +134 -7
- package/dist/printers/klipper.d.ts +33 -2
- package/dist/printers/klipper.js +106 -0
- package/dist/printers/octoprint.d.ts +23 -1
- package/dist/printers/octoprint.js +81 -0
- package/dist/printers/prusa.d.ts +13 -0
- package/dist/printers/prusa.js +59 -0
- package/dist/printers/repetier.d.ts +17 -1
- package/dist/printers/repetier.js +55 -0
- package/dist/slicer/bambu-cli.d.ts +2 -0
- package/dist/slicer/bambu-cli.js +11 -0
- package/dist/slicer/filament-profiles.d.ts +25 -0
- package/dist/slicer/filament-profiles.js +249 -0
- package/dist/stl/stl-manipulator.d.ts +21 -1
- package/dist/stl/stl-manipulator.js +86 -6
- package/package.json +7 -2
- package/server.json +2 -2
- package/src/blender-mcp-bridge.ts +3 -3
- package/src/counterpart/contract.ts +326 -0
- package/src/counterpart/progress.ts +470 -0
- package/src/counterpart/service.ts +253 -0
- package/src/fusion-mcp-bridge.ts +512 -0
- package/src/index.ts +314 -10
- package/src/printers/creality.ts +84 -1
- package/src/printers/duet.ts +67 -0
- package/src/printers/generic-base.ts +172 -1
- package/src/printers/klipper.ts +118 -2
- package/src/printers/octoprint.ts +86 -1
- package/src/printers/prusa.ts +60 -0
- package/src/printers/repetier.ts +63 -1
- package/src/slicer/bambu-cli.ts +13 -0
- package/src/slicer/filament-profiles.ts +249 -0
- 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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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>;
|
package/dist/printers/klipper.js
CHANGED
|
@@ -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, {
|
package/dist/printers/prusa.d.ts
CHANGED
|
@@ -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>;
|
package/dist/printers/prusa.js
CHANGED
|
@@ -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",
|