mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-10 07:55:39 +02:00
fix(camera): share broadcaster buffered frame with Obico + /camera/snapshot (#1271)
The MJPEG fan-out broadcaster from #1089 only solved viewer-side concurrency. Obico polling (every 5s) and the manual /camera/snapshot endpoint kept opening their own fresh RTSP sockets, which X1/H2/P2 firmwares tolerated but X2D firmware 01.01.00.00 enforces strict single-connection on — every poll kicked the live stream. Add try_get_active_buffered_frame(printer_id): returns the broadcaster's last buffered frame when a viewer is connected, None otherwise. Obico and /camera/snapshot consult it before opening a fresh socket. When no viewer is active they fall through to the existing fresh-capture path. plate_detection and layer_timelapse intentionally not converted.
This commit is contained in:
@@ -8,6 +8,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Spoolman weight tracking now uses per-print grams for all spools, matching the internal Filament Inventory** ([#1119](https://github.com/maziggy/bambuddy/issues/1119), reported by @Moskito99) — Spoolman previously had two mutually-exclusive weight paths: AMS remain%×tray_weight auto-sync (default; only worked for Bambu Lab spools with valid RFID tray_weight) and per-print 3MF-grams tracking (only enabled when "Disable AMS Weight Sync" was toggled on). Non-BL spools without RFID fell through both paths — AMS auto-sync had no tray_weight to multiply, and the inventory_remaining fallback was wiped because activating Spoolman deletes the internal `spool_assignment` table — so Spoolman never saw a weight update for them. The internal Filament Inventory has no such gap: it always uses per-print 3MF grams as the primary path with AMS-remain% delta as fallback, and it works for every spool type. Spoolman now does the same: per-print tracking runs whenever Spoolman is enabled and is the only writer of `remaining_weight`. AMS auto-sync continues to maintain spool metadata and slot assignments but no longer touches weight (eliminating the double-count that would otherwise occur for BL spools with both paths active). `store_print_data` ([`spoolman_tracking.py:159`](backend/app/services/spoolman_tracking.py)) had its `disable_weight_sync` early-return removed; the three `sync_ams_tray` callsites (`main.py:1450` auto-sync, `spoolman.py:318` per-printer manual, `spoolman.py:517` sync-all) now hard-code `disable_weight_sync=True`. The `spoolman_disable_weight_sync` setting is now deprecated and a no-op — kept in the DB/UI for backwards compat. Behavioral consequence for existing users on the default flag (False): live AMS-based remaining_weight updates between prints stop happening; weight updates now arrive once per print completion with 3MF gram precision. Regression test in `test_spoolman_tracking.py::test_stores_tracking_when_disable_weight_sync_is_false` proves the early-return is gone.
|
||||
|
||||
### Fixed
|
||||
- **X2D live camera stream no longer cut by Obico polling / snapshot capture** ([#1271](https://github.com/maziggy/bambuddy/issues/1271), reported by @clabeuhtegrite) — The MJPEG fan-out broadcaster from #1089 lets multiple browser viewers share one upstream RTSP socket per printer, but internal callers (Obico AI polling at the user's configured `obico_poll_interval`, and the manual `/camera/snapshot` endpoint) still opened their own fresh RTSP connections. X1C / H2D / P2S firmware tolerates brief concurrent camera sockets so the gap was invisible there. X2D firmware `01.01.00.00` (and likely future firmwares) enforces strict single-camera-connection more aggressively: every Obico poll (default every 5 s) kicked the live stream, the broadcaster paid the multi-second RTSP handshake to reconnect, and the user saw the stream cut "all the time." New helper `try_get_active_buffered_frame(printer_id)` at [`api/routes/camera.py:74`](backend/app/api/routes/camera.py) returns the broadcaster's last buffered frame (always <1 s old while any viewer is connected) and `None` when no viewer is active. Obico's `_capture_frame` and the `/camera/snapshot` endpoint check it first and only fall through to a fresh socket when no stream is running — preserving today's behavior when nobody is watching. `plate_detection` and `layer_timelapse` deliberately not converted: plate-detection needs guaranteed-fresh frames post-print (false-positive risk if the user already grabbed the print in the same second), and layer-timelapse is for external cameras only. Regression tests: `test_camera_snapshot_reuses_buffered_frame_when_stream_active` and two `TestCaptureFrameSharesBroadcasterUpstream` Obico tests.
|
||||
|
||||
- **Usage tracker: spool swaps in UNUSED slots mid-print no longer charge the old spool** ([#1269](https://github.com/maziggy/bambuddy/issues/1269), reported by @maugsburger) — Path 2 of the usage tracker (AMS remain% delta fallback) iterated every AMS tray that had a remain% delta, even slots the print never touched. When a user swapped spools in an unrelated slot during a print, the new spool reports `remain=0` (no RFID tag yet) while the snapshot from print-start was 100%, so the fallback charged the originally-assigned spool the full 1000 g. Reporter's case: single-filament print on AMS0-T3 (`ams_mapping=[3]`), swapped a spool in T1 and another in T2 to refill while the print continued — wound up with `Spool 27 consumed 1000.0g (100%) on printer 1 AMS0-T1` and `Spool 24 consumed 170.0g (17%) on printer 1 AMS0-T2`, neither of which were ever in the print. Fix: the fallback now builds `print_used_keys` from `session.ams_mapping`, `state.tray_change_log`, and `session.tray_now_at_start` (the three runtime signals telling us which trays were actually part of the print), converts each global tray ID to `(ams_id, tray_id)` using the standard convention (254/255 → external, ≥128 → AMS-HT, otherwise `id // 4, id % 4`), and skips fallback for trays whose key is not in that set. When all three signals are empty (legacy edge case: no slicer push, no MQTT tray-change events, no `tray_now` at start) the legacy "scan every tray" behavior is preserved so we don't regress prints with no metadata. Regression test in `test_usage_tracker.py::test_skips_fallback_for_trays_outside_print_mapping` reproduces the reporter's exact scenario.
|
||||
|
||||
- **Printer card: smart-plug live wattage now rounded to whole watts** ([#1266](https://github.com/maziggy/bambuddy/issues/1266), reported by @Carter3DP) — The printer card's smart-plug status badge rendered `plugStatus.energy.power` raw, so plugs that report fractional watts (Kauf PLF12 via ESPHome / Home Assistant in the reporter's case, but any MQTT plug pushing a float can hit this) showed values like `14.123456789012` W and overflowed the card width. `SmartPlugCard` and `SwitchbarPopover` already wrapped the same field in `Math.round()`; only the printer-card badge was missing the round. Single-line fix at `frontend/src/pages/PrintersPage.tsx:4569`.
|
||||
|
||||
@@ -79,6 +79,25 @@ def get_buffered_frame(printer_id: int) -> bytes | None:
|
||||
return _last_frames.get(printer_id)
|
||||
|
||||
|
||||
def try_get_active_buffered_frame(printer_id: int) -> bytes | None:
|
||||
"""Return a buffered frame iff a stream is currently running for this printer.
|
||||
|
||||
Snapshot callers (Obico polling, manual /camera/snapshot) tap the fan-out
|
||||
broadcaster's running upstream instead of opening a second concurrent
|
||||
RTSP/chamber-image socket. Critical for printers that allow only one
|
||||
camera connection (e.g. X2D firmware 01.01.00.00; see #1271).
|
||||
|
||||
Returns None when no broadcaster is active for this printer, so callers
|
||||
fall through to their existing fresh-socket path unchanged.
|
||||
"""
|
||||
has_stream = any(k.startswith(f"{printer_id}-") for k in _active_streams) or any(
|
||||
k.startswith(f"{printer_id}-") for k in _active_chamber_streams
|
||||
)
|
||||
if not has_stream:
|
||||
return None
|
||||
return _last_frames.get(printer_id)
|
||||
|
||||
|
||||
async def get_printer_or_404(printer_id: int, db: AsyncSession) -> Printer:
|
||||
"""Get printer by ID or raise 404."""
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
@@ -812,6 +831,21 @@ async def camera_snapshot(
|
||||
},
|
||||
)
|
||||
|
||||
# Reuse the fan-out broadcaster's buffered frame when a viewer is already
|
||||
# watching — avoids opening a second concurrent RTSP socket on printers
|
||||
# that allow only one camera connection (e.g. X2D firmware 01.01.00.00;
|
||||
# see #1271). Buffered frame is <1s old while a viewer is connected.
|
||||
buffered = try_get_active_buffered_frame(printer_id)
|
||||
if buffered:
|
||||
return Response(
|
||||
content=buffered,
|
||||
media_type="image/jpeg",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Content-Disposition": f'inline; filename="snapshot_{printer_id}.jpg"',
|
||||
},
|
||||
)
|
||||
|
||||
# Create temporary file for the snapshot (0600 so only the app user can read it)
|
||||
fd, tmp_name = tempfile.mkstemp(suffix=".jpg")
|
||||
os.close(fd)
|
||||
|
||||
@@ -199,6 +199,19 @@ class ObicoDetectionService:
|
||||
timeout=SNAPSHOT_CAPTURE_TIMEOUT,
|
||||
snapshot_url=printer.external_camera_snapshot_url,
|
||||
)
|
||||
|
||||
# Reuse the fan-out broadcaster's buffered frame when a viewer is
|
||||
# already watching — avoids opening a second concurrent RTSP socket
|
||||
# on printers that allow only one camera connection (e.g. X2D
|
||||
# firmware 01.01.00.00; see #1271). Buffered frame is <1s old while
|
||||
# a viewer is connected. Returns None when no stream is active, so
|
||||
# we fall through to a fresh socket as before.
|
||||
from backend.app.api.routes.camera import try_get_active_buffered_frame
|
||||
|
||||
buffered = try_get_active_buffered_frame(printer_id)
|
||||
if buffered:
|
||||
return buffered
|
||||
|
||||
return await capture_camera_frame_bytes(
|
||||
ip_address=printer.ip_address,
|
||||
access_code=printer.access_code,
|
||||
|
||||
@@ -239,6 +239,35 @@ class TestCameraAPI:
|
||||
assert response.status_code == 503
|
||||
assert "Failed to capture" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_camera_snapshot_reuses_buffered_frame_when_stream_active(
|
||||
self, async_client: AsyncClient, printer_factory
|
||||
):
|
||||
"""#1271: /camera/snapshot must reuse the broadcaster's buffered frame
|
||||
when a live stream is running, instead of opening a second concurrent
|
||||
RTSP socket. On printers with strict single-connection enforcement (e.g.
|
||||
X2D firmware 01.01.00.00) opening a second socket kicks the live stream.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
|
||||
|
||||
# Simulate a running broadcaster: one active stream entry + buffered frame.
|
||||
active_streams = {f"{printer.id}-fanout": MagicMock()}
|
||||
last_frames = {printer.id: fake_jpeg}
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.camera._active_streams", active_streams),
|
||||
patch("backend.app.api.routes.camera._last_frames", last_frames),
|
||||
patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture,
|
||||
):
|
||||
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == fake_jpeg
|
||||
# The fresh-capture path must NOT have been taken — that's the whole point.
|
||||
mock_capture.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_camera_snapshot_external_camera_success(self, async_client: AsyncClient, printer_factory):
|
||||
|
||||
@@ -270,6 +270,77 @@ class TestPollOneStateLifecycle:
|
||||
assert mock_action.call_count == 1
|
||||
|
||||
|
||||
class TestCaptureFrameSharesBroadcasterUpstream:
|
||||
"""#1271: Obico's per-poll snapshot must reuse the live-stream broadcaster's
|
||||
buffered frame when a viewer is watching, instead of opening a second RTSP
|
||||
socket. On X2D firmware 01.01.00.00 the second socket kicks the live stream.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_buffered_frame_when_stream_active(self):
|
||||
printer = MagicMock(
|
||||
external_camera_enabled=False,
|
||||
external_camera_url=None,
|
||||
ip_address="192.168.1.10",
|
||||
access_code="12345678",
|
||||
model="N6",
|
||||
)
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = AsyncMock(return_value=printer)
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
svc = ObicoDetectionService()
|
||||
with (
|
||||
patch("backend.app.services.obico_detection.async_session", return_value=mock_ctx),
|
||||
patch(
|
||||
"backend.app.api.routes.camera.try_get_active_buffered_frame",
|
||||
return_value=FAKE_JPEG,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.camera.capture_camera_frame_bytes",
|
||||
new=AsyncMock(return_value=b"FRESH-CAPTURE-SHOULD-NOT-BE-USED"),
|
||||
) as mock_fresh,
|
||||
):
|
||||
result = await svc._capture_frame(printer_id=1)
|
||||
|
||||
assert result == FAKE_JPEG
|
||||
mock_fresh.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_fresh_capture_when_no_stream(self):
|
||||
printer = MagicMock(
|
||||
external_camera_enabled=False,
|
||||
external_camera_url=None,
|
||||
ip_address="192.168.1.10",
|
||||
access_code="12345678",
|
||||
model="N6",
|
||||
)
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = AsyncMock(return_value=printer)
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
svc = ObicoDetectionService()
|
||||
with (
|
||||
patch("backend.app.services.obico_detection.async_session", return_value=mock_ctx),
|
||||
patch(
|
||||
"backend.app.api.routes.camera.try_get_active_buffered_frame",
|
||||
return_value=None, # No active stream
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.camera.capture_camera_frame_bytes",
|
||||
new=AsyncMock(return_value=FAKE_JPEG),
|
||||
) as mock_fresh,
|
||||
):
|
||||
result = await svc._capture_frame(printer_id=1)
|
||||
|
||||
assert result == FAKE_JPEG
|
||||
mock_fresh.assert_called_once()
|
||||
|
||||
|
||||
class TestFrameCache:
|
||||
"""One-shot JPEG cache that lets us sidestep Obico's 5s read timeout.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user