mcp-3d-printer-server 1.2.24 → 1.2.26

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 (96) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/CONTRIBUTORS.md +5 -1
  3. package/README.md +64 -29
  4. package/dist/ams-mapping.d.ts +38 -0
  5. package/dist/ams-mapping.js +125 -0
  6. package/dist/bambu-network-bridge.d.ts +200 -0
  7. package/dist/bambu-network-bridge.js +881 -0
  8. package/dist/bambu-network-certs.d.ts +13 -0
  9. package/dist/bambu-network-certs.js +152 -0
  10. package/dist/control-reply.d.ts +21 -0
  11. package/dist/control-reply.js +44 -0
  12. package/dist/counterpart/camera-sources.d.ts +16 -0
  13. package/dist/counterpart/camera-sources.js +138 -0
  14. package/dist/counterpart/camera-stream.d.ts +66 -0
  15. package/dist/counterpart/camera-stream.js +246 -0
  16. package/dist/counterpart/camera-tools.d.ts +247 -0
  17. package/dist/counterpart/camera-tools.js +35 -0
  18. package/dist/counterpart/contract.d.ts +21 -5
  19. package/dist/counterpart/contract.js +8 -2
  20. package/dist/counterpart/route.d.ts +31 -0
  21. package/dist/counterpart/route.js +70 -0
  22. package/dist/counterpart/service.d.ts +60 -4
  23. package/dist/counterpart/service.js +142 -16
  24. package/dist/design-guidance.d.ts +26 -0
  25. package/dist/design-guidance.js +177 -0
  26. package/dist/fulu-orca.d.ts +7 -0
  27. package/dist/fulu-orca.js +18 -2
  28. package/dist/index.js +528 -72
  29. package/dist/printers/bambu-filaments.d.ts +68 -0
  30. package/dist/printers/bambu-filaments.js +262 -0
  31. package/dist/printers/bambu.d.ts +99 -6
  32. package/dist/printers/bambu.js +478 -72
  33. package/dist/printers/creality.d.ts +2 -1
  34. package/dist/printers/creality.js +5 -1
  35. package/dist/printers/generic-base.d.ts +11 -6
  36. package/dist/printers/generic-base.js +21 -25
  37. package/dist/printers/klipper.d.ts +2 -1
  38. package/dist/printers/klipper.js +10 -7
  39. package/dist/printers/octoprint.d.ts +2 -1
  40. package/dist/printers/octoprint.js +15 -8
  41. package/dist/printers/printer-factory.d.ts +1 -1
  42. package/dist/printers/printer-factory.js +3 -3
  43. package/dist/printers/prusa.js +0 -1
  44. package/dist/safety/confirmation.d.ts +11 -16
  45. package/dist/safety/confirmation.js +8 -32
  46. package/dist/safety/hidden-hms.d.ts +32 -0
  47. package/dist/safety/hidden-hms.js +405 -0
  48. package/dist/safety/limits.d.ts +8 -2
  49. package/dist/safety/limits.js +14 -5
  50. package/dist/safety/print-file.d.ts +5 -0
  51. package/dist/safety/print-file.js +156 -19
  52. package/dist/safety/printer-state.d.ts +59 -8
  53. package/dist/safety/printer-state.js +174 -39
  54. package/dist/slicer/bambu-cli.d.ts +6 -2
  55. package/dist/slicer/bambu-cli.js +19 -12
  56. package/dist/slicer/layer-pauses.d.ts +47 -0
  57. package/dist/slicer/layer-pauses.js +130 -0
  58. package/dist/slicer/slicer-error.d.ts +11 -1
  59. package/dist/slicer/slicer-error.js +16 -2
  60. package/dist/stl/stl-manipulator.d.ts +27 -1
  61. package/dist/stl/stl-manipulator.js +216 -76
  62. package/dist/studio-print-options.d.ts +57 -0
  63. package/dist/studio-print-options.js +179 -0
  64. package/package.json +25 -4
  65. package/server.json +2 -2
  66. package/src/ams-mapping.ts +132 -0
  67. package/src/bambu-network-bridge.ts +1075 -0
  68. package/src/bambu-network-certs.ts +154 -0
  69. package/src/control-reply.ts +41 -0
  70. package/src/counterpart/camera-sources.ts +102 -0
  71. package/src/counterpart/camera-stream.ts +195 -0
  72. package/src/counterpart/camera-tools.ts +35 -0
  73. package/src/counterpart/contract.ts +12 -4
  74. package/src/counterpart/route.ts +82 -0
  75. package/src/counterpart/service.ts +150 -16
  76. package/src/design-guidance.ts +188 -0
  77. package/src/fulu-orca.ts +18 -2
  78. package/src/index.ts +546 -87
  79. package/src/printers/bambu-filaments.ts +346 -0
  80. package/src/printers/bambu.ts +506 -81
  81. package/src/printers/creality.ts +8 -3
  82. package/src/printers/generic-base.ts +33 -40
  83. package/src/printers/klipper.ts +12 -8
  84. package/src/printers/octoprint.ts +16 -9
  85. package/src/printers/printer-factory.ts +3 -3
  86. package/src/printers/prusa.ts +0 -1
  87. package/src/safety/confirmation.ts +15 -38
  88. package/src/safety/hidden-hms.ts +406 -0
  89. package/src/safety/limits.ts +15 -5
  90. package/src/safety/print-file.ts +139 -17
  91. package/src/safety/printer-state.ts +179 -40
  92. package/src/slicer/bambu-cli.ts +22 -13
  93. package/src/slicer/layer-pauses.ts +143 -0
  94. package/src/slicer/slicer-error.ts +21 -2
  95. package/src/stl/stl-manipulator.ts +254 -84
  96. package/src/studio-print-options.ts +201 -0
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Bambu AMS and external-spool inventory, ported from bambu-printer-mcp's
3
+ * get_printer_filaments (normalizePrinterFilamentInventory). `trays` and the
4
+ * fields beside it are bambu-printer-mcp's result, field for field, so a
5
+ * client reads either server alike; `slots` is the counterpart/1 contract,
6
+ * which also lists the external spool(s) the printer reports (vt_tray, and
7
+ * vir_slot on dual-nozzle printers).
8
+ *
9
+ * Profiles resolve by the tray's tray_info_idx (Bambu filament_id) against
10
+ * the slicer's BBL/filament profiles, preferring the model-and-nozzle preset.
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { detectProfilesRoot } from "../slicer/profile-flatten.js";
15
+ import type { FilamentSlot } from "../counterpart/contract.js";
16
+
17
+ export const FILAMENT_MODEL_CODES: Record<string, string> = {
18
+ p1s: "P1S",
19
+ p1p: "P1P",
20
+ p2s: "P2S",
21
+ x1c: "X1C",
22
+ x1e: "X1E",
23
+ a1: "A1",
24
+ a1mini: "A1M",
25
+ h2d: "H2D",
26
+ h2dpro: "H2DP",
27
+ h2s: "H2S",
28
+ h2c: "H2C",
29
+ x2d: "X2D",
30
+ };
31
+
32
+ type FilamentProfileIndex = {
33
+ byName: Map<string, string>;
34
+ baseNameByFilamentId: Map<string, string>;
35
+ };
36
+
37
+ type ProfileResolution = "exact-model-nozzle" | "model" | "generic" | "unresolved";
38
+
39
+ export type PrinterFilamentTray = {
40
+ ams_id: number | null;
41
+ tray_id: number | null;
42
+ slot: number | null;
43
+ state: number;
44
+ loaded: boolean;
45
+ tray_info_idx: string | null;
46
+ tray_type: string | null;
47
+ tray_sub_brands: string | null;
48
+ tray_color: string | null;
49
+ remain_percent: number | null;
50
+ nozzle_temp_min: number | null;
51
+ nozzle_temp_max: number | null;
52
+ resolved_base_profile_name: string | null;
53
+ resolved_profile_path: string | null;
54
+ profile_resolution: ProfileResolution;
55
+ match_confidence: "high" | "medium" | "low" | "none";
56
+ display_name: string;
57
+ profile_candidates: string[];
58
+ };
59
+
60
+ export type PrinterFilamentInventory = {
61
+ current_slot: number | null;
62
+ current_source: "ams" | "external" | null;
63
+ summary: {
64
+ loaded_slots: number;
65
+ resolved_profile_slots: number;
66
+ unresolved_loaded_slots: number;
67
+ empty_slots: number;
68
+ current_slot: number | null;
69
+ current_source: "ams" | "external" | null;
70
+ recommended_slot: number | null;
71
+ recommended_reason: string | null;
72
+ };
73
+ trays: PrinterFilamentTray[];
74
+ recommended: {
75
+ slot: number | null;
76
+ tray_info_idx: string | null;
77
+ tray_type: string | null;
78
+ resolved_profile_path: string | null;
79
+ load_filaments: string | null;
80
+ reason: string;
81
+ } | null;
82
+ load_filaments_all: string | null;
83
+ };
84
+
85
+ export interface FilamentProfileSource {
86
+ slicerPath?: string;
87
+ slicerType?: string;
88
+ }
89
+
90
+ const indexCache = new Map<string, FilamentProfileIndex>();
91
+
92
+ function buildFilamentProfileIndex(source: FilamentProfileSource): FilamentProfileIndex {
93
+ const byName = new Map<string, string>();
94
+ const baseNameByFilamentId = new Map<string, string>();
95
+ let filamentProfileDir: string;
96
+ try {
97
+ const slicerType = source.slicerType === "bambustudio" ? "bambustudio" : "orcaslicer";
98
+ filamentProfileDir = path.join(detectProfilesRoot(source.slicerPath, slicerType), "BBL", "filament");
99
+ } catch {
100
+ // Profile suggestions are optional; invalid slicer configuration must not hide AMS inventory.
101
+ return { byName, baseNameByFilamentId };
102
+ }
103
+
104
+ let profileEntries: string[];
105
+ try {
106
+ profileEntries = fs.readdirSync(filamentProfileDir);
107
+ } catch {
108
+ return { byName, baseNameByFilamentId };
109
+ }
110
+
111
+ for (const entry of profileEntries) {
112
+ if (!entry.endsWith(".json")) continue;
113
+ const filePath = path.join(filamentProfileDir, entry);
114
+ try {
115
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
116
+ const name = typeof parsed?.name === "string" ? parsed.name.trim() : "";
117
+ const filamentId = typeof parsed?.filament_id === "string" ? parsed.filament_id.trim() : "";
118
+ if (name) byName.set(name, filePath);
119
+ if (name && filamentId) baseNameByFilamentId.set(filamentId, name);
120
+ } catch {
121
+ // Ignore malformed or non-profile JSON files.
122
+ }
123
+ }
124
+ return { byName, baseNameByFilamentId };
125
+ }
126
+
127
+ function filamentProfileIndex(source: FilamentProfileSource): FilamentProfileIndex {
128
+ const key = `${process.env.BAMBU_PROFILES_ROOT ?? ""}\n${source.slicerType ?? ""}\n${source.slicerPath ?? ""}`;
129
+ let index = indexCache.get(key);
130
+ if (!index) {
131
+ index = buildFilamentProfileIndex(source);
132
+ indexCache.set(key, index);
133
+ }
134
+ return index;
135
+ }
136
+
137
+ export function clearBambuFilamentProfileCache(): void {
138
+ indexCache.clear();
139
+ }
140
+
141
+ function resolveFilamentProfileCandidates(
142
+ trayInfoIdx: string,
143
+ source: FilamentProfileSource,
144
+ bambuModel?: string,
145
+ nozzleDiameter?: string
146
+ ): { baseName: string | null; paths: string[]; resolution: ProfileResolution } {
147
+ const index = filamentProfileIndex(source);
148
+ const baseName = index.baseNameByFilamentId.get(trayInfoIdx) || null;
149
+ if (!baseName) return { baseName: null, paths: [], resolution: "unresolved" };
150
+
151
+ const bareName = baseName.replace(/\s*@base$/, "");
152
+ const modelCode = bambuModel ? FILAMENT_MODEL_CODES[bambuModel] : undefined;
153
+ const candidateNames: string[] = [];
154
+ if (modelCode && nozzleDiameter) candidateNames.push(`${bareName} @BBL ${modelCode} ${nozzleDiameter} nozzle`);
155
+ if (modelCode) candidateNames.push(`${bareName} @BBL ${modelCode}`);
156
+ candidateNames.push(bareName, baseName);
157
+
158
+ const resolvedPaths: string[] = [];
159
+ let resolution: ProfileResolution = "unresolved";
160
+ for (const [candidateIndex, candidateName] of candidateNames.entries()) {
161
+ const candidatePath = index.byName.get(candidateName);
162
+ if (!candidatePath || resolvedPaths.includes(candidatePath)) continue;
163
+ resolvedPaths.push(candidatePath);
164
+ if (resolution === "unresolved") {
165
+ if (candidateIndex === 0 && modelCode && nozzleDiameter) {
166
+ resolution = "exact-model-nozzle";
167
+ } else if (
168
+ (candidateIndex === 0 && modelCode && !nozzleDiameter) ||
169
+ (candidateIndex === 1 && modelCode && nozzleDiameter)
170
+ ) {
171
+ resolution = "model";
172
+ } else {
173
+ resolution = "generic";
174
+ }
175
+ }
176
+ }
177
+ return { baseName, paths: resolvedPaths, resolution };
178
+ }
179
+
180
+ function parseIntegerOrNull(value: unknown): number | null {
181
+ if (value === undefined || value === null || value === "") return null;
182
+ const parsed = Number.parseInt(String(value), 10);
183
+ return Number.isFinite(parsed) ? parsed : null;
184
+ }
185
+
186
+ /**
187
+ * Lowercase 6-character RGB, dropping a leading "#" and any alpha. Bambu mixes
188
+ * #RRGGBB (3MF project settings) and RRGGBBAA (AMS reports); RGB is the key.
189
+ */
190
+ export function normalizeRgbColor(color: string | null | undefined): string | null {
191
+ if (!color) return null;
192
+ const hex = color.replace(/^#/, "").toLowerCase();
193
+ if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/.test(hex)) return null;
194
+ return hex.slice(0, 6);
195
+ }
196
+
197
+ function trayEntry(
198
+ tray: any,
199
+ ids: { amsId: number | null; trayId: number | null; slot: number | null; state: number },
200
+ source: FilamentProfileSource,
201
+ bambuModel?: string,
202
+ nozzleDiameter?: string
203
+ ): PrinterFilamentTray {
204
+ const trayInfoIdx = typeof tray?.tray_info_idx === "string" ? tray.tray_info_idx : null;
205
+ const profileResolution = trayInfoIdx
206
+ ? resolveFilamentProfileCandidates(trayInfoIdx, source, bambuModel, nozzleDiameter)
207
+ : { baseName: null, paths: [], resolution: "unresolved" as const };
208
+ const matchConfidence =
209
+ profileResolution.resolution === "exact-model-nozzle" ? "high" :
210
+ profileResolution.resolution === "model" ? "medium" :
211
+ profileResolution.resolution === "generic" ? "low" :
212
+ "none";
213
+ const trayType = typeof tray?.tray_type === "string" ? tray.tray_type : null;
214
+ const traySubBrands = typeof tray?.tray_sub_brands === "string" ? tray.tray_sub_brands : null;
215
+ const trayColor = typeof tray?.tray_color === "string" ? tray.tray_color : null;
216
+ const displayParts = [
217
+ traySubBrands && traySubBrands !== trayType ? traySubBrands : null,
218
+ trayType,
219
+ trayColor ? `#${normalizeRgbColor(trayColor) ?? trayColor.replace(/^#/, "").slice(0, 6)}` : null,
220
+ ].filter(Boolean);
221
+ return {
222
+ ams_id: ids.amsId,
223
+ tray_id: ids.trayId,
224
+ slot: ids.slot,
225
+ state: ids.state,
226
+ loaded: ids.state !== 0 && Boolean(trayInfoIdx),
227
+ tray_info_idx: trayInfoIdx,
228
+ tray_type: trayType,
229
+ tray_sub_brands: traySubBrands,
230
+ tray_color: trayColor,
231
+ remain_percent: typeof tray?.remain === "number" && tray.remain >= 0 ? tray.remain : null,
232
+ nozzle_temp_min: parseIntegerOrNull(tray?.nozzle_temp_min),
233
+ nozzle_temp_max: parseIntegerOrNull(tray?.nozzle_temp_max),
234
+ resolved_base_profile_name: profileResolution.baseName,
235
+ resolved_profile_path: profileResolution.paths[0] || null,
236
+ profile_resolution: profileResolution.resolution,
237
+ match_confidence: matchConfidence,
238
+ display_name: displayParts.length > 0 ? displayParts.join(" ") : "empty/unknown",
239
+ profile_candidates: profileResolution.paths,
240
+ };
241
+ }
242
+
243
+ /** bambu-printer-mcp's inventory: AMS trays (absolute slot = ams_id * 4 + tray_id), summary and recommendation. */
244
+ export function normalizePrinterFilamentInventory(
245
+ status: any,
246
+ source: FilamentProfileSource,
247
+ bambuModel?: string,
248
+ nozzleDiameter?: string
249
+ ): PrinterFilamentInventory {
250
+ const rawAms = status?.raw?.ams || status?.ams || {};
251
+ const trayNow = parseIntegerOrNull(rawAms?.tray_now);
252
+ const trays: PrinterFilamentTray[] = [];
253
+
254
+ const amsArray = Array.isArray(rawAms?.ams) ? rawAms.ams : [];
255
+ for (const [amsIndex, amsUnit] of amsArray.entries()) {
256
+ const trayArray = Array.isArray(amsUnit?.tray) ? amsUnit.tray : [];
257
+ for (const [trayIndex, tray] of trayArray.entries()) {
258
+ const amsId = parseIntegerOrNull(amsUnit?.id) ?? amsIndex;
259
+ const trayId = parseIntegerOrNull(tray?.id) ?? trayIndex;
260
+ const slot = amsId !== null && trayId !== null ? amsId * 4 + trayId : null;
261
+ const state = parseIntegerOrNull(tray?.state) ?? 0;
262
+ trays.push(trayEntry(tray, { amsId, trayId, slot, state }, source, bambuModel, nozzleDiameter));
263
+ }
264
+ }
265
+
266
+ const loadedTrays = trays.filter((tray) => tray.loaded);
267
+ const recommendedTray =
268
+ loadedTrays.find((tray) => tray.slot === trayNow && tray.resolved_profile_path) ||
269
+ loadedTrays.find((tray) => tray.resolved_profile_path) ||
270
+ null;
271
+ const recommendedReason =
272
+ recommendedTray?.slot === trayNow
273
+ ? "current AMS slot with resolved slicer profile"
274
+ : recommendedTray
275
+ ? "first loaded AMS slot with resolved slicer profile"
276
+ : null;
277
+ const allProfilePaths = Array.from(new Set(
278
+ loadedTrays.map((tray) => tray.resolved_profile_path).filter((candidate): candidate is string => Boolean(candidate))
279
+ ));
280
+ const emptySlots = trays.filter((tray) => !tray.loaded).length;
281
+ const resolvedProfileSlots = loadedTrays.filter((tray) => tray.resolved_profile_path).length;
282
+ const currentSlot = trayNow !== null && trayNow >= 0 && trayNow < 254 ? trayNow : null;
283
+ const currentSource = trayNow === 254 ? "external" : currentSlot !== null ? "ams" : null;
284
+
285
+ return {
286
+ current_slot: currentSlot,
287
+ current_source: currentSource,
288
+ summary: {
289
+ loaded_slots: loadedTrays.length,
290
+ resolved_profile_slots: resolvedProfileSlots,
291
+ unresolved_loaded_slots: loadedTrays.length - resolvedProfileSlots,
292
+ empty_slots: emptySlots,
293
+ current_slot: currentSlot,
294
+ current_source: currentSource,
295
+ recommended_slot: recommendedTray?.slot ?? null,
296
+ recommended_reason: recommendedReason,
297
+ },
298
+ trays,
299
+ recommended: recommendedTray
300
+ ? {
301
+ slot: recommendedTray.slot,
302
+ tray_info_idx: recommendedTray.tray_info_idx,
303
+ tray_type: recommendedTray.tray_type,
304
+ resolved_profile_path: recommendedTray.resolved_profile_path,
305
+ load_filaments: recommendedTray.resolved_profile_path,
306
+ reason: recommendedReason ?? "resolved slicer profile",
307
+ }
308
+ : null,
309
+ load_filaments_all: allProfilePaths.length > 0 ? allProfilePaths.join(";") : null,
310
+ };
311
+ }
312
+
313
+ /**
314
+ * The external spool(s) the printer reports: vir_slot entries 254/255 on
315
+ * dual-nozzle printers, else vt_tray as 254. A spool is loaded once the printer
316
+ * knows its type (tray_type), as Bambu Studio shows it.
317
+ */
318
+ export function externalSpools(status: any, source: FilamentProfileSource, bambuModel?: string, nozzleDiameter?: string): PrinterFilamentTray[] {
319
+ const raw = status?.raw ?? status ?? {};
320
+ const virtual: any[] = Array.isArray(raw?.vir_slot) ? raw.vir_slot : [];
321
+ const spools = virtual
322
+ .filter((slot) => [254, 255].includes(Number(slot?.id)))
323
+ .map((slot) => ({ slot: Number(slot.id), tray: slot }));
324
+ if (!spools.length && raw?.vt_tray && typeof raw.vt_tray === "object") spools.push({ slot: 254, tray: raw.vt_tray });
325
+ return spools.map(({ slot, tray }) => {
326
+ const known = typeof tray?.tray_type === "string" && tray.tray_type.trim() !== "";
327
+ const entry = trayEntry(tray, { amsId: slot, trayId: 0, slot, state: known ? 1 : 0 }, source, bambuModel, nozzleDiameter);
328
+ return { ...entry, loaded: known };
329
+ });
330
+ }
331
+
332
+ /** counterpart/1 slots: every AMS tray (id "<absolute slot>") and external spool ("254"/"255"); no nulls. */
333
+ export function counterpartSlots(inventory: PrinterFilamentInventory, external: PrinterFilamentTray[]): FilamentSlot[] {
334
+ return [...inventory.trays, ...external].flatMap((tray): FilamentSlot[] => {
335
+ if (tray.slot === null || !tray.loaded) return [];
336
+ const rgb = normalizeRgbColor(tray.tray_color);
337
+ return [{
338
+ id: String(tray.slot),
339
+ ...(tray.tray_type ? { material: tray.tray_type } : {}),
340
+ ...(rgb ? { color: `#${rgb.toUpperCase()}` } : {}),
341
+ ...(tray.display_name && tray.display_name !== "empty/unknown" ? { name: tray.display_name } : {}),
342
+ source: "printer",
343
+ ...(tray.resolved_profile_path ? { profile: tray.resolved_profile_path } : {}),
344
+ }];
345
+ });
346
+ }