Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 8 additions & 3 deletions src/printers/bambu-node-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
51 changes: 49 additions & 2 deletions src/printers/bambu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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<void>, serial: string): Promise<void> {
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<void>((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);
}
}

Expand Down
28 changes: 28 additions & 0 deletions tests/safety/bambu-node-guard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});