mcp-3d-printer-server 1.2.11 → 1.2.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/CHANGELOG.md +24 -0
- package/CONTRIBUTORS.md +10 -1
- package/dist/safety/print-file.js +107 -14
- package/dist/safety/printer-state.js +9 -3
- package/package.json +2 -2
- package/server.json +37 -10
- package/src/safety/print-file.ts +89 -14
- package/src/safety/printer-state.ts +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.13 — 2026-10-20
|
|
4
|
+
|
|
5
|
+
### Dependencies
|
|
6
|
+
|
|
7
|
+
- Refresh the reproducible installation/test lock from MQTT.js 5.10.4 to 5.16.1, including its fix for reconnecting when the previous stream is already destroyed. Keep bambu-node pinned at 3.22.21 and preserve the server's checked MQTT wire format and rejection guard. Thank you to [Henk Kraal (hkraal)](https://github.com/hkraal) and the [MQTT.js maintainers](https://github.com/mqttjs/MQTT.js/releases/tag/v5.16.1).
|
|
8
|
+
- Update the documentation build's source-map-js to 1.2.2 for [GHSA-68fv-2mgg-jv7q](https://github.com/advisories/GHSA-68fv-2mgg-jv7q), which addresses indexed source-map offsets blocking the event loop. Thank you to [7rulnik](https://github.com/7rulnik/source-map-js/pull/79). Refresh compatible Node 18 typings and MQTT's required transitive packages.
|
|
9
|
+
|
|
10
|
+
The root lock controls `npm ci`, including CI and reproducible local installs; npm does not ship that lock to consumers. Existing parent projects can retain older transitive MQTT versions and must refresh their own locks. This is not a new published MQTT minimum or a claim that every advisory is resolved. Remaining nodemon/chokidar/braces and VitePress/Vite/esbuild development-server advisories have no compatible automatic fix; no major upgrade or downgrade is forced. Printer safety behavior is unchanged and no physical printer operation is performed.
|
|
11
|
+
|
|
12
|
+
## 1.2.12 — 2026-10-13
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Correct fresh H2C (`31B`) and H2D Pro (`239`) returned-serial identity without trusting configured identity or suppressing conflicts. Accept a cloud print-job identifier in `model_id` only when another recognized observed model or returned serial identifies the printer; unknown actual model fields still reject. Port reports from Adam (@adamkaplan, [fork #47](https://github.com/DMontgomery40/bambu-printer-mcp/issues/47)) and JaviOFC ([fork #36](https://github.com/DMontgomery40/bambu-printer-mcp/issues/36)).
|
|
17
|
+
- Accept Bambu Studio 02.08 P2S per-variant nozzle tables alongside the selected plate header, and the exact P2S `M145 P0/P1` airduct forms. Preserve selected extruder/variant conflicts and independent thermal checks. Port Izzy Mansurov's (@sapoepsilon) code and regressions from [fork #33](https://github.com/DMontgomery40/bambu-printer-mcp/pull/33), including the subsequent selection-validation corrections.
|
|
18
|
+
- Accept the official H2C startup forms only inside its single bounded executable startup block, with independent base/derived nozzle and bed checks and declared filament positions. Keep configuration `layer_change_gcode` keys from ending startup; actual layer markers and extrusion still close it. Reject unchecked `G383 U` targets outside the allowance. Thanks to Adam for [fork #48](https://github.com/DMontgomery40/bambu-printer-mcp/issues/48) and the exact vendor template evidence.
|
|
19
|
+
- Accept the official X2D bare material-switch `B` flag and exact `M620.22 I<declared filament> P1` runout-purge form while rejecting unsupported variants and preserving heater ceilings. Thanks to Travis Cashman (@travismcashan) for [fork #44](https://github.com/DMontgomery40/bambu-printer-mcp/issues/44). These are file-inspection changes; no native X2D helper is added to this server.
|
|
20
|
+
|
|
21
|
+
### Dependencies
|
|
22
|
+
|
|
23
|
+
- Update basic-ftp to 6.2.3 for [GHSA-r4jh-29pc-gfqr](https://github.com/patrickjuchli/basic-ftp/security/advisories/GHSA-r4jh-29pc-gfqr): rejected control-character passwords/resume paths no longer cause an uncaught exception or leave the client unusable, and rejected PASS errors omit the password. Keep bambu-node pinned and preserve the parent's direct MQTT wire-shape and rejection guard.
|
|
24
|
+
|
|
25
|
+
Validation uses mocked transports, the pinned official H2C template, unsafe-file mutations, and clean package installations. No physical printer operation is performed or claimed.
|
|
26
|
+
|
|
3
27
|
## 1.2.11 — 2026-10-06
|
|
4
28
|
|
|
5
29
|
This release also includes the safety, slicing, Blender, and documentation
|
package/CONTRIBUTORS.md
CHANGED
|
@@ -6,6 +6,9 @@ Thank you to everyone who builds, tests, reports problems, and shares real print
|
|
|
6
6
|
|
|
7
7
|
| Contributor | Contribution |
|
|
8
8
|
| --- | --- |
|
|
9
|
+
| [Adam (@adamkaplan)](https://github.com/adamkaplan) | Supplies H2C serial/status evidence in [fork #47](https://github.com/DMontgomery40/bambu-printer-mcp/issues/47) and exact Bambu Studio 02.08 startup/template reproductions in [fork #48](https://github.com/DMontgomery40/bambu-printer-mcp/issues/48), ported here with fresh identity and bounded file-inspection regressions. No maintainer physical H2C print is claimed. |
|
|
10
|
+
| [Izzy Mansurov (sapoepsilon)](https://github.com/sapoepsilon) | Implements P2S extruder-variant and airduct inspection in [fork #33](https://github.com/DMontgomery40/bambu-printer-mcp/pull/33). Original code authorship is retained in the port, alongside unsafe-file and selected-header regressions; hardware printing is not claimed. |
|
|
11
|
+
| [JaviOFC](https://github.com/JaviOFC) | Provides X2D firmware/status evidence in [fork #36](https://github.com/DMontgomery40/bambu-printer-mcp/issues/36) demonstrating that a cloud job identifier in model_id is not a printer declaration. The observed-identity correction and conflict regressions are ported here. |
|
|
9
12
|
| [Jack Manning (EastArctica)](https://github.com/EastArctica) | Sends `X-Api-Key` on every Klipper/Moonraker request, including uploads and remote-file downloads, in [#30](https://github.com/DMontgomery40/mcp-3D-printer-server/pull/30). Supplies Klipper 0.13.0 evidence of authenticated status succeeding after the previous 401 response. |
|
|
10
13
|
| [nitpreet22](https://github.com/nitpreet22) | Adds A1/A1 mini `M109 H` wait compatibility and safety regressions in the Bambu fork's [#41](https://github.com/DMontgomery40/bambu-printer-mcp/pull/41), ported here with original code authorship. Shares Windows A1 slicing and file-inspection evidence; this is not physical-print verification. |
|
|
11
14
|
| [Javier Cortejoso (jcortejoso)](https://github.com/jcortejoso) | Reads the FULU bridge command from server configuration by default, with an explicit opt-in for per-call bridge commands, in [#20](https://github.com/DMontgomery40/mcp-3D-printer-server/pull/20). [#21](https://github.com/DMontgomery40/mcp-3D-printer-server/pull/21) extends the same gate to every per-call executable selector. |
|
|
@@ -19,7 +22,7 @@ Thank you to everyone who builds, tests, reports problems, and shares real print
|
|
|
19
22
|
| Contributor | Contribution |
|
|
20
23
|
| --- | --- |
|
|
21
24
|
| [Steavie](https://github.com/Steavie) | Reports reversed A1/A1 mini serial identity in the Bambu fork's [#39](https://github.com/DMontgomery40/bambu-printer-mcp/issues/39). This repository now uses the corrected prefixes for fresh safety identity, with mocked MQTT regressions. |
|
|
22
|
-
| [Travis Cashman (travismcashan)](https://github.com/travismcashan) | Reports MCP stdout pollution from model parsing and slicing in the Bambu fork's [#44](https://github.com/DMontgomery40/bambu-printer-mcp/issues/44). The stderr correction and subprocess regressions are also applied here. |
|
|
25
|
+
| [Travis Cashman (travismcashan)](https://github.com/travismcashan) | Reports MCP stdout pollution from model parsing and slicing in the Bambu fork's [#44](https://github.com/DMontgomery40/bambu-printer-mcp/issues/44). The stderr correction and subprocess regressions are also applied here. Their exact stock material-switch and runout-purge evidence informs the X2D file-inspection port; maintainer validation remains offline and mocked. |
|
|
23
26
|
| [Lickitysplitted](https://github.com/Lickitysplitted) | Diagnoses Bambu FTPS "Premature close" upload failures on an X1C as missing TLS session reuse in [#22](https://github.com/DMontgomery40/mcp-3D-printer-server/issues/22). Release 1.2.9 addresses it; confirmation on that X1C firmware is still outstanding. |
|
|
24
27
|
| [CrowSoda](https://github.com/CrowSoda) | Tries slicing and printing on a Creality K1 Max through Klipper and Moonraker, and reports invalid OrcaSlicer CLI flags, missing filament-profile support, and unregistered upload and start tools in [#16](https://github.com/DMontgomery40/mcp-3D-printer-server/issues/16). All three are fixed. |
|
|
25
28
|
| [dongio20](https://github.com/dongio20) | Reports the PrusaLink 0.8.1 status 404, with firmware details and the working endpoint, in [#11](https://github.com/DMontgomery40/mcp-3D-printer-server/issues/11). Fixed in [#15](https://github.com/DMontgomery40/mcp-3D-printer-server/pull/15). |
|
|
@@ -44,6 +47,12 @@ Thank you to everyone who builds, tests, reports problems, and shares real print
|
|
|
44
47
|
|
|
45
48
|
See the [full contribution history](https://github.com/DMontgomery40/mcp-3D-printer-server/graphs/contributors) for commit authorship. Credit here includes bug reports, printer evidence, and reviewed proposals as well as merged code.
|
|
46
49
|
|
|
50
|
+
## Dependency maintenance
|
|
51
|
+
|
|
52
|
+
Thanks to [Patrick Juchli](https://github.com/patrickjuchli) and the basic-ftp maintainers for [6.2.3](https://github.com/patrickjuchli/basic-ftp/releases/tag/v6.2.3), which fixes rejected-command crashes, client recovery, and password exposure in PASS errors. The compatible FTPS security update is validated in this release.
|
|
53
|
+
|
|
54
|
+
Thanks to [Henk Kraal (hkraal)](https://github.com/hkraal) and the [MQTT.js maintainers](https://github.com/mqttjs/MQTT.js/releases/tag/v5.16.1) for correcting reconnect behavior when the previous stream is destroyed, and to [7rulnik](https://github.com/7rulnik/source-map-js/pull/79) for the indexed source-map offset security fix in source-map-js 1.2.2. Their fixes are incorporated into the installation/test and documentation locks; published consumers still manage their own transitive locks. Thanks also to the [DefinitelyTyped contributors](https://github.com/DefinitelyTyped/DefinitelyTyped) for maintained Node 18 and stream typings.
|
|
55
|
+
|
|
47
56
|
## Open-source community and interoperability
|
|
48
57
|
|
|
49
58
|
Special thanks to the [FULU Foundation](https://www.fulu.org/), [Louis Rossmann](https://www.youtube.com/@rossmanngroup), and the [OrcaSlicer-bambulab contributors](https://github.com/FULU-Foundation/OrcaSlicer-bambulab) for their work supporting user control and interoperable tools. This is community recognition, separate from code authorship in this repository. The Bambu-only fork, [bambu-printer-mcp](https://github.com/DMontgomery40/bambu-printer-mcp), credits its own contributors in its [CONTRIBUTORS.md](https://github.com/DMontgomery40/bambu-printer-mcp/blob/main/CONTRIBUTORS.md).
|
|
@@ -80,7 +80,7 @@ function parameters(text, flags = '') {
|
|
|
80
80
|
return result;
|
|
81
81
|
}
|
|
82
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);
|
|
83
|
+
const isLayerMarker = (line) => /^\s*;\s*(?:(?:LAYER_CHANGE|CHANGE_LAYER)(?:\s|$)|LAYER\s*:|layer num\/total_layer_count\s*:)/i.test(line);
|
|
84
84
|
function boundedX1ePurge(lines, index) {
|
|
85
85
|
// Official X1E common flush: 50 mm at 200 mm/min, followed immediately by
|
|
86
86
|
// lowering the target. No dwell, XY extrusion, repeated block or open-ended
|
|
@@ -206,12 +206,13 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
206
206
|
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\ufffd]/.test(source))
|
|
207
207
|
return fail('binary or malformed G-code is not supported');
|
|
208
208
|
const lines = source.split(/\r\n|\n|\r/);
|
|
209
|
-
const
|
|
209
|
+
const headers = [];
|
|
210
210
|
for (const line of lines) {
|
|
211
211
|
const match = line.match(/^\s*;\s*([a-z_][a-z0-9_]*(?:\s+used\s+\[(?:g|mm)\])?)\s*(?:=|:)\s*(.*?)\s*$/i);
|
|
212
212
|
if (match)
|
|
213
|
-
|
|
213
|
+
headers.push({ [match[1].toLowerCase()]: match[2] });
|
|
214
214
|
}
|
|
215
|
+
const metadata = [project, ...headers];
|
|
215
216
|
validateFffJob([...metadata, plate]);
|
|
216
217
|
const declaredModel = consistent(metadataValues(metadata, ['printer_model']), normalizeModel, 'printer model');
|
|
217
218
|
if (declaredModel !== model)
|
|
@@ -234,6 +235,20 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
234
235
|
const entries = list(value).map(entry => entry.toLowerCase().replace(/[\s_-]+/g, '_'));
|
|
235
236
|
return entries.length && entries.every(Boolean) ? entries : undefined;
|
|
236
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
|
+
};
|
|
237
252
|
const nozzleMetadata = (keys, variantRows = false) => {
|
|
238
253
|
const values = metadataValues(metadata, keys);
|
|
239
254
|
return values.length ? consistent(values, value => {
|
|
@@ -247,24 +262,34 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
247
262
|
// Bambu GUI projects may store nozzle types per extruder variant, while
|
|
248
263
|
// diameters and selected flow types are per physical extruder. Never truncate
|
|
249
264
|
// that variant table or mistake its rows for filament/nozzle positions.
|
|
250
|
-
const ids =
|
|
265
|
+
const { table: ids, selected: selectedIds } = variantTable('printer_extruder_id', entries.length, value => {
|
|
251
266
|
const ids = list(value);
|
|
252
|
-
return ids.length
|
|
253
|
-
}
|
|
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;
|
|
254
275
|
const resolved = [];
|
|
255
276
|
for (let index = 0; index < nozzleDiameters.length; index++) {
|
|
256
277
|
const rows = ids.flatMap((id, row) => id === index + 1 ? [row] : []);
|
|
257
278
|
if (!rows.length)
|
|
258
279
|
return undefined;
|
|
259
|
-
if (
|
|
280
|
+
if (!variants) {
|
|
281
|
+
if (!rows.every(row => entries[row] === entries[rows[0]]))
|
|
282
|
+
return undefined;
|
|
260
283
|
resolved.push(entries[rows[0]]);
|
|
261
284
|
continue;
|
|
262
285
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (variants.length !== entries.length || extruders.length !== nozzleDiameters.length || !nozzleFlows)
|
|
286
|
+
// Validate declared variants even when all rows use the same material.
|
|
287
|
+
if (!extruders || extruders.length !== nozzleDiameters.length || !nozzleFlows)
|
|
266
288
|
return undefined;
|
|
267
|
-
const
|
|
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);
|
|
268
293
|
if (selected.length !== 1)
|
|
269
294
|
return undefined;
|
|
270
295
|
resolved.push(entries[selected[0]]);
|
|
@@ -306,6 +331,10 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
306
331
|
let previous;
|
|
307
332
|
let maxNozzleTemperature = 0, maxBedTemperature = 0, maxChamberTemperature = 0, commandCount = 0;
|
|
308
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];
|
|
309
338
|
const heat = (component, value, position, allMaterials = false, startupPurge = false) => {
|
|
310
339
|
const affected = allMaterials || position === undefined ? materials.map((_, index) => index) : [requirePosition(position)];
|
|
311
340
|
if (component === 'nozzle' && startupPurge)
|
|
@@ -331,11 +360,14 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
331
360
|
};
|
|
332
361
|
for (let index = 0; index < lines.length; index++) {
|
|
333
362
|
try {
|
|
334
|
-
if (isLayerMarker(lines[index]))
|
|
363
|
+
if (isLayerMarker(lines[index]) || /^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(lines[index]))
|
|
335
364
|
closeStartupWindow();
|
|
336
365
|
let line = stripComments(lines[index]);
|
|
337
366
|
if (!line || line === '%')
|
|
338
367
|
continue;
|
|
368
|
+
// The official H2C template emits this literal separator without a semicolon.
|
|
369
|
+
if (line === '========== record data ==========' && h2cStartup(index))
|
|
370
|
+
continue;
|
|
339
371
|
line = line.replace(/^N\d+\s*/i, '').replace(/\*\d+\s*$/, '');
|
|
340
372
|
if (/[{}\[\]#]/.test(line))
|
|
341
373
|
fail('unresolved dynamic command syntax');
|
|
@@ -380,6 +412,34 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
380
412
|
}
|
|
381
413
|
if (['M104', 'M109', 'M140', 'M190', 'M141', 'M191'].includes(code)) {
|
|
382
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
|
+
}
|
|
383
443
|
const vendorWait = code === 'M109' && ['a1', 'a1mini'].includes(model);
|
|
384
444
|
for (const key of args.keys())
|
|
385
445
|
if (!'SRTA'.includes(key) && !(key === 'H' && vendorWait))
|
|
@@ -402,7 +462,12 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
402
462
|
heat(component, args.get(key), position, args.has('T') || args.has('A'), commonFlush);
|
|
403
463
|
}
|
|
404
464
|
else if (code === 'M620' || code === 'M621') {
|
|
405
|
-
const args = parameters(argumentsText, 'MA');
|
|
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');
|
|
406
471
|
if (args.has('S')) {
|
|
407
472
|
const position = args.get('S');
|
|
408
473
|
if ([254, 255, 65279, 65535].includes(position)) {
|
|
@@ -426,6 +491,14 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
426
491
|
}
|
|
427
492
|
}
|
|
428
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
|
+
}
|
|
429
502
|
else if (code === 'M620.1' || code === 'M620.10') {
|
|
430
503
|
const args = parameters(argumentsText, 'E');
|
|
431
504
|
const position = code === 'M620.10' && args.get('A') === 1 ? pending ?? active : code === 'M620.10' && args.get('A') === 0 ? active : undefined;
|
|
@@ -444,10 +517,27 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
444
517
|
else if (/^G150\./.test(code) && /T/i.test(argumentsText)) {
|
|
445
518
|
fail(`unsupported thermal-affecting wipe command ${code}`);
|
|
446
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
|
+
}
|
|
447
535
|
else if (code === 'G383' || code === 'G383.3') {
|
|
448
536
|
// Bambu probing routines carry nozzle temperature in T and optionally
|
|
449
537
|
// the project filament position in L (including the H2D G383.3 form).
|
|
450
538
|
const args = parameters(argumentsText);
|
|
539
|
+
if (args.has('U'))
|
|
540
|
+
fail('unsupported G383 temperature parameter U outside H2C startup');
|
|
451
541
|
if (code === 'G383.3' && (!args.has('T') || [...args.keys()].some(key => !'TL'.includes(key))))
|
|
452
542
|
fail('unsupported G383.3 temperature parameters');
|
|
453
543
|
const position = args.has('L') ? requirePosition(args.get('L')) : active;
|
|
@@ -490,8 +580,11 @@ export async function inspectPrintFile(filePath, options) {
|
|
|
490
580
|
fail(`unsupported thermal-affecting filament command ${code}`);
|
|
491
581
|
}
|
|
492
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.
|
|
493
586
|
const args = parameters(argumentsText);
|
|
494
|
-
if (args.size !== 1 || !args.has('P') || ![0, 1].includes(args.get('P')) || !['h2d', 'h2dpro', 'h2c', 'h2s', 'x2d'].includes(model))
|
|
587
|
+
if (args.size !== 1 || !args.has('P') || ![0, 1].includes(args.get('P')) || !['h2d', 'h2dpro', 'h2c', 'h2s', 'x2d', 'p2s'].includes(model))
|
|
495
588
|
fail('unsupported thermal-affecting M145 parameters');
|
|
496
589
|
}
|
|
497
590
|
else if (code === 'M142') {
|
|
@@ -17,18 +17,24 @@ const MODEL_IDS = {
|
|
|
17
17
|
N2S: "A1", A1M: "A1 Mini", C11: "P1P", C12: "P1S", C13: "X1E", "BL-P001": "X1C", "BL-P002": "X1",
|
|
18
18
|
};
|
|
19
19
|
const SERIAL_MODELS = {
|
|
20
|
-
"093": "H2S", "094": "H2D", "
|
|
20
|
+
"093": "H2S", "094": "H2D", "31B": "H2C", "239": "H2D Pro", "20P": "X2D",
|
|
21
21
|
"00M": "X1C", "00W": "X1", "03W": "X1E", "01S": "P1P", "01P": "P1S", "22E": "P2S", "039": "A1", "030": "A1 Mini",
|
|
22
22
|
};
|
|
23
23
|
function object(value) {
|
|
24
24
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
25
25
|
}
|
|
26
26
|
function observedIdentity(raw, configuredSerial) {
|
|
27
|
-
const
|
|
27
|
+
const recognizedModelFromModelId = typeof raw.model_id === "string" && raw.model_id.trim()
|
|
28
|
+
? normalizeModel(MODEL_IDS[raw.model_id.trim().toUpperCase()] ?? raw.model_id)
|
|
29
|
+
: undefined;
|
|
30
|
+
const declarations = [raw.model, raw.printer_type, raw.device?.devModel, raw.device?.dev_model]
|
|
28
31
|
.filter(value => typeof value === "string" && value.trim());
|
|
29
32
|
const models = declarations.map(value => normalizeModel(MODEL_IDS[value.trim().toUpperCase()] ?? value));
|
|
30
33
|
if (models.some(model => !model))
|
|
31
34
|
throw new Error("Printer reported an unknown model identity; cannot verify this printer.");
|
|
35
|
+
if (recognizedModelFromModelId)
|
|
36
|
+
models.unshift(recognizedModelFromModelId);
|
|
37
|
+
const hasReportedModel = models.length > 0;
|
|
32
38
|
const serials = [raw.serial, raw.serial_number, raw.sn, raw.device?.sn,
|
|
33
39
|
...(Array.isArray(raw.modules) ? raw.modules.filter((module) => module?.name === "ota").map((module) => module.sn) : [])]
|
|
34
40
|
.filter(value => typeof value === "string" && value.trim()).map(value => value.trim());
|
|
@@ -43,7 +49,7 @@ function observedIdentity(raw, configuredSerial) {
|
|
|
43
49
|
if (new Set(models).size > 1)
|
|
44
50
|
throw new Error("Printer returned contradictory model identity information.");
|
|
45
51
|
const model = models[0];
|
|
46
|
-
return { model, observedSerial: serials[0], identitySource:
|
|
52
|
+
return { model, observedSerial: serials[0], identitySource: hasReportedModel ? "report" : "module-serial" };
|
|
47
53
|
}
|
|
48
54
|
/** Only raw reports received after this request are evidence. Never read printer.data here. */
|
|
49
55
|
export function readFreshPrinterStatus(printer, serial, timeoutMs = 5000) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-3d-printer-server",
|
|
3
3
|
"mcpName": "io.github.DMontgomery40/mcp-3d-printer-server",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.13",
|
|
5
5
|
"description": "MCP server for connecting Claude with 3D printer management systems",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"repository": "https://github.com/DMontgomery40/mcp-3D-printer-server",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
83
83
|
"axios": "^1.20.0",
|
|
84
84
|
"bambu-node": "3.22.21",
|
|
85
|
-
"basic-ftp": "^6.2.
|
|
85
|
+
"basic-ftp": "^6.2.3",
|
|
86
86
|
"dotenv": "^16.6.1",
|
|
87
87
|
"form-data": "^4.0.6",
|
|
88
88
|
"jszip": "^3.10.2",
|
package/server.json
CHANGED
|
@@ -7,22 +7,49 @@
|
|
|
7
7
|
"url": "https://github.com/DMontgomery40/mcp-3D-printer-server",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "1.2.
|
|
10
|
+
"version": "1.2.13",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "mcp-3d-printer-server",
|
|
15
|
-
"version": "1.2.
|
|
15
|
+
"version": "1.2.13",
|
|
16
16
|
"runtimeHint": "npx",
|
|
17
|
-
"transport": {
|
|
17
|
+
"transport": {
|
|
18
|
+
"type": "stdio"
|
|
19
|
+
},
|
|
18
20
|
"environmentVariables": [
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
{
|
|
25
|
-
|
|
21
|
+
{
|
|
22
|
+
"name": "PRINTER_TYPE",
|
|
23
|
+
"description": "Printer backend: octoprint, klipper, duet, repetier, bambu, prusa, or creality.",
|
|
24
|
+
"isRequired": true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "PRINTER_HOST",
|
|
28
|
+
"description": "Printer IP address or hostname.",
|
|
29
|
+
"isRequired": true
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "PRINTER_PORT",
|
|
33
|
+
"description": "Optional backend-specific port."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "API_KEY",
|
|
37
|
+
"description": "API key for printer backends that require authentication.",
|
|
38
|
+
"isSecret": true
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "BAMBU_SERIAL",
|
|
42
|
+
"description": "Printer serial number; required for Bambu Lab."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "BAMBU_TOKEN",
|
|
46
|
+
"description": "LAN access code; required for Bambu Lab.",
|
|
47
|
+
"isSecret": true
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "BAMBU_MODEL",
|
|
51
|
+
"description": "Exact Bambu model; required for Bambu print operations."
|
|
52
|
+
}
|
|
26
53
|
]
|
|
27
54
|
}
|
|
28
55
|
]
|
package/src/safety/print-file.ts
CHANGED
|
@@ -70,7 +70,7 @@ function parameters(text: string, flags = ''): Map<string,number> {
|
|
|
70
70
|
return result;
|
|
71
71
|
}
|
|
72
72
|
// Layer comments only close a startup exception; they can never enable one.
|
|
73
|
-
const isLayerMarker=(line:string):boolean=>/^\s*;\s*(?:LAYER_CHANGE|CHANGE_LAYER|LAYER\s*:|layer num\/total_layer_count\s*:)/i.test(line);
|
|
73
|
+
const isLayerMarker=(line:string):boolean=>/^\s*;\s*(?:(?:LAYER_CHANGE|CHANGE_LAYER)(?:\s|$)|LAYER\s*:|layer num\/total_layer_count\s*:)/i.test(line);
|
|
74
74
|
function boundedX1ePurge(lines:string[],index:number):boolean {
|
|
75
75
|
// Official X1E common flush: 50 mm at 200 mm/min, followed immediately by
|
|
76
76
|
// lowering the target. No dwell, XY extrusion, repeated block or open-ended
|
|
@@ -154,11 +154,12 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
154
154
|
if(plateIndex!==0) return fail('raw G-code has only plate index 0');source=bytes.toString('utf8');
|
|
155
155
|
} else return fail('unsupported printable artifact; use a sliced .3mf or textual .gcode file');
|
|
156
156
|
if(/[\x00-\x08\x0b\x0c\x0e-\x1f\ufffd]/.test(source)) return fail('binary or malformed G-code is not supported');
|
|
157
|
-
const lines=source.split(/\r\n|\n|\r/);const
|
|
157
|
+
const lines=source.split(/\r\n|\n|\r/);const headers:Metadata[]=[];
|
|
158
158
|
for(const line of lines) {
|
|
159
159
|
const match=line.match(/^\s*;\s*([a-z_][a-z0-9_]*(?:\s+used\s+\[(?:g|mm)\])?)\s*(?:=|:)\s*(.*?)\s*$/i);
|
|
160
|
-
if(match)
|
|
160
|
+
if(match) headers.push({[match[1].toLowerCase()]:match[2]});
|
|
161
161
|
}
|
|
162
|
+
const metadata:Metadata[]=[project,...headers];
|
|
162
163
|
validateFffJob([...metadata,plate]);
|
|
163
164
|
const declaredModel=consistent(metadataValues(metadata,['printer_model']),normalizeModel,'printer model');
|
|
164
165
|
if(declaredModel!==model) return fail(`file model ${declaredModel} contradicts requested model ${model}`);
|
|
@@ -178,6 +179,17 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
178
179
|
const entries=list(value).map(entry=>entry.toLowerCase().replace(/[\s_-]+/g,'_'));
|
|
179
180
|
return entries.length && entries.every(Boolean)?entries:undefined;
|
|
180
181
|
};
|
|
182
|
+
// Bambu Studio writes printer_extruder_id/printer_extruder_variant to
|
|
183
|
+
// project_settings.config as the full per-variant table, but to the plate
|
|
184
|
+
// G-code header as the per-physical-extruder selection it sliced with (for
|
|
185
|
+
// example ["1","1","1"] vs "1" on a single-nozzle P2S). Preserve the source:
|
|
186
|
+
// a plate header can never substitute a full table for its selected values.
|
|
187
|
+
const variantTable=<T>(key:string,rows:number,convert:(value:unknown)=>T[]|undefined):{table:T[];selected?:T[]}=>{
|
|
188
|
+
const field=key.replace(/_/g,' ');const tableValues=metadataValues([project],[key]);const selectedValues=metadataValues(headers,[key]);
|
|
189
|
+
if(tableValues.some(value=>list(value).length!==rows) || selectedValues.some(value=>list(value).length!==nozzleDiameters.length)) return fail(`unknown or malformed ${field} metadata`);
|
|
190
|
+
const table=consistent(tableValues,convert,field);
|
|
191
|
+
return {table,selected:selectedValues.length?consistent(selectedValues,convert,`selected ${field}`):undefined};
|
|
192
|
+
};
|
|
181
193
|
const nozzleMetadata=(keys:string[],variantRows=false):string[]|undefined=>{
|
|
182
194
|
const values=metadataValues(metadata,keys);
|
|
183
195
|
return values.length ? consistent(values,value=>{
|
|
@@ -188,18 +200,27 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
188
200
|
// Bambu GUI projects may store nozzle types per extruder variant, while
|
|
189
201
|
// diameters and selected flow types are per physical extruder. Never truncate
|
|
190
202
|
// that variant table or mistake its rows for filament/nozzle positions.
|
|
191
|
-
const ids=
|
|
192
|
-
const ids=list(value);return ids.length
|
|
193
|
-
}
|
|
203
|
+
const {table:ids,selected:selectedIds}=variantTable('printer_extruder_id',entries.length,value=>{
|
|
204
|
+
const ids=list(value);return ids.length>0 && ids.every(id=>/^[1-9]\d*$/.test(id) && Number(id)<=nozzleDiameters.length)?ids.map(Number):undefined;
|
|
205
|
+
});
|
|
206
|
+
// The selected form names each physical extruder once, in order.
|
|
207
|
+
if(selectedIds && selectedIds.some((id,index)=>id!==index+1)) return undefined;
|
|
208
|
+
const variants=metadataValues(metadata,['printer_extruder_variant']).length
|
|
209
|
+
? variantTable('printer_extruder_variant',entries.length,normalizedEntries):undefined;
|
|
210
|
+
const extruders=variants ? consistent(metadataValues(metadata,['extruder_type']),normalizedEntries,'extruder type'):undefined;
|
|
194
211
|
const resolved:string[]=[];
|
|
195
212
|
for(let index=0;index<nozzleDiameters.length;index++) {
|
|
196
213
|
const rows=ids.flatMap((id,row)=>id===index+1?[row]:[]);
|
|
197
214
|
if(!rows.length) return undefined;
|
|
198
|
-
if(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
if(!variants) {
|
|
216
|
+
if(!rows.every(row=>entries[row]===entries[rows[0]])) return undefined;
|
|
217
|
+
resolved.push(entries[rows[0]]);continue;
|
|
218
|
+
}
|
|
219
|
+
// Validate declared variants even when all rows use the same material.
|
|
220
|
+
if(!extruders || extruders.length!==nozzleDiameters.length || !nozzleFlows) return undefined;
|
|
221
|
+
const variant=`${extruders[index]}_${nozzleFlows[index]}`;
|
|
222
|
+
if(variants.selected && variants.selected[index]!==variant) return undefined;
|
|
223
|
+
const selected=rows.filter(row=>variants.table[row]===variant);
|
|
203
224
|
if(selected.length!==1) return undefined;
|
|
204
225
|
resolved.push(entries[selected[0]]);
|
|
205
226
|
}
|
|
@@ -228,6 +249,10 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
228
249
|
let nozzleTarget=0;let selectsAms=false;let pending: number|undefined;let previous: number|undefined;
|
|
229
250
|
let maxNozzleTemperature=0,maxBedTemperature=0,maxChamberTemperature=0,commandCount=0;
|
|
230
251
|
let depositionStarted=false,commonFlushUsed=false;
|
|
252
|
+
// Bound the H2C vendor forms to its one explicit executable startup block.
|
|
253
|
+
const startupStarts=lines.flatMap((line,index)=>/^\s*;\s*EXECUTABLE_BLOCK_START\s*$/i.test(line)?[index]:[]);
|
|
254
|
+
const startupEnds=lines.flatMap((line,index)=>/^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(line)?[index]:[]);
|
|
255
|
+
const h2cStartup=(index:number)=>model==='h2c' && !depositionStarted && startupStarts.length===1 && startupEnds.length===1 && index>startupStarts[0] && index<startupEnds[0];
|
|
231
256
|
const heat=(component:'nozzle'|'bed'|'chamber',value:number,position?:number,allMaterials=false,startupPurge=false)=>{
|
|
232
257
|
const affected=allMaterials || position===undefined ? materials.map((_,index)=>index) : [requirePosition(position)];
|
|
233
258
|
if(component==='nozzle' && startupPurge) validateStartupPurgeTemperature(value,model,affected.map(index=>materials[index]));
|
|
@@ -246,8 +271,10 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
246
271
|
};
|
|
247
272
|
for(let index=0;index<lines.length;index++) {
|
|
248
273
|
try {
|
|
249
|
-
if(isLayerMarker(lines[index])) closeStartupWindow();
|
|
274
|
+
if(isLayerMarker(lines[index]) || /^\s*;\s*MACHINE_START_GCODE_END\s*$/i.test(lines[index])) closeStartupWindow();
|
|
250
275
|
let line=stripComments(lines[index]);if(!line||line==='%') continue;
|
|
276
|
+
// The official H2C template emits this literal separator without a semicolon.
|
|
277
|
+
if(line==='========== record data ==========' && h2cStartup(index)) continue;
|
|
251
278
|
line=line.replace(/^N\d+\s*/i,'').replace(/\*\d+\s*$/,'');
|
|
252
279
|
if(/[{}\[\]#]/.test(line)) fail('unresolved dynamic command syntax');
|
|
253
280
|
if(/^SYNC(?=$|\s)/i.test(line)) {
|
|
@@ -275,6 +302,30 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
275
302
|
}
|
|
276
303
|
if(['M104','M109','M140','M190','M141','M191'].includes(code)) {
|
|
277
304
|
const args=parameters(argumentsText,'A');
|
|
305
|
+
// Official A1 and A1 mini start G-code waits with M109 S<target> H<limit>
|
|
306
|
+
// (S25 H140, S100 H170, S220 H300). H is not a heater target: S stays the
|
|
307
|
+
// checked target, so H is accepted only within those vendor bounds.
|
|
308
|
+
if(args.has('O')) {
|
|
309
|
+
// H2C's exact all-hotend preheat offset is relative to the declared
|
|
310
|
+
// first-layer targets. Check every base and derived target, never use
|
|
311
|
+
// editable maxima or an unchecked offset as thermal evidence.
|
|
312
|
+
if(code!=='M104' || !h2cStartup(index) || args.size!==2 || args.get('O')!==-80 || !Number.isNaN(args.get('A')!)) fail('unsupported H2C startup offset parameters');
|
|
313
|
+
const targets=consistent(metadataValues(metadata,['nozzle_temperature_initial_layer']),value=>{
|
|
314
|
+
const values=list(value);return values.length===materials.length && values.every(v=>NUMBER.test(v) && Number.isFinite(Number(v)))?values.map(Number):undefined;
|
|
315
|
+
},'first-layer nozzle temperature');
|
|
316
|
+
for(let position=0;position<targets.length;position++) {
|
|
317
|
+
validateTemperature('nozzle',targets[position],model,[materials[position]]);
|
|
318
|
+
const preheat=Math.max(0,targets[position]-80);
|
|
319
|
+
validateTemperature('nozzle',preheat,model,[materials[position]]);
|
|
320
|
+
nozzleTarget=Math.max(nozzleTarget,preheat);
|
|
321
|
+
maxNozzleTemperature=Math.max(maxNozzleTemperature,preheat);
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if(args.has('D')) {
|
|
326
|
+
if(!['M140','M190'].includes(code) || !h2cStartup(index) || args.size!==1) fail('unsupported H2C startup bed parameters');
|
|
327
|
+
heat('bed',args.get('D')!,undefined,true);continue;
|
|
328
|
+
}
|
|
278
329
|
const vendorWait=code==='M109' && ['a1','a1mini'].includes(model);
|
|
279
330
|
for(const key of args.keys()) if(!'SRTA'.includes(key) && !(key==='H' && vendorWait)) fail(`unsupported ${code} temperature parameter ${key}`);
|
|
280
331
|
if(args.has('H') && (!Number.isFinite(args.get('H')) || args.get('H')!<0 || args.get('H')!>300)) fail(`unsupported ${code} wait parameter H`);
|
|
@@ -288,12 +339,21 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
288
339
|
if(commonFlush) commonFlushUsed=true;
|
|
289
340
|
for(const key of ['S','R']) if(args.has(key)) heat(component,args.get(key)!,position,args.has('T') || args.has('A'),commonFlush);
|
|
290
341
|
} else if(code==='M620' || code==='M621') {
|
|
291
|
-
const args=parameters(argumentsText,'MA');
|
|
342
|
+
const args=parameters(argumentsText,model==='x2d'?'MAB':model==='h2c'?'MAN':'MA');
|
|
343
|
+
if(args.has('N') && (code!=='M620' || !h2cStartup(index) || args.size!==1 || !Number.isNaN(args.get('N')!))) fail('unsupported H2C startup hotend-remap flag');
|
|
344
|
+
// X2D templates use B as a bare material-switch flag, never a target.
|
|
345
|
+
if(args.has('B') && (model!=='x2d' || !Number.isNaN(args.get('B')!))) fail('unsupported material-switch B flag');
|
|
292
346
|
if(args.has('S')) {
|
|
293
347
|
const position=args.get('S')!;
|
|
294
348
|
if([254,255,65279,65535].includes(position)) {if(code==='M621') {previous=active;active=undefined;}pending=undefined;}
|
|
295
349
|
else {selectsAms=true;requirePosition(position);if(code==='M620') pending=position;else {validateTemperature('nozzle',nozzleTarget,model,[materials[position]]);previous=active;active=position;pending=undefined;}}
|
|
296
350
|
}
|
|
351
|
+
} else if(code==='M620.22') {
|
|
352
|
+
// Official X2D runout auto-purge enables the declared project filament.
|
|
353
|
+
// It supplies no heater target; all explicit heat still uses the shared gate.
|
|
354
|
+
const args=parameters(argumentsText);
|
|
355
|
+
if(model!=='x2d' || args.size!==2 || !args.has('I') || args.get('P')!==1) fail('unsupported M620.22 runout purge parameters');
|
|
356
|
+
requirePosition(args.get('I')!);
|
|
297
357
|
} else if(code==='M620.1' || code==='M620.10') {
|
|
298
358
|
const args=parameters(argumentsText,'E');
|
|
299
359
|
const position=code==='M620.10' && args.get('A')===1 ? pending ?? active : code==='M620.10' && args.get('A')===0 ? active : undefined;
|
|
@@ -306,10 +366,22 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
306
366
|
if(args.has('T')) heat('nozzle',args.get('T')!,active);
|
|
307
367
|
} else if(/^G150\./.test(code) && /T/i.test(argumentsText)) {
|
|
308
368
|
fail(`unsupported thermal-affecting wipe command ${code}`);
|
|
369
|
+
} else if(code==='M620.14') {
|
|
370
|
+
const args=parameters(argumentsText);
|
|
371
|
+
// H2C tower coordinates include the rear wipe area outside object bounds.
|
|
372
|
+
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)) fail('unsupported H2C startup tower parameters');
|
|
373
|
+
} else if(model==='h2c' && ['G383','G383.3','G383.7'].includes(code) && /U/i.test(argumentsText)) {
|
|
374
|
+
const args=parameters(argumentsText);
|
|
375
|
+
const expected=code==='G383.7'?'UJ':code==='G383.3'?'UL':'OUL';
|
|
376
|
+
if(!h2cStartup(index) || args.size!==expected.length || [...args.keys()].some(key=>!expected.includes(key)) || args.get('U')!==140 ||
|
|
377
|
+
(code==='G383.7' && args.get('J')!==0) || (code==='G383' && args.get('O')!==2)) fail('unsupported H2C startup probing parameters');
|
|
378
|
+
const position=args.has('L')?requirePosition(args.get('L')!):active;
|
|
379
|
+
heat('nozzle',args.get('U')!,position);
|
|
309
380
|
} else if(code==='G383' || code==='G383.3') {
|
|
310
381
|
// Bambu probing routines carry nozzle temperature in T and optionally
|
|
311
382
|
// the project filament position in L (including the H2D G383.3 form).
|
|
312
383
|
const args=parameters(argumentsText);
|
|
384
|
+
if(args.has('U')) fail('unsupported G383 temperature parameter U outside H2C startup');
|
|
313
385
|
if(code==='G383.3' && (!args.has('T') || [...args.keys()].some(key=>!'TL'.includes(key)))) fail('unsupported G383.3 temperature parameters');
|
|
314
386
|
const position=args.has('L')?requirePosition(args.get('L')!):active;
|
|
315
387
|
if(args.has('T')) heat('nozzle',args.get('T')!,position);
|
|
@@ -338,8 +410,11 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
|
|
|
338
410
|
} else if((/^M620\./.test(code) && !['M620.3','M620.6','M620.11'].includes(code)) || /^M621\./.test(code)) {
|
|
339
411
|
fail(`unsupported thermal-affecting filament command ${code}`);
|
|
340
412
|
} else if(code==='M145') {
|
|
413
|
+
// Airduct mode switch (P0 cooling, P1 heating) in the official H2-series,
|
|
414
|
+
// X2D and P2S start/end G-code; it sets no heater target. Other forms and
|
|
415
|
+
// models without a switchable airduct stay rejected.
|
|
341
416
|
const args=parameters(argumentsText);
|
|
342
|
-
if(args.size!==1 || !args.has('P') || ![0,1].includes(args.get('P')!) || !['h2d','h2dpro','h2c','h2s','x2d'].includes(model)) fail('unsupported thermal-affecting M145 parameters');
|
|
417
|
+
if(args.size!==1 || !args.has('P') || ![0,1].includes(args.get('P')!) || !['h2d','h2dpro','h2c','h2s','x2d','p2s'].includes(model)) fail('unsupported thermal-affecting M145 parameters');
|
|
343
418
|
} else if(code==='M142') {
|
|
344
419
|
// Bambu chamber fan regulation thresholds, not an active heater command.
|
|
345
420
|
const args=parameters(argumentsText);for(const key of ['S','R']) if(args.has(key) && (!Number.isFinite(args.get(key)) || args.get(key)!<0 || args.get(key)!>65)) fail('unsupported chamber fan temperature threshold');
|
|
@@ -20,7 +20,7 @@ const MODEL_IDS: Record<string, string> = {
|
|
|
20
20
|
N2S: "A1", A1M: "A1 Mini", C11: "P1P", C12: "P1S", C13: "X1E", "BL-P001": "X1C", "BL-P002": "X1",
|
|
21
21
|
};
|
|
22
22
|
const SERIAL_MODELS: Record<string, string> = {
|
|
23
|
-
"093": "H2S", "094": "H2D", "
|
|
23
|
+
"093": "H2S", "094": "H2D", "31B": "H2C", "239": "H2D Pro", "20P": "X2D",
|
|
24
24
|
"00M": "X1C", "00W": "X1", "03W": "X1E", "01S": "P1P", "01P": "P1S", "22E": "P2S", "039": "A1", "030": "A1 Mini",
|
|
25
25
|
};
|
|
26
26
|
|
|
@@ -28,10 +28,15 @@ function object(value: unknown): value is Record<string, any> {
|
|
|
28
28
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
29
29
|
}
|
|
30
30
|
function observedIdentity(raw: Record<string, any>, configuredSerial: string) {
|
|
31
|
-
const
|
|
31
|
+
const recognizedModelFromModelId = typeof raw.model_id === "string" && raw.model_id.trim()
|
|
32
|
+
? normalizeModel(MODEL_IDS[raw.model_id.trim().toUpperCase()] ?? raw.model_id)
|
|
33
|
+
: undefined;
|
|
34
|
+
const declarations = [raw.model, raw.printer_type, raw.device?.devModel, raw.device?.dev_model]
|
|
32
35
|
.filter(value => typeof value === "string" && value.trim());
|
|
33
36
|
const models = declarations.map(value => normalizeModel(MODEL_IDS[value.trim().toUpperCase()] ?? value));
|
|
34
37
|
if (models.some(model => !model)) throw new Error("Printer reported an unknown model identity; cannot verify this printer.");
|
|
38
|
+
if (recognizedModelFromModelId) models.unshift(recognizedModelFromModelId);
|
|
39
|
+
const hasReportedModel = models.length > 0;
|
|
35
40
|
const serials = [raw.serial, raw.serial_number, raw.sn, raw.device?.sn,
|
|
36
41
|
...(Array.isArray(raw.modules) ? raw.modules.filter((module: any) => module?.name === "ota").map((module: any) => module.sn) : [])]
|
|
37
42
|
.filter(value => typeof value === "string" && value.trim()).map(value => value.trim());
|
|
@@ -44,7 +49,7 @@ function observedIdentity(raw: Record<string, any>, configuredSerial: string) {
|
|
|
44
49
|
}
|
|
45
50
|
if (new Set(models).size > 1) throw new Error("Printer returned contradictory model identity information.");
|
|
46
51
|
const model = models[0];
|
|
47
|
-
return { model, observedSerial: serials[0], identitySource:
|
|
52
|
+
return { model, observedSerial: serials[0], identitySource: hasReportedModel ? "report" : "module-serial" };
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
/** Only raw reports received after this request are evidence. Never read printer.data here. */
|