From 68920f8c62585bc9081b8be014d1f2c38b00ac2a Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 19 Apr 2026 08:04:05 +0200 Subject: [PATCH] Fix virtual printer dropping null-terminated MQTT payloads from OrcaSlicer Linux (#927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrcaSlicer's Linux BBLNetworkPlugin publishes MQTT payloads with the C-string null terminator included in the length, so decoded messages arrived as `{…}\x00`. The strict json.loads() raised JSONDecodeError and the publish handler silently returned — pushall, get_version, and project_file were never answered, and the slicer hit its 60 s sync timeout. Print_queue mode only (proxy mode tunnels MQTT). The b069b521 serial-adaptation fix was correct but ran past this earlier silent failure. _handle_publish now strips trailing \x00/whitespace before parsing and logs the raw payload on any remaining decode failure so future silent variants are visible in support bundles. --- CHANGELOG.md | 1 + .../services/virtual_printer/mqtt_server.py | 16 ++++++++++--- backend/tests/unit/test_vp_mqtt_server.py | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 110fe2c71..0e41204de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file. - **Dependency Updates for Published Advisories** — Bumped two dependencies flagged by vulnerability scanners. `python-multipart` 0.0.22 → 0.0.26 closes CVE-2026-40347 (GHSA-mj87-hwqh-73pj), a denial-of-service triggered by large preamble or epilogue data around a multipart boundary — the 0.0.26 release now skips the preamble before the first boundary and silently discards the epilogue after the closing one. Bambuddy uses `python-multipart` transitively through FastAPI/Starlette for form and file-upload parsing, so any authenticated endpoint accepting `multipart/form-data` (e.g. backup restore, project thumbnail upload) was exposed. `dompurify` 3.3.3 → 3.4.0 picks up the fix for GHSA-39q2-94rc-95cp (the function-form `ADD_TAGS` could bypass `FORBID_TAGS`); Bambuddy's two call sites (`ProjectDetailPage`, `ProjectPageModal`) only use array-form `ALLOWED_TAGS`/`ALLOWED_ATTR`, so the specific bypass was not reachable, but the bump still hardens the sanitizer against future misconfiguration and clears the audit warning. ### Fixed +- **Virtual Printer "Synchronizing device information" Timeout with OrcaSlicer on Linux** ([#927](https://github.com/maziggy/bambuddy/issues/927)) — Follow-up to the b069b521 serial-adaptation fix. OrcaSlicer's Linux builds publish MQTT payloads with the C-string null terminator included in the length (same pattern as [paho.mqtt.c #1198](https://github.com/eclipse-paho/paho.mqtt.c/issues/1198)), so every decoded message arrived as `{…}\x00`. The virtual printer's strict `json.loads()` raised `JSONDecodeError: Extra data` and the handler silently returned — no pushall, get_version, or project_file was ever answered, so the slicer hit its 60 s sync timeout and reconnected in a loop. Real Bambu firmware's mosquitto passed the trailing byte through, which is why direct LAN connections worked, and why print_queue mode was the only affected path (proxy mode tunnels MQTT to the real printer instead of running the VP broker). The handler now strips trailing `\x00`/whitespace before parsing and logs the raw payload on any remaining decode failure so future silent variants are visible in support bundles. Thanks to @EdwardChamberlain for the debug-enabled support log that made the null byte visible in the raw bytes. - **SpoolBuddy Kiosk Unusable After Full-Mode Install** — A bundled Bambuddy + SpoolBuddy install via `spoolbuddy/install/install.sh --mode full` produced an unusable kiosk on first boot: Chromium raced ahead of uvicorn and showed "can't connect to localhost"; after a manual reload the kiosk URL `/spoolbuddy?token=…` was hijacked by Bambuddy's first-run wizard (`AuthContext` force-redirects to `/setup` whenever `requires_setup=true`, regardless of the target path); the wizard asks for admin credentials, but a touch-only Pi has no on-screen keyboard; if the user skipped auth the browser landed at `/` instead of the kiosk, and if they tried to enable auth they were stranded. Standalone mode was unaffected because it runs against an already-configured remote Bambuddy. Fixed in three parts: (a) new `backend/app/cli.py` with a `kiosk-bootstrap` subcommand that in a single DB transaction creates a scoped API key (`can_read_status=True`, `can_queue=False`, `can_control_printer=False`) and upserts `setup_completed=true`, so the first-run wizard never triggers and the kiosk URL loads the SpoolBuddy page directly; users can still enable authentication later from the admin UI and the pre-provisioned key keeps working. (b) `install.sh` full-mode now runs the CLI as the bambuddy service user immediately after `create_bambuddy_service` and `sed`-replaces the `CHANGE_ME_AFTER_SETUP` placeholder in `spoolbuddy/.env`. (c) The generated `spoolbuddy-kiosk-launch` now polls `${backend_url}/health` with a 60 s timeout before exec'ing Chromium, so cold boots wait for uvicorn instead of flashing the connection-refused error. The CLI is idempotent with `--force` for re-installs. - **Bambu Lab X2D Support** ([#988](https://github.com/maziggy/bambuddy/issues/988)) — Added X2D to the Add Printer and Edit Printer model dropdowns (both were missing the new model, so manual printer setup had no X2D option — auto-discovery via SSDP was unaffected). The newly released X2D (dual-nozzle, enclosed, hardened steel rod gantry, AMS 2 Pro compatible) identifies itself as internal model code `N6` via SSDP/MQTT, and serials begin with `20P9`. Because neither the code nor the prefix existed in any of Bambuddy's model tables, multiple paths silently fell back to wrong defaults: the camera service routed to the chamber-image protocol on port 6000 (which the X2D doesn't speak) instead of RTSP on port 322 — the reporter saw `Chamber image: data is not a valid JPEG` spam and no stream; the K-profile edit/delete path conditioned its in-place `cali_idx` write on the H2D serial prefix `094` and would therefore have treated X2D as a single-nozzle printer even though its dual-extruder layout matches H2D; the firmware-update check logged `Unknown printer model: N6`; and the virtual-printer model registry had no way to emulate X2D. Added the `N6 → X2D` mapping across every registry (`PRINTER_MODEL_ID_MAP`, `PRINTER_MODEL_MAP`, `ETHERNET_MODELS`, `STEEL_ROD_MODELS`, `CHAMBER_TEMP_SUPPORTED_MODELS`, firmware-check API keys and wiki path, virtual-printer SSDP product names and serial prefix, DB migration `vp_model_fixes`), extended `supports_rtsp()` to match `X2` display names and the `N6` internal code (camera now goes to port 322), expanded the dual-nozzle serial prefix check in `kprofiles.py` and the K-profile delete command in `bambu_mqtt.py` to also accept `20P9` so the H2D-style `cali_idx` in-place edit path runs on X2D, added X2D to the `is_h2d` model-family gate that selects the integer-format `timelapse`/`bed_leveling`/`flow_cali`/`vibration_cali`/`layer_inspect` fields in the MQTT print command, and added X2D to the frontend's door-badge and airduct-mode whitelists, `mapModelCode` lookups on both the Printers page and Spoolbuddy AMS page, and the MaintenancePage wiki-URL resolver (X2D inherits P2S's steel-rod lubrication, belt-tension, nozzle cold-pull and PTFE wiki pages, since its hardware is closer to P2S than to H2). Credit to @krautech for the report and the debug bundle, and to @legend813 for the initial PR (#989) that seeded most of the registry changes — the classification was corrected (X2D uses hardened steel rods like P2S, not carbon rods) and the dual-nozzle/K-profile gaps were added on top. - **Print Speed Icon Not Updating Live When Changed on Printer** ([#993](https://github.com/maziggy/bambuddy/issues/993)) — Changing the print speed mode from the printer's own panel (instead of from Bambuddy) did not update the speed icon on the Printers page card; the new value only appeared after a full page reload. The MQTT parser was already tracking `spd_lvl` and updating `state.speed_level` correctly, but the WebSocket serializer (`printer_state_to_dict`) was missing the field — so live status pushes never carried `speed_level`, and the frontend's merge-over-old-cache update left the icon stuck on its previous value. The REST `/status` endpoint used on initial page load already included it, which is why reloads worked. Added `speed_level` to the WebSocket payload. Thanks to @chesterakl for reporting. diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py index 7b7eb0d89..c50c25ac9 100644 --- a/backend/app/services/virtual_printer/mqtt_server.py +++ b/backend/app/services/virtual_printer/mqtt_server.py @@ -843,9 +843,19 @@ class SimpleMQTTServer: self._client_serials[client_id] = client_serial try: - data = json.loads(message) - except json.JSONDecodeError: - return # Non-JSON payloads on request topic are safely ignored + # Some slicer builds (observed with OrcaSlicer on Linux, #927) + # include the C-string null terminator in the MQTT payload + # length, so the decoded message ends with \x00. Real brokers + # pass the bytes through; strict json.loads raises "Extra data" + # and every pushall/get_version/project_file silently dropped. + data = json.loads(message.rstrip("\x00 \r\n\t")) + except json.JSONDecodeError as e: + logger.debug( + "MQTT publish JSON decode failed: %s (payload=%r)", + e, + message[:200], + ) + return # Handle pushing command (status request) if "pushing" in data: diff --git a/backend/tests/unit/test_vp_mqtt_server.py b/backend/tests/unit/test_vp_mqtt_server.py index ef36ca633..d62cf6d03 100644 --- a/backend/tests/unit/test_vp_mqtt_server.py +++ b/backend/tests/unit/test_vp_mqtt_server.py @@ -152,6 +152,29 @@ class TestPublishHandlerAdaptiveSerial: assert b'"command": "push_status"' in all_bytes assert server._client_serials["c1"] == "CUSTOMSERIAL123" + def test_handle_publish_tolerates_null_terminated_payload(self): + """#927: OrcaSlicer on Linux appends the C-string \\0 to MQTT payloads. + The handler must still parse and respond rather than silently dropping.""" + server = _make_server(serial="01P00A391800001") + server._client_serials["c1"] = server.serial + + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + + topic = "device/01P00A391800001/request" + topic_bytes = topic.encode("utf-8") + # Real-world bytes captured from EdwardChamberlain's support log: the + # JSON ends with an extra \x00 that strict json.loads rejects. + message_bytes = b'{"pushing":{"command":"pushall","sequence_id":"7"}}\x00' + payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + message_bytes + + asyncio.run(server._handle_publish(0x30, payload, writer, "c1")) + + all_bytes = b"".join(call.args[0] for call in writer.write.call_args_list) + assert b"device/01P00A391800001/report" in all_bytes + assert b'"command": "push_status"' in all_bytes + class TestClientSerialLifecycle: """_client_serials must be cleaned up on disconnect/stop to avoid leaks."""