mcp-3d-printer-server 1.2.9 → 1.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/CONTRIBUTORS.md +56 -0
  3. package/README.md +528 -811
  4. package/dist/3mf_parser.js +8 -8
  5. package/dist/blender-mcp-bridge.d.ts +1 -0
  6. package/dist/blender-mcp-bridge.js +209 -21
  7. package/dist/fulu-orca.js +29 -5
  8. package/dist/index.js +614 -110
  9. package/dist/printers/bambu-node-guard.d.ts +2 -0
  10. package/dist/printers/bambu-node-guard.js +38 -0
  11. package/dist/printers/bambu.d.ts +53 -7
  12. package/dist/printers/bambu.js +420 -126
  13. package/dist/printers/creality.d.ts +15 -6
  14. package/dist/printers/creality.js +42 -9
  15. package/dist/printers/duet.d.ts +11 -6
  16. package/dist/printers/duet.js +57 -34
  17. package/dist/printers/generic-base.d.ts +69 -0
  18. package/dist/printers/generic-base.js +209 -0
  19. package/dist/printers/klipper.d.ts +17 -6
  20. package/dist/printers/klipper.js +63 -16
  21. package/dist/printers/octoprint.d.ts +18 -6
  22. package/dist/printers/octoprint.js +64 -9
  23. package/dist/printers/printer-factory.d.ts +11 -2
  24. package/dist/printers/printer-factory.js +9 -8
  25. package/dist/printers/prusa.d.ts +14 -6
  26. package/dist/printers/prusa.js +56 -9
  27. package/dist/printers/repetier.d.ts +18 -6
  28. package/dist/printers/repetier.js +48 -6
  29. package/dist/safety/archive.d.ts +11 -0
  30. package/dist/safety/archive.js +214 -0
  31. package/dist/safety/artifact.d.ts +6 -0
  32. package/dist/safety/artifact.js +66 -0
  33. package/dist/safety/confirmation.d.ts +22 -0
  34. package/dist/safety/confirmation.js +43 -0
  35. package/dist/safety/expected-peaks.d.ts +24 -0
  36. package/dist/safety/expected-peaks.js +24 -0
  37. package/dist/safety/gcode-thermal.d.ts +64 -0
  38. package/dist/safety/gcode-thermal.js +382 -0
  39. package/dist/safety/limits.d.ts +31 -0
  40. package/dist/safety/limits.js +105 -0
  41. package/dist/safety/print-file.d.ts +25 -0
  42. package/dist/safety/print-file.js +610 -0
  43. package/dist/safety/printer-state.d.ts +51 -0
  44. package/dist/safety/printer-state.js +371 -0
  45. package/dist/slicer/bambu-cli.d.ts +110 -0
  46. package/dist/slicer/bambu-cli.js +547 -0
  47. package/dist/slicer/profile-flatten.d.ts +104 -0
  48. package/dist/slicer/profile-flatten.js +676 -0
  49. package/dist/slicer/run-slicer.d.ts +18 -0
  50. package/dist/slicer/run-slicer.js +102 -0
  51. package/dist/slicer/slicer-error.d.ts +27 -0
  52. package/dist/slicer/slicer-error.js +53 -0
  53. package/dist/slicer/templates.d.ts +126 -0
  54. package/dist/slicer/templates.js +255 -0
  55. package/dist/stl/stl-manipulator.d.ts +25 -3
  56. package/dist/stl/stl-manipulator.js +214 -232
  57. package/package.json +16 -9
  58. package/server.json +56 -0
  59. package/src/3mf_parser.ts +8 -8
  60. package/src/blender-mcp-bridge.ts +200 -19
  61. package/src/fulu-orca.ts +36 -6
  62. package/src/index.ts +686 -137
  63. package/src/printers/bambu-node-guard.ts +44 -0
  64. package/src/printers/bambu.ts +458 -164
  65. package/src/printers/creality.ts +40 -9
  66. package/src/printers/duet.ts +64 -53
  67. package/src/printers/generic-base.ts +286 -0
  68. package/src/printers/klipper.ts +83 -28
  69. package/src/printers/octoprint.ts +74 -21
  70. package/src/printers/printer-factory.ts +21 -11
  71. package/src/printers/prusa.ts +53 -10
  72. package/src/printers/repetier.ts +55 -17
  73. package/src/safety/archive.ts +181 -0
  74. package/src/safety/artifact.ts +58 -0
  75. package/src/safety/confirmation.ts +56 -0
  76. package/src/safety/expected-peaks.ts +47 -0
  77. package/src/safety/gcode-thermal.ts +404 -0
  78. package/src/safety/limits.ts +98 -0
  79. package/src/safety/print-file.ts +429 -0
  80. package/src/safety/printer-state.ts +344 -0
  81. package/src/slicer/bambu-cli.ts +633 -0
  82. package/src/slicer/profile-flatten.ts +830 -0
  83. package/src/slicer/run-slicer.ts +126 -0
  84. package/src/slicer/slicer-error.ts +72 -0
  85. package/src/slicer/templates.ts +288 -0
  86. package/src/stl/stl-manipulator.ts +247 -275
@@ -0,0 +1,610 @@
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)(?:\s|$)|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 headers = [];
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
+ headers.push({ [match[1].toLowerCase()]: match[2] });
214
+ }
215
+ const metadata = [project, ...headers];
216
+ validateFffJob([...metadata, plate]);
217
+ const declaredModel = consistent(metadataValues(metadata, ['printer_model']), normalizeModel, 'printer model');
218
+ if (declaredModel !== model)
219
+ return fail(`file model ${declaredModel} contradicts requested model ${model}`);
220
+ const nozzleDiameters = consistent(metadataValues(metadata, ['nozzle_diameter']), value => {
221
+ const values = list(value);
222
+ return values.length && values.every(v => NUMBER.test(v) && [0.2, 0.4, 0.6, 0.8].includes(Number(v))) ? values.map(Number) : undefined;
223
+ }, 'nozzle diameter');
224
+ if (options.nozzleDiameters && (options.nozzleDiameters.length === 1 ? !nozzleDiameters.every(value => value === options.nozzleDiameters[0]) : JSON.stringify(options.nozzleDiameters) !== JSON.stringify(nozzleDiameters)))
225
+ return fail('file nozzle diameters contradict the requested nozzle diameters');
226
+ if (plate.nozzle_diameter !== undefined) {
227
+ const values = typeof plate.nozzle_diameter === 'number' ? [String(plate.nozzle_diameter)] : list(plate.nozzle_diameter);
228
+ if (!values.length || values.some(value => !NUMBER.test(value) || !Number.isFinite(Number(value))) ||
229
+ (values.length === 1 ? nozzleDiameters.some(diameter => Math.abs(diameter - Number(values[0])) > 1e-6) :
230
+ values.length !== nozzleDiameters.length || values.some((value, index) => Math.abs(Number(value) - nozzleDiameters[index]) > 1e-6))) {
231
+ fail('selected plate nozzle diameter metadata contradicts the sliced project nozzles');
232
+ }
233
+ }
234
+ const normalizedEntries = (value) => {
235
+ const entries = list(value).map(entry => entry.toLowerCase().replace(/[\s_-]+/g, '_'));
236
+ return entries.length && entries.every(Boolean) ? entries : undefined;
237
+ };
238
+ // Bambu Studio writes printer_extruder_id/printer_extruder_variant to
239
+ // project_settings.config as the full per-variant table, but to the plate
240
+ // G-code header as the per-physical-extruder selection it sliced with (for
241
+ // example ["1","1","1"] vs "1" on a single-nozzle P2S). Preserve the source:
242
+ // a plate header can never substitute a full table for its selected values.
243
+ const variantTable = (key, rows, convert) => {
244
+ const field = key.replace(/_/g, ' ');
245
+ const tableValues = metadataValues([project], [key]);
246
+ const selectedValues = metadataValues(headers, [key]);
247
+ if (tableValues.some(value => list(value).length !== rows) || selectedValues.some(value => list(value).length !== nozzleDiameters.length))
248
+ return fail(`unknown or malformed ${field} metadata`);
249
+ const table = consistent(tableValues, convert, field);
250
+ return { table, selected: selectedValues.length ? consistent(selectedValues, convert, `selected ${field}`) : undefined };
251
+ };
252
+ const nozzleMetadata = (keys, variantRows = false) => {
253
+ const values = metadataValues(metadata, keys);
254
+ return values.length ? consistent(values, value => {
255
+ const entries = normalizedEntries(value);
256
+ if (!entries)
257
+ return undefined;
258
+ if (entries.length === nozzleDiameters.length)
259
+ return entries;
260
+ if (!variantRows)
261
+ return undefined;
262
+ // Bambu GUI projects may store nozzle types per extruder variant, while
263
+ // diameters and selected flow types are per physical extruder. Never truncate
264
+ // that variant table or mistake its rows for filament/nozzle positions.
265
+ const { table: ids, selected: selectedIds } = variantTable('printer_extruder_id', entries.length, value => {
266
+ const ids = list(value);
267
+ return ids.length > 0 && ids.every(id => /^[1-9]\d*$/.test(id) && Number(id) <= nozzleDiameters.length) ? ids.map(Number) : undefined;
268
+ });
269
+ // The selected form names each physical extruder once, in order.
270
+ if (selectedIds && selectedIds.some((id, index) => id !== index + 1))
271
+ return undefined;
272
+ const variants = metadataValues(metadata, ['printer_extruder_variant']).length
273
+ ? variantTable('printer_extruder_variant', entries.length, normalizedEntries) : undefined;
274
+ const extruders = variants ? consistent(metadataValues(metadata, ['extruder_type']), normalizedEntries, 'extruder type') : undefined;
275
+ const resolved = [];
276
+ for (let index = 0; index < nozzleDiameters.length; index++) {
277
+ const rows = ids.flatMap((id, row) => id === index + 1 ? [row] : []);
278
+ if (!rows.length)
279
+ return undefined;
280
+ if (!variants) {
281
+ if (!rows.every(row => entries[row] === entries[rows[0]]))
282
+ return undefined;
283
+ resolved.push(entries[rows[0]]);
284
+ continue;
285
+ }
286
+ // Validate declared variants even when all rows use the same material.
287
+ if (!extruders || extruders.length !== nozzleDiameters.length || !nozzleFlows)
288
+ return undefined;
289
+ const variant = `${extruders[index]}_${nozzleFlows[index]}`;
290
+ if (variants.selected && variants.selected[index] !== variant)
291
+ return undefined;
292
+ const selected = rows.filter(row => variants.table[row] === variant);
293
+ if (selected.length !== 1)
294
+ return undefined;
295
+ resolved.push(entries[selected[0]]);
296
+ }
297
+ return resolved;
298
+ }, keys[0].replace(/_/g, ' ')) : undefined;
299
+ };
300
+ const nozzleFlows = nozzleMetadata(['nozzle_flow', 'nozzle_volume_type']);
301
+ const nozzleTypes = nozzleMetadata(['nozzle_type'], true);
302
+ const materials = consistent(metadataValues(metadata, ['filament_type']), value => {
303
+ const values = list(value);
304
+ return values.length && values.every(v => !!normalizeMaterial(v)) ? values.map(v => normalizeMaterial(v)) : undefined;
305
+ }, 'filament material');
306
+ const bedValues = metadataValues([...metadata, plate], ['curr_bed_type', 'bed_type']);
307
+ const bedType = bedValues.length ? consistent(bedValues, normalizeBed, 'bed type') : undefined;
308
+ if (options.bedType && (!bedType || normalizeBed(options.bedType) !== bedType))
309
+ return fail('selected bed type cannot be verified or contradicts the sliced file; re-slice with the intended bed');
310
+ validateBounds(metadata, model, plate);
311
+ const used = new Set();
312
+ const requirePosition = (position) => {
313
+ if (!Number.isInteger(position) || position < 0 || position >= materials.length)
314
+ return fail(`filament/tool position ${position} has no declared material`);
315
+ used.add(position);
316
+ return position;
317
+ };
318
+ if (plate.filament_ids !== undefined) {
319
+ if (!Array.isArray(plate.filament_ids) || !plate.filament_ids.length)
320
+ return fail('malformed selected plate filament_ids metadata');
321
+ for (const value of plate.filament_ids) {
322
+ if (typeof value !== 'number' || !Number.isInteger(value))
323
+ return fail('malformed selected plate filament position');
324
+ requirePosition(value);
325
+ }
326
+ }
327
+ let active = materials.length === 1 ? 0 : undefined;
328
+ let nozzleTarget = 0;
329
+ let selectsAms = false;
330
+ let pending;
331
+ let previous;
332
+ let maxNozzleTemperature = 0, maxBedTemperature = 0, maxChamberTemperature = 0, commandCount = 0;
333
+ let depositionStarted = false, commonFlushUsed = false;
334
+ // Bound the H2C vendor forms to its one explicit executable startup block.
335
+ const startupStarts = lines.flatMap((line, index) => /^\s*;\s*EXECUTABLE_BLOCK_START\s*$/i.test(line) ? [index] : []);
336
+ const startupEnds = lines.flatMap((line, index) => /^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(line) ? [index] : []);
337
+ const h2cStartup = (index) => model === 'h2c' && !depositionStarted && startupStarts.length === 1 && startupEnds.length === 1 && index > startupStarts[0] && index < startupEnds[0];
338
+ const heat = (component, value, position, allMaterials = false, startupPurge = false) => {
339
+ const affected = allMaterials || position === undefined ? materials.map((_, index) => index) : [requirePosition(position)];
340
+ if (component === 'nozzle' && startupPurge)
341
+ validateStartupPurgeTemperature(value, model, affected.map(index => materials[index]));
342
+ else
343
+ validateTemperature(component, value, model, affected.map(index => materials[index]));
344
+ // Candidate materials constrain heat, but only plate metadata and explicit
345
+ // filament selections establish physical spool use. Heating a nozzle is not
346
+ // evidence that every project filament must be mapped into the AMS.
347
+ if (component === 'nozzle') {
348
+ nozzleTarget = value;
349
+ maxNozzleTemperature = Math.max(maxNozzleTemperature, value);
350
+ }
351
+ else if (component === 'bed')
352
+ maxBedTemperature = Math.max(maxBedTemperature, value);
353
+ else
354
+ maxChamberTemperature = Math.max(maxChamberTemperature, value);
355
+ };
356
+ const closeStartupWindow = () => {
357
+ depositionStarted = true;
358
+ const affected = active === undefined ? (used.size ? [...used].map(position => materials[position]) : materials) : [materials[active]];
359
+ validateTemperature('nozzle', nozzleTarget, model, affected);
360
+ };
361
+ for (let index = 0; index < lines.length; index++) {
362
+ try {
363
+ if (isLayerMarker(lines[index]) || /^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(lines[index]))
364
+ closeStartupWindow();
365
+ let line = stripComments(lines[index]);
366
+ if (!line || line === '%')
367
+ continue;
368
+ // The official H2C template emits this literal separator without a semicolon.
369
+ if (line === '========== record data ==========' && h2cStartup(index))
370
+ continue;
371
+ line = line.replace(/^N\d+\s*/i, '').replace(/\*\d+\s*$/, '');
372
+ if (/[{}\[\]#]/.test(line))
373
+ fail('unresolved dynamic command syntax');
374
+ if (/^SYNC(?=$|\s)/i.test(line)) {
375
+ // H2D change_filament_gcode emits SYNC T{ceil(flush_length / 125) * 5}.
376
+ // T is a synchronization duration here, not a nozzle temperature. The
377
+ // vendor template establishes no independent maximum duration.
378
+ const args = parameters(line.slice(4));
379
+ if (args.size !== 1 || !args.has('T') || !Number.isFinite(args.get('T')) || args.get('T') < 0)
380
+ fail('unsupported SYNC duration parameters');
381
+ commandCount++;
382
+ continue;
383
+ }
384
+ const command = line.match(/^([GMT])(\d+(?:\.\d+)?)(?=$|\s|[A-Z+-])/i);
385
+ if (!command)
386
+ fail(`unsupported G-code command syntax '${line.slice(0, 100)}'`);
387
+ const [base, subcode] = command[2].split('.');
388
+ const code = command[1].toUpperCase() + String(Number(base)) + (subcode === undefined ? '' : '.' + subcode);
389
+ const argumentsText = line.slice(command[0].length).trim();
390
+ commandCount++;
391
+ if (!['M117', 'M118', 'M1002', 'M1006', 'M900', 'M970', 'M983.1', 'G383'].includes(code) && /[GM]\s*\d/i.test(argumentsText))
392
+ fail('multiple commands on one line are unsupported');
393
+ // Coordinated extrusion closes the startup-only allowance even when the
394
+ // source omits layer comments. Retraction also closes it conservatively.
395
+ if (['G0', 'G1', 'G2', 'G3'].includes(code) && /E/i.test(argumentsText) && /[XY]/i.test(argumentsText))
396
+ closeStartupWindow();
397
+ if (/^M(?:3|4|452)(?:\.|$)/.test(code))
398
+ fail(`laser/cutter command ${code} is unsupported by the FFF print path`);
399
+ if (/^T\d+$/.test(code)) {
400
+ const position = Number(code.slice(1));
401
+ if ([254, 255, 1000, 1001, 1100, 65279, 65535].includes(position)) {
402
+ previous = active;
403
+ active = undefined;
404
+ }
405
+ else {
406
+ validateTemperature('nozzle', nozzleTarget, model, [materials[requirePosition(position)]]);
407
+ selectsAms = true;
408
+ previous = active;
409
+ active = position;
410
+ }
411
+ continue;
412
+ }
413
+ if (['M104', 'M109', 'M140', 'M190', 'M141', 'M191'].includes(code)) {
414
+ const args = parameters(argumentsText, 'A');
415
+ // Official A1 and A1 mini start G-code waits with M109 S<target> H<limit>
416
+ // (S25 H140, S100 H170, S220 H300). H is not a heater target: S stays the
417
+ // checked target, so H is accepted only within those vendor bounds.
418
+ if (args.has('O')) {
419
+ // H2C's exact all-hotend preheat offset is relative to the declared
420
+ // first-layer targets. Check every base and derived target, never use
421
+ // editable maxima or an unchecked offset as thermal evidence.
422
+ if (code !== 'M104' || !h2cStartup(index) || args.size !== 2 || args.get('O') !== -80 || !Number.isNaN(args.get('A')))
423
+ fail('unsupported H2C startup offset parameters');
424
+ const targets = consistent(metadataValues(metadata, ['nozzle_temperature_initial_layer']), value => {
425
+ const values = list(value);
426
+ return values.length === materials.length && values.every(v => NUMBER.test(v) && Number.isFinite(Number(v))) ? values.map(Number) : undefined;
427
+ }, 'first-layer nozzle temperature');
428
+ for (let position = 0; position < targets.length; position++) {
429
+ validateTemperature('nozzle', targets[position], model, [materials[position]]);
430
+ const preheat = Math.max(0, targets[position] - 80);
431
+ validateTemperature('nozzle', preheat, model, [materials[position]]);
432
+ nozzleTarget = Math.max(nozzleTarget, preheat);
433
+ maxNozzleTemperature = Math.max(maxNozzleTemperature, preheat);
434
+ }
435
+ continue;
436
+ }
437
+ if (args.has('D')) {
438
+ if (!['M140', 'M190'].includes(code) || !h2cStartup(index) || args.size !== 1)
439
+ fail('unsupported H2C startup bed parameters');
440
+ heat('bed', args.get('D'), undefined, true);
441
+ continue;
442
+ }
443
+ const vendorWait = code === 'M109' && ['a1', 'a1mini'].includes(model);
444
+ for (const key of args.keys())
445
+ if (!'SRTA'.includes(key) && !(key === 'H' && vendorWait))
446
+ fail(`unsupported ${code} temperature parameter ${key}`);
447
+ if (args.has('H') && (!Number.isFinite(args.get('H')) || args.get('H') < 0 || args.get('H') > 300))
448
+ fail(`unsupported ${code} wait parameter H`);
449
+ if (!args.has('S') && !args.has('R'))
450
+ fail(`missing ${code} temperature target`);
451
+ const component = ['M104', 'M109'].includes(code) ? 'nozzle' : ['M140', 'M190'].includes(code) ? 'bed' : 'chamber';
452
+ // Heater T is a physical nozzle, unlike standalone remapped T filament selection.
453
+ // Without a verified per-filament nozzle map, apply all possible materials.
454
+ if (args.has('T') && (!Number.isInteger(args.get('T')) || args.get('T') < 0 || args.get('T') >= nozzleDiameters.length))
455
+ fail('physical heater target has no declared nozzle');
456
+ const position = args.has('T') ? undefined : active;
457
+ const commonFlush = code === 'M109' && args.size === 1 && args.get('S') === 290 && model === 'x1e' && !depositionStarted && !commonFlushUsed && boundedX1ePurge(lines, index);
458
+ if (commonFlush)
459
+ commonFlushUsed = true;
460
+ for (const key of ['S', 'R'])
461
+ if (args.has(key))
462
+ heat(component, args.get(key), position, args.has('T') || args.has('A'), commonFlush);
463
+ }
464
+ else if (code === 'M620' || code === 'M621') {
465
+ const args = parameters(argumentsText, model === 'x2d' ? 'MAB' : model === 'h2c' ? 'MAN' : 'MA');
466
+ if (args.has('N') && (code !== 'M620' || !h2cStartup(index) || args.size !== 1 || !Number.isNaN(args.get('N'))))
467
+ fail('unsupported H2C startup hotend-remap flag');
468
+ // X2D templates use B as a bare material-switch flag, never a target.
469
+ if (args.has('B') && (model !== 'x2d' || !Number.isNaN(args.get('B'))))
470
+ fail('unsupported material-switch B flag');
471
+ if (args.has('S')) {
472
+ const position = args.get('S');
473
+ if ([254, 255, 65279, 65535].includes(position)) {
474
+ if (code === 'M621') {
475
+ previous = active;
476
+ active = undefined;
477
+ }
478
+ pending = undefined;
479
+ }
480
+ else {
481
+ selectsAms = true;
482
+ requirePosition(position);
483
+ if (code === 'M620')
484
+ pending = position;
485
+ else {
486
+ validateTemperature('nozzle', nozzleTarget, model, [materials[position]]);
487
+ previous = active;
488
+ active = position;
489
+ pending = undefined;
490
+ }
491
+ }
492
+ }
493
+ }
494
+ else if (code === 'M620.22') {
495
+ // Official X2D runout auto-purge enables the declared project filament.
496
+ // It supplies no heater target; all explicit heat still uses the shared gate.
497
+ const args = parameters(argumentsText);
498
+ if (model !== 'x2d' || args.size !== 2 || !args.has('I') || args.get('P') !== 1)
499
+ fail('unsupported M620.22 runout purge parameters');
500
+ requirePosition(args.get('I'));
501
+ }
502
+ else if (code === 'M620.1' || code === 'M620.10') {
503
+ const args = parameters(argumentsText, 'E');
504
+ const position = code === 'M620.10' && args.get('A') === 1 ? pending ?? active : code === 'M620.10' && args.get('A') === 0 ? active : undefined;
505
+ // Setup commands are thermal-affecting too. Startup placement or a P
506
+ // target cannot establish a bounded purge, so no elevated PLA allowance.
507
+ for (const key of code === 'M620.10' ? ['T', 'P'] : ['T'])
508
+ if (args.has(key))
509
+ heat('nozzle', args.get(key), position);
510
+ }
511
+ else if (code === 'G150') {
512
+ // Official H2D nozzle-wipe routine carries the temperature in T.
513
+ const args = parameters(argumentsText);
514
+ if (args.has('T'))
515
+ heat('nozzle', args.get('T'), active);
516
+ }
517
+ else if (/^G150\./.test(code) && /T/i.test(argumentsText)) {
518
+ fail(`unsupported thermal-affecting wipe command ${code}`);
519
+ }
520
+ else if (code === 'M620.14') {
521
+ const args = parameters(argumentsText);
522
+ // H2C tower coordinates include the rear wipe area outside object bounds.
523
+ if (!h2cStartup(index) || args.size !== 2 || !['X', 'Y'].every(key => args.has(key) && Number.isFinite(args.get(key)) && args.get(key) >= 0 && args.get(key) <= 350))
524
+ fail('unsupported H2C startup tower parameters');
525
+ }
526
+ else if (model === 'h2c' && ['G383', 'G383.3', 'G383.7'].includes(code) && /U/i.test(argumentsText)) {
527
+ const args = parameters(argumentsText);
528
+ const expected = code === 'G383.7' ? 'UJ' : code === 'G383.3' ? 'UL' : 'OUL';
529
+ if (!h2cStartup(index) || args.size !== expected.length || [...args.keys()].some(key => !expected.includes(key)) || args.get('U') !== 140 ||
530
+ (code === 'G383.7' && args.get('J') !== 0) || (code === 'G383' && args.get('O') !== 2))
531
+ fail('unsupported H2C startup probing parameters');
532
+ const position = args.has('L') ? requirePosition(args.get('L')) : active;
533
+ heat('nozzle', args.get('U'), position);
534
+ }
535
+ else if (code === 'G383' || code === 'G383.3') {
536
+ // Bambu probing routines carry nozzle temperature in T and optionally
537
+ // the project filament position in L (including the H2D G383.3 form).
538
+ const args = parameters(argumentsText);
539
+ if (args.has('U'))
540
+ fail('unsupported G383 temperature parameter U outside H2C startup');
541
+ if (code === 'G383.3' && (!args.has('T') || [...args.keys()].some(key => !'TL'.includes(key))))
542
+ fail('unsupported G383.3 temperature parameters');
543
+ const position = args.has('L') ? requirePosition(args.get('L')) : active;
544
+ if (args.has('T'))
545
+ heat('nozzle', args.get('T'), position);
546
+ }
547
+ else if (code === 'G383.4' && !argumentsText) {
548
+ // Official H2D startup: left-extruder load status detection, no target.
549
+ }
550
+ else if (/^G383\./.test(code)) {
551
+ fail(`unsupported thermal-affecting probing command ${code}`);
552
+ }
553
+ else if (code === 'M620.13') {
554
+ // Verified H2D prime-tower interface form, after T[next_extruder]: L is
555
+ // purge volume, T is the active filament's temperature, W/R are zero.
556
+ const args = parameters(argumentsText);
557
+ if (args.size !== 4 || args.get('W') !== 0 || args.get('R') !== 0 || !args.has('T') ||
558
+ !args.has('L') || !Number.isFinite(args.get('L')) || args.get('L') < 0)
559
+ fail('unsupported M620.13 prime-tower temperature parameters');
560
+ heat('nozzle', args.get('T'), active);
561
+ }
562
+ else if (code === 'M620.15') {
563
+ // H2D change_filament_gcode supplies the incoming filament's cooling
564
+ // temperature as C{new_filament_temp - filament_cooling_before_tower}.
565
+ const args = parameters(argumentsText);
566
+ if (args.size !== 1 || !args.has('C'))
567
+ fail('unsupported M620.15 cooling temperature parameters');
568
+ heat('nozzle', args.get('C'), pending ?? active);
569
+ }
570
+ else if (code === 'M620.17') {
571
+ const args = parameters(argumentsText);
572
+ if (!args.has('S') || !args.has('L') || !args.has('T') || !Number.isInteger(args.get('T')) || args.get('T') < 0 || args.get('T') >= nozzleDiameters.length)
573
+ fail('unsupported M620.17 nozzle/material temperature mapping');
574
+ for (const key of args.keys())
575
+ if (!'SLT'.includes(key))
576
+ fail(`unsupported M620.17 parameter ${key}`);
577
+ heat('nozzle', args.get('S'), requirePosition(args.get('L')));
578
+ }
579
+ else if ((/^M620\./.test(code) && !['M620.3', 'M620.6', 'M620.11'].includes(code)) || /^M621\./.test(code)) {
580
+ fail(`unsupported thermal-affecting filament command ${code}`);
581
+ }
582
+ else if (code === 'M145') {
583
+ // Airduct mode switch (P0 cooling, P1 heating) in the official H2-series,
584
+ // X2D and P2S start/end G-code; it sets no heater target. Other forms and
585
+ // models without a switchable airduct stay rejected.
586
+ const args = parameters(argumentsText);
587
+ if (args.size !== 1 || !args.has('P') || ![0, 1].includes(args.get('P')) || !['h2d', 'h2dpro', 'h2c', 'h2s', 'x2d', 'p2s'].includes(model))
588
+ fail('unsupported thermal-affecting M145 parameters');
589
+ }
590
+ else if (code === 'M142') {
591
+ // Bambu chamber fan regulation thresholds, not an active heater command.
592
+ const args = parameters(argumentsText);
593
+ for (const key of ['S', 'R'])
594
+ if (args.has(key) && (!Number.isFinite(args.get(key)) || args.get(key) < 0 || args.get(key) > 65))
595
+ fail('unsupported chamber fan temperature threshold');
596
+ }
597
+ 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))) {
598
+ fail(`unsupported thermal-affecting or external program command ${code}; re-slice with standard Bambu heater commands`);
599
+ }
600
+ }
601
+ catch (error) {
602
+ throw new Error(`Print safety line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
603
+ }
604
+ }
605
+ if (!commandCount)
606
+ return fail('selected G-code contains no printable commands');
607
+ if (!used.size)
608
+ materials.forEach((_, index) => used.add(index));
609
+ return { model, nozzleDiameters, materials, usedFilamentPositions: [...used].sort((a, b) => a - b), plateInternalPath, sha256, maxNozzleTemperature, maxBedTemperature, maxChamberTemperature, bedType, selectsAms, nozzleTypes, nozzleFlows };
610
+ }
@@ -0,0 +1,51 @@
1
+ import type { BambuClient } from "bambu-node";
2
+ /**
3
+ * Publish one MQTT command with a string sequence id and no ACK wait.
4
+ * Unpatched bambu-node 3.22.21 command classes send numeric sequence ids;
5
+ * bambu-printer-mcp patches them to strings for H2-series firmware. This
6
+ * server does not install that patch, so safety-relevant commands are
7
+ * published here with the patched wire shape: {category:{sequence_id,command,...}}.
8
+ */
9
+ export declare function publishBambuCommand(printer: Pick<BambuClient, "publish">, category: "print" | "pushing" | "info", command: string, extra?: Record<string, unknown>): Promise<void>;
10
+ /** Only raw reports received after this request are evidence. Never read printer.data here. */
11
+ export declare function readFreshPrinterStatus(printer: BambuClient, serial: string, timeoutMs?: number): Promise<any>;
12
+ export interface PrinterStateRequirements {
13
+ model: string;
14
+ nozzleDiameters: number[];
15
+ materials?: string[];
16
+ usedFilamentPositions?: number[];
17
+ amsMapping?: number[];
18
+ useAMS?: boolean;
19
+ requireIdle?: boolean;
20
+ /** Raw gcode_file transport cannot load a mapped spool before printing. */
21
+ requireLoadedFilament?: boolean;
22
+ /** Upload-only identity checks do not select or consume a physical filament slot. */
23
+ verifyMaterials?: boolean;
24
+ /** Zero-based firmware nozzle indices corresponding positionally to nozzleDiameters. */
25
+ usedNozzleIndices?: number[];
26
+ nozzleTypes?: string[];
27
+ nozzleFlows?: string[];
28
+ }
29
+ interface ReportedNozzle {
30
+ index: number;
31
+ diameter: number;
32
+ type?: string;
33
+ flow?: string;
34
+ }
35
+ interface ReportedFilament {
36
+ position: number;
37
+ material: string;
38
+ trayIndex: number;
39
+ nozzleMin?: number;
40
+ nozzleMax?: number;
41
+ }
42
+ export interface ValidatedPrinterState {
43
+ model: string;
44
+ reportedNozzles: ReportedNozzle[];
45
+ filaments: ReportedFilament[];
46
+ }
47
+ /** Verifies reported configuration, not a physical nozzle/spool inspection. */
48
+ export declare function validatePrinterState(status: any, requirements: PrinterStateRequirements): ValidatedPrinterState;
49
+ /** Manual M104 cannot remap filament slots. Validate the material already at the nozzle. */
50
+ export declare function manualHeatingRequirements(status: any, model: string, nozzleDiameter: number, material: string): PrinterStateRequirements;
51
+ export {};