Browse Source

VP camera works with a personal access code (#1430)

- A non-proxy VP's camera port passed the slicer straight through to
  the printer, which checks the access code itself, so a slicer bound
  with a personal code was refused there and kept asking for the code
- The VP now ends the camera's TLS with its own certificate and signs
  the slicer in at the printer with the printer's code: RTSP Digest
  re-signed (port 322), the chamber image code replaced (port 6000)
- Accepted: the VP's code, the printer's, and the personal codes of
  active users with camera:view and the printer in scope, re-read at
  most every 60 s
- A sign-in it does not re-sign never reaches the printer: it ends the
  connection and counts toward the 5 per minute limit
- Stopping the VP no longer waits for a slicer watching the camera
- Access code card hint updated (16 locales)
maziggy 2 days ago
parent
commit
3711fa42aa
55 changed files with 1043 additions and 29 deletions
  1. 0 1
      CHANGELOG.md
  2. 420 0
      backend/app/services/virtual_printer/camera_bridge.py
  3. 44 5
      backend/app/services/virtual_printer/manager.py
  4. 32 0
      backend/app/services/virtual_printer/user_codes.py
  5. 31 1
      backend/tests/integration/test_vp_user_codes.py
  6. 65 3
      backend/tests/unit/services/test_virtual_printer.py
  7. 432 0
      backend/tests/unit/services/test_vp_camera_bridge.py
  8. 1 1
      frontend/src/i18n/locales/de.ts
  9. 1 1
      frontend/src/i18n/locales/en.ts
  10. 1 1
      frontend/src/i18n/locales/es.ts
  11. 1 1
      frontend/src/i18n/locales/fr.ts
  12. 1 1
      frontend/src/i18n/locales/it.ts
  13. 1 1
      frontend/src/i18n/locales/ja.ts
  14. 1 1
      frontend/src/i18n/locales/ko.ts
  15. 1 1
      frontend/src/i18n/locales/nl.ts
  16. 1 1
      frontend/src/i18n/locales/pl.ts
  17. 1 1
      frontend/src/i18n/locales/pt-BR.ts
  18. 1 1
      frontend/src/i18n/locales/ru.ts
  19. 1 1
      frontend/src/i18n/locales/sv.ts
  20. 1 1
      frontend/src/i18n/locales/tr.ts
  21. 1 1
      frontend/src/i18n/locales/uk.ts
  22. 1 1
      frontend/src/i18n/locales/zh-CN.ts
  23. 1 1
      frontend/src/i18n/locales/zh-TW.ts
  24. 0 0
      static/assets/ArchivesPage-Bv52d6Xc.js
  25. 0 0
      static/assets/CamWallPage-Ddx1i1w8.js
  26. 0 0
      static/assets/CameraPage-4wCEl5QE.js
  27. 0 0
      static/assets/FileManagerPage-BkP8YCzd.js
  28. 0 0
      static/assets/InventoryPage-DxaZrCdx.js
  29. 0 0
      static/assets/Layout-BgbPF2oW.js
  30. 0 0
      static/assets/ModelSourcesPage-D4Fhq6Zv.js
  31. 0 0
      static/assets/PrintersPage-BWr8c1xB.js
  32. 0 0
      static/assets/SettingsPage-CTCm32W7.js
  33. 0 0
      static/assets/SliceModal-CT9EOCVp.js
  34. 0 0
      static/assets/SpoolBuddyInventoryPage-DcRJaN0Y.js
  35. 1 1
      static/assets/SpoolBuddySettingsPage-D8V7PnVM.js
  36. 0 0
      static/assets/de-jO9wV-al.js
  37. 0 0
      static/assets/es-D2PJ5NEp.js
  38. 0 0
      static/assets/fr-CtEA70Xa.js
  39. 0 0
      static/assets/index-BCp8ipS2.js
  40. 0 0
      static/assets/it-xlu_L2av.js
  41. 0 0
      static/assets/ja-BIa2fFdE.js
  42. 0 0
      static/assets/ko-B1KhtJO7.js
  43. 0 0
      static/assets/locationSensorDefaults-bjNVCyNs.js
  44. 0 0
      static/assets/nl-Cmk5pB4r.js
  45. 0 0
      static/assets/pl-BgS5ZxGl.js
  46. 0 0
      static/assets/pt-BR-B92oYhCb.js
  47. 0 0
      static/assets/ru-CUhvNHg4.js
  48. 0 0
      static/assets/spoolCost-yWX-pcHl.js
  49. 0 0
      static/assets/sv-BukZIvUt.js
  50. 0 0
      static/assets/tr-jzvOWG_A.js
  51. 0 0
      static/assets/uk-BW-tUUxr.js
  52. 0 0
      static/assets/usePageFileDrop-8YvCP48i.js
  53. 0 0
      static/assets/zh-CN-C_wgaQt0.js
  54. 0 0
      static/assets/zh-TW-D-rGIIO1.js
  55. 2 2
      static/index.html

File diff suppressed because it is too large
+ 0 - 1
CHANGELOG.md


+ 420 - 0
backend/app/services/virtual_printer/camera_bridge.py

@@ -0,0 +1,420 @@
+"""The slicer's camera view through a virtual printer, signed in for it (#1430).
+
+A slicer bound to a virtual printer opens the camera on the virtual printer's
+address. A plain pass-through to the real printer only worked with the
+printer's own access code: the printer checks the code itself, so a slicer
+signed in with a personal access code was refused there, and the slicer then
+asked for the code again on every action.
+
+This bridge ends the slicer's TLS with the virtual printer's certificate (the
+one the slicer already trusts for MQTT and FTP), opens its own TLS connection
+to the printer, and passes the printer's own code on when the slicer's code is
+one it accepts:
+
+* RTSPS (port 322, X1/X2/H2/P2S): the printer asks for Digest auth, so the
+  code never crosses the wire, only a hash of it. Each ``Authorization``
+  header is checked against the accepted codes and, on a match, its hash is
+  computed anew with the printer's code. Nonce and URI stay the slicer's.
+* Chamber image (port 6000, A1/P1): the code is in plain text in the first 80
+  bytes; it is checked and replaced by the printer's.
+
+Codes it accepts (``codes``): the virtual printer's, the printer's own, and
+the personal codes of users allowed to watch this printer now (looked up
+when a sign-in arrives, again after ``CODES_MAX_AGE``). A sign-in the bridge
+does not accept is never passed on: it counts as a failed sign-in and ends
+the connection. Otherwise the camera would be a way to guess codes, the
+printer's own among them, with no limit.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import hmac
+import logging
+import re
+import ssl
+import struct
+import time
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from pathlib import Path
+
+from backend.app.services.virtual_printer.ftp_server import AuthRateLimiter
+
+logger = logging.getLogger(__name__)
+
+RTSP = "rtsp"
+CHAMBER = "chamber"
+
+# Chamber image sign-in: magic, command, padding, user (32), code (32).
+_CHAMBER_AUTH = struct.Struct("<II8s32s32s")
+_CHAMBER_MAGIC = 0x40
+_CHAMBER_COMMAND = 0x3000
+
+_DIGEST_PARAM = re.compile(r'(\w+)\s*=\s*(?:"([^"]*)"|([^\s,]+))')
+_DIGEST_RESPONSE = re.compile(r'(response\s*=\s*)(?:"[^"]*"|[^\s,]+)', re.IGNORECASE)
+_MAX_RTSP_BODY = 1 << 20
+
+
+@dataclass(frozen=True)
+class AcceptedCode:
+    """A code the camera accepts, and the user whose personal code it is (None for a shared one)."""
+
+    code: str
+    user_id: int | None = None
+
+
+def parse_digest(value: str) -> dict[str, str] | None:
+    """The parameters of a ``Digest ...`` Authorization value, or None for another scheme."""
+    scheme, _, rest = value.strip().partition(" ")
+    if scheme.lower() != "digest":
+        return None
+    return {
+        m.group(1).lower(): m.group(2) if m.group(2) is not None else m.group(3) for m in _DIGEST_PARAM.finditer(rest)
+    }
+
+
+def digest_response(params: dict[str, str], method: str, password: str) -> str | None:
+    """The Digest ``response`` (RFC 2617, MD5) for ``params`` signed with ``password``.
+
+    None when a field it needs is missing or the algorithm is not MD5.
+    """
+    if params.get("algorithm", "MD5").upper() != "MD5":
+        return None
+    try:
+        username, realm, nonce, uri = params["username"], params["realm"], params["nonce"], params["uri"]
+    except KeyError:
+        return None
+
+    def md5(text: str) -> str:
+        return hashlib.md5(text.encode("latin-1"), usedforsecurity=False).hexdigest()
+
+    ha1 = md5(f"{username}:{realm}:{password}")
+    ha2 = md5(f"{method}:{uri}")
+    qop = params.get("qop")
+    if qop:
+        nc, cnonce = params.get("nc"), params.get("cnonce")
+        if nc is None or cnonce is None:
+            return None
+        return md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}")
+    return md5(f"{ha1}:{nonce}:{ha2}")
+
+
+def match_digest(params: dict[str, str], method: str, accepted: list[AcceptedCode]) -> AcceptedCode | None:
+    """The accepted code the slicer's Digest response was made with, if any."""
+    given = params.get("response", "").lower()
+    found = None
+    for candidate in accepted:
+        expected = digest_response(params, method, candidate.code)
+        # Every candidate is tried, so the time taken does not tell which matched.
+        if expected is not None and hmac.compare_digest(expected, given) and found is None:
+            found = candidate
+    return found
+
+
+def rewrite_rtsp_auth(
+    head: bytes, accepted: list[AcceptedCode], printer_code: str
+) -> tuple[bytes, AcceptedCode | None, bool]:
+    """Sign an RTSP request's Digest header with the printer's code.
+
+    Returns ``(head, matched, had_auth)``: the request head (rewritten when
+    the slicer's code is accepted, else unchanged), the code it matched, and
+    whether the request tries to sign in at all.
+
+    Fail-closed: ``had_auth`` is True for any request that mentions
+    "authorization" anywhere, and it is only re-signed when that is one
+    ``Authorization: Digest`` header with an accepted code. The printer's
+    server finds a Digest header wherever it is in the request (in another
+    header's value, or after a Basic one), so a request the bridge does not
+    re-sign must never reach it: its answer would tell whether a guessed code
+    is right, without the guess being counted.
+    """
+    if b"authorization" not in head.lower():
+        return head, None, False
+    if head.lower().count(b"authorization") != 1:
+        return head, None, True
+    text = head.decode("latin-1")
+    lines = text.split("\r\n")
+    method = lines[0].split(" ", 1)[0] if lines else ""
+    for i, line in enumerate(lines[1:], start=1):
+        name, sep, value = line.partition(":")
+        if not sep or name.strip().lower() != "authorization":
+            continue
+        params = parse_digest(value)
+        if params is None:
+            return head, None, True
+        matched = match_digest(params, method, accepted)
+        if matched is None:
+            return head, None, True
+        new = digest_response(params, method, printer_code)
+        if new is None:
+            return head, None, True
+        signed = _DIGEST_RESPONSE.sub(lambda m, new=new: m.group(1) + '"' + new + '"', value, count=1)
+        lines[i] = f"{name}:{signed}"
+        return "\r\n".join(lines).encode("latin-1"), matched, True
+    return head, None, True
+
+
+def rewrite_chamber_auth(
+    payload: bytes, accepted: list[AcceptedCode], printer_code: str
+) -> tuple[bytes, AcceptedCode | None]:
+    """The chamber image sign-in with the printer's code, when the slicer's is accepted."""
+    if len(payload) != _CHAMBER_AUTH.size:
+        return payload, None
+    magic, command, padding, user, code = _CHAMBER_AUTH.unpack(payload)
+    if magic != _CHAMBER_MAGIC or command != _CHAMBER_COMMAND:
+        return payload, None
+    given = code.rstrip(b"\x00")
+    found = None
+    for candidate in accepted:
+        if hmac.compare_digest(candidate.code.encode(), given) and found is None:
+            found = candidate
+    if found is None:
+        return payload, None
+    return _CHAMBER_AUTH.pack(magic, command, padding, user, printer_code.encode().ljust(32, b"\x00")), found
+
+
+def _server_context(cert_path: Path, key_path: Path) -> ssl.SSLContext:
+    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+    context.load_cert_chain(str(cert_path), str(key_path))
+    context.minimum_version = ssl.TLSVersion.TLSv1_2
+    # The suites real printers offer; see mqtt_server (#1610).
+    context.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
+    return context
+
+
+def _printer_context() -> ssl.SSLContext:
+    # The printer's certificate is self-signed by its maker; Bambuddy's own
+    # camera connection does not check it either (services.camera).
+    context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+    context.check_hostname = False
+    context.verify_mode = ssl.CERT_NONE
+    return context
+
+
+FIRST_BYTES_TIMEOUT = 15.0
+# How long a connection keeps the accepted codes it looked up: a user who
+# loses camera:view, is disabled or renews their code is out within this.
+CODES_MAX_AGE = 60.0
+
+
+class _AcceptedCodes:
+    """One connection's accepted codes: looked up when first needed, again once older than ``CODES_MAX_AGE``."""
+
+    def __init__(self, lookup: Callable[[], Awaitable[list[AcceptedCode]]]):
+        self._lookup = lookup
+        self._codes: list[AcceptedCode] | None = None
+        self._at = 0.0
+
+    async def get(self) -> list[AcceptedCode]:
+        now = time.monotonic()
+        if self._codes is None or now - self._at > CODES_MAX_AGE:
+            self._codes = await self._lookup()
+            self._at = now
+        return self._codes
+
+
+class CameraBridge:
+    """Camera port of a virtual printer with a target printer, signing the slicer in with the printer's code."""
+
+    def __init__(
+        self,
+        *,
+        name: str,
+        protocol: str,
+        listen_port: int,
+        bind_address: str,
+        target_host: str,
+        target_port: int,
+        cert_path: Path,
+        key_path: Path,
+        codes: Callable[[], Awaitable[list[AcceptedCode]]],
+        printer_code: Callable[[], str | None],
+        rate_limiter: AuthRateLimiter | None = None,
+    ):
+        self.name = name
+        self.protocol = protocol
+        self.listen_port = listen_port
+        self.bind_address = bind_address
+        self.target_host = target_host
+        self.target_port = target_port
+        self.cert_path = cert_path
+        self.key_path = key_path
+        self._codes = codes
+        self._printer_code = printer_code
+        self.rate_limiter = rate_limiter or AuthRateLimiter()
+        self._server: asyncio.Server | None = None
+        self._handlers: set[asyncio.Task] = set()
+
+    async def start(self) -> None:
+        """Listen until stopped."""
+        logger.info(
+            "Starting %s camera bridge: %s:%s -> %s:%s",
+            self.name,
+            self.bind_address,
+            self.listen_port,
+            self.target_host,
+            self.target_port,
+        )
+        try:
+            self._server = await asyncio.start_server(
+                self._handle,
+                self.bind_address,
+                self.listen_port,
+                ssl=_server_context(self.cert_path, self.key_path),
+            )
+            # The server is serving already. Not ``serve_forever()`` nor
+            # ``async with``: once cancelled, both wait for every connection to
+            # close (Python 3.12+), before stop() below gets to end them, so a
+            # slicer watching the camera kept the virtual printer from stopping.
+            await asyncio.get_running_loop().create_future()
+        except OSError as e:
+            logger.error("%s camera bridge on port %s: %s", self.name, self.listen_port, e)
+        except asyncio.CancelledError:
+            pass
+        finally:
+            await self.stop()
+
+    async def stop(self) -> None:
+        """Stop listening and end every open camera connection."""
+        server, self._server = self._server, None
+        if server is not None:
+            server.close()
+        handlers = list(self._handlers)
+        for task in handlers:
+            task.cancel()
+        if handlers:
+            await asyncio.gather(*handlers, return_exceptions=True)
+        # No wait_closed(): since Python 3.12 it waits for each connection's
+        # TLS close, which a slicer that stopped reading holds up for 30 s.
+
+    async def _handle(self, client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter) -> None:
+        current = asyncio.current_task()
+        if current is not None:
+            self._handlers.add(current)
+        peer = client_writer.get_extra_info("peername")
+        source_ip = peer[0] if peer else "?"
+        printer_writer = None
+        try:
+            printer_code = self._printer_code()
+            if not printer_code:
+                logger.warning("%s camera: the target printer's access code is not known", self.name)
+                return
+            if self.rate_limiter.is_limited(source_ip):
+                logger.warning("%s camera from %s refused: too many failed attempts", self.name, source_ip)
+                return
+            # Nothing is looked up or opened for a connection that sends nothing.
+            first = await asyncio.wait_for(
+                client_reader.readexactly(_CHAMBER_AUTH.size if self.protocol == CHAMBER else 1),
+                timeout=FIRST_BYTES_TIMEOUT,
+            )
+            codes = _AcceptedCodes(self._codes)
+            if self.protocol == CHAMBER:
+                first, matched = rewrite_chamber_auth(first, await codes.get(), printer_code)
+                if matched is None:
+                    self._refused(source_ip)
+                    return
+                self._signed_in(source_ip, matched, set())
+            printer_reader, printer_writer = await asyncio.wait_for(
+                asyncio.open_connection(self.target_host, self.target_port, ssl=_printer_context()),
+                timeout=10.0,
+            )
+            logger.info("%s camera: %s connected", self.name, source_ip)
+            if self.protocol == CHAMBER:
+                printer_writer.write(first)
+                await printer_writer.drain()
+                pump = self._pipe(client_reader, printer_writer)
+            else:
+                pump = self._pump_rtsp(first, client_reader, printer_writer, source_ip, codes, printer_code)
+            await asyncio.gather(pump, self._pipe(printer_reader, client_writer))
+        except (ConnectionError, OSError, TimeoutError, asyncio.IncompleteReadError, asyncio.LimitOverrunError) as e:
+            logger.debug("%s camera connection from %s ended: %s", self.name, source_ip, e)
+        except asyncio.CancelledError:
+            pass
+        except Exception as e:  # noqa: BLE001 -- one camera connection must not take the bridge down
+            logger.error("%s camera connection from %s failed: %s", self.name, source_ip, e)
+        finally:
+            if current is not None:
+                self._handlers.discard(current)
+            for writer in (client_writer, printer_writer):
+                if writer is not None and not writer.is_closing():
+                    try:
+                        writer.close()
+                    except (OSError, RuntimeError):
+                        pass
+
+    def _signed_in(self, source_ip: str, matched: AcceptedCode, logged: set[int | None]) -> None:
+        if matched.user_id not in logged:
+            logged.add(matched.user_id)
+            if matched.user_id is not None:
+                logger.info("%s camera: %s signed in as user %s", self.name, source_ip, matched.user_id)
+
+    def _refused(self, source_ip: str) -> None:
+        """Count a sign-in the camera doesn't accept; the caller then ends the connection."""
+        self.rate_limiter.record_failure(source_ip)
+        logger.warning("%s camera from %s: access code not accepted", self.name, source_ip)
+
+    async def _pump_rtsp(
+        self,
+        first: bytes,
+        reader: asyncio.StreamReader,
+        writer: asyncio.StreamWriter,
+        source_ip: str,
+        codes: _AcceptedCodes,
+        printer_code: str,
+    ) -> None:
+        """Slicer -> printer: RTSP requests (Digest re-signed) and interleaved ``$`` frames as they are.
+
+        A sign-in that is not re-signed ends the connection: it is never
+        passed on (see ``rewrite_rtsp_auth``).
+        """
+        logged: set[int | None] = set()
+        try:
+            while True:
+                if first == b"$":
+                    header = await reader.readexactly(3)
+                    frame = await reader.readexactly(int.from_bytes(header[1:3], "big"))
+                    writer.write(first + header + frame)
+                else:
+                    head = first + await reader.readuntil(b"\r\n\r\n")
+                    body = b""
+                    match = re.search(rb"\r\ncontent-length\s*:\s*(\d+)", head, re.IGNORECASE)
+                    if match:
+                        length = int(match.group(1))
+                        if length > _MAX_RTSP_BODY:
+                            return
+                        body = await reader.readexactly(length)
+                    accepted = await codes.get() if b"authorization" in head.lower() else []
+                    head, matched, had_auth = rewrite_rtsp_auth(head, accepted, printer_code)
+                    if matched is not None:
+                        self._signed_in(source_ip, matched, logged)
+                    elif had_auth:
+                        self._refused(source_ip)
+                        return
+                    writer.write(head + body)
+                await writer.drain()
+                first = await reader.readexactly(1)
+        except asyncio.IncompleteReadError:
+            pass
+        finally:
+            if not writer.is_closing():
+                writer.close()
+
+    @staticmethod
+    async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
+        try:
+            while True:
+                data = await reader.read(65536)
+                if not data:
+                    break
+                writer.write(data)
+                await writer.drain()
+        except (ConnectionError, OSError, RuntimeError):
+            pass
+        finally:
+            if not writer.is_closing():
+                try:
+                    writer.close()
+                except (OSError, RuntimeError):
+                    pass

+ 44 - 5
backend/app/services/virtual_printer/manager.py

@@ -24,12 +24,13 @@ from backend.app.models.virtual_printer import (
 from backend.app.services import slicer_ams_pick
 from backend.app.services.bambu_mqtt import resolve_external_spools_in_mapping
 from backend.app.services.virtual_printer.bind_server import BindServer
+from backend.app.services.virtual_printer.camera_bridge import CHAMBER, RTSP, AcceptedCode, CameraBridge
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer, compute_passive_port_slice
 from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
-from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager, TCPProxy
+from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
 from backend.app.utils.printer_models import has_ams3h, is_nozzle_rack_model
 from backend.app.utils.tray_ids import TrayLayout
 
@@ -340,7 +341,7 @@ class VirtualPrinterInstance:
         self._ftp: VirtualPrinterFTPServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
         self._mqtt_bridge: MQTTBridge | None = None
-        self._rtsp_proxy: TCPProxy | None = None
+        self._rtsp_proxy: CameraBridge | None = None
         self._bind: BindServer | None = None
         self._ssdp: VirtualPrinterSSDPServer | None = None
         self._ssdp_proxy: SSDPProxy | None = None
@@ -420,6 +421,37 @@ class VirtualPrinterInstance:
             logger.error("[VP %s] Printer control check failed: %s", self.name, e)
             return False
 
+    def _target_access_code(self) -> str | None:
+        """The target printer's own access code, which its camera checks."""
+        if self._printer_manager is None or self.target_printer_id is None:
+            return None
+        client = self._printer_manager.get_client(self.target_printer_id)
+        code = getattr(client, "access_code", None) if client is not None else None
+        return code if isinstance(code, str) and code else None
+
+    async def camera_codes(self) -> list[AcceptedCode]:
+        """Codes the slicer's camera view through this VP accepts (#1430).
+
+        The VP's own, the target printer's own, and the personal codes of
+        users who may watch the target printer now. Asked at each camera
+        connection.
+        """
+        accepted = [AcceptedCode(self.access_code)]
+        printer_code = self._target_access_code()
+        if printer_code and printer_code != self.access_code:
+            accepted.append(AcceptedCode(printer_code))
+        if self._session_factory is None or self.target_printer_id is None:
+            return accepted
+        from backend.app.services.virtual_printer.user_codes import camera_codes
+
+        try:
+            async with self._session_factory() as db:
+                personal = await camera_codes(db, self.target_printer_id)
+        except Exception as e:  # noqa: BLE001 -- a lookup error leaves only the shared codes
+            logger.error("[VP %s] Personal camera code lookup failed: %s", self.name, e)
+            return accepted
+        return accepted + [AcceptedCode(code, user_id) for code, user_id in personal]
+
     async def on_file_received(self, file_path: Path, source_ip: str, user_id: int | None = None) -> None:
         """Handle file upload completion from FTP.
 
@@ -1577,12 +1609,19 @@ class VirtualPrinterInstance:
                 from backend.app.services.camera import get_camera_port
 
                 camera_port = get_camera_port(target_model)
-                self._rtsp_proxy = TCPProxy(
-                    name=f"Camera-{camera_port}",
+                # Signs the slicer in with the printer's own code, so the camera
+                # also works with a personal access code (#1430).
+                self._rtsp_proxy = CameraBridge(
+                    name=f"[VP {self.name}] Camera-{camera_port}",
+                    protocol=RTSP if camera_port == 322 else CHAMBER,
                     listen_port=camera_port,
+                    bind_address=bind_addr,
                     target_host=target_ip,
                     target_port=camera_port,
-                    bind_address=bind_addr,
+                    cert_path=cert_path,
+                    key_path=key_path,
+                    codes=self.camera_codes,
+                    printer_code=self._target_access_code,
                 )
                 self._tasks.append(
                     asyncio.create_task(

+ 32 - 0
backend/app/services/virtual_printer/user_codes.py

@@ -125,3 +125,35 @@ async def may_control_printer(db: AsyncSession, user_id: int, printer_id: int) -
     if user is None or not user.is_active or not user.has_permission(Permission.PRINTERS_CONTROL.value):
         return False
     return (await resolve_user_printer_scope(db, user)).allows(printer_id)
+
+
+async def camera_codes(db: AsyncSession, printer_id: int) -> list[tuple[str, int]]:
+    """``(code, user id)`` of every user who may watch printer ``printer_id``'s camera now.
+
+    For the slicer's camera view through a virtual printer: the account must be
+    active, hold camera:view and be allowed the printer (#1727). Empty while
+    auth is off, as personal codes then don't sign in anywhere.
+    """
+    from backend.app.core.auth import is_auth_enabled
+    from backend.app.core.printer_scope import resolve_user_printer_scope
+
+    if not await is_auth_enabled(db):
+        return []
+    users = (
+        (
+            await db.execute(
+                select(User)
+                .where(User.slicer_access_code.is_not(None), User.is_active.is_(True))
+                .options(selectinload(User.groups))
+            )
+        )
+        .scalars()
+        .all()
+    )
+    allowed: list[tuple[str, int]] = []
+    for user in users:
+        if not user.slicer_access_code or not user.has_permission(Permission.CAMERA_VIEW.value):
+            continue
+        if (await resolve_user_printer_scope(db, user)).allows(printer_id):
+            allowed.append((user.slicer_access_code, user.id))
+    return allowed

+ 31 - 1
backend/tests/integration/test_vp_user_codes.py

@@ -8,7 +8,7 @@ from backend.app.models.group import Group, group_printers
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.virtual_printer import VirtualPrinter
-from backend.app.services.virtual_printer.user_codes import code_for, new_code, user_for_code
+from backend.app.services.virtual_printer.user_codes import camera_codes, code_for, new_code, user_for_code
 
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 
@@ -205,3 +205,33 @@ async def test_commands_to_the_printer_need_printers_control_on_it(
     user = await _user(db_session, permissions, active=active, restrict_to=[mine.id])
 
     assert await may_control_printer(db_session, user.id, mine.id if in_scope else other.id) is allowed
+
+
+async def test_camera_codes_are_those_of_users_who_may_watch_the_printer(db_session, printer_factory):
+    """The slicer's camera through a VP: camera:view, an active account and the printer in scope."""
+    await _auth(db_session)
+    mine = await printer_factory(name="Mine")
+    other = await printer_factory(name="Other")
+    viewer = await _user(db_session, ["camera:view"], code="11110001", name="viewer")
+    scoped = await _user(db_session, ["camera:view"], code="11110002", name="scoped", restrict_to=[mine.id])
+    await _user(db_session, ["library:upload"], code="11110003", name="no-camera")
+    await _user(db_session, ["camera:view"], code="11110004", name="disabled", active=False)
+    db_session.add(
+        User(
+            username="no-code",
+            password_hash=get_password_hash("Pass1word!"),
+            groups=[(await db_session.execute(select(Group).where(Group.name == "g-viewer"))).scalar_one()],
+        )
+    )
+    await db_session.commit()
+
+    assert sorted(await camera_codes(db_session, mine.id)) == [("11110001", viewer.id), ("11110002", scoped.id)]
+    assert await camera_codes(db_session, other.id) == [("11110001", viewer.id)]
+
+
+async def test_no_camera_codes_while_auth_is_off(db_session, printer_factory):
+    await _auth(db_session, on=False)
+    printer = await printer_factory(name="Mine")
+    await _user(db_session, ["camera:view"])
+
+    assert await camera_codes(db_session, printer.id) == []

+ 65 - 3
backend/tests/unit/services/test_virtual_printer.py

@@ -4287,7 +4287,7 @@ class TestVirtualPrinterCameraPassthrough:
     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."""
+        ``CameraBridge`` calls get captured into."""
         from backend.app.services.virtual_printer import manager as vp_manager
 
         tcp_calls: list[dict] = []
@@ -4303,8 +4303,8 @@ class TestVirtualPrinterCameraPassthrough:
             async def stop(self):
                 return None
 
-        # Camera pass-through is the only TCPProxy start_server constructs.
-        monkeypatch.setattr(vp_manager, "TCPProxy", FakeTCPProxy)
+        # The camera bridge is the only camera listener start_server constructs (#1430).
+        monkeypatch.setattr(vp_manager, "CameraBridge", FakeTCPProxy)
 
         # No-op every other service constructor + resolve_cert_and_advertise.
         # start_server() awaits `.ready.wait()` on FTP/MQTT/Bind/SSDP after
@@ -4411,6 +4411,68 @@ class TestVirtualPrinterCameraPassthrough:
         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)
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(("target_model", "protocol"), [("H2C", "rtsp"), ("P1S", "chamber")])
+    async def test_the_camera_signs_the_slicer_in_for_the_printer(self, tmp_path, monkeypatch, target_model, protocol):
+        """#1430: the camera bridge, not a plain pass-through, so a personal code gets the stream too."""
+        calls = await self._run_start_server(tmp_path, monkeypatch, target_model=target_model)
+        (call,) = calls
+        assert call["protocol"] == protocol
+        assert call["cert_path"] == Path("/tmp/cert.pem")  # nosec B108
+        assert callable(call["codes"]) and callable(call["printer_code"])
+
+
+class TestCameraCodes:
+    """What the camera through a non-proxy VP accepts (#1430)."""
+
+    @staticmethod
+    def _instance(tmp_path, printer_code="12345678", session_factory=None):
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        instance = VirtualPrinterInstance(
+            vp_id=98,
+            name="Cam",
+            mode="library",
+            model="O1C",
+            access_code="87654321",
+            serial_suffix="391800098",
+            target_printer_id=7,
+            base_dir=tmp_path,
+            session_factory=session_factory,
+        )
+        client = MagicMock()
+        client.access_code = printer_code
+        printer_manager = MagicMock()
+        printer_manager.get_client.return_value = client
+        instance._printer_manager = printer_manager
+        return instance
+
+    @pytest.mark.asyncio
+    async def test_the_vp_and_printer_codes_without_a_database(self, tmp_path):
+        from backend.app.services.virtual_printer.camera_bridge import AcceptedCode
+
+        instance = self._instance(tmp_path)
+        assert instance._target_access_code() == "12345678"
+        assert await instance.camera_codes() == [AcceptedCode("87654321"), AcceptedCode("12345678")]
+
+    @pytest.mark.asyncio
+    async def test_personal_codes_of_users_who_may_watch(self, tmp_path):
+        from backend.app.services.virtual_printer.camera_bridge import AcceptedCode
+
+        session = MagicMock()
+        session.__aenter__ = AsyncMock(return_value=MagicMock())
+        session.__aexit__ = AsyncMock(return_value=False)
+        instance = self._instance(tmp_path, printer_code="87654321", session_factory=MagicMock(return_value=session))
+        with patch(
+            "backend.app.services.virtual_printer.user_codes.camera_codes",
+            AsyncMock(return_value=[("11112222", 5)]),
+        ) as lookup:
+            codes = await instance.camera_codes()
+        lookup.assert_awaited_once()
+        assert lookup.await_args.args[1] == 7
+        # The printer's code equals the VP's here, so it is listed once.
+        assert codes == [AcceptedCode("87654321"), AcceptedCode("11112222", 5)]
+
 
 class TestVirtualPrinterInstanceProxyMode:
     """Tests for VirtualPrinterInstance proxy mode."""

+ 432 - 0
backend/tests/unit/services/test_vp_camera_bridge.py

@@ -0,0 +1,432 @@
+"""The slicer's camera through a virtual printer signs in with the printer's code (#1430).
+
+The printer checks the camera's access code itself, so a slicer bound with a
+personal access code was refused there and kept asking for the code.
+"""
+
+import asyncio
+import re
+import ssl
+
+import pytest
+
+from backend.app.services.virtual_printer.camera_bridge import (
+    CHAMBER,
+    RTSP,
+    AcceptedCode,
+    CameraBridge,
+    digest_response,
+    parse_digest,
+    rewrite_chamber_auth,
+    rewrite_rtsp_auth,
+)
+from backend.app.services.virtual_printer.ftp_server import AuthRateLimiter
+
+PRINTER_CODE = "12345678"
+VP_CODE = "87654321"
+PERSONAL = "11112222"
+ACCEPTED = [AcceptedCode(VP_CODE), AcceptedCode(PERSONAL, user_id=7)]
+REALM = "LIVE555 Streaming Media"
+NONCE = "b0047e376b1831bbc8afa1461ba6c1ca"
+URI = "rtsps://192.0.2.10:322/streaming/live/1"
+
+
+def _signed(code: str, method: str = "DESCRIBE") -> dict[str, str]:
+    params = {"username": "bblp", "realm": REALM, "nonce": NONCE, "uri": URI}
+    params["response"] = digest_response(params, method, code)
+    return params
+
+
+def _request(code: str | None, method: str = "DESCRIBE", cseq: int = 2) -> bytes:
+    lines = [f"{method} {URI} RTSP/1.0", f"CSeq: {cseq}"]
+    if code is not None:
+        p = _signed(code, method)
+        lines.append(
+            f'Authorization: Digest username="bblp", realm="{REALM}", nonce="{NONCE}", uri="{URI}", '
+            f'response="{p["response"]}"'
+        )
+    return ("\r\n".join(lines) + "\r\n\r\n").encode()
+
+
+def _response_of(head: bytes) -> str:
+    return re.search(rb'response="([0-9a-f]+)"', head).group(1).decode()
+
+
+class TestDigest:
+    def test_rfc2617_example(self):
+        params = parse_digest(
+            'Digest username="Mufasa", realm="testrealm@host.com", '
+            'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", uri="/dir/index.html", qop=auth, '
+            'nc=00000001, cnonce="0a4f113b", response="6629fae49393a05397450978507c4ef1", '
+            'opaque="5ccc069c403ebaf9f0171e9517f40e41"'
+        )
+        assert digest_response(params, "GET", "Circle Of Life") == "6629fae49393a05397450978507c4ef1"
+
+    def test_other_schemes_are_not_digest(self):
+        assert parse_digest("Basic YmJscDoxMjM0") is None
+
+
+class TestRtspRewrite:
+    @pytest.mark.parametrize("code", [PERSONAL, VP_CODE])
+    def test_an_accepted_code_is_signed_with_the_printers(self, code):
+        head, matched, had_auth = rewrite_rtsp_auth(_request(code), ACCEPTED, PRINTER_CODE)
+
+        assert had_auth and matched is not None and matched.code == code
+        assert _response_of(head) == _signed(PRINTER_CODE)["response"]
+        # Everything else stays as the slicer sent it.
+        assert head.replace(_response_of(head).encode(), b"") == _request(code).replace(
+            _signed(code)["response"].encode(), b""
+        )
+
+    def test_the_personal_code_names_its_user(self):
+        _, matched, _ = rewrite_rtsp_auth(_request(PERSONAL), ACCEPTED, PRINTER_CODE)
+        assert matched.user_id == 7
+
+    def test_an_unknown_code_passes_unchanged(self):
+        request = _request("99990000")
+        assert rewrite_rtsp_auth(request, ACCEPTED, PRINTER_CODE) == (request, None, True)
+
+    def test_a_request_without_auth_passes_unchanged(self):
+        request = _request(None, method="OPTIONS", cseq=1)
+        assert rewrite_rtsp_auth(request, ACCEPTED, PRINTER_CODE) == (request, None, False)
+
+
+def _chamber_payload(code: str) -> bytes:
+    from backend.app.services.camera import _create_chamber_auth_payload
+
+    return _create_chamber_auth_payload(code)
+
+
+class TestChamberRewrite:
+    def test_an_accepted_code_is_replaced_by_the_printers(self):
+        payload, matched = rewrite_chamber_auth(_chamber_payload(PERSONAL), ACCEPTED, PRINTER_CODE)
+        assert payload == _chamber_payload(PRINTER_CODE)
+        assert matched.user_id == 7
+
+    def test_an_unknown_code_passes_unchanged(self):
+        payload = _chamber_payload("99990000")
+        assert rewrite_chamber_auth(payload, ACCEPTED, PRINTER_CODE) == (payload, None)
+
+
+# -- end to end, against a printer that checks Digest itself -------------------
+
+
+@pytest.fixture
+def certs(tmp_path):
+    from backend.app.services.virtual_printer.certificate import CertificateService
+
+    return CertificateService(cert_dir=tmp_path, serial="09400A391800001").generate_certificates()
+
+
+class FakePrinter:
+    """RTSPS that answers 401 unless the Digest was made with the printer's code, and records what it got."""
+
+    def __init__(self):
+        self.received = bytearray()
+        self.server = None
+
+    async def start(self, cert_path, key_path) -> int:
+        context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        context.load_cert_chain(str(cert_path), str(key_path))
+        self.server = await asyncio.start_server(self._handle, "127.0.0.1", 0, ssl=context)
+        return self.server.sockets[0].getsockname()[1]
+
+    async def _handle(self, reader, writer):
+        try:
+            while True:
+                first = await reader.readexactly(1)
+                if first == b"$":
+                    header = await reader.readexactly(3)
+                    frame = await reader.readexactly(int.from_bytes(header[1:3], "big"))
+                    self.received += first + header + frame
+                    continue
+                head = first + await reader.readuntil(b"\r\n\r\n")
+                self.received += head
+                cseq = re.search(rb"CSeq: (\d+)", head).group(1)
+                auth = re.search(rb"Authorization: (.*)\r\n", head)
+                ok = False
+                if auth:
+                    params = parse_digest(auth.group(1).decode())
+                    ok = params["response"] == digest_response(params, head.split(b" ")[0].decode(), PRINTER_CODE)
+                status = b"200 OK" if ok else b"401 Unauthorized"
+                writer.write(b"RTSP/1.0 " + status + b"\r\nCSeq: " + cseq + b"\r\n\r\n")
+                await writer.drain()
+        except (asyncio.IncompleteReadError, ConnectionError):
+            pass
+        finally:
+            writer.close()
+
+    async def stop(self):
+        self.server.close()
+        await self.server.wait_closed()
+
+
+@pytest.fixture
+async def bridge(certs):
+    cert_path, key_path = certs
+    printer = FakePrinter()
+    port = await printer.start(cert_path, key_path)
+
+    async def codes():
+        return ACCEPTED
+
+    camera = CameraBridge(
+        name="test",
+        protocol=RTSP,
+        listen_port=0,
+        bind_address="127.0.0.1",
+        target_host="127.0.0.1",
+        target_port=port,
+        cert_path=cert_path,
+        key_path=key_path,
+        codes=codes,
+        printer_code=lambda: PRINTER_CODE,
+        rate_limiter=AuthRateLimiter(max_attempts=3),
+    )
+    task = asyncio.create_task(camera.start())
+    for _ in range(100):
+        if camera._server is not None:
+            break
+        await asyncio.sleep(0.01)
+    camera.listen_port = camera._server.sockets[0].getsockname()[1]
+    yield camera, printer
+    task.cancel()
+    await asyncio.gather(task, return_exceptions=True)
+    await printer.stop()
+
+
+async def _connect(camera):
+    context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+    context.check_hostname = False
+    context.verify_mode = ssl.CERT_NONE
+    return await asyncio.open_connection("127.0.0.1", camera.listen_port, ssl=context)
+
+
+async def _reply(reader) -> bytes:
+    return await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=5)
+
+
+@pytest.mark.asyncio
+async def test_a_personal_code_gets_the_stream_and_frames_pass_through(bridge):
+    camera, printer = bridge
+    reader, writer = await _connect(camera)
+
+    writer.write(_request(None, method="OPTIONS", cseq=1))
+    assert b"401" in await _reply(reader)  # the fake asks for auth on everything
+    writer.write(_request(PERSONAL, cseq=2))
+    assert b"200 OK" in await _reply(reader)
+    frame = b"$\x01\x00\x04RTCP"
+    writer.write(frame + _request(PERSONAL, method="GET_PARAMETER", cseq=3))
+    assert b"200 OK" in await _reply(reader)
+    writer.close()
+
+    assert frame in printer.received
+    assert PERSONAL.encode() not in printer.received
+
+
+@pytest.mark.asyncio
+async def test_unknown_codes_never_reach_the_printer_and_are_rate_limited(bridge):
+    camera, printer = bridge
+    for attempt in range(3):
+        reader, writer = await _connect(camera)
+        writer.write(_request("99990000", cseq=attempt + 2))
+        # Cut off without an answer from the printer, which would say whether the guess was right.
+        assert await asyncio.wait_for(reader.read(), timeout=5) == b""
+    assert b"Authorization" not in printer.received
+    # Three failures: the source stays out, even with a good code.
+    reader, writer = await _connect(camera)
+    writer.write(_request(PERSONAL))
+    assert await asyncio.wait_for(reader.read(), timeout=5) == b""
+
+
+def _hidden_digest(code: str) -> bytes:
+    p = _signed(code)
+    return (f'username="bblp", realm="{REALM}", nonce="{NONCE}", uri="{URI}", response="{p["response"]}"').encode()
+
+
+@pytest.mark.parametrize(
+    "request_bytes",
+    [
+        # A Digest header inside another header's value, which the printer's server finds too.
+        lambda: (
+            f"DESCRIBE {URI} RTSP/1.0\r\nCSeq: 2\r\nX-A: Authorization: Digest ".encode()
+            + _hidden_digest("99990000")
+            + b"\r\n\r\n"
+        ),
+        # A Digest header after a Basic one.
+        lambda: (
+            f"DESCRIBE {URI} RTSP/1.0\r\nCSeq: 2\r\nAuthorization: Basic Zm9v\r\nAuthorization: Digest ".encode()
+            + _hidden_digest("99990000")
+            + b"\r\n\r\n"
+        ),
+        # A Basic header alone: not a sign-in the bridge can re-sign.
+        lambda: f"DESCRIBE {URI} RTSP/1.0\r\nCSeq: 2\r\nAuthorization: Basic Zm9v\r\n\r\n".encode(),
+        # Two Digest headers, the first one accepted.
+        lambda: _request(PERSONAL)[:-2] + b"Authorization: Digest " + _hidden_digest("99990000") + b"\r\n\r\n",
+    ],
+)
+def test_a_sign_in_it_does_not_re_sign_counts_as_one(request_bytes):
+    head = request_bytes()
+    assert rewrite_rtsp_auth(head, ACCEPTED, PRINTER_CODE) == (head, None, True)
+
+
+@pytest.mark.asyncio
+async def test_codes_are_looked_up_on_a_sign_in_and_again_when_old(monkeypatch):
+    from backend.app.services.virtual_printer import camera_bridge
+
+    calls = []
+
+    async def lookup():
+        calls.append(1)
+        return ACCEPTED
+
+    clock = [1000.0]
+    monkeypatch.setattr(camera_bridge.time, "monotonic", lambda: clock[0])
+    codes = camera_bridge._AcceptedCodes(lookup)
+    await codes.get()
+    await codes.get()
+    assert len(calls) == 1
+    clock[0] += camera_bridge.CODES_MAX_AGE + 1
+    await codes.get()
+    assert len(calls) == 2
+
+
+@pytest.mark.asyncio
+async def test_a_connection_that_sends_nothing_costs_no_lookup(certs, monkeypatch):
+    from backend.app.services.virtual_printer import camera_bridge
+
+    monkeypatch.setattr(camera_bridge, "FIRST_BYTES_TIMEOUT", 0.2)
+    cert_path, key_path = certs
+    looked_up = []
+
+    async def codes():
+        looked_up.append(1)
+        return ACCEPTED
+
+    camera = CameraBridge(
+        name="test",
+        protocol=RTSP,
+        listen_port=0,
+        bind_address="127.0.0.1",
+        target_host="127.0.0.1",
+        target_port=1,  # nothing listens: a connection attempt would fail the test below
+        cert_path=cert_path,
+        key_path=key_path,
+        codes=codes,
+        printer_code=lambda: PRINTER_CODE,
+    )
+    task = asyncio.create_task(camera.start())
+    for _ in range(100):
+        if camera._server is not None:
+            break
+        await asyncio.sleep(0.01)
+    camera.listen_port = camera._server.sockets[0].getsockname()[1]
+    try:
+        reader, _ = await _connect(camera)
+        assert await asyncio.wait_for(reader.read(), timeout=5) == b""
+        assert looked_up == []
+    finally:
+        task.cancel()
+        await asyncio.gather(task, return_exceptions=True)
+
+
+@pytest.mark.asyncio
+async def test_chamber_sign_in_uses_the_printers_code(certs):
+    cert_path, key_path = certs
+    got = asyncio.get_running_loop().create_future()
+
+    async def printer_side(reader, writer):
+        got.set_result(await reader.readexactly(80))
+        writer.write(b"jpeg")
+        await writer.drain()
+        writer.close()
+
+    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+    context.load_cert_chain(str(cert_path), str(key_path))
+    server = await asyncio.start_server(printer_side, "127.0.0.1", 0, ssl=context)
+
+    async def codes():
+        return ACCEPTED
+
+    camera = CameraBridge(
+        name="test",
+        protocol=CHAMBER,
+        listen_port=0,
+        bind_address="127.0.0.1",
+        target_host="127.0.0.1",
+        target_port=server.sockets[0].getsockname()[1],
+        cert_path=cert_path,
+        key_path=key_path,
+        codes=codes,
+        printer_code=lambda: PRINTER_CODE,
+    )
+    task = asyncio.create_task(camera.start())
+    for _ in range(100):
+        if camera._server is not None:
+            break
+        await asyncio.sleep(0.01)
+    camera.listen_port = camera._server.sockets[0].getsockname()[1]
+    try:
+        reader, writer = await _connect(camera)
+        writer.write(_chamber_payload(PERSONAL))
+        await writer.drain()
+        assert await asyncio.wait_for(got, timeout=5) == _chamber_payload(PRINTER_CODE)
+        assert await asyncio.wait_for(reader.read(4), timeout=5) == b"jpeg"
+        writer.close()
+    finally:
+        task.cancel()
+        await asyncio.gather(task, return_exceptions=True)
+        server.close()
+        await server.wait_closed()
+
+
+@pytest.mark.asyncio
+async def test_stopping_ends_a_connection_still_open(bridge):
+    """The virtual printer stops even while a slicer is watching the camera."""
+    camera, _ = bridge
+    reader, writer = await _connect(camera)
+    writer.write(_request(PERSONAL))
+    assert b"200 OK" in await _reply(reader)
+
+    await asyncio.wait_for(camera.stop(), timeout=5)
+    assert await asyncio.wait_for(reader.read(), timeout=5) == b""
+
+
+@pytest.mark.asyncio
+async def test_cancelling_the_bridge_ends_a_connection_still_open(certs):
+    """Cancelling the listening task must not wait for the slicer to hang up."""
+    cert_path, key_path = certs
+    printer = FakePrinter()
+    port = await printer.start(cert_path, key_path)
+
+    async def codes():
+        return ACCEPTED
+
+    camera = CameraBridge(
+        name="test",
+        protocol=RTSP,
+        listen_port=0,
+        bind_address="127.0.0.1",
+        target_host="127.0.0.1",
+        target_port=port,
+        cert_path=cert_path,
+        key_path=key_path,
+        codes=codes,
+        printer_code=lambda: PRINTER_CODE,
+    )
+    task = asyncio.create_task(camera.start())
+    for _ in range(100):
+        if camera._server is not None:
+            break
+        await asyncio.sleep(0.01)
+    camera.listen_port = camera._server.sockets[0].getsockname()[1]
+    try:
+        reader, writer = await _connect(camera)
+        writer.write(_request(PERSONAL))
+        assert b"200 OK" in await _reply(reader)
+
+        task.cancel()
+        await asyncio.wait_for(asyncio.gather(task, return_exceptions=True), timeout=5)
+        assert await asyncio.wait_for(reader.read(), timeout=5) == b""
+    finally:
+        await printer.stop()

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -7340,7 +7340,7 @@ export default {
     renewConfirm: 'Dein bisheriger Code funktioniert dann nicht mehr. Verbinde den virtuellen Drucker im Slicer mit dem neuen Code neu.',
     renewed: 'Neuer Zugangscode bereit',
     hintChecked: 'Bei jeder Anmeldung wird geprüft, ob du an diesen virtuellen Drucker und seinen Drucker senden darfst.',
-    hintCamera: 'Mit diesem Code funktioniert die Kameraansicht des Slicers nicht; die Kamera in Bambuddy schon.',
+    hintCamera: 'Die Kameraansicht des Slicers funktioniert mit diesem Code auch, wenn du die Kamera dieses Druckers sehen darfst.',
     hintSecret: 'Er wirkt wie ein Passwort: gib ihn nicht weiter.',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -7393,7 +7393,7 @@ export default {
     renewConfirm: 'Your current code stops working. Bind the virtual printer in your slicer again with the new code.',
     renewed: 'New access code ready',
     hintChecked: 'Every sign-in checks that you may send to that virtual printer and its printer.',
-    hintCamera: 'With this code the slicer\'s own camera view of the printer does not work; Bambuddy\'s camera does.',
+    hintCamera: 'The slicer\'s camera view works with this code too, if you may watch that printer\'s camera.',
     hintSecret: 'It works like a password: don\'t share it.',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/es.ts

@@ -7348,7 +7348,7 @@ export default {
     renewConfirm: 'Tu código actual dejará de funcionar. Vuelve a vincular la impresora virtual en el laminador con el nuevo código.',
     renewed: 'Nuevo código de acceso listo',
     hintChecked: 'Cada inicio de sesión comprueba que puedes enviar a esa impresora virtual y a su impresora.',
-    hintCamera: 'Con este código la vista de cámara del laminador no funciona; la cámara de Bambuddy sí.',
+    hintCamera: 'La vista de cámara del laminador también funciona con este código, si puedes ver la cámara de esa impresora.',
     hintSecret: 'Funciona como una contraseña: no lo compartas.',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -7328,7 +7328,7 @@ export default {
     renewConfirm: 'Votre code actuel ne fonctionnera plus. Reliez à nouveau l\'imprimante virtuelle dans le trancheur avec le nouveau code.',
     renewed: 'Nouveau code d\'accès prêt',
     hintChecked: 'Chaque connexion vérifie que vous pouvez envoyer à cette imprimante virtuelle et à son imprimante.',
-    hintCamera: 'Avec ce code, la vue caméra du trancheur ne fonctionne pas ; celle de Bambuddy, si.',
+    hintCamera: 'La vue caméra du trancheur fonctionne aussi avec ce code, si vous avez le droit de voir la caméra de cette imprimante.',
     hintSecret: 'Il agit comme un mot de passe : ne le partagez pas.',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/it.ts

@@ -7327,7 +7327,7 @@ export default {
     renewConfirm: 'Il codice attuale smetterà di funzionare. Collega di nuovo la stampante virtuale nello slicer con il nuovo codice.',
     renewed: 'Nuovo codice di accesso pronto',
     hintChecked: 'A ogni accesso si controlla che tu possa inviare a quella stampante virtuale e alla sua stampante.',
-    hintCamera: 'Con questo codice la vista della fotocamera nello slicer non funziona; quella di Bambuddy sì.',
+    hintCamera: 'La vista della fotocamera nello slicer funziona anche con questo codice, se puoi vedere la fotocamera di quella stampante.',
     hintSecret: 'Funziona come una password: non condividerlo.',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/ja.ts

@@ -7341,7 +7341,7 @@ export default {
     renewConfirm: '現在のコードは使えなくなります。新しいコードでスライサーの仮想プリンターを再度バインドしてください。',
     renewed: '新しいアクセスコードを用意しました',
     hintChecked: 'サインインのたびに、その仮想プリンターとプリンターに送信できるか確認されます。',
-    hintCamera: 'このコードではスライサーのカメラ表示は使えません。Bambuddy のカメラは使えます。',
+    hintCamera: 'このコードでもスライサーのカメラ表示を使えます(そのプリンターのカメラを見る権限がある場合)。',
     hintSecret: 'パスワードと同じように扱い、共有しないでください。',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/ko.ts

@@ -7034,7 +7034,7 @@ export default {
     renewConfirm: '현재 코드는 더 이상 작동하지 않습니다. 새 코드로 슬라이서에서 가상 프린터를 다시 연결하세요.',
     renewed: '새 액세스 코드가 준비되었습니다',
     hintChecked: '로그인할 때마다 해당 가상 프린터와 프린터로 보낼 수 있는지 확인합니다.',
-    hintCamera: '이 코드로는 슬라이서의 카메라 보기가 작동하지 않습니다. Bambuddy의 카메라는 작동합니다.',
+    hintCamera: '이 코드로도 슬라이서의 카메라 보기가 작동합니다(해당 프린터의 카메라를 볼 권한이 있는 경우).',
     hintSecret: '비밀번호처럼 다루고 공유하지 마세요.',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/nl.ts

@@ -7391,7 +7391,7 @@ export default {
     renewConfirm: 'Je huidige code werkt dan niet meer. Koppel de virtuele printer in de slicer opnieuw met de nieuwe code.',
     renewed: 'Nieuwe toegangscode klaar',
     hintChecked: 'Bij elke aanmelding wordt gecontroleerd of je naar die virtuele printer en zijn printer mag sturen.',
-    hintCamera: 'Met deze code werkt het camerabeeld van de slicer niet; de camera van Bambuddy wel.',
+    hintCamera: 'Het camerabeeld van de slicer werkt ook met deze code, als je de camera van die printer mag bekijken.',
     hintSecret: 'Hij werkt als een wachtwoord: deel hem niet.',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/pl.ts

@@ -7393,7 +7393,7 @@ export default {
     renewConfirm: 'Obecny kod przestanie działać. Połącz ponownie wirtualną drukarkę w slicerze, używając nowego kodu.',
     renewed: 'Nowy kod dostępu gotowy',
     hintChecked: 'Przy każdym logowaniu sprawdzane jest, czy możesz wysyłać do tej wirtualnej drukarki i jej drukarki.',
-    hintCamera: 'Z tym kodem podgląd kamery w slicerze nie działa; kamera w Bambuddy działa.',
+    hintCamera: 'Podgląd kamery w slicerze też działa z tym kodem, jeśli możesz oglądać kamerę tej drukarki.',
     hintSecret: 'Działa jak hasło: nie udostępniaj go.',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -7327,7 +7327,7 @@ export default {
     renewConfirm: 'Seu código atual deixará de funcionar. Vincule a impressora virtual no fatiador de novo com o novo código.',
     renewed: 'Novo código de acesso pronto',
     hintChecked: 'Cada login verifica se você pode enviar para essa impressora virtual e a impressora dela.',
-    hintCamera: 'Com este código a visualização de câmera do fatiador não funciona; a câmera do Bambuddy funciona.',
+    hintCamera: 'A visualização de câmera do fatiador também funciona com este código, se você puder ver a câmera dessa impressora.',
     hintSecret: 'Funciona como uma senha: não compartilhe.',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/ru.ts

@@ -7021,7 +7021,7 @@ export default {
     renewConfirm: "Текущий код перестанет работать. Заново привяжите виртуальный принтер в слайсере с новым кодом.",
     renewed: "Новый код доступа готов",
     hintChecked: "При каждом входе проверяется, можете ли вы отправлять на этот виртуальный принтер и его принтер.",
-    hintCamera: "С этим кодом камера в слайсере не работает; камера в Bambuddy работает.",
+    hintCamera: "Камера в слайсере тоже работает с этим кодом, если вам разрешено смотреть камеру этого принтера.",
     hintSecret: "Он работает как пароль: не передавайте его.",
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/sv.ts

@@ -7422,7 +7422,7 @@ errors: {
     renewConfirm: 'Din nuvarande kod slutar fungera. Koppla den virtuella skrivaren i slicern igen med den nya koden.',
     renewed: 'Ny åtkomstkod klar',
     hintChecked: 'Varje inloggning kontrollerar att du får skicka till den virtuella skrivaren och dess skrivare.',
-    hintCamera: 'Med den här koden fungerar inte slicerns kameravy; Bambuddys kamera gör det.',
+    hintCamera: 'Slicerns kameravy fungerar också med den här koden, om du får se den skrivarens kamera.',
     hintSecret: 'Den fungerar som ett lösenord: dela den inte.',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/tr.ts

@@ -7284,7 +7284,7 @@ export default {
     renewConfirm: 'Mevcut kodunuz artık çalışmaz. Dilimleyicide sanal yazıcıyı yeni kodla yeniden bağlayın.',
     renewed: 'Yeni erişim kodu hazır',
     hintChecked: 'Her girişte o sanal yazıcıya ve yazıcısına gönderme izniniz olup olmadığı kontrol edilir.',
-    hintCamera: 'Bu kodla dilimleyicinin kamera görünümü çalışmaz; Bambuddy\'nin kamerası çalışır.',
+    hintCamera: 'Dilimleyicinin kamera görünümü de bu kodla çalışır, o yazıcının kamerasını izleme izniniz varsa.',
     hintSecret: 'Bir parola gibidir: paylaşmayın.',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/uk.ts

@@ -7381,7 +7381,7 @@ export default {
     renewConfirm: "Поточний код перестане працювати. Знову прив'яжіть віртуальний принтер у слайсері з новим кодом.",
     renewed: "Новий код доступу готовий",
     hintChecked: "Під час кожного входу перевіряється, чи можете ви надсилати на цей віртуальний принтер і його принтер.",
-    hintCamera: "З цим кодом камера в слайсері не працює; камера в Bambuddy працює.",
+    hintCamera: "Камера в слайсері теж працює з цим кодом, якщо вам дозволено дивитися камеру цього принтера.",
     hintSecret: "Він працює як пароль: не передавайте його.",
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -7325,7 +7325,7 @@ export default {
     renewConfirm: '当前访问码将失效。请在切片软件中用新访问码重新绑定虚拟打印机。',
     renewed: '新访问码已就绪',
     hintChecked: '每次登录都会检查你是否可以发送到该虚拟打印机及其打印机。',
-    hintCamera: '使用此访问码时,切片软件的摄像头画面无法使用;Bambuddy 的摄像头可以。',
+    hintCamera: '使用此访问码时,切片软件的摄像头画面也可以使用(前提是你有权查看该打印机的摄像头)。',
     hintSecret: '它相当于密码:请勿分享。',
   },
   pendingUploads: {

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -7325,7 +7325,7 @@ export default {
     renewConfirm: '目前的存取碼將失效。請在切片軟體中用新存取碼重新綁定虛擬印表機。',
     renewed: '新存取碼已就緒',
     hintChecked: '每次登入都會檢查你是否可以傳送到該虛擬印表機及其印表機。',
-    hintCamera: '使用此存取碼時,切片軟體的攝影機畫面無法使用;Bambuddy 的攝影機可以。',
+    hintCamera: '使用此存取碼時,切片軟體的攝影機畫面也可以使用(前提是你有權查看該印表機的攝影機)。',
     hintSecret: '它相當於密碼:請勿分享。',
   },
   pendingUploads: {

File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-Bv52d6Xc.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-Ddx1i1w8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-4wCEl5QE.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileManagerPage-BkP8YCzd.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-DxaZrCdx.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/Layout-BgbPF2oW.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-D4Fhq6Zv.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-BWr8c1xB.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-CTCm32W7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-CT9EOCVp.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-DcRJaN0Y.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-D8V7PnVM.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/de-jO9wV-al.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/es-D2PJ5NEp.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/fr-CtEA70Xa.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BCp8ipS2.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/it-xlu_L2av.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ja-BIa2fFdE.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ko-B1KhtJO7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/locationSensorDefaults-bjNVCyNs.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/nl-Cmk5pB4r.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pl-BgS5ZxGl.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pt-BR-B92oYhCb.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ru-CUhvNHg4.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/spoolCost-yWX-pcHl.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/sv-BukZIvUt.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/tr-jzvOWG_A.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/uk-BW-tUUxr.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/usePageFileDrop-8YvCP48i.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-CN-C_wgaQt0.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-TW-D-rGIIO1.js


+ 2 - 2
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D-VdCyxc.js"></script>
+    <script type="module" crossorigin src="/assets/index-BCp8ipS2.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-Bol9xAus.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-DOwGWmSv.js">
@@ -122,7 +122,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-5VMJBKdj.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-PUlx2UXc.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CKKaaGUZ.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-D0e2pB8Y.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-BgbPF2oW.js">
     <link rel="stylesheet" crossorigin href="/assets/index-bwCl253z.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff