diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c86b900..75d83e6cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to Bambuddy will be documented in this file. - **VP wire-payload dump escape hatch for shape-of-payload triage (#1622 investigation)** — When a virtual printer in non-proxy mode is misbehaving for the slicer-facing surface (AMS slot fields rendering empty, filament dropdown unselectable, K-profile not visible), the existing logs prove the bridge is bound and pushing at 1Hz but don't show what's actually in the wire payload. Without that, "cache is missing fields" is indistinguishable from "the slicer-facing copy is stripping them." Set `BAMBUDDY_VP_DUMP_WIRE=1` and Bambuddy writes the bridge's cached push_status (`/vp_wire/_in.json`) and the periodic 1Hz copy that gets sent to the slicer (`/vp_wire/_out.json`) to disk, overwritten on each tick. Diffing the two answers the bisect question; diffing a misbehaving VP's `_out.json` against a known-good VP's `_out.json` (e.g. P1S vs H2D in the #1622 case) answers the model-shape question. Off by default, no overhead when disabled (single env-var read per tick); env var re-read on every call so toggling without restart works; failures swallowed at debug so a broken dump can never break the 1Hz loop. Implementation lives in `backend/app/services/virtual_printer/_debug.py` with call sites in `mqtt_server.py::_send_status_report` (cached branch only — synthetic fallback is uninteresting for this triage) and `mqtt_bridge.py::_on_printer_raw` (immediately after the merge that produces `_latest_print_state`). 21 unit tests in `test_vp_wire_dump.py` pin: disabled-by-default, atomic tmp+rename writes (no half-written .json visible to a reader), sanitized vp_name (path-separator stripped, empty name falls back to `vp`, .. inside a single filename component is harmless because slashes are collapsed before path construction), per-call env check, dict + bytes + str payload acceptance, swallow-on-OSError. Not gated on debug-logging because the bridge's verbose path is already noisy; this dump is small (one file per direction per VP) and only present when the operator opts in. Diagnostic-only — does not change the bridge data path. - **VP slicer↔printer command-flow trace (#1622 round 2)** — The snapshot dump above answers "is the cached push shape correct?", but the round-1 captures from #1622 ruled that out: P1S AMS payload reaches the slicer byte-identical to what the printer sent, sticky-key preservation works, the visible slot data is intact. The remaining symptom (picking a generic filament in archive mode "unloads" the slot) lives on the command path, not in the periodic push — and the snapshot dump doesn't capture command traffic. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every slicer-originated publish on `device//request` AND every printer-originated response the bridge fans out to the slicer (extrusion_cali_get, ams_filament_setting acks, xcam, system, etc.) to `/vp_wire/_cmd.jsonl`, one JSON line per event with UTC iso timestamp, direction (`slicer_to_bridge` / `printer_to_slicer`), MQTT topic, a `.` grep handle, and the parsed payload. Excludes the cached-as-base 1Hz push (already covered by the snapshot dump) and `pushall`/`get_version` (handled locally, never forwarded). Printer-side captures happen AFTER serial rewrite so the dump matches what the slicer actually saw on the wire. New `append_event` helper in `_debug.py` mirrors the same swallow-on-OSError + sanitized-vp_name + per-call env-check posture as `dump_wire`; bytes payloads are utf-8 decoded then json-parsed with the same `\x00`-tolerance fix from #927 so OrcaSlicer's C-string-null publishes parse cleanly; un-parseable bytes fall back to `{"raw": "..."}` so every line stays valid JSON. Eight additional unit tests in `test_vp_wire_dump.py` pin: disabled-by-default, bytes parsing, trailing-null tolerance, unparseable-fallback, vp_name sanitization, iso timestamp shape, append-multiple-lines, swallow-on-OSError. Diagnostic-only — does not change the publish or fan-out data path. +- **VP bridge-synthesised reply trace (#1622 round 3)** — The round-2 cmd.jsonl from shaddowlink's P1S vs H2D capture proves the actual failure mode: on P1S in archive mode the slicer issues `extrusion_cali_set` (push K/n directly) and the printer responds `fail`, on H2D and on the P1S second round the slicer takes the `extrusion_cali_sel` flow (select by `filament_id` / `cali_idx`) and the printer responds `success`. Both flows traverse the bridge cleanly — `ams_filament_setting` round-trips with `result=success` and the cached push_status carries `tray_info_idx=GFA11`, `tray_type=PLA-AERO`, K/n, and `cali_idx=-1` intact. So the bridge is innocent on every layer the dump can see, and the open question becomes: what makes the slicer pick `_set` vs `_sel`? Likely candidates are the `info.get_version` answer Bambuddy synthesises (slicer fingerprints on `sw_ver` / `hw_ver` / `module` to decide its command flow) or the first cached `pushall` response the slicer reads to bootstrap its UI. Round 2 captured neither — the JSONL had `slicer_to_bridge` and `printer_to_slicer` directions but no `bridge_to_slicer` direction for the bridge's own synthesised replies. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every bridge-synthesised reply (info.get_version answer, project_file ack, on-demand pushall response) to `/vp_wire/_cmd.jsonl` under direction `bridge_to_slicer`. Capture lives in `mqtt_server.py::_publish_to_report` — the single chokepoint every synthesised reply already passes through — gated on a new `log_event: bool = True` parameter; the 1Hz periodic-push path threads `log_event=False` so the JSONL isn't flooded with ~60 lines/min per VP (snapshot dump already covers cache shape). The on-demand pushall response from `_send_status_report` IS logged because that's the bootstrap-fingerprint reply the slicer reads on first connect. Two additional unit tests in `test_vp_mqtt_bridge.py::TestWireFormat` pin the event-on-default and skip-when-`log_event=False` posture; `test_vp_wire_dump.py` already covers the underlying `append_event` shape and the new direction is documented in `_debug.py`'s docstring. Diagnostic-only — does not change the publish data path; the new param defaults preserve every existing call site's behaviour. + - **Windows installer build pipeline scaffolded** — Lays down the infrastructure for producing a self-contained Bambuddy Windows installer `.exe` that doesn't require Python, Node, or any other runtime on the target machine. The installer ships an embedded Python 3.13 distribution (matching the Dockerfile's `python:3.13-slim-trixie`), the pre-built React bundle, NSSM (service supervisor), and ffmpeg — everything Bambuddy needs to run end-to-end on a stock Windows 10/11 box. **Architecture:** install target `C:\Program Files\Bambuddy\`, data target `C:\ProgramData\Bambuddy\data\` (preserved on uninstall so reinstalls keep the database + archives), service registered via NSSM running as `LocalSystem` with autostart on boot (LocalSystem is required because the Virtual Printer feature needs to bind 322 / 990 / 8883, all privileged ports on Windows). Browser is the UI — Start Menu shortcut opens `http://localhost:8000`, no Tauri / Electron launcher in v1, which matches how every other Bambuddy platform already works. **Why this shape over a PowerShell `install.ps1`:** the script approach was tried first and abandoned. Each failure across the Windows host fleet is environmental drift (Python version mismatches, execution-policy variants, antivirus heuristics, missing MSVC runtimes, OneDrive-redirected `%APPDATA%`, ARM64 vs x64, PowerShell 5.1 vs 7.x semantics) — a script can't insulate against host state, and every fix you add for one machine breaks two others. The self-contained-bundle approach takes that whole class of failure off the table. **Files:** `installers/windows/build.py` stages everything under `installers/windows/build/staging/`, `installers/windows/bambuddy.iss` is the Inno Setup 6 script, `installers/windows/service/install-service.bat` + `uninstall-service.bat` wrap NSSM. `build.py` hard-fails on non-Windows hosts; cross-build under Wine is an unsupported escape hatch behind `--allow-non-windows`. **CI:** `.github/workflows/windows-installer.yml` runs on tag push (`v*`) and manual dispatch, uses `windows-latest`, downloads Inno Setup via Chocolatey, runs `build.py` + ISCC, uploads the `.exe` as both a workflow artifact and a release asset. **Scope clarification:** this commit lands the build infrastructure, not a verified-working installer. The first real Windows-box smoke test happens after merge by triggering the workflow manually and installing the artifact on a target box; known unknowns are pip-installing `opencv-python-headless` / `curl_cffi` / `asyncpg` / `cryptography` / `bcrypt` against embedded Python (the `_pth` file edits in `build.py` cover the common gotchas but real-runtime imports are where surprises surface), ffmpeg path lookup from a LocalSystem service, and NSSM `AppEnvironmentExtra` line-continuation in cmd.exe. **Signing:** v1 ships unsigned — Windows SmartScreen will warn "Windows protected your PC" on first run, click-through works. SignPath OSS application submitted 2026-06-10 to wire free EV signing into CI once approved (typical 1–3 week approval window). **What's explicitly NOT in v1:** Spoolman bundling (Bambuddy's internal-inventory mode is the v1 default on Windows; users who want Spoolman install it separately), in-place upgrade (uninstall + install cycle works, but in-place upgrade-on-top needs end-to-end verification before we promise it), port-conflict pre-check (deferred to v1.1 — port collisions surface at first service start and the user reads the NSSM stderr log under `C:\ProgramData\Bambuddy\logs\service-stderr.log`). See `installers/windows/README.md` for the full build pipeline. - **Bambu Lab A2L support (#1684)** — Internal model code `N9`, serial prefix `26A19` (5 chars, same shape as H2C's late `31B8B`). Capabilities resolved from BambuStudio's `resources/profiles/BBL/machine/Bambu Lab A2L.json` cross-checked against Bambu's official A2L specs page: linear rail, single FDM extruder + integrated cutter/plotter head (the BambuStudio `use_double_extruder_default_texture: true` flag covers the dual TOOL HEADS, not dual filament extrusion — A2L must NOT route AMS to the deputy slot or firmware rejects with 07FF_8012). Specs page also confirms NO Ethernet (Wi-Fi 2.4 GHz 802.11 b/g/n only), `Low-Rate-Kamera` on the chamber-image protocol (port 6000, NOT RTSP:322), no heated chamber. **Registry updates**: `PRINTER_MODEL_MAP` + `PRINTER_MODEL_ID_MAP` + `LINEAR_RAIL_MODELS` in `utils/printer_models.py`; `MODEL_TO_API_KEY` + `API_KEY_TO_DEV_MODEL` + `API_KEY_TO_WIKI_PATH` in `firmware_check.py` (wiki path follows the established `/en/a2l/manual/a2l-firmware-release-history` pattern; the existing 404 handling in `_fetch_all_versions_from_wiki` makes this safe to ship before Bambu publishes the page); `VIRTUAL_PRINTER_MODELS` + `MODEL_SERIAL_PREFIXES` in `virtual_printer/manager.py` (prefix `26A19A` with the same revision-letter padding as X2D's `20P90A`); `MODEL_PRODUCT_NAMES` in `virtual_printer/mqtt_server.py`; `mapModelCode` + Add-Printer / Edit-Printer model dropdowns in `PrintersPage.tsx` (new "A2 Series" optgroup); `mapModelCode` in `SpoolBuddyAmsPage.tsx`. **Camera and dual-nozzle code paths need no edits**: `supports_rtsp()` correctly falls through to chamber-image for A2L because `N9` is neither in the internal-code RTSP set nor does the display name match the X1/X2/H2/P2 prefix tuple; `is_dual_nozzle_model()` correctly returns False because A2L is not in `DUAL_NOZZLE_MODELS`. The cutter/plotter capability surfaces in MQTT push fields Bambuddy doesn't yet model; ignored for v1, will surface as a follow-up only if a real-world A2L bundle reveals a confusing UI state. **Tests**: 12 new cases in `test_printer_models.py::TestA2LModel` pinning every dimension — rod type, model-id round-trip, both ethernet directions, both camera-port directions, the explicit non-dual-nozzle guard (regression guard for the BambuStudio profile flag misread), set membership in `LINEAR_RAIL_MODELS` and exclusion from `CARBON_ROD_MODELS` / `STEEL_ROD_MODELS`. diff --git a/backend/app/services/virtual_printer/_debug.py b/backend/app/services/virtual_printer/_debug.py index 4f68ec741..1279ac3b7 100644 --- a/backend/app/services/virtual_printer/_debug.py +++ b/backend/app/services/virtual_printer/_debug.py @@ -10,12 +10,15 @@ Set ``BAMBUDDY_VP_DUMP_WIRE=1`` to enable two complementary capture modes: ``*_in.json`` and ``*_out.json`` for the failing VP against a known-good one (e.g. H2D vs P1S). -2. ``append_event``: time-ordered JSONL log of every slicer↔printer command - payload that flows through the VP (excludes the cached-as-base 1Hz push, - which dump_wire already covers). Triages command-flow bugs (e.g. #1622 - round 2) where the cached state looks right but a slicer-initiated - write (ams_filament_setting / extrusion_cali_set / xcam / system) ends - up corrupting state. One line per event with wall-clock timestamp. +2. ``append_event``: time-ordered JSONL log of every slicer↔bridge↔printer + command payload that flows through the VP (excludes the cached-as-base + 1Hz push, which dump_wire already covers). Triages command-flow bugs + (e.g. #1622 round 2 / round 3) where the cached state looks right but a + slicer-initiated write (ams_filament_setting / extrusion_cali_set / + xcam / system) ends up corrupting state, or where the slicer's choice + of command flow depends on what the bridge replies to its initial + info.get_version / pushall probe. One line per event with wall-clock + timestamp. Layout: - snapshot: ``/vp_wire/_.json`` @@ -100,10 +103,14 @@ def append_event(vp_name: str, direction: str, topic: str, payload: dict | bytes """Append one event line to ``/vp_wire/_cmd.jsonl``. No-op when the env flag is unset. ``direction`` should be one of - ``"slicer_to_bridge"`` or ``"printer_to_slicer"`` so a diff between - a working VP and a broken VP can be read top-to-bottom in causal order. - Bytes payloads are utf-8 decoded then json-parsed best-effort; un-parseable - payloads are logged as ``{"raw": ""}`` so the line is still valid JSON. + ``"slicer_to_bridge"`` (slicer-originated publish reaching the bridge), + ``"printer_to_slicer"`` (real-printer response fanned out to the slicer), + or ``"bridge_to_slicer"`` (bridge-synthesised reply: info.get_version + answer, project_file ack, on-demand pushall response). A diff between + a working VP and a broken VP can then be read top-to-bottom in causal + order. Bytes payloads are utf-8 decoded then json-parsed best-effort; + un-parseable payloads are logged as ``{"raw": ""}`` so the line + is still valid JSON. """ if not _enabled(): return diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py index 16dec9090..1a2290148 100644 --- a/backend/app/services/virtual_printer/mqtt_server.py +++ b/backend/app/services/virtual_printer/mqtt_server.py @@ -432,7 +432,11 @@ class SimpleMQTTServer: disconnected.append(client_id) continue serial = self._client_serials.get(client_id, self.serial) - await self._send_status_report(writer, serial=serial) + # log_event=False: the 1Hz cached push is already + # captured by ``dump_wire`` snapshot mode (see + # _debug.py); appending it to the cmd.jsonl would + # flood the file ~60 lines/min per VP. + await self._send_status_report(writer, serial=serial, log_event=False) push_counts[client_id] = push_counts.get(client_id, 0) + 1 except OSError as e: logger.debug("Failed to push status to %s: %s", client_id, e) @@ -846,7 +850,9 @@ class SimpleMQTTServer: except (IndexError, ValueError, OSError) as e: logger.debug("MQTT SUBSCRIBE error: %s", e) - async def _send_status_report(self, writer: asyncio.StreamWriter, serial: str | None = None) -> None: + async def _send_status_report( + self, writer: asyncio.StreamWriter, serial: str | None = None, log_event: bool = True + ) -> None: """Send a status report to the slicer after connection. When a bridge is active and has cached the real printer's latest @@ -915,7 +921,7 @@ class SimpleMQTTServer: print_block["print_error"] = 0 status = {"print": print_block} dump_wire(self.vp_name, "out", status) - await self._publish_to_report(writer, status, serial or self.serial) + await self._publish_to_report(writer, status, serial or self.serial, log_event=log_event) return # No bridge / no cache yet — fall back to the synthetic stub. @@ -992,7 +998,7 @@ class SimpleMQTTServer: } } - await self._publish_to_report(writer, status, serial or self.serial) + await self._publish_to_report(writer, status, serial or self.serial, log_event=log_event) except OSError as e: logger.error("Failed to send status report: %s", e) @@ -1088,13 +1094,24 @@ class SimpleMQTTServer: self._current_file = filename self._prepare_percent = prepare_percent - async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict, serial: str = "") -> None: + async def _publish_to_report( + self, writer: asyncio.StreamWriter, payload: dict, serial: str = "", log_event: bool = True + ) -> None: """Publish a message on the device report topic. Real Bambu printers wire-format push_status JSON with 4-space indentation (32254 bytes for an idle H2D push vs 14268 bytes compact). BambuStudio's Send pre-flight rejects compact JSON — without matching the on-wire format the slicer never proceeds to FTP upload. + + ``log_event=True`` records the publish in ``vp_wire/_cmd.jsonl`` + under the ``bridge_to_slicer`` direction so #1622-style triages can + diff the bridge's own outbound replies (info.get_version answer, + project_file ack, on-demand pushall response) against the real + printer's ``printer_to_slicer`` forwards. The 1Hz periodic push + sets ``log_event=False`` because dump_wire's overwrite-snapshot + already covers cache shape and a per-second JSONL line would dwarf + the actual command events. """ topic = f"device/{serial or self.serial}/report" message = json.dumps(payload, indent=4) @@ -1116,6 +1133,16 @@ class SimpleMQTTServer: packet += topic_bytes packet += message_bytes + if log_event: + # Env-flagged command trace (#1622): captures bridge-synthesised + # replies (info.get_version, project_file ack, on-demand pushall + # response) AFTER the payload is finalised but before it hits + # the wire — so the cmd.jsonl reflects exactly what the slicer + # parses. Pair with the slicer_to_bridge events from + # _handle_publish and the printer_to_slicer fan-outs from + # mqtt_bridge. + append_event(self.vp_name, "bridge_to_slicer", topic, payload) + writer.write(packet) # Timeout the drain to prevent blocking the event loop if the # MQTT client stops reading (e.g. slicer busy with FTP upload). diff --git a/backend/tests/unit/test_vp_mqtt_bridge.py b/backend/tests/unit/test_vp_mqtt_bridge.py index 1512bf93f..5e6ac13b1 100644 --- a/backend/tests/unit/test_vp_mqtt_bridge.py +++ b/backend/tests/unit/test_vp_mqtt_bridge.py @@ -743,7 +743,7 @@ class TestStatusReportCachedAsBase: """Wrap _publish_to_report to capture (topic, payload_dict).""" published: list = [] - async def _capture(writer, payload, serial=""): + async def _capture(writer, payload, serial="", log_event=True): published.append((serial or server.serial, payload)) server._publish_to_report = _capture # type: ignore[assignment] @@ -939,6 +939,52 @@ class TestWireFormat: body = b"".join(captured) assert b'\n "print"' in body, "publish_to_report must use indent=4 JSON" + @pytest.mark.asyncio + async def test_publish_records_bridge_to_slicer_event_by_default(self, monkeypatch): + """#1622 round 3: every bridge-synthesised reply (info.get_version answer, + project_file ack, on-demand pushall response) must show up in the + cmd.jsonl trace under the ``bridge_to_slicer`` direction so a P1S↔H2D + diff captures the fingerprint the slicer reads back from us.""" + server = _make_server() + writer = MagicMock() + writer.write = lambda data: None + writer.drain = AsyncMock() + + recorded: list = [] + monkeypatch.setattr( + "backend.app.services.virtual_printer.mqtt_server.append_event", + lambda vp_name, direction, topic, payload: recorded.append((vp_name, direction, topic, payload)), + ) + + payload = {"info": {"command": "get_version", "sequence_id": "0"}} + await server._publish_to_report(writer, payload) + + assert len(recorded) == 1 + vp_name, direction, topic, recorded_payload = recorded[0] + assert direction == "bridge_to_slicer" + assert topic.endswith("/report") + assert recorded_payload == payload + + @pytest.mark.asyncio + async def test_publish_skips_event_when_log_event_false(self, monkeypatch): + """The 1Hz periodic-push path passes ``log_event=False`` so dump_wire's + snapshot stays the canonical record of cache shape and the cmd.jsonl + isn't flooded with ~60 lines/min per VP.""" + server = _make_server() + writer = MagicMock() + writer.write = lambda data: None + writer.drain = AsyncMock() + + recorded: list = [] + monkeypatch.setattr( + "backend.app.services.virtual_printer.mqtt_server.append_event", + lambda *args, **kwargs: recorded.append(args), + ) + + await server._publish_to_report(writer, {"print": {"command": "push_status"}}, log_event=False) + + assert recorded == [] + # --------------------------------------------------------------------------- # Routing: _handle_publish