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

Do not report a printer as Safe when nothing is checking it (issue #2952)

    The printer card's AI badge collapsed every class that was not Warning or
    Failure into green Safe, and the service reported `safe` whenever it had no
    verdict. The state entry is created when a monitored print is first seen --
    before the first snapshot, let alone the first inference -- so a rejected ML
    API token, an unreachable ML API, a failed capture and an unset External URL
    all rendered as a healthy watched print: green Safe at score 0.000.

    For a safety feature that is the worst failure mode available: it asserts the
    print is being watched exactly when it is not. The reporter read that badge and
    concluded the loop had never started. It had been calling the ML API every ten
    seconds and being turned away with a 401 -- invisible because Obico's auth
    layer rejects a bad token before its request log sees it, and because
    successful checks log nothing there either.

    Add two honest states. Not checking (amber) when the last poll produced no
    result, carrying the reason; Starting while a monitored print waits for its
    first result. Score and frame count are withheld while not checking, since
    0.000 beside "Not checking" reads as a measurement rather than its absence.
    The reason is per printer, so a card names its own problem rather than
    whichever printer failed most recently, and stays behind settings:read because
    it can quote configured URLs -- the badge state does not, because whether a
    print is watched is not configuration. An unrecognised class now falls back to
    Starting, not Safe.

    Test Connection saves the form before probing, so a green result describes the
    configuration the loop actually runs with rather than what is typed in the
    boxes.
maziggy 1 неделя назад
Родитель
Сommit
f6e8767aeb

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


+ 7 - 1
backend/app/api/routes/obico.py

@@ -53,11 +53,17 @@ async def get_printer_status(
     # Error strings can embed configured URLs (ML API base, external URL), so
     # they stay behind settings:read like the rest of the configuration.
     can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value)
+    per_printer = obico_detection_service.get_per_printer()
+    if not can_see_error:
+        # The "error" *class* is not configuration — a printers:read user still
+        # needs to know their print is not being watched. Only the reason, which
+        # can name a URL, is withheld.
+        per_printer = {pid: {**entry, "error": None} for pid, entry in per_printer.items()}
     return {
         "enabled": settings["enabled"],
         # None = all printers are monitored
         "monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
-        "per_printer": obico_detection_service.get_per_printer(),
+        "per_printer": per_printer,
         "last_error": obico_detection_service._last_error if can_see_error else None,
     }
 

+ 63 - 16
backend/app/services/obico_detection.py

@@ -96,8 +96,14 @@ class ObicoDetectionService:
         self._states: dict[int, PrintState] = {}
         # printer_id -> task_name active when state was created (used to detect new prints)
         self._state_keys: dict[int, str] = {}
-        # printer_id -> last classification ("safe"/"warning"/"failure")
+        # printer_id -> last classification ("safe"/"warning"/"failure").
+        # Only written after an inference actually came back, so a missing entry
+        # means "we have no verdict", which is not the same as "safe" (#2952).
         self._last_class: dict[int, str] = {}
+        # printer_id -> why the most recent poll produced no verdict, or absent
+        # when the last poll succeeded. Per-printer rather than global so a card
+        # can say what went wrong for *that* printer.
+        self._errors: dict[int, str] = {}
         # printer_id -> whether an action has already been fired for the current print
         self._action_fired: dict[int, bool] = {}
         # Global detection event log (most-recent-first)
@@ -191,6 +197,8 @@ class ObicoDetectionService:
                 self._states.pop(printer_id, None)
                 self._state_keys.pop(printer_id, None)
                 self._action_fired.pop(printer_id, None)
+                self._last_class.pop(printer_id, None)
+                self._errors.pop(printer_id, None)
                 continue
 
             await self._check_printer(printer_id, status, settings)
@@ -261,6 +269,17 @@ class ObicoDetectionService:
             timeout=SNAPSHOT_CAPTURE_TIMEOUT,
         )
 
+    def _no_verdict(self, printer_id: int, reason: str) -> None:
+        """Record that this poll produced no verdict for ``printer_id``.
+
+        Kept separate from the classification so the status surface can say
+        "not checking" instead of inheriting the previous verdict — or, worse,
+        the default "safe" a printer used to get before its first inference.
+        """
+        self._errors[printer_id] = reason
+        self._last_error = reason
+        logger.warning(reason)
+
     async def _check_printer(self, printer_id: int, status, settings: dict):
         task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
         key = f"{task_name}"
@@ -275,17 +294,16 @@ class ObicoDetectionService:
         # keyframe wait.
         frame = await self._capture_frame(printer_id)
         if not frame:
-            self._last_error = f"Failed to capture snapshot for printer {printer_id}"
-            logger.warning(self._last_error)
+            self._no_verdict(printer_id, f"Failed to capture snapshot for printer {printer_id}")
             return
 
         external_url = settings.get("external_url") or ""
         if not external_url:
-            self._last_error = (
+            self._no_verdict(
+                printer_id,
                 "external_url setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
-                "Set Settings → General → External URL."
+                "Set Settings → General → External URL.",
             )
-            logger.warning(self._last_error)
             return
 
         nonce = await stash_frame(frame)
@@ -304,19 +322,23 @@ class ObicoDetectionService:
                     # Say so plainly: the health endpoint is ungated, so "Test
                     # Connection" passes against exactly this configuration and
                     # a raw 401 gives the user nothing to act on (#2733).
-                    self._last_error = (
+                    #
+                    # Obico's auth decorator runs before the handler, so a call
+                    # rejected here leaves no trace in the ML API's own log —
+                    # which is how #2952 came to be reported as "the loop never
+                    # calls the ML API" while it was calling it every 10s.
+                    self._no_verdict(
+                        printer_id,
                         "Obico ML API rejected the token (401). Set Settings → Failure Detection → "
                         "ML API Token to the ML_API_TOKEN the server runs with, or clear ML_API_TOKEN "
-                        "on the server."
+                        "on the server.",
                     )
-                    logger.warning("%s (printer %s)", self._last_error, printer_id)
                     return
                 resp.raise_for_status()
                 payload = resp.json()
         except Exception as e:
             detail = str(e) or type(e).__name__
-            self._last_error = f"ML API call failed for printer {printer_id}: {detail}"
-            logger.warning(self._last_error)
+            self._no_verdict(printer_id, f"ML API call failed for printer {printer_id}: {detail}")
             return
 
         detections = payload.get("detections", []) if isinstance(payload, dict) else []
@@ -328,6 +350,7 @@ class ObicoDetectionService:
         # A successful capture + ML call clears any transient error from previous
         # polls (typical case: cold-start RTSP timeout on first frame after startup,
         # followed by healthy polls that otherwise leave the banner stuck in the UI).
+        self._errors.pop(printer_id, None)
         self._last_error = None
 
         # Log every non-safe sample — safe samples would flood history
@@ -371,15 +394,39 @@ class ObicoDetectionService:
 
         Only printers with a running, monitored print have a state entry, so
         consumers get "show nothing" for idle printers for free.
+
+        Four classes, and the two non-verdict ones matter as much as the rest:
+
+        ``error``    the most recent poll produced no verdict. ``error`` carries
+                     the reason — a rejected token, an unreachable ML API, a
+                     camera that would not yield a frame, an unset External URL.
+        ``unknown``  monitored, but no inference has come back yet. The state
+                     entry is created when the print is first seen, which is
+                     before the first capture, so this is the honest answer for
+                     that window.
+        ``safe`` / ``warning`` / ``failure``
+                     an actual verdict from an actual inference.
+
+        This used to default to ``safe`` whenever no verdict had been recorded,
+        so a printer whose detection had never once succeeded rendered exactly
+        like a healthy one: a green badge reading "Safe" at score 0.000. That is
+        the worst possible failure mode for a safety feature — it asserts the
+        print is being watched precisely when it is not (#2952).
         """
-        return {
-            pid: {
-                "class": self._last_class.get(pid, "safe"),
+        result = {}
+        for pid, state in self._states.items():
+            error = self._errors.get(pid)
+            if error:
+                verdict = "error"
+            else:
+                verdict = self._last_class.get(pid) or "unknown"
+            result[pid] = {
+                "class": verdict,
                 "frame_count": state.frame_count,
                 "score": round(state.ewm_mean, 4),
+                "error": error,
             }
-            for pid, state in self._states.items()
-        }
+        return result
 
     def get_status(self, sensitivity: str = "medium") -> dict:
         # Report the thresholds for the configured sensitivity, not a hardcoded

+ 66 - 0
backend/tests/integration/test_obico_api.py

@@ -79,10 +79,12 @@ class TestObicoPrinterStatus:
     def clear_detection_state(self):
         obico_detection_service._states.clear()
         obico_detection_service._last_class.clear()
+        obico_detection_service._errors.clear()
         obico_detection_service._last_error = None
         yield
         obico_detection_service._states.clear()
         obico_detection_service._last_class.clear()
+        obico_detection_service._errors.clear()
         obico_detection_service._last_error = None
 
     @pytest.mark.asyncio
@@ -142,3 +144,67 @@ class TestObicoPrinterStatus:
         data = response.json()
         for key in ("ml_url", "action", "history", "poll_interval", "external_url_configured"):
             assert key not in data
+
+
+class TestObicoPrinterStatusNoVerdict:
+    """A printer whose detection is not working must not read as monitored (#2952)."""
+
+    @pytest.fixture(autouse=True)
+    def clear_detection_state(self):
+        obico_detection_service._states.clear()
+        obico_detection_service._last_class.clear()
+        obico_detection_service._errors.clear()
+        obico_detection_service._last_error = None
+        yield
+        obico_detection_service._states.clear()
+        obico_detection_service._last_class.clear()
+        obico_detection_service._errors.clear()
+        obico_detection_service._last_error = None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_error_class_and_reason_reach_the_card(self, async_client: AsyncClient):
+        obico_detection_service._states[1] = PrintState()
+        obico_detection_service._errors[1] = "Obico ML API rejected the token (401)."
+
+        response = await async_client.get("/api/v1/obico/printer-status")
+        entry = response.json()["per_printer"]["1"]
+        assert entry["class"] == "error"
+        assert entry["error"] == "Obico ML API rejected the token (401)."
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_monitored_but_no_result_yet_is_unknown_not_safe(self, async_client: AsyncClient):
+        obico_detection_service._states[1] = PrintState()
+
+        response = await async_client.get("/api/v1/obico/printer-status")
+        entry = response.json()["per_printer"]["1"]
+        assert entry["class"] == "unknown"
+        assert entry["error"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reason_is_withheld_without_settings_read_but_the_class_is_not(self):
+        """The reason can name the ML API base or the External URL, so it stays
+        behind settings:read. Whether the print is being watched is not
+        configuration, so a printers:read user still gets the class."""
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        from backend.app.api.routes.obico import get_printer_status
+
+        obico_detection_service._states[1] = PrintState()
+        obico_detection_service._errors[1] = "ML API call failed: http://192.168.8.9:3333 refused"
+
+        user = MagicMock()
+        user.has_permission.return_value = False
+
+        # The route calls _load_settings for the enabled/monitored fields; the
+        # redaction under test is independent of them.
+        loaded = {"enabled": True, "enabled_printers": None}
+        with patch.object(obico_detection_service, "_load_settings", new=AsyncMock(return_value=loaded)):
+            data = await get_printer_status(user=user)
+        entry = data["per_printer"][1]
+        assert entry["class"] == "error"
+        assert entry["error"] is None
+        assert data["last_error"] is None
+        assert "192.168.8.9" not in str(data)

+ 153 - 0
backend/tests/unit/test_obico_detection.py

@@ -796,3 +796,156 @@ class TestCheckPrinterUsesCachedFrameUrl:
             await svc._check_printer(1, status, settings)
 
         assert svc._last_error is None
+
+
+class TestNoVerdictIsNotSafe:
+    """A printer nothing is looking at must not report itself as safe (#2952).
+
+    ``get_per_printer`` used to default to ``"safe"`` whenever no verdict had
+    been recorded, and the state entry is created when the print is first seen —
+    before the first capture, let alone the first inference. So a rejected token,
+    an unreachable ML API, a camera that never yields a frame and an unset
+    External URL all rendered as a green "Safe" badge at score 0.000, identical
+    to a healthy monitored print.
+
+    The reporter of #2952 read exactly that, concluded the detection loop had
+    never started, and spent an evening on the network path — while the loop was
+    calling the ML API every 10s and being turned away with a 401 that Obico's
+    auth decorator rejects before its own request log ever sees it.
+    """
+
+    SETTINGS = {
+        "enabled": True,
+        "ml_url": "http://obico:3333",
+        "ml_token": "wrong-token",
+        "sensitivity": "medium",
+        "action": "notify",
+        "poll_interval": 10,
+        "enabled_printers": None,
+        "external_url": "http://bambuddy:8000",
+    }
+
+    @staticmethod
+    def _status():
+        return MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+    @staticmethod
+    def _client(**kwargs):
+        client = MagicMock()
+        client.get = AsyncMock(**kwargs)
+        client.__aenter__ = AsyncMock(return_value=client)
+        client.__aexit__ = AsyncMock(return_value=False)
+        return client
+
+    @pytest.mark.asyncio
+    async def test_rejected_token_reports_error_and_names_the_setting(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=401)
+        with (
+            patch(
+                "backend.app.services.obico_detection.httpx.AsyncClient",
+                return_value=self._client(return_value=response),
+            ),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, self._status(), self.SETTINGS)
+
+        entry = svc.get_per_printer()[1]
+        assert entry["class"] == "error"
+        assert "ML API Token" in entry["error"]
+        assert entry["frame_count"] == 0
+
+    @pytest.mark.asyncio
+    async def test_unreachable_ml_api_reports_error(self):
+        svc = ObicoDetectionService()
+        with (
+            patch(
+                "backend.app.services.obico_detection.httpx.AsyncClient",
+                return_value=self._client(side_effect=RuntimeError("connection refused")),
+            ),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, self._status(), self.SETTINGS)
+
+        entry = svc.get_per_printer()[1]
+        assert entry["class"] == "error"
+        assert "connection refused" in entry["error"]
+
+    @pytest.mark.asyncio
+    async def test_failed_capture_reports_error(self):
+        svc = ObicoDetectionService()
+        with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)):
+            await svc._check_printer(1, self._status(), self.SETTINGS)
+
+        entry = svc.get_per_printer()[1]
+        assert entry["class"] == "error"
+        assert "capture" in entry["error"].lower()
+
+    @pytest.mark.asyncio
+    async def test_missing_external_url_reports_error(self):
+        svc = ObicoDetectionService()
+        settings = {**self.SETTINGS, "external_url": ""}
+        with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)):
+            await svc._check_printer(1, self._status(), settings)
+
+        entry = svc.get_per_printer()[1]
+        assert entry["class"] == "error"
+        assert "External URL" in entry["error"]
+
+    @pytest.mark.asyncio
+    async def test_a_recovered_printer_goes_back_to_a_real_verdict(self):
+        """The error must not stick once polling works again — otherwise the
+        badge trades one permanent lie for another."""
+        svc = ObicoDetectionService()
+        with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)):
+            await svc._check_printer(1, self._status(), self.SETTINGS)
+        assert svc.get_per_printer()[1]["class"] == "error"
+
+        ok = MagicMock(status_code=200)
+        ok.json.return_value = {"detections": []}
+        ok.raise_for_status = MagicMock()
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=self._client(return_value=ok)),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, self._status(), self.SETTINGS)
+
+        entry = svc.get_per_printer()[1]
+        assert entry["class"] == "safe"
+        assert entry["error"] is None
+        assert entry["frame_count"] == 1
+
+    @pytest.mark.asyncio
+    async def test_state_exists_before_the_first_inference_reports_unknown(self):
+        """The window between "print seen" and "first result" is not safe either."""
+        from backend.app.services.obico_smoothing import PrintState
+
+        svc = ObicoDetectionService()
+        svc._states[1] = PrintState()
+        svc._state_keys[1] = "job"
+
+        entry = svc.get_per_printer()[1]
+        assert entry["class"] == "unknown"
+        assert entry["error"] is None
+
+    @pytest.mark.asyncio
+    async def test_error_is_cleared_when_the_print_ends(self):
+        """A stale error must not carry into the next print's first poll."""
+        svc = ObicoDetectionService()
+        with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)):
+            await svc._check_printer(1, self._status(), self.SETTINGS)
+        assert 1 in svc._errors
+
+        idle = MagicMock(state="IDLE", task_name="", subtask_name="")
+        manager = MagicMock()
+        manager.get_all_statuses.return_value = {1: idle}
+        manager.is_connected.return_value = True
+        with patch.dict(
+            "sys.modules",
+            {"backend.app.services.printer_manager": MagicMock(printer_manager=manager)},
+        ):
+            await svc._poll_once(self.SETTINGS)
+
+        assert svc._errors == {}
+        assert svc._last_class == {}
+        assert svc.get_per_printer() == {}

+ 118 - 0
frontend/src/__tests__/pages/PrintersPageAiDetection.test.tsx

@@ -173,3 +173,121 @@ describe('PrintersPage AI detection badge (#1546)', () => {
     expect(screen.queryByText('Idle')).not.toBeInTheDocument();
   });
 });
+
+describe('PrintersPage AI detection badge — no verdict is not Safe (#2952)', () => {
+  beforeEach(() => {
+    localStorage.removeItem('printerCardSize');
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockPrinterStatus)),
+      http.get('/api/v1/settings/ui-preferences', () =>
+        HttpResponse.json({
+          ams_humidity_good: 40,
+          ams_humidity_fair: 60,
+          ams_temp_good: 30,
+          ams_temp_fair: 35,
+        })
+      ),
+      http.get('/api/v1/queue/', () => HttpResponse.json([]))
+    );
+  });
+
+  const withPerPrinter = (entry: Record<string, unknown>) =>
+    server.use(
+      http.get('/api/v1/obico/printer-status', () =>
+        HttpResponse.json({
+          enabled: true,
+          monitored_printers: [1],
+          per_printer: { '1': entry },
+          last_error: null,
+        })
+      )
+    );
+
+  it('a printer whose detection is failing reads "Not checking", never Safe', async () => {
+    // The reporter's case: the loop is calling the ML API every 10s and being
+    // turned away with a 401. This used to render as a green "Safe" pill at
+    // score 0.000, indistinguishable from a healthy print.
+    withPerPrinter({
+      class: 'error',
+      frame_count: 0,
+      score: 0,
+      error: 'Obico ML API rejected the token (401).',
+    });
+
+    render(<PrintersPage />);
+
+    const badge = await screen.findByText('Not checking');
+    expect(badge.closest('button')).toHaveAttribute(
+      'title',
+      'AI Failure Detection is not checking this print: Obico ML API rejected the token (401). - click for details'
+    );
+    expect(screen.queryByText('Safe')).not.toBeInTheDocument();
+  });
+
+  it('does not quote a score the model never produced', async () => {
+    withPerPrinter({ class: 'error', frame_count: 0, score: 0, error: 'Failed to capture snapshot' });
+
+    render(<PrintersPage />);
+    const user = userEvent.setup();
+    await user.click(await screen.findByText('Not checking'));
+
+    expect(await screen.findByText('AI Failure Detection - X1 Carbon')).toBeInTheDocument();
+    expect(screen.getByText('Failed to capture snapshot')).toBeInTheDocument();
+    // "Score 0.000" next to "Not checking" reads as a measurement rather than
+    // the absence of one, so neither figure is shown.
+    expect(screen.queryByText('0.000')).not.toBeInTheDocument();
+    expect(screen.queryByText('Frames analyzed')).not.toBeInTheDocument();
+  });
+
+  it('the window before the first result reads "Starting", never Safe', async () => {
+    withPerPrinter({ class: 'unknown', frame_count: 0, score: 0, error: null });
+
+    render(<PrintersPage />);
+
+    expect(await screen.findByText('Starting')).toBeInTheDocument();
+    expect(screen.queryByText('Safe')).not.toBeInTheDocument();
+  });
+
+  it('an unrecognised class falls back to Starting, not Safe', async () => {
+    // A newer backend class must never be silently absorbed into a green badge.
+    withPerPrinter({ class: 'something-new', frame_count: 5, score: 0.1, error: null });
+
+    render(<PrintersPage />);
+
+    expect(await screen.findByText('Starting')).toBeInTheDocument();
+    expect(screen.queryByText('Safe')).not.toBeInTheDocument();
+  });
+
+  it('still shows Safe when an inference actually said so', async () => {
+    withPerPrinter({ class: 'safe', frame_count: 216, score: 0, error: null });
+
+    render(<PrintersPage />);
+
+    const badge = await screen.findByText('Safe');
+    expect(badge.closest('button')).toHaveAttribute(
+      'title',
+      'AI Failure Detection: Safe (score 0.000) - click for details'
+    );
+  });
+
+  it("prefers this printer's own reason over the service-wide last error", async () => {
+    server.use(
+      http.get('/api/v1/obico/printer-status', () =>
+        HttpResponse.json({
+          enabled: true,
+          monitored_printers: [1],
+          per_printer: { '1': { class: 'error', frame_count: 0, score: 0, error: 'This printer: camera timed out' } },
+          last_error: 'Some other printer: token rejected',
+        })
+      )
+    );
+
+    render(<PrintersPage />);
+    const user = userEvent.setup();
+    await user.click(await screen.findByText('Not checking'));
+
+    expect(await screen.findByText('This printer: camera timed out')).toBeInTheDocument();
+    expect(screen.queryByText('Some other printer: token rejected')).not.toBeInTheDocument();
+  });
+});

+ 19 - 10
frontend/src/components/AiDetectionModal.tsx

@@ -5,10 +5,11 @@ import { useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router-dom';
 import { X, ScanEye, AlertCircle, Settings } from 'lucide-react';
+import { aiDetectionClass, hasVerdict, type AiDetection } from '../utils/aiDetection';
 
 interface AiDetectionModalProps {
   printerName: string;
-  detection?: { class: string; frame_count: number; score: number };
+  detection?: AiDetection;
   // null = no error, or the viewer lacks settings:read (the backend withholds
   // error strings from non-settings users because they can embed config URLs)
   lastError: string | null;
@@ -27,9 +28,7 @@ export function AiDetectionModal({ printerName, detection, lastError, onClose }:
     return () => window.removeEventListener('keydown', handleKeyDown);
   }, [onClose]);
 
-  const cls = detection
-    ? (detection.class === 'failure' || detection.class === 'warning' ? detection.class : 'safe')
-    : 'idle';
+  const cls = aiDetectionClass(detection);
   const statusColor =
     cls === 'failure'
       ? 'text-status-error'
@@ -37,7 +36,14 @@ export function AiDetectionModal({ printerName, detection, lastError, onClose }:
         ? 'text-status-warning'
         : cls === 'safe'
           ? 'text-status-ok'
-          : 'text-bambu-gray';
+          : cls === 'error'
+            ? 'text-amber-600 dark:text-amber-400'
+            : 'text-bambu-gray';
+
+  // This printer's own reason beats the service-wide one: with several printers
+  // monitored, the global string is whichever failed most recently and may be
+  // about someone else's printer entirely.
+  const reason = detection?.error ?? lastError;
 
   return (
     <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
@@ -66,7 +72,7 @@ export function AiDetectionModal({ printerName, detection, lastError, onClose }:
               <span className="text-bambu-gray">{t('printers.aiDetection.currentStatus')}</span>
               <span className={`font-medium ${statusColor}`}>{t(`printers.aiDetection.${cls}`)}</span>
             </div>
-            {detection ? (
+            {detection && hasVerdict(cls) && (
               <>
                 <div className="flex justify-between">
                   <span className="text-bambu-gray">{t('printers.aiDetection.score')}</span>
@@ -77,19 +83,22 @@ export function AiDetectionModal({ printerName, detection, lastError, onClose }:
                   <span className="text-white font-mono">{detection.frame_count}</span>
                 </div>
               </>
-            ) : (
-              <p className="text-bambu-gray">{t('printers.aiDetection.idleHint')}</p>
             )}
+            {/* A score of 0.000 next to "Not checking" reads as a reassuring
+                measurement rather than the absence of one, so it is withheld
+                until an inference has actually produced it (#2952). */}
+            {cls === 'error' && <p className="text-bambu-gray">{t('printers.aiDetection.errorHint')}</p>}
+            {!detection && <p className="text-bambu-gray">{t('printers.aiDetection.idleHint')}</p>}
           </div>
 
-          {lastError && (
+          {reason && (
             <div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
               <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-700 dark:text-red-400" />
               <div className="min-w-0">
                 <div className="font-medium text-red-700 dark:text-red-400">
                   {t('printers.aiDetection.lastError')}
                 </div>
-                <p className="text-red-700/80 dark:text-red-300/80 break-words mt-1">{lastError}</p>
+                <p className="text-red-700/80 dark:text-red-300/80 break-words mt-1">{reason}</p>
               </div>
             </div>
           )}

+ 21 - 8
frontend/src/components/FailureDetectionSettings.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Loader2, ScanEye, Check, X, AlertTriangle, Info } from 'lucide-react';
@@ -81,26 +81,39 @@ export function FailureDetectionSettings() {
     },
   });
 
-  // Auto-save on change (debounced)
-  useEffect(() => {
-    if (!initialized || !settings) return;
-    const changed =
+  const hasUnsavedChanges = useMemo(() => {
+    if (!initialized || !settings) return false;
+    return (
       settings.obico_enabled !== enabled ||
       settings.obico_ml_url !== mlUrl ||
       (settings.obico_ml_token ?? '') !== mlToken ||
       settings.obico_sensitivity !== sensitivity ||
       settings.obico_action !== action ||
       settings.obico_poll_interval !== pollInterval ||
-      settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters));
-    if (!changed) return;
+      settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters))
+    );
+  }, [settings, initialized, enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
+
+  // Auto-save on change (debounced)
+  useEffect(() => {
+    if (!hasUnsavedChanges) return;
     const id = setTimeout(() => saveMutation.mutate(), 500);
     return () => clearTimeout(id);
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters, initialized]);
+  }, [hasUnsavedChanges, enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
 
   const handleTest = async () => {
     setTestResult(null);
     try {
+      // Flush first, so a green result describes the configuration the
+      // detection loop is actually running with. The loop reads the saved
+      // settings; this form tests what is typed in the boxes. Inside the 500ms
+      // auto-save debounce — or after a save that failed — those are different
+      // values, and "reachable and healthy" for a token the service never
+      // received is the reassuring-green-light problem all over again (#2952).
+      if (hasUnsavedChanges) {
+        await saveMutation.mutateAsync();
+      }
       const res = await api.testObicoConnection(mlUrl, mlToken);
       if (res.ok) {
         // auth_ok is null when the token could not be checked — don't claim it

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

@@ -799,6 +799,11 @@ export default {
       warning: 'Warnung',
       failure: 'Fehldruck',
       idle: 'Bereit',
+      error: 'Prüft nicht',
+      unknown: 'Startet',
+      tooltipError: 'KI-Fehlererkennung prüft diesen Druck nicht: {{reason}} - klicken für Details',
+      tooltipUnknown: 'KI-Fehlererkennung: warte auf das erste Ergebnis - klicken für Details',
+      errorHint: 'Dieser Druck wird nicht geprüft. Die Erkennung läuft automatisch weiter, sobald das Problem unten behoben ist.',
       tooltip: 'KI-Fehlererkennung: {{status}} (Score {{score}}) - klicken für Details',
       tooltipIdle: 'KI-Fehlererkennung aktiviert - Überwachung startet mit dem nächsten Druck - klicken für Details',
       modalTitle: 'KI-Fehlererkennung - {{name}}',

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

@@ -804,6 +804,11 @@ export default {
       warning: 'Warning',
       failure: 'Failure',
       idle: 'Idle',
+      error: 'Not checking',
+      unknown: 'Starting',
+      tooltipError: 'AI Failure Detection is not checking this print: {{reason}} - click for details',
+      tooltipUnknown: 'AI Failure Detection: waiting for the first result - click for details',
+      errorHint: 'This print is not being checked. Detection resumes automatically once the problem below is fixed.',
       tooltip: 'AI Failure Detection: {{status}} (score {{score}}) - click for details',
       tooltipIdle: 'AI Failure Detection enabled - monitoring starts with the next print - click for details',
       modalTitle: 'AI Failure Detection - {{name}}',

+ 5 - 0
frontend/src/i18n/locales/es.ts

@@ -799,6 +799,11 @@ export default {
       warning: 'Advertencia',
       failure: 'Fallo',
       idle: 'Inactiva',
+      error: 'Sin comprobar',
+      unknown: 'Iniciando',
+      tooltipError: 'La detección de fallos por IA no está comprobando esta impresión: {{reason}} - haga clic para más detalles',
+      tooltipUnknown: 'Detección de fallos por IA: esperando el primer resultado - haga clic para más detalles',
+      errorHint: 'Esta impresión no se está comprobando. La detección se reanudará automáticamente cuando se solucione el problema indicado abajo.',
       tooltip: 'Detección de fallos por IA: {{status}} (puntuación {{score}}) - haga clic para más detalles',
       tooltipIdle: 'Detección de fallos por IA activada - la supervisión comienza con la próxima impresión - haga clic para más detalles',
       modalTitle: 'Detección de fallos por IA - {{name}}',

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

@@ -799,6 +799,11 @@ export default {
       warning: 'Avertissement',
       failure: 'Échec',
       idle: 'Inactif',
+      error: 'Ne vérifie pas',
+      unknown: 'Démarrage',
+      tooltipError: 'La détection d\'échec par IA ne vérifie pas cette impression : {{reason}} - cliquez pour les détails',
+      tooltipUnknown: 'Détection d\'échec par IA : en attente du premier résultat - cliquez pour les détails',
+      errorHint: 'Cette impression n\'est pas vérifiée. La détection reprendra automatiquement une fois le problème ci-dessous résolu.',
       tooltip: 'Détection d\'échec par IA : {{status}} (score {{score}}) - cliquez pour les détails',
       tooltipIdle: 'Détection d\'échec par IA activée - la surveillance démarre à la prochaine impression - cliquez pour les détails',
       modalTitle: 'Détection d\'échec par IA - {{name}}',

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

@@ -799,6 +799,11 @@ export default {
       warning: 'Avviso',
       failure: 'Guasto',
       idle: 'Inattiva',
+      error: 'Non controlla',
+      unknown: 'Avvio',
+      tooltipError: 'Il rilevamento guasti con IA non sta controllando questa stampa: {{reason}} - clicca per i dettagli',
+      tooltipUnknown: 'Rilevamento guasti con IA: in attesa del primo risultato - clicca per i dettagli',
+      errorHint: 'Questa stampa non viene controllata. Il rilevamento riprenderà automaticamente una volta risolto il problema indicato sotto.',
       tooltip: 'Rilevamento guasti con IA: {{status}} (punteggio {{score}}) - clicca per i dettagli',
       tooltipIdle: 'Rilevamento guasti con IA attivo - il monitoraggio inizia con la prossima stampa - clicca per i dettagli',
       modalTitle: 'Rilevamento guasti con IA - {{name}}',

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

@@ -798,6 +798,11 @@ export default {
       warning: '警告',
       failure: '失敗',
       idle: '待機中',
+      error: '確認していません',
+      unknown: '開始中',
+      tooltipError: 'AI 失敗検出はこの印刷を確認していません: {{reason}} - クリックで詳細を表示',
+      tooltipUnknown: 'AI 失敗検出: 最初の結果を待機中 - クリックで詳細を表示',
+      errorHint: 'この印刷は確認されていません。下記の問題が解決されると、検出は自動的に再開されます。',
       tooltip: 'AI 失敗検出: {{status}}(スコア {{score}})- クリックで詳細を表示',
       tooltipIdle: 'AI 失敗検出が有効 - 次の印刷から監視を開始します - クリックで詳細を表示',
       modalTitle: 'AI 失敗検出 - {{name}}',

+ 5 - 0
frontend/src/i18n/locales/ko.ts

@@ -754,6 +754,11 @@ export default {
       warning: '경고',
       failure: '실패',
       idle: '대기 중',
+      error: '확인 안 함',
+      unknown: '시작 중',
+      tooltipError: 'AI 실패 감지가 이 인쇄를 확인하고 있지 않습니다: {{reason}} - 클릭하여 자세히 보기',
+      tooltipUnknown: 'AI 실패 감지: 첫 번째 결과를 기다리는 중 - 클릭하여 자세히 보기',
+      errorHint: '이 인쇄는 확인되고 있지 않습니다. 아래 문제가 해결되면 감지가 자동으로 재개됩니다.',
       tooltip: 'AI 실패 감지: {{status}} (점수 {{score}}) - 클릭하여 자세히 보기',
       tooltipIdle: 'AI 실패 감지 활성화됨 - 다음 인쇄부터 모니터링을 시작합니다 - 클릭하여 자세히 보기',
       modalTitle: 'AI 실패 감지 - {{name}}',

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

@@ -799,6 +799,11 @@ export default {
       warning: 'Aviso',
       failure: 'Falha',
       idle: 'Ocioso',
+      error: 'Não verificando',
+      unknown: 'Iniciando',
+      tooltipError: 'A Detecção de Falhas por IA não está verificando esta impressão: {{reason}} - clique para detalhes',
+      tooltipUnknown: 'Detecção de Falhas por IA: aguardando o primeiro resultado - clique para detalhes',
+      errorHint: 'Esta impressão não está sendo verificada. A detecção será retomada automaticamente assim que o problema abaixo for resolvido.',
       tooltip: 'Detecção de Falhas por IA: {{status}} (pontuação {{score}}) - clique para detalhes',
       tooltipIdle: 'Detecção de Falhas por IA ativada - o monitoramento começa na próxima impressão - clique para detalhes',
       modalTitle: 'Detecção de Falhas por IA - {{name}}',

+ 5 - 0
frontend/src/i18n/locales/ru.ts

@@ -759,6 +759,11 @@ export default {
       warning: "Предупреждение",
       failure: "Сбой",
       idle: "Ожидает",
+      error: "Не проверяется",
+      unknown: "Запуск",
+      tooltipError: "Обнаружение сбоев с помощью ИИ не проверяет эту печать: {{reason}} - нажмите для подробностей",
+      tooltipUnknown: "Обнаружение сбоев с помощью ИИ: ожидание первого результата - нажмите для подробностей",
+      errorHint: "Эта печать не проверяется. Обнаружение возобновится автоматически после устранения проблемы, указанной ниже.",
       tooltip: "Обнаружение сбоев с помощью ИИ: {{status}} (оценка {{score}}) - нажмите для подробностей",
       tooltipIdle: "Обнаружение сбоев с помощью ИИ включено - мониторинг начнётся со следующей печати - нажмите для подробностей",
       modalTitle: "Обнаружение сбоев с помощью ИИ - {{name}}",

+ 5 - 0
frontend/src/i18n/locales/tr.ts

@@ -799,6 +799,11 @@ export default {
       warning: 'Uyarı',
       failure: 'Başarısızlık',
       idle: 'Boşta',
+      error: 'Kontrol etmiyor',
+      unknown: 'Başlatılıyor',
+      tooltipError: 'AI Başarısızlık Algılama bu baskıyı kontrol etmiyor: {{reason}} - ayrıntılar için tıklayın',
+      tooltipUnknown: 'AI Başarısızlık Algılama: ilk sonuç bekleniyor - ayrıntılar için tıklayın',
+      errorHint: 'Bu baskı kontrol edilmiyor. Aşağıdaki sorun giderildiğinde algılama otomatik olarak devam eder.',
       tooltip: 'AI Başarısızlık Algılama: {{status}} (puan {{score}}) - ayrıntılar için tıklayın',
       tooltipIdle: 'AI Başarısızlık Algılama etkin - izleme bir sonraki baskıyla başlar - ayrıntılar için tıklayın',
       modalTitle: 'AI Başarısızlık Algılama - {{name}}',

+ 5 - 0
frontend/src/i18n/locales/uk.ts

@@ -803,6 +803,11 @@ export default {
       warning: "Попередження",
       failure: "Помилка",
       idle: "Очікування",
+      error: "Не перевіряє",
+      unknown: "Запуск",
+      tooltipError: "Виявлення помилок за допомогою ШІ не перевіряє цей друк: {{reason}} — натисніть, щоб переглянути деталі",
+      tooltipUnknown: "Виявлення помилок за допомогою ШІ: очікування першого результату — натисніть, щоб переглянути деталі",
+      errorHint: "Цей друк не перевіряється. Виявлення відновиться автоматично після усунення проблеми, наведеної нижче.",
       tooltip: "Виявлення помилок за допомогою ШІ: {{status}} (оцінка {{score}}) — натисніть, щоб переглянути деталі",
       tooltipIdle: "Виявлення помилок за допомогою ШІ ввімкнено — моніторинг почнеться з наступним друком — натисніть, щоб переглянути деталі",
       modalTitle: "Виявлення помилок за допомогою ШІ — {{name}}",

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

@@ -799,6 +799,11 @@ export default {
       warning: '警告',
       failure: '失败',
       idle: '空闲',
+      error: '未检测',
+      unknown: '启动中',
+      tooltipError: 'AI 故障检测未检测此次打印:{{reason}} - 点击查看详情',
+      tooltipUnknown: 'AI 故障检测:正在等待首个结果 - 点击查看详情',
+      errorHint: '本次打印未被检测。下方问题解决后,检测将自动恢复。',
       tooltip: 'AI 故障检测:{{status}}(评分 {{score}})- 点击查看详情',
       tooltipIdle: 'AI 故障检测已启用 - 下次打印时开始监控 - 点击查看详情',
       modalTitle: 'AI 故障检测 - {{name}}',

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

@@ -799,6 +799,11 @@ export default {
       warning: '警告',
       failure: '失敗',
       idle: '空閒',
+      error: '未檢測',
+      unknown: '啟動中',
+      tooltipError: 'AI 故障檢測未檢測此次列印:{{reason}} - 點擊查看詳情',
+      tooltipUnknown: 'AI 故障檢測:正在等待首個結果 - 點擊查看詳情',
+      errorHint: '本次列印未被檢測。下方問題解決後,檢測將自動恢復。',
       tooltip: 'AI 故障檢測:{{status}}(評分 {{score}})- 點擊查看詳情',
       tooltipIdle: 'AI 故障檢測已啟用 - 下次列印時開始監控 - 點擊查看詳情',
       modalTitle: 'AI 故障檢測 - {{name}}',

+ 25 - 14
frontend/src/pages/PrintersPage.tsx

@@ -157,6 +157,7 @@ import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
 import { AiDetectionModal } from '../components/AiDetectionModal';
+import { aiDetectionClass, type AiDetection } from '../utils/aiDetection';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { PrinterHASensorRow } from '../components/PrinterHASensorRow';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
@@ -2154,7 +2155,7 @@ function PrinterCard({
   chamberTempPresets?: readonly [number, number, number];
   fanSpeedPresets?: readonly [number, number, number];
   aiDetectionEnabled?: boolean;
-  aiDetection?: { class: string; frame_count: number; score: number };
+  aiDetection?: AiDetection;
   aiLastError?: string | null;
 }) {
   const { t } = useTranslation();
@@ -3991,9 +3992,7 @@ function PrinterCard({
                   is enabled for this printer, like the other health badges. Gray
                   "Idle" outside a monitored print, class-colored during one. */}
               {aiDetectionEnabled && (() => {
-                const cls = aiDetection
-                  ? (aiDetection.class === 'failure' || aiDetection.class === 'warning' ? aiDetection.class : 'safe')
-                  : 'idle';
+                const cls = aiDetectionClass(aiDetection);
                 const colorClass =
                   cls === 'failure'
                     ? 'bg-status-error/20 text-status-error'
@@ -4001,21 +4000,33 @@ function PrinterCard({
                       ? 'bg-status-warning/20 text-status-warning'
                       : cls === 'safe'
                         ? 'bg-status-ok/20 text-status-ok'
-                        : 'bg-bambu-dark-tertiary text-bambu-gray';
-                return (
-                  <button
-                    onClick={() => setShowAiModal(true)}
-                    className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${colorClass}`}
-                    title={
-                      aiDetection
+                        : cls === 'error'
+                          ? 'bg-amber-500/20 text-amber-600 dark:text-amber-400'
+                          : 'bg-bambu-dark-tertiary text-bambu-gray';
+                // 'error' and 'unknown' have no score to quote — saying
+                // "Safe (0.000)" for a print nothing is looking at is the
+                // whole of #2952.
+                const title =
+                  cls === 'error'
+                    ? t('printers.aiDetection.tooltipError', {
+                        reason: aiDetection?.error ?? t('printers.aiDetection.error'),
+                      })
+                    : cls === 'unknown'
+                      ? t('printers.aiDetection.tooltipUnknown')
+                      : aiDetection
                         ? t('printers.aiDetection.tooltip', {
                             status: t(`printers.aiDetection.${cls}`),
                             score: aiDetection.score.toFixed(3),
                           })
-                        : t('printers.aiDetection.tooltipIdle')
-                    }
+                        : t('printers.aiDetection.tooltipIdle');
+                const Icon = cls === 'error' ? EyeOff : ScanEye;
+                return (
+                  <button
+                    onClick={() => setShowAiModal(true)}
+                    className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${colorClass}`}
+                    title={title}
                   >
-                    <ScanEye className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)]" />
+                    <Icon className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)]" />
                     {t(`printers.aiDetection.${cls}`)}
                   </button>
                 );

+ 45 - 0
frontend/src/utils/aiDetection.ts

@@ -0,0 +1,45 @@
+// Shared shape + class mapping for the Obico AI failure-detection surfaces
+// (the printer card badge and the detail modal), so the two cannot disagree
+// about what a given backend class means.
+
+export type AiDetectionClass = 'failure' | 'warning' | 'safe' | 'error' | 'unknown' | 'idle';
+
+export interface AiDetection {
+  class: string;
+  frame_count: number;
+  score: number;
+  // Why the most recent poll produced no verdict. null when the last poll
+  // succeeded, and also when the viewer lacks settings:read — the backend
+  // withholds the reason (it can name configured URLs) but still sends the
+  // 'error' class, because "your print is not being watched" is not
+  // configuration.
+  error?: string | null;
+}
+
+/**
+ * Canonical display class for a printer's detection state.
+ *
+ * `undefined` means the printer has no monitored print right now -> 'idle'.
+ *
+ * Anything unrecognised falls back to 'unknown', deliberately NOT to 'safe'.
+ * Collapsing every non-failure/warning class into a green "Safe" badge is
+ * exactly what made a printer whose detection had never once succeeded look
+ * identical to a healthy one (#2952).
+ */
+export function aiDetectionClass(detection?: AiDetection): AiDetectionClass {
+  if (!detection) return 'idle';
+  switch (detection.class) {
+    case 'failure':
+    case 'warning':
+    case 'safe':
+    case 'error':
+      return detection.class;
+    default:
+      return 'unknown';
+  }
+}
+
+/** True when the class represents an actual verdict, so score/frames mean something. */
+export function hasVerdict(cls: AiDetectionClass): boolean {
+  return cls === 'failure' || cls === 'warning' || cls === 'safe';
+}

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


+ 1 - 1
static/index.html

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

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