mcp-3d-printer-server 1.2.5 → 1.2.11
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 +228 -0
- package/CONTRIBUTORS.md +49 -0
- package/README.md +529 -739
- package/dist/3mf_parser.js +8 -8
- package/dist/blender-mcp-bridge.d.ts +11 -0
- package/dist/blender-mcp-bridge.js +661 -0
- package/dist/fulu-orca.js +29 -5
- package/dist/index.js +752 -212
- package/dist/printers/bambu-node-guard.d.ts +2 -0
- package/dist/printers/bambu-node-guard.js +38 -0
- package/dist/printers/bambu.d.ts +53 -7
- package/dist/printers/bambu.js +420 -120
- package/dist/printers/creality.d.ts +15 -6
- package/dist/printers/creality.js +42 -9
- package/dist/printers/duet.d.ts +11 -6
- package/dist/printers/duet.js +57 -34
- package/dist/printers/generic-base.d.ts +69 -0
- package/dist/printers/generic-base.js +209 -0
- package/dist/printers/klipper.d.ts +17 -6
- package/dist/printers/klipper.js +63 -16
- package/dist/printers/octoprint.d.ts +18 -6
- package/dist/printers/octoprint.js +64 -9
- package/dist/printers/printer-factory.d.ts +11 -2
- package/dist/printers/printer-factory.js +9 -8
- package/dist/printers/prusa.d.ts +14 -6
- package/dist/printers/prusa.js +56 -9
- package/dist/printers/repetier.d.ts +18 -6
- package/dist/printers/repetier.js +48 -6
- package/dist/safety/archive.d.ts +11 -0
- package/dist/safety/archive.js +214 -0
- package/dist/safety/artifact.d.ts +6 -0
- package/dist/safety/artifact.js +66 -0
- package/dist/safety/confirmation.d.ts +22 -0
- package/dist/safety/confirmation.js +43 -0
- package/dist/safety/expected-peaks.d.ts +24 -0
- package/dist/safety/expected-peaks.js +24 -0
- package/dist/safety/gcode-thermal.d.ts +64 -0
- package/dist/safety/gcode-thermal.js +382 -0
- package/dist/safety/limits.d.ts +31 -0
- package/dist/safety/limits.js +105 -0
- package/dist/safety/print-file.d.ts +25 -0
- package/dist/safety/print-file.js +517 -0
- package/dist/safety/printer-state.d.ts +51 -0
- package/dist/safety/printer-state.js +365 -0
- package/dist/slicer/bambu-cli.d.ts +110 -0
- package/dist/slicer/bambu-cli.js +547 -0
- package/dist/slicer/profile-flatten.d.ts +104 -0
- package/dist/slicer/profile-flatten.js +676 -0
- package/dist/slicer/run-slicer.d.ts +18 -0
- package/dist/slicer/run-slicer.js +102 -0
- package/dist/slicer/slicer-error.d.ts +27 -0
- package/dist/slicer/slicer-error.js +53 -0
- package/dist/slicer/templates.d.ts +126 -0
- package/dist/slicer/templates.js +255 -0
- package/dist/stl/stl-manipulator.d.ts +25 -3
- package/dist/stl/stl-manipulator.js +214 -232
- package/package.json +30 -14
- package/server.json +29 -0
- package/src/3mf_parser.ts +8 -8
- package/src/blender-mcp-bridge.ts +611 -0
- package/src/fulu-orca.ts +36 -6
- package/src/index.ts +888 -255
- package/src/printers/bambu-node-guard.ts +44 -0
- package/src/printers/bambu.ts +458 -158
- package/src/printers/creality.ts +40 -9
- package/src/printers/duet.ts +64 -53
- package/src/printers/generic-base.ts +286 -0
- package/src/printers/klipper.ts +83 -28
- package/src/printers/octoprint.ts +74 -21
- package/src/printers/printer-factory.ts +21 -11
- package/src/printers/prusa.ts +53 -10
- package/src/printers/repetier.ts +55 -17
- package/src/safety/archive.ts +181 -0
- package/src/safety/artifact.ts +58 -0
- package/src/safety/confirmation.ts +56 -0
- package/src/safety/expected-peaks.ts +47 -0
- package/src/safety/gcode-thermal.ts +404 -0
- package/src/safety/limits.ts +98 -0
- package/src/safety/print-file.ts +354 -0
- package/src/safety/printer-state.ts +339 -0
- package/src/slicer/bambu-cli.ts +633 -0
- package/src/slicer/profile-flatten.ts +830 -0
- package/src/slicer/run-slicer.ts +126 -0
- package/src/slicer/slicer-error.ts +72 -0
- package/src/slicer/templates.ts +288 -0
- package/src/stl/stl-manipulator.ts +247 -275
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { normalizeMaterial, normalizeModel } from "./limits.js";
|
|
2
|
+
let sequence = 0;
|
|
3
|
+
/**
|
|
4
|
+
* Publish one MQTT command with a string sequence id and no ACK wait.
|
|
5
|
+
* Unpatched bambu-node 3.22.21 command classes send numeric sequence ids;
|
|
6
|
+
* bambu-printer-mcp patches them to strings for H2-series firmware. This
|
|
7
|
+
* server does not install that patch, so safety-relevant commands are
|
|
8
|
+
* published here with the patched wire shape: {category:{sequence_id,command,...}}.
|
|
9
|
+
*/
|
|
10
|
+
export function publishBambuCommand(printer, category, command, extra = {}) {
|
|
11
|
+
sequence = (sequence + 1) % 1000000000;
|
|
12
|
+
return printer.publish({ [category]: { sequence_id: String(sequence), command, ...extra } });
|
|
13
|
+
}
|
|
14
|
+
const MAX_REPORT_AGE_MS = 15000;
|
|
15
|
+
const MODEL_IDS = {
|
|
16
|
+
O1C: "H2C", O1C2: "H2C", O1D: "H2D", O1E: "H2D Pro", O1S: "H2S", N6: "X2D",
|
|
17
|
+
N2S: "A1", A1M: "A1 Mini", C11: "P1P", C12: "P1S", C13: "X1E", "BL-P001": "X1C", "BL-P002": "X1",
|
|
18
|
+
};
|
|
19
|
+
const SERIAL_MODELS = {
|
|
20
|
+
"093": "H2S", "094": "H2D", "239": "H2C", "31B": "H2D Pro", "20P": "X2D",
|
|
21
|
+
"00M": "X1C", "00W": "X1", "03W": "X1E", "01S": "P1P", "01P": "P1S", "22E": "P2S", "039": "A1", "030": "A1 Mini",
|
|
22
|
+
};
|
|
23
|
+
function object(value) {
|
|
24
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
function observedIdentity(raw, configuredSerial) {
|
|
27
|
+
const declarations = [raw.model_id, raw.model, raw.printer_type, raw.device?.devModel, raw.device?.dev_model]
|
|
28
|
+
.filter(value => typeof value === "string" && value.trim());
|
|
29
|
+
const models = declarations.map(value => normalizeModel(MODEL_IDS[value.trim().toUpperCase()] ?? value));
|
|
30
|
+
if (models.some(model => !model))
|
|
31
|
+
throw new Error("Printer reported an unknown model identity; cannot verify this printer.");
|
|
32
|
+
const serials = [raw.serial, raw.serial_number, raw.sn, raw.device?.sn,
|
|
33
|
+
...(Array.isArray(raw.modules) ? raw.modules.filter((module) => module?.name === "ota").map((module) => module.sn) : [])]
|
|
34
|
+
.filter(value => typeof value === "string" && value.trim()).map(value => value.trim());
|
|
35
|
+
if (serials.some(serial => serial.toUpperCase() !== configuredSerial.trim().toUpperCase())) {
|
|
36
|
+
throw new Error("Printer-returned serial does not match the configured printer serial.");
|
|
37
|
+
}
|
|
38
|
+
for (const serial of serials) {
|
|
39
|
+
const model = normalizeModel(SERIAL_MODELS[serial.toUpperCase().slice(0, 3)] ?? "");
|
|
40
|
+
if (model)
|
|
41
|
+
models.push(model);
|
|
42
|
+
}
|
|
43
|
+
if (new Set(models).size > 1)
|
|
44
|
+
throw new Error("Printer returned contradictory model identity information.");
|
|
45
|
+
const model = models[0];
|
|
46
|
+
return { model, observedSerial: serials[0], identitySource: declarations.length ? "report" : "module-serial" };
|
|
47
|
+
}
|
|
48
|
+
/** Only raw reports received after this request are evidence. Never read printer.data here. */
|
|
49
|
+
export function readFreshPrinterStatus(printer, serial, timeoutMs = 5000) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
let raw = {};
|
|
52
|
+
const requestedAt = Date.now();
|
|
53
|
+
let receivedAt = 0;
|
|
54
|
+
let requestsPublished = false;
|
|
55
|
+
let complete = false;
|
|
56
|
+
const cleanup = () => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
printer.off("rawMessage", onRaw);
|
|
59
|
+
printer.off("client:disconnect", onDisconnect);
|
|
60
|
+
printer.off("client:error", onError);
|
|
61
|
+
};
|
|
62
|
+
const fail = (error) => {
|
|
63
|
+
if (complete)
|
|
64
|
+
return;
|
|
65
|
+
complete = true;
|
|
66
|
+
cleanup();
|
|
67
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
68
|
+
};
|
|
69
|
+
const maybeResolve = () => {
|
|
70
|
+
if (complete || !requestsPublished || !receivedAt || !Object.prototype.hasOwnProperty.call(raw, "gcode_state") ||
|
|
71
|
+
!Object.prototype.hasOwnProperty.call(raw, "print_error") || !Object.prototype.hasOwnProperty.call(raw, "hms"))
|
|
72
|
+
return;
|
|
73
|
+
try {
|
|
74
|
+
const identity = observedIdentity(raw, serial);
|
|
75
|
+
if (!identity.model)
|
|
76
|
+
return;
|
|
77
|
+
complete = true;
|
|
78
|
+
cleanup();
|
|
79
|
+
resolve({ connected: true, model: identity.model, status: raw.gcode_state, serial,
|
|
80
|
+
observedSerial: identity.observedSerial, raw,
|
|
81
|
+
observation: { source: "mqtt", requestedAt, receivedAt, identitySource: identity.identitySource } });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
fail(error);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const onRaw = (topic, payload) => {
|
|
88
|
+
if (complete || topic !== `device/${serial}/report`)
|
|
89
|
+
return;
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(payload.toString());
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (object(parsed.print) && parsed.print.command === "push_status") {
|
|
98
|
+
raw = { ...raw, ...parsed.print };
|
|
99
|
+
receivedAt = Date.now();
|
|
100
|
+
}
|
|
101
|
+
if (object(parsed.info) && parsed.info.command === "get_version" && Array.isArray(parsed.info.module)) {
|
|
102
|
+
raw = { ...raw, modules: parsed.info.module };
|
|
103
|
+
}
|
|
104
|
+
maybeResolve();
|
|
105
|
+
};
|
|
106
|
+
const onDisconnect = () => fail(new Error("Printer disconnected while waiting for a fresh safety report."));
|
|
107
|
+
const onError = (error) => fail(error);
|
|
108
|
+
const timer = setTimeout(() => fail(new Error("Timed out waiting for a fresh MQTT safety report with observed printer identity, state and errors; cached status cannot authorize an operation.")), timeoutMs);
|
|
109
|
+
printer.on("rawMessage", onRaw);
|
|
110
|
+
printer.on("client:disconnect", onDisconnect);
|
|
111
|
+
printer.on("client:error", onError);
|
|
112
|
+
// Publish only; an ACK wait would add a dependency that H2 firmware lacks.
|
|
113
|
+
void Promise.all([publishBambuCommand(printer, "pushing", "pushall"), publishBambuCommand(printer, "info", "get_version")])
|
|
114
|
+
.then(() => { requestsPublished = true; maybeResolve(); }, fail);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function finiteNumber(value) {
|
|
118
|
+
if (typeof value !== "number" && (typeof value !== "string" || !value.trim()))
|
|
119
|
+
return undefined;
|
|
120
|
+
const number = Number(value);
|
|
121
|
+
return Number.isFinite(number) ? number : undefined;
|
|
122
|
+
}
|
|
123
|
+
const token = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/[\s_-]/g, "") : "";
|
|
124
|
+
function nozzleTypeAndFlow(value) {
|
|
125
|
+
const text = String(value ?? "");
|
|
126
|
+
if (/^H[A-Z]\d\d/i.test(text)) {
|
|
127
|
+
// BambuStudio DeviceCore/DevNozzleSystem.cpp s_parse_nozzle_type.
|
|
128
|
+
const types = { "00": "stainlesssteel", "01": "hardenedsteel", "05": "tungstencarbide" };
|
|
129
|
+
const flows = { S: "standard", A: "standard", X: "standard", H: "highflow", E: "highflow", U: "tpuhighflow", B: "e3dhighflow" };
|
|
130
|
+
return { type: types[text.slice(2, 4)], flow: flows[text[1].toUpperCase()] };
|
|
131
|
+
}
|
|
132
|
+
const type = token(value);
|
|
133
|
+
return { type: ["stainlesssteel", "hardenedsteel", "tungstencarbide", "brass"].includes(type) ? type : undefined };
|
|
134
|
+
}
|
|
135
|
+
function reportedNozzles(raw) {
|
|
136
|
+
const info = raw.device?.nozzle?.info;
|
|
137
|
+
const output = [];
|
|
138
|
+
if (Array.isArray(info)) {
|
|
139
|
+
for (const entry of info) {
|
|
140
|
+
const index = finiteNumber(entry?.id);
|
|
141
|
+
// Rack nozzles (0x10+) are not interchangeable with the active extruder nozzle.
|
|
142
|
+
if (index !== undefined && index >= 16)
|
|
143
|
+
continue;
|
|
144
|
+
const diameter = finiteNumber(entry?.diameter);
|
|
145
|
+
if (index === undefined || !Number.isInteger(index) || index < 0 || diameter === undefined || diameter <= 0)
|
|
146
|
+
throw new Error("Printer reported malformed nozzle identity or diameter.");
|
|
147
|
+
if (entry.stat !== undefined) {
|
|
148
|
+
const stat = finiteNumber(entry.stat);
|
|
149
|
+
if (stat === undefined || !Number.isInteger(stat) || stat < 0 || (stat & 7) !== 0)
|
|
150
|
+
throw new Error(`Printer nozzle ${index} information is unreliable, abnormal or unknown.`);
|
|
151
|
+
}
|
|
152
|
+
if (output.some(nozzle => nozzle.index === index))
|
|
153
|
+
throw new Error("Printer reported ambiguous duplicate nozzle indices.");
|
|
154
|
+
output.push({ index, diameter, ...nozzleTypeAndFlow(entry.type) });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (Array.isArray(raw.nozzle_diameter)) {
|
|
158
|
+
raw.nozzle_diameter.forEach((value, index) => {
|
|
159
|
+
const diameter = finiteNumber(value);
|
|
160
|
+
if (diameter === undefined || diameter <= 0)
|
|
161
|
+
throw new Error("Printer reported an unknown nozzle diameter.");
|
|
162
|
+
output.push({ index, diameter, ...nozzleTypeAndFlow(Array.isArray(raw.nozzle_type) ? raw.nozzle_type[index] : raw.nozzle_type),
|
|
163
|
+
...(Array.isArray(raw.nozzle_volume_type) ? { flow: token(raw.nozzle_volume_type[index]) } : {}) });
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const diameter = finiteNumber(raw.nozzle_diameter);
|
|
168
|
+
if (diameter !== undefined && diameter > 0) {
|
|
169
|
+
const configuration = nozzleTypeAndFlow(raw.nozzle_type);
|
|
170
|
+
// Manufacturer legacy ParseV1_0 defaults a recognized nozzle to standard flow;
|
|
171
|
+
// flag3 bits 10..12 equal 1 report the legacy high-flow upgrade.
|
|
172
|
+
// https://github.com/bambulab/BambuStudio/blob/master/src/slic3r/GUI/DeviceCore/DevNozzleSystem.cpp
|
|
173
|
+
if (configuration.type && !configuration.flow) {
|
|
174
|
+
configuration.flow = "standard";
|
|
175
|
+
if (raw.flag3 !== undefined) {
|
|
176
|
+
const flag = finiteNumber(raw.flag3);
|
|
177
|
+
if (flag === undefined || !Number.isInteger(flag) || flag < 0 || flag > 0xffffffff)
|
|
178
|
+
throw new Error("Printer legacy nozzle flow report is malformed.");
|
|
179
|
+
const flowFlag = (flag >>> 10) & 7;
|
|
180
|
+
if (flowFlag > 1)
|
|
181
|
+
throw new Error("Printer legacy nozzle flow variant is unknown.");
|
|
182
|
+
if (flowFlag === 1)
|
|
183
|
+
configuration.flow = "highflow";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
output.push({ index: 0, diameter, ...configuration,
|
|
187
|
+
...(raw.nozzle_volume_type !== undefined ? { flow: token(raw.nozzle_volume_type) } : {}) });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return output;
|
|
191
|
+
}
|
|
192
|
+
function validateErrors(raw, requireIdle) {
|
|
193
|
+
const state = typeof raw.gcode_state === "string" ? raw.gcode_state.toUpperCase() : "UNKNOWN";
|
|
194
|
+
if (requireIdle && !["IDLE", "FINISH"].includes(state))
|
|
195
|
+
throw new Error(`Printer state ${state} is not safely idle.`);
|
|
196
|
+
if (!requireIdle && !["IDLE", "FINISH", "RUNNING", "PAUSE"].includes(state))
|
|
197
|
+
throw new Error(`Printer state ${state} is unknown or failed.`);
|
|
198
|
+
if (finiteNumber(raw.print_error) !== 0)
|
|
199
|
+
throw new Error("Printer reports a print error or did not report its error state.");
|
|
200
|
+
if (!Array.isArray(raw.hms))
|
|
201
|
+
throw new Error("Printer HMS error state is missing or malformed.");
|
|
202
|
+
for (const entry of raw.hms) {
|
|
203
|
+
const code = finiteNumber(entry?.code);
|
|
204
|
+
const attr = finiteNumber(entry?.attr);
|
|
205
|
+
// BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
|
|
206
|
+
// Unknown severities fail closed; attr identifies the module and is not a severity bitmask.
|
|
207
|
+
if (code === undefined || attr === undefined || !Number.isInteger(code) || !Number.isInteger(attr) || code < 0 || code > 0xffffffff || attr < 0 || attr > 0xffffffff || Math.floor(code / 65536) !== 4) {
|
|
208
|
+
const hex = (value) => value.toString(16).toUpperCase().padStart(8, "0");
|
|
209
|
+
const label = code !== undefined && attr !== undefined && Number.isInteger(code) && Number.isInteger(attr) && code >= 0 && attr >= 0
|
|
210
|
+
? ` (HMS ${hex(attr).slice(0, 4)}-${hex(attr).slice(4)}-${hex(code).slice(0, 4)}-${hex(code).slice(4)})` : "";
|
|
211
|
+
throw new Error(`Printer reports actionable or unknown HMS errors${label}; inspect and resolve them on the printer before proceeding.`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function trayRange(value, label) {
|
|
216
|
+
if (value === undefined || value === null || value === "" || value === "0" || value === 0)
|
|
217
|
+
return undefined;
|
|
218
|
+
const number = finiteNumber(value);
|
|
219
|
+
if (number === undefined || number <= 0)
|
|
220
|
+
throw new Error(`Reported filament ${label} temperature is malformed.`);
|
|
221
|
+
return number;
|
|
222
|
+
}
|
|
223
|
+
function externalTray(raw) {
|
|
224
|
+
const slots = raw.vir_slot;
|
|
225
|
+
if (Array.isArray(slots)) {
|
|
226
|
+
const selected = slots.filter((slot) => Number(slot?.id) === 254);
|
|
227
|
+
if (slots.length === 1 && selected.length === 1)
|
|
228
|
+
return selected[0];
|
|
229
|
+
if (slots.length > 0)
|
|
230
|
+
throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
|
|
231
|
+
}
|
|
232
|
+
return object(raw.vt_tray) ? raw.vt_tray : undefined;
|
|
233
|
+
}
|
|
234
|
+
function mappedTray(raw, index) {
|
|
235
|
+
const unit = index >= 128 ? 128 : Math.floor(index / 4);
|
|
236
|
+
const slot = index >= 128 ? index - 128 : index % 4;
|
|
237
|
+
const units = raw.ams?.ams;
|
|
238
|
+
const ams = Array.isArray(units) ? units.find((value) => Number(value?.id) === unit) : undefined;
|
|
239
|
+
const tray = Array.isArray(ams?.tray) ? ams.tray.find((value) => Number(value?.id) === slot) : undefined;
|
|
240
|
+
if (!object(tray))
|
|
241
|
+
throw new Error(`AMS mapped slot ${index} is not present in the fresh printer report.`);
|
|
242
|
+
const bits = index >= 128 ? ams?.tray_exist_bits : raw.ams?.tray_exist_bits;
|
|
243
|
+
if (bits !== undefined) {
|
|
244
|
+
let present;
|
|
245
|
+
try {
|
|
246
|
+
present = (BigInt(typeof bits === "string" ? `0x${bits.replace(/^0x/i, "")}` : bits) & (1n << BigInt(index >= 128 ? slot : index))) !== 0n;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
throw new Error("AMS slot presence report is malformed.");
|
|
250
|
+
}
|
|
251
|
+
if (!present)
|
|
252
|
+
throw new Error(`AMS mapped slot ${index} is reported empty.`);
|
|
253
|
+
}
|
|
254
|
+
return tray;
|
|
255
|
+
}
|
|
256
|
+
/** Verifies reported configuration, not a physical nozzle/spool inspection. */
|
|
257
|
+
export function validatePrinterState(status, requirements) {
|
|
258
|
+
if (status?.connected !== true)
|
|
259
|
+
throw new Error("A connected printer is required for safety validation.");
|
|
260
|
+
const observation = status.observation;
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
if (!observation || observation.source !== "mqtt" || !Number.isFinite(observation.requestedAt) || !Number.isFinite(observation.receivedAt) ||
|
|
263
|
+
observation.receivedAt < observation.requestedAt || observation.receivedAt > now || now - observation.requestedAt > MAX_REPORT_AGE_MS) {
|
|
264
|
+
throw new Error("A fresh observed MQTT report is required; cached or stale status cannot authorize an operation.");
|
|
265
|
+
}
|
|
266
|
+
if (!object(status.raw))
|
|
267
|
+
throw new Error("Fresh raw printer report is missing.");
|
|
268
|
+
const raw = status.raw;
|
|
269
|
+
const identity = observedIdentity(raw, String(status.serial ?? ""));
|
|
270
|
+
const model = normalizeModel(requirements.model);
|
|
271
|
+
if (!model || !identity.model || model !== identity.model)
|
|
272
|
+
throw new Error(`Printer model identity mismatch: requested ${requirements.model}, observed ${identity.model ?? "unknown"}.`);
|
|
273
|
+
validateErrors(raw, requirements.requireIdle !== false);
|
|
274
|
+
if (requirements.requireLoadedFilament && (raw.ams?.tray_now !== undefined || Array.isArray(raw.ams?.ams))) {
|
|
275
|
+
const current = finiteNumber(raw.ams?.tray_now);
|
|
276
|
+
if (current === 255)
|
|
277
|
+
throw new Error("The nozzle is explicitly reported unloaded. Load the declared filament before starting or resuming this G-code job.");
|
|
278
|
+
if (current === undefined || !Number.isInteger(current) || current < 0 || current > 254) {
|
|
279
|
+
throw new Error("Cannot verify the currently loaded AMS tray before starting or resuming this G-code job.");
|
|
280
|
+
}
|
|
281
|
+
if ((requirements.usedFilamentPositions ?? []).some(position => current !== (requirements.useAMS ? requirements.amsMapping?.[position] : 254))) {
|
|
282
|
+
throw new Error("The currently loaded spool differs from the tray checked for this G-code job.");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
|
|
286
|
+
if (requirements.nozzleDiameters.length && ["h2d", "h2dpro", "h2c", "x2d"].includes(model) &&
|
|
287
|
+
!Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
|
|
288
|
+
throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
|
|
289
|
+
}
|
|
290
|
+
// Without a physical selection, a scalar file requirement must match every reported nozzle.
|
|
291
|
+
const broadcast = !requirements.usedNozzleIndices && requirements.nozzleDiameters.length === 1 && nozzles.length > 1;
|
|
292
|
+
const expectedDiameters = broadcast ? nozzles.map(() => requirements.nozzleDiameters[0]) : requirements.nozzleDiameters;
|
|
293
|
+
const indices = requirements.usedNozzleIndices ?? (broadcast ? nozzles.map(nozzle => nozzle.index) : requirements.nozzleDiameters.map((_, index) => index));
|
|
294
|
+
if (indices.length !== expectedDiameters.length || (!requirements.usedNozzleIndices && nozzles.length !== expectedDiameters.length)) {
|
|
295
|
+
throw new Error("Nozzle selection is missing or ambiguous; specify each used nozzle and verify its reported diameter.");
|
|
296
|
+
}
|
|
297
|
+
expectedDiameters.forEach((diameter, position) => {
|
|
298
|
+
const nozzle = nozzles.find(value => value.index === indices[position]);
|
|
299
|
+
if (!Number.isFinite(diameter) || diameter <= 0 || !nozzle || Math.abs(nozzle.diameter - diameter) > 0.0001)
|
|
300
|
+
throw new Error(`Printer nozzle ${indices[position]} diameter does not match the job (${diameter} mm).`);
|
|
301
|
+
const expectedType = token(requirements.nozzleTypes?.[broadcast ? 0 : position]);
|
|
302
|
+
const expectedFlow = token(requirements.nozzleFlows?.[broadcast ? 0 : position]);
|
|
303
|
+
if (requirements.nozzleTypes !== undefined && (!["stainlesssteel", "hardenedsteel", "tungstencarbide", "brass"].includes(expectedType) || expectedType !== nozzle.type)) {
|
|
304
|
+
throw new Error(`Printer nozzle ${indices[position]} type is unknown or does not match the job.`);
|
|
305
|
+
}
|
|
306
|
+
if (requirements.nozzleFlows !== undefined && (!["standard", "highflow", "tpuhighflow", "e3dhighflow"].includes(expectedFlow) || expectedFlow !== nozzle.flow)) {
|
|
307
|
+
throw new Error(`Printer nozzle ${indices[position]} flow variant is unknown or does not match the job.`);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
const filaments = [];
|
|
311
|
+
// Heating the bed alone has no filament requirement. All other callers declare each used material.
|
|
312
|
+
if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
|
|
313
|
+
const materials = requirements.materials ?? [];
|
|
314
|
+
const positions = requirements.usedFilamentPositions ?? materials.map((_, index) => index);
|
|
315
|
+
if (!positions.length)
|
|
316
|
+
throw new Error("A declared material is required for each used filament or external spool.");
|
|
317
|
+
for (const position of positions) {
|
|
318
|
+
const material = normalizeMaterial(materials[position]);
|
|
319
|
+
if (!Number.isInteger(position) || position < 0 || !material)
|
|
320
|
+
throw new Error(`A known declared material is required for filament position ${position}.`);
|
|
321
|
+
const trayIndex = requirements.useAMS ? requirements.amsMapping?.[position] : 254;
|
|
322
|
+
if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > 254)
|
|
323
|
+
throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
|
|
324
|
+
const tray = trayIndex === 254 ? externalTray(raw) : mappedTray(raw, trayIndex);
|
|
325
|
+
const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
|
|
326
|
+
if (reportedType && normalizeMaterial(reportedType) !== material)
|
|
327
|
+
throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
|
|
328
|
+
const nozzleMin = trayRange(tray?.nozzle_temp_min, "minimum");
|
|
329
|
+
const nozzleMax = trayRange(tray?.nozzle_temp_max, "maximum");
|
|
330
|
+
if (nozzleMin !== undefined && nozzleMax !== undefined && nozzleMin > nozzleMax)
|
|
331
|
+
throw new Error(`Reported filament temperature range in slot ${trayIndex} is invalid.`);
|
|
332
|
+
filaments.push({ position, material, trayIndex, nozzleMin, nozzleMax });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { model, reportedNozzles: nozzles, filaments };
|
|
336
|
+
}
|
|
337
|
+
/** Manual M104 cannot remap filament slots. Validate the material already at the nozzle. */
|
|
338
|
+
export function manualHeatingRequirements(status, model, nozzleDiameter, material) {
|
|
339
|
+
const normalized = normalizeModel(model);
|
|
340
|
+
if (!normalized)
|
|
341
|
+
throw new Error("A known printer model is required for manual nozzle heating.");
|
|
342
|
+
const raw = status?.raw;
|
|
343
|
+
if (!object(raw))
|
|
344
|
+
throw new Error("A fresh printer report is required for manual nozzle heating.");
|
|
345
|
+
if (["h2d", "h2dpro", "h2c", "x2d"].includes(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
|
|
346
|
+
throw new Error("Manual heating cannot verify the active nozzle and loaded material on this multi-nozzle printer. Use a checked sliced print instead.");
|
|
347
|
+
}
|
|
348
|
+
const requirements = {
|
|
349
|
+
model: normalized, nozzleDiameters: [nozzleDiameter], usedNozzleIndices: [0],
|
|
350
|
+
materials: [material], usedFilamentPositions: [0], useAMS: false,
|
|
351
|
+
};
|
|
352
|
+
const current = raw.ams?.tray_now;
|
|
353
|
+
if (current === undefined && !Array.isArray(raw.ams?.ams))
|
|
354
|
+
return requirements;
|
|
355
|
+
const tray = finiteNumber(current);
|
|
356
|
+
if (tray === undefined || !Number.isInteger(tray) || tray < 0 || tray > 255) {
|
|
357
|
+
throw new Error("Cannot identify the currently loaded AMS tray for manual heating; refresh the printer's filament state.");
|
|
358
|
+
}
|
|
359
|
+
// 254 is external spool; 255 means no filament loaded. The caller must still declare a material.
|
|
360
|
+
if (tray < 254) {
|
|
361
|
+
requirements.useAMS = true;
|
|
362
|
+
requirements.amsMapping = [tray];
|
|
363
|
+
}
|
|
364
|
+
return requirements;
|
|
365
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bambu-compatible CLI (BambuStudio, OrcaSlicer, FULU OrcaSlicer-bambulab)
|
|
3
|
+
* profile preparation. Ported from bambu-printer-mcp's STLManipulator so both
|
|
4
|
+
* servers share the same safety semantics:
|
|
5
|
+
*
|
|
6
|
+
* - The exact model/nozzle machine preset must exist in the selected
|
|
7
|
+
* installation's BBL tree; it is never borrowed from another tree.
|
|
8
|
+
* - `inherits` and `include` are resolved recursively (profile-flatten.ts);
|
|
9
|
+
* missing references, cycles, and malformed profiles stop preparation.
|
|
10
|
+
* - Custom process/filament overrides keep their own values on top of their
|
|
11
|
+
* resolved parents; filament slots keep their positional order.
|
|
12
|
+
*/
|
|
13
|
+
export type BambuCliSlicerType = "bambustudio" | "orcaslicer" | "orcaslicer-bambulab";
|
|
14
|
+
export declare const BAMBU_CLI_BED_TYPES: Record<string, string>;
|
|
15
|
+
/** Bambu-compatible slicer CLI options exposed through slice_stl. */
|
|
16
|
+
export interface BambuSliceOptions {
|
|
17
|
+
uptodate?: boolean;
|
|
18
|
+
repetitions?: number;
|
|
19
|
+
orient?: boolean;
|
|
20
|
+
arrange?: boolean;
|
|
21
|
+
ensureOnBed?: boolean;
|
|
22
|
+
cloneObjects?: string;
|
|
23
|
+
skipObjects?: string;
|
|
24
|
+
loadFilaments?: string;
|
|
25
|
+
loadFilamentIds?: string;
|
|
26
|
+
filamentColours?: string[];
|
|
27
|
+
bedType?: string;
|
|
28
|
+
nozzleType?: string;
|
|
29
|
+
enableTimelapse?: boolean;
|
|
30
|
+
allowMixTemp?: boolean;
|
|
31
|
+
scale?: number;
|
|
32
|
+
rotate?: number;
|
|
33
|
+
rotateX?: number;
|
|
34
|
+
rotateY?: number;
|
|
35
|
+
minSave?: boolean;
|
|
36
|
+
skipModifiedGcodes?: boolean;
|
|
37
|
+
slicePlate?: number;
|
|
38
|
+
}
|
|
39
|
+
export interface BambuSettingsBundle {
|
|
40
|
+
settingsArg?: string;
|
|
41
|
+
filamentPaths: string[];
|
|
42
|
+
/** Positional slot colours declared by the input 3MF project. */
|
|
43
|
+
filamentColours?: string[];
|
|
44
|
+
/** Saved per-plate tower coordinates must survive default process loading. */
|
|
45
|
+
projectTowerPosition?: {
|
|
46
|
+
wipe_tower_x?: unknown;
|
|
47
|
+
wipe_tower_y?: unknown;
|
|
48
|
+
};
|
|
49
|
+
/** Identity provenance before the process is merged into a generated file. */
|
|
50
|
+
processSource?: {
|
|
51
|
+
filePath: string;
|
|
52
|
+
profile: Record<string, unknown>;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* User/system profile directories searched for custom process and filament
|
|
57
|
+
* dependencies (never for machine presets). BAMBU_SLICER_PROFILE_DIRS
|
|
58
|
+
* (path-delimiter separated) replaces the defaults; an empty value disables them.
|
|
59
|
+
*/
|
|
60
|
+
export declare function configuredBambuProfileDirs(): string[];
|
|
61
|
+
export declare function resolveBambuCliBedType(bedType?: string): string | undefined;
|
|
62
|
+
export declare class BambuCliProfilePreparer {
|
|
63
|
+
private readonly tempDir;
|
|
64
|
+
constructor(tempDir: string);
|
|
65
|
+
private readJsonFile;
|
|
66
|
+
writeTempJson(outputBase: string, suffix: string, value: unknown): string;
|
|
67
|
+
private findProfileFile;
|
|
68
|
+
private buildFilamentIdIndex;
|
|
69
|
+
private stripAbsoluteExtruderResets;
|
|
70
|
+
/** Orca's existing absolute-extrusion normalization for process G-code. */
|
|
71
|
+
sanitizeProcessForOrca(processConfig: any, printerPreset?: string): any;
|
|
72
|
+
/**
|
|
73
|
+
* The same normalization for the machine file. Machine isolation drops these
|
|
74
|
+
* keys from the process when the machine defines them, so the machine copy
|
|
75
|
+
* must carry the absolute-extrusion values or Orca would pair absolute E with
|
|
76
|
+
* the machine's G92 E0 resets.
|
|
77
|
+
*/
|
|
78
|
+
sanitizeMachineForOrca(machineConfig: any): any;
|
|
79
|
+
/**
|
|
80
|
+
* Select the machine preset, process, and positional filament profiles
|
|
81
|
+
* from the active installation. Throws before any slicer runs when the
|
|
82
|
+
* exact machine preset or a named dependency is missing.
|
|
83
|
+
*/
|
|
84
|
+
resolveSettingsBundle(outputBase: string, slicerType: BambuCliSlicerType, activeProfilesRoot: string, printerPreset: string | undefined, slicerProfile: string | undefined, options?: BambuSliceOptions): Promise<BambuSettingsBundle>;
|
|
85
|
+
/** --load-filaments is positional; a single override must cover every project slot. */
|
|
86
|
+
expandProjectFilaments(inputPath: string, bundle: BambuSettingsBundle): Promise<BambuSettingsBundle>;
|
|
87
|
+
/** Resolve BBL dependencies before invoking the CLI; failures stop the slice. */
|
|
88
|
+
flattenBundle(bundle: BambuSettingsBundle, options: BambuSliceOptions, activeProfilesRoot?: string): Promise<BambuSettingsBundle>;
|
|
89
|
+
/**
|
|
90
|
+
* Full preparation: select profiles, expand project slots, resolve every
|
|
91
|
+
* BBL dependency, and apply Orca's post-resolution normalization.
|
|
92
|
+
*/
|
|
93
|
+
prepare(inputPath: string, outputBase: string, slicerType: BambuCliSlicerType, activeProfilesRoot: string, printerPreset: string | undefined, slicerProfile: string | undefined, options: BambuSliceOptions): Promise<BambuSettingsBundle>;
|
|
94
|
+
/**
|
|
95
|
+
* The CLI applies every key in every --load-settings/--load-filaments file,
|
|
96
|
+
* so machine settings carried by a process or filament file (for example a
|
|
97
|
+
* template 3MF's project_settings from another printer) would replace the
|
|
98
|
+
* selected preset's start G-code, model, and bed geometry. The machine
|
|
99
|
+
* preset owns those keys; drop them from the other profiles.
|
|
100
|
+
*/
|
|
101
|
+
private isolateMachineSettings;
|
|
102
|
+
}
|
|
103
|
+
/** CLI flags for the prepared bundle and caller options (input path appended by the caller). */
|
|
104
|
+
export declare function buildBambuCliArgs(bundle: BambuSettingsBundle, outputDir: string, exportName: string, options: BambuSliceOptions): string[];
|
|
105
|
+
/**
|
|
106
|
+
* A sliced Bambu project must contain at least one nonempty
|
|
107
|
+
* Metadata/plate_<n>.gcode entry. A .gcode.md5 checksum or the unsliced
|
|
108
|
+
* input geometry is not printable output.
|
|
109
|
+
*/
|
|
110
|
+
export declare function assertSlicedProjectOutput(outputPath: string): Promise<string[]>;
|