فهرست منبع

Camera P2S + snapshot fix (#661):
Fix P2S camera stream dropping and snapshot capture race (#661)

P2S firmware's TLS renegotiation is rejected by Debian's hardened GnuTLS
defaults, causing ffmpeg RTSP sessions to drop after ~3 seconds. Add
GnuTLS config allowing unsafe renegotiation and legacy ciphers. Also add
ffmpeg fast-start flags, reduce reconnect delay from 1.0s to 0.2s,
remove double rate-limiting on external camera streams, and fix orphan
cleanup killing snapshot capture ffmpeg processes (exit code -9).

Or as a single combined commit:
Fix P2S camera streaming, snapshot race, and energy stats (#661, #695)

Camera: P2S firmware's TLS renegotiation rejected by Debian's hardened
GnuTLS defaults, dropping RTSP sessions after ~3s. Add GnuTLS compat
config, ffmpeg fast-start flags, reduce reconnect delay to 0.2s, remove
external camera double rate-limiting, and register snapshot ffmpeg PIDs
with the orphan tracker to prevent SIGKILL during capture.

maziggy 2 ماه پیش
والد
کامیت
7ea78a2969
4فایلهای تغییر یافته به همراه40 افزوده شده و 16 حذف شده
  1. 3 1
      CHANGELOG.md
  2. 3 1
      Dockerfile
  3. 23 14
      backend/app/api/routes/camera.py
  4. 11 0
      backend/app/services/camera.py

+ 3 - 1
CHANGELOG.md

@@ -14,7 +14,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **Cloud Profiles Shared Across All Users** ([#665](https://github.com/maziggy/bambuddy/issues/665)) — When authentication was enabled, Bambu Cloud credentials were stored globally — one account per Bambuddy instance. If User A logged into Cloud, every other user saw User A's account and profiles. User B logging in would overwrite User A's credentials. Cloud credentials are now stored per-user: each user logs into their own Bambu Cloud account independently. When auth is disabled (single-user mode), behavior is unchanged. Also fixed cloud data endpoints (`/cloud/settings`, `/cloud/fields`, preset CRUD) requiring `settings:read` / `settings:update` permissions instead of `cloud:auth` — users who had "Cloud Auth" enabled but "Settings" disabled couldn't load profiles after logging in. Reported by @cadtoolbox.
 - **Local Profiles Not Shown in AMS Slot Configuration** — Imported local filament profiles were hidden in the AMS slot configure modal when a printer model was set. The `compatible_printers` filter parsed the stored JSON array as a semicolon-delimited string, so the matching always failed and every local preset was silently skipped. Removed the filter entirely — user-imported profiles should be available on any printer.
 - **Interface Aliases Not Shown in Virtual Printer Interface Select** — Interface aliases (e.g. `eth0:1`) added for multi-virtual-printer setups were invisible in the bind IP dropdown. The Docker image didn't include `iproute2`, so the `ip` command wasn't available and the code fell back to ioctl-based enumeration which can only return one IP per interface. Added `iproute2` to the Docker image.
-- **P2S Camera Stream Disconnects After a Few Seconds** ([#661](https://github.com/maziggy/bambuddy/issues/661)) — The P2S firmware drops RTSP sessions after a few seconds with an I/O error. The backend treated this as a fatal failure, ending the MJPEG stream and forcing the frontend through a full reconnection cycle (stop → start → brief connection → fail → repeat). Added transparent auto-reconnection: when ffmpeg's RTSP connection dies, it respawns immediately and continues streaming MJPEG frames to the browser without interruption. Reported by @ddetton, confirmed by @DMoenning.
+- **P2S Camera Stream Disconnects After a Few Seconds** ([#661](https://github.com/maziggy/bambuddy/issues/661)) — The P2S firmware drops RTSP sessions after a few seconds with an I/O error due to TLS renegotiation rejected by Debian's hardened GnuTLS defaults. The backend treated this as a fatal failure. Three fixes: (1) Added a GnuTLS config in the Docker image allowing unsafe renegotiation and legacy ciphers for P2S compatibility. (2) Added ffmpeg fast-start flags (`-probesize 32 -analyzeduration 0 -fflags nobuffer -flags low_delay`) and reduced reconnect delay from 1.0s to 0.2s so even transient drops barely interrupt the stream. (3) Removed double rate-limiting on external camera streams (go2rtc, etc.) that caused choppiness — `generate_mjpeg_stream()` already throttles, so the wrapper no longer throttles again. Also added GnuTLS debug logging when debug mode is active for TLS diagnostics. Reported by @ddetton, confirmed by @DMoenning.
+- **Camera Snapshot Capture Killed by Orphan Cleanup** — The periodic orphan ffmpeg cleanup scanned `/proc` for Bambu RTSP processes and killed any not tracked in `_active_streams`. Snapshot captures (`capture_camera_frame_bytes`) spawn short-lived ffmpeg processes that weren't registered, so they got SIGKILL'd (exit code -9) if the cleanup ran during capture. Now registers snapshot PIDs in the spawn tracker and excludes all tracked PIDs from the orphan scan.
+- **Energy Costs Not Updating Per Timeframe** ([#695](https://github.com/maziggy/bambuddy/issues/695)) — In "total" energy tracking mode, the statistics endpoint queried smart plug lifetime counters which can't be filtered by date range, so energy costs and kWh values stayed the same regardless of the selected timeframe (Today, This Week, This Month). Now falls back to per-print archive data when date filters are active. Reported by @Bademeister89.
 - **iOS/iPadOS Cannot Reposition Floating Camera** ([#687](https://github.com/maziggy/bambuddy/issues/687)) — The floating camera viewer (embedded camera window on the dashboard) could not be dragged or resized on iOS/iPadOS because it only handled mouse events. Touch input scrolled the page underneath instead of moving the camera window. Added touch event support (`touchstart`/`touchmove`/`touchend`) to both the header drag handle and the resize handle, with `preventDefault` to stop page scrolling during drag. Reported by @dsmitty166.
 - **PA-CF / PA12-CF / PAHT-CF Not Treated as Compatible** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — Bambu Lab firmware treats PA-CF, PA12-CF, and PAHT-CF as interchangeable, but the print scheduler and filament override UI used exact string matching. If a 3MF required PA-CF but the AMS had PA12-CF loaded, the scheduler wouldn't assign the job and the filament override dropdown was empty/disabled. Added a filament type equivalence system so these PA variants are treated as compatible in scheduler assignment, AMS slot matching, force color match validation, and the filament override dropdown. Reported by @aneopsy.
 - **Force Color Match Toggle Click Target Too Large** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — In the Schedule Print modal, clicking anywhere on the "Force color match" row toggled the checkbox, not just the checkbox and its label. The click target now covers only the checkbox, icon, and label text. Reported by @aneopsy.

+ 3 - 1
Dockerfile

@@ -25,7 +25,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     ffmpeg \
     iproute2 \
     libcap2-bin \
-    && rm -rf /var/lib/apt/lists/*
+    && rm -rf /var/lib/apt/lists/* \
+    && mkdir -p /etc/gnutls \
+    && printf '[overrides]\ninsecure-hash = SHA1\n[priorities]\nSYSTEM = NORMAL:%%UNSAFE_RENEGOTIATION:%%COMPAT\n' > /etc/gnutls/config
 
 # Allow binding to privileged ports (e.g. 990/FTPS) as non-root user.
 # File capabilities are more reliable than Docker cap_add with user: directive,

+ 23 - 14
backend/app/api/routes/camera.py

@@ -197,7 +197,7 @@ async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None
 # Some printer firmwares (notably P2S) drop RTSP sessions after a few seconds,
 # so we transparently respawn ffmpeg to keep the MJPEG stream alive.
 _RTSP_MAX_RECONNECTS = 30
-_RTSP_RECONNECT_DELAY = 1.0  # seconds between respawns
+_RTSP_RECONNECT_DELAY = 0.2  # seconds between respawns
 
 
 async def generate_rtsp_mjpeg_stream(
@@ -244,6 +244,14 @@ async def generate_rtsp_mjpeg_stream(
         "1024000",  # 1MB buffer
         "-max_delay",
         "500000",  # 0.5 seconds max delay
+        "-probesize",
+        "32",  # Minimal probing for fast start
+        "-analyzeduration",
+        "0",  # Skip format analysis
+        "-fflags",
+        "nobuffer",  # Reduce internal buffering
+        "-flags",
+        "low_delay",  # Minimize decode latency
         "-i",
         camera_url,
         "-f",
@@ -291,11 +299,18 @@ async def generate_rtsp_mjpeg_stream(
                 if disconnect_event and disconnect_event.is_set():
                     break
 
-            # Spawn ffmpeg
+            # Spawn ffmpeg — enable GnuTLS debug output in debug mode
+            env = None
+            if logger.isEnabledFor(logging.DEBUG):
+                import os
+
+                env = {**os.environ, "GNUTLS_DEBUG_LEVEL": "2"}
+
             process = await asyncio.create_subprocess_exec(
                 *cmd,
                 stdout=asyncio.subprocess.PIPE,
                 stderr=asyncio.subprocess.PIPE,
+                env=env,
                 **spawn_kwargs,
             )
 
@@ -306,7 +321,7 @@ async def generate_rtsp_mjpeg_stream(
             _spawned_ffmpeg_pids[process.pid] = _time.time()
 
             # Give ffmpeg a moment to start and check for immediate failures
-            await asyncio.sleep(0.5)
+            await asyncio.sleep(0.1)
             if process.returncode is not None:
                 stderr = await process.stderr.read()
                 stderr_text = stderr.decode(errors="replace")
@@ -486,19 +501,12 @@ async def camera_stream(
 
         async def external_stream_wrapper():
             """Wrap external stream to track start/stop and update frame times."""
-            frame_interval = 1.0 / fps
-            last_yield_time = 0.0
             try:
                 async for frame in generate_mjpeg_stream(
                     printer.external_camera_url, printer.external_camera_type, fps
                 ):
-                    # Rate limit to prevent overwhelming browser
-                    current_time = time.time()
-                    elapsed = current_time - last_yield_time
-                    if elapsed < frame_interval:
-                        await asyncio.sleep(frame_interval - elapsed)
-                    last_yield_time = time.time()
-                    _last_frame_times[printer_id] = last_yield_time
+                    # generate_mjpeg_stream already rate-limits; just track frame times
+                    _last_frame_times[printer_id] = time.time()
                     yield frame
             finally:
                 _active_external_streams.discard(printer_id)
@@ -1274,11 +1282,12 @@ async def cleanup_orphaned_streams():
     cleaned = 0
     now = time.time()
 
-    # Collect PIDs that are legitimately in-use (active stream, process alive)
+    # Collect PIDs that are legitimately in-use (active streams + any tracked spawn)
     active_pids = {proc.pid for proc in _active_streams.values() if proc.returncode is None}
+    active_pids.update(_spawned_ffmpeg_pids.keys())
 
     # 1. /proc scan — catch ALL orphaned Bambu ffmpeg processes on the system.
-    #    Any ffmpeg with rtsps://bblp: that is NOT in an active stream is orphaned.
+    #    Any ffmpeg with rtsps://bblp: that is NOT tracked by us is orphaned.
     for pid in _scan_bambu_ffmpeg_pids():
         if pid in active_pids:
             continue

+ 11 - 0
backend/app/services/camera.py

@@ -386,6 +386,10 @@ async def capture_camera_frame_bytes(
 
     logger.info("Capturing camera frame bytes from %s using RTSP (model: %s)", ip_address, model)
 
+    # Register PID with the orphan cleanup tracker so the periodic /proc scan
+    # doesn't SIGKILL our short-lived snapshot process (see camera routes).
+    from backend.app.api.routes.camera import _spawned_ffmpeg_pids
+
     try:
         process = await asyncio.create_subprocess_exec(
             *cmd,
@@ -393,14 +397,21 @@ async def capture_camera_frame_bytes(
             stderr=asyncio.subprocess.PIPE,
         )
 
+        import time as _time
+
+        _spawned_ffmpeg_pids[process.pid] = _time.time()
+
         try:
             stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
         except TimeoutError:
             process.kill()
             await process.wait()
+            _spawned_ffmpeg_pids.pop(process.pid, None)
             logger.error("Camera frame bytes capture timed out after %ss", timeout)
             return None
 
+        _spawned_ffmpeg_pids.pop(process.pid, None)
+
         if process.returncode == 0 and stdout and len(stdout) >= 100:
             logger.info("Successfully captured camera frame bytes: %s bytes", len(stdout))
             return stdout