bambu-printer-mcp 1.1.26 → 1.1.28
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 +4 -0
- package/README.md +43 -8
- package/dist/bambu-native.d.ts +55 -0
- package/dist/bambu-native.js +212 -13
- package/dist/index.js +154 -26
- package/dist/printers/bambu.d.ts +29 -2
- package/dist/printers/bambu.js +45 -12
- package/dist/safety/hidden-hms.d.ts +32 -0
- package/dist/safety/hidden-hms.js +405 -0
- package/dist/safety/print-file.js +54 -4
- package/dist/safety/printer-state.d.ts +9 -0
- package/dist/safety/printer-state.js +80 -13
- package/dist/slicer/profile-files.d.ts +2 -0
- package/dist/slicer/profile-files.js +24 -0
- package/dist/slicer/profile-flatten.js +27 -13
- package/dist/stl/stl-manipulator.d.ts +1 -0
- package/dist/stl/stl-manipulator.js +6 -8
- 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/bambu-native.ts +229 -14
- package/src/index.ts +152 -25
- package/src/printers/bambu.ts +51 -12
- package/src/safety/hidden-hms.ts +406 -0
- package/src/safety/print-file.ts +43 -4
- package/src/safety/printer-state.ts +85 -13
- package/src/slicer/profile-files.ts +21 -0
- package/src/slicer/profile-flatten.ts +34 -11
- package/src/stl/stl-manipulator.ts +7 -10
- 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/bambu-native.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
7
|
|
|
@@ -71,30 +73,240 @@ export type BambuNativeTemperatureCommand = {
|
|
|
71
73
|
function firstExistingExecutable(candidates: string[]): string | undefined {
|
|
72
74
|
return candidates.find((candidate) => {
|
|
73
75
|
try {
|
|
74
|
-
|
|
76
|
+
const stat = fs.statSync(candidate);
|
|
77
|
+
return stat.isFile() && (stat.mode & 0o111) !== 0;
|
|
75
78
|
} catch {
|
|
76
79
|
return false;
|
|
77
80
|
}
|
|
78
81
|
});
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Hosts for which the native helper and Bambu's networking plug-in exist.
|
|
86
|
+
* Bambu publishes its Linux plug-in for x86-64 only.
|
|
87
|
+
*/
|
|
88
|
+
export function isBambuNativePlatform(
|
|
89
|
+
platform: NodeJS.Platform = process.platform,
|
|
90
|
+
arch: string = process.arch
|
|
91
|
+
): boolean {
|
|
92
|
+
return platform === "darwin" || (platform === "linux" && arch === "x64");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Private directory used by `bambu-printer-mcp-native-setup` on Linux. Mirrors
|
|
97
|
+
* nativeHomeDir() in native/bambu-native-print.cpp: empty values count as unset.
|
|
98
|
+
*/
|
|
99
|
+
export function bambuNativeHomeDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
100
|
+
if (env.BAMBU_NATIVE_HOME) return env.BAMBU_NATIVE_HOME;
|
|
101
|
+
const configHome = env.XDG_CONFIG_HOME || path.join(env.HOME || os.homedir(), ".config");
|
|
102
|
+
return path.join(configHome, "bambu-printer-mcp", "bambu-native");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const BAMBU_NATIVE_CERTIFICATES = ["slicer_base64.cer", "printer.cer"] as const;
|
|
106
|
+
/**
|
|
107
|
+
* The networking plug-in followed by the library it loads from its own
|
|
108
|
+
* directory for the eMMC file tunnel; without it uploads fail with -4.
|
|
109
|
+
*/
|
|
110
|
+
export const BAMBU_NATIVE_PLUGIN_LIBRARIES = ["libbambu_networking.so", "libBambuSource.so"] as const;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The only Linux plug-in build whose PrintParams layout matches the helper
|
|
114
|
+
* (Bambu Studio v02.08.02.61, network agent 02.08.02.54). The setup installs
|
|
115
|
+
* exactly these files and the server refuses anything else.
|
|
116
|
+
*/
|
|
117
|
+
export const BAMBU_NATIVE_PINNED_PLUGIN = Object.freeze({
|
|
118
|
+
version: "02.08.02.54",
|
|
119
|
+
sha256: Object.freeze({
|
|
120
|
+
"libbambu_networking.so": "bba49709c8dea14ba70251b43965646c0a5e2cde8fd133c64f764d425691bdf2",
|
|
121
|
+
"libBambuSource.so": "4e05f16fa02740fd12f0040b9efc3e70d43446c7a1a877c6a3926ef0758ca7db",
|
|
122
|
+
} as Record<(typeof BAMBU_NATIVE_PLUGIN_LIBRARIES)[number], string>),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** Layout shared by the Linux setup script, the server and the helper's environment. */
|
|
126
|
+
export function bambuNativeManagedPaths(env: NodeJS.ProcessEnv = process.env) {
|
|
127
|
+
const home = bambuNativeHomeDir(env);
|
|
128
|
+
return {
|
|
129
|
+
home,
|
|
130
|
+
helper: path.join(home, "bin", "bambu-native-print"),
|
|
131
|
+
plugin: path.join(home, "plugins", BAMBU_NATIVE_PLUGIN_LIBRARIES[0]),
|
|
132
|
+
certDir: path.join(home, "cert"),
|
|
133
|
+
configDir: path.join(home, "config"),
|
|
134
|
+
receipt: path.join(home, "install.json"),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
139
|
+
export const BAMBU_NATIVE_SOURCE = path.join(PACKAGE_ROOT, "native", "bambu-native-print.cpp");
|
|
140
|
+
|
|
141
|
+
export function sha256Hex(data: Buffer | string): string {
|
|
142
|
+
return createHash("sha256").update(data).digest("hex");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function sha256File(file: string): string {
|
|
146
|
+
return sha256Hex(fs.readFileSync(file));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let nativeSourceSha256: string | undefined;
|
|
150
|
+
/** The packaged helper source never changes while the server runs. */
|
|
151
|
+
function packagedHelperSha256(): string {
|
|
152
|
+
nativeSourceSha256 ??= sha256File(BAMBU_NATIVE_SOURCE);
|
|
153
|
+
return nativeSourceSha256;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function packageVersion(): string {
|
|
157
|
+
try {
|
|
158
|
+
return String(JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")).version || "");
|
|
159
|
+
} catch {
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Name the setup from this server's own version, whose helper source it
|
|
166
|
+
* accepts, and its own install directory when that is not the default.
|
|
167
|
+
*/
|
|
168
|
+
export function bambuNativeSetupCommand(version = packageVersion(), env: NodeJS.ProcessEnv = process.env): string {
|
|
169
|
+
const home = bambuNativeHomeDir(env);
|
|
170
|
+
const defaultHome = bambuNativeHomeDir({ HOME: env.HOME });
|
|
171
|
+
const homeOption = home === defaultHome ? "" : ` --home ${JSON.stringify(home)}`;
|
|
172
|
+
return `npx -y -p bambu-printer-mcp${version ? `@${version}` : ""} bambu-printer-mcp-native-setup${homeOption}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const linuxSetupHint = () =>
|
|
176
|
+
`Run \`${bambuNativeSetupCommand()}\` to build the helper and install the pinned networking plug-in.`;
|
|
177
|
+
|
|
178
|
+
type NativeInstall = { helper: string; env: Record<string, string> };
|
|
179
|
+
|
|
180
|
+
const verifiedLibraries = new Map<string, string>();
|
|
181
|
+
/** Hash a plug-in library once per file version; it is ~16 MB. */
|
|
182
|
+
function libraryMatches(file: string, expected: string): boolean {
|
|
183
|
+
const stat = fs.statSync(file);
|
|
184
|
+
const key = `${fs.realpathSync(file)}\n${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
185
|
+
let digest = verifiedLibraries.get(key);
|
|
186
|
+
if (!digest) {
|
|
187
|
+
digest = sha256File(file);
|
|
188
|
+
verifiedLibraries.set(key, digest);
|
|
189
|
+
}
|
|
190
|
+
return digest === expected;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function samePath(a: string, b: string): boolean {
|
|
194
|
+
try {
|
|
195
|
+
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
196
|
+
} catch {
|
|
197
|
+
return path.resolve(a) === path.resolve(b);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isRegularFile(file: string): boolean {
|
|
202
|
+
try {
|
|
203
|
+
return fs.statSync(file).isFile();
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve a complete native installation. On Linux the helper, the pinned
|
|
211
|
+
* plug-in and both certificates must be present before any native route is
|
|
212
|
+
* chosen, and their resolved paths are handed to the helper so server and
|
|
213
|
+
* helper can never disagree about which plug-in is loaded.
|
|
214
|
+
*/
|
|
215
|
+
function resolveNativeInstall(): NativeInstall {
|
|
216
|
+
if (!isBambuNativePlatform()) {
|
|
217
|
+
throw new Error("Bambu native X2D transport requires macOS or x86-64 Linux and Bambu's networking plug-in.");
|
|
218
|
+
}
|
|
219
|
+
const helper = resolveNativeHelper();
|
|
220
|
+
if (process.platform !== "linux") return { helper, env: {} };
|
|
221
|
+
|
|
222
|
+
const managed = bambuNativeManagedPaths();
|
|
223
|
+
const plugin = process.env.BAMBU_NATIVE_PLUGIN || managed.plugin;
|
|
224
|
+
const certDir = process.env.BAMBU_NATIVE_CERT_DIR || managed.certDir;
|
|
225
|
+
const configDir = process.env.BAMBU_NATIVE_CONFIG_DIR || managed.configDir;
|
|
226
|
+
const certFile = process.env.BAMBU_NATIVE_CERT_FILE || BAMBU_NATIVE_CERTIFICATES[0];
|
|
227
|
+
if (!isRegularFile(plugin)) {
|
|
228
|
+
throw new Error(`Bambu networking plug-in is not installed at ${plugin}. ${linuxSetupHint()}`);
|
|
229
|
+
}
|
|
230
|
+
for (const library of BAMBU_NATIVE_PLUGIN_LIBRARIES.slice(1)) {
|
|
231
|
+
if (!isRegularFile(path.join(path.dirname(plugin), library))) {
|
|
232
|
+
throw new Error(`Bambu networking library ${library} is missing beside ${plugin}. ${linuxSetupHint()}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// A different plug-in build can change the PrintParams layout passed by
|
|
236
|
+
// value to start_local_print. Only an explicit trusted opt-in skips this.
|
|
237
|
+
if (process.env.BAMBU_NATIVE_ALLOW_UNPINNED_PLUGIN !== "1") {
|
|
238
|
+
const files: Array<[string, string]> = [
|
|
239
|
+
[plugin, BAMBU_NATIVE_PINNED_PLUGIN.sha256[BAMBU_NATIVE_PLUGIN_LIBRARIES[0]]],
|
|
240
|
+
...BAMBU_NATIVE_PLUGIN_LIBRARIES.slice(1).map(library =>
|
|
241
|
+
[path.join(path.dirname(plugin), library), BAMBU_NATIVE_PINNED_PLUGIN.sha256[library]] as [string, string]),
|
|
242
|
+
];
|
|
243
|
+
for (const [file, expected] of files) {
|
|
244
|
+
if (!libraryMatches(file, expected)) {
|
|
245
|
+
throw new Error(`${file} is not the pinned Bambu plug-in ${BAMBU_NATIVE_PINNED_PLUGIN.version}. ${linuxSetupHint()}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const certificate of new Set([certFile, ...BAMBU_NATIVE_CERTIFICATES.slice(1)])) {
|
|
250
|
+
if (!isRegularFile(path.join(certDir, certificate))) {
|
|
251
|
+
throw new Error(`Bambu certificate ${certificate} is missing from ${certDir}. ${linuxSetupHint()}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (samePath(helper, managed.helper)) {
|
|
255
|
+
// A setup run from another package version builds a different helper.
|
|
256
|
+
let built: unknown;
|
|
257
|
+
try {
|
|
258
|
+
built = JSON.parse(fs.readFileSync(managed.receipt, "utf8")).helperSourceSha256;
|
|
259
|
+
} catch {
|
|
260
|
+
built = undefined;
|
|
261
|
+
}
|
|
262
|
+
if (built !== packagedHelperSha256()) {
|
|
263
|
+
throw new Error(`The installed native helper was built from a different bambu-printer-mcp version. ${linuxSetupHint()}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
helper,
|
|
268
|
+
env: { BAMBU_NATIVE_PLUGIN: plugin, BAMBU_NATIVE_CERT_DIR: certDir, BAMBU_NATIVE_CONFIG_DIR: configDir },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
81
272
|
export function assertBambuNativeAvailable(): void {
|
|
82
|
-
|
|
83
|
-
|
|
273
|
+
resolveNativeInstall();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* True when X2D controls should use the native route. macOS keeps its
|
|
278
|
+
* unconditional routing. Linux opts in only with a complete installation, so
|
|
279
|
+
* stop, pause and heater-off keep their MQTT route otherwise.
|
|
280
|
+
*/
|
|
281
|
+
export function bambuNativeControlsAvailable(): boolean {
|
|
282
|
+
return bambuNativeUnavailableReason() === undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Why native X2D controls are not used on this host, or undefined when they are. */
|
|
286
|
+
export function bambuNativeUnavailableReason(): string | undefined {
|
|
287
|
+
if (process.platform === "darwin") return undefined;
|
|
288
|
+
if (process.platform !== "linux") return "Native X2D controls require macOS or x86-64 Linux.";
|
|
289
|
+
try {
|
|
290
|
+
resolveNativeInstall();
|
|
291
|
+
return undefined;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return error instanceof Error ? error.message : String(error);
|
|
294
|
+
}
|
|
84
295
|
}
|
|
85
296
|
|
|
86
297
|
function resolveNativeHelper(): string {
|
|
87
298
|
const configured = process.env.BAMBU_NATIVE_HELPER?.trim();
|
|
88
|
-
|
|
299
|
+
// Linux uses only the setup-managed helper, whose receipt proves it matches
|
|
300
|
+
// this package; a package-local build could be stale after an update.
|
|
89
301
|
const candidates = [
|
|
90
302
|
configured,
|
|
91
|
-
path.resolve(
|
|
303
|
+
process.platform === "linux" ? bambuNativeManagedPaths().helper : path.resolve(PACKAGE_ROOT, "native/bambu-native-print"),
|
|
92
304
|
].filter((value): value is string => Boolean(value));
|
|
93
305
|
const helper = firstExistingExecutable(candidates);
|
|
94
306
|
if (!helper) {
|
|
95
|
-
throw new Error(
|
|
96
|
-
|
|
97
|
-
|
|
307
|
+
throw new Error(process.platform === "linux"
|
|
308
|
+
? `Bambu native helper is not installed. ${linuxSetupHint()} Alternatively set BAMBU_NATIVE_HELPER.`
|
|
309
|
+
: "Bambu native helper is not installed. Build native/bambu-native-print with clang++ or set BAMBU_NATIVE_HELPER.");
|
|
98
310
|
}
|
|
99
311
|
return helper;
|
|
100
312
|
}
|
|
@@ -121,7 +333,11 @@ function isEmergencyCommand(messageJson: string): boolean {
|
|
|
121
333
|
(command?.command === "set_nozzle_temp" && command.target_temp === 0);
|
|
122
334
|
}
|
|
123
335
|
|
|
124
|
-
|
|
336
|
+
/**
|
|
337
|
+
* Interrupt this printer's pending print, upload and heating helpers and
|
|
338
|
+
* resolve once they have exited, so none can still dispatch afterwards.
|
|
339
|
+
*/
|
|
340
|
+
export async function interruptNativeOperations(serial: string): Promise<void> {
|
|
125
341
|
const pending = [...activeHelpers.values()].filter(helper => helper.serial === serial && !helper.emergency);
|
|
126
342
|
for (const helper of pending) helper.interrupt();
|
|
127
343
|
// Send stop/heater-off only after an older helper can no longer send a
|
|
@@ -142,11 +358,10 @@ function runNativeHelper(
|
|
|
142
358
|
if (needsAuthorization && typeof execution.beforeDispatch !== "function") {
|
|
143
359
|
throw new Error("Native dispatch requires a beforeDispatch fresh-state check.");
|
|
144
360
|
}
|
|
145
|
-
|
|
146
|
-
const helper = resolveNativeHelper();
|
|
361
|
+
const install = resolveNativeInstall();
|
|
147
362
|
return new Promise((resolve, reject) => {
|
|
148
|
-
const child = spawn(helper, [mode], {
|
|
149
|
-
env,
|
|
363
|
+
const child = spawn(install.helper, [mode], {
|
|
364
|
+
env: { ...env, ...install.env },
|
|
150
365
|
stdio: ["pipe", "pipe", "pipe"],
|
|
151
366
|
});
|
|
152
367
|
let markClosed!: () => void;
|
|
@@ -608,7 +823,7 @@ export async function probeBambuNative(host: string, token: string): Promise<Rec
|
|
|
608
823
|
20_000
|
|
609
824
|
);
|
|
610
825
|
if (result.resultCode !== 0) {
|
|
611
|
-
throw new Error(`Bambu native local tunnel probe failed (${result.resultCode})
|
|
826
|
+
throw new Error(`Bambu native local tunnel probe failed (${result.resultCode}): ${result.updates.join(" | ") || tail(result.stderr, 500)}`);
|
|
612
827
|
}
|
|
613
828
|
return { status: "ok", route: "bambu:///local", updates: result.updates };
|
|
614
829
|
}
|