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.
This commit is contained in:
maziggy
2026-05-18 10:52:02 +02:00
parent 67cb5275d0
commit 134847a3bd
20 changed files with 1616 additions and 415 deletions
+3
View File
@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
### Added
- **Camera: in-app diagnostic for "Connection lost" (#1395 follow-up)** — Second step of the camera architecture overhaul. When the camera viewer hits its error state, a new **Diagnose** button next to **Retry** 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 → wait days → ask for the support bundle → finally figure out it was their reverse proxy / LAN-only toggle / wrong access code" loop at the user's screen. **Backend** ships `backend/app/services/camera_diagnose.py` (orchestrator) and a new `POST /printers/{id}/camera/diagnose` route in `camera.py`. Stages: (1) `tcp_reachable` — opens a TCP socket to the camera port (322 RTSPS / 6000 chamber image) with a 3-second timeout; distinguishes timeout (`tcp_timeout` → "printer not reachable, check IP/network/power") from refused (`tcp_refused` → "camera port closed, check LAN-only and developer mode") from host-unreachable (`tcp_unreachable` → "printer not reachable"). (2) `first_frame` — captures one JPEG end-to-end via the existing `capture_camera_frame_bytes` pipeline (15-second timeout, same code that powers `/camera/snapshot`); auth, RTSP handshake, and first keyframe collapse into one stage because the user-facing answer is the same regardless of which sub-layer failed. **Live-stream shortcut**: when a viewer is currently watching the printer's camera AND the buffered last-frame timestamp is fresher than 10 seconds, 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 includes structured metadata for support triage: `protocol` (rtsp / chamber_image), `port`, `profile` (`default` or the model name with an override — currently only `P2S`), per-stage duration in ms, and the machine-readable summary code. **Frontend** adds `CameraDiagnoseModal.tsx` that fires the API call on mount, renders one row per stage with green-check / red-X / grey-skipped icons, and shows the summary remediation message in a bordered banner styled by overall status. The metadata line at the bottom (protocol / port / profile) lets support triage ask "what does your modal say?" instead of "send the support bundle". A **Run again** button re-runs the diagnostic without dismissing the modal. **EmbeddedCameraViewer** error state grows the Diagnose button (kept "Retry" as the primary action; Diagnose is the escape hatch for users who can't see what's wrong). A small stethoscope icon also lives in the viewer's always-visible control bar between **Refresh** and **Fullscreen**, so 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 properly translated alongside the new keys. **i18n**: 16 new keys (`unavailable`, `retry`, plus `diagnose.{button,modalTitle,running,runFailed,retry,stage.*,summary.*,meta.*}`) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). German "Diagnose" is a real cognate — added to `IDENTICAL_TO_EN_ALLOWED.de` rather than translated to a synthetic. Parity check holds at 4849 leaves per locale. **Tests**: 11 backend unit tests in `test_camera_diagnose.py` cover the live-stream shortcut (skip when fresh, run when stale), the three TCP failure modes (timeout / refused / OSError) → distinct summary codes, the first-frame stage (no-frame and capture-exception cases), the all-OK path, and the result metadata (P2S → P2S profile / rtsp / 322; A1 → default / chamber_image / 6000; X1C → default / rtsp / 322). 1 backend integration test pins the route's response shape end-to-end. 3 frontend tests in `CameraDiagnoseModal.test.tsx` (mounted → API call, failure → translated remediation, Run again → re-call). 5021 backend tests + 1905 frontend tests green; ruff clean; build clean; i18n parity clean.
### Fixed
- **P2S camera: relaxed ffmpeg probe settings so the RTSP stream actually locks (#1395 follow-up, reported by @Tschipel)** — Reporter on a P2S running firmware 01.02.00.00 saw the camera connect for a few seconds and then time out, repeating. P1S on the same install worked fine because P1S uses the chamber-image protocol (port 6000), not RTSP — different code path. The P2S RTSP path was running ffmpeg with `-probesize 32 -analyzeduration 0`, tuned for X1/H2 fast startup. The P2S's slower keyframe pacing means ffmpeg can't lock onto the stream within 32 bytes; its own stderr literally says "Stream #0: not enough frames to estimate rate; consider increasing probesize". After ~2 s ffmpeg gives up, Bambuddy reconnects, the cycle repeats. The naïve "just bump probesize" patch would regress every other RTSP-capable printer, so the fix is also the first step of the camera architecture overhaul: per-model tuning lives in a new `backend/app/services/camera_profiles.py` registry instead of hard-coded module constants. `CameraProfile` dataclass holds the previously-global knobs (`probesize`, `analyzeduration`, `rtsp_reconnect_max`, `rtsp_reconnect_delay`, plus an `extra_ffmpeg_input_args` hook for future per-model flags); `get_camera_profile(model)` returns the model's profile or the default. The default profile preserves the historical X1/H2 fast-startup values verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no behaviour change. P2S gets the only override today: `probesize=1_000_000`, `analyzeduration=500_000` — enough room for the slow keyframe without adding multi-second startup latency. Internal SSDP codes (e.g. `N7` → P2S) resolve via an alias map so the camera path works during the early-connect window before the display name is settled. The two `_RTSP_MAX_RECONNECTS` / `_RTSP_RECONNECT_DELAY` module constants are gone in favour of `profile.rtsp_reconnect_max` / `profile.rtsp_reconnect_delay`; same defaults, but now overridable per model. Pattern is intentionally extensible — adding the next quirky model is a config entry in `_PROFILES`, not another global constant. **Tests:** 9 new in `test_camera_profiles.py` cover unknown model → default, `None`/empty → default, default preserves historical values, P2S has relaxed probe, P2S internal code (`N7`) resolves to P2S profile, lookup is case-insensitive, every other RTSP model still uses the default (so the next refactor regression is caught at unit-test time), profile is frozen (immutable). 58 existing camera-related tests still green; 5008 backend tests total green; ruff clean.
+35
View File
@@ -927,6 +927,41 @@ async def test_camera(
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")
async def camera_status(
printer_id: int,
+288
View File
@@ -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
@@ -190,6 +190,56 @@ class TestCameraAPI:
result = response.json()
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
# ========================================================================
@@ -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
View File
@@ -164,6 +164,7 @@ const DE_COGNATES = [
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'China', 'Proxy', 'Start',
'Diagnose', // DE: same spelling/meaning as EN — camera diagnostic button label
];
// French cognates — many UI labels overlap with English exactly.
@@ -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
View File
@@ -159,6 +159,30 @@ async function request<T>(
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
// only on the create response — listing endpoints set it to null because
// 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`),
getCameraStatus: (printerId: number) =>
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)
checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
@@ -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>
);
}
@@ -1,12 +1,13 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
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 { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { ChamberLight } from './icons/ChamberLight';
import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
import { CameraDiagnoseModal } from './CameraDiagnoseModal';
interface EmbeddedCameraViewerProps {
printerId: number;
@@ -98,6 +99,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
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
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' : ''}`} />
</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
onClick={toggleFullscreen}
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="text-center p-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>
)}
@@ -748,6 +768,14 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
isOpen={showSkipObjectsModal}
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>
);
}
+26
View File
@@ -2638,6 +2638,32 @@ export default {
startRecording: 'Aufnahme starten',
stopRecording: 'Aufnahme stoppen',
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
+26
View File
@@ -2641,6 +2641,32 @@ export default {
startRecording: 'Start Recording',
stopRecording: 'Stop Recording',
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
+26
View File
@@ -2627,6 +2627,32 @@ export default {
startRecording: 'Démarrer l\'enregistrement',
stopRecording: 'Arrêter l\'enregistrement',
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
+26
View File
@@ -2626,6 +2626,32 @@ export default {
startRecording: 'Avvia registrazione',
stopRecording: 'Ferma registrazione',
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
+26
View File
@@ -2638,6 +2638,32 @@ export default {
startRecording: '録画開始',
stopRecording: '録画停止',
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
+26
View File
@@ -2626,6 +2626,32 @@ export default {
startRecording: 'Iniciar gravação',
stopRecording: 'Parar gravação',
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
+26
View File
@@ -2626,6 +2626,32 @@ export default {
startRecording: '开始录制',
stopRecording: '停止录制',
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
+26
View File
@@ -2626,6 +2626,32 @@ export default {
startRecording: '開始錄製',
stopRecording: '停止錄製',
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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-CBOvp83T.js"></script>
<script type="module" crossorigin src="/assets/index-C8m37mQ7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
</head>
<body>