debug(vp): env-flagged bridge-synthesised reply trace for slicer↔printer #1622 round-3 triage

Round-2 cmd.jsonl from shaddowlink proves the bridge forwards both commands and
  responses correctly: ams_filament_setting round-trips with result=success on P1S,
  the cached push_status carries tray_info_idx=GFA11/tray_type=PLA-AERO/K-n/cali_idx
  intact, and the visible "unload" symptom comes from the slicer's choice of
  extrusion_cali_set (push K direct, P1S firmware rejects) vs extrusion_cali_sel
  (select by id, both H2D and P1S accept). The open question is what makes the
  slicer pick _set vs _sel — likely the info.get_version response Bambuddy
  synthesises or the first cached pushall reply the slicer reads at connect.
  Round 2 captured neither; the JSONL had slicer_to_bridge and printer_to_slicer
  but no direction for the bridge's own synthesised replies.

  Same BAMBUDDY_VP_DUMP_WIRE=1 flag now also appends a bridge_to_slicer line for
  every bridge-synthesised reply (info.get_version answer, project_file ack,
  on-demand pushall response). Capture is in _publish_to_report — the single
  chokepoint — gated on a new log_event param; the 1Hz periodic push threads
  log_event=False so the JSONL isn't flooded (~60 lines/min/VP) because
  dump_wire already covers cache shape per tick.

  Diagnostic-only, no data-path change. Default param preserves every existing
  call site's behaviour.
This commit is contained in:
maziggy
2026-06-11 15:09:24 +02:00
parent 6b477088a2
commit 5644f11495
4 changed files with 98 additions and 16 deletions
+2
View File
@@ -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 (`<log_dir>/vp_wire/<vp_name>_in.json`) and the periodic 1Hz copy that gets sent to the slicer (`<log_dir>/vp_wire/<vp_name>_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/<vp_serial>/request` AND every printer-originated response the bridge fans out to the slicer (extrusion_cali_get, ams_filament_setting acks, xcam, system, etc.) to `<log_dir>/vp_wire/<vp_name>_cmd.jsonl`, one JSON line per event with UTC iso timestamp, direction (`slicer_to_bridge` / `printer_to_slicer`), MQTT topic, a `<channel>.<command>` 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 `<log_dir>/vp_wire/<vp_name>_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`.
+17 -10
View File
@@ -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: ``<log_dir>/vp_wire/<sanitized_vp_name>_<direction>.json``
@@ -100,10 +103,14 @@ def append_event(vp_name: str, direction: str, topic: str, payload: dict | bytes
"""Append one event line to ``<log_dir>/vp_wire/<vp_name>_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": "<text>"}`` 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": "<text>"}`` so the line
is still valid JSON.
"""
if not _enabled():
return
@@ -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/<vp>_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).
+47 -1
View File
@@ -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