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."""