diff --git a/CHANGELOG.md b/CHANGELOG.md index 247c91be6..3b609cda6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to Bambuddy will be documented in this file. - **SpoolBuddy Scale Tare & Calibration Not Applied** — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Five bugs in the chain: (1) the daemon received the `tare` command via heartbeat but never called `scale.tare()` — a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called `scale.update_calibration()`, so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) the `set-factor` endpoint computed `calibration_factor` using the DB `tare_offset`, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added a `POST /devices/{device_id}/calibration/set-tare` endpoint and `update_tare()` API client method. The heartbeat loop now executes `scale.tare()` when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard. - **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors="replace")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics. - **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend. +- **Support Package Leaks Full Subnet IPs and Misdetects Docker Network Mode** — Three support package fixes. First, the network section included full subnet addresses (e.g., `192.168.192.0/24`); now masks the first two octets (`x.x.192.0/24`). Second, `network_mode_hint` used `len(interfaces) > 2` which always reported "bridge" on single-NIC hosts even with `network_mode: host`, because `get_network_interfaces()` excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (`docker0`, `br-*`, `veth*`) via `socket.if_nameindex()` — these are only visible when the container shares the host network namespace. Third, `developer_mode` was still null for most users because the MQTT `fun` field was only parsed inside the `print` key; some firmware versions send it at the top level of the payload. Now also checks top-level `fun`. Also added a `virtual_printers` section with mode, model, enabled/running status, and pending file count for each configured virtual printer. - **SpoolBuddy NFC Reader Fails to Detect Tags** — The PN5180 NFC reader had two polling issues. First, each `activate_type_a()` call that returned `None` (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health. ### Improved diff --git a/backend/app/api/routes/support.py b/backend/app/api/routes/support.py index 07a323879..e4c4caf51 100644 --- a/backend/app/api/routes/support.py +++ b/backend/app/api/routes/support.py @@ -325,6 +325,37 @@ def _sanitize_path(path: str) -> str: return path +def _detect_docker_network_mode() -> str: + """Detect Docker network mode by checking for host-level interfaces. + + In host mode the container shares the host network namespace, so Docker + infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge + mode the container is isolated and only sees its own veth (named eth0). + """ + try: + import socket + + for _idx, name in socket.if_nameindex(): + if name.startswith(("docker", "br-", "veth", "virbr")): + return "host" + except Exception: + pass + return "bridge" + + +def _mask_subnet(subnet: str) -> str: + """Mask the first two octets of a subnet string. e.g. '192.168.1.0/24' -> 'x.x.1.0/24'.""" + try: + parts = subnet.split(".") + if len(parts) >= 4: + parts[0] = "x" + parts[1] = "x" + return ".".join(parts) + except Exception: + pass + return subnet + + def _anonymize_mqtt_broker(broker: str) -> str: """Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain.""" if not broker: @@ -418,11 +449,10 @@ async def _collect_support_info() -> dict: if in_docker: try: mem_limit = _get_container_memory_limit() - interfaces = get_network_interfaces() info["docker"] = { "container_memory_limit_bytes": mem_limit, "container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None, - "network_mode_hint": "host" if len(interfaces) > 2 else "bridge", + "network_mode_hint": _detect_docker_network_mode(), } except Exception: logger.debug("Failed to collect Docker info", exc_info=True) @@ -500,6 +530,34 @@ async def _collect_support_info() -> dict: } ) + # Virtual printers + try: + from backend.app.models.virtual_printer import VirtualPrinter + from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager + + result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.id)) + vps = result.scalars().all() + info["virtual_printers"] = [] + for vp in vps: + instance = virtual_printer_manager.get_instance(vp.id) + status = instance.get_status() if instance else None + model_code = vp.model or "C12" + info["virtual_printers"].append( + { + "index": vp.id, + "enabled": vp.enabled, + "mode": vp.mode, + "model": model_code, + "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code), + "has_target_printer": vp.target_printer_id is not None, + "has_bind_ip": bool(vp.bind_ip), + "running": status.get("running", False) if status else False, + "pending_files": status.get("pending_files", 0) if status else 0, + } + ) + except Exception: + logger.debug("Failed to collect virtual printer info", exc_info=True) + # Non-sensitive settings result = await db.execute(select(Settings)) all_settings = result.scalars().all() @@ -642,12 +700,12 @@ async def _collect_support_info() -> dict: except Exception: logger.debug("Failed to collect log file info", exc_info=True) - # Network interfaces (subnets only — already anonymized) + # Network interfaces (subnets with first two octets masked) try: interfaces = get_network_interfaces() info["network"] = { "interface_count": len(interfaces), - "interfaces": [{"name": iface["name"], "subnet": iface["subnet"]} for iface in interfaces], + "interfaces": [{"name": iface["name"], "subnet": _mask_subnet(iface["subnet"])} for iface in interfaces], } except Exception: logger.debug("Failed to collect network info", exc_info=True) diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index b92590202..a881ec3a7 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -538,6 +538,16 @@ class BambuMQTTClient: except ValueError: pass # Ignore unparseable wifi_signal strings; field is non-critical + # Parse developer LAN mode from top-level "fun" field + # Some firmware versions send "fun" at the top level, others inside "print" + if "fun" in payload and self.state.developer_mode is None: + try: + fun_val = payload["fun"] + fun_int = fun_val if isinstance(fun_val, int) else int(fun_val, 16) + self.state.developer_mode = (fun_int & 0x20000000) == 0 + except (ValueError, TypeError): + pass + if "print" in payload: print_data = payload["print"] diff --git a/backend/tests/unit/test_support_helpers.py b/backend/tests/unit/test_support_helpers.py index aa9019fb7..26f9d2fd3 100644 --- a/backend/tests/unit/test_support_helpers.py +++ b/backend/tests/unit/test_support_helpers.py @@ -373,6 +373,7 @@ class TestCollectSupportInfo: with ( patch("backend.app.api.routes.support.is_running_in_docker", return_value=True), patch("backend.app.api.routes.support._get_container_memory_limit", return_value=1073741824), + patch("backend.app.api.routes.support._detect_docker_network_mode", return_value="bridge"), patch("backend.app.api.routes.support.async_session") as mock_session_ctx, patch("backend.app.api.routes.support.printer_manager") as mock_pm, patch( @@ -529,10 +530,11 @@ class TestCollectSupportInfo: assert info["network"]["interface_count"] == 2 assert info["network"]["interfaces"][0]["name"] == "eth0" - assert info["network"]["interfaces"][0]["subnet"] == "192.168.1.0/24" - # Verify IP addresses are NOT included + assert info["network"]["interfaces"][0]["subnet"] == "x.x.1.0/24" + # Verify IP addresses are NOT included (first two octets masked) for iface in info["network"]["interfaces"]: assert "ip" not in iface + assert iface["subnet"].startswith("x.x.") @pytest.mark.asyncio @pytest.mark.unit