Переглянути джерело

feat(printers): show AI failure detection state on printer cards (#1546)

The live Obico classification was only visible under Settings ->
Failure Detection, so tracking how detection matched an ongoing print
meant flipping between the Printers screen and Settings.

Each printer card's badge row now shows an AI badge whenever detection
is enabled for that printer, like the other health badges: gray Idle
while no print is being watched, then green Safe, amber Warning, or
red Failure while a print is actively monitored. The tooltip carries
the current smoothed score; clicking jumps to the full detection
status and history in Settings. Printers excluded from the monitored
subset show no badge.

Served by a new lightweight /obico/printer-status endpoint readable
with printer permissions alone - it exposes only the enabled flag, the
monitored-printer set, and per-printer classification, keeping ML URL
and other configuration behind the existing settings-gated endpoint.
maziggy 1 місяць тому
батько
коміт
eae5359fbc

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **AI failure detection is now visible on the printer cards (#1546, reporter @Jeff-GebhartCA)** — Previously the live Obico classification (safe / warning / failure, smoothed score) was only visible under Settings → Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray **Idle** while no print is being watched, then green **Safe**, amber **Warning**, or red **Failure** while a print is actively monitored. The tooltip carries the current score, and clicking opens a modal (like the HMS error badge) with the live status, score, frames analyzed, and the detection service's last error — plus a shortcut to the full settings. Toggling detection on or off updates the cards immediately. Printers excluded from monitoring and setups without failure detection show nothing. Served by a new lightweight `/obico/printer-status` endpoint readable with printer permissions alone (the existing settings-gated endpoint is unchanged and keeps configuration private). Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Bark is now a notification provider (#1495)** — [Bark](https://github.com/Finb/Bark) is the open-source, account-free iOS push app (self-hostable via bark-server), popular especially with Chinese-speaking users. Configure it with just the device key from the app; the server URL defaults to the official `api.day.app` relay and accepts a self-hosted instance. Optional settings: notification **Group**, **Sound**, and iOS **Interruption Level** — Time Sensitive breaks through scheduled summaries, Critical bypasses Silent mode and Focus (useful for print-failure alerts), Passive delivers silently. Send failures wrapped in an HTTP 200 body by bark-server are detected and reported properly. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Home Assistant notifications can carry custom data fields (#1441)** — When a notification provider targets an HA notify service (e.g. `notify.mobile_app_myphone`), a new optional **Data (JSON)** field is forwarded as the service call's nested `data` object — the same place HA automations put mobile push options like `priority`, `ttl`, `channel`, and `group`. `ttl: 0` + `priority: high` make Android pushes arrive immediately instead of batched, and `channel` gives printer alerts their own notification channel/sound. The field is JSON (not key=value lines) so numbers stay numbers (`ttl: 0`) and nested options work. Validated on both ends: the UI rejects malformed JSON before saving, and the sender fails loudly with a clear message rather than posting a half-built payload. Only included when configured — the default persistent-notification path is unchanged, as its schema rejects unknown keys. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Energy usage now feeds the statistics that previously only knew about filament (#1432)** — Bambuddy has measured per-print energy via an attached smart plug for a while (the plug's lifetime counter is captured at print start and the delta stored with the print), but two stats surfaces ignored it. First, the **Most Expensive** record on the Statistics page ranked prints by filament cost alone, so a cheap-filament print with hours of heated-chamber time could never win; it now ranks by filament + measured energy cost (prints without a smart plug simply compete on filament cost, as before). Second, **Filament Trends** gained an **Energy Over Time** chart — per-day kWh (per-hour for short ranges, per-week for long ones), with the range's total kWh and energy cost in the header. The chart only appears when the selected range actually contains measured energy data, so setups without smart plugs see no change. The `/archives/slim` stats feed now carries each run's `energy_kwh`/`energy_cost`. Translated in all locales. Covered by backend and frontend tests.

+ 23 - 0
backend/app/api/routes/obico.py

@@ -37,6 +37,29 @@ async def get_status(
     }
 
 
+@router.get("/printer-status")
+async def get_printer_status(
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+):
+    """Per-printer live classification for the printer cards (#1546).
+
+    Deliberately excludes configuration (ML URL, action, history) so users
+    with printers:read but no settings:read can still render the badge.
+    """
+    settings = await obico_detection_service._load_settings()
+    enabled_printers = settings["enabled_printers"]
+    # 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)
+    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(),
+        "last_error": obico_detection_service._last_error if can_see_error else None,
+    }
+
+
 @router.post("/test-connection")
 async def test_connection(
     req: TestConnectionRequest,

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

@@ -320,6 +320,21 @@ class ObicoDetectionService:
 
     # ---- queries ----
 
+    def get_per_printer(self) -> dict:
+        """Live classification per actively monitored printer.
+
+        Only printers with a running, monitored print have a state entry, so
+        consumers get "show nothing" for idle printers for free.
+        """
+        return {
+            pid: {
+                "class": self._last_class.get(pid, "safe"),
+                "frame_count": state.frame_count,
+                "score": round(state.ewm_mean, 4),
+            }
+            for pid, state in self._states.items()
+        }
+
     def get_status(self, sensitivity: str = "medium") -> dict:
         # Report the thresholds for the configured sensitivity, not a hardcoded
         # "medium" — otherwise the Status panel always shows the medium row
@@ -329,14 +344,7 @@ class ObicoDetectionService:
         return {
             "is_running": self._task is not None and not self._task.done(),
             "last_error": self._last_error,
-            "per_printer": {
-                pid: {
-                    "class": self._last_class.get(pid, "safe"),
-                    "frame_count": state.frame_count,
-                    "score": round(state.ewm_mean, 4),
-                }
-                for pid, state in self._states.items()
-            },
+            "per_printer": self.get_per_printer(),
             "thresholds": {"low": low, "high": high},
             "history": list(self._history),
         }

+ 3 - 0
backend/tests/conftest.py

@@ -217,6 +217,9 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
         patch("backend.app.core.database.async_session", test_async_session),
         patch("backend.app.core.auth.async_session", test_async_session),
         patch("backend.app.main.async_session", test_async_session),
+        # Obico endpoints load settings through the service's module-level binding;
+        # without this patch they'd read whatever DB the cwd resolves to (#1546).
+        patch("backend.app.services.obico_detection.async_session", test_async_session),
         patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
     ):
         # Seed default groups for tests that need them

+ 74 - 1
backend/tests/integration/test_obico_api.py

@@ -8,7 +8,8 @@ hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
 import pytest
 from httpx import AsyncClient
 
-from backend.app.services.obico_detection import _frame_cache, stash_frame
+from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
+from backend.app.services.obico_smoothing import PrintState
 
 FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
 
@@ -69,3 +70,75 @@ class TestObicoCachedFrame:
         response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
         assert response.status_code == 200
         assert "no-store" in response.headers.get("cache-control", "")
+
+
+class TestObicoPrinterStatus:
+    """The lightweight /obico/printer-status endpoint for printer-card badges (#1546)."""
+
+    @pytest.fixture(autouse=True)
+    def clear_detection_state(self):
+        obico_detection_service._states.clear()
+        obico_detection_service._last_class.clear()
+        obico_detection_service._last_error = None
+        yield
+        obico_detection_service._states.clear()
+        obico_detection_service._last_class.clear()
+        obico_detection_service._last_error = None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_per_printer_classification(self, async_client: AsyncClient):
+        state = PrintState()
+        state.update(0.5)
+        obico_detection_service._states[1] = state
+        obico_detection_service._last_class[1] = "warning"
+
+        response = await async_client.get("/api/v1/obico/printer-status")
+        assert response.status_code == 200
+        data = response.json()
+        assert "enabled" in data
+        # None = all printers monitored (no obico_enabled_printers subset configured)
+        assert data["monitored_printers"] is None
+        entry = data["per_printer"]["1"]
+        assert entry["class"] == "warning"
+        assert entry["frame_count"] == 1
+        assert isinstance(entry["score"], float)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_when_nothing_monitored(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/obico/printer-status")
+        assert response.status_code == 200
+        assert response.json()["per_printer"] == {}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_monitored_subset_is_returned(self, async_client: AsyncClient):
+        """A configured obico_enabled_printers subset surfaces (as a sorted list) so
+        the frontend can show the idle badge only on monitored printers."""
+        update = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": "[3, 1]"})
+        assert update.status_code == 200
+        try:
+            response = await async_client.get("/api/v1/obico/printer-status")
+            assert response.json()["monitored_printers"] == [1, 3]
+        finally:
+            await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": ""})
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_last_error_is_surfaced(self, async_client: AsyncClient):
+        """The badge modal shows the service's last error (auth disabled in the
+        test env, so the settings:read gate on the field is open)."""
+        obico_detection_service._last_error = "Failed to capture snapshot for printer 1"
+        response = await async_client.get("/api/v1/obico/printer-status")
+        assert response.json()["last_error"] == "Failed to capture snapshot for printer 1"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_does_not_leak_settings(self, async_client: AsyncClient):
+        """Unlike /obico/status, this endpoint is readable with printers:read only,
+        so it must not expose the ML URL or other configuration."""
+        response = await async_client.get("/api/v1/obico/printer-status")
+        data = response.json()
+        for key in ("ml_url", "action", "history", "poll_interval", "external_url_configured"):
+            assert key not in data

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

@@ -174,6 +174,7 @@ const DE_COGNATES = [
   'China', 'Proxy', 'Start',
   'Diagnose',  // DE: same spelling/meaning as EN — camera diagnostic button label
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
+  'Score',  // #1546 AI detection modal — established DE loanword (Duden)
 ];
 
 // French cognates — many UI labels overlap with English exactly.
@@ -188,6 +189,7 @@ const FR_COGNATES = [
   'Copies', '{{n}} copies', 'max {{n}}',  // #1425 PR C — French uses these forms verbatim
   'round robin',  // borrowed English term used as-is in French tech contexts
   'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
+  'Score',  // #1546 AI detection modal — "le score" is standard French
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
   'Mode', 'Format', 'Default', 'Auto', 'Image', 'Audio', 'Video', 'Hex',

+ 3 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -535,6 +535,9 @@ export const handlers = [
       external_url_configured: false,
     })
   ),
+  http.get('/api/v1/obico/printer-status', () =>
+    HttpResponse.json({ enabled: false, monitored_printers: null, per_printer: {}, last_error: null })
+  ),
   http.get('/api/v1/printers/:id/current-print-user', () => HttpResponse.json(null)),
   http.get('/api/v1/settings/check-ffmpeg', () =>
     HttpResponse.json({ available: false, version: null })

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

@@ -0,0 +1,175 @@
+/**
+ * Tests for the AI failure detection badge on printer cards (#1546).
+ *
+ * The badge reflects /obico/printer-status: always shown for printers in the
+ * monitored set while detection is enabled — gray "Idle" outside a monitored
+ * print, class-colored (safe/warning/failure) during one.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinters = [
+  {
+    id: 1,
+    name: 'X1 Carbon',
+    ip_address: '192.168.1.100',
+    serial_number: '00M09A350100001',
+    access_code: '12345678',
+    model: 'X1C',
+    enabled: true,
+    is_active: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'hardened_steel',
+    location: 'Workshop',
+    auto_archive: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+  {
+    id: 2,
+    name: 'P1S Backup',
+    ip_address: '192.168.1.101',
+    serial_number: '00W00A123456789',
+    access_code: '87654321',
+    model: 'P1S',
+    enabled: true,
+    is_active: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'stainless_steel',
+    location: null,
+    auto_archive: true,
+    created_at: '2024-01-02T00:00:00Z',
+    updated_at: '2024-01-02T00:00:00Z',
+  },
+];
+
+const mockPrinterStatus = {
+  connected: true,
+  state: 'RUNNING',
+  awaiting_plate_clear: false,
+  progress: 42,
+  layer_num: 10,
+  total_layers: 100,
+  temperatures: { nozzle: 220, bed: 55, chamber: 30 },
+  remaining_time: 3600,
+  filename: 'benchy.3mf',
+  wifi_signal: -50,
+  vt_tray: [],
+};
+
+describe('PrintersPage AI detection badge (#1546)', () => {
+  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([]))
+    );
+  });
+
+  it('shows the live class for a monitored print and Idle for other monitored printers', async () => {
+    server.use(
+      http.get('/api/v1/obico/printer-status', () =>
+        HttpResponse.json({
+          enabled: true,
+          monitored_printers: null,
+          per_printer: { '1': { class: 'warning', frame_count: 12, score: 0.31 } },
+          last_error: null,
+        })
+      )
+    );
+
+    render(<PrintersPage />);
+
+    const badge = await screen.findByText('Warning');
+    expect(badge.closest('button')).toHaveAttribute(
+      'title',
+      'AI Failure Detection: Warning (score 0.310) - click for details'
+    );
+    // Printer 2 is monitored (null = all) but has no active print — gray Idle badge
+    const idleBadge = await screen.findByText('Idle');
+    expect(idleBadge.closest('button')).toHaveAttribute(
+      'title',
+      'AI Failure Detection enabled - monitoring starts with the next print - click for details'
+    );
+    expect(screen.getAllByText('Warning')).toHaveLength(1);
+  });
+
+  it('shows no badge for printers outside the monitored subset', async () => {
+    server.use(
+      http.get('/api/v1/obico/printer-status', () =>
+        HttpResponse.json({
+          enabled: true,
+          monitored_printers: [2],
+          per_printer: {},
+          last_error: null,
+        })
+      )
+    );
+
+    render(<PrintersPage />);
+
+    // Printer 2 gets the Idle badge; printer 1 (not monitored) gets none
+    expect(await screen.findAllByText('Idle')).toHaveLength(1);
+  });
+
+  it('clicking the badge opens a modal with live status and the last error', async () => {
+    server.use(
+      http.get('/api/v1/obico/printer-status', () =>
+        HttpResponse.json({
+          enabled: true,
+          monitored_printers: null,
+          per_printer: { '1': { class: 'failure', frame_count: 30, score: 0.92 } },
+          last_error: 'ML API call failed for printer 1: connection refused',
+        })
+      )
+    );
+
+    render(<PrintersPage />);
+    const user = userEvent.setup();
+
+    await user.click(await screen.findByText('Failure'));
+
+    expect(await screen.findByText('AI Failure Detection - X1 Carbon')).toBeInTheDocument();
+    expect(screen.getByText('0.920')).toBeInTheDocument();
+    expect(screen.getByText('30')).toBeInTheDocument();
+    expect(screen.getByText('Last error')).toBeInTheDocument();
+    expect(screen.getByText('ML API call failed for printer 1: connection refused')).toBeInTheDocument();
+  });
+
+  it('shows no badge when detection is disabled, even with stale per_printer state', async () => {
+    server.use(
+      http.get('/api/v1/obico/printer-status', () =>
+        HttpResponse.json({
+          enabled: false,
+          monitored_printers: null,
+          per_printer: { '1': { class: 'failure', frame_count: 30, score: 0.92 } },
+          last_error: null,
+        })
+      )
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('Failure')).not.toBeInTheDocument();
+    expect(screen.queryByText('Idle')).not.toBeInTheDocument();
+  });
+});

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

@@ -2744,6 +2744,16 @@ export interface ObicoStatus {
   external_url_configured: boolean;
 }
 
+// Lightweight subset served under printers:read for the printer-card badge (#1546)
+export interface ObicoPrinterStatus {
+  enabled: boolean;
+  // null = all printers are monitored
+  monitored_printers: number[] | null;
+  per_printer: Record<string, { class: string; frame_count: number; score: number }>;
+  // null = no error, or the viewer lacks settings:read (error strings can embed config URLs)
+  last_error: string | null;
+}
+
 export interface ObicoTestConnection {
   ok: boolean;
   status_code: number | null;
@@ -6468,6 +6478,9 @@ export const api = {
   getObicoStatus: () =>
     request<ObicoStatus>('/obico/status'),
 
+  getObicoPrinterStatus: () =>
+    request<ObicoPrinterStatus>('/obico/printer-status'),
+
   testObicoConnection: (url: string) =>
     request<ObicoTestConnection>('/obico/test-connection', {
       method: 'POST',

+ 111 - 0
frontend/src/components/AiDetectionModal.tsx

@@ -0,0 +1,111 @@
+// AI Failure Detection modal (#1546) — opened from the printer card's AI badge.
+// Shows the live classification for this printer and the detection service's
+// last error, without leaving the Printers page (mirrors the HMS error modal).
+import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+import { X, ScanEye, AlertCircle, Settings } from 'lucide-react';
+
+interface AiDetectionModalProps {
+  printerName: string;
+  detection?: { class: string; frame_count: number; score: number };
+  // 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;
+  onClose: () => void;
+}
+
+export function AiDetectionModal({ printerName, detection, lastError, onClose }: AiDetectionModalProps) {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const cls = detection
+    ? (detection.class === 'failure' || detection.class === 'warning' ? detection.class : 'safe')
+    : 'idle';
+  const statusColor =
+    cls === 'failure'
+      ? 'text-status-error'
+      : cls === 'warning'
+        ? 'text-status-warning'
+        : cls === 'safe'
+          ? 'text-status-ok'
+          : 'text-bambu-gray';
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2">
+            <ScanEye className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">
+              {t('printers.aiDetection.modalTitle', { name: printerName })}
+            </h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5 text-bambu-gray" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="flex-1 overflow-y-auto p-4 space-y-4 text-sm">
+          <div className="space-y-2">
+            <div className="flex justify-between">
+              <span className="text-bambu-gray">{t('printers.aiDetection.currentStatus')}</span>
+              <span className={`font-medium ${statusColor}`}>{t(`printers.aiDetection.${cls}`)}</span>
+            </div>
+            {detection ? (
+              <>
+                <div className="flex justify-between">
+                  <span className="text-bambu-gray">{t('printers.aiDetection.score')}</span>
+                  <span className="text-white font-mono">{detection.score.toFixed(3)}</span>
+                </div>
+                <div className="flex justify-between">
+                  <span className="text-bambu-gray">{t('printers.aiDetection.framesAnalyzed')}</span>
+                  <span className="text-white font-mono">{detection.frame_count}</span>
+                </div>
+              </>
+            ) : (
+              <p className="text-bambu-gray">{t('printers.aiDetection.idleHint')}</p>
+            )}
+          </div>
+
+          {lastError && (
+            <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>
+              </div>
+            </div>
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="p-4 border-t border-bambu-dark-tertiary flex items-center justify-end">
+          <button
+            onClick={() => navigate('/settings?tab=failure-detection')}
+            className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+          >
+            <Settings className="w-4 h-4" />
+            {t('printers.aiDetection.openSettings')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 3 - 0
frontend/src/components/FailureDetectionSettings.tsx

@@ -71,6 +71,9 @@ export function FailureDetectionSettings() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['settings'] });
       queryClient.invalidateQueries({ queryKey: ['obico-status'] });
+      // Printer-card AI badges (#1546) — refresh immediately on toggle instead
+      // of waiting for the cards' 10s poll.
+      queryClient.invalidateQueries({ queryKey: ['obico-printer-status'] });
       showToast(t('settings.toast.settingsSaved'));
     },
   });

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

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Klicken, um HMS-Fehler anzuzeigen',
+    aiDetection: {
+      safe: 'Sicher',
+      warning: 'Warnung',
+      failure: 'Fehldruck',
+      idle: 'Bereit',
+      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}}',
+      currentStatus: 'Status',
+      score: 'Score',
+      framesAnalyzed: 'Analysierte Bilder',
+      idleHint: 'Derzeit wird kein Druck überwacht. Die Überwachung startet automatisch mit dem nächsten Druck.',
+      lastError: 'Letzter Fehler',
+      openSettings: 'Einstellungen öffnen',
+    },
     estimatedCompletion: 'Geschätzte Fertigstellungszeit',
     plateNumber: 'Platte {{number}}',
     slotOptions: 'Slot-Optionen',

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

@@ -676,6 +676,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Click to view HMS errors',
+    aiDetection: {
+      safe: 'Safe',
+      warning: 'Warning',
+      failure: 'Failure',
+      idle: 'Idle',
+      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}}',
+      currentStatus: 'Status',
+      score: 'Score',
+      framesAnalyzed: 'Frames analyzed',
+      idleHint: 'No print is being monitored right now. Monitoring starts automatically with the next print.',
+      lastError: 'Last error',
+      openSettings: 'Open settings',
+    },
     estimatedCompletion: 'Estimated completion time',
     plateNumber: 'Plate {{number}}',
     slotOptions: 'Slot options',

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

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Haga clic para ver los errores HMS',
+    aiDetection: {
+      safe: 'Seguro',
+      warning: 'Advertencia',
+      failure: 'Fallo',
+      idle: 'Inactiva',
+      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}}',
+      currentStatus: 'Estado',
+      score: 'Puntuación',
+      framesAnalyzed: 'Fotogramas analizados',
+      idleHint: 'Ahora mismo no se supervisa ninguna impresión. La supervisión comienza automáticamente con la próxima impresión.',
+      lastError: 'Último error',
+      openSettings: 'Abrir ajustes',
+    },
     estimatedCompletion: 'Hora estimada de finalización',
     plateNumber: 'Cama {{number}}',
     slotOptions: 'Opciones de la ranura',

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

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Cliquez pour voir les erreurs HMS',
+    aiDetection: {
+      safe: 'Sûr',
+      warning: 'Avertissement',
+      failure: 'Échec',
+      idle: 'Inactif',
+      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}}',
+      currentStatus: 'Statut',
+      score: 'Score',
+      framesAnalyzed: 'Images analysées',
+      idleHint: 'Aucune impression n\'est surveillée actuellement. La surveillance démarre automatiquement à la prochaine impression.',
+      lastError: 'Dernière erreur',
+      openSettings: 'Ouvrir les paramètres',
+    },
     estimatedCompletion: 'Fin estimée',
     plateNumber: 'Plaque {{number}}',
     slotOptions: 'Options du slot',

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

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Clicca per vedere errori HMS',
+    aiDetection: {
+      safe: 'Sicuro',
+      warning: 'Avviso',
+      failure: 'Guasto',
+      idle: 'Inattiva',
+      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}}',
+      currentStatus: 'Stato',
+      score: 'Punteggio',
+      framesAnalyzed: 'Fotogrammi analizzati',
+      idleHint: 'Al momento nessuna stampa è monitorata. Il monitoraggio inizia automaticamente con la prossima stampa.',
+      lastError: 'Ultimo errore',
+      openSettings: 'Apri impostazioni',
+    },
     estimatedCompletion: 'Tempo completamento stimato',
     plateNumber: 'Piastra {{number}}',
     slotOptions: 'Opzioni slot',

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

@@ -671,6 +671,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'クリックしてHMSエラーを表示',
+    aiDetection: {
+      safe: '安全',
+      warning: '警告',
+      failure: '失敗',
+      idle: '待機中',
+      tooltip: 'AI 失敗検出: {{status}}(スコア {{score}})- クリックで詳細を表示',
+      tooltipIdle: 'AI 失敗検出が有効 - 次の印刷から監視を開始します - クリックで詳細を表示',
+      modalTitle: 'AI 失敗検出 - {{name}}',
+      currentStatus: 'ステータス',
+      score: 'スコア',
+      framesAnalyzed: '解析フレーム数',
+      idleHint: '現在監視中の印刷はありません。次の印刷から自動的に監視を開始します。',
+      lastError: '最後のエラー',
+      openSettings: '設定を開く',
+    },
     estimatedCompletion: '完了予定時刻',
     plateNumber: 'プレート {{number}}',
     slotOptions: 'スロットオプション',

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

@@ -628,6 +628,21 @@ export default {
       chamber: '챔버 팬'
     },
     clickToViewHmsErrors: 'HMS 오류 보기 클릭',
+    aiDetection: {
+      safe: '안전',
+      warning: '경고',
+      failure: '실패',
+      idle: '대기 중',
+      tooltip: 'AI 실패 감지: {{status}} (점수 {{score}}) - 클릭하여 자세히 보기',
+      tooltipIdle: 'AI 실패 감지 활성화됨 - 다음 인쇄부터 모니터링을 시작합니다 - 클릭하여 자세히 보기',
+      modalTitle: 'AI 실패 감지 - {{name}}',
+      currentStatus: '상태',
+      score: '점수',
+      framesAnalyzed: '분석된 프레임',
+      idleHint: '현재 모니터링 중인 인쇄가 없습니다. 다음 인쇄부터 자동으로 모니터링을 시작합니다.',
+      lastError: '마지막 오류',
+      openSettings: '설정 열기'
+    },
     estimatedCompletion: '예상 완료 시간',
     plateNumber: '플레이트 {{number}}',
     slotOptions: '슬롯 옵션',

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

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Clique para ver erros do HMS',
+    aiDetection: {
+      safe: 'Seguro',
+      warning: 'Aviso',
+      failure: 'Falha',
+      idle: 'Ocioso',
+      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}}',
+      currentStatus: 'Status',
+      score: 'Pontuação',
+      framesAnalyzed: 'Quadros analisados',
+      idleHint: 'Nenhuma impressão está sendo monitorada no momento. O monitoramento começa automaticamente na próxima impressão.',
+      lastError: 'Último erro',
+      openSettings: 'Abrir configurações',
+    },
     estimatedCompletion: 'Tempo estimado de conclusão',
     plateNumber: 'Placa {{number}}',
     slotOptions: 'Opções de slot',

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

@@ -633,6 +633,21 @@ export default {
       chamber: "Вентилятор камеры",
     },
     clickToViewHmsErrors: "Нажмите, чтобы посмотреть ошибки HMS",
+    aiDetection: {
+      safe: "Безопасно",
+      warning: "Предупреждение",
+      failure: "Сбой",
+      idle: "Ожидает",
+      tooltip: "Обнаружение сбоев с помощью ИИ: {{status}} (оценка {{score}}) - нажмите для подробностей",
+      tooltipIdle: "Обнаружение сбоев с помощью ИИ включено - мониторинг начнётся со следующей печати - нажмите для подробностей",
+      modalTitle: "Обнаружение сбоев с помощью ИИ - {{name}}",
+      currentStatus: "Статус",
+      score: "Оценка",
+      framesAnalyzed: "Проанализировано кадров",
+      idleHint: "Сейчас ни одна печать не отслеживается. Мониторинг начнётся автоматически со следующей печати.",
+      lastError: "Последняя ошибка",
+      openSettings: "Открыть настройки",
+    },
     estimatedCompletion: "Расчётное время завершения",
     plateNumber: "Пластина {{number}}",
     slotOptions: "Параметры слота",

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

@@ -672,6 +672,21 @@ export default {
     },
     // HMS hataları
     clickToViewHmsErrors: 'HMS hatalarını görüntülemek için tıklayın',
+    aiDetection: {
+      safe: 'Güvenli',
+      warning: 'Uyarı',
+      failure: 'Başarısızlık',
+      idle: 'Boşta',
+      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}}',
+      currentStatus: 'Durum',
+      score: 'Puan',
+      framesAnalyzed: 'Analiz edilen kareler',
+      idleHint: 'Şu anda izlenen bir baskı yok. İzleme bir sonraki baskıyla otomatik olarak başlar.',
+      lastError: 'Son hata',
+      openSettings: 'Ayarları aç',
+    },
     estimatedCompletion: 'Tahmini tamamlanma süresi',
     plateNumber: 'Plaka {{number}}',
     slotOptions: 'Yuva seçenekleri',

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

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: '点击查看 HMS 错误',
+    aiDetection: {
+      safe: '安全',
+      warning: '警告',
+      failure: '失败',
+      idle: '空闲',
+      tooltip: 'AI 故障检测:{{status}}(评分 {{score}})- 点击查看详情',
+      tooltipIdle: 'AI 故障检测已启用 - 下次打印时开始监控 - 点击查看详情',
+      modalTitle: 'AI 故障检测 - {{name}}',
+      currentStatus: '状态',
+      score: '评分',
+      framesAnalyzed: '已分析帧数',
+      idleHint: '当前没有正在监控的打印。下次打印时将自动开始监控。',
+      lastError: '最近错误',
+      openSettings: '打开设置',
+    },
     estimatedCompletion: '预计完成时间',
     plateNumber: '板 {{number}}',
     slotOptions: '槽位选项',

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

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: '點選檢視 HMS 錯誤',
+    aiDetection: {
+      safe: '安全',
+      warning: '警告',
+      failure: '失敗',
+      idle: '空閒',
+      tooltip: 'AI 故障檢測:{{status}}(評分 {{score}})- 點擊查看詳情',
+      tooltipIdle: 'AI 故障檢測已啟用 - 下次列印時開始監控 - 點擊查看詳情',
+      modalTitle: 'AI 故障檢測 - {{name}}',
+      currentStatus: '狀態',
+      score: '評分',
+      framesAnalyzed: '已分析幀數',
+      idleHint: '目前沒有正在監控的列印。下次列印時將自動開始監控。',
+      lastError: '最近錯誤',
+      openSettings: '開啟設定',
+    },
     estimatedCompletion: '預計完成時間',
     plateNumber: '板 {{number}}',
     slotOptions: '槽位選項',

+ 75 - 0
frontend/src/pages/PrintersPage.tsx

@@ -81,6 +81,7 @@ import {
   MoreHorizontal,
   SlidersHorizontal,
   Stethoscope,
+  ScanEye,
   LineChart as LineChartIcon,
   LayoutGrid,
   MonitorPlay,
@@ -101,6 +102,7 @@ import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
 import { CameraWall } from '../components/CameraWall';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
+import { AiDetectionModal } from '../components/AiDetectionModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { AmsBackupModal } from '../components/AmsBackupModal';
@@ -1772,6 +1774,9 @@ function PrinterCard({
   bedTempPresets = BED_TEMP_DEFAULTS,
   chamberTempPresets = CHAMBER_TEMP_DEFAULTS,
   fanSpeedPresets = FAN_SPEED_DEFAULTS,
+  aiDetectionEnabled = false,
+  aiDetection,
+  aiLastError = null,
 }: {
   printer: Printer;
   hideIfDisconnected?: boolean;
@@ -1810,6 +1815,9 @@ function PrinterCard({
   bedTempPresets?: readonly [number, number, number];
   chamberTempPresets?: readonly [number, number, number];
   fanSpeedPresets?: readonly [number, number, number];
+  aiDetectionEnabled?: boolean;
+  aiDetection?: { class: string; frame_count: number; score: number };
+  aiLastError?: string | null;
 }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -1826,6 +1834,8 @@ function PrinterCard({
   const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
   const [haToggleConfirm, setHaToggleConfirm] = useState<SmartPlug | null>(null);
   const [showHMSModal, setShowHMSModal] = useState(false);
+  // #1546: AI failure detection modal — opens from the AI badge.
+  const [showAiModal, setShowAiModal] = useState(false);
   // #1762: AMS Filament Backup status / control modal — opens from the badge.
   const [amsBackupModalOpen, setAmsBackupModalOpen] = useState(false);
   const [showStopConfirm, setShowStopConfirm] = useState(false);
@@ -3429,6 +3439,39 @@ function PrinterCard({
                   </button>
                 );
               })()}
+              {/* AI failure detection badge (#1546) — always shown while detection
+                  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 colorClass =
+                  cls === 'failure'
+                    ? 'bg-status-error/20 text-status-error'
+                    : cls === 'warning'
+                      ? '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
+                        ? t('printers.aiDetection.tooltip', {
+                            status: t(`printers.aiDetection.${cls}`),
+                            score: aiDetection.score.toFixed(3),
+                          })
+                        : t('printers.aiDetection.tooltipIdle')
+                    }
+                  >
+                    <ScanEye className="w-3 h-3" />
+                    {t(`printers.aiDetection.${cls}`)}
+                  </button>
+                );
+              })()}
               {/* Maintenance Status Indicator */}
               {maintenanceInfo && (
                 <button
@@ -6286,6 +6329,16 @@ function PrinterCard({
         />
       )}
 
+      {/* AI failure detection modal (#1546) */}
+      {showAiModal && (
+        <AiDetectionModal
+          printerName={printer.name}
+          detection={aiDetection}
+          lastError={aiLastError}
+          onClose={() => setShowAiModal(false)}
+        />
+      )}
+
       {/* AMS Filament Backup status / control modal (#1762) */}
       {amsBackupModalOpen && status && (
         <AmsBackupModal
@@ -7882,6 +7935,22 @@ export function PrintersPage() {
     staleTime: 60 * 1000, // 1 minute
   });
 
+  // Live AI failure detection state for the card badges (#1546). Matches the
+  // 10s refetch of the Failure Detection settings panel.
+  const { data: obicoPrinterStatus } = useQuery({
+    queryKey: ['obico-printer-status'],
+    queryFn: api.getObicoPrinterStatus,
+    refetchInterval: 10000,
+  });
+  // Badge visibility: detection enabled AND this printer in the monitored set
+  // (monitored_printers null = all). Live per-print state is passed separately.
+  const isAiMonitored = useCallback(
+    (printerId: number) =>
+      obicoPrinterStatus?.enabled === true &&
+      (obicoPrinterStatus.monitored_printers === null || obicoPrinterStatus.monitored_printers.includes(printerId)),
+    [obicoPrinterStatus],
+  );
+
   // Fetch Spoolman status to enable link spool feature
   const { data: spoolmanStatus } = useQuery({
     queryKey: ['spoolman-status'],
@@ -8854,6 +8923,9 @@ export function PrintersPage() {
                       isSelected={selectedPrinterIds.has(printer.id)}
                       onToggleSelect={toggleSelect}
                       onOpenCompactCard={openCompactCard}
+                      aiDetectionEnabled={isAiMonitored(printer.id)}
+                      aiDetection={obicoPrinterStatus?.enabled ? obicoPrinterStatus.per_printer[String(printer.id)] : undefined}
+                      aiLastError={obicoPrinterStatus?.last_error ?? null}
                     />
                   ))}
                 </div>
@@ -8903,6 +8975,9 @@ export function PrintersPage() {
               isSelected={selectedPrinterIds.has(printer.id)}
               onToggleSelect={toggleSelect}
               onOpenCompactCard={openCompactCard}
+              aiDetectionEnabled={isAiMonitored(printer.id)}
+              aiDetection={obicoPrinterStatus?.enabled ? obicoPrinterStatus.per_printer[String(printer.id)] : undefined}
+              aiLastError={obicoPrinterStatus?.last_error ?? null}
             />
           ))}
         </div>

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/index-CX7cNuer.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-D6JvpN-_.js"></script>
+    <script type="module" crossorigin src="/assets/index-CX7cNuer.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
   </head>
   <body>

Деякі файли не було показано, через те що забагато файлів було змінено