mcp-3d-printer-server 1.2.25 → 1.2.27

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/CONTRIBUTORS.md +5 -1
  3. package/README.md +64 -29
  4. package/dist/ams-mapping.d.ts +38 -0
  5. package/dist/ams-mapping.js +125 -0
  6. package/dist/bambu-network-bridge.d.ts +211 -0
  7. package/dist/bambu-network-bridge.js +918 -0
  8. package/dist/bambu-network-certs.d.ts +13 -0
  9. package/dist/bambu-network-certs.js +152 -0
  10. package/dist/control-reply.d.ts +21 -0
  11. package/dist/control-reply.js +44 -0
  12. package/dist/counterpart/camera-sources.d.ts +16 -0
  13. package/dist/counterpart/camera-sources.js +138 -0
  14. package/dist/counterpart/camera-stream.d.ts +66 -0
  15. package/dist/counterpart/camera-stream.js +246 -0
  16. package/dist/counterpart/camera-tools.d.ts +247 -0
  17. package/dist/counterpart/camera-tools.js +35 -0
  18. package/dist/counterpart/contract.d.ts +21 -5
  19. package/dist/counterpart/contract.js +8 -2
  20. package/dist/counterpart/route.d.ts +31 -0
  21. package/dist/counterpart/route.js +70 -0
  22. package/dist/counterpart/service.d.ts +60 -4
  23. package/dist/counterpart/service.js +142 -16
  24. package/dist/design-guidance.d.ts +26 -0
  25. package/dist/design-guidance.js +177 -0
  26. package/dist/fulu-orca.d.ts +7 -0
  27. package/dist/fulu-orca.js +18 -2
  28. package/dist/index.js +514 -70
  29. package/dist/printers/bambu-filaments.d.ts +68 -0
  30. package/dist/printers/bambu-filaments.js +262 -0
  31. package/dist/printers/bambu.d.ts +99 -6
  32. package/dist/printers/bambu.js +478 -72
  33. package/dist/printers/creality.d.ts +2 -1
  34. package/dist/printers/creality.js +5 -1
  35. package/dist/printers/generic-base.d.ts +11 -6
  36. package/dist/printers/generic-base.js +21 -25
  37. package/dist/printers/klipper.d.ts +2 -1
  38. package/dist/printers/klipper.js +10 -7
  39. package/dist/printers/octoprint.d.ts +2 -1
  40. package/dist/printers/octoprint.js +15 -8
  41. package/dist/printers/printer-factory.d.ts +1 -1
  42. package/dist/printers/printer-factory.js +3 -3
  43. package/dist/printers/prusa.js +0 -1
  44. package/dist/safety/confirmation.d.ts +11 -16
  45. package/dist/safety/confirmation.js +8 -32
  46. package/dist/safety/hidden-hms.d.ts +32 -0
  47. package/dist/safety/hidden-hms.js +405 -0
  48. package/dist/safety/limits.d.ts +8 -2
  49. package/dist/safety/limits.js +14 -5
  50. package/dist/safety/print-file.d.ts +5 -0
  51. package/dist/safety/print-file.js +156 -19
  52. package/dist/safety/printer-state.d.ts +68 -8
  53. package/dist/safety/printer-state.js +187 -40
  54. package/dist/slicer/bambu-cli.d.ts +2 -0
  55. package/dist/slicer/bambu-cli.js +7 -5
  56. package/dist/slicer/layer-pauses.d.ts +47 -0
  57. package/dist/slicer/layer-pauses.js +130 -0
  58. package/dist/stl/stl-manipulator.d.ts +1 -0
  59. package/dist/stl/stl-manipulator.js +40 -0
  60. package/dist/studio-print-options.d.ts +57 -0
  61. package/dist/studio-print-options.js +179 -0
  62. package/dist/tool-annotations.d.ts +25 -0
  63. package/dist/tool-annotations.js +58 -0
  64. package/package.json +27 -4
  65. package/server.json +2 -2
  66. package/src/ams-mapping.ts +132 -0
  67. package/src/bambu-network-bridge.ts +1106 -0
  68. package/src/bambu-network-certs.ts +154 -0
  69. package/src/control-reply.ts +41 -0
  70. package/src/counterpart/camera-sources.ts +102 -0
  71. package/src/counterpart/camera-stream.ts +195 -0
  72. package/src/counterpart/camera-tools.ts +35 -0
  73. package/src/counterpart/contract.ts +12 -4
  74. package/src/counterpart/route.ts +82 -0
  75. package/src/counterpart/service.ts +150 -16
  76. package/src/design-guidance.ts +188 -0
  77. package/src/fulu-orca.ts +18 -2
  78. package/src/index.ts +528 -83
  79. package/src/printers/bambu-filaments.ts +346 -0
  80. package/src/printers/bambu.ts +506 -81
  81. package/src/printers/creality.ts +8 -3
  82. package/src/printers/generic-base.ts +33 -40
  83. package/src/printers/klipper.ts +12 -8
  84. package/src/printers/octoprint.ts +16 -9
  85. package/src/printers/printer-factory.ts +3 -3
  86. package/src/printers/prusa.ts +0 -1
  87. package/src/safety/confirmation.ts +15 -38
  88. package/src/safety/hidden-hms.ts +406 -0
  89. package/src/safety/limits.ts +15 -5
  90. package/src/safety/print-file.ts +139 -17
  91. package/src/safety/printer-state.ts +192 -41
  92. package/src/slicer/bambu-cli.ts +8 -5
  93. package/src/slicer/layer-pauses.ts +143 -0
  94. package/src/stl/stl-manipulator.ts +45 -0
  95. package/src/studio-print-options.ts +201 -0
@@ -0,0 +1,918 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { BAMBU_NETWORK_CERTIFICATES } from "./bambu-network-certs.js";
7
+ import { discoveredFuluBridgeCommand, linuxPluginLibraryEnv } from "./fulu-orca.js";
8
+ import { controlReply, refusedControlMessage } from "./control-reply.js";
9
+ // Ported from bambu-printer-mcp src/bambu-network-bridge.ts (FULU's authenticated
10
+ // BambuNetwork route). Differences: the bridge command also reads this server's
11
+ // FULU_BAMBU_BRIDGE_COMMAND and its FULU install discovery (fulu-orca.ts), and the
12
+ // config folder is this server's own.
13
+ const FRAME_MAGIC = 0x52424a50;
14
+ const FRAME_HEADER_SIZE = 16;
15
+ const FRAME_TYPE_JSON_REQUEST = 1;
16
+ const FRAME_TYPE_JSON_RESPONSE = 2;
17
+ const FRAME_TYPE_BINARY_DATA = 3;
18
+ const FRAME_TYPE_LOG = 4;
19
+ const DEFAULT_REQUEST_TIMEOUT_MS = 120000;
20
+ // bambu_networking.hpp ConnectStatus.
21
+ const LOCAL_CONNECT_STATUS = ["ok", "failed", "lost"];
22
+ // bambu_networking.hpp BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED.
23
+ const LOCAL_PRINT_PUBLISH_FAILED = -4030;
24
+ // bambu_networking.hpp print errors that name a recoverable cause.
25
+ const BAMBU_NETWORK_PRINT_ERRORS = {
26
+ [-4010]: "the file exceeds the 1 GB upload limit",
27
+ [-4020]: "FTPS upload to the printer failed",
28
+ [LOCAL_PRINT_PUBLISH_FAILED]: "the networking plug-in could not send the print command to the printer over MQTT",
29
+ };
30
+ /** A parenthesized explanation of a plug-in print result, or "" when it names no known cause. */
31
+ export function describeBambuNetworkPrintError(value) {
32
+ return BAMBU_NETWORK_PRINT_ERRORS[value] ? ` (${BAMBU_NETWORK_PRINT_ERRORS[value]})` : "";
33
+ }
34
+ export const LOCAL_PRINT_TIMING = Object.freeze({ connectTimeoutMs: 30000, pollMs: 250, settleMs: 3000, retryWaitMs: 8000 });
35
+ export const LOCAL_CONTROL_TIMING = Object.freeze({ ...LOCAL_PRINT_TIMING, settleMs: 1000, retryWaitMs: 1500, certificateWaitMs: 5000, attempts: 3, deliveryMs: 5000 });
36
+ /** Bounded event text for diagnostics, with any access code removed. */
37
+ function traceText(text) {
38
+ const clean = text.replace(/("?access_code"?\s*[:=]\s*"?)[^",}\s]*/gi, "$1[redacted]");
39
+ // A signed printer reply (a header, then its payload) is the printer's answer to a command: keep it whole.
40
+ return clean.startsWith('{"header"') ? clean.slice(0, 4000) : clean.slice(0, 160);
41
+ }
42
+ export { controlReply, isAuthorizationRefusal } from "./control-reply.js";
43
+ /** Result of a sent control from the printer's answer during the hold; a refusal throws. */
44
+ function controlResult(command, route, reply, attempts) {
45
+ const word = `${command[0].toUpperCase()}${command.slice(1)}`;
46
+ if (reply && !reply.accepted)
47
+ throw new Error(refusedControlMessage(command, route, reply.errCode));
48
+ return { status: "success", attempts, confirmed: Boolean(reply),
49
+ message: reply ? `The printer accepted ${word}.` : `${word} submitted over ${route}. Check printer status to confirm it took effect.` };
50
+ }
51
+ export const CLOUD_PRINT_TIMING = Object.freeze({ connectTimeoutMs: 30000, pollMs: 250, settleMs: 3000, certificateWaitMs: 10000 });
52
+ let stateSequence = 0;
53
+ const nextSequence = () => String(++stateSequence);
54
+ // The signed Studio channel on P1S 01.10 accepts string IDs in [20000, 29999].
55
+ // Larger IDs receive 0502400A even when the RSA signature is valid. Share the
56
+ // counter across LAN/cloud controls and retries; never derive IDs from wall time.
57
+ let controlSequence = 19999;
58
+ const nextControlSequence = () => String(controlSequence = controlSequence >= 29999 ? 20000 : controlSequence + 1);
59
+ /**
60
+ * The plug-in's certificate folder: BAMBU_NETWORK_CERT_DIR when set, else
61
+ * Bambu's pinned public certificates written to the agent's config folder,
62
+ * which the host must already be able to read.
63
+ */
64
+ export function bambuNetworkCertDir(configDir, env = process.env) {
65
+ return trimEnv(env.BAMBU_NETWORK_CERT_DIR) || path.join(configDir, "cert");
66
+ }
67
+ export function prepareBambuNetworkCertDir(configDir, env = process.env) {
68
+ const certDir = bambuNetworkCertDir(configDir, env);
69
+ if (trimEnv(env.BAMBU_NETWORK_CERT_DIR))
70
+ return certDir;
71
+ fs.mkdirSync(certDir, { recursive: true });
72
+ for (const [name, { sha256, base64 }] of Object.entries(BAMBU_NETWORK_CERTIFICATES)) {
73
+ const content = Buffer.from(base64, "base64");
74
+ if (createHash("sha256").update(content).digest("hex") !== sha256)
75
+ throw new Error(`Bundled Bambu certificate ${name} does not match its pinned SHA-256.`);
76
+ const target = path.join(certDir, name);
77
+ if (!fileExists(target) || !fs.readFileSync(target).equals(content))
78
+ fs.writeFileSync(target, content);
79
+ }
80
+ return certDir;
81
+ }
82
+ export function defaultBambuNetworkConfigDir() {
83
+ return path.join(os.homedir(), ".config", "mcp-3d-printer-server", "bambu-network");
84
+ }
85
+ function shellQuote(value) {
86
+ return `'${value.replace(/'/g, "'\\''")}'`;
87
+ }
88
+ function trimEnv(value) {
89
+ const trimmed = value?.trim();
90
+ return trimmed ? trimmed : undefined;
91
+ }
92
+ /**
93
+ * The client identity FULU's GUI gives the plug-in before agent start
94
+ * (GUI_App::init_http_extra_header). Through the Linux bridge FULU presents
95
+ * itself as Bambu Studio at the version its plug-in ABI belongs to
96
+ * (PJarczakLinuxBridgeConfig.cpp forced_client_version, "02.05.02.51").
97
+ * Bambu's cloud issues the printer-control certificate and creates print
98
+ * tasks for this client, so an agent started without it is an unknown client.
99
+ * The device id is kept in the config folder so it stays the same per install.
100
+ * Ported from bambu-printer-mcp src/bambu-network-bridge.ts.
101
+ */
102
+ export function bambuNetworkClientHeaders(configDir, env = process.env) {
103
+ const idFile = path.join(configDir, "client-device-id");
104
+ let deviceId = "";
105
+ try {
106
+ deviceId = fs.readFileSync(idFile, "utf8").trim();
107
+ }
108
+ catch { /* first start */ }
109
+ if (!deviceId) {
110
+ deviceId = randomUUID();
111
+ try {
112
+ fs.mkdirSync(configDir, { recursive: true });
113
+ fs.writeFileSync(idFile, `${deviceId}\n`, { mode: 0o600 });
114
+ }
115
+ catch { /* still send this run's id */ }
116
+ }
117
+ return {
118
+ "X-BBL-Client-Type": "slicer",
119
+ "X-BBL-Client-Name": trimEnv(env.BAMBU_NETWORK_CLIENT_NAME) ?? "BambuStudio",
120
+ "X-BBL-Client-Version": trimEnv(env.BAMBU_NETWORK_CLIENT_VERSION) ?? "02.05.02.51",
121
+ "X-BBL-OS-Type": "linux",
122
+ "X-BBL-OS-Version": os.release(),
123
+ "X-BBL-Device-ID": deviceId,
124
+ "X-BBL-Language": "en",
125
+ };
126
+ }
127
+ function fileExists(filePath) {
128
+ try {
129
+ return fs.existsSync(filePath);
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
135
+ function resolveFirstExistingDirectory(candidates) {
136
+ return candidates.find((candidate) => fileExists(candidate) && fs.statSync(candidate).isDirectory());
137
+ }
138
+ function resolveFirstDirectoryWithFile(candidates, requiredFile) {
139
+ return candidates.find((candidate) => fileExists(candidate) &&
140
+ fs.statSync(candidate).isDirectory() &&
141
+ fileExists(path.join(candidate, requiredFile)));
142
+ }
143
+ function collectMissingFiles(baseDir, files) {
144
+ if (!baseDir)
145
+ return files;
146
+ return files.filter((file) => !fileExists(path.join(baseDir, file)));
147
+ }
148
+ /**
149
+ * FULU's Linux host opens its network and source libraries by bare name from
150
+ * its working directory instead of PJARCZAK_BAMBU_PLUGIN_DIR
151
+ * (FULU-Foundation/OrcaSlicer-bambulab#51), so name them in that directory
152
+ * unless they are already set. macOS wrappers run the host in a Linux guest
153
+ * that may not see host paths, so they are left alone.
154
+ */
155
+ /** FULU's Linux host exits 101 when it cannot reopen a socket stdout (FULU-Foundation/OrcaSlicer-bambulab#50). */
156
+ const STDOUT_SOCKET_HINT = "Exit code 101 means FULU's Linux host could not reopen its stdout, which fails when stdout is a socket, as Node.js provides " +
157
+ "(https://github.com/FULU-Foundation/OrcaSlicer-bambulab/issues/50). Launch the host through a wrapper that pipes its output through cat: " +
158
+ "https://github.com/DMontgomery40/mcp-3D-printer-server/blob/main/docs/FULU.md#linux";
159
+ /** Conventional FULU macOS runtime locations; the wrapper command is suggested only when it exists. */
160
+ function macosRuntimeHints() {
161
+ const macosRuntimeDir = trimEnv(process.env.PJARCZAK_MAC_RUNTIME_DIR) ||
162
+ path.join(os.homedir(), "Library", "Application Support", "OrcaSlicer", "macos-bridge", "runtime");
163
+ const macosPluginDir = trimEnv(process.env.BAMBU_NETWORK_PLUGIN_DIR) ||
164
+ trimEnv(process.env.PJARCZAK_BAMBU_PLUGIN_DIR) ||
165
+ resolveFirstDirectoryWithFile([
166
+ path.join(os.homedir(), "Library", "Application Support", "OrcaSlicer", "plugins"),
167
+ "/Applications/Orca Studio.app/Contents/MacOS",
168
+ "/Applications/OrcaSlicer.app/Contents/MacOS",
169
+ "/Applications/OrcaSlicer-BMCU.app/Contents/MacOS",
170
+ "/Applications/Orca Studio.app/Contents/Resources",
171
+ "/Applications/OrcaSlicer.app/Contents/Resources",
172
+ "/Applications/OrcaSlicer-BMCU.app/Contents/Resources",
173
+ macosRuntimeDir,
174
+ ], "pjarczak-bambu-linux-host-wrapper") ||
175
+ resolveFirstExistingDirectory([macosRuntimeDir]);
176
+ const runtimeRequiredFiles = [
177
+ "libbambu_networking.so",
178
+ "libBambuSource.so",
179
+ "pjarczak_bambu_linux_host",
180
+ "pjarczak_bambu_linux_host_abi1",
181
+ "pjarczak_bambu_linux_host_abi0",
182
+ "ca-certificates.crt",
183
+ "slicer_base64.cer",
184
+ ];
185
+ const pluginRequiredFiles = [
186
+ "pjarczak-bambu-linux-host-wrapper",
187
+ "install_runtime_macos.sh",
188
+ "verify_runtime_macos.sh",
189
+ ];
190
+ const wrapper = macosPluginDir ? path.join(macosPluginDir, "pjarczak-bambu-linux-host-wrapper") : undefined;
191
+ return {
192
+ macosRuntimeDir,
193
+ macosRuntimeDirExists: fileExists(macosRuntimeDir),
194
+ macosPluginDir,
195
+ macosPluginDirExists: macosPluginDir ? fileExists(macosPluginDir) : undefined,
196
+ macosMissingRuntimeFiles: collectMissingFiles(macosRuntimeDir, runtimeRequiredFiles),
197
+ macosMissingPluginFiles: collectMissingFiles(macosPluginDir, pluginRequiredFiles),
198
+ suggestedMacCommand: wrapper && fileExists(wrapper)
199
+ ? `PJARCZAK_BAMBU_PLUGIN_DIR=${shellQuote(macosRuntimeDir)} ${shellQuote(wrapper)} ${shellQuote(path.join(macosRuntimeDir, "pjarczak_bambu_linux_host"))}`
200
+ : undefined,
201
+ };
202
+ }
203
+ function readFrameJson(payload) {
204
+ const text = payload.toString("utf8");
205
+ return text.length > 0 ? JSON.parse(text) : {};
206
+ }
207
+ function isBridgeFailure(value) {
208
+ return (typeof value === "object" &&
209
+ value !== null &&
210
+ value.ok === false);
211
+ }
212
+ function bridgeValueAsNumber(value, method) {
213
+ if (typeof value === "object" &&
214
+ value !== null &&
215
+ typeof value.value === "number") {
216
+ return value.value;
217
+ }
218
+ throw new Error(`FULU BambuNetwork bridge method ${method} did not return a numeric value.`);
219
+ }
220
+ function bridgeNumericResult(value) {
221
+ return typeof value === "object" && value !== null && typeof value.value === "number"
222
+ ? value.value
223
+ : undefined;
224
+ }
225
+ function bridgeValueIsTrue(value) {
226
+ return typeof value === "object" && value !== null && value.value === true;
227
+ }
228
+ function assertBridgeOk(value, method) {
229
+ if (isBridgeFailure(value)) {
230
+ const errorText = value.error === undefined ? "unknown bridge error" : String(value.error);
231
+ throw new Error(`FULU BambuNetwork bridge method ${method} failed: ${errorText}`);
232
+ }
233
+ }
234
+ export class BambuNetworkBridge {
235
+ constructor() {
236
+ this.stdoutBuffer = Buffer.alloc(0);
237
+ this.pending = new Map();
238
+ this.nextRequestId = 1;
239
+ this.lastStderr = "";
240
+ this.recentLogs = [];
241
+ /** Whether the host accepted the certificate folder and callbacks a LAN session needs. */
242
+ this.localSessionReady = false;
243
+ /**
244
+ * The plug-in holds one LAN printer connection and the host one event queue,
245
+ * so LAN sessions run one at a time, as Bambu Studio connects one printer.
246
+ */
247
+ this.localSessionTail = Promise.resolve();
248
+ }
249
+ resolveBridgeCommand(override) {
250
+ return (trimEnv(override) ||
251
+ trimEnv(process.env.BAMBU_NETWORK_BRIDGE_COMMAND) ||
252
+ trimEnv(process.env.FULU_BAMBU_NETWORK_BRIDGE_COMMAND) ||
253
+ trimEnv(process.env.PJARCZAK_BAMBU_BRIDGE_COMMAND) ||
254
+ trimEnv(process.env.FULU_BAMBU_BRIDGE_COMMAND) ||
255
+ discoveredFuluBridgeCommand() ||
256
+ "");
257
+ }
258
+ resolveConfigDir(override) {
259
+ return (trimEnv(override) ||
260
+ trimEnv(process.env.BAMBU_NETWORK_CONFIG_DIR) ||
261
+ trimEnv(process.env.PJARCZAK_BAMBU_PROBE_LOG_DIR) ||
262
+ defaultBambuNetworkConfigDir());
263
+ }
264
+ resolveCountryCode(override) {
265
+ return (trimEnv(override) ||
266
+ trimEnv(process.env.BAMBU_NETWORK_COUNTRY_CODE) ||
267
+ trimEnv(process.env.PJARCZAK_BAMBU_COUNTRY_CODE) ||
268
+ "US");
269
+ }
270
+ getStatus(options = {}) {
271
+ const command = this.resolveBridgeCommand(options.bridgeCommand);
272
+ const configDir = this.resolveConfigDir(options.configDir);
273
+ const countryCode = this.resolveCountryCode(options.countryCode);
274
+ const runtime = { certDir: bambuNetworkCertDir(configDir) };
275
+ if (process.platform === "linux") {
276
+ const pluginLibraries = {
277
+ ...Object.fromEntries(["PJARCZAK_BAMBU_NETWORK_SO", "PJARCZAK_BAMBU_SOURCE_SO"].flatMap(name => {
278
+ const value = trimEnv(process.env[name]);
279
+ return value ? [[name, value]] : [];
280
+ })),
281
+ ...linuxPluginLibraryEnv(process.env, process.platform),
282
+ };
283
+ if (Object.keys(pluginLibraries).length)
284
+ runtime.pluginLibraries = pluginLibraries;
285
+ }
286
+ if (process.platform === "darwin")
287
+ Object.assign(runtime, macosRuntimeHints());
288
+ return {
289
+ configured: command.length > 0,
290
+ command,
291
+ platform: process.platform,
292
+ running: Boolean(this.child && this.child.exitCode === null),
293
+ pid: this.child?.pid,
294
+ configDir,
295
+ countryCode,
296
+ agentReady: this.agentId !== undefined,
297
+ lastStderr: this.lastStderr,
298
+ recentLogs: this.recentLogs.slice(-10),
299
+ runtime,
300
+ note: command
301
+ ? undefined
302
+ : "Set BAMBU_NETWORK_BRIDGE_COMMAND to the FULU OrcaSlicer-bambulab Linux host or macOS/WSL wrapper command.",
303
+ };
304
+ }
305
+ async request(method, payload = {}, options = {}) {
306
+ if (!method.trim()) {
307
+ throw new Error("BambuNetwork bridge method is required.");
308
+ }
309
+ await this.ensureStarted(options.bridgeCommand);
310
+ await options.beforeDispatch?.(method);
311
+ const id = this.nextRequestId++;
312
+ const requestPayload = Buffer.from(JSON.stringify({ method, payload }), "utf8");
313
+ const frame = Buffer.alloc(FRAME_HEADER_SIZE + requestPayload.length);
314
+ frame.writeUInt32LE(FRAME_MAGIC, 0);
315
+ frame.writeUInt32LE(FRAME_TYPE_JSON_REQUEST, 4);
316
+ frame.writeUInt32LE(id, 8);
317
+ frame.writeUInt32LE(requestPayload.length, 12);
318
+ requestPayload.copy(frame, FRAME_HEADER_SIZE);
319
+ return await new Promise((resolve, reject) => {
320
+ const timer = setTimeout(() => {
321
+ this.pending.delete(id);
322
+ reject(new Error(`Timed out waiting for FULU BambuNetwork bridge method ${method}.`));
323
+ }, options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
324
+ this.pending.set(id, { method, resolve, reject, timer });
325
+ try {
326
+ options.assertDispatchAllowed?.(method);
327
+ this.child.stdin.write(frame, (error) => {
328
+ if (error) {
329
+ const pending = this.pending.get(id);
330
+ if (pending) {
331
+ clearTimeout(pending.timer);
332
+ this.pending.delete(id);
333
+ pending.reject(error);
334
+ }
335
+ }
336
+ });
337
+ options.onDispatched?.(method);
338
+ }
339
+ catch (error) {
340
+ clearTimeout(timer);
341
+ this.pending.delete(id);
342
+ reject(error instanceof Error ? error : new Error(String(error)));
343
+ }
344
+ });
345
+ }
346
+ async ensureAgent(options = {}) {
347
+ const command = this.resolveBridgeCommand(options.bridgeCommand);
348
+ const configDir = this.resolveConfigDir(options.configDir);
349
+ const countryCode = this.resolveCountryCode(options.countryCode);
350
+ const userInfo = trimEnv(options.userInfo) || trimEnv(process.env.BAMBU_NETWORK_USER_INFO) || "";
351
+ const certDir = bambuNetworkCertDir(configDir);
352
+ const key = `${command}\n${configDir}\n${countryCode}\n${userInfo}\n${certDir}`;
353
+ if (this.agentId !== undefined && this.agentKey === key) {
354
+ return { agent: this.agentId, handshake: this.handshake };
355
+ }
356
+ fs.mkdirSync(configDir, { recursive: true });
357
+ prepareBambuNetworkCertDir(configDir);
358
+ this.handshake = await this.request("bridge.handshake", {}, options);
359
+ assertBridgeOk(this.handshake, "bridge.handshake");
360
+ if (this.needsExpectedVersionRetry(this.handshake) && !trimEnv(process.env.PJARCZAK_EXPECTED_BAMBU_NETWORK_VERSION)) {
361
+ this.inferredExpectedNetworkVersion = this.handshake.network_actual_abi_version;
362
+ await this.stop();
363
+ this.handshake = await this.request("bridge.handshake", {}, options);
364
+ assertBridgeOk(this.handshake, "bridge.handshake");
365
+ }
366
+ const loaded = this.handshake;
367
+ if (loaded?.network_loaded === false && typeof loaded.network_status === "string" && /file open failed/i.test(loaded.network_status)) {
368
+ throw new Error(`FULU BambuNetwork bridge could not open its networking library (${loaded.network_status}). ` +
369
+ "Set PJARCZAK_BAMBU_PLUGIN_DIR to the directory containing libbambu_networking.so and libBambuSource.so, " +
370
+ "or set PJARCZAK_BAMBU_NETWORK_SO and PJARCZAK_BAMBU_SOURCE_SO to their absolute paths: " +
371
+ "https://github.com/DMontgomery40/mcp-3D-printer-server/blob/main/docs/FULU.md#linux");
372
+ }
373
+ const created = await this.request("net.create_agent", { log_dir: configDir, country_code: countryCode }, options);
374
+ assertBridgeOk(created, "net.create_agent");
375
+ const agent = bridgeValueAsNumber(created, "net.create_agent");
376
+ const withAgent = async (method, payload = {}, hardFail = true) => {
377
+ const response = await this.request(method, { ...payload, agent }, options);
378
+ if (hardFail)
379
+ assertBridgeOk(response, method);
380
+ return response;
381
+ };
382
+ await withAgent("net.set_config_dir", { config_dir: configDir });
383
+ await withAgent("net.init_log");
384
+ // Bambu Studio and FULU's GUI set this before starting the agent. The
385
+ // plug-in verifies a LAN printer with printer.cer from this folder, and
386
+ // FULU's host crashes on connect_printer when no folder was ever set.
387
+ const certificates = await withAgent("net.set_cert_file", { folder: certDir, filename: "slicer_base64.cer" }, false);
388
+ // FULU's GUI order: set_cert_file, then the client headers, then start.
389
+ await withAgent("net.set_extra_http_header", { headers: bambuNetworkClientHeaders(configDir) }, false);
390
+ await withAgent("net.set_country_code", { country_code: countryCode });
391
+ await withAgent("net.start");
392
+ // The plug-in runs callbacks through queue_on_main; FULU's host executes
393
+ // them on its own dispatcher and queues on_local_connect for poll_events.
394
+ // Cloud jobs and probes do not depend on these, so a host without them
395
+ // still starts; a LAN session refuses before connecting instead.
396
+ const callbacks = [await withAgent("net.set_queue_on_main_fn", {}, false), await withAgent("net.set_on_local_connect_fn", {}, false)];
397
+ const localSessionReady = ![certificates, ...callbacks].some(isBridgeFailure);
398
+ await withAgent("net.connect_server", {}, false);
399
+ if (userInfo) {
400
+ await withAgent("net.change_user", { user_info: userInfo });
401
+ }
402
+ this.agentId = agent;
403
+ this.agentKey = key;
404
+ this.localSessionReady = localSessionReady;
405
+ return { agent, handshake: this.handshake };
406
+ }
407
+ /**
408
+ * Open the plug-in's own LAN MQTT session with a printer, in the order Bambu
409
+ * Studio and native/bambu-native-print.cpp use: connect, install the
410
+ * printer's application certificate (again on each "wait_info"), wait for
411
+ * the local connection, request the printer's state and let the key
412
+ * exchange settle. Without the certificate the plug-in uploads a job but
413
+ * cannot encrypt or publish project_file (-4030). The session is closed
414
+ * after `run`, so printer reports stop accumulating in the host's queue.
415
+ */
416
+ async withLocalSession(printer, options, assertActive, timing, run) {
417
+ const previous = this.localSessionTail;
418
+ let release;
419
+ this.localSessionTail = new Promise(resolve => { release = resolve; });
420
+ try {
421
+ await previous;
422
+ return await this.runLocalSession(printer, options, assertActive, timing, run);
423
+ }
424
+ finally {
425
+ release();
426
+ }
427
+ }
428
+ async runLocalSession(printer, options, assertActive, timing, run) {
429
+ const jobUpdates = [];
430
+ const controlMessages = [];
431
+ let certificateReady = false;
432
+ const installCertificate = async () => {
433
+ await this.callWithAgent("net.install_device_cert", { dev_id: printer.devId, lan_only: true }, options);
434
+ await this.callWithAgent("net.update_cert", {}, options);
435
+ };
436
+ // Drain queued events: collect job reports, return this printer's
437
+ // local-connect statuses, and reinstall the certificate on "wait_info".
438
+ const pump = async () => {
439
+ const polled = await this.request("bridge.poll_events", { limit: 1024 }, options);
440
+ assertBridgeOk(polled, "bridge.poll_events");
441
+ const statuses = [];
442
+ let waitInfo = false;
443
+ for (const event of Array.isArray(polled?.events) ? polled.events : []) {
444
+ options.trace?.({ event: event?.name, devMatch: event?.payload?.dev_id === undefined ? "none" : event.payload.dev_id === printer.devId,
445
+ ...(event?.payload?.status !== undefined ? { status: event.payload.status } : {}), ...(typeof event?.payload?.msg === "string" ? { msg: traceText(event.payload.msg) } : {}) });
446
+ if (event?.name === "job.update_status")
447
+ jobUpdates.push({ status: event.payload?.status, code: event.payload?.code, msg: event.payload?.msg });
448
+ const eventDev = event?.payload?.dev_id;
449
+ if (eventDev !== undefined && eventDev !== "" && eventDev !== printer.devId)
450
+ continue;
451
+ // FULU v1.0.0 GUI_App's certificate-state callback. A connect callback
452
+ // alone does not establish that protected controls can be encrypted.
453
+ if (eventDev === printer.devId && (event?.name === "on_local_message" || event?.name === "on_message")) {
454
+ if (event.payload?.msg === "device_cert_installed")
455
+ certificateReady = true;
456
+ if (event.payload?.msg === "device_cert_uninstalled")
457
+ certificateReady = false;
458
+ if (typeof event.payload?.msg === "string" && /"command":"(stop|pause|resume)"/.test(event.payload.msg))
459
+ controlMessages.push(event.payload.msg);
460
+ }
461
+ if (event?.name === "on_local_connect")
462
+ statuses.push(event.payload.status);
463
+ if ((event?.name === "on_local_message" || event?.name === "on_message") && event.payload?.msg === "wait_info")
464
+ waitInfo = true;
465
+ }
466
+ if (waitInfo)
467
+ await installCertificate();
468
+ return statuses;
469
+ };
470
+ const settle = async (ms) => {
471
+ for (const deadline = Date.now() + ms; Date.now() < deadline;) {
472
+ assertActive();
473
+ await pump();
474
+ await new Promise(resolve => setTimeout(resolve, timing.pollMs));
475
+ }
476
+ };
477
+ const certificateReadyWithin = async (ms) => {
478
+ const deadline = Date.now() + ms;
479
+ do {
480
+ assertActive();
481
+ await pump();
482
+ if (certificateReady)
483
+ return true;
484
+ await new Promise(resolve => setTimeout(resolve, timing.pollMs));
485
+ } while (Date.now() < deadline);
486
+ return certificateReady;
487
+ };
488
+ await this.ensureAgent(options);
489
+ assertActive();
490
+ if (!this.localSessionReady) {
491
+ throw new Error("FULU BambuNetwork bridge host did not accept the certificate folder or local-connection callbacks a LAN print needs. Update the FULU runtime; no print command was sent.");
492
+ }
493
+ for (const callback of ["net.set_on_printer_connected_fn", "net.set_on_message_fn", "net.set_on_local_message_fn"]) {
494
+ assertBridgeOk(await this.callWithAgent(callback, {}, options), callback);
495
+ }
496
+ await this.callWithAgent("net.update_cert", {}, options);
497
+ await pump();
498
+ certificateReady = false; // Ignore completion queued by an earlier session.
499
+ assertActive();
500
+ const requested = await this.callWithAgent("net.connect_printer", {
501
+ dev_id: printer.devId, dev_ip: printer.devIp, username: printer.username, password: printer.password, use_ssl: printer.useSsl,
502
+ }, options);
503
+ assertBridgeOk(requested, "net.connect_printer");
504
+ const connectResult = bridgeValueAsNumber(requested, "net.connect_printer");
505
+ if (connectResult !== 0) {
506
+ throw new Error(`FULU BambuNetwork bridge could not start a LAN connection to printer ${printer.devId} (net.connect_printer returned ${connectResult}). No print command was sent.`);
507
+ }
508
+ let connected = false;
509
+ try {
510
+ await installCertificate();
511
+ const deadline = Date.now() + timing.connectTimeoutMs;
512
+ while (!connected) {
513
+ assertActive();
514
+ for (const status of await pump()) {
515
+ if (status === 0) {
516
+ connected = true;
517
+ continue;
518
+ }
519
+ const reason = typeof status === "number" && LOCAL_CONNECT_STATUS[status] ? LOCAL_CONNECT_STATUS[status] : `reported status ${status}`;
520
+ throw new Error(`FULU BambuNetwork bridge LAN connection to printer ${printer.devId} at ${printer.devIp} ${reason}. Check the printer IP, LAN access code and LAN mode. No print command was sent.`);
521
+ }
522
+ if (connected)
523
+ break;
524
+ if (Date.now() >= deadline) {
525
+ throw new Error(`FULU BambuNetwork bridge did not connect to printer ${printer.devId} at ${printer.devIp} over LAN within ${Math.round(timing.connectTimeoutMs / 1000)} s. No print command was sent.`);
526
+ }
527
+ await new Promise(resolve => setTimeout(resolve, timing.pollMs));
528
+ }
529
+ // Bambu Studio asks a newly connected printer for its full state and
530
+ // security metadata before building the encrypted project_file message.
531
+ for (const [message, qos] of [
532
+ [{ pushing: { sequence_id: "1", command: "pushall", version: 1, push_target: 1 } }, 0],
533
+ [{ info: { sequence_id: "2", command: "get_version" } }, 1],
534
+ [{ system: { sequence_id: "3", command: "get_access_code" } }, 0],
535
+ ]) {
536
+ await this.callWithAgent("net.send_message_to_printer", { dev_id: printer.devId, msg: JSON.stringify(message), qos, flag: 0 }, options);
537
+ }
538
+ await settle(timing.settleMs);
539
+ const result = await run({ installCertificate, settle, certificateReadyWithin, controlMessages, jobUpdates });
540
+ await pump();
541
+ return result;
542
+ }
543
+ finally {
544
+ const cleanupFailed = (step) => (error) => console.error(`FULU BambuNetwork bridge ${step} after the LAN session failed: ${error instanceof Error ? error.message : error}`);
545
+ if (connected)
546
+ await this.callWithAgent("net.disconnect_printer", {}, options).catch(cleanupFailed("disconnect"));
547
+ await this.request("bridge.poll_events", { limit: 4096 }, options).catch(cleanupFailed("event cleanup"));
548
+ }
549
+ }
550
+ /**
551
+ * Run a LAN print method in a local session. A synchronous -4030 means the
552
+ * plug-in published nothing, so it is retried once after the certificate
553
+ * exchange, as the native helper does; each attempt passes the caller's
554
+ * fresh pre-dispatch checks.
555
+ */
556
+ async startLocalPrint(printer, method, payload, options = {}, assertActive = () => { }, timing = LOCAL_PRINT_TIMING) {
557
+ return await this.withLocalSession(printer, options, assertActive, timing, async (session) => {
558
+ let result = await this.callWithAgent(method, payload, options);
559
+ if (bridgeNumericResult(result) === LOCAL_PRINT_PUBLISH_FAILED) {
560
+ await session.settle(timing.retryWaitMs);
561
+ await session.installCertificate();
562
+ await session.settle(timing.settleMs);
563
+ result = await this.callWithAgent(method, payload, options);
564
+ }
565
+ return { result, jobUpdates: session.jobUpdates };
566
+ });
567
+ }
568
+ /**
569
+ * A print control (stop, pause, resume) through the same authenticated LAN
570
+ * session as a FULU print. FULU's own app sends controls exactly this way
571
+ * (send_message_to_printer, qos 1, flag 0) on a connection whose certificate
572
+ * is installed. A fresh connection can report SEND_MSG_FAILED (-4) or
573
+ * LP_PUBLISH_MSG_FAILED (-4030) while that exchange finishes: those returns
574
+ * mean nothing was sent, so the certificate is reinstalled and the control
575
+ * tried again, as the native X2D helper does. Any other return is final.
576
+ * beforeDispatch runs before every send, so a resume re-checks the paused job.
577
+ */
578
+ async sendLocalPrintCommand(printer, command, options = {}, beforeDispatch, timing = LOCAL_CONTROL_TIMING) {
579
+ const assertActive = () => options.assertDispatchAllowed?.("net.send_message_to_printer");
580
+ const trace = options.trace ?? this.controlTrace(options);
581
+ const traced = { ...options, trace };
582
+ return this.withLocalSession(printer, traced, assertActive, timing, async (session) => {
583
+ const ready = await session.certificateReadyWithin(timing.certificateWaitMs);
584
+ trace({ step: "certificate", ready });
585
+ const method = "net.send_message_to_printer";
586
+ let code = 0;
587
+ for (let attempt = 1; attempt <= timing.attempts; attempt++) {
588
+ await beforeDispatch?.();
589
+ assertActive();
590
+ const sequenceId = nextControlSequence();
591
+ const result = await this.callWithAgent(method, {
592
+ dev_id: printer.devId,
593
+ msg: JSON.stringify({ print: { command, param: "", sequence_id: sequenceId } }),
594
+ qos: 1, flag: 0,
595
+ }, traced);
596
+ assertBridgeOk(result, method);
597
+ code = bridgeValueAsNumber(result, method);
598
+ trace({ step: "send", command, attempt, code });
599
+ if (code === 0) {
600
+ // The plug-in queues the publish; disconnecting at once dropped it (Nov 3: five accepted
601
+ // controls, printer never paused or stopped). Stay connected while it reaches the printer.
602
+ await session.settle(timing.deliveryMs);
603
+ trace({ step: "delivered", command, heldMs: timing.deliveryMs });
604
+ const reply = session.controlMessages.map(msg => controlReply(msg, command, sequenceId)).find(Boolean);
605
+ return controlResult(command, "the local network", reply, attempt);
606
+ }
607
+ if ((code !== -4 && code !== -4030) || attempt === timing.attempts)
608
+ break;
609
+ await session.settle(timing.retryWaitMs);
610
+ await session.installCertificate();
611
+ await session.certificateReadyWithin(timing.certificateWaitMs);
612
+ }
613
+ throw new Error(`The printer did not accept ${command} over the authenticated FULU LAN connection (plug-in code ${code}).`);
614
+ });
615
+ }
616
+ /**
617
+ * Open the plug-in's Bambu Cloud session with a printer bound to the account, in the order
618
+ * Bambu Studio uses (GUI_App.cpp on_server_connected / on_printer_connected): wait for the
619
+ * cloud connection, select and subscribe to the printer, and when it connects request its
620
+ * state (pushall, get_version, get_access_code) and install its certificate for cloud use.
621
+ * Without that state the plug-in refuses a cloud print with -3140 (ENC_FLAG_NOT_READY).
622
+ * Shares the LAN session queue: one reader of the plug-in's events at a time.
623
+ */
624
+ async withCloudSession(devId, options, assertActive, timing, run) {
625
+ const previous = this.localSessionTail;
626
+ let release;
627
+ this.localSessionTail = new Promise(resolve => { release = resolve; });
628
+ try {
629
+ await previous;
630
+ await this.ensureAgent(options);
631
+ assertActive();
632
+ if (!bridgeValueIsTrue(await this.callWithAgent("net.is_user_login", {}, options))) {
633
+ throw new Error("FULU's networking plug-in is not signed in to Bambu Cloud, so nothing was sent to the printer.");
634
+ }
635
+ for (const callback of ["net.set_on_printer_connected_fn", "net.set_on_message_fn", "net.set_on_server_connected_fn"]) {
636
+ assertBridgeOk(await this.callWithAgent(callback, {}, options), callback);
637
+ }
638
+ const messages = [];
639
+ const jobUpdates = [];
640
+ let printerConnected = false;
641
+ let certificateReady = false;
642
+ const pump = async () => {
643
+ const polled = await this.request("bridge.poll_events", { limit: 1024 }, options);
644
+ assertBridgeOk(polled, "bridge.poll_events");
645
+ for (const event of Array.isArray(polled?.events) ? polled.events : []) {
646
+ const eventDev = event?.payload?.dev_id ?? (typeof event?.payload?.topic_str === "string" ? event.payload.topic_str.replace(/^tunnel\//, "") : undefined);
647
+ options.trace?.({ event: event?.name, devMatch: eventDev === undefined ? "none" : eventDev === devId, ...(typeof event?.payload?.msg === "string" ? { msg: traceText(event.payload.msg) } : {}) });
648
+ if (event?.name === "job.update_status")
649
+ jobUpdates.push({ status: event.payload?.status, code: event.payload?.code, msg: event.payload?.msg });
650
+ if (eventDev !== devId)
651
+ continue;
652
+ if (event?.name === "on_printer_connected")
653
+ printerConnected = true;
654
+ if (event?.name === "on_message" && typeof event.payload?.msg === "string") {
655
+ if (event.payload.msg === "device_cert_installed")
656
+ certificateReady = true;
657
+ else if (event.payload.msg === "device_cert_uninstalled")
658
+ certificateReady = false;
659
+ else
660
+ messages.push(event.payload.msg);
661
+ }
662
+ }
663
+ };
664
+ const waitFor = async (done, ms) => {
665
+ for (const deadline = Date.now() + ms; !done() && Date.now() < deadline;) {
666
+ assertActive();
667
+ await pump();
668
+ if (!done())
669
+ await new Promise(resolve => setTimeout(resolve, timing.pollMs));
670
+ }
671
+ return done();
672
+ };
673
+ const settle = async (ms) => { await waitFor(() => false, ms); };
674
+ for (const deadline = Date.now() + timing.connectTimeoutMs; !bridgeValueIsTrue(await this.callWithAgent("net.is_server_connected", {}, options));) {
675
+ if (Date.now() >= deadline)
676
+ throw new Error(`FULU's networking plug-in could not reach Bambu Cloud within ${Math.round(timing.connectTimeoutMs / 1000)} s, so nothing was sent to the printer.`);
677
+ assertActive();
678
+ await new Promise(resolve => setTimeout(resolve, timing.pollMs));
679
+ }
680
+ await this.callWithAgent("net.set_user_selected_machine", { dev_id: devId }, options);
681
+ await this.callWithAgent("net.add_subscribe", { devs: [devId] }, options);
682
+ if (!await waitFor(() => printerConnected, timing.connectTimeoutMs)) {
683
+ throw new Error(`Bambu Cloud did not connect to printer ${devId} within ${Math.round(timing.connectTimeoutMs / 1000)} s. Check that the printer is online and bound to this account. Nothing was sent to the printer.`);
684
+ }
685
+ for (const [message, qos] of [
686
+ [{ pushing: { sequence_id: nextSequence(), command: "pushall", version: 1, push_target: 1 } }, 0],
687
+ [{ info: { sequence_id: nextSequence(), command: "get_version" } }, 1],
688
+ [{ system: { sequence_id: nextSequence(), command: "get_access_code" } }, 0],
689
+ ]) {
690
+ await this.callWithAgent("net.send_message", { dev_id: devId, msg: JSON.stringify(message), qos, flag: 0 }, options);
691
+ }
692
+ const installCertificate = async () => { await this.callWithAgent("net.install_device_cert", { dev_id: devId, lan_only: false }, options); };
693
+ const certificateReadyWithin = (ms) => waitFor(() => certificateReady, ms);
694
+ await installCertificate();
695
+ const ready = await certificateReadyWithin(timing.certificateWaitMs);
696
+ options.trace?.({ step: "certificate", route: "cloud", ready });
697
+ await settle(timing.settleMs);
698
+ const result = await run({ settle, messages, jobUpdates, installCertificate, certificateReadyWithin });
699
+ await pump();
700
+ return result;
701
+ }
702
+ finally {
703
+ release();
704
+ }
705
+ }
706
+ /** A cloud print method (start_print) in a cloud session, so the plug-in has the printer's state. */
707
+ async startCloudPrint(devId, method, payload, options = {}, assertActive = () => { }, timing = CLOUD_PRINT_TIMING) {
708
+ return await this.withCloudSession(devId, options, assertActive, timing, async (session) => ({ result: await this.callWithAgent(method, payload, options), jobUpdates: session.jobUpdates }));
709
+ }
710
+ /**
711
+ * A print control over Bambu Cloud: how Bambu Studio and FULU's slicer control a printer
712
+ * bound to the account (MachineObject::publish_json → cloud_publish_json: send_message,
713
+ * qos 1, flag 0), in a cloud session. Held open like the LAN control.
714
+ */
715
+ async sendCloudPrintCommand(devId, command, options = {}, beforeDispatch, timing = LOCAL_CONTROL_TIMING) {
716
+ const method = "net.send_message";
717
+ const assertActive = () => options.assertDispatchAllowed?.(method);
718
+ const trace = options.trace ?? this.controlTrace(options);
719
+ const traced = { ...options, trace };
720
+ return this.withCloudSession(devId, traced, assertActive, timing, async (session) => {
721
+ let code = 0;
722
+ for (let attempt = 1; attempt <= timing.attempts; attempt++) {
723
+ await beforeDispatch?.();
724
+ assertActive();
725
+ const sequenceId = nextControlSequence();
726
+ const result = await this.callWithAgent(method, {
727
+ dev_id: devId, msg: JSON.stringify({ print: { command, param: "", sequence_id: sequenceId } }), qos: 1, flag: 0,
728
+ }, traced);
729
+ assertBridgeOk(result, method);
730
+ code = bridgeValueAsNumber(result, method);
731
+ trace({ step: "send", route: "cloud", command, attempt, code });
732
+ if (code === 0) {
733
+ await session.settle(timing.deliveryMs);
734
+ trace({ step: "delivered", route: "cloud", command, heldMs: timing.deliveryMs });
735
+ return controlResult(command, "Bambu Cloud", session.messages.map(msg => controlReply(msg, command, sequenceId)).find(Boolean), attempt);
736
+ }
737
+ // Before the printer's certificate is installed the plug-in sends nothing and returns
738
+ // CONNECT_FAILED (-2) or SEND_MSG_FAILED (-4), as on the LAN route (Nov 3: cloud pause -2
739
+ // with the certificate not yet installed). Reinstall it, wait, and send again.
740
+ if ((code !== -2 && code !== -4) || attempt === timing.attempts)
741
+ break;
742
+ await session.settle(timing.retryWaitMs);
743
+ await session.installCertificate();
744
+ trace({ step: "certificate", route: "cloud", attempt, ready: await session.certificateReadyWithin(timing.certificateWaitMs) });
745
+ }
746
+ throw new Error(`Bambu Cloud did not accept ${command} for printer ${devId} (plug-in code ${code}).`);
747
+ });
748
+ }
749
+ /** Appends control diagnostics to control-trace.log in the bridge's config folder. */
750
+ controlTrace(options) {
751
+ const file = path.join(this.resolveConfigDir(options.configDir), "control-trace.log");
752
+ return entry => {
753
+ try {
754
+ fs.appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n", { mode: 0o600 });
755
+ }
756
+ catch { /* diagnostics only */ }
757
+ };
758
+ }
759
+ async callWithAgent(method, payload = {}, options = {}) {
760
+ const { agent } = await this.ensureAgent(options);
761
+ return await this.request(method, { ...payload, agent }, options);
762
+ }
763
+ async stop() {
764
+ const child = this.child;
765
+ this.child = undefined;
766
+ this.commandLine = undefined;
767
+ this.agentId = undefined;
768
+ this.agentKey = undefined;
769
+ this.handshake = undefined;
770
+ this.stdoutBuffer = Buffer.alloc(0);
771
+ for (const [id, pending] of this.pending.entries()) {
772
+ clearTimeout(pending.timer);
773
+ pending.reject(new Error(`FULU BambuNetwork bridge stopped before ${pending.method} completed.`));
774
+ this.pending.delete(id);
775
+ }
776
+ if (child && child.exitCode === null) {
777
+ await new Promise((resolve) => {
778
+ const forceTimer = setTimeout(() => {
779
+ if (child.exitCode === null) {
780
+ child.kill("SIGKILL");
781
+ }
782
+ }, 2000);
783
+ child.once("close", () => {
784
+ clearTimeout(forceTimer);
785
+ resolve();
786
+ });
787
+ child.kill("SIGTERM");
788
+ });
789
+ }
790
+ }
791
+ async ensureStarted(commandOverride) {
792
+ const command = this.resolveBridgeCommand(commandOverride);
793
+ if (!command) {
794
+ throw new Error("BAMBU_NETWORK_BRIDGE_COMMAND is required for FULU BambuNetwork support. " +
795
+ "Point it at pjarczak_bambu_linux_host or the OrcaSlicer-bambulab macOS/WSL wrapper.");
796
+ }
797
+ if (this.child && this.child.exitCode === null && this.commandLine === command) {
798
+ return;
799
+ }
800
+ if (this.child && this.commandLine !== command) {
801
+ await this.stop();
802
+ }
803
+ const shell = process.platform === "win32" ? process.env.ComSpec || "cmd.exe" : "/bin/sh";
804
+ const shellArgs = process.platform === "win32"
805
+ ? ["/d", "/s", "/c", command]
806
+ : ["-lc", command];
807
+ this.child = spawn(shell, shellArgs, {
808
+ env: {
809
+ ...process.env,
810
+ ...linuxPluginLibraryEnv(process.env, process.platform),
811
+ PJARCZAK_BAMBU_COUNTRY_CODE: this.resolveCountryCode(),
812
+ PJARCZAK_EXPECTED_BAMBU_NETWORK_VERSION: this.inferredExpectedNetworkVersion ||
813
+ process.env.PJARCZAK_EXPECTED_BAMBU_NETWORK_VERSION ||
814
+ "",
815
+ },
816
+ stdio: ["pipe", "pipe", "pipe"],
817
+ });
818
+ this.commandLine = command;
819
+ const spawnedChild = this.child;
820
+ spawnedChild.stdout.on("data", (chunk) => this.handleStdout(chunk));
821
+ spawnedChild.stderr.on("data", (chunk) => this.rememberStderr(chunk.toString("utf8")));
822
+ spawnedChild.on("error", (error) => {
823
+ if (this.child === spawnedChild) {
824
+ this.rejectAllPending(error);
825
+ }
826
+ });
827
+ spawnedChild.on("close", (code, signal) => {
828
+ if (this.child !== spawnedChild) {
829
+ return;
830
+ }
831
+ this.rejectAllPending(new Error(`FULU BambuNetwork bridge exited (code=${code ?? "null"}, signal=${signal ?? "null"}). ${this.lastStderr}`.trim() +
832
+ (code === 101 ? ` ${STDOUT_SOCKET_HINT}` : "")));
833
+ this.child = undefined;
834
+ this.commandLine = undefined;
835
+ this.agentId = undefined;
836
+ this.agentKey = undefined;
837
+ this.handshake = undefined;
838
+ });
839
+ }
840
+ handleStdout(chunk) {
841
+ this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
842
+ while (this.stdoutBuffer.length >= FRAME_HEADER_SIZE) {
843
+ const magic = this.stdoutBuffer.readUInt32LE(0);
844
+ if (magic !== FRAME_MAGIC) {
845
+ const preview = this.stdoutBuffer.subarray(0, Math.min(this.stdoutBuffer.length, 120)).toString("utf8");
846
+ this.rejectAllPending(new Error(`Invalid FULU BambuNetwork bridge frame magic. Output began with: ${preview}`));
847
+ this.stdoutBuffer = Buffer.alloc(0);
848
+ return;
849
+ }
850
+ const type = this.stdoutBuffer.readUInt32LE(4);
851
+ const id = this.stdoutBuffer.readUInt32LE(8);
852
+ const size = this.stdoutBuffer.readUInt32LE(12);
853
+ if (this.stdoutBuffer.length < FRAME_HEADER_SIZE + size) {
854
+ return;
855
+ }
856
+ const payload = this.stdoutBuffer.subarray(FRAME_HEADER_SIZE, FRAME_HEADER_SIZE + size);
857
+ this.stdoutBuffer = this.stdoutBuffer.subarray(FRAME_HEADER_SIZE + size);
858
+ if (type === FRAME_TYPE_JSON_RESPONSE) {
859
+ this.resolvePendingJson(id, payload);
860
+ }
861
+ else if (type === FRAME_TYPE_LOG) {
862
+ this.rememberLog(payload.toString("utf8"));
863
+ }
864
+ else if (type === FRAME_TYPE_BINARY_DATA) {
865
+ this.rememberLog(`received ${payload.length} bytes of bridge binary data for request ${id}`);
866
+ }
867
+ else {
868
+ this.rememberLog(`ignored bridge frame type ${type} for request ${id}`);
869
+ }
870
+ }
871
+ }
872
+ resolvePendingJson(id, payload) {
873
+ const pending = this.pending.get(id);
874
+ if (!pending) {
875
+ this.rememberLog(`received response for unknown bridge request ${id}`);
876
+ return;
877
+ }
878
+ clearTimeout(pending.timer);
879
+ this.pending.delete(id);
880
+ try {
881
+ pending.resolve(readFrameJson(payload));
882
+ }
883
+ catch (error) {
884
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
885
+ }
886
+ }
887
+ rememberLog(message) {
888
+ const trimmed = message.trim();
889
+ if (!trimmed)
890
+ return;
891
+ this.recentLogs.push(trimmed);
892
+ if (this.recentLogs.length > 25) {
893
+ this.recentLogs = this.recentLogs.slice(-25);
894
+ }
895
+ }
896
+ rememberStderr(message) {
897
+ const combined = `${this.lastStderr}${message}`;
898
+ this.lastStderr = combined.length > 4000 ? combined.slice(-4000) : combined;
899
+ }
900
+ rejectAllPending(error) {
901
+ for (const [id, pending] of this.pending.entries()) {
902
+ clearTimeout(pending.timer);
903
+ pending.reject(error);
904
+ this.pending.delete(id);
905
+ }
906
+ }
907
+ needsExpectedVersionRetry(handshake) {
908
+ if (typeof handshake !== "object" || handshake === null) {
909
+ return false;
910
+ }
911
+ const value = handshake;
912
+ return (value.network_loaded === false &&
913
+ typeof value.network_actual_abi_version === "string" &&
914
+ value.network_actual_abi_version.length > 0 &&
915
+ typeof value.network_status === "string" &&
916
+ value.network_status.toLowerCase().includes("expected abi version"));
917
+ }
918
+ }