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.
@@ -0,0 +1,181 @@
1
+ import { open } from 'node:fs/promises';
2
+ import { promisify, TextDecoder } from 'node:util';
3
+ import { inflateRaw } from 'node:zlib';
4
+ import JSZip from 'jszip';
5
+
6
+ // Fixed inspection limits, not caller-overridable printer safety options.
7
+ export const MAX_PRINT_FILE_BYTES = 256 * 1024 * 1024;
8
+ const MAX_ENTRY_BYTES = 256 * 1024 * 1024;
9
+ const MAX_TOTAL_BYTES = 512 * 1024 * 1024;
10
+ const inflate = promisify(inflateRaw);
11
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
12
+ const fail = (message: string): never => { throw new Error(`Print archive safety: ${message}`); };
13
+ const crcTable = Uint32Array.from({ length: 256 }, (_, index) => {
14
+ let value = index;
15
+ for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
16
+ return value >>> 0;
17
+ });
18
+ function crc32(bytes: Buffer): number {
19
+ let crc = 0xffffffff;
20
+ for (const byte of bytes) crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 0xff];
21
+ return (crc ^ 0xffffffff) >>> 0;
22
+ }
23
+
24
+ /** Read no more than the fixed print-artifact limit, even if the file grows. */
25
+ export async function readBoundedPrintFile(filePath: string): Promise<Buffer> {
26
+ const file = await open(filePath, 'r');
27
+ try {
28
+ const stat = await file.stat();
29
+ if (!stat.isFile()) return fail('print artifact must be a regular file');
30
+ if (stat.size > MAX_PRINT_FILE_BYTES) return fail('compressed archive or G-code file size exceeds the 256 MiB inspection limit');
31
+ const bytes = Buffer.alloc(stat.size);
32
+ let read = 0;
33
+ while (read < bytes.length) {
34
+ const result = await file.read(bytes, read, bytes.length - read, read);
35
+ if (!result.bytesRead) return fail('file changed while being read');
36
+ read += result.bytesRead;
37
+ }
38
+ if ((await file.read(Buffer.alloc(1), 0, 1, read)).bytesRead) return fail('file size changed while being read');
39
+ return bytes;
40
+ } finally { await file.close(); }
41
+ }
42
+
43
+ interface Entry {
44
+ name: string;
45
+ rawName: Buffer;
46
+ flags: number;
47
+ method: number;
48
+ crc: number;
49
+ compressed: number;
50
+ uncompressed: number;
51
+ local: number;
52
+ data?: Buffer;
53
+ }
54
+ function decodeName(raw: Buffer): string {
55
+ let name: string;
56
+ try { name = utf8.decode(raw); } catch { return fail('entry name is not valid UTF-8'); }
57
+ const segments = name.replace(/\/$/, '').split('/');
58
+ if (!name || /[\\\x00-\x1f]/.test(name) || segments.some(part => !part || part === '.' || part === '..')) {
59
+ return fail('ambiguous archive entry path');
60
+ }
61
+ return name;
62
+ }
63
+ function checkExtra(extra: Buffer, rawName: Buffer, name: string): void {
64
+ let offset = 0;
65
+ while (offset < extra.length) {
66
+ if (offset + 4 > extra.length) return fail('malformed ZIP extra field');
67
+ const kind = extra.readUInt16LE(offset);
68
+ const length = extra.readUInt16LE(offset + 2);
69
+ offset += 4;
70
+ if (offset + length > extra.length) return fail('malformed ZIP extra field length');
71
+ if (kind === 0x0001) return fail('ZIP64 archives are unsupported for verified printing');
72
+ if (kind === 0x7075) {
73
+ const field = extra.subarray(offset, offset + length);
74
+ if (length < 5 || field[0] !== 1 || field.readUInt32LE(1) !== crc32(rawName) || decodeName(field.subarray(5)) !== name) {
75
+ return fail('Unicode path metadata disagrees with the archive entry name');
76
+ }
77
+ }
78
+ offset += length;
79
+ }
80
+ }
81
+
82
+ /** Parse every central and local record before decompression. Competing names and
83
+ * orphan local records could cause the firmware to execute a different plate. */
84
+ function parseEntries(bytes: Buffer): Entry[] {
85
+ if (bytes.length > MAX_PRINT_FILE_BYTES) return fail('compressed archive size exceeds the 256 MiB inspection limit');
86
+ let end = -1;
87
+ for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) {
88
+ if (bytes.readUInt32LE(i) === 0x06054b50 && i + 22 + bytes.readUInt16LE(i + 20) === bytes.length) { end = i; break; }
89
+ }
90
+ if (end < 0) return fail('missing or malformed ZIP central-directory end record');
91
+ const count = bytes.readUInt16LE(end + 10);
92
+ const size = bytes.readUInt32LE(end + 12);
93
+ const start = bytes.readUInt32LE(end + 16);
94
+ if (count === 0xffff || size === 0xffffffff || start === 0xffffffff || (end >= 20 && bytes.readUInt32LE(end - 20) === 0x07064b50)) {
95
+ return fail('ZIP64 archives are unsupported for verified printing');
96
+ }
97
+ if (bytes.readUInt16LE(end + 4) || bytes.readUInt16LE(end + 6) || bytes.readUInt16LE(end + 8) !== count) return fail('multi-disk ZIP archives are unsupported');
98
+ if (start + size !== end) return fail('ambiguous ZIP central-directory extent');
99
+ const entries: Entry[] = [];
100
+ const names = new Set<string>();
101
+ const foldedNames = new Set<string>();
102
+ let offset = start;
103
+ let total = 0;
104
+ for (let i = 0; i < count; i++) {
105
+ if (offset + 46 > end || bytes.readUInt32LE(offset) !== 0x02014b50) return fail('malformed ZIP central-directory entry');
106
+ const nameLength = bytes.readUInt16LE(offset + 28);
107
+ const extraLength = bytes.readUInt16LE(offset + 30);
108
+ const commentLength = bytes.readUInt16LE(offset + 32);
109
+ const next = offset + 46 + nameLength + extraLength + commentLength;
110
+ if (next > end) return fail('truncated ZIP central-directory entry');
111
+ const rawName = bytes.subarray(offset + 46, offset + 46 + nameLength);
112
+ const name = decodeName(rawName);
113
+ if (names.has(name)) return fail(`duplicate archive entry '${name}'`);
114
+ if (foldedNames.has(name.toLowerCase())) return fail(`case-colliding archive entry '${name}'`);
115
+ names.add(name); foldedNames.add(name.toLowerCase());
116
+ const entry: Entry = {
117
+ name, rawName, flags: bytes.readUInt16LE(offset + 8), method: bytes.readUInt16LE(offset + 10),
118
+ crc: bytes.readUInt32LE(offset + 16), compressed: bytes.readUInt32LE(offset + 20),
119
+ uncompressed: bytes.readUInt32LE(offset + 24), local: bytes.readUInt32LE(offset + 42),
120
+ };
121
+ if ([entry.compressed, entry.uncompressed, entry.local].includes(0xffffffff)) return fail('ZIP64 archives are unsupported for verified printing');
122
+ if (bytes.readUInt16LE(offset + 34)) return fail('multi-disk ZIP entries are unsupported');
123
+ if (entry.flags & ~0x080e || ![0, 8].includes(entry.method)) return fail('encrypted or unsupported ZIP entry encoding');
124
+ if (entry.uncompressed > MAX_ENTRY_BYTES) return fail(`entry size for '${name}' exceeds the 256 MiB inspection limit`);
125
+ total += entry.uncompressed;
126
+ if (total > MAX_TOTAL_BYTES) return fail('total uncompressed archive size exceeds the 512 MiB inspection limit');
127
+ if (name.endsWith('/') && (entry.compressed || entry.uncompressed)) return fail('directory entry contains hidden file data');
128
+ checkExtra(bytes.subarray(offset + 46 + nameLength, offset + 46 + nameLength + extraLength), rawName, name);
129
+ entries.push(entry); offset = next;
130
+ }
131
+ if (offset !== end) return fail('unaccounted central-directory entries');
132
+ let cursor = 0;
133
+ for (const entry of [...entries].sort((a, b) => a.local - b.local)) {
134
+ if (entry.local !== cursor) return fail('unreferenced, overlapping, or ambiguous local ZIP entries');
135
+ if (cursor + 30 > start || bytes.readUInt32LE(cursor) !== 0x04034b50) return fail('malformed local ZIP entry');
136
+ const nameLength = bytes.readUInt16LE(cursor + 26);
137
+ const extraLength = bytes.readUInt16LE(cursor + 28);
138
+ const dataStart = cursor + 30 + nameLength + extraLength;
139
+ const dataEnd = dataStart + entry.compressed;
140
+ if (dataEnd > start) return fail('local entry extends into the central directory');
141
+ if (bytes.readUInt16LE(cursor + 6) !== entry.flags || bytes.readUInt16LE(cursor + 8) !== entry.method || !bytes.subarray(cursor + 30, cursor + 30 + nameLength).equals(entry.rawName)) {
142
+ return fail('local and central ZIP entry names or encoding disagree');
143
+ }
144
+ checkExtra(bytes.subarray(cursor + 30 + nameLength, dataStart), entry.rawName, entry.name);
145
+ const localFields = [bytes.readUInt32LE(cursor + 14), bytes.readUInt32LE(cursor + 18), bytes.readUInt32LE(cursor + 22)];
146
+ const expected = [entry.crc, entry.compressed, entry.uncompressed];
147
+ if (localFields.some((value, i) => value !== expected[i] && (!(entry.flags & 8) || value !== 0))) return fail('local and central ZIP entry sizes or CRC disagree');
148
+ entry.data = bytes.subarray(dataStart, dataEnd);
149
+ cursor = dataEnd;
150
+ if (entry.flags & 8) {
151
+ if (cursor + 12 > start) return fail('missing ZIP data descriptor');
152
+ if (bytes.readUInt32LE(cursor) === 0x08074b50) cursor += 4;
153
+ if (cursor + 12 > start || expected.some((value, i) => bytes.readUInt32LE(cursor + i * 4) !== value)) return fail('ZIP data descriptor disagrees with central entry');
154
+ cursor += 12;
155
+ }
156
+ }
157
+ if (cursor !== start) return fail('unreferenced local ZIP data before the central directory');
158
+ return entries;
159
+ }
160
+
161
+ /** Bounded native inflation prevents false size fields from making JSZip allocate
162
+ * unbounded output. Populate JSZip only with the verified, uniquely named bytes. */
163
+ export async function loadSafe3mfArchive(bytes: Buffer): Promise<JSZip> {
164
+ const entries = parseEntries(bytes);
165
+ const zip = new JSZip();
166
+ for (const entry of entries) {
167
+ let data: Buffer;
168
+ try {
169
+ data = entry.method === 0 ? entry.data! : await inflate(entry.data!, { maxOutputLength: Math.max(1, entry.uncompressed) });
170
+ } catch { return fail(`entry inflation exceeds its declared size or is corrupt: '${entry.name}'`); }
171
+ if (data.length !== entry.uncompressed) return fail(`uncompressed entry size mismatch for '${entry.name}'`);
172
+ if (crc32(data) !== entry.crc) return fail(`entry CRC mismatch for '${entry.name}'`);
173
+ zip.file(entry.name, data, { dir: entry.name.endsWith('/'), createFolders: false });
174
+ }
175
+ return zip;
176
+ }
177
+
178
+ export async function readSafe3mfArchive(filePath: string): Promise<{ bytes: Buffer; zip: JSZip }> {
179
+ const bytes = await readBoundedPrintFile(filePath);
180
+ return { bytes, zip: await loadSafe3mfArchive(bytes) };
181
+ }
@@ -0,0 +1,58 @@
1
+ import fs from "node:fs/promises";
2
+ import { rmSync } from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ // Serialise mutations from this server for a printer. Stop/cancel remain available.
8
+ const operations = new Map<string, Promise<unknown>>();
9
+ const generations = new Map<string, number>();
10
+ export function cancelPendingPrinterOperations(_host: string, serial: string): void {
11
+ generations.set(serial, (generations.get(serial) ?? 0) + 1);
12
+ }
13
+ const retained = new Set<string>();
14
+ process.once("exit", () => {
15
+ for (const directory of retained) {
16
+ try { rmSync(directory, { recursive: true, force: true }); } catch { /* OS temp cleanup remains available. */ }
17
+ }
18
+ });
19
+ export async function withPrinterOperation<T>(_host: string, serial: string, operation: (assertActive: () => void) => Promise<T>): Promise<T> {
20
+ const key = serial;
21
+ const generation = generations.get(key) ?? 0;
22
+ const assertActive = () => {
23
+ if ((generations.get(key) ?? 0) !== generation) throw new Error("Pending printer operation cancelled by stop or heater-off request.");
24
+ };
25
+ const previous = operations.get(key) ?? Promise.resolve();
26
+ const next = previous.catch(() => undefined).then(() => { assertActive(); return operation(assertActive); });
27
+ operations.set(key, next);
28
+ try { return await next; }
29
+ finally { if (operations.get(key) === next) operations.delete(key); }
30
+ }
31
+
32
+ /** Inspect and send a private copy, so changes to the user's source cannot change the job. */
33
+ export async function withPrintSnapshot<T>(source: string, operation: (snapshot: string, retainForDispatchedUpload: () => void) => Promise<T>): Promise<T> {
34
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bambu-print-"));
35
+ const snapshot = path.join(directory, path.basename(source));
36
+ let retainUntilExit = false;
37
+ try {
38
+ await fs.copyFile(source, snapshot);
39
+ await fs.chmod(snapshot, 0o400);
40
+ return await operation(snapshot, () => { retainUntilExit = true; });
41
+ } finally {
42
+ // Native bridge APIs may return before their asynchronous upload completes.
43
+ if (retainUntilExit) retained.add(directory);
44
+ else await fs.rm(directory, { recursive: true, force: true });
45
+ }
46
+ }
47
+
48
+ export function uniquePrintName(filename: string): string {
49
+ return `checked-${randomUUID()}-${path.basename(filename)}`;
50
+ }
51
+
52
+ export function normalizedRemotePath(filename: string): string {
53
+ const normalized = filename.replace(/^\/+/, "");
54
+ if (!normalized || normalized.includes("\\") || normalized.split("/").some(part => !part || part === "." || part === "..") || /[\r\n\0]/.test(normalized)) {
55
+ throw new Error("Printer filename must be a relative path without traversal or control characters.");
56
+ }
57
+ return normalized.includes("/") ? normalized : `cache/${normalized}`;
58
+ }
@@ -0,0 +1,93 @@
1
+ /** Independent hardware ceilings. Editable slicer profiles never raise these limits.
2
+ * Manufacturer references (verified 2026-09-27):
3
+ * P1P: https://public-cdn.bambulab.com/store/bambulab-P1P-tech-specs.pdf
4
+ * P1S: https://cdn1.bambulab.com/documentation/quick-start-59b0cefdc0fc4/P1S/English%20version-Quick%20Start%20Guide%20for%20P1S.pdf
5
+ * A1: https://bambulab.com/en/a1/tech-specs
6
+ * A1 mini: https://us.store.bambulab.com/products/a1-mini
7
+ * X1/X1C: https://cdn1.bambulab.com/documentation/Quick%20Start%20Guide%20for%20X1%20Combo%26X1-Carbon%20Combo-v1.pdf
8
+ * X1E: https://cdn1.bambulab.com/x1e/spec/Bambu%20Lab%20X1E%20Technical%20Specification.pdf
9
+ * H2D: https://cdn1.bambulab.com/documentation/h2d/en/H2D_Laser_Full_Combo_20250305.pdf
10
+ * H2D Pro: https://bambulab.com/en/h2d-pro/tech-specs
11
+ * H2C: https://csm.bblcdn.com/hub/eca403f48aee405393afc97adbbf6422.pdf
12
+ * H2S: https://bambulab.com/it/support/buying-guide
13
+ * P2S: https://blog.bambulab.com/the-icon-redefined-meet-the-p2s-a-completely-reengineered-version-of-the-ultra-productive-p1-series/
14
+ * X2D: https://csm.bblcdn.com/hub/7c58718aaa2e40edab56efb87419a96a.pdf
15
+ * X1-family bed caps use 110 C: 120 C requires verified 110 V supply, unavailable here.
16
+ * A zero chamber cap means no supported active chamber heater, not ambient temperature.
17
+ */
18
+ export const MACHINE_LIMITS: Readonly<Record<string, {nozzle: number; bed: number; chamber: number; volume: readonly number[]}>> = {
19
+ p1p: {nozzle:300,bed:100,chamber:0,volume:[256,256,256]},
20
+ p1s: {nozzle:300,bed:100,chamber:0,volume:[256,256,256]},
21
+ p2s: {nozzle:300,bed:110,chamber:0,volume:[256,256,256]},
22
+ a1: {nozzle:300,bed:100,chamber:0,volume:[256,256,256]},
23
+ a1mini: {nozzle:300,bed:80,chamber:0,volume:[180,180,180]},
24
+ x1: {nozzle:300,bed:110,chamber:0,volume:[256,256,256]},
25
+ x1c: {nozzle:300,bed:110,chamber:0,volume:[256,256,256]},
26
+ x1e: {nozzle:320,bed:110,chamber:60,volume:[256,256,256]},
27
+ h2d: {nozzle:350,bed:120,chamber:65,volume:[350,320,325]},
28
+ h2dpro: {nozzle:350,bed:120,chamber:65,volume:[350,320,325]},
29
+ h2c: {nozzle:350,bed:120,chamber:65,volume:[330,320,325]},
30
+ h2s: {nozzle:350,bed:120,chamber:65,volume:[340,320,340]},
31
+ x2d: {nozzle:300,bed:120,chamber:65,volume:[256,256,260]},
32
+ };
33
+
34
+ export function normalizeModel(value: unknown): string | undefined {
35
+ if (typeof value !== 'string') return undefined;
36
+ let model = value.trim().toLowerCase().replace(/^bambu\s*(?:lab\s*)?/, '').replace(/[\s_-]/g, '');
37
+ if (model === 'x1carbon') model = 'x1c';
38
+ return Object.prototype.hasOwnProperty.call(MACHINE_LIMITS, model) ? model : undefined;
39
+ }
40
+
41
+ /** Policy ceilings, NOT manufacturer-recommended print temperatures or decomposition
42
+ * thresholds. The normal PLA ceiling of 260 C includes Bambu PLA Aero's bundled
43
+ * range_high=260 (other bundled PLA ranges are lower). A separate 290 C startup
44
+ * allowance is only used by the inspector for the exact one-shot X1E common flush;
45
+ * it must never authorize manual heating or sustained deposition at that target.
46
+ * File/profile nozzle_temperature_range_high and RFID limits cannot raise this policy.
47
+ */
48
+ const MATERIAL_LIMITS: Readonly<Record<string, number>> = {
49
+ PLA:260, PETG:300, ABS:300, ASA:300, TPU:290, PVA:290, BVOH:290,
50
+ HIPS:300, PP:300, POM:250, PET:350, PA:350, PC:350, PPA:350, PPS:350,
51
+ 'SUPPORT-PLA':290, 'SUPPORT-PA':350,
52
+ };
53
+ export function normalizeMaterial(value: unknown): string | undefined {
54
+ if (typeof value !== 'string') return undefined;
55
+ const material = value.trim().toUpperCase().replace(/^BAMBU(?: LAB)?\s+/, '').replace(/^GENERIC\s+/, '');
56
+ if (material === 'SUPPORT-PLA' || material === 'SUPPORT-PA') return material;
57
+ if (/^SUPPORT(?: FOR)? (?:PLA(?:\/PETG)?|W)(?:\b|$)/.test(material)) return 'SUPPORT-PLA';
58
+ if (/^SUPPORT(?: FOR)? (?:PA(?:\/PET)?|G)(?:\b|$)/.test(material)) return 'SUPPORT-PA';
59
+ const match = material.match(/^(PPA|PPS|PETG|PLA|ABS|ASA|TPU|PVA|BVOH|HIPS|PP|POM|PET|PA(?:6|12|HT)?|PC)(?=$|[\s+_/-])/);
60
+ return match ? match[1].replace(/^PA(?:6|12|HT)$/, 'PA') : undefined;
61
+ }
62
+
63
+ function validate(component: 'nozzle'|'bed'|'chamber', value: unknown, model: string, materials: string[]|undefined, startupPurge=false): number {
64
+ if ((typeof value !== 'number' && (typeof value !== 'string' || !/^[+]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value.trim()))) || !Number.isFinite(Number(value)) || Number(value) < 0)
65
+ throw new Error(`${component} temperature must be a finite nonnegative number; received ${String(value)}`);
66
+ const temperature = Number(value);
67
+ // Switching a heater off needs no material or machine declaration.
68
+ if (temperature === 0) return 0;
69
+ const normalized = normalizeModel(model);
70
+ if (!normalized) throw new Error(`Unknown printer model '${model}'; cannot establish temperature limits`);
71
+ const maximum = MACHINE_LIMITS[normalized][component];
72
+ if (maximum === undefined || temperature > maximum) throw new Error(`${normalized} ${component} temperature ${temperature} C exceeds the ${maximum ?? 0} C hardware limit`);
73
+ if (component === 'nozzle') {
74
+ if (!materials?.length) throw new Error('Nozzle heating requires a declared material for every affected filament');
75
+ for (const declaration of materials) {
76
+ const material = normalizeMaterial(declaration);
77
+ if (!material) throw new Error(`Unknown material '${declaration}'; cannot authorize positive nozzle temperature`);
78
+ const ceiling = startupPurge && material === 'PLA' ? 290 : MATERIAL_LIMITS[material];
79
+ if (temperature > ceiling) throw new Error(`${material} nozzle temperature ${temperature} C exceeds the independent ${ceiling} C material policy limit`);
80
+ }
81
+ }
82
+ return temperature;
83
+ }
84
+
85
+ export function validateTemperature(component: 'nozzle'|'bed'|'chamber', value: unknown, model: string, materials?: string[]): number {
86
+ return validate(component,value,model,materials);
87
+ }
88
+
89
+ /** Inspector-only exception: the caller must have verified the bounded startup
90
+ * purge form and that no layer/deposition has begun. Never use for manual heat. */
91
+ export function validateStartupPurgeTemperature(value: unknown, model: string, materials: string[]): number {
92
+ return validate('nozzle',value,model,materials,true);
93
+ }