Просмотр исходного кода

Fix support package: mask subnet IPs, detect host mode, parse top-level fun, add virtual printers

  Four support package improvements:

  1. Mask first two octets of subnet IPs in support info
     (192.168.1.0/24 → x.x.1.0/24) to avoid leaking private network
     addresses.

  2. Fix Docker network_mode_hint detection. The old heuristic
     (interface count > 2) always reported "bridge" on single-NIC
     hosts because get_network_interfaces() excludes Docker
     interfaces. Now checks for docker0/br-*/veth* visibility via
     socket.if_nameindex() — these are only visible in host mode.

  3. Parse MQTT "fun" field at top level of payload (not just inside
     "print" key). Some firmware versions send it there, which
     explains why developer_mode was null for most users.

  4. Add virtual_printers section to support info with mode, model,
     enabled/running status, and pending file count.
maziggy 2 месяцев назад
Родитель
Сommit
8843af6665

+ 1 - 0
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.
 - **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.
 - **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.
 - **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.
 - **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
 ### Improved

+ 62 - 4
backend/app/api/routes/support.py

@@ -325,6 +325,37 @@ def _sanitize_path(path: str) -> str:
     return path
     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:
 def _anonymize_mqtt_broker(broker: str) -> str:
     """Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain."""
     """Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain."""
     if not broker:
     if not broker:
@@ -418,11 +449,10 @@ async def _collect_support_info() -> dict:
     if in_docker:
     if in_docker:
         try:
         try:
             mem_limit = _get_container_memory_limit()
             mem_limit = _get_container_memory_limit()
-            interfaces = get_network_interfaces()
             info["docker"] = {
             info["docker"] = {
                 "container_memory_limit_bytes": mem_limit,
                 "container_memory_limit_bytes": mem_limit,
                 "container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None,
                 "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:
         except Exception:
             logger.debug("Failed to collect Docker info", exc_info=True)
             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
         # Non-sensitive settings
         result = await db.execute(select(Settings))
         result = await db.execute(select(Settings))
         all_settings = result.scalars().all()
         all_settings = result.scalars().all()
@@ -642,12 +700,12 @@ async def _collect_support_info() -> dict:
     except Exception:
     except Exception:
         logger.debug("Failed to collect log file info", exc_info=True)
         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:
     try:
         interfaces = get_network_interfaces()
         interfaces = get_network_interfaces()
         info["network"] = {
         info["network"] = {
             "interface_count": len(interfaces),
             "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:
     except Exception:
         logger.debug("Failed to collect network info", exc_info=True)
         logger.debug("Failed to collect network info", exc_info=True)

+ 10 - 0
backend/app/services/bambu_mqtt.py

@@ -538,6 +538,16 @@ class BambuMQTTClient:
                 except ValueError:
                 except ValueError:
                     pass  # Ignore unparseable wifi_signal strings; field is non-critical
                     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:
         if "print" in payload:
             print_data = payload["print"]
             print_data = payload["print"]
 
 

+ 4 - 2
backend/tests/unit/test_support_helpers.py

@@ -373,6 +373,7 @@ class TestCollectSupportInfo:
         with (
         with (
             patch("backend.app.api.routes.support.is_running_in_docker", return_value=True),
             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._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.async_session") as mock_session_ctx,
             patch("backend.app.api.routes.support.printer_manager") as mock_pm,
             patch("backend.app.api.routes.support.printer_manager") as mock_pm,
             patch(
             patch(
@@ -529,10 +530,11 @@ class TestCollectSupportInfo:
 
 
         assert info["network"]["interface_count"] == 2
         assert info["network"]["interface_count"] == 2
         assert info["network"]["interfaces"][0]["name"] == "eth0"
         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"]:
         for iface in info["network"]["interfaces"]:
             assert "ip" not in iface
             assert "ip" not in iface
+            assert iface["subnet"].startswith("x.x.")
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.unit
     @pytest.mark.unit