mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 20:22:15 +02:00
debug(vp): env-flagged wire-payload dump for slicer-mirror triage (#1622)
Add a BAMBUDDY_VP_DUMP_WIRE=1 escape hatch that writes the bridge's cached push_status (in) and the 1Hz slicer-facing copy (out) to <log_dir>/vp_wire/<vp_name>_<direction>.json, overwritten each tick. #1622's symptom — empty filament dropdown in slicer's AMS slot details for P1S/A1 but not H2D in non-proxy VP modes — needs visibility into the actual wire bytes flowing through the bridge to bisect between "cache is missing fields" and "_send_status_report strips them on copy." The existing logs prove the bridge is bound and pushing at 1Hz, but not what's in the payload. Off by default, single env flag, single file per VP per direction (bounded disk footprint), failures swallowed at debug so a broken dump can never break the 1Hz loop. 21 tests pin the helper contract: disabled-by-default, atomic writes, sanitized vp_name (no path escape), per-call env check so toggling without restart works.
This commit is contained in:
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.2.5b1] - Unreleased
|
||||
|
||||
### Added
|
||||
- **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.
|
||||
|
||||
- **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`.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Env-flagged wire-payload dump for VP MQTT debug (gated; off by default).
|
||||
|
||||
Set ``BAMBUDDY_VP_DUMP_WIRE=1`` to write the most recent inbound (bridge
|
||||
cache input) and outbound (slicer-facing 1Hz push) MQTT payloads to disk,
|
||||
one file per VP per direction, overwritten each tick.
|
||||
|
||||
Used to triage shape-of-payload bugs (e.g. #1622) where the question is
|
||||
"is the bridge missing fields in the cache, or is something else stripping
|
||||
them on the way out to the slicer?" Compare ``*_in.json`` and ``*_out.json``
|
||||
for the failing VP against a known-good VP (e.g. H2D vs P1S).
|
||||
|
||||
Layout: ``<log_dir>/vp_wire/<sanitized_vp_name>_<direction>.json``
|
||||
|
||||
Failure modes are swallowed at debug level — debug instrumentation must
|
||||
never break the bridge or slicer-facing 1Hz loop. Disable by unsetting the
|
||||
env var; the in-progress files stay on disk and can be deleted manually.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from backend.app.core.config import settings as app_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENV_FLAG = "BAMBUDDY_VP_DUMP_WIRE"
|
||||
_NAME_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return os.environ.get(_ENV_FLAG, "").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _sanitize(name: str) -> str:
|
||||
safe = _NAME_SAFE.sub("_", name or "vp").strip("_")
|
||||
return safe or "vp"
|
||||
|
||||
|
||||
def dump_wire(vp_name: str, direction: str, payload: dict | bytes | str) -> None:
|
||||
"""Write ``payload`` to ``<log_dir>/vp_wire/<vp_name>_<direction>.json``.
|
||||
|
||||
No-op when the env flag is unset. Accepts dict (json-encoded with
|
||||
``indent=2``), bytes (decoded as utf-8 with errors='replace'), or
|
||||
str (written verbatim).
|
||||
"""
|
||||
if not _enabled():
|
||||
return
|
||||
try:
|
||||
target_dir = app_settings.log_dir / "vp_wire"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = target_dir / f"{_sanitize(vp_name)}_{_sanitize(direction)}.json"
|
||||
if isinstance(payload, dict):
|
||||
text = json.dumps(payload, indent=2, default=str)
|
||||
elif isinstance(payload, bytes):
|
||||
text = payload.decode("utf-8", errors="replace")
|
||||
else:
|
||||
text = str(payload)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(text, encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
except OSError as e:
|
||||
logger.debug("[%s] vp_wire dump (%s) failed: %s", vp_name, direction, e)
|
||||
@@ -41,6 +41,8 @@ import logging
|
||||
import socket
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from backend.app.services.virtual_printer._debug import dump_wire
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
from backend.app.services.printer_manager import PrinterManager
|
||||
@@ -634,6 +636,7 @@ class MQTTBridge:
|
||||
):
|
||||
new_state["ams"] = _merge_ams_dict(prev["ams"], new_state["ams"])
|
||||
self._latest_print_state = new_state
|
||||
dump_wire(self.vp_name, "in", new_state)
|
||||
return
|
||||
|
||||
# info.get_version responses → cache the module list so the synthetic
|
||||
|
||||
@@ -15,6 +15,8 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from backend.app.services.virtual_printer._debug import dump_wire
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
|
||||
|
||||
@@ -912,6 +914,7 @@ class SimpleMQTTServer:
|
||||
print_block["total_layer_num"] = 0
|
||||
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)
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests for the env-flagged VP wire-payload dump helper used to triage
|
||||
shape-of-payload bugs like #1622."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.services.virtual_printer import _debug
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _isolated_log_dir(tmp_path, monkeypatch):
|
||||
with patch.object(app_settings, "log_dir", tmp_path):
|
||||
yield tmp_path
|
||||
|
||||
|
||||
def test_disabled_by_default_writes_nothing(_isolated_log_dir, monkeypatch):
|
||||
monkeypatch.delenv("BAMBUDDY_VP_DUMP_WIRE", raising=False)
|
||||
_debug.dump_wire("VP1", "out", {"hello": "world"})
|
||||
assert not (_isolated_log_dir / "vp_wire").exists()
|
||||
|
||||
|
||||
def test_enabled_writes_dict_as_pretty_json(_isolated_log_dir, monkeypatch):
|
||||
monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
|
||||
payload = {"print": {"ams": {"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]}}}
|
||||
_debug.dump_wire("Bambuddy P1S", "out", payload)
|
||||
out = _isolated_log_dir / "vp_wire" / "Bambuddy_P1S_out.json"
|
||||
assert out.is_file()
|
||||
assert json.loads(out.read_text()) == payload
|
||||
|
||||
|
||||
def test_overwrites_on_repeat_call(_isolated_log_dir, monkeypatch):
|
||||
monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
|
||||
_debug.dump_wire("VP1", "out", {"v": 1})
|
||||
_debug.dump_wire("VP1", "out", {"v": 2})
|
||||
out = _isolated_log_dir / "vp_wire" / "VP1_out.json"
|
||||
assert json.loads(out.read_text()) == {"v": 2}
|
||||
|
||||
|
||||
def test_separate_files_per_direction(_isolated_log_dir, monkeypatch):
|
||||
monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
|
||||
_debug.dump_wire("VP1", "in", {"src": "printer"})
|
||||
_debug.dump_wire("VP1", "out", {"src": "slicer"})
|
||||
assert (_isolated_log_dir / "vp_wire" / "VP1_in.json").is_file()
|
||||
assert (_isolated_log_dir / "vp_wire" / "VP1_out.json").is_file()
|
||||
|
||||
|
||||
def test_sanitizes_path_traversal_in_vp_name(_isolated_log_dir, monkeypatch):
|
||||
monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
|
||||
_debug.dump_wire("../../etc/passwd", "out", {"x": 1})
|
||||
vp_wire = _isolated_log_dir / "vp_wire"
|
||||
files = list(vp_wire.glob("*"))
|
||||
assert len(files) == 1
|
||||
# The actual safety property: the written file is inside vp_wire/.
|
||||
# `..` as a substring of a single filename component is harmless because
|
||||
# the path separator (/) is collapsed to _ before construction.
|
||||
assert files[0].resolve().parent == vp_wire.resolve()
|
||||
assert "/" not in files[0].name
|
||||
|
||||
|
||||
def test_empty_vp_name_falls_back_to_default(_isolated_log_dir, monkeypatch):
|
||||
monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
|
||||
_debug.dump_wire("", "out", {"x": 1})
|
||||
assert (_isolated_log_dir / "vp_wire" / "vp_out.json").is_file()
|
||||
|
||||
|
||||
def test_bytes_payload_decoded(_isolated_log_dir, monkeypatch):
|
||||
monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
|
||||
_debug.dump_wire("VP1", "in", b'{"raw": true}')
|
||||
out = _isolated_log_dir / "vp_wire" / "VP1_in.json"
|
||||
assert out.read_text() == '{"raw": true}'
|
||||
|
||||
|
||||
def test_unwritable_dir_is_swallowed(_isolated_log_dir, monkeypatch):
|
||||
"""A debug-instrumentation failure must not crash the bridge or 1Hz loop."""
|
||||
monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
|
||||
# Point log_dir at a location that mkdir refuses (a regular file occupying
|
||||
# the path). Failure must be swallowed.
|
||||
blocker = _isolated_log_dir / "blocker"
|
||||
blocker.write_text("not a dir")
|
||||
with patch.object(app_settings, "log_dir", blocker):
|
||||
_debug.dump_wire("VP1", "out", {"x": 1}) # must not raise
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag_value", ["0", "false", "off", "", "no"])
|
||||
def test_falsy_flag_values_disable(_isolated_log_dir, monkeypatch, flag_value):
|
||||
monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", flag_value)
|
||||
_debug.dump_wire("VP1", "out", {"x": 1})
|
||||
assert not (_isolated_log_dir / "vp_wire").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag_value", ["1", "true", "TRUE", "yes", "on", "On"])
|
||||
def test_truthy_flag_values_enable(_isolated_log_dir, monkeypatch, flag_value):
|
||||
monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", flag_value)
|
||||
_debug.dump_wire("VP1", "out", {"x": 1})
|
||||
assert (_isolated_log_dir / "vp_wire" / "VP1_out.json").is_file()
|
||||
|
||||
|
||||
def test_idempotent_atomic_no_partial_file_visible(_isolated_log_dir, monkeypatch):
|
||||
"""tmp+rename pattern means a reader never sees a half-written .json file."""
|
||||
monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
|
||||
_debug.dump_wire("VP1", "out", {"x": 1})
|
||||
files = sorted(p.name for p in (_isolated_log_dir / "vp_wire").iterdir())
|
||||
# No leftover .tmp file after a successful write.
|
||||
assert files == ["VP1_out.json"]
|
||||
|
||||
|
||||
def test_env_check_is_per_call_not_module_load(_isolated_log_dir, monkeypatch):
|
||||
"""Flag toggle must take effect on the next call without restarting; we
|
||||
re-read the env var inside ``dump_wire`` rather than caching at import."""
|
||||
monkeypatch.delenv("BAMBUDDY_VP_DUMP_WIRE", raising=False)
|
||||
_debug.dump_wire("VP1", "out", {"v": 1})
|
||||
assert not (_isolated_log_dir / "vp_wire").exists()
|
||||
|
||||
os.environ["BAMBUDDY_VP_DUMP_WIRE"] = "1"
|
||||
try:
|
||||
_debug.dump_wire("VP1", "out", {"v": 2})
|
||||
assert (_isolated_log_dir / "vp_wire" / "VP1_out.json").is_file()
|
||||
finally:
|
||||
os.environ.pop("BAMBUDDY_VP_DUMP_WIRE", None)
|
||||
Reference in New Issue
Block a user