bambu-printer-mcp 1.0.4 → 1.0.8

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,526 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const FRAME_MAGIC = 0x52424a50;
7
+ const FRAME_HEADER_SIZE = 16;
8
+ const FRAME_TYPE_JSON_REQUEST = 1;
9
+ const FRAME_TYPE_JSON_RESPONSE = 2;
10
+ const FRAME_TYPE_BINARY_DATA = 3;
11
+ const FRAME_TYPE_LOG = 4;
12
+ const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
13
+
14
+ type JsonObject = Record<string, unknown>;
15
+
16
+ type PendingRequest = {
17
+ method: string;
18
+ resolve: (value: unknown) => void;
19
+ reject: (error: Error) => void;
20
+ timer: NodeJS.Timeout;
21
+ };
22
+
23
+ export type BambuNetworkBridgeOptions = {
24
+ bridgeCommand?: string;
25
+ configDir?: string;
26
+ countryCode?: string;
27
+ userInfo?: string;
28
+ timeoutMs?: number;
29
+ };
30
+
31
+ export type BambuNetworkBridgeStatus = {
32
+ configured: boolean;
33
+ command: string;
34
+ platform: NodeJS.Platform;
35
+ running: boolean;
36
+ pid?: number;
37
+ configDir: string;
38
+ countryCode: string;
39
+ agentReady: boolean;
40
+ lastStderr: string;
41
+ recentLogs: string[];
42
+ runtime: {
43
+ macosRuntimeDir: string;
44
+ macosRuntimeDirExists: boolean;
45
+ macosPluginDir?: string;
46
+ macosPluginDirExists?: boolean;
47
+ macosMissingRuntimeFiles: string[];
48
+ macosMissingPluginFiles: string[];
49
+ suggestedMacCommand?: string;
50
+ };
51
+ note?: string;
52
+ };
53
+
54
+ export function defaultBambuNetworkConfigDir(): string {
55
+ return path.join(os.homedir(), ".config", "bambu-printer-mcp", "bambu-network");
56
+ }
57
+
58
+ function shellQuote(value: string): string {
59
+ return `'${value.replace(/'/g, "'\\''")}'`;
60
+ }
61
+
62
+ function trimEnv(value: string | undefined): string | undefined {
63
+ const trimmed = value?.trim();
64
+ return trimmed ? trimmed : undefined;
65
+ }
66
+
67
+ function fileExists(filePath: string): boolean {
68
+ try {
69
+ return fs.existsSync(filePath);
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function resolveFirstExistingDirectory(candidates: string[]): string | undefined {
76
+ return candidates.find((candidate) => fileExists(candidate) && fs.statSync(candidate).isDirectory());
77
+ }
78
+
79
+ function resolveFirstDirectoryWithFile(candidates: string[], requiredFile: string): string | undefined {
80
+ return candidates.find(
81
+ (candidate) =>
82
+ fileExists(candidate) &&
83
+ fs.statSync(candidate).isDirectory() &&
84
+ fileExists(path.join(candidate, requiredFile))
85
+ );
86
+ }
87
+
88
+ function collectMissingFiles(baseDir: string | undefined, files: string[]): string[] {
89
+ if (!baseDir) return files;
90
+ return files.filter((file) => !fileExists(path.join(baseDir, file)));
91
+ }
92
+
93
+ function readFrameJson(payload: Buffer): unknown {
94
+ const text = payload.toString("utf8");
95
+ return text.length > 0 ? JSON.parse(text) : {};
96
+ }
97
+
98
+ function isBridgeFailure(value: unknown): value is { ok: false; error?: unknown } {
99
+ return (
100
+ typeof value === "object" &&
101
+ value !== null &&
102
+ (value as { ok?: unknown }).ok === false
103
+ );
104
+ }
105
+
106
+ function bridgeValueAsNumber(value: unknown, method: string): number {
107
+ if (
108
+ typeof value === "object" &&
109
+ value !== null &&
110
+ typeof (value as { value?: unknown }).value === "number"
111
+ ) {
112
+ return (value as { value: number }).value;
113
+ }
114
+
115
+ throw new Error(`FULU BambuNetwork bridge method ${method} did not return a numeric value.`);
116
+ }
117
+
118
+ function assertBridgeOk(value: unknown, method: string): void {
119
+ if (isBridgeFailure(value)) {
120
+ const errorText = value.error === undefined ? "unknown bridge error" : String(value.error);
121
+ throw new Error(`FULU BambuNetwork bridge method ${method} failed: ${errorText}`);
122
+ }
123
+ }
124
+
125
+ export class BambuNetworkBridge {
126
+ private child?: ChildProcessWithoutNullStreams;
127
+ private commandLine?: string;
128
+ private stdoutBuffer = Buffer.alloc(0);
129
+ private pending = new Map<number, PendingRequest>();
130
+ private nextRequestId = 1;
131
+ private agentId?: number;
132
+ private agentKey?: string;
133
+ private handshake?: unknown;
134
+ private lastStderr = "";
135
+ private recentLogs: string[] = [];
136
+ private inferredExpectedNetworkVersion?: string;
137
+
138
+ resolveBridgeCommand(override?: string): string {
139
+ return (
140
+ trimEnv(override) ||
141
+ trimEnv(process.env.BAMBU_NETWORK_BRIDGE_COMMAND) ||
142
+ trimEnv(process.env.FULU_BAMBU_NETWORK_BRIDGE_COMMAND) ||
143
+ trimEnv(process.env.PJARCZAK_BAMBU_BRIDGE_COMMAND) ||
144
+ ""
145
+ );
146
+ }
147
+
148
+ resolveConfigDir(override?: string): string {
149
+ return (
150
+ trimEnv(override) ||
151
+ trimEnv(process.env.BAMBU_NETWORK_CONFIG_DIR) ||
152
+ trimEnv(process.env.PJARCZAK_BAMBU_PROBE_LOG_DIR) ||
153
+ defaultBambuNetworkConfigDir()
154
+ );
155
+ }
156
+
157
+ resolveCountryCode(override?: string): string {
158
+ return (
159
+ trimEnv(override) ||
160
+ trimEnv(process.env.BAMBU_NETWORK_COUNTRY_CODE) ||
161
+ trimEnv(process.env.PJARCZAK_BAMBU_COUNTRY_CODE) ||
162
+ "US"
163
+ );
164
+ }
165
+
166
+ getStatus(options: BambuNetworkBridgeOptions = {}): BambuNetworkBridgeStatus {
167
+ const command = this.resolveBridgeCommand(options.bridgeCommand);
168
+ const configDir = this.resolveConfigDir(options.configDir);
169
+ const countryCode = this.resolveCountryCode(options.countryCode);
170
+
171
+ const macosRuntimeDir =
172
+ trimEnv(process.env.PJARCZAK_MAC_RUNTIME_DIR) ||
173
+ path.join(os.homedir(), "Library", "Application Support", "OrcaSlicer", "macos-bridge", "runtime");
174
+ const macosPluginDir =
175
+ trimEnv(process.env.BAMBU_NETWORK_PLUGIN_DIR) ||
176
+ trimEnv(process.env.PJARCZAK_BAMBU_PLUGIN_DIR) ||
177
+ resolveFirstDirectoryWithFile([
178
+ path.join(os.homedir(), "Library", "Application Support", "OrcaSlicer", "plugins"),
179
+ "/Applications/Orca Studio.app/Contents/MacOS",
180
+ "/Applications/OrcaSlicer.app/Contents/MacOS",
181
+ "/Applications/OrcaSlicer-BMCU.app/Contents/MacOS",
182
+ "/Applications/Orca Studio.app/Contents/Resources",
183
+ "/Applications/OrcaSlicer.app/Contents/Resources",
184
+ "/Applications/OrcaSlicer-BMCU.app/Contents/Resources",
185
+ macosRuntimeDir,
186
+ ], "pjarczak-bambu-linux-host-wrapper") ||
187
+ resolveFirstExistingDirectory([macosRuntimeDir]);
188
+
189
+ const runtimeRequiredFiles = [
190
+ "libbambu_networking.so",
191
+ "libBambuSource.so",
192
+ "pjarczak_bambu_linux_host",
193
+ "pjarczak_bambu_linux_host_abi1",
194
+ "pjarczak_bambu_linux_host_abi0",
195
+ "ca-certificates.crt",
196
+ "slicer_base64.cer",
197
+ ];
198
+ const pluginRequiredFiles = [
199
+ "pjarczak-bambu-linux-host-wrapper",
200
+ "install_runtime_macos.sh",
201
+ "verify_runtime_macos.sh",
202
+ ];
203
+
204
+ let suggestedMacCommand: string | undefined;
205
+ if (macosPluginDir) {
206
+ const wrapper = path.join(macosPluginDir, "pjarczak-bambu-linux-host-wrapper");
207
+ const host = path.join(macosRuntimeDir, "pjarczak_bambu_linux_host");
208
+ suggestedMacCommand =
209
+ `PJARCZAK_BAMBU_PLUGIN_DIR=${shellQuote(macosRuntimeDir)} ` +
210
+ `${shellQuote(wrapper)} ${shellQuote(host)}`;
211
+ }
212
+
213
+ return {
214
+ configured: command.length > 0,
215
+ command,
216
+ platform: process.platform,
217
+ running: Boolean(this.child && this.child.exitCode === null),
218
+ pid: this.child?.pid,
219
+ configDir,
220
+ countryCode,
221
+ agentReady: this.agentId !== undefined,
222
+ lastStderr: this.lastStderr,
223
+ recentLogs: this.recentLogs.slice(-10),
224
+ runtime: {
225
+ macosRuntimeDir,
226
+ macosRuntimeDirExists: fileExists(macosRuntimeDir),
227
+ macosPluginDir,
228
+ macosPluginDirExists: macosPluginDir ? fileExists(macosPluginDir) : undefined,
229
+ macosMissingRuntimeFiles: collectMissingFiles(macosRuntimeDir, runtimeRequiredFiles),
230
+ macosMissingPluginFiles: collectMissingFiles(macosPluginDir, pluginRequiredFiles),
231
+ suggestedMacCommand,
232
+ },
233
+ note: command
234
+ ? undefined
235
+ : "Set BAMBU_NETWORK_BRIDGE_COMMAND to the FULU OrcaSlicer-bambulab Linux host or macOS/WSL wrapper command.",
236
+ };
237
+ }
238
+
239
+ async request(method: string, payload: JsonObject = {}, options: BambuNetworkBridgeOptions = {}): Promise<unknown> {
240
+ if (!method.trim()) {
241
+ throw new Error("BambuNetwork bridge method is required.");
242
+ }
243
+
244
+ await this.ensureStarted(options.bridgeCommand);
245
+
246
+ const id = this.nextRequestId++;
247
+ const requestPayload = Buffer.from(JSON.stringify({ method, payload }), "utf8");
248
+ const frame = Buffer.alloc(FRAME_HEADER_SIZE + requestPayload.length);
249
+ frame.writeUInt32LE(FRAME_MAGIC, 0);
250
+ frame.writeUInt32LE(FRAME_TYPE_JSON_REQUEST, 4);
251
+ frame.writeUInt32LE(id, 8);
252
+ frame.writeUInt32LE(requestPayload.length, 12);
253
+ requestPayload.copy(frame, FRAME_HEADER_SIZE);
254
+
255
+ return await new Promise<unknown>((resolve, reject) => {
256
+ const timer = setTimeout(() => {
257
+ this.pending.delete(id);
258
+ reject(new Error(`Timed out waiting for FULU BambuNetwork bridge method ${method}.`));
259
+ }, options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
260
+
261
+ this.pending.set(id, { method, resolve, reject, timer });
262
+
263
+ try {
264
+ this.child!.stdin.write(frame, (error) => {
265
+ if (error) {
266
+ const pending = this.pending.get(id);
267
+ if (pending) {
268
+ clearTimeout(pending.timer);
269
+ this.pending.delete(id);
270
+ pending.reject(error);
271
+ }
272
+ }
273
+ });
274
+ } catch (error) {
275
+ clearTimeout(timer);
276
+ this.pending.delete(id);
277
+ reject(error instanceof Error ? error : new Error(String(error)));
278
+ }
279
+ });
280
+ }
281
+
282
+ async ensureAgent(options: BambuNetworkBridgeOptions = {}): Promise<{
283
+ agent: number;
284
+ handshake: unknown;
285
+ }> {
286
+ const command = this.resolveBridgeCommand(options.bridgeCommand);
287
+ const configDir = this.resolveConfigDir(options.configDir);
288
+ const countryCode = this.resolveCountryCode(options.countryCode);
289
+ const userInfo = trimEnv(options.userInfo) || trimEnv(process.env.BAMBU_NETWORK_USER_INFO) || "";
290
+ const key = `${command}\n${configDir}\n${countryCode}\n${userInfo}`;
291
+
292
+ if (this.agentId !== undefined && this.agentKey === key) {
293
+ return { agent: this.agentId, handshake: this.handshake };
294
+ }
295
+
296
+ fs.mkdirSync(configDir, { recursive: true });
297
+
298
+ this.handshake = await this.request("bridge.handshake", {}, options);
299
+ assertBridgeOk(this.handshake, "bridge.handshake");
300
+ if (this.needsExpectedVersionRetry(this.handshake) && !trimEnv(process.env.PJARCZAK_EXPECTED_BAMBU_NETWORK_VERSION)) {
301
+ this.inferredExpectedNetworkVersion = this.handshake.network_actual_abi_version;
302
+ await this.stop();
303
+ this.handshake = await this.request("bridge.handshake", {}, options);
304
+ assertBridgeOk(this.handshake, "bridge.handshake");
305
+ }
306
+
307
+ const created = await this.request("net.create_agent", { log_dir: configDir, country_code: countryCode }, options);
308
+ assertBridgeOk(created, "net.create_agent");
309
+ const agent = bridgeValueAsNumber(created, "net.create_agent");
310
+
311
+ const withAgent = async (method: string, payload: JsonObject = {}, hardFail = true): Promise<unknown> => {
312
+ const response = await this.request(method, { ...payload, agent }, options);
313
+ if (hardFail) assertBridgeOk(response, method);
314
+ return response;
315
+ };
316
+
317
+ await withAgent("net.set_config_dir", { config_dir: configDir });
318
+ await withAgent("net.init_log");
319
+ await withAgent("net.set_country_code", { country_code: countryCode });
320
+ await withAgent("net.start");
321
+ await withAgent("net.connect_server", {}, false);
322
+
323
+ if (userInfo) {
324
+ await withAgent("net.change_user", { user_info: userInfo });
325
+ }
326
+
327
+ this.agentId = agent;
328
+ this.agentKey = key;
329
+ return { agent, handshake: this.handshake };
330
+ }
331
+
332
+ async callWithAgent(
333
+ method: string,
334
+ payload: JsonObject = {},
335
+ options: BambuNetworkBridgeOptions = {}
336
+ ): Promise<unknown> {
337
+ const { agent } = await this.ensureAgent(options);
338
+ return await this.request(method, { ...payload, agent }, options);
339
+ }
340
+
341
+ async stop(): Promise<void> {
342
+ const child = this.child;
343
+ this.child = undefined;
344
+ this.commandLine = undefined;
345
+ this.agentId = undefined;
346
+ this.agentKey = undefined;
347
+ this.handshake = undefined;
348
+ this.stdoutBuffer = Buffer.alloc(0);
349
+
350
+ for (const [id, pending] of this.pending.entries()) {
351
+ clearTimeout(pending.timer);
352
+ pending.reject(new Error(`FULU BambuNetwork bridge stopped before ${pending.method} completed.`));
353
+ this.pending.delete(id);
354
+ }
355
+
356
+ if (child && child.exitCode === null) {
357
+ await new Promise<void>((resolve) => {
358
+ const forceTimer = setTimeout(() => {
359
+ if (child.exitCode === null) {
360
+ child.kill("SIGKILL");
361
+ }
362
+ }, 2000);
363
+ child.once("close", () => {
364
+ clearTimeout(forceTimer);
365
+ resolve();
366
+ });
367
+ child.kill("SIGTERM");
368
+ });
369
+ }
370
+ }
371
+
372
+ private async ensureStarted(commandOverride?: string): Promise<void> {
373
+ const command = this.resolveBridgeCommand(commandOverride);
374
+ if (!command) {
375
+ throw new Error(
376
+ "BAMBU_NETWORK_BRIDGE_COMMAND is required for FULU BambuNetwork support. " +
377
+ "Point it at pjarczak_bambu_linux_host or the OrcaSlicer-bambulab macOS/WSL wrapper."
378
+ );
379
+ }
380
+
381
+ if (this.child && this.child.exitCode === null && this.commandLine === command) {
382
+ return;
383
+ }
384
+
385
+ if (this.child && this.commandLine !== command) {
386
+ await this.stop();
387
+ }
388
+
389
+ const shell = process.platform === "win32" ? process.env.ComSpec || "cmd.exe" : "/bin/sh";
390
+ const shellArgs = process.platform === "win32"
391
+ ? ["/d", "/s", "/c", command]
392
+ : ["-lc", command];
393
+
394
+ this.child = spawn(shell, shellArgs, {
395
+ env: {
396
+ ...process.env,
397
+ PJARCZAK_BAMBU_COUNTRY_CODE: this.resolveCountryCode(),
398
+ PJARCZAK_EXPECTED_BAMBU_NETWORK_VERSION:
399
+ this.inferredExpectedNetworkVersion ||
400
+ process.env.PJARCZAK_EXPECTED_BAMBU_NETWORK_VERSION ||
401
+ "",
402
+ },
403
+ stdio: ["pipe", "pipe", "pipe"],
404
+ });
405
+ this.commandLine = command;
406
+
407
+ const spawnedChild = this.child;
408
+ spawnedChild.stdout.on("data", (chunk: Buffer) => this.handleStdout(chunk));
409
+ spawnedChild.stderr.on("data", (chunk: Buffer) => this.rememberStderr(chunk.toString("utf8")));
410
+ spawnedChild.on("error", (error) => {
411
+ if (this.child === spawnedChild) {
412
+ this.rejectAllPending(error);
413
+ }
414
+ });
415
+ spawnedChild.on("close", (code, signal) => {
416
+ if (this.child !== spawnedChild) {
417
+ return;
418
+ }
419
+ this.rejectAllPending(
420
+ new Error(`FULU BambuNetwork bridge exited (code=${code ?? "null"}, signal=${signal ?? "null"}). ${this.lastStderr}`.trim())
421
+ );
422
+ this.child = undefined;
423
+ this.commandLine = undefined;
424
+ this.agentId = undefined;
425
+ this.agentKey = undefined;
426
+ this.handshake = undefined;
427
+ });
428
+ }
429
+
430
+ private handleStdout(chunk: Buffer): void {
431
+ this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
432
+
433
+ while (this.stdoutBuffer.length >= FRAME_HEADER_SIZE) {
434
+ const magic = this.stdoutBuffer.readUInt32LE(0);
435
+ if (magic !== FRAME_MAGIC) {
436
+ const preview = this.stdoutBuffer.subarray(0, Math.min(this.stdoutBuffer.length, 120)).toString("utf8");
437
+ this.rejectAllPending(new Error(`Invalid FULU BambuNetwork bridge frame magic. Output began with: ${preview}`));
438
+ this.stdoutBuffer = Buffer.alloc(0);
439
+ return;
440
+ }
441
+
442
+ const type = this.stdoutBuffer.readUInt32LE(4);
443
+ const id = this.stdoutBuffer.readUInt32LE(8);
444
+ const size = this.stdoutBuffer.readUInt32LE(12);
445
+ if (this.stdoutBuffer.length < FRAME_HEADER_SIZE + size) {
446
+ return;
447
+ }
448
+
449
+ const payload = this.stdoutBuffer.subarray(FRAME_HEADER_SIZE, FRAME_HEADER_SIZE + size);
450
+ this.stdoutBuffer = this.stdoutBuffer.subarray(FRAME_HEADER_SIZE + size);
451
+
452
+ if (type === FRAME_TYPE_JSON_RESPONSE) {
453
+ this.resolvePendingJson(id, payload);
454
+ } else if (type === FRAME_TYPE_LOG) {
455
+ this.rememberLog(payload.toString("utf8"));
456
+ } else if (type === FRAME_TYPE_BINARY_DATA) {
457
+ this.rememberLog(`received ${payload.length} bytes of bridge binary data for request ${id}`);
458
+ } else {
459
+ this.rememberLog(`ignored bridge frame type ${type} for request ${id}`);
460
+ }
461
+ }
462
+ }
463
+
464
+ private resolvePendingJson(id: number, payload: Buffer): void {
465
+ const pending = this.pending.get(id);
466
+ if (!pending) {
467
+ this.rememberLog(`received response for unknown bridge request ${id}`);
468
+ return;
469
+ }
470
+
471
+ clearTimeout(pending.timer);
472
+ this.pending.delete(id);
473
+
474
+ try {
475
+ pending.resolve(readFrameJson(payload));
476
+ } catch (error) {
477
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
478
+ }
479
+ }
480
+
481
+ private rememberLog(message: string): void {
482
+ const trimmed = message.trim();
483
+ if (!trimmed) return;
484
+ this.recentLogs.push(trimmed);
485
+ if (this.recentLogs.length > 25) {
486
+ this.recentLogs = this.recentLogs.slice(-25);
487
+ }
488
+ }
489
+
490
+ private rememberStderr(message: string): void {
491
+ const combined = `${this.lastStderr}${message}`;
492
+ this.lastStderr = combined.length > 4000 ? combined.slice(-4000) : combined;
493
+ }
494
+
495
+ private rejectAllPending(error: Error): void {
496
+ for (const [id, pending] of this.pending.entries()) {
497
+ clearTimeout(pending.timer);
498
+ pending.reject(error);
499
+ this.pending.delete(id);
500
+ }
501
+ }
502
+
503
+ private needsExpectedVersionRetry(handshake: unknown): handshake is {
504
+ network_actual_abi_version: string;
505
+ network_loaded?: boolean;
506
+ network_status?: string;
507
+ } {
508
+ if (typeof handshake !== "object" || handshake === null) {
509
+ return false;
510
+ }
511
+
512
+ const value = handshake as {
513
+ network_actual_abi_version?: unknown;
514
+ network_loaded?: unknown;
515
+ network_status?: unknown;
516
+ };
517
+
518
+ return (
519
+ value.network_loaded === false &&
520
+ typeof value.network_actual_abi_version === "string" &&
521
+ value.network_actual_abi_version.length > 0 &&
522
+ typeof value.network_status === "string" &&
523
+ value.network_status.toLowerCase().includes("expected abi version")
524
+ );
525
+ }
526
+ }