fix(vp): route non-proxy camera passthrough by target model — 6000 for A1/P1 (#1868)

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 `<VP-IP>:6000 → <P1S-IP>: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.
This commit is contained in:
maziggy
2026-07-02 09:53:33 +02:00
parent 10ad9267e1
commit 932aa557f2
3 changed files with 164 additions and 11 deletions
+2
View File
@@ -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 `<VP-IP>:6000 → <P1S-IP>: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 `<VP-IP>: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).
+22 -11
View File
@@ -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 <bind_ip>: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()
@@ -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."""