Explorar el Código

Fix P2S camera TLS compatibility via OpenSSL proxy (#661)

  The Debian ffmpeg package uses GnuTLS, whose hardened defaults reject
  TLS renegotiation and legacy ciphers that some Bambu printer firmwares
  (notably P2S) rely on — causing RTSP sessions to drop after a few
  seconds.

  Add a local TLS termination proxy (Python ssl/OpenSSL) that handles
  the TLS connection to the printer and exposes a plain RTSP port to
  ffmpeg. The proxy rewrites RTSP request-line URLs (rtsp://proxy →
  rtsps://printer) while preserving Authorization headers so Digest
  auth hashes remain valid.

  Also:
  - Reduce RTSP reconnect delay from 1.0s to 0.2s
  - Add ffmpeg fast-start flags (-probesize 32, -analyzeduration 0,
    -fflags nobuffer, -flags low_delay)
  - Fix external camera double rate-limiting causing choppy streams
  - Apply TLS proxy to external camera rtsps:// URLs and snapshot capture
  - Update orphan ffmpeg cleanup to match rtsp:// (proxied) URLs
  - Add unit tests for RTSP URL rewriting and proxy lifecycle
maziggy hace 2 meses
padre
commit
0feed83ce4

+ 1 - 1
CHANGELOG.md

@@ -22,7 +22,7 @@ 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.
 - **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.
 - **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.
 - **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. Root cause: ffmpeg in the Docker image uses GnuTLS for TLS, and Debian's hardened GnuTLS defaults reject TLS behaviors (renegotiation, legacy ciphers) that some printer firmwares rely on. Added a local TLS termination proxy that uses Python's ssl module (OpenSSL) to handle the TLS connection to the printer, exposing a plain RTSP port to ffmpeg. The proxy rewrites RTSP request-line URLs while preserving Digest auth headers. Also reduced RTSP reconnect delay from 1.0s to 0.2s, added ffmpeg fast-start flags for lower startup latency, and fixed external camera streams being choppy due to double rate-limiting in the proxy layer. Reported by @ddetton, confirmed by @DMoenning.
 - **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.
 - **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.
 - **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.
 - **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.

+ 30 - 24
backend/app/api/routes/camera.py

@@ -18,6 +18,7 @@ from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.camera import (
 from backend.app.services.camera import (
     capture_camera_frame,
     capture_camera_frame,
+    create_tls_proxy,
     generate_chamber_image_stream,
     generate_chamber_image_stream,
     get_camera_port,
     get_camera_port,
     get_ffmpeg_path,
     get_ffmpeg_path,
@@ -197,7 +198,7 @@ async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None
 # Some printer firmwares (notably P2S) drop RTSP sessions after a few seconds,
 # Some printer firmwares (notably P2S) drop RTSP sessions after a few seconds,
 # so we transparently respawn ffmpeg to keep the MJPEG stream alive.
 # so we transparently respawn ffmpeg to keep the MJPEG stream alive.
 _RTSP_MAX_RECONNECTS = 30
 _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(
 async def generate_rtsp_mjpeg_stream(
@@ -221,17 +222,15 @@ async def generate_rtsp_mjpeg_stream(
         return
         return
 
 
     port = get_camera_port(model)
     port = get_camera_port(model)
-    camera_url = f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
+
+    # Use a local TLS proxy so Python's OpenSSL handles TLS instead of
+    # ffmpeg's GnuTLS.  This fixes P2S (and potentially other models)
+    # dropping the RTSP session after a few seconds due to GnuTLS's
+    # hardened Debian defaults rejecting TLS renegotiation.
+    proxy_port, proxy_server = await create_tls_proxy(ip_address, port)
+    camera_url = f"rtsp://bblp:{access_code}@127.0.0.1:{proxy_port}/streaming/live/1"
 
 
     # ffmpeg command to output MJPEG stream to stdout
     # ffmpeg command to output MJPEG stream to stdout
-    # -rtsp_transport tcp: Use TCP for reliability
-    # -rtsp_flags prefer_tcp: Prefer TCP for RTSP
-    # -timeout: Socket I/O timeout in microseconds (30 seconds)
-    # -buffer_size: Larger buffer for network jitter
-    # -max_delay: Maximum demuxing delay
-    # -f mjpeg: Output as MJPEG
-    # -q:v 5: Quality (lower = better, 2-10 is good range)
-    # -r: Output framerate
     cmd = [
     cmd = [
         ffmpeg,
         ffmpeg,
         "-rtsp_transport",
         "-rtsp_transport",
@@ -244,6 +243,14 @@ async def generate_rtsp_mjpeg_stream(
         "1024000",  # 1MB buffer
         "1024000",  # 1MB buffer
         "-max_delay",
         "-max_delay",
         "500000",  # 0.5 seconds max delay
         "500000",  # 0.5 seconds max delay
+        "-probesize",
+        "32",  # Minimal probing for faster startup
+        "-analyzeduration",
+        "0",  # Skip format analysis for faster startup
+        "-fflags",
+        "nobuffer",  # Reduce internal buffering
+        "-flags",
+        "low_delay",  # Minimize decode latency
         "-i",
         "-i",
         camera_url,
         camera_url,
         "-f",
         "-f",
@@ -305,8 +312,8 @@ async def generate_rtsp_mjpeg_stream(
 
 
             _spawned_ffmpeg_pids[process.pid] = _time.time()
             _spawned_ffmpeg_pids[process.pid] = _time.time()
 
 
-            # Give ffmpeg a moment to start and check for immediate failures
-            await asyncio.sleep(0.5)
+            # Brief check for immediate startup failures
+            await asyncio.sleep(0.1)
             if process.returncode is not None:
             if process.returncode is not None:
                 stderr = await process.stderr.read()
                 stderr = await process.stderr.read()
                 stderr_text = stderr.decode(errors="replace")
                 stderr_text = stderr.decode(errors="replace")
@@ -440,6 +447,10 @@ async def generate_rtsp_mjpeg_stream(
             await _terminate_ffmpeg(process, stream_id)
             await _terminate_ffmpeg(process, stream_id)
             logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
             logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
 
 
+        # Shut down the TLS proxy
+        proxy_server.close()
+        await proxy_server.wait_closed()
+
 
 
 @router.get("/{printer_id}/camera/stream")
 @router.get("/{printer_id}/camera/stream")
 async def camera_stream(
 async def camera_stream(
@@ -486,19 +497,13 @@ async def camera_stream(
 
 
         async def external_stream_wrapper():
         async def external_stream_wrapper():
             """Wrap external stream to track start/stop and update frame times."""
             """Wrap external stream to track start/stop and update frame times."""
-            frame_interval = 1.0 / fps
-            last_yield_time = 0.0
             try:
             try:
                 async for frame in generate_mjpeg_stream(
                 async for frame in generate_mjpeg_stream(
                     printer.external_camera_url, printer.external_camera_type, fps
                     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 handles rate limiting;
+                    # just track frame times for stall detection
+                    _last_frame_times[printer_id] = time.time()
                     yield frame
                     yield frame
             finally:
             finally:
                 _active_external_streams.discard(printer_id)
                 _active_external_streams.discard(printer_id)
@@ -1233,7 +1238,7 @@ async def delete_reference(
 def _scan_bambu_ffmpeg_pids() -> list[int]:
 def _scan_bambu_ffmpeg_pids() -> list[int]:
     """Scan /proc for ffmpeg processes with Bambu RTSP URLs.
     """Scan /proc for ffmpeg processes with Bambu RTSP URLs.
 
 
-    These are definitely ours — no other software connects to rtsps://bblp:.
+    These are definitely ours — no other software connects to rtsp(s)://bblp:.
     This catches orphans that survive app restarts and are not in any tracking dict.
     This catches orphans that survive app restarts and are not in any tracking dict.
     """
     """
     import os
     import os
@@ -1246,7 +1251,8 @@ def _scan_bambu_ffmpeg_pids() -> list[int]:
             try:
             try:
                 with open(f"/proc/{entry}/cmdline", "rb") as f:
                 with open(f"/proc/{entry}/cmdline", "rb") as f:
                     cmdline = f.read()
                     cmdline = f.read()
-                if b"ffmpeg" in cmdline and b"rtsps://bblp:" in cmdline:
+                # Match both rtsp:// (via TLS proxy) and rtsps:// (direct)
+                if b"ffmpeg" in cmdline and (b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline):
                     pids.append(int(entry))
                     pids.append(int(entry))
             except (OSError, PermissionError, ValueError):
             except (OSError, PermissionError, ValueError):
                 continue
                 continue
@@ -1278,7 +1284,7 @@ async def cleanup_orphaned_streams():
     active_pids = {proc.pid for proc in _active_streams.values() if proc.returncode is None}
     active_pids = {proc.pid for proc in _active_streams.values() if proc.returncode is None}
 
 
     # 1. /proc scan — catch ALL orphaned Bambu ffmpeg processes on the system.
     # 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 rtsp(s)://bblp: that is NOT in an active stream is orphaned.
     for pid in _scan_bambu_ffmpeg_pids():
     for pid in _scan_bambu_ffmpeg_pids():
         if pid in active_pids:
         if pid in active_pids:
             continue
             continue

+ 122 - 1
backend/app/services/camera.py

@@ -100,6 +100,119 @@ def get_camera_port(model: str | None) -> int:
     return 6000
     return 6000
 
 
 
 
+def rewrite_rtsp_request_url(data: bytes, proxy_url: bytes, real_url: bytes) -> bytes:
+    """Rewrite RTSP request-line URLs, leaving other lines (e.g. Authorization) intact.
+
+    RTSP request lines have the form ``METHOD <url> RTSP/1.0\\r\\n``.
+    Only those lines are modified so that Digest auth headers (which embed
+    the original URL and a cryptographic hash) are not broken.
+    """
+    rtsp_marker = b" RTSP/1.0"
+    if rtsp_marker not in data:
+        return data
+    lines = data.split(b"\r\n")
+    for i, line in enumerate(lines):
+        if line.endswith(rtsp_marker):
+            lines[i] = line.replace(proxy_url, real_url)
+            break
+    return b"\r\n".join(lines)
+
+
+async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "asyncio.Server"]:
+    """Create a local TCP→TLS proxy for RTSP streams.
+
+    Bambu printers use RTSPS (RTSP over TLS) with self-signed certificates.
+    The Debian ffmpeg package uses GnuTLS, whose hardened defaults reject
+    certain TLS behaviors (renegotiation, legacy ciphers) that some printer
+    firmwares (notably P2S) rely on.  This causes streams to drop after a
+    few seconds.
+
+    This proxy terminates TLS using Python's ssl module (OpenSSL), which is
+    more permissive, and exposes a plain TCP port that ffmpeg connects to
+    with ``rtsp://`` instead of ``rtsps://``.
+
+    RTSP embeds URLs in protocol messages (DESCRIBE, SETUP, PLAY).  The proxy
+    rewrites ``127.0.0.1:<proxy_port>`` → ``<target_host>:<target_port>`` in
+    client→server data so the printer recognises the stream path.
+
+    Returns ``(local_port, server)``.  Caller must close the server when done.
+    """
+    ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+    ssl_ctx.check_hostname = False
+    ssl_ctx.verify_mode = ssl.CERT_NONE
+
+    # Filled in after the server socket is created (handler only runs after).
+    _local_port: list[int] = [0]
+
+    async def _handle(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter):
+        tls_writer = None
+        try:
+            tls_reader, tls_writer = await asyncio.wait_for(
+                asyncio.open_connection(target_host, target_port, ssl=ssl_ctx),
+                timeout=10.0,
+            )
+
+            # URL patterns for RTSP request-line rewriting.
+            proxy_url = f"rtsp://127.0.0.1:{_local_port[0]}".encode()
+            real_url = f"rtsps://{target_host}:{target_port}".encode()
+
+            async def _fwd_to_server(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
+                """Forward client→server, rewriting RTSP request-line URLs only."""
+                try:
+                    while True:
+                        data = await src.read(65536)
+                        if not data:
+                            break
+                        data = rewrite_rtsp_request_url(data, proxy_url, real_url)
+                        dst.write(data)
+                        await dst.drain()
+                except (ConnectionError, OSError, asyncio.CancelledError):
+                    pass
+                finally:
+                    if not dst.is_closing():
+                        try:
+                            dst.close()
+                        except OSError:
+                            pass
+
+            async def _fwd_to_client(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
+                """Forward server→client unchanged."""
+                try:
+                    while True:
+                        data = await src.read(65536)
+                        if not data:
+                            break
+                        dst.write(data)
+                        await dst.drain()
+                except (ConnectionError, OSError, asyncio.CancelledError):
+                    pass
+                finally:
+                    if not dst.is_closing():
+                        try:
+                            dst.close()
+                        except OSError:
+                            pass
+
+            await asyncio.gather(
+                _fwd_to_server(client_reader, tls_writer),
+                _fwd_to_client(tls_reader, client_writer),
+            )
+        except (ConnectionError, OSError, TimeoutError) as e:
+            logger.debug("TLS proxy connection to %s:%s failed: %s", target_host, target_port, e)
+        finally:
+            for w in (client_writer, tls_writer):
+                if w and not w.is_closing():
+                    try:
+                        w.close()
+                    except OSError:
+                        pass
+
+    server = await asyncio.start_server(_handle, "127.0.0.1", 0)
+    _local_port[0] = server.sockets[0].getsockname()[1]
+    logger.debug("TLS proxy for %s:%s listening on 127.0.0.1:%s", target_host, target_port, _local_port[0])
+    return _local_port[0], server
+
+
 def is_chamber_image_model(model: str | None) -> bool:
 def is_chamber_image_model(model: str | None) -> bool:
     """Check if printer uses chamber image protocol instead of RTSP.
     """Check if printer uses chamber image protocol instead of RTSP.
 
 
@@ -357,10 +470,15 @@ async def capture_camera_frame_bytes(
         return await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
         return await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
 
 
     # RTSP models: X1/H2/P2 - use ffmpeg piping to stdout
     # RTSP models: X1/H2/P2 - use ffmpeg piping to stdout
-    camera_url = build_camera_url(ip_address, access_code, model)
+    # TLS proxy avoids GnuTLS compatibility issues with some printer firmwares
+    port = get_camera_port(model)
+    proxy_port, proxy_server = await create_tls_proxy(ip_address, port)
+    camera_url = f"rtsp://bblp:{access_code}@127.0.0.1:{proxy_port}/streaming/live/1"
 
 
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
+        proxy_server.close()
+        await proxy_server.wait_closed()
         logger.error("ffmpeg not found for camera frame capture")
         logger.error("ffmpeg not found for camera frame capture")
         return None
         return None
 
 
@@ -415,6 +533,9 @@ async def capture_camera_frame_bytes(
     except Exception as e:
     except Exception as e:
         logger.exception("Camera frame bytes capture failed: %s", e)
         logger.exception("Camera frame bytes capture failed: %s", e)
         return None
         return None
+    finally:
+        proxy_server.close()
+        await proxy_server.wait_closed()
 
 
 
 
 async def capture_finish_photo(
 async def capture_finish_photo(

+ 85 - 11
backend/app/services/external_camera.py

@@ -336,20 +336,46 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
 
 
 
 
 async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
 async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
-    """Capture frame from RTSP using ffmpeg."""
+    """Capture frame from RTSP using ffmpeg.
+
+    For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.
+    """
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
         logger.error("ffmpeg not found - required for RTSP capture")
         logger.error("ffmpeg not found - required for RTSP capture")
         return None
         return None
 
 
-    # Use ffmpeg to grab a single frame from RTSP stream
-    # ffmpeg handles both rtsp:// and rtsps:// URLs automatically
+    # If rtsps://, use TLS proxy
+    proxy_server = None
+    effective_url = url
+    if url.lower().startswith("rtsps://"):
+        try:
+            from urllib.parse import urlparse
+
+            from backend.app.services.camera import create_tls_proxy
+
+            parsed = urlparse(url)
+            target_port = parsed.port or 322
+            proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
+            userinfo = ""
+            if parsed.username:
+                userinfo = parsed.username
+                if parsed.password:
+                    userinfo += f":{parsed.password}"
+                userinfo += "@"
+            effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
+            if parsed.query:
+                effective_url += f"?{parsed.query}"
+        except Exception as e:
+            logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
+            effective_url = url
+
     cmd = [
     cmd = [
         ffmpeg,
         ffmpeg,
         "-rtsp_transport",
         "-rtsp_transport",
         "tcp",
         "tcp",
         "-i",
         "-i",
-        url,
+        effective_url,
         "-frames:v",
         "-frames:v",
         "1",
         "1",
         "-f",
         "-f",
@@ -362,7 +388,7 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
     ]
     ]
 
 
     try:
     try:
-        logger.debug(f"Running ffmpeg command: {' '.join(cmd[:6])}...")
+        logger.debug("Running ffmpeg RTSP capture...")
         process = await asyncio.create_subprocess_exec(
         process = await asyncio.create_subprocess_exec(
             *cmd,
             *cmd,
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
@@ -371,7 +397,10 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
 
 
         stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
         stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
         logger.debug(
         logger.debug(
-            f"ffmpeg returned: code={process.returncode}, stdout={len(stdout)} bytes, stderr={len(stderr)} bytes"
+            "ffmpeg returned: code=%s, stdout=%s bytes, stderr=%s bytes",
+            process.returncode,
+            len(stdout),
+            len(stderr),
         )
         )
 
 
         if process.returncode != 0:
         if process.returncode != 0:
@@ -392,6 +421,10 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
     except OSError as e:
     except OSError as e:
         logger.error("RTSP frame capture failed: %s", e)
         logger.error("RTSP frame capture failed: %s", e)
         return None
         return None
+    finally:
+        if proxy_server:
+            proxy_server.close()
+            await proxy_server.wait_closed()
 
 
 
 
 async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
 async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
@@ -605,13 +638,43 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
 
 
 
 
 async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
 async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
-    """Stream frames from RTSP URL via ffmpeg."""
+    """Stream frames from RTSP URL via ffmpeg.
+
+    For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
+    of relying on ffmpeg's GnuTLS backend, which has compatibility issues
+    with some printer firmwares.
+    """
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
         logger.error("ffmpeg not found - required for RTSP streaming")
         logger.error("ffmpeg not found - required for RTSP streaming")
         return
         return
 
 
-    # ffmpeg handles both rtsp:// and rtsps:// URLs automatically
+    # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
+    proxy_server = None
+    effective_url = url
+    if url.lower().startswith("rtsps://"):
+        try:
+            from urllib.parse import urlparse
+
+            from backend.app.services.camera import create_tls_proxy
+
+            parsed = urlparse(url)
+            target_port = parsed.port or 322
+            proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
+            # Rewrite URL: rtsps://user:pass@host:port/path → rtsp://user:pass@127.0.0.1:proxy/path
+            userinfo = ""
+            if parsed.username:
+                userinfo = parsed.username
+                if parsed.password:
+                    userinfo += f":{parsed.password}"
+                userinfo += "@"
+            effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
+            if parsed.query:
+                effective_url += f"?{parsed.query}"
+        except Exception as e:
+            logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
+            effective_url = url
+
     cmd = [
     cmd = [
         ffmpeg,
         ffmpeg,
         "-rtsp_transport",
         "-rtsp_transport",
@@ -624,8 +687,16 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
         "1024000",
         "1024000",
         "-max_delay",
         "-max_delay",
         "500000",
         "500000",
+        "-probesize",
+        "32",
+        "-analyzeduration",
+        "0",
+        "-fflags",
+        "nobuffer",
+        "-flags",
+        "low_delay",
         "-i",
         "-i",
-        url,
+        effective_url,
         "-f",
         "-f",
         "mjpeg",
         "mjpeg",
         "-q:v",
         "-q:v",
@@ -644,8 +715,8 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
             stderr=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
         )
 
 
-        # Give ffmpeg a moment to start and check for immediate failures
-        await asyncio.sleep(0.5)
+        # Brief check for immediate startup failures
+        await asyncio.sleep(0.1)
         if process.returncode is not None:
         if process.returncode is not None:
             stderr = await process.stderr.read()
             stderr = await process.stderr.read()
             logger.error("ffmpeg RTSP stream failed immediately: %s", stderr.decode()[:300])
             logger.error("ffmpeg RTSP stream failed immediately: %s", stderr.decode()[:300])
@@ -698,6 +769,9 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
             except TimeoutError:
             except TimeoutError:
                 process.kill()
                 process.kill()
                 await process.wait()
                 await process.wait()
+        if proxy_server:
+            proxy_server.close()
+            await proxy_server.wait_closed()
 
 
 
 
 async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
 async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:

+ 155 - 0
backend/tests/unit/services/test_camera_tls_proxy.py

@@ -0,0 +1,155 @@
+"""Tests for the camera TLS proxy and RTSP URL rewriting."""
+
+import asyncio
+
+import pytest
+
+from backend.app.services.camera import create_tls_proxy, rewrite_rtsp_request_url
+
+
+class TestRewriteRtspRequestUrl:
+    """Tests for RTSP request-line URL rewriting."""
+
+    def test_rewrites_describe_request_line(self):
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = b"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\r\nCSeq: 1\r\n\r\n"
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        assert b"DESCRIBE rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0\r\n" in result
+
+    def test_rewrites_setup_request_line(self):
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = b"SETUP rtsp://127.0.0.1:45221/streaming/live/1/trackID=0 RTSP/1.0\r\nCSeq: 3\r\n\r\n"
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        assert b"SETUP rtsps://192.168.1.100:322/streaming/live/1/trackID=0 RTSP/1.0\r\n" in result
+
+    def test_rewrites_play_request_line(self):
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = b"PLAY rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\r\nCSeq: 5\r\n\r\n"
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        assert b"PLAY rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0\r\n" in result
+
+    def test_preserves_authorization_header(self):
+        """Digest auth embeds the URI in a hash — rewriting it breaks auth."""
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = (
+            b"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\r\n"
+            b"CSeq: 2\r\n"
+            b'Authorization: Digest username="bblp", '
+            b'uri="rtsp://127.0.0.1:45221/streaming/live/1", '
+            b'response="abc123"\r\n'
+            b"\r\n"
+        )
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        # Request line IS rewritten
+        assert b"DESCRIBE rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0\r\n" in result
+        # Authorization header is NOT rewritten
+        assert b'uri="rtsp://127.0.0.1:45221/streaming/live/1"' in result
+        assert b'response="abc123"' in result
+
+    def test_no_rewrite_on_non_rtsp_data(self):
+        """Binary RTP data and other non-RTSP data should pass through unchanged."""
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        # Interleaved RTP data (starts with $)
+        data = b"$\x00\x00\x10" + b"\x00" * 16
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+        assert result == data
+
+    def test_no_rewrite_on_empty_data(self):
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        assert rewrite_rtsp_request_url(b"", proxy_url, real_url) == b""
+
+    def test_only_first_rtsp_line_rewritten(self):
+        """If somehow multiple RTSP/1.0 lines exist, only the first is rewritten."""
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = (
+            b"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\r\n"
+            b"CSeq: 1\r\n"
+            b"X-Custom: rtsp://127.0.0.1:45221/other RTSP/1.0\r\n"
+            b"\r\n"
+        )
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        lines = result.split(b"\r\n")
+        # First line rewritten
+        assert lines[0] == b"DESCRIBE rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0"
+        # Hypothetical other line NOT rewritten
+        assert lines[2] == b"X-Custom: rtsp://127.0.0.1:45221/other RTSP/1.0"
+
+    def test_preserves_crlf_structure(self):
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = b"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\r\nCSeq: 1\r\n\r\n"
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        # Must still end with double CRLF (empty line terminates headers)
+        assert result.endswith(b"\r\n\r\n")
+        # Must have CSeq intact
+        assert b"CSeq: 1\r\n" in result
+
+
+class TestCreateTlsProxy:
+    """Tests for TLS proxy server lifecycle."""
+
+    @pytest.mark.asyncio
+    async def test_proxy_returns_port_and_server(self):
+        """Verify proxy creates a listening server on an ephemeral port."""
+        # Use a non-routable target — we just test the server starts, not the TLS connection
+        port, server = await create_tls_proxy("192.0.2.1", 322)
+
+        assert isinstance(port, int)
+        assert port > 0
+        assert server.is_serving()
+
+        server.close()
+        await server.wait_closed()
+
+    @pytest.mark.asyncio
+    async def test_proxy_accepts_connection(self):
+        """Verify proxy accepts TCP connections (TLS to target will fail, but accept works)."""
+        port, server = await create_tls_proxy("192.0.2.1", 322)
+
+        try:
+            # Connect to the proxy — it should accept the connection
+            reader, writer = await asyncio.wait_for(
+                asyncio.open_connection("127.0.0.1", port),
+                timeout=2.0,
+            )
+            # The proxy will try to connect to 192.0.2.1:322 (non-routable), fail,
+            # and close our connection. That's expected.
+            writer.close()
+            await writer.wait_closed()
+        except (ConnectionError, TimeoutError):
+            pass  # Expected — target is unreachable
+
+        server.close()
+        await server.wait_closed()
+
+    @pytest.mark.asyncio
+    async def test_proxy_cleanup(self):
+        """Verify proxy stops serving after close."""
+        port, server = await create_tls_proxy("192.0.2.1", 322)
+        assert server.is_serving()
+
+        server.close()
+        await server.wait_closed()
+
+        assert not server.is_serving()

+ 3 - 3
frontend/src/pages/PrintersPage.tsx

@@ -3200,7 +3200,7 @@ function PrinterCard({
                                 // Build filament data for hover card
                                 // Build filament data for hover card
                                 const filamentData = tray?.tray_type ? {
                                 const filamentData = tray?.tray_type ? {
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                                  profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
+                                  profile: slotPreset?.preset_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
                                   colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(tray.tray_color),
                                   colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(tray.tray_color),
                                   colorHex: tray.tray_color || null,
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
                                   kFactor: formatKValue(tray.k),
@@ -3427,7 +3427,7 @@ function PrinterCard({
                         // Build filament data for hover card
                         // Build filament data for hover card
                         const filamentData = tray?.tray_type ? {
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                          profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
+                          profile: slotPreset?.preset_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
                           colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(tray.tray_color),
                           colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(tray.tray_color),
                           colorHex: tray.tray_color || null,
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
                           kFactor: formatKValue(tray.k),
@@ -3755,7 +3755,7 @@ function PrinterCard({
 
 
                               const extFilamentData = {
                               const extFilamentData = {
                                 vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                 vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                                profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
+                                profile: extSlotPreset?.preset_name || extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
                                 colorName: getBambuColorName(extTray.tray_id_name) || hexToColorName(extTray.tray_color),
                                 colorName: getBambuColorName(extTray.tray_id_name) || hexToColorName(extTray.tray_color),
                                 colorHex: extTray.tray_color || null,
                                 colorHex: extTray.tray_color || null,
                                 kFactor: formatKValue(extTray.k),
                                 kFactor: formatKValue(extTray.k),

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-jwN56PpH.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C-iDXnGm.js"></script>
+    <script type="module" crossorigin src="/assets/index-jwN56PpH.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BgeMokkR.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BgeMokkR.css">
   </head>
   </head>
   <body>
   <body>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio