Browse Source

feat(obico): AI print-failure detection via self-hosted Obico ML API (#172)

  Adds a Failure Detection tab under Settings that wires Bambuddy to a
  self-hosted Obico ml_api container — no cloud, no account, no WebSocket.
  While a print is running, the detection service periodically hands the
  printer's camera snapshot URL to the ML API and smooths scores over
  time (30-frame warmup + EWM, alpha=2/13, short/long rolling means) so
  one noisy frame can't trigger an action. When the smoothed score
  crosses HIGH, the configured action fires exactly once per print:
  notify, pause, or pause-and-cut-power (via linked smart plugs).

  - Backend: new obico_detection + obico_smoothing + obico_actions
    services, /obico/status and /obico/test-connection routes
    (SETTINGS_READ / SETTINGS_UPDATE), six obico_* AppSettings fields
    with validators for sensitivity/action/enabled_printers.
  - Frontend: FailureDetectionSettings component (enable, ML URL + test,
    sensitivity, action, poll interval, per-printer monitor list, live
    status + detection history), new sidebar tab with service-active
    bullet, toast on save.
  - Tests: 17 detection unit tests + 15 smoothing unit tests + 4
    frontend component tests.
  - Docs: README bullet, CHANGELOG entry, wiki page under Analytics,
    website features.html entry.
maziggy 1 month ago
parent
commit
eec7793955

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.2.3b4] - Unreleased
 
+### New Features
+- **AI Print-Failure Detection via self-hosted Obico ML API** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — New Settings → Failure Detection tab wires Bambuddy to a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) `ml_api` container (no Obico account, no cloud, no WebSocket). While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API, which returns YOLO failure-detection scores. Scores are smoothed over time using Obico's own EWM + short/long rolling-mean math (30-frame warmup, alpha = 2/13, short window ≈ 5 min at 10s/frame, long window ≈ 20 h) so a single noisy frame cannot trigger an action. Sensitivity (Low / Medium / High) scales the LOW/HIGH thresholds; when the smoothed score crosses HIGH, the configured action runs exactly once per print: *Notify only*, *Pause print* (MQTT pause command), or *Pause and cut power* (pause + turn off any smart plug linked to that printer). A per-printer toggle lets you monitor all connected printers or just a subset. The Status card shows whether the service is running, the active thresholds, each monitored print's current verdict (safe / warning / failure), and a live rolling detection history. Requires that the External URL setting (General tab) points to a hostname/IP reachable from the ML API container, since the ML API fetches snapshots by URL.
+
 ### Fixed
 - **Clear Plate Confirmation Bypassed on Power Cycle** ([#961](https://github.com/maziggy/bambuddy/issues/961)) — With Auto Off enabled and another job queued, the smart plug would cut power when a print finished and immediately re-power when the scheduler saw the queue, at which point the printer booted fresh into `IDLE` and the next job auto-dispatched without the "Clear Plate & Start Next" confirmation. Root cause: the plate-cleared gate lived only in the in-memory `PrinterManager._plate_cleared` set, and the scheduler's idle check treated `IDLE` as always-idle regardless of whether a previous finish had been acknowledged — so the gate was lost across both Bambuddy restarts and the IDLE-on-boot state transition. The gate is now an `awaiting_plate_clear` column on the `printers` table, set by `on_print_complete` when a print finishes or fails, cleared by the `/printers/{id}/clear-plate` endpoint and by the scheduler when it dispatches the next job, and rehydrated from the DB into `PrinterManager` on startup. `_is_printer_idle` now short-circuits to not-idle whenever `require_plate_clear` is on and the printer is awaiting ack, regardless of the currently reported state — so the prompt survives Auto Off cycles, Bambuddy restarts, and the printer booting back into `IDLE`. The clear-plate endpoint no longer requires the printer to currently report `FINISH`/`FAILED` (it accepts the ack whenever the awaiting flag is set), and the Printers page widget prompts based on the flag rather than the reported state. Thanks to @miaopas for reporting.
 - **Insecure Temp File Creation in Backup Export** — The manual backup download endpoint used `tempfile.mktemp()`, which is vulnerable to a symlink race condition (CWE-377). Replaced with `tempfile.mkstemp()` which atomically creates the file, eliminating the TOCTOU window.

+ 1 - 0
README.md

@@ -119,6 +119,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Print success rates & trends
 - Filament usage tracking
 - Cost analytics & failure analysis
+- **AI print-failure detection** — Optional integration with a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) ML API: watches each running print's camera feed, smooths scores over time (30-frame warmup + EWM + rolling means), and fires a configurable action once per print (notify / pause / pause-and-off)
 - Per-user statistics filtering (admin permission gated)
 - CSV/Excel export
 

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

@@ -0,0 +1,48 @@
+"""API routes for Obico AI failure detection."""
+
+import logging
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.services.obico_detection import obico_detection_service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/obico", tags=["obico"])
+
+
+class TestConnectionRequest(BaseModel):
+    url: str
+
+
+@router.get("/status")
+async def get_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Scheduler status, per-printer classification, and recent detection history."""
+    settings = await obico_detection_service._load_settings()
+    status = obico_detection_service.get_status()
+    return {
+        **status,
+        "enabled": settings["enabled"],
+        "ml_url": settings["ml_url"],
+        "sensitivity": settings["sensitivity"],
+        "action": settings["action"],
+        "poll_interval": settings["poll_interval"],
+        "external_url_configured": bool(settings["external_url"]),
+    }
+
+
+@router.post("/test-connection")
+async def test_connection(
+    req: TestConnectionRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Ping the Obico ML API `/hc/` health endpoint. Returns ok + raw body."""
+    if not req.url:
+        return {"ok": False, "status_code": None, "body": None, "error": "URL is empty"}
+    return await obico_detection_service.test_connection(req.url)

+ 5 - 0
backend/app/main.py

@@ -34,6 +34,7 @@ from backend.app.api.routes import (
     metrics,
     notification_templates,
     notifications,
+    obico,
     pending_uploads,
     print_log,
     print_queue,
@@ -68,6 +69,7 @@ from backend.app.services.local_backup import local_backup_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
 from backend.app.services.notification_service import notification_service
+from backend.app.services.obico_detection import obico_detection_service
 from backend.app.services.print_scheduler import scheduler as print_scheduler
 from backend.app.services.printer_manager import (
     init_printer_connections,
@@ -3922,6 +3924,7 @@ async def lifespan(app: FastAPI):
 
     # Start the local backup scheduler
     await local_backup_service.start_scheduler()
+    await obico_detection_service.start()
 
     # Start AMS history recording
     start_ams_history_recording()
@@ -3958,6 +3961,7 @@ async def lifespan(app: FastAPI):
     notification_service.stop_digest_scheduler()
     github_backup_service.stop_scheduler()
     local_backup_service.stop_scheduler()
+    obico_detection_service.stop()
     stop_ams_history_recording()
     stop_runtime_tracking()
     stop_spoolbuddy_watchdog()
@@ -4190,6 +4194,7 @@ app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
 app.include_router(firmware.router, prefix=app_settings.api_prefix)
 app.include_router(github_backup.router, prefix=app_settings.api_prefix)
 app.include_router(local_backup.router, prefix=app_settings.api_prefix)
+app.include_router(obico.router, prefix=app_settings.api_prefix)
 app.include_router(metrics.router, prefix=app_settings.api_prefix)
 app.include_router(virtual_printers.router, prefix=app_settings.api_prefix)
 app.include_router(spoolbuddy.router, prefix=app_settings.api_prefix)

+ 62 - 0
backend/app/schemas/settings.py

@@ -256,6 +256,31 @@ class AppSettings(BaseModel):
         description="Fallback BamBuddy group name assigned when an LDAP user authenticates but has no mapped groups. Empty = no fallback.",
     )
 
+    # Obico AI failure detection (#172)
+    obico_enabled: bool = Field(default=False, description="Enable Obico AI print failure detection")
+    obico_ml_url: str = Field(
+        default="",
+        description="Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)",
+    )
+    obico_sensitivity: str = Field(
+        default="medium",
+        description="Detection sensitivity: 'low', 'medium', or 'high' (adjusts LOW/HIGH thresholds)",
+    )
+    obico_action: str = Field(
+        default="notify",
+        description="Action on detected failure: 'notify', 'pause', or 'pause_and_off'",
+    )
+    obico_poll_interval: int = Field(
+        default=10,
+        ge=5,
+        le=120,
+        description="Seconds between detection checks while a print is running",
+    )
+    obico_enabled_printers: str = Field(
+        default="",
+        description="JSON array of printer IDs to monitor (empty = all connected printers)",
+    )
+
     # Default sidebar order (admin-set for all users)
     default_sidebar_order: str = Field(
         default="",
@@ -356,6 +381,12 @@ class AppSettingsUpdate(BaseModel):
     ldap_group_mapping: str | None = None
     ldap_auto_provision: bool | None = None
     ldap_default_group: str | None = None
+    obico_enabled: bool | None = None
+    obico_ml_url: str | None = None
+    obico_sensitivity: str | None = None
+    obico_action: str | None = None
+    obico_poll_interval: int | None = Field(default=None, ge=5, le=120)
+    obico_enabled_printers: str | None = None
     default_sidebar_order: str | None = None
 
     @field_validator("gcode_snippets")
@@ -384,6 +415,37 @@ class AppSettingsUpdate(BaseModel):
             raise ValueError("ldap_group_mapping must be a JSON object mapping LDAP group DNs to BamBuddy group names")
         return v
 
+    @field_validator("obico_enabled_printers")
+    @classmethod
+    def validate_obico_enabled_printers(cls, v: str | None) -> str | None:
+        if v is None or v == "":
+            return v
+        try:
+            parsed = json.loads(v)
+        except json.JSONDecodeError:
+            raise ValueError("obico_enabled_printers must be valid JSON or empty")
+        if not isinstance(parsed, list) or not all(isinstance(item, int) for item in parsed):
+            raise ValueError("obico_enabled_printers must be a JSON array of printer IDs (integers)")
+        return v
+
+    @field_validator("obico_sensitivity")
+    @classmethod
+    def validate_obico_sensitivity(cls, v: str | None) -> str | None:
+        if v is None:
+            return v
+        if v not in ("low", "medium", "high"):
+            raise ValueError("obico_sensitivity must be 'low', 'medium', or 'high'")
+        return v
+
+    @field_validator("obico_action")
+    @classmethod
+    def validate_obico_action(cls, v: str | None) -> str | None:
+        if v is None:
+            return v
+        if v not in ("notify", "pause", "pause_and_off"):
+            raise ValueError("obico_action must be 'notify', 'pause', or 'pause_and_off'")
+        return v
+
     @field_validator("default_sidebar_order")
     @classmethod
     def validate_default_sidebar_order(cls, v: str | None) -> str | None:

+ 83 - 0
backend/app/services/obico_actions.py

@@ -0,0 +1,83 @@
+"""Action dispatch for Obico failure detection.
+
+Separated from the detection loop so actions can be unit-tested and swapped.
+"""
+
+import logging
+
+from sqlalchemy import select
+
+from backend.app.core.database import async_session
+from backend.app.models.printer import Printer
+
+logger = logging.getLogger(__name__)
+
+
+async def execute_action(printer_id: int, action: str, task_name: str, score: float) -> None:
+    """Run the configured action for a detected print failure.
+
+    action: 'notify' | 'pause' | 'pause_and_off'
+    """
+    printer_name = await _get_printer_name(printer_id)
+
+    if action in ("pause", "pause_and_off"):
+        _pause_print(printer_id)
+
+    if action == "pause_and_off":
+        await _turn_off_linked_plugs(printer_id)
+
+    await _notify(printer_id, printer_name, task_name, score, action)
+
+
+async def _get_printer_name(printer_id: int) -> str:
+    async with async_session() as db:
+        result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        printer = result.scalar_one_or_none()
+    return printer.name if printer else f"Printer {printer_id}"
+
+
+def _pause_print(printer_id: int) -> None:
+    from backend.app.services.printer_manager import printer_manager
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        logger.warning("Obico pause: no MQTT client for printer %s", printer_id)
+        return
+    if not client.pause_print():
+        logger.warning("Obico pause: pause_print() returned False for printer %s", printer_id)
+
+
+async def _turn_off_linked_plugs(printer_id: int) -> None:
+    from backend.app.services.smart_plug_manager import smart_plug_manager
+
+    async with async_session() as db:
+        plugs = await smart_plug_manager._get_plugs_for_printer(printer_id, db)
+        for plug in plugs:
+            if not plug.enabled:
+                continue
+            try:
+                service = await smart_plug_manager.get_service_for_plug(plug, db)
+                await service.turn_off(plug)
+                logger.info("Obico action: turned off plug %s for printer %s", plug.name, printer_id)
+            except Exception as e:
+                logger.error("Obico action: failed to turn off plug %s: %s", plug.name, e)
+
+
+async def _notify(printer_id: int, printer_name: str, task_name: str, score: float, action: str) -> None:
+    from backend.app.services.notification_service import notification_service
+
+    detail = (
+        f"Possible print failure detected on '{task_name or 'current job'}' "
+        f"(confidence {score:.2f}). Action taken: {action}."
+    )
+    async with async_session() as db:
+        try:
+            await notification_service.on_printer_error(
+                printer_id=printer_id,
+                printer_name=printer_name,
+                error_type="ai_failure_detection",
+                db=db,
+                error_detail=detail,
+            )
+        except Exception as e:
+            logger.error("Obico notify failed for printer %s: %s", printer_id, e)

+ 244 - 0
backend/app/services/obico_detection.py

@@ -0,0 +1,244 @@
+"""Obico AI print-failure detection service.
+
+Polls a self-hosted Obico ML API with snapshots from each monitored printer
+while a print is running, smooths scores over time, and dispatches a configured
+action (notify / pause / pause_and_off) when a sustained failure is detected.
+
+See `obico_smoothing.py` for the per-print EWM + rolling-mean math.
+"""
+
+import asyncio
+import json
+import logging
+from collections import deque
+from datetime import datetime, timezone
+
+import httpx
+from sqlalchemy import select
+
+from backend.app.core.database import async_session
+from backend.app.models.settings import Settings
+from backend.app.services.obico_smoothing import (
+    PrintState,
+    classify,
+    score_from_detections,
+    thresholds,
+)
+
+logger = logging.getLogger(__name__)
+
+HISTORY_MAX = 50
+HEALTH_TIMEOUT = 5.0
+DETECTION_TIMEOUT = 30.0
+
+
+class ObicoDetectionService:
+    """Singleton service that polls the ML API and acts on sustained failures."""
+
+    def __init__(self):
+        self._task: asyncio.Task | None = None
+        # printer_id -> PrintState (reset when a new print starts)
+        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")
+        self._last_class: 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)
+        self._history: deque = deque(maxlen=HISTORY_MAX)
+        self._last_error: str | None = None
+
+    # ---- lifecycle ----
+
+    async def start(self):
+        if self._task is not None:
+            return
+        logger.info("Starting Obico detection service")
+        self._task = asyncio.create_task(self._loop())
+
+    def stop(self):
+        if self._task:
+            self._task.cancel()
+            self._task = None
+            logger.info("Stopped Obico detection service")
+
+    # ---- settings ----
+
+    async def _load_settings(self) -> dict:
+        keys = [
+            "obico_enabled",
+            "obico_ml_url",
+            "obico_sensitivity",
+            "obico_action",
+            "obico_poll_interval",
+            "obico_enabled_printers",
+            "external_url",
+        ]
+        async with async_session() as db:
+            result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
+            rows = {r.key: r.value for r in result.scalars().all()}
+
+        enabled_printers_raw = rows.get("obico_enabled_printers", "")
+        if enabled_printers_raw:
+            try:
+                enabled_printers = set(json.loads(enabled_printers_raw))
+            except json.JSONDecodeError:
+                enabled_printers = set()
+        else:
+            enabled_printers = None  # None = all printers
+
+        return {
+            "enabled": rows.get("obico_enabled", "false").lower() == "true",
+            "ml_url": (rows.get("obico_ml_url") or "").rstrip("/"),
+            "sensitivity": rows.get("obico_sensitivity", "medium"),
+            "action": rows.get("obico_action", "notify"),
+            "poll_interval": int(rows.get("obico_poll_interval", "10")),
+            "enabled_printers": enabled_printers,
+            "external_url": (rows.get("external_url") or "").rstrip("/"),
+        }
+
+    # ---- main loop ----
+
+    async def _loop(self):
+        """Poll active printers while enabled. Adjusts interval from settings each cycle."""
+        while True:
+            try:
+                settings = await self._load_settings()
+                interval = max(5, settings.get("poll_interval", 10))
+                if not settings["enabled"] or not settings["ml_url"]:
+                    await asyncio.sleep(interval)
+                    continue
+                if not settings["external_url"]:
+                    # Without a reachable base URL, the ML API can't fetch snapshots.
+                    self._last_error = "external_url not set — ML API cannot reach snapshot endpoint"
+                    await asyncio.sleep(interval)
+                    continue
+
+                await self._poll_once(settings)
+                await asyncio.sleep(interval)
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                logger.error("Obico detection loop error: %s", e)
+                self._last_error = str(e)
+                await asyncio.sleep(30)
+
+    async def _poll_once(self, settings: dict):
+        # Late import to avoid cycles at module load time
+        from backend.app.services.printer_manager import printer_manager
+
+        statuses = printer_manager.get_all_statuses()
+        for printer_id, status in list(statuses.items()):
+            if settings["enabled_printers"] is not None and printer_id not in settings["enabled_printers"]:
+                continue
+            if not printer_manager.is_connected(printer_id):
+                continue
+            if not status or getattr(status, "state", None) != "RUNNING":
+                # Reset state when not printing so the next print starts fresh
+                self._states.pop(printer_id, None)
+                self._state_keys.pop(printer_id, None)
+                self._action_fired.pop(printer_id, None)
+                continue
+
+            await self._check_printer(printer_id, status, settings)
+
+    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}"
+        if self._state_keys.get(printer_id) != key:
+            self._states[printer_id] = PrintState()
+            self._state_keys[printer_id] = key
+            self._action_fired[printer_id] = False
+
+        snapshot_url = f"{settings['external_url']}/api/v1/printers/{printer_id}/camera/snapshot"
+        ml_url = f"{settings['ml_url']}/p/"
+
+        try:
+            async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
+                resp = await client.get(ml_url, params={"img": snapshot_url})
+                resp.raise_for_status()
+                payload = resp.json()
+        except Exception as e:
+            self._last_error = f"ML API call failed for printer {printer_id}: {e}"
+            logger.warning(self._last_error)
+            return
+
+        detections = payload.get("detections", []) if isinstance(payload, dict) else []
+        current_p = score_from_detections(detections)
+        state = self._states[printer_id]
+        score = state.update(current_p)
+        verdict = classify(score, settings["sensitivity"])
+        self._last_class[printer_id] = verdict
+
+        # Log every non-safe sample — safe samples would flood history
+        if verdict != "safe" or detections:
+            self._history.appendleft(
+                {
+                    "printer_id": printer_id,
+                    "task_name": task_name,
+                    "timestamp": datetime.now(timezone.utc).isoformat(),
+                    "current_p": round(current_p, 4),
+                    "score": round(score, 4),
+                    "class": verdict,
+                    "detections": len(detections),
+                }
+            )
+
+        if verdict == "failure" and not self._action_fired.get(printer_id):
+            self._action_fired[printer_id] = True
+            await self._dispatch_action(printer_id, settings["action"], task_name, score)
+
+    async def _dispatch_action(self, printer_id: int, action: str, task_name: str, score: float):
+        from backend.app.services.obico_actions import execute_action
+
+        logger.warning(
+            "Obico: failure detected on printer %s (task=%r score=%.3f) — action=%s",
+            printer_id,
+            task_name,
+            score,
+            action,
+        )
+        try:
+            await execute_action(printer_id, action, task_name, score)
+        except Exception as e:
+            self._last_error = f"Action dispatch failed: {e}"
+            logger.error(self._last_error)
+
+    # ---- queries ----
+
+    def get_status(self) -> dict:
+        low, high = thresholds("medium")
+        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()
+            },
+            "thresholds": {"low": low, "high": high},
+            "history": list(self._history),
+        }
+
+    async def test_connection(self, url: str) -> dict:
+        """Ping the ML API health endpoint. Returns {ok, status_code, body, error}."""
+        target = f"{url.rstrip('/')}/hc/"
+        try:
+            async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
+                resp = await client.get(target)
+            body = resp.text.strip()
+            return {
+                "ok": resp.status_code == 200 and body.lower() == "ok",
+                "status_code": resp.status_code,
+                "body": body,
+                "error": None,
+            }
+        except Exception as e:
+            return {"ok": False, "status_code": None, "body": None, "error": str(e)}
+
+
+obico_detection_service = ObicoDetectionService()

+ 105 - 0
backend/app/services/obico_smoothing.py

@@ -0,0 +1,105 @@
+"""Temporal smoothing for Obico ML detection scores.
+
+Ports Obico's failure-detection math:
+- per-frame `current_p` = sum of detection confidences
+- `ewm_mean` = exponentially weighted mean (alpha = 2 / (span + 1), span = 12)
+- `rolling_mean_short` = ~310 frames of recent activity (≈52 min at 10s/frame)
+- `rolling_mean_long`  = ~7200 frames of long-term baseline noise
+- First `WARMUP_FRAMES` frames always report "safe" while the state settles
+- Final score = max(ewm_mean, rolling_mean_short - rolling_mean_long)
+- Thresholds: LOW < score < HIGH is "warning", >= HIGH is "failure"
+"""
+
+import math
+from collections import deque
+from dataclasses import dataclass, field
+
+EWM_SPAN = 12
+EWM_ALPHA = 2.0 / (EWM_SPAN + 1)
+ROLLING_SHORT = 310
+ROLLING_LONG = 7200
+WARMUP_FRAMES = 30
+
+# Base thresholds; sensitivity multipliers adjust them
+BASE_LOW = 0.38
+BASE_HIGH = 0.78
+
+SENSITIVITY_MULT = {
+    "low": 1.25,  # harder to trigger — higher thresholds
+    "medium": 1.0,
+    "high": 0.75,  # easier to trigger — lower thresholds
+}
+
+
+def thresholds(sensitivity: str) -> tuple[float, float]:
+    mult = SENSITIVITY_MULT.get(sensitivity, 1.0)
+    return BASE_LOW * mult, BASE_HIGH * mult
+
+
+@dataclass
+class PrintState:
+    """Per-print smoothing state. Reset when a new print starts."""
+
+    frame_count: int = 0
+    ewm_mean: float = 0.0
+    short_sum: float = 0.0
+    long_sum: float = 0.0
+    short_buf: deque = field(default_factory=lambda: deque(maxlen=ROLLING_SHORT))
+    long_buf: deque = field(default_factory=lambda: deque(maxlen=ROLLING_LONG))
+
+    def update(self, current_p: float) -> float:
+        """Feed a new per-frame score and return the smoothed score.
+
+        Returns 0.0 during warmup so early noise doesn't trigger actions.
+        """
+        self.frame_count += 1
+
+        if self.frame_count == 1:
+            self.ewm_mean = current_p
+        else:
+            self.ewm_mean = EWM_ALPHA * current_p + (1 - EWM_ALPHA) * self.ewm_mean
+
+        if len(self.short_buf) == self.short_buf.maxlen:
+            self.short_sum -= self.short_buf[0]
+        self.short_buf.append(current_p)
+        self.short_sum += current_p
+
+        if len(self.long_buf) == self.long_buf.maxlen:
+            self.long_sum -= self.long_buf[0]
+        self.long_buf.append(current_p)
+        self.long_sum += current_p
+
+        if self.frame_count <= WARMUP_FRAMES:
+            return 0.0
+
+        short_mean = self.short_sum / len(self.short_buf)
+        long_mean = self.long_sum / len(self.long_buf)
+        return max(self.ewm_mean, short_mean - long_mean)
+
+
+def classify(score: float, sensitivity: str) -> str:
+    """Return 'safe', 'warning', or 'failure' for a smoothed score."""
+    low, high = thresholds(sensitivity)
+    if score >= high:
+        return "failure"
+    if score >= low:
+        return "warning"
+    return "safe"
+
+
+def score_from_detections(detections: list) -> float:
+    """Sum confidences from the ML API `detections` array.
+
+    Each detection is `[label, confidence, [x, y, w, h]]`. We only care about
+    the confidence column — label is always "failure" for the single-class model.
+    """
+    total = 0.0
+    for det in detections or []:
+        try:
+            value = float(det[1])
+        except (IndexError, TypeError, ValueError):
+            continue
+        if math.isnan(value) or math.isinf(value):
+            continue
+        total += value
+    return total

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

@@ -0,0 +1,220 @@
+"""Unit tests for Obico detection service (#172)."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.schemas.settings import AppSettingsUpdate
+from backend.app.services.obico_detection import ObicoDetectionService
+from backend.app.services.obico_smoothing import WARMUP_FRAMES
+
+
+class TestSettingsSchemaValidators:
+    """Guard rails on the new obico_* AppSettings fields."""
+
+    def test_sensitivity_accepts_valid_values(self):
+        for value in ("low", "medium", "high"):
+            u = AppSettingsUpdate(obico_sensitivity=value)
+            assert u.obico_sensitivity == value
+
+    def test_sensitivity_rejects_garbage(self):
+        with pytest.raises(ValueError, match="obico_sensitivity"):
+            AppSettingsUpdate(obico_sensitivity="extreme")
+
+    def test_action_accepts_valid_values(self):
+        for value in ("notify", "pause", "pause_and_off"):
+            assert AppSettingsUpdate(obico_action=value).obico_action == value
+
+    def test_action_rejects_garbage(self):
+        with pytest.raises(ValueError, match="obico_action"):
+            AppSettingsUpdate(obico_action="explode")
+
+    def test_enabled_printers_accepts_empty(self):
+        assert AppSettingsUpdate(obico_enabled_printers="").obico_enabled_printers == ""
+        assert AppSettingsUpdate(obico_enabled_printers=None).obico_enabled_printers is None
+
+    def test_enabled_printers_accepts_int_array(self):
+        u = AppSettingsUpdate(obico_enabled_printers="[1, 2, 3]")
+        assert u.obico_enabled_printers == "[1, 2, 3]"
+
+    def test_enabled_printers_rejects_non_json(self):
+        with pytest.raises(ValueError, match="valid JSON"):
+            AppSettingsUpdate(obico_enabled_printers="1,2,3")
+
+    def test_enabled_printers_rejects_non_list(self):
+        with pytest.raises(ValueError, match="JSON array"):
+            AppSettingsUpdate(obico_enabled_printers='{"1": true}')
+
+    def test_enabled_printers_rejects_non_int_elements(self):
+        with pytest.raises(ValueError, match="JSON array"):
+            AppSettingsUpdate(obico_enabled_printers='[1, "two"]')
+
+    def test_poll_interval_bounds(self):
+        with pytest.raises(ValueError):
+            AppSettingsUpdate(obico_poll_interval=4)
+        with pytest.raises(ValueError):
+            AppSettingsUpdate(obico_poll_interval=121)
+        assert AppSettingsUpdate(obico_poll_interval=10).obico_poll_interval == 10
+
+
+class TestGetStatus:
+    def test_empty_initial_status(self):
+        svc = ObicoDetectionService()
+        s = svc.get_status()
+        assert s["is_running"] is False
+        assert s["per_printer"] == {}
+        assert s["history"] == []
+        assert "low" in s["thresholds"] and "high" in s["thresholds"]
+
+
+class TestTestConnection:
+    @pytest.mark.asyncio
+    async def test_empty_url_via_route(self):
+        """Service does not special-case empty URL — the route does."""
+        svc = ObicoDetectionService()
+        # This will fail DNS/connect, but should return ok=False
+        result = await svc.test_connection("http://nonexistent-obico-host-xyz.invalid:3333")
+        assert result["ok"] is False
+        assert result["error"] is not None
+
+    @pytest.mark.asyncio
+    async def test_healthy_response_is_ok(self):
+        svc = ObicoDetectionService()
+        mock_response = MagicMock(status_code=200, text="ok")
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(return_value=mock_response)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333")
+        assert result["ok"] is True
+        assert result["status_code"] == 200
+        assert result["body"] == "ok"
+        assert result["error"] is None
+
+    @pytest.mark.asyncio
+    async def test_non_ok_body_is_not_ok(self):
+        svc = ObicoDetectionService()
+        mock_response = MagicMock(status_code=200, text="something else")
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(return_value=mock_response)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333/")
+        assert result["ok"] is False
+        assert result["body"] == "something else"
+
+
+class TestPollOneStateLifecycle:
+    """Confirms per-printer state is reset when a new print starts."""
+
+    @pytest.mark.asyncio
+    async def test_new_task_name_resets_state(self):
+        svc = ObicoDetectionService()
+        # Seed a state that has been running for a while
+        from backend.app.services.obico_smoothing import PrintState
+
+        seeded = PrintState()
+        for _ in range(WARMUP_FRAMES + 5):
+            seeded.update(0.5)
+        svc._states[1] = seeded
+        svc._state_keys[1] = "old_task"
+        svc._action_fired[1] = True
+
+        settings = {
+            "enabled": True,
+            "ml_url": "http://obico:3333",
+            "sensitivity": "medium",
+            "action": "notify",
+            "poll_interval": 10,
+            "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
+        }
+        status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="")
+
+        mock_response = MagicMock()
+        mock_response.json.return_value = {"detections": []}
+        mock_response.raise_for_status = MagicMock()
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(return_value=mock_response)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            await svc._check_printer(1, status, settings)
+
+        # State was reset (frame_count is 1 after the single update, not 36)
+        assert svc._states[1].frame_count == 1
+        assert svc._state_keys[1] == "new_task"
+        assert svc._action_fired[1] is False
+
+    @pytest.mark.asyncio
+    async def test_ml_api_error_does_not_crash(self):
+        svc = ObicoDetectionService()
+        settings = {
+            "enabled": True,
+            "ml_url": "http://obico:3333",
+            "sensitivity": "medium",
+            "action": "notify",
+            "poll_interval": 10,
+            "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
+        }
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(side_effect=RuntimeError("connection refused"))
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            await svc._check_printer(1, status, settings)
+
+        assert svc._last_error is not None
+        assert "connection refused" in svc._last_error
+
+    @pytest.mark.asyncio
+    async def test_failure_fires_action_only_once(self):
+        """Once a failure has fired for a print, subsequent failures should not re-fire."""
+        svc = ObicoDetectionService()
+        settings = {
+            "enabled": True,
+            "ml_url": "http://obico:3333",
+            "sensitivity": "medium",
+            "action": "notify",
+            "poll_interval": 10,
+            "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
+        }
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        # Seed state so the next frame crosses HIGH immediately
+        from backend.app.services.obico_smoothing import PrintState
+
+        seeded = PrintState()
+        for _ in range(WARMUP_FRAMES + 500):
+            seeded.update(1.0)
+        svc._states[1] = seeded
+        svc._state_keys[1] = "job"
+        svc._action_fired[1] = False
+
+        mock_response = MagicMock()
+        mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]}
+        mock_response.raise_for_status = MagicMock()
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(return_value=mock_response)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch("backend.app.services.obico_actions.execute_action", new=AsyncMock()) as mock_action,
+        ):
+            await svc._check_printer(1, status, settings)
+            assert mock_action.call_count == 1
+            await svc._check_printer(1, status, settings)
+            # Second call must not dispatch again
+            assert mock_action.call_count == 1

+ 98 - 0
backend/tests/unit/test_obico_smoothing.py

@@ -0,0 +1,98 @@
+"""Unit tests for Obico detection smoothing math."""
+
+import pytest
+
+from backend.app.services.obico_smoothing import (
+    BASE_HIGH,
+    BASE_LOW,
+    WARMUP_FRAMES,
+    PrintState,
+    classify,
+    score_from_detections,
+    thresholds,
+)
+
+
+class TestThresholds:
+    def test_medium_matches_base(self):
+        low, high = thresholds("medium")
+        assert low == pytest.approx(BASE_LOW)
+        assert high == pytest.approx(BASE_HIGH)
+
+    def test_low_sensitivity_is_stricter(self):
+        low, high = thresholds("low")
+        assert low > BASE_LOW
+        assert high > BASE_HIGH
+
+    def test_high_sensitivity_is_looser(self):
+        low, high = thresholds("high")
+        assert low < BASE_LOW
+        assert high < BASE_HIGH
+
+    def test_unknown_falls_back_to_medium(self):
+        assert thresholds("bogus") == thresholds("medium")
+
+
+class TestScoreFromDetections:
+    def test_empty(self):
+        assert score_from_detections([]) == 0.0
+        assert score_from_detections(None) == 0.0
+
+    def test_sums_confidences(self):
+        dets = [["failure", 0.3, [0, 0, 10, 10]], ["failure", 0.5, [0, 0, 10, 10]]]
+        assert score_from_detections(dets) == pytest.approx(0.8)
+
+    def test_ignores_malformed(self):
+        dets = [["failure", 0.4, []], ["bad"], ["failure", "nan", []]]
+        assert score_from_detections(dets) == pytest.approx(0.4)
+
+
+class TestPrintState:
+    def test_warmup_returns_zero(self):
+        state = PrintState()
+        for _ in range(WARMUP_FRAMES):
+            assert state.update(0.9) == 0.0
+
+    def test_after_warmup_returns_nonzero_for_hits(self):
+        state = PrintState()
+        for _ in range(WARMUP_FRAMES):
+            state.update(0.9)
+        score = state.update(0.9)
+        assert score > 0.0
+
+    def test_sustained_zero_stays_safe(self):
+        state = PrintState()
+        scores = [state.update(0.0) for _ in range(WARMUP_FRAMES + 50)]
+        assert max(scores) == 0.0
+
+    def test_sustained_hits_eventually_cross_high(self):
+        """A stream of high-confidence frames must escalate to 'failure'."""
+        state = PrintState()
+        final = 0.0
+        for _ in range(WARMUP_FRAMES + 200):
+            final = state.update(1.0)
+        _, high = thresholds("medium")
+        assert final >= high
+
+    def test_isolated_spike_does_not_trigger_failure(self):
+        """A single noisy frame in a clean stream must not cross HIGH."""
+        state = PrintState()
+        for _ in range(WARMUP_FRAMES):
+            state.update(0.0)
+        score = state.update(1.0)
+        _, high = thresholds("medium")
+        assert score < high
+
+
+class TestClassify:
+    def test_safe(self):
+        assert classify(0.0, "medium") == "safe"
+        assert classify(BASE_LOW - 0.01, "medium") == "safe"
+
+    def test_warning(self):
+        assert classify(BASE_LOW, "medium") == "warning"
+        assert classify((BASE_LOW + BASE_HIGH) / 2, "medium") == "warning"
+
+    def test_failure(self):
+        assert classify(BASE_HIGH, "medium") == "failure"
+        assert classify(1.0, "medium") == "failure"

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

@@ -0,0 +1,123 @@
+/**
+ * Tests for the Failure Detection settings component (#172).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { FailureDetectionSettings } from '../../components/FailureDetectionSettings';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const baseSettings = {
+  auto_archive: true,
+  save_thumbnails: true,
+  capture_finish_photo: true,
+  default_filament_cost: 25,
+  currency: 'USD',
+  energy_cost_per_kwh: 0.15,
+  energy_tracking_mode: 'total',
+  check_updates: true,
+  check_printer_firmware: true,
+  include_beta_updates: false,
+  obico_enabled: false,
+  obico_ml_url: '',
+  obico_sensitivity: 'medium',
+  obico_action: 'notify',
+  obico_poll_interval: 10,
+  obico_enabled_printers: '',
+};
+
+const baseStatus = {
+  is_running: true,
+  last_error: null,
+  per_printer: {},
+  thresholds: { low: 0.38, high: 0.78 },
+  history: [],
+  enabled: false,
+  ml_url: '',
+  sensitivity: 'medium',
+  action: 'notify',
+  poll_interval: 10,
+  external_url_configured: true,
+};
+
+describe('FailureDetectionSettings', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
+      http.get('/api/v1/obico/status', () => HttpResponse.json(baseStatus)),
+      http.get('/api/v1/printers', () => HttpResponse.json([])),
+    );
+  });
+
+  it('renders headings and fields', async () => {
+    render(<FailureDetectionSettings />);
+    await waitFor(() => {
+      expect(screen.getByText(/AI Failure Detection|Failure Detection/i)).toBeInTheDocument();
+    });
+    expect(screen.getByText(/Obico ML API URL/i)).toBeInTheDocument();
+    expect(screen.getByText(/Sensitivity/i)).toBeInTheDocument();
+  });
+
+  it('warns when external URL is missing and detection is enabled', async () => {
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json({ ...baseSettings, obico_enabled: true })),
+      http.get('/api/v1/obico/status', () =>
+        HttpResponse.json({ ...baseStatus, enabled: true, external_url_configured: false }),
+      ),
+    );
+    render(<FailureDetectionSettings />);
+    await waitFor(() => {
+      expect(screen.getByText(/External URL is not set/i)).toBeInTheDocument();
+    });
+  });
+
+  it('test button calls the test-connection endpoint and shows success', async () => {
+    let called = false;
+    server.use(
+      http.get('/api/v1/settings/', () =>
+        HttpResponse.json({ ...baseSettings, obico_enabled: true, obico_ml_url: 'http://obico:3333' }),
+      ),
+      http.post('/api/v1/obico/test-connection', async ({ request }) => {
+        called = true;
+        const body = (await request.json()) as { url: string };
+        expect(body.url).toBe('http://obico:3333');
+        return HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null });
+      }),
+    );
+    render(<FailureDetectionSettings />);
+    const testBtn = await screen.findByRole('button', { name: /test/i });
+    await userEvent.click(testBtn);
+    await waitFor(() => {
+      expect(called).toBe(true);
+    });
+    expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument();
+  });
+
+  it('shows failure class history entries with red styling', async () => {
+    server.use(
+      http.get('/api/v1/obico/status', () =>
+        HttpResponse.json({
+          ...baseStatus,
+          history: [
+            {
+              printer_id: 1,
+              task_name: 'test.3mf',
+              timestamp: '2026-04-13T10:00:00Z',
+              current_p: 0.9,
+              score: 0.85,
+              class: 'failure',
+              detections: 1,
+            },
+          ],
+        }),
+      ),
+    );
+    render(<FailureDetectionSettings />);
+    // Match the history row's score-and-class text, which looks like "failure 0.850"
+    expect(await screen.findByText(/failure\s+0\.850/)).toBeInTheDocument();
+  });
+});

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

@@ -925,6 +925,12 @@ export interface AppSettings {
   ldap_group_mapping: string;
   ldap_auto_provision: boolean;
   ldap_default_group: string;
+  obico_enabled: boolean;
+  obico_ml_url: string;
+  obico_sensitivity: 'low' | 'medium' | 'high';
+  obico_action: 'notify' | 'pause' | 'pause_and_off';
+  obico_poll_interval: number;
+  obico_enabled_printers: string;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -1832,6 +1838,37 @@ export interface LocalBackupFile {
   created_at: string;
 }
 
+export interface ObicoDetectionEvent {
+  printer_id: number;
+  task_name: string;
+  timestamp: string;
+  current_p: number;
+  score: number;
+  class: 'safe' | 'warning' | 'failure';
+  detections: number;
+}
+
+export interface ObicoStatus {
+  is_running: boolean;
+  last_error: string | null;
+  per_printer: Record<string, { class: string; frame_count: number; score: number }>;
+  thresholds: { low: number; high: number };
+  history: ObicoDetectionEvent[];
+  enabled: boolean;
+  ml_url: string;
+  sensitivity: 'low' | 'medium' | 'high';
+  action: 'notify' | 'pause' | 'pause_and_off';
+  poll_interval: number;
+  external_url_configured: boolean;
+}
+
+export interface ObicoTestConnection {
+  ok: boolean;
+  status_code: number | null;
+  body: string | null;
+  error: string | null;
+}
+
 export interface GitHubTestConnectionResponse {
   success: boolean;
   message: string;
@@ -4518,6 +4555,16 @@ export const api = {
   deleteLocalBackup: (filename: string) =>
     request<{ success: boolean; message: string }>(`/local-backup/backups/${encodeURIComponent(filename)}`, { method: 'DELETE' }),
 
+  // Obico AI failure detection
+  getObicoStatus: () =>
+    request<ObicoStatus>('/obico/status'),
+
+  testObicoConnection: (url: string) =>
+    request<ObicoTestConnection>('/obico/test-connection', {
+      method: 'POST',
+      body: JSON.stringify({ url }),
+    }),
+
   // Local Presets (OrcaSlicer imports)
   getLocalPresets: () =>
     request<LocalPresetsResponse>('/local-presets/'),

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

@@ -0,0 +1,374 @@
+import { useState, useEffect } 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';
+import { api } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+import { useToast } from '../contexts/ToastContext';
+
+type TestResult = { ok: boolean; message: string } | null;
+
+export function FailureDetectionSettings() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [enabled, setEnabled] = useState(false);
+  const [mlUrl, setMlUrl] = useState('');
+  const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium');
+  const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');
+  const [pollInterval, setPollInterval] = useState(10);
+  const [enabledPrinters, setEnabledPrinters] = useState<number[] | null>(null); // null = all
+  const [testResult, setTestResult] = useState<TestResult>(null);
+  const [initialized, setInitialized] = useState(false);
+
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const { data: status, refetch: refetchStatus } = useQuery({
+    queryKey: ['obico-status'],
+    queryFn: api.getObicoStatus,
+    refetchInterval: 10000,
+  });
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  useEffect(() => {
+    if (!settings) return;
+    setEnabled(settings.obico_enabled ?? false);
+    setMlUrl(settings.obico_ml_url ?? '');
+    setSensitivity(settings.obico_sensitivity ?? 'medium');
+    setAction(settings.obico_action ?? 'notify');
+    setPollInterval(settings.obico_poll_interval ?? 10);
+    try {
+      const list = settings.obico_enabled_printers
+        ? (JSON.parse(settings.obico_enabled_printers) as number[])
+        : null;
+      setEnabledPrinters(Array.isArray(list) ? list : null);
+    } catch {
+      setEnabledPrinters(null);
+    }
+    setInitialized(true);
+  }, [settings]);
+
+  const saveMutation = useMutation({
+    mutationFn: () =>
+      api.updateSettings({
+        obico_enabled: enabled,
+        obico_ml_url: mlUrl,
+        obico_sensitivity: sensitivity,
+        obico_action: action,
+        obico_poll_interval: pollInterval,
+        obico_enabled_printers: enabledPrinters === null ? '' : JSON.stringify(enabledPrinters),
+      }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['settings'] });
+      queryClient.invalidateQueries({ queryKey: ['obico-status'] });
+      showToast(t('settings.toast.settingsSaved'));
+    },
+  });
+
+  // Auto-save on change (debounced)
+  useEffect(() => {
+    if (!initialized || !settings) return;
+    const changed =
+      settings.obico_enabled !== enabled ||
+      settings.obico_ml_url !== mlUrl ||
+      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;
+    const id = setTimeout(() => saveMutation.mutate(), 500);
+    return () => clearTimeout(id);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [enabled, mlUrl, sensitivity, action, pollInterval, enabledPrinters, initialized]);
+
+  const handleTest = async () => {
+    setTestResult(null);
+    try {
+      const res = await api.testObicoConnection(mlUrl);
+      if (res.ok) {
+        setTestResult({ ok: true, message: t('failureDetection.testSuccess') });
+      } else {
+        setTestResult({
+          ok: false,
+          message: res.error || `HTTP ${res.status_code ?? '?'} — ${res.body ?? t('failureDetection.testFailed')}`,
+        });
+      }
+    } catch (e: unknown) {
+      setTestResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
+    }
+  };
+
+  const togglePrinter = (printerId: number, checked: boolean) => {
+    if (enabledPrinters === null) {
+      // switch from "all" to an explicit list
+      const allIds = printers?.map((p) => p.id) ?? [];
+      const next = checked ? allIds : allIds.filter((id) => id !== printerId);
+      setEnabledPrinters(next);
+      return;
+    }
+    if (checked) {
+      setEnabledPrinters([...enabledPrinters, printerId]);
+    } else {
+      setEnabledPrinters(enabledPrinters.filter((id) => id !== printerId));
+    }
+  };
+
+  return (
+    <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
+      <div className="space-y-3 flex-1 lg:max-w-xl">
+        <Card>
+          <CardHeader>
+            <div className="flex items-center justify-between">
+              <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('failureDetection.title')}</h2>
+              </div>
+              <Toggle checked={enabled} onChange={setEnabled} />
+            </div>
+            <p className="text-sm text-bambu-gray mt-2">{t('failureDetection.description')}</p>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">
+                {t('failureDetection.mlUrl')}
+              </label>
+              <div className="flex gap-2">
+                <input
+                  type="text"
+                  value={mlUrl}
+                  onChange={(e) => setMlUrl(e.target.value)}
+                  placeholder="http://192.168.1.10:3333"
+                  className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
+                  disabled={!enabled}
+                />
+                <Button
+                  onClick={handleTest}
+                  disabled={!mlUrl || saveMutation.isPending}
+                  variant="secondary"
+                >
+                  {t('failureDetection.test')}
+                </Button>
+              </div>
+              <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlUrlHint')}</p>
+              {testResult && (
+                <div
+                  className={`flex items-start gap-2 mt-2 text-sm ${
+                    testResult.ok ? 'text-green-400' : 'text-red-400'
+                  }`}
+                >
+                  {testResult.ok ? <Check className="w-4 h-4 mt-0.5" /> : <X className="w-4 h-4 mt-0.5" />}
+                  <span>{testResult.message}</span>
+                </div>
+              )}
+            </div>
+
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">
+                {t('failureDetection.sensitivity')}
+              </label>
+              <select
+                value={sensitivity}
+                onChange={(e) => setSensitivity(e.target.value as 'low' | 'medium' | 'high')}
+                disabled={!enabled}
+                className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
+              >
+                <option value="low">{t('failureDetection.sensitivityLow')}</option>
+                <option value="medium">{t('failureDetection.sensitivityMedium')}</option>
+                <option value="high">{t('failureDetection.sensitivityHigh')}</option>
+              </select>
+              <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.sensitivityHint')}</p>
+            </div>
+
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">
+                {t('failureDetection.action')}
+              </label>
+              <select
+                value={action}
+                onChange={(e) => setAction(e.target.value as 'notify' | 'pause' | 'pause_and_off')}
+                disabled={!enabled}
+                className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
+              >
+                <option value="notify">{t('failureDetection.actionNotify')}</option>
+                <option value="pause">{t('failureDetection.actionPause')}</option>
+                <option value="pause_and_off">{t('failureDetection.actionPauseOff')}</option>
+              </select>
+            </div>
+
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">
+                {t('failureDetection.pollInterval')}
+              </label>
+              <input
+                type="number"
+                value={pollInterval}
+                onChange={(e) => setPollInterval(Math.max(5, Math.min(120, Number(e.target.value) || 10)))}
+                min={5}
+                max={120}
+                disabled={!enabled}
+                className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
+              />
+              <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.pollIntervalHint')}</p>
+            </div>
+
+            {status && !status.external_url_configured && enabled && (
+              <div className="flex items-start gap-2 p-3 bg-amber-900/30 border border-amber-700 rounded text-sm text-amber-200">
+                <AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
+                <div>
+                  <div className="font-medium">{t('failureDetection.externalUrlMissing')}</div>
+                  <div className="text-xs mt-1">{t('failureDetection.externalUrlHint')}</div>
+                </div>
+              </div>
+            )}
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader>
+            <h2 className="text-lg font-semibold text-white">{t('failureDetection.perPrinterTitle')}</h2>
+            <p className="text-sm text-bambu-gray mt-1">{t('failureDetection.perPrinterHint')}</p>
+          </CardHeader>
+          <CardContent className="space-y-2">
+            <label className="flex items-center gap-2 text-sm">
+              <input
+                type="checkbox"
+                checked={enabledPrinters === null}
+                onChange={(e) => setEnabledPrinters(e.target.checked ? null : printers?.map((p) => p.id) ?? [])}
+                disabled={!enabled}
+              />
+              <span className="text-white">{t('failureDetection.monitorAll')}</span>
+            </label>
+            {enabledPrinters !== null && printers && (
+              <div className="pl-5 space-y-1 border-l border-gray-700">
+                {printers.map((p) => (
+                  <label key={p.id} className="flex items-center gap-2 text-sm">
+                    <input
+                      type="checkbox"
+                      checked={enabledPrinters.includes(p.id)}
+                      onChange={(e) => togglePrinter(p.id, e.target.checked)}
+                      disabled={!enabled}
+                    />
+                    <span className="text-white">{p.name}</span>
+                  </label>
+                ))}
+              </div>
+            )}
+          </CardContent>
+        </Card>
+      </div>
+
+      <div className="space-y-3 flex-1 lg:max-w-xl">
+        <Card>
+          <CardHeader>
+            <h2 className="text-lg font-semibold text-white">{t('failureDetection.statusTitle')}</h2>
+          </CardHeader>
+          <CardContent>
+            {!status ? (
+              <div className="flex items-center gap-2 text-bambu-gray">
+                <Loader2 className="w-4 h-4 animate-spin" />
+                <span>{t('common.loading')}</span>
+              </div>
+            ) : (
+              <div className="space-y-3 text-sm">
+                <div className="flex justify-between">
+                  <span className="text-bambu-gray">{t('failureDetection.serviceRunning')}</span>
+                  <span className={status.is_running ? 'text-green-400' : 'text-red-400'}>
+                    {status.is_running ? t('common.yes') : t('common.no')}
+                  </span>
+                </div>
+                <div className="flex justify-between">
+                  <span className="text-bambu-gray">{t('failureDetection.thresholds')}</span>
+                  <span className="text-white font-mono">
+                    {status.thresholds.low.toFixed(2)} / {status.thresholds.high.toFixed(2)}
+                  </span>
+                </div>
+                {status.last_error && (
+                  <div className="flex items-start gap-2 text-red-400">
+                    <X className="w-4 h-4 mt-0.5 flex-shrink-0" />
+                    <span className="break-words">{status.last_error}</span>
+                  </div>
+                )}
+                <div>
+                  <div className="text-bambu-gray mb-1">{t('failureDetection.activePrinters')}</div>
+                  {Object.keys(status.per_printer).length === 0 ? (
+                    <div className="text-bambu-gray italic text-xs">{t('failureDetection.noActivePrints')}</div>
+                  ) : (
+                    <div className="space-y-1">
+                      {Object.entries(status.per_printer).map(([pid, info]) => {
+                        const printer = printers?.find((p) => String(p.id) === pid);
+                        const colorClass =
+                          info.class === 'failure'
+                            ? 'text-red-400'
+                            : info.class === 'warning'
+                              ? 'text-amber-400'
+                              : 'text-green-400';
+                        return (
+                          <div key={pid} className="flex justify-between">
+                            <span className="text-white">{printer?.name ?? `Printer ${pid}`}</span>
+                            <span className={`font-mono ${colorClass}`}>
+                              {info.class} ({info.score.toFixed(3)}, {info.frame_count}f)
+                            </span>
+                          </div>
+                        );
+                      })}
+                    </div>
+                  )}
+                </div>
+              </div>
+            )}
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader>
+            <div className="flex items-center justify-between">
+              <h2 className="text-lg font-semibold text-white">{t('failureDetection.historyTitle')}</h2>
+              <button onClick={() => refetchStatus()} className="text-xs text-bambu-gray hover:text-white">
+                {t('common.refresh')}
+              </button>
+            </div>
+          </CardHeader>
+          <CardContent>
+            {!status || status.history.length === 0 ? (
+              <div className="flex items-center gap-2 text-bambu-gray text-sm">
+                <Info className="w-4 h-4" />
+                <span>{t('failureDetection.noHistory')}</span>
+              </div>
+            ) : (
+              <div className="space-y-1 max-h-96 overflow-y-auto text-xs font-mono">
+                {status.history.map((ev, idx) => {
+                  const printer = printers?.find((p) => p.id === ev.printer_id);
+                  const colorClass =
+                    ev.class === 'failure'
+                      ? 'text-red-400'
+                      : ev.class === 'warning'
+                        ? 'text-amber-400'
+                        : 'text-bambu-gray';
+                  return (
+                    <div key={idx} className="flex justify-between gap-2 py-1 border-b border-gray-800">
+                      <span className="text-bambu-gray">{new Date(ev.timestamp).toLocaleTimeString()}</span>
+                      <span className="text-white truncate">{printer?.name ?? `#${ev.printer_id}`}</span>
+                      <span className={colorClass}>
+                        {ev.class} {ev.score.toFixed(3)}
+                      </span>
+                    </div>
+                  );
+                })}
+              </div>
+            )}
+          </CardContent>
+        </Card>
+      </div>
+    </div>
+  );
+}

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

@@ -1308,6 +1308,7 @@ export default {
       apiKeys: 'API-Schlüssel',
       virtualPrinter: 'Virtueller Drucker',
       spoolbuddy: 'SpoolBuddy',
+      failureDetection: 'Fehlererkennung',
       users: 'Authentifizierung',
       backup: 'Sicherung',
       emailAuth: 'E-Mail-Authentifizierung',
@@ -4846,4 +4847,36 @@ export default {
     viewIssue: 'Issue ansehen',
     unexpectedError: 'Ein unerwarteter Fehler ist aufgetreten',
   },
+  failureDetection: {
+    title: 'KI-Fehlererkennung',
+    description: 'Überwacht Drucke über eine selbst gehostete Obico-ML-API und reagiert automatisch auf erkannte Fehldrucke.',
+    mlUrl: 'Obico-ML-API-URL',
+    mlUrlHint: 'Basis-URL deines selbst gehosteten Obico-ml_api-Containers (z. B. http://192.168.1.10:3333).',
+    test: 'Testen',
+    testSuccess: 'ML-API erreichbar und funktionsfähig.',
+    testFailed: 'ML-API konnte nicht erreicht werden.',
+    sensitivity: 'Empfindlichkeit',
+    sensitivityLow: 'Niedrig (weniger Fehlalarme)',
+    sensitivityMedium: 'Mittel (ausgewogen)',
+    sensitivityHigh: 'Hoch (frühe Erkennung, mehr Fehlalarme)',
+    sensitivityHint: 'Passt die Konfidenz-Schwellwerte an, die Warnungen und Fehler auslösen.',
+    action: 'Aktion bei erkanntem Fehler',
+    actionNotify: 'Nur benachrichtigen',
+    actionPause: 'Druck pausieren',
+    actionPauseOff: 'Pausieren und Strom abschalten',
+    pollInterval: 'Prüfintervall (Sekunden)',
+    pollIntervalHint: 'Wie oft jeder Drucker während eines laufenden Drucks geprüft wird. Minimum 5 s, Maximum 120 s.',
+    externalUrlMissing: 'Externe URL ist nicht gesetzt.',
+    externalUrlHint: 'Die ML-API lädt das Kamerabild per URL. Setze die Externe URL in den allgemeinen Einstellungen, damit der ML-API-Container Bambuddy erreichen kann.',
+    perPrinterTitle: 'Überwachte Drucker',
+    perPrinterHint: 'Wähle, welche Drucker vom Erkennungsdienst überwacht werden.',
+    monitorAll: 'Alle verbundenen Drucker überwachen',
+    statusTitle: 'Status',
+    serviceRunning: 'Dienst läuft',
+    thresholds: 'Niedrig / Hoch-Schwellwerte',
+    activePrinters: 'Aktive Drucke',
+    noActivePrints: 'Derzeit laufen keine Drucke.',
+    historyTitle: 'Letzte Erkennungen',
+    noHistory: 'Noch keine Erkennungen.',
+  },
 };

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

@@ -1309,6 +1309,7 @@ export default {
       apiKeys: 'API Keys',
       virtualPrinter: 'Virtual Printer',
       spoolbuddy: 'SpoolBuddy',
+      failureDetection: 'Failure Detection',
       users: 'Authentication',
       backup: 'Backup',
       emailAuth: 'Email Authentication',
@@ -4853,4 +4854,36 @@ export default {
     viewIssue: 'View Issue',
     unexpectedError: 'An unexpected error occurred',
   },
+  failureDetection: {
+    title: 'AI Failure Detection',
+    description: 'Monitor prints with a self-hosted Obico ML API and act on detected failures automatically.',
+    mlUrl: 'Obico ML API URL',
+    mlUrlHint: 'Base URL of your self-hosted Obico ml_api container (e.g. http://192.168.1.10:3333).',
+    test: 'Test',
+    testSuccess: 'ML API reachable and healthy.',
+    testFailed: 'Could not reach the ML API.',
+    sensitivity: 'Sensitivity',
+    sensitivityLow: 'Low (fewer false positives)',
+    sensitivityMedium: 'Medium (balanced)',
+    sensitivityHigh: 'High (detect early, more false positives)',
+    sensitivityHint: 'Adjusts the confidence thresholds that trigger warnings and failures.',
+    action: 'Action on detected failure',
+    actionNotify: 'Notify only',
+    actionPause: 'Pause print',
+    actionPauseOff: 'Pause and cut power',
+    pollInterval: 'Poll interval (seconds)',
+    pollIntervalHint: 'How often to check each printer while it is printing. Minimum 5s, maximum 120s.',
+    externalUrlMissing: 'External URL is not set.',
+    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
+    perPrinterTitle: 'Monitored Printers',
+    perPrinterHint: 'Choose which printers the detection service watches.',
+    monitorAll: 'Monitor all connected printers',
+    statusTitle: 'Status',
+    serviceRunning: 'Service running',
+    thresholds: 'Low / High thresholds',
+    activePrinters: 'Active prints',
+    noActivePrints: 'No prints currently running.',
+    historyTitle: 'Recent Detections',
+    noHistory: 'No detections yet.',
+  },
 };

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

@@ -1307,6 +1307,7 @@ export default {
       network: 'Réseau',
       apiKeys: 'Clés API',
       virtualPrinter: 'Imprimante virtuelle',
+      failureDetection: 'Détection d\'échec',
       users: 'Authentification',
       backup: 'Sauvegarde',
       emailAuth: 'Authentification Email',
@@ -4799,4 +4800,36 @@ export default {
     viewIssue: 'Voir l\'issue',
     unexpectedError: 'Une erreur inattendue est survenue',
   },
+  failureDetection: {
+    title: 'Détection d\'échec par IA',
+    description: 'Surveille les impressions via une API ML Obico auto-hébergée et agit automatiquement sur les échecs détectés.',
+    mlUrl: 'URL de l\'API ML Obico',
+    mlUrlHint: 'URL de base de votre conteneur Obico ml_api auto-hébergé (ex. http://192.168.1.10:3333).',
+    test: 'Tester',
+    testSuccess: 'API ML accessible et fonctionnelle.',
+    testFailed: 'Impossible d\'atteindre l\'API ML.',
+    sensitivity: 'Sensibilité',
+    sensitivityLow: 'Basse (moins de faux positifs)',
+    sensitivityMedium: 'Moyenne (équilibrée)',
+    sensitivityHigh: 'Haute (détection précoce, plus de faux positifs)',
+    sensitivityHint: 'Ajuste les seuils de confiance qui déclenchent les avertissements et les échecs.',
+    action: 'Action sur échec détecté',
+    actionNotify: 'Notifier uniquement',
+    actionPause: 'Mettre en pause',
+    actionPauseOff: 'Pause et couper l\'alimentation',
+    pollInterval: 'Intervalle de vérification (secondes)',
+    pollIntervalHint: 'Fréquence de vérification de chaque imprimante pendant l\'impression. Minimum 5s, maximum 120s.',
+    externalUrlMissing: 'L\'URL externe n\'est pas définie.',
+    externalUrlHint: 'L\'API ML récupère l\'image de la caméra via URL. Définissez l\'URL externe dans les paramètres généraux pour que le conteneur ML API puisse atteindre Bambuddy.',
+    perPrinterTitle: 'Imprimantes surveillées',
+    perPrinterHint: 'Choisissez quelles imprimantes le service de détection surveille.',
+    monitorAll: 'Surveiller toutes les imprimantes connectées',
+    statusTitle: 'Statut',
+    serviceRunning: 'Service en cours d\'exécution',
+    thresholds: 'Seuils bas / haut',
+    activePrinters: 'Impressions actives',
+    noActivePrints: 'Aucune impression en cours.',
+    historyTitle: 'Détections récentes',
+    noHistory: 'Aucune détection pour le moment.',
+  },
 };

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

@@ -1307,6 +1307,7 @@ export default {
       network: 'Rete',
       apiKeys: 'Chiavi API',
       virtualPrinter: 'Stampante virtuale',
+      failureDetection: 'Rilevamento guasti',
       users: 'Utenti',
       backup: 'Backup',
       emailAuth: 'Autenticazione Email',
@@ -4798,4 +4799,36 @@ export default {
     viewIssue: 'Vedi issue',
     unexpectedError: 'Si è verificato un errore imprevisto',
   },
+  failureDetection: {
+    title: 'Rilevamento guasti con IA',
+    description: 'Monitora le stampe tramite un\'API ML Obico auto-ospitata e agisce automaticamente sui guasti rilevati.',
+    mlUrl: 'URL API ML Obico',
+    mlUrlHint: 'URL base del tuo container Obico ml_api auto-ospitato (es. http://192.168.1.10:3333).',
+    test: 'Prova',
+    testSuccess: 'API ML raggiungibile e funzionante.',
+    testFailed: 'Impossibile raggiungere l\'API ML.',
+    sensitivity: 'Sensibilità',
+    sensitivityLow: 'Bassa (meno falsi positivi)',
+    sensitivityMedium: 'Media (bilanciata)',
+    sensitivityHigh: 'Alta (rilevamento precoce, più falsi positivi)',
+    sensitivityHint: 'Regola le soglie di confidenza che attivano avvisi e guasti.',
+    action: 'Azione al guasto rilevato',
+    actionNotify: 'Solo notifica',
+    actionPause: 'Metti in pausa',
+    actionPauseOff: 'Pausa e stacca corrente',
+    pollInterval: 'Intervallo di controllo (secondi)',
+    pollIntervalHint: 'Frequenza di controllo di ogni stampante durante la stampa. Minimo 5s, massimo 120s.',
+    externalUrlMissing: 'URL esterno non impostato.',
+    externalUrlHint: 'L\'API ML recupera l\'immagine della fotocamera tramite URL. Imposta l\'URL esterno nelle impostazioni generali affinché il container ML API possa raggiungere Bambuddy.',
+    perPrinterTitle: 'Stampanti monitorate',
+    perPrinterHint: 'Scegli quali stampanti il servizio di rilevamento deve monitorare.',
+    monitorAll: 'Monitora tutte le stampanti connesse',
+    statusTitle: 'Stato',
+    serviceRunning: 'Servizio in esecuzione',
+    thresholds: 'Soglie bassa / alta',
+    activePrinters: 'Stampe attive',
+    noActivePrints: 'Nessuna stampa in corso.',
+    historyTitle: 'Rilevamenti recenti',
+    noHistory: 'Nessun rilevamento finora.',
+  },
 };

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

@@ -1307,6 +1307,7 @@ export default {
       apiKeys: 'APIキー',
       virtualPrinter: '仮想プリンター',
       spoolbuddy: 'SpoolBuddy',
+      failureDetection: '失敗検出',
       users: '認証',
       backup: 'バックアップ',
       emailAuth: 'メール認証',
@@ -4837,4 +4838,36 @@ export default {
     viewIssue: 'Issueを表示',
     unexpectedError: '予期しないエラーが発生しました',
   },
+  failureDetection: {
+    title: 'AI 失敗検出',
+    description: 'セルフホストされた Obico ML API で印刷を監視し、検出された失敗に自動的に対応します。',
+    mlUrl: 'Obico ML API の URL',
+    mlUrlHint: 'セルフホストした Obico ml_api コンテナのベース URL (例: http://192.168.1.10:3333)。',
+    test: 'テスト',
+    testSuccess: 'ML API に接続でき、正常です。',
+    testFailed: 'ML API に接続できませんでした。',
+    sensitivity: '感度',
+    sensitivityLow: '低(誤検出が少ない)',
+    sensitivityMedium: '中(バランス型)',
+    sensitivityHigh: '高(早期検出、誤検出が増加)',
+    sensitivityHint: '警告と失敗をトリガーする信頼度のしきい値を調整します。',
+    action: '失敗検出時の動作',
+    actionNotify: '通知のみ',
+    actionPause: '印刷を一時停止',
+    actionPauseOff: '一時停止して電源を切る',
+    pollInterval: 'ポーリング間隔(秒)',
+    pollIntervalHint: '印刷中に各プリンターをチェックする頻度。最小 5 秒、最大 120 秒。',
+    externalUrlMissing: '外部 URL が設定されていません。',
+    externalUrlHint: 'ML API は URL 経由でカメラ画像を取得します。ML API コンテナが Bambuddy に到達できるよう、一般設定で外部 URL を設定してください。',
+    perPrinterTitle: '監視対象プリンター',
+    perPrinterHint: '検出サービスが監視するプリンターを選択します。',
+    monitorAll: '接続されているすべてのプリンターを監視',
+    statusTitle: 'ステータス',
+    serviceRunning: 'サービス稼働中',
+    thresholds: '低 / 高しきい値',
+    activePrinters: 'アクティブな印刷',
+    noActivePrints: '現在、実行中の印刷はありません。',
+    historyTitle: '最近の検出',
+    noHistory: 'まだ検出はありません。',
+  },
 };

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

@@ -1307,6 +1307,7 @@ export default {
       network: 'Rede',
       apiKeys: 'Chaves API',
       virtualPrinter: 'Impressora Virtual',
+      failureDetection: 'Detecção de Falhas',
       users: 'Autenticação',
       backup: 'Backup',
       emailAuth: 'Autenticação por Email',
@@ -4798,4 +4799,36 @@ export default {
     viewIssue: 'Ver issue',
     unexpectedError: 'Ocorreu um erro inesperado',
   },
+  failureDetection: {
+    title: 'Detecção de Falhas por IA',
+    description: 'Monitora impressões via API ML do Obico auto-hospedada e age automaticamente em falhas detectadas.',
+    mlUrl: 'URL da API ML do Obico',
+    mlUrlHint: 'URL base do seu contêiner Obico ml_api auto-hospedado (ex.: http://192.168.1.10:3333).',
+    test: 'Testar',
+    testSuccess: 'API ML acessível e operacional.',
+    testFailed: 'Não foi possível acessar a API ML.',
+    sensitivity: 'Sensibilidade',
+    sensitivityLow: 'Baixa (menos falsos positivos)',
+    sensitivityMedium: 'Média (equilibrada)',
+    sensitivityHigh: 'Alta (detecção precoce, mais falsos positivos)',
+    sensitivityHint: 'Ajusta os limiares de confiança que disparam avisos e falhas.',
+    action: 'Ação em falha detectada',
+    actionNotify: 'Apenas notificar',
+    actionPause: 'Pausar impressão',
+    actionPauseOff: 'Pausar e cortar energia',
+    pollInterval: 'Intervalo de verificação (segundos)',
+    pollIntervalHint: 'Frequência de verificação de cada impressora durante a impressão. Mínimo 5s, máximo 120s.',
+    externalUrlMissing: 'URL externa não configurada.',
+    externalUrlHint: 'A API ML busca a imagem da câmera por URL. Defina a URL externa nas configurações gerais para que o contêiner da API ML possa alcançar o Bambuddy.',
+    perPrinterTitle: 'Impressoras monitoradas',
+    perPrinterHint: 'Escolha quais impressoras o serviço de detecção monitora.',
+    monitorAll: 'Monitorar todas as impressoras conectadas',
+    statusTitle: 'Status',
+    serviceRunning: 'Serviço em execução',
+    thresholds: 'Limiares baixo / alto',
+    activePrinters: 'Impressões ativas',
+    noActivePrints: 'Nenhuma impressão em andamento.',
+    historyTitle: 'Detecções recentes',
+    noHistory: 'Nenhuma detecção ainda.',
+  },
 };

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

@@ -1307,6 +1307,7 @@ export default {
       network: '网络',
       apiKeys: 'API 密钥',
       virtualPrinter: '虚拟打印机',
+      failureDetection: '故障检测',
       users: '身份验证',
       backup: '备份',
       emailAuth: '邮箱认证',
@@ -4797,4 +4798,36 @@ export default {
     viewIssue: '查看Issue',
     unexpectedError: '发生了意外错误',
   },
+  failureDetection: {
+    title: 'AI 故障检测',
+    description: '通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。',
+    mlUrl: 'Obico ML API 地址',
+    mlUrlHint: '您自托管的 Obico ml_api 容器的基础 URL(例如 http://192.168.1.10:3333)。',
+    test: '测试',
+    testSuccess: 'ML API 可访问且正常。',
+    testFailed: '无法访问 ML API。',
+    sensitivity: '灵敏度',
+    sensitivityLow: '低(减少误报)',
+    sensitivityMedium: '中(平衡)',
+    sensitivityHigh: '高(更早检测,更多误报)',
+    sensitivityHint: '调整触发警告和故障的置信度阈值。',
+    action: '检测到故障时的操作',
+    actionNotify: '仅通知',
+    actionPause: '暂停打印',
+    actionPauseOff: '暂停并切断电源',
+    pollInterval: '检查间隔(秒)',
+    pollIntervalHint: '打印过程中每台打印机的检查频率。最小 5 秒,最大 120 秒。',
+    externalUrlMissing: '未设置外部 URL。',
+    externalUrlHint: 'ML API 通过 URL 获取相机截图。请在常规设置中设置外部 URL,以便 ML API 容器可以访问 Bambuddy。',
+    perPrinterTitle: '监控的打印机',
+    perPrinterHint: '选择检测服务要监视哪些打印机。',
+    monitorAll: '监控所有已连接的打印机',
+    statusTitle: '状态',
+    serviceRunning: '服务运行中',
+    thresholds: '低 / 高阈值',
+    activePrinters: '活动打印',
+    noActivePrints: '当前没有正在进行的打印。',
+    historyTitle: '最近检测',
+    noHistory: '暂无检测记录。',
+  },
 };

+ 30 - 3
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -25,6 +25,7 @@ import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { VirtualPrinterList } from '../components/VirtualPrinterList';
 import { SpoolBuddySettings } from '../components/SpoolBuddySettings';
 import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
+import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { LDAPSettings } from '../components/LDAPSettings';
 import { APIBrowser } from '../components/APIBrowser';
@@ -37,7 +38,7 @@ import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, t
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { Palette } from 'lucide-react';
 
-const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'users', 'backup'] as const;
+const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
 type UsersSubTab = 'users' | 'email' | 'ldap';
 
@@ -366,6 +367,14 @@ export function SettingsPage() {
   const spoolbuddyDeviceCount = spoolbuddyDevices?.length ?? 0;
   const spoolbuddyAnyOnline = spoolbuddyDevices?.some((d) => d.online) ?? false;
 
+  // Obico failure-detection service status for tab indicator
+  const { data: obicoStatus } = useQuery({
+    queryKey: ['obico-status'],
+    queryFn: api.getObicoStatus,
+    refetchInterval: 15000,
+  });
+  const obicoActive = !!(obicoStatus?.is_running && obicoStatus?.enabled);
+
   const { data: ffmpegStatus } = useQuery({
     queryKey: ['ffmpeg-status'],
     queryFn: api.checkFfmpeg,
@@ -1096,7 +1105,7 @@ export function SettingsPage() {
                 >
                   <p className="text-sm text-white">{entry.label}</p>
                   <p className="text-xs text-bambu-gray">
-                    {t(`settings.tabs.${entry.tab === 'virtual-printer' ? 'virtualPrinter' : entry.tab}`)}
+                    {t(`settings.tabs.${entry.tab === 'virtual-printer' ? 'virtualPrinter' : entry.tab === 'failure-detection' ? 'failureDetection' : entry.tab}`)}
                     {entry.subTab ? ` › ${t(`settings.tabs.${entry.subTab}`, entry.subTab)}` : ''}
                   </p>
                 </button>
@@ -1236,6 +1245,18 @@ export function SettingsPage() {
           )}
           <span className={`w-2 h-2 rounded-full ${spoolbuddyAnyOnline ? 'bg-green-400' : 'bg-gray-500'}`} />
         </button>
+        <button
+          onClick={() => handleTabChange('failure-detection')}
+          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
+            activeTab === 'failure-detection'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+          }`}
+        >
+          <ScanEye className="w-4 h-4" />
+          {t('settings.tabs.failureDetection')}
+          <span className={`w-2 h-2 rounded-full ${obicoActive ? 'bg-green-400' : 'bg-gray-500'}`} />
+        </button>
         <button
           onClick={() => handleTabChange('users')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
@@ -5171,6 +5192,12 @@ export function SettingsPage() {
       )}
 
       {/* Backup Tab */}
+      {activeTab === 'failure-detection' && (
+        <div id="card-failure-detection">
+          <FailureDetectionSettings />
+        </div>
+      )}
+
       {activeTab === 'backup' && (
         <div id="card-backup">
           <GitHubBackupSettings />

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CP-2HMi5.css


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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DfEBMSZy.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Czpqfgna.css">
+    <script type="module" crossorigin src="/assets/index-Dc-TAKpR.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CP-2HMi5.css">
   </head>
   <body>
     <div id="root"></div>

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