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