Просмотр исходного кода

feat(camera): in-app diagnostic for "Connection lost" (#1395 follow-up)

  Step 2 of the camera architecture overhaul agreed after #1395. When
  the camera viewer hits its error state OR before a print at any
  time, a Diagnose button runs a staged check against the printer and
  renders the result inline: which stage failed, how long it took,
  and a translated remediation hint. Cuts off the "user opens a
  'camera broken' ticket → ask for support bundle → triage" loop at
  the user's screen.

  Backend

  - New `backend/app/services/camera_diagnose.py` orchestrator with
    CameraDiagnoseResult / CameraDiagnoseStage dataclasses.
  - New POST /printers/{id}/camera/diagnose route in camera.py.
  - Stages:
      tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber)
        with 3 s timeout. Distinguishes timeout, refused, and host-
        unreachable into distinct summary codes so the frontend can
        show a precise remediation (firewall vs LAN-only off vs
        wrong IP).
      first_frame — captures one JPEG end-to-end via the existing
        capture_camera_frame_bytes pipeline. Auth + RTSP handshake +
        first keyframe collapse into one stage; the user-facing
        answer is the same regardless of which sub-layer failed.
  - Live-stream shortcut: when a viewer is currently watching the
    camera with a buffered frame < 10 s old, the diagnostic skips
    the real test and returns live_stream_active_healthy. Opening a
    fresh socket would kick the live viewer off on single-camera-
    connection firmwares (the #1348 reconnect-storm trigger), so we
    trust the real-world evidence instead.
  - Response surfaces protocol, port, and profile name for support
    triage — lets us ask "what does your modal say?" instead of
    "send the support bundle".

  Frontend

  - New CameraDiagnoseModal renders one row per stage with green-
    check / red-X / grey-skipped icons, the per-stage duration in
    ms, a remediation banner styled by overall status, and a Run
    again button.
  - Two entry points:
      1. The viewer's error overlay grows a Diagnose button next to
         Retry. Retry stays the primary action; Diagnose is the
         escape hatch for users who can't see what's wrong.
      2. A stethoscope icon in the viewer's always-visible control
         bar, between Refresh and Fullscreen. Pre-flight testing
         ("did my firmware update break the camera?", "is the
         camera up before I send a print?") doesn't require waiting
         for the stream to fail first.
  - Also lifted the previously-hard-coded "Camera unavailable" /
    "Retry" strings into camera.unavailable / camera.retry so the
    error UI is fully translated alongside the new keys.
maziggy 1 неделя назад
Родитель
Сommit
134847a3bd

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 35 - 0
backend/app/api/routes/camera.py

@@ -927,6 +927,41 @@ async def test_camera(
     return result
     return result
 
 
 
 
+@router.post("/{printer_id}/camera/diagnose")
+async def diagnose_camera_route(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+):
+    """Run staged diagnostics for a printer's camera path.
+
+    Returns a structured result the frontend renders inline so users can
+    self-diagnose "connection lost" before opening a ticket. See
+    ``camera_diagnose`` for stage details and the live-stream shortcut.
+    """
+    import time
+
+    from backend.app.services.camera_diagnose import diagnose_camera
+
+    printer = await get_printer_or_404(printer_id, db)
+
+    # Look up live-stream evidence so the diagnostic can short-circuit
+    # instead of fighting a viewer for the printer's single camera slot.
+    has_live = is_stream_active(printer_id)
+    last_ts = _last_frame_times.get(printer_id) if has_live else None
+    live_age = (time.time() - last_ts) if (has_live and last_ts) else None
+
+    result = await diagnose_camera(
+        ip_address=printer.ip_address,
+        access_code=printer.access_code,
+        model=printer.model,
+        printer_id=printer_id,
+        has_live_stream=has_live,
+        live_frame_age_seconds=live_age,
+    )
+    return result.to_dict()
+
+
 @router.get("/{printer_id}/camera/status")
 @router.get("/{printer_id}/camera/status")
 async def camera_status(
 async def camera_status(
     printer_id: int,
     printer_id: int,

+ 288 - 0
backend/app/services/camera_diagnose.py

@@ -0,0 +1,288 @@
+"""End-to-end camera diagnostic, surfaced via ``POST /printers/{id}/camera/diagnose``.
+
+Cuts off the "camera broken" support-ticket loop at the user's screen by
+running the printer-side camera path through staged checks (TCP, end-
+to-end frame capture) and reporting WHICH stage failed plus a
+remediation key the frontend can render translated.
+
+The goal isn't to be a perfect protocol analyser — it's to be the diff
+between "user opens a ticket with 'connection lost'" and "user sees
+'Printer not reachable; check IP and LAN-only mode'" before they ever
+write a message.
+
+Stages
+------
+
+1. **tcp_reachable** — open a TCP socket to the camera port (322 for
+   RTSPS models, 6000 for the chamber-image-protocol A1 / P1 family).
+   Distinguishes "printer down" / "firewall" / "LAN-only off" from
+   stream-content problems.
+2. **first_frame** — call the existing ``capture_camera_frame_bytes``
+   pipeline (same code that powers /camera/snapshot) and verify at
+   least one JPEG comes back within the model's profile-derived
+   timeout. Combines auth + protocol handshake + first keyframe into
+   one stage because splitting RTSP's ``ffmpeg`` invocation is heavy
+   and the user-facing answer is the same either way: "the camera
+   itself isn't producing frames".
+
+Shortcut
+--------
+
+Most Bambu firmwares allow exactly one concurrent camera connection.
+Opening a fresh socket while a viewer is attached would kick them off
+(and trigger the same #1348 reconnect-storm pattern we built the fan-
+out broadcaster to prevent). When ``is_stream_active`` reports True
+AND a buffered frame is fresh (last 10 s), we short-circuit the test
+with ``live_stream_active`` and report success — the user is
+literally watching the camera right now, no test needed.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from dataclasses import dataclass, field
+
+from backend.app.services.camera import (
+    capture_camera_frame_bytes,
+    get_camera_port,
+    is_chamber_image_model,
+)
+from backend.app.services.camera_profiles import DEFAULT_PROFILE, get_camera_profile
+
+logger = logging.getLogger(__name__)
+
+
+# How long a live-stream buffered frame stays "fresh enough" to count as
+# proof that the camera works. Tuned conservatively — if the active
+# stream hasn't produced a frame in this window, run the real test
+# instead of trusting a possibly-stale buffer.
+_LIVE_FRAME_FRESHNESS_SECONDS = 10.0
+
+
+@dataclass
+class CameraDiagnoseStage:
+    """One step of the diagnostic. Status drives the green/red icon
+    the frontend renders next to the stage name."""
+
+    name: str  # "tcp_reachable" | "first_frame" | "live_stream_active"
+    status: str  # "ok" | "failed" | "skipped"
+    duration_ms: int = 0
+    # Optional machine-readable code for failures so the frontend can
+    # render a stage-specific hint without parsing free-text errors.
+    code: str | None = None
+
+
+@dataclass
+class CameraDiagnoseResult:
+    printer_id: int
+    protocol: str  # "rtsp" | "chamber_image"
+    port: int
+    # Whether this model's camera path uses the default profile or has
+    # an override entry in ``camera_profiles._PROFILES``. Useful for
+    # triage: tells us instantly whether the user is on a tuned model.
+    profile: str
+    overall_status: str  # "ok" | "failed"
+    stages: list[CameraDiagnoseStage] = field(default_factory=list)
+    # i18n key. Frontend maps to a translated remediation hint.
+    summary_code: str = ""
+
+    def to_dict(self) -> dict:
+        return {
+            "printer_id": self.printer_id,
+            "protocol": self.protocol,
+            "port": self.port,
+            "profile": self.profile,
+            "overall_status": self.overall_status,
+            "stages": [
+                {"name": s.name, "status": s.status, "duration_ms": s.duration_ms, "code": s.code} for s in self.stages
+            ],
+            "summary_code": self.summary_code,
+        }
+
+
+def _profile_label(model: str | None) -> str:
+    """Return ``"default"`` or the resolved model name when this model
+    has an override entry in :data:`camera_profiles._PROFILES`."""
+    profile = get_camera_profile(model)
+    if profile is DEFAULT_PROFILE:
+        return "default"
+    # Normalise via the same alias map the lookup uses. If the model
+    # resolves to a profile but the lookup is by alias (e.g. N7 → P2S),
+    # report the canonical display name.
+    from backend.app.services.camera_profiles import _MODEL_ALIASES, _PROFILES
+
+    key = (model or "").upper().strip()
+    key = _MODEL_ALIASES.get(key, key)
+    return key if key in _PROFILES else "default"
+
+
+async def _check_tcp_reachable(ip_address: str, port: int, timeout: float) -> CameraDiagnoseStage:
+    """Stage 1 — open a TCP socket to the camera port."""
+    started = time.monotonic()
+    try:
+        _, writer = await asyncio.wait_for(
+            asyncio.open_connection(ip_address, port),
+            timeout=timeout,
+        )
+        try:
+            writer.close()
+            await writer.wait_closed()
+        except OSError:
+            pass
+        return CameraDiagnoseStage(
+            name="tcp_reachable",
+            status="ok",
+            duration_ms=int((time.monotonic() - started) * 1000),
+        )
+    except asyncio.TimeoutError:
+        return CameraDiagnoseStage(
+            name="tcp_reachable",
+            status="failed",
+            duration_ms=int((time.monotonic() - started) * 1000),
+            code="tcp_timeout",
+        )
+    except (ConnectionRefusedError, OSError) as exc:
+        # ConnectionRefusedError = printer up, camera port closed (likely
+        # LAN-only off or developer mode off). Other OSError = host
+        # unreachable. We keep these separate codes so the frontend can
+        # surface a precise remediation hint.
+        is_refused = isinstance(exc, ConnectionRefusedError)
+        return CameraDiagnoseStage(
+            name="tcp_reachable",
+            status="failed",
+            duration_ms=int((time.monotonic() - started) * 1000),
+            code="tcp_refused" if is_refused else "tcp_unreachable",
+        )
+
+
+async def _check_first_frame(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    timeout: int,
+) -> CameraDiagnoseStage:
+    """Stage 2 — capture one frame end-to-end. Combines auth + protocol
+    handshake + first keyframe; either it works or it doesn't."""
+    started = time.monotonic()
+    try:
+        jpeg = await capture_camera_frame_bytes(
+            ip_address=ip_address,
+            access_code=access_code,
+            model=model,
+            timeout=timeout,
+        )
+    except Exception as exc:  # noqa: BLE001 — see camera_profiles.py rationale
+        # capture_camera_frame_bytes can raise from many layers (ffmpeg
+        # spawn, TLS proxy startup, asyncio.open_connection). For the
+        # user-facing answer, any exception during the capture path is
+        # "first frame failed" — drilling down is for the support log.
+        logger.warning("Camera diagnose first-frame capture raised: %s", exc)
+        return CameraDiagnoseStage(
+            name="first_frame",
+            status="failed",
+            duration_ms=int((time.monotonic() - started) * 1000),
+            code="capture_exception",
+        )
+    if jpeg:
+        return CameraDiagnoseStage(
+            name="first_frame",
+            status="ok",
+            duration_ms=int((time.monotonic() - started) * 1000),
+        )
+    return CameraDiagnoseStage(
+        name="first_frame",
+        status="failed",
+        duration_ms=int((time.monotonic() - started) * 1000),
+        code="no_frame",
+    )
+
+
+def _summary_for_stages(stages: list[CameraDiagnoseStage]) -> str:
+    """Pick the remediation key from the first failing stage's ``code``,
+    or ``all_ok`` when every stage passed."""
+    for stage in stages:
+        if stage.status != "failed":
+            continue
+        if stage.code == "tcp_timeout":
+            return "printer_unreachable"
+        if stage.code == "tcp_refused":
+            return "camera_port_closed"
+        if stage.code == "tcp_unreachable":
+            return "printer_unreachable"
+        if stage.code in ("no_frame", "capture_exception"):
+            return "no_frame"
+        return "unknown_failure"
+    return "all_ok"
+
+
+async def diagnose_camera(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    printer_id: int,
+    *,
+    has_live_stream: bool = False,
+    live_frame_age_seconds: float | None = None,
+    tcp_timeout: float = 3.0,
+    capture_timeout: int = 15,
+) -> CameraDiagnoseResult:
+    """Run the camera diagnostic and return a structured result.
+
+    ``has_live_stream`` and ``live_frame_age_seconds`` are looked up
+    by the route handler from the active-stream registry (see the
+    docstring at the top of this file for why). When they indicate a
+    fresh frame is already buffered, the diagnostic short-circuits with
+    a ``live_stream_active`` stage and ``all_ok`` summary — real-world
+    proof of a working camera beats any synthetic test.
+    """
+    is_chamber = is_chamber_image_model(model)
+    protocol = "chamber_image" if is_chamber else "rtsp"
+    port = get_camera_port(model)
+
+    result = CameraDiagnoseResult(
+        printer_id=printer_id,
+        protocol=protocol,
+        port=port,
+        profile=_profile_label(model),
+        overall_status="ok",
+        stages=[],
+    )
+
+    # Shortcut: the camera is currently streaming with a fresh frame.
+    # Running the real diagnostic here would either kick the live
+    # viewer off (single-camera-connection printers) or block on the
+    # second-socket-refused timeout (#1348). Trust the live evidence.
+    if (
+        has_live_stream
+        and live_frame_age_seconds is not None
+        and 0 <= live_frame_age_seconds < _LIVE_FRAME_FRESHNESS_SECONDS
+    ):
+        result.stages.append(
+            CameraDiagnoseStage(
+                name="live_stream_active",
+                status="ok",
+                duration_ms=0,
+            )
+        )
+        result.summary_code = "live_stream_active_healthy"
+        return result
+
+    # Stage 1
+    tcp_stage = await _check_tcp_reachable(ip_address, port, tcp_timeout)
+    result.stages.append(tcp_stage)
+    if tcp_stage.status != "ok":
+        result.overall_status = "failed"
+        # Skip first_frame — without TCP there's no point spawning ffmpeg.
+        result.stages.append(CameraDiagnoseStage(name="first_frame", status="skipped", duration_ms=0))
+        result.summary_code = _summary_for_stages(result.stages)
+        return result
+
+    # Stage 2
+    frame_stage = await _check_first_frame(ip_address, access_code, model, capture_timeout)
+    result.stages.append(frame_stage)
+    if frame_stage.status != "ok":
+        result.overall_status = "failed"
+    result.summary_code = _summary_for_stages(result.stages)
+    return result

+ 50 - 0
backend/tests/integration/test_camera_api.py

@@ -190,6 +190,56 @@ class TestCameraAPI:
         result = response.json()
         result = response.json()
         assert result["success"] is False
         assert result["success"] is False
 
 
+    # ========================================================================
+    # Camera Diagnose Endpoint (#1395 follow-up)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_diagnose_printer_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/camera/diagnose")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_diagnose_returns_structured_result(self, async_client: AsyncClient, printer_factory):
+        """Endpoint returns the per-stage shape the frontend modal renders."""
+        from backend.app.services.camera_diagnose import (
+            CameraDiagnoseResult,
+            CameraDiagnoseStage,
+        )
+
+        printer = await printer_factory()
+
+        fake = CameraDiagnoseResult(
+            printer_id=printer.id,
+            protocol="rtsp",
+            port=322,
+            profile="P2S",
+            overall_status="failed",
+            stages=[
+                CameraDiagnoseStage(name="tcp_reachable", status="ok", duration_ms=12),
+                CameraDiagnoseStage(name="first_frame", status="failed", duration_ms=15123, code="no_frame"),
+            ],
+            summary_code="no_frame",
+        )
+        with patch(
+            "backend.app.services.camera_diagnose.diagnose_camera",
+            new_callable=AsyncMock,
+            return_value=fake,
+        ):
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/diagnose")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["printer_id"] == printer.id
+        assert body["protocol"] == "rtsp"
+        assert body["profile"] == "P2S"
+        assert body["overall_status"] == "failed"
+        assert body["summary_code"] == "no_frame"
+        assert [s["name"] for s in body["stages"]] == ["tcp_reachable", "first_frame"]
+        assert body["stages"][1]["code"] == "no_frame"
+
     # ========================================================================
     # ========================================================================
     # Camera Snapshot Endpoint
     # Camera Snapshot Endpoint
     # ========================================================================
     # ========================================================================

+ 281 - 0
backend/tests/unit/services/test_camera_diagnose.py

@@ -0,0 +1,281 @@
+"""Unit tests for the staged camera diagnostic.
+
+Covers the per-stage pass/fail contract that drives the frontend
+remediation hints. The live-stream shortcut and the failure-to-summary
+mapping are the load-bearing pieces — both are pinned with explicit
+tests so future profile/protocol changes don't silently turn
+"camera_port_closed" into "printer_unreachable".
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.camera_diagnose import (
+    _LIVE_FRAME_FRESHNESS_SECONDS,
+    diagnose_camera,
+)
+
+
+class TestLiveStreamShortcut:
+    """If a viewer is currently watching the camera with a fresh frame,
+    diagnose must NOT open a fresh socket — single-camera-connection
+    firmwares would kick the live viewer off. Trust the live evidence.
+    """
+
+    @pytest.mark.asyncio
+    async def test_skips_test_when_fresh_frame_in_active_stream(self):
+        result = await diagnose_camera(
+            ip_address="192.0.2.1",
+            access_code="x",
+            model="X1C",
+            printer_id=1,
+            has_live_stream=True,
+            live_frame_age_seconds=2.0,
+        )
+        assert result.overall_status == "ok"
+        assert result.summary_code == "live_stream_active_healthy"
+        assert len(result.stages) == 1
+        assert result.stages[0].name == "live_stream_active"
+        assert result.stages[0].status == "ok"
+
+    @pytest.mark.asyncio
+    async def test_runs_test_when_stale_frame_in_active_stream(self):
+        """An active stream with a stale buffered frame (e.g. mid-
+        reconnect) shouldn't short-circuit — the stream might be
+        wedged and the user needs the real test."""
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=TimeoutError,
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="X1C",
+                printer_id=1,
+                has_live_stream=True,
+                live_frame_age_seconds=_LIVE_FRAME_FRESHNESS_SECONDS + 5,
+            )
+        # No short-circuit — we ran the real check and it failed.
+        assert result.summary_code != "live_stream_active_healthy"
+        assert any(s.name == "tcp_reachable" for s in result.stages)
+
+
+class TestTcpStage:
+    """The first stage answers "can we even talk to the printer at all".
+    The three failure modes (timeout / refused / unreachable) map to
+    distinct user-facing remediation hints, so the codes must round-
+    trip correctly through ``_summary_for_stages``."""
+
+    @pytest.mark.asyncio
+    async def test_timeout_maps_to_printer_unreachable(self):
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=TimeoutError,
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.99",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.overall_status == "failed"
+        assert result.summary_code == "printer_unreachable"
+        first = result.stages[0]
+        assert first.name == "tcp_reachable"
+        assert first.code == "tcp_timeout"
+        # Second stage was skipped — no point spawning ffmpeg with no socket.
+        assert result.stages[1].name == "first_frame"
+        assert result.stages[1].status == "skipped"
+
+    @pytest.mark.asyncio
+    async def test_connection_refused_maps_to_camera_port_closed(self):
+        """ConnectionRefusedError = printer up, port closed. Common
+        cause: LAN-only mode off, or developer mode off. The user
+        sees a specific remediation hint, not the generic
+        'unreachable' message."""
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=ConnectionRefusedError(),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.summary_code == "camera_port_closed"
+        assert result.stages[0].code == "tcp_refused"
+
+    @pytest.mark.asyncio
+    async def test_oserror_maps_to_printer_unreachable(self):
+        """Generic OSError (no-route-to-host etc.) lumps under
+        'printer_unreachable' — same remediation as timeout."""
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=OSError("No route to host"),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.summary_code == "printer_unreachable"
+        assert result.stages[0].code == "tcp_unreachable"
+
+
+class TestFirstFrameStage:
+    """The second stage answers "is the camera actually producing
+    frames". If TCP passes but no frame comes back, the answer is the
+    same regardless of which sub-layer failed (auth, RTSP handshake,
+    keyframe probe): the user can't see the camera."""
+
+    @pytest.mark.asyncio
+    async def test_no_frame_maps_to_no_frame_summary(self):
+        async def _tcp_ok(*_a, **_kw):
+            writer = AsyncMock()
+            return AsyncMock(), writer
+
+        with (
+            patch(
+                "backend.app.services.camera_diagnose.asyncio.open_connection",
+                new=_tcp_ok,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_camera_frame_bytes",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.overall_status == "failed"
+        assert result.summary_code == "no_frame"
+        assert result.stages[0].status == "ok"
+        assert result.stages[1].name == "first_frame"
+        assert result.stages[1].code == "no_frame"
+
+    @pytest.mark.asyncio
+    async def test_capture_exception_maps_to_no_frame_summary(self):
+        """ffmpeg crash / TLS proxy startup failure / etc. — all the
+        sub-layer exceptions surface as 'no_frame' for the user, with
+        a distinct ``capture_exception`` code in the stage so the
+        support log retains the distinction."""
+
+        async def _tcp_ok(*_a, **_kw):
+            writer = AsyncMock()
+            return AsyncMock(), writer
+
+        with (
+            patch(
+                "backend.app.services.camera_diagnose.asyncio.open_connection",
+                new=_tcp_ok,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_camera_frame_bytes",
+                new_callable=AsyncMock,
+                side_effect=RuntimeError("ffmpeg died"),
+            ),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.summary_code == "no_frame"
+        assert result.stages[1].code == "capture_exception"
+
+    @pytest.mark.asyncio
+    async def test_full_success_path(self):
+        async def _tcp_ok(*_a, **_kw):
+            writer = AsyncMock()
+            return AsyncMock(), writer
+
+        with (
+            patch(
+                "backend.app.services.camera_diagnose.asyncio.open_connection",
+                new=_tcp_ok,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_camera_frame_bytes",
+                new_callable=AsyncMock,
+                return_value=b"\xff\xd8\xff\xd9",  # tiny valid-looking JPEG
+            ),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.overall_status == "ok"
+        assert result.summary_code == "all_ok"
+        assert all(s.status == "ok" for s in result.stages)
+
+
+class TestResultMetadata:
+    """Surface fields the support triage relies on — protocol, port,
+    profile name. The frontend renders these so we can ask the user
+    'is your profile 'P2S' or 'default'?' over a screenshot rather
+    than asking for the support bundle."""
+
+    @pytest.mark.asyncio
+    async def test_p2s_reports_p2s_profile_and_rtsp_protocol(self):
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=TimeoutError,
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.protocol == "rtsp"
+        assert result.profile == "P2S"
+        assert result.port == 322
+
+    @pytest.mark.asyncio
+    async def test_a1_reports_default_profile_and_chamber_protocol(self):
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=TimeoutError,
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="A1",
+                printer_id=1,
+            )
+        assert result.protocol == "chamber_image"
+        assert result.profile == "default"
+        assert result.port == 6000
+
+    @pytest.mark.asyncio
+    async def test_x1c_reports_default_profile_and_rtsp(self):
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=TimeoutError,
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="X1C",
+                printer_id=1,
+            )
+        assert result.protocol == "rtsp"
+        assert result.profile == "default"
+        assert result.port == 322

+ 1 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -164,6 +164,7 @@ const DE_COGNATES = [
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'China', 'Proxy', 'Start',
   'China', 'Proxy', 'Start',
+  'Diagnose',  // DE: same spelling/meaning as EN — camera diagnostic button label
 ];
 ];
 
 
 // French cognates — many UI labels overlap with English exactly.
 // French cognates — many UI labels overlap with English exactly.

+ 123 - 0
frontend/src/__tests__/components/CameraDiagnoseModal.test.tsx

@@ -0,0 +1,123 @@
+/**
+ * Tests for the camera diagnostic modal (#1395 follow-up).
+ *
+ * Covers the three observable behaviours that matter for user-facing
+ * triage: the modal kicks off the diagnostic on mount, renders per-
+ * stage results when the API replies, and maps the summary code to a
+ * translated remediation hint. Each test mocks the API client so the
+ * suite never actually opens a socket.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { I18nextProvider } from 'react-i18next';
+import i18n from '../../i18n';
+import { CameraDiagnoseModal } from '../../components/CameraDiagnoseModal';
+import { api, type CameraDiagnoseResult } from '../../api/client';
+
+function renderModal() {
+  const queryClient = new QueryClient({
+    defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+  });
+  const onClose = vi.fn();
+  render(
+    <QueryClientProvider client={queryClient}>
+      <I18nextProvider i18n={i18n}>
+        <CameraDiagnoseModal printerId={1} printerName="Test P2S" onClose={onClose} />
+      </I18nextProvider>
+    </QueryClientProvider>,
+  );
+  return { onClose };
+}
+
+describe('CameraDiagnoseModal', () => {
+  it('runs the diagnostic on mount and shows per-stage results', async () => {
+    const okResult: CameraDiagnoseResult = {
+      printer_id: 1,
+      protocol: 'rtsp',
+      port: 322,
+      profile: 'P2S',
+      overall_status: 'ok',
+      stages: [
+        { name: 'tcp_reachable', status: 'ok', duration_ms: 12, code: null },
+        { name: 'first_frame', status: 'ok', duration_ms: 1230, code: null },
+      ],
+      summary_code: 'all_ok',
+    };
+    const spy = vi.spyOn(api, 'diagnoseCamera').mockResolvedValue(okResult);
+
+    renderModal();
+
+    // Mounted → API called once
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+
+    // Stage names render via i18n
+    expect(await screen.findByText(/Network reachability/i)).toBeInTheDocument();
+    expect(screen.getByText(/Frame capture/i)).toBeInTheDocument();
+
+    // Per-stage duration is shown for support triage
+    expect(screen.getByText(/12 ms/i)).toBeInTheDocument();
+    expect(screen.getByText(/1230 ms/i)).toBeInTheDocument();
+
+    // Summary remediation message is rendered translated
+    expect(screen.getByText(/Camera is working/i)).toBeInTheDocument();
+
+    // Metadata for support triage
+    expect(screen.getByText('rtsp')).toBeInTheDocument();
+    expect(screen.getByText('322')).toBeInTheDocument();
+    expect(screen.getByText('P2S')).toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('maps a failure summary code to a translated remediation hint', async () => {
+    const failedResult: CameraDiagnoseResult = {
+      printer_id: 1,
+      protocol: 'rtsp',
+      port: 322,
+      profile: 'P2S',
+      overall_status: 'failed',
+      stages: [
+        { name: 'tcp_reachable', status: 'failed', duration_ms: 3001, code: 'tcp_timeout' },
+        { name: 'first_frame', status: 'skipped', duration_ms: 0, code: null },
+      ],
+      summary_code: 'printer_unreachable',
+    };
+    const spy = vi.spyOn(api, 'diagnoseCamera').mockResolvedValue(failedResult);
+
+    renderModal();
+
+    // The remediation hint for printer_unreachable mentions IP / network /
+    // power — the user-facing fix-it instructions, not the raw summary code.
+    expect(await screen.findByText(/IP address/i)).toBeInTheDocument();
+
+    // The machine-readable stage code is also surfaced (small font) for
+    // support triage so users can paste it into a ticket.
+    expect(screen.getByText('tcp_timeout')).toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('re-runs the diagnostic when the user clicks Run again', async () => {
+    const okResult: CameraDiagnoseResult = {
+      printer_id: 1,
+      protocol: 'rtsp',
+      port: 322,
+      profile: 'P2S',
+      overall_status: 'ok',
+      stages: [{ name: 'tcp_reachable', status: 'ok', duration_ms: 12, code: null }],
+      summary_code: 'all_ok',
+    };
+    const spy = vi.spyOn(api, 'diagnoseCamera').mockResolvedValue(okResult);
+
+    renderModal();
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+
+    fireEvent.click(screen.getByText(/Run again/i));
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(2));
+
+    spy.mockRestore();
+  });
+});

+ 26 - 0
frontend/src/api/client.ts

@@ -159,6 +159,30 @@ async function request<T>(
   return await response.json();
   return await response.json();
 }
 }
 
 
+// Camera diagnostic result (#1395 follow-up). Returned by
+// POST /printers/{id}/camera/diagnose; the frontend modal renders one
+// row per stage and looks up the summary code in i18n for the user-
+// facing remediation hint.
+export interface CameraDiagnoseStage {
+  name: 'tcp_reachable' | 'first_frame' | 'live_stream_active';
+  status: 'ok' | 'failed' | 'skipped';
+  duration_ms: number;
+  code: string | null;
+}
+
+export interface CameraDiagnoseResult {
+  printer_id: number;
+  protocol: 'rtsp' | 'chamber_image';
+  port: number;
+  // 'default' = historical X1/H2 tuning. Anything else = this model has
+  // an override entry in backend/app/services/camera_profiles.py.
+  profile: string;
+  overall_status: 'ok' | 'failed';
+  stages: CameraDiagnoseStage[];
+  // i18n key under `camera.diagnose.summary.*`.
+  summary_code: string;
+}
+
 // Long-lived camera-stream tokens (#1108). The `token` field is populated
 // Long-lived camera-stream tokens (#1108). The `token` field is populated
 // only on the create response — listing endpoints set it to null because
 // only on the create response — listing endpoints set it to null because
 // the plaintext value is shown to the user exactly once.
 // the plaintext value is shown to the user exactly once.
@@ -4976,6 +5000,8 @@ export const api = {
     request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
     request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
   getCameraStatus: (printerId: number) =>
   getCameraStatus: (printerId: number) =>
     request<{ active: boolean; stalled: boolean }>(`/printers/${printerId}/camera/status`),
     request<{ active: boolean; stalled: boolean }>(`/printers/${printerId}/camera/status`),
+  diagnoseCamera: (printerId: number) =>
+    request<CameraDiagnoseResult>(`/printers/${printerId}/camera/diagnose`, { method: 'POST' }),
 
 
   // Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
   // Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
   checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
   checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {

+ 158 - 0
frontend/src/components/CameraDiagnoseModal.tsx

@@ -0,0 +1,158 @@
+import { useEffect } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { X, Stethoscope, CheckCircle2, XCircle, MinusCircle, Loader2 } from 'lucide-react';
+import { api, type CameraDiagnoseResult, type CameraDiagnoseStage } from '../api/client';
+
+interface CameraDiagnoseModalProps {
+  printerId: number;
+  printerName: string | null;
+  onClose: () => void;
+}
+
+function StageIcon({ status }: { status: CameraDiagnoseStage['status'] }) {
+  if (status === 'ok') return <CheckCircle2 className="w-5 h-5 text-bambu-green flex-shrink-0" />;
+  if (status === 'failed') return <XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />;
+  return <MinusCircle className="w-5 h-5 text-bambu-gray flex-shrink-0" />;
+}
+
+export function CameraDiagnoseModal({ printerId, printerName, onClose }: CameraDiagnoseModalProps) {
+  const { t } = useTranslation();
+
+  // Kick the diagnostic off as soon as the modal mounts. There's no
+  // "Start" button — opening the modal IS the test. The mutation
+  // shape is right here: we want a one-shot POST with isPending /
+  // data / error, not a cached query.
+  const diagnose = useMutation({
+    mutationFn: () => api.diagnoseCamera(printerId),
+  });
+
+  useEffect(() => {
+    diagnose.mutate();
+    // Intentionally only on mount — re-running needs the user to click "Retry".
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const result = diagnose.data as CameraDiagnoseResult | undefined;
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2 min-w-0">
+            <Stethoscope className="w-5 h-5 text-bambu-green flex-shrink-0" />
+            <h2 className="text-lg font-semibold text-white truncate">
+              {t('camera.diagnose.modalTitle', { name: printerName || '' })}
+            </h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+            title={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <div className="p-6 space-y-4">
+          {diagnose.isPending && (
+            <div className="flex items-center gap-2 text-bambu-gray">
+              <Loader2 className="w-4 h-4 animate-spin" />
+              <span>{t('camera.diagnose.running')}</span>
+            </div>
+          )}
+
+          {diagnose.isError && (
+            <div className="rounded-lg bg-red-500/10 border border-red-500/30 px-4 py-3 text-sm text-red-300">
+              {t('camera.diagnose.runFailed', { error: (diagnose.error as Error).message })}
+            </div>
+          )}
+
+          {result && (
+            <>
+              {/* Per-stage results */}
+              <ol className="space-y-2">
+                {result.stages.map((stage) => (
+                  <li
+                    key={stage.name}
+                    className="flex items-center gap-3 bg-bambu-dark rounded-lg px-4 py-2.5"
+                  >
+                    <StageIcon status={stage.status} />
+                    <div className="flex-1 min-w-0">
+                      <div className="text-sm text-white">
+                        {t(`camera.diagnose.stage.${stage.name}`)}
+                      </div>
+                      {stage.code && (
+                        <div className="text-xs text-bambu-gray font-mono">{stage.code}</div>
+                      )}
+                    </div>
+                    <div className="text-xs text-bambu-gray tabular-nums flex-shrink-0">
+                      {stage.duration_ms} ms
+                    </div>
+                  </li>
+                ))}
+              </ol>
+
+              {/* Summary + remediation */}
+              <div
+                className={
+                  result.overall_status === 'ok'
+                    ? 'rounded-lg bg-bambu-green/10 border border-bambu-green/30 px-4 py-3 text-sm text-bambu-green'
+                    : 'rounded-lg bg-red-500/10 border border-red-500/30 px-4 py-3 text-sm text-red-300'
+                }
+              >
+                {t(`camera.diagnose.summary.${result.summary_code}`, {
+                  defaultValue: t('camera.diagnose.summary.unknown_failure'),
+                })}
+              </div>
+
+              {/* Metadata for support triage */}
+              <div className="text-xs text-bambu-gray space-y-0.5">
+                <div>
+                  <span className="text-bambu-gray/60">{t('camera.diagnose.meta.protocol')}: </span>
+                  <span className="font-mono">{result.protocol}</span>
+                  {' • '}
+                  <span className="text-bambu-gray/60">{t('camera.diagnose.meta.port')}: </span>
+                  <span className="font-mono">{result.port}</span>
+                  {' • '}
+                  <span className="text-bambu-gray/60">{t('camera.diagnose.meta.profile')}: </span>
+                  <span className="font-mono">{result.profile}</span>
+                </div>
+              </div>
+            </>
+          )}
+        </div>
+
+        <div className="px-6 py-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
+          <button
+            onClick={() => diagnose.mutate()}
+            disabled={diagnose.isPending}
+            className="px-4 py-2 bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
+          >
+            {t('camera.diagnose.retry')}
+          </button>
+          <button
+            onClick={onClose}
+            className="px-4 py-2 bg-bambu-green hover:bg-bambu-green/90 text-white text-sm rounded-lg transition-colors"
+          >
+            {t('common.close')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 36 - 8
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -1,12 +1,13 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';
+import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize, Stethoscope } from 'lucide-react';
 import { api, getAuthToken, withStreamToken } from '../api/client';
 import { api, getAuthToken, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { ChamberLight } from './icons/ChamberLight';
 import { ChamberLight } from './icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
 import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
+import { CameraDiagnoseModal } from './CameraDiagnoseModal';
 
 
 interface EmbeddedCameraViewerProps {
 interface EmbeddedCameraViewerProps {
   printerId: number;
   printerId: number;
@@ -98,6 +99,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
 
 
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
+  // Modal opens from the error-state "Diagnose" button when the user
+  // hits "Camera unavailable" — saves a round trip through "open a
+  // ticket → wait for response → check setting". See #1395 follow-up.
+  const [showDiagnoseModal, setShowDiagnoseModal] = useState(false);
 
 
   // Fetch printer info
   // Fetch printer info
   const { data: printer } = useQuery({
   const { data: printer } = useQuery({
@@ -605,6 +610,13 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           >
           >
             <RefreshCw className={`w-3.5 h-3.5 text-bambu-gray ${streamLoading ? 'animate-spin' : ''}`} />
             <RefreshCw className={`w-3.5 h-3.5 text-bambu-gray ${streamLoading ? 'animate-spin' : ''}`} />
           </button>
           </button>
+          <button
+            onClick={() => setShowDiagnoseModal(true)}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded"
+            title={t('camera.diagnose.button')}
+          >
+            <Stethoscope className="w-3.5 h-3.5 text-bambu-gray" />
+          </button>
           <button
           <button
             onClick={toggleFullscreen}
             onClick={toggleFullscreen}
             className="p-1 hover:bg-bambu-dark-tertiary rounded"
             className="p-1 hover:bg-bambu-dark-tertiary rounded"
@@ -669,13 +681,21 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
             <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
             <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
               <div className="text-center p-2">
               <div className="text-center p-2">
                 <AlertTriangle className="w-6 h-6 text-orange-400 mx-auto mb-2" />
                 <AlertTriangle className="w-6 h-6 text-orange-400 mx-auto mb-2" />
-                <p className="text-xs text-bambu-gray mb-2">Camera unavailable</p>
-                <button
-                  onClick={refresh}
-                  className="px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80"
-                >
-                  Retry
-                </button>
+                <p className="text-xs text-bambu-gray mb-2">{t('camera.unavailable')}</p>
+                <div className="flex gap-2 justify-center">
+                  <button
+                    onClick={refresh}
+                    className="px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80"
+                  >
+                    {t('camera.retry')}
+                  </button>
+                  <button
+                    onClick={() => setShowDiagnoseModal(true)}
+                    className="px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white rounded transition-colors"
+                  >
+                    {t('camera.diagnose.button')}
+                  </button>
+                </div>
               </div>
               </div>
             </div>
             </div>
           )}
           )}
@@ -748,6 +768,14 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
         isOpen={showSkipObjectsModal}
         isOpen={showSkipObjectsModal}
         onClose={() => setShowSkipObjectsModal(false)}
         onClose={() => setShowSkipObjectsModal(false)}
       />
       />
+      {/* Camera diagnostic modal — opens from the error-state Diagnose button (#1395 follow-up) */}
+      {showDiagnoseModal && (
+        <CameraDiagnoseModal
+          printerId={printerId}
+          printerName={printer?.name || null}
+          onClose={() => setShowDiagnoseModal(false)}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 26 - 0
frontend/src/i18n/locales/de.ts

@@ -2638,6 +2638,32 @@ export default {
     startRecording: 'Aufnahme starten',
     startRecording: 'Aufnahme starten',
     stopRecording: 'Aufnahme stoppen',
     stopRecording: 'Aufnahme stoppen',
     chamberLight: 'Kammerbeleuchtung umschalten',
     chamberLight: 'Kammerbeleuchtung umschalten',
+    unavailable: 'Kamera nicht verfügbar',
+    diagnose: {
+      button: 'Diagnose',
+      modalTitle: 'Kamera-Diagnose',
+      running: 'Diagnose läuft...',
+      runFailed: 'Diagnose konnte nicht ausgeführt werden: {{error}}',
+      retry: 'Erneut ausführen',
+      stage: {
+        tcp_reachable: 'Netzwerk-Erreichbarkeit',
+        first_frame: 'Bilderfassung',
+        live_stream_active: 'Live-Stream aktiv',
+      },
+      summary: {
+        all_ok: 'Kamera funktioniert. Die Diagnose hat alle Phasen erfolgreich abgeschlossen.',
+        live_stream_active_healthy: 'Kamera streamt gerade mit aktuellen Bildern — kein Test nötig.',
+        printer_unreachable: 'Drucker ist nicht erreichbar. Prüfe IP-Adresse, Netzwerkverbindung und ob der Drucker eingeschaltet ist.',
+        camera_port_closed: 'Drucker ist erreichbar, aber der Kamera-Port ist geschlossen. Stelle sicher, dass LAN-Modus und Entwicklermodus in den Druckereinstellungen aktiviert sind.',
+        no_frame: 'Verbindung zur Kamera hergestellt, aber keine Bilder empfangen. Versuche es erneut oder prüfe, ob die Kamera in den Druckereinstellungen aktiviert ist.',
+        unknown_failure: 'Kamera-Diagnose aus unbekanntem Grund fehlgeschlagen. Prüfe das Support-Log für Details.',
+      },
+      meta: {
+        protocol: 'Protokoll',
+        port: 'Port',
+        profile: 'Profil',
+      },
+    },
   },
   },
 
 
   // Groups management
   // Groups management

+ 26 - 0
frontend/src/i18n/locales/en.ts

@@ -2641,6 +2641,32 @@ export default {
     startRecording: 'Start Recording',
     startRecording: 'Start Recording',
     stopRecording: 'Stop Recording',
     stopRecording: 'Stop Recording',
     chamberLight: 'Toggle chamber light',
     chamberLight: 'Toggle chamber light',
+    unavailable: 'Camera unavailable',
+    diagnose: {
+      button: 'Diagnose',
+      modalTitle: 'Camera diagnostic',
+      running: 'Running diagnostic...',
+      runFailed: 'Diagnostic could not run: {{error}}',
+      retry: 'Run again',
+      stage: {
+        tcp_reachable: 'Network reachability',
+        first_frame: 'Frame capture',
+        live_stream_active: 'Live stream active',
+      },
+      summary: {
+        all_ok: 'Camera is working. The diagnostic completed all stages successfully.',
+        live_stream_active_healthy: 'Camera is currently streaming with recent frames — no test needed.',
+        printer_unreachable: 'Printer is not reachable. Check the IP address, network connection, and that the printer is powered on.',
+        camera_port_closed: 'Printer is reachable but the camera port is closed. Make sure LAN-only mode and Developer Mode are enabled in the printer settings.',
+        no_frame: 'Connected to the camera but no frames were received. Try again, or check that the camera is enabled in the printer settings.',
+        unknown_failure: 'Camera diagnostic failed for an unknown reason. Check the support log for details.',
+      },
+      meta: {
+        protocol: 'Protocol',
+        port: 'Port',
+        profile: 'Profile',
+      },
+    },
   },
   },
 
 
   // Groups management
   // Groups management

+ 26 - 0
frontend/src/i18n/locales/fr.ts

@@ -2627,6 +2627,32 @@ export default {
     startRecording: 'Démarrer l\'enregistrement',
     startRecording: 'Démarrer l\'enregistrement',
     stopRecording: 'Arrêter l\'enregistrement',
     stopRecording: 'Arrêter l\'enregistrement',
     chamberLight: 'Basculer lumière chambre',
     chamberLight: 'Basculer lumière chambre',
+    unavailable: 'Caméra indisponible',
+    diagnose: {
+      button: 'Diagnostic',
+      modalTitle: 'Diagnostic de la caméra',
+      running: 'Diagnostic en cours...',
+      runFailed: 'Impossible d\'exécuter le diagnostic : {{error}}',
+      retry: 'Relancer',
+      stage: {
+        tcp_reachable: 'Accessibilité réseau',
+        first_frame: 'Capture d\'image',
+        live_stream_active: 'Flux en direct actif',
+      },
+      summary: {
+        all_ok: 'La caméra fonctionne. Toutes les étapes du diagnostic ont réussi.',
+        live_stream_active_healthy: 'La caméra diffuse actuellement avec des images récentes — aucun test nécessaire.',
+        printer_unreachable: 'L\'imprimante n\'est pas joignable. Vérifiez l\'adresse IP, la connexion réseau et que l\'imprimante est allumée.',
+        camera_port_closed: 'L\'imprimante répond mais le port de la caméra est fermé. Assurez-vous que le mode LAN et le mode développeur sont activés dans les paramètres de l\'imprimante.',
+        no_frame: 'Connexion à la caméra établie mais aucune image reçue. Réessayez ou vérifiez que la caméra est activée dans les paramètres de l\'imprimante.',
+        unknown_failure: 'Le diagnostic de la caméra a échoué pour une raison inconnue. Consultez le journal de support pour plus de détails.',
+      },
+      meta: {
+        protocol: 'Protocole',
+        port: 'Port',
+        profile: 'Profil',
+      },
+    },
   },
   },
 
 
   // Groups management
   // Groups management

+ 26 - 0
frontend/src/i18n/locales/it.ts

@@ -2626,6 +2626,32 @@ export default {
     startRecording: 'Avvia registrazione',
     startRecording: 'Avvia registrazione',
     stopRecording: 'Ferma registrazione',
     stopRecording: 'Ferma registrazione',
     chamberLight: 'Accendi/Spegni luce camera',
     chamberLight: 'Accendi/Spegni luce camera',
+    unavailable: 'Telecamera non disponibile',
+    diagnose: {
+      button: 'Diagnostica',
+      modalTitle: 'Diagnostica telecamera',
+      running: 'Diagnostica in corso...',
+      runFailed: 'Impossibile eseguire la diagnostica: {{error}}',
+      retry: 'Riesegui',
+      stage: {
+        tcp_reachable: 'Raggiungibilità di rete',
+        first_frame: 'Acquisizione frame',
+        live_stream_active: 'Stream live attivo',
+      },
+      summary: {
+        all_ok: 'La telecamera funziona. La diagnostica ha completato tutte le fasi con successo.',
+        live_stream_active_healthy: 'La telecamera sta trasmettendo con frame recenti — nessun test necessario.',
+        printer_unreachable: 'La stampante non è raggiungibile. Verifica l\'indirizzo IP, la connessione di rete e che la stampante sia accesa.',
+        camera_port_closed: 'La stampante risponde ma la porta della telecamera è chiusa. Assicurati che la modalità LAN e la modalità sviluppatore siano attive nelle impostazioni della stampante.',
+        no_frame: 'Connessione alla telecamera riuscita ma nessun frame ricevuto. Riprova o verifica che la telecamera sia abilitata nelle impostazioni della stampante.',
+        unknown_failure: 'Diagnostica della telecamera fallita per motivo sconosciuto. Controlla il log di supporto per i dettagli.',
+      },
+      meta: {
+        protocol: 'Protocollo',
+        port: 'Porta',
+        profile: 'Profilo',
+      },
+    },
   },
   },
 
 
   // Groups management
   // Groups management

+ 26 - 0
frontend/src/i18n/locales/ja.ts

@@ -2638,6 +2638,32 @@ export default {
     startRecording: '録画開始',
     startRecording: '録画開始',
     stopRecording: '録画停止',
     stopRecording: '録画停止',
     chamberLight: 'チャンバーライト切替',
     chamberLight: 'チャンバーライト切替',
+    unavailable: 'カメラは利用できません',
+    diagnose: {
+      button: '診断',
+      modalTitle: 'カメラ診断',
+      running: '診断を実行中...',
+      runFailed: '診断を実行できませんでした: {{error}}',
+      retry: '再実行',
+      stage: {
+        tcp_reachable: 'ネットワーク到達性',
+        first_frame: 'フレーム取得',
+        live_stream_active: 'ライブストリーム動作中',
+      },
+      summary: {
+        all_ok: 'カメラは正常に動作しています。診断のすべての段階が成功しました。',
+        live_stream_active_healthy: 'カメラは現在新しいフレームを配信しています — テストは不要です。',
+        printer_unreachable: 'プリンターに到達できません。IPアドレス、ネットワーク接続、プリンターの電源が入っていることを確認してください。',
+        camera_port_closed: 'プリンターには到達できますが、カメラポートが閉じています。プリンター設定でLANのみモードと開発者モードが有効になっていることを確認してください。',
+        no_frame: 'カメラに接続できましたがフレームを受信できませんでした。再試行するか、プリンター設定でカメラが有効になっていることを確認してください。',
+        unknown_failure: 'カメラ診断が不明な理由で失敗しました。詳細はサポートログを確認してください。',
+      },
+      meta: {
+        protocol: 'プロトコル',
+        port: 'ポート',
+        profile: 'プロファイル',
+      },
+    },
   },
   },
 
 
   // Groups management
   // Groups management

+ 26 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2626,6 +2626,32 @@ export default {
     startRecording: 'Iniciar gravação',
     startRecording: 'Iniciar gravação',
     stopRecording: 'Parar gravação',
     stopRecording: 'Parar gravação',
     chamberLight: 'Alternar luz da câmara',
     chamberLight: 'Alternar luz da câmara',
+    unavailable: 'Câmera indisponível',
+    diagnose: {
+      button: 'Diagnóstico',
+      modalTitle: 'Diagnóstico da câmera',
+      running: 'Executando diagnóstico...',
+      runFailed: 'Não foi possível executar o diagnóstico: {{error}}',
+      retry: 'Executar novamente',
+      stage: {
+        tcp_reachable: 'Conectividade de rede',
+        first_frame: 'Captura de frame',
+        live_stream_active: 'Transmissão ao vivo ativa',
+      },
+      summary: {
+        all_ok: 'A câmera está funcionando. O diagnóstico concluiu todas as etapas com sucesso.',
+        live_stream_active_healthy: 'A câmera está transmitindo com frames recentes — nenhum teste necessário.',
+        printer_unreachable: 'Impressora inacessível. Verifique o endereço IP, a conexão de rede e se a impressora está ligada.',
+        camera_port_closed: 'A impressora responde mas a porta da câmera está fechada. Verifique se o modo LAN e o modo desenvolvedor estão ativados nas configurações da impressora.',
+        no_frame: 'Conexão com a câmera estabelecida mas nenhum frame recebido. Tente novamente ou verifique se a câmera está habilitada nas configurações da impressora.',
+        unknown_failure: 'Diagnóstico da câmera falhou por motivo desconhecido. Consulte o log de suporte para mais detalhes.',
+      },
+      meta: {
+        protocol: 'Protocolo',
+        port: 'Porta',
+        profile: 'Perfil',
+      },
+    },
   },
   },
 
 
   // Groups management
   // Groups management

+ 26 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2626,6 +2626,32 @@ export default {
     startRecording: '开始录制',
     startRecording: '开始录制',
     stopRecording: '停止录制',
     stopRecording: '停止录制',
     chamberLight: '切换腔室灯',
     chamberLight: '切换腔室灯',
+    unavailable: '摄像头不可用',
+    diagnose: {
+      button: '诊断',
+      modalTitle: '摄像头诊断',
+      running: '正在运行诊断...',
+      runFailed: '无法运行诊断: {{error}}',
+      retry: '重新运行',
+      stage: {
+        tcp_reachable: '网络可达性',
+        first_frame: '画面捕获',
+        live_stream_active: '直播流正在进行',
+      },
+      summary: {
+        all_ok: '摄像头工作正常。诊断已成功完成所有阶段。',
+        live_stream_active_healthy: '摄像头正在传输最新画面 — 无需测试。',
+        printer_unreachable: '无法访问打印机。请检查 IP 地址、网络连接以及打印机是否已开机。',
+        camera_port_closed: '打印机可访问,但摄像头端口已关闭。请确保打印机设置中已启用仅 LAN 模式和开发者模式。',
+        no_frame: '已连接到摄像头,但未收到画面。请重试或检查打印机设置中摄像头是否已启用。',
+        unknown_failure: '摄像头诊断因未知原因失败。请查看支持日志了解详情。',
+      },
+      meta: {
+        protocol: '协议',
+        port: '端口',
+        profile: '配置',
+      },
+    },
   },
   },
 
 
   // Groups management
   // Groups management

+ 26 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2626,6 +2626,32 @@ export default {
     startRecording: '開始錄製',
     startRecording: '開始錄製',
     stopRecording: '停止錄製',
     stopRecording: '停止錄製',
     chamberLight: '切換腔室燈',
     chamberLight: '切換腔室燈',
+    unavailable: '攝影機無法使用',
+    diagnose: {
+      button: '診斷',
+      modalTitle: '攝影機診斷',
+      running: '正在執行診斷...',
+      runFailed: '無法執行診斷: {{error}}',
+      retry: '重新執行',
+      stage: {
+        tcp_reachable: '網路可達性',
+        first_frame: '畫面擷取',
+        live_stream_active: '直播串流進行中',
+      },
+      summary: {
+        all_ok: '攝影機運作正常。診斷已成功完成所有階段。',
+        live_stream_active_healthy: '攝影機正在傳輸最新畫面 — 無需測試。',
+        printer_unreachable: '無法存取印表機。請檢查 IP 位址、網路連線以及印表機是否已開機。',
+        camera_port_closed: '印表機可存取,但攝影機連接埠已關閉。請確認印表機設定中已啟用僅 LAN 模式與開發者模式。',
+        no_frame: '已連線到攝影機,但未收到畫面。請重試或檢查印表機設定中攝影機是否已啟用。',
+        unknown_failure: '攝影機診斷因不明原因失敗。請查看支援日誌了解詳情。',
+      },
+      meta: {
+        protocol: '協定',
+        port: '連接埠',
+        profile: '設定檔',
+      },
+    },
   },
   },
 
 
   // Groups management
   // Groups management

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-C8m37mQ7.js


+ 1 - 1
static/index.html

@@ -26,7 +26,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-CBOvp83T.js"></script>
+    <script type="module" crossorigin src="/assets/index-C8m37mQ7.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
     <link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов