bambu-printer-mcp 1.1.12 → 1.1.13
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/CONTRIBUTORS.md +3 -1
- package/README.md +10 -4
- package/dist/bambu-network-bridge.d.ts +4 -0
- package/dist/bambu-network-bridge.js +3 -0
- package/dist/index.js +196 -85
- package/dist/printers/bambu.d.ts +36 -4
- package/dist/printers/bambu.js +315 -122
- 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/limits.d.ts +29 -0
- package/dist/safety/limits.js +100 -0
- package/dist/safety/print-file.d.ts +25 -0
- package/dist/safety/print-file.js +514 -0
- package/dist/safety/printer-state.d.ts +43 -0
- package/dist/safety/printer-state.js +351 -0
- package/package.json +1 -1
- package/src/bambu-network-bridge.ts +7 -0
- package/src/index.ts +203 -93
- package/src/printers/bambu.ts +308 -152
- package/src/safety/archive.ts +181 -0
- package/src/safety/artifact.ts +58 -0
- package/src/safety/limits.ts +93 -0
- package/src/safety/print-file.ts +352 -0
- package/src/safety/printer-state.ts +323 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { GetVersionCommand, PushAllCommand } from "bambu-node";
|
|
2
|
+
import { normalizeMaterial, normalizeModel } from "./limits.js";
|
|
3
|
+
const MAX_REPORT_AGE_MS = 15000;
|
|
4
|
+
const MODEL_IDS = {
|
|
5
|
+
O1C: "H2C", O1C2: "H2C", O1D: "H2D", O1E: "H2D Pro", O1S: "H2S", N6: "X2D",
|
|
6
|
+
N2S: "A1", A1M: "A1 Mini", C11: "P1P", C12: "P1S", C13: "X1E", "BL-P001": "X1C", "BL-P002": "X1",
|
|
7
|
+
};
|
|
8
|
+
const SERIAL_MODELS = {
|
|
9
|
+
"093": "H2S", "094": "H2D", "239": "H2C", "31B": "H2D Pro", "20P": "X2D",
|
|
10
|
+
"00M": "X1C", "00W": "X1", "03W": "X1E", "01S": "P1P", "01P": "P1S", "22E": "P2S", "030": "A1", "039": "A1 Mini",
|
|
11
|
+
};
|
|
12
|
+
function object(value) {
|
|
13
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function observedIdentity(raw, configuredSerial) {
|
|
16
|
+
const declarations = [raw.model_id, raw.model, raw.printer_type, raw.device?.devModel, raw.device?.dev_model]
|
|
17
|
+
.filter(value => typeof value === "string" && value.trim());
|
|
18
|
+
const models = declarations.map(value => normalizeModel(MODEL_IDS[value.trim().toUpperCase()] ?? value));
|
|
19
|
+
if (models.some(model => !model))
|
|
20
|
+
throw new Error("Printer reported an unknown model identity; cannot verify this printer.");
|
|
21
|
+
const serials = [raw.serial, raw.serial_number, raw.sn, raw.device?.sn,
|
|
22
|
+
...(Array.isArray(raw.modules) ? raw.modules.filter((module) => module?.name === "ota").map((module) => module.sn) : [])]
|
|
23
|
+
.filter(value => typeof value === "string" && value.trim()).map(value => value.trim());
|
|
24
|
+
if (serials.some(serial => serial.toUpperCase() !== configuredSerial.trim().toUpperCase())) {
|
|
25
|
+
throw new Error("Printer-returned serial does not match the configured printer serial.");
|
|
26
|
+
}
|
|
27
|
+
for (const serial of serials) {
|
|
28
|
+
const model = normalizeModel(SERIAL_MODELS[serial.toUpperCase().slice(0, 3)] ?? "");
|
|
29
|
+
if (model)
|
|
30
|
+
models.push(model);
|
|
31
|
+
}
|
|
32
|
+
if (new Set(models).size > 1)
|
|
33
|
+
throw new Error("Printer returned contradictory model identity information.");
|
|
34
|
+
const model = models[0];
|
|
35
|
+
return { model, observedSerial: serials[0], identitySource: declarations.length ? "report" : "module-serial" };
|
|
36
|
+
}
|
|
37
|
+
/** Only raw reports received after this request are evidence. Never read printer.data here. */
|
|
38
|
+
export function readFreshPrinterStatus(printer, serial, timeoutMs = 5000) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
let raw = {};
|
|
41
|
+
const requestedAt = Date.now();
|
|
42
|
+
let receivedAt = 0;
|
|
43
|
+
let requestsPublished = false;
|
|
44
|
+
let complete = false;
|
|
45
|
+
const cleanup = () => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
printer.off("rawMessage", onRaw);
|
|
48
|
+
printer.off("client:disconnect", onDisconnect);
|
|
49
|
+
printer.off("client:error", onError);
|
|
50
|
+
};
|
|
51
|
+
const fail = (error) => {
|
|
52
|
+
if (complete)
|
|
53
|
+
return;
|
|
54
|
+
complete = true;
|
|
55
|
+
cleanup();
|
|
56
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
57
|
+
};
|
|
58
|
+
const maybeResolve = () => {
|
|
59
|
+
if (complete || !requestsPublished || !receivedAt || !Object.prototype.hasOwnProperty.call(raw, "gcode_state") ||
|
|
60
|
+
!Object.prototype.hasOwnProperty.call(raw, "print_error") || !Object.prototype.hasOwnProperty.call(raw, "hms"))
|
|
61
|
+
return;
|
|
62
|
+
try {
|
|
63
|
+
const identity = observedIdentity(raw, serial);
|
|
64
|
+
if (!identity.model)
|
|
65
|
+
return;
|
|
66
|
+
complete = true;
|
|
67
|
+
cleanup();
|
|
68
|
+
resolve({ connected: true, model: identity.model, status: raw.gcode_state, serial,
|
|
69
|
+
observedSerial: identity.observedSerial, raw,
|
|
70
|
+
observation: { source: "mqtt", requestedAt, receivedAt, identitySource: identity.identitySource } });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
fail(error);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const onRaw = (topic, payload) => {
|
|
77
|
+
if (complete || topic !== `device/${serial}/report`)
|
|
78
|
+
return;
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(payload.toString());
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (object(parsed.print) && parsed.print.command === "push_status") {
|
|
87
|
+
raw = { ...raw, ...parsed.print };
|
|
88
|
+
receivedAt = Date.now();
|
|
89
|
+
}
|
|
90
|
+
if (object(parsed.info) && parsed.info.command === "get_version" && Array.isArray(parsed.info.module)) {
|
|
91
|
+
raw = { ...raw, modules: parsed.info.module };
|
|
92
|
+
}
|
|
93
|
+
maybeResolve();
|
|
94
|
+
};
|
|
95
|
+
const onDisconnect = () => fail(new Error("Printer disconnected while waiting for a fresh safety report."));
|
|
96
|
+
const onError = (error) => fail(error);
|
|
97
|
+
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);
|
|
98
|
+
printer.on("rawMessage", onRaw);
|
|
99
|
+
printer.on("client:disconnect", onDisconnect);
|
|
100
|
+
printer.on("client:error", onError);
|
|
101
|
+
// invoke() publishes only; executeCommand() would add an ACK dependency that H2 firmware lacks.
|
|
102
|
+
void Promise.all([new PushAllCommand().invoke(printer), new GetVersionCommand().invoke(printer)])
|
|
103
|
+
.then(() => { requestsPublished = true; maybeResolve(); }, fail);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function finiteNumber(value) {
|
|
107
|
+
if (typeof value !== "number" && (typeof value !== "string" || !value.trim()))
|
|
108
|
+
return undefined;
|
|
109
|
+
const number = Number(value);
|
|
110
|
+
return Number.isFinite(number) ? number : undefined;
|
|
111
|
+
}
|
|
112
|
+
const token = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/[\s_-]/g, "") : "";
|
|
113
|
+
function nozzleTypeAndFlow(value) {
|
|
114
|
+
const text = String(value ?? "");
|
|
115
|
+
if (/^H[A-Z]\d\d/i.test(text)) {
|
|
116
|
+
// BambuStudio DeviceCore/DevNozzleSystem.cpp s_parse_nozzle_type.
|
|
117
|
+
const types = { "00": "stainlesssteel", "01": "hardenedsteel", "05": "tungstencarbide" };
|
|
118
|
+
const flows = { S: "standard", A: "standard", X: "standard", H: "highflow", E: "highflow", U: "tpuhighflow", B: "e3dhighflow" };
|
|
119
|
+
return { type: types[text.slice(2, 4)], flow: flows[text[1].toUpperCase()] };
|
|
120
|
+
}
|
|
121
|
+
const type = token(value);
|
|
122
|
+
return { type: ["stainlesssteel", "hardenedsteel", "tungstencarbide", "brass"].includes(type) ? type : undefined };
|
|
123
|
+
}
|
|
124
|
+
function reportedNozzles(raw) {
|
|
125
|
+
const info = raw.device?.nozzle?.info;
|
|
126
|
+
const output = [];
|
|
127
|
+
if (Array.isArray(info)) {
|
|
128
|
+
for (const entry of info) {
|
|
129
|
+
const index = finiteNumber(entry?.id);
|
|
130
|
+
// Rack nozzles (0x10+) are not interchangeable with the active extruder nozzle.
|
|
131
|
+
if (index !== undefined && index >= 16)
|
|
132
|
+
continue;
|
|
133
|
+
const diameter = finiteNumber(entry?.diameter);
|
|
134
|
+
if (index === undefined || !Number.isInteger(index) || index < 0 || diameter === undefined || diameter <= 0)
|
|
135
|
+
throw new Error("Printer reported malformed nozzle identity or diameter.");
|
|
136
|
+
if (entry.stat !== undefined) {
|
|
137
|
+
const stat = finiteNumber(entry.stat);
|
|
138
|
+
if (stat === undefined || !Number.isInteger(stat) || stat < 0 || (stat & 7) !== 0)
|
|
139
|
+
throw new Error(`Printer nozzle ${index} information is unreliable, abnormal or unknown.`);
|
|
140
|
+
}
|
|
141
|
+
if (output.some(nozzle => nozzle.index === index))
|
|
142
|
+
throw new Error("Printer reported ambiguous duplicate nozzle indices.");
|
|
143
|
+
output.push({ index, diameter, ...nozzleTypeAndFlow(entry.type) });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else if (Array.isArray(raw.nozzle_diameter)) {
|
|
147
|
+
raw.nozzle_diameter.forEach((value, index) => {
|
|
148
|
+
const diameter = finiteNumber(value);
|
|
149
|
+
if (diameter === undefined || diameter <= 0)
|
|
150
|
+
throw new Error("Printer reported an unknown nozzle diameter.");
|
|
151
|
+
output.push({ index, diameter, ...nozzleTypeAndFlow(Array.isArray(raw.nozzle_type) ? raw.nozzle_type[index] : raw.nozzle_type),
|
|
152
|
+
...(Array.isArray(raw.nozzle_volume_type) ? { flow: token(raw.nozzle_volume_type[index]) } : {}) });
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const diameter = finiteNumber(raw.nozzle_diameter);
|
|
157
|
+
if (diameter !== undefined && diameter > 0) {
|
|
158
|
+
const configuration = nozzleTypeAndFlow(raw.nozzle_type);
|
|
159
|
+
// Manufacturer legacy ParseV1_0 defaults a recognized nozzle to standard flow;
|
|
160
|
+
// flag3 bits 10..12 equal 1 report the legacy high-flow upgrade.
|
|
161
|
+
// https://github.com/bambulab/BambuStudio/blob/master/src/slic3r/GUI/DeviceCore/DevNozzleSystem.cpp
|
|
162
|
+
if (configuration.type && !configuration.flow) {
|
|
163
|
+
configuration.flow = "standard";
|
|
164
|
+
if (raw.flag3 !== undefined) {
|
|
165
|
+
const flag = finiteNumber(raw.flag3);
|
|
166
|
+
if (flag === undefined || !Number.isInteger(flag) || flag < 0 || flag > 0xffffffff)
|
|
167
|
+
throw new Error("Printer legacy nozzle flow report is malformed.");
|
|
168
|
+
const flowFlag = (flag >>> 10) & 7;
|
|
169
|
+
if (flowFlag > 1)
|
|
170
|
+
throw new Error("Printer legacy nozzle flow variant is unknown.");
|
|
171
|
+
if (flowFlag === 1)
|
|
172
|
+
configuration.flow = "highflow";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
output.push({ index: 0, diameter, ...configuration,
|
|
176
|
+
...(raw.nozzle_volume_type !== undefined ? { flow: token(raw.nozzle_volume_type) } : {}) });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return output;
|
|
180
|
+
}
|
|
181
|
+
function validateErrors(raw, requireIdle) {
|
|
182
|
+
const state = typeof raw.gcode_state === "string" ? raw.gcode_state.toUpperCase() : "UNKNOWN";
|
|
183
|
+
if (requireIdle && !["IDLE", "FINISH"].includes(state))
|
|
184
|
+
throw new Error(`Printer state ${state} is not safely idle.`);
|
|
185
|
+
if (!requireIdle && !["IDLE", "FINISH", "RUNNING", "PAUSE"].includes(state))
|
|
186
|
+
throw new Error(`Printer state ${state} is unknown or failed.`);
|
|
187
|
+
if (finiteNumber(raw.print_error) !== 0)
|
|
188
|
+
throw new Error("Printer reports a print error or did not report its error state.");
|
|
189
|
+
if (!Array.isArray(raw.hms))
|
|
190
|
+
throw new Error("Printer HMS error state is missing or malformed.");
|
|
191
|
+
for (const entry of raw.hms) {
|
|
192
|
+
const code = finiteNumber(entry?.code);
|
|
193
|
+
const attr = finiteNumber(entry?.attr);
|
|
194
|
+
// BambuStudio DevHMS.cpp: code >> 16; DevHMS.h: 1=fatal,2=serious,3=common,4=info.
|
|
195
|
+
// Unknown severities fail closed; attr identifies the module and is not a severity bitmask.
|
|
196
|
+
if (code === undefined || attr === undefined || !Number.isInteger(code) || !Number.isInteger(attr) || code < 0 || code > 0xffffffff || attr < 0 || attr > 0xffffffff || Math.floor(code / 65536) !== 4) {
|
|
197
|
+
throw new Error("Printer reports actionable or unknown HMS errors; inspect and resolve them before proceeding.");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function trayRange(value, label) {
|
|
202
|
+
if (value === undefined || value === null || value === "" || value === "0" || value === 0)
|
|
203
|
+
return undefined;
|
|
204
|
+
const number = finiteNumber(value);
|
|
205
|
+
if (number === undefined || number <= 0)
|
|
206
|
+
throw new Error(`Reported filament ${label} temperature is malformed.`);
|
|
207
|
+
return number;
|
|
208
|
+
}
|
|
209
|
+
function externalTray(raw) {
|
|
210
|
+
const slots = raw.vir_slot;
|
|
211
|
+
if (Array.isArray(slots)) {
|
|
212
|
+
const selected = slots.filter((slot) => Number(slot?.id) === 254);
|
|
213
|
+
if (slots.length === 1 && selected.length === 1)
|
|
214
|
+
return selected[0];
|
|
215
|
+
if (slots.length > 0)
|
|
216
|
+
throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
|
|
217
|
+
}
|
|
218
|
+
return object(raw.vt_tray) ? raw.vt_tray : undefined;
|
|
219
|
+
}
|
|
220
|
+
function mappedTray(raw, index) {
|
|
221
|
+
const unit = index >= 128 ? 128 : Math.floor(index / 4);
|
|
222
|
+
const slot = index >= 128 ? index - 128 : index % 4;
|
|
223
|
+
const units = raw.ams?.ams;
|
|
224
|
+
const ams = Array.isArray(units) ? units.find((value) => Number(value?.id) === unit) : undefined;
|
|
225
|
+
const tray = Array.isArray(ams?.tray) ? ams.tray.find((value) => Number(value?.id) === slot) : undefined;
|
|
226
|
+
if (!object(tray))
|
|
227
|
+
throw new Error(`AMS mapped slot ${index} is not present in the fresh printer report.`);
|
|
228
|
+
const bits = index >= 128 ? ams?.tray_exist_bits : raw.ams?.tray_exist_bits;
|
|
229
|
+
if (bits !== undefined) {
|
|
230
|
+
let present;
|
|
231
|
+
try {
|
|
232
|
+
present = (BigInt(typeof bits === "string" ? `0x${bits.replace(/^0x/i, "")}` : bits) & (1n << BigInt(index >= 128 ? slot : index))) !== 0n;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
throw new Error("AMS slot presence report is malformed.");
|
|
236
|
+
}
|
|
237
|
+
if (!present)
|
|
238
|
+
throw new Error(`AMS mapped slot ${index} is reported empty.`);
|
|
239
|
+
}
|
|
240
|
+
return tray;
|
|
241
|
+
}
|
|
242
|
+
/** Verifies reported configuration, not a physical nozzle/spool inspection. */
|
|
243
|
+
export function validatePrinterState(status, requirements) {
|
|
244
|
+
if (status?.connected !== true)
|
|
245
|
+
throw new Error("A connected printer is required for safety validation.");
|
|
246
|
+
const observation = status.observation;
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
if (!observation || observation.source !== "mqtt" || !Number.isFinite(observation.requestedAt) || !Number.isFinite(observation.receivedAt) ||
|
|
249
|
+
observation.receivedAt < observation.requestedAt || observation.receivedAt > now || now - observation.requestedAt > MAX_REPORT_AGE_MS) {
|
|
250
|
+
throw new Error("A fresh observed MQTT report is required; cached or stale status cannot authorize an operation.");
|
|
251
|
+
}
|
|
252
|
+
if (!object(status.raw))
|
|
253
|
+
throw new Error("Fresh raw printer report is missing.");
|
|
254
|
+
const raw = status.raw;
|
|
255
|
+
const identity = observedIdentity(raw, String(status.serial ?? ""));
|
|
256
|
+
const model = normalizeModel(requirements.model);
|
|
257
|
+
if (!model || !identity.model || model !== identity.model)
|
|
258
|
+
throw new Error(`Printer model identity mismatch: requested ${requirements.model}, observed ${identity.model ?? "unknown"}.`);
|
|
259
|
+
validateErrors(raw, requirements.requireIdle !== false);
|
|
260
|
+
if (requirements.requireLoadedFilament && (raw.ams?.tray_now !== undefined || Array.isArray(raw.ams?.ams))) {
|
|
261
|
+
const current = finiteNumber(raw.ams?.tray_now);
|
|
262
|
+
if (current === 255)
|
|
263
|
+
throw new Error("The nozzle is explicitly reported unloaded. Load the declared filament before starting or resuming this G-code job.");
|
|
264
|
+
if (current === undefined || !Number.isInteger(current) || current < 0 || current > 254) {
|
|
265
|
+
throw new Error("Cannot verify the currently loaded AMS tray before starting or resuming this G-code job.");
|
|
266
|
+
}
|
|
267
|
+
if ((requirements.usedFilamentPositions ?? []).some(position => current !== (requirements.useAMS ? requirements.amsMapping?.[position] : 254))) {
|
|
268
|
+
throw new Error("The currently loaded spool differs from the tray checked for this G-code job.");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
|
|
272
|
+
if (requirements.nozzleDiameters.length && ["h2d", "h2dpro", "h2c", "x2d"].includes(model) &&
|
|
273
|
+
!Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
|
|
274
|
+
throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
|
|
275
|
+
}
|
|
276
|
+
// Without a physical selection, a scalar file requirement must match every reported nozzle.
|
|
277
|
+
const broadcast = !requirements.usedNozzleIndices && requirements.nozzleDiameters.length === 1 && nozzles.length > 1;
|
|
278
|
+
const expectedDiameters = broadcast ? nozzles.map(() => requirements.nozzleDiameters[0]) : requirements.nozzleDiameters;
|
|
279
|
+
const indices = requirements.usedNozzleIndices ?? (broadcast ? nozzles.map(nozzle => nozzle.index) : requirements.nozzleDiameters.map((_, index) => index));
|
|
280
|
+
if (indices.length !== expectedDiameters.length || (!requirements.usedNozzleIndices && nozzles.length !== expectedDiameters.length)) {
|
|
281
|
+
throw new Error("Nozzle selection is missing or ambiguous; specify each used nozzle and verify its reported diameter.");
|
|
282
|
+
}
|
|
283
|
+
expectedDiameters.forEach((diameter, position) => {
|
|
284
|
+
const nozzle = nozzles.find(value => value.index === indices[position]);
|
|
285
|
+
if (!Number.isFinite(diameter) || diameter <= 0 || !nozzle || Math.abs(nozzle.diameter - diameter) > 0.0001)
|
|
286
|
+
throw new Error(`Printer nozzle ${indices[position]} diameter does not match the job (${diameter} mm).`);
|
|
287
|
+
const expectedType = token(requirements.nozzleTypes?.[broadcast ? 0 : position]);
|
|
288
|
+
const expectedFlow = token(requirements.nozzleFlows?.[broadcast ? 0 : position]);
|
|
289
|
+
if (requirements.nozzleTypes !== undefined && (!["stainlesssteel", "hardenedsteel", "tungstencarbide", "brass"].includes(expectedType) || expectedType !== nozzle.type)) {
|
|
290
|
+
throw new Error(`Printer nozzle ${indices[position]} type is unknown or does not match the job.`);
|
|
291
|
+
}
|
|
292
|
+
if (requirements.nozzleFlows !== undefined && (!["standard", "highflow", "tpuhighflow", "e3dhighflow"].includes(expectedFlow) || expectedFlow !== nozzle.flow)) {
|
|
293
|
+
throw new Error(`Printer nozzle ${indices[position]} flow variant is unknown or does not match the job.`);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
const filaments = [];
|
|
297
|
+
// Heating the bed alone has no filament requirement. All other callers declare each used material.
|
|
298
|
+
if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
|
|
299
|
+
const materials = requirements.materials ?? [];
|
|
300
|
+
const positions = requirements.usedFilamentPositions ?? materials.map((_, index) => index);
|
|
301
|
+
if (!positions.length)
|
|
302
|
+
throw new Error("A declared material is required for each used filament or external spool.");
|
|
303
|
+
for (const position of positions) {
|
|
304
|
+
const material = normalizeMaterial(materials[position]);
|
|
305
|
+
if (!Number.isInteger(position) || position < 0 || !material)
|
|
306
|
+
throw new Error(`A known declared material is required for filament position ${position}.`);
|
|
307
|
+
const trayIndex = requirements.useAMS ? requirements.amsMapping?.[position] : 254;
|
|
308
|
+
if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > 254)
|
|
309
|
+
throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
|
|
310
|
+
const tray = trayIndex === 254 ? externalTray(raw) : mappedTray(raw, trayIndex);
|
|
311
|
+
const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
|
|
312
|
+
if (reportedType && normalizeMaterial(reportedType) !== material)
|
|
313
|
+
throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
|
|
314
|
+
const nozzleMin = trayRange(tray?.nozzle_temp_min, "minimum");
|
|
315
|
+
const nozzleMax = trayRange(tray?.nozzle_temp_max, "maximum");
|
|
316
|
+
if (nozzleMin !== undefined && nozzleMax !== undefined && nozzleMin > nozzleMax)
|
|
317
|
+
throw new Error(`Reported filament temperature range in slot ${trayIndex} is invalid.`);
|
|
318
|
+
filaments.push({ position, material, trayIndex, nozzleMin, nozzleMax });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { model, reportedNozzles: nozzles, filaments };
|
|
322
|
+
}
|
|
323
|
+
/** Manual M104 cannot remap filament slots. Validate the material already at the nozzle. */
|
|
324
|
+
export function manualHeatingRequirements(status, model, nozzleDiameter, material) {
|
|
325
|
+
const normalized = normalizeModel(model);
|
|
326
|
+
if (!normalized)
|
|
327
|
+
throw new Error("A known printer model is required for manual nozzle heating.");
|
|
328
|
+
const raw = status?.raw;
|
|
329
|
+
if (!object(raw))
|
|
330
|
+
throw new Error("A fresh printer report is required for manual nozzle heating.");
|
|
331
|
+
if (["h2d", "h2dpro", "h2c", "x2d"].includes(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
|
|
332
|
+
throw new Error("Manual heating cannot verify the active nozzle and loaded material on this multi-nozzle printer. Use a checked sliced print instead.");
|
|
333
|
+
}
|
|
334
|
+
const requirements = {
|
|
335
|
+
model: normalized, nozzleDiameters: [nozzleDiameter], usedNozzleIndices: [0],
|
|
336
|
+
materials: [material], usedFilamentPositions: [0], useAMS: false,
|
|
337
|
+
};
|
|
338
|
+
const current = raw.ams?.tray_now;
|
|
339
|
+
if (current === undefined && !Array.isArray(raw.ams?.ams))
|
|
340
|
+
return requirements;
|
|
341
|
+
const tray = finiteNumber(current);
|
|
342
|
+
if (tray === undefined || !Number.isInteger(tray) || tray < 0 || tray > 255) {
|
|
343
|
+
throw new Error("Cannot identify the currently loaded AMS tray for manual heating; refresh the printer's filament state.");
|
|
344
|
+
}
|
|
345
|
+
// 254 is external spool; 255 means no filament loaded. The caller must still declare a material.
|
|
346
|
+
if (tray < 254) {
|
|
347
|
+
requirements.useAMS = true;
|
|
348
|
+
requirements.amsMapping = [tray];
|
|
349
|
+
}
|
|
350
|
+
return requirements;
|
|
351
|
+
}
|
package/package.json
CHANGED
|
@@ -26,6 +26,10 @@ export type BambuNetworkBridgeOptions = {
|
|
|
26
26
|
countryCode?: string;
|
|
27
27
|
userInfo?: string;
|
|
28
28
|
timeoutMs?: number;
|
|
29
|
+
/** Trusted in-process preflight; never populated from tool arguments. */
|
|
30
|
+
beforeDispatch?: (method: string) => Promise<void>;
|
|
31
|
+
assertDispatchAllowed?: (method: string) => void;
|
|
32
|
+
onDispatched?: (method: string) => void;
|
|
29
33
|
};
|
|
30
34
|
|
|
31
35
|
export type BambuNetworkBridgeStatus = {
|
|
@@ -242,6 +246,7 @@ export class BambuNetworkBridge {
|
|
|
242
246
|
}
|
|
243
247
|
|
|
244
248
|
await this.ensureStarted(options.bridgeCommand);
|
|
249
|
+
await options.beforeDispatch?.(method);
|
|
245
250
|
|
|
246
251
|
const id = this.nextRequestId++;
|
|
247
252
|
const requestPayload = Buffer.from(JSON.stringify({ method, payload }), "utf8");
|
|
@@ -261,6 +266,7 @@ export class BambuNetworkBridge {
|
|
|
261
266
|
this.pending.set(id, { method, resolve, reject, timer });
|
|
262
267
|
|
|
263
268
|
try {
|
|
269
|
+
options.assertDispatchAllowed?.(method);
|
|
264
270
|
this.child!.stdin.write(frame, (error) => {
|
|
265
271
|
if (error) {
|
|
266
272
|
const pending = this.pending.get(id);
|
|
@@ -271,6 +277,7 @@ export class BambuNetworkBridge {
|
|
|
271
277
|
}
|
|
272
278
|
}
|
|
273
279
|
});
|
|
280
|
+
options.onDispatched?.(method);
|
|
274
281
|
} catch (error) {
|
|
275
282
|
clearTimeout(timer);
|
|
276
283
|
this.pending.delete(id);
|