mcp-3d-printer-server 1.2.19 → 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.
- package/CHANGELOG.md +31 -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 +224 -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 +94 -13
- 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 +247 -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 +108 -13
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-firmware mapping for counterpart_print_progress (PRD §8.4). Each function
|
|
3
|
+
* is pure: it receives the firmware's own JSON and returns the shared shape.
|
|
4
|
+
* Sources are cited per mapper; rows marked "simulator" were also checked
|
|
5
|
+
* against a running instance (see CHANGELOG evidence notes).
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
type CounterpartJob,
|
|
9
|
+
type CounterpartProblem,
|
|
10
|
+
type CounterpartProgress,
|
|
11
|
+
type ProblemSeverity,
|
|
12
|
+
type ProgressState,
|
|
13
|
+
finiteNumber,
|
|
14
|
+
heaterReading,
|
|
15
|
+
layerNumber,
|
|
16
|
+
nonEmptyText,
|
|
17
|
+
percent,
|
|
18
|
+
progressResult,
|
|
19
|
+
remoteJobName,
|
|
20
|
+
seconds,
|
|
21
|
+
} from "./contract.js";
|
|
22
|
+
|
|
23
|
+
/** States in which a known job is reported; idle/offline/busy report none. */
|
|
24
|
+
const JOB_STATES = new Set<ProgressState>(["preparing", "printing", "paused", "finished", "failed", "cancelled"]);
|
|
25
|
+
|
|
26
|
+
function job(state: ProgressState, details: Partial<CounterpartJob> & { name?: string }): CounterpartJob | undefined {
|
|
27
|
+
if (!JOB_STATES.has(state) || !details.name) return undefined;
|
|
28
|
+
return details as CounterpartJob;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// OctoPrint — GET /api/job and GET /api/printer
|
|
33
|
+
// https://docs.octoprint.org/en/master/api/job.html
|
|
34
|
+
// https://docs.octoprint.org/en/master/api/printer.html (409 when not operational)
|
|
35
|
+
// State strings: OctoPrint src/octoprint/util/comm.py getStateString.
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export interface OctoPrintInputs {
|
|
39
|
+
job: any;
|
|
40
|
+
/** GET /api/printer body; undefined when it returned 409 (not operational). */
|
|
41
|
+
printer?: any;
|
|
42
|
+
/** prints.last of the selected file, read only when an Operational job has no progress. */
|
|
43
|
+
lastPrint?: { success?: unknown };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function octoPrintState(text: string, completion: number | undefined, hasFile: boolean, lastPrint?: { success?: unknown }): ProgressState {
|
|
47
|
+
const state = text.trim().toLowerCase();
|
|
48
|
+
if (state === "operational") {
|
|
49
|
+
if (hasFile && completion !== undefined && completion >= 100) return "finished";
|
|
50
|
+
// A cancelled job returns to Operational with null progress; OctoPrint
|
|
51
|
+
// records it in the file's print history as an unsuccessful print.
|
|
52
|
+
if (hasFile && completion === undefined && lastPrint?.success === false) return "cancelled";
|
|
53
|
+
return "idle";
|
|
54
|
+
}
|
|
55
|
+
if (state === "printing" || state === "printing from sd" || state === "finishing" || state === "resuming") return "printing";
|
|
56
|
+
if (state === "starting" || state === "starting print from sd") return "preparing";
|
|
57
|
+
if (state === "pausing" || state === "paused") return "paused";
|
|
58
|
+
if (state === "cancelling") return "cancelled";
|
|
59
|
+
if (state === "error") return "failed";
|
|
60
|
+
if (state === "offline after error") return hasFile ? "failed" : "offline";
|
|
61
|
+
if (state === "offline" || state === "") return "offline";
|
|
62
|
+
// Opening/detecting serial connection, connecting, SD-card transfers and
|
|
63
|
+
// unknown states: the printer is not idle, but no print of ours is running.
|
|
64
|
+
return "busy";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Transitional printer states, reported as job.stage; resume waits for a settled pause. */
|
|
68
|
+
const OCTOPRINT_STAGES: Record<string, string> = {
|
|
69
|
+
starting: "Starting", "starting print from sd": "Starting", pausing: "Pausing", resuming: "Resuming", cancelling: "Cancelling", finishing: "Finishing",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function mapOctoPrint(inputs: OctoPrintInputs, observedAt: string): CounterpartProgress {
|
|
73
|
+
const data = inputs.job ?? {};
|
|
74
|
+
const file = data.job?.file ?? {};
|
|
75
|
+
const name = remoteJobName(file.name ?? file.path);
|
|
76
|
+
const completion = percent(data.progress?.completion);
|
|
77
|
+
const state = octoPrintState(String(data.state ?? ""), completion, name !== undefined, inputs.lastPrint);
|
|
78
|
+
const problems: CounterpartProblem[] = [];
|
|
79
|
+
const error = nonEmptyText(data.error) ?? nonEmptyText(inputs.printer?.state?.error);
|
|
80
|
+
if (error) problems.push({ code: "octoprint.error", severity: state === "failed" ? "error" : "warning", message: error });
|
|
81
|
+
const temperature = inputs.printer?.temperature;
|
|
82
|
+
return progressResult(observedAt, state, {
|
|
83
|
+
job: job(state, {
|
|
84
|
+
name,
|
|
85
|
+
progress: state === "finished" ? 100 : completion,
|
|
86
|
+
elapsedSec: seconds(data.progress?.printTime),
|
|
87
|
+
remainingSec: state === "finished" ? 0 : seconds(data.progress?.printTimeLeft),
|
|
88
|
+
stage: OCTOPRINT_STAGES[String(data.state ?? "").trim().toLowerCase()],
|
|
89
|
+
}),
|
|
90
|
+
temps: temperature ? {
|
|
91
|
+
nozzle: heaterReading(temperature.tool0?.actual, temperature.tool0?.target),
|
|
92
|
+
bed: heaterReading(temperature.bed?.actual, temperature.bed?.target),
|
|
93
|
+
chamber: finiteNumber(temperature.chamber?.actual),
|
|
94
|
+
} : undefined,
|
|
95
|
+
problems,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Klipper via Moonraker — GET /printer/objects/query?print_stats&virtual_sdcard&
|
|
101
|
+
// display_status&extruder&heater_bed&webhooks, plus file metadata estimated_time.
|
|
102
|
+
// https://moonraker.readthedocs.io/en/latest/external_api/printer/
|
|
103
|
+
// https://www.klipper3d.org/Status_Reference.html (print_stats, display_status,
|
|
104
|
+
// virtual_sdcard, webhooks, heaters); state values: klippy/extras/print_stats.py.
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
const KLIPPER_STATES: Record<string, ProgressState> = {
|
|
108
|
+
standby: "idle",
|
|
109
|
+
printing: "printing",
|
|
110
|
+
paused: "paused",
|
|
111
|
+
complete: "finished",
|
|
112
|
+
cancelled: "cancelled",
|
|
113
|
+
error: "failed",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export function mapMoonraker(status: any, estimatedTime: number | undefined, observedAt: string): CounterpartProgress {
|
|
117
|
+
const stats = status?.print_stats ?? {};
|
|
118
|
+
const webhooks = status?.webhooks ?? {};
|
|
119
|
+
const klippy = typeof webhooks.state === "string" ? webhooks.state : undefined;
|
|
120
|
+
let state: ProgressState = KLIPPER_STATES[String(stats.state)] ?? "busy";
|
|
121
|
+
if (klippy !== undefined && klippy !== "ready") state = "busy";
|
|
122
|
+
const problems: CounterpartProblem[] = [];
|
|
123
|
+
const message = nonEmptyText(stats.message);
|
|
124
|
+
if (message) {
|
|
125
|
+
problems.push({ code: "klipper.print_stats", severity: state === "failed" ? "error" : state === "paused" ? "warning" : "info", message });
|
|
126
|
+
}
|
|
127
|
+
if (klippy !== undefined && klippy !== "ready") {
|
|
128
|
+
const severity: ProblemSeverity = klippy === "shutdown" ? "fatal" : klippy === "error" ? "error" : "info";
|
|
129
|
+
problems.push({ code: `klipper.${klippy}`, severity, message: nonEmptyText(webhooks.state_message) ?? `Klipper is ${klippy}` });
|
|
130
|
+
}
|
|
131
|
+
const displayProgress = finiteNumber(status?.display_status?.progress);
|
|
132
|
+
const sdProgress = finiteNumber(status?.virtual_sdcard?.progress);
|
|
133
|
+
const fraction = displayProgress ?? sdProgress;
|
|
134
|
+
const elapsed = finiteNumber(stats.print_duration);
|
|
135
|
+
const remaining = estimatedTime !== undefined && elapsed !== undefined ? Math.max(0, estimatedTime - elapsed) : undefined;
|
|
136
|
+
const info = stats.info ?? {};
|
|
137
|
+
return progressResult(observedAt, state, {
|
|
138
|
+
job: job(state, {
|
|
139
|
+
name: remoteJobName(stats.filename),
|
|
140
|
+
progress: state === "finished" ? 100 : fraction !== undefined ? percent(fraction * 100) : undefined,
|
|
141
|
+
elapsedSec: seconds(elapsed),
|
|
142
|
+
remainingSec: state === "finished" ? 0 : seconds(remaining),
|
|
143
|
+
layer: layerNumber(info.current_layer),
|
|
144
|
+
totalLayers: layerNumber(info.total_layer),
|
|
145
|
+
}),
|
|
146
|
+
temps: {
|
|
147
|
+
nozzle: heaterReading(status?.extruder?.temperature, status?.extruder?.target),
|
|
148
|
+
bed: heaterReading(status?.heater_bed?.temperature, status?.heater_bed?.target),
|
|
149
|
+
},
|
|
150
|
+
problems,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// PrusaLink — GET /api/v1/status and GET /api/v1/job
|
|
156
|
+
// https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi.yaml
|
|
157
|
+
// (StatusPrinter.state enum; StatusJob progress in percent, times in seconds;
|
|
158
|
+
// Job.file.display_name is the long file name, file.name the 8.3 short name).
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
const PRUSALINK_STATES: Record<string, ProgressState> = {
|
|
162
|
+
PRINTING: "printing",
|
|
163
|
+
PAUSED: "paused",
|
|
164
|
+
FINISHED: "finished",
|
|
165
|
+
STOPPED: "cancelled",
|
|
166
|
+
ERROR: "failed",
|
|
167
|
+
ATTENTION: "paused",
|
|
168
|
+
IDLE: "idle",
|
|
169
|
+
READY: "idle",
|
|
170
|
+
BUSY: "busy",
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export function mapPrusaLink(status: any, jobDetails: any | undefined, observedAt: string): CounterpartProgress {
|
|
174
|
+
const printer = status?.printer ?? {};
|
|
175
|
+
const raw = String(printer.state ?? "").toUpperCase();
|
|
176
|
+
const state = PRUSALINK_STATES[raw] ?? "busy";
|
|
177
|
+
const statusJob = status?.job ?? {};
|
|
178
|
+
const file = jobDetails?.file ?? {};
|
|
179
|
+
const problems: CounterpartProblem[] = [];
|
|
180
|
+
if (raw === "ATTENTION") {
|
|
181
|
+
problems.push({ code: "prusalink.attention", severity: "warning", message: "The printer needs attention; check its screen before resuming." });
|
|
182
|
+
} else if (raw === "ERROR") {
|
|
183
|
+
problems.push({ code: "prusalink.error", severity: "error", message: "The printer reports an error; check its screen." });
|
|
184
|
+
}
|
|
185
|
+
const id = finiteNumber(statusJob.id ?? jobDetails?.id);
|
|
186
|
+
return progressResult(observedAt, state, {
|
|
187
|
+
job: job(state, {
|
|
188
|
+
name: remoteJobName(file.display_name) ?? remoteJobName(file.name),
|
|
189
|
+
id: id !== undefined ? String(id) : undefined,
|
|
190
|
+
progress: state === "finished" ? 100 : percent(statusJob.progress ?? jobDetails?.progress),
|
|
191
|
+
elapsedSec: seconds(statusJob.time_printing ?? jobDetails?.time_printing),
|
|
192
|
+
remainingSec: state === "finished" ? 0 : seconds(statusJob.time_remaining ?? jobDetails?.time_remaining),
|
|
193
|
+
}),
|
|
194
|
+
temps: {
|
|
195
|
+
nozzle: heaterReading(printer.temp_nozzle, printer.target_nozzle),
|
|
196
|
+
bed: heaterReading(printer.temp_bed, printer.target_bed),
|
|
197
|
+
},
|
|
198
|
+
problems,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Duet / RepRapFirmware 3 — rr_model?key=state|job&flags=d99v (standalone and
|
|
204
|
+
// DSF 3.5+), or the DSF object model from GET /machine/status.
|
|
205
|
+
// Flags: 'f' returns only frequently changing values and drops job.file
|
|
206
|
+
// (RepRapFirmware ObjectModel.cpp / PrintMonitor.cpp), so this server asks for
|
|
207
|
+
// the verbose model ('d99v') instead of the PRD's 'd99f'.
|
|
208
|
+
// https://github.com/Duet3D/RepRapFirmware/wiki/HTTP-requests
|
|
209
|
+
// https://github.com/Duet3D/RepRapFirmware/wiki/Object-Model-Documentation
|
|
210
|
+
// lastFile* flags: DuetSoftwareFramework src/DuetAPI/ObjectModel/Job/Job.cs.
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
export function mapDuet(stateModel: any, jobModel: any, observedAt: string): CounterpartProgress {
|
|
214
|
+
const status = String(stateModel?.status ?? "");
|
|
215
|
+
const file = jobModel?.file ?? {};
|
|
216
|
+
const activeName = remoteJobName(file.fileName);
|
|
217
|
+
let state: ProgressState;
|
|
218
|
+
let name = activeName;
|
|
219
|
+
switch (status) {
|
|
220
|
+
case "processing":
|
|
221
|
+
case "resuming":
|
|
222
|
+
state = "printing";
|
|
223
|
+
break;
|
|
224
|
+
case "paused":
|
|
225
|
+
case "pausing":
|
|
226
|
+
state = "paused";
|
|
227
|
+
break;
|
|
228
|
+
case "cancelling":
|
|
229
|
+
state = "cancelled";
|
|
230
|
+
break;
|
|
231
|
+
case "busy":
|
|
232
|
+
case "changingTool":
|
|
233
|
+
state = activeName ? "printing" : "busy";
|
|
234
|
+
break;
|
|
235
|
+
case "halted":
|
|
236
|
+
state = "failed";
|
|
237
|
+
break;
|
|
238
|
+
case "off":
|
|
239
|
+
case "disconnected":
|
|
240
|
+
state = "offline";
|
|
241
|
+
break;
|
|
242
|
+
case "idle": {
|
|
243
|
+
// After a job the firmware keeps a record of how the last file ended.
|
|
244
|
+
const last = remoteJobName(jobModel?.lastFileName);
|
|
245
|
+
if (last && jobModel?.lastFileSimulated !== true) {
|
|
246
|
+
name = last;
|
|
247
|
+
state = jobModel?.lastFileCancelled === true ? "cancelled" : jobModel?.lastFileAborted === true ? "failed" : "finished";
|
|
248
|
+
} else {
|
|
249
|
+
state = "idle";
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
default:
|
|
254
|
+
// starting, updating, simulating and unknown values.
|
|
255
|
+
state = "busy";
|
|
256
|
+
}
|
|
257
|
+
const problems: CounterpartProblem[] = [];
|
|
258
|
+
if (status === "halted") problems.push({ code: "duet.halted", severity: "fatal", message: "The machine is halted (emergency stop or firmware fault)." });
|
|
259
|
+
const box = stateModel?.messageBox;
|
|
260
|
+
const boxMessage = nonEmptyText(box?.message);
|
|
261
|
+
if (boxMessage) {
|
|
262
|
+
const title = nonEmptyText(box?.title);
|
|
263
|
+
const blocking = box?.mode === 2 || box?.mode === 3;
|
|
264
|
+
problems.push({ code: "duet.messageBox", severity: blocking ? "warning" : "info", message: title ? `${title}: ${boxMessage}` : boxMessage });
|
|
265
|
+
}
|
|
266
|
+
const display = nonEmptyText(stateModel?.displayMessage);
|
|
267
|
+
if (display) problems.push({ code: "duet.displayMessage", severity: "info", message: display });
|
|
268
|
+
|
|
269
|
+
const active = name === activeName && activeName !== undefined;
|
|
270
|
+
const size = finiteNumber(file.size);
|
|
271
|
+
const position = finiteNumber(jobModel?.filePosition);
|
|
272
|
+
const timesLeft = jobModel?.timesLeft ?? {};
|
|
273
|
+
// timesLeft values are null when there is no estimate (<= 0).
|
|
274
|
+
const remaining = [timesLeft.slicer, timesLeft.file].map(seconds).find((value) => value !== undefined && value > 0);
|
|
275
|
+
return progressResult(observedAt, state, {
|
|
276
|
+
job: job(state, {
|
|
277
|
+
name,
|
|
278
|
+
progress: state === "finished" ? 100 : active && size && position !== undefined ? percent((position / size) * 100) : undefined,
|
|
279
|
+
elapsedSec: active ? seconds(jobModel?.duration) : seconds(jobModel?.lastDuration),
|
|
280
|
+
remainingSec: state === "finished" ? 0 : active ? remaining : undefined,
|
|
281
|
+
layer: active ? layerNumber(jobModel?.layer) : undefined,
|
|
282
|
+
totalLayers: active ? layerNumber(file.numLayers) : undefined,
|
|
283
|
+
stage: ({ pausing: "Pausing", resuming: "Resuming", cancelling: "Cancelling", changingTool: "Changing tool" } as Record<string, string>)[status],
|
|
284
|
+
}),
|
|
285
|
+
problems,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Repetier-Server — GET /printer/api/<slug>?a=listPrinter
|
|
291
|
+
// https://www.repetier-server.com/manuals/programming/API/index.html documents
|
|
292
|
+
// active/job/name/online/slug. done, paused, jobid, printTime and
|
|
293
|
+
// printedTimeComp are returned by current servers but are not in that manual.
|
|
294
|
+
// printTime is the job's estimated total seconds; printedTimeComp the seconds
|
|
295
|
+
// printed so far, so remaining = printTime - printedTimeComp.
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
export function mapRepetier(printer: any, observedAt: string): CounterpartProgress {
|
|
299
|
+
const online = printer?.online === 1 || printer?.online === true;
|
|
300
|
+
const jobText = nonEmptyText(printer?.job);
|
|
301
|
+
const hasJob = jobText !== undefined && jobText.toLowerCase() !== "none";
|
|
302
|
+
let state: ProgressState;
|
|
303
|
+
if (!online) state = "offline";
|
|
304
|
+
else if (hasJob && (printer?.paused === true || finiteNumber(printer?.pauseState) === 1)) state = "paused";
|
|
305
|
+
else if (hasJob) state = "printing";
|
|
306
|
+
else state = "idle";
|
|
307
|
+
const total = finiteNumber(printer?.printTime);
|
|
308
|
+
const printed = finiteNumber(printer?.printedTimeComp);
|
|
309
|
+
const id = finiteNumber(printer?.jobid);
|
|
310
|
+
return progressResult(observedAt, state, {
|
|
311
|
+
job: job(state, {
|
|
312
|
+
name: hasJob ? remoteJobName(jobText) : undefined,
|
|
313
|
+
id: id !== undefined && id > 0 ? String(id) : undefined,
|
|
314
|
+
progress: percent(printer?.done),
|
|
315
|
+
elapsedSec: seconds(printed),
|
|
316
|
+
remainingSec: total !== undefined && printed !== undefined ? seconds(Math.max(0, total - printed)) : undefined,
|
|
317
|
+
}),
|
|
318
|
+
problems: [],
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Creality without Moonraker — the adapter's existing GET /api/device/status.
|
|
324
|
+
// Not a documented Creality API: the state is read from the same candidate
|
|
325
|
+
// fields the print gate already accepts, and progress only when numeric.
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
export function mapCrealityDevice(data: any, observedAt: string): CounterpartProgress {
|
|
329
|
+
const raw = [data?.state, data?.status, data?.printStatus, data?.print_state, data?.data?.state, data?.data?.status]
|
|
330
|
+
.find((value) => typeof value === "string" && value.trim());
|
|
331
|
+
const text = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
|
332
|
+
const map: Record<string, ProgressState> = {
|
|
333
|
+
idle: "idle", ready: "idle", standby: "idle", operational: "idle",
|
|
334
|
+
printing: "printing", paused: "paused", pausing: "paused",
|
|
335
|
+
complete: "finished", completed: "finished", finished: "finished", finish: "finished",
|
|
336
|
+
cancelled: "cancelled", canceled: "cancelled", stopped: "cancelled",
|
|
337
|
+
error: "failed", failed: "failed",
|
|
338
|
+
};
|
|
339
|
+
const state = text ? map[text] ?? "busy" : "busy";
|
|
340
|
+
const name = remoteJobName(data?.filename ?? data?.fileName ?? data?.data?.filename);
|
|
341
|
+
return progressResult(observedAt, state, {
|
|
342
|
+
job: job(state, {
|
|
343
|
+
name,
|
|
344
|
+
progress: percent(data?.progress ?? data?.printProgress ?? data?.data?.progress),
|
|
345
|
+
}),
|
|
346
|
+
problems: [],
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Bambu — this server's cached MQTT report (BambuImplementation.getStatus raw).
|
|
352
|
+
// Encodings match bambu-printer-mcp's counterpart_print_progress.
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
/** print_error for a user cancel; reported with gcode_state FAILED. */
|
|
356
|
+
export const BAMBU_CANCEL_ERROR = 0x0300400c;
|
|
357
|
+
const NO_CHAMBER_MODELS = new Set(["p1s", "p1p", "a1", "a1mini", "a1 mini"]);
|
|
358
|
+
|
|
359
|
+
function hex(value: number, digits: number): string {
|
|
360
|
+
return value.toString(16).toUpperCase().padStart(digits, "0");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function bambuCode(value: unknown): number | undefined {
|
|
364
|
+
const number = finiteNumber(value);
|
|
365
|
+
return number !== undefined && Number.isInteger(number) && number >= 0 && number <= 0xffffffff ? number : undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function bambuHmsSeverity(code: number): ProblemSeverity {
|
|
369
|
+
switch (Math.floor(code / 0x10000)) {
|
|
370
|
+
case 1: return "fatal";
|
|
371
|
+
case 2: return "error";
|
|
372
|
+
case 3: return "warning";
|
|
373
|
+
case 4: return "info";
|
|
374
|
+
default: return "error";
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function mapBambu(raw: any, observedAt: string, options: { connected: boolean; model?: string }): CounterpartProgress {
|
|
379
|
+
if (!options.connected || !raw || typeof raw !== "object") return progressResult(observedAt, "offline", { problems: [] });
|
|
380
|
+
const gcodeState = typeof raw.gcode_state === "string" ? raw.gcode_state.toUpperCase() : "";
|
|
381
|
+
const printError = bambuCode(raw.print_error) ?? 0;
|
|
382
|
+
const failReason = bambuCode(raw.fail_reason);
|
|
383
|
+
const cancelled = gcodeState === "FAILED" && (printError === BAMBU_CANCEL_ERROR || (printError === 0 && failReason === BAMBU_CANCEL_ERROR));
|
|
384
|
+
const states: Record<string, ProgressState> = {
|
|
385
|
+
IDLE: "idle", PREPARE: "preparing", SLICING: "preparing", RUNNING: "printing", PAUSE: "paused", FINISH: "finished", FAILED: "failed",
|
|
386
|
+
};
|
|
387
|
+
const state: ProgressState = cancelled ? "cancelled" : states[gcodeState] ?? "busy";
|
|
388
|
+
const problems: CounterpartProblem[] = [];
|
|
389
|
+
if (printError !== 0 && printError !== BAMBU_CANCEL_ERROR) {
|
|
390
|
+
problems.push({ code: hex(printError, 8), severity: "error", message: `Printer error ${hex(printError, 8)}` });
|
|
391
|
+
}
|
|
392
|
+
for (const entry of Array.isArray(raw.hms) ? raw.hms : []) {
|
|
393
|
+
const attr = bambuCode(entry?.attr);
|
|
394
|
+
const code = bambuCode(entry?.code);
|
|
395
|
+
if (attr === undefined || code === undefined) continue;
|
|
396
|
+
const key = [attr >>> 16, attr & 0xffff, code >>> 16, code & 0xffff].map((part) => hex(part, 4)).join("_");
|
|
397
|
+
problems.push({
|
|
398
|
+
code: key,
|
|
399
|
+
severity: bambuHmsSeverity(code),
|
|
400
|
+
message: `HMS ${key}`,
|
|
401
|
+
url: `https://e.bambulab.com/index.php?e=${key.replace(/_/g, "")}&s=device_hms&lang=en`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const taskId = typeof raw.task_id === "number" ? String(raw.task_id) : nonEmptyText(raw.task_id);
|
|
405
|
+
const totalLayers = layerNumber(raw.total_layer_num);
|
|
406
|
+
const model = String(options.model ?? raw.model ?? "").trim().toLowerCase();
|
|
407
|
+
// V2 reports (H2/X2D) carry the active extruder's temperature and spool in device.extruder
|
|
408
|
+
// (Bambu Studio DevExtruderSystem.cpp ParseV2_0); older reports use nozzle_temper and tray_now.
|
|
409
|
+
const extruder = raw.device?.extruder;
|
|
410
|
+
const extruders: any[] = Array.isArray(extruder?.info) ? extruder.info : [];
|
|
411
|
+
const activeId = finiteNumber(extruder?.state) !== undefined ? (finiteNumber(extruder.state)! >> 4) & 0xf : undefined;
|
|
412
|
+
const active = activeId !== undefined ? extruders.find((entry) => finiteNumber(entry?.id) === activeId) : undefined;
|
|
413
|
+
const packed = finiteNumber(active?.temp);
|
|
414
|
+
const nozzle = packed !== undefined ? heaterReading(packed & 0xffff, (packed >>> 16) & 0xffff) : heaterReading(raw.nozzle_temper, raw.nozzle_target_temper);
|
|
415
|
+
let activeFilament: CounterpartProgress["activeFilament"];
|
|
416
|
+
const dualNozzle = extruders.length > 1;
|
|
417
|
+
if (active) {
|
|
418
|
+
const snow = finiteNumber(active.snow);
|
|
419
|
+
if (snow !== undefined && snow !== 0xffff) activeFilament = bambuSpool(raw, snow >>> 8, snow & 0xff, dualNozzle);
|
|
420
|
+
} else {
|
|
421
|
+
const trayNow = finiteNumber(raw.ams?.tray_now);
|
|
422
|
+
if (trayNow === 254) activeFilament = bambuSpool(raw, 254, 0, dualNozzle);
|
|
423
|
+
else if (trayNow !== undefined && trayNow >= 0x80 && trayNow <= 0x87) activeFilament = bambuSpool(raw, trayNow, trayNow & 3, dualNozzle);
|
|
424
|
+
else if (trayNow !== undefined && trayNow !== 255) activeFilament = bambuSpool(raw, Math.floor(trayNow / 4), trayNow % 4, dualNozzle);
|
|
425
|
+
}
|
|
426
|
+
return progressResult(observedAt, state, {
|
|
427
|
+
job: job(state, {
|
|
428
|
+
// subtask_name is the project name; a raw G-code print reports only its file path.
|
|
429
|
+
name: nonEmptyText(raw.subtask_name) ?? remoteJobName(raw.gcode_file),
|
|
430
|
+
id: taskId && taskId !== "0" ? taskId : undefined,
|
|
431
|
+
progress: percent(raw.mc_percent),
|
|
432
|
+
// mc_remaining_time is in minutes.
|
|
433
|
+
remainingSec: seconds(finiteNumber(raw.mc_remaining_time) !== undefined ? finiteNumber(raw.mc_remaining_time)! * 60 : undefined),
|
|
434
|
+
layer: totalLayers ? layerNumber(raw.layer_num) : undefined,
|
|
435
|
+
totalLayers,
|
|
436
|
+
}),
|
|
437
|
+
temps: {
|
|
438
|
+
nozzle,
|
|
439
|
+
bed: heaterReading(raw.bed_temper, raw.bed_target_temper),
|
|
440
|
+
chamber: NO_CHAMBER_MODELS.has(model) ? undefined : finiteNumber(raw.chamber_temper),
|
|
441
|
+
},
|
|
442
|
+
activeFilament,
|
|
443
|
+
problems,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The spool feeding the nozzle. External spools are 254/255: "ext" on a single
|
|
449
|
+
* nozzle, "254" (left) or "255" (right) from vir_slot on dual-nozzle printers.
|
|
450
|
+
* AMS-HT units are ids 0x80-0x87 with one tray; other AMS slots are ams_id*4+tray.
|
|
451
|
+
*/
|
|
452
|
+
function bambuSpool(raw: any, amsId: number, trayIndex: number, dualNozzle: boolean): CounterpartProgress["activeFilament"] {
|
|
453
|
+
if (amsId === 254 || amsId === 255) {
|
|
454
|
+
if (!dualNozzle) return bambuTray(raw.vt_tray ?? (Array.isArray(raw.vir_slot) ? raw.vir_slot[0] : undefined), "ext");
|
|
455
|
+
const virtual = Array.isArray(raw.vir_slot) ? raw.vir_slot.find((entry: any) => finiteNumber(entry?.id) === amsId) : undefined;
|
|
456
|
+
return bambuTray(virtual, String(amsId));
|
|
457
|
+
}
|
|
458
|
+
const unit = Array.isArray(raw.ams?.ams) ? raw.ams.ams.find((entry: any) => finiteNumber(entry?.id) === amsId) : undefined;
|
|
459
|
+
const trays: any[] = Array.isArray(unit?.tray) ? unit.tray : [];
|
|
460
|
+
if (amsId >= 0x80 && amsId <= 0x87) {
|
|
461
|
+
const tray = trays.find((entry) => finiteNumber(entry?.id) === (trayIndex & 3)) ?? (trays.length === 1 ? trays[0] : undefined);
|
|
462
|
+
return bambuTray(tray, String(amsId));
|
|
463
|
+
}
|
|
464
|
+
return bambuTray(trays.find((entry) => finiteNumber(entry?.id) === trayIndex), String(amsId * 4 + trayIndex));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function bambuTray(tray: any, slot: string): CounterpartProgress["activeFilament"] {
|
|
468
|
+
const colour = typeof tray?.tray_color === "string" && /^[0-9a-f]{6}/i.test(tray.tray_color) ? `#${tray.tray_color.slice(0, 6).toUpperCase()}` : undefined;
|
|
469
|
+
return { slot, material: nonEmptyText(tray?.tray_type), color: colour };
|
|
470
|
+
}
|