bambu-printer-mcp 1.1.27 → 1.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTORS.md +7 -1
- package/README.md +29 -10
- package/dist/ams-mapping.d.ts +18 -2
- package/dist/ams-mapping.js +36 -5
- package/dist/bambu-native.d.ts +55 -0
- package/dist/bambu-native.js +212 -13
- package/dist/index.js +154 -71
- package/dist/printers/bambu.d.ts +29 -2
- package/dist/printers/bambu.js +59 -28
- package/dist/safety/hidden-hms.d.ts +32 -0
- package/dist/safety/hidden-hms.js +405 -0
- package/dist/safety/limits.d.ts +6 -0
- package/dist/safety/limits.js +9 -0
- package/dist/safety/print-file.d.ts +2 -0
- package/dist/safety/print-file.js +83 -7
- package/dist/safety/printer-state.d.ts +11 -0
- package/dist/safety/printer-state.js +115 -22
- package/dist/slicer/profile-flatten.js +25 -0
- package/dist/slicer/remote-slicer.d.ts +12 -0
- package/dist/slicer/remote-slicer.js +240 -0
- package/dist/stl/stl-manipulator.d.ts +2 -1
- package/dist/stl/stl-manipulator.js +71 -42
- package/native/bambu-native-print.cpp +100 -26
- package/package.json +7 -5
- package/scripts/build-bambu-native.sh +53 -0
- package/scripts/setup-bambu-native.mjs +372 -0
- package/src/ams-mapping.ts +38 -5
- package/src/bambu-native.ts +229 -14
- package/src/index.ts +156 -67
- package/src/printers/bambu.ts +65 -25
- package/src/safety/hidden-hms.ts +406 -0
- package/src/safety/limits.ts +9 -0
- package/src/safety/print-file.ts +71 -7
- package/src/safety/printer-state.ts +117 -22
- package/src/slicer/profile-flatten.ts +32 -0
- package/src/slicer/remote-slicer.ts +216 -0
- package/src/stl/stl-manipulator.ts +70 -43
- package/scripts/build-bambu-native.zsh +0 -33
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Install the optional X2D native transport on Linux: build the helper from
|
|
3
|
+
// this package's source and install Bambu's networking plug-in plus the slicer
|
|
4
|
+
// certificates under one private directory. Every download is pinned to an
|
|
5
|
+
// exact version and SHA-256. `--check-update` reports newer plug-ins and
|
|
6
|
+
// whether their PrintParams layout still matches the helper; it never installs
|
|
7
|
+
// an unpinned plug-in, because a layout change would corrupt print parameters.
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import JSZip from "jszip";
|
|
13
|
+
// The server owns the install layout; reuse it so setup and server cannot drift.
|
|
14
|
+
import {
|
|
15
|
+
BAMBU_NATIVE_CERTIFICATES, BAMBU_NATIVE_PINNED_PLUGIN, BAMBU_NATIVE_PLUGIN_LIBRARIES, BAMBU_NATIVE_SOURCE, bambuNativeHomeDir,
|
|
16
|
+
bambuNativeManagedPaths, sha256File, sha256Hex as sha256,
|
|
17
|
+
} from "../dist/bambu-native.js";
|
|
18
|
+
|
|
19
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
|
+
const BUILD_SCRIPT = path.join(PACKAGE_ROOT, "scripts/build-bambu-native.sh");
|
|
21
|
+
const DOWNLOAD_TIMEOUT_MS = 300_000;
|
|
22
|
+
const MAX_DOWNLOAD_BYTES = 128 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
// Bambu Studio v02.08.02.61 declares network agent 02.08.02.54 with the same
|
|
25
|
+
// PrintParams layout as native/bambu-native-print.cpp. The server owns the
|
|
26
|
+
// version and library checksums; this adds where to download them from.
|
|
27
|
+
export const PINNED = Object.freeze({
|
|
28
|
+
studioTag: "v02.08.02.61",
|
|
29
|
+
plugin: Object.freeze({
|
|
30
|
+
version: BAMBU_NATIVE_PINNED_PLUGIN.version,
|
|
31
|
+
url: `https://public-cdn.bblmw.com/upgrade/studio/plugins/${BAMBU_NATIVE_PINNED_PLUGIN.version}/f1a94b8828/linux_${BAMBU_NATIVE_PINNED_PLUGIN.version}.zip`,
|
|
32
|
+
sha256: "a7ea8ccec85ab5a7b8959a5697f7be957d58da82e67aad1cb0ad9b3cdf5baed6",
|
|
33
|
+
// The networking plug-in opens the eMMC file tunnel through libBambuSource
|
|
34
|
+
// in its own directory; without it uploads fail with -4.
|
|
35
|
+
members: BAMBU_NATIVE_PINNED_PLUGIN.sha256,
|
|
36
|
+
}),
|
|
37
|
+
certificates: Object.freeze({
|
|
38
|
+
"slicer_base64.cer": "f504dc1c37bc9741b7a95198914e67f3f6f1d917b1e45eaa14b9e84eb7b51ec8",
|
|
39
|
+
"printer.cer": "36f2bcee347ec7adce719b5fd350099591a4d3d0ec4e039c7019890d78e152a0",
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const certificateUrl = (tag, name) =>
|
|
44
|
+
`https://raw.githubusercontent.com/bambulab/BambuStudio/${tag}/resources/cert/${name}`;
|
|
45
|
+
const networkingHeaderUrl = (tag) =>
|
|
46
|
+
`https://raw.githubusercontent.com/bambulab/BambuStudio/${tag}/src/slic3r/Utils/bambu_networking.hpp`;
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
function managedPaths(home) {
|
|
50
|
+
return bambuNativeManagedPaths({ ...process.env, BAMBU_NATIVE_HOME: home });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertSha256(buffer, expected, label) {
|
|
54
|
+
const actual = sha256(buffer);
|
|
55
|
+
if (actual !== expected) {
|
|
56
|
+
throw new Error(`${label} checksum mismatch: expected ${expected}, got ${actual}. Nothing was installed.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read a response body, aborting as soon as it exceeds the limit. */
|
|
61
|
+
export async function readLimited(response, limit, url) {
|
|
62
|
+
const declared = Number(response.headers.get("content-length") || 0);
|
|
63
|
+
if (declared > limit) throw new Error(`Download too large (${declared} bytes): ${url}`);
|
|
64
|
+
const chunks = [];
|
|
65
|
+
let received = 0;
|
|
66
|
+
for await (const chunk of response.body ?? []) {
|
|
67
|
+
received += chunk.length;
|
|
68
|
+
if (received > limit) throw new Error(`Download exceeded ${limit} bytes: ${url}`);
|
|
69
|
+
chunks.push(Buffer.from(chunk));
|
|
70
|
+
}
|
|
71
|
+
return Buffer.concat(chunks);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function defaultFetchBytes(url, headers = {}) {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(url, { headers, signal: controller.signal });
|
|
79
|
+
if (!response.ok) throw new Error(`Download failed (${response.status} ${response.statusText}): ${url}`);
|
|
80
|
+
return await readLimited(response, MAX_DOWNLOAD_BYTES, url);
|
|
81
|
+
} finally {
|
|
82
|
+
// Also cancels a body left unread after an oversized response.
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
controller.abort();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Write through a sibling temporary file so readers never see partial content. */
|
|
89
|
+
function writeFileAtomic(target, buffer, mode) {
|
|
90
|
+
const temporary = `${target}.tmp.${process.pid}`;
|
|
91
|
+
try {
|
|
92
|
+
fs.writeFileSync(temporary, buffer, { mode });
|
|
93
|
+
fs.chmodSync(temporary, mode);
|
|
94
|
+
fs.renameSync(temporary, target);
|
|
95
|
+
} finally {
|
|
96
|
+
fs.rmSync(temporary, { force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fileMatches(file, expected) {
|
|
101
|
+
try {
|
|
102
|
+
return fs.statSync(file).isFile() && sha256File(file) === expected;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Extract exactly the pinned libraries from Bambu's archive, each verified. */
|
|
109
|
+
export async function extractPlugin(archive, expectedMembers) {
|
|
110
|
+
const zip = await JSZip.loadAsync(archive, { checkCRC32: true });
|
|
111
|
+
const extracted = new Map();
|
|
112
|
+
for (const [name, expected] of Object.entries(expectedMembers)) {
|
|
113
|
+
// JSZip rewrites unsafe names such as "../x" to "x"; match the raw name so
|
|
114
|
+
// only a genuine top-level member is accepted.
|
|
115
|
+
const matches = Object.values(zip.files)
|
|
116
|
+
.filter(entry => !entry.dir && (entry.unsafeOriginalName ?? entry.name) === name);
|
|
117
|
+
if (matches.length !== 1) throw new Error(`Plug-in archive must contain exactly one top-level ${name}.`);
|
|
118
|
+
const member = await matches[0].async("nodebuffer");
|
|
119
|
+
assertSha256(member, expected, name);
|
|
120
|
+
extracted.set(name, member);
|
|
121
|
+
}
|
|
122
|
+
return extracted;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function assertSupportedHost(platform = process.platform, arch = process.arch) {
|
|
126
|
+
if (platform !== "linux") {
|
|
127
|
+
throw new Error("This setup is for Linux. On macOS, install Bambu Studio and run `npm run build:native` in the package directory.");
|
|
128
|
+
}
|
|
129
|
+
// Bambu publishes its Linux networking plug-in for x86-64 only.
|
|
130
|
+
if (arch !== "x64") throw new Error(`Bambu's Linux networking plug-in is only published for x86-64; this host is ${arch}.`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function buildHelper(output, { runner = execFileSync } = {}) {
|
|
134
|
+
runner("/bin/sh", [BUILD_SCRIPT, "--output", output], { stdio: ["ignore", "pipe", "pipe"], timeout: 180_000 });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Load the installed plug-in and its tunnel library through the helper,
|
|
139
|
+
* offline. Missing system libraries surface here instead of on a stop command.
|
|
140
|
+
*/
|
|
141
|
+
export function checkPlugin(paths, { runner = execFileSync } = {}) {
|
|
142
|
+
try {
|
|
143
|
+
runner(paths.helper, ["--check-plugin"], {
|
|
144
|
+
env: { ...process.env, BAMBU_NATIVE_PLUGIN: paths.plugin }, stdio: ["ignore", "pipe", "pipe"], timeout: 60_000,
|
|
145
|
+
});
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const detail = String(error?.stderr || error?.message || error).trim();
|
|
148
|
+
throw new Error(`The installed Bambu plug-in cannot be loaded on this host: ${detail}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function install({
|
|
153
|
+
home = bambuNativeHomeDir(),
|
|
154
|
+
pinned = PINNED,
|
|
155
|
+
fetchBytes = defaultFetchBytes,
|
|
156
|
+
build = buildHelper,
|
|
157
|
+
check = checkPlugin,
|
|
158
|
+
log = message => console.error(message),
|
|
159
|
+
platform = process.platform,
|
|
160
|
+
arch = process.arch,
|
|
161
|
+
} = {}) {
|
|
162
|
+
assertSupportedHost(platform, arch);
|
|
163
|
+
const sameNames = (a, b) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
|
|
164
|
+
if (!sameNames(Object.keys(pinned.certificates), BAMBU_NATIVE_CERTIFICATES) ||
|
|
165
|
+
!sameNames(Object.keys(pinned.plugin.members), BAMBU_NATIVE_PLUGIN_LIBRARIES)) {
|
|
166
|
+
throw new Error("Pinned files must match the plug-in libraries and certificates the server requires.");
|
|
167
|
+
}
|
|
168
|
+
const paths = managedPaths(home);
|
|
169
|
+
const directories = [path.dirname(paths.helper), path.dirname(paths.plugin), paths.certDir, paths.configDir];
|
|
170
|
+
// Only restrict a directory this setup creates; never change an existing one.
|
|
171
|
+
if (!fs.existsSync(home)) {
|
|
172
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
173
|
+
fs.chmodSync(home, 0o700);
|
|
174
|
+
}
|
|
175
|
+
for (const directory of directories) fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
176
|
+
|
|
177
|
+
// Fetch and verify everything before replacing any installed file.
|
|
178
|
+
const pluginDirectory = path.dirname(paths.plugin);
|
|
179
|
+
let libraries = new Map();
|
|
180
|
+
if (Object.entries(pinned.plugin.members).every(([name, expected]) => fileMatches(path.join(pluginDirectory, name), expected))) {
|
|
181
|
+
log(`Plug-in ${pinned.plugin.version} already installed and verified.`);
|
|
182
|
+
} else {
|
|
183
|
+
log(`Downloading Bambu networking plug-in ${pinned.plugin.version}...`);
|
|
184
|
+
const archive = await fetchBytes(pinned.plugin.url);
|
|
185
|
+
assertSha256(archive, pinned.plugin.sha256, "Plug-in archive");
|
|
186
|
+
libraries = await extractPlugin(archive, pinned.plugin.members);
|
|
187
|
+
}
|
|
188
|
+
const certificates = [];
|
|
189
|
+
for (const [name, expected] of Object.entries(pinned.certificates)) {
|
|
190
|
+
const target = path.join(paths.certDir, name);
|
|
191
|
+
if (fileMatches(target, expected)) continue;
|
|
192
|
+
log(`Downloading ${name} from Bambu Studio ${pinned.studioTag}...`);
|
|
193
|
+
const buffer = await fetchBytes(certificateUrl(pinned.studioTag, name));
|
|
194
|
+
assertSha256(buffer, expected, name);
|
|
195
|
+
certificates.push([target, buffer]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Withdraw the receipt before replacing anything: until a complete, checked
|
|
199
|
+
// installation is written again, the server refuses this helper.
|
|
200
|
+
fs.rmSync(paths.receipt, { force: true });
|
|
201
|
+
|
|
202
|
+
// Rebuild every time so the helper always matches this package's source.
|
|
203
|
+
const helper = paths.helper;
|
|
204
|
+
log("Building the native helper...");
|
|
205
|
+
build(helper);
|
|
206
|
+
if (!fs.statSync(helper).isFile()) throw new Error(`Helper build did not produce ${helper}.`);
|
|
207
|
+
|
|
208
|
+
for (const [name, buffer] of libraries) writeFileAtomic(path.join(pluginDirectory, name), buffer, 0o600);
|
|
209
|
+
for (const [target, buffer] of certificates) writeFileAtomic(target, buffer, 0o600);
|
|
210
|
+
log("Checking that the plug-in loads...");
|
|
211
|
+
check(paths);
|
|
212
|
+
const receipt = {
|
|
213
|
+
pluginVersion: pinned.plugin.version,
|
|
214
|
+
pluginLibraries: pinned.plugin.members,
|
|
215
|
+
studioTag: pinned.studioTag,
|
|
216
|
+
helperSourceSha256: sha256File(BAMBU_NATIVE_SOURCE),
|
|
217
|
+
installedAt: new Date().toISOString(),
|
|
218
|
+
};
|
|
219
|
+
writeFileAtomic(paths.receipt, Buffer.from(`${JSON.stringify(receipt, null, 2)}\n`), 0o600);
|
|
220
|
+
log(`Installed the X2D native transport in ${home}.`);
|
|
221
|
+
if (path.resolve(home) !== path.resolve(bambuNativeHomeDir())) {
|
|
222
|
+
log(`Set BAMBU_NATIVE_HOME=${home} in the MCP server's environment so it uses this installation.`);
|
|
223
|
+
}
|
|
224
|
+
return { home, helper, plugin: paths.plugin, receipt };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Report installed files against the pinned versions; never contacts the network. */
|
|
228
|
+
export function verify({ home = bambuNativeHomeDir(), pinned = PINNED, check = checkPlugin } = {}) {
|
|
229
|
+
const problems = [];
|
|
230
|
+
const paths = managedPaths(home);
|
|
231
|
+
const helper = paths.helper;
|
|
232
|
+
try {
|
|
233
|
+
if ((fs.statSync(helper).mode & 0o111) === 0) problems.push(`${helper} is not executable`);
|
|
234
|
+
} catch {
|
|
235
|
+
problems.push(`${helper} is missing`);
|
|
236
|
+
}
|
|
237
|
+
for (const [name, expected] of Object.entries(pinned.plugin.members)) {
|
|
238
|
+
if (!fileMatches(path.join(path.dirname(paths.plugin), name), expected)) {
|
|
239
|
+
problems.push(`${name} is missing or not the pinned ${pinned.plugin.version}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const [name, expected] of Object.entries(pinned.certificates)) {
|
|
243
|
+
if (!fileMatches(path.join(paths.certDir, name), expected)) problems.push(`${name} is missing or modified`);
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const receipt = JSON.parse(fs.readFileSync(paths.receipt, "utf8"));
|
|
247
|
+
if (receipt.helperSourceSha256 !== sha256File(BAMBU_NATIVE_SOURCE)) {
|
|
248
|
+
problems.push("the helper was built from a different package version; rerun setup");
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
problems.push("install.json is missing or unreadable");
|
|
252
|
+
}
|
|
253
|
+
if (problems.length === 0) {
|
|
254
|
+
try {
|
|
255
|
+
check(paths);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
problems.push(error.message);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { ok: problems.length === 0, home, pluginVersion: pinned.plugin.version, problems };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Normalize the members of `struct PrintParams` so layout-relevant changes
|
|
265
|
+
* (added, removed, reordered or retyped fields) are detected while comments
|
|
266
|
+
* and default initializers are ignored.
|
|
267
|
+
*/
|
|
268
|
+
export function printParamsLayout(source) {
|
|
269
|
+
const match = /struct\s+PrintParams\s*\{([\s\S]*?)\n\s*\};/.exec(source);
|
|
270
|
+
if (!match) throw new Error("struct PrintParams not found");
|
|
271
|
+
return match[1]
|
|
272
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
273
|
+
.replace(/\/\/[^\n]*/g, "")
|
|
274
|
+
.split(";")
|
|
275
|
+
.map(member => member.replace(/\{[^}]*\}\s*$/, "").replace(/=[^]*$/, "").replace(/\s+/g, " ").trim())
|
|
276
|
+
.filter(Boolean);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function agentVersion(header) {
|
|
280
|
+
return /#define\s+BAMBU_NETWORK_AGENT_VERSION\s+"([0-9.]+)"/.exec(header)?.[1];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function studioRequestVersion(tag) {
|
|
284
|
+
const match = /^v?(\d{2})\.(\d{2})\.(\d{2})\.\d{2}$/.exec(tag);
|
|
285
|
+
if (!match) throw new Error(`Unexpected Bambu Studio release tag: ${tag}`);
|
|
286
|
+
return `${match[1]}.${match[2]}.${match[3]}.00`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function checkUpdate({ fetchBytes = defaultFetchBytes, pinned = PINNED, helperSource = fs.readFileSync(BAMBU_NATIVE_SOURCE, "utf8") } = {}) {
|
|
290
|
+
const release = JSON.parse((await fetchBytes("https://api.github.com/repos/bambulab/BambuStudio/releases/latest",
|
|
291
|
+
{ Accept: "application/vnd.github+json" })).toString("utf8"));
|
|
292
|
+
const tag = String(release.tag_name || "");
|
|
293
|
+
const requestVersion = studioRequestVersion(tag);
|
|
294
|
+
const resource = JSON.parse((await fetchBytes(
|
|
295
|
+
`https://api.bambulab.com/v1/iot-service/api/slicer/resource?slicer/plugins/cloud=${requestVersion}`,
|
|
296
|
+
{
|
|
297
|
+
"X-BBL-Client-Type": "slicer",
|
|
298
|
+
"X-BBL-Client-Name": "BambuStudio",
|
|
299
|
+
"X-BBL-Client-Version": tag.replace(/^v/, ""),
|
|
300
|
+
"X-BBL-OS-Type": "linux",
|
|
301
|
+
}
|
|
302
|
+
)).toString("utf8"));
|
|
303
|
+
const offered = resource.resources?.find(entry => entry.type === "slicer/plugins/cloud");
|
|
304
|
+
if (!offered?.version || !offered?.url) throw new Error("Bambu's plug-in service returned no Linux plug-in.");
|
|
305
|
+
const header = (await fetchBytes(networkingHeaderUrl(tag))).toString("utf8");
|
|
306
|
+
const headerAgent = agentVersion(header);
|
|
307
|
+
const helperLayout = printParamsLayout(helperSource);
|
|
308
|
+
const releaseLayout = printParamsLayout(header);
|
|
309
|
+
const layoutMatches = JSON.stringify(helperLayout) === JSON.stringify(releaseLayout);
|
|
310
|
+
return {
|
|
311
|
+
pinned: { studioTag: pinned.studioTag, plugin: pinned.plugin.version },
|
|
312
|
+
latest: { studioTag: tag, plugin: offered.version, url: offered.url, headerAgentVersion: headerAgent },
|
|
313
|
+
upToDate: offered.version === pinned.plugin.version,
|
|
314
|
+
// The header describes its own agent version; a different offered plug-in
|
|
315
|
+
// is unverified even when the layout matches.
|
|
316
|
+
headerMatchesOffered: headerAgent === offered.version,
|
|
317
|
+
printParamsLayoutMatches: layoutMatches,
|
|
318
|
+
// Members present on one side only; a pure reordering shows as a mismatch with both lists empty.
|
|
319
|
+
layoutDifferences: {
|
|
320
|
+
helperOnly: helperLayout.filter(member => !releaseLayout.includes(member)),
|
|
321
|
+
releaseOnly: releaseLayout.filter(member => !helperLayout.includes(member)),
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function usage() {
|
|
327
|
+
return [
|
|
328
|
+
"usage: bambu-printer-mcp-native-setup [--verify | --check-update] [--home DIR]",
|
|
329
|
+
" (default) build the helper and install the pinned plug-in and certificates",
|
|
330
|
+
" --verify check the installed files without network access",
|
|
331
|
+
" --check-update report newer Bambu plug-ins and helper layout compatibility",
|
|
332
|
+
].join("\n");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
336
|
+
let mode = "install";
|
|
337
|
+
let home;
|
|
338
|
+
for (let index = 0; index < argv.length; index++) {
|
|
339
|
+
const argument = argv[index];
|
|
340
|
+
if (argument === "--verify" || argument === "--check-update") {
|
|
341
|
+
if (mode !== "install") throw new Error(usage());
|
|
342
|
+
mode = argument.slice(2);
|
|
343
|
+
} else if (argument === "--home" && argv[index + 1] && !argv[index + 1].startsWith("-")) {
|
|
344
|
+
home = path.resolve(argv[++index]);
|
|
345
|
+
} else if (argument === "--help" || argument === "-h") {
|
|
346
|
+
console.log(usage());
|
|
347
|
+
return 0;
|
|
348
|
+
} else {
|
|
349
|
+
throw new Error(usage());
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (mode === "verify") {
|
|
353
|
+
const result = verify({ home: home ?? bambuNativeHomeDir() });
|
|
354
|
+
console.log(JSON.stringify(result, null, 2));
|
|
355
|
+
return result.ok ? 0 : 1;
|
|
356
|
+
}
|
|
357
|
+
if (mode === "check-update") {
|
|
358
|
+
const result = await checkUpdate();
|
|
359
|
+
console.log(JSON.stringify(result, null, 2));
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
const result = await install({ home: home ?? bambuNativeHomeDir() });
|
|
363
|
+
console.log(JSON.stringify({ status: "installed", ...result }, null, 2));
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
368
|
+
main().then(code => { process.exitCode = code; }, error => {
|
|
369
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
370
|
+
process.exitCode = 1;
|
|
371
|
+
});
|
|
372
|
+
}
|
package/src/ams-mapping.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
/** Tray-value options. `dualNozzle` admits 255, the right/main nozzle external spool that
|
|
2
|
+
* dual-nozzle printers report beside the left/deputy external spool 254 (Bambu Studio
|
|
3
|
+
* VIRTUAL_TRAY_MAIN_ID / VIRTUAL_TRAY_DEPUTY_ID). Single-nozzle printers keep 254 only. */
|
|
4
|
+
export interface AmsTrayValueOptions { dualNozzle?: boolean }
|
|
5
|
+
|
|
6
|
+
export function describeAmsTrayRange(options: AmsTrayValueOptions = {}): string {
|
|
7
|
+
return options.dualNozzle
|
|
8
|
+
? "[-1, 15] (absolute tray), 128-253 (AMS-HT), 254 (left/deputy external) or 255 (right/main external)"
|
|
9
|
+
: "[-1, 15] (absolute tray) or 128-254 (HT/external)";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeBridgeAmsTrayValue(value: unknown, label: string, options: AmsTrayValueOptions = {}): number {
|
|
2
13
|
let numeric: number;
|
|
3
14
|
if (typeof value === "number") {
|
|
4
15
|
numeric = value;
|
|
@@ -12,10 +23,10 @@ export function normalizeBridgeAmsTrayValue(value: unknown, label: string): numb
|
|
|
12
23
|
!Number.isInteger(numeric) ||
|
|
13
24
|
numeric < -1 ||
|
|
14
25
|
(numeric > 15 && numeric < 128) ||
|
|
15
|
-
numeric > 254
|
|
26
|
+
numeric > (options.dualNozzle ? 255 : 254)
|
|
16
27
|
) {
|
|
17
28
|
throw new Error(
|
|
18
|
-
`${label} must be an integer in
|
|
29
|
+
`${label} must be an integer in ${describeAmsTrayRange(options)}; got ${JSON.stringify(value)}`
|
|
19
30
|
);
|
|
20
31
|
}
|
|
21
32
|
|
|
@@ -27,7 +38,7 @@ function isNumericMappingKey(key: string): boolean {
|
|
|
27
38
|
return Number.isInteger(numeric) && numeric >= 0;
|
|
28
39
|
}
|
|
29
40
|
|
|
30
|
-
export function normalizeAmsMappingObject(mapping: Record<string, unknown>, label = "ams_mapping"): number[] {
|
|
41
|
+
export function normalizeAmsMappingObject(mapping: Record<string, unknown>, label = "ams_mapping", options: AmsTrayValueOptions = {}): number[] {
|
|
31
42
|
return Object.entries(mapping)
|
|
32
43
|
.sort(([leftKey], [rightKey]) => {
|
|
33
44
|
const leftNumeric = isNumericMappingKey(leftKey);
|
|
@@ -37,7 +48,7 @@ export function normalizeAmsMappingObject(mapping: Record<string, unknown>, labe
|
|
|
37
48
|
if (rightNumeric) return 1;
|
|
38
49
|
return 0;
|
|
39
50
|
})
|
|
40
|
-
.map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]
|
|
51
|
+
.map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]`, options));
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
export function hasAmsMappingInput(value: unknown): boolean {
|
|
@@ -45,3 +56,25 @@ export function hasAmsMappingInput(value: unknown): boolean {
|
|
|
45
56
|
if (value && typeof value === "object") return Object.keys(value as Record<string, unknown>).length > 0;
|
|
46
57
|
return value !== undefined && value !== null;
|
|
47
58
|
}
|
|
59
|
+
|
|
60
|
+
/** Flat wire entries use -1 for dual-nozzle virtual trays; ams_mapping2 identifies their spool. */
|
|
61
|
+
export function bridgeAmsMappingValue(value: number, dualNozzle = false): number {
|
|
62
|
+
return dualNozzle && (value === 254 || value === 255) ? -1 : value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Select an external spool only from a verified physical nozzle on dual-nozzle printers. */
|
|
66
|
+
export function externalTrayForNozzle(nozzle: number | undefined, dualNozzle = false): number {
|
|
67
|
+
if (!dualNozzle) return 254;
|
|
68
|
+
if (nozzle === 0) return 255;
|
|
69
|
+
if (nozzle === 1) return 254;
|
|
70
|
+
throw new Error("External-spool printing requires inspected filament_map and physical_extruder_map nozzle bindings.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Virtual trays use slot zero on dual-nozzle printers; retain the historical single-nozzle form. */
|
|
74
|
+
export function bridgeAmsMapping2Entry(value: number, dualNozzle = false): { ams_id: number; slot_id: number } {
|
|
75
|
+
if (value < 0) return { ams_id: 255, slot_id: 255 };
|
|
76
|
+
if (value === 255) return dualNozzle ? { ams_id: 255, slot_id: 0 } : { ams_id: 255, slot_id: 255 };
|
|
77
|
+
if (value === 254) return { ams_id: 254, slot_id: dualNozzle ? 0 : 254 };
|
|
78
|
+
if (value >= 128) return { ams_id: 128, slot_id: value - 128 };
|
|
79
|
+
return { ams_id: Math.floor(value / 4), slot_id: value % 4 };
|
|
80
|
+
}
|