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.
- package/CHANGELOG.md +222 -0
- package/CONTRIBUTORS.md +56 -0
- package/README.md +528 -811
- package/dist/3mf_parser.js +8 -8
- package/dist/blender-mcp-bridge.d.ts +1 -0
- package/dist/blender-mcp-bridge.js +209 -21
- package/dist/fulu-orca.js +29 -5
- package/dist/index.js +614 -110
- package/dist/printers/bambu-node-guard.d.ts +2 -0
- package/dist/printers/bambu-node-guard.js +38 -0
- package/dist/printers/bambu.d.ts +53 -7
- package/dist/printers/bambu.js +420 -126
- package/dist/printers/creality.d.ts +15 -6
- package/dist/printers/creality.js +42 -9
- package/dist/printers/duet.d.ts +11 -6
- package/dist/printers/duet.js +57 -34
- package/dist/printers/generic-base.d.ts +69 -0
- package/dist/printers/generic-base.js +209 -0
- package/dist/printers/klipper.d.ts +17 -6
- package/dist/printers/klipper.js +63 -16
- package/dist/printers/octoprint.d.ts +18 -6
- package/dist/printers/octoprint.js +64 -9
- package/dist/printers/printer-factory.d.ts +11 -2
- package/dist/printers/printer-factory.js +9 -8
- package/dist/printers/prusa.d.ts +14 -6
- package/dist/printers/prusa.js +56 -9
- package/dist/printers/repetier.d.ts +18 -6
- package/dist/printers/repetier.js +48 -6
- package/dist/safety/archive.d.ts +11 -0
- package/dist/safety/archive.js +214 -0
- package/dist/safety/artifact.d.ts +6 -0
- package/dist/safety/artifact.js +66 -0
- package/dist/safety/confirmation.d.ts +22 -0
- package/dist/safety/confirmation.js +43 -0
- package/dist/safety/expected-peaks.d.ts +24 -0
- package/dist/safety/expected-peaks.js +24 -0
- package/dist/safety/gcode-thermal.d.ts +64 -0
- package/dist/safety/gcode-thermal.js +382 -0
- package/dist/safety/limits.d.ts +31 -0
- package/dist/safety/limits.js +105 -0
- package/dist/safety/print-file.d.ts +25 -0
- package/dist/safety/print-file.js +610 -0
- package/dist/safety/printer-state.d.ts +51 -0
- package/dist/safety/printer-state.js +371 -0
- package/dist/slicer/bambu-cli.d.ts +110 -0
- package/dist/slicer/bambu-cli.js +547 -0
- package/dist/slicer/profile-flatten.d.ts +104 -0
- package/dist/slicer/profile-flatten.js +676 -0
- package/dist/slicer/run-slicer.d.ts +18 -0
- package/dist/slicer/run-slicer.js +102 -0
- package/dist/slicer/slicer-error.d.ts +27 -0
- package/dist/slicer/slicer-error.js +53 -0
- package/dist/slicer/templates.d.ts +126 -0
- package/dist/slicer/templates.js +255 -0
- package/dist/stl/stl-manipulator.d.ts +25 -3
- package/dist/stl/stl-manipulator.js +214 -232
- package/package.json +16 -9
- package/server.json +56 -0
- package/src/3mf_parser.ts +8 -8
- package/src/blender-mcp-bridge.ts +200 -19
- package/src/fulu-orca.ts +36 -6
- package/src/index.ts +686 -137
- package/src/printers/bambu-node-guard.ts +44 -0
- package/src/printers/bambu.ts +458 -164
- package/src/printers/creality.ts +40 -9
- package/src/printers/duet.ts +64 -53
- package/src/printers/generic-base.ts +286 -0
- package/src/printers/klipper.ts +83 -28
- package/src/printers/octoprint.ts +74 -21
- package/src/printers/printer-factory.ts +21 -11
- package/src/printers/prusa.ts +53 -10
- package/src/printers/repetier.ts +55 -17
- package/src/safety/archive.ts +181 -0
- package/src/safety/artifact.ts +58 -0
- package/src/safety/confirmation.ts +56 -0
- package/src/safety/expected-peaks.ts +47 -0
- package/src/safety/gcode-thermal.ts +404 -0
- package/src/safety/limits.ts +98 -0
- package/src/safety/print-file.ts +429 -0
- package/src/safety/printer-state.ts +344 -0
- package/src/slicer/bambu-cli.ts +633 -0
- package/src/slicer/profile-flatten.ts +830 -0
- package/src/slicer/run-slicer.ts +126 -0
- package/src/slicer/slicer-error.ts +72 -0
- package/src/slicer/templates.ts +288 -0
- package/src/stl/stl-manipulator.ts +247 -275
|
@@ -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,56 @@
|
|
|
1
|
+
/** Human confirmation for physical printer mutations (print start, positive heating). */
|
|
2
|
+
export type ConfirmHardware = (message: string) => Promise<boolean>;
|
|
3
|
+
export type ConfirmationScope = "bambu" | "generic";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ordinary print/heat prompts are on by default. Only an explicit "0" opts out.
|
|
7
|
+
* PRINT_REQUIRE_CONFIRMATION applies to every printer type; the older
|
|
8
|
+
* BAMBU_REQUIRE_CONFIRMATION applies to Bambu printers only. If any variable
|
|
9
|
+
* relevant to the scope is set to another value, confirmation stays required.
|
|
10
|
+
*/
|
|
11
|
+
export function confirmationOptOut(scope: ConfirmationScope): boolean {
|
|
12
|
+
const names = scope === "bambu"
|
|
13
|
+
? ["BAMBU_REQUIRE_CONFIRMATION", "PRINT_REQUIRE_CONFIRMATION"]
|
|
14
|
+
: ["PRINT_REQUIRE_CONFIRMATION"];
|
|
15
|
+
const values = names
|
|
16
|
+
.map((name) => process.env[name]?.trim())
|
|
17
|
+
.filter((value): value is string => value !== undefined && value !== "");
|
|
18
|
+
return values.length > 0 && values.every((value) => value === "0");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How long a human has to answer a confirmation prompt. The MCP SDK's default
|
|
23
|
+
* request timeout (60 s) is too short for someone walking to the printer.
|
|
24
|
+
*/
|
|
25
|
+
export function confirmationTimeoutMs(): number {
|
|
26
|
+
const raw = process.env.PRINT_CONFIRMATION_TIMEOUT_MS?.trim();
|
|
27
|
+
const value = raw ? Number(raw) : 600_000;
|
|
28
|
+
return Number.isInteger(value) && value >= 1_000 && value <= 3_600_000 ? value : 600_000;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function confirmationTimedOut(timeoutMs: number): string {
|
|
32
|
+
const minutes = timeoutMs / 60_000;
|
|
33
|
+
return `No confirmation was received within ${minutes >= 1 ? `${Number(minutes.toFixed(1))} minute(s)` : `${timeoutMs / 1000} seconds`}; ` +
|
|
34
|
+
"nothing was sent to the printer. Ask again when you are at the printer, or raise PRINT_CONFIRMATION_TIMEOUT_MS.";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const CONFIRMATION_UNAVAILABLE =
|
|
38
|
+
"Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, " +
|
|
39
|
+
"PRINT_REQUIRE_CONFIRMATION=0 (or BAMBU_REQUIRE_CONFIRMATION=0 for Bambu printers) disables ordinary print/heat prompts; " +
|
|
40
|
+
"finished-bed clearance still requires elicitation. No command was sent.";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* physicalCheck marks confirmations that software cannot replace (for example a
|
|
44
|
+
* previous part left on the bed). Those are required even when prompts are opted out.
|
|
45
|
+
*/
|
|
46
|
+
export async function requireHumanConfirmation(
|
|
47
|
+
confirm: ConfirmHardware | undefined,
|
|
48
|
+
message: string,
|
|
49
|
+
scope: ConfirmationScope,
|
|
50
|
+
physicalCheck = false
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
if (!physicalCheck && confirmationOptOut(scope)) return;
|
|
53
|
+
if (!confirm || !(await confirm(message))) {
|
|
54
|
+
throw new Error("Hardware safety confirmation was declined or unavailable. Use an MCP client with elicitation support. No command was sent.");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface ExpectedPeakTemperatures {
|
|
2
|
+
/** Expected highest positive nozzle target (S and R forms, every tool). */
|
|
3
|
+
nozzle?: number;
|
|
4
|
+
/** Expected highest positive bed target (S and R forms). */
|
|
5
|
+
bed?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Caller-declared options for guarded print and heating mutations. */
|
|
9
|
+
export interface PrintSafetyOptions {
|
|
10
|
+
/** Bambu printer model (validated against the file and the live printer). */
|
|
11
|
+
bambuModel?: string;
|
|
12
|
+
/** Declared material for positive nozzle heating or G-code without slicer material metadata. */
|
|
13
|
+
material?: string;
|
|
14
|
+
/** Installed nozzle diameter for Bambu manual nozzle heating. */
|
|
15
|
+
nozzleDiameter?: number;
|
|
16
|
+
/** process_and_print_stl expectations; any mismatch refuses before upload. */
|
|
17
|
+
expectedPeaks?: ExpectedPeakTemperatures;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Validate caller expectations before any network use. */
|
|
21
|
+
export function validateExpectedPeaks(expected: ExpectedPeakTemperatures | undefined): void {
|
|
22
|
+
if (!expected) return;
|
|
23
|
+
for (const component of ["nozzle", "bed"] as const) {
|
|
24
|
+
const value = expected[component];
|
|
25
|
+
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value < 0)) {
|
|
26
|
+
throw new Error(`Expected ${component} temperature must be a finite nonnegative number.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A requested expected peak must equal the inspected peak; a mismatch stops before upload. */
|
|
32
|
+
export function assertExpectedPeaks(
|
|
33
|
+
expected: ExpectedPeakTemperatures | undefined,
|
|
34
|
+
actual: { nozzle: number; bed: number }
|
|
35
|
+
): void {
|
|
36
|
+
validateExpectedPeaks(expected);
|
|
37
|
+
if (!expected) return;
|
|
38
|
+
for (const component of ["nozzle", "bed"] as const) {
|
|
39
|
+
const value = expected[component];
|
|
40
|
+
if (value !== undefined && actual[component] !== value) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Print safety: the job's highest ${component} target is ${actual[component]} C (S and R forms, every tool), ` +
|
|
43
|
+
`but ${value} C was expected. Re-slice or correct the expected temperature. Nothing was uploaded or started.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|