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,514 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readBoundedPrintFile, loadSafe3mfArchive } from './archive.js';
|
|
3
|
+
import { MACHINE_LIMITS, normalizeMaterial, normalizeModel, validateStartupPurgeTemperature, validateTemperature } from './limits.js';
|
|
4
|
+
const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
|
|
5
|
+
const fail = (message) => { throw new Error(`Print safety: ${message}`); };
|
|
6
|
+
function list(value) {
|
|
7
|
+
if (Array.isArray(value))
|
|
8
|
+
return value.map(String).map(s => s.trim().replace(/^"|"$/g, ''));
|
|
9
|
+
if (typeof value === 'string')
|
|
10
|
+
return value.split(/[;,]/).map(s => s.trim().replace(/^"|"$/g, ''));
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
function normalizeBed(value) {
|
|
14
|
+
if (typeof value !== 'string' || !value.trim())
|
|
15
|
+
return undefined;
|
|
16
|
+
const text = value.trim().toLowerCase().replace(/[_-]/g, ' ').replace(/\s+/g, ' ');
|
|
17
|
+
if (['textured pei plate', 'textured plate'].includes(text))
|
|
18
|
+
return 'textured pei plate';
|
|
19
|
+
if (['supertack plate', 'cool plate supertack'].includes(text))
|
|
20
|
+
return 'cool plate supertack';
|
|
21
|
+
if (['smooth pei plate', 'high temp plate', 'high temperature plate', 'hot plate'].includes(text))
|
|
22
|
+
return 'smooth pei plate';
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
function metadataValues(metadata, keys) {
|
|
26
|
+
return metadata.flatMap(data => keys.filter(key => data[key] !== undefined).map(key => data[key]));
|
|
27
|
+
}
|
|
28
|
+
function consistent(values, convert, field) {
|
|
29
|
+
if (!values.length)
|
|
30
|
+
return fail(`missing ${field} metadata; export a fully sliced Bambu job with machine, nozzle and filament settings`);
|
|
31
|
+
const converted = values.map(convert);
|
|
32
|
+
if (converted.some(value => value === undefined))
|
|
33
|
+
return fail(`unknown or malformed ${field} metadata`);
|
|
34
|
+
if (converted.some(value => JSON.stringify(value) !== JSON.stringify(converted[0])))
|
|
35
|
+
return fail(`contradictory ${field} metadata`);
|
|
36
|
+
return converted[0];
|
|
37
|
+
}
|
|
38
|
+
function stripComments(line) {
|
|
39
|
+
let clean = '';
|
|
40
|
+
let depth = 0;
|
|
41
|
+
for (const char of line) {
|
|
42
|
+
if (char === ';' && depth === 0)
|
|
43
|
+
break;
|
|
44
|
+
if (char === '(') {
|
|
45
|
+
if (depth)
|
|
46
|
+
fail('unsupported nested comment syntax');
|
|
47
|
+
depth = 1;
|
|
48
|
+
}
|
|
49
|
+
else if (char === ')') {
|
|
50
|
+
if (!depth)
|
|
51
|
+
fail('unbalanced comment syntax');
|
|
52
|
+
depth = 0;
|
|
53
|
+
clean += ' ';
|
|
54
|
+
}
|
|
55
|
+
else if (!depth)
|
|
56
|
+
clean += char;
|
|
57
|
+
}
|
|
58
|
+
if (depth)
|
|
59
|
+
fail('unclosed comment syntax');
|
|
60
|
+
return clean.trim();
|
|
61
|
+
}
|
|
62
|
+
/** A deliberately narrow parameter lexer for commands affecting heat/material selection.
|
|
63
|
+
* Unknown nonthermal vendor commands do not require a global G-code allowlist.
|
|
64
|
+
*/
|
|
65
|
+
function parameters(text, flags = '') {
|
|
66
|
+
const result = new Map();
|
|
67
|
+
let rest = text.trim();
|
|
68
|
+
while (rest) {
|
|
69
|
+
const token = rest.match(/^([A-Z])\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))?/i);
|
|
70
|
+
if (!token)
|
|
71
|
+
return fail(`unsupported temperature command parameter syntax '${rest}'`);
|
|
72
|
+
const key = token[1].toUpperCase();
|
|
73
|
+
if (result.has(key))
|
|
74
|
+
return fail(`duplicate ${key} temperature command parameter`);
|
|
75
|
+
if (!token[2] && !flags.includes(key))
|
|
76
|
+
return fail(`missing numeric ${key} temperature target or parameter`);
|
|
77
|
+
result.set(key, token[2] === undefined ? NaN : Number(token[2]));
|
|
78
|
+
rest = rest.slice(token[0].length).trimStart();
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
// Layer comments only close a startup exception; they can never enable one.
|
|
83
|
+
const isLayerMarker = (line) => /^\s*;\s*(?:LAYER_CHANGE|CHANGE_LAYER|LAYER\s*:|layer num\/total_layer_count\s*:)/i.test(line);
|
|
84
|
+
function boundedX1ePurge(lines, index) {
|
|
85
|
+
// Official X1E common flush: 50 mm at 200 mm/min, followed immediately by
|
|
86
|
+
// lowering the target. No dwell, XY extrusion, repeated block or open-ended
|
|
87
|
+
// high-temperature program receives this exception. This bounds command form,
|
|
88
|
+
// not firmware heating time, which cannot be predicted from a file.
|
|
89
|
+
const expected = [['M106', { P: 1, S: 0 }], ['G92', { E: 0 }], ['G1', { E: 50, F: 200 }], ['M400', {}], ['M104', {}]];
|
|
90
|
+
let next = 0;
|
|
91
|
+
for (let cursor = index + 1; cursor < lines.length; cursor++) {
|
|
92
|
+
if (isLayerMarker(lines[cursor]))
|
|
93
|
+
return false;
|
|
94
|
+
const line = stripComments(lines[cursor]).replace(/^N\d+\s*/i, '').replace(/\*\d+\s*$/, '');
|
|
95
|
+
if (!line)
|
|
96
|
+
continue;
|
|
97
|
+
const match = line.match(/^([GM])(\d+)(?=$|\s|[A-Z+-])/i);
|
|
98
|
+
if (!match || match[1].toUpperCase() + Number(match[2]) !== expected[next][0])
|
|
99
|
+
return false;
|
|
100
|
+
const args = parameters(line.slice(match[0].length));
|
|
101
|
+
if (next === 4)
|
|
102
|
+
return args.size === 1 && args.has('S') && args.get('S') >= 0 && args.get('S') <= 260;
|
|
103
|
+
const entries = Object.entries(expected[next][1]);
|
|
104
|
+
if (args.size !== entries.length || entries.some(([key, value]) => args.get(key) !== value))
|
|
105
|
+
return false;
|
|
106
|
+
next++;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
function validateFffJob(metadata) {
|
|
111
|
+
for (const data of metadata)
|
|
112
|
+
for (const [key, value] of Object.entries(data)) {
|
|
113
|
+
if (key === 'printer_technology' && (typeof value !== 'string' || value.trim().toUpperCase() !== 'FFF'))
|
|
114
|
+
fail('only FFF printing is supported; non-FFF printer technology is not permitted');
|
|
115
|
+
const text = typeof value === 'string' ? value.trim().toLowerCase() : String(value);
|
|
116
|
+
if ((/(?:^|_)(?:type|mode|technology)$/.test(key) && /(?:^|[ _-])(?:laser|cutting|cutter|engraving|engrave|plotter)(?:$|[ _-])/.test(text)) ||
|
|
117
|
+
(/^(?:laser|cutting|cutter|engraving)_(?:enabled|mode|power)$/.test(key) && !['', '0', 'false', 'off', 'disabled', 'none'].includes(text))) {
|
|
118
|
+
fail(`laser/cutting job metadata '${key}' is unsupported by the FFF print path`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function validateBounds(headers, model, plate) {
|
|
123
|
+
const volume = MACHINE_LIMITS[model].volume;
|
|
124
|
+
const boxes = [];
|
|
125
|
+
if (plate.bbox_all !== undefined)
|
|
126
|
+
boxes.push(plate.bbox_all);
|
|
127
|
+
if (plate.bbox_objects !== undefined) {
|
|
128
|
+
if (!Array.isArray(plate.bbox_objects))
|
|
129
|
+
fail('malformed declared object bounds');
|
|
130
|
+
for (const object of plate.bbox_objects) {
|
|
131
|
+
if (!object || typeof object !== 'object')
|
|
132
|
+
fail('malformed declared object bounds');
|
|
133
|
+
const box = object.bbox;
|
|
134
|
+
if (box !== undefined)
|
|
135
|
+
boxes.push(box);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const box of boxes) {
|
|
139
|
+
if (!Array.isArray(box) || box.length !== 4 || box.some(value => typeof value !== 'number' || !Number.isFinite(value)))
|
|
140
|
+
fail('malformed declared object bounds');
|
|
141
|
+
const [minX, minY, maxX, maxY] = box;
|
|
142
|
+
if (minX < 0 || minY < 0 || maxX < minX || maxY < minY || maxX > volume[0] || maxY > volume[1])
|
|
143
|
+
fail(`declared object bounds exceed ${model} printable volume`);
|
|
144
|
+
}
|
|
145
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
146
|
+
const name = 'xyz'[axis];
|
|
147
|
+
const minimum = metadataValues(headers, [`min${name}`]);
|
|
148
|
+
const maximum = metadataValues(headers, [`max${name}`]);
|
|
149
|
+
if (!minimum.length && !maximum.length)
|
|
150
|
+
continue;
|
|
151
|
+
const number = (v) => typeof v === 'string' && NUMBER.test(v) && Number.isFinite(Number(v)) ? Number(v) : undefined;
|
|
152
|
+
const low = consistent(minimum, number, `object bounds min${name}`);
|
|
153
|
+
const high = consistent(maximum, number, `object bounds max${name}`);
|
|
154
|
+
if (low < 0 || high < low || high > volume[axis])
|
|
155
|
+
fail(`declared object bounds ${name}=${low}..${high} exceed ${model} printable volume 0..${volume[axis]} mm`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Inspect the exact selected plate. This does not simulate firmware or arbitrary motion:
|
|
159
|
+
* only declared object bounds are checked, never purge/homing/wipe travel coordinates.
|
|
160
|
+
* It also cannot authenticate self-declared metadata or the physical spool/nozzle.
|
|
161
|
+
*/
|
|
162
|
+
export async function inspectPrintFile(filePath, options) {
|
|
163
|
+
const model = normalizeModel(options.model);
|
|
164
|
+
if (!model)
|
|
165
|
+
return fail(`unknown or missing printer model '${options.model}'`);
|
|
166
|
+
const plateIndex = options.plateIndex ?? 0;
|
|
167
|
+
if (!Number.isInteger(plateIndex) || plateIndex < 0)
|
|
168
|
+
return fail('selected plate index must be a nonnegative integer');
|
|
169
|
+
if (/\.md5$/i.test(filePath))
|
|
170
|
+
return fail('a checksum file is not a printable artifact');
|
|
171
|
+
const bytes = await readBoundedPrintFile(filePath);
|
|
172
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
173
|
+
let source;
|
|
174
|
+
let project = {};
|
|
175
|
+
let plate = {};
|
|
176
|
+
let plateInternalPath;
|
|
177
|
+
if (/\.3mf$/i.test(filePath)) {
|
|
178
|
+
const zip = await loadSafe3mfArchive(bytes);
|
|
179
|
+
plateInternalPath = `Metadata/plate_${plateIndex + 1}.gcode`;
|
|
180
|
+
const entry = zip.file(plateInternalPath);
|
|
181
|
+
if (!entry)
|
|
182
|
+
return fail(`selected plate ${plateIndex + 1} has no printable ${plateInternalPath}; slice that plate first`);
|
|
183
|
+
source = await entry.async('string');
|
|
184
|
+
for (const [name, assign] of [["Metadata/project_settings.config", (data) => project = data], [`Metadata/plate_${plateIndex + 1}.json`, (data) => plate = data]]) {
|
|
185
|
+
const config = zip.file(name);
|
|
186
|
+
if (!config)
|
|
187
|
+
continue;
|
|
188
|
+
try {
|
|
189
|
+
const data = JSON.parse(await config.async('string'));
|
|
190
|
+
if (!data || Array.isArray(data) || typeof data !== 'object')
|
|
191
|
+
throw new Error('expected object');
|
|
192
|
+
assign(data);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return fail(`malformed ${name} metadata`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else if (/\.gcode$/i.test(filePath)) {
|
|
200
|
+
if (plateIndex !== 0)
|
|
201
|
+
return fail('raw G-code has only plate index 0');
|
|
202
|
+
source = bytes.toString('utf8');
|
|
203
|
+
}
|
|
204
|
+
else
|
|
205
|
+
return fail('unsupported printable artifact; use a sliced .3mf or textual .gcode file');
|
|
206
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\ufffd]/.test(source))
|
|
207
|
+
return fail('binary or malformed G-code is not supported');
|
|
208
|
+
const lines = source.split(/\r\n|\n|\r/);
|
|
209
|
+
const metadata = [project];
|
|
210
|
+
for (const line of lines) {
|
|
211
|
+
const match = line.match(/^\s*;\s*([a-z_][a-z0-9_]*(?:\s+used\s+\[(?:g|mm)\])?)\s*(?:=|:)\s*(.*?)\s*$/i);
|
|
212
|
+
if (match)
|
|
213
|
+
metadata.push({ [match[1].toLowerCase()]: match[2] });
|
|
214
|
+
}
|
|
215
|
+
validateFffJob([...metadata, plate]);
|
|
216
|
+
const declaredModel = consistent(metadataValues(metadata, ['printer_model']), normalizeModel, 'printer model');
|
|
217
|
+
if (declaredModel !== model)
|
|
218
|
+
return fail(`file model ${declaredModel} contradicts requested model ${model}`);
|
|
219
|
+
const nozzleDiameters = consistent(metadataValues(metadata, ['nozzle_diameter']), value => {
|
|
220
|
+
const values = list(value);
|
|
221
|
+
return values.length && values.every(v => NUMBER.test(v) && [0.2, 0.4, 0.6, 0.8].includes(Number(v))) ? values.map(Number) : undefined;
|
|
222
|
+
}, 'nozzle diameter');
|
|
223
|
+
if (options.nozzleDiameters && (options.nozzleDiameters.length === 1 ? !nozzleDiameters.every(value => value === options.nozzleDiameters[0]) : JSON.stringify(options.nozzleDiameters) !== JSON.stringify(nozzleDiameters)))
|
|
224
|
+
return fail('file nozzle diameters contradict the requested nozzle diameters');
|
|
225
|
+
if (plate.nozzle_diameter !== undefined) {
|
|
226
|
+
const values = typeof plate.nozzle_diameter === 'number' ? [String(plate.nozzle_diameter)] : list(plate.nozzle_diameter);
|
|
227
|
+
if (!values.length || values.some(value => !NUMBER.test(value) || !Number.isFinite(Number(value))) ||
|
|
228
|
+
(values.length === 1 ? nozzleDiameters.some(diameter => Math.abs(diameter - Number(values[0])) > 1e-6) :
|
|
229
|
+
values.length !== nozzleDiameters.length || values.some((value, index) => Math.abs(Number(value) - nozzleDiameters[index]) > 1e-6))) {
|
|
230
|
+
fail('selected plate nozzle diameter metadata contradicts the sliced project nozzles');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const normalizedEntries = (value) => {
|
|
234
|
+
const entries = list(value).map(entry => entry.toLowerCase().replace(/[\s_-]+/g, '_'));
|
|
235
|
+
return entries.length && entries.every(Boolean) ? entries : undefined;
|
|
236
|
+
};
|
|
237
|
+
const nozzleMetadata = (keys, variantRows = false) => {
|
|
238
|
+
const values = metadataValues(metadata, keys);
|
|
239
|
+
return values.length ? consistent(values, value => {
|
|
240
|
+
const entries = normalizedEntries(value);
|
|
241
|
+
if (!entries)
|
|
242
|
+
return undefined;
|
|
243
|
+
if (entries.length === nozzleDiameters.length)
|
|
244
|
+
return entries;
|
|
245
|
+
if (!variantRows)
|
|
246
|
+
return undefined;
|
|
247
|
+
// Bambu GUI projects may store nozzle types per extruder variant, while
|
|
248
|
+
// diameters and selected flow types are per physical extruder. Never truncate
|
|
249
|
+
// that variant table or mistake its rows for filament/nozzle positions.
|
|
250
|
+
const ids = consistent(metadataValues(metadata, ['printer_extruder_id']), value => {
|
|
251
|
+
const ids = list(value);
|
|
252
|
+
return ids.length === entries.length && ids.every(id => /^[1-9]\d*$/.test(id) && Number(id) <= nozzleDiameters.length) ? ids.map(Number) : undefined;
|
|
253
|
+
}, 'printer extruder id');
|
|
254
|
+
const resolved = [];
|
|
255
|
+
for (let index = 0; index < nozzleDiameters.length; index++) {
|
|
256
|
+
const rows = ids.flatMap((id, row) => id === index + 1 ? [row] : []);
|
|
257
|
+
if (!rows.length)
|
|
258
|
+
return undefined;
|
|
259
|
+
if (rows.every(row => entries[row] === entries[rows[0]])) {
|
|
260
|
+
resolved.push(entries[rows[0]]);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const variants = consistent(metadataValues(metadata, ['printer_extruder_variant']), normalizedEntries, 'printer extruder variant');
|
|
264
|
+
const extruders = consistent(metadataValues(metadata, ['extruder_type']), normalizedEntries, 'extruder type');
|
|
265
|
+
if (variants.length !== entries.length || extruders.length !== nozzleDiameters.length || !nozzleFlows)
|
|
266
|
+
return undefined;
|
|
267
|
+
const selected = rows.filter(row => variants[row] === `${extruders[index]}_${nozzleFlows[index]}`);
|
|
268
|
+
if (selected.length !== 1)
|
|
269
|
+
return undefined;
|
|
270
|
+
resolved.push(entries[selected[0]]);
|
|
271
|
+
}
|
|
272
|
+
return resolved;
|
|
273
|
+
}, keys[0].replace(/_/g, ' ')) : undefined;
|
|
274
|
+
};
|
|
275
|
+
const nozzleFlows = nozzleMetadata(['nozzle_flow', 'nozzle_volume_type']);
|
|
276
|
+
const nozzleTypes = nozzleMetadata(['nozzle_type'], true);
|
|
277
|
+
const materials = consistent(metadataValues(metadata, ['filament_type']), value => {
|
|
278
|
+
const values = list(value);
|
|
279
|
+
return values.length && values.every(v => !!normalizeMaterial(v)) ? values.map(v => normalizeMaterial(v)) : undefined;
|
|
280
|
+
}, 'filament material');
|
|
281
|
+
const bedValues = metadataValues([...metadata, plate], ['curr_bed_type', 'bed_type']);
|
|
282
|
+
const bedType = bedValues.length ? consistent(bedValues, normalizeBed, 'bed type') : undefined;
|
|
283
|
+
if (options.bedType && (!bedType || normalizeBed(options.bedType) !== bedType))
|
|
284
|
+
return fail('selected bed type cannot be verified or contradicts the sliced file; re-slice with the intended bed');
|
|
285
|
+
validateBounds(metadata, model, plate);
|
|
286
|
+
const used = new Set();
|
|
287
|
+
const requirePosition = (position) => {
|
|
288
|
+
if (!Number.isInteger(position) || position < 0 || position >= materials.length)
|
|
289
|
+
return fail(`filament/tool position ${position} has no declared material`);
|
|
290
|
+
used.add(position);
|
|
291
|
+
return position;
|
|
292
|
+
};
|
|
293
|
+
if (plate.filament_ids !== undefined) {
|
|
294
|
+
if (!Array.isArray(plate.filament_ids) || !plate.filament_ids.length)
|
|
295
|
+
return fail('malformed selected plate filament_ids metadata');
|
|
296
|
+
for (const value of plate.filament_ids) {
|
|
297
|
+
if (typeof value !== 'number' || !Number.isInteger(value))
|
|
298
|
+
return fail('malformed selected plate filament position');
|
|
299
|
+
requirePosition(value);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
let active = materials.length === 1 ? 0 : undefined;
|
|
303
|
+
let nozzleTarget = 0;
|
|
304
|
+
let selectsAms = false;
|
|
305
|
+
let pending;
|
|
306
|
+
let previous;
|
|
307
|
+
let maxNozzleTemperature = 0, maxBedTemperature = 0, maxChamberTemperature = 0, commandCount = 0;
|
|
308
|
+
let depositionStarted = false, commonFlushUsed = false;
|
|
309
|
+
const heat = (component, value, position, allMaterials = false, startupPurge = false) => {
|
|
310
|
+
const affected = allMaterials || position === undefined ? materials.map((_, index) => index) : [requirePosition(position)];
|
|
311
|
+
if (component === 'nozzle' && startupPurge)
|
|
312
|
+
validateStartupPurgeTemperature(value, model, affected.map(index => materials[index]));
|
|
313
|
+
else
|
|
314
|
+
validateTemperature(component, value, model, affected.map(index => materials[index]));
|
|
315
|
+
// Candidate materials constrain heat, but only plate metadata and explicit
|
|
316
|
+
// filament selections establish physical spool use. Heating a nozzle is not
|
|
317
|
+
// evidence that every project filament must be mapped into the AMS.
|
|
318
|
+
if (component === 'nozzle') {
|
|
319
|
+
nozzleTarget = value;
|
|
320
|
+
maxNozzleTemperature = Math.max(maxNozzleTemperature, value);
|
|
321
|
+
}
|
|
322
|
+
else if (component === 'bed')
|
|
323
|
+
maxBedTemperature = Math.max(maxBedTemperature, value);
|
|
324
|
+
else
|
|
325
|
+
maxChamberTemperature = Math.max(maxChamberTemperature, value);
|
|
326
|
+
};
|
|
327
|
+
const closeStartupWindow = () => {
|
|
328
|
+
depositionStarted = true;
|
|
329
|
+
const affected = active === undefined ? (used.size ? [...used].map(position => materials[position]) : materials) : [materials[active]];
|
|
330
|
+
validateTemperature('nozzle', nozzleTarget, model, affected);
|
|
331
|
+
};
|
|
332
|
+
for (let index = 0; index < lines.length; index++) {
|
|
333
|
+
try {
|
|
334
|
+
if (isLayerMarker(lines[index]))
|
|
335
|
+
closeStartupWindow();
|
|
336
|
+
let line = stripComments(lines[index]);
|
|
337
|
+
if (!line || line === '%')
|
|
338
|
+
continue;
|
|
339
|
+
line = line.replace(/^N\d+\s*/i, '').replace(/\*\d+\s*$/, '');
|
|
340
|
+
if (/[{}\[\]#]/.test(line))
|
|
341
|
+
fail('unresolved dynamic command syntax');
|
|
342
|
+
if (/^SYNC(?=$|\s)/i.test(line)) {
|
|
343
|
+
// H2D change_filament_gcode emits SYNC T{ceil(flush_length / 125) * 5}.
|
|
344
|
+
// T is a synchronization duration here, not a nozzle temperature. The
|
|
345
|
+
// vendor template establishes no independent maximum duration.
|
|
346
|
+
const args = parameters(line.slice(4));
|
|
347
|
+
if (args.size !== 1 || !args.has('T') || !Number.isFinite(args.get('T')) || args.get('T') < 0)
|
|
348
|
+
fail('unsupported SYNC duration parameters');
|
|
349
|
+
commandCount++;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const command = line.match(/^([GMT])(\d+(?:\.\d+)?)(?=$|\s|[A-Z+-])/i);
|
|
353
|
+
if (!command)
|
|
354
|
+
fail(`unsupported G-code command syntax '${line.slice(0, 100)}'`);
|
|
355
|
+
const [base, subcode] = command[2].split('.');
|
|
356
|
+
const code = command[1].toUpperCase() + String(Number(base)) + (subcode === undefined ? '' : '.' + subcode);
|
|
357
|
+
const argumentsText = line.slice(command[0].length).trim();
|
|
358
|
+
commandCount++;
|
|
359
|
+
if (!['M117', 'M118', 'M1002', 'M1006', 'M900', 'M970', 'M983.1', 'G383'].includes(code) && /[GM]\s*\d/i.test(argumentsText))
|
|
360
|
+
fail('multiple commands on one line are unsupported');
|
|
361
|
+
// Coordinated extrusion closes the startup-only allowance even when the
|
|
362
|
+
// source omits layer comments. Retraction also closes it conservatively.
|
|
363
|
+
if (['G0', 'G1', 'G2', 'G3'].includes(code) && /E/i.test(argumentsText) && /[XY]/i.test(argumentsText))
|
|
364
|
+
closeStartupWindow();
|
|
365
|
+
if (/^M(?:3|4|452)(?:\.|$)/.test(code))
|
|
366
|
+
fail(`laser/cutter command ${code} is unsupported by the FFF print path`);
|
|
367
|
+
if (/^T\d+$/.test(code)) {
|
|
368
|
+
const position = Number(code.slice(1));
|
|
369
|
+
if ([254, 255, 1000, 1001, 1100, 65279, 65535].includes(position)) {
|
|
370
|
+
previous = active;
|
|
371
|
+
active = undefined;
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
validateTemperature('nozzle', nozzleTarget, model, [materials[requirePosition(position)]]);
|
|
375
|
+
selectsAms = true;
|
|
376
|
+
previous = active;
|
|
377
|
+
active = position;
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (['M104', 'M109', 'M140', 'M190', 'M141', 'M191'].includes(code)) {
|
|
382
|
+
const args = parameters(argumentsText, 'A');
|
|
383
|
+
for (const key of args.keys())
|
|
384
|
+
if (!'SRTA'.includes(key))
|
|
385
|
+
fail(`unsupported ${code} temperature parameter ${key}`);
|
|
386
|
+
if (!args.has('S') && !args.has('R'))
|
|
387
|
+
fail(`missing ${code} temperature target`);
|
|
388
|
+
const component = ['M104', 'M109'].includes(code) ? 'nozzle' : ['M140', 'M190'].includes(code) ? 'bed' : 'chamber';
|
|
389
|
+
// Heater T is a physical nozzle, unlike standalone remapped T filament selection.
|
|
390
|
+
// Without a verified per-filament nozzle map, apply all possible materials.
|
|
391
|
+
if (args.has('T') && (!Number.isInteger(args.get('T')) || args.get('T') < 0 || args.get('T') >= nozzleDiameters.length))
|
|
392
|
+
fail('physical heater target has no declared nozzle');
|
|
393
|
+
const position = args.has('T') ? undefined : active;
|
|
394
|
+
const commonFlush = code === 'M109' && args.size === 1 && args.get('S') === 290 && model === 'x1e' && !depositionStarted && !commonFlushUsed && boundedX1ePurge(lines, index);
|
|
395
|
+
if (commonFlush)
|
|
396
|
+
commonFlushUsed = true;
|
|
397
|
+
for (const key of ['S', 'R'])
|
|
398
|
+
if (args.has(key))
|
|
399
|
+
heat(component, args.get(key), position, args.has('T') || args.has('A'), commonFlush);
|
|
400
|
+
}
|
|
401
|
+
else if (code === 'M620' || code === 'M621') {
|
|
402
|
+
const args = parameters(argumentsText, 'MA');
|
|
403
|
+
if (args.has('S')) {
|
|
404
|
+
const position = args.get('S');
|
|
405
|
+
if ([254, 255, 65279, 65535].includes(position)) {
|
|
406
|
+
if (code === 'M621') {
|
|
407
|
+
previous = active;
|
|
408
|
+
active = undefined;
|
|
409
|
+
}
|
|
410
|
+
pending = undefined;
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
selectsAms = true;
|
|
414
|
+
requirePosition(position);
|
|
415
|
+
if (code === 'M620')
|
|
416
|
+
pending = position;
|
|
417
|
+
else {
|
|
418
|
+
validateTemperature('nozzle', nozzleTarget, model, [materials[position]]);
|
|
419
|
+
previous = active;
|
|
420
|
+
active = position;
|
|
421
|
+
pending = undefined;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
else if (code === 'M620.1' || code === 'M620.10') {
|
|
427
|
+
const args = parameters(argumentsText, 'E');
|
|
428
|
+
const position = code === 'M620.10' && args.get('A') === 1 ? pending ?? active : code === 'M620.10' && args.get('A') === 0 ? active : undefined;
|
|
429
|
+
// Setup commands are thermal-affecting too. Startup placement or a P
|
|
430
|
+
// target cannot establish a bounded purge, so no elevated PLA allowance.
|
|
431
|
+
for (const key of code === 'M620.10' ? ['T', 'P'] : ['T'])
|
|
432
|
+
if (args.has(key))
|
|
433
|
+
heat('nozzle', args.get(key), position);
|
|
434
|
+
}
|
|
435
|
+
else if (code === 'G150') {
|
|
436
|
+
// Official H2D nozzle-wipe routine carries the temperature in T.
|
|
437
|
+
const args = parameters(argumentsText);
|
|
438
|
+
if (args.has('T'))
|
|
439
|
+
heat('nozzle', args.get('T'), active);
|
|
440
|
+
}
|
|
441
|
+
else if (/^G150\./.test(code) && /T/i.test(argumentsText)) {
|
|
442
|
+
fail(`unsupported thermal-affecting wipe command ${code}`);
|
|
443
|
+
}
|
|
444
|
+
else if (code === 'G383' || code === 'G383.3') {
|
|
445
|
+
// Bambu probing routines carry nozzle temperature in T and optionally
|
|
446
|
+
// the project filament position in L (including the H2D G383.3 form).
|
|
447
|
+
const args = parameters(argumentsText);
|
|
448
|
+
if (code === 'G383.3' && (!args.has('T') || [...args.keys()].some(key => !'TL'.includes(key))))
|
|
449
|
+
fail('unsupported G383.3 temperature parameters');
|
|
450
|
+
const position = args.has('L') ? requirePosition(args.get('L')) : active;
|
|
451
|
+
if (args.has('T'))
|
|
452
|
+
heat('nozzle', args.get('T'), position);
|
|
453
|
+
}
|
|
454
|
+
else if (code === 'G383.4' && !argumentsText) {
|
|
455
|
+
// Official H2D startup: left-extruder load status detection, no target.
|
|
456
|
+
}
|
|
457
|
+
else if (/^G383\./.test(code)) {
|
|
458
|
+
fail(`unsupported thermal-affecting probing command ${code}`);
|
|
459
|
+
}
|
|
460
|
+
else if (code === 'M620.13') {
|
|
461
|
+
// Verified H2D prime-tower interface form, after T[next_extruder]: L is
|
|
462
|
+
// purge volume, T is the active filament's temperature, W/R are zero.
|
|
463
|
+
const args = parameters(argumentsText);
|
|
464
|
+
if (args.size !== 4 || args.get('W') !== 0 || args.get('R') !== 0 || !args.has('T') ||
|
|
465
|
+
!args.has('L') || !Number.isFinite(args.get('L')) || args.get('L') < 0)
|
|
466
|
+
fail('unsupported M620.13 prime-tower temperature parameters');
|
|
467
|
+
heat('nozzle', args.get('T'), active);
|
|
468
|
+
}
|
|
469
|
+
else if (code === 'M620.15') {
|
|
470
|
+
// H2D change_filament_gcode supplies the incoming filament's cooling
|
|
471
|
+
// temperature as C{new_filament_temp - filament_cooling_before_tower}.
|
|
472
|
+
const args = parameters(argumentsText);
|
|
473
|
+
if (args.size !== 1 || !args.has('C'))
|
|
474
|
+
fail('unsupported M620.15 cooling temperature parameters');
|
|
475
|
+
heat('nozzle', args.get('C'), pending ?? active);
|
|
476
|
+
}
|
|
477
|
+
else if (code === 'M620.17') {
|
|
478
|
+
const args = parameters(argumentsText);
|
|
479
|
+
if (!args.has('S') || !args.has('L') || !args.has('T') || !Number.isInteger(args.get('T')) || args.get('T') < 0 || args.get('T') >= nozzleDiameters.length)
|
|
480
|
+
fail('unsupported M620.17 nozzle/material temperature mapping');
|
|
481
|
+
for (const key of args.keys())
|
|
482
|
+
if (!'SLT'.includes(key))
|
|
483
|
+
fail(`unsupported M620.17 parameter ${key}`);
|
|
484
|
+
heat('nozzle', args.get('S'), requirePosition(args.get('L')));
|
|
485
|
+
}
|
|
486
|
+
else if ((/^M620\./.test(code) && !['M620.3', 'M620.6', 'M620.11'].includes(code)) || /^M621\./.test(code)) {
|
|
487
|
+
fail(`unsupported thermal-affecting filament command ${code}`);
|
|
488
|
+
}
|
|
489
|
+
else if (code === 'M145') {
|
|
490
|
+
const args = parameters(argumentsText);
|
|
491
|
+
if (args.size !== 1 || !args.has('P') || ![0, 1].includes(args.get('P')) || !['h2d', 'h2dpro', 'h2c', 'h2s', 'x2d'].includes(model))
|
|
492
|
+
fail('unsupported thermal-affecting M145 parameters');
|
|
493
|
+
}
|
|
494
|
+
else if (code === 'M142') {
|
|
495
|
+
// Bambu chamber fan regulation thresholds, not an active heater command.
|
|
496
|
+
const args = parameters(argumentsText);
|
|
497
|
+
for (const key of ['S', 'R'])
|
|
498
|
+
if (args.has(key) && (!Number.isFinite(args.get(key)) || args.get(key) < 0 || args.get(key) > 65))
|
|
499
|
+
fail('unsupported chamber fan temperature threshold');
|
|
500
|
+
}
|
|
501
|
+
else if (/^(?:M(?:104|109|140|190|141|191)\.|M(?:143|144|145|149|301|302|303|304|306|307|568|570|950|98|32)$)/.test(code) || (code === 'G10' && /[SR]/i.test(argumentsText))) {
|
|
502
|
+
fail(`unsupported thermal-affecting or external program command ${code}; re-slice with standard Bambu heater commands`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
throw new Error(`Print safety line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (!commandCount)
|
|
510
|
+
return fail('selected G-code contains no printable commands');
|
|
511
|
+
if (!used.size)
|
|
512
|
+
materials.forEach((_, index) => used.add(index));
|
|
513
|
+
return { model, nozzleDiameters, materials, usedFilamentPositions: [...used].sort((a, b) => a - b), plateInternalPath, sha256, maxNozzleTemperature, maxBedTemperature, maxChamberTemperature, bedType, selectsAms, nozzleTypes, nozzleFlows };
|
|
514
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { BambuClient } from "bambu-node";
|
|
2
|
+
/** Only raw reports received after this request are evidence. Never read printer.data here. */
|
|
3
|
+
export declare function readFreshPrinterStatus(printer: BambuClient, serial: string, timeoutMs?: number): Promise<any>;
|
|
4
|
+
export interface PrinterStateRequirements {
|
|
5
|
+
model: string;
|
|
6
|
+
nozzleDiameters: number[];
|
|
7
|
+
materials?: string[];
|
|
8
|
+
usedFilamentPositions?: number[];
|
|
9
|
+
amsMapping?: number[];
|
|
10
|
+
useAMS?: boolean;
|
|
11
|
+
requireIdle?: boolean;
|
|
12
|
+
/** Raw gcode_file transport cannot load a mapped spool before printing. */
|
|
13
|
+
requireLoadedFilament?: boolean;
|
|
14
|
+
/** Upload-only identity checks do not select or consume a physical filament slot. */
|
|
15
|
+
verifyMaterials?: boolean;
|
|
16
|
+
/** Zero-based firmware nozzle indices corresponding positionally to nozzleDiameters. */
|
|
17
|
+
usedNozzleIndices?: number[];
|
|
18
|
+
nozzleTypes?: string[];
|
|
19
|
+
nozzleFlows?: string[];
|
|
20
|
+
}
|
|
21
|
+
interface ReportedNozzle {
|
|
22
|
+
index: number;
|
|
23
|
+
diameter: number;
|
|
24
|
+
type?: string;
|
|
25
|
+
flow?: string;
|
|
26
|
+
}
|
|
27
|
+
interface ReportedFilament {
|
|
28
|
+
position: number;
|
|
29
|
+
material: string;
|
|
30
|
+
trayIndex: number;
|
|
31
|
+
nozzleMin?: number;
|
|
32
|
+
nozzleMax?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface ValidatedPrinterState {
|
|
35
|
+
model: string;
|
|
36
|
+
reportedNozzles: ReportedNozzle[];
|
|
37
|
+
filaments: ReportedFilament[];
|
|
38
|
+
}
|
|
39
|
+
/** Verifies reported configuration, not a physical nozzle/spool inspection. */
|
|
40
|
+
export declare function validatePrinterState(status: any, requirements: PrinterStateRequirements): ValidatedPrinterState;
|
|
41
|
+
/** Manual M104 cannot remap filament slots. Validate the material already at the nozzle. */
|
|
42
|
+
export declare function manualHeatingRequirements(status: any, model: string, nozzleDiameter: number, material: string): PrinterStateRequirements;
|
|
43
|
+
export {};
|