From 932aa557f2f0e624d8648768b33398ee4dd1dc3d Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 2 Jul 2026 09:53:33 +0200 Subject: [PATCH] =?UTF-8?q?fix(vp):=20route=20non-proxy=20camera=20passthr?= =?UTF-8?q?ough=20by=20target=20model=20=E2=80=94=206000=20for=20A1/P1=20(?= =?UTF-8?q?#1868)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-proxy VP mode hardcoded the camera-passthrough TCPProxy to listen_port=322 / target_port=322 regardless of the target printer's model. That port is correct for RTSPS models (X1/X2/H2/P2S), but A1 / A1 Mini / P1P / P1S use Bambu's proprietary chamber-image protocol on port 6000. Result: A1/P1 targets got a 322 listener with no upstream, OrcaSlicer Liveview failed with [2:-10061], BambuStudio's camera button timed out. Reporter confirmed a raw socat forwarder `:6000 → :6000` restored the stream — the target camera works, the VP just wasn't publishing it. Proxy mode was unaffected because SlicerProxyManager already opens 6000 (nominally file-transfer; Bambu reuses the port for chamber-image), so the passthrough coincidentally works there. Fix: read the target's model from `printer_manager.get_client(target_id).model` at the same point we read target_ip, then use `get_camera_port(target_model)` — the same source of truth as routes/camera.py — to pick 322 or 6000. Model comes from the physical printer, NOT self.model (the VP's spoofed identity has no bearing on how the real device serves its camera). Renamed the log tag from "RTSP" to f"Camera-{camera_port}" so support bundles show which protocol the VP is fronting at a glance. Kept the _rtsp_proxy attribute name to keep the diff tight; the block comment spells out that it doubles as chamber-image passthrough on A1/P1. --- CHANGELOG.md | 2 + .../app/services/virtual_printer/manager.py | 33 +++-- .../unit/services/test_virtual_printer.py | 140 ++++++++++++++++++ 3 files changed, 164 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8bd82cf2..29533b8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b2] - Unreleased ### Fixed +- **Non-proxy VP camera passthrough is dead for A1 / P1 targets — OrcaSlicer Liveview fails with `[2:-10061]` (#1868, reporter @tom4711-2)** — Symptom on a P1S target in server/non-proxy VP mode: the VP starts 3000, 3002, 8883, 990 and 322, but nothing on 6000, so OrcaSlicer's Liveview button fails. Reporter confirmed a raw `socat` forwarder `:6000 → :6000` immediately restores the stream — the target camera works, the VP just isn't publishing it. **Root cause.** `backend/app/services/virtual_printer/manager.py:1098-1118` hardcoded the camera-passthrough `TCPProxy` to `listen_port=322 / target_port=322` regardless of the target printer's model. That port is correct for RTSPS models (X1/X2/H2/P2S), but A1 / A1 Mini / P1P / P1S use Bambu's proprietary chamber-image protocol on port 6000 — the 322 listener the manager opens for those targets has no upstream, and the slicer's connection to `:322` yields "connection refused". Proxy mode is unaffected because `SlicerProxyManager` (`tcp_proxy.py:1596`) already opens 6000 for file-transfer, and Bambu reuses the same port for chamber-image, so the passthrough coincidentally works there. **Fix.** Read the target printer's model from `printer_manager.get_client(target_id).model` at the same point the target IP is read, then call `get_camera_port(target_model)` — the same source of truth used by `routes/camera.py` and covered by `test_printer_models.py::TestSupportsRtsp` / `TestGetCameraPort` — to pick 322 or 6000. TCPProxy listen_port and target_port both follow the same value. Rename the log tag from `"RTSP"` to `f"Camera-{camera_port}"` so support-bundle grep tells you at a glance which protocol the VP is fronting for. The internal `_rtsp_proxy` attribute name is unchanged to keep the diff tight; the comment above the block spells out that it doubles as chamber-image passthrough on A1/P1. Model comes from the target printer's client instance (`target_client.model`), NOT `self.model` — the VP's spoofed identity has no bearing on how the physical printer serves its camera. **Tests.** New `TestVirtualPrinterCameraPassthrough` in `test_virtual_printer.py` — 4 cases pin the branch: `test_rtsp_model_p2s_opens_port_322` and `test_rtsp_model_x1c_opens_port_322` guard the RTSP path stays on 322 (regression against the fix accidentally routing everything to 6000); `test_chamber_image_model_p1s_opens_port_6000` and `test_chamber_image_model_a1_opens_port_6000` are the direct #1868 guards — P1S / A1 targets must expose 6000. The P1S case additionally asserts NO 322 listener was opened, since a stale 322 on an A1/P1 install would confuse anyone port-scanning the VP. Test harness monkeypatches `TCPProxy` and every peer service (`VirtualPrinterFTPServer`, `SimpleMQTTServer`, `MQTTBridge`, `BindServer`, `VirtualPrinterSSDPServer`, `SSDPProxy`) to lightweight MagicMocks with an already-set `asyncio.Event` on `.ready` — `start_server()` awaits `.ready.wait()` on those four barriers before returning, so an unset event would deadlock the test. 143/143 in the file green (was 139 + 4 new). **Scope.** Backend-only, one-hunk change in `manager.py` plus 4 tests. No i18n key, no permission change, no DB migration, no frontend change. Users on 0.2.5b1 or earlier with A1 / A1 Mini / P1P / P1S targets in non-proxy VP mode: the fix takes effect on the next Bambuddy restart, no reconfiguration needed. The workaround `socat` forwarder can be removed after upgrade. + - **Editing a queue item assigned to "Any of model X" left the printer selection area blank** — `PrintModal` initialises `assignmentMode` from `queueItem.target_model`: items created with a specific printer got `'printer'` mode (renders the printer list), items created with model-based assignment got `'model'` mode. But `PrintModal/index.tsx:1102-1106` was passing `onAssignmentModeChange={!isEditing ? setAssignmentMode : undefined}` (same for `onTargetModelChange` / `onTargetLocationChange`), which flipped `modelAssignmentAvailable` to `false` in `PrinterSelector` and hid every model-mode control — the mode toggle at `PrinterSelector.tsx:390`, the model dropdown at `:431`, the location filter at `:459`. Combined with the `assignmentMode === 'printer'` gate on the printer list at `:519`, edit-mode for a model-assigned item rendered an empty container. Users couldn't retarget the item or even see what model it was assigned to; the only way to change it was delete + re-queue. **Fix.** Drop the `!isEditing` gate on all three props — the underlying submit path already handles both directions cleanly (`printer_id: null, target_model, target_location` when saving in model mode at `index.tsx:788-802`; `printer_id, target_model: null, target_location: null` when saving in printer mode at `:844-857`), so un-gating the UI just surfaces the machinery that was already there. Users can now (a) see the current target model + location on a model-assigned item, (b) change the target model or narrow / widen the location filter, and (c) flip the item between "Specific Printer" and "Any of Model" without deleting + re-queueing. Edit is only offered on `pending` items (`QueuePage.tsx:2137`), so there's no race with an in-flight dispatch when the assignment mode flips. **Scope.** Frontend-only, one-hunk change to the props on the existing `PrinterSelector` invocation. No new i18n key (the model / location / mode strings already existed for create mode). No backend change. Existing 61/61 `PrintModal.test.tsx` cases green — the tests that pass `initialSelectedPrinterIds={[1]}` for the create-with-printer flow are unaffected because the `!initialSelectedPrinterIds?.length` gate at `index.tsx:1088` still hides the whole `PrinterSelector` for the post-upload dispatch dialog. - **Finish photo captures the wrong plate on A1 / A1 Mini when SwapMod plate-swap End G-code is injected (#1867, reporter @qoatzelcoat)** — The "Print Complete" snapshot arrived after the user's SwapMod plate-swap moves had already ejected the printed part, so the notification always carried an image of the *swapped* (empty) plate. **Root cause.** The stage-22 ("Filament unloading") edge that normally fires the finish-photo pre-capture never fires on A1 Mini firmware (confirmed in the reporter's log: `FINISH PHOTO MOMENT (FINISH fallback) — stage-22 never fired; capturing at FINISH-state transition`). The fallback path in `bambu_mqtt.py` waits for `gcode_state → FINISH`, and Bambu Studio runs the user-defined End G-code *before* that state transition — so by the time the fallback captures, SwapMod has already moved the plate. **Fix.** Added a new `layer_num → total_layer_num` edge trigger inside `_parse_print_data` that fires the finish-photo moment the instant the last object layer completes, before any end G-code runs. Guarded by the existing `_was_running` / `_finish_photo_captured` one-shot so subsequent stage-22 and FINISH-state ticks become no-ops for the same print. Works on every Bambu variant (A1, A1 Mini, P1P/S, X1C, H2S/H2D) without model detection; on AMS printers the last-layer edge fires slightly before stage-22 would have, which also fixes SwapMod-style setups on those printers rather than only on the A1 family. Test coverage: `TestLastLayerFinishPhotoTrigger` in `backend/tests/unit/services/test_bambu_mqtt.py` (7 cases — fires on last layer, edge-only, skipped on stage-22 replay, skipped on the FINISH fallback, ignores `total=0` bootstrap frames, ignores non-running catch-up messages). diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 7d6cdb751..74a709de7 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -1095,25 +1095,36 @@ class VirtualPrinterInstance: self._mqtt.set_bridge(self._mqtt_bridge) await self._mqtt_bridge.start() - # RTSPS camera passthrough on port 322. BambuStudio's camera button - # connects to the device IP it bound on (the VP), not the IP in - # `ipcam.rtsp_url`. Without a listener on :322 the slicer - # gets connection refused → "LAN connection failed". Same raw TCP - # pass-through used by SlicerProxyManager in proxy mode. + # Camera passthrough. BambuStudio / OrcaSlicer connect the "camera" + # button to the device IP they bound on (the VP), not the IP in the + # printer's `ipcam.rtsp_url`. Without a listener the slicer gets + # connection refused → "LAN connection failed" (RTSP models) or + # OrcaSlicer error `[2:-10061]` (chamber-image models, #1868). + # + # The port depends on the TARGET printer's model: + # RTSPS (X1/X2/H2/P2S) → 322 + # chamber-image (A1/P1P/P1S) → 6000 + # + # `get_camera_port()` is the same source of truth used by + # `routes/camera.py`, so slicer and Bambuddy UI agree. target_client = self._printer_manager.get_client(self.target_printer_id) target_ip = getattr(target_client, "ip_address", None) if target_client else None + target_model = getattr(target_client, "model", None) if target_client else None if target_ip: + from backend.app.services.camera import get_camera_port + + camera_port = get_camera_port(target_model) self._rtsp_proxy = TCPProxy( - name="RTSP", - listen_port=322, + name=f"Camera-{camera_port}", + listen_port=camera_port, target_host=target_ip, - target_port=322, + target_port=camera_port, bind_address=bind_addr, ) self._tasks.append( asyncio.create_task( - run_with_logging(self._rtsp_proxy.start(), "RTSP"), - name=f"vp_{self.id}_rtsp", + run_with_logging(self._rtsp_proxy.start(), f"Camera-{camera_port}"), + name=f"vp_{self.id}_camera", ) ) @@ -1197,7 +1208,7 @@ class VirtualPrinterInstance: try: await self._rtsp_proxy.stop() except Exception: - logger.exception("[VP %s] RTSP proxy stop failed", self.name) + logger.exception("[VP %s] Camera proxy stop failed", self.name) self._rtsp_proxy = None if self._ftp: await self._ftp.stop() diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index 9c5b54883..339022bd6 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -3175,6 +3175,146 @@ class TestVirtualPrinterManagerDirectories: assert (tmp_path / "certs" / "42").exists() +class TestVirtualPrinterCameraPassthrough: + """Tests for the non-proxy VP camera pass-through port selection (#1868). + + The camera port must follow the TARGET printer's model, not the VP's + spoofed model: RTSP models (X1/X2/H2/P2S) use 322, chamber-image models + (A1/P1) use 6000. Before the fix, the manager hardcoded 322, so P1S / + A1 targets got a 322 listener with no upstream and BambuStudio / + OrcaSlicer Liveview failed with error `[2:-10061]` (the reporter's + symptom in #1868). + """ + + @staticmethod + def _patch_start_server(monkeypatch): + """Patch every non-camera constructor start_server touches so the + test only exercises the camera-port branch. Returns the list that + ``TCPProxy`` calls get captured into.""" + from backend.app.services.virtual_printer import manager as vp_manager + + tcp_calls: list[dict] = [] + + class FakeTCPProxy: + def __init__(self, **kwargs): + tcp_calls.append(kwargs) + self.kwargs = kwargs + + async def start(self): + return None + + async def stop(self): + return None + + # Camera pass-through is the only TCPProxy start_server constructs. + monkeypatch.setattr(vp_manager, "TCPProxy", FakeTCPProxy) + + # No-op every other service constructor + resolve_cert_and_advertise. + # start_server() awaits `.ready.wait()` on FTP/MQTT/Bind/SSDP after + # spawning them, so each fake instance must carry an already-set + # asyncio.Event as `.ready` — a plain MagicMock returns another + # MagicMock for `.wait()`, which `asyncio.gather` then rejects with + # `TypeError: An asyncio.Future, a coroutine or an awaitable is + # required`. + def _service_factory(): + def make(*args, **kwargs): + inst = MagicMock() + inst.start = AsyncMock(return_value=None) + inst.stop = AsyncMock(return_value=None) + ready = asyncio.Event() + ready.set() + inst.ready = ready + return inst + + return make + + for name in ( + "VirtualPrinterFTPServer", + "SimpleMQTTServer", + "MQTTBridge", + "BindServer", + "VirtualPrinterSSDPServer", + "SSDPProxy", + ): + monkeypatch.setattr(vp_manager, name, _service_factory()) + + return tcp_calls + + async def _run_start_server( + self, + tmp_path, + monkeypatch, + *, + target_model: str, + target_ip: str = "192.168.1.100", + ) -> list[dict]: + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + tcp_calls = self._patch_start_server(monkeypatch) + + instance = VirtualPrinterInstance( + vp_id=99, + name="CamTest", + mode="archive", + model="BL-P001", # VP's spoofed identity — irrelevant here + access_code="12345678", + serial_suffix="391800099", + target_printer_id=7, + base_dir=tmp_path, + ) + + # printer_manager stub returns a client with the model + ip we want. + client = MagicMock() + client.ip_address = target_ip + client.model = target_model + printer_manager = MagicMock() + printer_manager.get_client.return_value = client + instance._printer_manager = printer_manager + + # Cert / advertise resolution — start_server calls this early. Patch + # to a fixed tuple so no filesystem I/O is required. + monkeypatch.setattr( + instance, + "_resolve_cert_and_advertise", + lambda: (Path("/tmp/cert.pem"), Path("/tmp/key.pem"), "192.168.1.1"), # nosec B108 + ) + + try: + await instance.start_server() + finally: + for task in instance._tasks: + task.cancel() + await asyncio.gather(*instance._tasks, return_exceptions=True) + + return tcp_calls + + @pytest.mark.asyncio + async def test_rtsp_model_p2s_opens_port_322(self, tmp_path, monkeypatch): + calls = await self._run_start_server(tmp_path, monkeypatch, target_model="P2S") + assert any(c["listen_port"] == 322 and c["target_port"] == 322 for c in calls), ( + f"Expected 322 pass-through for RTSP model P2S, got {calls}" + ) + + @pytest.mark.asyncio + async def test_chamber_image_model_p1s_opens_port_6000(self, tmp_path, monkeypatch): + """#1868 regression guard: P1S target must expose 6000, not 322.""" + calls = await self._run_start_server(tmp_path, monkeypatch, target_model="P1S") + assert any(c["listen_port"] == 6000 and c["target_port"] == 6000 for c in calls), ( + f"Expected 6000 pass-through for chamber-image model P1S (#1868), got {calls}" + ) + assert not any(c["listen_port"] == 322 for c in calls), f"P1S should NOT get a 322 listener, got {calls}" + + @pytest.mark.asyncio + async def test_chamber_image_model_a1_opens_port_6000(self, tmp_path, monkeypatch): + calls = await self._run_start_server(tmp_path, monkeypatch, target_model="A1") + assert any(c["listen_port"] == 6000 and c["target_port"] == 6000 for c in calls) + + @pytest.mark.asyncio + async def test_rtsp_model_x1c_opens_port_322(self, tmp_path, monkeypatch): + calls = await self._run_start_server(tmp_path, monkeypatch, target_model="X1C") + assert any(c["listen_port"] == 322 and c["target_port"] == 322 for c in calls) + + class TestVirtualPrinterInstanceProxyMode: """Tests for VirtualPrinterInstance proxy mode."""