diff --git a/CHANGELOG.md b/CHANGELOG.md index 1783300..20fadd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Unreleased + +### Fixed + +- Keep the server alive and answer `get_printer_status` for Bambu printers that + broadcast reports but do not answer bambu-node's initial request (seen on two + X1 Carbons on firmware 01.07 in LAN Only Mode, which still accept control + commands such as the chamber light; the cause is unknown, and Developer Mode + does not exist on that firmware). The unanswered request used to become an unhandled rejection that + terminated the server, and bambu-node's `connect()` never resolved, which + also hung every concurrent caller. The client now proceeds 8 s after the MQTT + session is up, concurrent callers share that bounded wait, a failed or late + connection is evicted, and `get_printer_status` returns the latest broadcast + report with `commandsAnswered: false` and a LAN Only Mode / Developer Mode hint when its own + status request also goes unanswered. Evidence: a stub MQTT client for the + crash and its guard, and read-only status calls (three concurrent) against two + real X1 Carbons on 2026-09-30; no print, heating or motion command was sent. + The guard ignores any unawaited bambu-node command timeout, not only the + initial one. + ## 1.2.10 ### Safety diff --git a/package-lock.json b/package-lock.json index 112b361..5f1d223 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcp-3d-printer-server", - "version": "1.2.10", + "version": "1.2.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcp-3d-printer-server", - "version": "1.2.10", + "version": "1.2.11", "license": "GPL-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.0", diff --git a/package.json b/package.json index c8c52be..6726813 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-3d-printer-server", - "version": "1.2.10", + "version": "1.2.11", "description": "MCP server for connecting Claude with 3D printer management systems", "main": "dist/index.js", "repository": "https://github.com/DMontgomery40/mcp-3D-printer-server", diff --git a/src/printers/bambu-node-guard.ts b/src/printers/bambu-node-guard.ts index 71b3161..2155f25 100644 --- a/src/printers/bambu-node-guard.ts +++ b/src/printers/bambu-node-guard.ts @@ -2,9 +2,13 @@ * Unpatched bambu-node 3.22.21 parses every MQTT report in an async listener and * throws from it for printers or state transitions it does not model: H2S/H2D/ * H2C/H2D Pro/P2S serial prefixes, a get_version reply without an ota module, and - * transitions such as PAUSE -> IDLE after a cancel. Those throws become unhandled - * promise rejections, which terminate Node by default. bambu-printer-mcp avoids - * this with a postinstall patch; this package does not install one. + * transitions such as PAUSE -> IDLE after a cancel. It also sends a PushAll + * request on connect and leaves its 5 s timeout unhandled, which rejects when a + * printer never answers information requests (seen on X1 Carbons on firmware + * 01.07, which still accept control commands) but still broadcasts reports. Those throws become + * unhandled promise rejections, which terminate Node by default. + * bambu-printer-mcp avoids this with a postinstall patch; this package does not + * install one. * * Safety checks here never rely on bambu-node's model or job tracking: they read * fresh raw MQTT reports. Only these exact internal errors originating inside @@ -15,6 +19,7 @@ const KNOWN_INTERNAL_ERRORS = new Set([ "Printer model not supported!", "OTA module version data not found, unable to determine printer model!", "Edge case detected while updating printer status!", + "Command execution timed out after 5 seconds.", ]); let installed = false; diff --git a/src/printers/bambu.ts b/src/printers/bambu.ts index cb56613..0a5802b 100644 --- a/src/printers/bambu.ts +++ b/src/printers/bambu.ts @@ -56,6 +56,10 @@ interface ProjectFileMetadata { } const COMMAND_SETTLE_MS = 300; +/** bambu-node waits 1 s, then 5 s for its first command; allow that plus margin before giving up on it. */ +const BAMBU_INITIAL_COMMAND_GRACE_MS = 8_000; +const DEVELOPER_MODE_HINT = + "Some printers do not answer information requests and only broadcast status (seen on X1 Carbons on firmware 01.07, where control commands still work). On firmware 01.08.05 and later, control commands also need LAN Only Mode and Developer Mode (Settings > WLAN)."; /** HMS 0500-0500-0001-0007: firmware 01.08.05+ rejected an unsigned MQTT command. */ const COMMAND_VERIFICATION_HMS = { attr: 0x05000500, code: 0x00010007 }; const COMMAND_REJECTED_MESSAGE = @@ -133,14 +137,57 @@ class BambuClientStore { }); const connectPromise = printer.connect().then(() => {}); - this.initialConnectionPromises.set(key, connectPromise); + // bambu-node resolves connect() only after its own initial commands are + // answered. A printer that broadcasts reports but never answers information + // requests (seen on X1 Carbons on firmware 01.07) never answers them, so + // connect() would hang forever. Once the MQTT session is up, keep the + // client after a grace period: reports still fill printer.data, and each + // caller's own command fails on its own timeout instead. Concurrent callers + // wait on the same bounded promise. + const ready = this.awaitUsableClient(printer, connectPromise, serial).then(() => { + this.printers.set(key, printer); + }); + this.initialConnectionPromises.set(key, ready); + // A late connect() failure must not leave a half-initialised client cached. + connectPromise.catch(() => { + if (this.printers.get(key) === printer) this.printers.delete(key); + }); try { - await connectPromise; + await ready; return printer; } catch (error) { this.initialConnectionPromises.delete(key); + this.printers.delete(key); + void printer.disconnect(true).catch(() => {}); throw error; + } finally { + this.initialConnectionPromises.delete(key); + } + } + + private async awaitUsableClient(printer: BambuClient, connectPromise: Promise, serial: string): Promise { + const mqttClient = (printer as unknown as { + mqttClient?: { connected?: boolean; once(event: "connect", listener: () => void): unknown }; + }).mqttClient; + let graceTimer: NodeJS.Timeout | undefined; + const sessionUpThenSilent = new Promise((resolve) => { + const armGrace = () => { + graceTimer = setTimeout(() => { + console.warn( + `Bambu printer ${serial} accepted the MQTT login but did not answer the initial version request; continuing with its broadcast reports. ${DEVELOPER_MODE_HINT}` + ); + resolve(); + }, BAMBU_INITIAL_COMMAND_GRACE_MS); + }; + if (!mqttClient) return; // no session handle: rely on connect() alone + if (mqttClient.connected) armGrace(); + else mqttClient.once("connect", armGrace); + }); + try { + await Promise.race([connectPromise, sessionUpThenSilent]); + } finally { + if (graceTimer) clearTimeout(graceTimer); } } diff --git a/tests/safety/bambu-node-guard.test.mjs b/tests/safety/bambu-node-guard.test.mjs index 37e8803..2c23fd4 100644 --- a/tests/safety/bambu-node-guard.test.mjs +++ b/tests/safety/bambu-node-guard.test.mjs @@ -62,3 +62,31 @@ test("the guard rethrows unrelated rejections and look-alike errors from outside assert.doesNotMatch(result.stdout, /alive/, body); } }); + +// A printer that broadcasts reports but never answers information requests +// never answers the request bambu-node sends on connect. Its 5 s timeout must not terminate the server. A stub MQTT client +// stands in for the printer; nothing is opened or contacted. +function runSilentPrinter(guarded) { + const script = ` + import { BambuClient, PushAllCommand } from "bambu-node"; + ${guarded ? `import { installBambuNodeRejectionGuard } from ${JSON.stringify(guard)}; installBambuNodeRejectionGuard();` : ""} + const client = new BambuClient({ host: "192.0.2.1", serialNumber: "094TESTONLY", accessToken: "unused" }); + client.mqttClient = { publish(_topic, _message, _options, callback) { callback?.(); }, on() {}, subscribe() {} }; + void client.executeCommand(new PushAllCommand()); + setTimeout(() => { console.log("alive"); process.exit(0); }, 6_500); + `; + return spawnSync(process.execPath, ["--input-type=module", "-e", script], { cwd: root, encoding: "utf8", timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }); +} + +test("an unanswered bambu-node PushAll terminates Node without the guard", () => { + const result = runSilentPrinter(false); + assert.notEqual(result.status, 0, "unguarded command timeout must terminate the process"); + assert.doesNotMatch(result.stdout, /alive/); +}); + +test("the guard keeps the server alive when a printer never answers information requests", () => { + const result = runSilentPrinter(true); + assert.equal(result.status, 0, result.stderr.slice(-2000)); + assert.match(result.stdout, /alive/); + assert.ok(result.stderr.includes("Ignored internal status-tracking error: Command execution timed out after 5 seconds."), result.stderr.slice(-500)); +});